v0.11.0 后台 CRM 化:媒体上传/单页面 Elementor/修订/分类标签/菜单/用户/审计/设置/Dashboard v2 + dusuniot-cms MCP 服务器
This commit is contained in:
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dusuniot-rebuild",
|
"name": "dusuniot-rebuild",
|
||||||
"version": "0.10.37",
|
"version": "0.11.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -22,11 +22,14 @@ import { Route as ShopRouteImport } from './routes/shop'
|
|||||||
import { Route as SitemapDotxmlRouteImport } from './routes/sitemap[.]xml'
|
import { Route as SitemapDotxmlRouteImport } from './routes/sitemap[.]xml'
|
||||||
import { Route as SolutionRouteImport } from './routes/solution'
|
import { Route as SolutionRouteImport } from './routes/solution'
|
||||||
import { Route as AdminIndexRouteImport } from './routes/admin.index'
|
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 AdminFiltersRouteImport } from './routes/admin.filters'
|
||||||
import { Route as AdminInquiriesRouteImport } from './routes/admin.inquiries'
|
import { Route as AdminInquiriesRouteImport } from './routes/admin.inquiries'
|
||||||
import { Route as AdminLanguagesRouteImport } from './routes/admin.languages'
|
import { Route as AdminLanguagesRouteImport } from './routes/admin.languages'
|
||||||
import { Route as AdminMediaRouteImport } from './routes/admin.media'
|
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 AdminSeoRouteImport } from './routes/admin.seo'
|
||||||
|
import { Route as AdminSettingsRouteImport } from './routes/admin.settings'
|
||||||
import { Route as AdminUsersRouteImport } from './routes/admin.users'
|
import { Route as AdminUsersRouteImport } from './routes/admin.users'
|
||||||
import { Route as BlogIndexRouteImport } from './routes/blog.index'
|
import { Route as BlogIndexRouteImport } from './routes/blog.index'
|
||||||
import { Route as BlogSlugRouteImport } from './routes/blog.$slug'
|
import { Route as BlogSlugRouteImport } from './routes/blog.$slug'
|
||||||
@@ -109,6 +112,11 @@ const AdminIndexRoute = AdminIndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => AdminRoute,
|
getParentRoute: () => AdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AdminAuditRoute = AdminAuditRouteImport.update({
|
||||||
|
id: '/audit',
|
||||||
|
path: '/audit',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
const AdminFiltersRoute = AdminFiltersRouteImport.update({
|
const AdminFiltersRoute = AdminFiltersRouteImport.update({
|
||||||
id: '/filters',
|
id: '/filters',
|
||||||
path: '/filters',
|
path: '/filters',
|
||||||
@@ -129,11 +137,21 @@ const AdminMediaRoute = AdminMediaRouteImport.update({
|
|||||||
path: '/media',
|
path: '/media',
|
||||||
getParentRoute: () => AdminRoute,
|
getParentRoute: () => AdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AdminMenusRoute = AdminMenusRouteImport.update({
|
||||||
|
id: '/menus',
|
||||||
|
path: '/menus',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
const AdminSeoRoute = AdminSeoRouteImport.update({
|
const AdminSeoRoute = AdminSeoRouteImport.update({
|
||||||
id: '/seo',
|
id: '/seo',
|
||||||
path: '/seo',
|
path: '/seo',
|
||||||
getParentRoute: () => AdminRoute,
|
getParentRoute: () => AdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AdminSettingsRoute = AdminSettingsRouteImport.update({
|
||||||
|
id: '/settings',
|
||||||
|
path: '/settings',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
const AdminUsersRoute = AdminUsersRouteImport.update({
|
const AdminUsersRoute = AdminUsersRouteImport.update({
|
||||||
id: '/users',
|
id: '/users',
|
||||||
path: '/users',
|
path: '/users',
|
||||||
@@ -228,11 +246,14 @@ export interface FileRoutesByFullPath {
|
|||||||
'/shop': typeof ShopRoute
|
'/shop': typeof ShopRoute
|
||||||
'/sitemap.xml': typeof SitemapDotxmlRoute
|
'/sitemap.xml': typeof SitemapDotxmlRoute
|
||||||
'/solution': typeof SolutionRouteWithChildren
|
'/solution': typeof SolutionRouteWithChildren
|
||||||
|
'/admin/audit': typeof AdminAuditRoute
|
||||||
'/admin/filters': typeof AdminFiltersRoute
|
'/admin/filters': typeof AdminFiltersRoute
|
||||||
'/admin/inquiries': typeof AdminInquiriesRoute
|
'/admin/inquiries': typeof AdminInquiriesRoute
|
||||||
'/admin/languages': typeof AdminLanguagesRoute
|
'/admin/languages': typeof AdminLanguagesRoute
|
||||||
'/admin/media': typeof AdminMediaRoute
|
'/admin/media': typeof AdminMediaRoute
|
||||||
|
'/admin/menus': typeof AdminMenusRoute
|
||||||
'/admin/seo': typeof AdminSeoRoute
|
'/admin/seo': typeof AdminSeoRoute
|
||||||
|
'/admin/settings': typeof AdminSettingsRoute
|
||||||
'/admin/users': typeof AdminUsersRoute
|
'/admin/users': typeof AdminUsersRoute
|
||||||
'/blog/$slug': typeof BlogSlugRoute
|
'/blog/$slug': typeof BlogSlugRoute
|
||||||
'/collection/$slug': typeof CollectionSlugRoute
|
'/collection/$slug': typeof CollectionSlugRoute
|
||||||
@@ -262,11 +283,14 @@ export interface FileRoutesByTo {
|
|||||||
'/search': typeof SearchRoute
|
'/search': typeof SearchRoute
|
||||||
'/shop': typeof ShopRoute
|
'/shop': typeof ShopRoute
|
||||||
'/sitemap.xml': typeof SitemapDotxmlRoute
|
'/sitemap.xml': typeof SitemapDotxmlRoute
|
||||||
|
'/admin/audit': typeof AdminAuditRoute
|
||||||
'/admin/filters': typeof AdminFiltersRoute
|
'/admin/filters': typeof AdminFiltersRoute
|
||||||
'/admin/inquiries': typeof AdminInquiriesRoute
|
'/admin/inquiries': typeof AdminInquiriesRoute
|
||||||
'/admin/languages': typeof AdminLanguagesRoute
|
'/admin/languages': typeof AdminLanguagesRoute
|
||||||
'/admin/media': typeof AdminMediaRoute
|
'/admin/media': typeof AdminMediaRoute
|
||||||
|
'/admin/menus': typeof AdminMenusRoute
|
||||||
'/admin/seo': typeof AdminSeoRoute
|
'/admin/seo': typeof AdminSeoRoute
|
||||||
|
'/admin/settings': typeof AdminSettingsRoute
|
||||||
'/admin/users': typeof AdminUsersRoute
|
'/admin/users': typeof AdminUsersRoute
|
||||||
'/blog/$slug': typeof BlogSlugRoute
|
'/blog/$slug': typeof BlogSlugRoute
|
||||||
'/collection/$slug': typeof CollectionSlugRoute
|
'/collection/$slug': typeof CollectionSlugRoute
|
||||||
@@ -299,11 +323,14 @@ export interface FileRoutesById {
|
|||||||
'/shop': typeof ShopRoute
|
'/shop': typeof ShopRoute
|
||||||
'/sitemap.xml': typeof SitemapDotxmlRoute
|
'/sitemap.xml': typeof SitemapDotxmlRoute
|
||||||
'/solution': typeof SolutionRouteWithChildren
|
'/solution': typeof SolutionRouteWithChildren
|
||||||
|
'/admin/audit': typeof AdminAuditRoute
|
||||||
'/admin/filters': typeof AdminFiltersRoute
|
'/admin/filters': typeof AdminFiltersRoute
|
||||||
'/admin/inquiries': typeof AdminInquiriesRoute
|
'/admin/inquiries': typeof AdminInquiriesRoute
|
||||||
'/admin/languages': typeof AdminLanguagesRoute
|
'/admin/languages': typeof AdminLanguagesRoute
|
||||||
'/admin/media': typeof AdminMediaRoute
|
'/admin/media': typeof AdminMediaRoute
|
||||||
|
'/admin/menus': typeof AdminMenusRoute
|
||||||
'/admin/seo': typeof AdminSeoRoute
|
'/admin/seo': typeof AdminSeoRoute
|
||||||
|
'/admin/settings': typeof AdminSettingsRoute
|
||||||
'/admin/users': typeof AdminUsersRoute
|
'/admin/users': typeof AdminUsersRoute
|
||||||
'/blog/$slug': typeof BlogSlugRoute
|
'/blog/$slug': typeof BlogSlugRoute
|
||||||
'/collection/$slug': typeof CollectionSlugRoute
|
'/collection/$slug': typeof CollectionSlugRoute
|
||||||
@@ -337,11 +364,14 @@ export interface FileRouteTypes {
|
|||||||
| '/shop'
|
| '/shop'
|
||||||
| '/sitemap.xml'
|
| '/sitemap.xml'
|
||||||
| '/solution'
|
| '/solution'
|
||||||
|
| '/admin/audit'
|
||||||
| '/admin/filters'
|
| '/admin/filters'
|
||||||
| '/admin/inquiries'
|
| '/admin/inquiries'
|
||||||
| '/admin/languages'
|
| '/admin/languages'
|
||||||
| '/admin/media'
|
| '/admin/media'
|
||||||
|
| '/admin/menus'
|
||||||
| '/admin/seo'
|
| '/admin/seo'
|
||||||
|
| '/admin/settings'
|
||||||
| '/admin/users'
|
| '/admin/users'
|
||||||
| '/blog/$slug'
|
| '/blog/$slug'
|
||||||
| '/collection/$slug'
|
| '/collection/$slug'
|
||||||
@@ -371,11 +401,14 @@ export interface FileRouteTypes {
|
|||||||
| '/search'
|
| '/search'
|
||||||
| '/shop'
|
| '/shop'
|
||||||
| '/sitemap.xml'
|
| '/sitemap.xml'
|
||||||
|
| '/admin/audit'
|
||||||
| '/admin/filters'
|
| '/admin/filters'
|
||||||
| '/admin/inquiries'
|
| '/admin/inquiries'
|
||||||
| '/admin/languages'
|
| '/admin/languages'
|
||||||
| '/admin/media'
|
| '/admin/media'
|
||||||
|
| '/admin/menus'
|
||||||
| '/admin/seo'
|
| '/admin/seo'
|
||||||
|
| '/admin/settings'
|
||||||
| '/admin/users'
|
| '/admin/users'
|
||||||
| '/blog/$slug'
|
| '/blog/$slug'
|
||||||
| '/collection/$slug'
|
| '/collection/$slug'
|
||||||
@@ -407,11 +440,14 @@ export interface FileRouteTypes {
|
|||||||
| '/shop'
|
| '/shop'
|
||||||
| '/sitemap.xml'
|
| '/sitemap.xml'
|
||||||
| '/solution'
|
| '/solution'
|
||||||
|
| '/admin/audit'
|
||||||
| '/admin/filters'
|
| '/admin/filters'
|
||||||
| '/admin/inquiries'
|
| '/admin/inquiries'
|
||||||
| '/admin/languages'
|
| '/admin/languages'
|
||||||
| '/admin/media'
|
| '/admin/media'
|
||||||
|
| '/admin/menus'
|
||||||
| '/admin/seo'
|
| '/admin/seo'
|
||||||
|
| '/admin/settings'
|
||||||
| '/admin/users'
|
| '/admin/users'
|
||||||
| '/blog/$slug'
|
| '/blog/$slug'
|
||||||
| '/collection/$slug'
|
| '/collection/$slug'
|
||||||
@@ -545,6 +581,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AdminIndexRouteImport
|
preLoaderRoute: typeof AdminIndexRouteImport
|
||||||
parentRoute: typeof AdminRoute
|
parentRoute: typeof AdminRoute
|
||||||
}
|
}
|
||||||
|
'/admin/audit': {
|
||||||
|
id: '/admin/audit'
|
||||||
|
path: '/audit'
|
||||||
|
fullPath: '/admin/audit'
|
||||||
|
preLoaderRoute: typeof AdminAuditRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
'/admin/filters': {
|
'/admin/filters': {
|
||||||
id: '/admin/filters'
|
id: '/admin/filters'
|
||||||
path: '/filters'
|
path: '/filters'
|
||||||
@@ -573,6 +616,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AdminMediaRouteImport
|
preLoaderRoute: typeof AdminMediaRouteImport
|
||||||
parentRoute: typeof AdminRoute
|
parentRoute: typeof AdminRoute
|
||||||
}
|
}
|
||||||
|
'/admin/menus': {
|
||||||
|
id: '/admin/menus'
|
||||||
|
path: '/menus'
|
||||||
|
fullPath: '/admin/menus'
|
||||||
|
preLoaderRoute: typeof AdminMenusRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
'/admin/seo': {
|
'/admin/seo': {
|
||||||
id: '/admin/seo'
|
id: '/admin/seo'
|
||||||
path: '/seo'
|
path: '/seo'
|
||||||
@@ -580,6 +630,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AdminSeoRouteImport
|
preLoaderRoute: typeof AdminSeoRouteImport
|
||||||
parentRoute: typeof AdminRoute
|
parentRoute: typeof AdminRoute
|
||||||
}
|
}
|
||||||
|
'/admin/settings': {
|
||||||
|
id: '/admin/settings'
|
||||||
|
path: '/settings'
|
||||||
|
fullPath: '/admin/settings'
|
||||||
|
preLoaderRoute: typeof AdminSettingsRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
'/admin/users': {
|
'/admin/users': {
|
||||||
id: '/admin/users'
|
id: '/admin/users'
|
||||||
path: '/users'
|
path: '/users'
|
||||||
@@ -696,11 +753,14 @@ declare module '@tanstack/react-router' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AdminRouteChildren {
|
interface AdminRouteChildren {
|
||||||
|
AdminAuditRoute: typeof AdminAuditRoute
|
||||||
AdminFiltersRoute: typeof AdminFiltersRoute
|
AdminFiltersRoute: typeof AdminFiltersRoute
|
||||||
AdminInquiriesRoute: typeof AdminInquiriesRoute
|
AdminInquiriesRoute: typeof AdminInquiriesRoute
|
||||||
AdminLanguagesRoute: typeof AdminLanguagesRoute
|
AdminLanguagesRoute: typeof AdminLanguagesRoute
|
||||||
AdminMediaRoute: typeof AdminMediaRoute
|
AdminMediaRoute: typeof AdminMediaRoute
|
||||||
|
AdminMenusRoute: typeof AdminMenusRoute
|
||||||
AdminSeoRoute: typeof AdminSeoRoute
|
AdminSeoRoute: typeof AdminSeoRoute
|
||||||
|
AdminSettingsRoute: typeof AdminSettingsRoute
|
||||||
AdminUsersRoute: typeof AdminUsersRoute
|
AdminUsersRoute: typeof AdminUsersRoute
|
||||||
AdminIndexRoute: typeof AdminIndexRoute
|
AdminIndexRoute: typeof AdminIndexRoute
|
||||||
AdminContentsIdRoute: typeof AdminContentsIdRoute
|
AdminContentsIdRoute: typeof AdminContentsIdRoute
|
||||||
@@ -712,11 +772,14 @@ interface AdminRouteChildren {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AdminRouteChildren: AdminRouteChildren = {
|
const AdminRouteChildren: AdminRouteChildren = {
|
||||||
|
AdminAuditRoute: AdminAuditRoute,
|
||||||
AdminFiltersRoute: AdminFiltersRoute,
|
AdminFiltersRoute: AdminFiltersRoute,
|
||||||
AdminInquiriesRoute: AdminInquiriesRoute,
|
AdminInquiriesRoute: AdminInquiriesRoute,
|
||||||
AdminLanguagesRoute: AdminLanguagesRoute,
|
AdminLanguagesRoute: AdminLanguagesRoute,
|
||||||
AdminMediaRoute: AdminMediaRoute,
|
AdminMediaRoute: AdminMediaRoute,
|
||||||
|
AdminMenusRoute: AdminMenusRoute,
|
||||||
AdminSeoRoute: AdminSeoRoute,
|
AdminSeoRoute: AdminSeoRoute,
|
||||||
|
AdminSettingsRoute: AdminSettingsRoute,
|
||||||
AdminUsersRoute: AdminUsersRoute,
|
AdminUsersRoute: AdminUsersRoute,
|
||||||
AdminIndexRoute: AdminIndexRoute,
|
AdminIndexRoute: AdminIndexRoute,
|
||||||
AdminContentsIdRoute: AdminContentsIdRoute,
|
AdminContentsIdRoute: AdminContentsIdRoute,
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* # 路由 /admin/audit 审计日志
|
||||||
|
* 后台全部写操作留痕(audit_logs):操作人 / 动作 / 实体 / 时间。
|
||||||
|
* 支持按动作名过滤与分页;meta 为 JSON 摘要。
|
||||||
|
*/
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { Card, Input, Table, Typography } from "antd";
|
||||||
|
import { SearchOutlined } from "@ant-design/icons";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { adminListAuditLogs } from "~/server/admin";
|
||||||
|
import { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/admin/audit")({
|
||||||
|
validateSearch: (search: Record<string, unknown>): AdminSearch => {
|
||||||
|
const out: AdminSearch = {};
|
||||||
|
const page = Math.floor(Number(search.page)) || 0;
|
||||||
|
if (page > 1) out.page = page;
|
||||||
|
if (typeof search.q === "string" && search.q) out.q = search.q;
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
loaderDeps: ({ search }) => ({ search }),
|
||||||
|
loader: ({ deps }) => guardAdmin(() => adminListAuditLogs({ data: { page: deps.search.page, action: deps.search.q || undefined } })),
|
||||||
|
component: AuditPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 函数 审计日志页面组件 */
|
||||||
|
function AuditPage() {
|
||||||
|
const data = Route.useLoaderData();
|
||||||
|
const search = Route.useSearch();
|
||||||
|
const navigate = Route.useNavigate();
|
||||||
|
const [q, setQ] = useState(search.q ?? "");
|
||||||
|
|
||||||
|
if (!data) return <Forbidden />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="small">
|
||||||
|
<PageHeader
|
||||||
|
title="审计日志"
|
||||||
|
extra={
|
||||||
|
<Input
|
||||||
|
allowClear
|
||||||
|
placeholder="按动作过滤(如 content.save)"
|
||||||
|
prefix={<SearchOutlined aria-hidden />}
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
onPressEnter={() => void navigate({ search: (prev) => ({ ...prev, q, page: 1 }) })}
|
||||||
|
style={{ width: 280 }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
dataSource={data.items}
|
||||||
|
locale={{ emptyText: <AdminEmpty /> }}
|
||||||
|
pagination={tablePagination(data.total, data.page, data.pageSize, (p) =>
|
||||||
|
void navigate({ search: (prev) => ({ ...prev, page: p }) }),
|
||||||
|
)}
|
||||||
|
columns={[
|
||||||
|
{ title: "ID", dataIndex: "id", width: 70 },
|
||||||
|
{ title: "操作人", dataIndex: "actorEmail", width: 220, ellipsis: true, render: (v: string | null) => v ?? `#${""}` },
|
||||||
|
{ title: "动作", dataIndex: "action", width: 200, render: (v: string) => <code style={{ fontSize: 12 }}>{v}</code> },
|
||||||
|
{ title: "实体", dataIndex: "entity", width: 110 },
|
||||||
|
{ title: "实体 ID", dataIndex: "entityId", width: 90 },
|
||||||
|
{
|
||||||
|
title: "摘要",
|
||||||
|
dataIndex: "meta",
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v: unknown) => (
|
||||||
|
<Typography.Text code style={{ fontSize: 12 }}>
|
||||||
|
{Object.keys(v ?? {}).length ? JSON.stringify(v) : "-"}
|
||||||
|
</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: "时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* # 路由 /admin/contents/$id 内容编辑页
|
* # 路由 /admin/contents/$id 内容编辑页(自定义单页面/文章/落地页)
|
||||||
* id 为 new 时新建;否则加载正本(默认语言)编辑。HTML 使用 TextArea 明文编辑。
|
* id 为 new 时新建;否则加载正本编辑。支持:
|
||||||
|
* - 分类/标签绑定(content_terms,对应 WP 分类目录与标签)
|
||||||
|
* - Elementor JSON 编辑(meta.elementor,前台 catch-all 用 ElementorRenderer 渲染)
|
||||||
|
* - 修订历史弹窗(保存自动留档,可查看/恢复)
|
||||||
|
* - 已发布内容直达前台链接(预览)
|
||||||
*/
|
*/
|
||||||
import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router";
|
import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router";
|
||||||
import { App, Button, Card, Form, Input, Select, Space } from "antd";
|
import { App, Button, Card, Form, Input, Modal, Select, Space, Table, TreeSelect } from "antd";
|
||||||
import { ArrowLeftOutlined, SaveOutlined } from "@ant-design/icons";
|
import { ArrowLeft, ExternalLink, History, Save } from "lucide-react";
|
||||||
import { adminGetContent, adminSaveContent } from "~/server/admin";
|
import { useEffect, useState } from "react";
|
||||||
import { CONTENT_STATUS_OPTIONS, Forbidden, guardAdmin } from "~/components/admin/common";
|
import { adminGetContent, adminGetRevision, adminListRevisions, adminRestoreRevision, adminSaveContent } from "~/server/admin";
|
||||||
|
import { CONTENT_STATUS_OPTIONS, Forbidden, fmtDateTime, guardAdmin } from "~/components/admin/common";
|
||||||
|
|
||||||
/** # 类型 编辑表单数据形状(新建时给默认值) **/
|
/** # 类型 编辑表单数据形状(新建时给默认值) **/
|
||||||
type ContentForm = {
|
type ContentForm = {
|
||||||
@@ -20,15 +25,25 @@ type ContentForm = {
|
|||||||
template: string;
|
template: string;
|
||||||
seoTitle: string;
|
seoTitle: string;
|
||||||
seoDescription: 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")({
|
export const Route = createFileRoute("/admin/contents/$id")({
|
||||||
loader: ({ params }) =>
|
loader: ({ params }) =>
|
||||||
guardAdmin(async () => {
|
guardAdmin(async (): Promise<EditorData> => {
|
||||||
if (params.id === "new") {
|
if (params.id === "new") {
|
||||||
// 权限探测 + 返回新建默认值
|
// 权限探测 + 返回新建默认值
|
||||||
await adminGetContent({ data: { id: 0 } });
|
await adminGetContent({ data: { id: 0 } });
|
||||||
const fresh: ContentForm = {
|
return {
|
||||||
id: 0,
|
id: 0,
|
||||||
type: "page",
|
type: "page",
|
||||||
slug: "",
|
slug: "",
|
||||||
@@ -39,12 +54,15 @@ export const Route = createFileRoute("/admin/contents/$id")({
|
|||||||
template: "default",
|
template: "default",
|
||||||
seoTitle: "",
|
seoTitle: "",
|
||||||
seoDescription: "",
|
seoDescription: "",
|
||||||
|
elementorJson: "",
|
||||||
|
selectedCategoryIds: [],
|
||||||
|
termOptions: [],
|
||||||
};
|
};
|
||||||
return fresh;
|
|
||||||
}
|
}
|
||||||
const c = await adminGetContent({ data: { id: Number(params.id) } });
|
const c = await adminGetContent({ data: { id: Number(params.id) } });
|
||||||
if (!c) return null;
|
if (!c) return null;
|
||||||
const form: ContentForm = {
|
const meta = (c.meta ?? {}) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
id: c.id,
|
id: c.id,
|
||||||
type: c.type,
|
type: c.type,
|
||||||
slug: c.slug,
|
slug: c.slug,
|
||||||
@@ -55,8 +73,10 @@ export const Route = createFileRoute("/admin/contents/$id")({
|
|||||||
template: c.template,
|
template: c.template,
|
||||||
seoTitle: c.seoTitle ?? "",
|
seoTitle: c.seoTitle ?? "",
|
||||||
seoDescription: c.seoDescription ?? "",
|
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,
|
component: ContentEditPage,
|
||||||
});
|
});
|
||||||
@@ -66,8 +86,31 @@ function ContentEditPage() {
|
|||||||
const data = Route.useLoaderData();
|
const data = Route.useLoaderData();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { message } = App.useApp();
|
const { message, modal } = App.useApp();
|
||||||
const [form] = Form.useForm();
|
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 />;
|
if (!data) return <Forbidden />;
|
||||||
// 收窄后的非空引用(闭包内使用)
|
// 收窄后的非空引用(闭包内使用)
|
||||||
@@ -76,8 +119,14 @@ function ContentEditPage() {
|
|||||||
/** # 函数 保存内容:提交到服务端 → 提示 → 返回列表 */
|
/** # 函数 保存内容:提交到服务端 → 提示 → 返回列表 */
|
||||||
async function handleSave(values: Omit<ContentForm, "id">) {
|
async function handleSave(values: Omit<ContentForm, "id">) {
|
||||||
try {
|
try {
|
||||||
await adminSaveContent({ data: { ...values, id: content.id || undefined } });
|
await adminSaveContent({
|
||||||
message.success("保存成功");
|
data: {
|
||||||
|
...values,
|
||||||
|
id: content.id || undefined,
|
||||||
|
categoryIds: selectedTermIds,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
message.success("保存成功(已留修订快照)");
|
||||||
await router.invalidate();
|
await router.invalidate();
|
||||||
void navigate({ to: "/admin/contents/" });
|
void navigate({ to: "/admin/contents/" });
|
||||||
} catch (e) {
|
} 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 (
|
return (
|
||||||
<Card size="small">
|
<Card size="small">
|
||||||
<Space style={{ marginBottom: 16 }}>
|
<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>
|
</Button>
|
||||||
<h2 style={{ margin: 0, fontSize: 18 }}>{content.id ? `编辑内容 #${content.id}` : "新建内容"}</h2>
|
<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>
|
</Space>
|
||||||
<Form
|
<Form form={form} layout="vertical" style={{ maxWidth: 960 }} onFinish={(v) => void handleSave(v as Omit<ContentForm, "id">)}>
|
||||||
form={form}
|
|
||||||
layout="vertical"
|
|
||||||
initialValues={content}
|
|
||||||
style={{ maxWidth: 860 }}
|
|
||||||
onFinish={(v) => void handleSave(v)}
|
|
||||||
>
|
|
||||||
<Space size={16} style={{ display: "flex" }} align="start">
|
<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="内容标题" />
|
<Input placeholder="内容标题" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="slug" label="别名(留空自动生成)">
|
<Form.Item name="slug" label="别名(留空自动生成;即前台 URL /slug/)">
|
||||||
<Input placeholder="url-slug" style={{ width: 260 }} />
|
<Input placeholder="url-slug" style={{ width: 280 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Space>
|
</Space>
|
||||||
<Space size={16} style={{ display: "flex" }}>
|
<Space size={16} style={{ display: "flex" }}>
|
||||||
<Form.Item name="type" label="类型">
|
<Form.Item name="type" label="类型">
|
||||||
<Select
|
<Select
|
||||||
style={{ width: 140 }}
|
style={{ width: 150 }}
|
||||||
options={[
|
options={[
|
||||||
{ value: "page", label: "页面 page" },
|
{ value: "page", label: "页面 page" },
|
||||||
{ value: "post", label: "文章 post" },
|
{ value: "post", label: "文章 post" },
|
||||||
@@ -123,25 +229,84 @@ function ContentEditPage() {
|
|||||||
<Select style={{ width: 140 }} options={CONTENT_STATUS_OPTIONS} />
|
<Select style={{ width: 140 }} options={CONTENT_STATUS_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="template" label="模板">
|
<Form.Item name="template" label="模板">
|
||||||
<Input style={{ width: 200 }} placeholder="default / home / product …" />
|
<Input style={{ width: 200 }} placeholder="default / home / blank …" />
|
||||||
</Form.Item>
|
</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>
|
</Space>
|
||||||
<Form.Item name="excerpt" label="摘要">
|
<Form.Item name="excerpt" label="摘要">
|
||||||
<Input.TextArea rows={2} />
|
<Input.TextArea rows={2} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="html" label="正文 HTML">
|
<Form.Item
|
||||||
<Input.TextArea rows={18} style={{ fontFamily: "monospace", fontSize: 12 }} />
|
name="html"
|
||||||
|
label="正文 HTML(无 Elementor JSON 时前台按此渲染)"
|
||||||
|
tooltip="自定义单页面推荐:留空 HTML、填写 Elementor JSON,前台通栏渲染与原站一致"
|
||||||
|
>
|
||||||
|
<Input.TextArea rows={12} style={{ fontFamily: "monospace", fontSize: 12 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="seoTitle" label="SEO 标题">
|
<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 />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
<Form.Item name="seoDescription" label="SEO 描述">
|
<Form.Item name="seoDescription" label="SEO 描述">
|
||||||
<Input.TextArea rows={2} />
|
<Input.TextArea rows={2} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Button type="primary" htmlType="submit" icon={<SaveOutlined aria-hidden />}>
|
<Button type="primary" htmlType="submit" icon={<Save size={14} aria-hidden />}>
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</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>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+185
-45
@@ -1,12 +1,34 @@
|
|||||||
/**
|
/**
|
||||||
* # 路由 /admin/ 后台首页(Dashboard)
|
* # 路由 /admin/ 后台首页(现代化 CRM 看板)
|
||||||
* 统计卡片 + 最近 7 天询盘趋势(纯 div 柱图)+ 最新 10 条询盘。
|
* KPI 卡(含 30 天环比)→ 30 天订单/询盘双趋势图 → 待办与询盘漏斗 →
|
||||||
|
* 最近订单 / 最新询盘并排 + 快捷入口。
|
||||||
*/
|
*/
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
import { Card, Col, Row, Statistic, Table, Tag } from "antd";
|
import { Card, Col, Progress, Row, Space, Statistic, Table, Tag } from "antd";
|
||||||
import { FileText, Inbox, Package, ShoppingCart, TimerReset, Image as ImageIcon } from "lucide-react";
|
import {
|
||||||
|
ArrowDownRight,
|
||||||
|
ArrowUpRight,
|
||||||
|
ClipboardList,
|
||||||
|
DollarSign,
|
||||||
|
FileText,
|
||||||
|
Inbox,
|
||||||
|
MessageSquarePlus,
|
||||||
|
Package,
|
||||||
|
PackagePlus,
|
||||||
|
ShoppingCart,
|
||||||
|
TimerReset,
|
||||||
|
} from "lucide-react";
|
||||||
import { adminStats } from "~/server/admin";
|
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/")({
|
export const Route = createFileRoute("/admin/")({
|
||||||
loader: () => guardAdmin(() => adminStats()),
|
loader: () => guardAdmin(() => adminStats()),
|
||||||
@@ -14,53 +36,166 @@ export const Route = createFileRoute("/admin/")({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** # 函数 Dashboard 页面组件
|
/** # 函数 Dashboard 页面组件
|
||||||
* 渲染统计卡片、询盘趋势柱图与最新询盘表;未登录时展示占位。
|
* 数据全部来自 adminStats(30 天趋势、待办、状态分布、最近订单/询盘)。
|
||||||
*/
|
*/
|
||||||
function DashboardPage() {
|
function DashboardPage() {
|
||||||
const stats = Route.useLoaderData();
|
const stats = Route.useLoaderData();
|
||||||
if (!stats) return <Forbidden />;
|
if (!stats) return <Forbidden />;
|
||||||
const { counts, trend, recentInquiries } = stats;
|
const { counts, trend, trend30, inquiryStatus, revenue, recentInquiries, recentOrders } = stats;
|
||||||
const max = Math.max(1, ...trend.map((t) => t.count));
|
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 = [
|
const cards = [
|
||||||
{ title: "内容", value: counts.contents, icon: <FileText size={20} aria-hidden color="#1677ff" /> },
|
{ title: "订单(30 天环比)", value: ordNow, delta: delta(ordNow, ordPrev), icon: <ShoppingCart size={18} aria-hidden color="#faad14" />, href: "/admin/orders/" },
|
||||||
{ title: "产品", value: counts.products, icon: <Package size={20} aria-hidden color="#52c41a" /> },
|
{ title: "营收(30 天环比)", value: revNow, delta: delta(revNow, revPrev), icon: <DollarSign size={18} aria-hidden color="#52c41a" />, href: "/admin/orders/", money: true },
|
||||||
{ title: "订单", value: counts.orders, icon: <ShoppingCart size={20} aria-hidden color="#faad14" /> },
|
{ title: "新询盘(待跟进)", value: newInq, delta: delta(inqNow, inqPrev), icon: <Inbox size={18} aria-hidden color="#722ed1" />, href: "/admin/inquiries?status=new" },
|
||||||
{ title: "询盘", value: counts.inquiries, icon: <Inbox size={20} aria-hidden color="#722ed1" /> },
|
{ title: "待处理订单", value: counts.pendingOrders, icon: <TimerReset size={18} aria-hidden color="#fa541c" />, href: "/admin/orders/" },
|
||||||
{ title: "媒体", value: counts.media, icon: <ImageIcon size={20} aria-hidden color="#13c2c2" /> },
|
{ title: "产品", value: counts.products, icon: <Package size={18} aria-hidden color="#13c2c2" />, href: "/admin/products/" },
|
||||||
{ title: "待处理订单", value: counts.pendingOrders, icon: <TimerReset size={20} aria-hidden color="#fa541c" /> },
|
{ 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Row gutter={[16, 16]}>
|
{/* KPI 卡 */}
|
||||||
|
<Row gutter={[12, 12]}>
|
||||||
{cards.map((c) => (
|
{cards.map((c) => (
|
||||||
<Col key={c.title} xs={12} sm={8} lg={4}>
|
<Col key={c.title} xs={12} sm={8} xl={4}>
|
||||||
<Card size="small">
|
<a href={c.href} style={{ display: "block" }}>
|
||||||
<Statistic title={c.title} value={c.value} prefix={c.icon} />
|
<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>
|
</Card>
|
||||||
|
</a>
|
||||||
</Col>
|
</Col>
|
||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Card title="最近 7 天询盘趋势" size="small" style={{ marginTop: 16 }}>
|
{/* 30 天双趋势 + 询盘漏斗 */}
|
||||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 16, height: 160, padding: "0 8px" }}>
|
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||||
{trend.map((t) => (
|
<Col xs={24} xl={16}>
|
||||||
<div key={t.day} style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 4, height: "100%", justifyContent: "flex-end" }}>
|
<Card
|
||||||
<span style={{ fontSize: 12, color: "#666" }}>{t.count}</span>
|
title="近 30 天订单 / 询盘趋势"
|
||||||
<div
|
size="small"
|
||||||
role="img"
|
extra={
|
||||||
aria-label={`${t.day} 询盘 ${t.count} 条`}
|
<Space size={16} style={{ fontSize: 12, color: "#666" }}>
|
||||||
style={{ width: "60%", maxWidth: 48, height: `${Math.round((t.count / max) * 100)}%`, minHeight: 4, background: "#1677ff", borderRadius: 4, opacity: 0.85 }}
|
<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>
|
||||||
<span style={{ fontSize: 12, color: "#999" }}>{t.day.slice(5)}</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>
|
</div>
|
||||||
|
<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>
|
</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 }}>
|
{/* 最近订单 / 最新询盘并排 */}
|
||||||
|
<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
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -68,22 +203,27 @@ function DashboardPage() {
|
|||||||
pagination={false}
|
pagination={false}
|
||||||
locale={{ emptyText: <AdminEmpty /> }}
|
locale={{ emptyText: <AdminEmpty /> }}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: "ID", dataIndex: "id", width: 70 },
|
{ title: "姓名", dataIndex: "name", width: 120, ellipsis: true },
|
||||||
{ title: "姓名", dataIndex: "name", width: 140 },
|
{ title: "邮箱", dataIndex: "email", width: 200, ellipsis: true },
|
||||||
{ title: "邮箱", dataIndex: "email", width: 220 },
|
{ title: "国家", dataIndex: "country", width: 100 },
|
||||||
{ title: "公司", dataIndex: "company", width: 160, ellipsis: true },
|
{ title: "状态", dataIndex: "status", width: 90, render: (v: string) => <Tag color={INQUIRY_STATUS_COLOR[v] ?? "default"}>{INQUIRY_STATUS_LABEL[v] ?? v}</Tag> },
|
||||||
{ title: "国家", dataIndex: "country", width: 120 },
|
{ title: "时间", dataIndex: "createdAt", width: 150, render: (v: string) => fmtDateTime(v) },
|
||||||
{ 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) },
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
* 表格 + 缩略图预览;编辑替代文本(alt)与标题。
|
* 表格 + 缩略图预览;编辑替代文本(alt)与标题。
|
||||||
*/
|
*/
|
||||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||||
import { App, Button, Card, Form, Image, Input, Modal, Space, Table, Typography } from "antd";
|
import { App, Button, Card, Form, Image, Input, Modal, Space, Table, Typography, Upload } from "antd";
|
||||||
import { EditOutlined, SearchOutlined } from "@ant-design/icons";
|
import { EditOutlined, SearchOutlined, UploadOutlined } from "@ant-design/icons";
|
||||||
import { File as FileIcon } from "lucide-react";
|
import { File as FileIcon } from "lucide-react";
|
||||||
import { useState } from "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 { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common";
|
||||||
import type { media } from "~/db/schema";
|
import type { media } from "~/db/schema";
|
||||||
|
|
||||||
@@ -74,6 +74,7 @@ function MediaPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="媒体库"
|
title="媒体库"
|
||||||
extra={
|
extra={
|
||||||
|
<Space>
|
||||||
<Input
|
<Input
|
||||||
allowClear
|
allowClear
|
||||||
placeholder="搜索文件路径"
|
placeholder="搜索文件路径"
|
||||||
@@ -83,6 +84,33 @@ function MediaPage() {
|
|||||||
onPressEnter={() => updateSearch({ q })}
|
onPressEnter={() => updateSearch({ q })}
|
||||||
style={{ width: 240 }}
|
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
|
<Table
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* # 路由 /admin/menus 菜单管理
|
||||||
|
* 左侧菜单选择(menus 表 4 个位置)+ 右侧菜单项表格(menu_items 树平铺展示)。
|
||||||
|
* 支持新增/编辑/删除菜单项;删除时子项提升为顶层(与 server 端逻辑一致)。
|
||||||
|
*/
|
||||||
|
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||||
|
import { App, Button, Card, Form, Input, InputNumber, Modal, Select, Space, Table, TreeSelect } from "antd";
|
||||||
|
import { DeleteOutlined, EditOutlined, PlusOutlined } from "@ant-design/icons";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { adminDeleteMenuItem, adminListMenus, adminSaveMenuItem } from "~/server/admin";
|
||||||
|
import { AdminEmpty, Forbidden, PageHeader, guardAdmin } from "~/components/admin/common";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/admin/menus")({
|
||||||
|
loader: () => guardAdmin(() => adminListMenus()),
|
||||||
|
component: MenusPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 类型 菜单定义行 **/
|
||||||
|
type MenuDef = { id: number; name: string; location: string };
|
||||||
|
/** # 类型 菜单项行(平铺) **/
|
||||||
|
type MenuItemRow = { id: number; menuId: number; parentId: number | null; title: string; url: string; target: string; sort: number };
|
||||||
|
|
||||||
|
/** # 函数 菜单管理页面组件 */
|
||||||
|
function MenusPage() {
|
||||||
|
const data = Route.useLoaderData();
|
||||||
|
const router = useRouter();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
/** # 成员变量 当前选中菜单 ID(默认第一个) **/
|
||||||
|
const [menuId, setMenuId] = useState<number | null>(null);
|
||||||
|
/** # 成员变量 编辑弹窗(null = 关闭;id=0 新建) **/
|
||||||
|
const [editing, setEditing] = useState<(Partial<MenuItemRow> & { id?: number }) | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
// 首次/数据刷新后确定选中菜单
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
if (menuId === null || !data.menus.some((m) => m.id === menuId)) setMenuId(data.menus[0]?.id ?? null);
|
||||||
|
}, [data, menuId]);
|
||||||
|
|
||||||
|
/** # 成员变量 当前菜单的项(树形缩进展示:父在前,子随父缩进) **/
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
if (!data || menuId === null) return [];
|
||||||
|
const items = data.items.filter((i) => i.menuId === menuId);
|
||||||
|
const byId = new Map(items.map((i) => [i.id, { ...i, depth: 0 }]));
|
||||||
|
const out: (MenuItemRow & { depth: number })[] = [];
|
||||||
|
// 顶层(无父)按 sort 排序后递归挂子项
|
||||||
|
const roots = items.filter((i) => !i.parentId || !byId.has(i.parentId)).sort((a, b) => a.sort - b.sort || a.id - b.id);
|
||||||
|
const walk = (node: MenuItemRow, depth: number) => {
|
||||||
|
out.push({ ...node, depth });
|
||||||
|
items
|
||||||
|
.filter((i) => i.parentId === node.id)
|
||||||
|
.sort((a, b) => a.sort - b.sort || a.id - b.id)
|
||||||
|
.forEach((c) => walk(c, Math.min(depth + 1, 3)));
|
||||||
|
};
|
||||||
|
roots.forEach((r) => walk(r, 0));
|
||||||
|
return out;
|
||||||
|
}, [data, menuId]);
|
||||||
|
|
||||||
|
if (!data) return <Forbidden />;
|
||||||
|
|
||||||
|
/** # 函数 打开编辑弹窗(item 缺省 = 新建,挂在当前菜单顶层) */
|
||||||
|
function openEdit(item?: MenuItemRow) {
|
||||||
|
setEditing(item ?? { menuId: menuId ?? 0, parentId: null, title: "", url: "", target: "_self", sort: 0 });
|
||||||
|
form.setFieldsValue(item ?? { menuId: menuId ?? 0, parentId: null, title: "", url: "", target: "_self", sort: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 保存菜单项 */
|
||||||
|
async function handleSave(values: { menuId: number; parentId?: number | null; title: string; url: string; target: string; sort: number }) {
|
||||||
|
try {
|
||||||
|
await adminSaveMenuItem({ data: { ...values, parentId: values.parentId ?? null, id: editing?.id || undefined } });
|
||||||
|
message.success("已保存");
|
||||||
|
setEditing(null);
|
||||||
|
await router.invalidate();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : "保存失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 删除菜单项(子项自动提升顶层) */
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
await adminDeleteMenuItem({ data: { id } });
|
||||||
|
message.success("已删除(子项已提升为顶层)");
|
||||||
|
await router.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 成员变量 父级选择树(排除自身防环) **/
|
||||||
|
const parentOptions = [
|
||||||
|
{ title: "(顶层)", value: 0 },
|
||||||
|
...rows.filter((r) => r.id !== editing?.id).map((r) => ({ title: `${"— ".repeat(r.depth)}${r.title}`, value: r.id })),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="small">
|
||||||
|
<PageHeader
|
||||||
|
title="菜单管理"
|
||||||
|
extra={
|
||||||
|
<Button type="primary" icon={<PlusOutlined aria-hidden />} onClick={() => openEdit()} disabled={menuId === null}>
|
||||||
|
新增菜单项
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Space style={{ marginBottom: 12 }} wrap>
|
||||||
|
<Select
|
||||||
|
style={{ width: 300 }}
|
||||||
|
value={menuId ?? undefined}
|
||||||
|
onChange={(v) => setMenuId(v)}
|
||||||
|
options={data.menus.map((m: MenuDef) => ({ value: m.id, label: `${m.name}(${m.location})` }))}
|
||||||
|
/>
|
||||||
|
<span style={{ color: "#999", fontSize: 12 }}>前台 Header/Footer 即时读取所选位置菜单</span>
|
||||||
|
</Space>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
dataSource={rows}
|
||||||
|
locale={{ emptyText: <AdminEmpty /> }}
|
||||||
|
pagination={false}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: "标题",
|
||||||
|
dataIndex: "title",
|
||||||
|
render: (v: string, r) => (
|
||||||
|
<span style={{ paddingLeft: r.depth * 24 }}>
|
||||||
|
{r.depth > 0 ? <span style={{ color: "#bbb", marginRight: 4 }}>└</span> : null}
|
||||||
|
{v}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: "链接", dataIndex: "url", ellipsis: true, render: (v: string) => <code style={{ fontSize: 12 }}>{v}</code> },
|
||||||
|
{ title: "排序", dataIndex: "sort", width: 70 },
|
||||||
|
{ title: "打开方式", dataIndex: "target", width: 100 },
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
key: "act",
|
||||||
|
width: 110,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<Button size="small" type="text" icon={<EditOutlined aria-label="编辑" />} onClick={() => openEdit(r)} />
|
||||||
|
<Button size="small" danger type="text" icon={<DeleteOutlined aria-label="删除" />} onClick={() => void handleDelete(r.id)} />
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editing?.id ? `编辑菜单项 #${editing.id}` : "新增菜单项"}
|
||||||
|
open={editing !== null}
|
||||||
|
onCancel={() => setEditing(null)}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" onFinish={(v) => void handleSave(v as { menuId: number; parentId?: number | null; title: string; url: string; target: string; sort: number })}>
|
||||||
|
<Form.Item name="title" label="显示文字" rules={[{ required: true, message: "请输入显示文字" }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="url" label="链接地址" rules={[{ required: true, message: "请输入链接" }]}>
|
||||||
|
<Input placeholder="/products/ 或 https://…" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="parentId" label="父级菜单项" initialValue={0}>
|
||||||
|
<TreeSelect treeData={parentOptions} treeDefaultExpandAll />
|
||||||
|
</Form.Item>
|
||||||
|
<Space size={16} style={{ display: "flex" }}>
|
||||||
|
<Form.Item name="sort" label="排序" initialValue={0}>
|
||||||
|
<InputNumber min={0} max={9999} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="target" label="打开方式" initialValue="_self">
|
||||||
|
<Select style={{ width: 140 }} options={[{ value: "_self", label: "当前窗口" }, { value: "_blank", label: "新窗口" }]} />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
<Form.Item name="menuId" hidden initialValue={menuId ?? 0}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* # 路由 /admin/settings 系统设置
|
||||||
|
* settings 表 kv 编辑器:每键一个 JSON 编辑卡片,保存整键覆写。
|
||||||
|
* 仅 admin 角色可保存(server 端强制)。
|
||||||
|
*/
|
||||||
|
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||||
|
import { App, Button, Card, Input, Space, Tag, Typography } from "antd";
|
||||||
|
import { Save } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { adminListSettings, adminUpdateSetting } from "~/server/admin";
|
||||||
|
import { Forbidden, PageHeader, fmtDateTime, guardAdmin, type Json } from "~/components/admin/common";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/admin/settings")({
|
||||||
|
loader: () => guardAdmin(() => adminListSettings()),
|
||||||
|
component: SettingsPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 成员变量 设置键中文说明(未知键回退原键名) **/
|
||||||
|
const SETTING_LABEL: Record<string, string> = {
|
||||||
|
site: "站点信息",
|
||||||
|
woocommerce: "商城配置",
|
||||||
|
mail: "邮件配置",
|
||||||
|
seo: "SEO 配置",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** # 类型 设置行(updatedAt 为 Date,loader 序列化前的服务端类型) **/
|
||||||
|
type SettingRow = { key: string; value: Json; updatedAt: Date | string };
|
||||||
|
|
||||||
|
/** # 函数 系统设置页面组件 */
|
||||||
|
function SettingsPage() {
|
||||||
|
const data = Route.useLoaderData();
|
||||||
|
const router = useRouter();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
/** # 成员变量 各键的草稿文本(键 → JSON 字符串) **/
|
||||||
|
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
setDrafts(Object.fromEntries(data.map((r: SettingRow) => [r.key, JSON.stringify(r.value, null, 2)])));
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
if (!data) return <Forbidden />;
|
||||||
|
|
||||||
|
/** # 函数 保存单个设置键(JSON 校验失败提示) */
|
||||||
|
async function handleSave(key: string) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(drafts[key] ?? "{}") as Json;
|
||||||
|
await adminUpdateSetting({ data: { key, value: parsed } });
|
||||||
|
message.success(`已保存 ${key}`);
|
||||||
|
await router.invalidate();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : "保存失败(JSON 格式需合法)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="small">
|
||||||
|
<PageHeader title="系统设置" extra={<Tag color="orange">仅管理员可保存</Tag>} />
|
||||||
|
<Space direction="vertical" size={16} style={{ display: "flex" }}>
|
||||||
|
{data.map((r: SettingRow) => (
|
||||||
|
<Card
|
||||||
|
key={r.key}
|
||||||
|
type="inner"
|
||||||
|
size="small"
|
||||||
|
title={`${SETTING_LABEL[r.key] ?? r.key}(${r.key})`}
|
||||||
|
extra={
|
||||||
|
<Button size="small" type="primary" icon={<Save size={13} aria-hidden />} onClick={() => void handleSave(r.key)}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={Math.min(14, Math.max(4, (drafts[r.key] ?? "").split("\n").length))}
|
||||||
|
value={drafts[r.key] ?? ""}
|
||||||
|
onChange={(e) => setDrafts((d) => ({ ...d, [r.key]: e.target.value }))}
|
||||||
|
style={{ fontFamily: "monospace", fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
最后更新:{fmtDateTime(r.updatedAt)}
|
||||||
|
</Typography.Text>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
+41
-11
@@ -12,34 +12,60 @@ import {
|
|||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Languages,
|
Languages,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
List,
|
||||||
LogOut,
|
LogOut,
|
||||||
Package,
|
Package,
|
||||||
Route as RouteIcon,
|
Route as RouteIcon,
|
||||||
|
ScrollText,
|
||||||
Settings,
|
Settings,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
UserRound,
|
UserRound,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import { StyleProvider } from "@ant-design/cssinjs";
|
import { StyleProvider } from "@ant-design/cssinjs";
|
||||||
import { getCurrentUser, loginFn, logoutFn } from "~/server/auth";
|
import { getCurrentUser, loginFn, logoutFn } from "~/server/auth";
|
||||||
|
|
||||||
/** # 类型 后台菜单项(key 即目标路由路径) **/
|
/** # 类型 后台菜单分组(分组标签不可点;key 即目标路由路径) **/
|
||||||
const MENU_ITEMS = [
|
const MENU_GROUPS: { label: string; children: { key: string; icon: ReactNode; label: string }[] }[] = [
|
||||||
{ key: "/admin/", icon: <LayoutDashboard size={16} aria-hidden />, label: "Dashboard" },
|
{
|
||||||
{ key: "/admin/contents/", icon: <Files size={16} aria-hidden />, label: "内容" },
|
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/products/", icon: <Package size={16} aria-hidden />, label: "产品" },
|
||||||
{ key: "/admin/orders/", icon: <ShoppingCart 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/inquiries/", icon: <Inbox size={16} aria-hidden />, label: "询盘" },
|
||||||
{ key: "/admin/media/", icon: <ImageIcon 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/languages/", icon: <Languages size={16} aria-hidden />, label: "语言" },
|
||||||
{ key: "/admin/seo/", icon: <RouteIcon size={16} aria-hidden />, label: "SEO / 重定向" },
|
{ 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: <UserRound size={16} aria-hidden />, label: "用户" },
|
||||||
{ key: "/admin/users/", icon: <Settings size={16} aria-hidden />, label: "系统设置" },
|
{ key: "/admin/settings/", icon: <Settings size={16} aria-hidden />, label: "系统设置" },
|
||||||
] as const;
|
{ 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"];
|
const ADMIN_ROLES = ["admin", "editor", "manager"];
|
||||||
@@ -159,7 +185,11 @@ function AdminShell({ user }: { user: { displayName: string | null; email: strin
|
|||||||
<Menu
|
<Menu
|
||||||
theme="dark"
|
theme="dark"
|
||||||
mode="inline"
|
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]}
|
selectedKeys={[selectedKey]}
|
||||||
onClick={({ key }) => void navigate({ to: key as MenuKey })}
|
onClick={({ key }) => void navigate({ to: key as MenuKey })}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+124
-16
@@ -1,17 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* # 路由 /admin/users 用户管理(系统设置,只读)
|
* # 路由 /admin/users 用户管理
|
||||||
* 用户列表:分页 + 关键字搜索;角色用 Tag 展示;不做编辑操作。
|
* 用户列表(分页 + 搜索);admin 可新建用户、改角色、重置密码。
|
||||||
|
* 服务端有防锁死保护:最后一个 admin 不可降级。
|
||||||
*/
|
*/
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||||
import { Card, Input, Table, Tag } from "antd";
|
import { App, Button, Card, Form, Input, Modal, Select, Space, Table, Tag } from "antd";
|
||||||
import { SearchOutlined } from "@ant-design/icons";
|
import { KeyRound, Search, UserPlus } from "lucide-react";
|
||||||
|
import { getCurrentUser } from "~/server/auth";
|
||||||
import { useState } from "react";
|
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";
|
import { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common";
|
||||||
|
|
||||||
export const Route = createFileRoute("/admin/users")({
|
export const Route = createFileRoute("/admin/users")({
|
||||||
validateSearch: (search: Record<string, unknown>): AdminSearch => {
|
validateSearch: (search: Record<string, unknown>): AdminSearch => {
|
||||||
// 只保留显式传入的参数,缺省时不回写 URL(避免 307 归一化重定向)
|
|
||||||
const out: AdminSearch = {};
|
const out: AdminSearch = {};
|
||||||
const page = Math.floor(Number(search.page)) || 0;
|
const page = Math.floor(Number(search.page)) || 0;
|
||||||
if (page > 1) out.page = page;
|
if (page > 1) out.page = page;
|
||||||
@@ -19,61 +20,168 @@ export const Route = createFileRoute("/admin/users")({
|
|||||||
return out;
|
return out;
|
||||||
},
|
},
|
||||||
loaderDeps: ({ search }) => ({ search }),
|
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,
|
component: UsersPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** # 成员变量 角色中文标签与配色 **/
|
/** # 成员变量 角色中文标签与配色 **/
|
||||||
const ROLE_LABEL: Record<string, string> = { admin: "管理员", editor: "编辑", manager: "经理", customer: "客户" };
|
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_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() {
|
function UsersPage() {
|
||||||
const data = Route.useLoaderData();
|
const data = Route.useLoaderData();
|
||||||
const search = Route.useSearch();
|
const search = Route.useSearch();
|
||||||
const navigate = Route.useNavigate();
|
const navigate = Route.useNavigate();
|
||||||
|
const router = useRouter();
|
||||||
|
const { message, modal } = App.useApp();
|
||||||
const [q, setQ] = useState(search.q ?? "");
|
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 />;
|
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 (
|
return (
|
||||||
<Card size="small">
|
<Card size="small">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="用户管理"
|
title="用户管理"
|
||||||
extra={
|
extra={
|
||||||
|
<Space>
|
||||||
<Input
|
<Input
|
||||||
allowClear
|
allowClear
|
||||||
placeholder="搜索邮箱 / 显示名"
|
placeholder="搜索邮箱 / 显示名"
|
||||||
prefix={<SearchOutlined aria-hidden />}
|
prefix={<Search size={13} aria-hidden />}
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => setQ(e.target.value)}
|
||||||
onPressEnter={() => void navigate({ search: (prev) => ({ ...prev, q, page: 1 }) })}
|
onPressEnter={() => void navigate({ search: (prev) => ({ ...prev, q, page: 1 }) })}
|
||||||
style={{ width: 240 }}
|
style={{ width: 240 }}
|
||||||
/>
|
/>
|
||||||
|
{isAdmin ? (
|
||||||
|
<Button type="primary" icon={<UserPlus size={14} aria-hidden />} onClick={() => setCreateOpen(true)}>
|
||||||
|
新建用户
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
size="small"
|
size="small"
|
||||||
dataSource={data.items}
|
dataSource={data.users.items}
|
||||||
locale={{ emptyText: <AdminEmpty /> }}
|
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 }) }),
|
void navigate({ search: (prev) => ({ ...prev, page: p }) }),
|
||||||
)}
|
)}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: "ID", dataIndex: "id", width: 80 },
|
{ title: "ID", dataIndex: "id", width: 80 },
|
||||||
{ title: "邮箱", dataIndex: "email", width: 260, ellipsis: true },
|
{ title: "邮箱", dataIndex: "email", width: 240, ellipsis: true },
|
||||||
{ title: "用户名", dataIndex: "username", width: 160, ellipsis: true },
|
{ title: "显示名", dataIndex: "displayName", width: 170, ellipsis: true },
|
||||||
{ title: "显示名", dataIndex: "displayName", width: 180, ellipsis: true },
|
|
||||||
{
|
{
|
||||||
title: "角色",
|
title: "角色",
|
||||||
dataIndex: "role",
|
dataIndex: "role",
|
||||||
width: 110,
|
width: 170,
|
||||||
render: (v: string) => <Tag color={ROLE_COLOR[v] ?? "default"}>{ROLE_LABEL[v] ?? v}</Tag>,
|
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: "注册时间", 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>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+412
-9
@@ -6,6 +6,11 @@
|
|||||||
import { db } from "~/db";
|
import { db } from "~/db";
|
||||||
import {
|
import {
|
||||||
contents,
|
contents,
|
||||||
|
contentRevisions,
|
||||||
|
contentTerms,
|
||||||
|
menus,
|
||||||
|
menuItems,
|
||||||
|
settings,
|
||||||
products,
|
products,
|
||||||
productAttributes,
|
productAttributes,
|
||||||
productAttributeValues,
|
productAttributeValues,
|
||||||
@@ -25,6 +30,7 @@ import {
|
|||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { and, asc, desc, eq, ilike, inArray, or, sql } from "drizzle-orm";
|
import { and, asc, desc, eq, ilike, inArray, or, sql } from "drizzle-orm";
|
||||||
import type { PgTable } from "drizzle-orm/pg-core";
|
import type { PgTable } from "drizzle-orm/pg-core";
|
||||||
|
import { parseImageSize } from "./image-size";
|
||||||
import type { Json } from "~/components/admin/common";
|
import type { Json } from "~/components/admin/common";
|
||||||
|
|
||||||
/** # 类型 后台通用分页入参 **/
|
/** # 类型 后台通用分页入参 **/
|
||||||
@@ -102,18 +108,39 @@ export const adminListContents = createServerFn({ method: "GET" })
|
|||||||
return { items, total: Number(total), page, pageSize };
|
return { items, total: Number(total), page, pageSize };
|
||||||
});
|
});
|
||||||
|
|
||||||
/** # 函数 Server Function:读取单条内容(编辑页用) */
|
/** # 函数 Server Function:读取单条内容(编辑页用)
|
||||||
|
* 返回值 内容正本 + 已选分类/标签 ID + 分类/标签选项(taxonomy=category/post_tag)
|
||||||
|
*/
|
||||||
export const adminGetContent = createServerFn({ method: "GET" })
|
export const adminGetContent = createServerFn({ method: "GET" })
|
||||||
.validator((d: { id: number }) => d)
|
.validator((d: { id: number }) => d)
|
||||||
.handler(async ({ data }) => {
|
.handler(async ({ data }) => {
|
||||||
const { requireAdmin } = await import("./auth");
|
const { requireAdmin } = await import("./auth");
|
||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
const [item] = await db.select().from(contents).where(eq(contents.id, data.id));
|
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 可序列化校验
|
// 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" })
|
export const adminSaveContent = createServerFn({ method: "POST" })
|
||||||
.validator(
|
.validator(
|
||||||
(d: {
|
(d: {
|
||||||
@@ -127,13 +154,32 @@ export const adminSaveContent = createServerFn({ method: "POST" })
|
|||||||
template?: string;
|
template?: string;
|
||||||
seoTitle?: string | null;
|
seoTitle?: string | null;
|
||||||
seoDescription?: string | null;
|
seoDescription?: string | null;
|
||||||
|
/** # 成员变量 Elementor 文档 JSON 字符串(空串/非法 JSON 视为不修改;仅显式 null 清除) **/
|
||||||
|
elementorJson?: string | null;
|
||||||
|
/** # 成员变量 关联分类/标签 ID(WP 分类目录 + 标签) **/
|
||||||
|
categoryIds?: number[];
|
||||||
}) => d,
|
}) => d,
|
||||||
)
|
)
|
||||||
.handler(async ({ data }) => {
|
.handler(async ({ data }) => {
|
||||||
const { requireAdmin } = await import("./auth");
|
const { requireAdmin } = await import("./auth");
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
const slug = (data.slug || "").trim() || slugify(data.title);
|
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,
|
type: data.type,
|
||||||
slug,
|
slug,
|
||||||
title: data.title,
|
title: data.title,
|
||||||
@@ -145,20 +191,35 @@ export const adminSaveContent = createServerFn({ method: "POST" })
|
|||||||
seoDescription: data.seoDescription ?? null,
|
seoDescription: data.seoDescription ?? null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
if (metaPatch) values.meta = metaPatch;
|
||||||
let id = data.id;
|
let id = data.id;
|
||||||
if (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) {
|
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));
|
await db.update(contents).set(values).where(eq(contents.id, id));
|
||||||
} else {
|
} else {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.insert(contents)
|
.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 });
|
.returning({ id: contents.id });
|
||||||
id = row!.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 });
|
await writeAudit(admin.id, "content.save", "content", id!, { title: data.title, type: data.type });
|
||||||
return { ok: true as const, id };
|
return { ok: true as const, id };
|
||||||
});
|
});
|
||||||
@@ -657,13 +718,14 @@ export const adminListUsers = createServerFn({ method: "GET" })
|
|||||||
/* ============================= Dashboard 统计 ============================= */
|
/* ============================= Dashboard 统计 ============================= */
|
||||||
|
|
||||||
/** # 函数 Server Function:Dashboard 统计
|
/** # 函数 Server Function:Dashboard 统计
|
||||||
* 返回值 各实体计数、待处理订单数、最近 7 天询盘趋势、最新 10 条询盘
|
* 返回值 各实体计数、待处理订单数、近 7 天询盘趋势、最新 10 条询盘、
|
||||||
|
* 近 30 天订单/询盘趋势(CRM 看板用)、新询盘待办数、状态分布、最近 10 笔订单
|
||||||
*/
|
*/
|
||||||
export const adminStats = createServerFn({ method: "GET" }).handler(async () => {
|
export const adminStats = createServerFn({ method: "GET" }).handler(async () => {
|
||||||
const { requireAdmin } = await import("./auth");
|
const { requireAdmin } = await import("./auth");
|
||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
const countOf = async (table: PgTable) => Number(await db.$count(table));
|
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(contents),
|
||||||
countOf(products),
|
countOf(products),
|
||||||
countOf(orders),
|
countOf(orders),
|
||||||
@@ -673,6 +735,7 @@ export const adminStats = createServerFn({ method: "GET" }).handler(async () =>
|
|||||||
.select({ n: sql<number>`count(*)` })
|
.select({ n: sql<number>`count(*)` })
|
||||||
.from(orders)
|
.from(orders)
|
||||||
.where(or(eq(orders.status, "pending"), eq(orders.status, "on-hold"))),
|
.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 天询盘趋势(按天分组,补齐空白天)
|
// 最近 7 天询盘趋势(按天分组,补齐空白天)
|
||||||
const trendRows = await db
|
const trendRows = await db
|
||||||
@@ -704,6 +767,56 @@ export const adminStats = createServerFn({ method: "GET" }).handler(async () =>
|
|||||||
.from(inquiries)
|
.from(inquiries)
|
||||||
.orderBy(desc(inquiries.createdAt))
|
.orderBy(desc(inquiries.createdAt))
|
||||||
.limit(10);
|
.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 {
|
return {
|
||||||
counts: {
|
counts: {
|
||||||
contents: contentsCount,
|
contents: contentsCount,
|
||||||
@@ -712,8 +825,298 @@ export const adminStats = createServerFn({ method: "GET" }).handler(async () =>
|
|||||||
inquiries: inquiriesCount,
|
inquiries: inquiriesCount,
|
||||||
media: mediaCount,
|
media: mediaCount,
|
||||||
pendingOrders: Number(pendingOrders[0]?.n ?? 0),
|
pendingOrders: Number(pendingOrders[0]?.n ?? 0),
|
||||||
|
newInquiries: Number(newInquiries[0]?.n ?? 0),
|
||||||
},
|
},
|
||||||
trend,
|
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,
|
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 };
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user