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

This commit is contained in:
bigcode
2026-11-05 01:46:00 +08:00
parent c3df5d9db1
commit a018019504
47 changed files with 3008 additions and 830 deletions
+66
View File
@@ -0,0 +1,66 @@
/**
* # 脚本 清洗 inquiries 表中 WordPress 遗留的 PHP 序列化字符串
* 范围:product_interest(数组→逗号连接)、message(数组→换行连接)、
* fields / utm(jsonb 内递归还原)。
* 幂等:已清洗的行不再匹配序列化前缀,重复执行零更新。
* 运行:bun scripts/cleanup-php-serialize.ts [--dry]
*/
import { SQL } from "bun";
import { phpToPlain, deepConvertPhp, tryPhpUnserialize } from "../src/lib/php-serialize";
const dry = process.argv.includes("--dry");
const sql = new SQL(process.env.DATABASE_URL || "postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot", { max: 2 });
/** # 成员变量 疑似序列化前缀(SQL LIKE 预筛用,覆盖 a:/s:/i:/b:/d:/N;) **/
const LIKE = ["a:%", "s:%", "i:%", "b:%", "d:%", "N;%"];
/** # 函数 OR 拼接 LIKE 条件(jsonb::text 对全文预筛) */
function orLike(col: string): string {
return `(${LIKE.map((p) => `${col} like '${p}'`).join(" or ")})`;
}
let scanned = 0;
let updated = 0;
// ---- 1) 标量列:product_interest / message ----
for (const col of ["product_interest", "message"] as const) {
const rows = await sql.unsafe(
`select id, ${col} v from inquiries where ${orLike(col)} order by id`,
);
for (const r of rows as { id: number; v: string }[]) {
scanned++;
const u = tryPhpUnserialize(r.v);
if (u === undefined) continue;
// message 为多行文本:数组用换行连接;product_interest 逗号连接
const cleaned = col === "message" && Array.isArray(u)
? u.filter((x) => x !== null && x !== "").map(String).join("\n")
: phpToPlain(r.v);
updated++;
if (dry) { if (updated <= 5) console.log(`[${col}] #${r.id}: ${r.v.slice(0, 80)} -> ${cleaned.slice(0, 80)}`); continue; }
await sql`update inquiries set ${sql(col)} = ${cleaned} where id = ${r.id}`;
}
console.log(`${col}: scanned ${rows.length} suspicious rows`);
}
// ---- 2) jsonb 列:fields / utm(递归还原) ----
for (const col of ["fields", "utm"] as const) {
// jsonb::text 以 { 开头,序列化串出现在字符串值内部 → 包含匹配预筛(宽进严出,
// deepConvertPhp 只转换能完整解析的串,LIKE 误中会被安全跳过)
const contains = `(${LIKE.map((p) => `${col}::text like '%${p}' or ${col}::text like '%${p.slice(0, -1)}%'`).join(" or ")})`;
const rows = await sql.unsafe(
`select id, ${col} v from inquiries where ${contains} order by id`,
);
for (const r of rows as { id: number; v: unknown }[]) {
scanned++;
const conv = deepConvertPhp(r.v);
if (JSON.stringify(conv) === JSON.stringify(r.v)) continue;
updated++;
if (dry) { if (updated <= 5) console.log(`[${col}] #${r.id}: ${JSON.stringify(r.v).slice(0, 100)} -> ${JSON.stringify(conv).slice(0, 100)}`); continue; }
await sql`update inquiries set ${sql(col)} = ${JSON.stringify(conv)}::jsonb where id = ${r.id}`;
updated++;
}
console.log(`${col}: scanned ${rows.length} suspicious rows`);
}
console.log(dry ? `DRY RUN — scanned ${scanned}, would update ${updated}` : `done: scanned ${scanned}, updated ${updated} rows`);
sql.end();
+37
View File
@@ -0,0 +1,37 @@
/**
* # 脚本 收集后台 i18n 键并输出 en 词典草稿
* 扫描后台路由/组件中的 t("…") 静态键 + 动态标签映射键(从 admin-menus / common 导入),
* 打印当前 en 词典缺失的键清单(JSON 形式,便于翻译补齐)。
* 运行:bun scripts/collect-admin-i18n.ts [--all]
* --all 输出全部键;缺省只输出 en.ts 缺失的键。
*/
import { readdirSync } from "node:fs";
import { en } from "../src/admin-i18n/locales/en";
import { ADMIN_MENU_GROUPS } from "../src/admin-menus";
import { CONTENT_TYPE_LABEL, INQUIRY_STATUS_LABEL, ORDER_STATUS_LABEL } from "../src/components/admin/common";
const all = process.argv.includes("--all");
const files = [
...readdirSync("src/routes").filter((f) => f.startsWith("admin")).map((f) => `src/routes/${f}`),
...readdirSync("src/components/admin").map((f) => `src/components/admin/${f}`),
];
const keyRe = /\bt\(\s*"((?:[^"\\]|\\.)*)"/g;
const keys = new Set<string>();
for (const f of files) {
const src = await Bun.file(f).text();
for (const m of src.matchAll(keyRe)) keys.add(m[1]!);
}
// 动态键(label 映射值)
const dynamicKeys = [
...Object.values(ORDER_STATUS_LABEL),
...Object.values(INQUIRY_STATUS_LABEL),
...Object.values(CONTENT_TYPE_LABEL),
...ADMIN_MENU_GROUPS.flatMap((g) => [g.label, ...g.children.map((m) => m.label)]),
];
dynamicKeys.forEach((k) => keys.add(k));
const missing = [...keys].filter((k) => !(k in en)).sort();
const list = all ? [...keys].sort() : missing;
console.log(`total keys: ${keys.size}, ${all ? "dump all" : "missing in en"}: ${list.length}`);
for (const k of list) console.log(JSON.stringify(k));
+20 -1
View File
@@ -15,6 +15,7 @@ import { mkdirSync, writeFileSync } from "node:fs";
import { renderToString } from "react-dom/server";
import { renderToString as _r } from "react-dom/server";
import {
Alert,
App,
Avatar,
Button,
@@ -23,6 +24,7 @@ import {
ConfigProvider,
Descriptions,
Drawer,
Dropdown,
Empty,
Form,
Image,
@@ -31,6 +33,7 @@ import {
Layout,
Menu,
Modal,
Pagination,
Popconfirm,
Row,
Select,
@@ -40,13 +43,15 @@ import {
Table,
Tabs,
Tag,
Tooltip,
Tree,
Typography,
} from "antd";
import { createCache, StyleProvider, extractStyle } from "@ant-design/cssinjs";
/** # 成员变量 与 admin.tsx 完全一致的主题配置(改动后必须重新烘焙)
* cssVar.key 固定为 "dusun":变量作用域类名确定,烘焙 CSS 与运行时 DOM 一致 **/
const THEME = { token: { colorPrimary: "#1677ff" }, cssVar: { key: "dusun" } };
const THEME = { token: { colorPrimary: "#1677ff", fontSize: 15 }, cssVar: { key: "dusun" } };
/** # 组件 覆盖后台全部 AntD 组件的样例树(每个组件至少渲染一次以注册其样式) **/
function Showcase() {
@@ -158,6 +163,20 @@ function Showcase() {
<Drawer open title="抽屉" width={120}>
内容
</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.Footer>F</Layout.Footer>
</Layout>
+4 -4
View File
@@ -44,18 +44,18 @@ let added = 0, removed = 0;
for (const [wpId, cats] of liveCats) {
const cid = contentByLegacy.get(wpId);
if (!cid) continue;
const localRows = await sql`select category_id from content_terms where content_id = ${cid}`;
const localSet = new Set(localRows.map((r: any) => r.category_id as number));
const localRows = await sql`select category_id from content_terms where content_id = ${Number(cid)}`;
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));
for (const add of liveSet) {
if (localSet.has(add)) continue;
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) {
if (liveSet.has(rm)) continue;
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 未写库)" : ""}`);