v0.16.0: 后台体验升级——i18n(默认英文)/浅深主题/字号放大/关站维护页/个人设置(头像·语言·主题·密码·MCP Token)/权限管理(菜单+MCP·API)/询盘抽屉翻倍+PHP序列化数据清洗(22.7万行)+去裸JSON

This commit is contained in:
bigcode
2026-11-05 01:46:00 +08:00
parent c3df5d9db1
commit a018019504
47 changed files with 3008 additions and 830 deletions
+15
View File
@@ -78,6 +78,21 @@ bun test # 单元/集成测试
- 更新发布:rsync 源码后 `cd /home/docker/dusuniot && docker compose up -d --build app`。 - 更新发布:rsync 源码后 `cd /home/docker/dusuniot && docker compose up -d --build app`。
- `scripts/prod-server.ts` 的 `/uploads/*` 运行时直读磁盘(媒体上传后无需重启即可访问,本地裸跑同样受益)。 - `scripts/prod-server.ts` 的 `/uploads/*` 运行时直读磁盘(媒体上传后无需重启即可访问,本地裸跑同样受益)。
## v0.16.0 升级要点(后台体验)
- **数据清洗(生产必跑一次)**:询盘表存量 WordPress PHP 序列化串还原为 JSON/纯文本
(productInterest → 逗号连接、fields/utm 递归还原)。幂等,重复执行零更新:
`bun scripts/cleanup-php-serialize.ts`(先 `--dry` 核对)。
- **数据库迁移**:`users` 新增 avatar / locale / theme / mcp_token 四列(0001_user_profile.sql)。
- **后台 i18n**:默认英文,词典 `src/admin-i18n/locales/en.ts`(键=中文源串);
偏好存 localStorage(admin_locale / admin_theme)。新增语言时注册词典并在
`src/admin-i18n/shared.ts` 的 ADMIN_LOCALES 加选项;`tests/admin-i18n.test.ts` 强制 en 全覆盖。
- **主题/字号**:antd 主题 token 改动(fontSize 15、深色算法)后需重跑 `bun scripts/gen-antd-css.tsx`。
- **MCP 令牌**:个人设置页生成 `users.mcp_token`;MCP server 以
`DUSUN_MCP_TOKEN=<token> bun mcp/server.ts` 启动即校验(角色须在权限管理中允许 MCP)。
- **关站**:设置 → 关闭网站(settings.maintenance),前台 503 维护页,/admin 不受影响,
配置读取缓存 60s。
## 目录结构 ## 目录结构
见方案文档第 6 节。`src/server/*` 按业务域拆分(auth/content→data/products→data/filters/orders→commerce/inquiries/translations/seo/jobs)。 见方案文档第 6 节。`src/server/*` 按业务域拆分(auth/content→data/products→data/filters/orders→commerce/inquiries/translations/seo/jobs)。
+16 -1
View File
@@ -10,6 +10,7 @@
*/ */
import { SQL } from "bun"; import { SQL } from "bun";
import { parseImageSize } from "../src/server/image-size"; import { parseImageSize } from "../src/server/image-size";
import pkg from "../package.json";
/** # 成员变量 数据库连接(与站点同库;单连接足够 MCP 串行调用) **/ /** # 成员变量 数据库连接(与站点同库;单连接足够 MCP 串行调用) **/
const sql = new SQL(process.env.DATABASE_URL || "postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot", { max: 2 }); const sql = new SQL(process.env.DATABASE_URL || "postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot", { max: 2 });
@@ -451,6 +452,20 @@ function send(msg: Record<string, unknown>) {
/** # 类型 已初始化标记(initialize 之前拒绝 tools/* 调用) **/ /** # 类型 已初始化标记(initialize 之前拒绝 tools/* 调用) **/
let initialized = false; let initialized = false;
// ---- 访问令牌校验(v0.16.0)----
// 设置环境变量 DUSUN_MCP_TOKEN 后启动即进入令牌模式:值必须命中某后台角色用户的
// users.mcp_token(个人设置页生成),且该角色在 权限管理 里允许 MCP;否则进程退出。
// 未设置环境变量保持旧行为(本地直连受信任环境,不校验),向后兼容。
if (process.env.DUSUN_MCP_TOKEN) {
const { findMcpTokenUser } = await import("../src/server/permissions");
const hit = await findMcpTokenUser(process.env.DUSUN_MCP_TOKEN).catch(() => null);
if (!hit) {
console.error("dusuniot-cms: DUSUN_MCP_TOKEN 无效或该角色未获 MCP 权限,拒绝启动");
process.exit(1);
}
console.error(`dusuniot-cms: token auth ok (${hit})`);
}
/** # 函数 逐行解析 stdin 请求并分发(initialize / tools/list / tools/call / ping) */ /** # 函数 逐行解析 stdin 请求并分发(initialize / tools/list / tools/call / ping) */
for await (const chunk of Bun.stdin.stream()) { for await (const chunk of Bun.stdin.stream()) {
const text = new TextDecoder().decode(chunk); const text = new TextDecoder().decode(chunk);
@@ -472,7 +487,7 @@ for await (const chunk of Bun.stdin.stream()) {
result: { result: {
protocolVersion: "2024-11-05", protocolVersion: "2024-11-05",
capabilities: { tools: {} }, capabilities: { tools: {} },
serverInfo: { name: "dusuniot-cms", version: process.env.npm_package_version ?? "0.11.0" }, serverInfo: { name: "dusuniot-cms", version: pkg.version },
}, },
}); });
break; break;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dusuniot-rebuild", "name": "dusuniot-rebuild",
"version": "0.15.0", "version": "0.16.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
File diff suppressed because one or more lines are too long
+66
View File
@@ -0,0 +1,66 @@
/**
* # 脚本 清洗 inquiries 表中 WordPress 遗留的 PHP 序列化字符串
* 范围:product_interest(数组→逗号连接)、message(数组→换行连接)、
* fields / utm(jsonb 内递归还原)。
* 幂等:已清洗的行不再匹配序列化前缀,重复执行零更新。
* 运行:bun scripts/cleanup-php-serialize.ts [--dry]
*/
import { SQL } from "bun";
import { phpToPlain, deepConvertPhp, tryPhpUnserialize } from "../src/lib/php-serialize";
const dry = process.argv.includes("--dry");
const sql = new SQL(process.env.DATABASE_URL || "postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot", { max: 2 });
/** # 成员变量 疑似序列化前缀(SQL LIKE 预筛用,覆盖 a:/s:/i:/b:/d:/N;) **/
const LIKE = ["a:%", "s:%", "i:%", "b:%", "d:%", "N;%"];
/** # 函数 OR 拼接 LIKE 条件(jsonb::text 对全文预筛) */
function orLike(col: string): string {
return `(${LIKE.map((p) => `${col} like '${p}'`).join(" or ")})`;
}
let scanned = 0;
let updated = 0;
// ---- 1) 标量列:product_interest / message ----
for (const col of ["product_interest", "message"] as const) {
const rows = await sql.unsafe(
`select id, ${col} v from inquiries where ${orLike(col)} order by id`,
);
for (const r of rows as { id: number; v: string }[]) {
scanned++;
const u = tryPhpUnserialize(r.v);
if (u === undefined) continue;
// message 为多行文本:数组用换行连接;product_interest 逗号连接
const cleaned = col === "message" && Array.isArray(u)
? u.filter((x) => x !== null && x !== "").map(String).join("\n")
: phpToPlain(r.v);
updated++;
if (dry) { if (updated <= 5) console.log(`[${col}] #${r.id}: ${r.v.slice(0, 80)} -> ${cleaned.slice(0, 80)}`); continue; }
await sql`update inquiries set ${sql(col)} = ${cleaned} where id = ${r.id}`;
}
console.log(`${col}: scanned ${rows.length} suspicious rows`);
}
// ---- 2) jsonb 列:fields / utm(递归还原) ----
for (const col of ["fields", "utm"] as const) {
// jsonb::text 以 { 开头,序列化串出现在字符串值内部 → 包含匹配预筛(宽进严出,
// deepConvertPhp 只转换能完整解析的串,LIKE 误中会被安全跳过)
const contains = `(${LIKE.map((p) => `${col}::text like '%${p}' or ${col}::text like '%${p.slice(0, -1)}%'`).join(" or ")})`;
const rows = await sql.unsafe(
`select id, ${col} v from inquiries where ${contains} order by id`,
);
for (const r of rows as { id: number; v: unknown }[]) {
scanned++;
const conv = deepConvertPhp(r.v);
if (JSON.stringify(conv) === JSON.stringify(r.v)) continue;
updated++;
if (dry) { if (updated <= 5) console.log(`[${col}] #${r.id}: ${JSON.stringify(r.v).slice(0, 100)} -> ${JSON.stringify(conv).slice(0, 100)}`); continue; }
await sql`update inquiries set ${sql(col)} = ${JSON.stringify(conv)}::jsonb where id = ${r.id}`;
updated++;
}
console.log(`${col}: scanned ${rows.length} suspicious rows`);
}
console.log(dry ? `DRY RUN — scanned ${scanned}, would update ${updated}` : `done: scanned ${scanned}, updated ${updated} rows`);
sql.end();
+37
View File
@@ -0,0 +1,37 @@
/**
* # 脚本 收集后台 i18n 键并输出 en 词典草稿
* 扫描后台路由/组件中的 t("…") 静态键 + 动态标签映射键(从 admin-menus / common 导入),
* 打印当前 en 词典缺失的键清单(JSON 形式,便于翻译补齐)。
* 运行:bun scripts/collect-admin-i18n.ts [--all]
* --all 输出全部键;缺省只输出 en.ts 缺失的键。
*/
import { readdirSync } from "node:fs";
import { en } from "../src/admin-i18n/locales/en";
import { ADMIN_MENU_GROUPS } from "../src/admin-menus";
import { CONTENT_TYPE_LABEL, INQUIRY_STATUS_LABEL, ORDER_STATUS_LABEL } from "../src/components/admin/common";
const all = process.argv.includes("--all");
const files = [
...readdirSync("src/routes").filter((f) => f.startsWith("admin")).map((f) => `src/routes/${f}`),
...readdirSync("src/components/admin").map((f) => `src/components/admin/${f}`),
];
const keyRe = /\bt\(\s*"((?:[^"\\]|\\.)*)"/g;
const keys = new Set<string>();
for (const f of files) {
const src = await Bun.file(f).text();
for (const m of src.matchAll(keyRe)) keys.add(m[1]!);
}
// 动态键(label 映射值)
const dynamicKeys = [
...Object.values(ORDER_STATUS_LABEL),
...Object.values(INQUIRY_STATUS_LABEL),
...Object.values(CONTENT_TYPE_LABEL),
...ADMIN_MENU_GROUPS.flatMap((g) => [g.label, ...g.children.map((m) => m.label)]),
];
dynamicKeys.forEach((k) => keys.add(k));
const missing = [...keys].filter((k) => !(k in en)).sort();
const list = all ? [...keys].sort() : missing;
console.log(`total keys: ${keys.size}, ${all ? "dump all" : "missing in en"}: ${list.length}`);
for (const k of list) console.log(JSON.stringify(k));
+20 -1
View File
@@ -15,6 +15,7 @@ import { mkdirSync, writeFileSync } from "node:fs";
import { renderToString } from "react-dom/server"; import { renderToString } from "react-dom/server";
import { renderToString as _r } from "react-dom/server"; import { renderToString as _r } from "react-dom/server";
import { import {
Alert,
App, App,
Avatar, Avatar,
Button, Button,
@@ -23,6 +24,7 @@ import {
ConfigProvider, ConfigProvider,
Descriptions, Descriptions,
Drawer, Drawer,
Dropdown,
Empty, Empty,
Form, Form,
Image, Image,
@@ -31,6 +33,7 @@ import {
Layout, Layout,
Menu, Menu,
Modal, Modal,
Pagination,
Popconfirm, Popconfirm,
Row, Row,
Select, Select,
@@ -40,13 +43,15 @@ import {
Table, Table,
Tabs, Tabs,
Tag, Tag,
Tooltip,
Tree,
Typography, Typography,
} from "antd"; } from "antd";
import { createCache, StyleProvider, extractStyle } from "@ant-design/cssinjs"; import { createCache, StyleProvider, extractStyle } from "@ant-design/cssinjs";
/** # 成员变量 与 admin.tsx 完全一致的主题配置(改动后必须重新烘焙) /** # 成员变量 与 admin.tsx 完全一致的主题配置(改动后必须重新烘焙)
* cssVar.key 固定为 "dusun":变量作用域类名确定,烘焙 CSS 与运行时 DOM 一致 **/ * cssVar.key 固定为 "dusun":变量作用域类名确定,烘焙 CSS 与运行时 DOM 一致 **/
const THEME = { token: { colorPrimary: "#1677ff" }, cssVar: { key: "dusun" } }; const THEME = { token: { colorPrimary: "#1677ff", fontSize: 15 }, cssVar: { key: "dusun" } };
/** # 组件 覆盖后台全部 AntD 组件的样例树(每个组件至少渲染一次以注册其样式) **/ /** # 组件 覆盖后台全部 AntD 组件的样例树(每个组件至少渲染一次以注册其样式) **/
function Showcase() { function Showcase() {
@@ -158,6 +163,20 @@ function Showcase() {
<Drawer open title="抽屉" width={120}> <Drawer open title="抽屉" width={120}>
内容 内容
</Drawer> </Drawer>
{/* v0.16.0 后台新增组件:Dropdown(用户菜单)/ Tooltip(编辑器工具栏)/ Tree(权限页)/ Alert / Pagination */}
<Dropdown menu={{ items: [{ key: "a", label: "菜单项" }] }} open>
<Button>下拉</Button>
</Dropdown>
<Tooltip title="提示" open>
<span>悬停</span>
</Tooltip>
<Tree
checkable
defaultExpandAll
treeData={[{ title: "分组", key: "g", children: [{ title: "子项", key: "c" }] }]}
/>
<Alert type="info" showIcon message="提示" style={{ marginTop: 8 }} />
<Pagination size="small" total={50} style={{ marginTop: 8 }} />
</Layout.Content> </Layout.Content>
<Layout.Footer>F</Layout.Footer> <Layout.Footer>F</Layout.Footer>
</Layout> </Layout>
+4 -4
View File
@@ -44,18 +44,18 @@ let added = 0, removed = 0;
for (const [wpId, cats] of liveCats) { for (const [wpId, cats] of liveCats) {
const cid = contentByLegacy.get(wpId); const cid = contentByLegacy.get(wpId);
if (!cid) continue; if (!cid) continue;
const localRows = await sql`select category_id from content_terms where content_id = ${cid}`; const localRows = await sql`select category_id from content_terms where content_id = ${Number(cid)}`;
const localSet = new Set(localRows.map((r: any) => r.category_id as number)); const localSet = new Set<number>(localRows.map((r: any) => r.category_id as number));
const liveSet = new Set(cats.map((t) => catByLegacy.get(t)).filter((x): x is number => x != null)); const liveSet = new Set(cats.map((t) => catByLegacy.get(t)).filter((x): x is number => x != null));
for (const add of liveSet) { for (const add of liveSet) {
if (localSet.has(add)) continue; if (localSet.has(add)) continue;
added++; added++;
if (!dry) await sql`insert into content_terms (content_id, category_id) values (${cid}, ${add}) on conflict do nothing`; if (!dry) await sql`insert into content_terms (content_id, category_id) values (${cid}, ${Number(add)}) on conflict do nothing`;
} }
for (const rm of localSet) { for (const rm of localSet) {
if (liveSet.has(rm)) continue; if (liveSet.has(rm)) continue;
removed++; removed++;
if (!dry) await sql`delete from content_terms where content_id = ${cid} and category_id = ${rm}`; if (!dry) await sql`delete from content_terms where content_id = ${cid} and category_id = ${Number(rm)}`;
} }
} }
console.log(`分类成员同步: 新增 ${added} 移除 ${removed}${dry ? "(dry 未写库)" : ""}`); console.log(`分类成员同步: 新增 ${added} 移除 ${removed}${dry ? "(dry 未写库)" : ""}`);
+72
View File
@@ -0,0 +1,72 @@
/**
* # 模块 后台 i18n 核心(React 上下文 + t 函数)
* 词典以中文源串为 key(各页面直接 t("中文字符串")),en 词典提供英文译文,
* 缺词回退源串(中文)。默认英文:localStorage 未设置时 locale=en。
*/
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import {
getAdminLocalePref,
setAdminLocalePref,
type AdminDict,
type AdminLocaleDef,
} from "./shared";
import { en } from "./locales/en";
// 客户端安全部分原样转出
export { ADMIN_LOCALES, ADMIN_LOCALE_STORAGE_KEY, ADMIN_THEME_STORAGE_KEY, getAdminLocalePref, setAdminLocalePref, getAdminThemePref, setAdminThemePref } from "./shared";
export type { AdminDict, AdminLocaleDef, AdminTheme } from "./shared";
/** # 成员变量 词典注册表(locale → AdminDict;en 启动即注册) **/
const dicts = new Map<string, AdminDict>([["en", en]]);
/** # 函数 注册某语言的后台词典 */
export function registerAdminDict(code: string, dict: AdminDict): void {
dicts.set(code, dict);
}
/** # 函数 构造后台翻译函数:按词典查 key,缺词回退源串(中文),支持 {var} 插值 */
export function createAdminT(locale: string) {
const d = dicts.get(locale);
return (key: string, vars?: Record<string, string | number>): string => {
let s: string = d?.[key] ?? key;
if (vars) for (const [k, v] of Object.entries(vars)) s = s.replaceAll(`{${k}}`, String(v));
return s;
};
}
/** # 类型 后台 t 函数 **/
export type AdminT = ReturnType<typeof createAdminT>;
// ---- React 上下文 ----
/** # 成员变量 后台语言上下文(默认英文) **/
const AdminLocaleContext = createContext<{ locale: string; setLocale: (code: string) => void; t: AdminT }>({
locale: "en",
setLocale: () => {},
t: createAdminT("en"),
});
/** # 组件 后台语言提供者(纯客户端:SSR 渲染英文,水合后读 localStorage 切换,无水合不一致) */
export function AdminLocaleProvider(props: { children: ReactNode }) {
const [locale, setLocaleState] = useState("en");
// 水合后应用本地偏好(effect 中 setState 不触发水合校验)
useEffect(() => {
setLocaleState(getAdminLocalePref());
}, []);
/** # 函数 切换后台语言:写 localStorage → 重渲染(组件树即时切换,无需整页刷新) */
const setLocale = (code: string) => {
setAdminLocalePref(code);
setLocaleState(code);
};
const t = createAdminT(locale);
return <AdminLocaleContext.Provider value={{ locale, setLocale, t }}>{props.children}</AdminLocaleContext.Provider>;
}
/** # 函数 组件内取后台语言上下文(locale / setLocale / t) */
export function useAdminI18n() {
return useContext(AdminLocaleContext);
}
/** # 函数 组件内取后台翻译函数(便捷别名) */
export function useAdminT(): AdminT {
return useContext(AdminLocaleContext).t;
}
+540
View File
@@ -0,0 +1,540 @@
/**
* # 词典 后台英文翻译(中文源串 → English)
* key 为界面中文源串,value 为英文译文;后台默认语言 en,缺词回退源串。
* 由 scripts/collect-admin-i18n.ts 收集键(tests/admin-i18n.test.ts 强制覆盖)。
*/
import type { AdminDict } from "../shared";
/** # 成员变量 英文词典 **/
export const en: AdminDict = {
// ---- 通用 ----
"保存": "Save",
"取消": "Cancel",
"删除": "Delete",
"编辑": "Edit",
"预览": "Preview",
"复制": "Copy",
"清除": "Clear",
"查看详情": "View details",
"返回列表": "Back to list",
"操作": "Actions",
"状态": "Status",
"类型": "Type",
"名称": "Name",
"标题": "Title",
"别名": "Slug",
"排序": "Order",
"标签": "Tags",
"分类": "Categories",
"价格": "Price",
"金额": "Amount",
"数量": "Qty",
"单价": "Unit price",
"小计": "Subtotal",
"总额": "Total",
"时间": "Time",
"更新时间": "Updated",
"上传时间": "Uploaded",
"最后更新": "Last updated",
"版本": "Version",
"路径": "Path",
"链接": "Link",
"目标": "Target",
"来源": "Source",
"备注": "Note",
"邮箱": "Email",
"密码": "Password",
"电话": "Phone",
"公司": "Company",
"国家": "Country",
"姓名": "Name",
"代码": "Code",
"语言": "Language",
"全部": "All",
"是": "Yes",
"否": "No",
"默认": "Default",
"已保存": "Saved",
"保存成功": "Saved successfully",
"保存失败": "Save failed",
"已删除": "Deleted",
"已保存「{name}」": "Saved \"{name}\"",
"操作失败": "Operation failed",
"保存成功(已留修订快照)": "Saved (revision snapshot created)",
"共 {n} 条": "{n} items",
"共 {n} 条,其中垃圾 {s} 条({p}%)": "{n} total, {s} spam ({p}%)",
"…(截断预览)": "… (truncated preview)",
"(空正文)": "(empty body)",
"(顶层)": "(top level)",
// ---- 布局 / 登录 ----
"DusunIoT 后台": "DusunIoT Admin",
"登录": "Sign in",
"退出登录": "Sign out",
"已退出登录": "Signed out",
"个人设置": "Profile settings",
"请使用管理员账号登录": "Sign in with an administrator account",
"当前账号无后台权限,请更换管理员账号": "This account has no admin access. Try another administrator account.",
"请输入邮箱": "Please enter your email",
"请输入密码": "Please enter your password",
"登录失败,请稍后重试": "Sign-in failed. Please try again later.",
"人机验证未完成,请完成勾选/等待验证脚本加载后重试": "Human verification not completed. Check the box or wait for the script to load, then retry.",
"主题切换": "Toggle theme",
"浅色": "Light",
"深暗": "Dark",
// ---- 菜单 ----
"概览": "Overview",
"内容": "Content",
"商务": "Commerce",
"系统": "System",
"Dashboard": "Dashboard",
"页面 / 文章": "Pages / Posts",
"菜单": "Menus",
"媒体库": "Media",
"产品": "Products",
"订单": "Orders",
"询盘": "Inquiries",
"筛选": "Filters",
"SEO / 重定向": "SEO / Redirects",
"用户": "Users",
"权限管理": "Permissions",
"系统设置": "Settings",
"备份": "Backups",
"审计日志": "Audit log",
// ---- Dashboard ----
"待处理订单": "Pending orders",
"新询盘(待跟进)": "New inquiries (to follow up)",
"跟进新询盘": "Follow up new inquiries",
"全部订单 →": "All orders →",
"全部询盘 →": "All inquiries →",
"快捷操作": "Quick actions",
"最近订单": "Recent orders",
"最新询盘": "Latest inquiries",
"近 30 天订单 / 询盘趋势": "Orders / inquiries — last 30 days",
"订单(30 天环比)": "Orders (30d)",
"询盘漏斗": "Inquiry funnel",
"营收(30 天环比)": "Revenue (30d)",
"每日营收($)": "Daily revenue ($)",
"订单号": "Order No.",
"客户姓名": "Customer",
"下单时间": "Placed at",
// ---- 内容 ----
"内容管理": "Content",
"新建内容": "New content",
"新建页面": "New page",
"页面 page": "Page",
"文章 post": "Post",
"落地页 landing": "Landing page",
"页面": "Page",
"文章": "Post",
"落地页": "Landing page",
"草稿": "Draft",
"已发布": "Published",
"内容标题": "Content title",
"搜索标题 / 别名": "Search title / slug",
"确认删除该内容?": "Delete this content?",
"编辑内容 #{id}": "Edit content #{id}",
"别名(留空自动生成;即前台 URL /slug/)": "Slug (auto-generated if empty; public URL /slug/)",
"SEO 标题": "SEO title",
"SEO 描述": "SEO description",
"特色图": "Featured image",
"搜索媒体文件名选择特色图": "Search media to pick a featured image",
"分类目录": "Categories",
"选择分类与标签": "Pick categories & tags",
"选择分类": "Pick categories",
"正文(富文本编辑,Markdown 快捷语法自动转换)": "Body (rich text; Markdown shortcuts auto-convert)",
"可视化编辑;需要精细控制时可切 HTML 源码。无 Elementor JSON 时前台按此渲染": "Visual editing; switch to HTML source for fine control. Rendered on the site when no Elementor JSON exists.",
"Elementor JSON(meta.elementor,优先于 HTML 渲染)": "Elementor JSON (meta.elementor; takes priority over HTML)",
"修订历史": "Revisions",
"修订历史(每次保存自动留档)": "Revisions (auto-snapshot on every save)",
"暂无修订": "No revisions",
"修订 ID": "Revision ID",
"修订人": "By",
"查看 / 恢复": "View / restore",
"恢复此版本": "Restore this version",
"恢复修订 #{id}({time})": "Restore revision #{id} ({time})",
"已恢复(恢复前版本已再留档)": "Restored (previous version snapshotted)",
"标题快照": "Title snapshot",
// ---- 菜单管理 ----
"菜单管理": "Menus",
"新增菜单项": "New menu item",
"父级菜单项": "Parent item",
"显示文字": "Label",
"链接地址": "URL",
"打开方式": "Open in",
"当前窗口": "Same window",
"新窗口": "New window",
"位置": "Location",
"前台 Header/Footer 即时读取所选位置菜单": "The site Header/Footer reads the menu of the selected location in real time.",
"{name}({location})": "{name} ({location})",
"编辑菜单项 #{id}": "Edit menu item #{id}",
"已删除(子项已提升为顶层)": "Deleted (children moved to top level)",
"请输入名称": "Please enter a name",
"请输入显示文字": "Please enter a label",
"请输入链接": "Please enter a URL",
// ---- 媒体库 ----
"上传媒体": "Upload media",
"搜索媒体文件名": "Search media by filename",
"搜索文件路径": "Search file path",
"选择媒体": "Choose media",
"上传图片": "Upload images",
"暂无媒体,先上传": "No media yet — upload first",
"替代文本": "Alt text",
"替代文本(alt)": "Alt text",
"尺寸": "Dimensions",
"大小": "Size",
"已上传 {name}(#{id})": "Uploaded {name} (#{id})",
"上传失败": "Upload failed",
"编辑媒体 #{id}": "Edit media #{id}",
// ---- 产品 ----
"产品管理": "Products",
"新建产品": "New product",
"编辑产品 #{id}": "Edit product #{id}",
"搜索标题 / SKU / 别名": "Search title / SKU / slug",
"产品缩略图": "Thumbnail",
"产品名称": "Product name",
"请输入产品名称": "Please enter the product name",
"产品分类": "Categories",
"产品属性": "Attributes",
"关联属性": "Linked attributes",
"属性": "Attribute",
"仅属性类型需要": "Attribute types only",
"选择 {name} 属性值": "Pick values for {name}",
"如 Chip Platform": "e.g. Chip Platform",
"图集(可多选)": "Gallery (multiple)",
"库存": "Stock",
"库存数量": "Stock qty",
"库存状态": "Stock status",
"有货": "In stock",
"缺货": "Out of stock",
"预订": "On backorder",
"现价(USD)": "Price (USD)",
"原价(USD)": "Regular price (USD)",
"短描述": "Short description",
"详细描述(富文本编辑)": "Full description (rich text)",
"规格书地址": "Datasheet URL",
"主推产品": "Featured",
"产品路由前缀": "Product route prefix",
"分类路由前缀": "Category route prefix",
"标签路由前缀": "Tag route prefix",
"确认删除该产品?": "Delete this product?",
// ---- 订单 ----
"订单管理": "Orders",
"订单信息": "Order details",
"订单明细": "Line items",
"订单状态": "Order status",
"订单 {n}": "Order {n}",
"单号": "Order No.",
"商品名": "Item",
"状态流转": "Status",
"支付状态": "Payment status",
"支付方式": "Payment method",
"运费 / 税费": "Shipping / tax",
"客户邮箱": "Customer email",
"客户备注": "Customer note",
"账单信息": "Billing",
"收货信息": "Shipping address",
"待支付": "Pending payment",
"挂起": "On hold",
"处理中": "Processing",
"已完成": "Completed",
"已取消": "Cancelled",
"已退款": "Refunded",
"状态已更新": "Status updated",
"名": "First name",
"姓": "Last name",
"地址 1": "Address 1",
"地址 2": "Address 2",
"城市": "City",
"州 / 省": "State / Province",
"邮编": "Postcode",
// ---- 询盘 ----
"询盘管理": "Inquiries",
"搜索姓名 / 邮箱 / 公司": "Search name / email / company",
"新询盘": "New",
"已读": "Read",
"已回复": "Replied",
"垃圾": "Spam",
"归档": "Archived",
"感兴趣产品": "Product of interest",
"询盘内容": "Message",
"来源页面": "Source page",
"其他提交字段": "Other submitted fields",
"标记已读": "Mark as read",
"标记已回复": "Mark as replied",
"标记垃圾": "Mark as spam",
// ---- 筛选 ----
"筛选配置": "Filters",
"新增筛选": "New filter",
"编辑筛选 #{id}": "Edit filter #{id}",
"确认删除该筛选?": "Delete this filter?",
"URL 参数": "URL parameter",
"URL 参数名": "URL parameter name",
"请输入 URL 参数名": "Please enter the URL parameter name",
// ---- 语言 ----
"语言配置": "Languages",
"共 {n} 种语言": "{n} languages",
"启用": "Enabled",
"停用": "Disabled",
"机翻": "Machine-translated",
"切换语言 {code} 启用状态": "Toggle language {code}",
"已启用 {code}": "{code} enabled",
"已禁用 {code}": "{code} disabled",
"启用后该语言会生成真实页面;默认语言不可禁用逻辑由前台渲染决定": "Enabled languages get real pages; default-language protection is handled by the front-end renderer.",
// ---- SEO / 重定向 ----
"SEO": "SEO",
"重定向管理": "Redirects",
"新增重定向": "New redirect",
"编辑重定向 #{id}": "Edit redirect #{id}",
"确认删除该重定向?": "Delete this redirect?",
"源路径": "Source path",
"源路径(支持正则)": "Source path (regex supported)",
"目标地址": "Target URL",
"正则": "Regex",
"正则匹配": "Regex match",
"状态码": "Status code",
"命中": "Hits",
"搜索源路径 / 目标": "Search source / target",
"请输入源路径": "Please enter the source path",
"请输入目标地址": "Please enter the target URL",
"/old-path 或 /blog/(.*)": "/old-path or /blog/(.*)",
"/new-path 或 https://…": "/new-path or https://…",
"切换重定向 {source}": "Toggle redirect {source}",
"全站 robots": "Site robots",
"all(允许索引)": "all (index allowed)",
"noindex(禁止索引)": "noindex (no index)",
"启用 sitemap.xml": "Enable sitemap.xml",
"标题模板": "Title template",
"%title% 为页面标题占位符": "%title% is the page-title placeholder",
// ---- 用户 ----
"客户": "Customer",
"管理员": "Administrator",
"经理": "Manager",
"编辑员": "Editor",
"搜索邮箱 / 显示名": "Search email / display name",
"新建用户": "New user",
"用户已创建": "User created",
"显示名": "Display name",
"角色": "Role",
"初始密码": "Initial password",
"至少 8 位": "At least 8 characters",
"注册时间": "Created at",
"重置密码": "Reset password",
"后台用户": "Backend users",
"普通用户": "Customers",
"留空取邮箱前缀": "Leave empty to use the email prefix",
"请输入有效邮箱": "Please enter a valid email",
// ---- 权限 ----
"权限配置已保存": "Permissions saved",
"按角色配置菜单功能权限与 MCP / API 访问。admin 角色恒为全量,不可编辑;未勾选任何菜单的角色无法访问对应后台页面。": "Configure menu access and MCP / API permissions per role. The admin role always has full access and cannot be edited; roles with no menu checked cannot reach the corresponding admin pages.",
// ---- 个人设置 ----
"基本资料": "Profile",
"头像": "Avatar",
"从媒体库选择": "Choose from media",
"默认语言": "Default language",
"默认主题": "Default theme",
"保存后立即生效,并作为下次登录的默认语言": "Applies immediately and is saved as your default for next sign-in.",
"保存后立即生效,并作为下次登录的默认主题": "Applies immediately and is saved as your default for next sign-in.",
"个人资料已保存": "Profile saved",
"修改密码": "Change password",
"当前密码": "Current password",
"新密码": "New password",
"确认新密码": "Confirm new password",
"请输入当前密码": "Please enter your current password",
"请再次输入新密码": "Please re-enter the new password",
"两次输入不一致": "Passwords do not match",
"密码已修改": "Password changed",
"修改失败": "Change failed",
"确认修改密码?": "Change password?",
"修改成功后,其他已登录设备将被退出。": "After changing, you will be signed out on other devices.",
"确认修改": "Change",
"生成令牌": "Generate token",
"重新生成": "Regenerate",
"重新生成 MCP Token?": "Regenerate MCP token?",
"旧令牌将立即失效,使用旧令牌的客户端需要更新配置。": "The old token is revoked immediately; clients using it must be reconfigured.",
"清除 MCP Token?": "Clear MCP token?",
"清除后使用该令牌的客户端将无法访问。": "Clients using this token will lose access.",
"已生成新令牌": "New token generated",
"已清除令牌": "Token cleared",
"已复制到剪贴板": "Copied to clipboard",
"复制失败,请手动选择复制": "Copy failed — please copy manually",
"尚未生成令牌": "No token yet",
"用于 Agent / MCP 客户端访问本站 MCP 服务的个人访问令牌。令牌等同于你的账号权限,请妥善保管;泄露时请立即重新生成。": "A personal access token for Agents / MCP clients to use this site's MCP service. It grants your account's permissions — keep it safe and regenerate immediately if leaked.",
// ---- 设置 ----
"仅管理员可保存": "Admin only",
"站点信息": "Site",
"站点名称": "Site name",
"站点 URL": "Site URL",
"货币": "Currency",
"时区": "Timezone",
"商城配置": "Shop",
"游客结账": "Guest checkout",
"允许未注册用户下单": "Allow ordering without an account",
"价格含税": "Prices include tax",
"结账时引导注册": "Prompt sign-up at checkout",
"销售地区": "Sell to",
"全部国家": "All countries",
"全部(除特定地区)": "All except specific countries",
"仅指定地区": "Specific countries only",
"邮件配置": "Email",
"询盘通知 / 订单邮件的 SMTP 发信配置;修改后建议发一封测试邮件验证。": "SMTP settings for inquiry notifications and order emails; send a test email after changing.",
"发件人名称": "From name",
"发件邮箱": "From email",
"SMTP 主机": "SMTP host",
"SMTP 端口": "SMTP port",
"加密方式": "Encryption",
"不加密": "None",
"SSL(465)": "SSL (465)",
"STARTTLS(587)": "STARTTLS (587)",
"SMTP 账号": "SMTP user",
"SMTP 密码/授权码": "SMTP password",
"其他": "Other",
"以下键无表单定义(由脚本或系统内部写入),此处只读展示。": "These keys have no form definition (written by scripts or internals); shown read-only here.",
"登录安全": "Sign-in security",
"控制台": "console",
"申请;v2 为「我是人类」复选框,v3 为隐形评分。开启后 /admin 与 /my-account 登录均需通过验证;超过失败次数上限的账号将锁定指定分钟。": "Apply at reCAPTCHA; v2 is the \"I'm not a robot\" checkbox, v3 is invisible scoring. When enabled, both /admin and /my-account sign-ins require verification; accounts exceeding the failure limit are locked for the configured minutes.",
"启用 reCAPTCHA": "Enable reCAPTCHA",
"关闭时登录流程保持现状,不加载 Google 脚本": "When off, sign-in stays as-is and no Google script loads",
"v2 复选框": "v2 checkbox",
"v3 隐形评分": "v3 invisible",
"Site Key(站点密钥)": "Site Key",
"Secret Key(服务端密钥)": "Secret Key",
"v3 评分阈值": "v3 score threshold",
"0~1,低于该分数判定为机器人,默认 0.5": "0–1; below this score is treated as a bot. Default 0.5",
"失败次数上限": "Max failed attempts",
"同一账号连续失败达上限即锁定": "The account is locked after this many consecutive failures",
"锁定时长(分钟)": "Lock duration (minutes)",
"关闭网站": "Site offline",
"关闭网站(维护模式)": "Site offline (maintenance mode)",
"开启后前台所有页面立即显示下方维护内容(返回 503),后台 /admin 不受影响。可用于临时停机维护或站点下线公告。": "When on, every public page immediately shows the maintenance content below (HTTP 503); /admin is unaffected. Use for planned downtime or an outage notice.",
"开启后前台立即只显示维护页": "When on, the public site shows only the maintenance page",
"维护页内容": "Maintenance page content",
"支持富文本 / HTML(图片可从媒体库选择),将显示在维护页中央": "Rich text / HTML (images from the media library); shown in the center of the maintenance page.",
// ---- 审计 ----
"动作": "Action",
"操作人": "Actor",
"实体": "Entity",
"实体 ID": "Entity ID",
"摘要": "Summary",
"按动作过滤(如 content.save)": "Filter by action (e.g. content.save)",
// ---- 补充(内容/产品域增量) ----
"ID": "ID",
"分类 / 标签": "Categories / Tags",
"引用数": "Uses",
"别名(留空自动生成)": "Slug (auto-generated if empty)",
"前台预览": "Front-end preview",
"图": "Image",
"模板": "Template",
"浏览": "Views",
"留空不限": "Leave empty for no limit",
"请输入标题": "Please enter a title",
"/products/ 或 https://…": "/products/ or https://…",
// ---- 用户管理 / 备份 / 编辑器(共享组件增量) ----
"用户管理": "Users",
"角色已更新": "Role updated",
"更新失败": "Update failed",
"创建失败": "Create failed",
"密码已重置": "Password reset",
"重置失败": "Reset failed",
"重置": "Reset",
"重置密码:{email}": "Reset password: {email}",
"新密码(至少 8 位)": "New password (at least 8 characters)",
"确定": "OK",
"未登录或权限不足,请使用管理员账号登录后台": "Not signed in or insufficient permissions. Sign in with an administrator account.",
"暂无数据": "No data",
"备份管理(PostgreSQL 18 物理备份)": "Backups (PostgreSQL 18 physical backup)",
"刷新": "Refresh",
"全量": "Full",
"增量": "Incremental",
"全量备份": "Full backup",
"增量备份": "Incremental backup",
"定时备份": "Scheduled backup",
"定时备份由 jobs worker(`bun scripts/jobs-worker.ts`)消费 `backup.run` 任务执行:自动选择增量(无备份时全量),完成后按间隔自排下一轮。": "Scheduled backups run via the jobs worker (`bun scripts/jobs-worker.ts`) consuming `backup.run` tasks: incremental automatically (full when none exists), self-scheduling the next round by interval.",
"间隔(小时)": "Interval (hours)",
"保存配置": "Save settings",
"备份配置已保存": "Backup settings saved",
"备份名": "Backup name",
"数据目录": "Data directory",
"已打包": "Packed",
"未打包": "Not packed",
"暂无备份(建议先做一次全量)": "No backups yet (a full backup is recommended first)",
"备份失败": "Backup failed",
"备份完成:{name}({size}{s3})": "Backup done: {name} ({size}{s3})",
",已上传 S3": ", uploaded to S3",
"删除备份 {name}?": "Delete backup {name}?",
"仅删除该增量备份,不影响全量与其他增量。": "Only this incremental backup is deleted; the full backup and other incrementals are unaffected.",
"该全量之后的所有增量将无法合并恢复,请确认不再需要这条备份链。": "All incrementals after this full backup will no longer merge for restore. Confirm this backup chain is no longer needed.",
"S3 归档与保留策略": "S3 archive & retention",
"S3 已配置": "S3 configured",
"未配置(仅本地)": "Not configured (local only)",
"S3 暂无归档对象": "No S3 archive objects",
"S3 对象": "S3 object",
"S3 Endpoint(S3 兼容,如 https://s3.endpoint)": "S3 endpoint (S3-compatible, e.g. https://s3.endpoint)",
"https://s3.us-east-1.amazonaws.com 或自建端点": "https://s3.us-east-1.amazonaws.com or self-hosted endpoint",
"对象前缀": "Object prefix",
"保留全量链数": "Full-backup chains to keep",
"恢复步骤(示例):`docker exec -u postgres dusuniot-pg pg_combinebackup -o /tmp/restore /backups/<全量> /backups/<增量1> /backups/<增量2>` → 停库后把合并目录作为 PGDATA 启动。S3 归档为 tar.gz,解包后同法操作。": "Restore steps (example): `docker exec -u postgres dusuniot-pg pg_combinebackup -o /tmp/restore /backups/<full> /backups/<incr1> /backups/<incr2>` → stop the database, then start with the merged directory as PGDATA. S3 archives are tar.gz; unpack and follow the same steps.",
"增量备份基于 PG 17+ summarize_wal(当前 18.6 已开启):只落全量以来的变更块;恢复用 pg_combinebackup 合并「全量 + 其后全部增量」": "Incremental backups rely on PG 17+ summarize_wal (enabled on 18.6): only changed blocks since the last full backup are stored; restore merges \"full + all later incrementals\" with pg_combinebackup.",
"富文本工具栏": "Rich text toolbar",
"撤销": "Undo",
"重做": "Redo",
"正文": "Body",
"标题 H2": "Heading H2",
"标题 H3": "Heading H3",
"标题 H4": "Heading H4",
"加粗": "Bold",
"斜体": "Italic",
"下划线": "Underline",
"删除线": "Strikethrough",
"行内代码": "Inline code",
"无序列表": "Bulleted list",
"有序列表": "Numbered list",
"引用": "Blockquote",
"分隔线": "Horizontal rule",
"代码块": "Code block",
"取消链接": "Remove link",
"插入图片(本地上传)": "Insert image (upload)",
"媒体库选图": "Pick from media",
"插入表格 3×3": "Insert table 3×3",
"下方插入行": "Insert row below",
"右侧插入列": "Insert column right",
"删除当前行": "Delete row",
"删除当前列": "Delete column",
"删除表格": "Delete table",
"清除格式": "Clear formatting",
"Markdown 导入": "Markdown import",
"HTML 源码": "HTML source",
"返回可视化编辑": "Back to visual editing",
"插入 / 编辑链接": "Insert / edit link",
"移除": "Remove",
"插入到光标处": "Insert at cursor",
"替换正文": "Replace body",
"导入 Markdown": "Import Markdown",
"支持 # 标题、**加粗**、- 列表、[链接](url)、![图片](url)、表格等(marked 解析)。日常输入时编辑器已自动转换 Markdown 快捷语法。": "Supports # headings, **bold**, - lists, [link](url), ![image](url), tables, etc. (parsed by marked). Markdown shortcuts already convert while typing.",
"Markdown 内容": "Markdown content",
"## 标题\\n\\n正文 **加粗** …": "## Heading\\n\\nBody **bold** …",
"表格": "Table",
"加载媒体失败": "Failed to load media",
"已上传 {name}": "Uploaded {name}",
"reCAPTCHA 脚本加载失败,请检查网络(需可访问 google.com)后刷新重试。": "reCAPTCHA script failed to load. Check your network (google.com must be reachable) and refresh.",
};
+52
View File
@@ -0,0 +1,52 @@
/**
* # 模块 后台 i18n 客户端共享层(不引入 server 模块)
* 词典类型:key 为中文源串,value 为目标语言译文;缺词回退源串(即中文)。
* 偏好存 localStorage:admin_locale / admin_theme(用户设置页可保存到账号作为默认值)。
*/
/** # 类型 后台词典(中文源串 → 译文) **/
export type AdminDict = Record<string, string>;
/** # 常量 后台语言 localStorage 键 **/
export const ADMIN_LOCALE_STORAGE_KEY = "admin_locale";
/** # 常量 后台主题 localStorage 键 **/
export const ADMIN_THEME_STORAGE_KEY = "admin_theme";
/** # 类型 后台支持的语言(与全站语言列表一致;未注册词典的语言回退英文/源串) **/
export type AdminLocaleDef = { code: string; label: string };
/** # 常量 后台语言选择器选项(有后台词典的语言排前) **/
export const ADMIN_LOCALES: AdminLocaleDef[] = [
{ code: "en", label: "English" },
{ code: "zh-CN", label: "中文 (简体)" },
];
/** # 函数 客户端读取后台语言偏好(无/非法回退 en) */
export function getAdminLocalePref(): string {
if (typeof localStorage === "undefined") return "en";
const v = localStorage.getItem(ADMIN_LOCALE_STORAGE_KEY);
return v && ADMIN_LOCALES.some((l) => l.code === v) ? v : "en";
}
/** # 函数 客户端写入后台语言偏好 */
export function setAdminLocalePref(code: string): void {
if (typeof localStorage === "undefined") return;
localStorage.setItem(ADMIN_LOCALE_STORAGE_KEY, code);
}
/** # 类型 后台主题 **/
export type AdminTheme = "light" | "dark";
/** # 函数 客户端读取后台主题偏好(无/非法回退 light) */
export function getAdminThemePref(): AdminTheme {
if (typeof localStorage === "undefined") return "light";
const v = localStorage.getItem(ADMIN_THEME_STORAGE_KEY);
return v === "dark" ? "dark" : "light";
}
/** # 函数 客户端写入后台主题偏好 */
export function setAdminThemePref(theme: AdminTheme): void {
if (typeof localStorage === "undefined") return;
localStorage.setItem(ADMIN_THEME_STORAGE_KEY, theme);
}
+76
View File
@@ -0,0 +1,76 @@
/**
* # 模块 后台菜单清单(客户端安全,无 server 依赖)
* 权限页与服务端 requireCapability 共用本清单;admin.tsx 渲染时补充图标。
* label 为中文源串(配合后台 i18n 直接作 t() 键)。
*/
/** # 类型 后台菜单项定义 **/
export type AdminMenuDef = {
/** # 成员变量 菜单路由 key **/
key: string;
/** # 成员变量 中文标签(i18n 源串) **/
label: string;
};
/** # 类型 后台能力域(与 auth.ts AdminCapability 一致:content/commerce/system) **/
export type AdminCapabilityKey = "content" | "commerce" | "system";
/** # 常量 后台菜单分组(分组 label 同样为 i18n 源串;key 即目标路由) **/
export const ADMIN_MENU_GROUPS: { label: string; children: AdminMenuDef[] }[] = [
{
label: "概览",
children: [{ key: "/admin/", label: "Dashboard" }],
},
{
label: "内容",
children: [
{ key: "/admin/contents/", label: "页面 / 文章" },
{ key: "/admin/menus/", label: "菜单" },
{ key: "/admin/media/", label: "媒体库" },
],
},
{
label: "商务",
children: [
{ key: "/admin/products/", label: "产品" },
{ key: "/admin/orders/", label: "订单" },
{ key: "/admin/inquiries/", label: "询盘" },
],
},
{
label: "系统",
children: [
{ key: "/admin/filters/", label: "筛选" },
{ key: "/admin/languages/", label: "语言" },
{ key: "/admin/seo/", label: "SEO / 重定向" },
{ key: "/admin/users/", label: "用户" },
{ key: "/admin/permissions/", label: "权限管理" },
{ key: "/admin/settings/", label: "系统设置" },
{ key: "/admin/backups/", label: "备份" },
{ key: "/admin/audit/", label: "审计日志" },
],
},
];
/** # 成员变量 全部菜单扁平表(key → 定义;不含分组) **/
export const ADMIN_MENU_FLAT: Map<string, AdminMenuDef> = new Map(
ADMIN_MENU_GROUPS.flatMap((g) => g.children).map((m) => [m.key, m]),
);
/** # 常量 能力域 → 归属菜单(服务端 requireCapability 用:角色在该域至少拥有一个菜单才放行) **/
export const CAPABILITY_MENUS: Record<AdminCapabilityKey, string[]> = {
content: ["/admin/contents/", "/admin/menus/", "/admin/media/", "/admin/filters/", "/admin/seo/"],
commerce: ["/admin/products/", "/admin/orders/", "/admin/inquiries/"],
system: ["/admin/users/", "/admin/permissions/", "/admin/settings/", "/admin/backups/", "/admin/audit/", "/admin/languages/"],
};
/** # 常量 后台角色(含中文标签,权限页/用户页共用) **/
export const ADMIN_ROLES = ["admin", "editor", "manager"] as const;
/** # 函数 角色的默认菜单授权(未配置权限时的兜底:admin 全量,manager 内容+商务,editor 内容) */
export function defaultMenusForRole(role: string): string[] {
if (role === "admin") return ADMIN_MENU_GROUPS.flatMap((g) => g.children.map((m) => m.key));
if (role === "manager") return [...CAPABILITY_MENUS.content, ...CAPABILITY_MENUS.commerce];
if (role === "editor") return [...CAPABILITY_MENUS.content];
return [];
}
+61
View File
@@ -0,0 +1,61 @@
/**
* # 模块 后台主题(浅色 / 深暗)
* AdminThemeProvider:纯客户端组件 —— SSR 恒渲染浅色(与烘焙 CSS 一致,防闪烁),
* 水合后读 localStorage 应用偏好;ConfigProvider 按 theme 切换 antd 算法
* (defaultAlgorithm / darkAlgorithm),cssVar 开启使 .dusun 变量块整体翻转,
* 页面内联样式一律用 var(--ant-*) 取色即可自适应深色。
*/
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { App, ConfigProvider, theme as antdTheme } from "antd";
import { StyleProvider } from "@ant-design/cssinjs";
import { getAdminThemePref, setAdminThemePref, type AdminTheme } from "~/admin-i18n/shared";
/** # 类型 主题上下文 **/
type ThemeCtx = {
/** # 成员变量 当前主题 **/
theme: AdminTheme;
/** # 成员变量 切换主题(写 localStorage 并重渲染) **/
setTheme: (t: AdminTheme) => void;
};
/** # 成员变量 主题上下文(默认浅色) **/
const Ctx = createContext<ThemeCtx>({ theme: "light", setTheme: () => {} });
/** # 成员变量 后台统一主题 token(字号放大一号:14 → 15;主题变更后需重跑 scripts/gen-antd-css.tsx) **/
export const ADMIN_THEME_TOKEN = { colorPrimary: "#1677ff", fontSize: 15 };
/** # 组件 后台主题 + AntD 全局包装(语言上下文之外的所有页面都应在其中) */
export function AdminThemeProvider(props: { children: ReactNode }) {
const [theme, setThemeState] = useState<AdminTheme>("light");
// 水合后应用本地偏好(防 SSR/CSR 首帧不一致)
useEffect(() => {
setThemeState(getAdminThemePref());
}, []);
/** # 函数 切换主题:写 localStorage → 重渲染 */
const setTheme = (t: AdminTheme) => {
setAdminThemePref(t);
setThemeState(t);
};
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return (
<Ctx.Provider value={value}>
<ConfigProvider
theme={{
token: ADMIN_THEME_TOKEN,
algorithm: theme === "dark" ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
cssVar: { key: "dusun" },
}}
>
{/* hashPriority=high:运行时选择器不带哈希前缀,与烘焙 CSS(antd-baked.css)保持一致 */}
<StyleProvider hashPriority="high">
<App>{props.children}</App>
</StyleProvider>
</ConfigProvider>
</Ctx.Provider>
);
}
/** # 函数 组件内取主题上下文(theme / setTheme) */
export function useAdminTheme(): ThemeCtx {
return useContext(Ctx);
}
+11 -9
View File
@@ -6,6 +6,7 @@ import { App, Button, Image, Input, Modal, Pagination, Space, Spin, Upload } fro
import { SearchOutlined, UploadOutlined } from "@ant-design/icons"; import { SearchOutlined, UploadOutlined } from "@ant-design/icons";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { adminListMedia } from "~/server/admin"; import { adminListMedia } from "~/server/admin";
import { useAdminT } from "~/admin-i18n";
import { uploadMediaFile } from "~/utils/media-upload"; import { uploadMediaFile } from "~/utils/media-upload";
/** # 类型 媒体行(弹窗网格所需最小字段) **/ /** # 类型 媒体行(弹窗网格所需最小字段) **/
@@ -19,6 +20,7 @@ export function MediaPickerModal(props: {
onlyImages?: boolean; onlyImages?: boolean;
}) { }) {
const { message } = App.useApp(); const { message } = App.useApp();
const t = useAdminT();
const [items, setItems] = useState<MediaItem[]>([]); const [items, setItems] = useState<MediaItem[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -35,7 +37,7 @@ export function MediaPickerModal(props: {
setItems(list); setItems(list);
setTotal(res.total); setTotal(res.total);
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : "加载媒体失败"); message.error(e instanceof Error ? e.message : t("加载媒体失败"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -51,21 +53,21 @@ export function MediaPickerModal(props: {
async function handleUpload(file: File) { async function handleUpload(file: File) {
try { try {
const res = await uploadMediaFile(file); const res = await uploadMediaFile(file);
message.success(`已上传 ${file.name}`); message.success(t("已上传 {name}", { name: file.name }));
props.onSelect(res.url, file.name.replace(/\.[^.]+$/, "")); props.onSelect(res.url, file.name.replace(/\.[^.]+$/, ""));
props.onClose(); props.onClose();
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : "上传失败"); message.error(e instanceof Error ? e.message : t("上传失败"));
} }
return false; return false;
} }
return ( return (
<Modal title="选择媒体" open={props.open} onCancel={props.onClose} footer={null} width={860} destroyOnHidden> <Modal title={t("选择媒体")} open={props.open} onCancel={props.onClose} footer={null} width={860} destroyOnHidden>
<Space style={{ marginBottom: 14, display: "flex" }}> <Space style={{ marginBottom: 14, display: "flex" }}>
<Input <Input
allowClear allowClear
placeholder="搜索文件路径" placeholder={t("搜索文件路径")}
prefix={<SearchOutlined aria-hidden />} prefix={<SearchOutlined aria-hidden />}
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
@@ -76,7 +78,7 @@ export function MediaPickerModal(props: {
style={{ width: 240 }} style={{ width: 240 }}
/> />
<Upload multiple showUploadList={false} beforeUpload={(f) => void handleUpload(f as File)}> <Upload multiple showUploadList={false} beforeUpload={(f) => void handleUpload(f as File)}>
<Button icon={<UploadOutlined aria-hidden />}>上传图片</Button> <Button icon={<UploadOutlined aria-hidden />}>{t("上传图片")}</Button>
</Upload> </Upload>
</Space> </Space>
<Spin spinning={loading}> <Spin spinning={loading}>
@@ -90,13 +92,13 @@ export function MediaPickerModal(props: {
props.onSelect(it.url, it.altText || it.path.split("/").pop() || ""); props.onSelect(it.url, it.altText || it.path.split("/").pop() || "");
props.onClose(); props.onClose();
}} }}
style={{ cursor: "pointer", border: "1px solid #f0f0f0", borderRadius: 6, padding: 4, background: "#fff", textAlign: "center" }} style={{ cursor: "pointer", border: "1px solid var(--ant-color-split)", borderRadius: 6, padding: 4, background: "var(--ant-color-bg-container)", textAlign: "center" }}
> >
<Image src={it.url} alt={it.altText || it.path} width="100%" height={92} style={{ objectFit: "cover", borderRadius: 4 }} preview={false} /> <Image src={it.url} alt={it.altText || it.path} width="100%" height={92} style={{ objectFit: "cover", borderRadius: 4 }} preview={false} />
<div style={{ fontSize: 11, color: "#8c8c8c", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{it.path.split("/").pop()}</div> <div style={{ fontSize: 11, color: "var(--ant-color-text-tertiary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{it.path.split("/").pop()}</div>
</button> </button>
))} ))}
{!loading && items.length === 0 ? <div style={{ gridColumn: "1/-1", color: "#8c8c8c", textAlign: "center", padding: 40 }}>暂无媒体,先上传</div> : null} {!loading && items.length === 0 ? <div style={{ gridColumn: "1/-1", color: "var(--ant-color-text-tertiary)", textAlign: "center", padding: 40 }}>{t("暂无媒体,先上传")}</div> : null}
</div> </div>
<div style={{ marginTop: 14, textAlign: "right" }}> <div style={{ marginTop: 14, textAlign: "right" }}>
<Pagination <Pagination
+3 -1
View File
@@ -7,6 +7,7 @@
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { getLoginSecurityPublic } from "~/server/security"; import { getLoginSecurityPublic } from "~/server/security";
import { useAdminT } from "~/admin-i18n";
/** # 类型 登录页公开配置(服务端 getLoginSecurityPublic 返回) **/ /** # 类型 登录页公开配置(服务端 getLoginSecurityPublic 返回) **/
export type LoginSecurityPublic = { enabled: boolean; version: "v2" | "v3"; siteKey: string }; export type LoginSecurityPublic = { enabled: boolean; version: "v2" | "v3"; siteKey: string };
@@ -128,13 +129,14 @@ export function useRecaptcha(cfg: LoginSecurityPublic | null) {
/** # 组件 登录表单内嵌字段:v2 渲染复选框容器,v3 隐藏执行;脚本加载失败给提示 */ /** # 组件 登录表单内嵌字段:v2 渲染复选框容器,v3 隐藏执行;脚本加载失败给提示 */
export function RecaptchaField(props: { cfg: LoginSecurityPublic | null; rec: ReturnType<typeof useRecaptcha> }) { export function RecaptchaField(props: { cfg: LoginSecurityPublic | null; rec: ReturnType<typeof useRecaptcha> }) {
const t = useAdminT();
if (!props.cfg?.enabled) return null; if (!props.cfg?.enabled) return null;
return ( return (
<> <>
{props.cfg.version === "v2" ? <div ref={props.rec.containerRef} style={{ marginBottom: 16, minHeight: 78 }} /> : null} {props.cfg.version === "v2" ? <div ref={props.rec.containerRef} style={{ marginBottom: 16, minHeight: 78 }} /> : null}
{props.rec.failed ? ( {props.rec.failed ? (
<p style={{ margin: "0 0 16px", fontSize: 12, color: "#cf1322" }}> <p style={{ margin: "0 0 16px", fontSize: 12, color: "#cf1322" }}>
reCAPTCHA 脚本加载失败,请检查网络(需可访问 google.com)后刷新重试。 {t("reCAPTCHA 脚本加载失败,请检查网络(需可访问 google.com)后刷新重试。")}
</p> </p>
) : null} ) : null}
</> </>
+45 -42
View File
@@ -21,6 +21,7 @@ import {
Undo2, Redo2, FileCode2, FileText, Undo2, Redo2, FileCode2, FileText,
} from "lucide-react"; } from "lucide-react";
import { Button, Dropdown, Input, Modal, Select, Space, Tooltip } from "antd"; import { Button, Dropdown, Input, Modal, Select, Space, Tooltip } from "antd";
import { useAdminT } from "~/admin-i18n";
import { MediaPickerModal } from "./MediaPickerModal"; import { MediaPickerModal } from "./MediaPickerModal";
import { uploadMediaFile } from "~/utils/media-upload"; import { uploadMediaFile } from "~/utils/media-upload";
import "~/styles/admin-editor.css"; import "~/styles/admin-editor.css";
@@ -57,6 +58,7 @@ function TbButton(props: { onAct: () => void; active?: boolean; disabled?: boole
/** # 函数 RichEditor 主组件 */ /** # 函数 RichEditor 主组件 */
export function RichEditor(props: RichEditorProps) { export function RichEditor(props: RichEditorProps) {
const t = useAdminT();
/** # 成员变量 最近一次向外发出的 HTML(防止受控回写与内部更新打环) **/ /** # 成员变量 最近一次向外发出的 HTML(防止受控回写与内部更新打环) **/
const lastHtml = useRef<string>(""); const lastHtml = useRef<string>("");
/** # 成员变量 HTML 源码模式开关与草稿 **/ /** # 成员变量 HTML 源码模式开关与草稿 **/
@@ -144,7 +146,7 @@ export function RichEditor(props: RichEditorProps) {
const res = await uploadMediaFile(file); const res = await uploadMediaFile(file);
act((c) => c.setImage({ src: res.url, alt: file.name.replace(/\.[^.]+$/, "") })); act((c) => c.setImage({ src: res.url, alt: file.name.replace(/\.[^.]+$/, "") }));
} catch (e) { } catch (e) {
setErr(e instanceof Error ? e.message : "上传失败"); setErr(e instanceof Error ? e.message : t("上传失败"));
} }
} }
@@ -187,9 +189,10 @@ export function RichEditor(props: RichEditorProps) {
return ( return (
<div className="rich-editor"> <div className="rich-editor">
<div className="re-toolbar" role="toolbar" aria-label="富文本工具栏"> <div className="re-toolbar" role="toolbar" aria-label={t("富文本工具栏")}>
<TbButton tip="撤销" onAct={() => act((c) => c.undo())} disabled={!editor?.can().undo()}><Undo2 size={15} aria-hidden /></TbButton> {/* TbButton 非 hooks 组件:tip 在外层先 t() 好再传入 */}
<TbButton tip="重做" onAct={() => act((c) => c.redo())} disabled={!editor?.can().redo()}><Redo2 size={15} aria-hidden /></TbButton> <TbButton tip={t("撤销")} onAct={() => act((c) => c.undo())} disabled={!editor?.can().undo()}><Undo2 size={15} aria-hidden /></TbButton>
<TbButton tip={t("重做")} onAct={() => act((c) => c.redo())} disabled={!editor?.can().redo()}><Redo2 size={15} aria-hidden /></TbButton>
<i className="re-sep" aria-hidden /> <i className="re-sep" aria-hidden />
<Select <Select
size="small" size="small"
@@ -200,37 +203,37 @@ export function RichEditor(props: RichEditorProps) {
else act((c) => c.setHeading({ level: Number(v.slice(1)) as 2 | 3 | 4 })); else act((c) => c.setHeading({ level: Number(v.slice(1)) as 2 | 3 | 4 }));
}} }}
options={[ options={[
{ value: "p", label: "正文" }, { value: "p", label: t("正文") },
{ value: "h2", label: "标题 H2" }, { value: "h2", label: t("标题 H2") },
{ value: "h3", label: "标题 H3" }, { value: "h3", label: t("标题 H3") },
{ value: "h4", label: "标题 H4" }, { value: "h4", label: t("标题 H4") },
]} ]}
/> />
<TbButton tip="加粗" onAct={() => act((c) => c.toggleBold())} active={editor?.isActive("bold")}><Bold size={15} aria-hidden /></TbButton> <TbButton tip={t("加粗")} onAct={() => act((c) => c.toggleBold())} active={editor?.isActive("bold")}><Bold size={15} aria-hidden /></TbButton>
<TbButton tip="斜体" onAct={() => act((c) => c.toggleItalic())} active={editor?.isActive("italic")}><Italic size={15} aria-hidden /></TbButton> <TbButton tip={t("斜体")} onAct={() => act((c) => c.toggleItalic())} active={editor?.isActive("italic")}><Italic size={15} aria-hidden /></TbButton>
<TbButton tip="下划线" onAct={() => act((c) => c.toggleUnderline())} active={editor?.isActive("underline")}><Underline size={15} aria-hidden /></TbButton> <TbButton tip={t("下划线")} onAct={() => act((c) => c.toggleUnderline())} active={editor?.isActive("underline")}><Underline size={15} aria-hidden /></TbButton>
<TbButton tip="删除线" onAct={() => act((c) => c.toggleStrike())} active={editor?.isActive("strike")}><Strikethrough size={15} aria-hidden /></TbButton> <TbButton tip={t("删除线")} onAct={() => act((c) => c.toggleStrike())} active={editor?.isActive("strike")}><Strikethrough size={15} aria-hidden /></TbButton>
<TbButton tip="行内代码" onAct={() => act((c) => c.toggleCode())} active={editor?.isActive("code")}><Code size={15} aria-hidden /></TbButton> <TbButton tip={t("行内代码")} onAct={() => act((c) => c.toggleCode())} active={editor?.isActive("code")}><Code size={15} aria-hidden /></TbButton>
<i className="re-sep" aria-hidden /> <i className="re-sep" aria-hidden />
<TbButton tip="无序列表" onAct={() => act((c) => c.toggleBulletList())} active={editor?.isActive("bulletList")}><List size={15} aria-hidden /></TbButton> <TbButton tip={t("无序列表")} onAct={() => act((c) => c.toggleBulletList())} active={editor?.isActive("bulletList")}><List size={15} aria-hidden /></TbButton>
<TbButton tip="有序列表" onAct={() => act((c) => c.toggleOrderedList())} active={editor?.isActive("orderedList")}><ListOrdered size={15} aria-hidden /></TbButton> <TbButton tip={t("有序列表")} onAct={() => act((c) => c.toggleOrderedList())} active={editor?.isActive("orderedList")}><ListOrdered size={15} aria-hidden /></TbButton>
<TbButton tip="引用" onAct={() => act((c) => c.toggleBlockquote())} active={editor?.isActive("blockquote")}><Quote size={15} aria-hidden /></TbButton> <TbButton tip={t("引用")} onAct={() => act((c) => c.toggleBlockquote())} active={editor?.isActive("blockquote")}><Quote size={15} aria-hidden /></TbButton>
<TbButton tip="分隔线" onAct={() => act((c) => c.setHorizontalRule())}><Minus size={15} aria-hidden /></TbButton> <TbButton tip={t("分隔线")} onAct={() => act((c) => c.setHorizontalRule())}><Minus size={15} aria-hidden /></TbButton>
<TbButton tip="代码块" onAct={() => act((c) => c.toggleCodeBlock())} active={editor?.isActive("codeBlock")}><CodeXml size={15} aria-hidden /></TbButton> <TbButton tip={t("代码块")} onAct={() => act((c) => c.toggleCodeBlock())} active={editor?.isActive("codeBlock")}><CodeXml size={15} aria-hidden /></TbButton>
<i className="re-sep" aria-hidden /> <i className="re-sep" aria-hidden />
<TbButton tip="链接" onAct={openLink} active={editor?.isActive("link")}><Link2 size={15} aria-hidden /></TbButton> <TbButton tip={t("链接")} onAct={openLink} active={editor?.isActive("link")}><Link2 size={15} aria-hidden /></TbButton>
<TbButton tip="取消链接" onAct={() => act((c) => c.unsetLink())} disabled={!editor?.isActive("link")}><Link2Off size={15} aria-hidden /></TbButton> <TbButton tip={t("取消链接")} onAct={() => act((c) => c.unsetLink())} disabled={!editor?.isActive("link")}><Link2Off size={15} aria-hidden /></TbButton>
<TbButton tip="插入图片(本地上传)" onAct={() => fileRef.current?.click()}><ImagePlus size={15} aria-hidden /></TbButton> <TbButton tip={t("插入图片(本地上传)")} onAct={() => fileRef.current?.click()}><ImagePlus size={15} aria-hidden /></TbButton>
<TbButton tip="媒体库选图" onAct={() => setMediaOpen(true)}><LibraryBig size={15} aria-hidden /></TbButton> <TbButton tip={t("媒体库选图")} onAct={() => setMediaOpen(true)}><LibraryBig size={15} aria-hidden /></TbButton>
<Dropdown <Dropdown
menu={{ menu={{
items: [ items: [
{ key: "ins", label: "插入表格 3×3" }, { key: "ins", label: t("插入表格 3×3") },
{ key: "row+", label: "下方插入行", disabled: !inTable }, { key: "row+", label: t("下方插入行"), disabled: !inTable },
{ key: "col+", label: "右侧插入列", disabled: !inTable }, { key: "col+", label: t("右侧插入列"), disabled: !inTable },
{ key: "row-", label: "删除当前行", disabled: !inTable }, { key: "row-", label: t("删除当前行"), disabled: !inTable },
{ key: "col-", label: "删除当前列", disabled: !inTable }, { key: "col-", label: t("删除当前列"), disabled: !inTable },
{ key: "del", label: "删除表格", disabled: !inTable }, { key: "del", label: t("删除表格"), disabled: !inTable },
], ],
onClick: ({ key }) => { onClick: ({ key }) => {
if (key === "ins") act((c) => c.insertTable({ rows: 3, cols: 3, withHeaderRow: true })); if (key === "ins") act((c) => c.insertTable({ rows: 3, cols: 3, withHeaderRow: true }));
@@ -242,14 +245,14 @@ export function RichEditor(props: RichEditorProps) {
}, },
}} }}
> >
<button type="button" className={TIP_CLS} aria-label="表格" title="表格"> <button type="button" className={TIP_CLS} aria-label={t("表格")} title={t("表格")}>
<TableIcon size={15} aria-hidden /> <TableIcon size={15} aria-hidden />
</button> </button>
</Dropdown> </Dropdown>
<i className="re-sep" aria-hidden /> <i className="re-sep" aria-hidden />
<TbButton tip="清除格式" onAct={() => act((c) => c.unsetAllMarks().clearNodes())}><RemoveFormatting size={15} aria-hidden /></TbButton> <TbButton tip={t("清除格式")} onAct={() => act((c) => c.unsetAllMarks().clearNodes())}><RemoveFormatting size={15} aria-hidden /></TbButton>
<TbButton tip="Markdown 导入" onAct={() => setMdOpen(true)}><FileText size={15} aria-hidden /></TbButton> <TbButton tip={t("Markdown 导入")} onAct={() => setMdOpen(true)}><FileText size={15} aria-hidden /></TbButton>
<TbButton tip={srcMode ? "返回可视化编辑" : "HTML 源码"} onAct={() => (srcMode ? applySrc() : enterSrc())} active={srcMode}><FileCode2 size={15} aria-hidden /></TbButton> <TbButton tip={srcMode ? t("返回可视化编辑") : t("HTML 源码")} onAct={() => (srcMode ? applySrc() : enterSrc())} active={srcMode}><FileCode2 size={15} aria-hidden /></TbButton>
</div> </div>
{/* 隐藏文件选择(本地图片上传) */} {/* 隐藏文件选择(本地图片上传) */}
@@ -273,18 +276,18 @@ export function RichEditor(props: RichEditorProps) {
onChange={(e) => setSrcDraft(e.target.value)} onChange={(e) => setSrcDraft(e.target.value)}
rows={16} rows={16}
style={{ fontFamily: "ui-monospace, monospace", fontSize: 12.5, borderRadius: 0 }} style={{ fontFamily: "ui-monospace, monospace", fontSize: 12.5, borderRadius: 0 }}
aria-label="HTML 源码" aria-label={t("HTML 源码")}
/> />
) : ( ) : (
<EditorContent editor={editor} style={{ minHeight: props.minHeight ?? 320 }} /> <EditorContent editor={editor} style={{ minHeight: props.minHeight ?? 320 }} />
)} )}
{/* 链接弹层 */} {/* 链接弹层 */}
<Modal title="插入 / 编辑链接" open={linkOpen} onOk={applyLink} onCancel={() => setLinkOpen(false)} okText="确定" cancelText="取消" width={440}> <Modal title={t("插入 / 编辑链接")} open={linkOpen} onOk={applyLink} onCancel={() => setLinkOpen(false)} okText={t("确定")} cancelText={t("取消")} width={440}>
<Space.Compact style={{ width: "100%" }}> <Space.Compact style={{ width: "100%" }}>
<Input value={linkUrl} onChange={(e) => setLinkUrl(e.target.value)} placeholder="https://…" onPressEnter={applyLink} /> <Input value={linkUrl} onChange={(e) => setLinkUrl(e.target.value)} placeholder="https://…" onPressEnter={applyLink} />
<Button danger onClick={() => { act((c) => c.unsetLink()); setLinkOpen(false); }}> <Button danger onClick={() => { act((c) => c.unsetLink()); setLinkOpen(false); }}>
移除 {t("移除")}
</Button> </Button>
</Space.Compact> </Space.Compact>
</Modal> </Modal>
@@ -294,20 +297,20 @@ export function RichEditor(props: RichEditorProps) {
{/* Markdown 导入 */} {/* Markdown 导入 */}
<Modal <Modal
title="导入 Markdown" title={t("导入 Markdown")}
open={mdOpen} open={mdOpen}
onCancel={() => setMdOpen(false)} onCancel={() => setMdOpen(false)}
width={680} width={680}
footer={[ footer={[
<Button key="c" onClick={() => setMdOpen(false)}>取消</Button>, <Button key="c" onClick={() => setMdOpen(false)}>{t("取消")}</Button>,
<Button key="i" type="primary" disabled={!mdText.trim()} onClick={() => applyMarkdown(false)}>插入到光标处</Button>, <Button key="i" type="primary" disabled={!mdText.trim()} onClick={() => applyMarkdown(false)}>{t("插入到光标处")}</Button>,
<Button key="r" danger disabled={!mdText.trim()} onClick={() => applyMarkdown(true)}>替换正文</Button>, <Button key="r" danger disabled={!mdText.trim()} onClick={() => applyMarkdown(true)}>{t("替换正文")}</Button>,
]} ]}
> >
<p style={{ margin: "0 0 8px", fontSize: 12, color: "#8c8c8c" }}> <p style={{ margin: "0 0 8px", fontSize: 12, color: "var(--ant-color-text-tertiary)" }}>
支持 # 标题、**加粗**、- 列表、[链接](url)、![图片](url)、表格等(marked 解析)。日常输入时编辑器已自动转换 Markdown 快捷语法。 {t("支持 # 标题、**加粗**、- 列表、[链接](url)、![图片](url)、表格等(marked 解析)。日常输入时编辑器已自动转换 Markdown 快捷语法。")}
</p> </p>
<Input.TextArea value={mdText} onChange={(e) => setMdText(e.target.value)} rows={12} placeholder={"## 标题\n\n正文 **加粗** …"} aria-label="Markdown 内容" /> <Input.TextArea value={mdText} onChange={(e) => setMdText(e.target.value)} rows={12} placeholder={t("## 标题\n\n正文 **加粗** …")} aria-label={t("Markdown 内容")} />
</Modal> </Modal>
</div> </div>
); );
+9 -5
View File
@@ -5,6 +5,7 @@
import { Empty, Tag } from "antd"; import { Empty, Tag } from "antd";
import { Database, Lock } from "lucide-react"; import { Database, Lock } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useAdminT } from "~/admin-i18n";
/** # 类型 递归 JSON 值(用于把数据库 jsonb 的 unknown 收窄为可序列化类型) **/ /** # 类型 递归 JSON 值(用于把数据库 jsonb 的 unknown 收窄为可序列化类型) **/
export type Json = string | number | boolean | null | Json[] | { [key: string]: Json }; export type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
@@ -54,12 +55,13 @@ export async function guardAdmin<T>(fn: () => Promise<T>): Promise<T | null> {
* 各后台页面在 loader 返回 null(401)时渲染。 * 各后台页面在 loader 返回 null(401)时渲染。
*/ */
export function Forbidden() { export function Forbidden() {
const t = useAdminT();
return ( return (
<div style={{ display: "flex", justifyContent: "center", paddingTop: 80 }}> <div style={{ display: "flex", justifyContent: "center", paddingTop: 80 }}>
<Empty <Empty
image={<Lock size={48} aria-hidden color="#bfbfbf" />} image={<Lock size={48} aria-hidden color="#bfbfbf" />}
imageStyle={{ height: 64 }} imageStyle={{ height: 64 }}
description="未登录或权限不足,请使用管理员账号登录后台" description={t("未登录或权限不足,请使用管理员账号登录后台")}
/> />
</div> </div>
); );
@@ -67,20 +69,22 @@ export function Forbidden() {
/** # 函数 表格空状态(SVG 图标,禁用默认灰图) */ /** # 函数 表格空状态(SVG 图标,禁用默认灰图) */
export function AdminEmpty() { export function AdminEmpty() {
return <Empty image={<Database size={40} aria-hidden color="#bfbfbf" />} imageStyle={{ height: 48 }} description="暂无数据" />; const t = useAdminT();
return <Empty image={<Database size={40} aria-hidden color="#bfbfbf" />} imageStyle={{ height: 48 }} description={t("暂无数据")} />;
} }
/** # 函数 表格通用分页配置 /** # 函数 表格通用分页配置
* 参数 total:总条数;page:当前页;pageSize:每页条数;onChange:翻页回调 * 参数 total:总条数;page:当前页;pageSize:每页条数;onChange:翻页回调;
* totalText:可选的总条数文案函数(i18n 用,如 (n) => t("共 {n} 条", { n });缺省回退中文默认)
* 返回值 AntD Table pagination 配置对象 * 返回值 AntD Table pagination 配置对象
*/ */
export function tablePagination(total: number, page: number, pageSize: number, onChange: (p: number) => void) { export function tablePagination(total: number, page: number, pageSize: number, onChange: (p: number) => void, totalText?: (n: number) => string) {
return { return {
total, total,
current: page, current: page,
pageSize, pageSize,
showSizeChanger: false, showSizeChanger: false,
showTotal: (t: number) => `共 ${t} 条`, showTotal: (t: number) => (totalText ? totalText(t) : `共 ${t} 条`),
onChange, onChange,
}; };
} }
+5
View File
@@ -0,0 +1,5 @@
-- v0.16.0:用户个人设置字段(头像 / 默认语言 / 默认主题 / MCP Token)
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar varchar(512) DEFAULT '' NOT NULL;
ALTER TABLE users ADD COLUMN IF NOT EXISTS locale varchar(16) DEFAULT '' NOT NULL;
ALTER TABLE users ADD COLUMN IF NOT EXISTS theme varchar(16) DEFAULT '' NOT NULL;
ALTER TABLE users ADD COLUMN IF NOT EXISTS mcp_token varchar(64) DEFAULT '' NOT NULL;
+8
View File
@@ -414,6 +414,14 @@ export const users = pgTable(
legacyPasswordHash: varchar("legacy_password_hash", { length: 255 }), legacyPasswordHash: varchar("legacy_password_hash", { length: 255 }),
/** # 数据库字段 角色:admin | editor | manager | customer */ /** # 数据库字段 角色:admin | editor | manager | customer */
role: varchar("role", { length: 32 }).default("customer").notNull(), role: varchar("role", { length: 32 }).default("customer").notNull(),
/** # 数据库字段 头像 URL(媒体库选择,空 = 默认图标) */
avatar: varchar("avatar", { length: 512 }).default("").notNull(),
/** # 数据库字段 默认后台语言(BCP47,空 = 跟随浏览器/默认 en) */
locale: varchar("locale", { length: 16 }).default("").notNull(),
/** # 数据库字段 默认后台主题:light | dark(空 = 浅色) */
theme: varchar("theme", { length: 16 }).default("").notNull(),
/** # 数据库字段 MCP 访问令牌(Agent 经 MCP 网关调用时鉴权;空 = 未启用) */
mcpToken: varchar("mcp_token", { length: 64 }).default("").notNull(),
/** # 数据库字段 账单地址 */ /** # 数据库字段 账单地址 */
billing: jsonb("billing").default({}).notNull(), billing: jsonb("billing").default({}).notNull(),
/** # 数据库字段 收货地址 */ /** # 数据库字段 收货地址 */
+106
View File
@@ -0,0 +1,106 @@
/**
* # 模块 PHP serialize 反序列化(同构安全:TextEncoder,无 Buffer,可进客户端包)
* 处理 WordPress 遗留的 PHP 序列化字符串(如 a:1:{i:0;s:17:"Development Board";}),
* 用于存量数据清洗与后台展示兜底。覆盖类型:s/i/d/b/a/N(与 WP meta 常见值一致)。
*/
/** # 成员变量 UTF-8 编解码器(模块级复用) **/
const ENC = new TextEncoder();
const DEC = new TextDecoder();
/** # 类型 解析结果:ok=成功消费完整字符串;fail=非 PHP 序列化或解析失败 **/
type ParseOk = { ok: true; value: unknown };
type ParseFail = { ok: false };
/** # 函数 解析 PHP 序列化字符串(内部:字节精确,要求整串消费完毕)
* 参数 input:待解析字符串
* 返回值 成功带值;失败返回 fail
*/
function parseAll(input: string): ParseOk | ParseFail {
const buf = ENC.encode(input);
let i = 0;
/** # 函数 递归解析单个值(类型字节起始为 i) */
function parse(): unknown {
const type = String.fromCharCode(buf[i]!);
// 类型后第一个冒号(计数值结束位置)
const colon = buf.indexOf(0x3a, i + 2);
if (type === "N") { i += 2; return null; } // N;
if (type === "b") { const v = buf[i + 2] === 0x31; i += 4; return v; } // b:0; / b:1;
if (type === "i") { const j = buf.indexOf(0x3b, i); const v = parseInt(DEC.decode(buf.subarray(i + 2, j))); i = j + 1; return v; }
if (type === "d") { const j = buf.indexOf(0x3b, i); const v = parseFloat(DEC.decode(buf.subarray(i + 2, j))); i = j + 1; return v; }
if (type === "s") { // s:LEN:"..."(LEN 为 UTF-8 字节数)
if (colon < 0) throw new Error("bad s");
const len = parseInt(DEC.decode(buf.subarray(i + 2, colon)));
const start = colon + 2;
const v = DEC.decode(buf.subarray(start, start + len));
i = start + len + 2; // 跳过 ";
return v;
}
if (type === "a") { // a:COUNT:{ key;value; ... }
if (colon < 0) throw new Error("bad a");
const n = parseInt(DEC.decode(buf.subarray(i + 2, colon)));
i = colon + 2; // 跳过 {
const arr: unknown[] = [];
const obj: Record<string, unknown> = {};
let isArr = true;
for (let k = 0; k < n; k++) {
const key = parse();
const val = parse();
if (typeof key === "number") arr[key] = val;
else { isArr = false; obj[String(key)] = val; }
}
i += 1; // 消费 }
return isArr ? arr : obj;
}
throw new Error(`unknown type '${type}' at byte ${i}`);
}
try {
const v = parse();
if (i !== buf.length) return { ok: false }; // 尾部有残渣 → 不算序列化串
return { ok: true, value: v };
} catch {
return { ok: false };
}
}
/** # 成员变量 疑似 PHP 序列化的前缀(快速预筛,避免对普通文本做完整解析) **/
const PHP_SERIALIZED_RE = /^(a:\d+:|s:\d+:|i:-?\d+;|d:[-0-9.E;]+;|b:[01];|N;)/;
/** # 函数 尝试把 PHP 序列化字符串还原为 JS 值;非序列化串原样返回 undefined
* 参数 s:任意字符串
* 返回值 还原值,或 undefined(不是序列化串 / 解析失败)
*/
export function tryPhpUnserialize(s: string): unknown | undefined {
if (typeof s !== "string" || !PHP_SERIALIZED_RE.test(s)) return undefined;
const r = parseAll(s);
return r.ok ? r.value : undefined;
}
/** # 函数 把 PHP 序列化串转成「可读纯文本」(标量取自身;数组取字符串元素逗号连接)
* 非序列化串原样返回。用于 productInterest 这类 varchar 字段的清洗与展示。
*/
export function phpToPlain(s: string): string {
const v = tryPhpUnserialize(s);
if (v === undefined) return s;
if (Array.isArray(v)) return v.filter((x) => x !== null && x !== "").map((x) => String(x)).join(", ");
if (v === null) return "";
if (typeof v === "object") return Object.values(v as Record<string, unknown>).filter((x) => x !== null && x !== "").map((x) => String(x)).join(", ");
return String(v);
}
/** # 函数 递归转换 JSON 值内的 PHP 序列化字符串(fields/utm 等 jsonb 清洗与展示兜底)
* 字符串命中序列化 → 还原后继续递归(序列化里可嵌套);对象/数组逐项处理。
*/
export function deepConvertPhp(v: unknown): unknown {
if (typeof v === "string") {
const u = tryPhpUnserialize(v);
return u === undefined ? v : deepConvertPhp(u);
}
if (Array.isArray(v)) return v.map(deepConvertPhp);
if (v && typeof v === "object") {
const out: Record<string, unknown> = {};
for (const [k, val] of Object.entries(v)) out[k] = deepConvertPhp(val);
return out;
}
return v;
}
+42
View File
@@ -29,6 +29,8 @@ import { Route as AdminInquiriesRouteImport } from './routes/admin.inquiries'
import { Route as AdminLanguagesRouteImport } from './routes/admin.languages' import { Route as AdminLanguagesRouteImport } from './routes/admin.languages'
import { Route as AdminMediaRouteImport } from './routes/admin.media' import { Route as AdminMediaRouteImport } from './routes/admin.media'
import { Route as AdminMenusRouteImport } from './routes/admin.menus' import { Route as AdminMenusRouteImport } from './routes/admin.menus'
import { Route as AdminPermissionsRouteImport } from './routes/admin.permissions'
import { Route as AdminProfileRouteImport } from './routes/admin.profile'
import { Route as AdminSeoRouteImport } from './routes/admin.seo' import { Route as AdminSeoRouteImport } from './routes/admin.seo'
import { Route as AdminSettingsRouteImport } from './routes/admin.settings' import { Route as AdminSettingsRouteImport } from './routes/admin.settings'
import { Route as AdminUsersRouteImport } from './routes/admin.users' import { Route as AdminUsersRouteImport } from './routes/admin.users'
@@ -148,6 +150,16 @@ const AdminMenusRoute = AdminMenusRouteImport.update({
path: '/menus', path: '/menus',
getParentRoute: () => AdminRoute, getParentRoute: () => AdminRoute,
} as any) } as any)
const AdminPermissionsRoute = AdminPermissionsRouteImport.update({
id: '/permissions',
path: '/permissions',
getParentRoute: () => AdminRoute,
} as any)
const AdminProfileRoute = AdminProfileRouteImport.update({
id: '/profile',
path: '/profile',
getParentRoute: () => AdminRoute,
} as any)
const AdminSeoRoute = AdminSeoRouteImport.update({ const AdminSeoRoute = AdminSeoRouteImport.update({
id: '/seo', id: '/seo',
path: '/seo', path: '/seo',
@@ -259,6 +271,8 @@ export interface FileRoutesByFullPath {
'/admin/languages': typeof AdminLanguagesRoute '/admin/languages': typeof AdminLanguagesRoute
'/admin/media': typeof AdminMediaRoute '/admin/media': typeof AdminMediaRoute
'/admin/menus': typeof AdminMenusRoute '/admin/menus': typeof AdminMenusRoute
'/admin/permissions': typeof AdminPermissionsRoute
'/admin/profile': typeof AdminProfileRoute
'/admin/seo': typeof AdminSeoRoute '/admin/seo': typeof AdminSeoRoute
'/admin/settings': typeof AdminSettingsRoute '/admin/settings': typeof AdminSettingsRoute
'/admin/users': typeof AdminUsersRoute '/admin/users': typeof AdminUsersRoute
@@ -297,6 +311,8 @@ export interface FileRoutesByTo {
'/admin/languages': typeof AdminLanguagesRoute '/admin/languages': typeof AdminLanguagesRoute
'/admin/media': typeof AdminMediaRoute '/admin/media': typeof AdminMediaRoute
'/admin/menus': typeof AdminMenusRoute '/admin/menus': typeof AdminMenusRoute
'/admin/permissions': typeof AdminPermissionsRoute
'/admin/profile': typeof AdminProfileRoute
'/admin/seo': typeof AdminSeoRoute '/admin/seo': typeof AdminSeoRoute
'/admin/settings': typeof AdminSettingsRoute '/admin/settings': typeof AdminSettingsRoute
'/admin/users': typeof AdminUsersRoute '/admin/users': typeof AdminUsersRoute
@@ -338,6 +354,8 @@ export interface FileRoutesById {
'/admin/languages': typeof AdminLanguagesRoute '/admin/languages': typeof AdminLanguagesRoute
'/admin/media': typeof AdminMediaRoute '/admin/media': typeof AdminMediaRoute
'/admin/menus': typeof AdminMenusRoute '/admin/menus': typeof AdminMenusRoute
'/admin/permissions': typeof AdminPermissionsRoute
'/admin/profile': typeof AdminProfileRoute
'/admin/seo': typeof AdminSeoRoute '/admin/seo': typeof AdminSeoRoute
'/admin/settings': typeof AdminSettingsRoute '/admin/settings': typeof AdminSettingsRoute
'/admin/users': typeof AdminUsersRoute '/admin/users': typeof AdminUsersRoute
@@ -380,6 +398,8 @@ export interface FileRouteTypes {
| '/admin/languages' | '/admin/languages'
| '/admin/media' | '/admin/media'
| '/admin/menus' | '/admin/menus'
| '/admin/permissions'
| '/admin/profile'
| '/admin/seo' | '/admin/seo'
| '/admin/settings' | '/admin/settings'
| '/admin/users' | '/admin/users'
@@ -418,6 +438,8 @@ export interface FileRouteTypes {
| '/admin/languages' | '/admin/languages'
| '/admin/media' | '/admin/media'
| '/admin/menus' | '/admin/menus'
| '/admin/permissions'
| '/admin/profile'
| '/admin/seo' | '/admin/seo'
| '/admin/settings' | '/admin/settings'
| '/admin/users' | '/admin/users'
@@ -458,6 +480,8 @@ export interface FileRouteTypes {
| '/admin/languages' | '/admin/languages'
| '/admin/media' | '/admin/media'
| '/admin/menus' | '/admin/menus'
| '/admin/permissions'
| '/admin/profile'
| '/admin/seo' | '/admin/seo'
| '/admin/settings' | '/admin/settings'
| '/admin/users' | '/admin/users'
@@ -642,6 +666,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AdminMenusRouteImport preLoaderRoute: typeof AdminMenusRouteImport
parentRoute: typeof AdminRoute parentRoute: typeof AdminRoute
} }
'/admin/permissions': {
id: '/admin/permissions'
path: '/permissions'
fullPath: '/admin/permissions'
preLoaderRoute: typeof AdminPermissionsRouteImport
parentRoute: typeof AdminRoute
}
'/admin/profile': {
id: '/admin/profile'
path: '/profile'
fullPath: '/admin/profile'
preLoaderRoute: typeof AdminProfileRouteImport
parentRoute: typeof AdminRoute
}
'/admin/seo': { '/admin/seo': {
id: '/admin/seo' id: '/admin/seo'
path: '/seo' path: '/seo'
@@ -779,6 +817,8 @@ interface AdminRouteChildren {
AdminLanguagesRoute: typeof AdminLanguagesRoute AdminLanguagesRoute: typeof AdminLanguagesRoute
AdminMediaRoute: typeof AdminMediaRoute AdminMediaRoute: typeof AdminMediaRoute
AdminMenusRoute: typeof AdminMenusRoute AdminMenusRoute: typeof AdminMenusRoute
AdminPermissionsRoute: typeof AdminPermissionsRoute
AdminProfileRoute: typeof AdminProfileRoute
AdminSeoRoute: typeof AdminSeoRoute AdminSeoRoute: typeof AdminSeoRoute
AdminSettingsRoute: typeof AdminSettingsRoute AdminSettingsRoute: typeof AdminSettingsRoute
AdminUsersRoute: typeof AdminUsersRoute AdminUsersRoute: typeof AdminUsersRoute
@@ -799,6 +839,8 @@ const AdminRouteChildren: AdminRouteChildren = {
AdminLanguagesRoute: AdminLanguagesRoute, AdminLanguagesRoute: AdminLanguagesRoute,
AdminMediaRoute: AdminMediaRoute, AdminMediaRoute: AdminMediaRoute,
AdminMenusRoute: AdminMenusRoute, AdminMenusRoute: AdminMenusRoute,
AdminPermissionsRoute: AdminPermissionsRoute,
AdminProfileRoute: AdminProfileRoute,
AdminSeoRoute: AdminSeoRoute, AdminSeoRoute: AdminSeoRoute,
AdminSettingsRoute: AdminSettingsRoute, AdminSettingsRoute: AdminSettingsRoute,
AdminUsersRoute: AdminUsersRoute, AdminUsersRoute: AdminUsersRoute,
+31 -16
View File
@@ -1,13 +1,14 @@
/** /**
* # 路由 /admin/audit 审计日志 * # 路由 /admin/audit 审计日志
* 后台全部写操作留痕(audit_logs):操作人 / 动作 / 实体 / 时间。 * 后台全部写操作留痕(audit_logs):操作人 / 动作 / 实体 / 时间。
* 支持按动作名过滤与分页;meta 为 JSON 摘要。 * 支持按动作名过滤与分页;摘要以可读键值对展示(k=v 徽标),不再出现裸 JSON。
*/ */
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { Card, Input, Table, Typography } from "antd"; import { Card, Input, Table, Tag, Tooltip } from "antd";
import { SearchOutlined } from "@ant-design/icons"; import { SearchOutlined } from "@ant-design/icons";
import { useState } from "react"; import { useState } from "react";
import { adminListAuditLogs } from "~/server/admin"; import { adminListAuditLogs } from "~/server/admin";
import { useAdminT } from "~/admin-i18n";
import { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common"; import { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common";
export const Route = createFileRoute("/admin/audit")({ export const Route = createFileRoute("/admin/audit")({
@@ -25,6 +26,7 @@ export const Route = createFileRoute("/admin/audit")({
/** # 函数 审计日志页面组件 */ /** # 函数 审计日志页面组件 */
function AuditPage() { function AuditPage() {
const t = useAdminT();
const data = Route.useLoaderData(); const data = Route.useLoaderData();
const search = Route.useSearch(); const search = Route.useSearch();
const navigate = Route.useNavigate(); const navigate = Route.useNavigate();
@@ -35,11 +37,11 @@ function AuditPage() {
return ( return (
<Card size="small"> <Card size="small">
<PageHeader <PageHeader
title="审计日志" title={t("审计日志")}
extra={ extra={
<Input <Input
allowClear allowClear
placeholder="按动作过滤(如 content.save)" placeholder={t("按动作过滤(如 content.save)")}
prefix={<SearchOutlined aria-hidden />} prefix={<SearchOutlined aria-hidden />}
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
@@ -58,21 +60,34 @@ function AuditPage() {
)} )}
columns={[ columns={[
{ title: "ID", dataIndex: "id", width: 70 }, { title: "ID", dataIndex: "id", width: 70 },
{ title: "操作人", dataIndex: "actorEmail", width: 220, ellipsis: true, render: (v: string | null) => v ?? `#${""}` }, { title: t("操作人"), 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: t("动作"), dataIndex: "action", width: 200, render: (v: string) => <code style={{ fontSize: 12 }}>{v}</code> },
{ title: "实体", dataIndex: "entity", width: 110 }, { title: t("实体"), dataIndex: "entity", width: 110 },
{ title: "实体 ID", dataIndex: "entityId", width: 90 }, { title: t("实体 ID"), dataIndex: "entityId", width: 90 },
{ {
title: "摘要", title: t("摘要"),
dataIndex: "meta", dataIndex: "meta",
ellipsis: true, render: (v: unknown) => {
render: (v: unknown) => ( // 摘要以 k=v 徽标展示(嵌套值取其字符串形式),无摘要显示 -
<Typography.Text code style={{ fontSize: 12 }}> const entries = v && typeof v === "object" && !Array.isArray(v) ? Object.entries(v) : [];
{Object.keys(v ?? {}).length ? JSON.stringify(v) : "-"} if (!entries.length) return "-";
</Typography.Text> return (
), <span style={{ display: "inline-flex", flexWrap: "wrap", gap: 4 }}>
{entries.map(([k, val]) => {
const text = val === null || val === undefined ? "-" : String(val);
return (
<Tooltip key={k} title={text}>
<Tag style={{ marginInlineEnd: 0, maxWidth: 260, overflow: "hidden", textOverflow: "ellipsis" }}>
{k}={text}
</Tag>
</Tooltip>
);
})}
</span>
);
}, },
{ title: "时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) }, },
{ title: t("时间"), dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
]} ]}
/> />
</Card> </Card>
+39 -38
View File
@@ -8,6 +8,7 @@ import { App, Alert, Button, Card, Form, Input, InputNumber, Space, Switch, Tabl
import { CloudUpload, DatabaseBackup, Delete, RefreshCw, Zap } from "lucide-react"; import { CloudUpload, DatabaseBackup, Delete, RefreshCw, Zap } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { adminDeleteBackup, adminListBackups, adminRunBackup, adminUpdateSetting } from "~/server/admin"; import { adminDeleteBackup, adminListBackups, adminRunBackup, adminUpdateSetting } from "~/server/admin";
import { useAdminT } from "~/admin-i18n";
import { Forbidden, PageHeader, guardAdmin, fmtDateTime, type Json } from "~/components/admin/common"; import { Forbidden, PageHeader, guardAdmin, fmtDateTime, type Json } from "~/components/admin/common";
export const Route = createFileRoute("/admin/backups")({ export const Route = createFileRoute("/admin/backups")({
@@ -39,6 +40,7 @@ function fmtSize(n: number): string {
/** # 函数 备份管理页面组件 */ /** # 函数 备份管理页面组件 */
function BackupsPage() { function BackupsPage() {
const t = useAdminT();
const data = Route.useLoaderData() as BackupsData | null; const data = Route.useLoaderData() as BackupsData | null;
const router = useRouter(); const router = useRouter();
const { message, modal } = App.useApp(); const { message, modal } = App.useApp();
@@ -72,10 +74,10 @@ function BackupsPage() {
setBusy(true); setBusy(true);
try { try {
const entry = await adminRunBackup({ data: { kind } }); const entry = await adminRunBackup({ data: { kind } });
message.success(`备份完成:${entry.name}(${fmtSize(entry.sizeBytes)}${entry.s3Key ? ",已上传 S3" : ""})`); message.success(t("备份完成:{name}({size}{s3})", { name: entry.name, size: fmtSize(entry.sizeBytes), s3: entry.s3Key ? t(",已上传 S3") : "" }));
await router.invalidate(); await router.invalidate();
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : "备份失败"); message.error(e instanceof Error ? e.message : t("备份失败"));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -84,17 +86,17 @@ function BackupsPage() {
/** # 函数 删除备份(全量删除会破坏其后的增量链,弹窗确认) */ /** # 函数 删除备份(全量删除会破坏其后的增量链,弹窗确认) */
function confirmDelete(name: string, type: string) { function confirmDelete(name: string, type: string) {
modal.confirm({ modal.confirm({
title: `删除备份 ${name}?`, title: t("删除备份 {name}?", { name }),
content: content:
type === "full" type === "full"
? "该全量之后的所有增量将无法合并恢复,请确认不再需要这条备份链。" ? t("该全量之后的所有增量将无法合并恢复,请确认不再需要这条备份链。")
: "仅删除该增量备份,不影响全量与其他增量。", : t("仅删除该增量备份,不影响全量与其他增量。"),
okText: "删除", okText: t("删除"),
okButtonProps: { danger: true }, okButtonProps: { danger: true },
cancelText: "取消", cancelText: t("取消"),
onOk: async () => { onOk: async () => {
await adminDeleteBackup({ data: { name } }); await adminDeleteBackup({ data: { name } });
message.success("已删除"); message.success(t("已删除"));
await router.invalidate(); await router.invalidate();
}, },
}); });
@@ -119,10 +121,10 @@ function BackupsPage() {
if (!values.endpoint) delete merged.s3; if (!values.endpoint) delete merged.s3;
try { try {
await adminUpdateSetting({ data: { key: "backup", value: merged as Json } }); await adminUpdateSetting({ data: { key: "backup", value: merged as Json } });
message.success("备份配置已保存"); message.success(t("备份配置已保存"));
await router.invalidate(); await router.invalidate();
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : "保存失败"); message.error(e instanceof Error ? e.message : t("保存失败"));
} }
} }
@@ -130,17 +132,17 @@ function BackupsPage() {
<div> <div>
<Card size="small"> <Card size="small">
<PageHeader <PageHeader
title="备份管理(PostgreSQL 18 物理备份)" title={t("备份管理(PostgreSQL 18 物理备份)")}
extra={ extra={
<Space> <Space>
<Button icon={<RefreshCw size={14} aria-hidden />} onClick={() => void router.invalidate()}> <Button icon={<RefreshCw size={14} aria-hidden />} onClick={() => void router.invalidate()}>
刷新 {t("刷新")}
</Button> </Button>
<Button loading={busy} icon={<Zap size={14} aria-hidden />} onClick={() => void run("incr")} disabled={!d.local.length}> <Button loading={busy} icon={<Zap size={14} aria-hidden />} onClick={() => void run("incr")} disabled={!d.local.length}>
增量备份 {t("增量备份")}
</Button> </Button>
<Button loading={busy} type="primary" icon={<DatabaseBackup size={14} aria-hidden />} onClick={() => void run("full")}> <Button loading={busy} type="primary" icon={<DatabaseBackup size={14} aria-hidden />} onClick={() => void run("full")}>
全量备份 {t("全量备份")}
</Button> </Button>
</Space> </Space>
} }
@@ -148,7 +150,7 @@ function BackupsPage() {
<Alert <Alert
type="info" type="info"
showIcon showIcon
message="增量备份基于 PG 17+ summarize_wal(当前 18.6 已开启):只落全量以来的变更块;恢复用 pg_combinebackup 合并「全量 + 其后全部增量」" message={t("增量备份基于 PG 17+ summarize_wal(当前 18.6 已开启):只落全量以来的变更块;恢复用 pg_combinebackup 合并「全量 + 其后全部增量」")}
/> />
<Table <Table
style={{ marginTop: 12 }} style={{ marginTop: 12 }}
@@ -156,36 +158,35 @@ function BackupsPage() {
size="small" size="small"
dataSource={data.local} dataSource={data.local}
pagination={false} pagination={false}
locale={{ emptyText: "暂无备份(建议先做一次全量)" }} locale={{ emptyText: t("暂无备份(建议先做一次全量)") }}
columns={[ columns={[
{ title: "备份名", dataIndex: "name", width: 220, render: (v: string) => <code style={{ fontSize: 12 }}>{v}</code> }, { title: t("备份名"), dataIndex: "name", width: 220, render: (v: string) => <code style={{ fontSize: 12 }}>{v}</code> },
{ title: "类型", dataIndex: "type", width: 90, render: (v: string) => <Tag color={v === "full" ? "blue" : "cyan"}>{v === "full" ? "全量" : "增量"}</Tag> }, { title: t("类型"), dataIndex: "type", width: 90, render: (v: string) => <Tag color={v === "full" ? "blue" : "cyan"}>{v === "full" ? t("全量") : t("增量")}</Tag> },
{ title: "数据目录", dataIndex: "sizeBytes", width: 110, render: (v: number) => fmtSize(v) }, { title: t("数据目录"), dataIndex: "sizeBytes", width: 110, render: (v: number) => fmtSize(v) },
{ title: "归档", dataIndex: "artifact", width: 90, render: (v?: string) => (v ? <Tag color="green">已打包</Tag> : <Tag>未打包</Tag>) }, { title: t("归档"), dataIndex: "artifact", width: 90, render: (v?: string) => (v ? <Tag color="green">{t("已打包")}</Tag> : <Tag>{t("未打包")}</Tag>) },
{ title: "时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) }, { title: t("时间"), dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
{ {
title: "操作", title: t("操作"),
key: "act", key: "act",
width: 90, width: 90,
render: (_, r) => ( render: (_, r) => (
<Button size="small" danger type="text" icon={<Delete size={14} aria-label="删除" />} onClick={() => confirmDelete(r.name, r.type)} /> <Button size="small" danger type="text" icon={<Delete size={14} aria-label={t("删除")} />} onClick={() => confirmDelete(r.name, r.type)} />
), ),
}, },
]} ]}
/> />
<Typography.Paragraph type="secondary" style={{ marginTop: 12, fontSize: 12, marginBottom: 0 }}> <Typography.Paragraph type="secondary" style={{ marginTop: 12, fontSize: 12, marginBottom: 0 }}>
恢复步骤(示例):`docker exec -u postgres dusuniot-pg pg_combinebackup -o /tmp/restore /backups/&lt;全量&gt; /backups/&lt;增量1&gt; /backups/&lt;增量2&gt;` {t("恢复步骤(示例):`docker exec -u postgres dusuniot-pg pg_combinebackup -o /tmp/restore /backups/<全量> /backups/<增量1> /backups/<增量2>` → 停库后把合并目录作为 PGDATA 启动。S3 归档为 tar.gz,解包后同法操作。")}
→ 停库后把合并目录作为 PGDATA 启动。S3 归档为 tar.gz,解包后同法操作。
</Typography.Paragraph> </Typography.Paragraph>
</Card> </Card>
<Card size="small" style={{ marginTop: 16 }} title={ <Card size="small" style={{ marginTop: 16 }} title={
<Space><CloudUpload size={16} aria-hidden /> S3 归档与保留策略 {data.s3Configured ? <Tag color="green">S3 已配置</Tag> : <Tag>未配置(仅本地)</Tag>}</Space> <Space><CloudUpload size={16} aria-hidden /> {t("S3 归档与保留策略")} {data.s3Configured ? <Tag color="green">{t("S3 已配置")}</Tag> : <Tag>{t("未配置(仅本地)")}</Tag>}</Space>
}> }>
<Form form={form} layout="vertical" style={{ maxWidth: 860 }} onFinish={(v) => void saveConfig(v as Parameters<typeof saveConfig>[0])}> <Form form={form} layout="vertical" style={{ maxWidth: 860 }} onFinish={(v) => void saveConfig(v as Parameters<typeof saveConfig>[0])}>
<Space size={16} style={{ display: "flex" }} align="start"> <Space size={16} style={{ display: "flex" }} align="start">
<Form.Item name="endpoint" label="S3 Endpoint(S3 兼容,如 https://s3.endpoint)" style={{ minWidth: 300, flex: 1 }}> <Form.Item name="endpoint" label={t("S3 Endpoint(S3 兼容,如 https://s3.endpoint)")} style={{ minWidth: 300, flex: 1 }}>
<Input placeholder="https://s3.us-east-1.amazonaws.com 或自建端点" /> <Input placeholder={t("https://s3.us-east-1.amazonaws.com 或自建端点")} />
</Form.Item> </Form.Item>
<Form.Item name="bucket" label="Bucket" style={{ width: 200 }}> <Form.Item name="bucket" label="Bucket" style={{ width: 200 }}>
<Input placeholder="dusuniot-backup" /> <Input placeholder="dusuniot-backup" />
@@ -201,23 +202,23 @@ function BackupsPage() {
<Form.Item name="secretAccessKey" label="SecretAccessKey" style={{ width: 260 }}> <Form.Item name="secretAccessKey" label="SecretAccessKey" style={{ width: 260 }}>
<Input.Password autoComplete="new-password" /> <Input.Password autoComplete="new-password" />
</Form.Item> </Form.Item>
<Form.Item name="prefix" label="对象前缀" style={{ width: 160 }}> <Form.Item name="prefix" label={t("对象前缀")} style={{ width: 160 }}>
<Input placeholder="dusuniot" /> <Input placeholder="dusuniot" />
</Form.Item> </Form.Item>
</Space> </Space>
<Space size={24} style={{ display: "flex" }} align="start"> <Space size={24} style={{ display: "flex" }} align="start">
<Form.Item name="keepFull" label="保留全量链数" initialValue={3}> <Form.Item name="keepFull" label={t("保留全量链数")} initialValue={3}>
<InputNumber min={1} max={30} /> <InputNumber min={1} max={30} />
</Form.Item> </Form.Item>
<Form.Item name="scheduleEnabled" label="定时备份" valuePropName="checked" initialValue={false}> <Form.Item name="scheduleEnabled" label={t("定时备份")} valuePropName="checked" initialValue={false}>
<Switch /> <Switch />
</Form.Item> </Form.Item>
<Form.Item name="intervalHours" label="间隔(小时)" initialValue={24}> <Form.Item name="intervalHours" label={t("间隔(小时)")} initialValue={24}>
<InputNumber min={1} max={168} /> <InputNumber min={1} max={168} />
</Form.Item> </Form.Item>
<Form.Item label=" "> <Form.Item label=" ">
<Button type="primary" htmlType="submit" icon={<CloudUpload size={14} aria-hidden />}> <Button type="primary" htmlType="submit" icon={<CloudUpload size={14} aria-hidden />}>
保存配置 {t("保存配置")}
</Button> </Button>
</Form.Item> </Form.Item>
</Space> </Space>
@@ -229,16 +230,16 @@ function BackupsPage() {
style={{ marginTop: 8 }} style={{ marginTop: 8 }}
dataSource={data.s3} dataSource={data.s3}
pagination={false} pagination={false}
locale={{ emptyText: "S3 暂无归档对象" }} locale={{ emptyText: t("S3 暂无归档对象") }}
columns={[ columns={[
{ title: "S3 对象", dataIndex: "key", ellipsis: true, render: (v: string) => <code style={{ fontSize: 12 }}>{v}</code> }, { title: t("S3 对象"), dataIndex: "key", ellipsis: true, render: (v: string) => <code style={{ fontSize: 12 }}>{v}</code> },
{ title: "大小", dataIndex: "size", width: 110, render: (v: number) => fmtSize(v) }, { title: t("大小"), dataIndex: "size", width: 110, render: (v: number) => fmtSize(v) },
{ title: "上传时间", dataIndex: "lastModified", width: 170, render: (v: string) => fmtDateTime(v) }, { title: t("上传时间"), dataIndex: "lastModified", width: 170, render: (v: string) => fmtDateTime(v) },
]} ]}
/> />
) : null} ) : null}
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginTop: 8 }}> <Typography.Paragraph type="secondary" style={{ fontSize: 12, marginTop: 8 }}>
定时备份由 jobs worker(`bun scripts/jobs-worker.ts`)消费 `backup.run` 任务执行:自动选择增量(无备份时全量),完成后按间隔自排下一轮。 {t("定时备份由 jobs worker(`bun scripts/jobs-worker.ts`)消费 `backup.run` 任务执行:自动选择增量(无备份时全量),完成后按间隔自排下一轮。")}
</Typography.Paragraph> </Typography.Paragraph>
</Card> </Card>
</div> </div>
+44 -42
View File
@@ -12,6 +12,7 @@ import { ArrowLeft, ExternalLink, History, Save } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { adminGetContent, adminGetRevision, adminListRevisions, adminRestoreRevision, adminSaveContent } from "~/server/admin"; import { adminGetContent, adminGetRevision, adminListRevisions, adminRestoreRevision, adminSaveContent } from "~/server/admin";
import { CONTENT_STATUS_OPTIONS, Forbidden, fmtDateTime, guardAdmin } from "~/components/admin/common"; import { CONTENT_STATUS_OPTIONS, Forbidden, fmtDateTime, guardAdmin } from "~/components/admin/common";
import { useAdminT } from "~/admin-i18n";
import { RichEditor } from "~/components/admin/RichEditor"; import { RichEditor } from "~/components/admin/RichEditor";
/** # 类型 编辑表单数据形状(新建时给默认值) **/ /** # 类型 编辑表单数据形状(新建时给默认值) **/
@@ -84,6 +85,7 @@ export const Route = createFileRoute("/admin/contents/$id")({
/** # 函数 内容编辑页面组件 */ /** # 函数 内容编辑页面组件 */
function ContentEditPage() { function ContentEditPage() {
const t = useAdminT();
const data = Route.useLoaderData(); const data = Route.useLoaderData();
const navigate = useNavigate(); const navigate = useNavigate();
const router = useRouter(); const router = useRouter();
@@ -127,11 +129,11 @@ function ContentEditPage() {
categoryIds: selectedTermIds, categoryIds: selectedTermIds,
}, },
}); });
message.success("保存成功(已留修订快照)"); message.success(t("保存成功(已留修订快照)"));
await router.invalidate(); await router.invalidate();
void navigate({ to: "/admin/contents/" }); void navigate({ to: "/admin/contents/" });
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : "保存失败"); message.error(e instanceof Error ? e.message : t("保存失败"));
} }
} }
@@ -147,22 +149,22 @@ function ContentEditPage() {
const rev = await adminGetRevision({ data: { id: row.id } }); const rev = await adminGetRevision({ data: { id: row.id } });
if (!rev) return; if (!rev) return;
modal.confirm({ modal.confirm({
title: `恢复修订 #${row.id}(${fmtDateTime(row.createdAt)})`, title: t("恢复修订 #{id}({time})", { id: row.id, time: fmtDateTime(row.createdAt) }),
width: 760, width: 760,
content: ( content: (
<div style={{ maxHeight: 420, overflow: "auto" }}> <div style={{ maxHeight: 420, overflow: "auto" }}>
<p style={{ fontWeight: 600 }}>{rev.title}</p> <p style={{ fontWeight: 600 }}>{rev.title}</p>
<pre style={{ whiteSpace: "pre-wrap", fontSize: 12, maxHeight: 340, overflow: "auto", background: "#fafafa", padding: 8 }}> <pre style={{ whiteSpace: "pre-wrap", fontSize: 12, maxHeight: 340, overflow: "auto", background: "var(--ant-color-bg-container)", padding: 8 }}>
{rev.html.slice(0, 5000) || "(空正文)"} {rev.html.slice(0, 5000) || t("(空正文)")}
{rev.html.length > 5000 ? "\n…(截断预览)" : ""} {rev.html.length > 5000 ? "\n" + t("…(截断预览)") : ""}
</pre> </pre>
</div> </div>
), ),
okText: "恢复此版本", okText: t("恢复此版本"),
cancelText: "取消", cancelText: t("取消"),
onOk: async () => { onOk: async () => {
await adminRestoreRevision({ data: { id: row.id } }); await adminRestoreRevision({ data: { id: row.id } });
message.success("已恢复(恢复前版本已再留档)"); message.success(t("已恢复(恢复前版本已再留档)"));
setRevOpen(false); setRevOpen(false);
await router.invalidate(); await router.invalidate();
void navigate({ to: "/admin/contents/$id/", params: { id: String(content.id) } }).then(() => window.location.reload()); void navigate({ to: "/admin/contents/$id/", params: { id: String(content.id) } }).then(() => window.location.reload());
@@ -173,13 +175,13 @@ function ContentEditPage() {
/** # 成员变量 分类/标签树数据源(taxonomy 分组) **/ /** # 成员变量 分类/标签树数据源(taxonomy 分组) **/
const termTreeData = [ const termTreeData = [
{ {
title: "分类目录", title: t("分类目录"),
value: "__cat__", value: "__cat__",
selectable: false, selectable: false,
children: content.termOptions.filter((t) => t.taxonomy === "category").map((t) => ({ title: t.name, value: t.id })), children: content.termOptions.filter((t) => t.taxonomy === "category").map((t) => ({ title: t.name, value: t.id })),
}, },
{ {
title: "标签", title: t("标签"),
value: "__tag__", value: "__tag__",
selectable: false, selectable: false,
children: content.termOptions.filter((t) => t.taxonomy === "post_tag").map((t) => ({ title: t.name, value: t.id })), children: content.termOptions.filter((t) => t.taxonomy === "post_tag").map((t) => ({ title: t.name, value: t.id })),
@@ -190,17 +192,17 @@ function ContentEditPage() {
<Card size="small"> <Card size="small">
<Space style={{ marginBottom: 16 }}> <Space style={{ marginBottom: 16 }}>
<Button icon={<ArrowLeft size={14} aria-hidden />} onClick={() => void navigate({ to: "/admin/contents/" })}> <Button icon={<ArrowLeft size={14} aria-hidden />} onClick={() => void navigate({ to: "/admin/contents/" })}>
返回列表 {t("返回列表")}
</Button> </Button>
<h2 style={{ margin: 0, fontSize: 18 }}>{content.id ? `编辑内容 #${content.id}` : "新建内容"}</h2> <h2 style={{ margin: 0, fontSize: 18 }}>{content.id ? t("编辑内容 #{id}", { id: content.id }) : t("新建内容")}</h2>
{content.id ? ( {content.id ? (
<> <>
<Button icon={<History size={14} aria-hidden />} onClick={() => void openRevisions()}> <Button icon={<History size={14} aria-hidden />} onClick={() => void openRevisions()}>
修订历史 {t("修订历史")}
</Button> </Button>
{content.status === "published" && content.slug ? ( {content.status === "published" && content.slug ? (
<Button icon={<ExternalLink size={14} aria-hidden />} onClick={() => window.open(`/${content.slug}/`, "_blank")}> <Button icon={<ExternalLink size={14} aria-hidden />} onClick={() => window.open(`/${content.slug}/`, "_blank")}>
前台预览 {t("前台预览")}
</Button> </Button>
) : null} ) : null}
</> </>
@@ -208,32 +210,32 @@ function ContentEditPage() {
</Space> </Space>
<Form form={form} layout="vertical" style={{ maxWidth: 960 }} onFinish={(v) => void handleSave(v as Omit<ContentForm, "id">)}> <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"> <Space size={16} style={{ display: "flex" }} align="start">
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]} style={{ minWidth: 380, flex: 1 }}> <Form.Item name="title" label={t("标题")} rules={[{ required: true, message: t("请输入标题") }]} style={{ minWidth: 380, flex: 1 }}>
<Input placeholder="内容标题" /> <Input placeholder={t("内容标题")} />
</Form.Item> </Form.Item>
<Form.Item name="slug" label="别名(留空自动生成;即前台 URL /slug/)"> <Form.Item name="slug" label={t("别名(留空自动生成;即前台 URL /slug/)")}>
<Input placeholder="url-slug" style={{ width: 280 }} /> <Input placeholder="url-slug" style={{ width: 280 }} />
</Form.Item> </Form.Item>
</Space> </Space>
<Space size={16} style={{ display: "flex" }}> <Space size={16} style={{ display: "flex" }}>
<Form.Item name="type" label="类型"> <Form.Item name="type" label={t("类型")}>
<Select <Select
style={{ width: 150 }} style={{ width: 150 }}
options={[ options={[
{ value: "page", label: "页面 page" }, { value: "page", label: t("页面 page") },
{ value: "post", label: "文章 post" }, { value: "post", label: t("文章 post") },
{ value: "landing", label: "落地页 landing" }, { value: "landing", label: t("落地页 landing") },
]} ]}
/> />
</Form.Item> </Form.Item>
<Form.Item name="status" label="状态"> <Form.Item name="status" label={t("状态")}>
<Select style={{ width: 140 }} options={CONTENT_STATUS_OPTIONS} /> <Select style={{ width: 140 }} options={CONTENT_STATUS_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item name="template" label="模板"> <Form.Item name="template" label={t("模板")}>
<Input style={{ width: 200 }} placeholder="default / home / blank …" /> <Input style={{ width: 200 }} placeholder="default / home / blank …" />
</Form.Item> </Form.Item>
{content.id ? ( {content.id ? (
<Form.Item label="分类 / 标签"> <Form.Item label={t("分类 / 标签")}>
<TreeSelect <TreeSelect
treeData={termTreeData} treeData={termTreeData}
value={selectedTermIds} value={selectedTermIds}
@@ -241,44 +243,44 @@ function ContentEditPage() {
treeCheckable treeCheckable
showCheckedStrategy={TreeSelect.SHOW_CHILD} showCheckedStrategy={TreeSelect.SHOW_CHILD}
maxTagCount={4} maxTagCount={4}
placeholder="选择分类与标签" placeholder={t("选择分类与标签")}
style={{ width: 320 }} style={{ width: 320 }}
/> />
</Form.Item> </Form.Item>
) : null} ) : null}
</Space> </Space>
<Form.Item name="excerpt" label="摘要"> <Form.Item name="excerpt" label={t("摘要")}>
<Input.TextArea rows={2} /> <Input.TextArea rows={2} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="html" name="html"
label="正文(富文本编辑,Markdown 快捷语法自动转换)" label={t("正文(富文本编辑,Markdown 快捷语法自动转换)")}
tooltip="可视化编辑;需要精细控制时可切 HTML 源码。无 Elementor JSON 时前台按此渲染" tooltip={t("可视化编辑;需要精细控制时可切 HTML 源码。无 Elementor JSON 时前台按此渲染")}
> >
<RichEditor minHeight={380} /> <RichEditor minHeight={380} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="elementorJson" name="elementorJson"
label="Elementor JSON(meta.elementor,优先于 HTML 渲染)" label={t("Elementor JSON(meta.elementor,优先于 HTML 渲染)")}
tooltip='结构:{ "elements": [ ... ] } 或元素数组;可从原站 _elementor_data 复制' tooltip={t('结构:{ "elements": [ ... ] } 或元素数组;可从原站 _elementor_data 复制')}
> >
<Input.TextArea rows={10} style={{ fontFamily: "monospace", fontSize: 12 }} placeholder='{"elements": [...]}' /> <Input.TextArea rows={10} style={{ fontFamily: "monospace", fontSize: 12 }} placeholder='{"elements": [...]}' />
</Form.Item> </Form.Item>
<Space size={16} style={{ display: "flex" }}> <Space size={16} style={{ display: "flex" }}>
<Form.Item name="seoTitle" label="SEO 标题" style={{ minWidth: 420, flex: 1 }}> <Form.Item name="seoTitle" label={t("SEO 标题")} style={{ minWidth: 420, flex: 1 }}>
<Input /> <Input />
</Form.Item> </Form.Item>
</Space> </Space>
<Form.Item name="seoDescription" label="SEO 描述"> <Form.Item name="seoDescription" label={t("SEO 描述")}>
<Input.TextArea rows={2} /> <Input.TextArea rows={2} />
</Form.Item> </Form.Item>
<Button type="primary" htmlType="submit" icon={<Save size={14} aria-hidden />}> <Button type="primary" htmlType="submit" icon={<Save size={14} aria-hidden />}>
保存 {t("保存")}
</Button> </Button>
</Form> </Form>
<Modal <Modal
title="修订历史(每次保存自动留档)" title={t("修订历史(每次保存自动留档)")}
open={revOpen} open={revOpen}
onCancel={() => setRevOpen(false)} onCancel={() => setRevOpen(false)}
footer={null} footer={null}
@@ -289,19 +291,19 @@ function ContentEditPage() {
size="small" size="small"
dataSource={revisions} dataSource={revisions}
pagination={{ pageSize: 8 }} pagination={{ pageSize: 8 }}
locale={{ emptyText: "暂无修订" }} locale={{ emptyText: t("暂无修订") }}
columns={[ columns={[
{ title: "修订 ID", dataIndex: "id", width: 90 }, { title: t("修订 ID"), dataIndex: "id", width: 90 },
{ title: "标题快照", dataIndex: "title", ellipsis: true }, { title: t("标题快照"), dataIndex: "title", ellipsis: true },
{ title: "修订人", dataIndex: "authorId", width: 90 }, { title: t("修订人"), dataIndex: "authorId", width: 90 },
{ title: "时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) }, { title: t("时间"), dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
{ {
title: "操作", title: t("操作"),
key: "act", key: "act",
width: 100, width: 100,
render: (_, r) => ( render: (_, r) => (
<Button size="small" onClick={() => void viewRevision(r)}> <Button size="small" onClick={() => void viewRevision(r)}>
查看 / 恢复 {t("查看 / 恢复")}
</Button> </Button>
), ),
}, },
+21 -19
View File
@@ -7,6 +7,7 @@ import { App, Button, Card, Input, Popconfirm, Space, Table, Tabs, Tag } from "a
import { DeleteOutlined, EditOutlined, PlusOutlined, SearchOutlined } from "@ant-design/icons"; import { DeleteOutlined, EditOutlined, PlusOutlined, SearchOutlined } from "@ant-design/icons";
import { useState } from "react"; import { useState } from "react";
import { adminDeleteContent, adminListContents } from "~/server/admin"; import { adminDeleteContent, adminListContents } from "~/server/admin";
import { useAdminT } from "~/admin-i18n";
import { import {
AdminEmpty, AdminEmpty,
CONTENT_STATUS_COLOR, CONTENT_STATUS_COLOR,
@@ -48,6 +49,7 @@ export const Route = createFileRoute("/admin/contents/")({
/** # 函数 内容列表页面组件 */ /** # 函数 内容列表页面组件 */
function ContentsPage() { function ContentsPage() {
const t = useAdminT();
const data = Route.useLoaderData(); const data = Route.useLoaderData();
const search = Route.useSearch(); const search = Route.useSearch();
const navigate = Route.useNavigate(); const navigate = Route.useNavigate();
@@ -65,19 +67,19 @@ function ContentsPage() {
/** # 函数 删除内容并刷新 */ /** # 函数 删除内容并刷新 */
async function handleDelete(id: number) { async function handleDelete(id: number) {
await adminDeleteContent({ data: { id } }); await adminDeleteContent({ data: { id } });
message.success("已删除"); message.success(t("已删除"));
await router.invalidate(); await router.invalidate();
} }
return ( return (
<Card size="small"> <Card size="small">
<PageHeader <PageHeader
title="内容管理" title={t("内容管理")}
extra={ extra={
<> <>
<Input <Input
allowClear allowClear
placeholder="搜索标题 / 别名" placeholder={t("搜索标题 / 别名")}
prefix={<SearchOutlined aria-hidden />} prefix={<SearchOutlined aria-hidden />}
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
@@ -86,7 +88,7 @@ function ContentsPage() {
/> />
<Link to="/admin/contents/$id/" params={{ id: "new" }}> <Link to="/admin/contents/$id/" params={{ id: "new" }}>
<Button type="primary" icon={<PlusOutlined aria-hidden />}> <Button type="primary" icon={<PlusOutlined aria-hidden />}>
新建内容 {t("新建内容")}
</Button> </Button>
</Link> </Link>
</> </>
@@ -96,10 +98,10 @@ function ContentsPage() {
activeKey={search.type ?? ""} activeKey={search.type ?? ""}
onChange={(key) => updateSearch({ type: key })} onChange={(key) => updateSearch({ type: key })}
items={[ items={[
{ key: "", label: "全部" }, { key: "", label: t("全部") },
{ key: "page", label: "页面" }, { key: "page", label: t("页面") },
{ key: "post", label: "文章" }, { key: "post", label: t("文章") },
{ key: "landing", label: "落地页" }, { key: "landing", label: t("落地页") },
]} ]}
/> />
<Table <Table
@@ -111,7 +113,7 @@ function ContentsPage() {
columns={[ columns={[
{ title: "ID", dataIndex: "id", width: 70 }, { title: "ID", dataIndex: "id", width: 70 },
{ {
title: "标题", title: t("标题"),
dataIndex: "title", dataIndex: "title",
render: (v: string, r) => ( render: (v: string, r) => (
<Link to="/admin/contents/$id/" params={{ id: String(r.id) }}> <Link to="/admin/contents/$id/" params={{ id: String(r.id) }}>
@@ -119,32 +121,32 @@ function ContentsPage() {
</Link> </Link>
), ),
}, },
{ title: "别名", dataIndex: "slug", width: 220, ellipsis: true }, { title: t("别名"), dataIndex: "slug", width: 220, ellipsis: true },
{ {
title: "类型", title: t("类型"),
dataIndex: "type", dataIndex: "type",
width: 90, width: 90,
render: (v: string) => <Tag>{CONTENT_TYPE_LABEL[v] ?? v}</Tag>, render: (v: string) => <Tag>{t(CONTENT_TYPE_LABEL[v] ?? v)}</Tag>,
}, },
{ {
title: "状态", title: t("状态"),
dataIndex: "status", dataIndex: "status",
width: 100, width: 100,
render: (v: string) => <StatusTag value={v} colorMap={CONTENT_STATUS_COLOR} />, render: (v: string) => <StatusTag value={v} colorMap={CONTENT_STATUS_COLOR} />,
}, },
{ title: "浏览", dataIndex: "views", width: 80 }, { title: t("浏览"), dataIndex: "views", width: 80 },
{ title: "更新时间", dataIndex: "updatedAt", width: 160, render: (v: string) => fmtDateTime(v) }, { title: t("更新时间"), dataIndex: "updatedAt", width: 160, render: (v: string) => fmtDateTime(v) },
{ {
title: "操作", title: t("操作"),
key: "actions", key: "actions",
width: 110, width: 110,
render: (_, r) => ( render: (_, r) => (
<Space size={4}> <Space size={4}>
<Link to="/admin/contents/$id/" params={{ id: String(r.id) }}> <Link to="/admin/contents/$id/" params={{ id: String(r.id) }}>
<Button size="small" type="text" icon={<EditOutlined aria-label="编辑" />} /> <Button size="small" type="text" icon={<EditOutlined aria-label={t("编辑")} />} />
</Link> </Link>
<Popconfirm title="确认删除该内容?" onConfirm={() => void handleDelete(r.id)}> <Popconfirm title={t("确认删除该内容?")} onConfirm={() => void handleDelete(r.id)}>
<Button size="small" type="text" danger icon={<DeleteOutlined aria-label="删除" />} /> <Button size="small" type="text" danger icon={<DeleteOutlined aria-label={t("删除")} />} />
</Popconfirm> </Popconfirm>
</Space> </Space>
), ),
+30 -28
View File
@@ -7,6 +7,7 @@ import { App, Button, Card, Form, Input, InputNumber, Modal, Popconfirm, Select,
import { DeleteOutlined, EditOutlined, PlusOutlined } from "@ant-design/icons"; import { DeleteOutlined, EditOutlined, PlusOutlined } from "@ant-design/icons";
import { useState } from "react"; import { useState } from "react";
import { adminDeleteFilter, adminListFilters, adminSaveFilter } from "~/server/admin"; import { adminDeleteFilter, adminListFilters, adminSaveFilter } from "~/server/admin";
import { useAdminT } from "~/admin-i18n";
import { AdminEmpty, Forbidden, PageHeader, guardAdmin } from "~/components/admin/common"; import { AdminEmpty, Forbidden, PageHeader, guardAdmin } from "~/components/admin/common";
import type { filters } from "~/db/schema"; import type { filters } from "~/db/schema";
@@ -20,6 +21,7 @@ const FILTER_TYPE_LABEL: Record<string, string> = { attribute: "属性", categor
/** # 函数 筛选配置页面组件 */ /** # 函数 筛选配置页面组件 */
function FiltersPage() { function FiltersPage() {
const t = useAdminT();
const data = Route.useLoaderData(); const data = Route.useLoaderData();
const router = useRouter(); const router = useRouter();
const { message } = App.useApp(); const { message } = App.useApp();
@@ -43,7 +45,7 @@ function FiltersPage() {
/** # 函数 保存筛选配置 */ /** # 函数 保存筛选配置 */
async function handleSave(values: { name: string; slug: string; type: string; attributeId: number | null; position: number; enabled: boolean }) { async function handleSave(values: { name: string; slug: string; type: string; attributeId: number | null; position: number; enabled: boolean }) {
await adminSaveFilter({ data: { ...values, id: editing?.id } }); await adminSaveFilter({ data: { ...values, id: editing?.id } });
message.success("已保存"); message.success(t("已保存"));
setEditing(null); setEditing(null);
await router.invalidate(); await router.invalidate();
} }
@@ -51,17 +53,17 @@ function FiltersPage() {
/** # 函数 删除筛选配置 */ /** # 函数 删除筛选配置 */
async function handleDelete(id: number) { async function handleDelete(id: number) {
await adminDeleteFilter({ data: { id } }); await adminDeleteFilter({ data: { id } });
message.success("已删除"); message.success(t("已删除"));
await router.invalidate(); await router.invalidate();
} }
return ( return (
<Card size="small"> <Card size="small">
<PageHeader <PageHeader
title="筛选配置" title={t("筛选配置")}
extra={ extra={
<Button type="primary" icon={<PlusOutlined aria-hidden />} onClick={() => openEdit(null)}> <Button type="primary" icon={<PlusOutlined aria-hidden />} onClick={() => openEdit(null)}>
新增筛选 {t("新增筛选")}
</Button> </Button>
} }
/> />
@@ -73,36 +75,36 @@ function FiltersPage() {
pagination={false} pagination={false}
columns={[ columns={[
{ title: "ID", dataIndex: "id", width: 70 }, { title: "ID", dataIndex: "id", width: 70 },
{ title: "名称", dataIndex: "name", width: 180 }, { title: t("名称"), dataIndex: "name", width: 180 },
{ title: "URL 参数", dataIndex: "slug", width: 180 }, { title: t("URL 参数"), dataIndex: "slug", width: 180 },
{ {
title: "类型", title: t("类型"),
dataIndex: "type", dataIndex: "type",
width: 100, width: 100,
render: (v: string) => <Tag>{FILTER_TYPE_LABEL[v] ?? v}</Tag>, render: (v: string) => <Tag>{t(FILTER_TYPE_LABEL[v] ?? v)}</Tag>,
}, },
{ {
title: "关联属性", title: t("关联属性"),
dataIndex: "attributeId", dataIndex: "attributeId",
width: 160, width: 160,
render: (v: number | null) => (v ? (attrName.get(v) ?? `#${v}`) : "-"), render: (v: number | null) => (v ? (attrName.get(v) ?? `#${v}`) : "-"),
}, },
{ title: "位置", dataIndex: "position", width: 70 }, { title: t("位置"), dataIndex: "position", width: 70 },
{ {
title: "启用", title: t("启用"),
dataIndex: "enabled", dataIndex: "enabled",
width: 80, width: 80,
render: (_, r) => <Tag color={r.enabled ? "green" : "default"}>{r.enabled ? "启用" : "停用"}</Tag>, render: (_, r) => <Tag color={r.enabled ? "green" : "default"}>{r.enabled ? t("启用") : t("停用")}</Tag>,
}, },
{ {
title: "操作", title: t("操作"),
key: "actions", key: "actions",
width: 110, width: 110,
render: (_, r) => ( render: (_, r) => (
<Space size={4}> <Space size={4}>
<Button size="small" type="text" icon={<EditOutlined aria-label="编辑" />} onClick={() => openEdit(r)} /> <Button size="small" type="text" icon={<EditOutlined aria-label={t("编辑")} />} onClick={() => openEdit(r)} />
<Popconfirm title="确认删除该筛选?" onConfirm={() => void handleDelete(r.id)}> <Popconfirm title={t("确认删除该筛选?")} onConfirm={() => void handleDelete(r.id)}>
<Button size="small" type="text" danger icon={<DeleteOutlined aria-label="删除" />} /> <Button size="small" type="text" danger icon={<DeleteOutlined aria-label={t("删除")} />} />
</Popconfirm> </Popconfirm>
</Space> </Space>
), ),
@@ -111,42 +113,42 @@ function FiltersPage() {
/> />
<Modal <Modal
title={editing ? `编辑筛选 #${editing.id}` : "新增筛选"} title={editing ? t("编辑筛选 #{id}", { id: editing.id }) : t("新增筛选")}
open={editing !== null} open={editing !== null}
onCancel={() => setEditing(null)} onCancel={() => setEditing(null)}
onOk={() => form.submit()} onOk={() => form.submit()}
destroyOnHidden destroyOnHidden
> >
<Form form={form} layout="vertical" onFinish={(v) => void handleSave(v)}> <Form form={form} layout="vertical" onFinish={(v) => void handleSave(v)}>
<Form.Item name="name" label="名称" rules={[{ required: true, message: "请输入名称" }]}> <Form.Item name="name" label={t("名称")} rules={[{ required: true, message: t("请输入名称") }]}>
<Input placeholder="如 Chip Platform" /> <Input placeholder={t("如 Chip Platform")} />
</Form.Item> </Form.Item>
<Form.Item name="slug" label="URL 参数名" rules={[{ required: true, message: "请输入 URL 参数名" }]}> <Form.Item name="slug" label={t("URL 参数名")} rules={[{ required: true, message: t("请输入 URL 参数名") }]}>
<Input placeholder="chip-platform" /> <Input placeholder="chip-platform" />
</Form.Item> </Form.Item>
<Space size={16} style={{ display: "flex" }}> <Space size={16} style={{ display: "flex" }}>
<Form.Item name="type" label="类型"> <Form.Item name="type" label={t("类型")}>
<Select <Select
style={{ width: 130 }} style={{ width: 130 }}
options={[ options={[
{ value: "attribute", label: "属性" }, { value: "attribute", label: t("属性") },
{ value: "category", label: "分类" }, { value: "category", label: t("分类") },
{ value: "price", label: "价格" }, { value: "price", label: t("价格") },
]} ]}
/> />
</Form.Item> </Form.Item>
<Form.Item name="attributeId" label="关联属性"> <Form.Item name="attributeId" label={t("关联属性")}>
<Select <Select
allowClear allowClear
style={{ width: 200 }} style={{ width: 200 }}
placeholder="仅属性类型需要" placeholder={t("仅属性类型需要")}
options={data.attributes.map((a) => ({ value: a.id, label: `${a.name} (${a.slug})` }))} options={data.attributes.map((a) => ({ value: a.id, label: `${a.name} (${a.slug})` }))}
/> />
</Form.Item> </Form.Item>
<Form.Item name="position" label="位置"> <Form.Item name="position" label={t("位置")}>
<InputNumber min={0} style={{ width: 100 }} /> <InputNumber min={0} style={{ width: 100 }} />
</Form.Item> </Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked"> <Form.Item name="enabled" label={t("启用")} valuePropName="checked">
<Switch /> <Switch />
</Form.Item> </Form.Item>
</Space> </Space>
+44 -42
View File
@@ -19,6 +19,7 @@ import {
TimerReset, TimerReset,
} from "lucide-react"; } from "lucide-react";
import { adminStats } from "~/server/admin"; import { adminStats } from "~/server/admin";
import { useAdminT } from "~/admin-i18n";
import { import {
AdminEmpty, AdminEmpty,
Forbidden, Forbidden,
@@ -39,6 +40,7 @@ export const Route = createFileRoute("/admin/")({
* 数据全部来自 adminStats(30 天趋势、待办、状态分布、最近订单/询盘)。 * 数据全部来自 adminStats(30 天趋势、待办、状态分布、最近订单/询盘)。
*/ */
function DashboardPage() { function DashboardPage() {
const t = useAdminT();
const stats = Route.useLoaderData(); const stats = Route.useLoaderData();
if (!stats) return <Forbidden />; if (!stats) return <Forbidden />;
const { counts, trend, trend30, inquiryStatus, revenue, recentInquiries, recentOrders } = stats; const { counts, trend, trend30, inquiryStatus, revenue, recentInquiries, recentOrders } = stats;
@@ -61,12 +63,12 @@ function DashboardPage() {
/** # 成员变量 KPI 卡配置(图标 + 标题 + 数值 + 环比) **/ /** # 成员变量 KPI 卡配置(图标 + 标题 + 数值 + 环比) **/
const cards = [ const cards = [
{ title: "订单(30 天环比)", value: ordNow, delta: delta(ordNow, ordPrev), icon: <ShoppingCart size={18} aria-hidden color="#faad14" />, href: "/admin/orders/" }, { title: t("订单(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: t("营收(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: t("新询盘(待跟进)"), 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: t("待处理订单"), 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: t("产品"), 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/" }, { title: t("页面 / 文章"), value: counts.contents, icon: <FileText size={18} aria-hidden color="#1677ff" />, href: "/admin/contents/" },
]; ];
/** # 函数 环比箭头与配色 */ /** # 函数 环比箭头与配色 */
@@ -89,11 +91,11 @@ function DashboardPage() {
<a href={c.href} style={{ display: "block" }}> <a href={c.href} style={{ display: "block" }}>
<Card size="small" hoverable style={{ height: "100%" }}> <Card size="small" hoverable style={{ height: "100%" }}>
<Space size={10} align="start"> <Space size={10} align="start">
<div style={{ width: 34, height: 34, borderRadius: 8, background: "#f5f7fa", display: "flex", alignItems: "center", justifyContent: "center" }}> <div style={{ width: 34, height: 34, borderRadius: 8, background: "var(--ant-color-fill-quaternary)", display: "flex", alignItems: "center", justifyContent: "center" }}>
{c.icon} {c.icon}
</div> </div>
<div> <div>
<div style={{ fontSize: 12, color: "#8c8c8c" }}>{c.title}</div> <div style={{ fontSize: 12, color: "var(--ant-color-text-tertiary)" }}>{c.title}</div>
<Space size={8}> <Space size={8}>
<span style={{ fontSize: 22, fontWeight: 600, lineHeight: 1.2 }}> <span style={{ fontSize: 22, fontWeight: 600, lineHeight: 1.2 }}>
{c.money ? `$${Math.round(c.value).toLocaleString()}` : c.value.toLocaleString()} {c.money ? `$${Math.round(c.value).toLocaleString()}` : c.value.toLocaleString()}
@@ -112,31 +114,31 @@ function DashboardPage() {
<Row gutter={[16, 16]} style={{ marginTop: 16 }}> <Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={24} xl={16}> <Col xs={24} xl={16}>
<Card <Card
title="近 30 天订单 / 询盘趋势" title={t("近 30 天订单 / 询盘趋势")}
size="small" size="small"
extra={ extra={
<Space size={16} style={{ fontSize: 12, color: "#666" }}> <Space size={16} style={{ fontSize: 12, color: "var(--ant-color-text-secondary)" }}>
<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: "#1677ff", borderRadius: 2, marginRight: 4 }} />{t("订单")}</span>
<span><span style={{ display: "inline-block", width: 10, height: 10, background: "#722ed1", borderRadius: 2, marginRight: 4 }} />询盘</span> <span><span style={{ display: "inline-block", width: 10, height: 10, background: "#722ed1", borderRadius: 2, marginRight: 4 }} />{t("询盘")}</span>
</Space> </Space>
} }
> >
{/* 双列柱图(纯 div,无图表库依赖) */} {/* 双列柱图(纯 div,无图表库依赖) */}
<div style={{ display: "flex", alignItems: "flex-end", gap: 3, height: 180 }}> <div style={{ display: "flex", alignItems: "flex-end", gap: 3, height: 180 }}>
{trend30.map((t) => ( {trend30.map((d) => (
<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 key={d.day} title={`${d.day}\n${t("订单")} ${d.orders}($${Math.round(d.revenue)})\n${t("询盘")} ${d.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: `${(d.orders / max30) * 100}%`, minHeight: d.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 style={{ width: "46%", height: `${(d.inquiries / max30) * 100}%`, minHeight: d.inquiries ? 3 : 0, background: "#722ed1", borderRadius: "2px 2px 0 0", opacity: 0.75 }} />
</div> </div>
))} ))}
</div> </div>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 11, color: "#999", marginTop: 6 }}> <div style={{ display: "flex", justifyContent: "space-between", fontSize: 11, color: "var(--ant-color-text-tertiary)", marginTop: 6 }}>
<span>{trend30[0]?.day}</span> <span>{trend30[0]?.day}</span>
<span>{trend30[Math.floor(trend30.length / 2)]?.day}</span> <span>{trend30[Math.floor(trend30.length / 2)]?.day}</span>
<span>{trend30[trend30.length - 1]?.day}</span> <span>{trend30[trend30.length - 1]?.day}</span>
</div> </div>
{/* 营收迷你面积条 */} {/* 营收迷你面积条 */}
<div style={{ display: "flex", alignItems: "flex-end", gap: 3, height: 44, marginTop: 10 }} title="每日营收($)"> <div style={{ display: "flex", alignItems: "flex-end", gap: 3, height: 44, marginTop: 10 }} title={t("每日营收($)")}>
{trend30.map((t) => ( {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 key={t.day} style={{ flex: 1, height: `${Math.max(t.revenue ? 8 : 0, (t.revenue / maxRevenue) * 100)}%`, background: "#b7eb8f", borderRadius: "2px 2px 0 0" }} />
))} ))}
@@ -144,22 +146,22 @@ function DashboardPage() {
</Card> </Card>
</Col> </Col>
<Col xs={24} xl={8}> <Col xs={24} xl={8}>
<Card title="询盘漏斗" size="small" style={{ height: "100%" }}> <Card title={t("询盘漏斗")} size="small" style={{ height: "100%" }}>
<Space direction="vertical" size={12} style={{ display: "flex" }}> <Space direction="vertical" size={12} style={{ display: "flex" }}>
{(["new", "read", "replied", "spam", "archived"] as const).map((st) => { {(["new", "read", "replied", "spam", "archived"] as const).map((st) => {
const n = inquiryStatus.find((s) => s.status === st)?.count ?? 0; const n = inquiryStatus.find((s) => s.status === st)?.count ?? 0;
return ( return (
<div key={st}> <div key={st}>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 13, marginBottom: 4 }}> <div style={{ display: "flex", justifyContent: "space-between", fontSize: 13, marginBottom: 4 }}>
<span>{INQUIRY_STATUS_LABEL[st] ?? st}</span> <span>{t(INQUIRY_STATUS_LABEL[st] ?? st)}</span>
<span style={{ fontWeight: 600 }}>{n.toLocaleString()}</span> <span style={{ fontWeight: 600 }}>{n.toLocaleString()}</span>
</div> </div>
<Progress percent={Math.round((n / totalInq) * 100)} showInfo={false} strokeColor={INQUIRY_STATUS_COLOR[st] === "red" ? "#ff4d4f" : st === "new" ? "#1677ff" : "#95de64"} size="small" /> <Progress percent={Math.round((n / totalInq) * 100)} showInfo={false} strokeColor={INQUIRY_STATUS_COLOR[st] === "red" ? "#ff4d4f" : st === "new" ? "#1677ff" : "#95de64"} size="small" />
</div> </div>
); );
})} })}
<div style={{ fontSize: 12, color: "#999" }}> <div style={{ fontSize: 12, color: "var(--ant-color-text-tertiary)" }}>
共 {totalInq.toLocaleString()} 条,其中垃圾 {spamInq.toLocaleString()} 条({(Math.round((spamInq / totalInq) * 100))}%) {t("共 {n} 条,其中垃圾 {s} 条({p}%)", { n: totalInq.toLocaleString(), s: spamInq.toLocaleString(), p: Math.round((spamInq / totalInq) * 100) })}
</div> </div>
</Space> </Space>
</Card> </Card>
@@ -170,9 +172,9 @@ function DashboardPage() {
<Row gutter={[16, 16]} style={{ marginTop: 16 }}> <Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={24} xl={12}> <Col xs={24} xl={12}>
<Card <Card
title="最近订单" title={t("最近订单")}
size="small" size="small"
extra={<Link to="/admin/orders/">全部订单 →</Link>} extra={<Link to="/admin/orders/">{t("全部订单 →")}</Link>}
> >
<Table <Table
rowKey="id" rowKey="id"
@@ -181,20 +183,20 @@ function DashboardPage() {
pagination={false} pagination={false}
locale={{ emptyText: <AdminEmpty /> }} locale={{ emptyText: <AdminEmpty /> }}
columns={[ columns={[
{ title: "单号", dataIndex: "orderNumber", width: 130, render: (v: string, r) => <Link to="/admin/orders/$id/" params={{ id: String(r.id) }}>{v}</Link> }, { title: t("单号"), 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: t("客户"), dataIndex: "customerName", ellipsis: true },
{ title: "金额", dataIndex: "total", width: 100, render: (v: string, r) => `${r.currency} ${v}` }, { title: t("金额"), 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: t("状态"), dataIndex: "status", width: 90, render: (v: string) => <Tag color={ORDER_STATUS_COLOR[v] ?? "default"}>{t(ORDER_STATUS_LABEL[v] ?? v)}</Tag> },
{ title: "时间", dataIndex: "createdAt", width: 150, render: (v: string) => fmtDateTime(v) }, { title: t("时间"), dataIndex: "createdAt", width: 150, render: (v: string) => fmtDateTime(v) },
]} ]}
/> />
</Card> </Card>
</Col> </Col>
<Col xs={24} xl={12}> <Col xs={24} xl={12}>
<Card <Card
title="最新询盘" title={t("最新询盘")}
size="small" size="small"
extra={<Link to="/admin/inquiries/">全部询盘 →</Link>} extra={<Link to="/admin/inquiries/">{t("全部询盘 →")}</Link>}
> >
<Table <Table
rowKey="id" rowKey="id"
@@ -203,11 +205,11 @@ function DashboardPage() {
pagination={false} pagination={false}
locale={{ emptyText: <AdminEmpty /> }} locale={{ emptyText: <AdminEmpty /> }}
columns={[ columns={[
{ title: "姓名", dataIndex: "name", width: 120, ellipsis: true }, { title: t("姓名"), dataIndex: "name", width: 120, ellipsis: true },
{ title: "邮箱", dataIndex: "email", width: 200, ellipsis: true }, { title: t("邮箱"), dataIndex: "email", width: 200, ellipsis: true },
{ title: "国家", dataIndex: "country", width: 100 }, { title: t("国家"), 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: t("状态"), dataIndex: "status", width: 90, render: (v: string) => <Tag color={INQUIRY_STATUS_COLOR[v] ?? "default"}>{t(INQUIRY_STATUS_LABEL[v] ?? v)}</Tag> },
{ title: "时间", dataIndex: "createdAt", width: 150, render: (v: string) => fmtDateTime(v) }, { title: t("时间"), dataIndex: "createdAt", width: 150, render: (v: string) => fmtDateTime(v) },
]} ]}
/> />
</Card> </Card>
@@ -215,13 +217,13 @@ function DashboardPage() {
</Row> </Row>
{/* 快捷入口 */} {/* 快捷入口 */}
<Card title="快捷操作" size="small" style={{ marginTop: 16 }}> <Card title={t("快捷操作")} size="small" style={{ marginTop: 16 }}>
<Space size={12} wrap> <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/contents/$id/" params={{ id: "new" }}><Tag style={{ padding: "6px 12px", fontSize: 13 }}><FileText size={13} aria-hidden style={{ verticalAlign: -2, marginRight: 6 }} />{t("新建页面")}</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/products/"><Tag style={{ padding: "6px 12px", fontSize: 13 }}><PackagePlus size={13} aria-hidden style={{ verticalAlign: -2, marginRight: 6 }} />{t("产品管理")}</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/media/"><Tag style={{ padding: "6px 12px", fontSize: 13 }}><ClipboardList size={13} aria-hidden style={{ verticalAlign: -2, marginRight: 6 }} />{t("媒体库")}</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/inquiries/" search={{ status: "new" }}><Tag style={{ padding: "6px 12px", fontSize: 13 }}><MessageSquarePlus size={13} aria-hidden style={{ verticalAlign: -2, marginRight: 6 }} />{t("跟进新询盘")}</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> <Link to="/admin/menus/"><Tag style={{ padding: "6px 12px", fontSize: 13 }}><ClipboardList size={13} aria-hidden style={{ verticalAlign: -2, marginRight: 6 }} />{t("菜单管理")}</Tag></Link>
</Space> </Space>
</Card> </Card>
</div> </div>
+89 -40
View File
@@ -1,6 +1,7 @@
/** /**
* # 路由 /admin/inquiries 询盘管理 * # 路由 /admin/inquiries 询盘管理
* 状态 Tab 过滤 + 表格;行点击抽屉查看完整 fields JSON;操作按钮标记已读/已回复/垃圾。 * 状态 Tab 过滤 + 表格;行点击抽屉查看详情(宽度为普通抽屉两倍,便于阅读);
* fields 以可读键值渲染(过滤下划线内部键,PHP 序列化遗留值兜底还原),不再出现裸 JSON。
* 数据接口复用 src/server/inquiries.ts 的 listInquiries / updateInquiryStatus。 * 数据接口复用 src/server/inquiries.ts 的 listInquiries / updateInquiryStatus。
*/ */
import { createFileRoute, useRouter } from "@tanstack/react-router"; import { createFileRoute, useRouter } from "@tanstack/react-router";
@@ -9,6 +10,8 @@ import { CheckOutlined, SearchOutlined, SendOutlined, StopOutlined } from "@ant-
import { useState } from "react"; import { useState } from "react";
import { listInquiries, updateInquiryStatus } from "~/server/inquiries"; import { listInquiries, updateInquiryStatus } from "~/server/inquiries";
import type { inquiries } from "~/db/schema"; import type { inquiries } from "~/db/schema";
import { deepConvertPhp } from "~/lib/php-serialize";
import { useAdminT } from "~/admin-i18n";
import { import {
AdminEmpty, AdminEmpty,
Forbidden, Forbidden,
@@ -16,7 +19,6 @@ import {
INQUIRY_STATUS_LABEL, INQUIRY_STATUS_LABEL,
Json, Json,
PageHeader, PageHeader,
StatusTag,
fmtDateTime, fmtDateTime,
guardAdmin, guardAdmin,
tablePagination, tablePagination,
@@ -44,8 +46,42 @@ export const Route = createFileRoute("/admin/inquiries")({
/** # 成员变量 询盘行类型(fields/utm 收窄为 Json 后的形状) **/ /** # 成员变量 询盘行类型(fields/utm 收窄为 Json 后的形状) **/
type InquiryRow = Omit<typeof inquiries.$inferSelect, "fields" | "utm"> & { fields: Json; utm: Json }; type InquiryRow = Omit<typeof inquiries.$inferSelect, "fields" | "utm"> & { fields: Json; utm: Json };
/** # 成员变量 fields 常见键的中文标签(其余键原样展示;下划线内部键不展示) **/
const FIELD_LABELS: Record<string, string> = {
"your-name": "姓名",
"your-email": "邮箱",
"your-message": "询盘内容",
"your-subject": "主题",
tel: "电话",
telephone: "电话",
phone: "电话",
company: "公司",
website: "网站",
country: "国家",
subject: "主题",
message: "询盘内容",
annualdemand: "年需求量",
"annual-demand": "年需求量",
accept: "接受条款",
};
/** # 函数 把任意字段值转为可读文本(数组逗号连接;对象键值拼接;PHP 序列化串已由 deepConvertPhp 还原) */
function fieldText(v: unknown): string {
if (v === null || v === undefined) return "";
if (typeof v === "boolean") return v ? "yes" : "no";
if (Array.isArray(v)) return v.map((x) => fieldText(x)).filter(Boolean).join(", ");
if (typeof v === "object") {
return Object.entries(v as Record<string, unknown>)
.map(([k, x]) => `${k}: ${fieldText(x)}`)
.filter((s) => !s.endsWith(": "))
.join(";");
}
return String(v).trim();
}
/** # 函数 询盘管理页面组件 */ /** # 函数 询盘管理页面组件 */
function InquiriesPage() { function InquiriesPage() {
const t = useAdminT();
const data = Route.useLoaderData(); const data = Route.useLoaderData();
const search = Route.useSearch(); const search = Route.useSearch();
const navigate = Route.useNavigate(); const navigate = Route.useNavigate();
@@ -65,18 +101,25 @@ function InquiriesPage() {
/** # 函数 更新询盘状态并刷新 */ /** # 函数 更新询盘状态并刷新 */
async function handleStatus(id: number, status: string) { async function handleStatus(id: number, status: string) {
await updateInquiryStatus({ data: { id, status } }); await updateInquiryStatus({ data: { id, status } });
message.success("状态已更新"); message.success(t("状态已更新"));
await router.invalidate(); await router.invalidate();
} }
/** # 成员变量 抽屉内可读展示的字段行(过滤下划线内部键与空值;PHP 遗留串兜底还原) **/
const extraFieldRows = viewing
? Object.entries((deepConvertPhp(viewing.fields) ?? {}) as Record<string, unknown>)
.filter(([k, v]) => !k.startsWith("_") && fieldText(v) !== "")
.map(([k, v]) => ({ key: k, label: FIELD_LABELS[k] ? t(FIELD_LABELS[k]) : k, text: fieldText(v) }))
: [];
return ( return (
<Card size="small"> <Card size="small">
<PageHeader <PageHeader
title="询盘管理" title={t("询盘管理")}
extra={ extra={
<Input <Input
allowClear allowClear
placeholder="搜索姓名 / 邮箱 / 公司" placeholder={t("搜索姓名 / 邮箱 / 公司")}
prefix={<SearchOutlined aria-hidden />} prefix={<SearchOutlined aria-hidden />}
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
@@ -89,8 +132,8 @@ function InquiriesPage() {
activeKey={search.status ?? ""} activeKey={search.status ?? ""}
onChange={(key) => updateSearch({ status: key })} onChange={(key) => updateSearch({ status: key })}
items={[ items={[
{ key: "", label: "全部" }, { key: "", label: t("全部") },
...(["new", "read", "replied", "spam", "archived"] as const).map((s) => ({ key: s, label: INQUIRY_STATUS_LABEL[s] })), ...(["new", "read", "replied", "spam", "archived"] as const).map((s) => ({ key: s, label: t(INQUIRY_STATUS_LABEL[s]) })),
]} ]}
/> />
<Table <Table
@@ -103,34 +146,34 @@ function InquiriesPage() {
void navigate({ search: (prev) => ({ ...prev, page: p }) }), void navigate({ search: (prev) => ({ ...prev, page: p }) }),
)} )}
columns={[ columns={[
{ title: "ID", dataIndex: "id", width: 70 }, { title: t("ID"), dataIndex: "id", width: 70 },
{ title: "时间", dataIndex: "createdAt", width: 150, render: (v: string) => fmtDateTime(v) }, { title: t("时间"), dataIndex: "createdAt", width: 150, render: (v: string) => fmtDateTime(v) },
{ title: "姓名", dataIndex: "name", width: 130, ellipsis: true }, { title: t("姓名"), dataIndex: "name", width: 130, ellipsis: true },
{ title: "邮箱", dataIndex: "email", width: 200, ellipsis: true }, { title: t("邮箱"), dataIndex: "email", width: 200, ellipsis: true },
{ title: "公司", dataIndex: "company", width: 160, ellipsis: true }, { title: t("公司"), dataIndex: "company", width: 160, ellipsis: true },
{ title: "国家", dataIndex: "country", width: 110, ellipsis: true }, { title: t("国家"), dataIndex: "country", width: 110, ellipsis: true },
{ title: "感兴趣产品", dataIndex: "productInterest", ellipsis: true }, { title: t("感兴趣产品"), dataIndex: "productInterest", ellipsis: true },
{ title: "来源", dataIndex: "formName", width: 150, ellipsis: true }, { title: t("来源"), dataIndex: "formName", width: 150, ellipsis: true },
{ {
title: "状态", title: t("状态"),
dataIndex: "status", dataIndex: "status",
width: 100, width: 100,
render: (v: string) => <StatusTag value={v} colorMap={INQUIRY_STATUS_COLOR} labelMap={INQUIRY_STATUS_LABEL} />, render: (v: string) => <Tag color={INQUIRY_STATUS_COLOR[v] ?? "default"}>{t(INQUIRY_STATUS_LABEL[v] ?? v)}</Tag>,
}, },
{ {
title: "操作", title: t("操作"),
key: "actions", key: "actions",
width: 130, width: 130,
render: (_, r) => ( render: (_, r) => (
<Space size={0} onClick={(e) => e.stopPropagation()}> <Space size={0} onClick={(e) => e.stopPropagation()}>
{r.status !== "read" && r.status !== "replied" && ( {r.status !== "read" && r.status !== "replied" && (
<Button size="small" type="text" icon={<CheckOutlined aria-label="标记已读" />} title="标记已读" onClick={() => void handleStatus(r.id, "read")} /> <Button size="small" type="text" icon={<CheckOutlined aria-label={t("标记已读")} />} title={t("标记已读")} onClick={() => void handleStatus(r.id, "read")} />
)} )}
{r.status !== "replied" && ( {r.status !== "replied" && (
<Button size="small" type="text" icon={<SendOutlined aria-label="标记已回复" />} title="标记已回复" onClick={() => void handleStatus(r.id, "replied")} /> <Button size="small" type="text" icon={<SendOutlined aria-label={t("标记已回复")} />} title={t("标记已回复")} onClick={() => void handleStatus(r.id, "replied")} />
)} )}
{r.status !== "spam" && ( {r.status !== "spam" && (
<Button size="small" type="text" danger icon={<StopOutlined aria-label="标记垃圾" />} title="标记垃圾" onClick={() => void handleStatus(r.id, "spam")} /> <Button size="small" type="text" danger icon={<StopOutlined aria-label={t("标记垃圾")} />} title={t("标记垃圾")} onClick={() => void handleStatus(r.id, "spam")} />
)} )}
</Space> </Space>
), ),
@@ -139,34 +182,40 @@ function InquiriesPage() {
/> />
<Drawer <Drawer
title={viewing ? `询盘 #${viewing.id} · ${viewing.name || viewing.email}` : ""} title={viewing ? `${t("询盘")} #${viewing.id} · ${viewing.name || viewing.email}` : ""}
width={560} width="min(1120px, 92vw)"
open={viewing !== null} open={viewing !== null}
onClose={() => setViewing(null)} onClose={() => setViewing(null)}
destroyOnHidden destroyOnHidden
> >
{viewing && ( {viewing && (
<> <>
<Descriptions size="small" column={1} bordered> <Descriptions size="small" column={2} bordered styles={{ label: { width: 130 } }}>
<Descriptions.Item label="状态"> <Descriptions.Item label={t("状态")}>
<Tag color={INQUIRY_STATUS_COLOR[viewing.status] ?? "default"}>{INQUIRY_STATUS_LABEL[viewing.status] ?? viewing.status}</Tag> <Tag color={INQUIRY_STATUS_COLOR[viewing.status] ?? "default"}>{t(INQUIRY_STATUS_LABEL[viewing.status] ?? viewing.status)}</Tag>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="时间">{fmtDateTime(viewing.createdAt)}</Descriptions.Item> <Descriptions.Item label={t("时间")}>{fmtDateTime(viewing.createdAt)}</Descriptions.Item>
<Descriptions.Item label="姓名">{viewing.name || "-"}</Descriptions.Item> <Descriptions.Item label={t("姓名")}>{viewing.name || "-"}</Descriptions.Item>
<Descriptions.Item label="邮箱">{viewing.email || "-"}</Descriptions.Item> <Descriptions.Item label={t("邮箱")}>{viewing.email || "-"}</Descriptions.Item>
<Descriptions.Item label="电话">{viewing.phone || "-"}</Descriptions.Item> <Descriptions.Item label={t("电话")}>{viewing.phone || "-"}</Descriptions.Item>
<Descriptions.Item label="公司">{viewing.company || "-"}</Descriptions.Item> <Descriptions.Item label={t("公司")}>{viewing.company || "-"}</Descriptions.Item>
<Descriptions.Item label="国家">{viewing.country || "-"}</Descriptions.Item> <Descriptions.Item label={t("国家")}>{viewing.country || "-"}</Descriptions.Item>
<Descriptions.Item label="感兴趣产品">{viewing.productInterest || "-"}</Descriptions.Item> <Descriptions.Item label={t("感兴趣产品")}>{viewing.productInterest || "-"}</Descriptions.Item>
<Descriptions.Item label="来源">{viewing.formName || viewing.channel || "-"}</Descriptions.Item> <Descriptions.Item label={t("来源")}>{viewing.formName || viewing.channel || "-"}</Descriptions.Item>
<Descriptions.Item label="来源页面">{viewing.sourcePage || "-"}</Descriptions.Item> <Descriptions.Item label={t("来源页面")}>{viewing.sourcePage || "-"}</Descriptions.Item>
<Descriptions.Item label="IP">{viewing.ip || "-"}</Descriptions.Item> <Descriptions.Item label="IP">{viewing.ip || "-"}</Descriptions.Item>
<Descriptions.Item label="询盘内容">{viewing.message || "-"}</Descriptions.Item> <Descriptions.Item label={t("询盘内容")}>{viewing.message || "-"}</Descriptions.Item>
</Descriptions> </Descriptions>
<h4 style={{ margin: "16px 0 8px" }}>完整字段(fields JSON)</h4> {extraFieldRows.length ? (
<pre style={{ background: "#f5f5f5", padding: 12, borderRadius: 6, fontSize: 12, maxHeight: 320, overflow: "auto" }}> <>
{JSON.stringify(viewing.fields, null, 2)} <h4 style={{ margin: "20px 0 8px" }}>{t("其他提交字段")}</h4>
</pre> <Descriptions size="small" column={1} bordered styles={{ label: { width: 180 } }}>
{extraFieldRows.map((r) => (
<Descriptions.Item key={r.key} label={r.label}>{r.text}</Descriptions.Item>
))}
</Descriptions>
</>
) : null}
</> </>
)} )}
</Drawer> </Drawer>
+26 -12
View File
@@ -5,6 +5,7 @@
import { createFileRoute, useRouter } from "@tanstack/react-router"; import { createFileRoute, useRouter } from "@tanstack/react-router";
import { App, Card, Switch, Table, Tag } from "antd"; import { App, Card, Switch, Table, Tag } from "antd";
import { adminListLanguages, adminUpdateLanguage } from "~/server/admin"; import { adminListLanguages, adminUpdateLanguage } from "~/server/admin";
import { useAdminT } from "~/admin-i18n";
import { AdminEmpty, Forbidden, PageHeader, guardAdmin } from "~/components/admin/common"; import { AdminEmpty, Forbidden, PageHeader, guardAdmin } from "~/components/admin/common";
export const Route = createFileRoute("/admin/languages")({ export const Route = createFileRoute("/admin/languages")({
@@ -14,6 +15,7 @@ export const Route = createFileRoute("/admin/languages")({
/** # 函数 语言配置页面组件 */ /** # 函数 语言配置页面组件 */
function LanguagesPage() { function LanguagesPage() {
const t = useAdminT();
const data = Route.useLoaderData(); const data = Route.useLoaderData();
const router = useRouter(); const router = useRouter();
const { message } = App.useApp(); const { message } = App.useApp();
@@ -23,40 +25,52 @@ function LanguagesPage() {
/** # 函数 切换语言启用状态并刷新 */ /** # 函数 切换语言启用状态并刷新 */
async function toggleEnabled(code: string, enabled: boolean) { async function toggleEnabled(code: string, enabled: boolean) {
await adminUpdateLanguage({ data: { code, enabled } }); await adminUpdateLanguage({ data: { code, enabled } });
message.success(enabled ? `已启用 ${code}` : `已禁用 ${code}`); message.success(enabled ? t("已启用 {code}", { code }) : t("已禁用 {code}", { code }));
await router.invalidate(); await router.invalidate();
} }
return ( return (
<Card size="small"> <Card size="small">
<PageHeader title="语言配置" extra={<span style={{ color: "#999" }}>启用后该语言会生成真实页面;默认语言不可禁用逻辑由前台渲染决定</span>} /> <PageHeader
title={t("语言配置")}
extra={
<span style={{ color: "var(--ant-color-text-tertiary)" }}>{t("启用后该语言会生成真实页面;默认语言不可禁用逻辑由前台渲染决定")}</span>
}
/>
<Table <Table
rowKey="code" rowKey="code"
size="small" size="small"
dataSource={data.items} dataSource={data.items}
locale={{ emptyText: <AdminEmpty /> }} locale={{ emptyText: <AdminEmpty /> }}
pagination={{ pageSize: 20, showTotal: (t: number) => `共 ${t} 种语言` }} pagination={{ pageSize: 20, showTotal: (total: number) => t("共 {n} 种语言", { n: total }) }}
columns={[ columns={[
{ title: "代码", dataIndex: "code", width: 110 }, { title: t("代码"), dataIndex: "code", width: 110 },
{ title: "名称", dataIndex: "name", width: 200 }, { title: t("名称"), dataIndex: "name", width: 200 },
{ {
title: "默认语言", title: t("默认语言"),
dataIndex: "isDefault", dataIndex: "isDefault",
width: 100, width: 100,
render: (v: boolean) => (v ? <Tag color="blue">默认</Tag> : <Tag>否</Tag>), render: (v: boolean) => (v ? <Tag color="blue">{t("默认")}</Tag> : <Tag>{t("否")}</Tag>),
}, },
{ {
title: "机翻", title: t("机翻"),
dataIndex: "machine", dataIndex: "machine",
width: 90, width: 90,
render: (v: boolean) => (v ? <Tag color="purple">机翻</Tag> : "-"), render: (v: boolean) => (v ? <Tag color="purple">{t("机翻")}</Tag> : "-"),
}, },
{ title: "排序", dataIndex: "sort", width: 80 }, { title: t("排序"), dataIndex: "sort", width: 80 },
{ {
title: "启用", title: t("启用"),
dataIndex: "enabled", dataIndex: "enabled",
width: 90, width: 90,
render: (v: boolean, r) => <Switch size="small" checked={v} onChange={(on) => void toggleEnabled(r.code, on)} aria-label={`切换语言 ${r.code} 启用状态`} />, render: (v: boolean, r) => (
<Switch
size="small"
checked={v}
onChange={(on) => void toggleEnabled(r.code, on)}
aria-label={t("切换语言 {code} 启用状态", { code: r.code })}
/>
),
}, },
]} ]}
/> />
+22 -20
View File
@@ -8,6 +8,7 @@ import { EditOutlined, SearchOutlined, UploadOutlined } from "@ant-design/icons"
import { File as FileIcon } from "lucide-react"; import { File as FileIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { adminListMedia, adminUpdateMedia, adminUploadMedia } from "~/server/admin"; import { adminListMedia, adminUpdateMedia, adminUploadMedia } from "~/server/admin";
import { useAdminT } from "~/admin-i18n";
import { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common"; import { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common";
import type { media } from "~/db/schema"; import type { media } from "~/db/schema";
@@ -30,6 +31,7 @@ type MediaRow = typeof media.$inferSelect;
/** # 函数 媒体库页面组件 */ /** # 函数 媒体库页面组件 */
function MediaPage() { function MediaPage() {
const t = useAdminT();
const data = Route.useLoaderData(); const data = Route.useLoaderData();
const search = Route.useSearch(); const search = Route.useSearch();
const navigate = Route.useNavigate(); const navigate = Route.useNavigate();
@@ -57,7 +59,7 @@ function MediaPage() {
async function handleSave(values: { altText?: string; title?: string }) { async function handleSave(values: { altText?: string; title?: string }) {
if (!editing) return; if (!editing) return;
await adminUpdateMedia({ data: { id: editing.id, altText: values.altText, title: values.title } }); await adminUpdateMedia({ data: { id: editing.id, altText: values.altText, title: values.title } });
message.success("已保存"); message.success(t("已保存"));
setEditing(null); setEditing(null);
await router.invalidate(); await router.invalidate();
} }
@@ -72,12 +74,12 @@ function MediaPage() {
return ( return (
<Card size="small"> <Card size="small">
<PageHeader <PageHeader
title="媒体库" title={t("媒体库")}
extra={ extra={
<Space> <Space>
<Input <Input
allowClear allowClear
placeholder="搜索文件路径" placeholder={t("搜索文件路径")}
prefix={<SearchOutlined aria-hidden />} prefix={<SearchOutlined aria-hidden />}
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
@@ -97,17 +99,17 @@ function MediaPage() {
const CHUNK = 0x8000; const CHUNK = 0x8000;
for (let i = 0; i < buf.length; i += CHUNK) bin += String.fromCharCode(...buf.subarray(i, i + CHUNK)); 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) } }); const res = await adminUploadMedia({ data: { filename: f.name, mime: f.type || "", dataBase64: btoa(bin) } });
message.success(`已上传 ${f.name}(#${res.id})`); message.success(t("已上传 {name}(#{id})", { name: f.name, id: res.id }));
onSuccess?.(res); onSuccess?.(res);
await router.invalidate(); await router.invalidate();
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : "上传失败"); message.error(e instanceof Error ? e.message : t("上传失败"));
onError?.(e as Error); onError?.(e as Error);
} }
}} }}
> >
<Button type="primary" icon={<UploadOutlined aria-hidden />}> <Button type="primary" icon={<UploadOutlined aria-hidden />}>
上传媒体 {t("上传媒体")}
</Button> </Button>
</Upload> </Upload>
</Space> </Space>
@@ -124,18 +126,18 @@ function MediaPage() {
columns={[ columns={[
{ title: "ID", dataIndex: "id", width: 70 }, { title: "ID", dataIndex: "id", width: 70 },
{ {
title: "预览", title: t("预览"),
dataIndex: "url", dataIndex: "url",
width: 88, width: 88,
render: (v: string, r) => render: (v: string, r) =>
r.mimeType.startsWith("image") ? ( r.mimeType.startsWith("image") ? (
<Image src={v} alt={r.altText || r.path} width={56} height={56} style={{ objectFit: "cover", borderRadius: 4 }} /> <Image src={v} alt={r.altText || r.path} width={56} height={56} style={{ objectFit: "cover", borderRadius: 4 }} />
) : ( ) : (
<FileIcon size={36} aria-hidden color="#bfbfbf" /> <FileIcon size={36} aria-hidden color="var(--ant-color-text-quaternary)" />
), ),
}, },
{ {
title: "路径", title: t("路径"),
dataIndex: "path", dataIndex: "path",
render: (v: string, r) => ( render: (v: string, r) => (
<Typography.Link href={r.url} target="_blank" style={{ wordBreak: "break-all" }}> <Typography.Link href={r.url} target="_blank" style={{ wordBreak: "break-all" }}>
@@ -143,29 +145,29 @@ function MediaPage() {
</Typography.Link> </Typography.Link>
), ),
}, },
{ title: "类型", dataIndex: "mimeType", width: 140 }, { title: t("类型"), dataIndex: "mimeType", width: 140 },
{ title: "尺寸", key: "dim", width: 110, render: (_, r) => (r.width ? `${r.width} x ${r.height}` : "-") }, { title: t("尺寸"), key: "dim", width: 110, render: (_, r) => (r.width ? `${r.width} x ${r.height}` : "-") },
{ title: "大小", dataIndex: "size", width: 90, render: (v: number | null) => fmtSize(v) }, { title: t("大小"), dataIndex: "size", width: 90, render: (v: number | null) => fmtSize(v) },
{ title: "引用", dataIndex: "refCount", width: 70 }, { title: t("引用数"), dataIndex: "refCount", width: 70 },
{ title: "替代文本", dataIndex: "altText", ellipsis: true }, { title: t("替代文本"), dataIndex: "altText", ellipsis: true },
{ title: "上传时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) }, { title: t("上传时间"), dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
{ {
title: "操作", title: t("操作"),
key: "actions", key: "actions",
width: 70, width: 70,
render: (_, r) => ( render: (_, r) => (
<Button size="small" type="text" icon={<EditOutlined aria-label="编辑" />} onClick={() => openEdit(r)} /> <Button size="small" type="text" icon={<EditOutlined aria-label={t("编辑")} />} onClick={() => openEdit(r)} />
), ),
}, },
]} ]}
/> />
<Modal title={`编辑媒体 #${editing?.id ?? ""}`} open={editing !== null} onCancel={() => setEditing(null)} onOk={() => form.submit()} destroyOnHidden> <Modal title={t("编辑媒体 #{id}", { id: editing?.id ?? "" })} open={editing !== null} onCancel={() => setEditing(null)} onOk={() => form.submit()} destroyOnHidden>
<Form form={form} layout="vertical" onFinish={(v) => void handleSave(v)}> <Form form={form} layout="vertical" onFinish={(v) => void handleSave(v)}>
<Form.Item name="altText" label="替代文本(alt)"> <Form.Item name="altText" label={t("替代文本(alt)")}>
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item name="title" label="标题"> <Form.Item name="title" label={t("标题")}>
<Input /> <Input />
</Form.Item> </Form.Item>
</Form> </Form>
+26 -24
View File
@@ -8,6 +8,7 @@ import { App, Button, Card, Form, Input, InputNumber, Modal, Select, Space, Tabl
import { DeleteOutlined, EditOutlined, PlusOutlined } from "@ant-design/icons"; import { DeleteOutlined, EditOutlined, PlusOutlined } from "@ant-design/icons";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { adminDeleteMenuItem, adminListMenus, adminSaveMenuItem } from "~/server/admin"; import { adminDeleteMenuItem, adminListMenus, adminSaveMenuItem } from "~/server/admin";
import { useAdminT } from "~/admin-i18n";
import { AdminEmpty, Forbidden, PageHeader, guardAdmin } from "~/components/admin/common"; import { AdminEmpty, Forbidden, PageHeader, guardAdmin } from "~/components/admin/common";
export const Route = createFileRoute("/admin/menus")({ export const Route = createFileRoute("/admin/menus")({
@@ -22,6 +23,7 @@ type MenuItemRow = { id: number; menuId: number; parentId: number | null; title:
/** # 函数 菜单管理页面组件 */ /** # 函数 菜单管理页面组件 */
function MenusPage() { function MenusPage() {
const t = useAdminT();
const data = Route.useLoaderData(); const data = Route.useLoaderData();
const router = useRouter(); const router = useRouter();
const { message } = App.useApp(); const { message } = App.useApp();
@@ -68,34 +70,34 @@ function MenusPage() {
async function handleSave(values: { menuId: number; parentId?: number | null; title: string; url: string; target: string; sort: number }) { async function handleSave(values: { menuId: number; parentId?: number | null; title: string; url: string; target: string; sort: number }) {
try { try {
await adminSaveMenuItem({ data: { ...values, parentId: values.parentId ?? null, id: editing?.id || undefined } }); await adminSaveMenuItem({ data: { ...values, parentId: values.parentId ?? null, id: editing?.id || undefined } });
message.success("已保存"); message.success(t("已保存"));
setEditing(null); setEditing(null);
await router.invalidate(); await router.invalidate();
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : "保存失败"); message.error(e instanceof Error ? e.message : t("保存失败"));
} }
} }
/** # 函数 删除菜单项(子项自动提升顶层) */ /** # 函数 删除菜单项(子项自动提升顶层) */
async function handleDelete(id: number) { async function handleDelete(id: number) {
await adminDeleteMenuItem({ data: { id } }); await adminDeleteMenuItem({ data: { id } });
message.success("已删除(子项已提升为顶层)"); message.success(t("已删除(子项已提升为顶层)"));
await router.invalidate(); await router.invalidate();
} }
/** # 成员变量 父级选择树(排除自身防环) **/ /** # 成员变量 父级选择树(排除自身防环) **/
const parentOptions = [ const parentOptions = [
{ title: "(顶层)", value: 0 }, { title: t("(顶层)"), value: 0 },
...rows.filter((r) => r.id !== editing?.id).map((r) => ({ title: `${"— ".repeat(r.depth)}${r.title}`, value: r.id })), ...rows.filter((r) => r.id !== editing?.id).map((r) => ({ title: `${"— ".repeat(r.depth)}${r.title}`, value: r.id })),
]; ];
return ( return (
<Card size="small"> <Card size="small">
<PageHeader <PageHeader
title="菜单管理" title={t("菜单管理")}
extra={ extra={
<Button type="primary" icon={<PlusOutlined aria-hidden />} onClick={() => openEdit()} disabled={menuId === null}> <Button type="primary" icon={<PlusOutlined aria-hidden />} onClick={() => openEdit()} disabled={menuId === null}>
新增菜单项 {t("新增菜单项")}
</Button> </Button>
} }
/> />
@@ -104,9 +106,9 @@ function MenusPage() {
style={{ width: 300 }} style={{ width: 300 }}
value={menuId ?? undefined} value={menuId ?? undefined}
onChange={(v) => setMenuId(v)} onChange={(v) => setMenuId(v)}
options={data.menus.map((m: MenuDef) => ({ value: m.id, label: `${m.name}(${m.location})` }))} options={data.menus.map((m: MenuDef) => ({ value: m.id, label: t("{name}({location})", { name: m.name, location: m.location }) }))}
/> />
<span style={{ color: "#999", fontSize: 12 }}>前台 Header/Footer 即时读取所选位置菜单</span> <span style={{ color: "var(--ant-color-text-tertiary)", fontSize: 12 }}>{t("前台 Header/Footer 即时读取所选位置菜单")}</span>
</Space> </Space>
<Table <Table
rowKey="id" rowKey="id"
@@ -116,26 +118,26 @@ function MenusPage() {
pagination={false} pagination={false}
columns={[ columns={[
{ {
title: "标题", title: t("标题"),
dataIndex: "title", dataIndex: "title",
render: (v: string, r) => ( render: (v: string, r) => (
<span style={{ paddingLeft: r.depth * 24 }}> <span style={{ paddingLeft: r.depth * 24 }}>
{r.depth > 0 ? <span style={{ color: "#bbb", marginRight: 4 }}>└</span> : null} {r.depth > 0 ? <span style={{ color: "var(--ant-color-text-quaternary)", marginRight: 4 }}>└</span> : null}
{v} {v}
</span> </span>
), ),
}, },
{ title: "链接", dataIndex: "url", ellipsis: true, render: (v: string) => <code style={{ fontSize: 12 }}>{v}</code> }, { title: t("链接"), dataIndex: "url", ellipsis: true, render: (v: string) => <code style={{ fontSize: 12 }}>{v}</code> },
{ title: "排序", dataIndex: "sort", width: 70 }, { title: t("排序"), dataIndex: "sort", width: 70 },
{ title: "打开方式", dataIndex: "target", width: 100 }, { title: t("打开方式"), dataIndex: "target", width: 100 },
{ {
title: "操作", title: t("操作"),
key: "act", key: "act",
width: 110, width: 110,
render: (_, r) => ( render: (_, r) => (
<Space size={4}> <Space size={4}>
<Button size="small" type="text" icon={<EditOutlined aria-label="编辑" />} onClick={() => openEdit(r)} /> <Button size="small" type="text" icon={<EditOutlined aria-label={t("编辑")} />} onClick={() => openEdit(r)} />
<Button size="small" danger type="text" icon={<DeleteOutlined aria-label="删除" />} onClick={() => void handleDelete(r.id)} /> <Button size="small" danger type="text" icon={<DeleteOutlined aria-label={t("删除")} />} onClick={() => void handleDelete(r.id)} />
</Space> </Space>
), ),
}, },
@@ -143,28 +145,28 @@ function MenusPage() {
/> />
<Modal <Modal
title={editing?.id ? `编辑菜单项 #${editing.id}` : "新增菜单项"} title={editing?.id ? t("编辑菜单项 #{id}", { id: editing.id }) : t("新增菜单项")}
open={editing !== null} open={editing !== null}
onCancel={() => setEditing(null)} onCancel={() => setEditing(null)}
onOk={() => form.submit()} onOk={() => form.submit()}
destroyOnHidden 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 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: "请输入显示文字" }]}> <Form.Item name="title" label={t("显示文字")} rules={[{ required: true, message: t("请输入显示文字") }]}>
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item name="url" label="链接地址" rules={[{ required: true, message: "请输入链接" }]}> <Form.Item name="url" label={t("链接地址")} rules={[{ required: true, message: t("请输入链接") }]}>
<Input placeholder="/products/ 或 https://…" /> <Input placeholder={t("/products/ 或 https://…")} />
</Form.Item> </Form.Item>
<Form.Item name="parentId" label="父级菜单项" initialValue={0}> <Form.Item name="parentId" label={t("父级菜单项")} initialValue={0}>
<TreeSelect treeData={parentOptions} treeDefaultExpandAll /> <TreeSelect treeData={parentOptions} treeDefaultExpandAll />
</Form.Item> </Form.Item>
<Space size={16} style={{ display: "flex" }}> <Space size={16} style={{ display: "flex" }}>
<Form.Item name="sort" label="排序" initialValue={0}> <Form.Item name="sort" label={t("排序")} initialValue={0}>
<InputNumber min={0} max={9999} /> <InputNumber min={0} max={9999} />
</Form.Item> </Form.Item>
<Form.Item name="target" label="打开方式" initialValue="_self"> <Form.Item name="target" label={t("打开方式")} initialValue="_self">
<Select style={{ width: 140 }} options={[{ value: "_self", label: "当前窗口" }, { value: "_blank", label: "新窗口" }]} /> <Select style={{ width: 140 }} options={[{ value: "_self", label: t("当前窗口") }, { value: "_blank", label: t("新窗口") }]} />
</Form.Item> </Form.Item>
</Space> </Space>
<Form.Item name="menuId" hidden initialValue={menuId ?? 0}> <Form.Item name="menuId" hidden initialValue={menuId ?? 0}>
+75 -26
View File
@@ -1,17 +1,19 @@
/** /**
* # 路由 /admin/orders/$id 订单详情 * # 路由 /admin/orders/$id 订单详情
* 订单信息描述列表 + 明细表格 + 状态流转 Select。 * 订单信息描述列表 + 明细表格 + 状态流转 Select。
* 账单 / 收货信息以可读键值渲染(地址字段中文化),不再出现裸 JSON。
*/ */
import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router"; import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router";
import { App, Button, Card, Descriptions, Select, Space, Table, Tag } from "antd"; import { App, Button, Card, Descriptions, Select, Space, Table, Tag } from "antd";
import { ArrowLeftOutlined } from "@ant-design/icons"; import { ArrowLeftOutlined } from "@ant-design/icons";
import type { Json } from "~/components/admin/common";
import { adminGetOrder, adminUpdateOrderStatus } from "~/server/admin"; import { adminGetOrder, adminUpdateOrderStatus } from "~/server/admin";
import { useAdminT } from "~/admin-i18n";
import { import {
AdminEmpty, AdminEmpty,
ORDER_STATUS_COLOR, ORDER_STATUS_COLOR,
ORDER_STATUS_LABEL, ORDER_STATUS_LABEL,
ORDER_STATUS_OPTIONS, ORDER_STATUS_OPTIONS,
StatusTag,
fmtDateTime, fmtDateTime,
Forbidden, Forbidden,
guardAdmin, guardAdmin,
@@ -22,8 +24,32 @@ export const Route = createFileRoute("/admin/orders/$id")({
component: OrderDetailPage, component: OrderDetailPage,
}); });
/** # 成员变量 地址对象常见字段的中文标签 **/
const ADDRESS_LABELS: Record<string, string> = {
first_name: "名",
last_name: "姓",
company: "公司",
address_1: "地址 1",
address_2: "地址 2",
city: "城市",
state: "州 / 省",
postcode: "邮编",
country: "国家",
email: "邮箱",
phone: "电话",
};
/** # 函数 地址对象 → 可读键值行(过滤空值;未知键原样展示) */
function addressRows(v: Json | undefined): { key: string; label: string; text: string }[] {
if (!v || typeof v !== "object" || Array.isArray(v)) return [];
return Object.entries(v)
.filter(([, x]) => x !== null && x !== undefined && String(x).trim() !== "")
.map(([k, x]) => ({ key: k, label: ADDRESS_LABELS[k] ?? k, text: String(x) }));
}
/** # 函数 订单详情页面组件 */ /** # 函数 订单详情页面组件 */
function OrderDetailPage() { function OrderDetailPage() {
const t = useAdminT();
const data = Route.useLoaderData(); const data = Route.useLoaderData();
const navigate = useNavigate(); const navigate = useNavigate();
const router = useRouter(); const router = useRouter();
@@ -31,56 +57,79 @@ function OrderDetailPage() {
if (!data) return <Forbidden />; if (!data) return <Forbidden />;
const { order, userEmail, items } = data; const { order, userEmail, items } = data;
const billingRows = addressRows(order.billing as Json);
const shippingRows = addressRows(order.shipping as Json);
/** # 函数 状态流转:更新订单状态并刷新 */ /** # 函数 状态流转:更新订单状态并刷新 */
async function handleStatus(status: string) { async function handleStatus(status: string) {
await adminUpdateOrderStatus({ data: { id: order.id, status } }); await adminUpdateOrderStatus({ data: { id: order.id, status } });
message.success("状态已更新"); message.success(t("状态已更新"));
await router.invalidate(); await router.invalidate();
} }
/** # 函数 地址键值行渲染(一行一组,紧凑排版) */
function renderAddressRows(rows: { key: string; label: string; text: string }[]) {
if (!rows.length) return "-";
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: "2px 20px" }}>
{rows.map((r) => (
<span key={r.key} style={{ fontSize: 14 }}>
<span style={{ color: "var(--ant-color-text-tertiary)" }}>{t(r.label)}:</span>
{r.text}
</span>
))}
</div>
);
}
return ( return (
<Card size="small"> <Card size="small">
<Space style={{ marginBottom: 16 }}> <Space style={{ marginBottom: 16 }}>
<Button icon={<ArrowLeftOutlined aria-hidden />} onClick={() => void navigate({ to: "/admin/orders/" })}> <Button icon={<ArrowLeftOutlined aria-hidden />} onClick={() => void navigate({ to: "/admin/orders/" })}>
返回列表 {t("返回列表")}
</Button> </Button>
<h2 style={{ margin: 0, fontSize: 18 }}>订单 {order.orderNumber}</h2> <h2 style={{ margin: 0, fontSize: 18 }}>{t("订单")} {order.orderNumber}</h2>
<StatusTag value={order.status} colorMap={ORDER_STATUS_COLOR} labelMap={ORDER_STATUS_LABEL} /> <Tag color={ORDER_STATUS_COLOR[order.status] ?? "default"}>{t(ORDER_STATUS_LABEL[order.status] ?? order.status)}</Tag>
</Space> </Space>
<Card type="inner" title="订单信息" size="small" style={{ marginBottom: 16 }}> <Card type="inner" title={t("订单信息")} size="small" style={{ marginBottom: 16 }}>
<Descriptions size="small" column={3}> <Descriptions size="small" column={3}>
<Descriptions.Item label="状态流转"> <Descriptions.Item label={t("状态流转")}>
<Select value={order.status} options={ORDER_STATUS_OPTIONS} style={{ width: 140 }} onChange={(v) => void handleStatus(v)} aria-label="订单状态" /> <Select
value={order.status}
options={ORDER_STATUS_OPTIONS.map((o) => ({ value: o.value, label: t(o.label) }))}
style={{ width: 140 }}
onChange={(v) => void handleStatus(v)}
aria-label={t("订单状态")}
/>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="支付状态"> <Descriptions.Item label={t("支付状态")}>
<Tag>{order.paymentStatus}</Tag> <Tag>{order.paymentStatus}</Tag>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="支付方式">{order.paymentMethod || "-"}</Descriptions.Item> <Descriptions.Item label={t("支付方式")}>{order.paymentMethod || "-"}</Descriptions.Item>
<Descriptions.Item label="总额"> <Descriptions.Item label={t("总额")}>
{order.currency} {order.total} {order.currency} {order.total}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="小计">{order.subtotal}</Descriptions.Item> <Descriptions.Item label={t("小计")}>{order.subtotal}</Descriptions.Item>
<Descriptions.Item label="运费 / 税费"> <Descriptions.Item label={t("运费 / 税费")}>
{order.shippingTotal} / {order.taxTotal} {order.shippingTotal} / {order.taxTotal}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="客户邮箱">{userEmail || "-"}</Descriptions.Item> <Descriptions.Item label={t("客户邮箱")}>{userEmail || "-"}</Descriptions.Item>
<Descriptions.Item label="客户姓名">{order.customerName || "-"}</Descriptions.Item> <Descriptions.Item label={t("客户姓名")}>{order.customerName || "-"}</Descriptions.Item>
<Descriptions.Item label="下单时间">{fmtDateTime(order.createdAt)}</Descriptions.Item> <Descriptions.Item label={t("下单时间")}>{fmtDateTime(order.createdAt)}</Descriptions.Item>
<Descriptions.Item label="客户备注" span={3}> <Descriptions.Item label={t("客户备注")} span={3}>
{order.customerNote || "-"} {order.customerNote || "-"}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="账单信息" span={3}> <Descriptions.Item label={t("账单信息")} span={3}>
<pre style={{ margin: 0, fontSize: 12 }}>{JSON.stringify(order.billing, null, 2)}</pre> {renderAddressRows(billingRows)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="收货信息" span={3}> <Descriptions.Item label={t("收货信息")} span={3}>
<pre style={{ margin: 0, fontSize: 12 }}>{JSON.stringify(order.shipping, null, 2)}</pre> {renderAddressRows(shippingRows)}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
</Card> </Card>
<Card type="inner" title="订单明细" size="small"> <Card type="inner" title={t("订单明细")} size="small">
<Table <Table
rowKey="id" rowKey="id"
size="small" size="small"
@@ -88,11 +137,11 @@ function OrderDetailPage() {
pagination={false} pagination={false}
locale={{ emptyText: <AdminEmpty /> }} locale={{ emptyText: <AdminEmpty /> }}
columns={[ columns={[
{ title: "商品名", dataIndex: "name" }, { title: t("商品名"), dataIndex: "name" },
{ title: "SKU", dataIndex: "sku", width: 140 }, { title: "SKU", dataIndex: "sku", width: 140 },
{ title: "数量", dataIndex: "qty", width: 80 }, { title: t("数量"), dataIndex: "qty", width: 80 },
{ title: "单价", dataIndex: "price", width: 110 }, { title: t("单价"), dataIndex: "price", width: 110 },
{ title: "小计", dataIndex: "total", width: 110 }, { title: t("小计"), dataIndex: "total", width: 110 },
]} ]}
/> />
</Card> </Card>
+21 -12
View File
@@ -6,6 +6,7 @@ import { createFileRoute, Link, useRouter } from "@tanstack/react-router";
import { Button, Card, Space, Table, Tabs, Tag } from "antd"; import { Button, Card, Space, Table, Tabs, Tag } from "antd";
import { EyeOutlined } from "@ant-design/icons"; import { EyeOutlined } from "@ant-design/icons";
import { adminListOrders } from "~/server/admin"; import { adminListOrders } from "~/server/admin";
import { useAdminT } from "~/admin-i18n";
import { import {
AdminEmpty, AdminEmpty,
Forbidden, Forbidden,
@@ -35,6 +36,7 @@ export const Route = createFileRoute("/admin/orders/")({
/** # 函数 订单列表页面组件 */ /** # 函数 订单列表页面组件 */
function OrdersPage() { function OrdersPage() {
const t = useAdminT();
const data = Route.useLoaderData(); const data = Route.useLoaderData();
const search = Route.useSearch(); const search = Route.useSearch();
const navigate = Route.useNavigate(); const navigate = Route.useNavigate();
@@ -43,15 +45,15 @@ function OrdersPage() {
return ( return (
<Card size="small"> <Card size="small">
<PageHeader title="订单管理" /> <PageHeader title={t("订单管理")} />
<Tabs <Tabs
activeKey={search.status ?? ""} activeKey={search.status ?? ""}
onChange={(key) => void navigate({ search: (prev) => ({ ...prev, status: key, page: 1 }) })} onChange={(key) => void navigate({ search: (prev) => ({ ...prev, status: key, page: 1 }) })}
items={[ items={[
{ key: "", label: "全部" }, { key: "", label: t("全部") },
...(["pending", "on-hold", "processing", "completed", "cancelled", "refunded"] as const).map((s) => ({ ...(["pending", "on-hold", "processing", "completed", "cancelled", "refunded"] as const).map((s) => ({
key: s, key: s,
label: ORDER_STATUS_LABEL[s], label: t(ORDER_STATUS_LABEL[s]),
})), })),
]} ]}
/> />
@@ -66,7 +68,7 @@ function OrdersPage() {
columns={[ columns={[
{ title: "ID", dataIndex: "id", width: 70 }, { title: "ID", dataIndex: "id", width: 70 },
{ {
title: "订单号", title: t("订单号"),
dataIndex: "orderNumber", dataIndex: "orderNumber",
width: 160, width: 160,
render: (v: string, r) => ( render: (v: string, r) => (
@@ -75,29 +77,36 @@ function OrdersPage() {
</Link> </Link>
), ),
}, },
{ title: "客户", dataIndex: "customerName", width: 180, ellipsis: true }, { title: t("客户"), dataIndex: "customerName", width: 180, ellipsis: true },
{ {
title: "金额", title: t("金额"),
dataIndex: "total", dataIndex: "total",
width: 120, width: 120,
render: (v: string, r) => `${r.currency} ${v}`, render: (v: string, r) => `${r.currency} ${v}`,
}, },
{ title: "支付状态", dataIndex: "paymentStatus", width: 110, render: (v: string) => <Tag>{v}</Tag> }, { title: t("支付状态"), dataIndex: "paymentStatus", width: 110, render: (v: string) => <Tag>{v}</Tag> },
{ {
title: "订单状态", title: t("订单状态"),
dataIndex: "status", dataIndex: "status",
width: 110, width: 110,
render: (v: string) => <StatusTag value={v} colorMap={ORDER_STATUS_COLOR} labelMap={ORDER_STATUS_LABEL} />, // 渲染处翻译标签映射(不改动 common 中的常量定义)
render: (v: string) => (
<StatusTag
value={v}
colorMap={ORDER_STATUS_COLOR}
labelMap={Object.fromEntries(Object.entries(ORDER_STATUS_LABEL).map(([k, label]) => [k, t(label)]))}
/>
),
}, },
{ title: "下单时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) }, { title: t("下单时间"), dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
{ {
title: "操作", title: t("操作"),
key: "actions", key: "actions",
width: 80, width: 80,
render: (_, r) => ( render: (_, r) => (
<Space size={4}> <Space size={4}>
<Link to="/admin/orders/$id/" params={{ id: String(r.id) }}> <Link to="/admin/orders/$id/" params={{ id: String(r.id) }}>
<Button size="small" type="text" icon={<EyeOutlined aria-label="查看详情" />} /> <Button size="small" type="text" icon={<EyeOutlined aria-label={t("查看详情")} />} />
</Link> </Link>
</Space> </Space>
), ),
+180
View File
@@ -0,0 +1,180 @@
/**
* # 路由 /admin/permissions 权限管理(仅管理员可编辑)
* 按角色配置:菜单功能权限(树形勾选)+ MCP / API 访问开关。
* admin 角色恒为全量且不可编辑(防锁死);customer 未勾选任何菜单即无后台权限。
* 配置存 settings.permissions;服务端 requireCapability 叠加校验(域内至少一个菜单)。
*/
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { App, Alert, Button, Card, Space, Spin, Switch, Tag, Tree, Typography } from "antd";
import { Save } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { adminGetPermissions, adminSavePermissions } from "~/server/permissions";
import { Forbidden, PageHeader, guardAdmin } from "~/components/admin/common";
import { ADMIN_MENU_GROUPS } from "~/admin-menus";
import { useAdminT } from "~/admin-i18n";
export const Route = createFileRoute("/admin/permissions")({
loader: () => guardAdmin(() => adminGetPermissions()),
component: PermissionsPage,
});
/** # 类型 权限页数据(loader 返回:各角色有效授权 + 是否已显式配置) **/
type PermData = Record<string, { menus: string[]; mcp: boolean; api: boolean; configured: boolean }>;
/** # 成员变量 可编辑角色(admin 恒全量,不在此列) **/
const EDITABLE_ROLES = ["manager", "editor", "customer"] as const;
/** # 成员变量 角色中文标签与说明 **/
const ROLE_META: Record<string, { label: string; desc: string }> = {
manager: { label: "经理", desc: "默认:内容 + 商务全部菜单,MCP / API 开启" },
editor: { label: "编辑", desc: "默认:内容全部菜单,MCP / API 关闭" },
customer: { label: "客户", desc: "商城注册用户,默认无任何后台权限" },
};
/** # 成员变量 分组 key → 该组全部菜单 key(父子联动勾选换算用) **/
const GROUP_CHILDREN = new Map(ADMIN_MENU_GROUPS.map((g) => [`group:${g.label}`, g.children.map((m) => m.key)]));
/** # 函数 菜单树数据(分组 → 菜单;标题经 t() 翻译,key 即菜单路由) */
function buildMenuTree(t: (s: string) => string) {
return ADMIN_MENU_GROUPS.map((g) => ({
title: t(g.label),
key: `group:${g.label}`,
children: g.children.map((m) => ({ title: t(m.label), key: m.key })),
}));
}
/** # 函数 权限管理页面组件 */
function PermissionsPage() {
const t = useAdminT();
const data = Route.useLoaderData() as PermData | null;
const router = useRouter();
const { message } = App.useApp();
/** # 成员变量 各角色草稿(勾选菜单 + MCP/API 开关) **/
const [draft, setDraft] = useState<Record<string, { menus: Set<string>; mcp: boolean; api: boolean }>>({});
/** # 成员变量 保存中 **/
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!data) return;
setDraft(
Object.fromEntries(
EDITABLE_ROLES.map((role) => [
role,
{ menus: new Set(data[role]?.menus ?? []), mcp: data[role]?.mcp ?? false, api: data[role]?.api ?? false },
]),
),
);
}, [data]);
/** # 成员变量 未登录/无权限(非 admin)占位 **/
const forbidden = useMemo(() => !data, [data]);
if (forbidden) return <Forbidden />;
/** # 函数 Tree 勾选变化:父子联动后把「整组全选」折叠回分组 key 存储(等价语义) */
function handleCheck(role: string, checked: { checked: string[] }) {
setDraft((d) => {
const menus = new Set<string>();
for (const k of checked.checked) {
if (k.startsWith("group:")) {
const children = GROUP_CHILDREN.get(k) ?? [];
children.forEach((c) => menus.add(c));
} else menus.add(k);
}
return { ...d, [role]: { ...d[role]!, menus } };
});
}
/** # 函数 Tree 默认勾选项(整组全选渲染为分组勾选) */
function checkedKeys(role: string): string[] {
const menus = draft[role]?.menus ?? new Set<string>();
const keys: string[] = [];
for (const [gk, children] of GROUP_CHILDREN) {
if (children.every((c) => menus.has(c))) keys.push(gk);
else children.forEach((c) => menus.has(c) && keys.push(c));
}
return keys;
}
/** # 函数 保存:提交全部角色草稿 */
async function handleSave() {
setSaving(true);
try {
await adminSavePermissions({
data: {
roles: Object.fromEntries(
EDITABLE_ROLES.map((role) => [
role,
{ menus: [...(draft[role]?.menus ?? [])], mcp: draft[role]?.mcp ?? false, api: draft[role]?.api ?? false },
]),
),
},
});
message.success(t("权限配置已保存"));
await router.invalidate();
} catch (e) {
message.error(e instanceof Error ? e.message : t("保存失败"));
} finally {
setSaving(false);
}
}
return (
<Card size="small">
<PageHeader
title={t("权限管理")}
extra={
<Button type="primary" icon={<Save size={14} aria-hidden />} loading={saving} onClick={() => void handleSave()}>
{t("保存")}
</Button>
}
/>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message={t("按角色配置菜单功能权限与 MCP / API 访问。admin 角色恒为全量,不可编辑;未勾选任何菜单的角色无法访问对应后台页面。")}
/>
<Space direction="vertical" size={16} style={{ display: "flex" }}>
{EDITABLE_ROLES.map((role) =>
draft[role] ? (
<Card
key={role}
type="inner"
size="small"
title={
<Space size={8}>
<Tag color={role === "manager" ? "geekblue" : role === "editor" ? "cyan" : "default"}>{t(ROLE_META[role]!.label)}</Tag>
<Typography.Text type="secondary" style={{ fontSize: 13, fontWeight: 400 }}>{t(ROLE_META[role]!.desc)}</Typography.Text>
</Space>
}
extra={
<Space size={16}>
<Space size={6}>
<Typography.Text style={{ fontSize: 13 }}>MCP</Typography.Text>
<Switch size="small" checked={draft[role]!.mcp} onChange={(v) => setDraft((d) => ({ ...d, [role]: { ...d[role]!, mcp: v } }))} />
</Space>
<Space size={6}>
<Typography.Text style={{ fontSize: 13 }}>API</Typography.Text>
<Switch size="small" checked={draft[role]!.api} onChange={(v) => setDraft((d) => ({ ...d, [role]: { ...d[role]!, api: v } }))} />
</Space>
</Space>
}
>
<Tree
checkable
selectable={false}
defaultExpandAll
checkedKeys={checkedKeys(role)}
onCheck={(_, info) => handleCheck(role, { checked: (info as unknown as { checked: string[] }).checked })}
treeData={buildMenuTree(t)}
style={{ maxWidth: 560 }}
/>
</Card>
) : (
<Spin key={role} />
),
)}
</Space>
</Card>
);
}
+35 -32
View File
@@ -8,6 +8,7 @@ import { useState } from "react";
import { App, Button, Card, Form, Input, InputNumber, Select, Space, Switch } from "antd"; import { App, Button, Card, Form, Input, InputNumber, Select, Space, Switch } from "antd";
import { ArrowLeftOutlined, SaveOutlined } from "@ant-design/icons"; import { ArrowLeftOutlined, SaveOutlined } from "@ant-design/icons";
import { adminGetProduct, adminSaveProduct, adminSearchMedia } from "~/server/admin"; import { adminGetProduct, adminSaveProduct, adminSearchMedia } from "~/server/admin";
import { useAdminT } from "~/admin-i18n";
import { Forbidden, guardAdmin } from "~/components/admin/common"; import { Forbidden, guardAdmin } from "~/components/admin/common";
import { RichEditor } from "~/components/admin/RichEditor"; import { RichEditor } from "~/components/admin/RichEditor";
@@ -34,6 +35,7 @@ function mediaLabel(path: string): string {
* 参数 options:初始选项;multiple:是否多选;value/onChange 受控由 Form.Item 接管 * 参数 options:初始选项;multiple:是否多选;value/onChange 受控由 Form.Item 接管
*/ */
function MediaSelect(props: { options: MediaOption[]; multiple?: boolean; placeholder?: string }) { function MediaSelect(props: { options: MediaOption[]; multiple?: boolean; placeholder?: string }) {
const t = useAdminT();
const [opts, setOpts] = useState<MediaOption[]>(props.options); const [opts, setOpts] = useState<MediaOption[]>(props.options);
/** # 函数 远程搜索媒体(防抖 300ms) */ /** # 函数 远程搜索媒体(防抖 300ms) */
@@ -51,7 +53,7 @@ function MediaSelect(props: { options: MediaOption[]; multiple?: boolean; placeh
mode={props.multiple ? "multiple" : undefined} mode={props.multiple ? "multiple" : undefined}
allowClear allowClear
showSearch showSearch
placeholder={props.placeholder || "搜索媒体文件名"} placeholder={props.placeholder || t("搜索媒体文件名")}
onSearch={handleSearch} onSearch={handleSearch}
optionFilterProp="label" optionFilterProp="label"
style={{ maxWidth: 640, width: "100%" }} style={{ maxWidth: 640, width: "100%" }}
@@ -62,6 +64,7 @@ function MediaSelect(props: { options: MediaOption[]; multiple?: boolean; placeh
/** # 函数 产品编辑页面组件 */ /** # 函数 产品编辑页面组件 */
function ProductEditPage() { function ProductEditPage() {
const t = useAdminT();
const data = Route.useLoaderData(); const data = Route.useLoaderData();
const navigate = useNavigate(); const navigate = useNavigate();
const router = useRouter(); const router = useRouter();
@@ -122,11 +125,11 @@ function ProductEditPage() {
categoryIds: (values.categoryIds as number[]) ?? [], categoryIds: (values.categoryIds as number[]) ?? [],
}, },
}); });
message.success("保存成功"); message.success(t("保存成功"));
await router.invalidate(); await router.invalidate();
void navigate({ to: "/admin/products/" }); void navigate({ to: "/admin/products/" });
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : "保存失败"); message.error(e instanceof Error ? e.message : t("保存失败"));
} }
} }
@@ -134,16 +137,16 @@ function ProductEditPage() {
<Card size="small"> <Card size="small">
<Space style={{ marginBottom: 16 }}> <Space style={{ marginBottom: 16 }}>
<Button icon={<ArrowLeftOutlined aria-hidden />} onClick={() => void navigate({ to: "/admin/products/" })}> <Button icon={<ArrowLeftOutlined aria-hidden />} onClick={() => void navigate({ to: "/admin/products/" })}>
返回列表 {t("返回列表")}
</Button> </Button>
<h2 style={{ margin: 0, fontSize: 18 }}>{product ? `编辑产品 #${product.id}` : "新建产品"}</h2> <h2 style={{ margin: 0, fontSize: 18 }}>{product ? t("编辑产品 #{id}", { id: product.id }) : t("新建产品")}</h2>
</Space> </Space>
<Form form={form} layout="vertical" style={{ maxWidth: 900 }} initialValues={initialValues} onFinish={(v) => void handleSave(v)}> <Form form={form} layout="vertical" style={{ maxWidth: 900 }} initialValues={initialValues} onFinish={(v) => void handleSave(v)}>
<Space size={16} style={{ display: "flex" }} align="start"> <Space size={16} style={{ display: "flex" }} align="start">
<Form.Item name="title" label="名称" rules={[{ required: true, message: "请输入产品名称" }]} style={{ minWidth: 420, flex: 1 }}> <Form.Item name="title" label={t("名称")} rules={[{ required: true, message: t("请输入产品名称") }]} style={{ minWidth: 420, flex: 1 }}>
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item name="slug" label="别名(留空自动生成)"> <Form.Item name="slug" label={t("别名(留空自动生成)")}>
<Input style={{ width: 240 }} /> <Input style={{ width: 240 }} />
</Form.Item> </Form.Item>
<Form.Item name="sku" label="SKU"> <Form.Item name="sku" label="SKU">
@@ -151,57 +154,57 @@ function ProductEditPage() {
</Form.Item> </Form.Item>
</Space> </Space>
<Space size={16} style={{ display: "flex" }} wrap> <Space size={16} style={{ display: "flex" }} wrap>
<Form.Item name="price" label="现价(USD)"> <Form.Item name="price" label={t("现价(USD)")}>
<Input style={{ width: 120 }} placeholder="0.00" /> <Input style={{ width: 120 }} placeholder="0.00" />
</Form.Item> </Form.Item>
<Form.Item name="regularPrice" label="原价(USD)"> <Form.Item name="regularPrice" label={t("原价(USD)")}>
<Input style={{ width: 120 }} placeholder="0.00" /> <Input style={{ width: 120 }} placeholder="0.00" />
</Form.Item> </Form.Item>
<Form.Item name="stock" label="库存数量"> <Form.Item name="stock" label={t("库存数量")}>
<InputNumber min={0} style={{ width: 120 }} placeholder="留空不限" /> <InputNumber min={0} style={{ width: 120 }} placeholder={t("留空不限")} />
</Form.Item> </Form.Item>
<Form.Item name="stockStatus" label="库存状态"> <Form.Item name="stockStatus" label={t("库存状态")}>
<Select <Select
style={{ width: 130 }} style={{ width: 130 }}
options={[ options={[
{ value: "instock", label: "有货" }, { value: "instock", label: t("有货") },
{ value: "outofstock", label: "缺货" }, { value: "outofstock", label: t("缺货") },
{ value: "onbackorder", label: "预订" }, { value: "onbackorder", label: t("预订") },
]} ]}
/> />
</Form.Item> </Form.Item>
<Form.Item name="status" label="状态"> <Form.Item name="status" label={t("状态")}>
<Select <Select
style={{ width: 130 }} style={{ width: 130 }}
options={[ options={[
{ value: "draft", label: "草稿" }, { value: "draft", label: t("草稿") },
{ value: "published", label: "已发布" }, { value: "published", label: t("已发布") },
]} ]}
/> />
</Form.Item> </Form.Item>
<Form.Item name="featured" label="主推产品" valuePropName="checked"> <Form.Item name="featured" label={t("主推产品")} valuePropName="checked">
<Switch /> <Switch />
</Form.Item> </Form.Item>
<Form.Item name="specUrl" label="规格书地址"> <Form.Item name="specUrl" label={t("规格书地址")}>
<Input style={{ width: 240 }} /> <Input style={{ width: 240 }} />
</Form.Item> </Form.Item>
</Space> </Space>
<Space size={16} style={{ display: "flex" }} wrap> <Space size={16} style={{ display: "flex" }} wrap>
<Form.Item name="featuredMediaId" label="特色图" style={{ minWidth: 300 }}> <Form.Item name="featuredMediaId" label={t("特色图")} style={{ minWidth: 300 }}>
<MediaSelect options={mediaOptions} placeholder="搜索媒体文件名选择特色图" /> <MediaSelect options={mediaOptions} placeholder={t("搜索媒体文件名选择特色图")} />
</Form.Item> </Form.Item>
<Form.Item name="galleryIds" label="图集(可多选)" style={{ minWidth: 300 }}> <Form.Item name="galleryIds" label={t("图集(可多选)")} style={{ minWidth: 300 }}>
<MediaSelect options={[...initialGalleryOptions, ...mediaOptions.filter((m) => !initialGalleryOptions.some((g) => g.id === m.id))]} multiple /> <MediaSelect options={[...initialGalleryOptions, ...mediaOptions.filter((m) => !initialGalleryOptions.some((g) => g.id === m.id))]} multiple />
</Form.Item> </Form.Item>
</Space> </Space>
<Form.Item name="shortDescription" label="短描述"> <Form.Item name="shortDescription" label={t("短描述")}>
<Input.TextArea rows={2} /> <Input.TextArea rows={2} />
</Form.Item> </Form.Item>
<Form.Item name="description" label="详细描述(富文本编辑)"> <Form.Item name="description" label={t("详细描述(富文本编辑)")}>
<RichEditor minHeight={380} /> <RichEditor minHeight={380} />
</Form.Item> </Form.Item>
{attributes.length > 0 && ( {attributes.length > 0 && (
<Card type="inner" title="产品属性" size="small" style={{ marginBottom: 16 }}> <Card type="inner" title={t("产品属性")} size="small" style={{ marginBottom: 16 }}>
{attributes.map((a) => ( {attributes.map((a) => (
<Form.Item <Form.Item
key={a.id} key={a.id}
@@ -212,7 +215,7 @@ function ProductEditPage() {
<Select <Select
mode="multiple" mode="multiple"
allowClear allowClear
placeholder={`选择 ${a.name} 属性值`} placeholder={t("选择 {name} 属性值", { name: a.name })}
style={{ maxWidth: 640 }} style={{ maxWidth: 640 }}
options={a.values.map((v) => ({ value: v.id, label: v.value }))} options={a.values.map((v) => ({ value: v.id, label: v.value }))}
/> />
@@ -220,17 +223,17 @@ function ProductEditPage() {
))} ))}
</Card> </Card>
)} )}
<Form.Item name="categoryIds" label="产品分类"> <Form.Item name="categoryIds" label={t("产品分类")}>
<Select mode="multiple" allowClear placeholder="选择分类" style={{ maxWidth: 640 }} options={categories.map((c) => ({ value: c.id, label: c.name }))} /> <Select mode="multiple" allowClear placeholder={t("选择分类")} style={{ maxWidth: 640 }} options={categories.map((c) => ({ value: c.id, label: c.name }))} />
</Form.Item> </Form.Item>
<Form.Item name="seoTitle" label="SEO 标题"> <Form.Item name="seoTitle" label={t("SEO 标题")}>
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item name="seoDescription" label="SEO 描述"> <Form.Item name="seoDescription" label={t("SEO 描述")}>
<Input.TextArea rows={2} /> <Input.TextArea rows={2} />
</Form.Item> </Form.Item>
<Button type="primary" htmlType="submit" icon={<SaveOutlined aria-hidden />}> <Button type="primary" htmlType="submit" icon={<SaveOutlined aria-hidden />}>
保存 {t("保存")}
</Button> </Button>
</Form> </Form>
</Card> </Card>
+19 -17
View File
@@ -8,6 +8,7 @@ import { DeleteOutlined, EditOutlined, PlusOutlined, SearchOutlined } from "@ant
import { Package } from "lucide-react"; import { Package } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { adminDeleteProduct, adminListProducts } from "~/server/admin"; import { adminDeleteProduct, adminListProducts } from "~/server/admin";
import { useAdminT } from "~/admin-i18n";
import { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common"; import { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common";
export const Route = createFileRoute("/admin/products/")({ export const Route = createFileRoute("/admin/products/")({
@@ -30,6 +31,7 @@ const STOCK_COLOR: Record<string, string> = { instock: "green", outofstock: "red
/** # 函数 产品列表页面组件 */ /** # 函数 产品列表页面组件 */
function ProductsPage() { function ProductsPage() {
const t = useAdminT();
const data = Route.useLoaderData(); const data = Route.useLoaderData();
const search = Route.useSearch(); const search = Route.useSearch();
const navigate = Route.useNavigate(); const navigate = Route.useNavigate();
@@ -47,19 +49,19 @@ function ProductsPage() {
/** # 函数 删除产品并刷新 */ /** # 函数 删除产品并刷新 */
async function handleDelete(id: number) { async function handleDelete(id: number) {
await adminDeleteProduct({ data: { id } }); await adminDeleteProduct({ data: { id } });
message.success("已删除"); message.success(t("已删除"));
await router.invalidate(); await router.invalidate();
} }
return ( return (
<Card size="small"> <Card size="small">
<PageHeader <PageHeader
title="产品管理" title={t("产品管理")}
extra={ extra={
<> <>
<Input <Input
allowClear allowClear
placeholder="搜索标题 / SKU / 别名" placeholder={t("搜索标题 / SKU / 别名")}
prefix={<SearchOutlined aria-hidden />} prefix={<SearchOutlined aria-hidden />}
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
@@ -68,7 +70,7 @@ function ProductsPage() {
/> />
<Link to="/admin/products/$id/" params={{ id: "new" }}> <Link to="/admin/products/$id/" params={{ id: "new" }}>
<Button type="primary" icon={<PlusOutlined aria-hidden />}> <Button type="primary" icon={<PlusOutlined aria-hidden />}>
新建产品 {t("新建产品")}
</Button> </Button>
</Link> </Link>
</> </>
@@ -83,19 +85,19 @@ function ProductsPage() {
columns={[ columns={[
{ title: "ID", dataIndex: "id", width: 70 }, { title: "ID", dataIndex: "id", width: 70 },
{ {
title: "图", title: t("图"),
dataIndex: "thumb", dataIndex: "thumb",
width: 72, width: 72,
render: (v: string | null) => render: (v: string | null) =>
v ? ( v ? (
<Image src={v} alt="产品缩略图" width={44} height={44} style={{ objectFit: "cover", borderRadius: 4 }} /> <Image src={v} alt={t("产品缩略图")} width={44} height={44} style={{ objectFit: "cover", borderRadius: 4 }} />
) : ( ) : (
<Package size={36} aria-hidden color="#d9d9d9" /> <Package size={36} aria-hidden color="var(--ant-color-text-quaternary)" />
), ),
}, },
{ title: "SKU", dataIndex: "sku", width: 140 }, { title: "SKU", dataIndex: "sku", width: 140 },
{ {
title: "标题", title: t("标题"),
dataIndex: "title", dataIndex: "title",
render: (v: string, r) => ( render: (v: string, r) => (
<Link to="/admin/products/$id/" params={{ id: String(r.id) }}> <Link to="/admin/products/$id/" params={{ id: String(r.id) }}>
@@ -104,40 +106,40 @@ function ProductsPage() {
), ),
}, },
{ {
title: "价格", title: t("价格"),
dataIndex: "price", dataIndex: "price",
width: 110, width: 110,
render: (v: string | null, r) => (v != null ? `$${v}` : r.regularPrice != null ? `$${r.regularPrice}` : "-"), render: (v: string | null, r) => (v != null ? `$${v}` : r.regularPrice != null ? `$${r.regularPrice}` : "-"),
}, },
{ {
title: "库存", title: t("库存"),
dataIndex: "stockStatus", dataIndex: "stockStatus",
width: 100, width: 100,
render: (v: string, r) => ( render: (v: string, r) => (
<Tag color={STOCK_COLOR[v] ?? "default"}> <Tag color={STOCK_COLOR[v] ?? "default"}>
{STOCK_LABEL[v] ?? v} {t(STOCK_LABEL[v] ?? v)}
{typeof r.stock === "number" ? ` (${r.stock})` : ""} {typeof r.stock === "number" ? ` (${r.stock})` : ""}
</Tag> </Tag>
), ),
}, },
{ {
title: "状态", title: t("状态"),
dataIndex: "status", dataIndex: "status",
width: 90, width: 90,
render: (v: string) => <Tag color={v === "published" ? "green" : "default"}>{v}</Tag>, render: (v: string) => <Tag color={v === "published" ? "green" : "default"}>{v}</Tag>,
}, },
{ title: "更新时间", dataIndex: "updatedAt", width: 160, render: (v: string) => fmtDateTime(v) }, { title: t("更新时间"), dataIndex: "updatedAt", width: 160, render: (v: string) => fmtDateTime(v) },
{ {
title: "操作", title: t("操作"),
key: "actions", key: "actions",
width: 110, width: 110,
render: (_, r) => ( render: (_, r) => (
<Space size={4}> <Space size={4}>
<Link to="/admin/products/$id/" params={{ id: String(r.id) }}> <Link to="/admin/products/$id/" params={{ id: String(r.id) }}>
<Button size="small" type="text" icon={<EditOutlined aria-label="编辑" />} /> <Button size="small" type="text" icon={<EditOutlined aria-label={t("编辑")} />} />
</Link> </Link>
<Popconfirm title="确认删除该产品?" onConfirm={() => void handleDelete(r.id)}> <Popconfirm title={t("确认删除该产品?")} onConfirm={() => void handleDelete(r.id)}>
<Button size="small" type="text" danger icon={<DeleteOutlined aria-label="删除" />} /> <Button size="small" type="text" danger icon={<DeleteOutlined aria-label={t("删除")} />} />
</Popconfirm> </Popconfirm>
</Space> </Space>
), ),
+278
View File
@@ -0,0 +1,278 @@
/**
* # 路由 /admin/profile 个人设置
* 当前登录用户自助管理:头像(媒体库选择)/ 显示名 / 默认语言 / 默认主题 / 密码 / MCP Token。
* 语言与主题保存即生效(localStorage + 上下文即时切换),同时写入账号作为下次登录默认。
*/
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { App, Avatar, Button, Card, Form, Input, Select, Space, Typography } from "antd";
import { Copy, KeyRound, LibraryBig, RefreshCw, Save, Trash2, UserRound } from "lucide-react";
import { useState } from "react";
import { getMyProfile, updateMyProfile, changeMyPassword, regenerateMyMcpToken, clearMyMcpToken, type ProfileData } from "~/server/profile";
import { Forbidden, PageHeader, guardAdmin } from "~/components/admin/common";
import { MediaPickerModal } from "~/components/admin/MediaPickerModal";
import { useAdminI18n, useAdminT } from "~/admin-i18n";
import { ADMIN_LOCALES } from "~/admin-i18n/shared";
import { useAdminTheme } from "~/admin-theme";
export const Route = createFileRoute("/admin/profile")({
loader: () => guardAdmin(() => getMyProfile()),
component: ProfilePage,
});
/** # 函数 个人设置页面组件 */
function ProfilePage() {
const t = useAdminT();
const data = Route.useLoaderData() as ProfileData | null;
if (!data) return <Forbidden />;
return (
<Card size="small">
<PageHeader title={t("个人设置")} />
<Space direction="vertical" size={16} style={{ display: "flex", maxWidth: 820 }}>
<ProfileForm profile={data} />
<PasswordForm />
<McpTokenCard profile={data} />
</Space>
</Card>
);
}
/** # 组件 基本资料表单(头像 / 显示名 / 默认语言 / 默认主题) */
function ProfileForm(props: { profile: ProfileData }) {
const t = useAdminT();
const { message } = App.useApp();
const router = useRouter();
const { setLocale } = useAdminI18n();
const { setTheme } = useAdminTheme();
const [form] = Form.useForm();
/** # 成员变量 头像草稿(预览用;保存时提交) **/
const [avatar, setAvatar] = useState(props.profile.avatar || "");
/** # 成员变量 媒体库选择弹窗 **/
const [mediaOpen, setMediaOpen] = useState(false);
/** # 成员变量 保存中 **/
const [saving, setSaving] = useState(false);
/** # 函数 保存资料:提交服务端 + 本地偏好即时生效 */
async function handleSave(values: { displayName?: string; locale?: string; theme?: string }) {
setSaving(true);
try {
await updateMyProfile({ data: { displayName: values.displayName, avatar, locale: values.locale, theme: values.theme } });
if (values.locale) setLocale(values.locale);
if (values.theme) setTheme(values.theme as "light" | "dark");
message.success(t("个人资料已保存"));
await router.invalidate();
} catch (e) {
message.error(e instanceof Error ? e.message : t("保存失败"));
} finally {
setSaving(false);
}
}
return (
<Card type="inner" size="small" title={t("基本资料")}>
<Form
form={form}
layout="vertical"
onFinish={(v) => void handleSave(v as { displayName?: string; locale?: string; theme?: string })}
initialValues={{ displayName: props.profile.displayName, locale: props.profile.locale || "en", theme: props.profile.theme || "light" }}
style={{ maxWidth: 560 }}
>
<Space size={20} align="start" style={{ display: "flex" }}>
<Form.Item label={t("头像")} style={{ textAlign: "center" }}>
<Space direction="vertical" size={8}>
{avatar ? (
<Avatar size={72} src={avatar} />
) : (
<Avatar size={72} icon={<UserRound size={30} aria-hidden />} />
)}
<Space size={4}>
<Button size="small" icon={<LibraryBig size={13} aria-hidden />} onClick={() => setMediaOpen(true)}>
{t("从媒体库选择")}
</Button>
{avatar ? (
<Button size="small" type="text" danger icon={<Trash2 size={13} aria-hidden />} onClick={() => setAvatar("")} />
) : null}
</Space>
</Space>
</Form.Item>
<div style={{ flex: 1, minWidth: 320 }}>
<Form.Item name="displayName" label={t("显示名")} extra={props.profile.email}>
<Input maxLength={64} />
</Form.Item>
<Space size={16} style={{ display: "flex" }}>
<Form.Item name="locale" label={t("默认语言")} extra={t("保存后立即生效,并作为下次登录的默认语言")} style={{ flex: 1, minWidth: 220 }}>
<Select options={ADMIN_LOCALES.map((l) => ({ value: l.code, label: l.label }))} style={{ width: 200 }} />
</Form.Item>
<Form.Item name="theme" label={t("默认主题")} extra={t("保存后立即生效,并作为下次登录的默认主题")} style={{ flex: 1, minWidth: 220 }}>
<Select
options={[
{ value: "light", label: t("浅色") },
{ value: "dark", label: t("深暗") },
]}
style={{ width: 200 }}
/>
</Form.Item>
</Space>
</div>
</Space>
<Button type="primary" htmlType="submit" loading={saving} icon={<Save size={14} aria-hidden />}>
{t("保存")}
</Button>
</Form>
<MediaPickerModal
open={mediaOpen}
onClose={() => setMediaOpen(false)}
onSelect={(url) => setAvatar(url)}
/>
</Card>
);
}
/** # 组件 修改密码表单(当前密码 + 新密码 + 确认;成功后本人其他会话被吊销) */
function PasswordForm() {
const t = useAdminT();
const { message, modal } = App.useApp();
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
/** # 函数 提交改密(二次确认) */
async function handleFinish(values: { currentPassword: string; newPassword: string; confirm: string }) {
modal.confirm({
title: t("确认修改密码?"),
content: t("修改成功后,其他已登录设备将被退出。"),
okText: t("确认修改"),
cancelText: t("取消"),
onOk: async () => {
setSaving(true);
try {
await changeMyPassword({ data: { currentPassword: values.currentPassword, newPassword: values.newPassword } });
message.success(t("密码已修改"));
form.resetFields();
} catch (e) {
message.error(e instanceof Error ? e.message : t("修改失败"));
} finally {
setSaving(false);
}
},
});
}
return (
<Card type="inner" size="small" title={<Space size={6}><KeyRound size={14} aria-hidden />{t("修改密码")}</Space>}>
<Form form={form} layout="vertical" onFinish={(v) => void handleFinish(v as { currentPassword: string; newPassword: string; confirm: string })} style={{ maxWidth: 420 }}>
<Form.Item name="currentPassword" label={t("当前密码")} rules={[{ required: true, message: t("请输入当前密码") }]}>
<Input.Password autoComplete="current-password" />
</Form.Item>
<Form.Item name="newPassword" label={t("新密码")} rules={[{ required: true, min: 8, message: t("至少 8 位") }]}>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item
name="confirm"
label={t("确认新密码")}
dependencies={["newPassword"]}
rules={[
{ required: true, message: t("请再次输入新密码") },
({ getFieldValue }) => ({
validator: (_, v) => (v === getFieldValue("newPassword") ? Promise.resolve() : Promise.reject(new Error(t("两次输入不一致")))),
}),
]}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={saving}>
{t("修改密码")}
</Button>
</Form>
</Card>
);
}
/** # 组件 MCP Token 卡片(生成 / 复制 / 重置 / 清除) */
function McpTokenCard(props: { profile: ProfileData }) {
const t = useAdminT();
const { message, modal } = App.useApp();
const router = useRouter();
const [token, setToken] = useState(props.profile.mcpToken || "");
const [busy, setBusy] = useState(false);
/** # 函数 复制令牌到剪贴板 */
async function handleCopy() {
try {
await navigator.clipboard.writeText(token);
message.success(t("已复制到剪贴板"));
} catch {
message.error(t("复制失败,请手动选择复制"));
}
}
/** # 函数 重新生成(确认后覆盖旧令牌) */
function handleRegenerate() {
modal.confirm({
title: t("重新生成 MCP Token?"),
content: t("旧令牌将立即失效,使用旧令牌的客户端需要更新配置。"),
okText: t("重新生成"),
cancelText: t("取消"),
onOk: async () => {
setBusy(true);
try {
const res = await regenerateMyMcpToken();
setToken(res.token);
message.success(t("已生成新令牌"));
await router.invalidate();
} catch (e) {
message.error(e instanceof Error ? e.message : t("操作失败"));
} finally {
setBusy(false);
}
},
});
}
/** # 函数 清除令牌(确认后停用 MCP 访问) */
function handleClear() {
modal.confirm({
title: t("清除 MCP Token?"),
content: t("清除后使用该令牌的客户端将无法访问。"),
okText: t("清除"),
okButtonProps: { danger: true },
cancelText: t("取消"),
onOk: async () => {
setBusy(true);
try {
await clearMyMcpToken();
setToken("");
message.success(t("已清除令牌"));
await router.invalidate();
} catch (e) {
message.error(e instanceof Error ? e.message : t("操作失败"));
} finally {
setBusy(false);
}
},
});
}
return (
<Card type="inner" size="small" title="MCP Token" extra={
token ? (
<Space size={8}>
<Button size="small" icon={<Copy size={13} aria-hidden />} onClick={() => void handleCopy()}>{t("复制")}</Button>
<Button size="small" icon={<RefreshCw size={13} aria-hidden />} loading={busy} onClick={handleRegenerate}>{t("重新生成")}</Button>
<Button size="small" danger icon={<Trash2 size={13} aria-hidden />} loading={busy} onClick={handleClear}>{t("清除")}</Button>
</Space>
) : (
<Button size="small" type="primary" icon={<RefreshCw size={13} aria-hidden />} loading={busy} onClick={handleRegenerate}>{t("生成令牌")}</Button>
)
}>
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>
{t("用于 Agent / MCP 客户端访问本站 MCP 服务的个人访问令牌。令牌等同于你的账号权限,请妥善保管;泄露时请立即重新生成。")}
</Typography.Paragraph>
{token ? (
<Typography.Text code copyable={false} style={{ fontSize: 14 }}>
{token.slice(0, 6)}••••••••••••••••{token.slice(-4)}
</Typography.Text>
) : (
<Typography.Text type="secondary">{t("尚未生成令牌")}</Typography.Text>
)}
</Card>
);
}
+32 -25
View File
@@ -7,6 +7,7 @@ import { App, Button, Card, Form, Input, Modal, Popconfirm, Select, Space, Switc
import { DeleteOutlined, EditOutlined, PlusOutlined, SearchOutlined } from "@ant-design/icons"; import { DeleteOutlined, EditOutlined, PlusOutlined, SearchOutlined } from "@ant-design/icons";
import { useState } from "react"; import { useState } from "react";
import { adminDeleteRedirect, adminListRedirects, adminSaveRedirect } from "~/server/admin"; import { adminDeleteRedirect, adminListRedirects, adminSaveRedirect } from "~/server/admin";
import { useAdminT } from "~/admin-i18n";
import { AdminEmpty, Forbidden, PageHeader, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common"; import { AdminEmpty, Forbidden, PageHeader, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common";
import type { redirects } from "~/db/schema"; import type { redirects } from "~/db/schema";
@@ -29,6 +30,7 @@ type RedirectRow = typeof redirects.$inferSelect;
/** # 函数 SEO / 重定向页面组件 */ /** # 函数 SEO / 重定向页面组件 */
function SeoPage() { function SeoPage() {
const t = useAdminT();
const data = Route.useLoaderData(); const data = Route.useLoaderData();
const search = Route.useSearch(); const search = Route.useSearch();
const navigate = Route.useNavigate(); const navigate = Route.useNavigate();
@@ -57,7 +59,7 @@ function SeoPage() {
/** # 函数 保存重定向 */ /** # 函数 保存重定向 */
async function handleSave(values: { source: string; target: string; code: number; isRegex: boolean; enabled: boolean }) { async function handleSave(values: { source: string; target: string; code: number; isRegex: boolean; enabled: boolean }) {
await adminSaveRedirect({ data: { ...values, id: editing?.id } }); await adminSaveRedirect({ data: { ...values, id: editing?.id } });
message.success("已保存"); message.success(t("已保存"));
setEditing(null); setEditing(null);
await router.invalidate(); await router.invalidate();
} }
@@ -65,7 +67,7 @@ function SeoPage() {
/** # 函数 删除重定向 */ /** # 函数 删除重定向 */
async function handleDelete(id: number) { async function handleDelete(id: number) {
await adminDeleteRedirect({ data: { id } }); await adminDeleteRedirect({ data: { id } });
message.success("已删除"); message.success(t("已删除"));
await router.invalidate(); await router.invalidate();
} }
@@ -78,12 +80,12 @@ function SeoPage() {
return ( return (
<Card size="small"> <Card size="small">
<PageHeader <PageHeader
title="重定向管理" title={t("重定向管理")}
extra={ extra={
<> <>
<Input <Input
allowClear allowClear
placeholder="搜索源路径 / 目标" placeholder={t("搜索源路径 / 目标")}
prefix={<SearchOutlined aria-hidden />} prefix={<SearchOutlined aria-hidden />}
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
@@ -91,7 +93,7 @@ function SeoPage() {
style={{ width: 240 }} style={{ width: 240 }}
/> />
<Button type="primary" icon={<PlusOutlined aria-hidden />} onClick={() => openEdit(null)}> <Button type="primary" icon={<PlusOutlined aria-hidden />} onClick={() => openEdit(null)}>
新增重定向 {t("新增重定向")}
</Button> </Button>
</> </>
} }
@@ -106,38 +108,43 @@ function SeoPage() {
)} )}
columns={[ columns={[
{ title: "ID", dataIndex: "id", width: 70 }, { title: "ID", dataIndex: "id", width: 70 },
{ title: "源路径", dataIndex: "source", ellipsis: true }, { title: t("源路径"), dataIndex: "source", ellipsis: true },
{ title: "目标", dataIndex: "target", ellipsis: true }, { title: t("目标"), dataIndex: "target", ellipsis: true },
{ {
title: "状态码", title: t("状态码"),
dataIndex: "code", dataIndex: "code",
width: 90, width: 90,
render: (v: number) => <Tag color={v === 301 ? "green" : "blue"}>{v}</Tag>, render: (v: number) => <Tag color={v === 301 ? "green" : "blue"}>{v}</Tag>,
}, },
{ {
title: "正则", title: t("正则"),
dataIndex: "isRegex", dataIndex: "isRegex",
width: 70, width: 70,
render: (v: boolean) => (v ? <Tag color="purple">正则</Tag> : "-"), render: (v: boolean) => (v ? <Tag color="purple">{t("正则")}</Tag> : "-"),
}, },
{ title: "命中", dataIndex: "hits", width: 70 }, { title: t("命中"), dataIndex: "hits", width: 70 },
{ {
title: "启用", title: t("启用"),
dataIndex: "enabled", dataIndex: "enabled",
width: 80, width: 80,
render: (_, r) => ( render: (_, r) => (
<Switch size="small" checked={r.enabled} onChange={(on) => void toggleEnabled(r, on)} aria-label={`切换重定向 ${r.source}`} /> <Switch
size="small"
checked={r.enabled}
onChange={(on) => void toggleEnabled(r, on)}
aria-label={t("切换重定向 {source}", { source: r.source })}
/>
), ),
}, },
{ {
title: "操作", title: t("操作"),
key: "actions", key: "actions",
width: 110, width: 110,
render: (_, r) => ( render: (_, r) => (
<Space size={4}> <Space size={4}>
<Button size="small" type="text" icon={<EditOutlined aria-label="编辑" />} onClick={() => openEdit(r)} /> <Button size="small" type="text" icon={<EditOutlined aria-label={t("编辑")} />} onClick={() => openEdit(r)} />
<Popconfirm title="确认删除该重定向?" onConfirm={() => void handleDelete(r.id)}> <Popconfirm title={t("确认删除该重定向?")} onConfirm={() => void handleDelete(r.id)}>
<Button size="small" type="text" danger icon={<DeleteOutlined aria-label="删除" />} /> <Button size="small" type="text" danger icon={<DeleteOutlined aria-label={t("删除")} />} />
</Popconfirm> </Popconfirm>
</Space> </Space>
), ),
@@ -146,27 +153,27 @@ function SeoPage() {
/> />
<Modal <Modal
title={editing ? `编辑重定向 #${editing.id}` : "新增重定向"} title={editing ? t("编辑重定向 #{id}", { id: editing.id }) : t("新增重定向")}
open={editing !== null} open={editing !== null}
onCancel={() => setEditing(null)} onCancel={() => setEditing(null)}
onOk={() => form.submit()} onOk={() => form.submit()}
destroyOnHidden destroyOnHidden
> >
<Form form={form} layout="vertical" onFinish={(v) => void handleSave(v)}> <Form form={form} layout="vertical" onFinish={(v) => void handleSave(v)}>
<Form.Item name="source" label="源路径(支持正则)" rules={[{ required: true, message: "请输入源路径" }]}> <Form.Item name="source" label={t("源路径(支持正则)")} rules={[{ required: true, message: t("请输入源路径") }]}>
<Input placeholder="/old-path 或 /blog/(.*)" /> <Input placeholder={t("/old-path 或 /blog/(.*)")} />
</Form.Item> </Form.Item>
<Form.Item name="target" label="目标地址" rules={[{ required: true, message: "请输入目标地址" }]}> <Form.Item name="target" label={t("目标地址")} rules={[{ required: true, message: t("请输入目标地址") }]}>
<Input placeholder="/new-path 或 https://…" /> <Input placeholder={t("/new-path 或 https://…")} />
</Form.Item> </Form.Item>
<Space size={16} style={{ display: "flex" }}> <Space size={16} style={{ display: "flex" }}>
<Form.Item name="code" label="状态码"> <Form.Item name="code" label={t("状态码")}>
<Select style={{ width: 110 }} options={[{ value: 301, label: "301" }, { value: 302, label: "302" }]} /> <Select style={{ width: 110 }} options={[{ value: 301, label: "301" }, { value: 302, label: "302" }]} />
</Form.Item> </Form.Item>
<Form.Item name="isRegex" label="正则匹配" valuePropName="checked"> <Form.Item name="isRegex" label={t("正则匹配")} valuePropName="checked">
<Switch /> <Switch />
</Form.Item> </Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked"> <Form.Item name="enabled" label={t("启用")} valuePropName="checked">
<Switch /> <Switch />
</Form.Item> </Form.Item>
</Space> </Space>
+144 -154
View File
@@ -1,8 +1,9 @@
/** /**
* # 路由 /admin/settings 系统设置 * # 路由 /admin/settings 系统设置
* Tabs 分组表单(v0.14.0 起弃用整键 JSON 编辑器):站点信息 / 商城配置 / 邮件配置 / * Tabs 分组表单(v0.14.0 起弃用整键 JSON 编辑器):站点信息 / 商城配置 / 邮件配置 /
* SEO / 登录安全,每组一个 antd Form,保存时与现有值合并(未知字段保留)。 * SEO / 登录安全 / 关闭网站(维护模式 + 富文本维护页内容),每组一个 antd Form,
* 其余键(backup 等由专属页面管理的除外)落到「其他」Tab,保留 JSON 编辑兜底。 * 保存时与现有值合并(未知字段保留)。
* 其余键(backup 等由专属页面管理的除外)落到「其他」Tab,按可读键值展示(不再出现裸 JSON)。
* 仅 admin 角色可保存(server 端强制)。 * 仅 admin 角色可保存(server 端强制)。
*/ */
import { createFileRoute, useRouter } from "@tanstack/react-router"; import { createFileRoute, useRouter } from "@tanstack/react-router";
@@ -11,6 +12,8 @@ import { Save } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { adminListSettings, adminUpdateSetting } from "~/server/admin"; import { adminListSettings, adminUpdateSetting } from "~/server/admin";
import { Forbidden, PageHeader, fmtDateTime, guardAdmin, type Json } from "~/components/admin/common"; import { Forbidden, PageHeader, fmtDateTime, guardAdmin, type Json } from "~/components/admin/common";
import { RichEditor } from "~/components/admin/RichEditor";
import { useAdminT } from "~/admin-i18n";
export const Route = createFileRoute("/admin/settings")({ export const Route = createFileRoute("/admin/settings")({
loader: () => guardAdmin(() => adminListSettings()), loader: () => guardAdmin(() => adminListSettings()),
@@ -21,10 +24,10 @@ export const Route = createFileRoute("/admin/settings")({
type FieldDef = { type FieldDef = {
/** # 成员变量 字段名(写入 settings 值对象的键) **/ /** # 成员变量 字段名(写入 settings 值对象的键) **/
name: string; name: string;
/** # 成员变量 中文标签 **/ /** # 成员变量 中文标签(i18n 源串) **/
label: string; label: string;
/** # 成员变量 控件类型 **/ /** # 成员变量 控件类型 **/
type: "text" | "password" | "number" | "switch" | "select" | "textarea"; type: "text" | "password" | "number" | "switch" | "select" | "textarea" | "rich";
/** # 成员变量 说明/占位 **/ /** # 成员变量 说明/占位 **/
placeholder?: string; placeholder?: string;
/** # 成员变量 select 选项 **/ /** # 成员变量 select 选项 **/
@@ -39,180 +42,137 @@ type FieldDef = {
span?: 6 | 8 | 12 | 24; span?: 6 | 8 | 12 | 24;
}; };
/** # 成员变量 各 Tab 的分组表单配置(key 与 settings 表键一致;中文名 + 字段清单) **/ /** # 函数 各 Tab 的分组表单配置(key 与 settings 表键一致;中文源串 + 字段清单) */
const GROUPS: { key: string; label: string; desc?: ReactNode; fields: FieldDef[] }[] = [ function buildGroups(t: (s: string) => string) {
const currencyOptions = [
{ value: "USD", label: "USD" },
{ value: "CNY", label: "CNY" },
{ value: "EUR", label: "EUR" },
];
return [
{ {
key: "site", key: "site",
label: "站点信息", label: t("站点信息"),
fields: [ fields: [
{ name: "name", label: "站点名称", type: "text", placeholder: "Dusun IoT" }, { name: "name", label: t("站点名称"), type: "text", placeholder: "Dusun IoT" },
{ name: "url", label: "站点 URL", type: "text", placeholder: "https://dusuniot.com" }, { name: "url", label: t("站点 URL"), type: "text", placeholder: "https://dusuniot.com" },
{ { name: "currency", label: t("货币"), type: "select", options: currencyOptions },
name: "currency", { name: "timezone", label: t("时区"), type: "select", options: [
label: "货币", { value: "Asia/Shanghai", label: "Asia/Shanghai (UTC+8)" },
type: "select",
options: [
{ value: "USD", label: "USD 美元" },
{ value: "CNY", label: "CNY 人民币" },
{ value: "EUR", label: "EUR 欧元" },
],
},
{
name: "timezone",
label: "时区",
type: "select",
options: [
{ value: "Asia/Shanghai", label: "Asia/Shanghai(东八区)" },
{ value: "UTC", label: "UTC" }, { value: "UTC", label: "UTC" },
{ value: "America/New_York", label: "America/New_York" }, { value: "America/New_York", label: "America/New_York" },
{ value: "Europe/London", label: "Europe/London" }, { value: "Europe/London", label: "Europe/London" },
], ] },
}, ] as FieldDef[],
],
}, },
{ {
key: "woocommerce", key: "woocommerce",
label: "商城配置", label: t("商城配置"),
fields: [ fields: [
{ name: "productBase", label: "产品路由前缀", type: "text", placeholder: "product" }, { name: "productBase", label: t("产品路由前缀"), type: "text", placeholder: "product" },
{ name: "categoryBase", label: "分类路由前缀", type: "text", placeholder: "collection" }, { name: "categoryBase", label: t("分类路由前缀"), type: "text", placeholder: "collection" },
{ name: "tagBase", label: "标签路由前缀", type: "text", placeholder: "product-tag" }, { name: "tagBase", label: t("标签路由前缀"), type: "text", placeholder: "product-tag" },
{ { name: "currency", label: t("货币"), type: "select", options: currencyOptions },
name: "currency", { name: "guestCheckout", label: t("游客结账"), type: "switch", extra: t("允许未注册用户下单") },
label: "货币", { name: "pricesIncludeTax", label: t("价格含税"), type: "switch" },
type: "select", { name: "signupFromCheckout", label: t("结账时引导注册"), type: "switch" },
options: [ { name: "allowedCountries", label: t("销售地区"), type: "select", options: [
{ value: "USD", label: "USD 美元" }, { value: "all", label: t("全部国家") },
{ value: "CNY", label: "CNY 人民币" }, { value: "all_except", label: t("全部(除特定地区)") },
{ value: "EUR", label: "EUR 欧元" }, { value: "specified", label: t("仅指定地区") },
], ] },
}, ] as FieldDef[],
{ name: "guestCheckout", label: "游客结账", type: "switch", extra: "允许未注册用户下单" },
{ name: "pricesIncludeTax", label: "价格含税", type: "switch" },
{ name: "signupFromCheckout", label: "结账时引导注册", type: "switch" },
{
name: "allowedCountries",
label: "销售地区",
type: "select",
options: [
{ value: "all", label: "全部国家" },
{ value: "all_except", label: "全部(除特定地区)" },
{ value: "specified", label: "仅指定地区" },
],
},
],
}, },
{ {
key: "mail", key: "mail",
label: "邮件配置", label: t("邮件配置"),
desc: "询盘通知 / 订单邮件的 SMTP 发信配置;修改后建议发一封测试邮件验证。", desc: t("询盘通知 / 订单邮件的 SMTP 发信配置;修改后建议发一封测试邮件验证。"),
fields: [ fields: [
{ name: "fromName", label: "发件人名称", type: "text", placeholder: "Dusun IoT" }, { name: "fromName", label: t("发件人名称"), type: "text", placeholder: "Dusun IoT" },
{ name: "fromEmail", label: "发件邮箱", type: "text", placeholder: "no-reply@dusuniot.com" }, { name: "fromEmail", label: t("发件邮箱"), type: "text", placeholder: "no-reply@dusuniot.com" },
{ name: "smtpHost", label: "SMTP 主机", type: "text", placeholder: "smtp.exmail.qq.com" }, { name: "smtpHost", label: t("SMTP 主机"), type: "text", placeholder: "smtp.exmail.qq.com" },
{ name: "smtpPort", label: "SMTP 端口", type: "number", min: 1, max: 65535, placeholder: "465" }, { name: "smtpPort", label: t("SMTP 端口"), type: "number", min: 1, max: 65535, placeholder: "465" },
{ { name: "smtpSecure", label: t("加密方式"), type: "select", options: [
name: "smtpSecure", { value: "none", label: t("不加密") },
label: "加密方式", { value: "ssl", label: t("SSL(465)") },
type: "select", { value: "tls", label: t("STARTTLS(587)") },
options: [ ] },
{ value: "none", label: "不加密" }, { name: "smtpUser", label: t("SMTP 账号"), type: "text" },
{ value: "ssl", label: "SSL(465)" }, { name: "smtpPass", label: t("SMTP 密码/授权码"), type: "password" },
{ value: "tls", label: "STARTTLS(587)" }, ] as FieldDef[],
],
},
{ name: "smtpUser", label: "SMTP 账号", type: "text" },
{ name: "smtpPass", label: "SMTP 密码/授权码", type: "password" },
],
}, },
{ {
key: "seo", key: "seo",
label: "SEO", label: t("SEO"),
fields: [ fields: [
{ name: "titleTemplate", label: "标题模板", type: "text", placeholder: "%title% | Dusun IoT", extra: "%title% 为页面标题占位符" }, { name: "titleTemplate", label: t("标题模板"), type: "text", placeholder: "%title% | Dusun IoT", extra: t("%title% 为页面标题占位符") },
{ { name: "robots", label: t("全站 robots"), type: "select", options: [
name: "robots", { value: "all", label: t("all(允许索引)") },
label: "全站 robots", { value: "noindex", label: t("noindex(禁止索引)") },
type: "select", ] },
options: [ { name: "sitemap", label: t("启用 sitemap.xml"), type: "switch" },
{ value: "all", label: "all(允许索引)" }, ] as FieldDef[],
{ value: "noindex", label: "noindex(禁止索引)" },
],
},
{ name: "sitemap", label: "启用 sitemap.xml", type: "switch" },
],
}, },
{ {
key: "security", key: "security",
label: "登录安全", label: t("登录安全"),
desc: ( desc: (
<> <>
reCAPTCHA 在 <a href="https://www.google.com/recaptcha/admin" target="_blank" rel="noreferrer">Google reCAPTCHA 控制台</a> reCAPTCHA 在 <a href="https://www.google.com/recaptcha/admin" target="_blank" rel="noreferrer">Google reCAPTCHA {t("控制台")}</a>
申请;v2 为「我是人类」复选框,v3 为隐形评分。开启后 /admin 与 /my-account 登录均需通过验证; {t("申请;v2 为「我是人类」复选框,v3 为隐形评分。开启后 /admin 与 /my-account 登录均需通过验证;超过失败次数上限的账号将锁定指定分钟。")}
超过失败次数上限的账号将锁定指定分钟。
</> </>
), ),
fields: [ fields: [
{ name: "recaptchaEnabled", label: "启用 reCAPTCHA", type: "switch", extra: "关闭时登录流程保持现状,不加载 Google 脚本" }, { name: "recaptchaEnabled", label: t("启用 reCAPTCHA"), type: "switch", extra: t("关闭时登录流程保持现状,不加载 Google 脚本") },
{ name: "recaptchaVersion", label: t("版本"), type: "select", options: [
{ value: "v2", label: t("v2 复选框") },
{ value: "v3", label: t("v3 隐形评分") },
] },
{ name: "recaptchaSiteKey", label: t("Site Key(站点密钥)"), type: "text", placeholder: "6Le…", span: 12 },
{ name: "recaptchaSecretKey", label: t("Secret Key(服务端密钥)"), type: "password", placeholder: "6Le…", span: 12 },
{ name: "recaptchaScoreThreshold", label: t("v3 评分阈值"), type: "number", min: 0, max: 1, step: 0.1, extra: t("0~1,低于该分数判定为机器人,默认 0.5"), span: 8 },
{ name: "maxLoginAttempts", label: t("失败次数上限"), type: "number", min: 3, max: 100, extra: t("同一账号连续失败达上限即锁定"), span: 8 },
{ name: "lockMinutes", label: t("锁定时长(分钟)"), type: "number", min: 1, max: 1440, span: 8 },
] as FieldDef[],
},
{ {
name: "recaptchaVersion", key: "maintenance",
label: "版本", label: t("关闭网站"),
type: "select", desc: t("开启后前台所有页面立即显示下方维护内容(返回 503),后台 /admin 不受影响。可用于临时停机维护或站点下线公告。"),
options: [ fields: [
{ value: "v2", label: "v2 复选框" }, { name: "enabled", label: t("关闭网站(维护模式)"), type: "switch", extra: t("开启后前台立即只显示维护页") },
{ value: "v3", label: "v3 隐形评分" }, { name: "html", label: t("维护页内容"), type: "rich", extra: t("支持富文本 / HTML(图片可从媒体库选择),将显示在维护页中央") },
], ] as FieldDef[],
}, },
{ name: "recaptchaSiteKey", label: "Site Key(站点密钥)", type: "text", placeholder: "6Le…", span: 12 }, ];
{ name: "recaptchaSecretKey", label: "Secret Key(服务端密钥)", type: "password", placeholder: "6Le…", span: 12 }, }
{ name: "recaptchaScoreThreshold", label: "v3 评分阈值", type: "number", min: 0, max: 1, step: 0.1, extra: "0~1,低于该分数判定为机器人,默认 0.5", span: 8 },
{ name: "maxLoginAttempts", label: "失败次数上限", type: "number", min: 3, max: 100, extra: "同一账号连续失败达上限即锁定", span: 8 },
{ name: "lockMinutes", label: "锁定时长(分钟)", type: "number", min: 1, max: 1440, span: 8 },
],
},
];
/** # 成员变量 由专属页面管理的键(不在本页出现,避免双头编辑冲突) **/ /** # 成员变量 由专属页面管理的键(不在本页出现,避免双头编辑冲突) **/
const MANAGED_ELSEWHERE = new Set(["backup"]); const MANAGED_ELSEWHERE = new Set(["backup", "permissions"]);
/** # 类型 设置行(updatedAt 为 Date,loader 序列化前的服务端类型) **/ /** # 类型 设置行(updatedAt 为 Date,loader 序列化前的服务端类型) **/
type SettingRow = { key: string; value: Json; updatedAt: Date | string }; type SettingRow = { key: string; value: Json; updatedAt: Date | string };
/** # 函数 系统设置页面组件 */ /** # 函数 系统设置页面组件 */
function SettingsPage() { function SettingsPage() {
const t = useAdminT();
const data = Route.useLoaderData(); const data = Route.useLoaderData();
const router = useRouter(); const router = useRouter();
const { message } = App.useApp(); const { message } = App.useApp();
/** # 成员变量 其他键的 JSON 草稿(键 → 文本) **/ const GROUPS = buildGroups(t);
const [otherDrafts, setOtherDrafts] = useState<Record<string, string>>({}); /** # 成员变量 其他键(无表单定义)清单 **/
const [otherRows, setOtherRows] = useState<{ key: string; value: Json; updatedAt: Date | string }[]>([]);
useEffect(() => { useEffect(() => {
if (!data) return; if (!data) return;
const map: Record<string, string> = {}; setOtherRows((data as SettingRow[]).filter((r) => !MANAGED_ELSEWHERE.has(r.key) && !GROUPS.some((g) => g.key === r.key)));
for (const r of data as SettingRow[]) { // eslint-disable-next-line react-hooks/exhaustive-deps
if (!MANAGED_ELSEWHERE.has(r.key) && !GROUPS.some((g) => g.key === r.key)) {
map[r.key] = JSON.stringify(r.value, null, 2);
}
}
setOtherDrafts(map);
}, [data]); }, [data]);
if (!data) return <Forbidden />; if (!data) return <Forbidden />;
const rows = data as SettingRow[]; const rows = data as SettingRow[];
/** # 函数 保存「其他」键的 JSON */
async function handleSaveOther(key: string) {
try {
const parsed = JSON.parse(otherDrafts[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 格式需合法)");
}
}
/** # 函数 渲染单个字段控件 */ /** # 函数 渲染单个字段控件 */
function renderField(f: FieldDef) { function renderField(f: FieldDef) {
switch (f.type) { switch (f.type) {
@@ -224,6 +184,8 @@ function SettingsPage() {
return <Select options={f.options} style={{ width: 260 }} placeholder={f.placeholder} allowClear={false} />; return <Select options={f.options} style={{ width: 260 }} placeholder={f.placeholder} allowClear={false} />;
case "textarea": case "textarea":
return <Input.TextArea rows={3} placeholder={f.placeholder} />; return <Input.TextArea rows={3} placeholder={f.placeholder} />;
case "rich":
return <RichEditor minHeight={220} />;
case "password": case "password":
return <Input.Password placeholder={f.placeholder} autoComplete="new-password" />; return <Input.Password placeholder={f.placeholder} autoComplete="new-password" />;
default: default:
@@ -233,7 +195,7 @@ function SettingsPage() {
return ( return (
<Card size="small"> <Card size="small">
<PageHeader title="系统设置" extra={<Tag color="orange">仅管理员可保存</Tag>} /> <PageHeader title={t("系统设置")} extra={<Tag color="orange">{t("仅管理员可保存")}</Tag>} />
<Tabs <Tabs
defaultActiveKey={GROUPS[0]!.key} defaultActiveKey={GROUPS[0]!.key}
items={GROUPS.map((g) => ({ items={GROUPS.map((g) => ({
@@ -241,26 +203,17 @@ function SettingsPage() {
label: g.label, label: g.label,
children: <SettingsGroupForm group={g} current={(rows.find((r) => r.key === g.key)?.value ?? {}) as Record<string, unknown>} updatedAt={rows.find((r) => r.key === g.key)?.updatedAt} renderField={renderField} />, children: <SettingsGroupForm group={g} current={(rows.find((r) => r.key === g.key)?.value ?? {}) as Record<string, unknown>} updatedAt={rows.find((r) => r.key === g.key)?.updatedAt} renderField={renderField} />,
})).concat( })).concat(
Object.keys(otherDrafts).length otherRows.length
? [ ? [
{ {
key: "__other__", key: "__other__",
label: "其他", label: t("其他"),
children: ( children: (
<Space direction="vertical" size={16} style={{ display: "flex" }}> <Space direction="vertical" size={16} style={{ display: "flex" }}>
<Typography.Text type="secondary">以下键无表单定义(如由脚本写入的配置),按 JSON 编辑保存。</Typography.Text> <Typography.Text type="secondary">{t("以下键无表单定义(由脚本或系统内部写入),此处只读展示。")}</Typography.Text>
{Object.keys(otherDrafts).map((k) => ( {otherRows.map((r) => (
<Card key={k} type="inner" size="small" title={k} extra={ <Card key={r.key} type="inner" size="small" title={r.key} extra={<Typography.Text type="secondary" style={{ fontSize: 12 }}>{t("最后更新")}:{fmtDateTime(r.updatedAt)}</Typography.Text>}>
<Button size="small" type="primary" icon={<Save size={13} aria-hidden />} onClick={() => void handleSaveOther(k)}> <ReadableValue value={r.value} />
保存
</Button>
}>
<Input.TextArea
rows={Math.min(14, Math.max(4, (otherDrafts[k] ?? "").split("\n").length))}
value={otherDrafts[k] ?? ""}
onChange={(e) => setOtherDrafts((d) => ({ ...d, [k]: e.target.value }))}
style={{ fontFamily: "monospace", fontSize: 12 }}
/>
</Card> </Card>
))} ))}
</Space> </Space>
@@ -274,15 +227,52 @@ function SettingsPage() {
); );
} }
/** # 组件 可读键值展示(替代裸 JSON:对象/数组逐行键值,标量直接显示) */
function ReadableValue(props: { value: Json; level?: number }) {
const { value, level = 0 } = props;
if (value === null || value === undefined) return <Typography.Text type="secondary">-</Typography.Text>;
if (typeof value === "boolean") return <Tag>{value ? "true" : "false"}</Tag>;
if (typeof value !== "object") return <Typography.Text>{String(value)}</Typography.Text>;
const entries = Array.isArray(value) ? value.map((v, i) => [String(i), v] as const) : Object.entries(value);
if (entries.length === 0) return <Typography.Text type="secondary">-</Typography.Text>;
const plain = entries.every(([, v]) => v === null || typeof v !== "object");
if (plain) {
// 扁平结构:一行一组 key=value,紧凑易读
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px 24px" }}>
{entries.map(([k, v]) => (
<Typography.Text key={k} style={{ fontSize: 14 }}>
<Typography.Text type="secondary">{k}</Typography.Text>
{": "}
{v === null || v === "" ? "-" : String(v)}
</Typography.Text>
))}
</div>
);
}
// 嵌套结构:逐行缩进展开
return (
<div style={{ display: "flex", flexDirection: "column", gap: 4, paddingLeft: level ? 16 : 0 }}>
{entries.map(([k, v]) => (
<div key={k} style={{ display: "flex", gap: 8, alignItems: "baseline" }}>
<Typography.Text type="secondary" style={{ minWidth: level ? 120 : 180, fontSize: 13 }}>{k}</Typography.Text>
<ReadableValue value={v} level={level + 1} />
</div>
))}
</div>
);
}
/** # 组件 单个设置分组的表单(真实组件承载 Form.useForm,符合 hooks 规则) /** # 组件 单个设置分组的表单(真实组件承载 Form.useForm,符合 hooks 规则)
* 提交时表单值与现有 JSON 合并(保留未在表单中的字段)后整键覆写。 * 提交时表单值与现有 JSON 合并(保留未在表单中的字段)后整键覆写。
*/ */
function SettingsGroupForm(props: { function SettingsGroupForm(props: {
group: (typeof GROUPS)[number]; group: { key: string; label: string; desc?: ReactNode; fields: FieldDef[] };
current: Record<string, unknown>; current: Record<string, unknown>;
updatedAt?: string | Date; updatedAt?: string | Date;
renderField: (f: FieldDef) => ReactNode; renderField: (f: FieldDef) => ReactNode;
}) { }) {
const t = useAdminT();
const { message } = App.useApp(); const { message } = App.useApp();
const router = useRouter(); const router = useRouter();
const [form] = Form.useForm(); const [form] = Form.useForm();
@@ -293,15 +283,15 @@ function SettingsGroupForm(props: {
const values = await form.validateFields(); const values = await form.validateFields();
const merged = { ...props.current, ...values }; const merged = { ...props.current, ...values };
await adminUpdateSetting({ data: { key: props.group.key, value: merged as Json } }); await adminUpdateSetting({ data: { key: props.group.key, value: merged as Json } });
message.success(`已保存「${props.group.label}」`); message.success(t("已保存「{name}」", { name: props.group.label }));
await router.invalidate(); await router.invalidate();
} catch (e) { } catch (e) {
if (e && typeof e === "object" && "errorFields" in e) return; // 表单校验错误已就地提示 if (e && typeof e === "object" && "errorFields" in e) return; // 表单校验错误已就地提示
message.error(e instanceof Error ? e.message : "保存失败"); message.error(e instanceof Error ? e.message : t("保存失败"));
} }
} }
const g = props.group; const g = { ...props.group, label: t(props.group.label) };
return ( return (
<div> <div>
{g.desc ? <Alert type="info" showIcon message={g.desc} style={{ marginBottom: 16 }} /> : null} {g.desc ? <Alert type="info" showIcon message={g.desc} style={{ marginBottom: 16 }} /> : null}
@@ -317,21 +307,21 @@ function SettingsGroupForm(props: {
<Form.Item <Form.Item
key={f.name} key={f.name}
name={f.name} name={f.name}
label={f.label} label={t(f.label)}
extra={f.extra} extra={f.extra}
valuePropName={f.type === "switch" ? "checked" : "value"} valuePropName={f.type === "switch" ? "checked" : "value"}
style={{ width: `calc(${(f.span ?? 8) / 24 * 100}% - 16px)`, minWidth: 220 }} style={{ width: `calc(${(f.span ?? 8) / 24 * 100}% - 16px)`, minWidth: f.type === "rich" ? "100%" : 220 }}
> >
{props.renderField(f)} {props.renderField(f)}
</Form.Item> </Form.Item>
))} ))}
</div> </div>
<Button type="primary" htmlType="submit" icon={<Save size={14} aria-hidden />}> <Button type="primary" htmlType="submit" icon={<Save size={14} aria-hidden />}>
保存 {t("保存")}
</Button> </Button>
</Form> </Form>
<Typography.Text type="secondary" style={{ fontSize: 12, display: "block", marginTop: 10 }}> <Typography.Text type="secondary" style={{ fontSize: 12, display: "block", marginTop: 10 }}>
最后更新:{fmtDateTime(props.updatedAt ?? "")} {t("最后更新")}:{fmtDateTime(props.updatedAt ?? "")}
</Typography.Text> </Typography.Text>
</div> </div>
); );
+181 -113
View File
@@ -1,81 +1,71 @@
/** /**
* # 路由 /admin 后台布局 * # 路由 /admin 后台布局
* 左侧 Sider 菜单 + 顶部 Header(当前用户 / 退出)。 * 左侧 Sider 菜单(按角色权限过滤)+ 顶部 Header(语言选择 / 主题切换 / 用户头像下拉)。
* 未登录或无后台权限:整页渲染登录页(不再展示后台骨架)。 * 未登录或无后台权限:整页渲染登录页。
* v0.16.0:后台 i18n(默认英文)、浅/深主题、个人资料入口、菜单权限。
*/ */
import { createFileRoute, Outlet, useNavigate, useRouter, useRouterState } from "@tanstack/react-router"; import { createFileRoute, Outlet, useNavigate, useRouter, useRouterState } from "@tanstack/react-router";
import { App, Avatar, Button, Card, ConfigProvider, Form, Input, Layout, Menu, Space, Tag } from "antd"; import { App, Avatar, Button, Card, Dropdown, Form, Input, Layout, Menu, Select, Space, Switch, Tag } from "antd";
import { LockKeyhole } from "lucide-react"; import { LockKeyhole, LogOut, Moon, Settings, Sun, UserRound, Languages } from "lucide-react";
import { import {
DatabaseBackup, DatabaseBackup,
Files, Files,
Inbox, Inbox,
Image as ImageIcon, Image as ImageIcon,
Languages, Languages as LanguagesIcon,
LayoutDashboard, LayoutDashboard,
List, List,
LogOut,
Package, Package,
Route as RouteIcon, Route as RouteIcon,
ScrollText, ScrollText,
Settings,
ShoppingCart, ShoppingCart,
ShieldCheck,
SlidersHorizontal, SlidersHorizontal,
UserRound, UserRound as UserRoundIcon,
} from "lucide-react"; } from "lucide-react";
import { useState, useEffect, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { StyleProvider } from "@ant-design/cssinjs"; import { loginFn, logoutFn } from "~/server/auth";
import { getCurrentUser, loginFn, logoutFn } from "~/server/auth"; import { getAdminLayoutData } from "~/server/permissions";
import { getLoginSecurityPublic } from "~/server/security"; import { getLoginSecurityPublic } from "~/server/security";
import { RecaptchaField, useRecaptcha, type LoginSecurityPublic } from "~/components/admin/Recaptcha"; import { RecaptchaField, useRecaptcha, type LoginSecurityPublic } from "~/components/admin/Recaptcha";
import { AdminLocaleProvider, useAdminI18n, useAdminT, type AdminLocaleDef } from "~/admin-i18n";
import { ADMIN_LOCALES } from "~/admin-i18n/shared";
import { AdminThemeProvider, useAdminTheme } from "~/admin-theme";
import { ADMIN_MENU_GROUPS } from "~/admin-menus";
/** # 类型 后台菜单分组(分组标签不可点;key 即目标路由路径) **/ /** # 成员变量 菜单 key → 图标(菜单定义在 admin-menus.ts,图标在此补充) **/
const MENU_GROUPS: { label: string; children: { key: string; icon: ReactNode; label: string }[] }[] = [ const MENU_ICONS: Record<string, ReactNode> = {
{ "/admin/": <LayoutDashboard size={16} aria-hidden />,
label: "概览", "/admin/contents/": <Files size={16} aria-hidden />,
children: [{ key: "/admin/", icon: <LayoutDashboard size={16} aria-hidden />, label: "Dashboard" }], "/admin/menus/": <List size={16} aria-hidden />,
}, "/admin/media/": <ImageIcon size={16} aria-hidden />,
{ "/admin/products/": <Package size={16} aria-hidden />,
label: "内容", "/admin/orders/": <ShoppingCart size={16} aria-hidden />,
children: [ "/admin/inquiries/": <Inbox size={16} aria-hidden />,
{ key: "/admin/contents/", icon: <Files size={16} aria-hidden />, label: "页面 / 文章" }, "/admin/filters/": <SlidersHorizontal size={16} aria-hidden />,
{ key: "/admin/menus/", icon: <List size={16} aria-hidden />, label: "菜单" }, "/admin/languages/": <LanguagesIcon size={16} aria-hidden />,
{ key: "/admin/media/", icon: <ImageIcon size={16} aria-hidden />, label: "媒体库" }, "/admin/seo/": <RouteIcon size={16} aria-hidden />,
], "/admin/users/": <UserRoundIcon size={16} aria-hidden />,
}, "/admin/permissions/": <ShieldCheck size={16} aria-hidden />,
{ "/admin/settings/": <Settings size={16} aria-hidden />,
label: "商务", "/admin/backups/": <DatabaseBackup size={16} aria-hidden />,
children: [ "/admin/audit/": <ScrollText size={16} aria-hidden />,
{ 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/backups/", icon: <DatabaseBackup 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 = string;
/** # 类型 拥有后台权限的角色 **/ /** # 类型 拥有后台权限的角色 **/
const ADMIN_ROLES = ["admin", "editor", "manager"]; const ADMIN_ROLES = ["admin", "editor", "manager"];
/** # 类型 loader 返回(用户 + 该角色授权菜单) **/
type AdminLoaderData = {
user: { displayName: string | null; email: string; role: string; avatar: string; locale: string; theme: string } | null;
/** # 成员变量 当前角色可见的菜单 key(未登录为空) **/
menus: string[];
};
export const Route = createFileRoute("/admin")({ export const Route = createFileRoute("/admin")({
loader: () => getCurrentUser(), loader: async (): Promise<AdminLoaderData> => {
return await getAdminLayoutData();
},
component: AdminLayout, component: AdminLayout,
// AntD 烘焙静态样式(scripts/gen-antd-css.tsx 生成到 public/): // AntD 烘焙静态样式(scripts/gen-antd-css.tsx 生成到 public/):
// 用 <link> 而非 JS import —— dev 下 vite 会把 import 的 CSS 注入成 <style> 标签, // 用 <link> 而非 JS import —— dev 下 vite 会把 import 的 CSS 注入成 <style> 标签,
@@ -88,9 +78,10 @@ export const Route = createFileRoute("/admin")({
/** # 函数 后台布局组件 /** # 函数 后台布局组件
* 未登录 / 角色无后台权限 → 渲染全屏登录页;已授权 → 正常布局。 * 未登录 / 角色无后台权限 → 渲染全屏登录页;已授权 → 正常布局。
* 外层 AdminLocaleProvider / AdminThemeProvider 为纯客户端偏好(localStorage)。
*/ */
function AdminLayout() { function AdminLayout() {
const user = Route.useLoaderData(); const data = Route.useLoaderData();
const router = useRouter(); const router = useRouter();
/** # 函数 登录成功回调:刷新路由数据(loader 重新取用户,进入布局) */ /** # 函数 登录成功回调:刷新路由数据(loader 重新取用户,进入布局) */
@@ -98,15 +89,24 @@ function AdminLayout() {
await router.invalidate(); await router.invalidate();
} }
if (!user || !ADMIN_ROLES.includes(user.role)) { return (
return <AdminLoginPage noPermission={!!user} onOk={handleLoginSuccess} />; <AdminLocaleProvider>
} <AdminThemeProvider>
{!data.user || !ADMIN_ROLES.includes(data.user.role) ? (
return <AdminShell user={user} />; <AdminLoginPage noPermission={!!data.user} onOk={handleLoginSuccess} />
) : (
<AdminShell user={data.user} allowedMenus={data.menus} />
)}
</AdminThemeProvider>
</AdminLocaleProvider>
);
} }
/** # 组件 后台登录页(全屏居中卡片,邮箱 + 密码;reCAPTCHA 按系统设置「登录安全」开关渲染) */ /** # 组件 后台登录页(全屏居中卡片,邮箱 + 密码;reCAPTCHA 按系统设置「登录安全」开关渲染;i18n + 暗色自适应) */
function AdminLoginPage(props: { noPermission: boolean; onOk: () => Promise<void> }) { function AdminLoginPage(props: { noPermission: boolean; onOk: () => Promise<void> }) {
const t = useAdminT();
const { locale, setLocale } = useAdminI18n();
const { theme, setTheme } = useAdminTheme();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
/** # 成员变量 登录页公开配置(reCAPTCHA 启用状态/版本/站点密钥) **/ /** # 成员变量 登录页公开配置(reCAPTCHA 启用状态/版本/站点密钥) **/
@@ -127,7 +127,7 @@ function AdminLoginPage(props: { noPermission: boolean; onOk: () => Promise<void
try { try {
const token = await recaptcha.getToken(); const token = await recaptcha.getToken();
if (secCfg?.enabled && token === null) { if (secCfg?.enabled && token === null) {
setError("人机验证未完成,请完成勾选/等待验证脚本加载后重试"); setError(t("人机验证未完成,请完成勾选/等待验证脚本加载后重试"));
recaptcha.reset(); recaptcha.reset();
return; return;
} }
@@ -137,7 +137,7 @@ function AdminLoginPage(props: { noPermission: boolean; onOk: () => Promise<void
recaptcha.reset(); recaptcha.reset();
} else await props.onOk(); } else await props.onOk();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "登录失败,请稍后重试"); setError(e instanceof Error ? e.message : t("登录失败,请稍后重试"));
recaptcha.reset(); recaptcha.reset();
} finally { } finally {
setLoading(false); setLoading(false);
@@ -145,97 +145,116 @@ function AdminLoginPage(props: { noPermission: boolean; onOk: () => Promise<void
} }
return ( return (
<ConfigProvider theme={{ token: { colorPrimary: "#1677ff" }, cssVar: { key: "dusun" } }}> <div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--ant-color-bg-layout)", position: "relative" }}>
{/* hashPriority=high:运行时生成的选择器不带哈希前缀,与烘焙 CSS(antd-baked.css)保持一致 */} {/* 语言 / 主题偏好(登录页同样可用) */}
<StyleProvider hashPriority="high"> <Space size={8} style={{ position: "absolute", top: 16, right: 24 }}>
<App> <Select<AdminLocaleDef["code"]>
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "linear-gradient(135deg, #f5f8fb 0%, #e8eef5 100%)" }}> size="small"
<Card style={{ width: 380, boxShadow: "0 8px 32px rgba(16, 42, 76, 0.12)" }}> value={locale}
<div style={{ textAlign: "center", marginBottom: 24 }}> onChange={setLocale}
<LockKeyhole size={34} aria-hidden color="#1677ff" style={{ marginBottom: 10 }} /> options={ADMIN_LOCALES.map((l) => ({ value: l.code, label: l.label }))}
<h2 style={{ margin: 0, fontSize: 20 }}>DusunIoT 后台</h2> style={{ width: 130 }}
<p style={{ margin: "6px 0 0", color: "#8c8c8c", fontSize: 13 }}> aria-label={t("语言")}
{props.noPermission ? "当前账号无后台权限,请更换管理员账号" : "请使用管理员账号登录"} />
<Switch
size="small"
checked={theme === "dark"}
checkedChildren={<Moon size={12} aria-hidden />}
unCheckedChildren={<Sun size={12} aria-hidden />}
onChange={(v) => setTheme(v ? "dark" : "light")}
aria-label={t("主题切换")}
/>
</Space>
<Card style={{ width: 420, boxShadow: "0 8px 32px rgba(16, 42, 76, 0.12)" }}>
<div style={{ textAlign: "center", marginBottom: 28 }}>
<LockKeyhole size={38} aria-hidden color="#1677ff" style={{ marginBottom: 12 }} />
<h2 style={{ margin: 0, fontSize: 24 }}>{t("DusunIoT 后台")}</h2>
<p style={{ margin: "8px 0 0", color: "var(--ant-color-text-tertiary)", fontSize: 14 }}>
{props.noPermission ? t("当前账号无后台权限,请更换管理员账号") : t("请使用管理员账号登录")}
</p> </p>
</div> </div>
<Form layout="vertical" onFinish={(v) => void handleFinish(v as { email: string; password: string })} requiredMark={false}> <Form layout="vertical" onFinish={(v) => void handleFinish(v as { email: string; password: string })} requiredMark={false}>
<Form.Item name="email" label="邮箱" rules={[{ required: true, message: "请输入邮箱" }]}> <Form.Item name="email" label={t("邮箱")} rules={[{ required: true, message: t("请输入邮箱") }]}>
<Input type="email" placeholder="admin@example.com" autoComplete="email" /> <Input type="email" placeholder="admin@example.com" autoComplete="email" />
</Form.Item> </Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true, message: "请输入密码" }]}> <Form.Item name="password" label={t("密码")} rules={[{ required: true, message: t("请输入密码") }]}>
<Input.Password placeholder="密码" autoComplete="current-password" /> <Input.Password placeholder={t("密码")} autoComplete="current-password" />
</Form.Item> </Form.Item>
{/* reCAPTCHA(系统设置 → 登录安全 开启后出现;v2 复选框 / v3 隐形) */} {/* reCAPTCHA(系统设置 → 登录安全 开启后出现;v2 复选框 / v3 隐形) */}
<RecaptchaField cfg={secCfg} rec={recaptcha} /> <RecaptchaField cfg={secCfg} rec={recaptcha} />
{error ? ( {error ? (
<div role="alert" style={{ color: "#cf1322", background: "#fff1f0", border: "1px solid #ffa39e", borderRadius: 6, padding: "8px 12px", marginBottom: 16, fontSize: 13 }}> <div role="alert" style={{ color: "var(--ant-color-error)", background: "var(--ant-color-error-bg)", border: "1px solid var(--ant-color-error-border)", borderRadius: 6, padding: "8px 12px", marginBottom: 16, fontSize: 14 }}>
{error} {error}
</div> </div>
) : null} ) : null}
<Button type="primary" htmlType="submit" block loading={loading}> <Button type="primary" htmlType="submit" block loading={loading}>
登录 {t("登录")}
</Button> </Button>
</Form> </Form>
</Card> </Card>
</div> </div>
</App>
</StyleProvider>
</ConfigProvider>
); );
} }
/** # 函数 后台已授权布局外壳 /** # 函数 后台已授权布局外壳
* 顶部 Header 展示当前用户与退出按钮;左侧菜单按当前路径高亮;Outlet 渲染子页面。 * 顶部 Header 展示语言 / 主题 / 用户区;左侧菜单按当前路径高亮并按权限过滤;Outlet 渲染子页面。
* 注意:App.useApp() 必须在 <App> 内部的子组件中调用,故用户区拆为 UserBadge。 * 注意:App.useApp() 必须在 <App> 内部的子组件中调用,故用户区拆为 UserBadge。
*/ */
function AdminShell({ user }: { user: { displayName: string | null; email: string; role: string } }) { function AdminShell(props: { user: NonNullable<AdminLoaderData["user"]>; allowedMenus: string[] }) {
const t = useAdminT();
const navigate = useNavigate(); const navigate = useNavigate();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const pathname = useRouterState({ select: (s) => s.location.pathname }); const pathname = useRouterState({ select: (s) => s.location.pathname });
/** # 成员变量 按权限过滤后的菜单分组(个人设置对所有后台角色可见) **/
const allowed = new Set(props.allowedMenus);
allowed.add("/admin/profile");
const groups = ADMIN_MENU_GROUPS.map((g) => ({
type: "group" as const,
label: <span style={{ fontSize: 12, opacity: 0.6 }}>{t(g.label)}</span>,
children: g.children
.filter((m) => allowed.has(m.key))
.map((m) => ({ key: m.key, icon: MENU_ICONS[m.key], label: t(m.label) })),
})).filter((g) => g.children.length > 0);
/** # 成员变量 当前应高亮的菜单 key:取最长匹配的菜单路径 **/ /** # 成员变量 当前应高亮的菜单 key:取最长匹配的菜单路径 **/
const selectedKey = MENU_ITEMS.map((m) => m.key) const selectedKey = [...allowed]
.filter((k) => pathname === k || pathname.startsWith(k + "/")) .filter((k) => pathname === k || pathname.startsWith(k))
.sort((a, b) => b.length - a.length)[0] ?? "/admin/"; .sort((a, b) => b.length - a.length)[0] ?? "/admin/";
return ( return (
<ConfigProvider theme={{ token: { colorPrimary: "#1677ff" }, cssVar: { key: "dusun" } }}> <Layout style={{ minHeight: "100vh" }} className="dusun">
<StyleProvider hashPriority="high"> <Layout.Sider collapsible collapsed={collapsed} onCollapse={setCollapsed} width={200} theme="dark">
<App> <div style={{ height: 48, display: "flex", alignItems: "center", justifyContent: "center", color: "#fff", fontWeight: 600, fontSize: 17, letterSpacing: 1 }}>
<Layout style={{ minHeight: "100vh" }}> {collapsed ? "D" : t("DusunIoT 后台")}
<Layout.Sider collapsible collapsed={collapsed} onCollapse={setCollapsed} width={200}>
<div style={{ height: 48, display: "flex", alignItems: "center", justifyContent: "center", color: "#fff", fontWeight: 600, fontSize: 16, letterSpacing: 1 }}>
{collapsed ? "D" : "DusunIoT 后台"}
</div> </div>
<Menu <Menu
theme="dark" theme="dark"
mode="inline" mode="inline"
items={MENU_GROUPS.map((g) => ({ items={groups}
type: "group" as const,
label: <span style={{ fontSize: 12, opacity: 0.6 }}>{g.label}</span>,
children: g.children.map((m) => ({ key: m.key, icon: m.icon, label: m.label })),
}))}
selectedKeys={[selectedKey]} selectedKeys={[selectedKey]}
onClick={({ key }) => void navigate({ to: key as MenuKey })} onClick={({ key }) => void navigate({ to: key })}
/> />
</Layout.Sider> </Layout.Sider>
<Layout> <Layout>
<Layout.Header style={{ background: "#fff", display: "flex", justifyContent: "flex-end", alignItems: "center", paddingInline: 24, borderBottom: "1px solid #f0f0f0" }}> <Layout.Header style={{ background: "var(--ant-color-bg-container)", display: "flex", justifyContent: "flex-end", alignItems: "center", paddingInline: 24, borderBottom: "1px solid var(--ant-color-split)", height: 56 }}>
<UserBadge user={user} /> <UserBadge user={props.user} />
</Layout.Header> </Layout.Header>
<Layout.Content style={{ margin: 16 }}> <Layout.Content style={{ margin: 16 }}>
<Outlet /> <Outlet />
</Layout.Content> </Layout.Content>
</Layout> </Layout>
</Layout> </Layout>
</App>
</StyleProvider>
</ConfigProvider>
); );
} }
/** # 组件 顶部用户区(在 <App> 内渲染,可安全使用 App.useApp 的 message) */ /** # 组件 顶部用户区(在 <App> 内渲染,可安全使用 App.useApp 的 message)
function UserBadge({ user }: { user: { displayName: string | null; email: string; role: string } }) { * 从左到右:语言选择 → 主题切换 → 用户头像下拉(个人设置 / 退出登录)。
*/
function UserBadge(props: { user: NonNullable<AdminLoaderData["user"]> }) {
const t = useAdminT();
const { locale, setLocale } = useAdminI18n();
const { theme, setTheme } = useAdminTheme();
const navigate = useNavigate(); const navigate = useNavigate();
const router = useRouter(); const router = useRouter();
const { message } = App.useApp(); const { message } = App.useApp();
@@ -243,19 +262,68 @@ function UserBadge({ user }: { user: { displayName: string | null; email: string
/** # 函数 退出登录:注销会话 → 刷新路由数据 → 回到 /admin */ /** # 函数 退出登录:注销会话 → 刷新路由数据 → 回到 /admin */
async function handleLogout() { async function handleLogout() {
await logoutFn(); await logoutFn();
message.success("已退出登录"); message.success(t("已退出登录"));
await router.invalidate(); await router.invalidate();
void navigate({ to: "/admin/" }); void navigate({ to: "/admin/" });
} }
return ( return (
<Space size={12}> <Space size={14}>
<Avatar size="small" icon={<UserRound size={14} aria-hidden />} /> <Space size={6}>
<span>{user.displayName || user.email}</span> <Languages size={15} aria-hidden style={{ color: "var(--ant-color-text-tertiary)" }} />
<Tag color={user.role === "admin" ? "blue" : "default"}>{user.role}</Tag> <Select
<Button size="small" icon={<LogOut size={14} aria-label="退出登录" />} onClick={() => void handleLogout()}> size="small"
退出 variant="borderless"
</Button> value={locale}
onChange={setLocale}
options={ADMIN_LOCALES.map((l) => ({ value: l.code, label: l.label }))}
style={{ minWidth: 120 }}
aria-label={t("语言")}
/>
</Space>
<Switch
size="small"
checked={theme === "dark"}
checkedChildren={<Moon size={11} aria-hidden />}
unCheckedChildren={<Sun size={11} aria-hidden />}
onChange={(v) => setTheme(v ? "dark" : "light")}
aria-label={t("主题切换")}
/>
<Dropdown
menu={{
items: [
{
key: "profile",
icon: <UserRound size={14} aria-hidden />,
label: (
<div style={{ minWidth: 160 }}>
<div style={{ fontWeight: 600 }}>{props.user.displayName || props.user.email}</div>
<div style={{ fontSize: 12, color: "var(--ant-color-text-tertiary)" }}>{props.user.email}</div>
</div>
),
disabled: true,
},
{ type: "divider" as const },
{ key: "settings", icon: <Settings size={14} aria-hidden />, label: t("个人设置") },
{ key: "logout", icon: <LogOut size={14} aria-hidden />, label: t("退出登录"), danger: true },
],
onClick: ({ key }) => {
if (key === "settings") void navigate({ to: "/admin/profile/" });
else if (key === "logout") void handleLogout();
},
}}
placement="bottomRight"
>
<Space size={8} style={{ cursor: "pointer" }} data-testid="user-menu">
{props.user.avatar ? (
<Avatar size={30} src={props.user.avatar} />
) : (
<Avatar size={30} icon={<UserRound size={15} aria-hidden />} />
)}
<span>{props.user.displayName || props.user.email}</span>
<Tag color={props.user.role === "admin" ? "blue" : "default"} style={{ marginInlineEnd: 0 }}>{props.user.role}</Tag>
</Space>
</Dropdown>
</Space> </Space>
); );
} }
+39 -33
View File
@@ -9,6 +9,7 @@ import { KeyRound, Search, UserPlus } from "lucide-react";
import { getCurrentUser } from "~/server/auth"; import { getCurrentUser } from "~/server/auth";
import { useState } from "react"; import { useState } from "react";
import { adminCreateUser, adminListUsers, adminResetUserPassword, adminUpdateUserRole } from "~/server/admin"; import { adminCreateUser, adminListUsers, adminResetUserPassword, adminUpdateUserRole } from "~/server/admin";
import { useAdminT } from "~/admin-i18n";
import { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common"; import { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common";
export const Route = createFileRoute("/admin/users")({ export const Route = createFileRoute("/admin/users")({
@@ -40,6 +41,7 @@ const ROLE_OPTIONS = (["admin", "editor", "manager", "customer"] as const).map((
/** # 函数 用户列表页面组件 */ /** # 函数 用户列表页面组件 */
function UsersPage() { function UsersPage() {
const t = useAdminT();
const data = Route.useLoaderData(); const data = Route.useLoaderData();
const search = Route.useSearch(); const search = Route.useSearch();
const navigate = Route.useNavigate(); const navigate = Route.useNavigate();
@@ -57,10 +59,10 @@ function UsersPage() {
async function handleRoleChange(id: number, role: string) { async function handleRoleChange(id: number, role: string) {
try { try {
await adminUpdateUserRole({ data: { id, role } }); await adminUpdateUserRole({ data: { id, role } });
message.success("角色已更新"); message.success(t("角色已更新"));
await router.invalidate(); await router.invalidate();
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : "更新失败"); message.error(e instanceof Error ? e.message : t("更新失败"));
} }
} }
@@ -68,16 +70,16 @@ function UsersPage() {
function openResetPassword(id: number, email: string) { function openResetPassword(id: number, email: string) {
let pwd = ""; let pwd = "";
modal.confirm({ modal.confirm({
title: `重置密码:${email}`, title: t("重置密码:{email}", { email }),
content: <Input.Password placeholder="新密码(至少 8 位)" onChange={(e) => (pwd = e.target.value)} autoFocus />, content: <Input.Password placeholder={t("新密码(至少 8 位)")} onChange={(e) => (pwd = e.target.value)} autoFocus />,
okText: "重置", okText: t("重置"),
cancelText: "取消", cancelText: t("取消"),
onOk: async () => { onOk: async () => {
try { try {
await adminResetUserPassword({ data: { id, password: pwd } }); await adminResetUserPassword({ data: { id, password: pwd } });
message.success("密码已重置"); message.success(t("密码已重置"));
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : "重置失败"); message.error(e instanceof Error ? e.message : t("重置失败"));
} }
}, },
}); });
@@ -87,24 +89,24 @@ function UsersPage() {
async function handleCreate(values: { email: string; username?: string; displayName?: string; password: string; role: string }) { async function handleCreate(values: { email: string; username?: string; displayName?: string; password: string; role: string }) {
try { try {
await adminCreateUser({ data: values }); await adminCreateUser({ data: values });
message.success("用户已创建"); message.success(t("用户已创建"));
setCreateOpen(false); setCreateOpen(false);
createForm.resetFields(); createForm.resetFields();
await router.invalidate(); await router.invalidate();
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : "创建失败"); message.error(e instanceof Error ? e.message : t("创建失败"));
} }
} }
return ( return (
<Card size="small"> <Card size="small">
<PageHeader <PageHeader
title="用户管理" title={t("用户管理")}
extra={ extra={
<Space> <Space>
<Input <Input
allowClear allowClear
placeholder="搜索邮箱 / 显示名" placeholder={t("搜索邮箱 / 显示名")}
prefix={<Search size={13} aria-hidden />} prefix={<Search size={13} aria-hidden />}
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
@@ -113,7 +115,7 @@ function UsersPage() {
/> />
{isAdmin ? ( {isAdmin ? (
<Button type="primary" icon={<UserPlus size={14} aria-hidden />} onClick={() => setCreateOpen(true)}> <Button type="primary" icon={<UserPlus size={14} aria-hidden />} onClick={() => setCreateOpen(true)}>
新建用户 {t("新建用户")}
</Button> </Button>
) : null} ) : null}
</Space> </Space>
@@ -124,9 +126,9 @@ function UsersPage() {
activeKey={search.kind ?? ""} activeKey={search.kind ?? ""}
onChange={(key) => void navigate({ search: (prev) => ({ ...prev, kind: key || undefined, page: 1 }) })} onChange={(key) => void navigate({ search: (prev) => ({ ...prev, kind: key || undefined, page: 1 }) })}
items={[ items={[
{ key: "", label: "全部" }, { key: "", label: t("全部") },
{ key: "backend", label: "后台用户" }, { key: "backend", label: t("后台用户") },
{ key: "customer", label: "普通用户" }, { key: "customer", label: t("普通用户") },
]} ]}
/> />
<Table <Table
@@ -134,15 +136,19 @@ function UsersPage() {
size="small" size="small"
dataSource={data.users.items} dataSource={data.users.items}
locale={{ emptyText: <AdminEmpty /> }} locale={{ emptyText: <AdminEmpty /> }}
pagination={tablePagination(data.users.total, data.users.page, data.users.pageSize, (p) => pagination={tablePagination(
void navigate({ search: (prev) => ({ ...prev, page: p }) }), data.users.total,
data.users.page,
data.users.pageSize,
(p) => void navigate({ search: (prev) => ({ ...prev, page: p }) }),
(n) => t("共 {n} 条", { n }),
)} )}
columns={[ columns={[
{ title: "ID", dataIndex: "id", width: 80 }, { title: "ID", dataIndex: "id", width: 80 },
{ title: "邮箱", dataIndex: "email", width: 240, ellipsis: true }, { title: t("邮箱"), dataIndex: "email", width: 240, ellipsis: true },
{ title: "显示名", dataIndex: "displayName", width: 170, ellipsis: true }, { title: t("显示名"), dataIndex: "displayName", width: 170, ellipsis: true },
{ {
title: "角色", title: t("角色"),
dataIndex: "role", dataIndex: "role",
width: 170, width: 170,
render: (v: string, r) => render: (v: string, r) =>
@@ -151,22 +157,22 @@ function UsersPage() {
size="small" size="small"
value={v} value={v}
style={{ width: 120 }} style={{ width: 120 }}
options={ROLE_OPTIONS} options={ROLE_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))}
onChange={(role) => void handleRoleChange(r.id, role)} onChange={(role) => void handleRoleChange(r.id, role)}
/> />
) : ( ) : (
<Tag color={ROLE_COLOR[v] ?? "default"}>{ROLE_LABEL[v] ?? v}</Tag> <Tag color={ROLE_COLOR[v] ?? "default"}>{ROLE_LABEL[v] ? t(ROLE_LABEL[v]) : v}</Tag>
), ),
}, },
{ title: "注册时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) }, { title: t("注册时间"), dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
{ {
title: "操作", title: t("操作"),
key: "act", key: "act",
width: 120, width: 120,
render: (_, r) => render: (_, r) =>
isAdmin ? ( isAdmin ? (
<Button size="small" icon={<KeyRound size={13} aria-hidden />} onClick={() => openResetPassword(r.id, r.email)}> <Button size="small" icon={<KeyRound size={13} aria-hidden />} onClick={() => openResetPassword(r.id, r.email)}>
重置密码 {t("重置密码")}
</Button> </Button>
) : null, ) : null,
}, },
@@ -174,25 +180,25 @@ function UsersPage() {
/> />
<Modal <Modal
title="新建用户" title={t("新建用户")}
open={createOpen} open={createOpen}
onCancel={() => setCreateOpen(false)} onCancel={() => setCreateOpen(false)}
onOk={() => createForm.submit()} onOk={() => createForm.submit()}
destroyOnHidden destroyOnHidden
> >
<Form form={createForm} layout="vertical" onFinish={(v) => void handleCreate(v as { email: string; password: string; role: string })}> <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: "请输入有效邮箱" }]}> <Form.Item name="email" label={t("邮箱")} rules={[{ required: true, type: "email", message: t("请输入有效邮箱") }]}>
<Input /> <Input />
</Form.Item> </Form.Item>
<Space size={16} style={{ display: "flex" }}> <Space size={16} style={{ display: "flex" }}>
<Form.Item name="displayName" label="显示名" style={{ flex: 1 }}> <Form.Item name="displayName" label={t("显示名")} style={{ flex: 1 }}>
<Input placeholder="留空取邮箱前缀" /> <Input placeholder={t("留空取邮箱前缀")} />
</Form.Item> </Form.Item>
<Form.Item name="role" label="角色" initialValue="editor" rules={[{ required: true }]}> <Form.Item name="role" label={t("角色")} initialValue="editor" rules={[{ required: true }]}>
<Select style={{ width: 140 }} options={ROLE_OPTIONS} /> <Select style={{ width: 140 }} options={ROLE_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))} />
</Form.Item> </Form.Item>
</Space> </Space>
<Form.Item name="password" label="初始密码" rules={[{ required: true, min: 8, message: "至少 8 位" }]}> <Form.Item name="password" label={t("初始密码")} rules={[{ required: true, min: 8, message: t("至少 8 位") }]}>
<Input.Password /> <Input.Password />
</Form.Item> </Form.Item>
</Form> </Form>
+1 -1
View File
@@ -49,7 +49,7 @@ function parsePage(d: PageInput) {
/** # 函数 内部工具:写入审计日志 /** # 函数 内部工具:写入审计日志
* 参数 adminId:操作人 ID;action:动作名;entity:实体类型;entityId:实体 ID;meta:附加数据 * 参数 adminId:操作人 ID;action:动作名;entity:实体类型;entityId:实体 ID;meta:附加数据
*/ */
async function writeAudit(adminId: number, action: string, entity: string, entityId: string | number, meta?: unknown) { export async function writeAudit(adminId: number, action: string, entity: string, entityId: string | number, meta?: unknown) {
await db.insert(auditLogs).values({ await db.insert(auditLogs).values({
actorId: adminId, actorId: adminId,
action, action,
+7 -12
View File
@@ -53,7 +53,7 @@ export async function verifyLogin(email: string, password: string) {
export async function getSessionUser(token: string | undefined) { export async function getSessionUser(token: string | undefined) {
if (!token) return null; if (!token) return null;
const rows = await db const rows = await db
.select({ id: users.id, email: users.email, displayName: users.displayName, role: users.role }) .select({ id: users.id, email: users.email, displayName: users.displayName, role: users.role, avatar: users.avatar, locale: users.locale, theme: users.theme })
.from(sessions) .from(sessions)
.innerJoin(users, eq(users.id, sessions.userId)) .innerJoin(users, eq(users.id, sessions.userId))
.where(and(eq(sessions.token, token), gt(sessions.expiresAt, new Date()))); .where(and(eq(sessions.token, token), gt(sessions.expiresAt, new Date())));
@@ -155,25 +155,20 @@ export async function requireAdmin() {
* commerce:产品/订单/询盘 —— 经理及以上 * commerce:产品/订单/询盘 —— 经理及以上
* system:用户/设置/备份/审计/语言 —— 仅管理员 * system:用户/设置/备份/审计/语言 —— 仅管理员
* 普通用户(customer)不属于后台角色,任何能力域都无权限。 * 普通用户(customer)不属于后台角色,任何能力域都无权限。
* v0.16.0 起叠加 settings.permissions 菜单级配置(见 permissions.ts)。
*/ */
export type AdminCapability = "content" | "commerce" | "system"; export type AdminCapability = "content" | "commerce" | "system";
/** # 成员变量 能力域 → 允许的角色(后台用户分层:admin > manager > editor) **/ /** # 函数 按能力域校验后台权限(v0.16.0 起委托 permissions.ts:角色矩阵 + 菜单权限配置叠加校验)
const CAPABILITY_ROLES: Record<AdminCapability, string[]> = {
content: ["admin", "manager", "editor"],
commerce: ["admin", "manager"],
system: ["admin"],
};
/** # 函数 按能力域校验后台权限(比 requireAdmin 更细的角色分层)
* 参数 cap:能力域(content / commerce / system) * 参数 cap:能力域(content / commerce / system)
* 返回值 有该能力域权限的用户;否则抛 FORBIDDEN * 返回值 有该能力域权限的用户;否则抛 FORBIDDEN
*/ */
export async function requireCapability(cap: AdminCapability) { export async function requireCapability(cap: AdminCapability) {
const { getCookie } = await import("@tanstack/react-start/server"); const { getCookie } = await import("@tanstack/react-start/server");
const user = await getSessionUser(getCookie(SESSION_COOKIE)); const user = await getSessionUser(getCookie(SESSION_COOKIE));
if (!user || !CAPABILITY_ROLES[cap].includes(user.role)) { if (!user) throw new Error("FORBIDDEN");
throw new Error("FORBIDDEN"); const { checkCapability } = await import("./permissions");
} const denied = await checkCapability(user.role, cap);
if (denied) throw new Error(denied);
return user; return user;
} }
+41 -2
View File
@@ -1,12 +1,14 @@
/** /**
* # 模块 全局请求中间件:旧链接 301/302 重定向 * # 模块 全局请求中间件:旧链接 301/302 重定向 + 关站维护页
* 在 router 请求阶段查 redirects 表:先精确匹配(findRedirect), * 在 router 请求阶段查 redirects 表:先精确匹配(findRedirect),
* 再对 is_regex 的条目做正则匹配;命中即返回 301/302 Response。 * 再对 is_regex 的条目做正则匹配;命中即返回 301/302 Response。
* 正则条目内存缓存 60 秒,避免每请求全表扫描。 * 正则条目内存缓存 60 秒,避免每请求全表扫描。
* v0.16.0:settings.maintenance.enabled 开启时前台全部页面返回 503 维护页
* (内容为管理员在 设置→关闭网站 配置的 HTML;/admin 后台不受影响)。
*/ */
import { createMiddleware } from "@tanstack/react-start"; import { createMiddleware } from "@tanstack/react-start";
import { db } from "~/db"; import { db } from "~/db";
import { redirects } from "~/db/schema"; import { redirects, settings } from "~/db/schema";
import { and, eq, sql } from "drizzle-orm"; import { and, eq, sql } from "drizzle-orm";
import { hasPublishedSlug, resolveRedirect } from "./data"; import { hasPublishedSlug, resolveRedirect } from "./data";
@@ -36,8 +38,35 @@ function bumpHits(id: number) {
.catch(() => {}); .catch(() => {});
} }
/** # 类型 关站维护配置(settings.maintenance 值) **/
type MaintenanceCfg = { enabled: boolean; html: string };
/** # 成员变量 维护配置缓存(每请求查询太重,60s TTL;关站开关生效最多延迟 60s) **/
let maintenanceCache: { value: MaintenanceCfg; at: number } | null = null;
const MAINTENANCE_TTL = 60_000;
/** # 函数 读取关站维护配置(带缓存) */
async function getMaintenance(): Promise<MaintenanceCfg> {
if (maintenanceCache && Date.now() - maintenanceCache.at < MAINTENANCE_TTL) return maintenanceCache.value;
const [row] = await db.select().from(settings).where(eq(settings.key, "maintenance"));
const v = (row?.value ?? {}) as { enabled?: boolean; html?: string };
const value = { enabled: v.enabled === true, html: typeof v.html === "string" ? v.html : "" };
maintenanceCache = { value, at: Date.now() };
return value;
}
/** # 函数 构造 503 维护页响应(管理员配置的 HTML 居中渲染) */
function toMaintenanceResponse(request: Request, cfg: MaintenanceCfg): Response {
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex"><title>Site temporarily unavailable</title><style>body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f5f6f8;font-family:system-ui,-apple-system,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;color:#333}.wrap{max-width:680px;padding:48px 32px;text-align:center;line-height:1.7}.wrap img{max-width:100%;height:auto}</style></head><body><div class="wrap">${cfg.html || "<h1>Site temporarily unavailable</h1><p>We will be back shortly.</p>"}</div></body></html>`;
return new Response(html, {
status: 503,
headers: { "content-type": "text/html; charset=utf-8", "retry-after": "3600", "cache-control": "no-store" },
});
}
/** # 函数 请求重定向中间件 /** # 函数 请求重定向中间件
* 仅处理 router 类型请求;路径先带斜杠再无斜杠各查一次精确匹配。 * 仅处理 router 类型请求;路径先带斜杠再无斜杠各查一次精确匹配。
* 关站开启时(/admin 除外)直接返回 503 维护页,不再走重定向/内容渲染。
*/ */
export const redirectMiddleware = createMiddleware({ type: "request" }).server(async ({ request, pathname, next, handlerType }) => { export const redirectMiddleware = createMiddleware({ type: "request" }).server(async ({ request, pathname, next, handlerType }) => {
// server function RPC 与静态资源不查重定向 // server function RPC 与静态资源不查重定向
@@ -45,6 +74,16 @@ export const redirectMiddleware = createMiddleware({ type: "request" }).server(a
const path = decodeURI(pathname).replace(/\/+$/, "") || "/"; const path = decodeURI(pathname).replace(/\/+$/, "") || "/";
// 0) 关站维护:前台全部页面 503(后台 /admin、admin 登录会话接口不受影响)
if (!path.startsWith("/admin")) {
try {
const cfg = await getMaintenance();
if (cfg.enabled) return toMaintenanceResponse(request, cfg);
} catch {
// 配置读取失败不阻塞正常渲染
}
}
// 1) 链式解析(原样/规范化/带斜杠变体依次匹配,自指规则跳过防死循环)。 // 1) 链式解析(原样/规范化/带斜杠变体依次匹配,自指规则跳过防死循环)。
// 内容优先:路径末段能命中已发布内容时直接出内容不重定向(与 sitemap 的 // 内容优先:路径末段能命中已发布内容时直接出内容不重定向(与 sitemap 的
// canonical 一致;导入的重定向表存在 A→B 且 B→A 的矛盾规则对,内容优先可破环) // canonical 一致;导入的重定向表存在 A→B 且 B→A 的矛盾规则对,内容优先可破环)
+177
View File
@@ -0,0 +1,177 @@
/**
* # 模块 角色权限(菜单功能 + MCP / API 开关)
* 配置存 settings 表 key="permissions":{ roles: { [role]: { menus: string[], mcp: boolean, api: boolean } } }。
* admin 角色恒为全量且不可编辑(防锁死);其余角色未配置时按 defaultMenusForRole 兜底。
* 服务端 requireCapability 在基础角色矩阵之上叠加校验:角色在该能力域至少拥有一个菜单才放行。
*/
import { db } from "~/db";
import { settings, users } from "~/db/schema";
import { eq, sql } from "drizzle-orm";
import { createServerFn } from "@tanstack/react-start";
import { ADMIN_MENU_FLAT, CAPABILITY_MENUS, defaultMenusForRole, type AdminCapabilityKey } from "~/admin-menus";
import type { Json } from "~/components/admin/common";
/** # 类型 单角色权限配置 **/
export type RolePerm = {
/** # 成员变量 授权菜单 key 列表 **/
menus: string[];
/** # 成员变量 允许通过 MCP 访问 **/
mcp: boolean;
/** # 成员变量 允许通过 API 访问 **/
api: boolean;
};
/** # 类型 权限配置(settings.permissions 值) **/
export type PermissionsConfig = {
/** # 成员变量 角色 → 权限(不含 admin:admin 恒全量) **/
roles: Record<string, RolePerm>;
};
/** # 常量 settings 表中的权限配置键 **/
export const PERMISSIONS_KEY = "permissions";
/** # 成员变量 权限配置短缓存(requireCapability 每请求调用,30s TTL 足够) **/
let permCache: { value: PermissionsConfig; at: number } | null = null;
const PERM_TTL = 30_000;
/** # 函数 读取角色权限(合并默认兜底;带 30s 缓存) */
export async function getPermissions(): Promise<PermissionsConfig> {
if (permCache && Date.now() - permCache.at < PERM_TTL) return permCache.value;
const [row] = await db.select().from(settings).where(eq(settings.key, PERMISSIONS_KEY));
const cfg: PermissionsConfig = { roles: {} };
if (row) {
const v = row.value as { roles?: Record<string, RolePerm> } | null;
if (v && typeof v === "object" && v.roles && typeof v.roles === "object") cfg.roles = v.roles;
}
permCache = { value: cfg, at: Date.now() };
return cfg;
}
/** # 函数 取某角色的有效授权菜单(admin 恒全量;未配置的角色按默认矩阵兜底) */
export function roleMenus(cfg: PermissionsConfig, role: string): string[] {
if (role === "admin") return ADMIN_MENU_FLAT.size ? [...ADMIN_MENU_FLAT.keys()] : [];
const p = cfg.roles[role];
if (!p || !Array.isArray(p.menus)) return defaultMenusForRole(role);
// 只保留合法菜单 key,防脏数据
return p.menus.filter((k) => ADMIN_MENU_FLAT.has(k));
}
/** # 函数 取某角色的 MCP / API 开关(admin 恒 true;未配置按角色矩阵默认:manager 开、editor 关) */
export function roleAccess(cfg: PermissionsConfig, role: string): { mcp: boolean; api: boolean } {
if (role === "admin") return { mcp: true, api: true };
const p = cfg.roles[role];
if (p && typeof p.mcp === "boolean" && typeof p.api === "boolean") return { mcp: p.mcp, api: p.api };
return role === "manager" ? { mcp: true, api: true } : { mcp: false, api: false };
}
/** # 函数 能力域校验(叠加角色矩阵 + 权限配置;供 auth.requireCapability 调用)
* 返回值 null=放行;string=拒绝原因
*/
export async function checkCapability(role: string, cap: AdminCapabilityKey): Promise<string | null> {
// 基础角色矩阵(admin > manager > editor;customer 无后台权限)
const matrix: Record<AdminCapabilityKey, string[]> = {
content: ["admin", "manager", "editor"],
commerce: ["admin", "manager"],
system: ["admin"],
};
if (!matrix[cap].includes(role)) return "FORBIDDEN";
if (role === "admin") return null; // admin 不受限
const cfg = await getPermissions();
const granted = new Set(roleMenus(cfg, role));
// 该能力域至少拥有一个菜单才放行(粒度到菜单的控制由前端隐藏入口 + 各页面 capability 双保险)
if (!CAPABILITY_MENUS[cap].some((k) => granted.has(k))) return "FORBIDDEN";
return null;
}
/** # 函数 Server Function:后台布局 loader 数据(当前用户 + 该角色有效授权菜单) */
export const getAdminLayoutData = createServerFn({ method: "GET" }).handler(async () => {
const { getSessionUser, SESSION_COOKIE } = await import("./auth");
const { getCookie } = await import("@tanstack/react-start/server");
const user = await getSessionUser(getCookie(SESSION_COOKIE));
if (!user || !["admin", "editor", "manager"].includes(user.role)) return { user: null, menus: [] };
const cfg = await getPermissions();
return { user, menus: roleMenus(cfg, user.role) };
});
/** # 函数 Server Function:读取权限配置(权限管理页 loader 用;仅 admin) */
export const adminGetPermissions = createServerFn({ method: "GET" }).handler(async () => {
const { getSessionUser, SESSION_COOKIE } = await import("./auth");
const { getCookie } = await import("@tanstack/react-start/server");
const user = await getSessionUser(getCookie(SESSION_COOKIE));
if (!user || user.role !== "admin") throw new Error("FORBIDDEN");
const cfg = await getPermissions();
// 返回各角色有效授权(含默认兜底),前端直接渲染
return {
roles: Object.fromEntries(
["manager", "editor", "customer"].map((role) => {
const p = cfg.roles[role];
return [
role,
{
menus: roleMenus(cfg, role),
mcp: roleAccess(cfg, role).mcp,
api: roleAccess(cfg, role).api,
configured: !!p,
},
];
}),
),
};
});
/** # 函数 Server Function:保存权限配置(仅 admin;admin 角色条目强制忽略)
* 传 { roles: { [role]: { menus, mcp, api } } }(customer 允许全空 = 无任何后台权限)。
*/
export const adminSavePermissions = createServerFn({ method: "POST" })
.validator((d: { roles: Record<string, { menus: string[]; mcp: boolean; api: boolean }> }) => d)
.handler(async ({ data }) => {
const { getSessionUser, SESSION_COOKIE } = await import("./auth");
const { getCookie } = await import("@tanstack/react-start/server");
const { writeAudit } = await import("./admin");
const user = await getSessionUser(getCookie(SESSION_COOKIE));
if (!user || user.role !== "admin") throw new Error("FORBIDDEN");
// 只接受三个非 admin 角色的配置,菜单 key 白名单过滤
const roles: Record<string, RolePerm> = {};
for (const role of ["manager", "editor", "customer"]) {
const p = data.roles[role];
if (!p) continue;
roles[role] = {
menus: (Array.isArray(p.menus) ? p.menus : []).filter((k) => ADMIN_MENU_FLAT.has(k)),
mcp: !!p.mcp,
api: !!p.api,
};
}
const value = { roles } as unknown as Record<string, unknown>;
await db
.insert(settings)
.values({ key: PERMISSIONS_KEY, value, updatedAt: new Date() })
.onConflictDoUpdate({ target: settings.key, set: { value, updatedAt: new Date() } });
permCache = { value: { roles } as PermissionsConfig, at: Date.now() };
await writeAudit(user.id, "permissions.save", "settings", PERMISSIONS_KEY, { roles: Object.keys(roles) });
return { ok: true as const };
});
/** # 函数 供 auth.ts 的 requireCapability 委托调用(避免循环依赖) */
export async function enforceCapability(cap: AdminCapabilityKey): Promise<void> {
const { getSessionUser, SESSION_COOKIE } = await import("./auth");
const { getCookie } = await import("@tanstack/react-start/server");
const user = await getSessionUser(getCookie(SESSION_COOKIE));
if (!user) throw new Error("FORBIDDEN");
const denied = await checkCapability(user.role, cap);
if (denied) throw new Error(denied);
}
/** # 函数 MCP 令牌校验(mcp/server.ts 启动时用):令牌须命中某个后台角色用户且该角色允许 MCP
* 返回值 命中的用户邮箱;不命中返回 null
*/
export async function findMcpTokenUser(token: string): Promise<string | null> {
if (!token) return null;
const rows = await db
.select({ email: users.email, role: users.role })
.from(users)
.where(sql`${users.mcpToken} = ${token} and ${users.mcpToken} <> ''`);
const hit = rows[0];
if (!hit) return null;
const cfg = await getPermissions();
return roleAccess(cfg, hit.role).mcp ? hit.email : null;
}
+117
View File
@@ -0,0 +1,117 @@
/**
* # 模块 个人设置 Server Functions(/admin/profile)
* 当前登录用户自助修改:显示名 / 头像 / 默认语言 / 默认主题 / 密码 / MCP Token。
* 与用户管理页(admin 管理)分离;任何后台角色可改自己的资料。
*/
import { db } from "~/db";
import { users, sessions } from "~/db/schema";
import { and, eq, ne } from "drizzle-orm";
import { createServerFn } from "@tanstack/react-start";
import { SESSION_COOKIE, getSessionUser } from "./auth";
import { writeAudit } from "./admin";
/** # 函数 取当前会话用户(含新字段;未登录抛 FORBIDDEN) */
async function requireUser() {
const { getCookie } = await import("@tanstack/react-start/server");
const user = await getSessionUser(getCookie(SESSION_COOKIE));
if (!user) throw new Error("FORBIDDEN");
return user;
}
/** # 类型 个人资料(profile 页 loader 返回) **/
export type ProfileData = {
/** # 成员变量 用户 ID **/
id: number;
/** # 成员变量 邮箱 **/
email: string;
/** # 成员变量 显示名 **/
displayName: string;
/** # 成员变量 头像 URL(空 = 默认图标) **/
avatar: string;
/** # 成员变量 默认后台语言(空 = 跟随浏览器/默认 en) **/
locale: string;
/** # 成员变量 默认后台主题:light | dark(空 = 浅色) **/
theme: string;
/** # 成员变量 角色 **/
role: string;
/** # 成员变量 MCP 令牌(空 = 未生成) **/
mcpToken: string;
};
/** # 函数 Server Function:读取当前用户完整资料 */
export const getMyProfile = createServerFn({ method: "GET" }).handler(async (): Promise<ProfileData | null> => {
const me = await requireUser();
const [row] = await db
.select({ id: users.id, email: users.email, displayName: users.displayName, avatar: users.avatar, locale: users.locale, theme: users.theme, role: users.role, mcpToken: users.mcpToken })
.from(users)
.where(eq(users.id, me.id));
return row ?? null;
});
/** # 函数 Server Function:保存个人资料(显示名 / 头像 / 默认语言 / 默认主题) */
export const updateMyProfile = createServerFn({ method: "POST" })
.validator((d: { displayName?: string; avatar?: string; locale?: string; theme?: string }) => d)
.handler(async ({ data }) => {
const me = await requireUser();
const patch: Record<string, unknown> = {};
if (data.displayName !== undefined) {
const name = data.displayName.trim();
if (name.length > 64) throw new Error("显示名过长(最多 64 字符)");
patch.displayName = name;
}
if (data.avatar !== undefined) {
// 头像仅接受站内 /uploads/ 路径或 https 外链;空串 = 清除
const url = data.avatar.trim();
if (url && !/^\/uploads\//.test(url) && !/^https:\/\//.test(url)) throw new Error("头像仅支持媒体库地址或 https 链接");
if (url.length > 512) throw new Error("头像地址过长");
patch.avatar = url;
}
if (data.locale !== undefined) patch.locale = data.locale.trim().slice(0, 16);
if (data.theme !== undefined) patch.theme = data.theme === "dark" ? "dark" : data.theme === "light" ? "light" : "";
if (Object.keys(patch).length === 0) return { ok: true as const };
await db.update(users).set(patch).where(eq(users.id, me.id));
await writeAudit(me.id, "profile.update", "user", String(me.id));
return { ok: true as const };
});
/** # 函数 Server Function:修改密码(验证当前密码;成功后吊销本人其他会话) */
export const changeMyPassword = createServerFn({ method: "POST" })
.validator((d: { currentPassword: string; newPassword: string }) => d)
.handler(async ({ data }) => {
const { getCookie } = await import("@tanstack/react-start/server");
const me = await requireUser();
if (data.newPassword.length < 8) throw new Error("新密码至少 8 位");
const [row] = await db.select({ passwordHash: users.passwordHash, legacyPasswordHash: users.legacyPasswordHash }).from(users).where(eq(users.id, me.id));
if (!row) throw new Error("用户不存在");
// 校验当前密码(bcrypt 与旧 phpass 双兼容,与登录同逻辑)
let ok = false;
if (row.passwordHash) ok = await Bun.password.verify(data.currentPassword, row.passwordHash);
else if (row.legacyPasswordHash) {
const { phpassVerify } = await import("./phpass");
ok = await phpassVerify(data.currentPassword, row.legacyPasswordHash);
}
if (!ok) throw new Error("当前密码不正确");
await db.update(users).set({ passwordHash: Bun.password.hashSync(data.newPassword, { algorithm: "bcrypt", cost: 10 }), legacyPasswordHash: null }).where(eq(users.id, me.id));
// 吊销本人除当前会话外的全部会话
const token = getCookie(SESSION_COOKIE);
if (token) await db.delete(sessions).where(and(eq(sessions.userId, me.id), ne(sessions.token, token)));
await writeAudit(me.id, "profile.password", "user", String(me.id));
return { ok: true as const };
});
/** # 函数 Server Function:生成新的 MCP 令牌(覆盖旧令牌;返回明文仅此一次) */
export const regenerateMyMcpToken = createServerFn({ method: "POST" }).handler(async () => {
const me = await requireUser();
const token = Bun.randomUUIDv7().replace(/-/g, "") + Bun.randomUUIDv7().replace(/-/g, "");
await db.update(users).set({ mcpToken: token }).where(eq(users.id, me.id));
await writeAudit(me.id, "profile.mcp_token", "user", String(me.id));
return { token };
});
/** # 函数 Server Function:清除 MCP 令牌(停用 MCP 访问) */
export const clearMyMcpToken = createServerFn({ method: "POST" }).handler(async () => {
const me = await requireUser();
await db.update(users).set({ mcpToken: "" }).where(eq(users.id, me.id));
await writeAudit(me.id, "profile.mcp_token_clear", "user", String(me.id));
return { ok: true as const };
});
+42
View File
@@ -0,0 +1,42 @@
/** # 测试 后台 i18n 词典完整性:en 词典必须覆盖全部 t("…") 键与动态标签映射(后台默认英文) */
import { describe, expect, test } from "bun:test";
import { readdirSync } from "node:fs";
import { en } from "../src/admin-i18n/locales/en";
import { ADMIN_MENU_GROUPS } from "../src/admin-menus";
import { CONTENT_TYPE_LABEL, INQUIRY_STATUS_LABEL, ORDER_STATUS_LABEL } from "../src/components/admin/common";
// 扫描后台路由与组件源码中的 t("中文") 调用,收集全部静态源串键
const files = [
...readdirSync("src/routes").filter((f) => f.startsWith("admin")).map((f) => `src/routes/${f}`),
...readdirSync("src/components/admin").map((f) => `src/components/admin/${f}`),
];
const keyRe = /\bt\(\s*"((?:[^"\\]|\\.)*)"/g;
const keys = new Set<string>();
for (const f of files) {
const src = await Bun.file(f).text();
for (const m of src.matchAll(keyRe)) keys.add(m[1]!);
}
/** # 成员变量 动态键源(label 映射的值也作为 t() 的键使用,必须入词典) **/
const dynamicKeys: string[] = [
...Object.values(ORDER_STATUS_LABEL),
...Object.values(INQUIRY_STATUS_LABEL),
...Object.values(CONTENT_TYPE_LABEL),
...ADMIN_MENU_GROUPS.flatMap((g) => [g.label, ...g.children.map((m) => m.label)]),
];
describe("后台 i18n 词典", () => {
test("英文词典覆盖全部静态 t() 键", () => {
const missing = [...keys].filter((k) => !(k in en));
expect(missing).toEqual([]);
});
test("英文词典覆盖全部动态标签映射键", () => {
const missing = dynamicKeys.filter((k) => !(k in en));
expect(missing).toEqual([]);
});
test("英文词典键数合理(防止误清空)", () => {
expect(Object.keys(en).length).toBeGreaterThan(200);
});
});