v0.16.0: 后台体验升级——i18n(默认英文)/浅深主题/字号放大/关站维护页/个人设置(头像·语言·主题·密码·MCP Token)/权限管理(菜单+MCP·API)/询盘抽屉翻倍+PHP序列化数据清洗(22.7万行)+去裸JSON
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||||
@@ -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));
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 未写库)" : ""}`);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)、、表格等(marked 解析)。日常输入时编辑器已自动转换 Markdown 快捷语法。": "Supports # headings, **bold**, - lists, [link](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.",
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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)、、表格等(marked 解析)。日常输入时编辑器已自动转换 Markdown 快捷语法。
|
{t("支持 # 标题、**加粗**、- 列表、[链接](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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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(),
|
||||||
/** # 数据库字段 收货地址 */
|
/** # 数据库字段 收货地址 */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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/<全量> /backups/<增量1> /backups/<增量2>`
|
{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>
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
+169
-179
@@ -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 = [
|
||||||
key: "site",
|
{ value: "USD", label: "USD" },
|
||||||
label: "站点信息",
|
{ value: "CNY", label: "CNY" },
|
||||||
fields: [
|
{ value: "EUR", label: "EUR" },
|
||||||
{ name: "name", label: "站点名称", type: "text", placeholder: "Dusun IoT" },
|
];
|
||||||
{ name: "url", label: "站点 URL", type: "text", placeholder: "https://dusuniot.com" },
|
return [
|
||||||
{
|
{
|
||||||
name: "currency",
|
key: "site",
|
||||||
label: "货币",
|
label: t("站点信息"),
|
||||||
type: "select",
|
fields: [
|
||||||
options: [
|
{ name: "name", label: t("站点名称"), type: "text", placeholder: "Dusun IoT" },
|
||||||
{ value: "USD", label: "USD 美元" },
|
{ name: "url", label: t("站点 URL"), type: "text", placeholder: "https://dusuniot.com" },
|
||||||
{ value: "CNY", label: "CNY 人民币" },
|
{ name: "currency", label: t("货币"), type: "select", options: currencyOptions },
|
||||||
{ value: "EUR", label: "EUR 欧元" },
|
{ name: "timezone", label: t("时区"), type: "select", options: [
|
||||||
],
|
{ value: "Asia/Shanghai", label: "Asia/Shanghai (UTC+8)" },
|
||||||
},
|
|
||||||
{
|
|
||||||
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: t("商城配置"),
|
||||||
label: "商城配置",
|
fields: [
|
||||||
fields: [
|
{ name: "productBase", label: t("产品路由前缀"), type: "text", placeholder: "product" },
|
||||||
{ name: "productBase", label: "产品路由前缀", type: "text", placeholder: "product" },
|
{ name: "categoryBase", label: t("分类路由前缀"), type: "text", placeholder: "collection" },
|
||||||
{ name: "categoryBase", label: "分类路由前缀", type: "text", placeholder: "collection" },
|
{ name: "tagBase", label: t("标签路由前缀"), type: "text", placeholder: "product-tag" },
|
||||||
{ name: "tagBase", label: "标签路由前缀", type: "text", placeholder: "product-tag" },
|
{ name: "currency", label: t("货币"), type: "select", options: currencyOptions },
|
||||||
{
|
{ name: "guestCheckout", label: t("游客结账"), type: "switch", extra: t("允许未注册用户下单") },
|
||||||
name: "currency",
|
{ name: "pricesIncludeTax", label: t("价格含税"), type: "switch" },
|
||||||
label: "货币",
|
{ name: "signupFromCheckout", label: t("结账时引导注册"), type: "switch" },
|
||||||
type: "select",
|
{ name: "allowedCountries", label: t("销售地区"), type: "select", options: [
|
||||||
options: [
|
{ value: "all", label: t("全部国家") },
|
||||||
{ value: "USD", label: "USD 美元" },
|
{ value: "all_except", label: t("全部(除特定地区)") },
|
||||||
{ value: "CNY", label: "CNY 人民币" },
|
{ value: "specified", label: t("仅指定地区") },
|
||||||
{ value: "EUR", label: "EUR 欧元" },
|
] },
|
||||||
],
|
] as FieldDef[],
|
||||||
},
|
},
|
||||||
{ name: "guestCheckout", label: "游客结账", type: "switch", extra: "允许未注册用户下单" },
|
{
|
||||||
{ name: "pricesIncludeTax", label: "价格含税", type: "switch" },
|
key: "mail",
|
||||||
{ name: "signupFromCheckout", label: "结账时引导注册", type: "switch" },
|
label: t("邮件配置"),
|
||||||
{
|
desc: t("询盘通知 / 订单邮件的 SMTP 发信配置;修改后建议发一封测试邮件验证。"),
|
||||||
name: "allowedCountries",
|
fields: [
|
||||||
label: "销售地区",
|
{ name: "fromName", label: t("发件人名称"), type: "text", placeholder: "Dusun IoT" },
|
||||||
type: "select",
|
{ name: "fromEmail", label: t("发件邮箱"), type: "text", placeholder: "no-reply@dusuniot.com" },
|
||||||
options: [
|
{ name: "smtpHost", label: t("SMTP 主机"), type: "text", placeholder: "smtp.exmail.qq.com" },
|
||||||
{ value: "all", label: "全部国家" },
|
{ name: "smtpPort", label: t("SMTP 端口"), type: "number", min: 1, max: 65535, placeholder: "465" },
|
||||||
{ value: "all_except", label: "全部(除特定地区)" },
|
{ name: "smtpSecure", label: t("加密方式"), type: "select", options: [
|
||||||
{ value: "specified", label: "仅指定地区" },
|
{ value: "none", label: t("不加密") },
|
||||||
],
|
{ value: "ssl", label: t("SSL(465)") },
|
||||||
},
|
{ value: "tls", label: t("STARTTLS(587)") },
|
||||||
],
|
] },
|
||||||
},
|
{ name: "smtpUser", label: t("SMTP 账号"), type: "text" },
|
||||||
{
|
{ name: "smtpPass", label: t("SMTP 密码/授权码"), type: "password" },
|
||||||
key: "mail",
|
] as FieldDef[],
|
||||||
label: "邮件配置",
|
},
|
||||||
desc: "询盘通知 / 订单邮件的 SMTP 发信配置;修改后建议发一封测试邮件验证。",
|
{
|
||||||
fields: [
|
key: "seo",
|
||||||
{ name: "fromName", label: "发件人名称", type: "text", placeholder: "Dusun IoT" },
|
label: t("SEO"),
|
||||||
{ name: "fromEmail", label: "发件邮箱", type: "text", placeholder: "no-reply@dusuniot.com" },
|
fields: [
|
||||||
{ name: "smtpHost", label: "SMTP 主机", type: "text", placeholder: "smtp.exmail.qq.com" },
|
{ name: "titleTemplate", label: t("标题模板"), type: "text", placeholder: "%title% | Dusun IoT", extra: t("%title% 为页面标题占位符") },
|
||||||
{ name: "smtpPort", label: "SMTP 端口", type: "number", min: 1, max: 65535, placeholder: "465" },
|
{ name: "robots", label: t("全站 robots"), type: "select", options: [
|
||||||
{
|
{ value: "all", label: t("all(允许索引)") },
|
||||||
name: "smtpSecure",
|
{ value: "noindex", label: t("noindex(禁止索引)") },
|
||||||
label: "加密方式",
|
] },
|
||||||
type: "select",
|
{ name: "sitemap", label: t("启用 sitemap.xml"), type: "switch" },
|
||||||
options: [
|
] as FieldDef[],
|
||||||
{ value: "none", label: "不加密" },
|
},
|
||||||
{ value: "ssl", label: "SSL(465)" },
|
{
|
||||||
{ value: "tls", label: "STARTTLS(587)" },
|
key: "security",
|
||||||
],
|
label: t("登录安全"),
|
||||||
},
|
desc: (
|
||||||
{ name: "smtpUser", label: "SMTP 账号", type: "text" },
|
<>
|
||||||
{ name: "smtpPass", label: "SMTP 密码/授权码", type: "password" },
|
reCAPTCHA 在 <a href="https://www.google.com/recaptcha/admin" target="_blank" rel="noreferrer">Google reCAPTCHA {t("控制台")}</a>
|
||||||
],
|
{t("申请;v2 为「我是人类」复选框,v3 为隐形评分。开启后 /admin 与 /my-account 登录均需通过验证;超过失败次数上限的账号将锁定指定分钟。")}
|
||||||
},
|
</>
|
||||||
{
|
),
|
||||||
key: "seo",
|
fields: [
|
||||||
label: "SEO",
|
{ name: "recaptchaEnabled", label: t("启用 reCAPTCHA"), type: "switch", extra: t("关闭时登录流程保持现状,不加载 Google 脚本") },
|
||||||
fields: [
|
{ name: "recaptchaVersion", label: t("版本"), type: "select", options: [
|
||||||
{ name: "titleTemplate", label: "标题模板", type: "text", placeholder: "%title% | Dusun IoT", extra: "%title% 为页面标题占位符" },
|
{ value: "v2", label: t("v2 复选框") },
|
||||||
{
|
{ value: "v3", label: t("v3 隐形评分") },
|
||||||
name: "robots",
|
] },
|
||||||
label: "全站 robots",
|
{ name: "recaptchaSiteKey", label: t("Site Key(站点密钥)"), type: "text", placeholder: "6Le…", span: 12 },
|
||||||
type: "select",
|
{ name: "recaptchaSecretKey", label: t("Secret Key(服务端密钥)"), type: "password", placeholder: "6Le…", span: 12 },
|
||||||
options: [
|
{ name: "recaptchaScoreThreshold", label: t("v3 评分阈值"), type: "number", min: 0, max: 1, step: 0.1, extra: t("0~1,低于该分数判定为机器人,默认 0.5"), span: 8 },
|
||||||
{ value: "all", label: "all(允许索引)" },
|
{ name: "maxLoginAttempts", label: t("失败次数上限"), type: "number", min: 3, max: 100, extra: t("同一账号连续失败达上限即锁定"), span: 8 },
|
||||||
{ value: "noindex", label: "noindex(禁止索引)" },
|
{ name: "lockMinutes", label: t("锁定时长(分钟)"), type: "number", min: 1, max: 1440, span: 8 },
|
||||||
],
|
] as FieldDef[],
|
||||||
},
|
},
|
||||||
{ name: "sitemap", label: "启用 sitemap.xml", type: "switch" },
|
{
|
||||||
],
|
key: "maintenance",
|
||||||
},
|
label: t("关闭网站"),
|
||||||
{
|
desc: t("开启后前台所有页面立即显示下方维护内容(返回 503),后台 /admin 不受影响。可用于临时停机维护或站点下线公告。"),
|
||||||
key: "security",
|
fields: [
|
||||||
label: "登录安全",
|
{ name: "enabled", label: t("关闭网站(维护模式)"), type: "switch", extra: t("开启后前台立即只显示维护页") },
|
||||||
desc: (
|
{ name: "html", label: t("维护页内容"), type: "rich", extra: t("支持富文本 / HTML(图片可从媒体库选择),将显示在维护页中央") },
|
||||||
<>
|
] as FieldDef[],
|
||||||
reCAPTCHA 在 <a href="https://www.google.com/recaptcha/admin" target="_blank" rel="noreferrer">Google reCAPTCHA 控制台</a>
|
},
|
||||||
申请;v2 为「我是人类」复选框,v3 为隐形评分。开启后 /admin 与 /my-account 登录均需通过验证;
|
];
|
||||||
超过失败次数上限的账号将锁定指定分钟。
|
}
|
||||||
</>
|
|
||||||
),
|
|
||||||
fields: [
|
|
||||||
{ name: "recaptchaEnabled", label: "启用 reCAPTCHA", type: "switch", extra: "关闭时登录流程保持现状,不加载 Google 脚本" },
|
|
||||||
{
|
|
||||||
name: "recaptchaVersion",
|
|
||||||
label: "版本",
|
|
||||||
type: "select",
|
|
||||||
options: [
|
|
||||||
{ value: "v2", label: "v2 复选框" },
|
|
||||||
{ value: "v3", label: "v3 隐形评分" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ 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>
|
||||||
);
|
);
|
||||||
|
|||||||
+211
-143
@@ -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 ? "当前账号无后台权限,请更换管理员账号" : "请使用管理员账号登录"}
|
/>
|
||||||
</p>
|
<Switch
|
||||||
</div>
|
size="small"
|
||||||
<Form layout="vertical" onFinish={(v) => void handleFinish(v as { email: string; password: string })} requiredMark={false}>
|
checked={theme === "dark"}
|
||||||
<Form.Item name="email" label="邮箱" rules={[{ required: true, message: "请输入邮箱" }]}>
|
checkedChildren={<Moon size={12} aria-hidden />}
|
||||||
<Input type="email" placeholder="admin@example.com" autoComplete="email" />
|
unCheckedChildren={<Sun size={12} aria-hidden />}
|
||||||
</Form.Item>
|
onChange={(v) => setTheme(v ? "dark" : "light")}
|
||||||
<Form.Item name="password" label="密码" rules={[{ required: true, message: "请输入密码" }]}>
|
aria-label={t("主题切换")}
|
||||||
<Input.Password placeholder="密码" autoComplete="current-password" />
|
/>
|
||||||
</Form.Item>
|
</Space>
|
||||||
{/* reCAPTCHA(系统设置 → 登录安全 开启后出现;v2 复选框 / v3 隐形) */}
|
<Card style={{ width: 420, boxShadow: "0 8px 32px rgba(16, 42, 76, 0.12)" }}>
|
||||||
<RecaptchaField cfg={secCfg} rec={recaptcha} />
|
<div style={{ textAlign: "center", marginBottom: 28 }}>
|
||||||
{error ? (
|
<LockKeyhole size={38} aria-hidden color="#1677ff" style={{ marginBottom: 12 }} />
|
||||||
<div role="alert" style={{ color: "#cf1322", background: "#fff1f0", border: "1px solid #ffa39e", borderRadius: 6, padding: "8px 12px", marginBottom: 16, fontSize: 13 }}>
|
<h2 style={{ margin: 0, fontSize: 24 }}>{t("DusunIoT 后台")}</h2>
|
||||||
{error}
|
<p style={{ margin: "8px 0 0", color: "var(--ant-color-text-tertiary)", fontSize: 14 }}>
|
||||||
</div>
|
{props.noPermission ? t("当前账号无后台权限,请更换管理员账号") : t("请使用管理员账号登录")}
|
||||||
) : null}
|
</p>
|
||||||
<Button type="primary" htmlType="submit" block loading={loading}>
|
|
||||||
登录
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</App>
|
<Form layout="vertical" onFinish={(v) => void handleFinish(v as { email: string; password: string })} requiredMark={false}>
|
||||||
</StyleProvider>
|
<Form.Item name="email" label={t("邮箱")} rules={[{ required: true, message: t("请输入邮箱") }]}>
|
||||||
</ConfigProvider>
|
<Input type="email" placeholder="admin@example.com" autoComplete="email" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="password" label={t("密码")} rules={[{ required: true, message: t("请输入密码") }]}>
|
||||||
|
<Input.Password placeholder={t("密码")} autoComplete="current-password" />
|
||||||
|
</Form.Item>
|
||||||
|
{/* reCAPTCHA(系统设置 → 登录安全 开启后出现;v2 复选框 / v3 隐形) */}
|
||||||
|
<RecaptchaField cfg={secCfg} rec={recaptcha} />
|
||||||
|
{error ? (
|
||||||
|
<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}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||||
|
{t("登录")}
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** # 函数 后台已授权布局外壳
|
/** # 函数 后台已授权布局外壳
|
||||||
* 顶部 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>
|
||||||
<div style={{ height: 48, display: "flex", alignItems: "center", justifyContent: "center", color: "#fff", fontWeight: 600, fontSize: 16, letterSpacing: 1 }}>
|
<Menu
|
||||||
{collapsed ? "D" : "DusunIoT 后台"}
|
theme="dark"
|
||||||
</div>
|
mode="inline"
|
||||||
<Menu
|
items={groups}
|
||||||
theme="dark"
|
selectedKeys={[selectedKey]}
|
||||||
mode="inline"
|
onClick={({ key }) => void navigate({ to: key })}
|
||||||
items={MENU_GROUPS.map((g) => ({
|
/>
|
||||||
type: "group" as const,
|
</Layout.Sider>
|
||||||
label: <span style={{ fontSize: 12, opacity: 0.6 }}>{g.label}</span>,
|
<Layout>
|
||||||
children: g.children.map((m) => ({ key: m.key, icon: m.icon, label: m.label })),
|
<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={props.user} />
|
||||||
selectedKeys={[selectedKey]}
|
</Layout.Header>
|
||||||
onClick={({ key }) => void navigate({ to: key as MenuKey })}
|
<Layout.Content style={{ margin: 16 }}>
|
||||||
/>
|
<Outlet />
|
||||||
</Layout.Sider>
|
</Layout.Content>
|
||||||
<Layout>
|
</Layout>
|
||||||
<Layout.Header style={{ background: "#fff", display: "flex", justifyContent: "flex-end", alignItems: "center", paddingInline: 24, borderBottom: "1px solid #f0f0f0" }}>
|
</Layout>
|
||||||
<UserBadge user={user} />
|
|
||||||
</Layout.Header>
|
|
||||||
<Layout.Content style={{ margin: 16 }}>
|
|
||||||
<Outlet />
|
|
||||||
</Layout.Content>
|
|
||||||
</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
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 的矛盾规则对,内容优先可破环)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user