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
+14
View File
@@ -0,0 +1,14 @@
{
"mcp": {
"servers": {
"dusuniot-cms": {
"command": "bun",
"args": ["mcp/server.ts"],
"cwd": "/home/bigcode/data/dev/sites/dusuniot",
"env": {
"DATABASE_URL": "postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot"
}
}
}
}
}
+45
View File
@@ -0,0 +1,45 @@
# 后台(Admin / CRM)与 MCP 说明
> v0.11.0 起:后台按「功能接近原版 WordPress + 现代化 CRM 界面」重做一轮,并新增站点管理 MCP 服务器。
## 后台功能清单(/admin,React + Ant Design)
| 模块 | 路由 | 能力 |
| --- | --- | --- |
| Dashboard | `/admin/` | KPI 卡(30 天环比:订单/营收/新询盘)、近 30 天订单/询盘/营收三趋势图、询盘漏斗(new/read/replied/spam/archived 占比)、最近订单 + 最新询盘、快捷入口 |
| 页面 / 文章 | `/admin/contents/` | page/post/landing Tab、搜索、分页、删除;编辑页支持 **分类目录/标签绑定**(content_terms)、**Elementor JSON** 编辑(meta.elementor,前台 `/slug/` 通栏渲染)、**修订历史**(保存自动留档、查看/恢复)、已发布页面前台预览链接 |
| 菜单 | `/admin/menus/` | menus 4 个位置(menu-1/menu-2/max_mega_menu_1/max_mega_menu_2)+ menu_items 树编辑(父级/排序/新窗口),前台 Header/Footer 即时读取 |
| 媒体库 | `/admin/media/` | 列表/搜索/预览、alt 与标题编辑、**上传**(≤20MB,写入 uploads/YYYY/MM/,自动解析 PNG/JPEG/GIF/WebP 宽高) |
| 产品 | `/admin/products/` | CRUD + 属性/分类/特色图/图集 |
| 订单 | `/admin/orders/` | 列表/详情/状态流转 |
| 询盘 | `/admin/inquiries/` | 状态 Tab、关键字、详情抽屉、跟进流转(new→read→replied) |
| 语言 | `/admin/languages/` | 启用开关 |
| SEO / 重定向 | `/admin/seo/` | 重定向 CRUD(301/302/正则) |
| 筛选 | `/admin/filters/` | 前台产品筛选器配置 |
| 用户 | `/admin/users/` | 列表/搜索;admin 可新建用户、改角色、重置密码(最后一个 admin 防降级锁死) |
| 系统设置 | `/admin/settings/` | settings 表 kv JSON 编辑器(site/woocommerce/mail/seo),仅 admin 可保存 |
| 审计日志 | `/admin/audit/` | 全部后台写操作留痕(操作人/动作/实体/摘要),含 MCP 调用(actor 为空) |
规范:所有 server function 第一步 `requireAdmin()`;写操作落 `audit_logs`;图标全 SVG(lucide / antd icons)。
## MCP 服务器(dusuniot-cms)
- 入口:`mcp/server.ts`(Bun 直跑,stdio JSON-RPC,MCP 2024-11-05,无 SDK 依赖)
- 注册:`.zcode/config.json`(workspace 级,会话启动自动连接)
- 直连 PostgreSQL(`DATABASE_URL`,默认 `postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot`),仅限受信任本机;写操作落审计日志(actor 为空 = MCP)
- 工具(17 个):
- `dashboard_stats` 看板统计
- `list_contents` / `get_content` / `create_content` / `update_content` / `delete_content`(自定义单页面:type=page + elementorJson,保存/更新自动留修订快照,slug 冲突自动加后缀)
- `list_revisions` / `restore_revision`
- `list_media` / `upload_media`(base64,≤20MB,自动解析宽高)
- `list_products` / `list_orders` / `update_order_status`
- `list_inquiries` / `update_inquiry_status`
- `get_setting` / `set_setting`
## 手动测试
```sh
bun mcp/server.ts # stdio;initialize → tools/list → tools/call
bun test # 11 pass
bun run build && bun scripts/prod-server.ts # 站点 + 后台 :3312
```
+512
View File
@@ -0,0 +1,512 @@
/**
* # MCP 服务器 DusunIoT CMS(stdio)
* 让 ZCode 等 Agent 直接管理站点内容:页面/文章 CRUD(支持 Elementor JSON
* 自定义单页面)、修订恢复、媒体上传、产品/订单/询盘查询与状态流转、看板统计。
*
* 协议:JSON-RPC 2.0 over stdio(MCP 2024-11-05),无 SDK 依赖,Bun 直跑:
* bun mcp/server.ts
* 认证:本地直连 PostgreSQL(与站点同库),仅限受信任的本机环境使用;
* 写操作全部落 audit_logs(actorId 为空表示 MCP 调用)。
*/
import { SQL } from "bun";
import { parseImageSize } from "../src/server/image-size";
/** # 成员变量 数据库连接(与站点同库;单连接足够 MCP 串行调用) **/
const sql = new SQL(process.env.DATABASE_URL || "postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot", { max: 2 });
/** # 成员变量 MCP 工具定义(name/description/inputSchema) **/
const TOOLS = [
{
name: "dashboard_stats",
description: "站点运营看板统计:内容/产品/订单/询盘/媒体计数、新询盘数、待处理订单数、营收(按币种)、近 7 天询盘趋势",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
},
{
name: "list_contents",
description: "内容列表(页面 page / 文章 post / 落地页 landing),支持状态与关键字过滤、分页;返回 id/slug/标题/状态/更新时间",
inputSchema: {
type: "object",
properties: {
type: { type: "string", enum: ["page", "post", "landing", "template"], description: "内容类型过滤" },
status: { type: "string", enum: ["draft", "review", "published"], description: "状态过滤" },
q: { type: "string", description: "标题/别名关键字" },
page: { type: "number", description: "页码,默认 1" },
pageSize: { type: "number", description: "每页条数,默认 20,上限 100" },
},
additionalProperties: false,
},
},
{
name: "get_content",
description: "读取单条内容全文:正文 HTML、Elementor JSON(meta.elementor)、SEO 字段、修订数",
inputSchema: { type: "object", properties: { id: { type: "number" } }, required: ["id"], additionalProperties: false },
},
{
name: "create_content",
description: "新建内容(自定义单页面推荐 type=page + elementorJson,前台 /slug/ 通栏渲染;纯 HTML 页用 html 字段)",
inputSchema: {
type: "object",
properties: {
type: { type: "string", enum: ["page", "post", "landing"], description: "内容类型" },
title: { type: "string", description: "标题(必填)" },
slug: { type: "string", description: "URL 别名,留空按标题生成" },
html: { type: "string", description: "正文 HTML" },
elementorJson: { type: "string", description: "Elementor 文档 JSON 字符串(存 meta.elementor,优先于 HTML 渲染)" },
excerpt: { type: "string" },
status: { type: "string", enum: ["draft", "review", "published"] },
seoTitle: { type: "string" },
seoDescription: { type: "string" },
},
required: ["type", "title"],
additionalProperties: false,
},
},
{
name: "update_content",
description: "更新内容(部分字段即可;elementorJson 传 null 表示清除 Elementor 数据)。更新前自动写修订快照",
inputSchema: {
type: "object",
properties: {
id: { type: "number" },
title: { type: "string" },
slug: { type: "string" },
html: { type: "string" },
elementorJson: { type: ["string", "null"], description: "Elementor JSON 字符串或 null(清除)" },
excerpt: { type: "string" },
status: { type: "string", enum: ["draft", "review", "published"] },
seoTitle: { type: ["string", "null"] },
seoDescription: { type: ["string", "null"] },
},
required: ["id"],
additionalProperties: false,
},
},
{
name: "delete_content",
description: "删除内容(物理删除)",
inputSchema: { type: "object", properties: { id: { type: "number" } }, required: ["id"], additionalProperties: false },
},
{
name: "list_revisions",
description: "内容修订快照列表(每次保存自动留档)",
inputSchema: { type: "object", properties: { contentId: { type: "number" } }, required: ["contentId"], additionalProperties: false },
},
{
name: "restore_revision",
description: "恢复内容到指定修订(当前版本先再留档)",
inputSchema: { type: "object", properties: { id: { type: "number", description: "修订 ID" } }, required: ["id"], additionalProperties: false },
},
{
name: "list_media",
description: "媒体库列表(路径/URL/宽高/大小),支持文件名关键字与分页",
inputSchema: {
type: "object",
properties: { q: { type: "string" }, page: { type: "number" }, pageSize: { type: "number" } },
additionalProperties: false,
},
},
{
name: "upload_media",
description: "上传媒体文件(base64),写入 uploads/YYYY/MM/ 并入库,自动解析图片宽高",
inputSchema: {
type: "object",
properties: {
filename: { type: "string" },
mime: { type: "string" },
dataBase64: { type: "string", description: "文件内容 base64(≤20MB)" },
},
required: ["filename", "dataBase64"],
additionalProperties: false,
},
},
{
name: "list_products",
description: "产品列表(SKU/标题/价格/库存/状态),支持关键字与分页",
inputSchema: {
type: "object",
properties: { q: { type: "string" }, page: { type: "number" }, pageSize: { type: "number" } },
additionalProperties: false,
},
},
{
name: "list_orders",
description: "订单列表(单号/状态/金额/客户名),支持状态过滤与分页",
inputSchema: {
type: "object",
properties: { status: { type: "string" }, page: { type: "number" }, pageSize: { type: "number" } },
additionalProperties: false,
},
},
{
name: "update_order_status",
description: "更新订单状态(pending/on-hold/processing/completed/cancelled/refunded)",
inputSchema: {
type: "object",
properties: { id: { type: "number" }, status: { type: "string" } },
required: ["id", "status"],
additionalProperties: false,
},
},
{
name: "list_inquiries",
description: "询盘列表(姓名/邮箱/公司/国家/感兴趣产品/状态),支持状态过滤与分页;CRM 跟进用",
inputSchema: {
type: "object",
properties: { status: { type: "string", enum: ["new", "read", "replied", "spam", "archived"] }, q: { type: "string" }, page: { type: "number" }, pageSize: { type: "number" } },
additionalProperties: false,
},
},
{
name: "update_inquiry_status",
description: "更新询盘状态(跟进流转:new→read→replied,或标记 spam/archived)",
inputSchema: {
type: "object",
properties: { id: { type: "number" }, status: { type: "string", enum: ["new", "read", "replied", "spam", "archived"] } },
required: ["id", "status"],
additionalProperties: false,
},
},
{
name: "get_setting",
description: "读取系统设置键(site/woocommerce/mail/seo 等)",
inputSchema: { type: "object", properties: { key: { type: "string" } }, required: ["key"], additionalProperties: false },
},
{
name: "set_setting",
description: "写入系统设置键(整键覆写,value 须为 JSON 对象)",
inputSchema: {
type: "object",
properties: { key: { type: "string" }, value: { type: "object" } },
required: ["key", "value"],
additionalProperties: false,
},
},
];
/** # 函数 内部工具:把标题转成 URL 别名(slug),与后台 admin.ts 同规则 */
function slugify(title: string): string {
return (
title
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 200) || `item-${Date.now()}`
);
}
/** # 函数 内部工具:写审计日志(MCP 调用 actorId 为空) */
async function audit(action: string, entity: string, entityId: string | number, meta?: unknown) {
await sql`insert into audit_logs (actor_id, action, entity, entity_id, meta) values (null, ${action}, ${entity}, ${String(entityId)}, ${sql.json(meta ?? {})}::jsonb)`;
}
/** # 函数 内部工具:把 SQL 片段用 " and " 连接(Bun SQL 无 sql.join/sql.raw,嵌套模板实现) */
function andJoin(conds: SQL[]): SQL {
return conds.reduce((acc, c) => (acc ? (sql`${acc} and ${c}` as SQL) : c));
}
/** # 函数 内部工具:把 SQL 片段用 ", " 连接(UPDATE SET 列表用) */
function commaJoin(chunks: SQL[]): SQL {
return chunks.reduce((acc, c) => (acc ? (sql`${acc}, ${c}` as SQL) : c));
}
/** # 函数 内部工具:slug 冲突时追加 -2/-3 …(contents_slug_type_idx 唯一约束) */
async function uniqueSlug(base: string, type: string): Promise<string> {
let slug = base;
for (let n = 1; n < 100; n++) {
const hit = await sql`select 1 from contents where slug = ${slug} and type = ${type} limit 1`;
if (hit.length === 0) return slug;
slug = `${base}-${n + 1}`;
}
throw new Error("slug 生成失败:冲突过多");
}
/** # 函数 工具调用实现(tools/call 分发)
* 参数 name:工具名;args:入参对象
* 返回值 任意可 JSON 序列化结果(由调用方包成 MCP text content)
*/
async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
const a = args ?? {};
switch (name) {
case "dashboard_stats": {
const [counts] = await sql`
select
(select count(*) from contents) as contents,
(select count(*) from products) as products,
(select count(*) from orders) as orders,
(select count(*) from inquiries) as inquiries,
(select count(*) from media) as media,
(select count(*) from orders where status in ('pending','on-hold')) as pending_orders,
(select count(*) from inquiries where status = 'new') as new_inquiries`;
const revenue = await sql`select currency, coalesce(sum(total),0) as total from orders where status in ('completed','processing') group by currency`;
const trend = await sql`
select to_char(date_trunc('day', created_at), 'YYYY-MM-DD') as day, count(*) as n
from inquiries where created_at > now() - interval '7 days' group by 1 order by 1`;
return { counts, revenue, trend };
}
case "list_contents": {
const page = Math.max(1, Number(a.page) || 1);
const pageSize = Math.min(100, Number(a.pageSize) || 20);
const conds: SQL[] = [];
if (a.type) conds.push(sql`type = ${a.type}`);
if (a.status) conds.push(sql`status = ${a.status}`);
if (a.q) conds.push(sql`(title ilike ${"%" + a.q + "%"} or slug ilike ${"%" + a.q + "%"})`);
const where = conds.length ? sql` where ${andJoin(conds)}` : sql``;
const items = await sql`select id, type, slug, title, status, template, views, published_at, updated_at from contents${where} order by updated_at desc limit ${pageSize} offset ${(page - 1) * pageSize}`;
const [{ total }] = await sql`select count(*) as total from contents${where}`;
return { items, total: Number(total), page, pageSize };
}
case "get_content": {
const [row] = await sql`select * from contents where id = ${Number(a.id)}`;
if (!row) throw new Error(`内容 #${a.id} 不存在`);
const revs = await sql`select count(*) as n from content_revisions where content_id = ${Number(a.id)}`;
return { ...row, revisionCount: Number(revs[0].n) };
}
case "create_content": {
const type = String(a.type || "page");
const title = String(a.title || "").trim();
if (!title) throw new Error("title 必填");
const slug = await uniqueSlug(String(a.slug || "").trim() || slugify(title), type);
// elementorJson 解析进 meta.elementor(非法 JSON 直接报错,防脏数据入渲染器)
const meta: Record<string, unknown> = {};
if (typeof a.elementorJson === "string" && a.elementorJson.trim()) {
try {
meta.elementor = JSON.parse(a.elementorJson);
} catch {
throw new Error("elementorJson 不是合法 JSON");
}
}
const status = String(a.status || "draft");
const [row] = await sql`
insert into contents (type, slug, title, excerpt, html, status, template, seo_title, seo_description, meta, published_at, author_id)
values (${type}, ${slug}, ${title}, ${String(a.excerpt || "")}, ${String(a.html || "")}, ${status}, 'default',
${a.seoTitle ? String(a.seoTitle) : null}, ${a.seoDescription ? String(a.seoDescription) : null},
${sql.json(meta)}::jsonb, ${status === "published" ? new Date() : null}, null)
returning id`;
await audit("content.create", "content", row.id, { title, type, via: "mcp" });
return { ok: true, id: row.id, slug, frontUrl: status === "published" ? `/${slug}/` : null };
}
case "update_content": {
const id = Number(a.id);
const [cur] = await sql`select id, title, html, meta, status, published_at from contents where id = ${id}`;
if (!cur) throw new Error(`内容 #${id} 不存在`);
// 修订快照(与后台保存一致)
await sql`insert into content_revisions (content_id, title, html, author_id) values (${id}, ${cur.title}, ${cur.html}, null)`;
// 逐字段构建 SET(仅更新显式传入的字段)
const sets: SQL[] = [sql`updated_at = ${new Date()}`];
if (a.title !== undefined) sets.push(sql`title = ${String(a.title)}`);
if (a.slug !== undefined) sets.push(sql`slug = ${await uniqueSlug(String(a.slug), String(cur.type ?? "page"))}`);
if (a.html !== undefined) sets.push(sql`html = ${String(a.html)}`);
if (a.excerpt !== undefined) sets.push(sql`excerpt = ${String(a.excerpt)}`);
if (a.status !== undefined) {
const st = String(a.status);
sets.push(sql`status = ${st}`);
if (st === "published" && !cur.published_at) sets.push(sql`published_at = ${new Date()}`);
}
if (a.seoTitle !== undefined) sets.push(sql`seo_title = ${a.seoTitle === null ? null : String(a.seoTitle)}`);
if (a.seoDescription !== undefined) sets.push(sql`seo_description = ${a.seoDescription === null ? null : String(a.seoDescription)}`);
if (a.elementorJson !== undefined) {
const base = (cur.meta ?? {}) as Record<string, unknown>;
if (a.elementorJson === null) delete base.elementor;
else {
try {
base.elementor = JSON.parse(String(a.elementorJson));
} catch {
throw new Error("elementorJson 不是合法 JSON");
}
}
sets.push(sql`meta = ${sql.json(base)}::jsonb`);
}
await sql`update contents set ${commaJoin(sets)} where id = ${id}`;
await audit("content.update", "content", id, { fields: Object.keys(a).filter((k) => k !== "id"), via: "mcp" });
return { ok: true, id };
}
case "delete_content": {
const id = Number(a.id);
await sql`delete from content_terms where content_id = ${id}`;
await sql`delete from content_revisions where content_id = ${id}`;
await sql`delete from contents where id = ${id}`;
await audit("content.delete", "content", id, { via: "mcp" });
return { ok: true };
}
case "list_revisions":
return await sql`select id, content_id, title, author_id, created_at from content_revisions where content_id = ${Number(a.contentId)} order by id desc limit 50`;
case "restore_revision": {
const [rev] = await sql`select * from content_revisions where id = ${Number(a.id)}`;
if (!rev) throw new Error(`修订 #${a.id} 不存在`);
const [cur] = await sql`select title, html from contents where id = ${rev.content_id}`;
if (cur) await sql`insert into content_revisions (content_id, title, html, author_id) values (${rev.content_id}, ${cur.title}, ${cur.html}, null)`;
await sql`update contents set title = ${rev.title}, html = ${rev.html}, updated_at = ${new Date()} where id = ${rev.content_id}`;
await audit("content.revision.restore", "content", rev.content_id, { revisionId: rev.id, via: "mcp" });
return { ok: true, contentId: rev.content_id };
}
case "list_media": {
const page = Math.max(1, Number(a.page) || 1);
const pageSize = Math.min(100, Number(a.pageSize) || 20);
const where = a.q ? sql` where path ilike ${"%" + a.q + "%"}` : sql``;
const items = await sql`select id, path, url, mime_type, width, height, size, alt_text, created_at from media${where} order by id desc limit ${pageSize} offset ${(page - 1) * pageSize}`;
const [{ total }] = await sql`select count(*) as total from media${where}`;
return { items, total: Number(total), page, pageSize };
}
case "upload_media": {
const buf = Buffer.from(String(a.dataBase64 || ""), "base64");
if (!buf.length) throw new Error("空文件");
if (buf.length > 20 * 1024 * 1024) throw new Error("文件超过 20MB 上限");
const now = new Date();
const dir = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, "0")}`;
const clean = String(a.filename || `file-${Date.now()}`).replace(/[\\/]+/g, "-").replace(/[^a-zA-Z0-9._\-\u4e00-\u9fa5]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 160) || `file-${Date.now()}`;
const dot = clean.lastIndexOf(".");
const stem = dot === -1 ? clean : clean.slice(0, dot);
const ext = dot === -1 ? "" : clean.slice(dot).toLowerCase();
const fs = await import("node:fs/promises");
const path = await import("node:path");
const root = process.env.DUSUN_ROOT || path.resolve(import.meta.dir, "..");
const absDir = path.join(root, "public", "uploads", dir);
await fs.mkdir(absDir, { recursive: true });
let rel = `${dir}/${stem}${ext}`;
for (let n = 1; n < 1000; n++) {
try {
await fs.access(path.join(root, "public", "uploads", rel));
rel = `${dir}/${stem}-${n}${ext}`;
} catch {
break;
}
}
await fs.writeFile(path.join(root, "public", "uploads", rel), buf);
const dims = parseImageSize(new Uint8Array(buf.slice(0, Math.min(buf.length, 1024 * 1024))));
const [row] = await sql`
insert into media (path, url, mime_type, width, height, size)
values (${rel}, ${"/uploads/" + rel}, ${String(a.mime || "application/octet-stream")},
${dims?.[0] ?? null}, ${dims?.[1] ?? null}, ${buf.length})
returning id`;
await audit("media.upload", "media", row.id, { path: rel, via: "mcp" });
return { ok: true, id: row.id, url: `/uploads/${rel}`, width: dims?.[0] ?? null, height: dims?.[1] ?? null };
}
case "list_products": {
const page = Math.max(1, Number(a.page) || 1);
const pageSize = Math.min(100, Number(a.pageSize) || 20);
const where = a.q ? sql` where (title ilike ${"%" + a.q + "%"} or sku ilike ${"%" + a.q + "%"} or slug ilike ${"%" + a.q + "%"})` : sql``;
const items = await sql`select id, sku, slug, title, price, regular_price, stock, stock_status, status, featured, updated_at from products${where} order by updated_at desc limit ${pageSize} offset ${(page - 1) * pageSize}`;
const [{ total }] = await sql`select count(*) as total from products${where}`;
return { items, total: Number(total), page, pageSize };
}
case "list_orders": {
const page = Math.max(1, Number(a.page) || 1);
const pageSize = Math.min(100, Number(a.pageSize) || 20);
const where = a.status ? sql` where status = ${a.status}` : sql``;
const items = await sql`select id, order_number, status, currency, total, payment_status, billing, created_at from orders${where} order by created_at desc limit ${pageSize} offset ${(page - 1) * pageSize}`;
const [{ total }] = await sql`select count(*) as total from orders${where}`;
return { items, total: Number(total), page, pageSize };
}
case "update_order_status": {
const id = Number(a.id);
const status = String(a.status);
if (!["pending", "on-hold", "processing", "completed", "cancelled", "refunded"].includes(status)) throw new Error("非法订单状态");
await sql`update orders set status = ${status}, updated_at = ${new Date()} where id = ${id}`;
await audit("order.status", "order", id, { status, via: "mcp" });
return { ok: true };
}
case "list_inquiries": {
const page = Math.max(1, Number(a.page) || 1);
const pageSize = Math.min(100, Number(a.pageSize) || 20);
const conds: SQL[] = [];
if (a.status) conds.push(sql`status = ${a.status}`);
if (a.q) conds.push(sql`(name ilike ${"%" + a.q + "%"} or email ilike ${"%" + a.q + "%"} or company ilike ${"%" + a.q + "%"})`);
const where = conds.length ? sql` where ${andJoin(conds)}` : sql``;
const items = await sql`select id, name, email, company, country, product_interest, status, created_at from inquiries${where} order by created_at desc limit ${pageSize} offset ${(page - 1) * pageSize}`;
const [{ total }] = await sql`select count(*) as total from inquiries${where}`;
return { items, total: Number(total), page, pageSize };
}
case "update_inquiry_status": {
const id = Number(a.id);
const status = String(a.status);
if (!["new", "read", "replied", "spam", "archived"].includes(status)) throw new Error("非法询盘状态");
await sql`update inquiries set status = ${status} where id = ${id}`;
await audit("inquiry.status", "inquiry", id, { status, via: "mcp" });
return { ok: true };
}
case "get_setting": {
const [row] = await sql`select key, value, updated_at from settings where key = ${String(a.key)}`;
return row ?? null;
}
case "set_setting": {
const key = String(a.key);
if (!/^[a-z0-9_-]{1,128}$/i.test(key)) throw new Error("非法设置键名");
await sql`insert into settings (key, value, updated_at) values (${key}, ${sql.json(a.value)}::jsonb, ${new Date()})
on conflict (key) do update set value = ${sql.json(a.value)}::jsonb, updated_at = ${new Date()}`;
await audit("settings.update", "settings", key, { via: "mcp" });
return { ok: true };
}
default:
throw new Error(`未知工具:${name}`);
}
}
/* ============================= MCP stdio 协议层 ============================= */
/** # 函数 写一帧 JSON-RPC 响应到 stdout */
function send(msg: Record<string, unknown>) {
process.stdout.write(JSON.stringify(msg) + "\n");
}
/** # 类型 已初始化标记(initialize 之前拒绝 tools/* 调用) **/
let initialized = false;
/** # 函数 逐行解析 stdin 请求并分发(initialize / tools/list / tools/call / ping) */
for await (const chunk of Bun.stdin.stream()) {
const text = new TextDecoder().decode(chunk);
for (const line of text.split("\n")) {
if (!line.trim()) continue;
let req: { id?: unknown; method?: string; params?: Record<string, unknown> };
try {
req = JSON.parse(line);
} catch {
continue;
}
const id = req.id;
try {
switch (req.method) {
case "initialize":
send({
jsonrpc: "2.0",
id,
result: {
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "dusuniot-cms", version: process.env.npm_package_version ?? "0.11.0" },
},
});
break;
case "notifications/initialized":
initialized = true;
break;
case "ping":
send({ jsonrpc: "2.0", id, result: {} });
break;
case "tools/list":
send({ jsonrpc: "2.0", id, result: { tools: TOOLS } });
break;
case "tools/call": {
if (!initialized) throw new Error("server not initialized");
const name = String((req.params as { name?: string }).name);
const args = ((req.params as { arguments?: Record<string, unknown> }).arguments ?? {}) as Record<string, unknown>;
try {
const result = await callTool(name, args);
send({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] } });
} catch (e) {
// 工具级错误按 isError 返回(协议要求不中断会话)
send({
jsonrpc: "2.0",
id,
result: { content: [{ type: "text", text: String(e instanceof Error ? e.message : e) }], isError: true },
});
}
break;
}
default:
if (id !== undefined) send({ jsonrpc: "2.0", id, error: { code: -32601, message: `method not found: ${req.method}` } });
}
} catch (e) {
if (id !== undefined) send({ jsonrpc: "2.0", id, error: { code: -32603, message: String(e instanceof Error ? e.message : e) } });
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dusuniot-rebuild",
"version": "0.10.37",
"version": "0.11.0",
"private": true,
"type": "module",
"scripts": {
+63
View File
@@ -22,11 +22,14 @@ import { Route as ShopRouteImport } from './routes/shop'
import { Route as SitemapDotxmlRouteImport } from './routes/sitemap[.]xml'
import { Route as SolutionRouteImport } from './routes/solution'
import { Route as AdminIndexRouteImport } from './routes/admin.index'
import { Route as AdminAuditRouteImport } from './routes/admin.audit'
import { Route as AdminFiltersRouteImport } from './routes/admin.filters'
import { Route as AdminInquiriesRouteImport } from './routes/admin.inquiries'
import { Route as AdminLanguagesRouteImport } from './routes/admin.languages'
import { Route as AdminMediaRouteImport } from './routes/admin.media'
import { Route as AdminMenusRouteImport } from './routes/admin.menus'
import { Route as AdminSeoRouteImport } from './routes/admin.seo'
import { Route as AdminSettingsRouteImport } from './routes/admin.settings'
import { Route as AdminUsersRouteImport } from './routes/admin.users'
import { Route as BlogIndexRouteImport } from './routes/blog.index'
import { Route as BlogSlugRouteImport } from './routes/blog.$slug'
@@ -109,6 +112,11 @@ const AdminIndexRoute = AdminIndexRouteImport.update({
path: '/',
getParentRoute: () => AdminRoute,
} as any)
const AdminAuditRoute = AdminAuditRouteImport.update({
id: '/audit',
path: '/audit',
getParentRoute: () => AdminRoute,
} as any)
const AdminFiltersRoute = AdminFiltersRouteImport.update({
id: '/filters',
path: '/filters',
@@ -129,11 +137,21 @@ const AdminMediaRoute = AdminMediaRouteImport.update({
path: '/media',
getParentRoute: () => AdminRoute,
} as any)
const AdminMenusRoute = AdminMenusRouteImport.update({
id: '/menus',
path: '/menus',
getParentRoute: () => AdminRoute,
} as any)
const AdminSeoRoute = AdminSeoRouteImport.update({
id: '/seo',
path: '/seo',
getParentRoute: () => AdminRoute,
} as any)
const AdminSettingsRoute = AdminSettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => AdminRoute,
} as any)
const AdminUsersRoute = AdminUsersRouteImport.update({
id: '/users',
path: '/users',
@@ -228,11 +246,14 @@ export interface FileRoutesByFullPath {
'/shop': typeof ShopRoute
'/sitemap.xml': typeof SitemapDotxmlRoute
'/solution': typeof SolutionRouteWithChildren
'/admin/audit': typeof AdminAuditRoute
'/admin/filters': typeof AdminFiltersRoute
'/admin/inquiries': typeof AdminInquiriesRoute
'/admin/languages': typeof AdminLanguagesRoute
'/admin/media': typeof AdminMediaRoute
'/admin/menus': typeof AdminMenusRoute
'/admin/seo': typeof AdminSeoRoute
'/admin/settings': typeof AdminSettingsRoute
'/admin/users': typeof AdminUsersRoute
'/blog/$slug': typeof BlogSlugRoute
'/collection/$slug': typeof CollectionSlugRoute
@@ -262,11 +283,14 @@ export interface FileRoutesByTo {
'/search': typeof SearchRoute
'/shop': typeof ShopRoute
'/sitemap.xml': typeof SitemapDotxmlRoute
'/admin/audit': typeof AdminAuditRoute
'/admin/filters': typeof AdminFiltersRoute
'/admin/inquiries': typeof AdminInquiriesRoute
'/admin/languages': typeof AdminLanguagesRoute
'/admin/media': typeof AdminMediaRoute
'/admin/menus': typeof AdminMenusRoute
'/admin/seo': typeof AdminSeoRoute
'/admin/settings': typeof AdminSettingsRoute
'/admin/users': typeof AdminUsersRoute
'/blog/$slug': typeof BlogSlugRoute
'/collection/$slug': typeof CollectionSlugRoute
@@ -299,11 +323,14 @@ export interface FileRoutesById {
'/shop': typeof ShopRoute
'/sitemap.xml': typeof SitemapDotxmlRoute
'/solution': typeof SolutionRouteWithChildren
'/admin/audit': typeof AdminAuditRoute
'/admin/filters': typeof AdminFiltersRoute
'/admin/inquiries': typeof AdminInquiriesRoute
'/admin/languages': typeof AdminLanguagesRoute
'/admin/media': typeof AdminMediaRoute
'/admin/menus': typeof AdminMenusRoute
'/admin/seo': typeof AdminSeoRoute
'/admin/settings': typeof AdminSettingsRoute
'/admin/users': typeof AdminUsersRoute
'/blog/$slug': typeof BlogSlugRoute
'/collection/$slug': typeof CollectionSlugRoute
@@ -337,11 +364,14 @@ export interface FileRouteTypes {
| '/shop'
| '/sitemap.xml'
| '/solution'
| '/admin/audit'
| '/admin/filters'
| '/admin/inquiries'
| '/admin/languages'
| '/admin/media'
| '/admin/menus'
| '/admin/seo'
| '/admin/settings'
| '/admin/users'
| '/blog/$slug'
| '/collection/$slug'
@@ -371,11 +401,14 @@ export interface FileRouteTypes {
| '/search'
| '/shop'
| '/sitemap.xml'
| '/admin/audit'
| '/admin/filters'
| '/admin/inquiries'
| '/admin/languages'
| '/admin/media'
| '/admin/menus'
| '/admin/seo'
| '/admin/settings'
| '/admin/users'
| '/blog/$slug'
| '/collection/$slug'
@@ -407,11 +440,14 @@ export interface FileRouteTypes {
| '/shop'
| '/sitemap.xml'
| '/solution'
| '/admin/audit'
| '/admin/filters'
| '/admin/inquiries'
| '/admin/languages'
| '/admin/media'
| '/admin/menus'
| '/admin/seo'
| '/admin/settings'
| '/admin/users'
| '/blog/$slug'
| '/collection/$slug'
@@ -545,6 +581,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AdminIndexRouteImport
parentRoute: typeof AdminRoute
}
'/admin/audit': {
id: '/admin/audit'
path: '/audit'
fullPath: '/admin/audit'
preLoaderRoute: typeof AdminAuditRouteImport
parentRoute: typeof AdminRoute
}
'/admin/filters': {
id: '/admin/filters'
path: '/filters'
@@ -573,6 +616,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AdminMediaRouteImport
parentRoute: typeof AdminRoute
}
'/admin/menus': {
id: '/admin/menus'
path: '/menus'
fullPath: '/admin/menus'
preLoaderRoute: typeof AdminMenusRouteImport
parentRoute: typeof AdminRoute
}
'/admin/seo': {
id: '/admin/seo'
path: '/seo'
@@ -580,6 +630,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AdminSeoRouteImport
parentRoute: typeof AdminRoute
}
'/admin/settings': {
id: '/admin/settings'
path: '/settings'
fullPath: '/admin/settings'
preLoaderRoute: typeof AdminSettingsRouteImport
parentRoute: typeof AdminRoute
}
'/admin/users': {
id: '/admin/users'
path: '/users'
@@ -696,11 +753,14 @@ declare module '@tanstack/react-router' {
}
interface AdminRouteChildren {
AdminAuditRoute: typeof AdminAuditRoute
AdminFiltersRoute: typeof AdminFiltersRoute
AdminInquiriesRoute: typeof AdminInquiriesRoute
AdminLanguagesRoute: typeof AdminLanguagesRoute
AdminMediaRoute: typeof AdminMediaRoute
AdminMenusRoute: typeof AdminMenusRoute
AdminSeoRoute: typeof AdminSeoRoute
AdminSettingsRoute: typeof AdminSettingsRoute
AdminUsersRoute: typeof AdminUsersRoute
AdminIndexRoute: typeof AdminIndexRoute
AdminContentsIdRoute: typeof AdminContentsIdRoute
@@ -712,11 +772,14 @@ interface AdminRouteChildren {
}
const AdminRouteChildren: AdminRouteChildren = {
AdminAuditRoute: AdminAuditRoute,
AdminFiltersRoute: AdminFiltersRoute,
AdminInquiriesRoute: AdminInquiriesRoute,
AdminLanguagesRoute: AdminLanguagesRoute,
AdminMediaRoute: AdminMediaRoute,
AdminMenusRoute: AdminMenusRoute,
AdminSeoRoute: AdminSeoRoute,
AdminSettingsRoute: AdminSettingsRoute,
AdminUsersRoute: AdminUsersRoute,
AdminIndexRoute: AdminIndexRoute,
AdminContentsIdRoute: AdminContentsIdRoute,
+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>
);
}
+412 -9
View File
@@ -6,6 +6,11 @@
import { db } from "~/db";
import {
contents,
contentRevisions,
contentTerms,
menus,
menuItems,
settings,
products,
productAttributes,
productAttributeValues,
@@ -25,6 +30,7 @@ import {
import { createServerFn } from "@tanstack/react-start";
import { and, asc, desc, eq, ilike, inArray, or, sql } from "drizzle-orm";
import type { PgTable } from "drizzle-orm/pg-core";
import { parseImageSize } from "./image-size";
import type { Json } from "~/components/admin/common";
/** # 类型 后台通用分页入参 **/
@@ -102,18 +108,39 @@ export const adminListContents = createServerFn({ method: "GET" })
return { items, total: Number(total), page, pageSize };
});
/** # 函数 Server Function:读取单条内容(编辑页用) */
/** # 函数 Server Function:读取单条内容(编辑页用)
* 返回值 内容正本 + 已选分类/标签 ID + 分类/标签选项(taxonomy=category/post_tag)
*/
export const adminGetContent = createServerFn({ method: "GET" })
.validator((d: { id: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const [item] = await db.select().from(contents).where(eq(contents.id, data.id));
if (!item) return null;
const selected = await db
.select({ categoryId: contentTerms.categoryId })
.from(contentTerms)
.where(eq(contentTerms.contentId, data.id));
const termOptions = await db
.select({ id: categories.id, name: categories.name, taxonomy: categories.taxonomy })
.from(categories)
.where(inArray(categories.taxonomy, ["category", "post_tag"]))
.orderBy(asc(categories.taxonomy), asc(categories.name));
// meta 为 jsonb(unknown),收窄为 Json 以满足 loader 可序列化校验
return item ? { ...item, meta: item.meta as Json } : null;
return {
...item,
meta: item.meta as Json,
selectedCategoryIds: selected.map((s) => s.categoryId),
termOptions,
};
});
/** # 函数 Server Function:保存内容(新建或更新,slug 为空时自动生成) */
/** # 函数 Server Function:保存内容(新建或更新,slug 为空时自动生成)
* 更新时先写修订快照(content_revisions,回滚依据);保存后按 categoryIds 重建
* content_terms 关联(分类 category + 标签 post_tag,对应 WP 分类目录/标签)。
* elementorJson 非空时解析合并进 meta.elementor(自定义单页面走前台 Elementor 渲染器)。
*/
export const adminSaveContent = createServerFn({ method: "POST" })
.validator(
(d: {
@@ -127,13 +154,32 @@ export const adminSaveContent = createServerFn({ method: "POST" })
template?: string;
seoTitle?: string | null;
seoDescription?: string | null;
/** # 成员变量 Elementor 文档 JSON 字符串(空串/非法 JSON 视为不修改;仅显式 null 清除) **/
elementorJson?: string | null;
/** # 成员变量 关联分类/标签 ID(WP 分类目录 + 标签) **/
categoryIds?: number[];
}) => d,
)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const slug = (data.slug || "").trim() || slugify(data.title);
const values = {
// Elementor JSON:仅编辑场景且显式传入时更新 meta(避免旧客户端丢数据)
let metaPatch: Record<string, unknown> | null = null;
if (data.id && data.elementorJson !== undefined) {
const [existing] = await db.select({ meta: contents.meta }).from(contents).where(eq(contents.id, data.id));
const base = (existing?.meta ?? {}) as Record<string, unknown>;
if (data.elementorJson === null) delete base.elementor;
else if (data.elementorJson.trim()) {
try {
base.elementor = JSON.parse(data.elementorJson);
} catch {
throw new Error("Elementor JSON 解析失败,请检查格式");
}
}
metaPatch = base;
}
const values: Record<string, unknown> = {
type: data.type,
slug,
title: data.title,
@@ -145,20 +191,35 @@ export const adminSaveContent = createServerFn({ method: "POST" })
seoDescription: data.seoDescription ?? null,
updatedAt: new Date(),
};
if (metaPatch) values.meta = metaPatch;
let id = data.id;
if (id) {
const [existing] = await db.select({ publishedAt: contents.publishedAt, status: contents.status }).from(contents).where(eq(contents.id, id));
const [existing] = await db.select({ publishedAt: contents.publishedAt, status: contents.status, title: contents.title, html: contents.html }).from(contents).where(eq(contents.id, id));
// 修订快照:更新前留档(WP 修订版对齐)
if (existing) {
await db.insert(contentRevisions).values({ contentId: id, title: existing.title, html: existing.html, authorId: admin.id });
}
if (existing && values.status === "published" && !existing.publishedAt) {
Object.assign(values, { publishedAt: new Date() });
values.publishedAt = new Date();
}
await db.update(contents).set(values).where(eq(contents.id, id));
} else {
const [row] = await db
.insert(contents)
.values({ ...values, publishedAt: values.status === "published" ? new Date() : null, authorId: admin.id })
.values({ ...(values as typeof contents.$inferInsert), publishedAt: values.status === "published" ? new Date() : null, authorId: admin.id })
.returning({ id: contents.id });
id = row!.id;
}
// 重建分类/标签关联(未传则不动,兼容旧调用方)
if (data.categoryIds) {
await db.delete(contentTerms).where(eq(contentTerms.contentId, id));
if (data.categoryIds.length) {
await db
.insert(contentTerms)
.values(data.categoryIds.map((categoryId) => ({ contentId: id!, categoryId })))
.onConflictDoNothing();
}
}
await writeAudit(admin.id, "content.save", "content", id!, { title: data.title, type: data.type });
return { ok: true as const, id };
});
@@ -657,13 +718,14 @@ export const adminListUsers = createServerFn({ method: "GET" })
/* ============================= Dashboard 统计 ============================= */
/** # 函数 Server Function:Dashboard 统计
* 返回值 各实体计数、待处理订单数、最近 7 天询盘趋势、最新 10 条询盘
* 返回值 各实体计数、待处理订单数、近 7 天询盘趋势、最新 10 条询盘、
* 近 30 天订单/询盘趋势(CRM 看板用)、新询盘待办数、状态分布、最近 10 笔订单
*/
export const adminStats = createServerFn({ method: "GET" }).handler(async () => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const countOf = async (table: PgTable) => Number(await db.$count(table));
const [contentsCount, productsCount, ordersCount, inquiriesCount, mediaCount, pendingOrders] = await Promise.all([
const [contentsCount, productsCount, ordersCount, inquiriesCount, mediaCount, pendingOrders, newInquiries] = await Promise.all([
countOf(contents),
countOf(products),
countOf(orders),
@@ -673,6 +735,7 @@ export const adminStats = createServerFn({ method: "GET" }).handler(async () =>
.select({ n: sql<number>`count(*)` })
.from(orders)
.where(or(eq(orders.status, "pending"), eq(orders.status, "on-hold"))),
db.select({ n: sql<number>`count(*)` }).from(inquiries).where(eq(inquiries.status, "new")),
]);
// 最近 7 天询盘趋势(按天分组,补齐空白天)
const trendRows = await db
@@ -704,6 +767,56 @@ export const adminStats = createServerFn({ method: "GET" }).handler(async () =>
.from(inquiries)
.orderBy(desc(inquiries.createdAt))
.limit(10);
// 近 30 天订单/询盘双趋势(CRM 看板)
const ordersTrendRows = await db
.select({
day: sql<string>`to_char(date_trunc('day', ${orders.createdAt}), 'YYYY-MM-DD')`,
n: sql<number>`count(*)`,
revenue: sql<string>`coalesce(sum(${orders.total}), 0)`,
})
.from(orders)
.where(sql`${orders.createdAt} > now() - interval '30 days'`)
.groupBy(sql`1`);
const inquiriesTrendRows = await db
.select({
day: sql<string>`to_char(date_trunc('day', ${inquiries.createdAt}), 'YYYY-MM-DD')`,
n: sql<number>`count(*)`,
})
.from(inquiries)
.where(sql`${inquiries.createdAt} > now() - interval '30 days'`)
.groupBy(sql`1`);
const ordersTrendMap = new Map(ordersTrendRows.map((r) => [r.day, { count: Number(r.n), revenue: Number(r.revenue) }]));
const inquiriesTrendMap = new Map(inquiriesTrendRows.map((r) => [r.day, Number(r.n)]));
const trend30: { day: string; orders: number; revenue: number; inquiries: number }[] = [];
for (let i = 29; i >= 0; i--) {
const key = new Date(Date.now() - i * 86400_000).toISOString().slice(0, 10);
const o = ordersTrendMap.get(key);
trend30.push({ day: key, orders: o?.count ?? 0, revenue: o?.revenue ?? 0, inquiries: inquiriesTrendMap.get(key) ?? 0 });
}
// 询盘状态分布 + 最近订单(CRM 待办/跟进视图)
const inquiryStatusRows = await db
.select({ status: inquiries.status, n: sql<number>`count(*)` })
.from(inquiries)
.groupBy(inquiries.status);
const recentOrders = await db
.select({
id: orders.id,
orderNumber: orders.orderNumber,
status: orders.status,
total: orders.total,
currency: orders.currency,
billing: orders.billing,
createdAt: orders.createdAt,
})
.from(orders)
.orderBy(desc(orders.createdAt))
.limit(10);
// 营收合计(全部订单,按 currency 分组)
const revenueRows = await db
.select({ currency: orders.currency, total: sql<string>`coalesce(sum(${orders.total}), 0)` })
.from(orders)
.where(or(eq(orders.status, "completed"), eq(orders.status, "processing")))
.groupBy(orders.currency);
return {
counts: {
contents: contentsCount,
@@ -712,8 +825,298 @@ export const adminStats = createServerFn({ method: "GET" }).handler(async () =>
inquiries: inquiriesCount,
media: mediaCount,
pendingOrders: Number(pendingOrders[0]?.n ?? 0),
newInquiries: Number(newInquiries[0]?.n ?? 0),
},
trend,
trend30,
inquiryStatus: inquiryStatusRows.map((r) => ({ status: r.status, count: Number(r.n) })),
revenue: revenueRows.map((r) => ({ currency: r.currency, total: Number(r.total) })),
recentInquiries,
recentOrders: recentOrders.map((o) => ({ ...o, customerName: billingName(o.billing), billing: o.billing as Json })),
};
});
/* ============================= 内容修订 Revisions ============================= */
/** # 函数 Server Function:内容修订列表(近 50 条,编辑页「历史版本」弹窗用) */
export const adminListRevisions = createServerFn({ method: "GET" })
.validator((d: { contentId: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
return db
.select({
id: contentRevisions.id,
title: contentRevisions.title,
authorId: contentRevisions.authorId,
createdAt: contentRevisions.createdAt,
})
.from(contentRevisions)
.where(eq(contentRevisions.contentId, data.contentId))
.orderBy(desc(contentRevisions.id))
.limit(50);
});
/** # 函数 Server Function:读取修订正文快照 */
export const adminGetRevision = createServerFn({ method: "GET" })
.validator((d: { id: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const [row] = await db.select().from(contentRevisions).where(eq(contentRevisions.id, data.id));
return row ?? null;
});
/** # 函数 Server Function:恢复修订(把快照写回内容正本,当前版本先再留档) */
export const adminRestoreRevision = createServerFn({ method: "POST" })
.validator((d: { id: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const [rev] = await db.select().from(contentRevisions).where(eq(contentRevisions.id, data.id));
if (!rev) throw new Error("修订不存在");
const [current] = await db.select({ title: contents.title, html: contents.html }).from(contents).where(eq(contents.id, rev.contentId));
if (current) {
await db.insert(contentRevisions).values({ contentId: rev.contentId, title: current.title, html: current.html, authorId: admin.id });
}
await db.update(contents).set({ title: rev.title, html: rev.html, updatedAt: new Date() }).where(eq(contents.id, rev.contentId));
await writeAudit(admin.id, "content.revision.restore", "content", rev.contentId, { revisionId: rev.id });
return { ok: true as const, contentId: rev.contentId };
});
/* ============================= 媒体上传 Media Upload ============================= */
/** # 函数 内部工具:上传文件名清洗(保留中英文数字点横线,防路径穿越) */
function sanitizeFilename(name: string): string {
const base = name.replace(/[\\/]+/g, "-").replace(/[^a-zA-Z0-9._\-\u4e00-\u9fa5]+/g, "-").replace(/^-+|-+$/g, "");
return base.slice(0, 160) || `file-${Date.now()}`;
}
/** # 函数 Server Function:媒体上传
* 入参 base64(JSON RPC 不走 multipart,避免 FormData 序列化分歧)。
* 写入 public/uploads/YYYY/MM/,解析图片宽高入库,重名自动追加序号。
*/
export const adminUploadMedia = createServerFn({ method: "POST" })
.validator((d: { filename: string; mime: string; dataBase64: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const buf = Buffer.from(data.dataBase64, "base64");
if (!buf.length) throw new Error("空文件");
if (buf.length > 20 * 1024 * 1024) throw new Error("文件超过 20MB 上限");
// 目标路径:uploads/YYYY/MM/文件名,重名自动 -N
const now = new Date();
const dir = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, "0")}`;
const dot = data.filename.lastIndexOf(".");
const stem = sanitizeFilename(dot === -1 ? data.filename : data.filename.slice(0, dot));
const ext = dot === -1 ? "" : data.filename.slice(dot).toLowerCase();
const fs = await import("node:fs/promises");
const path = await import("node:path");
const absDir = path.join(process.cwd(), "public", "uploads", dir);
await fs.mkdir(absDir, { recursive: true });
let rel = `${dir}/${stem}${ext}`;
for (let n = 1; n < 1000; n++) {
try {
await fs.access(path.join(process.cwd(), "public", "uploads", rel));
rel = `${dir}/${stem}-${n}${ext}`;
} catch {
break;
}
}
await fs.writeFile(path.join(process.cwd(), "public", "uploads", rel), buf);
// 图片宽高解析(PNG/JPEG/GIF/WebP),失败不阻塞上传
const dims = parseImageSize(new Uint8Array(buf.slice(0, Math.min(buf.length, 1024 * 1024))));
const [row] = await db
.insert(media)
.values({
path: rel,
url: `/uploads/${rel}`,
mimeType: data.mime || "application/octet-stream",
width: dims?.[0] ?? null,
height: dims?.[1] ?? null,
size: buf.length,
})
.returning({ id: media.id });
await writeAudit(admin.id, "media.upload", "media", row!.id, { path: rel, size: buf.length });
return { ok: true as const, id: row!.id, url: `/uploads/${rel}`, width: dims?.[0] ?? null, height: dims?.[1] ?? null };
});
/* ============================= 菜单管理 Menus ============================= */
/** # 函数 Server Function:菜单列表(菜单定义 + 各菜单项平铺,前端组树) */
export const adminListMenus = createServerFn({ method: "GET" }).handler(async () => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const items = await db.select().from(menus).orderBy(asc(menus.id));
const allItems = await db.select().from(menuItems).orderBy(asc(menuItems.menuId), asc(menuItems.sort), asc(menuItems.id));
return { menus: items, items: allItems };
});
/** # 函数 Server Function:保存菜单项(新建或更新) */
export const adminSaveMenuItem = createServerFn({ method: "POST" })
.validator(
(d: { id?: number; menuId: number; parentId?: number | null; title: string; url: string; target?: string; sort?: number }) => d,
)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const values = {
menuId: data.menuId,
parentId: data.parentId ?? null,
title: data.title,
url: data.url,
target: data.target === "_blank" ? "_blank" : "_self",
sort: data.sort ?? 0,
};
let id = data.id;
if (id) {
await db.update(menuItems).set(values).where(eq(menuItems.id, id));
} else {
const [row] = await db.insert(menuItems).values(values).returning({ id: menuItems.id });
id = row!.id;
}
await writeAudit(admin.id, "menu.item.save", "menu_item", id!, { title: data.title, menuId: data.menuId });
return { ok: true as const, id };
});
/** # 函数 Server Function:删除菜单项(子项提升为顶层,避免整树丢失) */
export const adminDeleteMenuItem = createServerFn({ method: "POST" })
.validator((d: { id: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const [item] = await db.select().from(menuItems).where(eq(menuItems.id, data.id));
if (!item) throw new Error("菜单项不存在");
await db.update(menuItems).set({ parentId: item.parentId }).where(eq(menuItems.parentId, data.id));
await db.delete(menuItems).where(eq(menuItems.id, data.id));
await writeAudit(admin.id, "menu.item.delete", "menu_item", data.id, { title: item.title });
return { ok: true as const };
});
/* ============================= 用户管理 Users Admin ============================= */
/** # 函数 Server Function:新建后台用户(bcrypt;admin 角色仅 admin 可建) */
export const adminCreateUser = createServerFn({ method: "POST" })
.validator((d: { email: string; username?: string; displayName?: string; password: string; role: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
if (data.role === "admin" && admin.role !== "admin") throw new Error("仅管理员可创建管理员账号");
if (!["admin", "editor", "manager", "customer"].includes(data.role)) throw new Error("非法角色");
const email = data.email.toLowerCase().trim();
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) throw new Error("邮箱格式不正确");
if (data.password.length < 8) throw new Error("密码至少 8 位");
const [existing] = await db.select({ id: users.id }).from(users).where(eq(users.email, email));
if (existing) throw new Error("该邮箱已存在");
const [row] = await db
.insert(users)
.values({
email,
username: data.username?.trim() || email.split("@")[0]!,
displayName: data.displayName?.trim() || email.split("@")[0]!,
passwordHash: Bun.password.hashSync(data.password, { algorithm: "bcrypt", cost: 10 }),
role: data.role,
})
.returning({ id: users.id });
await writeAudit(admin.id, "user.create", "user", row!.id, { email, role: data.role });
return { ok: true as const, id: row!.id };
});
/** # 函数 Server Function:修改用户角色(最后一個 admin 不可降级,防锁死) */
export const adminUpdateUserRole = createServerFn({ method: "POST" })
.validator((d: { id: number; role: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
if (!["admin", "editor", "manager", "customer"].includes(data.role)) throw new Error("非法角色");
if (data.role === "admin" && admin.role !== "admin") throw new Error("仅管理员可授予管理员角色");
const [target] = await db.select({ role: users.role }).from(users).where(eq(users.id, data.id));
if (!target) throw new Error("用户不存在");
if (target.role === "admin" && data.role !== "admin") {
const [{ n }] = await db.select({ n: sql<number>`count(*)` }).from(users).where(eq(users.role, "admin"));
if (Number(n) <= 1) throw new Error("至少保留一个管理员账号");
}
await db.update(users).set({ role: data.role }).where(eq(users.id, data.id));
await writeAudit(admin.id, "user.role", "user", data.id, { role: data.role });
return { ok: true as const };
});
/** # 函数 Server Function:重置用户密码(admin 专用) */
export const adminResetUserPassword = createServerFn({ method: "POST" })
.validator((d: { id: number; password: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
if (admin.role !== "admin") throw new Error("仅管理员可重置密码");
if (data.password.length < 8) throw new Error("密码至少 8 位");
await db
.update(users)
.set({ passwordHash: Bun.password.hashSync(data.password, { algorithm: "bcrypt", cost: 10 }) })
.where(eq(users.id, data.id));
await writeAudit(admin.id, "user.password.reset", "user", data.id);
return { ok: true as const };
});
/* ============================= 审计日志 Audit Logs ============================= */
/** # 函数 Server Function:审计日志列表(分页 + 操作人/动作/关键字过滤) */
export const adminListAuditLogs = createServerFn({ method: "GET" })
.validator((d: PageInput & { action?: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const { page, pageSize, offset } = parsePage(data);
const conds = [];
if (data.action) conds.push(ilike(auditLogs.action, `%${data.action}%`));
const where = conds.length ? and(...conds) : undefined;
const items = await db
.select({
id: auditLogs.id,
actorId: auditLogs.actorId,
actorEmail: users.email,
action: auditLogs.action,
entity: auditLogs.entity,
entityId: auditLogs.entityId,
meta: auditLogs.meta,
createdAt: auditLogs.createdAt,
})
.from(auditLogs)
.leftJoin(users, eq(users.id, auditLogs.actorId))
.where(where)
.orderBy(desc(auditLogs.id))
.limit(pageSize)
.offset(offset);
const [{ total }] = await db.select({ total: sql<number>`count(*)` }).from(auditLogs).where(where);
return {
items: items.map((r) => ({ ...r, meta: r.meta as Json })),
total: Number(total),
page,
pageSize,
};
});
/* ============================= 系统设置 Settings ============================= */
/** # 函数 Server Function:系统设置列表(settings 全表 kv) */
export const adminListSettings = createServerFn({ method: "GET" }).handler(async () => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const rows = await db.select().from(settings).orderBy(asc(settings.key));
return rows.map((r) => ({ key: r.key, value: r.value as Json, updatedAt: r.updatedAt }));
});
/** # 函数 Server Function:保存系统设置(整键覆写,值须为 JSON 对象) */
export const adminUpdateSetting = createServerFn({ method: "POST" })
.validator((d: { key: string; value: Json }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
if (admin.role !== "admin") throw new Error("仅管理员可修改系统设置");
if (!/^[a-z0-9_-]{1,128}$/i.test(data.key)) throw new Error("非法设置键名");
await db
.insert(settings)
.values({ key: data.key, value: data.value as Record<string, unknown>, updatedAt: new Date() })
.onConflictDoUpdate({ target: settings.key, set: { value: data.value as Record<string, unknown>, updatedAt: new Date() } });
await writeAudit(admin.id, "settings.update", "settings", data.key);
return { ok: true as const };
});
+50
View File
@@ -0,0 +1,50 @@
/**
* # 模块 图片文件头尺寸解析
* 从图片二进制头部解析宽高(PNG/JPEG/GIF/WebP 四格式),
* 供媒体上传入库与 scripts/backfill-media-dimensions.ts 回填共用。
*/
/** # 函数 解析图片文件头取宽高
* 参数 buf:文件头部字节(≥ 64 字节足够,建议 64KB 以覆盖 JPEG 前置 EXIF 段)
* 返回值 [宽, 高];无法解析(含 SVG 等文本格式)返回 null
*/
export function parseImageSize(buf: Uint8Array): [number, number] | null {
const u8 = buf, dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
// PNG: 8 字节签名 + IHDR(宽高在 16/20 偏移)
if (u8[0] === 0x89 && u8[1] === 0x50 && u8[2] === 0x4e && u8[3] === 0x47)
return [dv.getUint32(16), dv.getUint32(20)];
// GIF: "GIF8" 宽高在 6/8(小端)
if (u8[0] === 0x47 && u8[1] === 0x49 && u8[2] === 0x46)
return [dv.getUint16(6, true), dv.getUint16(8, true)];
// WebP: "RIFF....WEBP",VP8/VP8L/VP8X 三子格式
if (u8[0] === 0x52 && u8[1] === 0x49 && u8[2] === 0x46 && u8[3] === 0x46 && u8[8] === 0x57 && u8[9] === 0x45) {
const fourcc = String.fromCharCode(u8[12], u8[13], u8[14], u8[15]);
if (fourcc === "VP8 ") return [dv.getUint16(26, true) & 0x3fff, dv.getUint16(28, true) & 0x3fff];
if (fourcc === "VP8L") {
const b = dv.getUint32(21, true);
return [(b & 0x3fff) + 1, ((b >> 14) & 0x3fff) + 1];
}
if (fourcc === "VP8X") {
// canvas 宽/高各 3 字节小端(24-26 宽、27-29 高),getUint32 会读到相邻字段越界
const w = (u8[24] | (u8[25] << 8) | (u8[26] << 16)) + 1;
const h = (u8[27] | (u8[28] << 8) | (u8[29] << 16)) + 1;
return [w, h];
}
return null;
}
// JPEG: 扫 SOFn 段(FFC0-FFCF 除 FFC4/FFC8/FFCC 与 RST/DHP 等非帧段)
if (u8[0] === 0xff && u8[1] === 0xd8) {
let i = 2;
while (i + 9 < u8.length) {
if (u8[i] !== 0xff) { i++; continue; }
const marker = u8[i + 1];
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { i += 2; continue; }
const len = dv.getUint16(i + 2);
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc)
return [dv.getUint16(i + 7), dv.getUint16(i + 9)];
i += 2 + len;
}
return null;
}
return null;
}