v0.11.0 后台 CRM 化:媒体上传/单页面 Elementor/修订/分类标签/菜单/用户/审计/设置/Dashboard v2 + dusuniot-cms MCP 服务器

This commit is contained in:
bigcode
2026-11-04 06:10:04 +08:00
parent 2251a25e12
commit b16c33c08b
15 changed files with 2053 additions and 152 deletions
+80
View File
@@ -0,0 +1,80 @@
/**
* # 路由 /admin/audit 审计日志
* 后台全部写操作留痕(audit_logs):操作人 / 动作 / 实体 / 时间。
* 支持按动作名过滤与分页;meta 为 JSON 摘要。
*/
import { createFileRoute } from "@tanstack/react-router";
import { Card, Input, Table, Typography } from "antd";
import { SearchOutlined } from "@ant-design/icons";
import { useState } from "react";
import { adminListAuditLogs } from "~/server/admin";
import { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common";
export const Route = createFileRoute("/admin/audit")({
validateSearch: (search: Record<string, unknown>): AdminSearch => {
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(() => adminListAuditLogs({ data: { page: deps.search.page, action: deps.search.q || undefined } })),
component: AuditPage,
});
/** # 函数 审计日志页面组件 */
function AuditPage() {
const data = Route.useLoaderData();
const search = Route.useSearch();
const navigate = Route.useNavigate();
const [q, setQ] = useState(search.q ?? "");
if (!data) return <Forbidden />;
return (
<Card size="small">
<PageHeader
title="审计日志"
extra={
<Input
allowClear
placeholder="按动作过滤(如 content.save)"
prefix={<SearchOutlined aria-hidden />}
value={q}
onChange={(e) => setQ(e.target.value)}
onPressEnter={() => void navigate({ search: (prev) => ({ ...prev, q, page: 1 }) })}
style={{ width: 280 }}
/>
}
/>
<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: "actorEmail", width: 220, ellipsis: true, render: (v: string | null) => v ?? `#${""}` },
{ title: "动作", dataIndex: "action", width: 200, render: (v: string) => <code style={{ fontSize: 12 }}>{v}</code> },
{ title: "实体", dataIndex: "entity", width: 110 },
{ title: "实体 ID", dataIndex: "entityId", width: 90 },
{
title: "摘要",
dataIndex: "meta",
ellipsis: true,
render: (v: unknown) => (
<Typography.Text code style={{ fontSize: 12 }}>
{Object.keys(v ?? {}).length ? JSON.stringify(v) : "-"}
</Typography.Text>
),
},
{ title: "时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
]}
/>
</Card>
);
}
+197 -32
View File
@@ -1,12 +1,17 @@
/**
* # 路由 /admin/contents/$id 内容编辑页
* id 为 new 时新建;否则加载正本(默认语言)编辑。HTML 使用 TextArea 明文编辑。
* # 路由 /admin/contents/$id 内容编辑页(自定义单页面/文章/落地页)
* id 为 new 时新建;否则加载正本编辑。支持:
* - 分类/标签绑定(content_terms,对应 WP 分类目录与标签)
* - Elementor JSON 编辑(meta.elementor,前台 catch-all 用 ElementorRenderer 渲染)
* - 修订历史弹窗(保存自动留档,可查看/恢复)
* - 已发布内容直达前台链接(预览)
*/
import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router";
import { App, Button, Card, Form, Input, Select, Space } from "antd";
import { ArrowLeftOutlined, SaveOutlined } from "@ant-design/icons";
import { adminGetContent, adminSaveContent } from "~/server/admin";
import { CONTENT_STATUS_OPTIONS, Forbidden, guardAdmin } from "~/components/admin/common";
import { App, Button, Card, Form, Input, Modal, Select, Space, Table, TreeSelect } from "antd";
import { ArrowLeft, ExternalLink, History, Save } from "lucide-react";
import { useEffect, useState } from "react";
import { adminGetContent, adminGetRevision, adminListRevisions, adminRestoreRevision, adminSaveContent } from "~/server/admin";
import { CONTENT_STATUS_OPTIONS, Forbidden, fmtDateTime, guardAdmin } from "~/components/admin/common";
/** # 类型 编辑表单数据形状(新建时给默认值) **/
type ContentForm = {
@@ -20,15 +25,25 @@ type ContentForm = {
template: string;
seoTitle: string;
seoDescription: string;
elementorJson: string;
};
/** # 类型 修订列表行(弹窗表格) **/
type RevisionRow = { id: number; title: string; authorId: number | null; createdAt: string | Date };
/** # 类型 分类/标签选项(编辑页回显用) **/
type TermOption = { id: number; name: string; taxonomy: string };
/** # 类型 编辑页 loader 数据(新建默认值或正本 + 术语选项) **/
type EditorData = (ContentForm & { selectedCategoryIds: number[]; termOptions: TermOption[] }) | null;
export const Route = createFileRoute("/admin/contents/$id")({
loader: ({ params }) =>
guardAdmin(async () => {
guardAdmin(async (): Promise<EditorData> => {
if (params.id === "new") {
// 权限探测 + 返回新建默认值
await adminGetContent({ data: { id: 0 } });
const fresh: ContentForm = {
return {
id: 0,
type: "page",
slug: "",
@@ -39,12 +54,15 @@ export const Route = createFileRoute("/admin/contents/$id")({
template: "default",
seoTitle: "",
seoDescription: "",
elementorJson: "",
selectedCategoryIds: [],
termOptions: [],
};
return fresh;
}
const c = await adminGetContent({ data: { id: Number(params.id) } });
if (!c) return null;
const form: ContentForm = {
const meta = (c.meta ?? {}) as Record<string, unknown>;
return {
id: c.id,
type: c.type,
slug: c.slug,
@@ -55,8 +73,10 @@ export const Route = createFileRoute("/admin/contents/$id")({
template: c.template,
seoTitle: c.seoTitle ?? "",
seoDescription: c.seoDescription ?? "",
elementorJson: meta.elementor ? JSON.stringify(meta.elementor, null, 2) : "",
selectedCategoryIds: (c as { selectedCategoryIds?: number[] }).selectedCategoryIds ?? [],
termOptions: (c as { termOptions?: TermOption[] }).termOptions ?? [],
};
return form;
}),
component: ContentEditPage,
});
@@ -66,8 +86,31 @@ function ContentEditPage() {
const data = Route.useLoaderData();
const navigate = useNavigate();
const router = useRouter();
const { message } = App.useApp();
const { message, modal } = App.useApp();
const [form] = Form.useForm();
/** # 成员变量 修订历史弹窗开关与数据 **/
const [revOpen, setRevOpen] = useState(false);
const [revisions, setRevisions] = useState<RevisionRow[]>([]);
/** # 成员变量 已选分类/标签(仅编辑态展示,新建暂不关联) **/
const [selectedTermIds, setSelectedTermIds] = useState<number[]>(data?.selectedCategoryIds ?? []);
// 表单初值与术语回显随 loader 数据同步(hooks 须在 early return 之前调用)
useEffect(() => {
if (!data) return;
setSelectedTermIds(data.selectedCategoryIds ?? []);
form.setFieldsValue({
title: data.title,
slug: data.slug,
type: data.type,
status: data.status,
template: data.template,
excerpt: data.excerpt,
html: data.html,
seoTitle: data.seoTitle,
seoDescription: data.seoDescription,
elementorJson: data.elementorJson,
});
}, [data, form]);
if (!data) return <Forbidden />;
// 收窄后的非空引用(闭包内使用)
@@ -76,8 +119,14 @@ function ContentEditPage() {
/** # 函数 保存内容:提交到服务端 → 提示 → 返回列表 */
async function handleSave(values: Omit<ContentForm, "id">) {
try {
await adminSaveContent({ data: { ...values, id: content.id || undefined } });
message.success("保存成功");
await adminSaveContent({
data: {
...values,
id: content.id || undefined,
categoryIds: selectedTermIds,
},
});
message.success("保存成功(已留修订快照)");
await router.invalidate();
void navigate({ to: "/admin/contents/" });
} catch (e) {
@@ -85,33 +134,90 @@ function ContentEditPage() {
}
}
/** # 函数 打开修订历史弹窗并加载列表 */
async function openRevisions() {
const rows = await adminListRevisions({ data: { contentId: content.id } });
setRevisions(rows as RevisionRow[]);
setRevOpen(true);
}
/** # 函数 查看修订正文并确认恢复 */
async function viewRevision(row: RevisionRow) {
const rev = await adminGetRevision({ data: { id: row.id } });
if (!rev) return;
modal.confirm({
title: `恢复修订 #${row.id}(${fmtDateTime(row.createdAt)})`,
width: 760,
content: (
<div style={{ maxHeight: 420, overflow: "auto" }}>
<p style={{ fontWeight: 600 }}>{rev.title}</p>
<pre style={{ whiteSpace: "pre-wrap", fontSize: 12, maxHeight: 340, overflow: "auto", background: "#fafafa", padding: 8 }}>
{rev.html.slice(0, 5000) || "(空正文)"}
{rev.html.length > 5000 ? "\n…(截断预览)" : ""}
</pre>
</div>
),
okText: "恢复此版本",
cancelText: "取消",
onOk: async () => {
await adminRestoreRevision({ data: { id: row.id } });
message.success("已恢复(恢复前版本已再留档)");
setRevOpen(false);
await router.invalidate();
void navigate({ to: "/admin/contents/$id/", params: { id: String(content.id) } }).then(() => window.location.reload());
},
});
}
/** # 成员变量 分类/标签树数据源(taxonomy 分组) **/
const termTreeData = [
{
title: "分类目录",
value: "__cat__",
selectable: false,
children: content.termOptions.filter((t) => t.taxonomy === "category").map((t) => ({ title: t.name, value: t.id })),
},
{
title: "标签",
value: "__tag__",
selectable: false,
children: content.termOptions.filter((t) => t.taxonomy === "post_tag").map((t) => ({ title: t.name, value: t.id })),
},
];
return (
<Card size="small">
<Space style={{ marginBottom: 16 }}>
<Button icon={<ArrowLeftOutlined aria-hidden />} onClick={() => void navigate({ to: "/admin/contents/" })}>
<Button icon={<ArrowLeft size={14} aria-hidden />} onClick={() => void navigate({ to: "/admin/contents/" })}>
返回列表
</Button>
<h2 style={{ margin: 0, fontSize: 18 }}>{content.id ? `编辑内容 #${content.id}` : "新建内容"}</h2>
{content.id ? (
<>
<Button icon={<History size={14} aria-hidden />} onClick={() => void openRevisions()}>
修订历史
</Button>
{content.status === "published" && content.slug ? (
<Button icon={<ExternalLink size={14} aria-hidden />} onClick={() => window.open(`/${content.slug}/`, "_blank")}>
前台预览
</Button>
) : null}
</>
) : null}
</Space>
<Form
form={form}
layout="vertical"
initialValues={content}
style={{ maxWidth: 860 }}
onFinish={(v) => void handleSave(v)}
>
<Form form={form} layout="vertical" style={{ maxWidth: 960 }} onFinish={(v) => void handleSave(v as Omit<ContentForm, "id">)}>
<Space size={16} style={{ display: "flex" }} align="start">
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]} style={{ minWidth: 420, flex: 1 }}>
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]} style={{ minWidth: 380, flex: 1 }}>
<Input placeholder="内容标题" />
</Form.Item>
<Form.Item name="slug" label="别名(留空自动生成)">
<Input placeholder="url-slug" style={{ width: 260 }} />
<Form.Item name="slug" label="别名(留空自动生成;即前台 URL /slug/)">
<Input placeholder="url-slug" style={{ width: 280 }} />
</Form.Item>
</Space>
<Space size={16} style={{ display: "flex" }}>
<Form.Item name="type" label="类型">
<Select
style={{ width: 140 }}
style={{ width: 150 }}
options={[
{ value: "page", label: "页面 page" },
{ value: "post", label: "文章 post" },
@@ -123,25 +229,84 @@ function ContentEditPage() {
<Select style={{ width: 140 }} options={CONTENT_STATUS_OPTIONS} />
</Form.Item>
<Form.Item name="template" label="模板">
<Input style={{ width: 200 }} placeholder="default / home / product …" />
<Input style={{ width: 200 }} placeholder="default / home / blank …" />
</Form.Item>
{content.id ? (
<Form.Item label="分类 / 标签">
<TreeSelect
treeData={termTreeData}
value={selectedTermIds}
onChange={(v) => setSelectedTermIds((v as number[]) ?? [])}
treeCheckable
showCheckedStrategy={TreeSelect.SHOW_CHILD}
maxTagCount={4}
placeholder="选择分类与标签"
style={{ width: 320 }}
/>
</Form.Item>
) : null}
</Space>
<Form.Item name="excerpt" label="摘要">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name="html" label="正文 HTML">
<Input.TextArea rows={18} style={{ fontFamily: "monospace", fontSize: 12 }} />
<Form.Item
name="html"
label="正文 HTML(无 Elementor JSON 时前台按此渲染)"
tooltip="自定义单页面推荐:留空 HTML、填写 Elementor JSON,前台通栏渲染与原站一致"
>
<Input.TextArea rows={12} style={{ fontFamily: "monospace", fontSize: 12 }} />
</Form.Item>
<Form.Item name="seoTitle" label="SEO 标题">
<Input />
<Form.Item
name="elementorJson"
label="Elementor JSON(meta.elementor,优先于 HTML 渲染)"
tooltip='结构:{ "elements": [ ... ] } 或元素数组;可从原站 _elementor_data 复制'
>
<Input.TextArea rows={10} style={{ fontFamily: "monospace", fontSize: 12 }} placeholder='{"elements": [...]}' />
</Form.Item>
<Space size={16} style={{ display: "flex" }}>
<Form.Item name="seoTitle" label="SEO 标题" style={{ minWidth: 420, flex: 1 }}>
<Input />
</Form.Item>
</Space>
<Form.Item name="seoDescription" label="SEO 描述">
<Input.TextArea rows={2} />
</Form.Item>
<Button type="primary" htmlType="submit" icon={<SaveOutlined aria-hidden />}>
<Button type="primary" htmlType="submit" icon={<Save size={14} aria-hidden />}>
保存
</Button>
</Form>
<Modal
title="修订历史(每次保存自动留档)"
open={revOpen}
onCancel={() => setRevOpen(false)}
footer={null}
width={720}
>
<Table
rowKey="id"
size="small"
dataSource={revisions}
pagination={{ pageSize: 8 }}
locale={{ emptyText: "暂无修订" }}
columns={[
{ title: "修订 ID", dataIndex: "id", width: 90 },
{ title: "标题快照", dataIndex: "title", ellipsis: true },
{ title: "修订人", dataIndex: "authorId", width: 90 },
{ title: "时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
{
title: "操作",
key: "act",
width: 100,
render: (_, r) => (
<Button size="small" onClick={() => void viewRevision(r)}>
查看 / 恢复
</Button>
),
},
]}
/>
</Modal>
</Card>
);
}
+198 -58
View File
@@ -1,12 +1,34 @@
/**
* # 路由 /admin/ 后台首页(Dashboard)
* 统计卡片 + 最近 7 天询盘趋势(纯 div 柱图)+ 最新 10 条询盘。
* # 路由 /admin/ 后台首页(现代化 CRM 看板)
* KPI 卡(含 30 天环比)→ 30 天订单/询盘双趋势图 → 待办与询盘漏斗 →
* 最近订单 / 最新询盘并排 + 快捷入口。
*/
import { createFileRoute } from "@tanstack/react-router";
import { Card, Col, Row, Statistic, Table, Tag } from "antd";
import { FileText, Inbox, Package, ShoppingCart, TimerReset, Image as ImageIcon } from "lucide-react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { Card, Col, Progress, Row, Space, Statistic, Table, Tag } from "antd";
import {
ArrowDownRight,
ArrowUpRight,
ClipboardList,
DollarSign,
FileText,
Inbox,
MessageSquarePlus,
Package,
PackagePlus,
ShoppingCart,
TimerReset,
} from "lucide-react";
import { adminStats } from "~/server/admin";
import { AdminEmpty, Forbidden, INQUIRY_STATUS_COLOR, INQUIRY_STATUS_LABEL, guardAdmin, fmtDateTime } from "~/components/admin/common";
import {
AdminEmpty,
Forbidden,
INQUIRY_STATUS_COLOR,
INQUIRY_STATUS_LABEL,
ORDER_STATUS_COLOR,
ORDER_STATUS_LABEL,
fmtDateTime,
guardAdmin,
} from "~/components/admin/common";
export const Route = createFileRoute("/admin/")({
loader: () => guardAdmin(() => adminStats()),
@@ -14,75 +36,193 @@ export const Route = createFileRoute("/admin/")({
});
/** # 函数 Dashboard 页面组件
* 渲染统计卡片、询盘趋势柱图与最新询盘表;未登录时展示占位。
* 数据全部来自 adminStats(30 天趋势、待办、状态分布、最近订单/询盘)。
*/
function DashboardPage() {
const stats = Route.useLoaderData();
if (!stats) return <Forbidden />;
const { counts, trend, recentInquiries } = stats;
const max = Math.max(1, ...trend.map((t) => t.count));
const { counts, trend, trend30, inquiryStatus, revenue, recentInquiries, recentOrders } = stats;
const maxBar = Math.max(1, ...trend.map((t) => t.count));
const max30 = Math.max(1, ...trend30.map((t) => Math.max(t.orders, t.inquiries)));
const maxRevenue = Math.max(1, ...trend30.map((t) => t.revenue));
// 30 天环比:后 15 天 vs 前 15 天
const half = 15;
const sum = (xs: number[]) => xs.reduce((a, b) => a + b, 0);
const delta = (curr: number, prev: number) => (prev === 0 ? (curr > 0 ? 1 : 0) : (curr - prev) / prev);
const ordNow = sum(trend30.slice(half).map((t) => t.orders));
const ordPrev = sum(trend30.slice(0, half).map((t) => t.orders));
const inqNow = sum(trend30.slice(half).map((t) => t.inquiries));
const inqPrev = sum(trend30.slice(0, half).map((t) => t.inquiries));
const revNow = sum(trend30.slice(half).map((t) => t.revenue));
const revPrev = sum(trend30.slice(0, half).map((t) => t.revenue));
const newInq = inquiryStatus.find((s) => s.status === "new")?.count ?? 0;
const spamInq = inquiryStatus.find((s) => s.status === "spam")?.count ?? 0;
const totalInq = inquiryStatus.reduce((a, b) => a + b.count, 0) || 1;
/** # 成员变量 统计卡片配置(图标 + 标题 + 数值) **/
/** # 成员变量 KPI 卡配置(图标 + 标题 + 数值 + 环比) **/
const cards = [
{ title: "内容", value: counts.contents, icon: <FileText size={20} aria-hidden color="#1677ff" /> },
{ title: "产品", value: counts.products, icon: <Package size={20} aria-hidden color="#52c41a" /> },
{ title: "订单", value: counts.orders, icon: <ShoppingCart size={20} aria-hidden color="#faad14" /> },
{ title: "询盘", value: counts.inquiries, icon: <Inbox size={20} aria-hidden color="#722ed1" /> },
{ title: "媒体", value: counts.media, icon: <ImageIcon size={20} aria-hidden color="#13c2c2" /> },
{ title: "待处理订单", value: counts.pendingOrders, icon: <TimerReset size={20} aria-hidden color="#fa541c" /> },
{ title: "订单(30 天环比)", value: ordNow, delta: delta(ordNow, ordPrev), icon: <ShoppingCart size={18} aria-hidden color="#faad14" />, href: "/admin/orders/" },
{ title: "营收(30 天环比)", value: revNow, delta: delta(revNow, revPrev), icon: <DollarSign size={18} aria-hidden color="#52c41a" />, href: "/admin/orders/", money: true },
{ title: "新询盘(待跟进)", value: newInq, delta: delta(inqNow, inqPrev), icon: <Inbox size={18} aria-hidden color="#722ed1" />, href: "/admin/inquiries?status=new" },
{ title: "待处理订单", value: counts.pendingOrders, icon: <TimerReset size={18} aria-hidden color="#fa541c" />, href: "/admin/orders/" },
{ title: "产品", value: counts.products, icon: <Package size={18} aria-hidden color="#13c2c2" />, href: "/admin/products/" },
{ title: "页面 / 文章", value: counts.contents, icon: <FileText size={18} aria-hidden color="#1677ff" />, href: "/admin/contents/" },
];
/** # 函数 环比箭头与配色 */
function DeltaTag({ d }: { d: number }) {
const up = d >= 0;
return (
<span style={{ fontSize: 12, color: up ? "#389e0d" : "#cf1322", display: "inline-flex", alignItems: "center", gap: 2 }}>
{up ? <ArrowUpRight size={12} aria-hidden /> : <ArrowDownRight size={12} aria-hidden />}
{`${(Math.abs(d) * 100).toFixed(0)}%`}
</span>
);
}
return (
<div>
<Row gutter={[16, 16]}>
{/* KPI 卡 */}
<Row gutter={[12, 12]}>
{cards.map((c) => (
<Col key={c.title} xs={12} sm={8} lg={4}>
<Card size="small">
<Statistic title={c.title} value={c.value} prefix={c.icon} />
</Card>
<Col key={c.title} xs={12} sm={8} xl={4}>
<a href={c.href} style={{ display: "block" }}>
<Card size="small" hoverable style={{ height: "100%" }}>
<Space size={10} align="start">
<div style={{ width: 34, height: 34, borderRadius: 8, background: "#f5f7fa", display: "flex", alignItems: "center", justifyContent: "center" }}>
{c.icon}
</div>
<div>
<div style={{ fontSize: 12, color: "#8c8c8c" }}>{c.title}</div>
<Space size={8}>
<span style={{ fontSize: 22, fontWeight: 600, lineHeight: 1.2 }}>
{c.money ? `$${Math.round(c.value).toLocaleString()}` : c.value.toLocaleString()}
</span>
{c.delta !== undefined ? <DeltaTag d={c.delta} /> : null}
</Space>
</div>
</Space>
</Card>
</a>
</Col>
))}
</Row>
<Card title="最近 7 天询盘趋势" size="small" style={{ marginTop: 16 }}>
<div style={{ display: "flex", alignItems: "flex-end", gap: 16, height: 160, padding: "0 8px" }}>
{trend.map((t) => (
<div key={t.day} style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 4, height: "100%", justifyContent: "flex-end" }}>
<span style={{ fontSize: 12, color: "#666" }}>{t.count}</span>
<div
role="img"
aria-label={`${t.day} 询盘 ${t.count} 条`}
style={{ width: "60%", maxWidth: 48, height: `${Math.round((t.count / max) * 100)}%`, minHeight: 4, background: "#1677ff", borderRadius: 4, opacity: 0.85 }}
/>
<span style={{ fontSize: 12, color: "#999" }}>{t.day.slice(5)}</span>
{/* 30 天双趋势 + 询盘漏斗 */}
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={24} xl={16}>
<Card
title="近 30 天订单 / 询盘趋势"
size="small"
extra={
<Space size={16} style={{ fontSize: 12, color: "#666" }}>
<span><span style={{ display: "inline-block", width: 10, height: 10, background: "#1677ff", borderRadius: 2, marginRight: 4 }} />订单</span>
<span><span style={{ display: "inline-block", width: 10, height: 10, background: "#722ed1", borderRadius: 2, marginRight: 4 }} />询盘</span>
</Space>
}
>
{/* 双列柱图(纯 div,无图表库依赖) */}
<div style={{ display: "flex", alignItems: "flex-end", gap: 3, height: 180 }}>
{trend30.map((t) => (
<div key={t.day} title={`${t.day}\n订单 ${t.orders}($${Math.round(t.revenue)})\n询盘 ${t.inquiries}`} style={{ flex: 1, height: "100%", display: "flex", alignItems: "flex-end", justifyContent: "center", gap: 1 }}>
<div style={{ width: "46%", height: `${(t.orders / max30) * 100}%`, minHeight: t.orders ? 3 : 0, background: "#1677ff", borderRadius: "2px 2px 0 0" }} />
<div style={{ width: "46%", height: `${(t.inquiries / max30) * 100}%`, minHeight: t.inquiries ? 3 : 0, background: "#722ed1", borderRadius: "2px 2px 0 0", opacity: 0.75 }} />
</div>
))}
</div>
))}
</div>
</Card>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 11, color: "#999", marginTop: 6 }}>
<span>{trend30[0]?.day}</span>
<span>{trend30[Math.floor(trend30.length / 2)]?.day}</span>
<span>{trend30[trend30.length - 1]?.day}</span>
</div>
{/* 营收迷你面积条 */}
<div style={{ display: "flex", alignItems: "flex-end", gap: 3, height: 44, marginTop: 10 }} title="每日营收($)">
{trend30.map((t) => (
<div key={t.day} style={{ flex: 1, height: `${Math.max(t.revenue ? 8 : 0, (t.revenue / maxRevenue) * 100)}%`, background: "#b7eb8f", borderRadius: "2px 2px 0 0" }} />
))}
</div>
</Card>
</Col>
<Col xs={24} xl={8}>
<Card title="询盘漏斗" size="small" style={{ height: "100%" }}>
<Space direction="vertical" size={12} style={{ display: "flex" }}>
{(["new", "read", "replied", "spam", "archived"] as const).map((st) => {
const n = inquiryStatus.find((s) => s.status === st)?.count ?? 0;
return (
<div key={st}>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 13, marginBottom: 4 }}>
<span>{INQUIRY_STATUS_LABEL[st] ?? st}</span>
<span style={{ fontWeight: 600 }}>{n.toLocaleString()}</span>
</div>
<Progress percent={Math.round((n / totalInq) * 100)} showInfo={false} strokeColor={INQUIRY_STATUS_COLOR[st] === "red" ? "#ff4d4f" : st === "new" ? "#1677ff" : "#95de64"} size="small" />
</div>
);
})}
<div style={{ fontSize: 12, color: "#999" }}>
共 {totalInq.toLocaleString()} 条,其中垃圾 {spamInq.toLocaleString()} 条({(Math.round((spamInq / totalInq) * 100))}%)
</div>
</Space>
</Card>
</Col>
</Row>
<Card title="最新询盘" size="small" style={{ marginTop: 16 }}>
<Table
rowKey="id"
size="small"
dataSource={recentInquiries}
pagination={false}
locale={{ emptyText: <AdminEmpty /> }}
columns={[
{ title: "ID", dataIndex: "id", width: 70 },
{ title: "姓名", dataIndex: "name", width: 140 },
{ title: "邮箱", dataIndex: "email", width: 220 },
{ title: "公司", dataIndex: "company", width: 160, ellipsis: true },
{ title: "国家", dataIndex: "country", width: 120 },
{ title: "感兴趣产品", dataIndex: "productInterest", ellipsis: true },
{
title: "状态",
dataIndex: "status",
width: 100,
render: (v: string) => <Tag color={INQUIRY_STATUS_COLOR[v] ?? "default"}>{INQUIRY_STATUS_LABEL[v] ?? v}</Tag>,
},
{ title: "时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
]}
/>
{/* 最近订单 / 最新询盘并排 */}
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={24} xl={12}>
<Card
title="最近订单"
size="small"
extra={<Link to="/admin/orders/">全部订单 →</Link>}
>
<Table
rowKey="id"
size="small"
dataSource={recentOrders}
pagination={false}
locale={{ emptyText: <AdminEmpty /> }}
columns={[
{ title: "单号", dataIndex: "orderNumber", width: 130, render: (v: string, r) => <Link to="/admin/orders/$id/" params={{ id: String(r.id) }}>{v}</Link> },
{ title: "客户", dataIndex: "customerName", ellipsis: true },
{ title: "金额", dataIndex: "total", width: 100, render: (v: string, r) => `${r.currency} ${v}` },
{ title: "状态", dataIndex: "status", width: 90, render: (v: string) => <Tag color={ORDER_STATUS_COLOR[v] ?? "default"}>{ORDER_STATUS_LABEL[v] ?? v}</Tag> },
{ title: "时间", dataIndex: "createdAt", width: 150, render: (v: string) => fmtDateTime(v) },
]}
/>
</Card>
</Col>
<Col xs={24} xl={12}>
<Card
title="最新询盘"
size="small"
extra={<Link to="/admin/inquiries/">全部询盘 →</Link>}
>
<Table
rowKey="id"
size="small"
dataSource={recentInquiries}
pagination={false}
locale={{ emptyText: <AdminEmpty /> }}
columns={[
{ title: "姓名", dataIndex: "name", width: 120, ellipsis: true },
{ title: "邮箱", dataIndex: "email", width: 200, ellipsis: true },
{ title: "国家", dataIndex: "country", width: 100 },
{ title: "状态", dataIndex: "status", width: 90, render: (v: string) => <Tag color={INQUIRY_STATUS_COLOR[v] ?? "default"}>{INQUIRY_STATUS_LABEL[v] ?? v}</Tag> },
{ title: "时间", dataIndex: "createdAt", width: 150, render: (v: string) => fmtDateTime(v) },
]}
/>
</Card>
</Col>
</Row>
{/* 快捷入口 */}
<Card title="快捷操作" size="small" style={{ marginTop: 16 }}>
<Space size={12} wrap>
<Link to="/admin/contents/$id/" params={{ id: "new" }}><Tag style={{ padding: "6px 12px", fontSize: 13 }}><FileText size={13} aria-hidden style={{ verticalAlign: -2, marginRight: 6 }} />新建页面</Tag></Link>
<Link to="/admin/products/"><Tag style={{ padding: "6px 12px", fontSize: 13 }}><PackagePlus size={13} aria-hidden style={{ verticalAlign: -2, marginRight: 6 }} />产品管理</Tag></Link>
<Link to="/admin/media/"><Tag style={{ padding: "6px 12px", fontSize: 13 }}><ClipboardList size={13} aria-hidden style={{ verticalAlign: -2, marginRight: 6 }} />媒体库</Tag></Link>
<Link to="/admin/inquiries/" search={{ status: "new" }}><Tag style={{ padding: "6px 12px", fontSize: 13 }}><MessageSquarePlus size={13} aria-hidden style={{ verticalAlign: -2, marginRight: 6 }} />跟进新询盘</Tag></Link>
<Link to="/admin/menus/"><Tag style={{ padding: "6px 12px", fontSize: 13 }}><ClipboardList size={13} aria-hidden style={{ verticalAlign: -2, marginRight: 6 }} />菜单管理</Tag></Link>
</Space>
</Card>
</div>
);
+40 -12
View File
@@ -3,11 +3,11 @@
* 表格 + 缩略图预览;编辑替代文本(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 { App, Button, Card, Form, Image, Input, Modal, Space, Table, Typography, Upload } from "antd";
import { EditOutlined, SearchOutlined, UploadOutlined } from "@ant-design/icons";
import { File as FileIcon } from "lucide-react";
import { useState } from "react";
import { adminListMedia, adminUpdateMedia } from "~/server/admin";
import { adminListMedia, adminUpdateMedia, adminUploadMedia } from "~/server/admin";
import { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common";
import type { media } from "~/db/schema";
@@ -74,15 +74,43 @@ function MediaPage() {
<PageHeader
title="媒体库"
extra={
<Input
allowClear
placeholder="搜索文件路径"
prefix={<SearchOutlined aria-hidden />}
value={q}
onChange={(e) => setQ(e.target.value)}
onPressEnter={() => updateSearch({ q })}
style={{ width: 240 }}
/>
<Space>
<Input
allowClear
placeholder="搜索文件路径"
prefix={<SearchOutlined aria-hidden />}
value={q}
onChange={(e) => setQ(e.target.value)}
onPressEnter={() => updateSearch({ q })}
style={{ width: 240 }}
/>
{/* 上传走 base64 JSON(server fn RPC),单文件 ≤20MB;成功后刷新列表 */}
<Upload
multiple
showUploadList={false}
customRequest={async ({ file, onSuccess, onError }) => {
try {
const f = file as File;
const buf = new Uint8Array(await f.arrayBuffer());
// 分块 btoa:直接 btoa(二进制串) 在大文件下栈溢出,按 0x8000 分块
let bin = "";
const CHUNK = 0x8000;
for (let i = 0; i < buf.length; i += CHUNK) bin += String.fromCharCode(...buf.subarray(i, i + CHUNK));
const res = await adminUploadMedia({ data: { filename: f.name, mime: f.type || "", dataBase64: btoa(bin) } });
message.success(`已上传 ${f.name}(#${res.id})`);
onSuccess?.(res);
await router.invalidate();
} catch (e) {
message.error(e instanceof Error ? e.message : "上传失败");
onError?.(e as Error);
}
}}
>
<Button type="primary" icon={<UploadOutlined aria-hidden />}>
上传媒体
</Button>
</Upload>
</Space>
}
/>
<Table
+177
View File
@@ -0,0 +1,177 @@
/**
* # 路由 /admin/menus 菜单管理
* 左侧菜单选择(menus 表 4 个位置)+ 右侧菜单项表格(menu_items 树平铺展示)。
* 支持新增/编辑/删除菜单项;删除时子项提升为顶层(与 server 端逻辑一致)。
*/
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { App, Button, Card, Form, Input, InputNumber, Modal, Select, Space, Table, TreeSelect } from "antd";
import { DeleteOutlined, EditOutlined, PlusOutlined } from "@ant-design/icons";
import { useEffect, useMemo, useState } from "react";
import { adminDeleteMenuItem, adminListMenus, adminSaveMenuItem } from "~/server/admin";
import { AdminEmpty, Forbidden, PageHeader, guardAdmin } from "~/components/admin/common";
export const Route = createFileRoute("/admin/menus")({
loader: () => guardAdmin(() => adminListMenus()),
component: MenusPage,
});
/** # 类型 菜单定义行 **/
type MenuDef = { id: number; name: string; location: string };
/** # 类型 菜单项行(平铺) **/
type MenuItemRow = { id: number; menuId: number; parentId: number | null; title: string; url: string; target: string; sort: number };
/** # 函数 菜单管理页面组件 */
function MenusPage() {
const data = Route.useLoaderData();
const router = useRouter();
const { message } = App.useApp();
/** # 成员变量 当前选中菜单 ID(默认第一个) **/
const [menuId, setMenuId] = useState<number | null>(null);
/** # 成员变量 编辑弹窗(null = 关闭;id=0 新建) **/
const [editing, setEditing] = useState<(Partial<MenuItemRow> & { id?: number }) | null>(null);
const [form] = Form.useForm();
// 首次/数据刷新后确定选中菜单
useEffect(() => {
if (!data) return;
if (menuId === null || !data.menus.some((m) => m.id === menuId)) setMenuId(data.menus[0]?.id ?? null);
}, [data, menuId]);
/** # 成员变量 当前菜单的项(树形缩进展示:父在前,子随父缩进) **/
const rows = useMemo(() => {
if (!data || menuId === null) return [];
const items = data.items.filter((i) => i.menuId === menuId);
const byId = new Map(items.map((i) => [i.id, { ...i, depth: 0 }]));
const out: (MenuItemRow & { depth: number })[] = [];
// 顶层(无父)按 sort 排序后递归挂子项
const roots = items.filter((i) => !i.parentId || !byId.has(i.parentId)).sort((a, b) => a.sort - b.sort || a.id - b.id);
const walk = (node: MenuItemRow, depth: number) => {
out.push({ ...node, depth });
items
.filter((i) => i.parentId === node.id)
.sort((a, b) => a.sort - b.sort || a.id - b.id)
.forEach((c) => walk(c, Math.min(depth + 1, 3)));
};
roots.forEach((r) => walk(r, 0));
return out;
}, [data, menuId]);
if (!data) return <Forbidden />;
/** # 函数 打开编辑弹窗(item 缺省 = 新建,挂在当前菜单顶层) */
function openEdit(item?: MenuItemRow) {
setEditing(item ?? { menuId: menuId ?? 0, parentId: null, title: "", url: "", target: "_self", sort: 0 });
form.setFieldsValue(item ?? { menuId: menuId ?? 0, parentId: null, title: "", url: "", target: "_self", sort: 0 });
}
/** # 函数 保存菜单项 */
async function handleSave(values: { menuId: number; parentId?: number | null; title: string; url: string; target: string; sort: number }) {
try {
await adminSaveMenuItem({ data: { ...values, parentId: values.parentId ?? null, id: editing?.id || undefined } });
message.success("已保存");
setEditing(null);
await router.invalidate();
} catch (e) {
message.error(e instanceof Error ? e.message : "保存失败");
}
}
/** # 函数 删除菜单项(子项自动提升顶层) */
async function handleDelete(id: number) {
await adminDeleteMenuItem({ data: { id } });
message.success("已删除(子项已提升为顶层)");
await router.invalidate();
}
/** # 成员变量 父级选择树(排除自身防环) **/
const parentOptions = [
{ title: "(顶层)", value: 0 },
...rows.filter((r) => r.id !== editing?.id).map((r) => ({ title: `${"— ".repeat(r.depth)}${r.title}`, value: r.id })),
];
return (
<Card size="small">
<PageHeader
title="菜单管理"
extra={
<Button type="primary" icon={<PlusOutlined aria-hidden />} onClick={() => openEdit()} disabled={menuId === null}>
新增菜单项
</Button>
}
/>
<Space style={{ marginBottom: 12 }} wrap>
<Select
style={{ width: 300 }}
value={menuId ?? undefined}
onChange={(v) => setMenuId(v)}
options={data.menus.map((m: MenuDef) => ({ value: m.id, label: `${m.name}(${m.location})` }))}
/>
<span style={{ color: "#999", fontSize: 12 }}>前台 Header/Footer 即时读取所选位置菜单</span>
</Space>
<Table
rowKey="id"
size="small"
dataSource={rows}
locale={{ emptyText: <AdminEmpty /> }}
pagination={false}
columns={[
{
title: "标题",
dataIndex: "title",
render: (v: string, r) => (
<span style={{ paddingLeft: r.depth * 24 }}>
{r.depth > 0 ? <span style={{ color: "#bbb", marginRight: 4 }}>└</span> : null}
{v}
</span>
),
},
{ title: "链接", dataIndex: "url", ellipsis: true, render: (v: string) => <code style={{ fontSize: 12 }}>{v}</code> },
{ title: "排序", dataIndex: "sort", width: 70 },
{ title: "打开方式", dataIndex: "target", width: 100 },
{
title: "操作",
key: "act",
width: 110,
render: (_, r) => (
<Space size={4}>
<Button size="small" type="text" icon={<EditOutlined aria-label="编辑" />} onClick={() => openEdit(r)} />
<Button size="small" danger type="text" icon={<DeleteOutlined aria-label="删除" />} onClick={() => void handleDelete(r.id)} />
</Space>
),
},
]}
/>
<Modal
title={editing?.id ? `编辑菜单项 #${editing.id}` : "新增菜单项"}
open={editing !== null}
onCancel={() => setEditing(null)}
onOk={() => form.submit()}
destroyOnHidden
>
<Form form={form} layout="vertical" onFinish={(v) => void handleSave(v as { menuId: number; parentId?: number | null; title: string; url: string; target: string; sort: number })}>
<Form.Item name="title" label="显示文字" rules={[{ required: true, message: "请输入显示文字" }]}>
<Input />
</Form.Item>
<Form.Item name="url" label="链接地址" rules={[{ required: true, message: "请输入链接" }]}>
<Input placeholder="/products/ 或 https://…" />
</Form.Item>
<Form.Item name="parentId" label="父级菜单项" initialValue={0}>
<TreeSelect treeData={parentOptions} treeDefaultExpandAll />
</Form.Item>
<Space size={16} style={{ display: "flex" }}>
<Form.Item name="sort" label="排序" initialValue={0}>
<InputNumber min={0} max={9999} />
</Form.Item>
<Form.Item name="target" label="打开方式" initialValue="_self">
<Select style={{ width: 140 }} options={[{ value: "_self", label: "当前窗口" }, { value: "_blank", label: "新窗口" }]} />
</Form.Item>
</Space>
<Form.Item name="menuId" hidden initialValue={menuId ?? 0}>
<Input />
</Form.Item>
</Form>
</Modal>
</Card>
);
}
+86
View File
@@ -0,0 +1,86 @@
/**
* # 路由 /admin/settings 系统设置
* settings 表 kv 编辑器:每键一个 JSON 编辑卡片,保存整键覆写。
* 仅 admin 角色可保存(server 端强制)。
*/
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { App, Button, Card, Input, Space, Tag, Typography } from "antd";
import { Save } from "lucide-react";
import { useEffect, useState } from "react";
import { adminListSettings, adminUpdateSetting } from "~/server/admin";
import { Forbidden, PageHeader, fmtDateTime, guardAdmin, type Json } from "~/components/admin/common";
export const Route = createFileRoute("/admin/settings")({
loader: () => guardAdmin(() => adminListSettings()),
component: SettingsPage,
});
/** # 成员变量 设置键中文说明(未知键回退原键名) **/
const SETTING_LABEL: Record<string, string> = {
site: "站点信息",
woocommerce: "商城配置",
mail: "邮件配置",
seo: "SEO 配置",
};
/** # 类型 设置行(updatedAt 为 Date,loader 序列化前的服务端类型) **/
type SettingRow = { key: string; value: Json; updatedAt: Date | string };
/** # 函数 系统设置页面组件 */
function SettingsPage() {
const data = Route.useLoaderData();
const router = useRouter();
const { message } = App.useApp();
/** # 成员变量 各键的草稿文本(键 → JSON 字符串) **/
const [drafts, setDrafts] = useState<Record<string, string>>({});
useEffect(() => {
if (!data) return;
setDrafts(Object.fromEntries(data.map((r: SettingRow) => [r.key, JSON.stringify(r.value, null, 2)])));
}, [data]);
if (!data) return <Forbidden />;
/** # 函数 保存单个设置键(JSON 校验失败提示) */
async function handleSave(key: string) {
try {
const parsed = JSON.parse(drafts[key] ?? "{}") as Json;
await adminUpdateSetting({ data: { key, value: parsed } });
message.success(`已保存 ${key}`);
await router.invalidate();
} catch (e) {
message.error(e instanceof Error ? e.message : "保存失败(JSON 格式需合法)");
}
}
return (
<Card size="small">
<PageHeader title="系统设置" extra={<Tag color="orange">仅管理员可保存</Tag>} />
<Space direction="vertical" size={16} style={{ display: "flex" }}>
{data.map((r: SettingRow) => (
<Card
key={r.key}
type="inner"
size="small"
title={`${SETTING_LABEL[r.key] ?? r.key}(${r.key})`}
extra={
<Button size="small" type="primary" icon={<Save size={13} aria-hidden />} onClick={() => void handleSave(r.key)}>
保存
</Button>
}
>
<Input.TextArea
rows={Math.min(14, Math.max(4, (drafts[r.key] ?? "").split("\n").length))}
value={drafts[r.key] ?? ""}
onChange={(e) => setDrafts((d) => ({ ...d, [r.key]: e.target.value }))}
style={{ fontFamily: "monospace", fontSize: 12 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
最后更新:{fmtDateTime(r.updatedAt)}
</Typography.Text>
</Card>
))}
</Space>
</Card>
);
}
+46 -16
View File
@@ -12,34 +12,60 @@ import {
Image as ImageIcon,
Languages,
LayoutDashboard,
List,
LogOut,
Package,
Route as RouteIcon,
ScrollText,
Settings,
ShoppingCart,
SlidersHorizontal,
UserRound,
} from "lucide-react";
import { useState } from "react";
import { useState, type ReactNode } from "react";
import { StyleProvider } from "@ant-design/cssinjs";
import { getCurrentUser, loginFn, logoutFn } from "~/server/auth";
/** # 类型 后台菜单项(key 即目标路由路径) **/
const MENU_ITEMS = [
{ key: "/admin/", icon: <LayoutDashboard size={16} aria-hidden />, label: "Dashboard" },
{ key: "/admin/contents/", icon: <Files size={16} aria-hidden />, label: "内容" },
{ key: "/admin/products/", icon: <Package size={16} aria-hidden />, label: "产品" },
{ key: "/admin/orders/", icon: <ShoppingCart size={16} aria-hidden />, label: "订单" },
{ key: "/admin/inquiries/", icon: <Inbox size={16} aria-hidden />, label: "询盘" },
{ key: "/admin/media/", icon: <ImageIcon size={16} aria-hidden />, label: "媒体" },
{ key: "/admin/languages/", icon: <Languages size={16} aria-hidden />, label: "语言" },
{ key: "/admin/seo/", icon: <RouteIcon size={16} aria-hidden />, label: "SEO / 重定向" },
{ key: "/admin/filters/", icon: <SlidersHorizontal size={16} aria-hidden />, label: "筛选" },
{ key: "/admin/users/", icon: <Settings size={16} aria-hidden />, label: "系统设置" },
] as const;
/** # 类型 后台菜单分组(分组标签不可点;key 即目标路由路径) **/
const MENU_GROUPS: { label: string; children: { key: string; icon: ReactNode; label: string }[] }[] = [
{
label: "概览",
children: [{ key: "/admin/", icon: <LayoutDashboard size={16} aria-hidden />, label: "Dashboard" }],
},
{
label: "内容",
children: [
{ key: "/admin/contents/", icon: <Files size={16} aria-hidden />, label: "页面 / 文章" },
{ key: "/admin/menus/", icon: <List size={16} aria-hidden />, label: "菜单" },
{ key: "/admin/media/", icon: <ImageIcon size={16} aria-hidden />, label: "媒体库" },
],
},
{
label: "商务",
children: [
{ key: "/admin/products/", icon: <Package size={16} aria-hidden />, label: "产品" },
{ key: "/admin/orders/", icon: <ShoppingCart size={16} aria-hidden />, label: "订单" },
{ key: "/admin/inquiries/", icon: <Inbox size={16} aria-hidden />, label: "询盘" },
],
},
{
label: "系统",
children: [
{ key: "/admin/filters/", icon: <SlidersHorizontal size={16} aria-hidden />, label: "筛选" },
{ key: "/admin/languages/", icon: <Languages size={16} aria-hidden />, label: "语言" },
{ key: "/admin/seo/", icon: <RouteIcon size={16} aria-hidden />, label: "SEO / 重定向" },
{ key: "/admin/users/", icon: <UserRound size={16} aria-hidden />, label: "用户" },
{ key: "/admin/settings/", icon: <Settings size={16} aria-hidden />, label: "系统设置" },
{ key: "/admin/audit/", icon: <ScrollText size={16} aria-hidden />, label: "审计日志" },
],
},
];
/** # 类型 后台菜单项(扁平视图,用于路径高亮匹配) **/
const MENU_ITEMS = MENU_GROUPS.flatMap((g) => g.children);
/** # 类型 合法菜单路径(用于类型安全的导航) **/
type MenuKey = (typeof MENU_ITEMS)[number]["key"];
type MenuKey = string;
/** # 类型 拥有后台权限的角色 **/
const ADMIN_ROLES = ["admin", "editor", "manager"];
@@ -159,7 +185,11 @@ function AdminShell({ user }: { user: { displayName: string | null; email: strin
<Menu
theme="dark"
mode="inline"
items={MENU_ITEMS.map((m) => ({ key: m.key, icon: m.icon, label: m.label }))}
items={MENU_GROUPS.map((g) => ({
type: "group" as const,
label: <span style={{ fontSize: 12, opacity: 0.6 }}>{g.label}</span>,
children: g.children.map((m) => ({ key: m.key, icon: m.icon, label: m.label })),
}))}
selectedKeys={[selectedKey]}
onClick={({ key }) => void navigate({ to: key as MenuKey })}
/>
+132 -24
View File
@@ -1,17 +1,18 @@
/**
* # 路由 /admin/users 用户管理(系统设置,只读)
* 用户列表:分页 + 关键字搜索;角色用 Tag 展示;不做编辑操作。
* # 路由 /admin/users 用户管理
* 用户列表(分页 + 搜索);admin 可新建用户、改角色、重置密码。
* 服务端有防锁死保护:最后一个 admin 不可降级。
*/
import { createFileRoute } from "@tanstack/react-router";
import { Card, Input, Table, Tag } from "antd";
import { SearchOutlined } from "@ant-design/icons";
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { App, Button, Card, Form, Input, Modal, Select, Space, Table, Tag } from "antd";
import { KeyRound, Search, UserPlus } from "lucide-react";
import { getCurrentUser } from "~/server/auth";
import { useState } from "react";
import { adminListUsers } from "~/server/admin";
import { adminCreateUser, adminListUsers, adminResetUserPassword, adminUpdateUserRole } from "~/server/admin";
import { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common";
export const Route = createFileRoute("/admin/users")({
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;
@@ -19,61 +20,168 @@ export const Route = createFileRoute("/admin/users")({
return out;
},
loaderDeps: ({ search }) => ({ search }),
loader: ({ deps }) => guardAdmin(() => adminListUsers({ data: { page: deps.search.page, q: deps.search.q || undefined } })),
loader: ({ deps }) =>
guardAdmin(async () => {
const [user, me] = await Promise.all([adminListUsers({ data: { page: deps.search.page, q: deps.search.q || undefined } }), getCurrentUser()]);
return { users: user, me };
}),
component: UsersPage,
});
/** # 成员变量 角色中文标签与配色 **/
const ROLE_LABEL: Record<string, string> = { admin: "管理员", editor: "编辑", manager: "经理", customer: "客户" };
const ROLE_COLOR: Record<string, string> = { admin: "blue", editor: "cyan", manager: "geekblue", customer: "default" };
const ROLE_OPTIONS = (["admin", "editor", "manager", "customer"] as const).map((r) => ({ value: r, label: ROLE_LABEL[r] ?? r }));
/** # 函数 用户列表页面组件 */
function UsersPage() {
const data = Route.useLoaderData();
const search = Route.useSearch();
const navigate = Route.useNavigate();
const router = useRouter();
const { message, modal } = App.useApp();
const [q, setQ] = useState(search.q ?? "");
/** # 成员变量 新建用户弹窗开关 **/
const [createOpen, setCreateOpen] = useState(false);
const [createForm] = Form.useForm();
const isAdmin = data?.me?.role === "admin";
if (!data) return <Forbidden />;
/** # 函数 修改用户角色(服务端校验最后一个 admin 不可降级) */
async function handleRoleChange(id: number, role: string) {
try {
await adminUpdateUserRole({ data: { id, role } });
message.success("角色已更新");
await router.invalidate();
} catch (e) {
message.error(e instanceof Error ? e.message : "更新失败");
}
}
/** # 函数 重置密码弹窗(admin 专用) */
function openResetPassword(id: number, email: string) {
let pwd = "";
modal.confirm({
title: `重置密码:${email}`,
content: <Input.Password placeholder="新密码(至少 8 位)" onChange={(e) => (pwd = e.target.value)} autoFocus />,
okText: "重置",
cancelText: "取消",
onOk: async () => {
try {
await adminResetUserPassword({ data: { id, password: pwd } });
message.success("密码已重置");
} catch (e) {
message.error(e instanceof Error ? e.message : "重置失败");
}
},
});
}
/** # 函数 新建用户提交 */
async function handleCreate(values: { email: string; username?: string; displayName?: string; password: string; role: string }) {
try {
await adminCreateUser({ data: values });
message.success("用户已创建");
setCreateOpen(false);
createForm.resetFields();
await router.invalidate();
} catch (e) {
message.error(e instanceof Error ? e.message : "创建失败");
}
}
return (
<Card size="small">
<PageHeader
title="用户管理"
extra={
<Input
allowClear
placeholder="搜索邮箱 / 显示名"
prefix={<SearchOutlined aria-hidden />}
value={q}
onChange={(e) => setQ(e.target.value)}
onPressEnter={() => void navigate({ search: (prev) => ({ ...prev, q, page: 1 }) })}
style={{ width: 240 }}
/>
<Space>
<Input
allowClear
placeholder="搜索邮箱 / 显示名"
prefix={<Search size={13} aria-hidden />}
value={q}
onChange={(e) => setQ(e.target.value)}
onPressEnter={() => void navigate({ search: (prev) => ({ ...prev, q, page: 1 }) })}
style={{ width: 240 }}
/>
{isAdmin ? (
<Button type="primary" icon={<UserPlus size={14} aria-hidden />} onClick={() => setCreateOpen(true)}>
新建用户
</Button>
) : null}
</Space>
}
/>
<Table
rowKey="id"
size="small"
dataSource={data.items}
dataSource={data.users.items}
locale={{ emptyText: <AdminEmpty /> }}
pagination={tablePagination(data.total, data.page, data.pageSize, (p) =>
pagination={tablePagination(data.users.total, data.users.page, data.users.pageSize, (p) =>
void navigate({ search: (prev) => ({ ...prev, page: p }) }),
)}
columns={[
{ title: "ID", dataIndex: "id", width: 80 },
{ title: "邮箱", dataIndex: "email", width: 260, ellipsis: true },
{ title: "用户名", dataIndex: "username", width: 160, ellipsis: true },
{ title: "显示名", dataIndex: "displayName", width: 180, ellipsis: true },
{ title: "邮箱", dataIndex: "email", width: 240, ellipsis: true },
{ title: "显示名", dataIndex: "displayName", width: 170, ellipsis: true },
{
title: "角色",
dataIndex: "role",
width: 110,
render: (v: string) => <Tag color={ROLE_COLOR[v] ?? "default"}>{ROLE_LABEL[v] ?? v}</Tag>,
width: 170,
render: (v: string, r) =>
isAdmin ? (
<Select
size="small"
value={v}
style={{ width: 120 }}
options={ROLE_OPTIONS}
onChange={(role) => void handleRoleChange(r.id, role)}
/>
) : (
<Tag color={ROLE_COLOR[v] ?? "default"}>{ROLE_LABEL[v] ?? v}</Tag>
),
},
{ title: "注册时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
{
title: "操作",
key: "act",
width: 120,
render: (_, r) =>
isAdmin ? (
<Button size="small" icon={<KeyRound size={13} aria-hidden />} onClick={() => openResetPassword(r.id, r.email)}>
重置密码
</Button>
) : null,
},
]}
/>
<Modal
title="新建用户"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={() => createForm.submit()}
destroyOnHidden
>
<Form form={createForm} layout="vertical" onFinish={(v) => void handleCreate(v as { email: string; password: string; role: string })}>
<Form.Item name="email" label="邮箱" rules={[{ required: true, type: "email", message: "请输入有效邮箱" }]}>
<Input />
</Form.Item>
<Space size={16} style={{ display: "flex" }}>
<Form.Item name="displayName" label="显示名" style={{ flex: 1 }}>
<Input placeholder="留空取邮箱前缀" />
</Form.Item>
<Form.Item name="role" label="角色" initialValue="editor" rules={[{ required: true }]}>
<Select style={{ width: 140 }} options={ROLE_OPTIONS} />
</Form.Item>
</Space>
<Form.Item name="password" label="初始密码" rules={[{ required: true, min: 8, message: "至少 8 位" }]}>
<Input.Password />
</Form.Item>
</Form>
</Modal>
</Card>
);
}