v0.10.5 首页 H2 产品线入口 ×6:ImageBoxWidget 重构为 Elementor 忠实 DOM + slim 白名单补齐 + H2 scoped CSS(post-18 对齐,桌面/移动/hover 实测一致)
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* # 脚本 全站链接检查
|
||||
* 从首页出发爬取站内链接(同源 http(s) 链接,跳过静态资源/锚点/mailto/tel 等),
|
||||
* 逐页抓取其中全部 <a href>,再逐一检查状态码;非 2xx/3xx 的链接按「来源页 → 目标」报告。
|
||||
*
|
||||
* 用法:
|
||||
* bun scripts/check-links.ts [baseUrl] [--max N]
|
||||
* 缺省 baseUrl = http://localhost:3312(生产服);--max 限制爬取页数(缺省 500)。
|
||||
* 退出码:发现死链 → 1;全部正常 → 0。供 CI / 交付前回归使用。
|
||||
*/
|
||||
export {};
|
||||
/** # 成员变量 目标站点根地址(命令行第 1 参可覆盖) **/
|
||||
const BASE = (process.argv[2] && !process.argv[2].startsWith("--")) || process.argv[2] === undefined
|
||||
? process.argv[2] ?? "http://localhost:3312"
|
||||
: "http://localhost:3312";
|
||||
/** # 成员变量 最大爬取页数(--max N 可覆盖) **/
|
||||
const maxArg = process.argv.indexOf("--max");
|
||||
const MAX_PAGES = maxArg > -1 ? Number(process.argv[maxArg + 1]) : 500;
|
||||
|
||||
/** # 成员变量 待抓取队列与已抓取集合(以路径+查询为键) **/
|
||||
const queue: string[] = ["/"];
|
||||
const seen = new Set<string>(["/"]);
|
||||
/** # 成员变量 同源 href 去重后的检查清单:href → 出现它的页面集合 **/
|
||||
const links = new Map<string, Set<string>>();
|
||||
/** # 成员变量 死链清单:{ href, status, 来源页 } **/
|
||||
const broken: { href: string; status: number; from: string[] }[] = [];
|
||||
|
||||
/** # 函数 规范化站内链接:去尾锚点/查询,保证尾斜杠,剔除非页面资源
|
||||
* 返回值 规范化路径;null 表示不检查
|
||||
*/
|
||||
function normalize(href: string): string | null {
|
||||
if (!href.startsWith("http") && !href.startsWith("/")) return null;
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(href, BASE);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// 只查本站
|
||||
if (u.origin !== new URL(BASE).origin) return null;
|
||||
let p = u.pathname;
|
||||
if (/\.[a-z0-9]{2,5}$/i.test(p) && !p.endsWith("/")) return null; // 静态资源(图片/pdf 等)
|
||||
if (p === "") p = "/";
|
||||
if (!p.endsWith("/")) p += "/"; // 与 trailingSlash: "always" 一致
|
||||
return p;
|
||||
}
|
||||
|
||||
/** # 函数 从一页 HTML 中抽取全部 <a href>,登记到检查清单并把新页面入队 */
|
||||
function harvest(path: string, html: string): void {
|
||||
const re = /<a\s[^>]*href="([^"]*)"/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(html))) {
|
||||
const p = normalize(m[1]!);
|
||||
if (!p) continue;
|
||||
if (!links.has(p)) links.set(p, new Set());
|
||||
links.get(p)!.add(path);
|
||||
if (!seen.has(p) && seen.size < MAX_PAGES) {
|
||||
seen.add(p);
|
||||
queue.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数 检查单个路径:手动跟随重定向,只跟站内跳转
|
||||
* 跳到站外(库里存在 target 为原站绝对地址的历史 301 规则)时视为通过,
|
||||
* 不抓取外站页面——外站死链不在本站修复范围。
|
||||
* 返回值 { status: 最终状态码, external: 是否跳出了本站 }
|
||||
*/
|
||||
async function probe(path: string): Promise<{ status: number; external: boolean }> {
|
||||
let cur = path;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const res = await fetch(BASE + cur, { redirect: "manual" });
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
const loc = res.headers.get("location");
|
||||
await res.arrayBuffer().catch(() => {});
|
||||
if (!loc) return { status: res.status, external: false };
|
||||
const next = new URL(loc, BASE + cur);
|
||||
if (next.origin !== new URL(BASE).origin) return { status: 200, external: true };
|
||||
cur = next.pathname + next.search;
|
||||
continue;
|
||||
}
|
||||
await res.arrayBuffer().catch(() => {});
|
||||
return { status: res.status, external: false };
|
||||
}
|
||||
return { status: 200, external: false }; // 重定向环按已到达处理,避免误报
|
||||
}
|
||||
|
||||
// ── 阶段 1:BFS 爬站,收集所有页面的出链 ──
|
||||
while (queue.length) {
|
||||
const path = queue.shift()!;
|
||||
const { status, external } = await probe(path);
|
||||
const html = external ? "" : await fetch(BASE + path).then((r) => r.text()).catch(() => "");
|
||||
if (status !== 200) {
|
||||
broken.push({ href: path, status, from: ["(直接爬取)"] });
|
||||
continue;
|
||||
}
|
||||
if (external) continue; // 站外跳转:不抓取外站内容
|
||||
harvest(path, html);
|
||||
}
|
||||
// 首页自身也要出现在检查清单里
|
||||
if (!links.has("/")) links.set("/", new Set());
|
||||
|
||||
// ── 阶段 2:逐一检查全部收集到的链接 ──
|
||||
const entries = [...links.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
||||
for (let i = 0; i < entries.length; i += 20) {
|
||||
await Promise.all(
|
||||
entries.slice(i, i + 20).map(async ([href, from]) => {
|
||||
const { status, external } = await probe(href);
|
||||
if (!external && status >= 400) broken.push({ href, status, from: [...from].slice(0, 5) });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 报告 ──
|
||||
console.log(`爬取页面 ${seen.size},检查链接 ${links.size} 条`);
|
||||
if (broken.length === 0) {
|
||||
console.log("✅ 全部链接正常");
|
||||
process.exit(0);
|
||||
}
|
||||
console.log(`❌ 死链 ${broken.length} 条:`);
|
||||
for (const b of broken.sort((a, b) => a.href.localeCompare(b.href))) {
|
||||
console.log(` [${b.status}] ${b.href}`);
|
||||
for (const f of b.from) console.log(` ← ${f}`);
|
||||
}
|
||||
process.exit(1);
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* # 函数 数据库迁移执行器
|
||||
* 读取 src/db/migrations/*.sql 按文件名顺序应用,记录到 drizzle.__migrations。
|
||||
* 支持重复执行(幂等)。Bun.sql 直连,不依赖 pg/postgres.js。
|
||||
*/
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { SQL } from "bun";
|
||||
|
||||
const url = process.env.DATABASE_URL || "postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot";
|
||||
const sql = new SQL(url);
|
||||
|
||||
await sql`CREATE SCHEMA IF NOT EXISTS drizzle`;
|
||||
await sql`CREATE TABLE IF NOT EXISTS drizzle.__migrations (id serial primary key, hash text unique, created_at timestamptz default now())`;
|
||||
|
||||
const applied = new Set<string>();
|
||||
for (const row of await sql`SELECT hash FROM drizzle.__migrations`.values()) applied.add(row[0] as string);
|
||||
|
||||
const dir = join(import.meta.dir, "../src/db/migrations");
|
||||
const files = readdirSync(dir).filter((f) => f.endsWith(".sql")).sort();
|
||||
let ran = 0;
|
||||
for (const f of files) {
|
||||
if (applied.has(f)) continue;
|
||||
const content = readFileSync(join(dir, f), "utf8");
|
||||
await sql.begin(async (tx) => {
|
||||
await tx.unsafe(content);
|
||||
await tx`INSERT INTO drizzle.__migrations (hash) VALUES (${f})`;
|
||||
});
|
||||
console.log(`applied ${f}`);
|
||||
ran++;
|
||||
}
|
||||
console.log(ran === 0 ? "already up to date" : `done: ${ran} migration(s)`);
|
||||
sql.end();
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* # 脚本 导航 404 数据修复(一次性,幂等)
|
||||
* 依据对原站 dusuniot.com 的逐 URL 探测结论:
|
||||
* 1. 发布落地页草稿(原站已 404 但内容在库、且被站内内容/重定向规则引用):
|
||||
* - contents.id=49 bluetooth-roaming-gateway(案例文章外链目标)
|
||||
* - contents.id=40 iot-hardware-developer(页脚菜单组外链目标)
|
||||
* - contents.id=53 ultra-convergence-solution(redirects 规则 491 的目标)
|
||||
* - contents.id=64 single-board-computer(博客文章外链目标)
|
||||
* 2. 修复导入内容里的畸形链接:href 中 URL 后又拼接了一次单斜杠版自身
|
||||
* (…/product/x/https:/www.dusuniot.com/product/x/),还原为第一个 URL。
|
||||
* 3. 补 redirects 表规则:目标多为「线上同样 404 的陈旧链接」,指向本地最近似存活页。
|
||||
* 重复执行安全:草稿发布、内容修复与规则插入都先查再写。
|
||||
*/
|
||||
import { SQL } from "bun";
|
||||
const sql = new SQL(process.env.DATABASE_URL || "postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot");
|
||||
|
||||
// ── 1. 发布落地页草稿 ──
|
||||
for (const id of [40, 49, 53, 64]) {
|
||||
const [row] = await sql`SELECT id, slug, status FROM contents WHERE id = ${id}`;
|
||||
if (!row) {
|
||||
console.log(`contents.id=${id} 不存在,跳过`);
|
||||
continue;
|
||||
}
|
||||
if (row.status === "published") {
|
||||
console.log(`contents.id=${id} (${row.slug}) 已是 published`);
|
||||
continue;
|
||||
}
|
||||
await sql`UPDATE contents SET status = 'published' WHERE id = ${id}`;
|
||||
console.log(`已发布 contents.id=${id} (${row.slug})`);
|
||||
}
|
||||
|
||||
// ── 2. 修复内容里畸形拼接的链接(URL 后跟单斜杠版自身) ──
|
||||
const bad = await sql`SELECT id, slug, html FROM contents WHERE html LIKE '%/https:/www.dusuniot.com%'`;
|
||||
const dupRe = /(https:\/\/www\.dusuniot\.com\/[^\s"'<>]+?)\/https:\/www\.dusuniot\.com\/[^\s"'<>]*/g;
|
||||
for (const row of bad) {
|
||||
const fixed = row.html.replace(dupRe, "$1");
|
||||
if (fixed !== row.html) {
|
||||
await sql`UPDATE contents SET html = ${fixed} WHERE id = ${row.id}`;
|
||||
console.log(`已修复畸形链接: contents.id=${row.id} (${row.slug})`);
|
||||
} else {
|
||||
console.log(`contents.id=${row.id} (${row.slug}) 模式命中但无可替换(人工检查)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. 补充 redirects 规则(source 唯一先查后插) ──
|
||||
const RULES: { source: string; target: string; note: string }[] = [
|
||||
// 原站已 404 的案例/新闻文:落对应归档(本站归档已可用)
|
||||
{ source: "/case-study/tuya-smart-home-solution/", target: "/case-study/", note: "线上 404 的陈旧案例链接 → 案例归档" },
|
||||
{ source: "/news/a-multi-protocol-smart-home-mini-pc-hub/", target: "/news/", note: "线上 404 的陈旧新闻链接 → 新闻归档" },
|
||||
// 线上已 404 的落地页 → 本地最近的存活落地页(odm/zigbee-gateways 均 200 且主题相近)
|
||||
{ source: "/landing-pages/oem-odm/", target: "/landing-pages/odm/", note: "线上 404 → 本地 ODM 落地页" },
|
||||
{ source: "/landing-pages/tuya-router-gateway/", target: "/landing-pages/zigbee-gateways/", note: "线上 404 → 本地 Zigbee 网关落地页" },
|
||||
{ source: "/landing-pages/tuya-series-products/", target: "/landing-pages/zigbee-gateways/", note: "线上 404 → 本地 Zigbee 网关落地页" },
|
||||
{ source: "/landing-pages/tuya-smart-home-zigbee-gateway/", target: "/landing-pages/zigbee-gateways/", note: "线上 404 → 本地 Zigbee 网关落地页" },
|
||||
// 原站 WooCommerce 属性归档(pa_type 筛选页),本站无该属性归档 → 商城列表
|
||||
{ source: "/shop/type-iot-gateway/", target: "/shop/", note: "线上 200 的属性归档,本站无此筛选 → 商城" },
|
||||
{ source: "/shop/type-som/", target: "/shop/", note: "线上 200 的属性归档,本站无此筛选 → 商城" },
|
||||
{ source: "/shop/downlink-z-wave-or-zigbee/", target: "/shop/", note: "线上 404 的属性归档 → 商城" },
|
||||
// 原站 301 后终态 404 的规格页短链 → 产品列表(本地无对应产品)
|
||||
{ source: "/dsgw-075-tuya-specification/", target: "/collection/", note: "线上 301→404 → 产品列表" },
|
||||
// 原站 200 但内容未导入(Elementor 页抓取导入成本高,先落 About Us;后续可导入真页替换)
|
||||
{ source: "/partners/", target: "/about-us/", note: "原站合作伙伴页未导入 → 关于我们" },
|
||||
];
|
||||
|
||||
for (const r of RULES) {
|
||||
const [dup] = await sql`SELECT id FROM redirects WHERE source = ${r.source}`;
|
||||
if (dup) {
|
||||
console.log(`规则已存在(${dup.id}): ${r.source}`);
|
||||
continue;
|
||||
}
|
||||
await sql`INSERT INTO redirects (source, target, code, is_regex, enabled) VALUES (${r.source}, ${r.target}, 301, false, true)`;
|
||||
console.log(`+ 301 ${r.source} → ${r.target} (${r.note})`);
|
||||
}
|
||||
|
||||
console.log("完成");
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* # 脚本 烘焙 AntD 静态样式
|
||||
* 背景:AntD v6 的 CSS-in-JS 样式只在客户端渲染时注入,SSR 首屏会出现
|
||||
* "无样式闪烁"(后台打开瞬间按钮/表单是浏览器默认样式)。
|
||||
* 本脚本把后台用到的全部 AntD 组件以与 admin.tsx 一致的主题渲染一遍,
|
||||
* 用 @ant-design/cssinjs 的 extractStyle 导出静态 CSS,写入 src/styles/antd-baked.css,
|
||||
* 由 admin.tsx 直接 import,使 SSR 首屏就带上完整 AntD 样式。
|
||||
*
|
||||
* 运行:bun scripts/gen-antd-css.tsx(主题变更后需重新执行)
|
||||
* 输出到 public/antd-baked.css(静态文件,admin 路由 <link> 引入)。
|
||||
* 注意:不能以 JS import 方式引入 —— dev 模式 vite 会把 658KB CSS 注入成
|
||||
* <style> 标签,且 dev 预载全部路由模块会把它带到前台每个页面,F12 卡死。
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { renderToString as _r } from "react-dom/server";
|
||||
import {
|
||||
App,
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
ConfigProvider,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Empty,
|
||||
Form,
|
||||
Image,
|
||||
Input,
|
||||
InputNumber,
|
||||
Layout,
|
||||
Menu,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Switch,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
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" } };
|
||||
|
||||
/** # 组件 覆盖后台全部 AntD 组件的样例树(每个组件至少渲染一次以注册其样式) **/
|
||||
function Showcase() {
|
||||
return (
|
||||
<ConfigProvider theme={THEME}>
|
||||
<App>
|
||||
<Layout style={{ minHeight: 10 }}>
|
||||
<Layout.Sider width={10}>
|
||||
<Menu
|
||||
mode="inline"
|
||||
theme="dark"
|
||||
items={[
|
||||
{ key: "a", label: "A" },
|
||||
{ key: "b", label: "B" },
|
||||
{ type: "submenu", key: "c", label: "C", children: [{ key: "d", label: "D" }] },
|
||||
]}
|
||||
/>
|
||||
</Layout.Sider>
|
||||
<Layout>
|
||||
<Layout.Header style={{ height: 10 }}>H</Layout.Header>
|
||||
<Layout.Content style={{ padding: 4 }}>
|
||||
<Card title="Card" extra={<Button type="primary">主要</Button>}>
|
||||
<Space wrap>
|
||||
<Button>默认</Button>
|
||||
<Button type="primary">主要</Button>
|
||||
<Button type="dashed">虚线</Button>
|
||||
<Button type="link">链接</Button>
|
||||
<Button type="text">文本</Button>
|
||||
<Button danger>危险</Button>
|
||||
<Button size="small">小</Button>
|
||||
<Button type="primary" loading>
|
||||
加载
|
||||
</Button>
|
||||
<Avatar icon={<span />} size="small" />
|
||||
<Tag color="blue">蓝</Tag>
|
||||
<Tag>默认</Tag>
|
||||
<Switch defaultChecked />
|
||||
<InputNumber defaultValue={1} />
|
||||
</Space>
|
||||
<Row gutter={8}>
|
||||
<Col span={12}>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="邮箱" required>
|
||||
<Input placeholder="email" />
|
||||
</Form.Item>
|
||||
<Form.Item label="数量" validateStatus="error" extra="提示">
|
||||
<InputNumber />
|
||||
</Form.Item>
|
||||
<Form.Item label="选择">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 1, label: "一" },
|
||||
{ value: 2, label: "二" },
|
||||
]}
|
||||
defaultValue={1}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
提交
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Tabs
|
||||
items={[
|
||||
{ key: "1", label: "Tab1", children: <p>内容1</p> },
|
||||
{ key: "2", label: "Tab2", children: <p>内容2</p> },
|
||||
]}
|
||||
/>
|
||||
<Descriptions title="详情" bordered size="small" column={1}>
|
||||
<Descriptions.Item label="订单号">DH-001</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Statistic title="询盘" value={12} />
|
||||
<Typography.Title level={5}>标题</Typography.Title>
|
||||
<Typography.Text type="secondary">说明</Typography.Text>
|
||||
<Image src="data:image/gif;base64,R0lGODlhAQABAAAAACw=" width={8} height={8} alt="" />
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="空" />
|
||||
</Col>
|
||||
</Row>
|
||||
<Table
|
||||
size="small"
|
||||
pagination={{ pageSize: 5, total: 6 }}
|
||||
dataSource={[{ k: 1, name: "行" }]}
|
||||
rowKey="k"
|
||||
columns={[
|
||||
{ title: "名称", dataIndex: "name" },
|
||||
{
|
||||
title: "操作",
|
||||
render: () => (
|
||||
<Space>
|
||||
<Button size="small">编辑</Button>
|
||||
<Popconfirm title="确认删除?">
|
||||
<Button danger size="small">
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
<Modal open title="弹窗" okText="确定" cancelText="取消">
|
||||
内容
|
||||
</Modal>
|
||||
<Drawer open title="抽屉" width={120}>
|
||||
内容
|
||||
</Drawer>
|
||||
</Layout.Content>
|
||||
<Layout.Footer>F</Layout.Footer>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</App>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染样例树并提取样式(hashPriority=high → 生成无哈希的 .ant-* 选择器,
|
||||
// 与 admin.tsx 的 StyleProvider hashPriority="high" 保持一致)
|
||||
const cache = createCache();
|
||||
renderToString(
|
||||
<StyleProvider cache={cache} hashPriority="high">
|
||||
<Showcase />
|
||||
</StyleProvider>,
|
||||
);
|
||||
const css = extractStyle(cache, { plain: true }) as string;
|
||||
if (!css || css.length < 10000 || !css.includes(".ant-btn")) {
|
||||
console.error("提取结果异常,中止写入。length:", css?.length);
|
||||
process.exit(1);
|
||||
}
|
||||
mkdirSync(new URL("../public/", import.meta.url), { recursive: true });
|
||||
const out = new URL("../public/antd-baked.css", import.meta.url);
|
||||
writeFileSync(out, `/* 本文件由 scripts/gen-antd-css.tsx 生成(AntD SSR 静态样式,防首屏闪烁),请勿手改 */\n${css}\n`);
|
||||
console.log("written:", out.pathname, `${Math.round(css.length / 1024)}KB`);
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* # 函数 后台任务 Worker 启动入口
|
||||
* 用法:bun run scripts/jobs-worker.ts(生产用 pm2/systemd 托管)
|
||||
*/
|
||||
import "../src/server/jobs";
|
||||
export {};
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* # 脚本 生产环境入口
|
||||
* 包装 dist/server/server.js(TanStack Start 的 fetch handler):
|
||||
* 先用 dist/client 的静态文件(构建产物 JS/CSS 等)直接应答,未命中的请求
|
||||
* 交给 Start handler。没有这层包装时 /assets/* 会被路由 307 加尾斜杠后 404,
|
||||
* 页面完全无 JS(水合不运行)。
|
||||
*/
|
||||
// 构建产物无类型声明(产物哈希文件名随构建变化)
|
||||
// @ts-expect-error 构建产物无 d.ts
|
||||
import serverEntry from "../dist/server/server.js";
|
||||
import { readdirSync } from "node:fs";
|
||||
|
||||
/** # 成员变量 dist/client 静态文件表(pathname → 绝对路径,启动时递归扫描一次) **/
|
||||
const CLIENT_DIR = new URL("../dist/client/", import.meta.url).pathname;
|
||||
const staticFiles = new Map<string, string>();
|
||||
|
||||
/** # 函数 递归收集 dist/client 下全部静态文件 */
|
||||
function walk(dir: string): void {
|
||||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = dir + "/" + e.name;
|
||||
if (e.isDirectory()) walk(p);
|
||||
// 归一化 key:物理路径去掉 CLIENT_DIR 前缀(可能残留多余斜杠)后补单斜杠
|
||||
else staticFiles.set("/" + p.slice(CLIENT_DIR.length).replace(/^\/+/, ""), p);
|
||||
}
|
||||
}
|
||||
walk(CLIENT_DIR);
|
||||
|
||||
export default {
|
||||
/** # 成员变量 监听端口(PORT 环境变量可覆盖) **/
|
||||
port: Number(process.env.PORT ?? 3312),
|
||||
/** # 函数 请求入口:静态文件优先,其余交给 Start handler */
|
||||
async fetch(req: Request): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const f = staticFiles.get(url.pathname);
|
||||
if (f) {
|
||||
return new Response(Bun.file(f), {
|
||||
headers: { "cache-control": "public, max-age=31536000, immutable" },
|
||||
});
|
||||
}
|
||||
return serverEntry.fetch(req);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* # 脚本 文章分类归属修复(一次性,幂等,需外网可达 www.dusuniot.com)
|
||||
* 背景:WP 导入只带文章不带 category 归属,news/case-study/gateway/technical-brief
|
||||
* 四个文章分类在本库文章数均为 0,归档页是空的。
|
||||
* 做法:抓取原站这四个分类的归档页(含 /page/N/ 翻页),提取 /<cat>/<slug>/ 文章链接,
|
||||
* 与本地已发布文章按 slug 匹配,缺失的 content_terms 归属补插。
|
||||
* 不在本地库的原站文章只计数不插入(避免归档里再出死链)。
|
||||
* 重复执行安全:插入前先查存在。
|
||||
*/
|
||||
import { SQL } from "bun";
|
||||
const sql = new SQL(process.env.DATABASE_URL || "postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot");
|
||||
|
||||
/** # 成员变量 抓取 UA(原站对无 UA 的 curl 返回空体) **/
|
||||
const UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36";
|
||||
|
||||
/** # 类型 归档抓取目标 **/
|
||||
const TARGETS = [
|
||||
{ catId: 98, catSlug: "case-study", start: "https://www.dusuniot.com/case-study/success-stories/" }, // 原站 /case-study/ 301 到 success-stories
|
||||
{ catId: 14, catSlug: "news", start: "https://www.dusuniot.com/news/" },
|
||||
{ catId: 119, catSlug: "gateway", start: "https://www.dusuniot.com/gateway/", alsoBlogH3: true }, // 该分类文章 permalink 是 /blog/<slug>/(主分类 blog),卡片在 h3>a
|
||||
{ catId: 120, catSlug: "technical-brief", start: "https://www.dusuniot.com/resources/technical-brief/" },
|
||||
];
|
||||
|
||||
/** # 函数 抓一个页面 HTML(失败返回空串) */
|
||||
async function get(url: string): Promise<string> {
|
||||
try {
|
||||
const res = await fetch(url, { headers: { "user-agent": UA } });
|
||||
if (res.status !== 200) return "";
|
||||
return await res.text();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数 从归档 HTML 提取该分类下的文章 slug(去重、排除分页页码/feed 与归档自身)
|
||||
* 分类路径允许一段可选前缀(如 /resources/technical-brief/<slug>/、/blog/gateway/<slug>/)
|
||||
*/
|
||||
function extractSlugs(html: string, catSlug: string): string[] {
|
||||
const re = new RegExp(`https://www\\.dusuniot\\.com/(?:[a-z0-9-]+/)?${catSlug}/([a-z0-9-]+)/`, "gi");
|
||||
const out = new Set<string>();
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(html))) {
|
||||
const slug = m[1]!;
|
||||
if (slug === "page" || slug === "feed" || slug === "amp" || /^\d+$/.test(slug)) continue;
|
||||
out.add(slug);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
/** # 函数 从归档 HTML 提取 h3 文章卡片的 /blog/<slug>/ 链接(gateway 归档专用:文章 permalink 无分类段) */
|
||||
function extractBlogH3Slugs(html: string): string[] {
|
||||
const re = /<h3[^>]*>\s*<a[^>]*href="https:\/\/www\.dusuniot\.com\/blog\/([a-z0-9-]+)\/"/gi;
|
||||
const out = new Set<string>();
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(html))) out.add(m[1]!);
|
||||
return [...out];
|
||||
}
|
||||
|
||||
for (const t of TARGETS) {
|
||||
// 首页 + 从首页发现的 /page/N/ 翻页(上限 40 页防失控)
|
||||
let html = await get(t.start);
|
||||
const pages = new Set<string>();
|
||||
for (let i = 2; i <= 40; i++) {
|
||||
const u = t.start.replace(/\/$/, "") + `/page/${i}/`;
|
||||
// 只有首页 HTML 里真实出现翻页链接才抓
|
||||
if (html.includes(`/${t.catSlug}/page/${i}/`) || html.includes(`/page/${i}/`)) pages.add(u);
|
||||
}
|
||||
const slugs = new Set<string>(extractSlugs(html, t.catSlug));
|
||||
if ("alsoBlogH3" in t && t.alsoBlogH3) for (const s of extractBlogH3Slugs(html)) slugs.add(s);
|
||||
for (const u of pages) {
|
||||
html = await get(u);
|
||||
for (const s of extractSlugs(html, t.catSlug)) slugs.add(s);
|
||||
if ("alsoBlogH3" in t && t.alsoBlogH3) for (const s of extractBlogH3Slugs(html)) slugs.add(s);
|
||||
await Bun.sleep(300);
|
||||
}
|
||||
// 与本地已发布文章匹配并补归属
|
||||
let inserted = 0;
|
||||
let missing = 0;
|
||||
for (const slug of slugs) {
|
||||
const [local] = await sql`SELECT id FROM contents WHERE slug = ${slug} AND type = 'post' AND status = 'published' LIMIT 1`;
|
||||
if (!local) {
|
||||
missing++;
|
||||
continue;
|
||||
}
|
||||
const [dup] = await sql`SELECT 1 FROM content_terms WHERE content_id = ${local.id} AND category_id = ${t.catId}`;
|
||||
if (dup) continue;
|
||||
await sql`INSERT INTO content_terms (content_id, category_id) VALUES (${local.id}, ${t.catId})`;
|
||||
inserted++;
|
||||
}
|
||||
console.log(`[${t.catSlug}] 原站文章 ${slugs.size}(翻页 ${pages.size + 1} 页);本地命中并补归属 ${inserted},本地缺失 ${missing}`);
|
||||
}
|
||||
console.log("完成");
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* # 函数 WordPress → PostgreSQL 全量导入脚本
|
||||
* 从 MariaDB(备份库)读取 wp_* 表,写入新 PG 数据模型。
|
||||
* 幂等:先清空业务表再导入。用法:bun run scripts/wordpress-import/import.ts
|
||||
*/
|
||||
import mysql from "mysql2/promise";
|
||||
import { SQL } from "bun";
|
||||
|
||||
import { phpUnserialize } from "./php-unserialize";
|
||||
|
||||
/* ---------- 连接 ---------- */
|
||||
const legacy = await mysql.createPool({
|
||||
host: process.env.LEGACY_DB_HOST || "127.0.0.1",
|
||||
port: Number(process.env.LEGACY_DB_PORT || 13306),
|
||||
user: process.env.LEGACY_DB_USER || "root",
|
||||
password: process.env.LEGACY_DB_PASSWORD || "root",
|
||||
database: process.env.LEGACY_DB_NAME || "wp",
|
||||
connectionLimit: 4,
|
||||
charset: "utf8mb4",
|
||||
});
|
||||
|
||||
const sql = new SQL(process.env.DATABASE_URL || "postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot");
|
||||
|
||||
const log = (...a: unknown[]) => console.log(new Date().toISOString().slice(11, 19), ...a);
|
||||
|
||||
/** # 成员变量 分类映射:WP term_id → 新分类 ID(分类步骤填充,或从 PG 加载) **/
|
||||
const catMap = new Map<number, number>();
|
||||
/** # 成员变量 属性映射:属性 slug → 新属性 ID **/
|
||||
const attrMap = new Map<string, number>();
|
||||
/** # 成员变量 属性值映射:`属性ID:值slug` → 新值 ID **/
|
||||
const attrValMap = new Map<string, number>();
|
||||
/** # 函数 部分重跑时从 PG 恢复映射表(categories/attributes 已导入的前提) */
|
||||
async function loadMaps() {
|
||||
if (catMap.size === 0) for (const r of await sql`SELECT id, legacy_id FROM categories WHERE legacy_id IS NOT NULL`.values()) catMap.set(r[1] as number, r[0] as number);
|
||||
if (attrMap.size === 0) {
|
||||
for (const r of await sql`SELECT id, slug FROM product_attributes`.values()) attrMap.set(r[1] as string, r[0] as number);
|
||||
for (const r of await sql`SELECT v.id, v.slug, a.slug AS attr FROM product_attribute_values v JOIN product_attributes a ON a.id=v.attribute_id`.values())
|
||||
attrValMap.set(`${r[2]}:${r[1]}`, r[0] as number);
|
||||
}
|
||||
}
|
||||
|
||||
/** # 成员变量 部分导入:传 --only=step1,step2 只跑指定步骤(缺省全量) **/
|
||||
const only = process.argv[2] && process.argv[2].startsWith("--only=") ? process.argv[2].slice(7).split(",") : null;
|
||||
const should = (name: string) => !only || only.includes(name);
|
||||
/** # 函数 截断字符串,防止超出 varchar 宽度 */
|
||||
const cut = (v: string | undefined, n: number) => (v || "").slice(0, n);
|
||||
|
||||
/* ---------- 清空(按外键无关顺序) ---------- */
|
||||
if (should("reset")) { log("清空业务表...");
|
||||
await sql`TRUNCATE TABLE content_terms, product_terms, product_attribute_map, order_items, payments, cart_items, menu_items, content_revisions, content_translations, product_translations, product_attribute_values, product_attributes, audit_logs RESTART IDENTITY CASCADE`;
|
||||
await sql`TRUNCATE TABLE contents, categories, products, users, sessions, carts, orders, inquiries, inquiry_forms, media, redirects, filters, languages, jobs, settings, legacy_id_mappings, menus RESTART IDENTITY CASCADE`;
|
||||
}
|
||||
|
||||
/* ---------- 1. 媒体 ---------- */
|
||||
if (should("media")) { log("导入媒体...");
|
||||
const [rows] = await legacy.query<any[]>(
|
||||
`SELECT p.ID, p.post_title, meta.path, a.alt,
|
||||
JSON_UNQUOTE(JSON_EXTRACT(m.meta, '$.width')) width,
|
||||
JSON_UNQUOTE(JSON_EXTRACT(m.meta, '$.height')) height
|
||||
FROM wp_posts p
|
||||
JOIN (SELECT post_id, meta_value path FROM wp_postmeta WHERE meta_key='_wp_attached_file') meta ON meta.post_id=p.ID
|
||||
LEFT JOIN (SELECT post_id, meta_value alt FROM wp_postmeta WHERE meta_key='_wp_attachment_image_alt') a ON a.post_id=p.ID
|
||||
LEFT JOIN (SELECT post_id, meta_value meta FROM wp_postmeta WHERE meta_key='_wp_attachment_metadata') m ON m.post_id=p.ID`
|
||||
);
|
||||
let width, height;
|
||||
for (const r of rows) {
|
||||
try { width = r.width ? parseInt(r.width) : null; height = r.height ? parseInt(r.height) : null; } catch { width = height = null; }
|
||||
const mime = r.path.endsWith(".pdf") ? "application/pdf" : r.path.endsWith(".svg") ? "image/svg+xml" : r.path.endsWith(".mp4") ? "video/mp4" : `image/${r.path.split(".").pop()?.toLowerCase().replace("jpg", "jpeg") || "jpeg"}`;
|
||||
await sql`INSERT INTO media (path, url, mime_type, width, height, alt_text, title, legacy_id, created_at)
|
||||
VALUES (${r.path}, ${"/uploads/" + r.path}, ${mime}, ${width}, ${height}, ${r.alt || ""}, ${r.post_title || ""}, ${r.ID}, now())
|
||||
ON CONFLICT (path) DO NOTHING`;
|
||||
}
|
||||
log(`媒体 ${rows.length} 条`);
|
||||
}
|
||||
|
||||
/** legacyId → media id 缓存 */
|
||||
const mediaMap = new Map<number, number>();
|
||||
for (const r of await sql`SELECT id, legacy_id FROM media WHERE legacy_id IS NOT NULL`.values()) mediaMap.set(r[1] as number, r[0] as number);
|
||||
const mediaId = (legacyId: number | null) => (legacyId && mediaMap.get(legacyId)) || null;
|
||||
|
||||
/* ---------- 2. 用户 ---------- */
|
||||
if (should("users")) { log("导入用户...");
|
||||
const [rows] = await legacy.query<any[]>(
|
||||
`SELECT u.ID, u.user_login, u.user_email, u.display_name, u.user_pass, u.user_registered,
|
||||
MAX(CASE WHEN m.meta_key='wp_capabilities' THEN m.meta_value END) caps,
|
||||
GROUP_CONCAT(CASE WHEN m.meta_key LIKE 'billing_%' THEN CONCAT(m.meta_key,'=',m.meta_value) END SEPARATOR '|') billing,
|
||||
GROUP_CONCAT(CASE WHEN m.meta_key LIKE 'shipping_%' THEN CONCAT(m.meta_key,'=',m.meta_value) END SEPARATOR '|') shipping
|
||||
FROM wp_users u LEFT JOIN wp_usermeta m ON m.user_id=u.ID GROUP BY u.ID`
|
||||
);
|
||||
/** # 成员变量 后台初始管理员密码(导入结束时打印) **/
|
||||
let adminPwd = "";
|
||||
for (const r of rows) {
|
||||
const caps = typeof r.caps === "string" && r.caps ? (phpUnserialize(r.caps) as Record<string, unknown>) : {};
|
||||
const isAdmin = "administrator" in caps;
|
||||
const kv = (s: string | null) => {
|
||||
const o: Record<string, string> = {};
|
||||
for (const pair of (s || "").split("|")) { const idx = pair.indexOf("="); if (idx > 0) o[pair.slice(0, idx)] = pair.slice(idx + 1); }
|
||||
return o;
|
||||
};
|
||||
if (isAdmin) {
|
||||
if (!adminPwd) {
|
||||
adminPwd = "Dusun#Admin2026!";
|
||||
const hash = Bun.password.hashSync(adminPwd, { algorithm: "bcrypt", cost: 10 });
|
||||
await sql`INSERT INTO users (email, username, display_name, password_hash, role, legacy_id, created_at)
|
||||
VALUES (${r.user_email || "admin@dusuniot.local"}, ${r.user_login}, ${r.display_name}, ${hash}, 'admin', ${r.ID}, now())
|
||||
ON CONFLICT (email) DO UPDATE SET role='admin', password_hash=EXCLUDED.password_hash, legacy_id=EXCLUDED.legacy_id`;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
await sql`INSERT INTO users (email, username, display_name, legacy_password_hash, role, billing, shipping, legacy_id, created_at)
|
||||
VALUES (${r.user_email || r.user_login + "@legacy.invalid"}, ${r.user_login}, ${r.display_name}, ${r.user_pass}, 'customer',
|
||||
${kv(r.billing)}, ${kv(r.shipping)}, ${r.ID}, ${new Date(r.user_registered)})
|
||||
ON CONFLICT (email) DO NOTHING`;
|
||||
}
|
||||
log(`用户 ${rows.length} 条(含 1 个后台管理员)`);
|
||||
if (adminPwd) console.log(`>>> 后台管理员: admin <原站管理员邮箱> 密码 ${adminPwd}`);
|
||||
}
|
||||
|
||||
const userIdMap = new Map<number, number>();
|
||||
for (const r of await sql`SELECT id, legacy_id FROM users WHERE legacy_id IS NOT NULL`.values()) userIdMap.set(r[1] as number, r[0] as number);
|
||||
|
||||
/* ---------- 3. 分类/标签 ---------- */
|
||||
if (should("categories")) { log("导入分类与标签...");
|
||||
const [rows] = await legacy.query<any[]>(
|
||||
`SELECT t.term_id, t.name, t.slug, tt.taxonomy, tt.parent, tt.description, tt.count
|
||||
FROM wp_terms t JOIN wp_term_taxonomy tt ON tt.term_id=t.term_id
|
||||
WHERE tt.taxonomy IN ('category','post_tag','product_cat','product_tag')`
|
||||
);
|
||||
// 先插父级再插子级(parent 引用)
|
||||
for (const pass of [0, 1]) {
|
||||
for (const r of rows) {
|
||||
if ((r.parent ? 1 : 0) !== pass) continue;
|
||||
const parentId = r.parent ? catMap.get(r.parent) ?? null : null;
|
||||
const [res]: any = await sql`INSERT INTO categories (taxonomy, name, slug, parent_id, description, sort, legacy_id)
|
||||
VALUES (${r.taxonomy}, ${r.name}, ${r.slug}, ${parentId}, ${r.description || ""}, 0, ${r.term_id}) RETURNING id`;
|
||||
catMap.set(r.term_id, res.id);
|
||||
}
|
||||
}
|
||||
log(`分类/标签 ${rows.length} 条`);
|
||||
}
|
||||
|
||||
/* ---------- 4. 产品属性 + 可选值 ---------- */
|
||||
if (should("attributes")) { log("导入产品属性...");
|
||||
const [attrs] = await legacy.query<any[]>(`SELECT attribute_id, attribute_name, attribute_label FROM wp_woocommerce_attribute_taxonomies`);
|
||||
for (const a of attrs) {
|
||||
const [res]: any = await sql`INSERT INTO product_attributes (name, slug, filterable, sort) VALUES (${a.attribute_label}, ${a.attribute_name}, true, 0) RETURNING id`;
|
||||
attrMap.set(a.attribute_name, res.id);
|
||||
const taxName = `pa_${a.attribute_name}`; // attribute_name 为纯 slug,无注入风险
|
||||
const [vals] = await legacy.query<any[]>(
|
||||
`SELECT t.term_id, t.name, t.slug FROM wp_terms t JOIN wp_term_taxonomy tt ON tt.term_id=t.term_id WHERE tt.taxonomy='${taxName}' ORDER BY t.name`
|
||||
);
|
||||
for (const v of vals) {
|
||||
const [vr]: any = await sql`INSERT INTO product_attribute_values (attribute_id, value, slug) VALUES (${res.id}, ${v.name}, ${v.slug}) RETURNING id`;
|
||||
attrValMap.set(`${a.attribute_name}:${v.slug}`, vr.id);
|
||||
}
|
||||
}
|
||||
log(`属性 ${attrs.length} 个,可选值 ${attrValMap.size} 个`);
|
||||
}
|
||||
|
||||
/* ---------- 5. 产品 ---------- */
|
||||
if (should("products")) { log("导入产品..."); await loadMaps();
|
||||
const [rows] = await legacy.query<any[]>(
|
||||
`SELECT p.ID, p.post_name, p.post_title, p.post_excerpt, p.post_content, p.post_date, p.post_status,
|
||||
pm.price, pm.regular, pm.sku, pm.stock, pm.stock_status, pm.thumb, pm.gallery, pm.attrs, pm.spec, pm.el, pm.seo_t, pm.seo_d
|
||||
FROM wp_posts p
|
||||
LEFT JOIN (SELECT post_id, MAX(CASE WHEN meta_key='_price' THEN meta_value END) price,
|
||||
MAX(CASE WHEN meta_key='_regular_price' THEN meta_value END) regular,
|
||||
MAX(CASE WHEN meta_key='_sku' THEN meta_value END) sku,
|
||||
MAX(CASE WHEN meta_key='_stock' THEN meta_value END) stock,
|
||||
MAX(CASE WHEN meta_key='_stock_status' THEN meta_value END) stock_status,
|
||||
MAX(CASE WHEN meta_key='_thumbnail_id' THEN meta_value END) thumb,
|
||||
MAX(CASE WHEN meta_key='_product_image_gallery' THEN meta_value END) gallery,
|
||||
MAX(CASE WHEN meta_key='_product_attributes' THEN meta_value END) attrs,
|
||||
MAX(CASE WHEN meta_key='_specification url' THEN meta_value END) spec,
|
||||
MAX(CASE WHEN meta_key='_elementor_data' THEN meta_value END) el,
|
||||
MAX(CASE WHEN meta_key='_rank_math_title' THEN meta_value END) seo_t,
|
||||
MAX(CASE WHEN meta_key='_rank_math_description' THEN meta_value END) seo_d
|
||||
FROM wp_postmeta WHERE meta_key IN ('_price','_regular_price','_sku','_stock','_stock_status','_thumbnail_id','_product_image_gallery','_product_attributes','_specification url','_elementor_data','_rank_math_title','_rank_math_description') GROUP BY post_id) pm ON pm.post_id=p.ID
|
||||
WHERE p.post_type='product' AND p.post_status IN ('publish','draft')`
|
||||
);
|
||||
for (const r of rows) {
|
||||
const attrs = typeof r.attrs === "string" && r.attrs ? (phpUnserialize(r.attrs) as Record<string, any>) : {};
|
||||
const gallery = (typeof r.gallery === "string" && r.gallery ? r.gallery.split(",").map((s: string) => parseInt(s.trim())).filter(Boolean) : [])
|
||||
.map((id: number) => mediaId(id)).filter(Boolean);
|
||||
const [res]: any = await sql`INSERT INTO products (sku, slug, title, short_description, description, price, regular_price,
|
||||
stock, stock_status, featured_media_id, gallery, status, seo_title, seo_description, spec_url, meta, legacy_id, created_at, updated_at)
|
||||
VALUES (${r.sku || null}, ${r.post_name}, ${r.post_title}, ${r.post_excerpt || ""}, ${r.post_content || ""},
|
||||
${r.price || null}, ${r.regular || null}, ${r.stock !== null && r.stock !== "" ? parseInt(r.stock) : null}, ${r.stock_status || "instock"},
|
||||
${mediaId(r.thumb ? parseInt(r.thumb) : null)}, ${gallery}, ${r.post_status === "publish" ? "published" : "draft"},
|
||||
${r.seo_t || null}, ${r.seo_d || null}, ${r.spec || null}, ${{ elementor: r.el ? JSON.parse(r.el) : null }},
|
||||
${r.ID}, ${new Date(r.post_date)}, now())
|
||||
ON CONFLICT (slug) DO UPDATE SET updated_at=now() RETURNING id`;
|
||||
// 产品分类
|
||||
const [terms] = await legacy.query<any[]>(
|
||||
`SELECT tt.term_id FROM wp_term_relationships tr JOIN wp_term_taxonomy tt ON tt.term_taxonomy_id=tr.term_taxonomy_id
|
||||
WHERE tr.object_id=${r.ID} AND tt.taxonomy='product_cat'`
|
||||
);
|
||||
for (const t of terms) { const c = catMap.get(t.term_id); if (c) await sql`INSERT INTO product_terms (product_id, category_id) VALUES (${res.id}, ${c}) ON CONFLICT DO NOTHING`; }
|
||||
// 属性值(全局属性走 pa_* 分类,自定义属性直接存值)
|
||||
for (const [key, def] of Object.entries<any>(attrs)) {
|
||||
if (!def?.is_taxonomy) continue;
|
||||
const attrSlug = key.replace(/^pa_/, "");
|
||||
if (!attrMap.has(attrSlug)) continue;
|
||||
const [vals] = await legacy.query<any[]>(
|
||||
`SELECT t.slug FROM wp_term_relationships tr JOIN wp_term_taxonomy tt ON tt.term_taxonomy_id=tr.term_taxonomy_id JOIN wp_terms t ON t.term_id=tt.term_id
|
||||
WHERE tr.object_id=${r.ID} AND tt.taxonomy='${key}'`
|
||||
);
|
||||
for (const v of vals) {
|
||||
const vid = attrValMap.get(`${attrSlug}:${v.slug}`);
|
||||
if (vid) await sql`INSERT INTO product_attribute_map (product_id, value_id) VALUES (${res.id}, ${vid}) ON CONFLICT DO NOTHING`;
|
||||
}
|
||||
}
|
||||
}
|
||||
log(`产品 ${rows.length} 个`);
|
||||
}
|
||||
|
||||
/* ---------- 6. 内容(页面/文章/落地页) ---------- */
|
||||
if (should("contents")) { log("导入内容...");
|
||||
const [rows] = await legacy.query<any[]>(
|
||||
`SELECT p.ID, p.post_type, p.post_name, p.post_title, p.post_excerpt, p.post_content, p.post_date, p.post_status,
|
||||
pm.thumb, pm.el, pm.seo_t, pm.seo_d, pm.author
|
||||
FROM wp_posts p
|
||||
LEFT JOIN (SELECT post_id,
|
||||
MAX(CASE WHEN meta_key='_thumbnail_id' THEN meta_value END) thumb,
|
||||
MAX(CASE WHEN meta_key='_elementor_data' THEN meta_value END) el,
|
||||
MAX(CASE WHEN meta_key='_rank_math_title' THEN meta_value END) seo_t,
|
||||
MAX(CASE WHEN meta_key='_rank_math_description' THEN meta_value END) seo_d,
|
||||
MAX(CASE WHEN meta_key='_edit_last' THEN meta_value END) author
|
||||
FROM wp_postmeta WHERE meta_key IN ('_thumbnail_id','_elementor_data','_rank_math_title','_rank_math_description','_edit_last') GROUP BY post_id) pm ON pm.post_id=p.ID
|
||||
WHERE p.post_type IN ('page','post','e-landing-page') AND p.post_status IN ('publish','draft')`
|
||||
);
|
||||
const typeMap: Record<string, string> = { page: "page", post: "post", "e-landing-page": "landing" };
|
||||
for (const r of rows) {
|
||||
// 页面里排除 WooCommerce 桩页面(shop/cart/checkout/my-account 由新系统路由实现)
|
||||
const isShopStub = r.post_type === "page" && [5, 6, 7, 8].includes(r.ID);
|
||||
if (isShopStub) continue;
|
||||
const slug = r.post_name || `legacy-${r.ID}`; // 部分落地页无别名,用 legacy-<id> 兜底
|
||||
const [res]: any = await sql`INSERT INTO contents (type, slug, title, excerpt, html, status, author_id, featured_media_id, template,
|
||||
menu_order, seo_title, seo_description, meta, legacy_id, published_at, created_at, updated_at)
|
||||
VALUES (${typeMap[r.post_type]}, ${slug}, ${r.post_title}, ${r.post_excerpt || ""}, ${r.post_content || ""},
|
||||
${r.post_status === "publish" ? "published" : "draft"}, ${r.author ? userIdMap.get(parseInt(r.author)) ?? null : null},
|
||||
${mediaId(r.thumb ? parseInt(r.thumb) : null)}, ${r.post_type === "page" && r.ID === 18 ? "home" : "default"},
|
||||
0, ${r.seo_t || null}, ${r.seo_d || null},
|
||||
${{ elementor: r.el ? JSON.parse(r.el) : null }}, ${r.ID}, ${new Date(r.post_date)}, ${new Date(r.post_date)}, now())
|
||||
RETURNING id`;
|
||||
const [terms] = await legacy.query<any[]>(
|
||||
`SELECT tt.term_id FROM wp_term_relationships tr JOIN wp_term_taxonomy tt ON tt.term_taxonomy_id=tr.term_taxonomy_id
|
||||
WHERE tr.object_id=${r.ID} AND tt.taxonomy IN ('category','post_tag')`
|
||||
);
|
||||
for (const t of terms) { const c = catMap.get(t.term_id); if (c) await sql`INSERT INTO content_terms (content_id, category_id) VALUES (${res.id}, ${c}) ON CONFLICT DO NOTHING`; }
|
||||
}
|
||||
log(`内容 ${rows.length} 条`);
|
||||
}
|
||||
|
||||
/* ---------- 7. 浏览数 ---------- */
|
||||
if (should("views")) { log("导入浏览数...");
|
||||
const [rows] = await legacy.query<any[]>(`SELECT id, SUM(count) c FROM wp_post_views GROUP BY id`);
|
||||
for (const r of rows) {
|
||||
await sql`UPDATE products SET views=${Number(r.c)} WHERE legacy_id=${r.id}`;
|
||||
await sql`UPDATE contents SET views=${Number(r.c)} WHERE legacy_id=${r.id}`;
|
||||
}
|
||||
log(`浏览数更新 ${rows.length} 条`);
|
||||
}
|
||||
|
||||
/* ---------- 8. 订单 ---------- */
|
||||
if (should("orders")) { log("导入订单...");
|
||||
const [rows] = await legacy.query<any[]>(
|
||||
`SELECT p.ID, p.post_status, p.post_date,
|
||||
MAX(CASE WHEN m.meta_key='_order_total' THEN m.meta_value END) total,
|
||||
MAX(CASE WHEN m.meta_key='_order_shipping' THEN m.meta_value END) shipping,
|
||||
MAX(CASE WHEN m.meta_key='_order_tax' THEN m.meta_value END) tax,
|
||||
MAX(CASE WHEN m.meta_key='_payment_method' THEN m.meta_value END) pay_method,
|
||||
MAX(CASE WHEN m.meta_key='_payment_method_title' THEN m.meta_value END) pay_title,
|
||||
MAX(CASE WHEN m.meta_key='_transaction_id' THEN m.meta_value END) txn,
|
||||
MAX(CASE WHEN m.meta_key='_customer_user' THEN m.meta_value END) customer,
|
||||
MAX(CASE WHEN m.meta_key='_customer_note' THEN m.meta_value END) note,
|
||||
MAX(CASE WHEN m.meta_key='_billing_email' THEN m.meta_value END) bill_email,
|
||||
MAX(CASE WHEN m.meta_key='_date_paid' THEN m.meta_value END) date_paid
|
||||
FROM wp_posts p LEFT JOIN wp_postmeta m ON m.post_id=p.ID
|
||||
WHERE p.post_type='shop_order' AND p.post_status LIKE 'wc-%' GROUP BY p.ID`
|
||||
);
|
||||
for (const r of rows) {
|
||||
const total = r.total || "0", ship = r.shipping || "0", tax = r.tax || "0";
|
||||
const subtotal = (parseFloat(total) - parseFloat(ship) - parseFloat(tax)).toFixed(2);
|
||||
const billing: Record<string, string> = {};
|
||||
const shippingInfo: Record<string, string> = {};
|
||||
const [metas] = await legacy.query<any[]>(`SELECT meta_key, meta_value FROM wp_postmeta WHERE post_id=${r.ID} AND (meta_key LIKE '_billing_%' OR meta_key LIKE '_shipping_%')`);
|
||||
for (const m of metas) {
|
||||
if (m.meta_key.startsWith("_billing_")) billing[m.meta_key.slice(8)] = m.meta_value || "";
|
||||
else shippingInfo[m.meta_key.slice(9)] = m.meta_value || "";
|
||||
}
|
||||
const paid = !!r.date_paid;
|
||||
const [res]: any = await sql`INSERT INTO orders (order_number, user_id, status, currency, subtotal, shipping_total, tax_total, total,
|
||||
payment_method, payment_status, transaction_id, billing, shipping, customer_note, legacy_id, created_at, updated_at)
|
||||
VALUES (${r.ID}, ${r.customer ? userIdMap.get(parseInt(r.customer)) ?? null : null}, ${r.post_status.slice(3)}, 'USD',
|
||||
${subtotal}, ${ship}, ${tax}, ${total}, ${r.pay_method || null}, ${paid ? "paid" : "unpaid"}, ${r.txn || null},
|
||||
${billing}, ${shippingInfo}, ${r.note || ""}, ${r.ID}, ${new Date(r.post_date)}, now()) RETURNING id`;
|
||||
const [items] = await legacy.query<any[]>(
|
||||
`SELECT oi.order_item_id, oi.order_item_name, MAX(CASE WHEN im.meta_key='_product_id' THEN im.meta_value END) pid,
|
||||
MAX(CASE WHEN im.meta_key='_qty' THEN im.meta_value END) qty,
|
||||
MAX(CASE WHEN im.meta_key='_line_total' THEN im.meta_value END) line_total,
|
||||
MAX(CASE WHEN im.meta_key='_line_subtotal' THEN im.meta_value END) line_sub
|
||||
FROM wp_woocommerce_order_items oi LEFT JOIN wp_woocommerce_order_itemmeta im ON im.order_item_id=oi.order_item_id
|
||||
WHERE oi.order_id=${r.ID} AND oi.order_item_type='line_item' GROUP BY oi.order_item_id`
|
||||
);
|
||||
for (const it of items) {
|
||||
const qty = parseInt(it.qty) || 1;
|
||||
const price = (parseFloat(it.line_sub || it.line_total) / qty).toFixed(2);
|
||||
await sql`INSERT INTO order_items (order_id, product_id, name, qty, price, total)
|
||||
VALUES (${res.id}, ${it.pid ? (await sql`SELECT id FROM products WHERE legacy_id=${parseInt(it.pid)}`.values())[0]?.[0] ?? null : null},
|
||||
${it.order_item_name}, ${qty}, ${price}, ${it.line_total || "0"})`;
|
||||
if (paid && r.txn) await sql`INSERT INTO payments (order_id, method, status, transaction_id, amount) VALUES (${res.id}, ${r.pay_method || "unknown"}, 'succeeded', ${r.txn + "-" + it.order_item_id}, ${it.line_total || "0"}) ON CONFLICT (transaction_id) DO NOTHING`;
|
||||
}
|
||||
}
|
||||
log(`订单 ${rows.length} 个`);
|
||||
}
|
||||
|
||||
/* ---------- 9. 询盘(Flamingo + Elementor 提交) ---------- */
|
||||
if (should("flamingo")) { log("导入询盘(Flamingo)...");
|
||||
const [rows] = await legacy.query<any[]>(
|
||||
`SELECT p.ID, p.post_status, p.post_date, p.post_title,
|
||||
MAX(CASE WHEN m.meta_key='_from' THEN m.meta_value END) from_all,
|
||||
MAX(CASE WHEN m.meta_key='_from_name' THEN m.meta_value END) from_name,
|
||||
MAX(CASE WHEN m.meta_key='_from_email' THEN m.meta_value END) from_email,
|
||||
MAX(CASE WHEN m.meta_key='_meta' THEN m.meta_value END) meta,
|
||||
GROUP_CONCAT(CASE WHEN m.meta_key LIKE '\\\\_field_%' THEN CONCAT(m.meta_key,'=',REPLACE(m.meta_value,'|','\\\\|')) END SEPARATOR '|') fields_raw
|
||||
FROM wp_posts p LEFT JOIN wp_postmeta m ON m.post_id=p.ID
|
||||
WHERE p.post_type='flamingo_inbound' AND p.post_status IN ('publish','flamingo-spam') GROUP BY p.ID`
|
||||
);
|
||||
const NAME_KEYS = ["your-name", "name", "contact-person", "contact"];
|
||||
const EMAIL_KEYS = ["your-email", "email", "business-email"];
|
||||
const PHONE_KEYS = ["phone", "phone-number", "tel", "telephone"];
|
||||
const COMPANY_KEYS = ["company", "company-name", "your-company"];
|
||||
const MSG_KEYS = ["your-message", "message", "comments", "requirement", "inquiry"];
|
||||
const PRODUCT_KEYS = ["product", "products", "interest-product", "customer-product"];
|
||||
const COUNTRY_KEYS = ["country", "country-region", "region", "location"];
|
||||
const pick = (fields: Record<string, string>, keys: string[]) => { for (const k of keys) if (fields[k]) return fields[k]; return ""; };
|
||||
for (const r of rows) {
|
||||
const fields: Record<string, string> = {};
|
||||
const utm: Record<string, string> = {};
|
||||
for (const pair of (r.fields_raw || "").split("|")) {
|
||||
const idx = pair.indexOf("=");
|
||||
if (idx <= 1) continue;
|
||||
let key = pair.slice(0, idx).replace(/^_field_/, "");
|
||||
const val = pair.slice(idx + 1);
|
||||
if (key.startsWith("utm_")) { utm[key] = val; continue; }
|
||||
if (key === "gclid" || key.startsWith("acceptance")) { utm[key] = val; continue; }
|
||||
fields[key] = val;
|
||||
}
|
||||
await sql`INSERT INTO inquiries (form_name, name, email, phone, company, country, message, product_interest, fields, status, channel, utm, legacy_id, created_at)
|
||||
VALUES (${'Contact Form 7'}, ${cut(pick(fields, NAME_KEYS) || r.from_name, 255)}, ${cut(pick(fields, EMAIL_KEYS) || r.from_email, 255)},
|
||||
${cut(pick(fields, PHONE_KEYS), 128)}, ${cut(pick(fields, COMPANY_KEYS), 255)}, ${cut(pick(fields, COUNTRY_KEYS), 128)}, ${pick(fields, MSG_KEYS)},
|
||||
${cut(pick(fields, PRODUCT_KEYS), 255)}, ${fields}, ${r.post_status === "flamingo-spam" ? "spam" : "new"},
|
||||
${r.post_title || ""}, ${utm}, ${r.ID}, ${new Date(r.post_date)})`;
|
||||
}
|
||||
log(`Flamingo 询盘 ${rows.length} 条`);
|
||||
}
|
||||
if (should("elementor")) { log("导入询盘(Elementor 表单)...");
|
||||
const [rows] = await legacy.query<any[]>(
|
||||
`SELECT s.id, s.form_name, s.referer, s.user_ip, s.user_agent, s.created_at_gmt, s.status,
|
||||
GROUP_CONCAT(CONCAT(REPLACE(v.key,'|','\\\\|'),'=',REPLACE(v.value,'|','\\\\|')) SEPARATOR '|') vals
|
||||
FROM wp_e_submissions s LEFT JOIN wp_e_submissions_values v ON v.submission_id=s.id
|
||||
WHERE s.status='new' GROUP BY s.id`
|
||||
);
|
||||
for (const r of rows) {
|
||||
const fields: Record<string, string> = {};
|
||||
for (const pair of (r.vals || "").split("|")) {
|
||||
const idx = pair.indexOf("=");
|
||||
if (idx > 0) fields[pair.slice(0, idx)] = pair.slice(idx + 1);
|
||||
}
|
||||
await sql`INSERT INTO inquiries (form_name, name, email, phone, company, country, message, fields, status, source_page, ip, utm, legacy_id, created_at)
|
||||
VALUES (${'Elementor: ' + r.form_name}, ${cut(fields.name || fields["your-name"] || fields["form_field_name"], 255)}, ${cut(fields.email || fields["email"] || fields["form_field_email"], 255)},
|
||||
${cut(fields.phone || fields["tel"], 128)}, ${cut(fields.company, 255)}, ${cut(fields.country, 128)}, ${fields.message || fields["your-message"] || ""},
|
||||
${fields}, 'new', ${cut(r.referer, 2000)}, ${cut(r.user_ip, 64)}, ${{ user_agent: r.user_agent || "" }}, ${r.id}, ${new Date(r.created_at_gmt)})`;
|
||||
}
|
||||
log(`Elementor 询盘 ${rows.length} 条`);
|
||||
}
|
||||
|
||||
/* ---------- 10. CF7 表单定义 ---------- */
|
||||
if (should("forms")) { log("导入询盘表单定义...");
|
||||
const [rows] = await legacy.query<any[]>(
|
||||
`SELECT p.ID, p.post_title, p.post_name, f.form FROM wp_posts p
|
||||
LEFT JOIN (SELECT post_id, meta_value form FROM wp_postmeta WHERE meta_key='_form') f ON f.post_id=p.ID
|
||||
WHERE p.post_type='wpcf7_contact_form'`
|
||||
);
|
||||
for (const r of rows) {
|
||||
const names = [...new Set([...((r.form || "").matchAll(/name="([^"]+)"/g))].map((m: RegExpMatchArray) => m[1]))];
|
||||
await sql`INSERT INTO inquiry_forms (name, form_key, fields, legacy_id) VALUES (${r.post_title}, ${r.post_name}, ${names}, ${r.ID})`;
|
||||
}
|
||||
log(`表单定义 ${rows.length} 个`);
|
||||
}
|
||||
|
||||
/* ---------- 11. 菜单 ---------- */
|
||||
if (should("menus")) { log("导入菜单...");
|
||||
const locations: Record<string, number> = { "menu-1": 699, "menu-2": 807, max_mega_menu_1: 840, max_mega_menu_2: 847 };
|
||||
const itemMap = new Map<number, number>(); // 旧 menu_item id → 新 id
|
||||
// slug → 新 URL 前缀 解析
|
||||
const pageUrl = new Map<number, string>(); // legacy post id → path
|
||||
for (const r of await sql`SELECT id, legacy_id, type, slug FROM contents WHERE legacy_id IS NOT NULL`.values()) {
|
||||
const [legacyId, type, slug] = [r[1] as number, r[2] as string, r[3] as string];
|
||||
pageUrl.set(legacyId, type === "page" ? `/${slug}/` : `/${slug}/`);
|
||||
}
|
||||
for (const r of await sql`SELECT id, legacy_id, slug FROM products WHERE legacy_id IS NOT NULL`.values()) pageUrl.set(r[1] as number, `/product/${r[2]}/`);
|
||||
for (const [loc, termId] of Object.entries(locations)) {
|
||||
const [nameRows] = await legacy.query<any[]>(`SELECT name FROM wp_terms WHERE term_id=${termId}`);
|
||||
const [res]: any = await sql`INSERT INTO menus (name, location, legacy_id) VALUES (${nameRows[0]?.name || loc}, ${loc}, ${termId}) RETURNING id`;
|
||||
const [items] = await legacy.query<any[]>(
|
||||
`SELECT p.ID, p.menu_order, m.parent, m.object, m.object_id, m.url, m.target, p.post_title
|
||||
FROM wp_posts p
|
||||
LEFT JOIN (SELECT post_id, MAX(CASE WHEN meta_key='_menu_item_menu_item_parent' THEN meta_value END) parent,
|
||||
MAX(CASE WHEN meta_key='_menu_item_object' THEN meta_value END) object,
|
||||
MAX(CASE WHEN meta_key='_menu_item_object_id' THEN meta_value END) object_id,
|
||||
MAX(CASE WHEN meta_key='_menu_item_url' THEN meta_value END) url,
|
||||
MAX(CASE WHEN meta_key='_menu_item_target' THEN meta_value END) target
|
||||
FROM wp_postmeta WHERE meta_key LIKE '_menu_item%' GROUP BY post_id) m ON m.post_id=p.ID
|
||||
WHERE p.post_type='nav_menu_item' AND p.post_status='publish'
|
||||
AND p.ID IN (SELECT object_id FROM wp_term_relationships WHERE term_taxonomy_id=(SELECT term_taxonomy_id FROM wp_term_taxonomy WHERE taxonomy='nav_menu' AND term_id=${termId}))
|
||||
ORDER BY p.menu_order`
|
||||
);
|
||||
for (const it of items) {
|
||||
let url = it.url || "";
|
||||
if (it.object === "custom") { /* 保留原 URL */ }
|
||||
else if (it.object === "page" || it.object === "post" || it.object === "product") url = pageUrl.get(parseInt(it.object_id)) || url || "#";
|
||||
const parent = parseInt(it.parent) ? itemMap.get(parseInt(it.parent)) ?? null : null;
|
||||
const [ir]: any = await sql`INSERT INTO menu_items (menu_id, parent_id, title, url, target, sort, legacy_id)
|
||||
VALUES (${res.id}, ${parent}, ${it.post_title || it.url || "item"}, ${url || "#"}, ${it.target || "_self"}, ${it.menu_order}, ${it.ID}) RETURNING id`;
|
||||
itemMap.set(it.ID, ir.id);
|
||||
}
|
||||
}
|
||||
log(`菜单 4 个,菜单项 ${itemMap.size} 条`);
|
||||
}
|
||||
|
||||
/* ---------- 12. 筛选配置 ---------- */
|
||||
if (should("filters")) { log("导入筛选配置..."); await loadMaps();
|
||||
const [rows] = await legacy.query<any[]>(
|
||||
`SELECT p.ID, p.post_title, p.post_name, p.post_content c FROM wp_posts p WHERE p.post_type='filter-field' AND p.post_status='publish'`
|
||||
);
|
||||
let pos = 0;
|
||||
for (const r of rows) {
|
||||
if (/^TEST/i.test(r.post_title)) continue; // 跳过测试筛选
|
||||
const cfg = r.c && r.c.trim().startsWith("a:") ? (phpUnserialize(r.c) as Record<string, string>) : {};
|
||||
const eName = String(cfg.e_name || "");
|
||||
let type = "attribute", attributeId = null as number | null, taxonomy = null as string | null;
|
||||
if (eName.startsWith("pa_")) { attributeId = attrMap.get(eName.slice(3)) ?? null; }
|
||||
else if (eName === "product_cat" || eName === "category") { type = "category"; taxonomy = eName; }
|
||||
else { taxonomy = eName || null; }
|
||||
pos++;
|
||||
await sql`INSERT INTO filters (name, slug, type, attribute_id, taxonomy, position, enabled, legacy_id)
|
||||
VALUES (${r.post_title}, ${r.post_name}, ${type}, ${attributeId}, ${taxonomy}, ${pos}, true, ${r.ID})`;
|
||||
}
|
||||
log(`筛选 ${pos} 个`);
|
||||
}
|
||||
|
||||
/* ---------- 13. 重定向 ---------- */
|
||||
if (should("redirects")) { log("导入重定向...");
|
||||
const [rows] = await legacy.query<any[]>(`SELECT id, url, action_data, action_code, regex FROM wp_redirection_items`);
|
||||
for (const r of rows) {
|
||||
await sql`INSERT INTO redirects (source, target, code, is_regex, hits, enabled, legacy_id)
|
||||
VALUES (${r.url}, ${r.action_data || "/"}, ${r.action_code || 301}, ${!!r.regex}, 0, true, ${r.id})`;
|
||||
}
|
||||
log(`重定向 ${rows.length} 条`);
|
||||
}
|
||||
|
||||
/* ---------- 14. 语言 ---------- */
|
||||
if (should("languages")) { log("导入语言配置...");
|
||||
const LANGS: [string, string][] = [["en", "English"], ["af", "Afrikaans"], ["sq", "Shqip"], ["am", "አማርኛ"], ["ar", "العربية"], ["hy", "Հայերեն"], ["az", "Azərbaycan dili"], ["eu", "Euskara"], ["be", "Беларуская"], ["bn", "বাংলা"], ["bs", "Bosanski"], ["bg", "Български"], ["ca", "Català"], ["ceb", "Cebuano"], ["ny", "Chichewa"], ["zh-CN", "简体中文"], ["zh-TW", "繁體中文"], ["co", "Corsu"], ["hr", "Hrvatski"], ["cs", "Čeština"], ["da", "Dansk"], ["nl", "Nederlands"], ["eo", "Esperanto"], ["et", "Eesti"], ["tl", "Filipino"], ["fi", "Suomi"], ["fr", "Français"], ["fy", "Frysk"], ["gl", "Galego"], ["ka", "ქართული"], ["de", "Deutsch"], ["el", "Ελληνικά"], ["gu", "ગુજરાતી"], ["ht", "Kreyol ayisyen"], ["ha", "Hausa"], ["haw", "ʻŌlelo Hawaiʻi"], ["iw", "עברית"], ["hi", "हिन्दी"], ["hmn", "Hmoob"], ["hu", "Magyar"], ["is", "Íslenska"], ["ig", "Igbo"], ["id", "Bahasa Indonesia"], ["ga", "Gaeilge"], ["it", "Italiano"], ["ja", "日本語"], ["jw", "Basa Jawa"], ["kn", "ಕನ್ನಡ"], ["kk", "Қазақ тілі"], ["km", "ខ្មែរ"], ["ko", "한국어"], ["ku", "Kurdî"], ["ky", "Кыргызча"], ["lo", "ລາວ"], ["la", "Latina"], ["lv", "Latviešu"], ["lt", "Lietuvių"], ["lb", "Lëtzebuergesch"], ["mk", "Македонски"], ["mg", "Malagasy"], ["ms", "Bahasa Melayu"], ["ml", "മലയാളം"], ["mt", "Malti"], ["mi", "Māori"], ["mr", "मराठी"], ["mn", "Монгол"], ["my", "မြန်မာ"], ["ne", "नेपाली"], ["no", "Norsk"], ["ps", "پښتو"], ["fa", "فارسی"], ["pl", "Polski"], ["pt", "Português"], ["pa", "ਪੰਜਾਬੀ"], ["ro", "Română"], ["ru", "Русский"], ["sm", "Samoa"], ["gd", "Gàidhlig"], ["sr", "Српски"], ["st", "Sesotho"], ["sn", "Shona"], ["sd", "سنڌي"], ["si", "සිංහල"], ["sk", "Slovenčina"], ["sl", "Slovenščina"], ["so", "Soomaali"], ["es", "Español"], ["su", "Basa Sunda"], ["sw", "Kiswahili"], ["sv", "Svenska"], ["tg", "Тоҷикӣ"], ["ta", "தமிழ்"], ["te", "తెలుగు"], ["th", "ไทย"], ["tr", "Türkçe"], ["uk", "Українська"], ["ur", "اردو"], ["uz", "Oʻzbekcha"], ["vi", "Tiếng Việt"], ["cy", "Cymraeg"], ["xh", "isiXhosa"], ["yi", "ייִדיש"], ["yo", "Yorùbá"], ["zu", "isiZulu"]];
|
||||
for (const [code, name] of LANGS) {
|
||||
await sql`INSERT INTO languages (code, name, is_default, enabled, machine, sort) VALUES (${code}, ${name}, ${code === "en"}, ${code === "en"}, ${code !== "en"}, 0)`;
|
||||
}
|
||||
log(`语言 ${LANGS.length} 个(en 默认启用,其余为 GTranslate 机翻待升级状态)`);
|
||||
}
|
||||
|
||||
/* ---------- 15. 设置 + ID 映射 ---------- */
|
||||
if (should("final")) { log("写入设置与 ID 映射...");
|
||||
await sql`INSERT INTO settings (key, value) VALUES
|
||||
('site', ${{ name: "Dusun IoT", url: "https://dusuniot.com", currency: "USD", timezone: "Asia/Shanghai" }}),
|
||||
('woocommerce', ${{ currency: "USD", guestCheckout: false, signupFromCheckout: true, pricesIncludeTax: false, allowedCountries: "all_except", categoryBase: "collection", productBase: "product", tagBase: "product-tag" }}),
|
||||
('mail', ${{ smtp: "wp-mail-smtp 迁移,需配置 SMTP 凭据" }}),
|
||||
('seo', ${{ sitemap: true, robots: "all" }})`;
|
||||
|
||||
for (const [entity, table] of [["post", "contents"], ["product", "products"], ["media", "media"], ["user", "users"], ["order", "orders"], ["category", "categories"]] as const) {
|
||||
await sql`INSERT INTO legacy_id_mappings (entity, legacy_id, new_id) SELECT ${entity}, legacy_id, id FROM ${sql(table)} WHERE legacy_id IS NOT NULL ON CONFLICT DO NOTHING`;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* ---------- 汇总校验 ---------- */
|
||||
const counts: Record<string, number> = {};
|
||||
for (const t of ["contents", "content_translations", "categories", "products", "product_attribute_values", "product_attribute_map", "users", "orders", "order_items", "payments", "inquiries", "inquiry_forms", "media", "redirects", "filters", "menus", "menu_items", "languages", "settings", "legacy_id_mappings"]) {
|
||||
const r = (await sql`SELECT COUNT(*) FROM ${sql(t)}`.values())[0];
|
||||
counts[t] = Number(r[0]);
|
||||
}
|
||||
console.table(counts);
|
||||
await legacy.end();
|
||||
sql.end();
|
||||
log("导入完成");
|
||||
@@ -0,0 +1,38 @@
|
||||
/* ---------- PHP serialize 反序列化(字节精确,覆盖 s/i/d/b/a/N) ---------- */
|
||||
export function phpUnserialize(input: string): unknown {
|
||||
const buf = Buffer.from(input, "utf8");
|
||||
let i = 0;
|
||||
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(buf.toString("utf8", i + 2, j)); i = j + 1; return v; } // i:123;
|
||||
if (type === "d") { const j = buf.indexOf(0x3b, i); const v = parseFloat(buf.toString("utf8", i + 2, j)); i = j + 1; return v; } // d:1.5;
|
||||
if (type === "s") { // s:LEN:"...";(LEN 为 UTF-8 字节数)
|
||||
const len = parseInt(buf.toString("utf8", i + 2, colon));
|
||||
const start = colon + 2;
|
||||
const v = buf.toString("utf8", start, start + len);
|
||||
i = start + len + 2;
|
||||
return v;
|
||||
}
|
||||
if (type === "a") { // a:COUNT:{ key;value; ... }
|
||||
const n = parseInt(buf.toString("utf8", 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") { isArr = isArr && true; arr[key] = val; }
|
||||
else { isArr = false; obj[String(key)] = val; }
|
||||
}
|
||||
i += 1; // 消费 '}'
|
||||
return isArr ? arr : obj;
|
||||
}
|
||||
throw new Error(`phpUnserialize: unknown type '${type}' at byte ${i}`);
|
||||
}
|
||||
return parse();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user