From b16c33c08bfbd7458ef2b40f1a33ca13b570d447 Mon Sep 17 00:00:00 2001 From: bigcode Date: Wed, 4 Nov 2026 06:10:04 +0800 Subject: [PATCH] =?UTF-8?q?v0.11.0=20=E5=90=8E=E5=8F=B0=20CRM=20=E5=8C=96?= =?UTF-8?q?=EF=BC=9A=E5=AA=92=E4=BD=93=E4=B8=8A=E4=BC=A0/=E5=8D=95?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=20Elementor/=E4=BF=AE=E8=AE=A2/=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E6=A0=87=E7=AD=BE/=E8=8F=9C=E5=8D=95/=E7=94=A8?= =?UTF-8?q?=E6=88=B7/=E5=AE=A1=E8=AE=A1/=E8=AE=BE=E7=BD=AE/Dashboard=20v2?= =?UTF-8?q?=20+=20dusuniot-cms=20MCP=20=E6=9C=8D=E5=8A=A1=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .zcode/config.json | 14 + docs/admin-crm.md | 45 +++ mcp/server.ts | 512 ++++++++++++++++++++++++++++++ package.json | 2 +- src/routeTree.gen.ts | 63 ++++ src/routes/admin.audit.tsx | 80 +++++ src/routes/admin.contents.$id.tsx | 229 +++++++++++-- src/routes/admin.index.tsx | 256 +++++++++++---- src/routes/admin.media.tsx | 52 ++- src/routes/admin.menus.tsx | 177 +++++++++++ src/routes/admin.settings.tsx | 86 +++++ src/routes/admin.tsx | 62 +++- src/routes/admin.users.tsx | 156 +++++++-- src/server/admin.ts | 421 +++++++++++++++++++++++- src/server/image-size.ts | 50 +++ 15 files changed, 2053 insertions(+), 152 deletions(-) create mode 100644 .zcode/config.json create mode 100644 docs/admin-crm.md create mode 100644 mcp/server.ts create mode 100644 src/routes/admin.audit.tsx create mode 100644 src/routes/admin.menus.tsx create mode 100644 src/routes/admin.settings.tsx create mode 100644 src/server/image-size.ts diff --git a/.zcode/config.json b/.zcode/config.json new file mode 100644 index 000000000..74a7cca28 --- /dev/null +++ b/.zcode/config.json @@ -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" + } + } + } + } +} diff --git a/docs/admin-crm.md b/docs/admin-crm.md new file mode 100644 index 000000000..570531596 --- /dev/null +++ b/docs/admin-crm.md @@ -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 +``` diff --git a/mcp/server.ts b/mcp/server.ts new file mode 100644 index 000000000..22a1a9411 --- /dev/null +++ b/mcp/server.ts @@ -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 { + 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): Promise { + 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 = {}; + 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; + 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) { + 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 }; + 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 }).arguments ?? {}) as Record; + 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) } }); + } + } +} diff --git a/package.json b/package.json index 0abbc429a..7a36edcd7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dusuniot-rebuild", - "version": "0.10.37", + "version": "0.11.0", "private": true, "type": "module", "scripts": { diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 920721ddf..a23a467f2 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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, diff --git a/src/routes/admin.audit.tsx b/src/routes/admin.audit.tsx new file mode 100644 index 000000000..38f1f5e8a --- /dev/null +++ b/src/routes/admin.audit.tsx @@ -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): 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 ; + + return ( + + } + value={q} + onChange={(e) => setQ(e.target.value)} + onPressEnter={() => void navigate({ search: (prev) => ({ ...prev, q, page: 1 }) })} + style={{ width: 280 }} + /> + } + /> + }} + 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) => {v} }, + { title: "实体", dataIndex: "entity", width: 110 }, + { title: "实体 ID", dataIndex: "entityId", width: 90 }, + { + title: "摘要", + dataIndex: "meta", + ellipsis: true, + render: (v: unknown) => ( + + {Object.keys(v ?? {}).length ? JSON.stringify(v) : "-"} + + ), + }, + { title: "时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) }, + ]} + /> + + ); +} diff --git a/src/routes/admin.contents.$id.tsx b/src/routes/admin.contents.$id.tsx index ce8538343..a2122193a 100644 --- a/src/routes/admin.contents.$id.tsx +++ b/src/routes/admin.contents.$id.tsx @@ -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 => { 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; + 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([]); + /** # 成员变量 已选分类/标签(仅编辑态展示,新建暂不关联) **/ + const [selectedTermIds, setSelectedTermIds] = useState(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 ; // 收窄后的非空引用(闭包内使用) @@ -76,8 +119,14 @@ function ContentEditPage() { /** # 函数 保存内容:提交到服务端 → 提示 → 返回列表 */ async function handleSave(values: Omit) { 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: ( +
+

{rev.title}

+
+            {rev.html.slice(0, 5000) || "(空正文)"}
+            {rev.html.length > 5000 ? "\n…(截断预览)" : ""}
+          
+
+ ), + 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 ( -

{content.id ? `编辑内容 #${content.id}` : "新建内容"}

+ {content.id ? ( + <> + + {content.status === "published" && content.slug ? ( + + ) : null} + + ) : null}
-
void handleSave(v)} - > + void handleSave(v as Omit)}> - + - - + + + + {content.id ? ( + + setSelectedTermIds((v as number[]) ?? [])} + treeCheckable + showCheckedStrategy={TreeSelect.SHOW_CHILD} + maxTagCount={4} + placeholder="选择分类与标签" + style={{ width: 320 }} + /> + + ) : null} - - + + - - + + + + + + + - + + setRevOpen(false)} + footer={null} + width={720} + > +
fmtDateTime(v) }, + { + title: "操作", + key: "act", + width: 100, + render: (_, r) => ( + + ), + }, + ]} + /> + ); } diff --git a/src/routes/admin.index.tsx b/src/routes/admin.index.tsx index 8a6d0b5dd..6a8b58f5b 100644 --- a/src/routes/admin.index.tsx +++ b/src/routes/admin.index.tsx @@ -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 ; - 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: }, - { title: "产品", value: counts.products, icon: }, - { title: "订单", value: counts.orders, icon: }, - { title: "询盘", value: counts.inquiries, icon: }, - { title: "媒体", value: counts.media, icon: }, - { title: "待处理订单", value: counts.pendingOrders, icon: }, + { title: "订单(30 天环比)", value: ordNow, delta: delta(ordNow, ordPrev), icon: , href: "/admin/orders/" }, + { title: "营收(30 天环比)", value: revNow, delta: delta(revNow, revPrev), icon: , href: "/admin/orders/", money: true }, + { title: "新询盘(待跟进)", value: newInq, delta: delta(inqNow, inqPrev), icon: , href: "/admin/inquiries?status=new" }, + { title: "待处理订单", value: counts.pendingOrders, icon: , href: "/admin/orders/" }, + { title: "产品", value: counts.products, icon: , href: "/admin/products/" }, + { title: "页面 / 文章", value: counts.contents, icon: , href: "/admin/contents/" }, ]; + /** # 函数 环比箭头与配色 */ + function DeltaTag({ d }: { d: number }) { + const up = d >= 0; + return ( + + {up ? : } + {`${(Math.abs(d) * 100).toFixed(0)}%`} + + ); + } + return (
- + {/* KPI 卡 */} + {cards.map((c) => ( -
- - - + + + + +
+ {c.icon} +
+
+
{c.title}
+ + + {c.money ? `$${Math.round(c.value).toLocaleString()}` : c.value.toLocaleString()} + + {c.delta !== undefined ? : null} + +
+
+
+
))} - -
- {trend.map((t) => ( -
- {t.count} -
- {t.day.slice(5)} + {/* 30 天双趋势 + 询盘漏斗 */} + +
+ + 订单 + 询盘 + + } + > + {/* 双列柱图(纯 div,无图表库依赖) */} +
+ {trend30.map((t) => ( +
+
+
+
+ ))}
- ))} -
- +
+ {trend30[0]?.day} + {trend30[Math.floor(trend30.length / 2)]?.day} + {trend30[trend30.length - 1]?.day} +
+ {/* 营收迷你面积条 */} +
+ {trend30.map((t) => ( +
+ ))} +
+ + +
+ + + {(["new", "read", "replied", "spam", "archived"] as const).map((st) => { + const n = inquiryStatus.find((s) => s.status === st)?.count ?? 0; + return ( +
+
+ {INQUIRY_STATUS_LABEL[st] ?? st} + {n.toLocaleString()} +
+ +
+ ); + })} +
+ 共 {totalInq.toLocaleString()} 条,其中垃圾 {spamInq.toLocaleString()} 条({(Math.round((spamInq / totalInq) * 100))}%) +
+
+
+ + - -
}} - 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) => {INQUIRY_STATUS_LABEL[v] ?? v}, - }, - { title: "时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) }, - ]} - /> + {/* 最近订单 / 最新询盘并排 */} + + + 全部订单 →} + > +
}} + columns={[ + { title: "单号", dataIndex: "orderNumber", width: 130, render: (v: string, r) => {v} }, + { 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) => {ORDER_STATUS_LABEL[v] ?? v} }, + { title: "时间", dataIndex: "createdAt", width: 150, render: (v: string) => fmtDateTime(v) }, + ]} + /> + + + + 全部询盘 →} + > +
}} + 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) => {INQUIRY_STATUS_LABEL[v] ?? v} }, + { title: "时间", dataIndex: "createdAt", width: 150, render: (v: string) => fmtDateTime(v) }, + ]} + /> + + + + + {/* 快捷入口 */} + + + 新建页面 + 产品管理 + 媒体库 + 跟进新询盘 + 菜单管理 + ); diff --git a/src/routes/admin.media.tsx b/src/routes/admin.media.tsx index 9ddb04cb9..f969c015e 100644 --- a/src/routes/admin.media.tsx +++ b/src/routes/admin.media.tsx @@ -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() { } - value={q} - onChange={(e) => setQ(e.target.value)} - onPressEnter={() => updateSearch({ q })} - style={{ width: 240 }} - /> + + } + value={q} + onChange={(e) => setQ(e.target.value)} + onPressEnter={() => updateSearch({ q })} + style={{ width: 240 }} + /> + {/* 上传走 base64 JSON(server fn RPC),单文件 ≤20MB;成功后刷新列表 */} + { + 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); + } + }} + > + + + } />
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(null); + /** # 成员变量 编辑弹窗(null = 关闭;id=0 新建) **/ + const [editing, setEditing] = useState<(Partial & { 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 ; + + /** # 函数 打开编辑弹窗(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 ( + + } onClick={() => openEdit()} disabled={menuId === null}> + 新增菜单项 + + } + /> + +
}} + pagination={false} + columns={[ + { + title: "标题", + dataIndex: "title", + render: (v: string, r) => ( + + {r.depth > 0 ? └ : null} + {v} + + ), + }, + { title: "链接", dataIndex: "url", ellipsis: true, render: (v: string) => {v} }, + { title: "排序", dataIndex: "sort", width: 70 }, + { title: "打开方式", dataIndex: "target", width: 100 }, + { + title: "操作", + key: "act", + width: 110, + render: (_, r) => ( + + + } + > + setDrafts((d) => ({ ...d, [r.key]: e.target.value }))} + style={{ fontFamily: "monospace", fontSize: 12 }} + /> + + 最后更新:{fmtDateTime(r.updatedAt)} + + + ))} + + + ); +} diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index cae6f6829..8309b78fb 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -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: , label: "Dashboard" }, - { key: "/admin/contents/", icon: , label: "内容" }, - { key: "/admin/products/", icon: , label: "产品" }, - { key: "/admin/orders/", icon: , label: "订单" }, - { key: "/admin/inquiries/", icon: , label: "询盘" }, - { key: "/admin/media/", icon: , label: "媒体" }, - { key: "/admin/languages/", icon: , label: "语言" }, - { key: "/admin/seo/", icon: , label: "SEO / 重定向" }, - { key: "/admin/filters/", icon: , label: "筛选" }, - { key: "/admin/users/", icon: , label: "系统设置" }, -] as const; +/** # 类型 后台菜单分组(分组标签不可点;key 即目标路由路径) **/ +const MENU_GROUPS: { label: string; children: { key: string; icon: ReactNode; label: string }[] }[] = [ + { + label: "概览", + children: [{ key: "/admin/", icon: , label: "Dashboard" }], + }, + { + label: "内容", + children: [ + { key: "/admin/contents/", icon: , label: "页面 / 文章" }, + { key: "/admin/menus/", icon: , label: "菜单" }, + { key: "/admin/media/", icon: , label: "媒体库" }, + ], + }, + { + label: "商务", + children: [ + { key: "/admin/products/", icon: , label: "产品" }, + { key: "/admin/orders/", icon: , label: "订单" }, + { key: "/admin/inquiries/", icon: , label: "询盘" }, + ], + }, + { + label: "系统", + children: [ + { key: "/admin/filters/", icon: , label: "筛选" }, + { key: "/admin/languages/", icon: , label: "语言" }, + { key: "/admin/seo/", icon: , label: "SEO / 重定向" }, + { key: "/admin/users/", icon: , label: "用户" }, + { key: "/admin/settings/", icon: , label: "系统设置" }, + { key: "/admin/audit/", icon: , 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 ({ key: m.key, icon: m.icon, label: m.label }))} + items={MENU_GROUPS.map((g) => ({ + type: "group" as const, + label: {g.label}, + children: g.children.map((m) => ({ key: m.key, icon: m.icon, label: m.label })), + }))} selectedKeys={[selectedKey]} onClick={({ key }) => void navigate({ to: key as MenuKey })} /> diff --git a/src/routes/admin.users.tsx b/src/routes/admin.users.tsx index 6d44a64d7..afa512597 100644 --- a/src/routes/admin.users.tsx +++ b/src/routes/admin.users.tsx @@ -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): 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 = { admin: "管理员", editor: "编辑", manager: "经理", customer: "客户" }; const ROLE_COLOR: Record = { 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 ; + /** # 函数 修改用户角色(服务端校验最后一个 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: (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 ( } - value={q} - onChange={(e) => setQ(e.target.value)} - onPressEnter={() => void navigate({ search: (prev) => ({ ...prev, q, page: 1 }) })} - style={{ width: 240 }} - /> + + } + value={q} + onChange={(e) => setQ(e.target.value)} + onPressEnter={() => void navigate({ search: (prev) => ({ ...prev, q, page: 1 }) })} + style={{ width: 240 }} + /> + {isAdmin ? ( + + ) : null} + } />
}} - 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) => {ROLE_LABEL[v] ?? v}, + width: 170, + render: (v: string, r) => + isAdmin ? ( + + + + + + + +