v0.16.0: 后台体验升级——i18n(默认英文)/浅深主题/字号放大/关站维护页/个人设置(头像·语言·主题·密码·MCP Token)/权限管理(菜单+MCP·API)/询盘抽屉翻倍+PHP序列化数据清洗(22.7万行)+去裸JSON
This commit is contained in:
@@ -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 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>
|
||||
|
||||
@@ -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 未写库)" : ""}`);
|
||||
|
||||
Reference in New Issue
Block a user