v0.11.0 后台 CRM 化:媒体上传/单页面 Elementor/修订/分类标签/菜单/用户/审计/设置/Dashboard v2 + dusuniot-cms MCP 服务器
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user