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",
|
||||
"version": "0.10.37",
|
||||
"version": "0.11.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -22,11 +22,14 @@ import { Route as ShopRouteImport } from './routes/shop'
|
||||
import { Route as SitemapDotxmlRouteImport } from './routes/sitemap[.]xml'
|
||||
import { Route as SolutionRouteImport } from './routes/solution'
|
||||
import { Route as AdminIndexRouteImport } from './routes/admin.index'
|
||||
import { Route as AdminAuditRouteImport } from './routes/admin.audit'
|
||||
import { Route as AdminFiltersRouteImport } from './routes/admin.filters'
|
||||
import { Route as AdminInquiriesRouteImport } from './routes/admin.inquiries'
|
||||
import { Route as AdminLanguagesRouteImport } from './routes/admin.languages'
|
||||
import { Route as AdminMediaRouteImport } from './routes/admin.media'
|
||||
import { Route as AdminMenusRouteImport } from './routes/admin.menus'
|
||||
import { Route as AdminSeoRouteImport } from './routes/admin.seo'
|
||||
import { Route as AdminSettingsRouteImport } from './routes/admin.settings'
|
||||
import { Route as AdminUsersRouteImport } from './routes/admin.users'
|
||||
import { Route as BlogIndexRouteImport } from './routes/blog.index'
|
||||
import { Route as BlogSlugRouteImport } from './routes/blog.$slug'
|
||||
@@ -109,6 +112,11 @@ const AdminIndexRoute = AdminIndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminAuditRoute = AdminAuditRouteImport.update({
|
||||
id: '/audit',
|
||||
path: '/audit',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminFiltersRoute = AdminFiltersRouteImport.update({
|
||||
id: '/filters',
|
||||
path: '/filters',
|
||||
@@ -129,11 +137,21 @@ const AdminMediaRoute = AdminMediaRouteImport.update({
|
||||
path: '/media',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminMenusRoute = AdminMenusRouteImport.update({
|
||||
id: '/menus',
|
||||
path: '/menus',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminSeoRoute = AdminSeoRouteImport.update({
|
||||
id: '/seo',
|
||||
path: '/seo',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminSettingsRoute = AdminSettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminUsersRoute = AdminUsersRouteImport.update({
|
||||
id: '/users',
|
||||
path: '/users',
|
||||
@@ -228,11 +246,14 @@ export interface FileRoutesByFullPath {
|
||||
'/shop': typeof ShopRoute
|
||||
'/sitemap.xml': typeof SitemapDotxmlRoute
|
||||
'/solution': typeof SolutionRouteWithChildren
|
||||
'/admin/audit': typeof AdminAuditRoute
|
||||
'/admin/filters': typeof AdminFiltersRoute
|
||||
'/admin/inquiries': typeof AdminInquiriesRoute
|
||||
'/admin/languages': typeof AdminLanguagesRoute
|
||||
'/admin/media': typeof AdminMediaRoute
|
||||
'/admin/menus': typeof AdminMenusRoute
|
||||
'/admin/seo': typeof AdminSeoRoute
|
||||
'/admin/settings': typeof AdminSettingsRoute
|
||||
'/admin/users': typeof AdminUsersRoute
|
||||
'/blog/$slug': typeof BlogSlugRoute
|
||||
'/collection/$slug': typeof CollectionSlugRoute
|
||||
@@ -262,11 +283,14 @@ export interface FileRoutesByTo {
|
||||
'/search': typeof SearchRoute
|
||||
'/shop': typeof ShopRoute
|
||||
'/sitemap.xml': typeof SitemapDotxmlRoute
|
||||
'/admin/audit': typeof AdminAuditRoute
|
||||
'/admin/filters': typeof AdminFiltersRoute
|
||||
'/admin/inquiries': typeof AdminInquiriesRoute
|
||||
'/admin/languages': typeof AdminLanguagesRoute
|
||||
'/admin/media': typeof AdminMediaRoute
|
||||
'/admin/menus': typeof AdminMenusRoute
|
||||
'/admin/seo': typeof AdminSeoRoute
|
||||
'/admin/settings': typeof AdminSettingsRoute
|
||||
'/admin/users': typeof AdminUsersRoute
|
||||
'/blog/$slug': typeof BlogSlugRoute
|
||||
'/collection/$slug': typeof CollectionSlugRoute
|
||||
@@ -299,11 +323,14 @@ export interface FileRoutesById {
|
||||
'/shop': typeof ShopRoute
|
||||
'/sitemap.xml': typeof SitemapDotxmlRoute
|
||||
'/solution': typeof SolutionRouteWithChildren
|
||||
'/admin/audit': typeof AdminAuditRoute
|
||||
'/admin/filters': typeof AdminFiltersRoute
|
||||
'/admin/inquiries': typeof AdminInquiriesRoute
|
||||
'/admin/languages': typeof AdminLanguagesRoute
|
||||
'/admin/media': typeof AdminMediaRoute
|
||||
'/admin/menus': typeof AdminMenusRoute
|
||||
'/admin/seo': typeof AdminSeoRoute
|
||||
'/admin/settings': typeof AdminSettingsRoute
|
||||
'/admin/users': typeof AdminUsersRoute
|
||||
'/blog/$slug': typeof BlogSlugRoute
|
||||
'/collection/$slug': typeof CollectionSlugRoute
|
||||
@@ -337,11 +364,14 @@ export interface FileRouteTypes {
|
||||
| '/shop'
|
||||
| '/sitemap.xml'
|
||||
| '/solution'
|
||||
| '/admin/audit'
|
||||
| '/admin/filters'
|
||||
| '/admin/inquiries'
|
||||
| '/admin/languages'
|
||||
| '/admin/media'
|
||||
| '/admin/menus'
|
||||
| '/admin/seo'
|
||||
| '/admin/settings'
|
||||
| '/admin/users'
|
||||
| '/blog/$slug'
|
||||
| '/collection/$slug'
|
||||
@@ -371,11 +401,14 @@ export interface FileRouteTypes {
|
||||
| '/search'
|
||||
| '/shop'
|
||||
| '/sitemap.xml'
|
||||
| '/admin/audit'
|
||||
| '/admin/filters'
|
||||
| '/admin/inquiries'
|
||||
| '/admin/languages'
|
||||
| '/admin/media'
|
||||
| '/admin/menus'
|
||||
| '/admin/seo'
|
||||
| '/admin/settings'
|
||||
| '/admin/users'
|
||||
| '/blog/$slug'
|
||||
| '/collection/$slug'
|
||||
@@ -407,11 +440,14 @@ export interface FileRouteTypes {
|
||||
| '/shop'
|
||||
| '/sitemap.xml'
|
||||
| '/solution'
|
||||
| '/admin/audit'
|
||||
| '/admin/filters'
|
||||
| '/admin/inquiries'
|
||||
| '/admin/languages'
|
||||
| '/admin/media'
|
||||
| '/admin/menus'
|
||||
| '/admin/seo'
|
||||
| '/admin/settings'
|
||||
| '/admin/users'
|
||||
| '/blog/$slug'
|
||||
| '/collection/$slug'
|
||||
@@ -545,6 +581,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AdminIndexRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/audit': {
|
||||
id: '/admin/audit'
|
||||
path: '/audit'
|
||||
fullPath: '/admin/audit'
|
||||
preLoaderRoute: typeof AdminAuditRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/filters': {
|
||||
id: '/admin/filters'
|
||||
path: '/filters'
|
||||
@@ -573,6 +616,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AdminMediaRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/menus': {
|
||||
id: '/admin/menus'
|
||||
path: '/menus'
|
||||
fullPath: '/admin/menus'
|
||||
preLoaderRoute: typeof AdminMenusRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/seo': {
|
||||
id: '/admin/seo'
|
||||
path: '/seo'
|
||||
@@ -580,6 +630,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AdminSeoRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/settings': {
|
||||
id: '/admin/settings'
|
||||
path: '/settings'
|
||||
fullPath: '/admin/settings'
|
||||
preLoaderRoute: typeof AdminSettingsRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/users': {
|
||||
id: '/admin/users'
|
||||
path: '/users'
|
||||
@@ -696,11 +753,14 @@ declare module '@tanstack/react-router' {
|
||||
}
|
||||
|
||||
interface AdminRouteChildren {
|
||||
AdminAuditRoute: typeof AdminAuditRoute
|
||||
AdminFiltersRoute: typeof AdminFiltersRoute
|
||||
AdminInquiriesRoute: typeof AdminInquiriesRoute
|
||||
AdminLanguagesRoute: typeof AdminLanguagesRoute
|
||||
AdminMediaRoute: typeof AdminMediaRoute
|
||||
AdminMenusRoute: typeof AdminMenusRoute
|
||||
AdminSeoRoute: typeof AdminSeoRoute
|
||||
AdminSettingsRoute: typeof AdminSettingsRoute
|
||||
AdminUsersRoute: typeof AdminUsersRoute
|
||||
AdminIndexRoute: typeof AdminIndexRoute
|
||||
AdminContentsIdRoute: typeof AdminContentsIdRoute
|
||||
@@ -712,11 +772,14 @@ interface AdminRouteChildren {
|
||||
}
|
||||
|
||||
const AdminRouteChildren: AdminRouteChildren = {
|
||||
AdminAuditRoute: AdminAuditRoute,
|
||||
AdminFiltersRoute: AdminFiltersRoute,
|
||||
AdminInquiriesRoute: AdminInquiriesRoute,
|
||||
AdminLanguagesRoute: AdminLanguagesRoute,
|
||||
AdminMediaRoute: AdminMediaRoute,
|
||||
AdminMenusRoute: AdminMenusRoute,
|
||||
AdminSeoRoute: AdminSeoRoute,
|
||||
AdminSettingsRoute: AdminSettingsRoute,
|
||||
AdminUsersRoute: AdminUsersRoute,
|
||||
AdminIndexRoute: AdminIndexRoute,
|
||||
AdminContentsIdRoute: AdminContentsIdRoute,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* # 路由 /admin/audit 审计日志
|
||||
* 后台全部写操作留痕(audit_logs):操作人 / 动作 / 实体 / 时间。
|
||||
* 支持按动作名过滤与分页;meta 为 JSON 摘要。
|
||||
*/
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Card, Input, Table, Typography } from "antd";
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import { useState } from "react";
|
||||
import { adminListAuditLogs } from "~/server/admin";
|
||||
import { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common";
|
||||
|
||||
export const Route = createFileRoute("/admin/audit")({
|
||||
validateSearch: (search: Record<string, unknown>): AdminSearch => {
|
||||
const out: AdminSearch = {};
|
||||
const page = Math.floor(Number(search.page)) || 0;
|
||||
if (page > 1) out.page = page;
|
||||
if (typeof search.q === "string" && search.q) out.q = search.q;
|
||||
return out;
|
||||
},
|
||||
loaderDeps: ({ search }) => ({ search }),
|
||||
loader: ({ deps }) => guardAdmin(() => adminListAuditLogs({ data: { page: deps.search.page, action: deps.search.q || undefined } })),
|
||||
component: AuditPage,
|
||||
});
|
||||
|
||||
/** # 函数 审计日志页面组件 */
|
||||
function AuditPage() {
|
||||
const data = Route.useLoaderData();
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
const [q, setQ] = useState(search.q ?? "");
|
||||
|
||||
if (!data) return <Forbidden />;
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<PageHeader
|
||||
title="审计日志"
|
||||
extra={
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="按动作过滤(如 content.save)"
|
||||
prefix={<SearchOutlined aria-hidden />}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onPressEnter={() => void navigate({ search: (prev) => ({ ...prev, q, page: 1 }) })}
|
||||
style={{ width: 280 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={data.items}
|
||||
locale={{ emptyText: <AdminEmpty /> }}
|
||||
pagination={tablePagination(data.total, data.page, data.pageSize, (p) =>
|
||||
void navigate({ search: (prev) => ({ ...prev, page: p }) }),
|
||||
)}
|
||||
columns={[
|
||||
{ title: "ID", dataIndex: "id", width: 70 },
|
||||
{ title: "操作人", dataIndex: "actorEmail", width: 220, ellipsis: true, render: (v: string | null) => v ?? `#${""}` },
|
||||
{ title: "动作", dataIndex: "action", width: 200, render: (v: string) => <code style={{ fontSize: 12 }}>{v}</code> },
|
||||
{ title: "实体", dataIndex: "entity", width: 110 },
|
||||
{ title: "实体 ID", dataIndex: "entityId", width: 90 },
|
||||
{
|
||||
title: "摘要",
|
||||
dataIndex: "meta",
|
||||
ellipsis: true,
|
||||
render: (v: unknown) => (
|
||||
<Typography.Text code style={{ fontSize: 12 }}>
|
||||
{Object.keys(v ?? {}).length ? JSON.stringify(v) : "-"}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{ title: "时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
/**
|
||||
* # 路由 /admin/contents/$id 内容编辑页
|
||||
* id 为 new 时新建;否则加载正本(默认语言)编辑。HTML 使用 TextArea 明文编辑。
|
||||
* # 路由 /admin/contents/$id 内容编辑页(自定义单页面/文章/落地页)
|
||||
* id 为 new 时新建;否则加载正本编辑。支持:
|
||||
* - 分类/标签绑定(content_terms,对应 WP 分类目录与标签)
|
||||
* - Elementor JSON 编辑(meta.elementor,前台 catch-all 用 ElementorRenderer 渲染)
|
||||
* - 修订历史弹窗(保存自动留档,可查看/恢复)
|
||||
* - 已发布内容直达前台链接(预览)
|
||||
*/
|
||||
import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { App, Button, Card, Form, Input, Select, Space } from "antd";
|
||||
import { ArrowLeftOutlined, SaveOutlined } from "@ant-design/icons";
|
||||
import { adminGetContent, adminSaveContent } from "~/server/admin";
|
||||
import { CONTENT_STATUS_OPTIONS, Forbidden, guardAdmin } from "~/components/admin/common";
|
||||
import { App, Button, Card, Form, Input, Modal, Select, Space, Table, TreeSelect } from "antd";
|
||||
import { ArrowLeft, ExternalLink, History, Save } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { adminGetContent, adminGetRevision, adminListRevisions, adminRestoreRevision, adminSaveContent } from "~/server/admin";
|
||||
import { CONTENT_STATUS_OPTIONS, Forbidden, fmtDateTime, guardAdmin } from "~/components/admin/common";
|
||||
|
||||
/** # 类型 编辑表单数据形状(新建时给默认值) **/
|
||||
type ContentForm = {
|
||||
@@ -20,15 +25,25 @@ type ContentForm = {
|
||||
template: string;
|
||||
seoTitle: string;
|
||||
seoDescription: string;
|
||||
elementorJson: string;
|
||||
};
|
||||
|
||||
/** # 类型 修订列表行(弹窗表格) **/
|
||||
type RevisionRow = { id: number; title: string; authorId: number | null; createdAt: string | Date };
|
||||
|
||||
/** # 类型 分类/标签选项(编辑页回显用) **/
|
||||
type TermOption = { id: number; name: string; taxonomy: string };
|
||||
|
||||
/** # 类型 编辑页 loader 数据(新建默认值或正本 + 术语选项) **/
|
||||
type EditorData = (ContentForm & { selectedCategoryIds: number[]; termOptions: TermOption[] }) | null;
|
||||
|
||||
export const Route = createFileRoute("/admin/contents/$id")({
|
||||
loader: ({ params }) =>
|
||||
guardAdmin(async () => {
|
||||
guardAdmin(async (): Promise<EditorData> => {
|
||||
if (params.id === "new") {
|
||||
// 权限探测 + 返回新建默认值
|
||||
await adminGetContent({ data: { id: 0 } });
|
||||
const fresh: ContentForm = {
|
||||
return {
|
||||
id: 0,
|
||||
type: "page",
|
||||
slug: "",
|
||||
@@ -39,12 +54,15 @@ export const Route = createFileRoute("/admin/contents/$id")({
|
||||
template: "default",
|
||||
seoTitle: "",
|
||||
seoDescription: "",
|
||||
elementorJson: "",
|
||||
selectedCategoryIds: [],
|
||||
termOptions: [],
|
||||
};
|
||||
return fresh;
|
||||
}
|
||||
const c = await adminGetContent({ data: { id: Number(params.id) } });
|
||||
if (!c) return null;
|
||||
const form: ContentForm = {
|
||||
const meta = (c.meta ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
id: c.id,
|
||||
type: c.type,
|
||||
slug: c.slug,
|
||||
@@ -55,8 +73,10 @@ export const Route = createFileRoute("/admin/contents/$id")({
|
||||
template: c.template,
|
||||
seoTitle: c.seoTitle ?? "",
|
||||
seoDescription: c.seoDescription ?? "",
|
||||
elementorJson: meta.elementor ? JSON.stringify(meta.elementor, null, 2) : "",
|
||||
selectedCategoryIds: (c as { selectedCategoryIds?: number[] }).selectedCategoryIds ?? [],
|
||||
termOptions: (c as { termOptions?: TermOption[] }).termOptions ?? [],
|
||||
};
|
||||
return form;
|
||||
}),
|
||||
component: ContentEditPage,
|
||||
});
|
||||
@@ -66,8 +86,31 @@ function ContentEditPage() {
|
||||
const data = Route.useLoaderData();
|
||||
const navigate = useNavigate();
|
||||
const router = useRouter();
|
||||
const { message } = App.useApp();
|
||||
const { message, modal } = App.useApp();
|
||||
const [form] = Form.useForm();
|
||||
/** # 成员变量 修订历史弹窗开关与数据 **/
|
||||
const [revOpen, setRevOpen] = useState(false);
|
||||
const [revisions, setRevisions] = useState<RevisionRow[]>([]);
|
||||
/** # 成员变量 已选分类/标签(仅编辑态展示,新建暂不关联) **/
|
||||
const [selectedTermIds, setSelectedTermIds] = useState<number[]>(data?.selectedCategoryIds ?? []);
|
||||
|
||||
// 表单初值与术语回显随 loader 数据同步(hooks 须在 early return 之前调用)
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
setSelectedTermIds(data.selectedCategoryIds ?? []);
|
||||
form.setFieldsValue({
|
||||
title: data.title,
|
||||
slug: data.slug,
|
||||
type: data.type,
|
||||
status: data.status,
|
||||
template: data.template,
|
||||
excerpt: data.excerpt,
|
||||
html: data.html,
|
||||
seoTitle: data.seoTitle,
|
||||
seoDescription: data.seoDescription,
|
||||
elementorJson: data.elementorJson,
|
||||
});
|
||||
}, [data, form]);
|
||||
|
||||
if (!data) return <Forbidden />;
|
||||
// 收窄后的非空引用(闭包内使用)
|
||||
@@ -76,8 +119,14 @@ function ContentEditPage() {
|
||||
/** # 函数 保存内容:提交到服务端 → 提示 → 返回列表 */
|
||||
async function handleSave(values: Omit<ContentForm, "id">) {
|
||||
try {
|
||||
await adminSaveContent({ data: { ...values, id: content.id || undefined } });
|
||||
message.success("保存成功");
|
||||
await adminSaveContent({
|
||||
data: {
|
||||
...values,
|
||||
id: content.id || undefined,
|
||||
categoryIds: selectedTermIds,
|
||||
},
|
||||
});
|
||||
message.success("保存成功(已留修订快照)");
|
||||
await router.invalidate();
|
||||
void navigate({ to: "/admin/contents/" });
|
||||
} catch (e) {
|
||||
@@ -85,33 +134,90 @@ function ContentEditPage() {
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数 打开修订历史弹窗并加载列表 */
|
||||
async function openRevisions() {
|
||||
const rows = await adminListRevisions({ data: { contentId: content.id } });
|
||||
setRevisions(rows as RevisionRow[]);
|
||||
setRevOpen(true);
|
||||
}
|
||||
|
||||
/** # 函数 查看修订正文并确认恢复 */
|
||||
async function viewRevision(row: RevisionRow) {
|
||||
const rev = await adminGetRevision({ data: { id: row.id } });
|
||||
if (!rev) return;
|
||||
modal.confirm({
|
||||
title: `恢复修订 #${row.id}(${fmtDateTime(row.createdAt)})`,
|
||||
width: 760,
|
||||
content: (
|
||||
<div style={{ maxHeight: 420, overflow: "auto" }}>
|
||||
<p style={{ fontWeight: 600 }}>{rev.title}</p>
|
||||
<pre style={{ whiteSpace: "pre-wrap", fontSize: 12, maxHeight: 340, overflow: "auto", background: "#fafafa", padding: 8 }}>
|
||||
{rev.html.slice(0, 5000) || "(空正文)"}
|
||||
{rev.html.length > 5000 ? "\n…(截断预览)" : ""}
|
||||
</pre>
|
||||
</div>
|
||||
),
|
||||
okText: "恢复此版本",
|
||||
cancelText: "取消",
|
||||
onOk: async () => {
|
||||
await adminRestoreRevision({ data: { id: row.id } });
|
||||
message.success("已恢复(恢复前版本已再留档)");
|
||||
setRevOpen(false);
|
||||
await router.invalidate();
|
||||
void navigate({ to: "/admin/contents/$id/", params: { id: String(content.id) } }).then(() => window.location.reload());
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** # 成员变量 分类/标签树数据源(taxonomy 分组) **/
|
||||
const termTreeData = [
|
||||
{
|
||||
title: "分类目录",
|
||||
value: "__cat__",
|
||||
selectable: false,
|
||||
children: content.termOptions.filter((t) => t.taxonomy === "category").map((t) => ({ title: t.name, value: t.id })),
|
||||
},
|
||||
{
|
||||
title: "标签",
|
||||
value: "__tag__",
|
||||
selectable: false,
|
||||
children: content.termOptions.filter((t) => t.taxonomy === "post_tag").map((t) => ({ title: t.name, value: t.id })),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button icon={<ArrowLeftOutlined aria-hidden />} onClick={() => void navigate({ to: "/admin/contents/" })}>
|
||||
<Button icon={<ArrowLeft size={14} aria-hidden />} onClick={() => void navigate({ to: "/admin/contents/" })}>
|
||||
返回列表
|
||||
</Button>
|
||||
<h2 style={{ margin: 0, fontSize: 18 }}>{content.id ? `编辑内容 #${content.id}` : "新建内容"}</h2>
|
||||
{content.id ? (
|
||||
<>
|
||||
<Button icon={<History size={14} aria-hidden />} onClick={() => void openRevisions()}>
|
||||
修订历史
|
||||
</Button>
|
||||
{content.status === "published" && content.slug ? (
|
||||
<Button icon={<ExternalLink size={14} aria-hidden />} onClick={() => window.open(`/${content.slug}/`, "_blank")}>
|
||||
前台预览
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</Space>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={content}
|
||||
style={{ maxWidth: 860 }}
|
||||
onFinish={(v) => void handleSave(v)}
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ maxWidth: 960 }} onFinish={(v) => void handleSave(v as Omit<ContentForm, "id">)}>
|
||||
<Space size={16} style={{ display: "flex" }} align="start">
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]} style={{ minWidth: 420, flex: 1 }}>
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]} style={{ minWidth: 380, flex: 1 }}>
|
||||
<Input placeholder="内容标题" />
|
||||
</Form.Item>
|
||||
<Form.Item name="slug" label="别名(留空自动生成)">
|
||||
<Input placeholder="url-slug" style={{ width: 260 }} />
|
||||
<Form.Item name="slug" label="别名(留空自动生成;即前台 URL /slug/)">
|
||||
<Input placeholder="url-slug" style={{ width: 280 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space size={16} style={{ display: "flex" }}>
|
||||
<Form.Item name="type" label="类型">
|
||||
<Select
|
||||
style={{ width: 140 }}
|
||||
style={{ width: 150 }}
|
||||
options={[
|
||||
{ value: "page", label: "页面 page" },
|
||||
{ value: "post", label: "文章 post" },
|
||||
@@ -123,25 +229,84 @@ function ContentEditPage() {
|
||||
<Select style={{ width: 140 }} options={CONTENT_STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="template" label="模板">
|
||||
<Input style={{ width: 200 }} placeholder="default / home / product …" />
|
||||
<Input style={{ width: 200 }} placeholder="default / home / blank …" />
|
||||
</Form.Item>
|
||||
{content.id ? (
|
||||
<Form.Item label="分类 / 标签">
|
||||
<TreeSelect
|
||||
treeData={termTreeData}
|
||||
value={selectedTermIds}
|
||||
onChange={(v) => setSelectedTermIds((v as number[]) ?? [])}
|
||||
treeCheckable
|
||||
showCheckedStrategy={TreeSelect.SHOW_CHILD}
|
||||
maxTagCount={4}
|
||||
placeholder="选择分类与标签"
|
||||
style={{ width: 320 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
</Space>
|
||||
<Form.Item name="excerpt" label="摘要">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="html" label="正文 HTML">
|
||||
<Input.TextArea rows={18} style={{ fontFamily: "monospace", fontSize: 12 }} />
|
||||
<Form.Item
|
||||
name="html"
|
||||
label="正文 HTML(无 Elementor JSON 时前台按此渲染)"
|
||||
tooltip="自定义单页面推荐:留空 HTML、填写 Elementor JSON,前台通栏渲染与原站一致"
|
||||
>
|
||||
<Input.TextArea rows={12} style={{ fontFamily: "monospace", fontSize: 12 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="seoTitle" label="SEO 标题">
|
||||
<Input />
|
||||
<Form.Item
|
||||
name="elementorJson"
|
||||
label="Elementor JSON(meta.elementor,优先于 HTML 渲染)"
|
||||
tooltip='结构:{ "elements": [ ... ] } 或元素数组;可从原站 _elementor_data 复制'
|
||||
>
|
||||
<Input.TextArea rows={10} style={{ fontFamily: "monospace", fontSize: 12 }} placeholder='{"elements": [...]}' />
|
||||
</Form.Item>
|
||||
<Space size={16} style={{ display: "flex" }}>
|
||||
<Form.Item name="seoTitle" label="SEO 标题" style={{ minWidth: 420, flex: 1 }}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="seoDescription" label="SEO 描述">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SaveOutlined aria-hidden />}>
|
||||
<Button type="primary" htmlType="submit" icon={<Save size={14} aria-hidden />}>
|
||||
保存
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<Modal
|
||||
title="修订历史(每次保存自动留档)"
|
||||
open={revOpen}
|
||||
onCancel={() => setRevOpen(false)}
|
||||
footer={null}
|
||||
width={720}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={revisions}
|
||||
pagination={{ pageSize: 8 }}
|
||||
locale={{ emptyText: "暂无修订" }}
|
||||
columns={[
|
||||
{ title: "修订 ID", dataIndex: "id", width: 90 },
|
||||
{ title: "标题快照", dataIndex: "title", ellipsis: true },
|
||||
{ title: "修订人", dataIndex: "authorId", width: 90 },
|
||||
{ title: "时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||
{
|
||||
title: "操作",
|
||||
key: "act",
|
||||
width: 100,
|
||||
render: (_, r) => (
|
||||
<Button size="small" onClick={() => void viewRevision(r)}>
|
||||
查看 / 恢复
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
+198
-58
@@ -1,12 +1,34 @@
|
||||
/**
|
||||
* # 路由 /admin/ 后台首页(Dashboard)
|
||||
* 统计卡片 + 最近 7 天询盘趋势(纯 div 柱图)+ 最新 10 条询盘。
|
||||
* # 路由 /admin/ 后台首页(现代化 CRM 看板)
|
||||
* KPI 卡(含 30 天环比)→ 30 天订单/询盘双趋势图 → 待办与询盘漏斗 →
|
||||
* 最近订单 / 最新询盘并排 + 快捷入口。
|
||||
*/
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Card, Col, Row, Statistic, Table, Tag } from "antd";
|
||||
import { FileText, Inbox, Package, ShoppingCart, TimerReset, Image as ImageIcon } from "lucide-react";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { Card, Col, Progress, Row, Space, Statistic, Table, Tag } from "antd";
|
||||
import {
|
||||
ArrowDownRight,
|
||||
ArrowUpRight,
|
||||
ClipboardList,
|
||||
DollarSign,
|
||||
FileText,
|
||||
Inbox,
|
||||
MessageSquarePlus,
|
||||
Package,
|
||||
PackagePlus,
|
||||
ShoppingCart,
|
||||
TimerReset,
|
||||
} from "lucide-react";
|
||||
import { adminStats } from "~/server/admin";
|
||||
import { AdminEmpty, Forbidden, INQUIRY_STATUS_COLOR, INQUIRY_STATUS_LABEL, guardAdmin, fmtDateTime } from "~/components/admin/common";
|
||||
import {
|
||||
AdminEmpty,
|
||||
Forbidden,
|
||||
INQUIRY_STATUS_COLOR,
|
||||
INQUIRY_STATUS_LABEL,
|
||||
ORDER_STATUS_COLOR,
|
||||
ORDER_STATUS_LABEL,
|
||||
fmtDateTime,
|
||||
guardAdmin,
|
||||
} from "~/components/admin/common";
|
||||
|
||||
export const Route = createFileRoute("/admin/")({
|
||||
loader: () => guardAdmin(() => adminStats()),
|
||||
@@ -14,75 +36,193 @@ export const Route = createFileRoute("/admin/")({
|
||||
});
|
||||
|
||||
/** # 函数 Dashboard 页面组件
|
||||
* 渲染统计卡片、询盘趋势柱图与最新询盘表;未登录时展示占位。
|
||||
* 数据全部来自 adminStats(30 天趋势、待办、状态分布、最近订单/询盘)。
|
||||
*/
|
||||
function DashboardPage() {
|
||||
const stats = Route.useLoaderData();
|
||||
if (!stats) return <Forbidden />;
|
||||
const { counts, trend, recentInquiries } = stats;
|
||||
const max = Math.max(1, ...trend.map((t) => t.count));
|
||||
const { counts, trend, trend30, inquiryStatus, revenue, recentInquiries, recentOrders } = stats;
|
||||
const maxBar = Math.max(1, ...trend.map((t) => t.count));
|
||||
const max30 = Math.max(1, ...trend30.map((t) => Math.max(t.orders, t.inquiries)));
|
||||
const maxRevenue = Math.max(1, ...trend30.map((t) => t.revenue));
|
||||
// 30 天环比:后 15 天 vs 前 15 天
|
||||
const half = 15;
|
||||
const sum = (xs: number[]) => xs.reduce((a, b) => a + b, 0);
|
||||
const delta = (curr: number, prev: number) => (prev === 0 ? (curr > 0 ? 1 : 0) : (curr - prev) / prev);
|
||||
const ordNow = sum(trend30.slice(half).map((t) => t.orders));
|
||||
const ordPrev = sum(trend30.slice(0, half).map((t) => t.orders));
|
||||
const inqNow = sum(trend30.slice(half).map((t) => t.inquiries));
|
||||
const inqPrev = sum(trend30.slice(0, half).map((t) => t.inquiries));
|
||||
const revNow = sum(trend30.slice(half).map((t) => t.revenue));
|
||||
const revPrev = sum(trend30.slice(0, half).map((t) => t.revenue));
|
||||
const newInq = inquiryStatus.find((s) => s.status === "new")?.count ?? 0;
|
||||
const spamInq = inquiryStatus.find((s) => s.status === "spam")?.count ?? 0;
|
||||
const totalInq = inquiryStatus.reduce((a, b) => a + b.count, 0) || 1;
|
||||
|
||||
/** # 成员变量 统计卡片配置(图标 + 标题 + 数值) **/
|
||||
/** # 成员变量 KPI 卡配置(图标 + 标题 + 数值 + 环比) **/
|
||||
const cards = [
|
||||
{ title: "内容", value: counts.contents, icon: <FileText size={20} aria-hidden color="#1677ff" /> },
|
||||
{ title: "产品", value: counts.products, icon: <Package size={20} aria-hidden color="#52c41a" /> },
|
||||
{ title: "订单", value: counts.orders, icon: <ShoppingCart size={20} aria-hidden color="#faad14" /> },
|
||||
{ title: "询盘", value: counts.inquiries, icon: <Inbox size={20} aria-hidden color="#722ed1" /> },
|
||||
{ title: "媒体", value: counts.media, icon: <ImageIcon size={20} aria-hidden color="#13c2c2" /> },
|
||||
{ title: "待处理订单", value: counts.pendingOrders, icon: <TimerReset size={20} aria-hidden color="#fa541c" /> },
|
||||
{ title: "订单(30 天环比)", value: ordNow, delta: delta(ordNow, ordPrev), icon: <ShoppingCart size={18} aria-hidden color="#faad14" />, href: "/admin/orders/" },
|
||||
{ title: "营收(30 天环比)", value: revNow, delta: delta(revNow, revPrev), icon: <DollarSign size={18} aria-hidden color="#52c41a" />, href: "/admin/orders/", money: true },
|
||||
{ title: "新询盘(待跟进)", value: newInq, delta: delta(inqNow, inqPrev), icon: <Inbox size={18} aria-hidden color="#722ed1" />, href: "/admin/inquiries?status=new" },
|
||||
{ title: "待处理订单", value: counts.pendingOrders, icon: <TimerReset size={18} aria-hidden color="#fa541c" />, href: "/admin/orders/" },
|
||||
{ title: "产品", value: counts.products, icon: <Package size={18} aria-hidden color="#13c2c2" />, href: "/admin/products/" },
|
||||
{ title: "页面 / 文章", value: counts.contents, icon: <FileText size={18} aria-hidden color="#1677ff" />, href: "/admin/contents/" },
|
||||
];
|
||||
|
||||
/** # 函数 环比箭头与配色 */
|
||||
function DeltaTag({ d }: { d: number }) {
|
||||
const up = d >= 0;
|
||||
return (
|
||||
<span style={{ fontSize: 12, color: up ? "#389e0d" : "#cf1322", display: "inline-flex", alignItems: "center", gap: 2 }}>
|
||||
{up ? <ArrowUpRight size={12} aria-hidden /> : <ArrowDownRight size={12} aria-hidden />}
|
||||
{`${(Math.abs(d) * 100).toFixed(0)}%`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={[16, 16]}>
|
||||
{/* KPI 卡 */}
|
||||
<Row gutter={[12, 12]}>
|
||||
{cards.map((c) => (
|
||||
<Col key={c.title} xs={12} sm={8} lg={4}>
|
||||
<Card size="small">
|
||||
<Statistic title={c.title} value={c.value} prefix={c.icon} />
|
||||
</Card>
|
||||
<Col key={c.title} xs={12} sm={8} xl={4}>
|
||||
<a href={c.href} style={{ display: "block" }}>
|
||||
<Card size="small" hoverable style={{ height: "100%" }}>
|
||||
<Space size={10} align="start">
|
||||
<div style={{ width: 34, height: 34, borderRadius: 8, background: "#f5f7fa", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
{c.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: "#8c8c8c" }}>{c.title}</div>
|
||||
<Space size={8}>
|
||||
<span style={{ fontSize: 22, fontWeight: 600, lineHeight: 1.2 }}>
|
||||
{c.money ? `$${Math.round(c.value).toLocaleString()}` : c.value.toLocaleString()}
|
||||
</span>
|
||||
{c.delta !== undefined ? <DeltaTag d={c.delta} /> : null}
|
||||
</Space>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</a>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Card title="最近 7 天询盘趋势" size="small" style={{ marginTop: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 16, height: 160, padding: "0 8px" }}>
|
||||
{trend.map((t) => (
|
||||
<div key={t.day} style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 4, height: "100%", justifyContent: "flex-end" }}>
|
||||
<span style={{ fontSize: 12, color: "#666" }}>{t.count}</span>
|
||||
<div
|
||||
role="img"
|
||||
aria-label={`${t.day} 询盘 ${t.count} 条`}
|
||||
style={{ width: "60%", maxWidth: 48, height: `${Math.round((t.count / max) * 100)}%`, minHeight: 4, background: "#1677ff", borderRadius: 4, opacity: 0.85 }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: "#999" }}>{t.day.slice(5)}</span>
|
||||
{/* 30 天双趋势 + 询盘漏斗 */}
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col xs={24} xl={16}>
|
||||
<Card
|
||||
title="近 30 天订单 / 询盘趋势"
|
||||
size="small"
|
||||
extra={
|
||||
<Space size={16} style={{ fontSize: 12, color: "#666" }}>
|
||||
<span><span style={{ display: "inline-block", width: 10, height: 10, background: "#1677ff", borderRadius: 2, marginRight: 4 }} />订单</span>
|
||||
<span><span style={{ display: "inline-block", width: 10, height: 10, background: "#722ed1", borderRadius: 2, marginRight: 4 }} />询盘</span>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{/* 双列柱图(纯 div,无图表库依赖) */}
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 3, height: 180 }}>
|
||||
{trend30.map((t) => (
|
||||
<div key={t.day} title={`${t.day}\n订单 ${t.orders}($${Math.round(t.revenue)})\n询盘 ${t.inquiries}`} style={{ flex: 1, height: "100%", display: "flex", alignItems: "flex-end", justifyContent: "center", gap: 1 }}>
|
||||
<div style={{ width: "46%", height: `${(t.orders / max30) * 100}%`, minHeight: t.orders ? 3 : 0, background: "#1677ff", borderRadius: "2px 2px 0 0" }} />
|
||||
<div style={{ width: "46%", height: `${(t.inquiries / max30) * 100}%`, minHeight: t.inquiries ? 3 : 0, background: "#722ed1", borderRadius: "2px 2px 0 0", opacity: 0.75 }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 11, color: "#999", marginTop: 6 }}>
|
||||
<span>{trend30[0]?.day}</span>
|
||||
<span>{trend30[Math.floor(trend30.length / 2)]?.day}</span>
|
||||
<span>{trend30[trend30.length - 1]?.day}</span>
|
||||
</div>
|
||||
{/* 营收迷你面积条 */}
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 3, height: 44, marginTop: 10 }} title="每日营收($)">
|
||||
{trend30.map((t) => (
|
||||
<div key={t.day} style={{ flex: 1, height: `${Math.max(t.revenue ? 8 : 0, (t.revenue / maxRevenue) * 100)}%`, background: "#b7eb8f", borderRadius: "2px 2px 0 0" }} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} xl={8}>
|
||||
<Card title="询盘漏斗" size="small" style={{ height: "100%" }}>
|
||||
<Space direction="vertical" size={12} style={{ display: "flex" }}>
|
||||
{(["new", "read", "replied", "spam", "archived"] as const).map((st) => {
|
||||
const n = inquiryStatus.find((s) => s.status === st)?.count ?? 0;
|
||||
return (
|
||||
<div key={st}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 13, marginBottom: 4 }}>
|
||||
<span>{INQUIRY_STATUS_LABEL[st] ?? st}</span>
|
||||
<span style={{ fontWeight: 600 }}>{n.toLocaleString()}</span>
|
||||
</div>
|
||||
<Progress percent={Math.round((n / totalInq) * 100)} showInfo={false} strokeColor={INQUIRY_STATUS_COLOR[st] === "red" ? "#ff4d4f" : st === "new" ? "#1677ff" : "#95de64"} size="small" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div style={{ fontSize: 12, color: "#999" }}>
|
||||
共 {totalInq.toLocaleString()} 条,其中垃圾 {spamInq.toLocaleString()} 条({(Math.round((spamInq / totalInq) * 100))}%)
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="最新询盘" size="small" style={{ marginTop: 16 }}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={recentInquiries}
|
||||
pagination={false}
|
||||
locale={{ emptyText: <AdminEmpty /> }}
|
||||
columns={[
|
||||
{ title: "ID", dataIndex: "id", width: 70 },
|
||||
{ title: "姓名", dataIndex: "name", width: 140 },
|
||||
{ title: "邮箱", dataIndex: "email", width: 220 },
|
||||
{ title: "公司", dataIndex: "company", width: 160, ellipsis: true },
|
||||
{ title: "国家", dataIndex: "country", width: 120 },
|
||||
{ title: "感兴趣产品", dataIndex: "productInterest", ellipsis: true },
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
width: 100,
|
||||
render: (v: string) => <Tag color={INQUIRY_STATUS_COLOR[v] ?? "default"}>{INQUIRY_STATUS_LABEL[v] ?? v}</Tag>,
|
||||
},
|
||||
{ title: "时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||
]}
|
||||
/>
|
||||
{/* 最近订单 / 最新询盘并排 */}
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col xs={24} xl={12}>
|
||||
<Card
|
||||
title="最近订单"
|
||||
size="small"
|
||||
extra={<Link to="/admin/orders/">全部订单 →</Link>}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={recentOrders}
|
||||
pagination={false}
|
||||
locale={{ emptyText: <AdminEmpty /> }}
|
||||
columns={[
|
||||
{ title: "单号", dataIndex: "orderNumber", width: 130, render: (v: string, r) => <Link to="/admin/orders/$id/" params={{ id: String(r.id) }}>{v}</Link> },
|
||||
{ title: "客户", dataIndex: "customerName", ellipsis: true },
|
||||
{ title: "金额", dataIndex: "total", width: 100, render: (v: string, r) => `${r.currency} ${v}` },
|
||||
{ title: "状态", dataIndex: "status", width: 90, render: (v: string) => <Tag color={ORDER_STATUS_COLOR[v] ?? "default"}>{ORDER_STATUS_LABEL[v] ?? v}</Tag> },
|
||||
{ title: "时间", dataIndex: "createdAt", width: 150, render: (v: string) => fmtDateTime(v) },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} xl={12}>
|
||||
<Card
|
||||
title="最新询盘"
|
||||
size="small"
|
||||
extra={<Link to="/admin/inquiries/">全部询盘 →</Link>}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={recentInquiries}
|
||||
pagination={false}
|
||||
locale={{ emptyText: <AdminEmpty /> }}
|
||||
columns={[
|
||||
{ title: "姓名", dataIndex: "name", width: 120, ellipsis: true },
|
||||
{ title: "邮箱", dataIndex: "email", width: 200, ellipsis: true },
|
||||
{ title: "国家", dataIndex: "country", width: 100 },
|
||||
{ title: "状态", dataIndex: "status", width: 90, render: (v: string) => <Tag color={INQUIRY_STATUS_COLOR[v] ?? "default"}>{INQUIRY_STATUS_LABEL[v] ?? v}</Tag> },
|
||||
{ title: "时间", dataIndex: "createdAt", width: 150, render: (v: string) => fmtDateTime(v) },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 快捷入口 */}
|
||||
<Card title="快捷操作" size="small" style={{ marginTop: 16 }}>
|
||||
<Space size={12} wrap>
|
||||
<Link to="/admin/contents/$id/" params={{ id: "new" }}><Tag style={{ padding: "6px 12px", fontSize: 13 }}><FileText size={13} aria-hidden style={{ verticalAlign: -2, marginRight: 6 }} />新建页面</Tag></Link>
|
||||
<Link to="/admin/products/"><Tag style={{ padding: "6px 12px", fontSize: 13 }}><PackagePlus size={13} aria-hidden style={{ verticalAlign: -2, marginRight: 6 }} />产品管理</Tag></Link>
|
||||
<Link to="/admin/media/"><Tag style={{ padding: "6px 12px", fontSize: 13 }}><ClipboardList size={13} aria-hidden style={{ verticalAlign: -2, marginRight: 6 }} />媒体库</Tag></Link>
|
||||
<Link to="/admin/inquiries/" search={{ status: "new" }}><Tag style={{ padding: "6px 12px", fontSize: 13 }}><MessageSquarePlus size={13} aria-hidden style={{ verticalAlign: -2, marginRight: 6 }} />跟进新询盘</Tag></Link>
|
||||
<Link to="/admin/menus/"><Tag style={{ padding: "6px 12px", fontSize: 13 }}><ClipboardList size={13} aria-hidden style={{ verticalAlign: -2, marginRight: 6 }} />菜单管理</Tag></Link>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
+40
-12
@@ -3,11 +3,11 @@
|
||||
* 表格 + 缩略图预览;编辑替代文本(alt)与标题。
|
||||
*/
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||
import { App, Button, Card, Form, Image, Input, Modal, Space, Table, Typography } from "antd";
|
||||
import { EditOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { App, Button, Card, Form, Image, Input, Modal, Space, Table, Typography, Upload } from "antd";
|
||||
import { EditOutlined, SearchOutlined, UploadOutlined } from "@ant-design/icons";
|
||||
import { File as FileIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { adminListMedia, adminUpdateMedia } from "~/server/admin";
|
||||
import { adminListMedia, adminUpdateMedia, adminUploadMedia } from "~/server/admin";
|
||||
import { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common";
|
||||
import type { media } from "~/db/schema";
|
||||
|
||||
@@ -74,15 +74,43 @@ function MediaPage() {
|
||||
<PageHeader
|
||||
title="媒体库"
|
||||
extra={
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="搜索文件路径"
|
||||
prefix={<SearchOutlined aria-hidden />}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onPressEnter={() => updateSearch({ q })}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
<Space>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="搜索文件路径"
|
||||
prefix={<SearchOutlined aria-hidden />}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onPressEnter={() => updateSearch({ q })}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
{/* 上传走 base64 JSON(server fn RPC),单文件 ≤20MB;成功后刷新列表 */}
|
||||
<Upload
|
||||
multiple
|
||||
showUploadList={false}
|
||||
customRequest={async ({ file, onSuccess, onError }) => {
|
||||
try {
|
||||
const f = file as File;
|
||||
const buf = new Uint8Array(await f.arrayBuffer());
|
||||
// 分块 btoa:直接 btoa(二进制串) 在大文件下栈溢出,按 0x8000 分块
|
||||
let bin = "";
|
||||
const CHUNK = 0x8000;
|
||||
for (let i = 0; i < buf.length; i += CHUNK) bin += String.fromCharCode(...buf.subarray(i, i + CHUNK));
|
||||
const res = await adminUploadMedia({ data: { filename: f.name, mime: f.type || "", dataBase64: btoa(bin) } });
|
||||
message.success(`已上传 ${f.name}(#${res.id})`);
|
||||
onSuccess?.(res);
|
||||
await router.invalidate();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : "上传失败");
|
||||
onError?.(e as Error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type="primary" icon={<UploadOutlined aria-hidden />}>
|
||||
上传媒体
|
||||
</Button>
|
||||
</Upload>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
<Table
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* # 路由 /admin/menus 菜单管理
|
||||
* 左侧菜单选择(menus 表 4 个位置)+ 右侧菜单项表格(menu_items 树平铺展示)。
|
||||
* 支持新增/编辑/删除菜单项;删除时子项提升为顶层(与 server 端逻辑一致)。
|
||||
*/
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||
import { App, Button, Card, Form, Input, InputNumber, Modal, Select, Space, Table, TreeSelect } from "antd";
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { adminDeleteMenuItem, adminListMenus, adminSaveMenuItem } from "~/server/admin";
|
||||
import { AdminEmpty, Forbidden, PageHeader, guardAdmin } from "~/components/admin/common";
|
||||
|
||||
export const Route = createFileRoute("/admin/menus")({
|
||||
loader: () => guardAdmin(() => adminListMenus()),
|
||||
component: MenusPage,
|
||||
});
|
||||
|
||||
/** # 类型 菜单定义行 **/
|
||||
type MenuDef = { id: number; name: string; location: string };
|
||||
/** # 类型 菜单项行(平铺) **/
|
||||
type MenuItemRow = { id: number; menuId: number; parentId: number | null; title: string; url: string; target: string; sort: number };
|
||||
|
||||
/** # 函数 菜单管理页面组件 */
|
||||
function MenusPage() {
|
||||
const data = Route.useLoaderData();
|
||||
const router = useRouter();
|
||||
const { message } = App.useApp();
|
||||
/** # 成员变量 当前选中菜单 ID(默认第一个) **/
|
||||
const [menuId, setMenuId] = useState<number | null>(null);
|
||||
/** # 成员变量 编辑弹窗(null = 关闭;id=0 新建) **/
|
||||
const [editing, setEditing] = useState<(Partial<MenuItemRow> & { id?: number }) | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// 首次/数据刷新后确定选中菜单
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
if (menuId === null || !data.menus.some((m) => m.id === menuId)) setMenuId(data.menus[0]?.id ?? null);
|
||||
}, [data, menuId]);
|
||||
|
||||
/** # 成员变量 当前菜单的项(树形缩进展示:父在前,子随父缩进) **/
|
||||
const rows = useMemo(() => {
|
||||
if (!data || menuId === null) return [];
|
||||
const items = data.items.filter((i) => i.menuId === menuId);
|
||||
const byId = new Map(items.map((i) => [i.id, { ...i, depth: 0 }]));
|
||||
const out: (MenuItemRow & { depth: number })[] = [];
|
||||
// 顶层(无父)按 sort 排序后递归挂子项
|
||||
const roots = items.filter((i) => !i.parentId || !byId.has(i.parentId)).sort((a, b) => a.sort - b.sort || a.id - b.id);
|
||||
const walk = (node: MenuItemRow, depth: number) => {
|
||||
out.push({ ...node, depth });
|
||||
items
|
||||
.filter((i) => i.parentId === node.id)
|
||||
.sort((a, b) => a.sort - b.sort || a.id - b.id)
|
||||
.forEach((c) => walk(c, Math.min(depth + 1, 3)));
|
||||
};
|
||||
roots.forEach((r) => walk(r, 0));
|
||||
return out;
|
||||
}, [data, menuId]);
|
||||
|
||||
if (!data) return <Forbidden />;
|
||||
|
||||
/** # 函数 打开编辑弹窗(item 缺省 = 新建,挂在当前菜单顶层) */
|
||||
function openEdit(item?: MenuItemRow) {
|
||||
setEditing(item ?? { menuId: menuId ?? 0, parentId: null, title: "", url: "", target: "_self", sort: 0 });
|
||||
form.setFieldsValue(item ?? { menuId: menuId ?? 0, parentId: null, title: "", url: "", target: "_self", sort: 0 });
|
||||
}
|
||||
|
||||
/** # 函数 保存菜单项 */
|
||||
async function handleSave(values: { menuId: number; parentId?: number | null; title: string; url: string; target: string; sort: number }) {
|
||||
try {
|
||||
await adminSaveMenuItem({ data: { ...values, parentId: values.parentId ?? null, id: editing?.id || undefined } });
|
||||
message.success("已保存");
|
||||
setEditing(null);
|
||||
await router.invalidate();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : "保存失败");
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数 删除菜单项(子项自动提升顶层) */
|
||||
async function handleDelete(id: number) {
|
||||
await adminDeleteMenuItem({ data: { id } });
|
||||
message.success("已删除(子项已提升为顶层)");
|
||||
await router.invalidate();
|
||||
}
|
||||
|
||||
/** # 成员变量 父级选择树(排除自身防环) **/
|
||||
const parentOptions = [
|
||||
{ title: "(顶层)", value: 0 },
|
||||
...rows.filter((r) => r.id !== editing?.id).map((r) => ({ title: `${"— ".repeat(r.depth)}${r.title}`, value: r.id })),
|
||||
];
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<PageHeader
|
||||
title="菜单管理"
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined aria-hidden />} onClick={() => openEdit()} disabled={menuId === null}>
|
||||
新增菜单项
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Space style={{ marginBottom: 12 }} wrap>
|
||||
<Select
|
||||
style={{ width: 300 }}
|
||||
value={menuId ?? undefined}
|
||||
onChange={(v) => setMenuId(v)}
|
||||
options={data.menus.map((m: MenuDef) => ({ value: m.id, label: `${m.name}(${m.location})` }))}
|
||||
/>
|
||||
<span style={{ color: "#999", fontSize: 12 }}>前台 Header/Footer 即时读取所选位置菜单</span>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={rows}
|
||||
locale={{ emptyText: <AdminEmpty /> }}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
title: "标题",
|
||||
dataIndex: "title",
|
||||
render: (v: string, r) => (
|
||||
<span style={{ paddingLeft: r.depth * 24 }}>
|
||||
{r.depth > 0 ? <span style={{ color: "#bbb", marginRight: 4 }}>└</span> : null}
|
||||
{v}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ title: "链接", dataIndex: "url", ellipsis: true, render: (v: string) => <code style={{ fontSize: 12 }}>{v}</code> },
|
||||
{ title: "排序", dataIndex: "sort", width: 70 },
|
||||
{ title: "打开方式", dataIndex: "target", width: 100 },
|
||||
{
|
||||
title: "操作",
|
||||
key: "act",
|
||||
width: 110,
|
||||
render: (_, r) => (
|
||||
<Space size={4}>
|
||||
<Button size="small" type="text" icon={<EditOutlined aria-label="编辑" />} onClick={() => openEdit(r)} />
|
||||
<Button size="small" danger type="text" icon={<DeleteOutlined aria-label="删除" />} onClick={() => void handleDelete(r.id)} />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing?.id ? `编辑菜单项 #${editing.id}` : "新增菜单项"}
|
||||
open={editing !== null}
|
||||
onCancel={() => setEditing(null)}
|
||||
onOk={() => form.submit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={(v) => void handleSave(v as { menuId: number; parentId?: number | null; title: string; url: string; target: string; sort: number })}>
|
||||
<Form.Item name="title" label="显示文字" rules={[{ required: true, message: "请输入显示文字" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="url" label="链接地址" rules={[{ required: true, message: "请输入链接" }]}>
|
||||
<Input placeholder="/products/ 或 https://…" />
|
||||
</Form.Item>
|
||||
<Form.Item name="parentId" label="父级菜单项" initialValue={0}>
|
||||
<TreeSelect treeData={parentOptions} treeDefaultExpandAll />
|
||||
</Form.Item>
|
||||
<Space size={16} style={{ display: "flex" }}>
|
||||
<Form.Item name="sort" label="排序" initialValue={0}>
|
||||
<InputNumber min={0} max={9999} />
|
||||
</Form.Item>
|
||||
<Form.Item name="target" label="打开方式" initialValue="_self">
|
||||
<Select style={{ width: 140 }} options={[{ value: "_self", label: "当前窗口" }, { value: "_blank", label: "新窗口" }]} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="menuId" hidden initialValue={menuId ?? 0}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* # 路由 /admin/settings 系统设置
|
||||
* settings 表 kv 编辑器:每键一个 JSON 编辑卡片,保存整键覆写。
|
||||
* 仅 admin 角色可保存(server 端强制)。
|
||||
*/
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||
import { App, Button, Card, Input, Space, Tag, Typography } from "antd";
|
||||
import { Save } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { adminListSettings, adminUpdateSetting } from "~/server/admin";
|
||||
import { Forbidden, PageHeader, fmtDateTime, guardAdmin, type Json } from "~/components/admin/common";
|
||||
|
||||
export const Route = createFileRoute("/admin/settings")({
|
||||
loader: () => guardAdmin(() => adminListSettings()),
|
||||
component: SettingsPage,
|
||||
});
|
||||
|
||||
/** # 成员变量 设置键中文说明(未知键回退原键名) **/
|
||||
const SETTING_LABEL: Record<string, string> = {
|
||||
site: "站点信息",
|
||||
woocommerce: "商城配置",
|
||||
mail: "邮件配置",
|
||||
seo: "SEO 配置",
|
||||
};
|
||||
|
||||
/** # 类型 设置行(updatedAt 为 Date,loader 序列化前的服务端类型) **/
|
||||
type SettingRow = { key: string; value: Json; updatedAt: Date | string };
|
||||
|
||||
/** # 函数 系统设置页面组件 */
|
||||
function SettingsPage() {
|
||||
const data = Route.useLoaderData();
|
||||
const router = useRouter();
|
||||
const { message } = App.useApp();
|
||||
/** # 成员变量 各键的草稿文本(键 → JSON 字符串) **/
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
setDrafts(Object.fromEntries(data.map((r: SettingRow) => [r.key, JSON.stringify(r.value, null, 2)])));
|
||||
}, [data]);
|
||||
|
||||
if (!data) return <Forbidden />;
|
||||
|
||||
/** # 函数 保存单个设置键(JSON 校验失败提示) */
|
||||
async function handleSave(key: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(drafts[key] ?? "{}") as Json;
|
||||
await adminUpdateSetting({ data: { key, value: parsed } });
|
||||
message.success(`已保存 ${key}`);
|
||||
await router.invalidate();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : "保存失败(JSON 格式需合法)");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<PageHeader title="系统设置" extra={<Tag color="orange">仅管理员可保存</Tag>} />
|
||||
<Space direction="vertical" size={16} style={{ display: "flex" }}>
|
||||
{data.map((r: SettingRow) => (
|
||||
<Card
|
||||
key={r.key}
|
||||
type="inner"
|
||||
size="small"
|
||||
title={`${SETTING_LABEL[r.key] ?? r.key}(${r.key})`}
|
||||
extra={
|
||||
<Button size="small" type="primary" icon={<Save size={13} aria-hidden />} onClick={() => void handleSave(r.key)}>
|
||||
保存
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={Math.min(14, Math.max(4, (drafts[r.key] ?? "").split("\n").length))}
|
||||
value={drafts[r.key] ?? ""}
|
||||
onChange={(e) => setDrafts((d) => ({ ...d, [r.key]: e.target.value }))}
|
||||
style={{ fontFamily: "monospace", fontSize: 12 }}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
最后更新:{fmtDateTime(r.updatedAt)}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+46
-16
@@ -12,34 +12,60 @@ import {
|
||||
Image as ImageIcon,
|
||||
Languages,
|
||||
LayoutDashboard,
|
||||
List,
|
||||
LogOut,
|
||||
Package,
|
||||
Route as RouteIcon,
|
||||
ScrollText,
|
||||
Settings,
|
||||
ShoppingCart,
|
||||
SlidersHorizontal,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { StyleProvider } from "@ant-design/cssinjs";
|
||||
import { getCurrentUser, loginFn, logoutFn } from "~/server/auth";
|
||||
|
||||
/** # 类型 后台菜单项(key 即目标路由路径) **/
|
||||
const MENU_ITEMS = [
|
||||
{ key: "/admin/", icon: <LayoutDashboard size={16} aria-hidden />, label: "Dashboard" },
|
||||
{ key: "/admin/contents/", icon: <Files size={16} aria-hidden />, label: "内容" },
|
||||
{ key: "/admin/products/", icon: <Package size={16} aria-hidden />, label: "产品" },
|
||||
{ key: "/admin/orders/", icon: <ShoppingCart size={16} aria-hidden />, label: "订单" },
|
||||
{ key: "/admin/inquiries/", icon: <Inbox size={16} aria-hidden />, label: "询盘" },
|
||||
{ key: "/admin/media/", icon: <ImageIcon size={16} aria-hidden />, label: "媒体" },
|
||||
{ key: "/admin/languages/", icon: <Languages size={16} aria-hidden />, label: "语言" },
|
||||
{ key: "/admin/seo/", icon: <RouteIcon size={16} aria-hidden />, label: "SEO / 重定向" },
|
||||
{ key: "/admin/filters/", icon: <SlidersHorizontal size={16} aria-hidden />, label: "筛选" },
|
||||
{ key: "/admin/users/", icon: <Settings size={16} aria-hidden />, label: "系统设置" },
|
||||
] as const;
|
||||
/** # 类型 后台菜单分组(分组标签不可点;key 即目标路由路径) **/
|
||||
const MENU_GROUPS: { label: string; children: { key: string; icon: ReactNode; label: string }[] }[] = [
|
||||
{
|
||||
label: "概览",
|
||||
children: [{ key: "/admin/", icon: <LayoutDashboard size={16} aria-hidden />, label: "Dashboard" }],
|
||||
},
|
||||
{
|
||||
label: "内容",
|
||||
children: [
|
||||
{ key: "/admin/contents/", icon: <Files size={16} aria-hidden />, label: "页面 / 文章" },
|
||||
{ key: "/admin/menus/", icon: <List size={16} aria-hidden />, label: "菜单" },
|
||||
{ key: "/admin/media/", icon: <ImageIcon size={16} aria-hidden />, label: "媒体库" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "商务",
|
||||
children: [
|
||||
{ key: "/admin/products/", icon: <Package size={16} aria-hidden />, label: "产品" },
|
||||
{ key: "/admin/orders/", icon: <ShoppingCart size={16} aria-hidden />, label: "订单" },
|
||||
{ key: "/admin/inquiries/", icon: <Inbox size={16} aria-hidden />, label: "询盘" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "系统",
|
||||
children: [
|
||||
{ key: "/admin/filters/", icon: <SlidersHorizontal size={16} aria-hidden />, label: "筛选" },
|
||||
{ key: "/admin/languages/", icon: <Languages size={16} aria-hidden />, label: "语言" },
|
||||
{ key: "/admin/seo/", icon: <RouteIcon size={16} aria-hidden />, label: "SEO / 重定向" },
|
||||
{ key: "/admin/users/", icon: <UserRound size={16} aria-hidden />, label: "用户" },
|
||||
{ key: "/admin/settings/", icon: <Settings size={16} aria-hidden />, label: "系统设置" },
|
||||
{ key: "/admin/audit/", icon: <ScrollText size={16} aria-hidden />, label: "审计日志" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** # 类型 后台菜单项(扁平视图,用于路径高亮匹配) **/
|
||||
const MENU_ITEMS = MENU_GROUPS.flatMap((g) => g.children);
|
||||
|
||||
/** # 类型 合法菜单路径(用于类型安全的导航) **/
|
||||
type MenuKey = (typeof MENU_ITEMS)[number]["key"];
|
||||
type MenuKey = string;
|
||||
|
||||
/** # 类型 拥有后台权限的角色 **/
|
||||
const ADMIN_ROLES = ["admin", "editor", "manager"];
|
||||
@@ -159,7 +185,11 @@ function AdminShell({ user }: { user: { displayName: string | null; email: strin
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
items={MENU_ITEMS.map((m) => ({ key: m.key, icon: m.icon, label: m.label }))}
|
||||
items={MENU_GROUPS.map((g) => ({
|
||||
type: "group" as const,
|
||||
label: <span style={{ fontSize: 12, opacity: 0.6 }}>{g.label}</span>,
|
||||
children: g.children.map((m) => ({ key: m.key, icon: m.icon, label: m.label })),
|
||||
}))}
|
||||
selectedKeys={[selectedKey]}
|
||||
onClick={({ key }) => void navigate({ to: key as MenuKey })}
|
||||
/>
|
||||
|
||||
+132
-24
@@ -1,17 +1,18 @@
|
||||
/**
|
||||
* # 路由 /admin/users 用户管理(系统设置,只读)
|
||||
* 用户列表:分页 + 关键字搜索;角色用 Tag 展示;不做编辑操作。
|
||||
* # 路由 /admin/users 用户管理
|
||||
* 用户列表(分页 + 搜索);admin 可新建用户、改角色、重置密码。
|
||||
* 服务端有防锁死保护:最后一个 admin 不可降级。
|
||||
*/
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Card, Input, Table, Tag } from "antd";
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||
import { App, Button, Card, Form, Input, Modal, Select, Space, Table, Tag } from "antd";
|
||||
import { KeyRound, Search, UserPlus } from "lucide-react";
|
||||
import { getCurrentUser } from "~/server/auth";
|
||||
import { useState } from "react";
|
||||
import { adminListUsers } from "~/server/admin";
|
||||
import { adminCreateUser, adminListUsers, adminResetUserPassword, adminUpdateUserRole } from "~/server/admin";
|
||||
import { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common";
|
||||
|
||||
export const Route = createFileRoute("/admin/users")({
|
||||
validateSearch: (search: Record<string, unknown>): AdminSearch => {
|
||||
// 只保留显式传入的参数,缺省时不回写 URL(避免 307 归一化重定向)
|
||||
const out: AdminSearch = {};
|
||||
const page = Math.floor(Number(search.page)) || 0;
|
||||
if (page > 1) out.page = page;
|
||||
@@ -19,61 +20,168 @@ export const Route = createFileRoute("/admin/users")({
|
||||
return out;
|
||||
},
|
||||
loaderDeps: ({ search }) => ({ search }),
|
||||
loader: ({ deps }) => guardAdmin(() => adminListUsers({ data: { page: deps.search.page, q: deps.search.q || undefined } })),
|
||||
loader: ({ deps }) =>
|
||||
guardAdmin(async () => {
|
||||
const [user, me] = await Promise.all([adminListUsers({ data: { page: deps.search.page, q: deps.search.q || undefined } }), getCurrentUser()]);
|
||||
return { users: user, me };
|
||||
}),
|
||||
component: UsersPage,
|
||||
});
|
||||
|
||||
/** # 成员变量 角色中文标签与配色 **/
|
||||
const ROLE_LABEL: Record<string, string> = { admin: "管理员", editor: "编辑", manager: "经理", customer: "客户" };
|
||||
const ROLE_COLOR: Record<string, string> = { admin: "blue", editor: "cyan", manager: "geekblue", customer: "default" };
|
||||
const ROLE_OPTIONS = (["admin", "editor", "manager", "customer"] as const).map((r) => ({ value: r, label: ROLE_LABEL[r] ?? r }));
|
||||
|
||||
/** # 函数 用户列表页面组件 */
|
||||
function UsersPage() {
|
||||
const data = Route.useLoaderData();
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
const router = useRouter();
|
||||
const { message, modal } = App.useApp();
|
||||
const [q, setQ] = useState(search.q ?? "");
|
||||
/** # 成员变量 新建用户弹窗开关 **/
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createForm] = Form.useForm();
|
||||
const isAdmin = data?.me?.role === "admin";
|
||||
|
||||
if (!data) return <Forbidden />;
|
||||
|
||||
/** # 函数 修改用户角色(服务端校验最后一个 admin 不可降级) */
|
||||
async function handleRoleChange(id: number, role: string) {
|
||||
try {
|
||||
await adminUpdateUserRole({ data: { id, role } });
|
||||
message.success("角色已更新");
|
||||
await router.invalidate();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : "更新失败");
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数 重置密码弹窗(admin 专用) */
|
||||
function openResetPassword(id: number, email: string) {
|
||||
let pwd = "";
|
||||
modal.confirm({
|
||||
title: `重置密码:${email}`,
|
||||
content: <Input.Password placeholder="新密码(至少 8 位)" onChange={(e) => (pwd = e.target.value)} autoFocus />,
|
||||
okText: "重置",
|
||||
cancelText: "取消",
|
||||
onOk: async () => {
|
||||
try {
|
||||
await adminResetUserPassword({ data: { id, password: pwd } });
|
||||
message.success("密码已重置");
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : "重置失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** # 函数 新建用户提交 */
|
||||
async function handleCreate(values: { email: string; username?: string; displayName?: string; password: string; role: string }) {
|
||||
try {
|
||||
await adminCreateUser({ data: values });
|
||||
message.success("用户已创建");
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
await router.invalidate();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : "创建失败");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<PageHeader
|
||||
title="用户管理"
|
||||
extra={
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="搜索邮箱 / 显示名"
|
||||
prefix={<SearchOutlined aria-hidden />}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onPressEnter={() => void navigate({ search: (prev) => ({ ...prev, q, page: 1 }) })}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
<Space>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="搜索邮箱 / 显示名"
|
||||
prefix={<Search size={13} aria-hidden />}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onPressEnter={() => void navigate({ search: (prev) => ({ ...prev, q, page: 1 }) })}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
{isAdmin ? (
|
||||
<Button type="primary" icon={<UserPlus size={14} aria-hidden />} onClick={() => setCreateOpen(true)}>
|
||||
新建用户
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={data.items}
|
||||
dataSource={data.users.items}
|
||||
locale={{ emptyText: <AdminEmpty /> }}
|
||||
pagination={tablePagination(data.total, data.page, data.pageSize, (p) =>
|
||||
pagination={tablePagination(data.users.total, data.users.page, data.users.pageSize, (p) =>
|
||||
void navigate({ search: (prev) => ({ ...prev, page: p }) }),
|
||||
)}
|
||||
columns={[
|
||||
{ title: "ID", dataIndex: "id", width: 80 },
|
||||
{ title: "邮箱", dataIndex: "email", width: 260, ellipsis: true },
|
||||
{ title: "用户名", dataIndex: "username", width: 160, ellipsis: true },
|
||||
{ title: "显示名", dataIndex: "displayName", width: 180, ellipsis: true },
|
||||
{ title: "邮箱", dataIndex: "email", width: 240, ellipsis: true },
|
||||
{ title: "显示名", dataIndex: "displayName", width: 170, ellipsis: true },
|
||||
{
|
||||
title: "角色",
|
||||
dataIndex: "role",
|
||||
width: 110,
|
||||
render: (v: string) => <Tag color={ROLE_COLOR[v] ?? "default"}>{ROLE_LABEL[v] ?? v}</Tag>,
|
||||
width: 170,
|
||||
render: (v: string, r) =>
|
||||
isAdmin ? (
|
||||
<Select
|
||||
size="small"
|
||||
value={v}
|
||||
style={{ width: 120 }}
|
||||
options={ROLE_OPTIONS}
|
||||
onChange={(role) => void handleRoleChange(r.id, role)}
|
||||
/>
|
||||
) : (
|
||||
<Tag color={ROLE_COLOR[v] ?? "default"}>{ROLE_LABEL[v] ?? v}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: "注册时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||
{
|
||||
title: "操作",
|
||||
key: "act",
|
||||
width: 120,
|
||||
render: (_, r) =>
|
||||
isAdmin ? (
|
||||
<Button size="small" icon={<KeyRound size={13} aria-hidden />} onClick={() => openResetPassword(r.id, r.email)}>
|
||||
重置密码
|
||||
</Button>
|
||||
) : null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="新建用户"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={() => createForm.submit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={createForm} layout="vertical" onFinish={(v) => void handleCreate(v as { email: string; password: string; role: string })}>
|
||||
<Form.Item name="email" label="邮箱" rules={[{ required: true, type: "email", message: "请输入有效邮箱" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Space size={16} style={{ display: "flex" }}>
|
||||
<Form.Item name="displayName" label="显示名" style={{ flex: 1 }}>
|
||||
<Input placeholder="留空取邮箱前缀" />
|
||||
</Form.Item>
|
||||
<Form.Item name="role" label="角色" initialValue="editor" rules={[{ required: true }]}>
|
||||
<Select style={{ width: 140 }} options={ROLE_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="password" label="初始密码" rules={[{ required: true, min: 8, message: "至少 8 位" }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
+412
-9
@@ -6,6 +6,11 @@
|
||||
import { db } from "~/db";
|
||||
import {
|
||||
contents,
|
||||
contentRevisions,
|
||||
contentTerms,
|
||||
menus,
|
||||
menuItems,
|
||||
settings,
|
||||
products,
|
||||
productAttributes,
|
||||
productAttributeValues,
|
||||
@@ -25,6 +30,7 @@ import {
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { and, asc, desc, eq, ilike, inArray, or, sql } from "drizzle-orm";
|
||||
import type { PgTable } from "drizzle-orm/pg-core";
|
||||
import { parseImageSize } from "./image-size";
|
||||
import type { Json } from "~/components/admin/common";
|
||||
|
||||
/** # 类型 后台通用分页入参 **/
|
||||
@@ -102,18 +108,39 @@ export const adminListContents = createServerFn({ method: "GET" })
|
||||
return { items, total: Number(total), page, pageSize };
|
||||
});
|
||||
|
||||
/** # 函数 Server Function:读取单条内容(编辑页用) */
|
||||
/** # 函数 Server Function:读取单条内容(编辑页用)
|
||||
* 返回值 内容正本 + 已选分类/标签 ID + 分类/标签选项(taxonomy=category/post_tag)
|
||||
*/
|
||||
export const adminGetContent = createServerFn({ method: "GET" })
|
||||
.validator((d: { id: number }) => d)
|
||||
.handler(async ({ data }) => {
|
||||
const { requireAdmin } = await import("./auth");
|
||||
await requireAdmin();
|
||||
const [item] = await db.select().from(contents).where(eq(contents.id, data.id));
|
||||
if (!item) return null;
|
||||
const selected = await db
|
||||
.select({ categoryId: contentTerms.categoryId })
|
||||
.from(contentTerms)
|
||||
.where(eq(contentTerms.contentId, data.id));
|
||||
const termOptions = await db
|
||||
.select({ id: categories.id, name: categories.name, taxonomy: categories.taxonomy })
|
||||
.from(categories)
|
||||
.where(inArray(categories.taxonomy, ["category", "post_tag"]))
|
||||
.orderBy(asc(categories.taxonomy), asc(categories.name));
|
||||
// meta 为 jsonb(unknown),收窄为 Json 以满足 loader 可序列化校验
|
||||
return item ? { ...item, meta: item.meta as Json } : null;
|
||||
return {
|
||||
...item,
|
||||
meta: item.meta as Json,
|
||||
selectedCategoryIds: selected.map((s) => s.categoryId),
|
||||
termOptions,
|
||||
};
|
||||
});
|
||||
|
||||
/** # 函数 Server Function:保存内容(新建或更新,slug 为空时自动生成) */
|
||||
/** # 函数 Server Function:保存内容(新建或更新,slug 为空时自动生成)
|
||||
* 更新时先写修订快照(content_revisions,回滚依据);保存后按 categoryIds 重建
|
||||
* content_terms 关联(分类 category + 标签 post_tag,对应 WP 分类目录/标签)。
|
||||
* elementorJson 非空时解析合并进 meta.elementor(自定义单页面走前台 Elementor 渲染器)。
|
||||
*/
|
||||
export const adminSaveContent = createServerFn({ method: "POST" })
|
||||
.validator(
|
||||
(d: {
|
||||
@@ -127,13 +154,32 @@ export const adminSaveContent = createServerFn({ method: "POST" })
|
||||
template?: string;
|
||||
seoTitle?: string | null;
|
||||
seoDescription?: string | null;
|
||||
/** # 成员变量 Elementor 文档 JSON 字符串(空串/非法 JSON 视为不修改;仅显式 null 清除) **/
|
||||
elementorJson?: string | null;
|
||||
/** # 成员变量 关联分类/标签 ID(WP 分类目录 + 标签) **/
|
||||
categoryIds?: number[];
|
||||
}) => d,
|
||||
)
|
||||
.handler(async ({ data }) => {
|
||||
const { requireAdmin } = await import("./auth");
|
||||
const admin = await requireAdmin();
|
||||
const slug = (data.slug || "").trim() || slugify(data.title);
|
||||
const values = {
|
||||
// Elementor JSON:仅编辑场景且显式传入时更新 meta(避免旧客户端丢数据)
|
||||
let metaPatch: Record<string, unknown> | null = null;
|
||||
if (data.id && data.elementorJson !== undefined) {
|
||||
const [existing] = await db.select({ meta: contents.meta }).from(contents).where(eq(contents.id, data.id));
|
||||
const base = (existing?.meta ?? {}) as Record<string, unknown>;
|
||||
if (data.elementorJson === null) delete base.elementor;
|
||||
else if (data.elementorJson.trim()) {
|
||||
try {
|
||||
base.elementor = JSON.parse(data.elementorJson);
|
||||
} catch {
|
||||
throw new Error("Elementor JSON 解析失败,请检查格式");
|
||||
}
|
||||
}
|
||||
metaPatch = base;
|
||||
}
|
||||
const values: Record<string, unknown> = {
|
||||
type: data.type,
|
||||
slug,
|
||||
title: data.title,
|
||||
@@ -145,20 +191,35 @@ export const adminSaveContent = createServerFn({ method: "POST" })
|
||||
seoDescription: data.seoDescription ?? null,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (metaPatch) values.meta = metaPatch;
|
||||
let id = data.id;
|
||||
if (id) {
|
||||
const [existing] = await db.select({ publishedAt: contents.publishedAt, status: contents.status }).from(contents).where(eq(contents.id, id));
|
||||
const [existing] = await db.select({ publishedAt: contents.publishedAt, status: contents.status, title: contents.title, html: contents.html }).from(contents).where(eq(contents.id, id));
|
||||
// 修订快照:更新前留档(WP 修订版对齐)
|
||||
if (existing) {
|
||||
await db.insert(contentRevisions).values({ contentId: id, title: existing.title, html: existing.html, authorId: admin.id });
|
||||
}
|
||||
if (existing && values.status === "published" && !existing.publishedAt) {
|
||||
Object.assign(values, { publishedAt: new Date() });
|
||||
values.publishedAt = new Date();
|
||||
}
|
||||
await db.update(contents).set(values).where(eq(contents.id, id));
|
||||
} else {
|
||||
const [row] = await db
|
||||
.insert(contents)
|
||||
.values({ ...values, publishedAt: values.status === "published" ? new Date() : null, authorId: admin.id })
|
||||
.values({ ...(values as typeof contents.$inferInsert), publishedAt: values.status === "published" ? new Date() : null, authorId: admin.id })
|
||||
.returning({ id: contents.id });
|
||||
id = row!.id;
|
||||
}
|
||||
// 重建分类/标签关联(未传则不动,兼容旧调用方)
|
||||
if (data.categoryIds) {
|
||||
await db.delete(contentTerms).where(eq(contentTerms.contentId, id));
|
||||
if (data.categoryIds.length) {
|
||||
await db
|
||||
.insert(contentTerms)
|
||||
.values(data.categoryIds.map((categoryId) => ({ contentId: id!, categoryId })))
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
await writeAudit(admin.id, "content.save", "content", id!, { title: data.title, type: data.type });
|
||||
return { ok: true as const, id };
|
||||
});
|
||||
@@ -657,13 +718,14 @@ export const adminListUsers = createServerFn({ method: "GET" })
|
||||
/* ============================= Dashboard 统计 ============================= */
|
||||
|
||||
/** # 函数 Server Function:Dashboard 统计
|
||||
* 返回值 各实体计数、待处理订单数、最近 7 天询盘趋势、最新 10 条询盘
|
||||
* 返回值 各实体计数、待处理订单数、近 7 天询盘趋势、最新 10 条询盘、
|
||||
* 近 30 天订单/询盘趋势(CRM 看板用)、新询盘待办数、状态分布、最近 10 笔订单
|
||||
*/
|
||||
export const adminStats = createServerFn({ method: "GET" }).handler(async () => {
|
||||
const { requireAdmin } = await import("./auth");
|
||||
await requireAdmin();
|
||||
const countOf = async (table: PgTable) => Number(await db.$count(table));
|
||||
const [contentsCount, productsCount, ordersCount, inquiriesCount, mediaCount, pendingOrders] = await Promise.all([
|
||||
const [contentsCount, productsCount, ordersCount, inquiriesCount, mediaCount, pendingOrders, newInquiries] = await Promise.all([
|
||||
countOf(contents),
|
||||
countOf(products),
|
||||
countOf(orders),
|
||||
@@ -673,6 +735,7 @@ export const adminStats = createServerFn({ method: "GET" }).handler(async () =>
|
||||
.select({ n: sql<number>`count(*)` })
|
||||
.from(orders)
|
||||
.where(or(eq(orders.status, "pending"), eq(orders.status, "on-hold"))),
|
||||
db.select({ n: sql<number>`count(*)` }).from(inquiries).where(eq(inquiries.status, "new")),
|
||||
]);
|
||||
// 最近 7 天询盘趋势(按天分组,补齐空白天)
|
||||
const trendRows = await db
|
||||
@@ -704,6 +767,56 @@ export const adminStats = createServerFn({ method: "GET" }).handler(async () =>
|
||||
.from(inquiries)
|
||||
.orderBy(desc(inquiries.createdAt))
|
||||
.limit(10);
|
||||
// 近 30 天订单/询盘双趋势(CRM 看板)
|
||||
const ordersTrendRows = await db
|
||||
.select({
|
||||
day: sql<string>`to_char(date_trunc('day', ${orders.createdAt}), 'YYYY-MM-DD')`,
|
||||
n: sql<number>`count(*)`,
|
||||
revenue: sql<string>`coalesce(sum(${orders.total}), 0)`,
|
||||
})
|
||||
.from(orders)
|
||||
.where(sql`${orders.createdAt} > now() - interval '30 days'`)
|
||||
.groupBy(sql`1`);
|
||||
const inquiriesTrendRows = await db
|
||||
.select({
|
||||
day: sql<string>`to_char(date_trunc('day', ${inquiries.createdAt}), 'YYYY-MM-DD')`,
|
||||
n: sql<number>`count(*)`,
|
||||
})
|
||||
.from(inquiries)
|
||||
.where(sql`${inquiries.createdAt} > now() - interval '30 days'`)
|
||||
.groupBy(sql`1`);
|
||||
const ordersTrendMap = new Map(ordersTrendRows.map((r) => [r.day, { count: Number(r.n), revenue: Number(r.revenue) }]));
|
||||
const inquiriesTrendMap = new Map(inquiriesTrendRows.map((r) => [r.day, Number(r.n)]));
|
||||
const trend30: { day: string; orders: number; revenue: number; inquiries: number }[] = [];
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const key = new Date(Date.now() - i * 86400_000).toISOString().slice(0, 10);
|
||||
const o = ordersTrendMap.get(key);
|
||||
trend30.push({ day: key, orders: o?.count ?? 0, revenue: o?.revenue ?? 0, inquiries: inquiriesTrendMap.get(key) ?? 0 });
|
||||
}
|
||||
// 询盘状态分布 + 最近订单(CRM 待办/跟进视图)
|
||||
const inquiryStatusRows = await db
|
||||
.select({ status: inquiries.status, n: sql<number>`count(*)` })
|
||||
.from(inquiries)
|
||||
.groupBy(inquiries.status);
|
||||
const recentOrders = await db
|
||||
.select({
|
||||
id: orders.id,
|
||||
orderNumber: orders.orderNumber,
|
||||
status: orders.status,
|
||||
total: orders.total,
|
||||
currency: orders.currency,
|
||||
billing: orders.billing,
|
||||
createdAt: orders.createdAt,
|
||||
})
|
||||
.from(orders)
|
||||
.orderBy(desc(orders.createdAt))
|
||||
.limit(10);
|
||||
// 营收合计(全部订单,按 currency 分组)
|
||||
const revenueRows = await db
|
||||
.select({ currency: orders.currency, total: sql<string>`coalesce(sum(${orders.total}), 0)` })
|
||||
.from(orders)
|
||||
.where(or(eq(orders.status, "completed"), eq(orders.status, "processing")))
|
||||
.groupBy(orders.currency);
|
||||
return {
|
||||
counts: {
|
||||
contents: contentsCount,
|
||||
@@ -712,8 +825,298 @@ export const adminStats = createServerFn({ method: "GET" }).handler(async () =>
|
||||
inquiries: inquiriesCount,
|
||||
media: mediaCount,
|
||||
pendingOrders: Number(pendingOrders[0]?.n ?? 0),
|
||||
newInquiries: Number(newInquiries[0]?.n ?? 0),
|
||||
},
|
||||
trend,
|
||||
trend30,
|
||||
inquiryStatus: inquiryStatusRows.map((r) => ({ status: r.status, count: Number(r.n) })),
|
||||
revenue: revenueRows.map((r) => ({ currency: r.currency, total: Number(r.total) })),
|
||||
recentInquiries,
|
||||
recentOrders: recentOrders.map((o) => ({ ...o, customerName: billingName(o.billing), billing: o.billing as Json })),
|
||||
};
|
||||
});
|
||||
|
||||
/* ============================= 内容修订 Revisions ============================= */
|
||||
|
||||
/** # 函数 Server Function:内容修订列表(近 50 条,编辑页「历史版本」弹窗用) */
|
||||
export const adminListRevisions = createServerFn({ method: "GET" })
|
||||
.validator((d: { contentId: number }) => d)
|
||||
.handler(async ({ data }) => {
|
||||
const { requireAdmin } = await import("./auth");
|
||||
await requireAdmin();
|
||||
return db
|
||||
.select({
|
||||
id: contentRevisions.id,
|
||||
title: contentRevisions.title,
|
||||
authorId: contentRevisions.authorId,
|
||||
createdAt: contentRevisions.createdAt,
|
||||
})
|
||||
.from(contentRevisions)
|
||||
.where(eq(contentRevisions.contentId, data.contentId))
|
||||
.orderBy(desc(contentRevisions.id))
|
||||
.limit(50);
|
||||
});
|
||||
|
||||
/** # 函数 Server Function:读取修订正文快照 */
|
||||
export const adminGetRevision = createServerFn({ method: "GET" })
|
||||
.validator((d: { id: number }) => d)
|
||||
.handler(async ({ data }) => {
|
||||
const { requireAdmin } = await import("./auth");
|
||||
await requireAdmin();
|
||||
const [row] = await db.select().from(contentRevisions).where(eq(contentRevisions.id, data.id));
|
||||
return row ?? null;
|
||||
});
|
||||
|
||||
/** # 函数 Server Function:恢复修订(把快照写回内容正本,当前版本先再留档) */
|
||||
export const adminRestoreRevision = createServerFn({ method: "POST" })
|
||||
.validator((d: { id: number }) => d)
|
||||
.handler(async ({ data }) => {
|
||||
const { requireAdmin } = await import("./auth");
|
||||
const admin = await requireAdmin();
|
||||
const [rev] = await db.select().from(contentRevisions).where(eq(contentRevisions.id, data.id));
|
||||
if (!rev) throw new Error("修订不存在");
|
||||
const [current] = await db.select({ title: contents.title, html: contents.html }).from(contents).where(eq(contents.id, rev.contentId));
|
||||
if (current) {
|
||||
await db.insert(contentRevisions).values({ contentId: rev.contentId, title: current.title, html: current.html, authorId: admin.id });
|
||||
}
|
||||
await db.update(contents).set({ title: rev.title, html: rev.html, updatedAt: new Date() }).where(eq(contents.id, rev.contentId));
|
||||
await writeAudit(admin.id, "content.revision.restore", "content", rev.contentId, { revisionId: rev.id });
|
||||
return { ok: true as const, contentId: rev.contentId };
|
||||
});
|
||||
|
||||
/* ============================= 媒体上传 Media Upload ============================= */
|
||||
|
||||
/** # 函数 内部工具:上传文件名清洗(保留中英文数字点横线,防路径穿越) */
|
||||
function sanitizeFilename(name: string): string {
|
||||
const base = name.replace(/[\\/]+/g, "-").replace(/[^a-zA-Z0-9._\-\u4e00-\u9fa5]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
return base.slice(0, 160) || `file-${Date.now()}`;
|
||||
}
|
||||
|
||||
/** # 函数 Server Function:媒体上传
|
||||
* 入参 base64(JSON RPC 不走 multipart,避免 FormData 序列化分歧)。
|
||||
* 写入 public/uploads/YYYY/MM/,解析图片宽高入库,重名自动追加序号。
|
||||
*/
|
||||
export const adminUploadMedia = createServerFn({ method: "POST" })
|
||||
.validator((d: { filename: string; mime: string; dataBase64: string }) => d)
|
||||
.handler(async ({ data }) => {
|
||||
const { requireAdmin } = await import("./auth");
|
||||
const admin = await requireAdmin();
|
||||
const buf = Buffer.from(data.dataBase64, "base64");
|
||||
if (!buf.length) throw new Error("空文件");
|
||||
if (buf.length > 20 * 1024 * 1024) throw new Error("文件超过 20MB 上限");
|
||||
// 目标路径:uploads/YYYY/MM/文件名,重名自动 -N
|
||||
const now = new Date();
|
||||
const dir = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
const dot = data.filename.lastIndexOf(".");
|
||||
const stem = sanitizeFilename(dot === -1 ? data.filename : data.filename.slice(0, dot));
|
||||
const ext = dot === -1 ? "" : data.filename.slice(dot).toLowerCase();
|
||||
const fs = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
const absDir = path.join(process.cwd(), "public", "uploads", dir);
|
||||
await fs.mkdir(absDir, { recursive: true });
|
||||
let rel = `${dir}/${stem}${ext}`;
|
||||
for (let n = 1; n < 1000; n++) {
|
||||
try {
|
||||
await fs.access(path.join(process.cwd(), "public", "uploads", rel));
|
||||
rel = `${dir}/${stem}-${n}${ext}`;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
await fs.writeFile(path.join(process.cwd(), "public", "uploads", rel), buf);
|
||||
// 图片宽高解析(PNG/JPEG/GIF/WebP),失败不阻塞上传
|
||||
const dims = parseImageSize(new Uint8Array(buf.slice(0, Math.min(buf.length, 1024 * 1024))));
|
||||
const [row] = await db
|
||||
.insert(media)
|
||||
.values({
|
||||
path: rel,
|
||||
url: `/uploads/${rel}`,
|
||||
mimeType: data.mime || "application/octet-stream",
|
||||
width: dims?.[0] ?? null,
|
||||
height: dims?.[1] ?? null,
|
||||
size: buf.length,
|
||||
})
|
||||
.returning({ id: media.id });
|
||||
await writeAudit(admin.id, "media.upload", "media", row!.id, { path: rel, size: buf.length });
|
||||
return { ok: true as const, id: row!.id, url: `/uploads/${rel}`, width: dims?.[0] ?? null, height: dims?.[1] ?? null };
|
||||
});
|
||||
|
||||
/* ============================= 菜单管理 Menus ============================= */
|
||||
|
||||
/** # 函数 Server Function:菜单列表(菜单定义 + 各菜单项平铺,前端组树) */
|
||||
export const adminListMenus = createServerFn({ method: "GET" }).handler(async () => {
|
||||
const { requireAdmin } = await import("./auth");
|
||||
await requireAdmin();
|
||||
const items = await db.select().from(menus).orderBy(asc(menus.id));
|
||||
const allItems = await db.select().from(menuItems).orderBy(asc(menuItems.menuId), asc(menuItems.sort), asc(menuItems.id));
|
||||
return { menus: items, items: allItems };
|
||||
});
|
||||
|
||||
/** # 函数 Server Function:保存菜单项(新建或更新) */
|
||||
export const adminSaveMenuItem = createServerFn({ method: "POST" })
|
||||
.validator(
|
||||
(d: { id?: number; menuId: number; parentId?: number | null; title: string; url: string; target?: string; sort?: number }) => d,
|
||||
)
|
||||
.handler(async ({ data }) => {
|
||||
const { requireAdmin } = await import("./auth");
|
||||
const admin = await requireAdmin();
|
||||
const values = {
|
||||
menuId: data.menuId,
|
||||
parentId: data.parentId ?? null,
|
||||
title: data.title,
|
||||
url: data.url,
|
||||
target: data.target === "_blank" ? "_blank" : "_self",
|
||||
sort: data.sort ?? 0,
|
||||
};
|
||||
let id = data.id;
|
||||
if (id) {
|
||||
await db.update(menuItems).set(values).where(eq(menuItems.id, id));
|
||||
} else {
|
||||
const [row] = await db.insert(menuItems).values(values).returning({ id: menuItems.id });
|
||||
id = row!.id;
|
||||
}
|
||||
await writeAudit(admin.id, "menu.item.save", "menu_item", id!, { title: data.title, menuId: data.menuId });
|
||||
return { ok: true as const, id };
|
||||
});
|
||||
|
||||
/** # 函数 Server Function:删除菜单项(子项提升为顶层,避免整树丢失) */
|
||||
export const adminDeleteMenuItem = createServerFn({ method: "POST" })
|
||||
.validator((d: { id: number }) => d)
|
||||
.handler(async ({ data }) => {
|
||||
const { requireAdmin } = await import("./auth");
|
||||
const admin = await requireAdmin();
|
||||
const [item] = await db.select().from(menuItems).where(eq(menuItems.id, data.id));
|
||||
if (!item) throw new Error("菜单项不存在");
|
||||
await db.update(menuItems).set({ parentId: item.parentId }).where(eq(menuItems.parentId, data.id));
|
||||
await db.delete(menuItems).where(eq(menuItems.id, data.id));
|
||||
await writeAudit(admin.id, "menu.item.delete", "menu_item", data.id, { title: item.title });
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
/* ============================= 用户管理 Users Admin ============================= */
|
||||
|
||||
/** # 函数 Server Function:新建后台用户(bcrypt;admin 角色仅 admin 可建) */
|
||||
export const adminCreateUser = createServerFn({ method: "POST" })
|
||||
.validator((d: { email: string; username?: string; displayName?: string; password: string; role: string }) => d)
|
||||
.handler(async ({ data }) => {
|
||||
const { requireAdmin } = await import("./auth");
|
||||
const admin = await requireAdmin();
|
||||
if (data.role === "admin" && admin.role !== "admin") throw new Error("仅管理员可创建管理员账号");
|
||||
if (!["admin", "editor", "manager", "customer"].includes(data.role)) throw new Error("非法角色");
|
||||
const email = data.email.toLowerCase().trim();
|
||||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) throw new Error("邮箱格式不正确");
|
||||
if (data.password.length < 8) throw new Error("密码至少 8 位");
|
||||
const [existing] = await db.select({ id: users.id }).from(users).where(eq(users.email, email));
|
||||
if (existing) throw new Error("该邮箱已存在");
|
||||
const [row] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
email,
|
||||
username: data.username?.trim() || email.split("@")[0]!,
|
||||
displayName: data.displayName?.trim() || email.split("@")[0]!,
|
||||
passwordHash: Bun.password.hashSync(data.password, { algorithm: "bcrypt", cost: 10 }),
|
||||
role: data.role,
|
||||
})
|
||||
.returning({ id: users.id });
|
||||
await writeAudit(admin.id, "user.create", "user", row!.id, { email, role: data.role });
|
||||
return { ok: true as const, id: row!.id };
|
||||
});
|
||||
|
||||
/** # 函数 Server Function:修改用户角色(最后一個 admin 不可降级,防锁死) */
|
||||
export const adminUpdateUserRole = createServerFn({ method: "POST" })
|
||||
.validator((d: { id: number; role: string }) => d)
|
||||
.handler(async ({ data }) => {
|
||||
const { requireAdmin } = await import("./auth");
|
||||
const admin = await requireAdmin();
|
||||
if (!["admin", "editor", "manager", "customer"].includes(data.role)) throw new Error("非法角色");
|
||||
if (data.role === "admin" && admin.role !== "admin") throw new Error("仅管理员可授予管理员角色");
|
||||
const [target] = await db.select({ role: users.role }).from(users).where(eq(users.id, data.id));
|
||||
if (!target) throw new Error("用户不存在");
|
||||
if (target.role === "admin" && data.role !== "admin") {
|
||||
const [{ n }] = await db.select({ n: sql<number>`count(*)` }).from(users).where(eq(users.role, "admin"));
|
||||
if (Number(n) <= 1) throw new Error("至少保留一个管理员账号");
|
||||
}
|
||||
await db.update(users).set({ role: data.role }).where(eq(users.id, data.id));
|
||||
await writeAudit(admin.id, "user.role", "user", data.id, { role: data.role });
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
/** # 函数 Server Function:重置用户密码(admin 专用) */
|
||||
export const adminResetUserPassword = createServerFn({ method: "POST" })
|
||||
.validator((d: { id: number; password: string }) => d)
|
||||
.handler(async ({ data }) => {
|
||||
const { requireAdmin } = await import("./auth");
|
||||
const admin = await requireAdmin();
|
||||
if (admin.role !== "admin") throw new Error("仅管理员可重置密码");
|
||||
if (data.password.length < 8) throw new Error("密码至少 8 位");
|
||||
await db
|
||||
.update(users)
|
||||
.set({ passwordHash: Bun.password.hashSync(data.password, { algorithm: "bcrypt", cost: 10 }) })
|
||||
.where(eq(users.id, data.id));
|
||||
await writeAudit(admin.id, "user.password.reset", "user", data.id);
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
/* ============================= 审计日志 Audit Logs ============================= */
|
||||
|
||||
/** # 函数 Server Function:审计日志列表(分页 + 操作人/动作/关键字过滤) */
|
||||
export const adminListAuditLogs = createServerFn({ method: "GET" })
|
||||
.validator((d: PageInput & { action?: string }) => d)
|
||||
.handler(async ({ data }) => {
|
||||
const { requireAdmin } = await import("./auth");
|
||||
await requireAdmin();
|
||||
const { page, pageSize, offset } = parsePage(data);
|
||||
const conds = [];
|
||||
if (data.action) conds.push(ilike(auditLogs.action, `%${data.action}%`));
|
||||
const where = conds.length ? and(...conds) : undefined;
|
||||
const items = await db
|
||||
.select({
|
||||
id: auditLogs.id,
|
||||
actorId: auditLogs.actorId,
|
||||
actorEmail: users.email,
|
||||
action: auditLogs.action,
|
||||
entity: auditLogs.entity,
|
||||
entityId: auditLogs.entityId,
|
||||
meta: auditLogs.meta,
|
||||
createdAt: auditLogs.createdAt,
|
||||
})
|
||||
.from(auditLogs)
|
||||
.leftJoin(users, eq(users.id, auditLogs.actorId))
|
||||
.where(where)
|
||||
.orderBy(desc(auditLogs.id))
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
const [{ total }] = await db.select({ total: sql<number>`count(*)` }).from(auditLogs).where(where);
|
||||
return {
|
||||
items: items.map((r) => ({ ...r, meta: r.meta as Json })),
|
||||
total: Number(total),
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
});
|
||||
|
||||
/* ============================= 系统设置 Settings ============================= */
|
||||
|
||||
/** # 函数 Server Function:系统设置列表(settings 全表 kv) */
|
||||
export const adminListSettings = createServerFn({ method: "GET" }).handler(async () => {
|
||||
const { requireAdmin } = await import("./auth");
|
||||
await requireAdmin();
|
||||
const rows = await db.select().from(settings).orderBy(asc(settings.key));
|
||||
return rows.map((r) => ({ key: r.key, value: r.value as Json, updatedAt: r.updatedAt }));
|
||||
});
|
||||
|
||||
/** # 函数 Server Function:保存系统设置(整键覆写,值须为 JSON 对象) */
|
||||
export const adminUpdateSetting = createServerFn({ method: "POST" })
|
||||
.validator((d: { key: string; value: Json }) => d)
|
||||
.handler(async ({ data }) => {
|
||||
const { requireAdmin } = await import("./auth");
|
||||
const admin = await requireAdmin();
|
||||
if (admin.role !== "admin") throw new Error("仅管理员可修改系统设置");
|
||||
if (!/^[a-z0-9_-]{1,128}$/i.test(data.key)) throw new Error("非法设置键名");
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({ key: data.key, value: data.value as Record<string, unknown>, updatedAt: new Date() })
|
||||
.onConflictDoUpdate({ target: settings.key, set: { value: data.value as Record<string, unknown>, updatedAt: new Date() } });
|
||||
await writeAudit(admin.id, "settings.update", "settings", data.key);
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
@@ -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