v0.10.5 首页 H2 产品线入口 ×6:ImageBoxWidget 重构为 Elementor 忠实 DOM + slim 白名单补齐 + H2 scoped CSS(post-18 对齐,桌面/移动/hover 实测一致)
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules/
|
||||||
|
.output/
|
||||||
|
.vite/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# 视觉校准截图(审计产物,不入库)
|
||||||
|
visual-audit/
|
||||||
|
|
||||||
|
# 原站镜像与原站媒体(取证/运行资产,体积 7G+,不同步 git;部署时另行同步 /public/uploads/)
|
||||||
|
/legacy/
|
||||||
|
/public/uploads/
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# DusunIoT 重建版(TanStack Start + Bun + PostgreSQL)
|
||||||
|
|
||||||
|
将原 WordPress + Elementor + WooCommerce + GTranslate 站点(dusuniot.com)重构为单体应用:
|
||||||
|
前台 SSR 1:1 还原、后台 React + Ant Design 功能等价实现、PostgreSQL 关系型数据模型。
|
||||||
|
|
||||||
|
> 方案文档:`docs/dusuniot-rebuild-tanstack-bun-pg-v2.md`
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 领域 | 选型 |
|
||||||
|
| --- | --- |
|
||||||
|
| 运行时 / 包管理 | Bun 1.4+ |
|
||||||
|
| 前台 SSR / 路由 | TanStack Start(Vite)+ React 19 + TS |
|
||||||
|
| 数据库 | PostgreSQL 16(Bun.sql + Drizzle ORM) |
|
||||||
|
| 后台 UI | React + Ant Design 6(**仅 SVG 图标**,禁 emoji/图标字体) |
|
||||||
|
| 后台任务 | jobs 表持久化队列(`scripts/jobs-worker.ts`,独立进程) |
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun install
|
||||||
|
# .env 默认 DATABASE_URL=postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot
|
||||||
|
bun run db:migrate # 应用迁移(src/db/migrations)
|
||||||
|
bun run import:wordpress # 全量导入 WP 备份库(需 MariaDB 容器 dusuniot-mariadb, 端口 13306)
|
||||||
|
bun run dev # 开发服务器 http://localhost:3311
|
||||||
|
bun run build && bun run start # 生产构建(Nitro .output)
|
||||||
|
bun run scripts/jobs-worker.ts # 后台任务 worker
|
||||||
|
bun test # 单元/集成测试
|
||||||
|
```
|
||||||
|
|
||||||
|
## WordPress 数据迁移
|
||||||
|
|
||||||
|
- 备份源:`dusuniot-full-20261029.tar.gz`(站点文件 + mysqldump)。媒体已硬链接到 `public/uploads`。
|
||||||
|
- 导入脚本:`scripts/wordpress-import/import.ts`,支持 `--only=step1,step2` 分步重跑(步骤:reset/media/users/categories/attributes/products/contents/views/orders/flamingo/elementor/forms/menus/filters/redirects/languages/final)。
|
||||||
|
- 导入规模:148 产品(含属性/筛选映射)、674 内容(含 Elementor JSON)、53,101 询盘(Flamingo + Elementor 双源)、108 订单、8,210 媒体、493 条 301 重定向、104 语言、4 菜单 283 菜单项。
|
||||||
|
- 旧 ID 映射存 `legacy_id_mappings` 表;WP 旧密码(phpass $P$)保留在 `users.legacy_password_hash`,客户登录时透明升级 bcrypt。
|
||||||
|
|
||||||
|
## 与原站插件的功能等价映射
|
||||||
|
|
||||||
|
| 原插件 | 新实现 |
|
||||||
|
| --- | --- |
|
||||||
|
| WooCommerce | products/orders/order_items/payments 关系模型 + 购物车/结账/账户(PayPal 回调位预留,幂等键 transaction_id) |
|
||||||
|
| Contact Form 7 + Flamingo | inquiry_forms(表单定义)+ inquiries(统一询盘表)+ 邮件任务队列 |
|
||||||
|
| Filter Everything | filters 配置表 + product_attributes/values/map 关系型筛选(复合索引 + EXISTS 交集查询) |
|
||||||
|
| GTranslate | /`<lang>`/ 前缀路由 + UI 壳层字符串 + content/product_translations 真实发布版本(缺翻译不生成伪语言页) |
|
||||||
|
| Redirection | redirects 表 + 请求中间件(301/302,支持正则) |
|
||||||
|
| Elementor(前台) | src/components/elementor/Renderer.tsx(section/column/heading/text-editor/image/button/image-box/icon-list/tabs/toggle 等 20+ widget) |
|
||||||
|
| Rank Math SEO | contents/products 内置 SEO 字段 + sitemap.xml + robots.txt + hreflang |
|
||||||
|
| WP Rocket | 设计上按 URL/语言做服务端缓存(Cloudflare/Nginx 层),登录态/购物车禁共享缓存 |
|
||||||
|
| Dusun Flamingo Reader | 后台询盘模块 + REST API(保留给 AI 线索管道) |
|
||||||
|
|
||||||
|
## URL 兼容(SEO 硬性要求)
|
||||||
|
|
||||||
|
- 页面 `/<slug>/`、文章 `/<category>/<slug>/`、产品 `/product/<slug>/`、产品分类 `/collection/<slug>/`(与原站 permalink 一致,trailingSlash always)。
|
||||||
|
- 493 条旧重定向规则原样生效;canonical/hreflang/sitemap 由 SEO 模块输出。
|
||||||
|
|
||||||
|
## 后台
|
||||||
|
|
||||||
|
`/admin`(默认管理员见导入输出)。模块:Dashboard / 内容 / 产品 / 订单 / 询盘 / 媒体 / 语言 / SEO 重定向 / 筛选 / 用户 / 设置。
|
||||||
|
所有接口 `requireAdmin()` 权限校验,敏感操作写 `audit_logs`。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
见方案文档第 6 节。`src/server/*` 按业务域拆分(auth/content→data/products→data/filters/orders→commerce/inquiries/translations/seo/jobs)。
|
||||||
|
|
||||||
|
## 版本
|
||||||
|
|
||||||
|
版本号以 `package.json` `version` 为准,每轮交付自增(patch/minor/major)。
|
||||||
@@ -0,0 +1,874 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 2,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "dusuniot-rebuild",
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^6.3.4",
|
||||||
|
"@radix-ui/react-accordion": "^1.3.1",
|
||||||
|
"@radix-ui/react-navigation-menu": "^1.4.1",
|
||||||
|
"@radix-ui/react-select": "^2.4.1",
|
||||||
|
"@radix-ui/react-slot": "^1.5.0",
|
||||||
|
"@radix-ui/react-tabs": "^1.2.1",
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@tanstack/react-query": "^5.105.0",
|
||||||
|
"@tanstack/react-router": "^1.170.46",
|
||||||
|
"@tanstack/react-router-devtools": "^1.167.2",
|
||||||
|
"@tanstack/react-start": "^1.169.4",
|
||||||
|
"@types/react": "^19.3.0",
|
||||||
|
"@types/react-dom": "^19.3.0",
|
||||||
|
"@vitejs/plugin-react": "^6.1.2",
|
||||||
|
"antd": "^6.6.8",
|
||||||
|
"bun-types": "^1.4.4",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"drizzle-kit": "^0.31.11",
|
||||||
|
"drizzle-orm": "^0.45.4",
|
||||||
|
"lucide-react": "^1.64.0",
|
||||||
|
"mysql2": "^3.24.6",
|
||||||
|
"react": "^19.3.1",
|
||||||
|
"react-dom": "^19.3.1",
|
||||||
|
"tailwind-merge": "^3.7.0",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"vite": "^8.3.5",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vite-tsconfig-paths": "^6.1.1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@ant-design/colors": ["@ant-design/colors@8.0.1", "", { "dependencies": { "@ant-design/fast-color": "^3.0.0" } }, "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ=="],
|
||||||
|
|
||||||
|
"@ant-design/cssinjs": ["@ant-design/cssinjs@2.1.2", "", { "dependencies": { "@babel/runtime": "^7.11.1", "@emotion/hash": "^0.8.0", "@emotion/unitless": "^0.7.5", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1", "csstype": "^3.1.3", "stylis": "^4.3.4" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ=="],
|
||||||
|
|
||||||
|
"@ant-design/cssinjs-utils": ["@ant-design/cssinjs-utils@2.1.2", "", { "dependencies": { "@ant-design/cssinjs": "^2.1.2", "@babel/runtime": "^7.23.2", "@rc-component/util": "^1.4.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-5fTHQ158jJJ5dC/ECeyIdZUzKxE/mpEMRZxthyG1sw/AKRHKgJBg00Yi6ACVXgycdje7KahRNvNET/uBccwCnA=="],
|
||||||
|
|
||||||
|
"@ant-design/fast-color": ["@ant-design/fast-color@3.0.1", "", {}, "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw=="],
|
||||||
|
|
||||||
|
"@ant-design/icons": ["@ant-design/icons@6.3.4", "", { "dependencies": { "@ant-design/colors": "^8.0.1", "@ant-design/icons-svg": "^4.6.0", "@rc-component/util": "^1.11.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-kDoUlppczVyCUTFthF6cHmPzMBYqNZQhu9j6EyIX/YIdM9j9CZNZoJUjzVw7/1v1VO8WwVMmPp15r/uwIAS0AQ=="],
|
||||||
|
|
||||||
|
"@ant-design/icons-svg": ["@ant-design/icons-svg@4.6.0", "", {}, "sha512-PRomU725ABMf/lnQp5HiB7my1kjEbFY0D10N4lXYxK6TIB1gKjIVD5MRThpDaezLgw1D774J8eOeeDB0M6wHrQ=="],
|
||||||
|
|
||||||
|
"@ant-design/react-slick": ["@ant-design/react-slick@2.0.0", "", { "dependencies": { "@babel/runtime": "^7.28.4", "clsx": "^2.1.1", "json2mq": "^0.2.0", "throttle-debounce": "^5.0.0" }, "peerDependencies": { "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg=="],
|
||||||
|
|
||||||
|
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
|
||||||
|
|
||||||
|
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
|
||||||
|
|
||||||
|
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
|
||||||
|
|
||||||
|
"@babel/generator": ["@babel/generator@7.29.12", "", { "dependencies": { "@babel/parser": "^7.29.12", "@babel/types": "^7.29.11", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-2W76mhjrpQFO1lK5e8IvGYB2D0YZ7S2R1Z2BjO2ZUYuW+amcpK/z60U6ZZmzskwoa7sJ6XohP/Wg8H5KxXI/IA=="],
|
||||||
|
|
||||||
|
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
|
||||||
|
|
||||||
|
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
|
||||||
|
|
||||||
|
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
|
||||||
|
|
||||||
|
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
|
||||||
|
|
||||||
|
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.11", "", {}, "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g=="],
|
||||||
|
|
||||||
|
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||||
|
|
||||||
|
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
|
||||||
|
|
||||||
|
"@babel/helpers": ["@babel/helpers@7.29.10", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.8" } }, "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg=="],
|
||||||
|
|
||||||
|
"@babel/parser": ["@babel/parser@7.29.12", "", { "dependencies": { "@babel/types": "^7.29.11" }, "bin": "./bin/babel-parser.js" }, "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg=="],
|
||||||
|
|
||||||
|
"@babel/runtime": ["@babel/runtime@7.29.10", "", {}, "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA=="],
|
||||||
|
|
||||||
|
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
|
||||||
|
|
||||||
|
"@babel/traverse": ["@babel/traverse@7.29.12", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.12", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.12", "@babel/template": "^7.29.7", "@babel/types": "^7.29.11", "debug": "^4.3.1" } }, "sha512-MscXe/NAZcOkYEsRajHZFt3ynsb22PUi6CTrp5U7+RYycR9By84mn9mFN54YgfITiqK4hFx3Ju3tZSyzfpS5ew=="],
|
||||||
|
|
||||||
|
"@babel/types": ["@babel/types@7.29.11", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.11", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ=="],
|
||||||
|
|
||||||
|
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
||||||
|
|
||||||
|
"@emotion/hash": ["@emotion/hash@0.8.0", "", {}, "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="],
|
||||||
|
|
||||||
|
"@emotion/unitless": ["@emotion/unitless@0.7.5", "", {}, "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
|
||||||
|
|
||||||
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
||||||
|
|
||||||
|
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||||
|
|
||||||
|
"@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
|
||||||
|
|
||||||
|
"@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
|
||||||
|
|
||||||
|
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="],
|
||||||
|
|
||||||
|
"@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
|
||||||
|
|
||||||
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
|
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||||
|
|
||||||
|
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||||
|
|
||||||
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
|
||||||
|
|
||||||
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="],
|
||||||
|
|
||||||
|
"@oozcitak/infra": ["@oozcitak/infra@2.0.2", "", { "dependencies": { "@oozcitak/util": "^10.0.0" } }, "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA=="],
|
||||||
|
|
||||||
|
"@oozcitak/url": ["@oozcitak/url@3.0.0", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/util": "^10.0.0" } }, "sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ=="],
|
||||||
|
|
||||||
|
"@oozcitak/util": ["@oozcitak/util@10.0.0", "", {}, "sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA=="],
|
||||||
|
|
||||||
|
"@oxc-project/types": ["@oxc-project/types@0.156.0", "", {}, "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw=="],
|
||||||
|
|
||||||
|
"@radix-ui/number": ["@radix-ui/number@1.1.3", "", {}, "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA=="],
|
||||||
|
|
||||||
|
"@radix-ui/primitive": ["@radix-ui/primitive@1.2.0", "", {}, "sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.3.1", "", { "dependencies": { "@radix-ui/primitive": "1.2.0", "@radix-ui/react-collapsible": "1.2.1", "@radix-ui/react-collection": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.3.0", "@radix-ui/react-direction": "1.2.0", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.2.0", "@radix-ui/react-use-controllable-state": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lCZ6sbGS/7WO0j84yx4srIgOf0MRJ/sZ13RonADXxvKBym8xrxNCq5f6DibGcr38sIlwPk8/52uYdXeOPsbEDw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.17", "", { "dependencies": { "@radix-ui/react-primitive": "2.2.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-67jjcVvxjXCCStnTW3Z+M7jmaFMuMR7M2JgpDzH4w7JSRrikmzhjfYgP/09ECb8n7whnsgh1ifJ+Ie96qEj+pQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.2.1", "", { "dependencies": { "@radix-ui/primitive": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.3.0", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.2.0", "@radix-ui/react-use-controllable-state": "1.3.0", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-fSzLb/qy1wuZSxQBcxpW0hNXYHDMWiIXDGyJGdA4+FSLknOWZsNRrTCUavxb4PI8b8lvHo/CysO6NVi5zLpo5g=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.2.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.3.0", "@radix-ui/react-primitive": "2.2.0", "@radix-ui/react-slot": "1.5.0", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+yiQzqcc7Sy7/5s0jrMCjpPRFB3538VNrXPWdTLZhE/SCfgEws4aI6lHWN/vvcnyhHndROWBzqmqB4QBrITZ9Q=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-context": ["@radix-ui/react-context@1.3.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.2.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FtRA52a/r+LKStFT+neLUm+8vSe7wbzkbQGGeU8ixFiwC8NU14Evoq9ATECqL3eGbcUzJR4fyfA4KkK/q6dfqg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.2.1", "", { "dependencies": { "@radix-ui/primitive": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.2.0", "@radix-ui/react-use-callback-ref": "1.1.5", "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-KIfDcAsx+oVylFZ4xryVKusJUrLLhTQmfnJW4kwk4Km5myvhHCrj04hnWWUC166+xD4DHdH8tQGylwYX1h6+Pg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.2.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MPm5R0iRGUDrDnPNxmLBkv+X7pN4XKzt+MbWJEuJQsH3NFH4omrbpXpucyYCCKTSt+na/ZxGhEWQrWTZ0285cA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.3.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.2.0", "@radix-ui/react-use-callback-ref": "1.1.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ugeMhbXt4y5S2T1GnIqbWDKEN962PDwnX1fN0eKOOgf5f63Dh2mn8F1FUySlgW0iF6GiVsRF32DzuDIWTUyQJg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.4.1", "", { "dependencies": { "@radix-ui/primitive": "1.2.0", "@radix-ui/react-collection": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.3.0", "@radix-ui/react-direction": "1.2.0", "@radix-ui/react-dismissable-layer": "1.2.1", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.2.0", "@radix-ui/react-slot": "1.5.0", "@radix-ui/react-use-callback-ref": "1.1.5", "@radix-ui/react-use-controllable-state": "1.3.0", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-previous": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.13" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XPPZjkeyCSMNNvt0FyDv/PUauG7Ow4GwHQ/XZSwW36F/TS7rR4HFTsXH8ewLnXTOLn3x96/pmTPNQIVoTX/86g=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.4.0", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.17", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.3.0", "@radix-ui/react-primitive": "2.2.0", "@radix-ui/react-use-callback-ref": "1.1.5", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-rect": "1.1.4", "@radix-ui/react-use-size": "1.1.5", "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-yCWRvLeGv9Pz3+efwHqGPp70mZ56r3kfIgQVF56Oj2aAa4uP/Tb+IE59wNcc+758rctqbCKx4LbK8II/r7Bn0Q=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.2.0", "", { "dependencies": { "@radix-ui/react-primitive": "2.2.0", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.11", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.2.0", "", { "dependencies": { "@radix-ui/react-slot": "1.5.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.2.1", "", { "dependencies": { "@radix-ui/primitive": "1.2.0", "@radix-ui/react-collection": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.3.0", "@radix-ui/react-direction": "1.2.0", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.2.0", "@radix-ui/react-use-callback-ref": "1.1.5", "@radix-ui/react-use-controllable-state": "1.3.0", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UwraORzVUeEPMclhO1p0vz0qI+TbdDLO9i+FiOqhJyHKwdpCRCck9yVEscUnAMIzCiuNoQa8D4A9bIpOPXGt6Q=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-select": ["@radix-ui/react-select@2.4.1", "", { "dependencies": { "@radix-ui/number": "1.1.3", "@radix-ui/primitive": "1.2.0", "@radix-ui/react-collection": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.3.0", "@radix-ui/react-direction": "1.2.0", "@radix-ui/react-dismissable-layer": "1.2.1", "@radix-ui/react-focus-guards": "1.2.0", "@radix-ui/react-focus-scope": "1.3.1", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.4.0", "@radix-ui/react-portal": "1.2.0", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.2.0", "@radix-ui/react-slot": "1.5.0", "@radix-ui/react-use-callback-ref": "1.1.5", "@radix-ui/react-use-controllable-state": "1.3.0", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.13", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-888ZwW0Md6GyptuqYABRX9nLKgoYfcooCyBBvVaX5Jp3e+8Jp2i4Zo/Uy50Hm+Ysn2JfloQXstW4U36w8teVtg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.5.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.2.1", "", { "dependencies": { "@radix-ui/primitive": "1.2.0", "@radix-ui/react-context": "1.3.0", "@radix-ui/react-direction": "1.2.0", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.2.0", "@radix-ui/react-roving-focus": "1.2.1", "@radix-ui/react-use-controllable-state": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-hRoJlode+hg5ZNlR+3tkfkUliIybdTpD5lkAvHQ9yaXseGf7XVO9Ib2GPEIOuaJJbo7YWYbNpDDRfo8Dhf6/xA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.5", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.3.0", "", { "dependencies": { "@radix-ui/primitive": "1.2.0", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.5", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.4", "", { "dependencies": { "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.5", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-V1McDQkMGzgivwqdu7u96APs7oi0Jvj9LTzgq3WWrDZqxWdAqU9yZnZQqk5OizGFR8badUQwZsrlao7MOlWYOA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.13", "", { "dependencies": { "@radix-ui/react-primitive": "2.2.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-feijiN1oPk4rck4u9Dm9tsU4mT6Wp9oeEEhaz1WUEs1VuqLf8xzsFG+Qk+uWq6TnMH9O+WRECRg0NftyP9E4Fw=="],
|
||||||
|
|
||||||
|
"@radix-ui/rect": ["@radix-ui/rect@1.1.3", "", {}, "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw=="],
|
||||||
|
|
||||||
|
"@rc-component/async-validator": ["@rc-component/async-validator@6.0.0", "", { "dependencies": { "@babel/runtime": "^7.24.4" } }, "sha512-D3AGQwdyE58gmvx6waVSXJ80JGO+IY5L2O8HDnSOex7JNlzB3GuN/4hyHNTdhy2qtOhkpbIjmeAN3tL993wKbA=="],
|
||||||
|
|
||||||
|
"@rc-component/cascader": ["@rc-component/cascader@1.22.0", "", { "dependencies": { "@rc-component/select": "~1.10.0", "@rc-component/tree": "~1.4.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-SffrA57aS9oub3VuI7ajPhJTPtaNxngSvtRhD40Rd8dwJ5vfWPSrVanWgeepdWFGBt7EHftIK5RUU0u3rCTwWw=="],
|
||||||
|
|
||||||
|
"@rc-component/checkbox": ["@rc-component/checkbox@2.0.0", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-3CXGPpAR9gsPKeO2N78HAPOzU30UdemD6HGJoWVJOpa6WleaGB5kzZj3v6bdTZab31YuWgY/RxV3VKPctn0DwQ=="],
|
||||||
|
|
||||||
|
"@rc-component/collapse": ["@rc-component/collapse@1.2.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/motion": "^1.1.4", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-ZRYSKSS39qsFx93p26bde7JUZJshsUBEQRlRXPuJYlAiNX0vyYlF5TsAm8JZN3LcF8XvKikdzPbgAtXSbkLUkw=="],
|
||||||
|
|
||||||
|
"@rc-component/color-picker": ["@rc-component/color-picker@3.1.1", "", { "dependencies": { "@ant-design/fast-color": "^3.0.1", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-OHaCHLHszCegdXmIq2ZRIZBN/EtpT6Wm8SG/gpzLATHbVKc/avvuKi+zlOuk05FTWvgaMmpxAko44uRJ3M+2pg=="],
|
||||||
|
|
||||||
|
"@rc-component/context": ["@rc-component/context@2.0.2", "", { "dependencies": { "@rc-component/util": "^1.11.0" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-uiGpAlblCNlziHPwj4S4Iy/oemeuz/hR03mbiEjTCXwsqOIN3BOzsRMyDwpyO5Fm0vIEEJRUf9ZtbRLbhksuTA=="],
|
||||||
|
|
||||||
|
"@rc-component/dialog": ["@rc-component/dialog@1.10.0", "", { "dependencies": { "@rc-component/motion": "^1.3.3", "@rc-component/portal": "^2.1.0", "@rc-component/util": "^1.9.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-eDukNlz9vNszAGv7i3zKXdxEd3wgVmNxuJijYt8zvTh17QwTu8KK/bdURRd/lU4qaMzhO1HKKmMrwOnkaw0BvQ=="],
|
||||||
|
|
||||||
|
"@rc-component/drawer": ["@rc-component/drawer@1.4.2", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/portal": "^2.1.3", "@rc-component/util": "^1.9.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-1ib+fZEp6FBu+YvcIktm+nCQ+Q+qIpwpoaJH6opGr4ofh2QMq+qdr5DLC4oCf5qf3pcWX9lUWPYX652k4ini8Q=="],
|
||||||
|
|
||||||
|
"@rc-component/dropdown": ["@rc-component/dropdown@1.0.3", "", { "dependencies": { "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.11.0", "react-dom": ">=16.11.0" } }, "sha512-YTST/N6kpqpDz3IMuM/PSSZnrDpSOA6dgHv12gPA90ZTSLv2CoqkZ0+9NtwTY6BeO7dstPblSic2QJg7dSFy/g=="],
|
||||||
|
|
||||||
|
"@rc-component/form": ["@rc-component/form@1.8.6", "", { "dependencies": { "@rc-component/async-validator": "^6.0.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-1EXVsSKPZC6kGrIqxVck7kAWM6T65b6et/n5wIcyiaIa41VrDshD0nVLHoBDEIZr2ATQsOP6icb4XQkNFMLBAw=="],
|
||||||
|
|
||||||
|
"@rc-component/image": ["@rc-component/image@1.10.0", "", { "dependencies": { "@rc-component/motion": "^1.0.0", "@rc-component/portal": "^2.1.2", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-BjeZCRQ+hw+4WAhvrw8rJvy5fckA2xpf/X2XQEOABUHvLTNB9inB98X3Mp54jYQ7g10DfWERQWHXeC4ylxp1Uw=="],
|
||||||
|
|
||||||
|
"@rc-component/input": ["@rc-component/input@1.3.1", "", { "dependencies": { "@rc-component/resize-observer": "^1.1.1", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-iFvTUT9W+JC/MSin2aGAk8NqsVlTzcExNC9DZariON1IWirju9NoNeEk47an4Q8iHazkoVI/y1LnDi88+CPcig=="],
|
||||||
|
|
||||||
|
"@rc-component/input-number": ["@rc-component/input-number@1.6.2", "", { "dependencies": { "@rc-component/mini-decimal": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Gjcq7meZlCOiWN1t1xCC+7/s85humHVokTBI7PJgTfoyw5OWF74y3e6P8PHX104g9+b54jsodFIzyaj6p8LI9w=="],
|
||||||
|
|
||||||
|
"@rc-component/listy": ["@rc-component/listy@1.2.3", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/portal": "^2.0.0", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.3.1", "@rc-component/virtual-list": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-IXiMjV5s0rczLBlfh7G5nB4M3365mrEeedjwKtf5I+Ns3PqRUsebR2h5u8CeFarsVfLUPC2I5p0h09TNoOWyvQ=="],
|
||||||
|
|
||||||
|
"@rc-component/mentions": ["@rc-component/mentions@1.12.0", "", { "dependencies": { "@rc-component/input": "~1.3.1", "@rc-component/menu": "~1.5.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-v5MBx8zDcqCT+uybJgKpcx3Fgrvj0m3u+A/15RlSBaMx12lLoJZTTYggC3pbYYfohcKujWWUyBz6O6Gp2vGiNA=="],
|
||||||
|
|
||||||
|
"@rc-component/menu": ["@rc-component/menu@1.5.0", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/overflow": "^1.0.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-RjvzPsZkwVEg3xu+/jDDqDeUCBJAnWylONFlGeQgg5enlNAnlpybqhaMGE/UPEIvr6hL90PGhjFBguJqj0Qb2Q=="],
|
||||||
|
|
||||||
|
"@rc-component/mini-decimal": ["@rc-component/mini-decimal@1.1.4", "", { "dependencies": { "@babel/runtime": "^7.18.0" } }, "sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w=="],
|
||||||
|
|
||||||
|
"@rc-component/motion": ["@rc-component/motion@1.3.3", "", { "dependencies": { "@rc-component/util": "^1.11.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Xh3IszxvlSv3/PLYFyC2UZi9LNB83yOnkB/LNmRzaypZLvkhqUIPS7MQpGZcCMWrNsXV2p6YTSWbSGvFpEle9A=="],
|
||||||
|
|
||||||
|
"@rc-component/mutate-observer": ["@rc-component/mutate-observer@2.0.1", "", { "dependencies": { "@rc-component/util": "^1.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-AyarjoLU5YlxuValRi+w8JRH2Z84TBbFO2RoGWz9d8bSu0FqT8DtugH3xC3BV7mUwlmROFauyWuXFuq4IFbH+w=="],
|
||||||
|
|
||||||
|
"@rc-component/notification": ["@rc-component/notification@2.1.1", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/util": "^1.13.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-HD+Gx/HUjivBI6GZtfPVLjvOJZybSEIoE2upnmoVg4w9vZQHMaIPWP6B52cHWOGg+u48HBbO+HnyVNN8AKQxgQ=="],
|
||||||
|
|
||||||
|
"@rc-component/overflow": ["@rc-component/overflow@1.0.1", "", { "dependencies": { "@babel/runtime": "^7.11.1", "@rc-component/resize-observer": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-syfmgAABaHCnCDzPwHZ/2tuvIcpOO3jefYZMmfkN+pmo8HKTzsfhS57vxo4ksPdN0By+uWVJhJWNFozNBxi2eA=="],
|
||||||
|
|
||||||
|
"@rc-component/pagination": ["@rc-component/pagination@1.4.0", "", { "dependencies": { "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-CW1g7P9V8u+e8JQdUsl2RWg+GCsoee0mtJjZUCCxn/vb3jzOwDKm6hAdwddHCVBfWJ58eGUBZz3IvnU8rRktjw=="],
|
||||||
|
|
||||||
|
"@rc-component/picker": ["@rc-component/picker@1.12.2", "", { "dependencies": { "@rc-component/overflow": "^1.0.0", "@rc-component/resize-observer": "^1.0.0", "@rc-component/trigger": "^3.6.15", "@rc-component/util": "^1.13.0", "clsx": "^2.1.1" }, "peerDependencies": { "date-fns": ">= 2.x", "dayjs": ">= 1.x", "luxon": ">= 3.x", "moment": ">= 2.x", "react": ">=16.9.0", "react-dom": ">=16.9.0" }, "optionalPeers": ["date-fns", "dayjs", "luxon", "moment"] }, "sha512-ZB1A+tzTQkgtR4flPrAMvxIAOelIIOOOsp22fU2Nad6PbZcJacnyGJUJqUH7ZS9mwxeUwurf1pLMKXrVsK1tlA=="],
|
||||||
|
|
||||||
|
"@rc-component/portal": ["@rc-component/portal@2.2.1", "", { "dependencies": { "@rc-component/util": "^1.11.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-ck+r1kW/JSv0wxPji3KN2ss9K6Z0qqwusw/mf/0JobXhZ8hC2ejZwCJObW/SvDi0uhA0VzmCnx0CaCci95tcmA=="],
|
||||||
|
|
||||||
|
"@rc-component/progress": ["@rc-component/progress@1.0.3", "", { "dependencies": { "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Q1nPLIvKr95el/q76yaBh5nv1hqCsWaHQb8U7vXEDMefEhHgM7n3CPoN+o4LHnTyANQHmMFgoS/naAT4Bhxuaw=="],
|
||||||
|
|
||||||
|
"@rc-component/qrcode": ["@rc-component/qrcode@2.0.0", "", { "dependencies": { "@babel/runtime": "^7.24.7" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-aAv3QhPP1xyafuTZOxub6a54pCeBnN3IwQkpETrBtthq4BL5IgxnCbuoBWPDpdLw1y1j6BgBUCAKV92+yX06Dw=="],
|
||||||
|
|
||||||
|
"@rc-component/rate": ["@rc-component/rate@1.0.1", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-bkXxeBqDpl5IOC7yL7GcSYjQx9G8H+6kLYQnNZWeBYq2OYIv1MONd6mqKTjnnJYpV0cQIU2z3atdW0j1kttpTw=="],
|
||||||
|
|
||||||
|
"@rc-component/resize-observer": ["@rc-component/resize-observer@1.1.2", "", { "dependencies": { "@rc-component/util": "^1.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-t/Bb0W8uvL4PYKAB3YcChC+DlHh0Wt5kM7q/J+0qpVEUMLe7Hk5zuvc9km0hMnTFPSx5Z7Wu/fzCLN6erVLE8Q=="],
|
||||||
|
|
||||||
|
"@rc-component/segmented": ["@rc-component/segmented@1.4.0", "", { "dependencies": { "@babel/runtime": "^8.0.0", "@rc-component/motion": "^1.1.4", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-NZlpKGSplOYlmF5ukUF1fCLr0ns5WfjNoqMlMkf4AHP0iu90wyiP7hn2qZKRHEuRlG4CvEEz/lF9qRiYnLTofQ=="],
|
||||||
|
|
||||||
|
"@rc-component/select": ["@rc-component/select@1.10.1", "", { "dependencies": { "@rc-component/overflow": "^1.0.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.11.1", "@rc-component/virtual-list": "^1.2.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-H+yQsl+qED9NilQ3g6zdpsMwUgwVjrcMTkNHAWRVU/MoNCYgTbDgU+MIMgZDK+rVdd2JUfI/MkysMcZZ0cyQKw=="],
|
||||||
|
|
||||||
|
"@rc-component/slider": ["@rc-component/slider@1.1.1", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-LSzgWGYDgeCDgR4r1XlU29gbYws6HpLnvJd/uMhLeW/vQgxldeR+Wb4uzHDCHiYEbr1bnEHWdjkPxjJRHxuiig=="],
|
||||||
|
|
||||||
|
"@rc-component/steps": ["@rc-component/steps@1.2.3", "", { "dependencies": { "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-/b9gHcvDGjTDWJZW7+1kKFvzs0jrEeQ8mFvvIgX+h7xNT0FsOYODJYKjyJR4fPbvMBCfCOEqaBK66HBxov5BQA=="],
|
||||||
|
|
||||||
|
"@rc-component/switch": ["@rc-component/switch@1.0.3", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Jgi+EbOBquje/XNdofr7xbJQZPYJP+BlPfR0h+WN4zFkdtB2EWqEfvkXJWeipflwjWip0/17rNbxEAqs8hVHfw=="],
|
||||||
|
|
||||||
|
"@rc-component/table": ["@rc-component/table@1.11.1", "", { "dependencies": { "@rc-component/context": "^2.0.1", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.11.1", "@rc-component/virtual-list": "^1.0.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-OWdS6DMmeWb7bJBGqPxYZpQbzBlBiXZUu2sqo6Ii7Sjs9GeK1IsrXrWk26SL2c6KEseabswdxrRj7WUm9LdECw=="],
|
||||||
|
|
||||||
|
"@rc-component/tabs": ["@rc-component/tabs@1.13.0", "", { "dependencies": { "@rc-component/dropdown": "~1.0.0", "@rc-component/menu": "~1.5.0", "@rc-component/motion": "^1.1.3", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-3FXr+9ZII8tFLWuDWrgjRaxZd2mazkKJAGRFEfVmp4r1qnBglOlxQ78l54Td1aj6/iFaaEReKSAF2HToztU5uw=="],
|
||||||
|
|
||||||
|
"@rc-component/tooltip": ["@rc-component/tooltip@1.5.2", "", { "dependencies": { "@rc-component/trigger": "^3.10.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-VLq4BclU3LgRe46g2hTr2bdsM8tFYs7dVwpuD88rPNNg4lCGe8mBSS4FiRzYzS+ffnZ46y4SI8KYKo9K7qBnhw=="],
|
||||||
|
|
||||||
|
"@rc-component/tour": ["@rc-component/tour@2.4.0", "", { "dependencies": { "@rc-component/portal": "^2.2.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.7.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-aui4r4TqmTzwaBgcQxHYep8kM8PTjZFufjokObpy35KfFeZ0k9ArquWFZqegQlH24P14t+F0qO0mGTgzlav1yg=="],
|
||||||
|
|
||||||
|
"@rc-component/tree": ["@rc-component/tree@1.4.0", "", { "dependencies": { "@rc-component/motion": "^1.0.0", "@rc-component/util": "^1.11.1", "@rc-component/virtual-list": "^1.2.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-dGsJGDJQedA0BqqVgj3F8BvHXTSZijyhTXdbAdkcx8lynzZkty/CV3Z3LOm/fxz+BCfl3dfGiAQpb7Q5XNvl0Q=="],
|
||||||
|
|
||||||
|
"@rc-component/tree-select": ["@rc-component/tree-select@1.16.1", "", { "dependencies": { "@rc-component/select": "~1.10.0", "@rc-component/tree": "~1.4.0", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-a1Oi6EJhqAhdOxxupdJi6fP0RPHMKn5TcfkX2+llaQ4lF4nwfH7b6SCHcnsybaa2s+pk1yZYwVyeOYkDnEBRdg=="],
|
||||||
|
|
||||||
|
"@rc-component/trigger": ["@rc-component/trigger@3.10.1", "", { "dependencies": { "@rc-component/motion": "^1.3.3", "@rc-component/portal": "^2.2.1", "@rc-component/resize-observer": "^1.1.2", "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-mXlDN0IXdtV8Yqqm8195ECCyrbmfvvfKvwVvSlH0+qvKD6BUF8gRhEjSy0FOcD1+CcDRHgTiX99LoxfQrmh3Cw=="],
|
||||||
|
|
||||||
|
"@rc-component/upload": ["@rc-component/upload@1.1.1", "", { "dependencies": { "@rc-component/util": "^1.11.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-GvYWSKeaJTOxxC5p6+nOSadzfvXA1h8C/iHFPFZX+szH3JUXrvs+DLiW8YUTBgvMh8m63mJeHrlYlJzAlg+pDA=="],
|
||||||
|
|
||||||
|
"@rc-component/util": ["@rc-component/util@1.14.0", "", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^19.2.7" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-qFpkGBCiyN3NNAU+FOnZdcdz7A/rXCg3JdQk+bujUKAExZgRqzf2yy1f4YN63Lwctic5FcK9rgjQ6aWbykqwEw=="],
|
||||||
|
|
||||||
|
"@rc-component/virtual-list": ["@rc-component/virtual-list@1.6.0", "", { "dependencies": { "@babel/runtime": "^8.0.0", "@rc-component/resize-observer": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-aHPlTWVtfgDmzb4oJNrqBW1yeEdFeIS28EoJLQ/dGBgZPRlrQ84c2Rk/Il6JxDUV5P7FDtNFD0aXc7EB22kR3Q=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.16", "", { "os": "android", "cpu": "arm" }, "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.16", "", { "os": "android", "cpu": "arm64" }, "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.16", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.16", "", { "os": "linux", "cpu": "arm" }, "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.16", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.16", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.16", "", { "os": "linux", "cpu": "ppc64", "libc": "glibc" }, "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.16", "", { "os": "linux", "cpu": "s390x", "libc": "glibc" }, "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.16", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.16", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.16", "", { "os": "none", "cpu": "arm64" }, "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.16", "", { "os": "win32", "cpu": "x64" }, "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw=="],
|
||||||
|
|
||||||
|
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
|
||||||
|
|
||||||
|
"@tanstack/history": ["@tanstack/history@1.162.5", "", {}, "sha512-w1+YvwmTkwt5n8yUqEH3IWZ9DsmorCZW/EQ3KYHnqEgIkOZpH54NvW6/Epq3Dzb8H0ifIeYX8PExvrNLIH5FIA=="],
|
||||||
|
|
||||||
|
"@tanstack/query-core": ["@tanstack/query-core@5.105.0", "", {}, "sha512-7p56vRmT/z4UJ6sctGIdl5WRUQyUv53IUpUwEKi2g97zMbO7zh8ZsCLffGAvCPBR4xD72e3VXsDyLwXHhoNTMQ=="],
|
||||||
|
|
||||||
|
"@tanstack/react-query": ["@tanstack/react-query@5.105.0", "", { "dependencies": { "@tanstack/query-core": "5.105.0" }, "peerDependencies": { "@types/react": "^18 || ^19", "react": "^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-y+hMuDERvgg6ay1da0V1a6ZC0ZONt7dELxSFkqRZuwm7p1TTZs0MoXrPlBRflIpRoYjvzQVYxycnLImpFLhWrw=="],
|
||||||
|
|
||||||
|
"@tanstack/react-router": ["@tanstack/react-router@1.170.46", "", { "dependencies": { "@tanstack/history": "1.162.5", "@tanstack/react-store": "^0.11.2", "@tanstack/router-core": "1.171.39", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-ez1iNIhsPjGlaDHg3BXWs67EWVIYzB2tbopEHYDESdkO7owgkGb5de52+5Kv2v1fxdkggcNzo35bdk4arTVGQw=="],
|
||||||
|
|
||||||
|
"@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.167.2", "", { "dependencies": { "@tanstack/router-devtools-core": "1.168.2" }, "peerDependencies": { "@tanstack/react-router": "^1.170.36", "@tanstack/router-core": "^1.171.30", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-VuoqB7OhDUrBa4WOFSKiAQ8xI8sJpeZdo9sYE1hQa89iMuVFUQu1iWE4iCiQhB+6TYOZDZ9e+iHKxYjop4ZVYw=="],
|
||||||
|
|
||||||
|
"@tanstack/react-start": ["@tanstack/react-start@1.169.4", "", { "dependencies": { "@tanstack/react-router": "1.170.46", "@tanstack/react-start-client": "1.168.44", "@tanstack/react-start-rsc": "0.1.65", "@tanstack/react-start-server": "1.167.52", "@tanstack/router-utils": "1.162.4", "@tanstack/start-client-core": "1.170.39", "@tanstack/start-plugin-core": "1.171.55", "@tanstack/start-server-core": "1.169.45", "pathe": "^2.0.3" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "@vitejs/plugin-rsc": "*", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=8.0.0" }, "optionalPeers": ["@rsbuild/core", "@vitejs/plugin-rsc", "vite"] }, "sha512-+DmlNPeQ1rsvo025I79GrlNgDm+zVCVAGg9gMIGmAevK2TJRMpKahgEKZZ1j4VSxDxjfTpkfLyuXiUxxjMJCaw=="],
|
||||||
|
|
||||||
|
"@tanstack/react-start-client": ["@tanstack/react-start-client@1.168.44", "", { "dependencies": { "@tanstack/react-router": "1.170.46", "@tanstack/router-core": "1.171.39", "@tanstack/start-client-core": "1.170.39" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-WvrYhQ0EgwYpxnP9mFCCo+jctqXV3qq8GfIyiSGLiZzz4GDyw+GuQ2lyA3BTN64wbNNNVeMll+9PV5OlGYi2/w=="],
|
||||||
|
|
||||||
|
"@tanstack/react-start-rsc": ["@tanstack/react-start-rsc@0.1.65", "", { "dependencies": { "@tanstack/react-router": "1.170.46", "@tanstack/router-core": "1.171.39", "@tanstack/router-utils": "1.162.4", "@tanstack/start-client-core": "1.170.39", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-plugin-core": "1.171.55", "@tanstack/start-storage-context": "1.167.41", "pathe": "^2.0.3" }, "peerDependencies": { "@rspack/core": ">=2.0.0-0", "@vitejs/plugin-rsc": ">=0.5.30", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "react-server-dom-rspack": ">=0.0.2" }, "optionalPeers": ["@rspack/core", "@vitejs/plugin-rsc", "react-server-dom-rspack"] }, "sha512-5MmzmQoADjMA/d2XawkwKAkgBaQFrfifw/wn8qwCaLIntGYd2jkrCga/KdQW2xdjBu9IoP98WFhZilm7DfI/4g=="],
|
||||||
|
|
||||||
|
"@tanstack/react-start-server": ["@tanstack/react-start-server@1.167.52", "", { "dependencies": { "@tanstack/react-router": "1.170.46", "@tanstack/router-core": "1.171.39", "@tanstack/start-server-core": "1.169.45" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-131olF9oWx1CqZg8JYmIJbLNKqS/vN3UbYuc9a++pRtvfY67tpPmPzuYj4OXSxRAYalGW3f1yYlFozxqE1LE7w=="],
|
||||||
|
|
||||||
|
"@tanstack/react-store": ["@tanstack/react-store@0.11.2", "", { "dependencies": { "@tanstack/store": "0.11.2", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ=="],
|
||||||
|
|
||||||
|
"@tanstack/router-core": ["@tanstack/router-core@1.171.39", "", { "dependencies": { "@tanstack/history": "1.162.5", "cookie-es": "^3.0.0", "seroval": "^1.6.7", "seroval-plugins": "^1.6.7" } }, "sha512-qUhoJ0meuSUqGYQbHbcJYdASr06YtK+bulbj6vtrhKYFkLgkSsZ6tXmMyym7sFxiSJSPYgasgozrL01LyyR/6A=="],
|
||||||
|
|
||||||
|
"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.168.2", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16" }, "peerDependencies": { "@tanstack/router-core": "^1.171.30", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-pCCdQ5M/NUeWdiAbPmjyfcNdls6G4vaVlIQbPf1IqZk1FLYFr00aQvi578yUuWFBNM2KNS2QcwM1uPucbtGf5Q=="],
|
||||||
|
|
||||||
|
"@tanstack/router-generator": ["@tanstack/router-generator@1.167.46", "", { "dependencies": { "@babel/types": "^7.29.8", "@tanstack/router-core": "1.171.39", "@tanstack/router-utils": "1.162.4", "@tanstack/virtual-file-routes": "1.162.0", "jiti": "^2.7.0", "magic-string": "^0.30.21", "prettier": "^3.9.6", "zod": "^4.5.4" } }, "sha512-JSailAThcqPvqBzE3+MhNh6zQyZM2IdmIsHOxZgAc061Lh++SZqamNm/Whe7QNXWK5sLP4E0J1F8AI0hP8wTSw=="],
|
||||||
|
|
||||||
|
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.168.48", "", { "dependencies": { "@babel/core": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "@tanstack/router-core": "1.171.39", "@tanstack/router-generator": "1.167.46", "@tanstack/router-utils": "1.162.4", "chokidar": "^5.0.0", "unplugin": "^3.3.0", "zod": "^4.5.4" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2 || ^2.0.0", "@tanstack/react-router": "^1.170.46", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-oLDEZO/vhvF5JLrQ6qMNYcCjMMhlHA0Byx6ZXl9AHJ6qVLRxUeCU41vlZQ4buisONY2p0Zl9O09/qwLiVrIBCQ=="],
|
||||||
|
|
||||||
|
"@tanstack/router-utils": ["@tanstack/router-utils@1.162.4", "", { "dependencies": { "@babel/generator": "^7.29.8", "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "ansis": "^4.3.1", "babel-dead-code-elimination": "^1.0.12", "diff": "^9.0.0", "pathe": "^2.0.3", "tinyglobby": "^0.2.17" } }, "sha512-3TSH3rwAQTqQOtTBF8IXMFXmGNJWnjwIm+l87uguyC/of0pSRKcU9GJrozaf0fY9it8dLQvUZJA2MOlfqbW1sA=="],
|
||||||
|
|
||||||
|
"@tanstack/start-client-core": ["@tanstack/start-client-core@1.170.39", "", { "dependencies": { "@tanstack/router-core": "1.171.39", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-storage-context": "1.167.41", "seroval": "^1.6.7" } }, "sha512-G0VJbZFf9/dYomQjZkWapaTjkjEI4vbk1jnxhhVtQ3nTRb3sIRc196j+S+qZcySfEV5W9TJHu4KatjSzjZZQwQ=="],
|
||||||
|
|
||||||
|
"@tanstack/start-fn-stubs": ["@tanstack/start-fn-stubs@1.162.0", "", {}, "sha512-QWfUZ3Yo923tdQn38LyKMU8rcTw69zc+T4dAvgTWV4O56SqFRsGfS0lSWIMhJRwXIx/bvdi7nTUBDdZtTHtpTQ=="],
|
||||||
|
|
||||||
|
"@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.171.55", "", { "dependencies": { "@babel/code-frame": "7.29.7", "@babel/core": "^7.29.7", "@babel/types": "^7.29.8", "@jridgewell/remapping": "^2.3.5", "@tanstack/router-core": "1.171.39", "@tanstack/router-generator": "1.167.46", "@tanstack/router-plugin": "1.168.48", "@tanstack/router-utils": "1.162.4", "@tanstack/start-server-core": "1.169.45", "exsolve": "^1.1.1", "lightningcss": "^1.33.0", "pathe": "^2.0.3", "picomatch": "^4.0.7", "seroval": "^1.6.7", "source-map": "^0.7.6", "srvx": "^1.0.0", "tinyglobby": "^0.2.17", "ufo": "^1.6.4", "vitefu": "^1.1.3", "xmlbuilder2": "^4.0.3", "zod": "^4.5.4" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "vite": ">=8.0.0" }, "optionalPeers": ["@rsbuild/core", "vite"] }, "sha512-8FjTuuxIE8MNJoveXahZPBCfEpm8p9tMFyWTvbllfqlysdhry34rw/nCwULfcVwTG39ezXEu/RYE83NSV+miog=="],
|
||||||
|
|
||||||
|
"@tanstack/start-server-core": ["@tanstack/start-server-core@1.169.45", "", { "dependencies": { "@tanstack/history": "1.162.5", "@tanstack/router-core": "1.171.39", "@tanstack/start-client-core": "1.170.39", "@tanstack/start-storage-context": "1.167.41", "cookie-es": "^3.0.0", "fetchdts": "^0.2.0", "seroval": "^1.6.7" } }, "sha512-fKySLUGIy7aVYR6ZRgjC19TQdsZ+q/54M9hVfHrJQQVpM/T5qq5djZ3gsqhugmrWP+gyd9qpa9D6cxRtR6CJvg=="],
|
||||||
|
|
||||||
|
"@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.167.41", "", { "dependencies": { "@tanstack/router-core": "1.171.39" } }, "sha512-MY+Z4cZBPbH7hjORcROMX7K3kV1X25ArFKmRbks+EukPP9UVwCXbZFVzYkTt1u0AHthvQTcpa4J9B9i0o6fEnA=="],
|
||||||
|
|
||||||
|
"@tanstack/store": ["@tanstack/store@0.11.2", "", {}, "sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q=="],
|
||||||
|
|
||||||
|
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@26.6.6", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog=="],
|
||||||
|
|
||||||
|
"@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
|
||||||
|
|
||||||
|
"@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
|
||||||
|
|
||||||
|
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
|
||||||
|
|
||||||
|
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.152.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw=="],
|
||||||
|
|
||||||
|
"ansis": ["ansis@4.4.0", "", {}, "sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA=="],
|
||||||
|
|
||||||
|
"antd": ["antd@6.6.8", "", { "dependencies": { "@ant-design/colors": "^8.0.1", "@ant-design/cssinjs": "^2.1.2", "@ant-design/cssinjs-utils": "^2.1.2", "@ant-design/fast-color": "^3.0.1", "@ant-design/icons": "^6.3.4", "@ant-design/react-slick": "~2.0.0", "@babel/runtime": "^7.29.2", "@rc-component/cascader": "~1.22.0", "@rc-component/checkbox": "~2.0.0", "@rc-component/collapse": "~1.2.0", "@rc-component/color-picker": "~3.1.1", "@rc-component/dialog": "~1.10.0", "@rc-component/drawer": "~1.4.2", "@rc-component/dropdown": "~1.0.3", "@rc-component/form": "~1.8.6", "@rc-component/image": "~1.10.0", "@rc-component/input": "~1.3.1", "@rc-component/input-number": "~1.6.2", "@rc-component/listy": "~1.2.3", "@rc-component/mentions": "~1.12.0", "@rc-component/menu": "~1.5.0", "@rc-component/motion": "^1.3.3", "@rc-component/mutate-observer": "^2.0.1", "@rc-component/notification": "~2.1.1", "@rc-component/pagination": "~1.4.0", "@rc-component/picker": "~1.12.2", "@rc-component/progress": "~1.0.3", "@rc-component/qrcode": "~2.0.0", "@rc-component/rate": "~1.0.1", "@rc-component/resize-observer": "^1.1.2", "@rc-component/segmented": "~1.4.0", "@rc-component/select": "~1.10.1", "@rc-component/slider": "~1.1.1", "@rc-component/steps": "~1.2.3", "@rc-component/switch": "~1.0.3", "@rc-component/table": "~1.11.1", "@rc-component/tabs": "~1.13.0", "@rc-component/tooltip": "~1.5.2", "@rc-component/tour": "~2.4.0", "@rc-component/tree": "~1.4.0", "@rc-component/tree-select": "~1.16.1", "@rc-component/trigger": "^3.10.1", "@rc-component/upload": "~1.1.1", "@rc-component/util": "^1.14.0", "clsx": "^2.1.1", "dayjs": "^1.11.11", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-e+hDj/0myqbLBn8UGS0dYhPROAENruNscWe4gzyTnwx7yTip7D72wJUNxtsTplPXwRM1Mo4tXNSgaSb3UmV3RQ=="],
|
||||||
|
|
||||||
|
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
|
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||||
|
|
||||||
|
"aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
|
||||||
|
|
||||||
|
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
|
||||||
|
|
||||||
|
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.34", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA=="],
|
||||||
|
|
||||||
|
"browserslist": ["browserslist@4.29.5", "", { "dependencies": { "baseline-browser-mapping": "^2.11.33", "caniuse-lite": "^1.0.30001819", "electron-to-chromium": "^1.5.464", "node-releases": "^2.0.60", "update-browserslist-db": "^1.3.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ=="],
|
||||||
|
|
||||||
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.4.4", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="],
|
||||||
|
|
||||||
|
"caniuse-lite": ["caniuse-lite@1.0.30001820", "", {}, "sha512-gfWD6MxqVCqAOwmODfZf7DchWvlrA+SRdNX7ceLfg3xRqe5ssjjpF7EgslC/0tiLiNTajdaNN/q8c/5u/7Gz8w=="],
|
||||||
|
|
||||||
|
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||||
|
|
||||||
|
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||||
|
|
||||||
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
|
"compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="],
|
||||||
|
|
||||||
|
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||||
|
|
||||||
|
"cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="],
|
||||||
|
|
||||||
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
|
"dayjs": ["dayjs@1.11.23", "", {}, "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ=="],
|
||||||
|
|
||||||
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
|
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||||
|
|
||||||
|
"diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
||||||
|
|
||||||
|
"drizzle-kit": ["drizzle-kit@0.31.11", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg=="],
|
||||||
|
|
||||||
|
"drizzle-orm": ["drizzle-orm@0.45.4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@netlify/db": ">=0.4.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@netlify/db", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-NiM1YZpJ2UxyeE67DpDDZ+Tsw4YLX3trSlkNT7Tm9eKoUNb1LvaAaQzXgYQsWs+AQ9TUmmwMmBOCXuqqxUWKKQ=="],
|
||||||
|
|
||||||
|
"electron-to-chromium": ["electron-to-chromium@1.5.467", "", {}, "sha512-Nw9X371rtZmYcBSnEijnCDONILjs93biCZO34juC7MBOmK8E+GFnyD4T/WvyrlJkppul7oxg6TwvtKst9SODTw=="],
|
||||||
|
|
||||||
|
"enhanced-resolve": ["enhanced-resolve@5.26.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw=="],
|
||||||
|
|
||||||
|
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||||
|
|
||||||
|
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
|
"exsolve": ["exsolve@1.2.0", "", {}, "sha512-h1pQPR9XoeSMdntRNCjyTznaume4UpfXrULBnlkZ2CUM/G5eSa+pBByGldO5uR8dxbPC+OuJXsPPNf+JZ81ADg=="],
|
||||||
|
|
||||||
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"fetchdts": ["fetchdts@0.2.0", "", {}, "sha512-PJb4ZTGT2/wK78RmVrxZITStWabhZlKBsL52VOgq+c7clEkdHdEB+0rtzrXCbZJqeYJj+IwNWYqB5/G6vouA6A=="],
|
||||||
|
|
||||||
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin", "hasInstallScript": true }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="],
|
||||||
|
|
||||||
|
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||||
|
|
||||||
|
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||||
|
|
||||||
|
"get-tsconfig": ["get-tsconfig@4.14.3", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA=="],
|
||||||
|
|
||||||
|
"globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="],
|
||||||
|
|
||||||
|
"goober": ["goober@2.1.19", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg=="],
|
||||||
|
|
||||||
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
|
"iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="],
|
||||||
|
|
||||||
|
"is-mobile": ["is-mobile@5.0.0", "", {}, "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ=="],
|
||||||
|
|
||||||
|
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
|
||||||
|
|
||||||
|
"isbot": ["isbot@5.2.2", "", {}, "sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w=="],
|
||||||
|
|
||||||
|
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||||
|
|
||||||
|
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||||
|
|
||||||
|
"js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="],
|
||||||
|
|
||||||
|
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||||
|
|
||||||
|
"json2mq": ["json2mq@0.2.0", "", { "dependencies": { "string-convert": "^0.2.0" } }, "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA=="],
|
||||||
|
|
||||||
|
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||||
|
|
||||||
|
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
|
||||||
|
|
||||||
|
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
|
||||||
|
|
||||||
|
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
|
||||||
|
|
||||||
|
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||||
|
|
||||||
|
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||||
|
|
||||||
|
"lru.min": ["lru.min@1.1.5", "", {}, "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA=="],
|
||||||
|
|
||||||
|
"lucide-react": ["lucide-react@1.64.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-v626F/bcLNvFom6vzztHILtG9tUgXXbgA5AGkd82YkV401+Ko/NHyrWafbPwGofa+6p/EgDJcvEkIwsMH9098w=="],
|
||||||
|
|
||||||
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"mysql2": ["mysql2@3.24.6", "", { "dependencies": { "aws-ssl-profiles": "^1.1.2", "generate-function": "^2.3.1", "iconv-lite": "^0.7.3", "long": "^5.3.2", "lru.min": "^1.1.4", "named-placeholders": "^1.1.6", "sql-escaper": "^1.5.1" }, "peerDependencies": { "@types/node": ">= 8" } }, "sha512-enu73KhbAua2L3dIxjc6qg6ZJ7H3pt1BImZASWNWY5xSsmJ1ZuUPULTDGXgmJbI61vb+nJAG19IZclIq19UidQ=="],
|
||||||
|
|
||||||
|
"named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="],
|
||||||
|
|
||||||
|
"nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
|
||||||
|
|
||||||
|
"node-releases": ["node-releases@2.0.61", "", {}, "sha512-5NUN6QCxNFrS9oEA9RPS+d0AP+ooGBzzovY2JhIpDRHacPvOhHjXjUsUMX9qp4nLHZ+5jVgXB2jj/P4arGOE/g=="],
|
||||||
|
|
||||||
|
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||||
|
|
||||||
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
||||||
|
|
||||||
|
"postcss": ["postcss@8.5.32", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g=="],
|
||||||
|
|
||||||
|
"prettier": ["prettier@3.9.10", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA=="],
|
||||||
|
|
||||||
|
"react": ["react@19.3.1", "", {}, "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw=="],
|
||||||
|
|
||||||
|
"react-dom": ["react-dom@19.3.1", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.1" } }, "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ=="],
|
||||||
|
|
||||||
|
"react-is": ["react-is@19.3.1", "", {}, "sha512-RRieF6IyxQBkcK6pP8xOHprLTPW6QjRkbGdImtqNme+/hYVwCZkLQLdlZGOhUqXwkS9IEynaGvPJtv6XB/cz0Q=="],
|
||||||
|
|
||||||
|
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||||
|
|
||||||
|
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||||
|
|
||||||
|
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||||
|
|
||||||
|
"readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="],
|
||||||
|
|
||||||
|
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||||
|
|
||||||
|
"rolldown": ["rolldown@1.2.16", "", { "dependencies": { "@oxc-project/types": "=0.156.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.16", "@rolldown/binding-android-arm64": "1.2.16", "@rolldown/binding-darwin-arm64": "1.2.16", "@rolldown/binding-darwin-x64": "1.2.16", "@rolldown/binding-freebsd-x64": "1.2.16", "@rolldown/binding-linux-arm-gnueabihf": "1.2.16", "@rolldown/binding-linux-arm64-gnu": "1.2.16", "@rolldown/binding-linux-arm64-musl": "1.2.16", "@rolldown/binding-linux-ppc64-gnu": "1.2.16", "@rolldown/binding-linux-s390x-gnu": "1.2.16", "@rolldown/binding-linux-x64-gnu": "1.2.16", "@rolldown/binding-linux-x64-musl": "1.2.16", "@rolldown/binding-openharmony-arm64": "1.2.16", "@rolldown/binding-win32-arm64-msvc": "1.2.16", "@rolldown/binding-win32-x64-msvc": "1.2.16" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw=="],
|
||||||
|
|
||||||
|
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||||
|
|
||||||
|
"scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
|
||||||
|
|
||||||
|
"scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="],
|
||||||
|
|
||||||
|
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
|
"seroval": ["seroval@1.7.5", "", {}, "sha512-UdNgpHdq0gVpme5JdQF+S3jNscnjFr4NTwUWOc1qcOa8OuWZmbV0+YX2hsqxkUZshBpHnpxDZfAKVZG2ugbrJA=="],
|
||||||
|
|
||||||
|
"seroval-plugins": ["seroval-plugins@1.7.6", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-/TPCAd6q5stAZnugD2fsvNKtM2UpgwrS8/fO1cXTgPC0EqCsGB+UfkEEsToFCfg35CDTMlWkppfr/zWnLJGpBA=="],
|
||||||
|
|
||||||
|
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||||
|
|
||||||
|
"source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
|
||||||
|
|
||||||
|
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||||
|
|
||||||
|
"sql-escaper": ["sql-escaper@1.5.2", "", {}, "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg=="],
|
||||||
|
|
||||||
|
"srvx": ["srvx@1.0.8", "", { "bin": { "srvx": "./bin/srvx.mjs" } }, "sha512-ngFS/J6St4PJfHRHGjr78xkPPtbyXtdMOLNE4x/XZunEK3OhV7aGUWMHf9WTUEy6wm1U0W4okkd82ZFFWRVQTw=="],
|
||||||
|
|
||||||
|
"string-convert": ["string-convert@0.2.1", "", {}, "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A=="],
|
||||||
|
|
||||||
|
"stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="],
|
||||||
|
|
||||||
|
"tailwind-merge": ["tailwind-merge@3.7.0", "", {}, "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA=="],
|
||||||
|
|
||||||
|
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
|
||||||
|
|
||||||
|
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||||
|
|
||||||
|
"throttle-debounce": ["throttle-debounce@5.0.2", "", {}, "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A=="],
|
||||||
|
|
||||||
|
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||||
|
|
||||||
|
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
|
||||||
|
|
||||||
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"tsx": ["tsx@4.23.15", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw=="],
|
||||||
|
|
||||||
|
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
|
||||||
|
|
||||||
|
"ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||||
|
|
||||||
|
"unplugin": ["unplugin@3.7.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@rolldown/pluginutils": "^1.0.1", "picomatch": "^4.0.7", "webpack-virtual-modules": "^0.6.2" }, "peerDependencies": { "@farmfe/core": "*", "@rsbuild/core": "*", "@rspack/core": "*", "bun-types-no-globals": "*", "esbuild": "*", "rolldown": "*", "rollup": "*", "unloader": "*", "vite": "*", "webpack": "*" }, "optionalPeers": ["@farmfe/core", "@rsbuild/core", "@rspack/core", "bun-types-no-globals", "esbuild", "rolldown", "rollup", "unloader", "vite", "webpack"] }, "sha512-GrM2uq3DQo3hvLU2FjeYhooJ8BY9N6j1Fbzgx7Pta3VVJevsD3VP4dPjZkZOS0mLvN8w9zzr9OlP+31bSMyuLw=="],
|
||||||
|
|
||||||
|
"update-browserslist-db": ["update-browserslist-db@1.3.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A=="],
|
||||||
|
|
||||||
|
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
||||||
|
|
||||||
|
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||||
|
|
||||||
|
"use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="],
|
||||||
|
|
||||||
|
"vite": ["vite@8.3.5", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.30", "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ=="],
|
||||||
|
|
||||||
|
"vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="],
|
||||||
|
|
||||||
|
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
|
||||||
|
|
||||||
|
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||||
|
|
||||||
|
"xmlbuilder2": ["xmlbuilder2@4.0.3", "", { "dependencies": { "@oozcitak/dom": "^2.0.2", "@oozcitak/infra": "^2.0.2", "@oozcitak/util": "^10.0.0", "js-yaml": "^4.1.1" } }, "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA=="],
|
||||||
|
|
||||||
|
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
|
|
||||||
|
"zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||||
|
|
||||||
|
"@rc-component/segmented/@babel/runtime": ["@babel/runtime@8.0.10", "", {}, "sha512-0QyWQOIhl2onOgg96BuOYV+oCgLINVDPC8RkVPCiQhLILVqpefDUmcY+aAsW6vp7fcuTZL6SXXlFrSDCTFIu1w=="],
|
||||||
|
|
||||||
|
"@rc-component/virtual-list/@babel/runtime": ["@babel/runtime@8.0.10", "", {}, "sha512-0QyWQOIhl2onOgg96BuOYV+oCgLINVDPC8RkVPCiQhLILVqpefDUmcY+aAsW6vp7fcuTZL6SXXlFrSDCTFIu1w=="],
|
||||||
|
|
||||||
|
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || 2.0.0-beta.2", "@emnapi/runtime": "^1.7.1 || 2.0.0-beta.2" }, "bundled": true }, "sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||||
|
|
||||||
|
"tsx/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||||
|
|
||||||
|
"@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||||
|
|
||||||
|
"@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
|
||||||
|
|
||||||
|
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# DusunIoT WordPress 重构开发方案 V2
|
||||||
|
|
||||||
|
> 状态:开发规划。基于 `dusuniot-website-stability-and-multilingual-brief.md` 和重构决策编写。
|
||||||
|
> 核心原则:前台 1:1 还原,后台功能等价实现,底层用 Bun 单体替换 WordPress。
|
||||||
|
|
||||||
|
## 1. 目标与边界
|
||||||
|
|
||||||
|
- 将现有 WordPress + Elementor + WooCommerce + GTranslate 迁移到 **TanStack Start + Bun + PostgreSQL**。
|
||||||
|
- **前台必须 1:1 还原**现有网站的视觉、响应式布局、内容、导航、产品筛选和实际交互;不要求复刻 WordPress/Elementor 的内部 DOM 或代码。以采集时的线上页面与截图为视觉基准。
|
||||||
|
- **后台使用 React + Ant Design**,还原现有实际使用的业务功能,不要求与 WordPress 后台界面一致;不开发插件系统、Elementor 克隆或 WordPress 兼容运行环境。
|
||||||
|
- **后台图标强制规范:只能使用 SVG 图标**(优先 Ant Design Icons SVG 或 Lucide React)。**禁止 Emoji、Unicode 特殊符号/特殊字体、Icon Font、字体图标、文字字符冒充图标**。菜单、按钮、提示、状态、空页面、表格操作、Dashboard 图标全部适用。图标要有可访问名称,装饰性图标 aria-hidden。
|
||||||
|
- 单体项目同时提供 SSR、服务端 API / Server Functions、后台与定时任务;不引入 Go、PHP、微服务;PostgreSQL 为主数据库。
|
||||||
|
- 保留 Cloudflare,按实际部署情况使用 Nginx/CDN 缓存;文件存储可用本地或 S3/R2。
|
||||||
|
- 不以“更换技术栈”替代故障归因;现有 WordPress CPU 高负载、数据库权限撤销、筛选器和 GTranslate 回源等问题,仍需采集基线和证据。
|
||||||
|
|
||||||
|
## 2. 技术栈
|
||||||
|
|
||||||
|
| 领域 | 选型 |
|
||||||
|
| --- | --- |
|
||||||
|
| 前台 SSR / 路由 | TanStack Start + React + TypeScript |
|
||||||
|
| 运行时和包管理 | Bun |
|
||||||
|
| 业务 API | TanStack Start Server Functions / 服务端路由(对外接口) |
|
||||||
|
| 数据库 | PostgreSQL |
|
||||||
|
| SQL / 迁移 | Drizzle ORM + SQL 索引、事务 |
|
||||||
|
| 后台 UI | React + Ant Design |
|
||||||
|
| 前台样式 | 以原站 CSS 和视觉结果为基准,按需 Tailwind CSS |
|
||||||
|
| 缓存 | Cloudflare + Nginx + 服务端缓存,按用户态区分 |
|
||||||
|
| 图片与文档 | 本地对象目录或 S3/R2 |
|
||||||
|
| 测试 | Playwright 截图对比 + E2E;Vitest 单元/集成测试 |
|
||||||
|
| 图标 | SVG only(Ant Design Icons / Lucide) |
|
||||||
|
|
||||||
|
生产环境要先验证 TanStack Start 与所选 Bun/Nitro 构建、SSR、Server Functions、流式响应及部署版本的兼容性,锁定经过测试的依赖版本。
|
||||||
|
|
||||||
|
## 3. 功能还原范围
|
||||||
|
|
||||||
|
### 前台:必须达到 1:1
|
||||||
|
|
||||||
|
- 页面类型:首页、产品列表和详情、产品分类/筛选、文章/新闻、Landing Page、公司/联系页面、搜索和现有其他公开模板。
|
||||||
|
- 还原 Header、Footer、导航及菜单、Banner、卡片、字体、颜色、边距、栅格、图片比例、动画、交互、移动端和平板布局。
|
||||||
|
- 保留询盘表单的字段、验证、提交流程、通知;保留客户注册/登录、购物车、结账、订单查询及原站实际启用的支付/物流/税费规则。
|
||||||
|
- 采集原站各页面样板和三类屏幕尺寸;通过 Playwright 截图差异及人工审核验收,不能仅以 DOM 或代码相同为标准。
|
||||||
|
- 仅迁移实际使用的功能,不复制与业务无关的 WordPress 插件能力。
|
||||||
|
|
||||||
|
### 后台:功能等价,界面重新设计
|
||||||
|
|
||||||
|
使用 Ant Design 建立以下菜单(按审计结果增减):
|
||||||
|
|
||||||
|
| 模块 | 核心能力 |
|
||||||
|
| --- | --- |
|
||||||
|
| Dashboard | 业务概览、站点运行统计 |
|
||||||
|
| Pages / Landing Pages | 内容、模板区块、发布/预览 |
|
||||||
|
| Posts | 文章、分类、标签、状态 |
|
||||||
|
| Products | 产品、分类、属性、规格、筛选配置、库存(如原站使用) |
|
||||||
|
| Orders | 订单、状态、支付/发货/退款(按实际业务) |
|
||||||
|
| Customers | 注册用户、资料、权限 |
|
||||||
|
| Inquiries | 询盘记录、状态、通知 |
|
||||||
|
| Media | 图片/附件上传、管理、引用 |
|
||||||
|
| Languages | 语言版本、翻译、审核与发布 |
|
||||||
|
| SEO | SEO Metadata、Sitemap、重定向 |
|
||||||
|
| Settings | 导航、基本配置、邮件、支付(按实际启用) |
|
||||||
|
|
||||||
|
**禁止在后台使用 Emoji、Icon Font、特殊字体或字符图标**;使用 SVG 图标组件库并统一尺寸、线宽和主题色。按钮图标也要兼容键盘和屏幕阅读器。
|
||||||
|
|
||||||
|
页面编辑推荐“固定模板 + 可编辑内容区块”;不实现通用拖拽建站器。复杂模板只为现有业务页面制作 React 组件。
|
||||||
|
|
||||||
|
## 4. 数据与后端模块
|
||||||
|
|
||||||
|
建议按业务域组织:`auth`、`content`、`products`、`filters`、`orders`、`customers`、`inquiries`、`media`、`translations`、`seo`、`jobs`。
|
||||||
|
|
||||||
|
核心表按实际数据进一步细化:
|
||||||
|
|
||||||
|
- 内容:`contents`、`content_translations`、`content_revisions`、`categories`、`tags`、`menus`
|
||||||
|
- 产品:`products`、`product_translations`、`product_attributes`、`product_attribute_values`、`product_categories`、`product_variants`(如存在)
|
||||||
|
- 商城:`users`、`carts`、`orders`、`order_items`、`payments`(按现有功能)
|
||||||
|
- 运营:`inquiries`、`media`、`redirects`、`jobs`、`audit_logs`、`legacy_id_mappings`
|
||||||
|
|
||||||
|
关键约束:
|
||||||
|
- 多语言为真实可发布的数据版本,每种语言有独立 URL、标题、正文、SEO 字段、CTA;缺翻译时不生成伪语言页面。
|
||||||
|
- 筛选使用合理的关系型结构、索引、稳定分页和服务端参数校验;先用真实数据和 `EXPLAIN (ANALYZE, BUFFERS)` 验证,不默认加搜索引擎。
|
||||||
|
- 订单创建、库存扣减、付款回调使用数据库事务、幂等键、唯一约束;第三方支付回调验证签名。
|
||||||
|
- 认证使用安全的 HttpOnly、Secure Cookie 和服务端会话校验,后台所有接口执行权限检查;敏感操作审计。
|
||||||
|
- 后台任务实现持久化队列、失败重试与可观测性;不能依赖 Web 请求触发后台任务。
|
||||||
|
- Public SSR 响应可按 URL/语言缓存;登录、购物车、结账、账户、个性化内容禁止共享页面缓存;发布后精准失效。
|
||||||
|
- 前端输入、富文本、文件上传要防 XSS/CSRF/恶意上传及滥用;密钥通过环境变量管理。
|
||||||
|
|
||||||
|
## 5. 多语言与 SEO 迁移(硬性验收项)
|
||||||
|
|
||||||
|
- 导出全部现有 WordPress 和 GTranslate 语言 URL,结合 Search Console/Analytics/询盘数据标记高价值页面;不直接下线小语种。
|
||||||
|
- 尽量保持现有被索引 URL;确需变化时逐条建立 301 映射,校验跳转链;维护 `canonical`、`hreflang`、多语言 Sitemap、robots 与结构化数据。
|
||||||
|
- 翻译支持草稿、人工审核、发布和原文更新提醒;语言的导航、CTA、表单字段等均可管理。
|
||||||
|
- 先选重点语言及代表页面试点,再逐步扩展;商城多语言不默认全量复制。
|
||||||
|
- 迁移前后比较:HTTP 状态码、标题/描述、页面正文、内部链接、结构化数据、索引覆盖、自然搜索流量、询盘转化。
|
||||||
|
|
||||||
|
## 6. 建议目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
routes/
|
||||||
|
index.tsx
|
||||||
|
products/
|
||||||
|
blog/
|
||||||
|
account/
|
||||||
|
admin/
|
||||||
|
components/
|
||||||
|
website/
|
||||||
|
admin/
|
||||||
|
icons/
|
||||||
|
server/
|
||||||
|
auth/
|
||||||
|
content/
|
||||||
|
products/
|
||||||
|
filters/
|
||||||
|
orders/
|
||||||
|
inquiries/
|
||||||
|
translations/
|
||||||
|
seo/
|
||||||
|
jobs/
|
||||||
|
db/
|
||||||
|
schema/
|
||||||
|
migrations/
|
||||||
|
styles/
|
||||||
|
scripts/
|
||||||
|
wordpress-import/
|
||||||
|
site-capture/
|
||||||
|
tests/
|
||||||
|
visual/
|
||||||
|
e2e/
|
||||||
|
integration/
|
||||||
|
public/
|
||||||
|
```
|
||||||
|
|
||||||
|
管理端图标组件集中维护于 `src/components/icons/`,通过 ESLint/代码审查阻止新增 Emoji、图标字体及字符冒充图标。
|
||||||
|
|
||||||
|
## 7. 实施步骤
|
||||||
|
|
||||||
|
1. **审计与基线**:盘点页面模板、WordPress 插件实际启用业务功能、WooCommerce 支付/运输/税费流程、GTranslate 语言 URL;采集桌面/移动端截图与可复现交互;统计源站 CPU、数据库慢查询、错误日志与高频请求,不能把相关性当根因。
|
||||||
|
2. **建模与导入**:完成 PostgreSQL 结构和迁移;编写可重复执行的 WordPress/WooCommerce 导入脚本;维护旧 ID、资源 URL 与链接映射;校验数量、关联和资源完整性。
|
||||||
|
3. **前台 1:1 还原**:先还原公共组件与代表模板,再批量覆盖产品/文章/页面;逐项校验截图、响应式及交互。
|
||||||
|
4. **后台业务实现**:用 React + Ant Design 实现实际使用的 CMS/商品/订单/询盘/语言/SEO 功能;不做插件机制和 Elementor 克隆。
|
||||||
|
5. **SEO、多语言、商城验收**:重点语言试点,验证 hreflang/301/Canonical/Sitemap;测试注册、筛选、询盘、购物车、下单、支付与订单管理。
|
||||||
|
6. **灰度上线**:测试环境压测和安全测试;Cloudflare/Nginx 小比例导流;保留旧站只读备份与可回滚部署,处理迁移期间新增订单和内容的增量同步;切换后持续监控。
|
||||||
|
|
||||||
|
## 8. 验收标准
|
||||||
|
|
||||||
|
- 前台:原站页面模板与典型实际页面在约定视口下视觉、交互、内容基本一致;明显差异逐项修复,所有关键功能 E2E 通过。
|
||||||
|
- 后台:原站实际启用的业务功能逐项通过检查;所有图标均为 SVG,**无 Emoji、Icon Font、特殊字体或字符图标**。
|
||||||
|
- SEO:重要旧链接保持可用或正确 301;多语言 URL、canonical、hreflang、Sitemap、SEO 元数据正确;重点语言测试页面可以被正常抓取。
|
||||||
|
- 交易:注册、询盘、购物车、结账、支付回调、订单状态端到端通过,订单数据与权限受保护。
|
||||||
|
- 性能:以原站基线为比较对象,记录 p50/p95 TTFB、慢查询、CPU/内存、错误率、缓存命中率;达到试点确定的目标后才推广。
|
||||||
|
- 运维:有备份恢复演练、日志、监控、迁移校验报告和回滚流程。
|
||||||
|
|
||||||
|
## 9. 不做的事情
|
||||||
|
|
||||||
|
- 不引入 Go、微服务、WordPress/PHP 运行环境或 WordPress 插件兼容层。
|
||||||
|
- 不复刻 WordPress 后台外观或 Elementor 通用可视化编辑器。
|
||||||
|
- 不凭推断删除原站重要功能、语言页面、已有 URL。
|
||||||
|
- 不在未明确数据一致性与回滚方案前一次性全站切换。
|
||||||
|
|
||||||
|
## 10. 开发执行优先级
|
||||||
|
|
||||||
|
**P0**:原站模板/业务审计、完整页面采集、数据模型、迁移验证、关键页面 1:1 还原。
|
||||||
|
|
||||||
|
**P1**:产品筛选、询盘、后台 CRUD、多语言 SEO、缓存与性能测试。
|
||||||
|
|
||||||
|
**P2**:WooCommerce 实际启用业务的交易迁移、全面语言覆盖、灰度切换与长期监控。
|
||||||
|
|
||||||
|
> 本文是目标架构与实施约束;现存 WordPress 的故障根因尚未被证明,不能将新框架本身视为修复证据。
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# DusunIoT 网站稳定性与多语言改造项目 Brief
|
||||||
|
|
||||||
|
**网站:** `dusuniot.com`
|
||||||
|
**用途:** 供外部网站/服务器专家评估、报价和执行方案讨论
|
||||||
|
**日期:** 2026-10-28
|
||||||
|
|
||||||
|
## 1. 项目目标
|
||||||
|
|
||||||
|
我们希望系统性解决网站反复宕机、服务器 CPU 长期偏高、WordPress 数据库连接/权限反复异常的问题,使现有英文网站、商城和询盘流程稳定运行。同时,重新设计小语种内容的发布方式,让有价值的多语言页面能够继续增长,而不会一扩充页面就使服务器负载明显恶化。
|
||||||
|
|
||||||
|
本项目的目标是找到有证据支持的负载来源并完成治理,而不是仅升级主机套餐、停用插件或直接重建网站。若认为现有 WordPress/PHP 架构必须替换,请提供性能证据、迁移范围、成本、风险和与优化现有架构的比较。
|
||||||
|
|
||||||
|
## 2. 当前架构与业务功能
|
||||||
|
|
||||||
|
- 网站运行在 Hostinger Cloud Enterprise Hosting,使用 WordPress、Elementor 和 WooCommerce。
|
||||||
|
- 域名通过 Cloudflare 代理;Hostinger 自带 CDN 未启用。需分别评估 Cloudflare、源站和 WordPress/LiteSpeed 缓存的作用。
|
||||||
|
- 网站包含产品内容、landing pages、联系表单、产品筛选,以及客户注册和商城下单流程。
|
||||||
|
- 当前使用 GTranslate 提供多语言页面。小语种页面过去为网站带来过重要自然搜索流量,因此不能在迁移时简单下线。
|
||||||
|
|
||||||
|
请在正式诊断时核实以上配置和当前启用状态,不要只依据历史插件清单。
|
||||||
|
|
||||||
|
## 3. 已观察到的问题与线索
|
||||||
|
|
||||||
|
1. **反复故障。** 网站曾出现 `dusuniot.com` 跳转到 `/wp-admin/install.php`、WordPress 数据库读取失败等现象。Hostinger 检查曾显示数据库用户权限被其自动保护机制临时撤销;权限恢复后也可能再次被限制。需要取得保护事件和触发条件的原始证据。
|
||||||
|
2. **持续高负载。** 过去曾观察到 CPU 长时间处于高位;近期 Hostinger 报告 2026-10-15 至 2026-10-22 期间多个采样点接近 100%。普通访客流量不能代表服务器收到的全部爬虫、自动化和 API 请求。
|
||||||
|
3. **高频请求。** Hostinger 曾报告大量 `/wp-admin/install.php`、Contact Form 7 REST 接口和 `admin-ajax.php` 请求;Cloudflare 分析也显示大量请求仍由源站提供服务。高频路径与高负载在时间上重叠,但现有资料不足以证明某个插件或请求是最初根因。安装页请求也可能是数据库故障后的结果。
|
||||||
|
4. **筛选器线索。** 同事近期停用了 landing page 上的筛选功能,随后观察到至少约 24 小时 CPU 比较稳定。请用停用前后的同口径 CPU、数据库负载、请求量、缓存命中和错误记录验证这一关联,并排除同时发生的其他变化。产品筛选项目较多,可能存在复杂查询。
|
||||||
|
5. **商城与后台任务线索。** 客户可注册并下单。请检查 WooCommerce 相关查询、产品筛选、Action Scheduler、WP-Cron、同步和重试任务是否长期占用资源。
|
||||||
|
6. **多语言线索。** 同事观察到 GTranslate 可能反复回源获取页面。请核查其请求频率、语言 URL、缓存命中、页面生成成本及爬虫行为;不能仅凭插件名称认定其直接查询数据库或造成故障。
|
||||||
|
|
||||||
|
**现有证据边界:** 目前没有足以完成插件级归因的 MySQL 慢查询、PHP 慢请求/调用栈和精确的数据库权限撤销时间线。错误栈中出现某插件,只能说明其在权限已受限时尝试访问数据库,不能证明它触发了限制。
|
||||||
|
|
||||||
|
## 4. 工作范围与交付要求
|
||||||
|
|
||||||
|
### A. 故障诊断与短期止损
|
||||||
|
|
||||||
|
- 建立故障时间线:首次异常、CPU/数据库负载变化、数据库保护事件、权限恢复与再次撤销、站点错误和恢复时间。
|
||||||
|
- 关联 Cloudflare 请求、Hostinger 源站访问日志、CPU/PHP/MySQL 指标;区分经过 Cloudflare 的请求和直接访问源站的请求。
|
||||||
|
- 在可行且受控的条件下,取得 MySQL 慢查询、实时数据库进程、PHP 慢请求、Web 服务器错误日志,以及相关 WordPress hook/Action Scheduler 记录。记录查询/请求的次数、累计耗时、时间戳和调用入口。
|
||||||
|
- 区分最初造成高负载的活动与故障发生后产生的重定向、重试或扫描流量。
|
||||||
|
- 对确认有害的流量或任务提出范围明确、可回滚的止损方案,并报告实施前后指标。不要无差别屏蔽 Contact Form 7 接口,以免影响询盘。
|
||||||
|
- 说明 Hostinger 数据库保护机制的触发条件、撤权时间和恢复条件;必要时由主机/数据库工程师参与。
|
||||||
|
|
||||||
|
### B. WordPress 后台与性能治理
|
||||||
|
|
||||||
|
- 盘点现有插件、主题、自定义代码及其业务用途,重点检查筛选器、WooCommerce、表单、翻译、缓存、搜索、统计和 SEO 功能。
|
||||||
|
- 对产品筛选的大量选项及其查询方式进行分析,提出保留关键筛选体验的优化方案。
|
||||||
|
- 检查注册、购物车、结账、支付、邮件、产品同步等路径和后台任务;识别积压、重试或重复执行。
|
||||||
|
- 核查页面缓存、对象缓存、数据库索引、慢查询及多语言页面的缓存策略。明确哪些页面必须动态生成,哪些可以缓存。
|
||||||
|
- 给出保留、合并、替换或移除插件的建议,并说明每项建议的性能收益、功能影响和回滚方法。
|
||||||
|
|
||||||
|
### C. 多语言页面方案
|
||||||
|
|
||||||
|
比较至少以下路线,并给出推荐方案与试点范围:
|
||||||
|
|
||||||
|
1. 保留 GTranslate,但优化回源、缓存和抓取控制。
|
||||||
|
2. 将选定语言的重点页面制作成本站 WordPress 中可编辑、可管理的真实页面,使用独立语言 URL;评估 WPML、Polylang 或适当的定制方案。
|
||||||
|
3. 对特定语言使用独立站点/多站点架构,仅在有明确必要时采用。
|
||||||
|
|
||||||
|
我们希望重点语言页面的标题、正文、SEO 元数据、CTA 和表单内容由自己控制。请评估翻译内容如何生成、审核、更新和保存,以及与 Elementor、WooCommerce、筛选器和现有表单的兼容性。不要默认把所有语言、所有产品和全部商城功能一次性复制到同一数据库。
|
||||||
|
|
||||||
|
多语言方案必须包含现有语言 URL 清单、自然搜索流量/询盘价值评估、保留或迁移策略、301 重定向、canonical、`hreflang`、站点地图和重要页面的验证计划。先以少量重点语言及代表性页面做试点,经性能和 SEO 检查后再扩展。
|
||||||
|
|
||||||
|
## 5. 实施原则
|
||||||
|
|
||||||
|
- 先收集证据并提出方案,再在测试环境验证。生产环境的插件停用、数据库修改、缓存规则、DNS/Cloudflare 规则和内容迁移应有明确变更记录与回滚方案。
|
||||||
|
- 在故障原因未查明前,不重新安装 WordPress、不直接恢复旧备份、不清空任务队列、不盲目优化数据库表。
|
||||||
|
- 不因 PHP 安全漏洞而将“放弃 PHP”作为默认解决方案。PHP 安全补丁属于主机运行环境的维护;WordPress 版本更新不能替代 PHP 补丁。架构迁移应单独论证。
|
||||||
|
- 对商城下单、客户注册、产品筛选和询盘表单进行端到端验证;性能改进不能以破坏关键业务流程为代价。
|
||||||
|
|
||||||
|
## 6. 预期交付物与验收
|
||||||
|
|
||||||
|
请提供:
|
||||||
|
|
||||||
|
1. **诊断报告:** 故障时间线、证据来源、确定原因与尚未证实的假设、关键慢查询/慢请求及关联插件或任务。
|
||||||
|
2. **整改计划:** 按紧急程度排序的措施、预计影响、实施步骤、测试方案、回滚方案和负责人。
|
||||||
|
3. **多语言方案:** 路线比较、试点语言和页面、内容与 SEO 迁移清单、性能容量评估、维护流程及报价。
|
||||||
|
4. **验证报告:** 整改前后 CPU、数据库负载/权限状态、请求耗时、错误率与缓存命中情况;连续观察期内无同类宕机;英文及试点语言页面、注册、筛选、下单和表单测试结果。
|
||||||
|
|
||||||
|
请在报价中区分**诊断与紧急止损、后台性能整改、多语言试点、全面迁移**四部分,并标明需要我们或 Hostinger/Cloudflare 提供的权限和日志。
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Dusun IoT 复刻 · 页面类型与版块清单
|
||||||
|
|
||||||
|
> 目标:以「页面类型 → 版块(自上而下)」为单位 1:1 复刻 dusuniot.com,一个版块一个版块做,
|
||||||
|
> 不一次做整页。每版块验证后交付一次版本号 +patch。
|
||||||
|
>
|
||||||
|
> 状态图例:
|
||||||
|
> ✅ 已按原站逐像素对齐(有截图对照) | 🟡 有基础渲染但未对齐原站 | ⬜ 未做 | ⚪ 纯文本页,优先级低
|
||||||
|
|
||||||
|
## 一、页面类型清单(共 10 类)
|
||||||
|
|
||||||
|
| # | 页面类型 | 路由(对本站) | 原站对应 | 版块数 | 数据来源 |
|
||||||
|
|---|---------|-------------|---------|-------|---------|
|
||||||
|
| 1 | 首页 | `/` | `/` | 20 | DB `contents` slug=home 的 Elementor 树 |
|
||||||
|
| 2 | 产品分类列表/落地页 | `/collection/$slug` | `/smart-gateways/` `/product-category/…` 等约 60 个入口 | 4(落地主题区…侧栏筛选…) | 分类(16 个 product_cat)+ 同 slug 落地文 |
|
||||||
|
| 3 | 方案页 | `/solution/` 与 `/solution/$slug` | `/solution/` 及各方案营销页 | 列表页 + 落地页(每页自定) | posts(solution 分类)+ `/solution/` 页 |
|
||||||
|
| 4 | 商城列表 | `/shop/` | `/shop/` | WordPress 模板 | products 表 |
|
||||||
|
| 5 | 产品详情 | `/product/$slug` | `/product/…` | WooCommerce 模板 | products 表 |
|
||||||
|
| 6 | 博客 | `/blog/` 与 `/blog/$slug` | `/blog/` | WordPress 模板 | posts 表(639 篇) |
|
||||||
|
| 7 | 公司/品牌页 ×7 | — | about-us / research-development / certification / after-sales-service / faq / distributor / login | 2~24 各异 | 各自 Elementor 树 |
|
||||||
|
| 8 | 合规/法务页 ×4 | — | privacy-policy / terms-of-service / refund_returns / cookie-policy | 1~2(富文本) | 各自 Elementor 树 |
|
||||||
|
| 9 | 商务流程页 ×4 | cart / checkout / my-account / search | 站内功能 | — | carts / orders |
|
||||||
|
| 10 | 管理后台 | `admin.*` | (本项目新增,不属复刻) | — | — |
|
||||||
|
|
||||||
|
## 二、通用版块(全局组件)
|
||||||
|
|
||||||
|
| # | 版块 | 状态 | 组件 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| G1 | 顶部导航 Header(含 mega menu、语言切换) | ✅ v0.10.3 | `Header.tsx` |
|
||||||
|
| G2 | 页脚 Footer | 🟡 | `Footer.tsx` |
|
||||||
|
| G3 | 询价弹窗/表单 | 🟡 | `InquiryForm.tsx` |
|
||||||
|
|
||||||
|
## 三、首页版块清单(自上而下,20 版块)
|
||||||
|
|
||||||
|
| # | 版块名(取标题) | 内容(Widget 组成) | 状态 |
|
||||||
|
|---|----------------|-------------------|------|
|
||||||
|
| H0 | Banner 轮播 | slides ×4(Swiper,fadeInUp 内容、5000ms 自动播) | ✅ v0.10.3 |
|
||||||
|
| H1 | 公告滚动条 | eael-content-ticker | ✅ v0.10.4(badge 18px/600、内容 #222 hover #ED6C00、fade 200ms/5000ms、1300px 容器、bg #F4F5F8,数据取最新 3 产品) |
|
||||||
|
| H2 | 产品线入口 ×6 | image-box ×6(6 条产品线图标卡) | ✅ v0.10.5(图片上方图标+标题 16px/400/#333、figure 30% 宽、hover 亮度 90%、五列右侧 v-line 竖线、列 10px 内边距、≤767 50% 两列;ImageBoxWidget 重构为 Elementor 忠实 DOM 供 H13/about 复用) |
|
||||||
|
| H3 | 「Dusun Hardware & Service」标题条 | heading | ⬜ |
|
||||||
|
| H4 | 硬件与服务系列 ×5 组 | 每组 heading+text+image+eael-info-box ×4(横向四列) | ⬜ |
|
||||||
|
| H5 | 可定制/易生产 双主张条 | heading ×2(双栏大字) | ⬜ |
|
||||||
|
| H6 | 从概念到量产 宣言条 | 全大写 heading(ACCELERATE YOUR IOT DEVICE LAUNCH…) | ⬜ |
|
||||||
|
| H7 | OS/芯片平台/语言/协议 四列 | heading ×4 图标列 | ⬜ |
|
||||||
|
| H8 | 热门嵌入式硬件 标题 | heading | ⬜ |
|
||||||
|
| H9 | 热门硬件切换器(html) | html widget(原站 JS 切换逻辑,需看原站行为) | ⬜ |
|
||||||
|
| H10 | 技术产品 Tab 行 ×3 | heading+image+image-box ×4+button(三段 Tab 面板) | ⬜ |
|
||||||
|
| H11 | Industries & Use Cases | heading + eael-adv-tabs + button(多标签竖排) | ⬜ |
|
||||||
|
| H12 | The Difference Maker 段一 | heading+text+icon-list+text+button(左侧列表优势) | ⬜ |
|
||||||
|
| H13 | Difference Maker 产品卡 ×6 | image-box ×6 | ⬜ |
|
||||||
|
| H14 | Why Choose Dusun IoT | heading + eael-info-box ×4 | ⬜ |
|
||||||
|
| H15 | Trusted By 世界领先品牌 | heading + image ×11(Logo 墙) | ⬜ |
|
||||||
|
| H16 | Developer Ecosystem | heading ×4+text+button+image+social-icons(左右两卡) | ⬜ |
|
||||||
|
| H17 | News & Updates 标题 | heading | ⬜ |
|
||||||
|
| H18 | 最新博文 *2 | posts widget ×2(标题行 + 列卡) | ⬜ |
|
||||||
|
| H19 | 定制 CTA | button(大按钮) | ⬜ |
|
||||||
|
|
||||||
|
## 四、其他核心页型版块(自上而下,概览)
|
||||||
|
|
||||||
|
### 1. 产品分类列表/落地页(4 版块)
|
||||||
|
- C1 落地主题区(同 slug 文章 Elementor 直出,每类内容不同)
|
||||||
|
- C2 侧栏:分类树 + 属性复选筛选(URL search 驱动)
|
||||||
|
- C3 产品网格(ProductCard ×24)
|
||||||
|
- C4 分页
|
||||||
|
|
||||||
|
### 2. 方案页
|
||||||
|
- S1 `/solution/` 列表入口页
|
||||||
|
- S2 `/solution/$slug` 落地页:与首页同款 Elementor 直出,版块逐页不同
|
||||||
|
|
||||||
|
### 3. 商城/产品/博客(WordPress 模板页)
|
||||||
|
- SH1 商城:`/shop/` 分类+商品网格
|
||||||
|
- P1 产品详情:标题、参数表、图集、询价 CTA(依原站 Woo 模板)
|
||||||
|
- B1 `/blog/` 列表:卡片+分页
|
||||||
|
- B2 `/blog/$slug` 详情:正文渲染(WP 样式)
|
||||||
|
|
||||||
|
### 4. 公司/品牌页
|
||||||
|
| Slug | 标题 | 顶层版块概览 | 备注 |
|
||||||
|
|------|------|-------------|------|
|
||||||
|
| about-us | About Dusun IoT | 24 版块:hero 图集、视频区、4 项数据 counter、里程碑、认证/展会/研发/语言能力等 | 最重的一页 |
|
||||||
|
| research-development | Research & Development | 5:标题、视频、info-box ×3+3、info-box ×9 | |
|
||||||
|
| certification | Certification | 2:文案+按钮;html ×2(证书大全) | |
|
||||||
|
| after-sales-service | After Sales Service | 2:标题;目录+文本长块(table-of-contents) | |
|
||||||
|
| faq | FAQs | 2:标题;目录+10 组 toggle 手风琴 | |
|
||||||
|
| contact-us | Contact Us | 1:标题+地图/联系方式 ×9 列表+社媒+表单(CF7 表单→改造) | |
|
||||||
|
| distributor | Distributor | 2:图+文+按钮;图区 | |
|
||||||
|
| login | Login | 1:登录注册表单 | |
|
||||||
|
|
||||||
|
### 5. 合规页(⚪ 只需 WP 正文样式统一)
|
||||||
|
privacy-policy / terms-of-service / refund_returns:标题+目录+正文。
|
||||||
|
cookie-policy:19 段纯文本。
|
||||||
|
|
||||||
|
## 五、复用组件策略(一批版块落下后沉淀)
|
||||||
|
|
||||||
|
| 组件 | 首次出现 | 复用页 |
|
||||||
|
|------|---------|--------|
|
||||||
|
| `SectionTitle`(中/左对齐大标题规范) | H3 | H8/H11/H14/H17、分类/方案/博客各页 |
|
||||||
|
| `IconBoxGrid`(eael-info-box 四列) | H4 | H14、R&D、分类落地页 |
|
||||||
|
| `ImageBoxGrid`(image-box 卡片行) | H2 | H13、about、distributor |
|
||||||
|
| `MediaTextRow`(图+文+按钮左右组合) | about [5] | about、recru-letter、distributor |
|
||||||
|
| `LogoWall`(灰度 Logo 行) | H15 | about |
|
||||||
|
| `TabsRow` / `AdvTabs`(左标签+右面板) | H10/H11 | 分类落地页 |
|
||||||
|
| `PostCardGrid`(posts widget 列卡) | H18 | 博客列表、分类页尾部 |
|
||||||
|
| `CTAButton`(红/描边大按钮,含 hover) | H19 | 全站尾部 CTA |
|
||||||
|
| `TickerBar`(滚动公告) | H1(v0.10.4 已落地 `src/components/website/TickerBar.tsx`) | 全站头部下方 |
|
||||||
|
| `CounterStats`(数字滚动统计) | about [3] | 方案页 |
|
||||||
|
| `VideoEmbed`(视频框) | about [2] | R&D |
|
||||||
|
| `TocText`(目录+层级文本,合规/售后页共用) | after-sales | terms、privacy、cookie |
|
||||||
|
|
||||||
|
## 六、执行顺序(与用户确认后逐项推进)
|
||||||
|
|
||||||
|
1. **首页自上而下**:H1(公告滚动)→ H2…H19,每版块对照原站截图核验后交付 +patch。
|
||||||
|
2. **产品分类列表页**(2 类入口最多、复用公共组件最多)。
|
||||||
|
3. **方案页/商城/产品详情/博客**。
|
||||||
|
4. **公司页**(about-us 24 版块最后收拾)。
|
||||||
|
|
||||||
|
> 每完成一个版块:与原站同页同区截图对比 → 头less Chromium 验算 computed style → 版本号 +1。
|
||||||
|
|
||||||
|
## 七、已知遗留(非当前版块范围,后续版块收口)
|
||||||
|
|
||||||
|
| # | 现象(实测 2026-11-01,1436/390px 视口) | 归属 | 优先级 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | 移动端(≤767px)Header 高度差 ~21px(原站 slides y=88,本地 109) | G1 Header 移动端 | 中 |
|
||||||
|
| 2 | 移动端 H1 ticker 高度差 ~14px(原站 117,本地 131.2) | H1 ticker 移动端 | 中 |
|
||||||
|
| 3 | 全局 `a:hover` 为 --brand-dark(#c85600),kit 全局 secondary 是 #FFA11B | G1/全局样式 | 低 |
|
||||||
|
| 4 | PG 连接曾被 stale vite dev 进程(3311 端口)泄漏占满(290 连接),已清理;重复发生时检查残留 dev 进程 | 环境 | 记录 |
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
dialect: "postgresql",
|
||||||
|
schema: "./src/db/schema.ts",
|
||||||
|
out: "./src/db/migrations",
|
||||||
|
dbCredentials: { url: process.env.DATABASE_URL || "postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot" },
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"name": "dusuniot-rebuild",
|
||||||
|
"version": "0.10.5",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev --port 3311",
|
||||||
|
"build": "vite build",
|
||||||
|
"start": "bun scripts/prod-server.ts",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "bun run scripts/db-migrate.ts",
|
||||||
|
"db:push": "drizzle-kit push",
|
||||||
|
"import:wordpress": "bun run scripts/wordpress-import/import.ts",
|
||||||
|
"test": "bun test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^6.3.4",
|
||||||
|
"@radix-ui/react-accordion": "^1.3.1",
|
||||||
|
"@radix-ui/react-navigation-menu": "^1.4.1",
|
||||||
|
"@radix-ui/react-select": "^2.4.1",
|
||||||
|
"@radix-ui/react-slot": "^1.5.0",
|
||||||
|
"@radix-ui/react-tabs": "^1.2.1",
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@tanstack/react-query": "^5.105.0",
|
||||||
|
"@tanstack/react-router": "^1.170.46",
|
||||||
|
"@tanstack/react-router-devtools": "^1.167.2",
|
||||||
|
"@tanstack/react-start": "^1.169.4",
|
||||||
|
"@types/react": "^19.3.0",
|
||||||
|
"@types/react-dom": "^19.3.0",
|
||||||
|
"@vitejs/plugin-react": "^6.1.2",
|
||||||
|
"antd": "^6.6.8",
|
||||||
|
"bun-types": "^1.4.4",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"drizzle-kit": "^0.31.11",
|
||||||
|
"drizzle-orm": "^0.45.4",
|
||||||
|
"lucide-react": "^1.64.0",
|
||||||
|
"mysql2": "^3.24.6",
|
||||||
|
"react": "^19.3.1",
|
||||||
|
"react-dom": "^19.3.1",
|
||||||
|
"tailwind-merge": "^3.7.0",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"vite": "^8.3.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vite-tsconfig-paths": "^6.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* # 模块 后台共享工具
|
||||||
|
* loader 权限兜底、未登录占位、状态标签配色、分页配置、空状态与日期格式化。
|
||||||
|
*/
|
||||||
|
import { Empty, Tag } from "antd";
|
||||||
|
import { Database, Lock } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/** # 类型 递归 JSON 值(用于把数据库 jsonb 的 unknown 收窄为可序列化类型) **/
|
||||||
|
export type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
|
||||||
|
|
||||||
|
/** # 类型 后台列表页 URL 查询参数(全部可选,避免 Link 强制携带 search) **/
|
||||||
|
export interface AdminSearch {
|
||||||
|
/** # 成员变量 页码 **/
|
||||||
|
page?: number;
|
||||||
|
/** # 成员变量 关键字 **/
|
||||||
|
q?: string;
|
||||||
|
/** # 成员变量 状态过滤 **/
|
||||||
|
status?: string;
|
||||||
|
/** # 成员变量 类型过滤(内容页用) **/
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 类型 后台列表统一返回结构 **/
|
||||||
|
export interface Paged<T> {
|
||||||
|
/** # 成员变量 当页数据 **/
|
||||||
|
items: T[];
|
||||||
|
/** # 成员变量 总条数 **/
|
||||||
|
total: number;
|
||||||
|
/** # 成员变量 当前页码 **/
|
||||||
|
page: number;
|
||||||
|
/** # 成员变量 每页条数 **/
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 loader 权限兜底包装
|
||||||
|
* 服务端函数抛出 FORBIDDEN(未登录/无权限)时返回 null,由页面渲染登录提示;其余错误照常抛出。
|
||||||
|
* 参数 fn:待执行的异步取数函数
|
||||||
|
* 返回值 数据或 null
|
||||||
|
*/
|
||||||
|
export async function guardAdmin<T>(fn: () => Promise<T>): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
if (/forbidden/i.test(msg)) return null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 未登录 / 无权限占位组件
|
||||||
|
* 各后台页面在 loader 返回 null(401)时渲染。
|
||||||
|
*/
|
||||||
|
export function Forbidden() {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", justifyContent: "center", paddingTop: 80 }}>
|
||||||
|
<Empty
|
||||||
|
image={<Lock size={48} aria-hidden color="#bfbfbf" />}
|
||||||
|
imageStyle={{ height: 64 }}
|
||||||
|
description="未登录或权限不足,请使用管理员账号登录后台"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 表格空状态(SVG 图标,禁用默认灰图) */
|
||||||
|
export function AdminEmpty() {
|
||||||
|
return <Empty image={<Database size={40} aria-hidden color="#bfbfbf" />} imageStyle={{ height: 48 }} description="暂无数据" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 表格通用分页配置
|
||||||
|
* 参数 total:总条数;page:当前页;pageSize:每页条数;onChange:翻页回调
|
||||||
|
* 返回值 AntD Table pagination 配置对象
|
||||||
|
*/
|
||||||
|
export function tablePagination(total: number, page: number, pageSize: number, onChange: (p: number) => void) {
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
showSizeChanger: false,
|
||||||
|
showTotal: (t: number) => `共 ${t} 条`,
|
||||||
|
onChange,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 日期时间格式化(兼容 ISO 字符串与 Date)
|
||||||
|
* 参数 v:时间值
|
||||||
|
* 返回值 YYYY-MM-DD HH:mm 或空串
|
||||||
|
*/
|
||||||
|
export function fmtDateTime(v: string | Date | null | undefined): string {
|
||||||
|
if (!v) return "";
|
||||||
|
const d = typeof v === "string" ? new Date(v) : v;
|
||||||
|
if (Number.isNaN(d.getTime())) return String(v);
|
||||||
|
const p = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 成员变量 内容状态配色(draft/review/published) **/
|
||||||
|
export const CONTENT_STATUS_COLOR: Record<string, string> = { draft: "default", review: "gold", published: "green" };
|
||||||
|
|
||||||
|
/** # 成员变量 内容类型中文标签(page/post/landing) **/
|
||||||
|
export const CONTENT_TYPE_LABEL: Record<string, string> = { page: "页面", post: "文章", landing: "落地页" };
|
||||||
|
|
||||||
|
/** # 成员变量 订单状态中文标签 **/
|
||||||
|
export const ORDER_STATUS_LABEL: Record<string, string> = {
|
||||||
|
pending: "待支付",
|
||||||
|
"on-hold": "挂起",
|
||||||
|
processing: "处理中",
|
||||||
|
completed: "已完成",
|
||||||
|
cancelled: "已取消",
|
||||||
|
refunded: "已退款",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** # 成员变量 订单状态标签配色 **/
|
||||||
|
export const ORDER_STATUS_COLOR: Record<string, string> = {
|
||||||
|
pending: "orange",
|
||||||
|
"on-hold": "gold",
|
||||||
|
processing: "blue",
|
||||||
|
completed: "green",
|
||||||
|
cancelled: "red",
|
||||||
|
refunded: "purple",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** # 成员变量 询盘状态标签配色(new/read/replied/spam/archived) **/
|
||||||
|
export const INQUIRY_STATUS_COLOR: Record<string, string> = {
|
||||||
|
new: "blue",
|
||||||
|
read: "default",
|
||||||
|
replied: "green",
|
||||||
|
spam: "red",
|
||||||
|
archived: "default",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** # 成员变量 询盘状态中文标签 **/
|
||||||
|
export const INQUIRY_STATUS_LABEL: Record<string, string> = {
|
||||||
|
new: "新询盘",
|
||||||
|
read: "已读",
|
||||||
|
replied: "已回复",
|
||||||
|
spam: "垃圾",
|
||||||
|
archived: "归档",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** # 成员变量 订单状态下拉选项 **/
|
||||||
|
export const ORDER_STATUS_OPTIONS = (["pending", "on-hold", "processing", "completed", "cancelled", "refunded"] as const).map((s) => ({
|
||||||
|
value: s,
|
||||||
|
label: ORDER_STATUS_LABEL[s] ?? s,
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** # 成员变量 内容状态下拉选项 **/
|
||||||
|
export const CONTENT_STATUS_OPTIONS = (["draft", "review", "published"] as const).map((s) => ({ value: s, label: s }));
|
||||||
|
|
||||||
|
/** # 函数 通用状态标签渲染(未知状态兜底灰色) */
|
||||||
|
export function StatusTag({ value, colorMap, labelMap }: { value: string; colorMap: Record<string, string>; labelMap?: Record<string, string> }) {
|
||||||
|
return <Tag color={colorMap[value] ?? "default"}>{labelMap?.[value] ?? value}</Tag>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 页面标题条(子页面统一的标题 + 右侧操作区) */
|
||||||
|
export function PageHeader({ title, extra }: { title: ReactNode; extra?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 18 }}>{title}</h2>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>{extra}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,981 @@
|
|||||||
|
/**
|
||||||
|
* # 组件 Elementor 内容渲染器
|
||||||
|
* 将 WordPress Elementor 导出的 JSON(contents.meta.elementor / products.meta.elementor)
|
||||||
|
* 渲染为 React 元素。支持 section/column/container 结构 + 常用 widget。
|
||||||
|
* 样式以内联 style 复现视觉效果(背景、内边距、对齐、列宽百分比),
|
||||||
|
* 未识别的 widgetType 优雅降级:渲染 heading_title / editor 文本或忽略。
|
||||||
|
*/
|
||||||
|
import { Component, useEffect, useState, type CSSProperties, type ReactNode } from "react";
|
||||||
|
import { getLatestPosts, getLatestProducts, type LatestItem } from "~/server/data";
|
||||||
|
import TickerBar from "~/components/website/TickerBar";
|
||||||
|
import { InquiryForm } from "~/components/website/InquiryForm";
|
||||||
|
import { localSiteUrl } from "~/utils/media";
|
||||||
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "~/components/ui/accordion";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||||
|
|
||||||
|
/** # 类型 Elementor 元素结构(section/column/widget 通用) */
|
||||||
|
export type ElElement = {
|
||||||
|
elType?: string;
|
||||||
|
widgetType?: string;
|
||||||
|
id?: string;
|
||||||
|
settings?: Record<string, unknown>;
|
||||||
|
elements?: ElElement[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** # 类型 Elementor 文档(数组或 {elements:[]} 包装) */
|
||||||
|
export type ElDocument = ElElement[] | { elements?: ElElement[] } | unknown;
|
||||||
|
|
||||||
|
/** # 类型 尺寸值 {size, unit} */
|
||||||
|
type Size = { size?: number | string; unit?: string };
|
||||||
|
|
||||||
|
/** # 函数 判断字符串是否主要是 emoji/unicode 图标(不可作为图片图标渲染) */
|
||||||
|
function isEmojiOrPictograph(s: unknown): boolean {
|
||||||
|
if (typeof s !== "string" || !s.trim()) return true;
|
||||||
|
// 含有 emoji 区段字符(U+1F000+ 或 U+2600-U+27BF 等)视为图标字符
|
||||||
|
return /[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}\u{2190}-\u{21FF}\u{2B00}-\u{2BFF}\u{E000}-\u{F8FF}]/u.test(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 读取 settings 中字符串字段 */
|
||||||
|
function s(v: unknown): string {
|
||||||
|
return typeof v === "string" ? v : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 把 Elementor 尺寸对象/数字转为 CSS 值 */
|
||||||
|
function sizeCss(v: unknown, fallbackUnit = "px"): string | undefined {
|
||||||
|
if (v == null) return undefined;
|
||||||
|
if (typeof v === "number") return `${v}${fallbackUnit}`;
|
||||||
|
if (typeof v === "object") {
|
||||||
|
const o = v as Size;
|
||||||
|
if (o.size != null) return `${o.size}${o.unit || fallbackUnit}`;
|
||||||
|
}
|
||||||
|
if (typeof v === "string" && v.trim()) return v;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 剥离内容中的文档结构级标签(WP 导入的原始 HTML 可能带 </main> 等,
|
||||||
|
* 浏览器解析会提前关闭页面骨架标签,导致水合不匹配与布局重排)
|
||||||
|
*/
|
||||||
|
function stripStructuralTags(html: string): string {
|
||||||
|
return html.replace(/<\/?(?:main|body|html|head)[^>]*>/gi, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 折叠 CSS 盒模型四值简写为最短等价形式(1/2/4 值)
|
||||||
|
* React 19 水合按 CSSOM 归一化后的字符串比对样式,"4rem 0rem 4rem 0rem"
|
||||||
|
* 会被浏览器归一为 "4rem 0rem",不折叠会导致水合不匹配。
|
||||||
|
*/
|
||||||
|
function collapseBoxValues(v: string): string {
|
||||||
|
const p = v.trim().split(/\s+/);
|
||||||
|
if (p.length === 4 && p[0] === p[2] && p[1] === p[3]) return `${p[0]} ${p[1]}`;
|
||||||
|
if (p.length === 4 && p[0] === p[1] && p[1] === p[2] && p[2] === p[3]) return p[0]!;
|
||||||
|
if (p.length === 3 && p[0] === p[2]) return `${p[0]} ${p[1]}`;
|
||||||
|
if (p.length === 2 && p[0] === p[1]) return p[0]!;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 解析 padding(Elementor 可能是对象/字符串/数组) */
|
||||||
|
function paddingCss(v: unknown): string | undefined {
|
||||||
|
if (!v) return undefined;
|
||||||
|
if (typeof v === "string") return v;
|
||||||
|
if (typeof v === "number") return `${v}px`;
|
||||||
|
if (Array.isArray(v)) return collapseBoxValues(v.map((x) => (typeof x === "number" ? `${x}px` : String(x ?? "0"))).join(" "));
|
||||||
|
if (typeof v === "object") {
|
||||||
|
const o = v as Record<string, unknown>;
|
||||||
|
const unit = s(o.unit) || "px";
|
||||||
|
const t = o.top, r = o.right, b = o.bottom, l = o.left;
|
||||||
|
if (t == null && r == null && b == null && l == null) return undefined;
|
||||||
|
const px = (x: unknown) => `${x ?? 0}${unit}`;
|
||||||
|
return collapseBoxValues(`${px(t)} ${px(r)} ${px(b)} ${px(l)}`);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 成员变量 Elementor 全局色映射(Default Kit:_id → 十六进制) **/
|
||||||
|
const ELEMENTOR_GLOBAL_COLORS: Record<string, string> = {
|
||||||
|
primary: "#ED6C00",
|
||||||
|
secondary: "#FFA11B",
|
||||||
|
text: "#000000",
|
||||||
|
accent: "#474DFF",
|
||||||
|
"4d5c430": "#FFFFFF",
|
||||||
|
e4e3ba3: "#4A75FF",
|
||||||
|
"282ef76": "#5C5E66",
|
||||||
|
"6fba50b": "#444444",
|
||||||
|
"78696cf": "#00C1BC",
|
||||||
|
a6f03ac: "#E9E9E9",
|
||||||
|
ee1917e: "#E0E5F9",
|
||||||
|
"26331af": "#F4F5F8",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** # 函数 递归解析 settings 中的全局色引用(globals/colors?id=x → 十六进制),
|
||||||
|
* 并把 __globals__ 里的引用合并到对应设置键(Elementor 全局色实际存放在 __globals__) */
|
||||||
|
function resolveGlobals<T>(settings: T): T {
|
||||||
|
if (Array.isArray(settings)) return settings.map((v) => resolveGlobals(v)) as unknown as T;
|
||||||
|
if (settings && typeof settings === "object" && settings.constructor === Object) {
|
||||||
|
const src = settings as Record<string, unknown>;
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(src)) {
|
||||||
|
out[k] = typeof v === "string" ? resolveGlobalsStr(v) : resolveGlobals(v);
|
||||||
|
}
|
||||||
|
const globals = src.__globals__;
|
||||||
|
if (globals && typeof globals === "object" && globals.constructor === Object) {
|
||||||
|
for (const [key, ref] of Object.entries(globals as Record<string, unknown>)) {
|
||||||
|
if (typeof ref !== "string") continue;
|
||||||
|
const resolved = resolveGlobalsStr(ref);
|
||||||
|
if (resolved !== ref) {
|
||||||
|
const cur = out[key];
|
||||||
|
// 仅当原设置为空(空串/未定义)时采用全局色
|
||||||
|
if (cur == null || cur === "") out[key] = resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out as T;
|
||||||
|
}
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 单值全局色引用解析 */
|
||||||
|
function resolveGlobalsStr(v: string): string {
|
||||||
|
const m = /^globals\/colors\?id=([0-9a-f]+)$/.exec(v.trim());
|
||||||
|
if (m) return ELEMENTOR_GLOBAL_COLORS[m[1]!] ?? v;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 section 的背景样式(纯色 / 图片) */
|
||||||
|
function sectionBgStyle(settings: Record<string, unknown>): CSSProperties {
|
||||||
|
const style: CSSProperties = {};
|
||||||
|
const bgType = s(settings.background_background);
|
||||||
|
if (bgType === "classic") {
|
||||||
|
const color = s(settings.background_color);
|
||||||
|
if (color) style.backgroundColor = color;
|
||||||
|
const img = (settings.background_image as { url?: string } | undefined)?.url;
|
||||||
|
if (img) {
|
||||||
|
style.backgroundImage = `url("${img}")`;
|
||||||
|
style.backgroundSize = "cover";
|
||||||
|
// "center" 会被浏览器归一为 "center center",直接写全值保证水合一致
|
||||||
|
style.backgroundPosition = "center center";
|
||||||
|
}
|
||||||
|
} else if (bgType === "gradient") {
|
||||||
|
const c1 = s(settings.background_color) || s(settings.color_a) || "#ffffff";
|
||||||
|
const c2 = s(settings.background_color_b) || s(settings.color_b) || s(settings.background_color_stop_b) || "#f4f5f8";
|
||||||
|
style.backgroundImage = `linear-gradient(${s(settings.gradient_angle) || "180"}deg, ${c1}, ${c2})`;
|
||||||
|
} else if (s(settings.background_color)) {
|
||||||
|
style.backgroundColor = s(settings.background_color);
|
||||||
|
}
|
||||||
|
const pad = paddingCss(settings.padding);
|
||||||
|
if (pad) style.padding = pad;
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 归一化 Elementor 文档为元素数组 */
|
||||||
|
function normalizeDoc(doc: ElDocument): ElElement[] {
|
||||||
|
if (Array.isArray(doc)) return doc as ElElement[];
|
||||||
|
if (doc && typeof doc === "object") {
|
||||||
|
const el = (doc as { elements?: ElElement[] }).elements;
|
||||||
|
if (Array.isArray(el)) return el;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 Elementor 渲染器入口 */
|
||||||
|
export function ElementorRenderer({ doc }: { doc: ElDocument }) {
|
||||||
|
const elements = normalizeDoc(doc);
|
||||||
|
if (!elements.length) return null;
|
||||||
|
const css = collectCustomCss(elements) + collectResponsiveCss(elements);
|
||||||
|
return (
|
||||||
|
<div className="content-html">
|
||||||
|
{css ? <style dangerouslySetInnerHTML={{ __html: css }} /> : null}
|
||||||
|
{elements.map((el, i) => (
|
||||||
|
<ElementNode key={el.id || i} el={el} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 收集整份文档所有节点的 custom_css(Elementor 每节点自定义样式,选择器挂在 _css_classes 上)
|
||||||
|
* 参数 elements 归一化后的元素数组
|
||||||
|
* 返回值 拼接后的 CSS(原站媒体地址已改写为本地 /uploads/);无则空串
|
||||||
|
*/
|
||||||
|
function collectCustomCss(elements: ElElement[]): string {
|
||||||
|
let css = "";
|
||||||
|
function walk(els: ElElement[]) {
|
||||||
|
for (const el of els) {
|
||||||
|
const c = s(el.settings?.custom_css);
|
||||||
|
if (c) css += c + "\n";
|
||||||
|
if (el.elements?.length) walk(el.elements);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(elements);
|
||||||
|
return css ? localSiteUrl(css) : css;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 收集各元素响应式内边距(padding_tablet / padding_mobile),输出媒体查询规则
|
||||||
|
* 选择器用 data-el 属性(section/core 容器/列都带 data-el=<elementor id>),
|
||||||
|
* 断点沿用 Elementor 默认:tablet ≤1024、mobile ≤767;!important 覆盖行内样式。
|
||||||
|
* 参数 elements 归一化后的元素数组
|
||||||
|
* 返回值 拼接后的 CSS;无则空串
|
||||||
|
*/
|
||||||
|
function collectResponsiveCss(elements: ElElement[]): string {
|
||||||
|
let css = "";
|
||||||
|
function emit(el: ElElement) {
|
||||||
|
const sel = `[data-el="${el.id}"]`;
|
||||||
|
const t = paddingCss((el.settings ?? {} as Record<string, unknown>).padding_tablet);
|
||||||
|
const m = paddingCss((el.settings ?? {} as Record<string, unknown>).padding_mobile);
|
||||||
|
if (t) css += `@media (max-width:1024px){${sel}{padding:${t} !important;}}\n`;
|
||||||
|
if (m) css += `@media (max-width:767px){${sel}{padding:${m} !important;}}\n`;
|
||||||
|
}
|
||||||
|
function walk(els: ElElement[]) {
|
||||||
|
for (const el of els) {
|
||||||
|
if (el.id && (s(el.elType) === "section" || s(el.elType) === "container" || s(el.elType) === "column")) emit(el);
|
||||||
|
if (el.elements?.length) walk(el.elements);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(elements);
|
||||||
|
return css;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 单个元素节点分发(section/column/container/widget) */
|
||||||
|
function ElementNode({ el }: { el: ElElement }) {
|
||||||
|
const type = s(el.elType);
|
||||||
|
if (type === "widget") return <WidgetNode el={el} />;
|
||||||
|
if (type === "section" || type === "container") return <SectionNode el={el} />;
|
||||||
|
if (type === "column") return <ColumnNode el={el} />;
|
||||||
|
// 未知结构:有子元素则递归,否则忽略
|
||||||
|
if (el.elements?.length) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{el.elements.map((c, i) => (
|
||||||
|
<ElementNode key={c.id || i} el={c} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 section / container:背景 + 内部布局
|
||||||
|
* 取证(原站实测/post CSS):boxed 内容容器 = margin auto + flex wrap 的定宽行;
|
||||||
|
* 内容宽度 content_width 优先,否则沿用 Elementor kit 默认 1140px;
|
||||||
|
* 自带 padding 只取 settings.padding(不再叠加通用 px/py)。
|
||||||
|
*/
|
||||||
|
function SectionNode({ el }: { el: ElElement }) {
|
||||||
|
const settings = resolveGlobals(el.settings || {});
|
||||||
|
const style = sectionBgStyle(settings);
|
||||||
|
const width = sizeCss(settings.content_width) || sizeCss((settings as Record<string, unknown>).width) || "1140px";
|
||||||
|
const inner: CSSProperties = {};
|
||||||
|
if (s(settings.layout) === "full_width") {
|
||||||
|
// 通栏 section:内容不设上限、无内边距(如首页 hero 轮播)
|
||||||
|
inner.maxWidth = "none";
|
||||||
|
inner.padding = "0";
|
||||||
|
} else {
|
||||||
|
inner.maxWidth = width;
|
||||||
|
const innerPad = paddingCss(settings.padding);
|
||||||
|
if (innerPad) inner.padding = innerPad;
|
||||||
|
}
|
||||||
|
// section 高度模式(Elementor: min-height / full),配合 custom_height;
|
||||||
|
// 取证(原站 09fe4b7 实测):height 未设置时 custom_height 不生效(min-height 0px),
|
||||||
|
// 故仅在显式 height 模式下应用
|
||||||
|
const heightMode = s(settings.height);
|
||||||
|
const ch = sizeCss(settings.custom_height);
|
||||||
|
if (heightMode === "min-height" && ch) style.minHeight = ch;
|
||||||
|
else if (heightMode === "full" && ch) style.height = ch;
|
||||||
|
return (
|
||||||
|
<section style={style} data-el={el.id}>
|
||||||
|
<div className="mx-auto flex flex-wrap" style={inner}>
|
||||||
|
{(el.elements || []).map((c, i) => (
|
||||||
|
<ElementNode key={c.id || i} el={c} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 column:按 _inline_size 百分比设置列宽 */
|
||||||
|
function ColumnNode({ el }: { el: ElElement }) {
|
||||||
|
const settings = resolveGlobals(el.settings || {});
|
||||||
|
// 未声明列宽时按 Elementor 语义均分(flex-basis 0),避免固定 240px 底宽把多列挤成换行
|
||||||
|
const style: CSSProperties = { flex: "1 1 0", minWidth: 0 };
|
||||||
|
const inline = settings._inline_size;
|
||||||
|
if (typeof inline === "number" && inline > 0) {
|
||||||
|
style.flex = `0 0 ${inline}%`;
|
||||||
|
style.maxWidth = `${inline}%`;
|
||||||
|
}
|
||||||
|
const pad = paddingCss(settings.padding);
|
||||||
|
if (pad) style.padding = pad;
|
||||||
|
const bg = sectionBgStyle(settings);
|
||||||
|
Object.assign(style, { backgroundColor: bg.backgroundColor, backgroundImage: bg.backgroundImage });
|
||||||
|
// 列圆角/边框(卡片样式)
|
||||||
|
const radius = paddingCss(settings.border_radius);
|
||||||
|
if (radius) style.borderRadius = radius;
|
||||||
|
if (s(settings.border_border) === "solid") {
|
||||||
|
const bw = paddingCss(settings.border_width);
|
||||||
|
style.border = `1px solid ${s(settings.border_color) || "#e3e9f0"}`;
|
||||||
|
if (bw && bw.split(" ").length === 4) {
|
||||||
|
const [t, r2, b, l] = bw.split(" ");
|
||||||
|
style.borderWidth = `${t} ${r2} ${b} ${l}`;
|
||||||
|
} else style.borderWidth = "1px";
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 px-2.5" style={style} data-el={el.id}>
|
||||||
|
{(el.elements || []).map((c, i) => (
|
||||||
|
<ElementNode key={c.id || i} el={c} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 widget 分发:按 widgetType 渲染,未知类型优雅降级
|
||||||
|
* 外层 div 与原站 widget 包裹层一致(.elementor-widget …),挂 data-el=<id>,
|
||||||
|
* 页面级规则(post-18.css 对应块按 [data-el] 范围书写)与 _css_classes 因此都能命中。
|
||||||
|
*/
|
||||||
|
function WidgetNode({ el }: { el: ElElement }) {
|
||||||
|
const inner = (
|
||||||
|
<WidgetGuard widgetType={s(el.widgetType)}>
|
||||||
|
<WidgetNodeInner el={el} />
|
||||||
|
</WidgetGuard>
|
||||||
|
);
|
||||||
|
const cls = s(el.settings?._css_classes) || undefined;
|
||||||
|
return <div className={cls} data-el={el.id}>{inner}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 单个 widget 渲染错误边界:异常 widget 降级为空,不拖垮整页 */
|
||||||
|
class WidgetGuard extends Component<{ widgetType: string; children: ReactNode }, { failed: boolean }> {
|
||||||
|
state = { failed: false };
|
||||||
|
static getDerivedStateFromError() { return { failed: true }; }
|
||||||
|
componentDidCatch(err: unknown) { console.warn("[elementor] widget 渲染失败:", this.props.widgetType, err); }
|
||||||
|
render() { return this.state.failed ? null : this.props.children; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function WidgetNodeInner({ el }: { el: ElElement }) {
|
||||||
|
const w = s(el.widgetType);
|
||||||
|
const settings = resolveGlobals(el.settings || {});
|
||||||
|
const unknownFallback = () => {
|
||||||
|
const title = s(settings.heading_title) || s(settings.title);
|
||||||
|
const text = s(settings.editor) || s(settings.html) || s(settings.text);
|
||||||
|
if (!title && !text) return null;
|
||||||
|
return (
|
||||||
|
<div className="mb-[18px]">
|
||||||
|
{title ? <h3>{title}</h3> : null}
|
||||||
|
{text ? <div dangerouslySetInnerHTML={{ __html: text }} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
switch (w) {
|
||||||
|
case "heading":
|
||||||
|
return <HeadingWidget settings={settings} />;
|
||||||
|
case "text-editor":
|
||||||
|
return <TextEditorWidget settings={settings} />;
|
||||||
|
case "image":
|
||||||
|
return <ImageWidget settings={settings} />;
|
||||||
|
case "button":
|
||||||
|
return <ButtonWidget settings={settings} />;
|
||||||
|
case "html":
|
||||||
|
return s(settings.html) ? (
|
||||||
|
<div className="mb-[18px]" dangerouslySetInnerHTML={{ __html: stripStructuralTags(s(settings.html)) }} />
|
||||||
|
) : null;
|
||||||
|
case "image-box":
|
||||||
|
return <ImageBoxWidget settings={settings} />;
|
||||||
|
case "icon-box":
|
||||||
|
return <IconBoxWidget settings={settings} />;
|
||||||
|
case "eael-info-box":
|
||||||
|
return <EaelInfoBoxWidget settings={settings} />;
|
||||||
|
case "flip-box":
|
||||||
|
return <FlipBoxWidget settings={settings} />;
|
||||||
|
case "eael-adv-tabs":
|
||||||
|
return <AdvTabsWidget settings={settings} />;
|
||||||
|
case "eael-data-table":
|
||||||
|
return <DataTableWidget settings={settings} />;
|
||||||
|
case "eael-testimonial":
|
||||||
|
return <TestimonialWidget settings={settings} />;
|
||||||
|
case "reviews":
|
||||||
|
return <ReviewsWidget settings={settings} />;
|
||||||
|
case "icon-list":
|
||||||
|
return <IconListWidget settings={settings} />;
|
||||||
|
case "spacer":
|
||||||
|
return <div style={{ height: sizeCss(settings.space, "px") || "20px" }} />;
|
||||||
|
case "divider":
|
||||||
|
return <hr style={{ border: 0, borderTop: `1px solid ${s(settings.color) || "#e3e9f0"}`, margin: "18px 0" }} />;
|
||||||
|
case "toggle":
|
||||||
|
case "accordion":
|
||||||
|
return <AccordionWidget settings={settings} />;
|
||||||
|
case "tabs":
|
||||||
|
return <TabsWidget settings={settings} />;
|
||||||
|
case "slides":
|
||||||
|
return <SlidesWidget settings={settings} />;
|
||||||
|
case "eael-content-ticker":
|
||||||
|
return <TickerWidget settings={settings} />;
|
||||||
|
case "posts":
|
||||||
|
return <PostsWidget settings={settings} />;
|
||||||
|
case "social-icons":
|
||||||
|
return <SocialIconsWidget settings={settings} />;
|
||||||
|
case "eael-contact-form-7":
|
||||||
|
return <ContactFormWidget settings={settings} />;
|
||||||
|
case "video":
|
||||||
|
return <VideoWidget settings={settings} />;
|
||||||
|
case "counter":
|
||||||
|
return (
|
||||||
|
<div className="mb-[18px]" style={{ textAlign: "center" }}>
|
||||||
|
<div style={{ fontSize: 34, fontWeight: 700, color: "var(--brand)" }}>
|
||||||
|
{s(settings.prefix)}
|
||||||
|
{s(settings.ending_number) || "0"}
|
||||||
|
{s(settings.suffix)}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "var(--muted)" }}>{s(settings.title)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "shortcode":
|
||||||
|
return null; // WordPress shortcode 无法在 Node 端执行,忽略
|
||||||
|
case "code-highlight":
|
||||||
|
return s(settings.source_code) ? (
|
||||||
|
<pre className="mb-[18px]" style={{ background: "#0b1c2c", color: "#d7e5f5", padding: 16, borderRadius: 8, overflowX: "auto" }}>
|
||||||
|
<code>{s(settings.source_code)}</code>
|
||||||
|
</pre>
|
||||||
|
) : null;
|
||||||
|
default:
|
||||||
|
return unknownFallback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 heading widget:标题级别/颜色/对齐 */
|
||||||
|
function HeadingWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||||
|
const size = s(settings.header_size) || "h2";
|
||||||
|
const Tag = (["h1", "h2", "h3", "h4", "h5", "h6"].includes(size) ? size : "h2") as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
||||||
|
const style: CSSProperties = {};
|
||||||
|
if (s(settings.title_color)) style.color = s(settings.title_color);
|
||||||
|
const align = s(settings.align);
|
||||||
|
if (align) style.textAlign = align as CSSProperties["textAlign"];
|
||||||
|
return <Tag className="mb-[18px]" style={style} dangerouslySetInnerHTML={{ __html: s(settings.title) }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 text-editor widget:富文本 HTML */
|
||||||
|
function TextEditorWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||||
|
const html = s(settings.editor);
|
||||||
|
if (!html) return null;
|
||||||
|
// 文本颜色可能来自全局色(如蓝底白字副标题),style 方式应用
|
||||||
|
const color = s(settings.text_color);
|
||||||
|
return <div className="mb-[18px]" style={color ? { color } : undefined} dangerouslySetInnerHTML={{ __html: html }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 image widget */
|
||||||
|
function ImageWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||||
|
const url = (settings.image as { url?: string } | undefined)?.url;
|
||||||
|
if (!url) return null;
|
||||||
|
const align = s(settings.align);
|
||||||
|
return (
|
||||||
|
<div className="mb-[18px]" style={{ textAlign: (align as CSSProperties["textAlign"]) || "center" }}>
|
||||||
|
<img src={url} alt={s((settings.image as { alt?: string }).alt) || ""} loading="lazy" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** # 组件 slides widget(Elementor 原生全宽轮播:背景图 + description HTML)
|
||||||
|
* 对齐原站取证值(见 app.css 轮播注释块):
|
||||||
|
* - 切换为水平推移(原站 swiper transition=slide,speed 500ms),激活 slide 内容
|
||||||
|
* 播放 fadeInUp(原站 data-animation=fadeInUp,duration 1s);
|
||||||
|
* - 自动播放 5000ms、悬停暂停(原站 autoplay_speed=5000 + pause_on_hover=yes);
|
||||||
|
* - slide 高度/内边距/箭头/圆点样式在 app.css 取证常量。
|
||||||
|
* 每张 slide 的标题/按钮内嵌在 description 自定义 HTML(ele-ban-* class)。
|
||||||
|
*/
|
||||||
|
function SlidesWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||||
|
const slides = (settings.slides as Array<Record<string, unknown>>) || [];
|
||||||
|
const [idx, setIdx] = useState(0);
|
||||||
|
const [pause, setPause] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (slides.length < 2 || pause) return;
|
||||||
|
const t = setInterval(() => setIdx((i) => (i + 1) % slides.length), 5000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [slides.length, pause]);
|
||||||
|
if (!slides.length) return null;
|
||||||
|
const height = sizeCss(settings.slides_height, "px") || "560px";
|
||||||
|
const go = (d: number) => setIdx((i) => (i + d + slides.length) % slides.length);
|
||||||
|
return (
|
||||||
|
<div className="el-slides" style={{ height }}
|
||||||
|
onMouseEnter={() => setPause(true)} onMouseLeave={() => setPause(false)}>
|
||||||
|
{slides.map((sl, i) => {
|
||||||
|
const bg = (sl.background_image as { url?: string } | undefined)?.url;
|
||||||
|
// 水平推移:相邻 offset,轮播任意距离;非激活 slide 不捕捉指针
|
||||||
|
return (
|
||||||
|
<div key={(sl._id as string) || i} className={"el-slide" + (i === idx ? " active" : "")}
|
||||||
|
style={{ backgroundImage: bg ? `url(${bg})` : undefined, transform: `translateX(${(i - idx) * 100}%)` }}>
|
||||||
|
<div className="el-slide-inner">
|
||||||
|
<div className="el-slide-contents">
|
||||||
|
<div className="el-slide-desc" dangerouslySetInnerHTML={{ __html: s(sl.description) }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{slides.length > 1 ? (
|
||||||
|
<>
|
||||||
|
<button className="el-slide-arrow prev" aria-label="Previous slide" onClick={() => go(-1)}>
|
||||||
|
<svg viewBox="0 0 1000 1000" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<path d="M646 125C629 125 613 133 604 142L308 442C296 454 292 471 292 487 292 504 296 521 308 533L604 854C617 867 629 875 646 875 663 875 679 871 692 858 704 846 713 829 713 812 713 796 708 779 692 767L438 487 692 225C700 217 708 204 708 187 708 171 704 154 692 142 675 129 663 125 646 125Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button className="el-slide-arrow next" aria-label="Next slide" onClick={() => go(1)}>
|
||||||
|
<svg viewBox="0 0 1000 1000" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<path d="M696 533C708 521 713 504 713 487 713 471 708 454 696 446L400 146C388 133 375 125 354 125 338 125 325 129 313 142 300 154 292 171 292 187 292 204 296 221 308 233L563 492 304 771C292 783 288 800 288 817 288 833 296 850 308 863 321 871 338 875 354 875 371 875 388 867 400 854L696 533Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div className="el-slide-dots">
|
||||||
|
{slides.map((_, i) => (
|
||||||
|
<button key={i} aria-label={"Go to slide " + (i + 1)} className={i === idx ? "on" : ""} onClick={() => setIdx(i)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 eael-content-ticker(标签 + 最新内容标题轮播,淡入切换)
|
||||||
|
* 交互/样式全部在 TickerBar(复用组件)中按原站实测值还原;
|
||||||
|
* 数据:product → 最新发布产品(created_at 降序,与原站一致),post → 最新文章。
|
||||||
|
*/
|
||||||
|
function TickerWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||||
|
const postType = s(settings.post_type) || "post";
|
||||||
|
const perPage = Number(s(settings.posts_per_page)) || 3;
|
||||||
|
const tagText = s(settings.eael_ticker_tag_text);
|
||||||
|
const speed = Number((settings.slider_speed as Record<string, unknown> | undefined)?.size) || 200;
|
||||||
|
const interval = Number((settings.autoplay_speed as Record<string, unknown> | undefined)?.size) || 5000;
|
||||||
|
const [titles, setTitles] = useState<LatestItem[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const rows = postType === "product" ? await getLatestProducts({ data: { limit: perPage } }) : await getLatestPosts();
|
||||||
|
if (alive) setTitles(rows.slice(0, perPage));
|
||||||
|
} catch { /* 数据不可用时静默 */ }
|
||||||
|
})();
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, [postType, perPage]);
|
||||||
|
if (!titles.length) return null;
|
||||||
|
return <TickerBar badge={tagText} items={titles} speed={speed} interval={interval} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 posts widget(Elementor Pro 卡片皮肤:文章/产品卡片网格) */
|
||||||
|
function PostsWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||||
|
const perPage = Number(s(settings.cards_posts_per_page)) || 3;
|
||||||
|
const [posts, setPosts] = useState<Array<{ title: string; slug: string; excerpt: string | null; publishedAt: Date | string | null; image: string | null }>>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
getLatestPosts()
|
||||||
|
.then((rows) => { if (alive) setPosts(rows.slice(0, perPage).map((r) => ({ title: r.title, slug: r.url, excerpt: null as string | null, publishedAt: null, image: null }))); })
|
||||||
|
.catch(() => {});
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, [perPage]);
|
||||||
|
if (!posts.length) return null;
|
||||||
|
return (
|
||||||
|
<div className="mx-auto grid max-w-[1140px] grid-cols-1 gap-[22px] px-6 py-2.5 min-[900px]:grid-cols-3">
|
||||||
|
{posts.map((p) => (
|
||||||
|
<a
|
||||||
|
className="block overflow-hidden rounded-lg border border-line bg-white text-inherit transition-shadow hover:shadow-[0_10px_26px_rgba(16,42,76,0.12)]"
|
||||||
|
key={p.slug}
|
||||||
|
href={"/blog/" + p.slug + "/"}
|
||||||
|
>
|
||||||
|
{p.image ? (
|
||||||
|
<div className="aspect-video overflow-hidden bg-[#f2f5f9]">
|
||||||
|
<img src={p.image} alt="" loading="lazy" className="h-full w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="px-4 pt-3.5 pb-[18px]">
|
||||||
|
<h3 className="mt-0 mb-1.5 text-[17px] leading-[1.4]">{p.title}</h3>
|
||||||
|
{p.publishedAt ? <time className="text-[13px] text-muted-foreground">{String(p.publishedAt).slice(0, 10)}</time> : null}
|
||||||
|
{p.excerpt ? <p className="mt-2 mb-0 line-clamp-2 text-sm text-muted-foreground">{p.excerpt}</p> : null}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 social-icons widget(图标行) */
|
||||||
|
function SocialIconsWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||||
|
const items = (settings.social_icon_list as Array<Record<string, unknown>>) || [];
|
||||||
|
return (
|
||||||
|
<div className="mb-[18px] flex gap-2.5">
|
||||||
|
{items.map((it, i) => {
|
||||||
|
const icon = ((it.social_icon as { value?: string })?.value || "").replace(/fa-brands |fa-solid |fa- /g, "");
|
||||||
|
return (
|
||||||
|
<a key={i} className="flex size-[34px] items-center justify-center rounded-full bg-soft text-ink hover:bg-brand hover:text-white" href={s((it.link as { url?: string })?.url) || "#"} target="_blank" rel="noreferrer" aria-label={icon}>
|
||||||
|
<span className={"soc " + icon} />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** # 组件 eael-contact-form-7(EAEL Contact Form 7 皮肤):渲染内置询盘表单 */
|
||||||
|
function ContactFormWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-[18px] rounded-md bg-white px-7 py-[26px]">
|
||||||
|
<InquiryForm formKey="contact-us-form" title={undefined} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 button widget */
|
||||||
|
function ButtonWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||||
|
const text = s(settings.text);
|
||||||
|
const link = (settings.link as { url?: string; target?: string } | undefined)?.url;
|
||||||
|
const style: CSSProperties = {
|
||||||
|
display: "inline-block",
|
||||||
|
padding: "10px 24px",
|
||||||
|
borderRadius: 4,
|
||||||
|
background: s(settings.background_color) || "var(--brand)",
|
||||||
|
color: s(settings.button_text_color) || s(settings.text_color) || "#fff",
|
||||||
|
fontWeight: 600,
|
||||||
|
textDecoration: "none",
|
||||||
|
};
|
||||||
|
if (s(settings.size) === "lg") style.padding = "14px 32px";
|
||||||
|
if (s(settings.size) === "sm") style.padding = "7px 16px";
|
||||||
|
if (!link && !text) return null;
|
||||||
|
return (
|
||||||
|
<div className="mb-[18px]" style={{ textAlign: (s(settings.align) as CSSProperties["textAlign"]) || "left" }}>
|
||||||
|
<a href={link || "#"} style={style} target={(settings.link as { target?: string } | undefined)?.target === "_blank" ? "_blank" : undefined} rel="noreferrer">
|
||||||
|
{text}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 image-box widget:图 + 标题 + 描述(忠实复刻原站 Elementor DOM)
|
||||||
|
* 结构(image-box.php render 对应,见 legacy 镜像):
|
||||||
|
* .el-imagebox > .el-imagebox-wrapper(text-align center) >
|
||||||
|
* figure.el-imagebox-img(≥768 position-top 时 margin auto 居中;≤767 margin-bottom 15px)
|
||||||
|
* > [a(tabindex -1) > img]
|
||||||
|
* + .el-imagebox-content > <Tag>title</Tag> + <p>description</p>;
|
||||||
|
* figure 内的 a 与标题内 a 是两个独立链接(原站同一 link 设置、各自 target=_blank);
|
||||||
|
* title 标签由 title_size 决定(默认 h3);颜色继承 body(原站 .elementor-image-box-title a
|
||||||
|
* color:inherit → 实测 #333),hover 提亮等由页面级规则([data-el] 范围)定义。
|
||||||
|
* 复用:首页 H2 六卡(图上标题下)与 H13/Difference Maker 六卡同源。
|
||||||
|
*/
|
||||||
|
function ImageBoxWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||||
|
const url = (settings.image as { url?: string } | undefined)?.url;
|
||||||
|
const title = s(settings.title_text);
|
||||||
|
const desc = s(settings.description_text);
|
||||||
|
if (!url && !title && !desc) return null;
|
||||||
|
const link = (settings.link as { url?: string } | undefined)?.url;
|
||||||
|
const isExternal = (settings.link as { is_external?: string } | undefined)?.is_external === "on";
|
||||||
|
const pos = s(settings.position);
|
||||||
|
// 原站未设置时渲染 elementor-position-top(控件默认 top,与原站 HTML 一致)
|
||||||
|
const posCls = " el-imagebox-pos-" + (pos || "top");
|
||||||
|
const alt = title.replace(/<[^>]*>/g, "").trim();
|
||||||
|
// link 通用属性(is_external → target=_blank,与原站 elementor link_attributes 一致)
|
||||||
|
const linkProps = link
|
||||||
|
? { href: link, ...(isExternal ? { target: "_blank" as const } : {}) }
|
||||||
|
: undefined;
|
||||||
|
// title 标签:title_size 合法值校验(原站 Utils::validate_html_tag),默认 h3
|
||||||
|
const tagOpt = s(settings.title_size) || "h3";
|
||||||
|
const tag = (["h1", "h2", "h3", "h4", "h5", "h6", "div", "span", "p"].includes(tagOpt)
|
||||||
|
? tagOpt
|
||||||
|
: "h3") as "div";
|
||||||
|
const Tag = tag as "div";
|
||||||
|
return (
|
||||||
|
<div className={"el-imagebox" + posCls}>
|
||||||
|
<div className="el-imagebox-wrapper">
|
||||||
|
{url ? (
|
||||||
|
<figure className="el-imagebox-img">
|
||||||
|
{link ? (
|
||||||
|
<a {...linkProps} tabIndex={-1}><img src={url} alt={alt} loading="lazy" /></a>
|
||||||
|
) : (
|
||||||
|
<img src={url} alt={alt} loading="lazy" />
|
||||||
|
)}
|
||||||
|
</figure>
|
||||||
|
) : null}
|
||||||
|
{title || desc ? (
|
||||||
|
<div className="el-imagebox-content">
|
||||||
|
{title ? (
|
||||||
|
<Tag className="el-imagebox-title">
|
||||||
|
{link ? (
|
||||||
|
<a {...linkProps} dangerouslySetInnerHTML={{ __html: title }} />
|
||||||
|
) : (
|
||||||
|
// 无链接时:原站文本直接落在标签内,这里用内联 span 承载(不改变样式)
|
||||||
|
<span dangerouslySetInnerHTML={{ __html: title }} />
|
||||||
|
)}
|
||||||
|
</Tag>
|
||||||
|
) : null}
|
||||||
|
{desc ? <p className="el-imagebox-description" dangerouslySetInnerHTML={{ __html: desc }} /> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 icon-box / eael-info-box:图标可跳过(emoji 不渲染),只保留标题与描述 */
|
||||||
|
function IconBoxWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||||
|
const title = s((settings as Record<string, unknown>).title_text) || s((settings as Record<string, unknown>).title);
|
||||||
|
const desc = s((settings as Record<string, unknown>).description_text) || s((settings as Record<string, unknown>).description);
|
||||||
|
const icon = (settings as Record<string, unknown>).selected_icon as { value?: string } | undefined;
|
||||||
|
const titleColor = s((settings as Record<string, unknown>).title_color) || s((settings as Record<string, unknown>).button_text_color);
|
||||||
|
// 图标是 emoji/unicode 字符或字体图标类:一律不渲染为图标,跳过
|
||||||
|
void icon;
|
||||||
|
if (!title && !desc) return null;
|
||||||
|
return (
|
||||||
|
<div className="mb-[18px]" style={{ border: "1px solid var(--line)", borderRadius: 8, padding: 18 }}>
|
||||||
|
{title ? <h3 style={{ margin: "0 0 8px", fontSize: 17, ...(titleColor ? { color: titleColor } : {}) }} dangerouslySetInnerHTML={{ __html: title }} /> : null}
|
||||||
|
{desc ? <div style={{ color: "var(--muted)" }} dangerouslySetInnerHTML={{ __html: desc }} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 eael-info-box(EAEL Infobox 皮肤):标题(.title,custom_css 提供左侧图标背景) + 富文本描述(.infobox-content)
|
||||||
|
* 与原生 icon-box 的键不同:内容在 eael_infobox_title / eael_infobox_text。
|
||||||
|
*/
|
||||||
|
function EaelInfoBoxWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||||
|
const title = s(settings.eael_infobox_title) || s(settings.title_text) || s(settings.title);
|
||||||
|
const desc = s(settings.eael_infobox_text) || s(settings.description_text) || s(settings.description);
|
||||||
|
if (!title && !desc) return null;
|
||||||
|
const tag = s(settings.eael_infobox_title_tag);
|
||||||
|
const Tag = (["h1", "h2", "h3", "h4", "h5", "h6", "div"].includes(tag) ? tag : "div") as "div";
|
||||||
|
return (
|
||||||
|
<div className="eael-infobox mb-[18px]">
|
||||||
|
{title ? <Tag className="title" dangerouslySetInnerHTML={{ __html: title }} /> : null}
|
||||||
|
{desc ? <div className="infobox-content" dangerouslySetInnerHTML={{ __html: desc }} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 flip-box(翻转框):静态渲染正面(标题/描述/按钮 + 可选背景图) */
|
||||||
|
function FlipBoxWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||||
|
const title = s(settings.title_text_a);
|
||||||
|
const desc = s(settings.description_text_a);
|
||||||
|
const btn = s(settings.button_text);
|
||||||
|
const link = (settings.link as { url?: string } | undefined)?.url;
|
||||||
|
const bg = settings.background_a_image as { url?: string } | undefined;
|
||||||
|
if (!title && !desc) return null;
|
||||||
|
const style: CSSProperties = {
|
||||||
|
minHeight: sizeCss(settings.height) || "300px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 10,
|
||||||
|
padding: 24,
|
||||||
|
borderRadius: 6,
|
||||||
|
color: "#fff",
|
||||||
|
};
|
||||||
|
if (bg?.url) {
|
||||||
|
style.backgroundImage = `url("${bg.url}")`;
|
||||||
|
style.backgroundSize = "cover";
|
||||||
|
style.backgroundPosition = "center";
|
||||||
|
}
|
||||||
|
const box = (
|
||||||
|
<div style={style}>
|
||||||
|
{title ? <h3 style={{ margin: 0, fontSize: 22 }}>{title}</h3> : null}
|
||||||
|
{desc ? <div dangerouslySetInnerHTML={{ __html: desc }} /> : null}
|
||||||
|
{btn ? (
|
||||||
|
<span className="inline-flex w-fit items-center rounded bg-[var(--brand)] px-4 py-2 text-sm text-white">{btn}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return link ? (
|
||||||
|
<a href={link} className="mb-[18px] block">
|
||||||
|
{box}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div className="mb-[18px]">{box}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 eael-adv-tabs(EAEL 高级标签页):结构同 tabs,键名不同 */
|
||||||
|
function AdvTabsWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||||
|
const raw = Array.isArray(settings.eael_adv_tabs_tab)
|
||||||
|
? (settings.eael_adv_tabs_tab as { eael_adv_tabs_tab_title?: string; eael_adv_tabs_tab_content?: string }[])
|
||||||
|
: [];
|
||||||
|
const items = raw
|
||||||
|
.map((t) => ({ title: s(t.eael_adv_tabs_tab_title), content: s(t.eael_adv_tabs_tab_content) }))
|
||||||
|
.filter((t) => t.title || t.content);
|
||||||
|
if (!items.length) return null;
|
||||||
|
return (
|
||||||
|
<Tabs defaultValue="adv-0" className="mb-[18px]">
|
||||||
|
<TabsList>
|
||||||
|
{items.map((t, i) => (
|
||||||
|
<TabsTrigger key={i} value={`adv-${i}`}>
|
||||||
|
{t.title}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
{items.map((t, i) => (
|
||||||
|
<TabsContent key={i} value={`adv-${i}`}>
|
||||||
|
<div className="content-html" dangerouslySetInnerHTML={{ __html: t.content }} />
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 eael-data-table:表头 + 行渲染为普通表格(行可为字符串或单元格数组) */
|
||||||
|
function DataTableWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||||
|
const headers = Array.isArray(settings.eael_data_table_header_cols_data)
|
||||||
|
? (settings.eael_data_table_header_cols_data as { eael_data_table_header_col?: string }[]).map((h) => s(h.eael_data_table_header_col))
|
||||||
|
: [];
|
||||||
|
const rows = Array.isArray(settings.eael_data_table_content_rows) ? (settings.eael_data_table_content_rows as unknown[]) : [];
|
||||||
|
if (!headers.length && !rows.length) return null;
|
||||||
|
// 单元格:行内 eael_data_table_content_row_title 可能是字符串或 {title/content} 数组
|
||||||
|
const cells = (r: unknown): string[] => {
|
||||||
|
const v = (r as Record<string, unknown>)?.eael_data_table_content_row_title;
|
||||||
|
const pick = (x: unknown) => s(typeof x === "object" && x ? ((x as Record<string, unknown>).title ?? (x as Record<string, unknown>).content) : x);
|
||||||
|
return Array.isArray(v) ? v.map(pick) : [pick(v)];
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<table className="mb-[18px] w-full border-collapse text-sm">
|
||||||
|
{headers.length ? (
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{headers.map((h, i) => (
|
||||||
|
<th key={i} className="border border-[var(--line)] bg-[#f4f5f8] px-3 py-2 text-left">
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
) : null}
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
{cells(r).map((c, j) => (
|
||||||
|
<td key={j} className="border border-[var(--line)] px-3 py-2" dangerouslySetInnerHTML={{ __html: c }} />
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 eael-testimonial(评价引用):姓名/公司/描述 */
|
||||||
|
function TestimonialWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||||
|
const name = s(settings.eael_testimonial_name);
|
||||||
|
const company = s(settings.eael_testimonial_company_title);
|
||||||
|
const desc = s(settings.eael_testimonial_description);
|
||||||
|
if (!name && !desc) return null;
|
||||||
|
return (
|
||||||
|
<blockquote className="mb-[18px] rounded-md border p-4">
|
||||||
|
{desc ? <div className="content-html" dangerouslySetInnerHTML={{ __html: desc }} /> : null}
|
||||||
|
{name ? <footer className="mt-2 font-medium">{name}{company ? <span className="text-muted-foreground"> · {company}</span> : null}</footer> : null}
|
||||||
|
</blockquote>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 reviews(评价轮播):静态卡片网格(slides 复用:name/title/rating/content/image) */
|
||||||
|
function ReviewsWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||||
|
const slides = Array.isArray(settings.slides)
|
||||||
|
? (settings.slides as { name?: string; title?: string; rating?: string; content?: string; image?: { url?: string; alt?: string } }[])
|
||||||
|
: [];
|
||||||
|
if (!slides.length) return null;
|
||||||
|
const stars = (n: string) => "★★★★★".slice(0, Math.max(1, Math.min(5, Math.round(Number(n) || 5))));
|
||||||
|
return (
|
||||||
|
<div className="mb-[18px] grid gap-4 min-[760px]:grid-cols-2">
|
||||||
|
{slides.map((it, i) => (
|
||||||
|
<div key={i} className="rounded-md border p-4">
|
||||||
|
<div className="text-[var(--brand)]">{stars(s(it.rating))}</div>
|
||||||
|
{s(it.content) ? <div className="content-html mt-2" dangerouslySetInnerHTML={{ __html: s(it.content) }} /> : null}
|
||||||
|
<div className="mt-3 flex items-center gap-3">
|
||||||
|
{it.image?.url ? <img src={it.image.url} alt={it.image.alt || it.name || ""} className="h-10 w-10 rounded-full object-cover" /> : null}
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{s(it.name)}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{s(it.title)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 icon-list widget:仅渲染文字列表,emoji 图标跳过 */
|
||||||
|
function IconListWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||||
|
const list = Array.isArray(settings.icon_list) ? (settings.icon_list as { text?: string; selected_icon?: { value?: string } }[]) : [];
|
||||||
|
const items = list.map((it) => s(it.text)).filter(Boolean);
|
||||||
|
if (!items.length) return null;
|
||||||
|
return (
|
||||||
|
<ul className="mb-[18px]" style={{ listStyle: "none", padding: 0, margin: "0 0 18px" }}>
|
||||||
|
{items.map((t, i) => {
|
||||||
|
// 文本本身以 emoji 开头时保留原样输出(内容照原样,只是不做图标渲染处理)
|
||||||
|
return (
|
||||||
|
<li key={i} style={{ marginBottom: 6, paddingLeft: 16, position: "relative" }}>
|
||||||
|
<span style={{ position: "absolute", left: 0, color: "var(--brand)" }}>•</span>
|
||||||
|
{t}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 toggle / accordion widget:手风琴 */
|
||||||
|
function AccordionWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||||
|
const tabs = Array.isArray(settings.tabs) ? (settings.tabs as { tab_title?: string; tab_content?: string }[]) : [];
|
||||||
|
if (!tabs.length) return null;
|
||||||
|
return (
|
||||||
|
<div className="mb-[18px]">
|
||||||
|
<Accordion type="single" collapsible defaultValue="item-0">
|
||||||
|
{tabs.map((t, i) => (
|
||||||
|
<AccordionItem key={i} value={`item-${i}`}>
|
||||||
|
<AccordionTrigger>{s(t.tab_title)}</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<div className="content-html" dangerouslySetInnerHTML={{ __html: s(t.tab_content) }} />
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 tabs widget:标签页 */
|
||||||
|
function TabsWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||||
|
const tabs = Array.isArray(settings.tabs) ? (settings.tabs as { tab_title?: string; tab_content?: string }[]) : [];
|
||||||
|
if (!tabs.length) return null;
|
||||||
|
return (
|
||||||
|
<Tabs defaultValue="tab-0" className="mb-[18px]">
|
||||||
|
<TabsList>
|
||||||
|
{tabs.map((t, i) => (
|
||||||
|
<TabsTrigger key={i} value={`tab-${i}`}>
|
||||||
|
{s(t.tab_title)}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
{tabs.map((t, i) => (
|
||||||
|
<TabsContent key={i} value={`tab-${i}`}>
|
||||||
|
<div className="content-html" dangerouslySetInnerHTML={{ __html: s(t.tab_content) }} />
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 video widget:YouTube iframe 嵌入 */
|
||||||
|
function VideoWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||||
|
let raw = s((settings.youtube_url as { url?: string } | undefined)?.url) || s(settings.youtube_url) || s(settings.video_url) || s((settings as Record<string, unknown>).url);
|
||||||
|
if (!raw) return null;
|
||||||
|
// 把 youtube watch 链接转为 embed
|
||||||
|
const m = raw.match(/[?&]v=([\w-]{6,})/) || raw.match(/youtu\.be\/([\w-]{6,})/);
|
||||||
|
const src = m ? `https://www.youtube.com/embed/${m[1]}` : raw;
|
||||||
|
return (
|
||||||
|
<div className="relative mb-[18px] pt-[56.25%]">
|
||||||
|
<iframe src={src} title="video" allowFullScreen className="absolute inset-0 h-full w-full border-0" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 从任意 meta 对象中安全提取 elementor 文档(含类型收窄) */
|
||||||
|
export function extractElementor(meta: unknown): ElDocument | undefined {
|
||||||
|
if (!meta || typeof meta !== "object") return undefined;
|
||||||
|
const el = (meta as Record<string, unknown>).elementor;
|
||||||
|
if (Array.isArray(el)) return el as ElElement[];
|
||||||
|
if (el && typeof el === "object") {
|
||||||
|
const els = (el as { elements?: unknown }).elements;
|
||||||
|
if (Array.isArray(els)) return el as ElDocument;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 兜底渲染:有 elementor 用渲染器,否则输出 html,否则标题+摘要 */
|
||||||
|
export function ContentOrFallback(props: { elementor?: ElDocument; html?: string | null; fallback?: ReactNode }) {
|
||||||
|
if (props.elementor) return <ElementorRenderer doc={props.elementor} />;
|
||||||
|
if (props.html) return <div className="content-html" dangerouslySetInnerHTML={{ __html: props.html }} />;
|
||||||
|
return <>{props.fallback ?? null}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* # 模块 Elementor 文档瘦身
|
||||||
|
* WordPress 导入的 Elementor JSON 包含大量桌面/平板/移动端响应式字段与空值,
|
||||||
|
* 整份文档会随 TanStack Router 脱水序列化进 SSR HTML(首页曾达 391KB),
|
||||||
|
* 严重影响 SEO 与首字节体积。本模块只保留渲染器实际读取的字段。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** # 成员变量 渲染器(Renderer.tsx)实际读取的 settings 字段白名单 **/
|
||||||
|
const SETTING_KEYS = new Set([
|
||||||
|
// 通用布局/背景/边框
|
||||||
|
"__globals__",
|
||||||
|
"align",
|
||||||
|
"background_background",
|
||||||
|
"background_color",
|
||||||
|
"background_color_b",
|
||||||
|
"background_color_stop_b",
|
||||||
|
"background_image",
|
||||||
|
"border_border",
|
||||||
|
"border_color",
|
||||||
|
"border_radius",
|
||||||
|
"border_width",
|
||||||
|
"content_width",
|
||||||
|
"custom_height",
|
||||||
|
"padding",
|
||||||
|
"layout",
|
||||||
|
"height",
|
||||||
|
"size",
|
||||||
|
"space",
|
||||||
|
"color",
|
||||||
|
"text_color",
|
||||||
|
"title_color",
|
||||||
|
"button_text_color",
|
||||||
|
// image-box 系列:图定位(position 默认 top)与标题标签(title_size 默认 h3)
|
||||||
|
"position",
|
||||||
|
"title_size",
|
||||||
|
"gradient_angle",
|
||||||
|
"color_a",
|
||||||
|
"color_b",
|
||||||
|
"header_size",
|
||||||
|
// 列结构
|
||||||
|
"_column_size",
|
||||||
|
"_inline_size",
|
||||||
|
// 各 widget 数据
|
||||||
|
"heading_title",
|
||||||
|
"title",
|
||||||
|
"title_text",
|
||||||
|
"description",
|
||||||
|
"description_text",
|
||||||
|
"selected_icon",
|
||||||
|
"url",
|
||||||
|
"width",
|
||||||
|
"text",
|
||||||
|
"editor",
|
||||||
|
"html",
|
||||||
|
"source_code",
|
||||||
|
"image",
|
||||||
|
"link",
|
||||||
|
"prefix",
|
||||||
|
"suffix",
|
||||||
|
"ending_number",
|
||||||
|
"icon_list",
|
||||||
|
"social_icon_list",
|
||||||
|
"slides",
|
||||||
|
"slides_height",
|
||||||
|
"tabs",
|
||||||
|
"video_url",
|
||||||
|
"youtube_url",
|
||||||
|
"map",
|
||||||
|
"posts_per_page",
|
||||||
|
"cards_posts_per_page",
|
||||||
|
"post_type",
|
||||||
|
"eael_ticker_tag_text",
|
||||||
|
// EAEL Infobox / flip-box / 高级标签页 / 数据表 / 评价等(Renderer 对应组件读取)
|
||||||
|
"custom_css",
|
||||||
|
"_css_classes",
|
||||||
|
"eael_infobox_title",
|
||||||
|
"eael_infobox_text",
|
||||||
|
"eael_infobox_title_tag",
|
||||||
|
"title_text_a",
|
||||||
|
"description_text_a",
|
||||||
|
"button_text",
|
||||||
|
"background_a_image",
|
||||||
|
"eael_adv_tabs_tab",
|
||||||
|
"eael_data_table_header_cols_data",
|
||||||
|
"eael_data_table_content_rows",
|
||||||
|
"eael_testimonial_name",
|
||||||
|
"eael_testimonial_company_title",
|
||||||
|
"eael_testimonial_description",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** # 函数 收窄对象型 settings 值(渲染器只读 url/alt/target 等少数字段)
|
||||||
|
* 参数 key settings 键名;val 原始值
|
||||||
|
* 返回值 收窄后的值;非命中键原样返回
|
||||||
|
*/
|
||||||
|
function slimSettingValue(key: string, val: unknown): unknown {
|
||||||
|
if (!val || typeof val !== "object" || Array.isArray(val)) return val;
|
||||||
|
const v = val as Record<string, unknown>;
|
||||||
|
if (key === "image" || key === "background_image" || key === "background_a_image") {
|
||||||
|
// 渲染器只读 url / alt
|
||||||
|
return { url: v.url, alt: v.alt };
|
||||||
|
}
|
||||||
|
if (key === "link") {
|
||||||
|
// 渲染器只读 url / target / is_external(is_external=on → target=_blank,与原站 link_attributes 一致)
|
||||||
|
return { url: v.url, target: v.target, is_external: v.is_external };
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 递归瘦身单个 Elementor 元素
|
||||||
|
* 参数 el 原始元素(section/column/widget 通用结构)
|
||||||
|
* 返回值 仅含 id/elType/widgetType/isInner/白名单 settings/elements 的新对象
|
||||||
|
*/
|
||||||
|
function slimElement(el: unknown): unknown {
|
||||||
|
if (!el || typeof el !== "object") return el;
|
||||||
|
const src = el as Record<string, unknown>;
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
if (src.id != null) out.id = src.id;
|
||||||
|
if (src.elType != null) out.elType = src.elType;
|
||||||
|
if (src.widgetType != null) out.widgetType = src.widgetType;
|
||||||
|
if (src.isInner) out.isInner = true;
|
||||||
|
const st = src.settings;
|
||||||
|
if (st && typeof st === "object" && !Array.isArray(st)) {
|
||||||
|
const slim: Record<string, unknown> = {};
|
||||||
|
for (const key of Object.keys(st as Record<string, unknown>)) {
|
||||||
|
if (SETTING_KEYS.has(key)) slim[key] = slimSettingValue(key, (st as Record<string, unknown>)[key]);
|
||||||
|
}
|
||||||
|
if (Object.keys(slim).length > 0) out.settings = slim;
|
||||||
|
}
|
||||||
|
if (Array.isArray(src.elements) && src.elements.length > 0) {
|
||||||
|
out.elements = src.elements.map(slimElement);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 瘦身整份 Elementor 文档(数组或 {elements:[]} 包装均支持)
|
||||||
|
* 参数 doc 原始文档(来自 meta.elementor)
|
||||||
|
* 返回值 瘦身后的文档;非对象/数组输入原样返回
|
||||||
|
*/
|
||||||
|
export function slimElementorDoc(doc: unknown): unknown {
|
||||||
|
if (Array.isArray(doc)) return doc.map(slimElement);
|
||||||
|
if (doc && typeof doc === "object" && Array.isArray((doc as Record<string, unknown>).elements)) {
|
||||||
|
const wrapped = doc as Record<string, unknown>;
|
||||||
|
return { elements: (wrapped.elements as unknown[]).map(slimElement) };
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 瘦身内容 meta:替换 meta.elementor 为瘦身版(原 meta 其余字段保留)
|
||||||
|
* 参数 meta 原始 meta JSON(可能为 null)
|
||||||
|
* 返回值 elementor 已瘦身的 meta;无 elementor 时原样返回
|
||||||
|
*/
|
||||||
|
export function slimMetaElementor<T>(meta: T): T {
|
||||||
|
if (!meta || typeof meta !== "object") return meta;
|
||||||
|
const m = meta as Record<string, unknown>;
|
||||||
|
if (!m.elementor) return meta;
|
||||||
|
return { ...m, elementor: slimElementorDoc(m.elementor) } as T;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* # 组件 shadcn/ui Accordion(Radix)
|
||||||
|
* 对齐原 .el-accordion:浅底标题、line 色分隔、6px 圆角。
|
||||||
|
*/
|
||||||
|
import * as React from "react";
|
||||||
|
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
/** # 组件 手风琴容器 */
|
||||||
|
function Accordion({ className, ...props }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||||
|
return <AccordionPrimitive.Root className={cn("overflow-hidden rounded-md border border-line", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 手风琴项 */
|
||||||
|
function AccordionItem({ className, ...props }: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||||
|
return <AccordionPrimitive.Item className={cn("border-b border-line last:border-b-0", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 手风琴标题(浅底、600 字重) */
|
||||||
|
function AccordionTrigger({ className, children, ...props }: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 cursor-pointer items-center justify-between bg-soft px-4 py-3 text-left text-[15px] font-semibold text-ink transition-colors hover:text-brand",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDown size={16} aria-hidden className="shrink-0 text-muted-foreground transition-transform duration-200 [[data-state=open]>&]:rotate-180" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 手风琴内容 */
|
||||||
|
function AccordionContent({ className, children, ...props }: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Content className="overflow-hidden text-sm" {...props}>
|
||||||
|
<div className={cn("px-4 py-3", className)}>{children}</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* # 组件 shadcn/ui Badge
|
||||||
|
* 状态徽标:库存/订单/支付状态等;色板对齐原 .badge ok/warn。
|
||||||
|
*/
|
||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
/** # 成员变量 徽标变体样式 **/
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border-line bg-soft text-muted-foreground",
|
||||||
|
outline: "border-line bg-white text-muted-foreground",
|
||||||
|
success: "border-[#b5e3c4] bg-[#e7f6ec] text-[#15803d]",
|
||||||
|
warning: "border-[#f2ddb0] bg-[#fef7e6] text-[#92600a]",
|
||||||
|
danger: "border-[#f3c1c1] bg-[#fdeaea] text-[#b91c1c]",
|
||||||
|
brand: "border-brand bg-brand text-white",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: "default" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** # 类型 Badge 属性 **/
|
||||||
|
function Badge({ className, variant, ...props }: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||||
|
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* # 组件 shadcn/ui Breadcrumb
|
||||||
|
* 对齐原 .breadcrumb:13px 灰蓝、分隔符浅色。
|
||||||
|
*/
|
||||||
|
import * as React from "react";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
/** # 组件 面包屑容器 */
|
||||||
|
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return <nav aria-label="Breadcrumb" className={cn("py-3.5 text-[13px] text-muted-foreground", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 面包屑列表 */
|
||||||
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||||
|
return <ol className={cn("flex flex-wrap items-center gap-[7px]", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 面包屑项 */
|
||||||
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return <li className={cn("inline-flex items-center gap-[7px]", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 面包屑链接(仅用于无路由参数的固定路径;带参数的链接请直接用 Link) */
|
||||||
|
function BreadcrumbLink({ className, ...props }: React.ComponentProps<typeof Link>) {
|
||||||
|
return <Link className={cn("text-muted-foreground hover:text-brand", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 面包屑分隔符 */
|
||||||
|
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span role="presentation" aria-hidden="true" className={cn("text-[#b6c2cf]", className)} {...props}>
|
||||||
|
{children ?? <ChevronRight size={12} aria-hidden />}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 面包屑当前页 */
|
||||||
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return <span aria-current="page" className={cn("text-muted-foreground", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator, BreadcrumbPage };
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* # 组件 shadcn/ui Button
|
||||||
|
* 变体与尺寸对齐原模板 .btn 体系:主色橙、4px 圆角、600 字重。
|
||||||
|
*/
|
||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
/** # 成员变量 按钮变体样式(primary/outline/ghost/link 对齐原 .btn-*) **/
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-[4px] text-sm font-semibold transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-60 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-brand text-white hover:bg-brand-dark",
|
||||||
|
outline: "border border-brand bg-white text-brand hover:bg-soft",
|
||||||
|
ghost: "text-ink hover:bg-soft",
|
||||||
|
link: "text-brand underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "px-[22px] py-[10px]",
|
||||||
|
sm: "px-4 py-2 text-[13px]",
|
||||||
|
lg: "px-8 py-3.5 text-[15px]",
|
||||||
|
icon: "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: "default", size: "default" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** # 类型 Button 属性(支持 asChild 渲染为链接) **/
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return <Comp className={cn(buttonVariants({ variant, size }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* # 组件 shadcn/ui Card
|
||||||
|
* 对齐原 .card:8px 圆角、line 色边框;悬停抬升效果由使用方按需追加。
|
||||||
|
*/
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
/** # 组件 卡片容器 */
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return <div className={cn("overflow-hidden rounded-lg border border-line bg-white text-ink", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 卡片内容区(原 .card-body:上下 14/18、左右 16) */
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return <div className={cn("px-4 pt-3.5 pb-[18px]", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Card, CardContent };
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* # 组件 shadcn/ui Input
|
||||||
|
* 对齐原表单输入:4px 圆角、line 色 1px 边框、聚焦变品牌橙。
|
||||||
|
*/
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
/** # 组件 文本输入框 */
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-[38px] w-full rounded-[4px] border border-line bg-white px-3 py-1 text-sm text-ink transition-colors",
|
||||||
|
"placeholder:text-muted-foreground/60 focus-visible:border-brand focus-visible:outline-none",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-60",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input };
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* # 组件 shadcn/ui Label
|
||||||
|
* 表单字段标签。
|
||||||
|
*/
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
/** # 组件 标签 */
|
||||||
|
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||||
|
return <label className={cn("flex flex-col gap-[5px] text-sm font-medium text-ink", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label };
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* # 组件 shadcn/ui NavigationMenu
|
||||||
|
* 基于 @radix-ui/react-navigation-menu,用于顶部主导航(含巨幕面板)。
|
||||||
|
* 样式对齐原站 Max Mega Menu(megamenu 内联 CSS 实测值):
|
||||||
|
* - 顶级项:高 68px、16px/400/#222,hover/展开仅文字变橙(背景不变);
|
||||||
|
* 指示器为实心三角 ▾(dashicons arrow-down 等价物),展开不旋转、颜色随文字。
|
||||||
|
* - 巨幕/飞出面板:#fcfbfa 底 + 顶部 2px #f5b279 橙线 + 0 10px 30px 阴影,直角。
|
||||||
|
* 切换动效保留 shadcn/ui(tw-animate-css 的 fade/zoom/slide + Radix motion 属性)。
|
||||||
|
*/
|
||||||
|
import * as React from "react";
|
||||||
|
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
/** # 组件 导航菜单根容器 */
|
||||||
|
function NavigationMenu({ className, children, viewportPosition = "inline", ...props }: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & { viewportPosition?: "inline" | "full" }) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Root data-viewport={viewportPosition} className={cn("relative z-[90] flex max-w-max items-center justify-center", className)} {...props}>
|
||||||
|
{children}
|
||||||
|
{viewportPosition === "full" ? <ViewportFullWidth /> : <ViewportCentered />}
|
||||||
|
</NavigationMenuPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 导航菜单列表(顶级项容器)
|
||||||
|
* p-0:本项目不引入 Tailwind preflight,需重置 UL 的浏览器默认左内边距。
|
||||||
|
*/
|
||||||
|
function NavigationMenuList({ className, ...props }: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||||
|
return <NavigationMenuPrimitive.List className={cn("m-0 flex flex-1 list-none items-center justify-center gap-0.5 p-0", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 导航菜单项 */
|
||||||
|
function NavigationMenuItem({ className, ...props }: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||||
|
return <NavigationMenuPrimitive.Item className={cn("relative", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 类型 NavigationMenuTrigger 属性 */
|
||||||
|
const triggerStyle =
|
||||||
|
"inline-flex h-[68px] w-max cursor-pointer items-center justify-center gap-[6px] rounded-none border-0 bg-transparent py-0 pr-[6px] pl-2 text-[16px] font-normal whitespace-nowrap text-[#222] transition-colors hover:text-brand focus-visible:outline-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 data-[state=open]:text-brand";
|
||||||
|
|
||||||
|
/** # 组件 导航菜单触发器(顶级可展开项)
|
||||||
|
* chevron=false 时子元素原样透传(asChild 场景:Radix Slot 要求 children 是
|
||||||
|
* 单个 React 元素,不能被 JSX 双槽包装成数组)。
|
||||||
|
*/
|
||||||
|
function NavigationMenuTrigger({ className, children, chevron = true, ...props }: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger> & { chevron?: boolean }) {
|
||||||
|
const content = chevron ? (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
<CaretDown />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Trigger className={cn(triggerStyle, "group", className)} {...props}>
|
||||||
|
{content}
|
||||||
|
</NavigationMenuPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 导航菜单内容面板(下拉/巨幕内容)
|
||||||
|
* data-[motion] 动画类依赖 tw-animate-css(app.css 已引入)。
|
||||||
|
*/
|
||||||
|
function NavigationMenuContent({ className, ...props }: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||||
|
return <NavigationMenuPrimitive.Content className={cn("data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion=from-end]:fade-in data-[motion=from-start]:fade-in data-[motion=to-end]:fade-out data-[motion=to-start]:fade-out", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 导航菜单链接(无下级的普通链接项) */
|
||||||
|
function NavigationMenuLink({ className, ...props }: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||||
|
return <NavigationMenuPrimitive.Link className={cn(triggerStyle, className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 巨幕面板标题(面板内小标题/副标题) */
|
||||||
|
function NavigationMenuHeading({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return <div className={cn("text-[16px] leading-relaxed font-bold text-[#222]", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 实心下三角指示器(等价原站 dashicons arrow-down,展开不旋转,颜色随文字) */
|
||||||
|
function CaretDown() {
|
||||||
|
return (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="shrink-0">
|
||||||
|
<path d="M4.5 7h11L10 13.5 4.5 7Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 居中式视口(小尺寸下拉用)
|
||||||
|
* 面板 chrome 对齐原站 flyout:#fcfbfa 底 + 顶部 2px 橙线 + 直角 + 大阴影。
|
||||||
|
*/
|
||||||
|
function ViewportCentered() {
|
||||||
|
return (
|
||||||
|
<div className="absolute top-full left-0 flex w-max justify-center">
|
||||||
|
<NavigationMenuPrimitive.Viewport className="relative mt-0 h-[var(--radix-navigation-menu-viewport-height)] w-[var(--radix-navigation-menu-viewport-width)] origin-top overflow-hidden rounded-none border-t-2 border-panel-line bg-panel shadow-[0_10px_30px_rgba(13,21,46,0.1)] transition-[width,height] duration-300 data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:zoom-in-95" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 全宽视口(巨幕面板用:固定在导航条下方、水平居中)
|
||||||
|
* 导航条(topbar 40px + 主导航 68px)sticky 置顶,故固定定位 top-108px 与其底边对齐;
|
||||||
|
* 面板宽度按原站实测 1340px(1436 视口),窄视口收窄到留 16px 边距。
|
||||||
|
*/
|
||||||
|
function ViewportFullWidth() {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-x-0 top-[108px] z-[90] flex justify-center">
|
||||||
|
<NavigationMenuPrimitive.Viewport className="relative h-[var(--radix-navigation-menu-viewport-height)] w-[calc(100%-32px)] max-w-[1340px] origin-top overflow-visible rounded-none border-t-2 border-panel-line bg-panel shadow-[0_10px_30px_rgba(13,21,46,0.1)] transition-[height] duration-300 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:pointer-events-none data-[state=closed]:slide-out-to-top-2 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { NavigationMenu, NavigationMenuList, NavigationMenuItem, NavigationMenuTrigger, NavigationMenuContent, NavigationMenuLink, NavigationMenuHeading, CaretDown };
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* # 组件 shadcn/ui Pagination(链接型)
|
||||||
|
* 对齐原 .pagination:36px 最小宽方框、当前页品牌橙填充。
|
||||||
|
*/
|
||||||
|
import * as React from "react";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
/** # 组件 分页容器 */
|
||||||
|
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return <nav aria-label="Pagination" className={cn("mx-0 my-8 flex flex-wrap justify-center gap-1.5", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 分页内容列表 */
|
||||||
|
function PaginationContent({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
|
return <ul className={cn("flex flex-wrap items-center gap-1.5", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 分页项 */
|
||||||
|
function PaginationItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return <li className={cn("", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 类型 分页链接属性(href 由调用方生成,isActive 标记当前页) **/
|
||||||
|
function PaginationLink({ className, isActive, disabled, children, ...props }: Omit<React.ComponentProps<typeof Link>, "children"> & { isActive?: boolean; disabled?: boolean; children?: React.ReactNode }) {
|
||||||
|
if (disabled) {
|
||||||
|
return <span aria-disabled className={cn(baseLink, "pointer-events-none text-[#b6c2cf]", className)}>{children}</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
className={cn(baseLink, isActive && "border-brand bg-brand text-white", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 成员变量 分页链接基础样式 **/
|
||||||
|
const baseLink =
|
||||||
|
"inline-flex h-9 min-w-9 items-center justify-center rounded-[4px] border border-line px-2.5 text-sm text-ink hover:border-brand hover:text-brand";
|
||||||
|
|
||||||
|
/** # 组件 分页省略号 */
|
||||||
|
function PaginationEllipsis({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span aria-hidden className={cn("pointer-events-none inline-flex h-9 min-w-9 items-center justify-center text-[#b6c2cf]", className)} {...props}>
|
||||||
|
…
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationEllipsis };
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* # 组件 shadcn/ui Select(Radix)
|
||||||
|
* 用于页脚语言切换等下拉场景;触发器/面板样式可通过 className 覆写。
|
||||||
|
*/
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
|
import { Check, ChevronDown } from "lucide-react";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
/** # 组件 Select 根 */
|
||||||
|
function Select(props: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 Select 触发器 */
|
||||||
|
function SelectTrigger({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
className={cn(
|
||||||
|
"flex h-[38px] w-full items-center justify-between rounded-[4px] border border-line bg-white px-3 py-2 text-sm text-ink transition-colors",
|
||||||
|
"focus:border-brand focus:outline-none disabled:cursor-not-allowed disabled:opacity-60 [&>span]:truncate",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown size={16} aria-hidden className="shrink-0 opacity-60" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 Select 选中值 */
|
||||||
|
function SelectValue(props: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 Select 下拉面板 */
|
||||||
|
function SelectContent({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
position="popper"
|
||||||
|
className={cn(
|
||||||
|
"z-[120] max-h-72 min-w-[8rem] overflow-hidden rounded-md border border-line bg-white text-sm text-ink shadow-[0_8px_24px_rgba(16,42,76,0.12)]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Viewport className="p-1">{children}</SelectPrimitive.Viewport>
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 Select 选项 */
|
||||||
|
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-pointer items-center rounded-[4px] py-1.5 pl-8 pr-3 text-sm outline-none select-none",
|
||||||
|
"focus:bg-soft data-[state=checked]:font-semibold data-[state=checked]:text-brand",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex size-4 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check size={14} aria-hidden />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Select, SelectTrigger, SelectValue, SelectContent, SelectItem };
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* # 组件 shadcn/ui Tabs(Radix)
|
||||||
|
* 对齐原 .el-tabs:底部 2px 线、激活项品牌橙下划线。
|
||||||
|
*/
|
||||||
|
import * as React from "react";
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
/** # 组件 Tabs 容器 */
|
||||||
|
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||||
|
return <TabsPrimitive.Root className={cn("", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 标签栏 */
|
||||||
|
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||||
|
return <TabsPrimitive.List className={cn("mb-[-2px] flex flex-wrap gap-1 border-b-2 border-line", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 标签触发器 */
|
||||||
|
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer border-0 border-b-2 border-transparent bg-none px-4 py-2.5 text-[15px] font-semibold text-muted-foreground transition-colors",
|
||||||
|
"hover:text-ink data-[state=active]:border-brand data-[state=active]:text-brand",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 标签内容 */
|
||||||
|
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
|
return <TabsPrimitive.Content className={cn("pt-3.5 focus-visible:outline-none", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* # 组件 shadcn/ui Textarea
|
||||||
|
* 样式与 Input 一致,多行文本。
|
||||||
|
*/
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
/** # 组件 多行文本框 */
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-16 w-full rounded-[4px] border border-line bg-white px-3 py-2 text-sm text-ink transition-colors",
|
||||||
|
"placeholder:text-muted-foreground/60 focus-visible:border-brand focus-visible:outline-none",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-60",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* # 组件 网站页脚 Footer(对齐原站,Tailwind + shadcn/ui 实现)
|
||||||
|
* 深灰底两段:上段 4 个菜单组;下段左侧品牌区(logo + 简介 + 社交 + 语言下拉),
|
||||||
|
* 右侧 3 个菜单组;底部版权条。
|
||||||
|
* 数据来自导入的 footer 菜单(menu-2);为空时降级为内置链接。
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Headset } from "lucide-react";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||||
|
import { useT } from "~/i18n";
|
||||||
|
import { LOCALES, getUiLocale, setUiLocale } from "~/i18n/shared";
|
||||||
|
import type { MenuNode } from "~/server/data";
|
||||||
|
|
||||||
|
/** # 类型 页脚产品分类节点(Layout 接口兼容保留) */
|
||||||
|
export type FooterCategory = { id: number; name: string; slug: string; parentId: number | null; count: number };
|
||||||
|
|
||||||
|
/** # 常量 无菜单数据时的降级分组 */
|
||||||
|
const FALLBACK_GROUPS: MenuNode[] = [
|
||||||
|
{ id: -1, title: "System on Modules", url: "/collection/", target: "", children: [] },
|
||||||
|
{ id: -2, title: "ODM/OEM Services", url: "/contact-us/", target: "", children: [] },
|
||||||
|
{ id: -3, title: "Multiprotocol IoT Gateway", url: "/collection/", target: "", children: [] },
|
||||||
|
{ id: -4, title: "One-Stop Hardware Solutions", url: "/collection/", target: "", children: [] },
|
||||||
|
{ id: -5, title: "About Dusun", url: "/about-us/", target: "", children: [{ id: -51, title: "Contact us", url: "/contact-us/", target: "", children: [] }] },
|
||||||
|
{ id: -6, title: "Developer Support", url: "/blog/", target: "", children: [] },
|
||||||
|
{ id: -7, title: "POLICY", url: "/about-us/", target: "", children: [] },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** # 组件 页脚 */
|
||||||
|
export function Footer(props: { categories: FooterCategory[]; menu: MenuNode[] }) {
|
||||||
|
const t = useT();
|
||||||
|
const groups = props.menu.length > 0 ? props.menu : FALLBACK_GROUPS;
|
||||||
|
// 前 4 组为上段列,后 3 组为下段右侧列(与原站 footer 菜单顺序一致)
|
||||||
|
const topGroups = groups.slice(0, 4);
|
||||||
|
const bottomGroups = groups.slice(4, 7);
|
||||||
|
return (
|
||||||
|
<footer className="mt-14 bg-footer text-[13px] text-[#c9c9ca]">
|
||||||
|
<div className="site-container">
|
||||||
|
<div className="grid grid-cols-2 gap-7 pt-[42px] pb-[26px] min-[900px]:grid-cols-4">
|
||||||
|
{topGroups.map((g) => (
|
||||||
|
<FooterGroup key={g.id} group={g} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-white/12" />
|
||||||
|
<div className="grid grid-cols-1 gap-10 py-[30px] pb-9 min-[900px]:grid-cols-[1.3fr_2fr]">
|
||||||
|
<div>
|
||||||
|
{/* 直接复用原站页脚 logo(同图:wp-content/uploads/2023/01/dusun-iot-logo.png,实测渲染 144.77×42.73px) */}
|
||||||
|
<a href="/" className="inline-block">
|
||||||
|
<img src="/uploads/2023/01/dusun-iot-logo.png" width={254} height={75} alt="Zhejiang Dusun Electron Co., Ltd." className="h-auto w-[145px]" />
|
||||||
|
</a>
|
||||||
|
<p className="mt-3.5 mb-4 leading-[1.7] text-[#b5b5b6]">{t("footer_intro")}</p>
|
||||||
|
<div className="mb-4 flex gap-2.5">
|
||||||
|
<a href="https://www.linkedin.com/company/dusun-iot" aria-label="LinkedIn" target="_blank" rel="noreferrer" className="inline-flex size-7 items-center justify-center rounded bg-white/8 text-[#dcdcdc] hover:bg-brand hover:text-white">
|
||||||
|
<LinkedinGlyph />
|
||||||
|
</a>
|
||||||
|
<a href="https://www.youtube.com/@dusuniot" aria-label="YouTube" target="_blank" rel="noreferrer" className="inline-flex size-7 items-center justify-center rounded bg-white/8 text-[#dcdcdc] hover:bg-brand hover:text-white">
|
||||||
|
<YoutubeGlyph />
|
||||||
|
</a>
|
||||||
|
<a href="https://www.facebook.com/dusuniot" aria-label="Facebook" target="_blank" rel="noreferrer" className="inline-flex size-7 items-center justify-center rounded bg-white/8 text-[#dcdcdc] hover:bg-brand hover:text-white">
|
||||||
|
<FacebookGlyph />
|
||||||
|
</a>
|
||||||
|
<a href="/contact-us/" aria-label="Contact" className="inline-flex size-7 items-center justify-center rounded bg-white/8 text-[#dcdcdc] hover:bg-brand hover:text-white">
|
||||||
|
<Headset size={15} aria-hidden />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<FooterLangSelect />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-7 min-[640px]:grid-cols-3">
|
||||||
|
{bottomGroups.map((g) => (
|
||||||
|
<FooterGroup key={g.id} group={g} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-footer-dark py-3.5 text-center text-xs text-[#a9a9aa]">
|
||||||
|
<div className="site-container flex justify-center">
|
||||||
|
<span>
|
||||||
|
Copyright ©2026 <a href="/" className="text-brand hover:text-[#ff8a2a]">Dusuniot</a>. {t("footer_rights")} |{" "}
|
||||||
|
<a href="/sitemap.xml" className="text-brand hover:text-[#ff8a2a]">{t("footer_sitemap")}</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 页脚语言下拉(选择后写 ui_lang cookie 并刷新,与 Header 切换共用 i18n) */
|
||||||
|
function FooterLangSelect() {
|
||||||
|
// 语言值客户端挂载后再读 cookie,避免 SSR 水合不一致
|
||||||
|
const [lang, setLang] = useState("en");
|
||||||
|
useEffect(() => setLang(getUiLocale()), []);
|
||||||
|
return (
|
||||||
|
<Select value={lang} onValueChange={(v) => v !== lang && setUiLocale(v)}>
|
||||||
|
<SelectTrigger aria-label="Language selector" className="w-[180px] border-white/25 bg-white/6 text-white">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{LOCALES.map((l) => (
|
||||||
|
<SelectItem key={l.code} value={l.code}>
|
||||||
|
{l.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 页脚菜单组(橙色标题 + 子链接列表;无子项时仅渲染标题链接) */
|
||||||
|
function FooterGroup({ group }: { group: MenuNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-3.5 text-[13px] font-bold tracking-[0.8px] text-brand uppercase">{group.title}</h4>
|
||||||
|
{group.children.length > 0 ? (
|
||||||
|
<ul className="m-0 list-none p-0">
|
||||||
|
{group.children.map((c) => (
|
||||||
|
<li key={c.id} className="mb-[9px]">
|
||||||
|
<a href={c.url || "#"} target={c.target === "_blank" ? "_blank" : undefined} className="text-[#c9c9ca] hover:text-white">
|
||||||
|
{c.title}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : group.url && group.url !== "#" ? (
|
||||||
|
<ul className="m-0 list-none p-0">
|
||||||
|
<li className="mb-[9px]">
|
||||||
|
<a href={group.url} className="text-[#c9c9ca] hover:text-white">
|
||||||
|
{group.title}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 页脚社交图标(lucide 新版无品牌图标,内联 SVG) */
|
||||||
|
function YoutubeGlyph() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M23 7.6a3 3 0 0 0-2.1-2.2C19 4.9 12 4.9 12 4.9s-7 0-8.9.5A3 3 0 0 0 1 7.6 32 32 0 0 0 .6 12 32 32 0 0 0 1 16.4a3 3 0 0 0 2.1 2.1c1.9.6 8.9.6 8.9.6s7 0 8.9-.6a3 3 0 0 0 2.1-2.1A32 32 0 0 0 23.4 12 32 32 0 0 0 23 7.6ZM9.8 15.1V8.9l5.8 3.1-5.8 3.1Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function FacebookGlyph() {
|
||||||
|
return (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M13.5 21v-8h2.7l.4-3.1h-3.1V7.9c0-.9.3-1.5 1.6-1.5h1.6V3.6c-.3 0-1.3-.1-2.4-.1-2.4 0-4 1.4-4 4.1v2.3H7.5V13h2.8v8h3.2Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function LinkedinGlyph() {
|
||||||
|
return (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M4.98 3.5A2.5 2.5 0 1 1 5 8.5a2.5 2.5 0 0 1-.02-5ZM3 9.5h4V21H3V9.5Zm6.5 0h3.8v1.6h.1c.5-1 1.8-2 3.7-2 4 0 4.7 2.6 4.7 6V21h-4v-5.2c0-1.2 0-2.9-1.8-2.9s-2.1 1.4-2.1 2.8V21h-4V9.5Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
/**
|
||||||
|
* # 组件 网站顶部导航 Header(对齐原站,Tailwind + shadcn/ui 实现)
|
||||||
|
* 结构:深灰 topbar(口号 + 社交/登录)+ 白底主导航(logo + NavigationMenu +
|
||||||
|
* 搜索图标 + CONTACT US + 邮件/语言图标)。移动端汉堡菜单(纯 React state)。
|
||||||
|
*
|
||||||
|
* 桌面导航用 shadcn/ui NavigationMenu(Radix)实现,巨幕面板逐列复刻原站
|
||||||
|
* Max Mega Menu 布局与样式(对原站 megamenu 内联 CSS + 无头浏览器 computed style
|
||||||
|
* 的逐项实测结论,2026-10):
|
||||||
|
* - 顶级条:高 68px、16px/400/#222,hover/展开文字变橙,实心三角指示器
|
||||||
|
* - Product:4 列巨幕(SoM | 主板+SBC+图 | 灰底 IoT 网关宽列(双子栏+芯片logo) | 其它产品)
|
||||||
|
* - Services:3 列(图 | ODM/OEM 服务 | 开发者支持),分组头带 #fad3b3 下边线
|
||||||
|
* - Solutions:4 列分组 + 首列「一站式硬件方案」橙色标题
|
||||||
|
* - Resources:原站为自定义 HTML 标签面板(博客/新闻/认证表格),本站以资源链接面板呈现
|
||||||
|
* - Success Stories:普通链接;Company:flyout 下拉列表
|
||||||
|
* - 面板 chrome:#fcfbfa 底 + 顶部 2px #f5b279 橙线 + 0 10px 30px 阴影,直角
|
||||||
|
* - 分组头三级样式:橙 18px(星标) / 橙 16px / 深色加粗+#e0e5f9 下边线;叶子 16px/400
|
||||||
|
* 切换动效为 shadcn/ui(tw-animate-css fade/zoom/slide),与原站视觉分离保留。
|
||||||
|
* 菜单数据来自 DB max_mega_menu_1(getMenuTree);面板列布局/样式以标题匹配的
|
||||||
|
* 配置常量描述(menu_items 表不存布局信息,widget 图片不在菜单表内,只能配置化)。
|
||||||
|
*/
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { Search, Menu, X, ChevronRight, Mail, User, Star } from "lucide-react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import {
|
||||||
|
NavigationMenu,
|
||||||
|
NavigationMenuList,
|
||||||
|
NavigationMenuItem,
|
||||||
|
NavigationMenuTrigger,
|
||||||
|
NavigationMenuContent,
|
||||||
|
CaretDown,
|
||||||
|
} from "~/components/ui/navigation-menu";
|
||||||
|
import { LangSwitcher } from "~/components/website/LangSwitcher";
|
||||||
|
import { useT } from "~/i18n";
|
||||||
|
import type { MenuNode } from "~/server/data";
|
||||||
|
|
||||||
|
/** # 类型 巨幕面板列配置(groups 引用 menu_items 表里的分组标题) */
|
||||||
|
type MegaCol = {
|
||||||
|
/** # 成员变量 本列分组标题清单(与 DB 菜单标题一致) **/
|
||||||
|
groups?: string[];
|
||||||
|
/** # 成员变量 列顶标题(原站 Solutions 首列「一站式硬件方案」) **/
|
||||||
|
heading?: string;
|
||||||
|
/** # 成员变量 列内双子栏分组(原站 IoT 网关灰底宽列:左「By Application/多协议」右「平台支持」) **/
|
||||||
|
subCols?: string[][];
|
||||||
|
/** # 成员变量 列底部 widget 图片(原站 media_image widget) **/
|
||||||
|
image?: { src: string; width: number; height: number; alt: string };
|
||||||
|
/** # 成员变量 列底部芯片厂商 logo 行(原站 6 个 media_image widget) **/
|
||||||
|
logos?: boolean;
|
||||||
|
/** # 成员变量 灰底列(原站实测 #f4f5f8,仅 IoT 网关宽列使用) **/
|
||||||
|
grey?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** # 成员变量 Product 巨幕 4 列布局(列宽比按原站实测 239:253:519:319)
|
||||||
|
* 注意 menu_items 里 Product 的子项是「平铺的分组」(NXP Serials / By Application /
|
||||||
|
* Other Products 的子分组均为 Product 直接子级),原站同列多个分组靠 CSS 呈现
|
||||||
|
* 层级;这里按原站视觉把它们编入同一列。灰底宽列含双子栏与芯片 logo 行。
|
||||||
|
**/
|
||||||
|
const PRODUCT_COLS: MegaCol[] = [
|
||||||
|
{ groups: ["System on Modules >", "NXP Serials", "Rockchip Serials", "MediaTek Serials"] },
|
||||||
|
{ groups: ["Embedded Mainboard", "Custom Industrial SBCs"], image: { src: "/uploads/2024/08/som-board.png", width: 211, height: 137, alt: "som board" } },
|
||||||
|
{ grey: true, groups: ["IoT Gateway >"], subCols: [["By Application", "Multi Protocols Support"], ["Platform/Software Support"]], logos: true },
|
||||||
|
{ groups: ["Other Products", "Remote Control >", "Far-Field Voice >", "BLE Beacon >", "Control Board/IoT Terminal Devices >", "Smart Sensors >"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** # 成员变量 Services 巨幕 3 列布局(列宽比按原站实测 230:460:230;分组头带浅橙下边线) **/
|
||||||
|
const SERVICES_COLS: MegaCol[] = [
|
||||||
|
{ image: { src: "/uploads/2024/08/odm-oem-service-dusuniot.jpg", width: 234, height: 300, alt: "ODM/OEM Services" } },
|
||||||
|
{ groups: ["ODM/OEM Services >"] },
|
||||||
|
{ groups: ["Developer Support"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** # 成员变量 Solutions 巨幕 4 列布局(列宽比按原站实测 289:246:278:257,首列带「一站式硬件方案」标题) **/
|
||||||
|
const SOLUTIONS_COLS: MegaCol[] = [
|
||||||
|
{ groups: ["Smart Control", "Building Automation"], heading: "One-Stop Hardware Solutions" },
|
||||||
|
{ groups: ["Healthcare IoT", "Smart Home"] },
|
||||||
|
{ groups: ["Security & Automation", "Smart Retail >"] },
|
||||||
|
{ groups: ["Embedded Applications"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** # 成员变量 橙色星标分组头(原站 dashicons-star-filled + mega-has-icon) **/
|
||||||
|
const STAR_GROUPS = new Set(["System on Modules >", "Embedded Mainboard", "IoT Gateway >", "ODM/OEM Services >"]);
|
||||||
|
/** # 成员变量 橙色 18px 无星分组头(原站对应列标题,实测 computed style) **/
|
||||||
|
const ORANGE_GROUPS = new Set(["Other Products", "Custom Industrial SBCs", "Developer Support", "Embedded Applications"]);
|
||||||
|
/** # 成员变量 橙色 16px 小分组头(原站 Other Products 列内嵌套橙色组,实测 16px/700) **/
|
||||||
|
const ORANGE_SM_GROUPS = new Set(["Control Board/IoT Terminal Devices >", "Smart Sensors >"]);
|
||||||
|
/** # 成员变量 深色加粗但无下边线的分组头(原站 Smart Retail >,实测无 border-bottom) **/
|
||||||
|
const DARK_NOLINE_GROUPS = new Set(["Smart Retail >"]);
|
||||||
|
|
||||||
|
/** # 成员变量 Resources 面板链接(原站为自定义 HTML 标签面板;此处收录其全部资源入口,本地均已 200) **/
|
||||||
|
const RESOURCES_LINKS: { title: string; url: string; note: string }[] = [
|
||||||
|
{ title: "Blog", url: "/blog/", note: "博客归档" },
|
||||||
|
{ title: "News", url: "/news/", note: "新闻归档" },
|
||||||
|
{ title: "Gateway Articles", url: "/gateway/", note: "网关技术文章归档" },
|
||||||
|
{ title: "Certification", url: "/certification/", note: "认证页(原站 Resources 面板标签)" },
|
||||||
|
{ title: "Hardware News", url: "/resources/hardware-news/", note: "原站 Resources 面板链接" },
|
||||||
|
{ title: "Software Update & News", url: "/resources/software-updates/", note: "原站 Resources 面板链接" },
|
||||||
|
{ title: "WiKi Resources", url: "https://wiki.dusuniot.com/", note: "原站 Services 面板同款外链" },
|
||||||
|
{ title: "Videos", url: "https://www.youtube.com/channel/UCyb4PpqVgvKgC9KpkByZaaQ", note: "原站 Services 面板同款外链" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** # 类型 Header 属性:菜单树由服务端 loader 传入 */
|
||||||
|
export function Header(props: { menu: MenuNode[] }) {
|
||||||
|
const t = useT();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
// 菜单为空时使用内置后备导航(数据导入未完成时优雅降级)
|
||||||
|
const items: MenuNode[] =
|
||||||
|
props.menu.length > 0
|
||||||
|
? props.menu
|
||||||
|
: [
|
||||||
|
{ id: -1, title: "Home", url: "/", target: "", children: [] },
|
||||||
|
{ id: -2, title: "Products", url: "/collection/", target: "", children: [] },
|
||||||
|
{ id: -3, title: "Blog", url: "/blog/", target: "", children: [] },
|
||||||
|
{ id: -4, title: "Contact Us", url: "/contact-us/", target: "", children: [] },
|
||||||
|
];
|
||||||
|
// 原站顶栏不含「Search」菜单项(搜索是独立图标),过滤掉
|
||||||
|
const tops = items.filter((it) => it.title !== "Search");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-[100] border-b border-line bg-white shadow-[0_1px_6px_rgba(16,42,76,0.06)]">
|
||||||
|
{/* topbar:口号 + 社交图标 + 登录 */}
|
||||||
|
<div className="bg-footer text-[13px] text-white">
|
||||||
|
<div className="site-container flex h-10 items-center justify-between">
|
||||||
|
<a href="/landing-pages/oem-odm/" className="text-white hover:text-[#f5b278]">
|
||||||
|
{t("topbar_oem")}
|
||||||
|
</a>
|
||||||
|
<div className="flex items-center gap-3.5">
|
||||||
|
<a href="https://www.youtube.com/@dusuniot" aria-label="YouTube" target="_blank" rel="noreferrer" className="text-white hover:text-[#f5b278]">
|
||||||
|
<YoutubeGlyph />
|
||||||
|
</a>
|
||||||
|
<a href="https://www.facebook.com/dusuniot" aria-label="Facebook" target="_blank" rel="noreferrer" className="text-white hover:text-[#f5b278]">
|
||||||
|
<FacebookGlyph />
|
||||||
|
</a>
|
||||||
|
<a href="https://www.linkedin.com/company/dusun-iot" aria-label="LinkedIn" target="_blank" rel="noreferrer" className="text-white hover:text-[#f5b278]">
|
||||||
|
<LinkedinGlyph />
|
||||||
|
</a>
|
||||||
|
<span className="h-3.5 w-px bg-white/35" aria-hidden />
|
||||||
|
<a href="/my-account/" className="inline-flex items-center gap-1 text-white hover:text-[#f5b278]">
|
||||||
|
<User size={15} aria-hidden /> {t("login")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 主导航(原站实测:白底条高 68px,topbar 40px,合计 108px) */}
|
||||||
|
<div className="site-container flex h-[68px] items-center gap-6">
|
||||||
|
{/* 直接复用原站 header logo(Elementor site-logo 同一图片,实测渲染 171×42.75px) */}
|
||||||
|
<Link to="/" className="shrink-0">
|
||||||
|
<img src="/uploads/2022/06/Dusuniot-logo-R-400-e1669108867399.png" width={300} height={75} alt="Dusun IoT" className="h-auto w-[171px]" />
|
||||||
|
</Link>
|
||||||
|
<DesktopNav tops={tops} />
|
||||||
|
{searchOpen ? (
|
||||||
|
<form action="/search" method="get" className="flex items-center overflow-hidden rounded-full border border-line bg-soft">
|
||||||
|
<Input type="search" name="q" placeholder={t("search_placeholder")} aria-label="Search" autoFocus className="w-40 rounded-none border-0 bg-transparent focus-visible:border-0" />
|
||||||
|
<button type="submit" aria-label="Submit search" className="flex cursor-pointer items-center border-0 bg-transparent px-2.5 py-1.5 text-muted-foreground">
|
||||||
|
<Search size={16} aria-hidden />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<button aria-label="Open search" onClick={() => setSearchOpen(true)} className="flex cursor-pointer items-center border-0 bg-transparent p-1.5 text-ink">
|
||||||
|
<Search size={19} aria-hidden />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Button asChild className="hidden whitespace-nowrap px-[18px] py-[9px] min-[960px]:inline-flex">
|
||||||
|
<a href="/contact-us/">{t("contact_us")}</a>
|
||||||
|
</Button>
|
||||||
|
<a href="/contact-us/" aria-label="Email us" className="hidden items-center p-1 text-brand min-[960px]:flex">
|
||||||
|
<Mail size={19} aria-hidden />
|
||||||
|
</a>
|
||||||
|
<LangSwitcher />
|
||||||
|
<button aria-label="Toggle menu" onClick={() => setOpen(!open)} className="ml-auto cursor-pointer rounded-[4px] border-0 bg-transparent p-1.5 text-ink min-[960px]:hidden">
|
||||||
|
{open ? <X size={24} aria-hidden /> : <Menu size={24} aria-hidden />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* 移动端菜单 */}
|
||||||
|
{open ? (
|
||||||
|
<div className="border-t border-line bg-white px-4 pt-2 pb-4 min-[960px]:hidden">
|
||||||
|
{tops.map((it) => (
|
||||||
|
<MobileNavItem key={it.id} node={it} onNavigate={() => setOpen(false)} />
|
||||||
|
))}
|
||||||
|
<a href="/contact-us/" onClick={() => setOpen(false)} className="block border-b border-line px-1 py-[9px] font-medium text-ink">
|
||||||
|
{t("contact_us")}
|
||||||
|
</a>
|
||||||
|
<form action="/search" method="get" className="mt-2.5 flex gap-2">
|
||||||
|
<input type="search" name="q" placeholder={t("search_placeholder_m")} className="min-w-0 flex-1 rounded-[4px] border border-line px-2.5 py-2" />
|
||||||
|
<Button type="submit" size="sm">
|
||||||
|
{t("search_go")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 桌面主导航(shadcn/ui NavigationMenu)
|
||||||
|
* 按原站菜单顺序逐项渲染,每个顶级项一个 NavigationMenu 根(视口形态不同:
|
||||||
|
* 巨幕项用全宽视口,Company 用居中小视口,Success Stories 是纯链接)。
|
||||||
|
* 独立根互不干扰,指针移出根范围即收起;根间距用外层 gap 保持一致。
|
||||||
|
*/
|
||||||
|
function DesktopNav({ tops }: { tops: MenuNode[] }) {
|
||||||
|
return (
|
||||||
|
<div className="hidden min-[960px]:flex min-[960px]:flex-1 min-[960px]:items-center gap-0.5">
|
||||||
|
{tops.map((node) => {
|
||||||
|
const linkHref = TOP_URL_FIX[node.title] ?? normalizeUrl(node.url) ?? undefined;
|
||||||
|
// 巨幕面板项(全宽视口)
|
||||||
|
if (node.title === "Product" || node.title === "Services" || node.title === "Solutions" || node.title === "Resources") {
|
||||||
|
return (
|
||||||
|
<NavigationMenu key={node.id} viewportPosition="full" className="min-[960px]:flex">
|
||||||
|
<NavigationMenuList className="justify-start">
|
||||||
|
<NavigationMenuItem>
|
||||||
|
<TopTrigger title={node.title} href={linkHref} />
|
||||||
|
<NavigationMenuContent>
|
||||||
|
{node.title === "Product" ? (
|
||||||
|
<MegaPanel cols={PRODUCT_COLS} source={node.children} layout="grid-cols-[1fr_1.06fr_2.17fr_1.33fr]" />
|
||||||
|
) : node.title === "Services" ? (
|
||||||
|
<MegaPanel cols={SERVICES_COLS} source={node.children} layout="grid-cols-[1fr_2fr_1fr]" headLine />
|
||||||
|
) : node.title === "Solutions" ? (
|
||||||
|
<MegaPanel cols={SOLUTIONS_COLS} source={node.children} layout="grid-cols-[1.13fr_0.96fr_1.09fr_1fr]" />
|
||||||
|
) : (
|
||||||
|
<ResourcesPanel />
|
||||||
|
)}
|
||||||
|
</NavigationMenuContent>
|
||||||
|
</NavigationMenuItem>
|
||||||
|
</NavigationMenuList>
|
||||||
|
</NavigationMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Company:flyout 简单下拉列表(居中小视口)
|
||||||
|
if (node.children.length > 0) {
|
||||||
|
return (
|
||||||
|
<NavigationMenu key={node.id} className="min-[960px]:flex">
|
||||||
|
<NavigationMenuList className="justify-start">
|
||||||
|
<NavigationMenuItem>
|
||||||
|
<TopTrigger title={node.title} href={linkHref} />
|
||||||
|
<NavigationMenuContent>
|
||||||
|
<ul className="w-[240px] list-none p-0 py-2">
|
||||||
|
{node.children.map((c) => {
|
||||||
|
const info = linkInfo(TOP_URL_FIX[c.title] ?? c.url);
|
||||||
|
return info ? (
|
||||||
|
<li key={c.id} className="list-none">
|
||||||
|
<a href={info.href} target={info.external ? "_blank" : c.target || undefined} rel={info.external ? "noreferrer" : undefined} className="block px-5 py-[6px] text-[16px] leading-[1.7] text-[#222] hover:text-brand">
|
||||||
|
{c.title}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
<li key={c.id} className="list-none px-5 py-[6px] text-[16px] leading-[1.7] text-muted-foreground">
|
||||||
|
{c.title}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</NavigationMenuContent>
|
||||||
|
</NavigationMenuItem>
|
||||||
|
</NavigationMenuList>
|
||||||
|
</NavigationMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 无面板的普通链接(Success Stories,样式与顶级触发项一致)
|
||||||
|
return linkHref ? (
|
||||||
|
<a key={node.id} href={linkHref} target={node.target || undefined} className="inline-flex h-[68px] w-max items-center rounded-none border-0 bg-transparent py-0 pr-[6px] pl-2 text-[16px] font-normal whitespace-nowrap text-[#222] transition-colors hover:text-brand">
|
||||||
|
{node.title}
|
||||||
|
</a>
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 Resources 面板(原站为自定义 HTML 标签面板,此处收录其全部资源入口;链接均已本地 200) */
|
||||||
|
function ResourcesPanel() {
|
||||||
|
return (
|
||||||
|
<div className="grid w-full grid-cols-1 gap-x-10 px-4 py-6 min-[1280px]:grid-cols-2">
|
||||||
|
{RESOURCES_LINKS.map((r) => (
|
||||||
|
<a key={r.title} href={r.url} target={/^https?:/.test(r.url) ? "_blank" : undefined} rel="noreferrer" className="flex items-center justify-between gap-4 border-b border-subline py-3 text-[16px] text-[#222] transition-colors hover:text-brand">
|
||||||
|
<span>{r.title}</span>
|
||||||
|
<ChevronRight size={16} aria-hidden className="shrink-0 text-brand" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 成员变量 顶级/子级触发链接修正(DB 存「#」或旧路径,按原站现值修正;均已本地 200) **/
|
||||||
|
const TOP_URL_FIX: Record<string, string> = {
|
||||||
|
Product: "/shop/", // 原站 Product 触发链接 → /shop/
|
||||||
|
Solutions: "/solution/", // 原站 Solutions 触发链接 → /solution/
|
||||||
|
"Blog >": "/blog/", // Company/Services 里的 Blog 子项(DB 为 #)
|
||||||
|
"News >": "/news/", // Company 里的 News 子项(DB 为 #)
|
||||||
|
};
|
||||||
|
|
||||||
|
/** # 组件 顶级触发项:有链接时 asChild 渲染 <a>(点击可跳转、样式落在 <a> 上
|
||||||
|
* 以免被全局 a{color:brand} 染橙),否则纯按钮。指示器为实心三角(原站
|
||||||
|
* dashicons arrow-down,展开不旋转、颜色随文字变橙)。
|
||||||
|
*/
|
||||||
|
function TopTrigger({ title, href }: { title: string; href?: string }) {
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuTrigger asChild chevron={false}>
|
||||||
|
<a href={href}>
|
||||||
|
{title}
|
||||||
|
<CaretDown />
|
||||||
|
</a>
|
||||||
|
</NavigationMenuTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <NavigationMenuTrigger>{title}</NavigationMenuTrigger>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 巨幕面板:按列配置从菜单树取分组并渲染
|
||||||
|
* 参数 cols 列布局配置;source 菜单树(顶级项 children);layout 列宽 grid-template;
|
||||||
|
* headLine 分组头是否带浅橙下边线(原站实测:Services 面板有 #fad3b3 线,Product/Solutions 无)。
|
||||||
|
* 样式按原站 megamenu CSS 实测:面板内边距 10px 0 20px 10px;每个分组条目块
|
||||||
|
* padding 15px(底 0);灰底列 #f4f5f8 通高。
|
||||||
|
*/
|
||||||
|
function MegaPanel(props: { cols: MegaCol[]; source: MenuNode[]; layout: string; headLine?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="w-full pt-2.5 pb-5 pl-2.5">
|
||||||
|
<div className={`grid grid-cols-1 ${props.layout}`}>
|
||||||
|
{props.cols.map((col, i) => {
|
||||||
|
const groups = (col.groups ?? []).map((g) => props.source.find((n) => n.title === g)).filter((n): n is MenuNode => Boolean(n));
|
||||||
|
const subCols = (col.subCols ?? []).map((titles) => titles.map((g) => props.source.find((n) => n.title === g)).filter((n): n is MenuNode => Boolean(n)));
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
{col.heading ? <div className="mb-3 px-[15px] pt-[15px] text-[18px] font-bold text-brand">{col.heading}</div> : null}
|
||||||
|
{groups.map((g) => (
|
||||||
|
<MegaGroup key={g.id} node={g} headLine={props.headLine} />
|
||||||
|
))}
|
||||||
|
{subCols.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-2">
|
||||||
|
{subCols.map((gs, si) => (
|
||||||
|
<div key={si}>
|
||||||
|
{gs.map((g) => (
|
||||||
|
<MegaGroup key={g.id} node={g} headLine={props.headLine} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{col.image ? (
|
||||||
|
<figure className="mt-4 mb-1 px-[15px]">
|
||||||
|
<img src={col.image.src} width={col.image.width} height={col.image.height} alt={col.image.alt} loading="lazy" className="h-auto max-w-full" />
|
||||||
|
</figure>
|
||||||
|
) : null}
|
||||||
|
{col.logos ? (
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-3 px-[15px] pb-4 opacity-90">
|
||||||
|
{["rockchip", "nxp", "mtk", "espressif", "silicon", "nordic"].map((n) => (
|
||||||
|
<img key={n} src={`/uploads/2023/07/${n}.png`} alt={n} loading="lazy" className="h-[24px] w-auto" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
return col.grey ? (
|
||||||
|
<div key={i} className="-mt-2.5 -mb-5 h-full bg-panel-grey">
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div key={i}>{body}</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 巨幕分组:分组头(橙 18px 星标 / 橙 16px / 深色加粗+#e0e5f9 下边线)
|
||||||
|
* + 子项列表(子项再含子级时渲染二级分组)。条目块 padding 15px(底 0)按原站实测。
|
||||||
|
*/
|
||||||
|
function MegaGroup({ node, headLine }: { node: MenuNode; headLine?: boolean }) {
|
||||||
|
const head = linkInfo(node.url);
|
||||||
|
const star = STAR_GROUPS.has(node.title);
|
||||||
|
const orangeLg = star || ORANGE_GROUPS.has(node.title);
|
||||||
|
const orangeSm = ORANGE_SM_GROUPS.has(node.title);
|
||||||
|
const darkNoLine = DARK_NOLINE_GROUPS.has(node.title);
|
||||||
|
let headCls: string;
|
||||||
|
if (orangeLg) headCls = `text-[18px] font-bold text-brand${headLine ? " border-b border-group-line pb-2.5" : ""}`;
|
||||||
|
else if (orangeSm) headCls = "text-[16px] font-bold text-brand";
|
||||||
|
else if (darkNoLine) headCls = "text-[16px] font-bold text-[#222]";
|
||||||
|
else headCls = "border-b border-subline pb-1 text-[16px] font-bold text-[#222]";
|
||||||
|
const headWrap = `flex items-center gap-1.5 whitespace-nowrap ${orangeLg ? "mb-3" : "mb-2.5"} ${headCls}`;
|
||||||
|
return (
|
||||||
|
<section className="px-[15px] pt-[15px]">
|
||||||
|
{head ? (
|
||||||
|
<a href={head.href} target={head.external ? "_blank" : node.target || undefined} rel={head.external ? "noreferrer" : undefined} className={headWrap}>
|
||||||
|
{star ? <Star size={17} aria-hidden className="fill-current" /> : null}
|
||||||
|
{node.title}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div className={headWrap}>
|
||||||
|
{star ? <Star size={17} aria-hidden className="fill-current" /> : null}
|
||||||
|
{node.title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ul className="list-none p-0">
|
||||||
|
{node.children.map((c) => {
|
||||||
|
// 二级分组(如 Remote Control → 遥控器型号列表)
|
||||||
|
if (c.children.length > 0) {
|
||||||
|
return (
|
||||||
|
<li key={c.id} className="mt-3.5 list-none">
|
||||||
|
<div className="mb-1.5 border-b border-subline pb-1 text-[16px] font-bold text-[#222]">{c.title}</div>
|
||||||
|
<ul className="list-none p-0">
|
||||||
|
{c.children.map((g) => (
|
||||||
|
<li key={g.id} className="list-none">
|
||||||
|
<MegaItemLink node={g} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<li key={c.id} className="list-none">
|
||||||
|
<MegaItemLink node={c} />
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 巨幕叶子链接:站内 <a> / 外链新窗口 / 无链接的普通文本(原站 16px/400/#222) */
|
||||||
|
function MegaItemLink({ node }: { node: MenuNode }) {
|
||||||
|
const info = linkInfo(node.url);
|
||||||
|
const cls = "block leading-[1.7] text-[16px] text-[#222] hover:text-brand";
|
||||||
|
if (info) {
|
||||||
|
return (
|
||||||
|
<a href={info.href} target={info.external ? "_blank" : node.target || undefined} rel={info.external ? "noreferrer" : undefined} className={cls}>
|
||||||
|
{node.title}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className={cls}>{node.title}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 顶部社交图标(lucide 新版已移除品牌图标,改为内联 SVG) */
|
||||||
|
function YoutubeGlyph() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M23 7.6a3 3 0 0 0-2.1-2.2C19 4.9 12 4.9 12 4.9s-7 0-8.9.5A3 3 0 0 0 1 7.6 32 32 0 0 0 .6 12 32 32 0 0 0 1 16.4a3 3 0 0 0 2.1 2.1c1.9.6 8.9.6 8.9.6s7 0 8.9-.6a3 3 0 0 0 2.1-2.1A32 32 0 0 0 23.4 12 32 32 0 0 0 23 7.6ZM9.8 15.1V8.9l5.8 3.1-5.8 3.1Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function FacebookGlyph() {
|
||||||
|
return (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M13.5 21v-8h2.7l.4-3.1h-3.1V7.9c0-.9.3-1.5 1.6-1.5h1.6V3.6c-.3 0-1.3-.1-2.4-.1-2.4 0-4 1.4-4 4.1v2.3H7.5V13h2.8v8h3.2Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function LinkedinGlyph() {
|
||||||
|
return (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M4.98 3.5A2.5 2.5 0 1 1 5 8.5a2.5 2.5 0 0 1-.02-5ZM3 9.5h4V21H3V9.5Zm6.5 0h3.8v1.6h.1c.5-1 1.8-2 3.7-2 4 0 4.7 2.6 4.7 6V21h-4v-5.2c0-1.2 0-2.9-1.8-2.9s-2.1 1.4-2.1 2.8V21h-4V9.5Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 移动端导航项(子菜单平铺展示) */
|
||||||
|
function MobileNavItem({ node, onNavigate }: { node: MenuNode; onNavigate: () => void }) {
|
||||||
|
const href = normalizeUrl(node.url);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{href ? (
|
||||||
|
<a href={href} onClick={onNavigate} className="block border-b border-line px-1 py-[9px] font-medium text-ink">
|
||||||
|
{node.title}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="block border-b border-line px-1 py-[9px] font-medium text-ink">{node.title}</span>
|
||||||
|
)}
|
||||||
|
{node.children.map((c) => {
|
||||||
|
const ch = normalizeUrl(c.url);
|
||||||
|
return (
|
||||||
|
<div key={c.id}>
|
||||||
|
{ch ? (
|
||||||
|
<a href={ch} onClick={onNavigate} className="block border-b border-line py-[9px] pl-[18px] font-normal text-muted-foreground">
|
||||||
|
{c.title}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="block border-b border-line py-[9px] pl-[18px] font-normal text-muted-foreground">{c.title}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 菜单 URL 归一化为站内路径(外部链接返回 null) */
|
||||||
|
function normalizeUrl(url: string): string | null {
|
||||||
|
if (!url) return null;
|
||||||
|
// 完整 URL:仅同域或相对路径可站内跳转
|
||||||
|
if (/^https?:\/\//i.test(url)) {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
if (u.host === "dusuniot.com" || u.host === "www.dusuniot.com") return u.pathname;
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (url.startsWith("#") || url.startsWith("mailto:") || url.startsWith("tel:")) return null;
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 类型 菜单链接解析结果 */
|
||||||
|
/** # 函数 菜单 URL → 可渲染链接(站内路径 / 外链原样;「#」等返回 null)
|
||||||
|
* 与 normalizeUrl 的差异:外链(WiKi/YouTube/LinkedIn 等)原样保留,由调用方
|
||||||
|
* 以新窗口锚点渲染,与原站 mega menu 行为一致。
|
||||||
|
*/
|
||||||
|
function linkInfo(url: string): { href: string; external: boolean } | null {
|
||||||
|
if (!url || url.startsWith("#") || url.startsWith("mailto:") || url.startsWith("tel:")) return null;
|
||||||
|
if (/^https?:\/\//i.test(url)) {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
if (u.host === "dusuniot.com" || u.host === "www.dusuniot.com") return { href: u.pathname, external: false };
|
||||||
|
return { href: url, external: true };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { href: url, external: false };
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* # 组件 询盘表单 InquiryForm(Tailwind + shadcn/ui 实现)
|
||||||
|
* 字段:name/email/phone/company/country/message + productInterest(hidden) + 蜜罐。
|
||||||
|
* 提交走 Server Function submitInquiry;成功显示绿色提示,失败显示红色错误。
|
||||||
|
*/
|
||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
|
import { submitInquiry } from "~/server/inquiries";
|
||||||
|
import { useT } from "~/i18n";
|
||||||
|
|
||||||
|
/** # 类型 InquiryForm 属性 */
|
||||||
|
export function InquiryForm(props: {
|
||||||
|
/** # 成员变量 表单标识(对应 inquiry_forms.form_key) **/
|
||||||
|
formKey: string;
|
||||||
|
/** # 成员变量 产品兴趣(产品页填产品名,博客/联系页可空) **/
|
||||||
|
productInterest?: string;
|
||||||
|
/** # 成员变量 标题(默认 Get a Quote) **/
|
||||||
|
title?: string;
|
||||||
|
}) {
|
||||||
|
const t = useT();
|
||||||
|
const [status, setStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");
|
||||||
|
const [errorMsg, setErrorMsg] = useState("");
|
||||||
|
|
||||||
|
/** # 函数 表单提交处理(组装 InquiryPayload 调用 Server Function) */
|
||||||
|
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.currentTarget;
|
||||||
|
const fd = new FormData(form);
|
||||||
|
setStatus("submitting");
|
||||||
|
setErrorMsg("");
|
||||||
|
try {
|
||||||
|
const res = await submitInquiry({
|
||||||
|
data: {
|
||||||
|
formKey: props.formKey,
|
||||||
|
name: String(fd.get("name") || ""),
|
||||||
|
email: String(fd.get("email") || ""),
|
||||||
|
phone: String(fd.get("phone") || ""),
|
||||||
|
company: String(fd.get("company") || ""),
|
||||||
|
country: String(fd.get("country") || ""),
|
||||||
|
message: String(fd.get("message") || ""),
|
||||||
|
productInterest: props.productInterest || String(fd.get("productInterest") || ""),
|
||||||
|
_gotcha: String(fd.get("_gotcha") || ""),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setStatus("success");
|
||||||
|
form.reset();
|
||||||
|
} else {
|
||||||
|
setStatus("error");
|
||||||
|
setErrorMsg(res.error || t("inquiry_error"));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setStatus("error");
|
||||||
|
setErrorMsg(err instanceof Error ? err.message : t("inquiry_network"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "success") {
|
||||||
|
return (
|
||||||
|
<div className="max-w-[720px] rounded-lg border border-line bg-soft p-6" id="inquiry">
|
||||||
|
<h2 className="mt-0">{props.title || t("inquiry_title")}</h2>
|
||||||
|
<div className="rounded-md border border-[#b5e3c4] bg-[#e7f6ec] px-4 py-3 text-[#15803d]">
|
||||||
|
{t("inquiry_success")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-[720px] rounded-lg border border-line bg-soft p-6" id="inquiry">
|
||||||
|
<h2 className="mt-0">{props.title || t("inquiry_title")}</h2>
|
||||||
|
<form className="grid grid-cols-1 gap-3.5 min-[640px]:grid-cols-2" onSubmit={handleSubmit}>
|
||||||
|
{/* 蜜罐字段:隐藏,机器人填写即视为垃圾提交 */}
|
||||||
|
<input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" className="hidden" aria-hidden="true" />
|
||||||
|
<Label>
|
||||||
|
{t("inquiry_name")}
|
||||||
|
<Input name="name" required maxLength={255} />
|
||||||
|
</Label>
|
||||||
|
<Label>
|
||||||
|
{t("inquiry_email")}
|
||||||
|
<Input name="email" type="email" required maxLength={255} />
|
||||||
|
</Label>
|
||||||
|
<Label>
|
||||||
|
{t("inquiry_phone")}
|
||||||
|
<Input name="phone" type="tel" maxLength={128} />
|
||||||
|
</Label>
|
||||||
|
<Label>
|
||||||
|
{t("inquiry_company")}
|
||||||
|
<Input name="company" maxLength={255} />
|
||||||
|
</Label>
|
||||||
|
<Label>
|
||||||
|
{t("inquiry_country")}
|
||||||
|
<Input name="country" maxLength={128} />
|
||||||
|
</Label>
|
||||||
|
<Label>
|
||||||
|
{t("inquiry_interest")}
|
||||||
|
<Input name="productInterest" defaultValue={props.productInterest || ""} readOnly={!!props.productInterest} />
|
||||||
|
</Label>
|
||||||
|
<Label className="col-span-full">
|
||||||
|
{t("inquiry_message")}
|
||||||
|
<Textarea name="message" required rows={5} placeholder={t("inquiry_message_ph")} />
|
||||||
|
</Label>
|
||||||
|
{status === "error" ? (
|
||||||
|
<div role="alert" className="col-span-full rounded-md border border-[#f3c1c1] bg-[#fdeaea] px-4 py-3 text-[#b91c1c]">
|
||||||
|
{errorMsg}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="col-span-full">
|
||||||
|
<Button type="submit" disabled={status === "submitting"}>
|
||||||
|
{status === "submitting" ? t("inquiry_submitting") : t("inquiry_submit")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* # 组件 Header 语言切换(Globe 图标 + 下拉语言列表)
|
||||||
|
* 选择后写 ui_lang cookie 并整页刷新;UI 文案按 cookie 的语言 SSR 渲染(自建 i18n 词典)。
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Globe } from "lucide-react";
|
||||||
|
import { LOCALES, getUiLocale, setUiLocale } from "~/i18n/shared";
|
||||||
|
|
||||||
|
export function LangSwitcher() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
/** # 成员变量 当前语言(客户端挂载后从 cookie 读取,避免 SSR 水合不一致) **/
|
||||||
|
const [cur, setCur] = useState("en");
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => setCur(getUiLocale()), []);
|
||||||
|
// 点击组件外部收起下拉
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDoc = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDoc);
|
||||||
|
return () => document.removeEventListener("mousedown", onDoc);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative hidden min-[960px]:block">
|
||||||
|
<button
|
||||||
|
aria-label="Language"
|
||||||
|
aria-expanded={open}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="flex cursor-pointer items-center border-0 bg-transparent p-1 text-brand"
|
||||||
|
>
|
||||||
|
<Globe size={19} aria-hidden />
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div className="absolute right-0 top-full z-50 max-h-[340px] w-[200px] overflow-auto rounded-md border border-line bg-white py-1 shadow-[0_8px_24px_rgba(16,42,76,0.14)]">
|
||||||
|
{LOCALES.map((l) => (
|
||||||
|
<button
|
||||||
|
key={l.code}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
if (l.code !== cur) setUiLocale(l.code);
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
"block w-full cursor-pointer border-0 bg-transparent px-4 py-2 text-left text-[13px] hover:bg-soft hover:text-brand " +
|
||||||
|
(l.code === cur ? "font-bold text-brand" : "text-ink")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{l.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* # 组件 网站公共布局 Layout
|
||||||
|
* 组合 Header + 页面内容 + Footer;样式入口(Tailwind v4 + shadcn/ui)在此引入一次。
|
||||||
|
* 布局数据(菜单/分类)由各路由 loader 通过 getLayoutData() 取得后传入。
|
||||||
|
*/
|
||||||
|
import "../../styles/app.css";
|
||||||
|
import { Header } from "./Header";
|
||||||
|
import { Footer, type FooterCategory } from "./Footer";
|
||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
import { getMenuTree, getProductCategoryTree, type MenuNode } from "~/server/data";
|
||||||
|
|
||||||
|
/** # 类型 布局数据(Header/Footer 所需) */
|
||||||
|
export type LayoutData = {
|
||||||
|
/** # 成员变量 顶部导航菜单树(max_mega_menu_1) **/
|
||||||
|
menu: MenuNode[];
|
||||||
|
/** # 成员变量 页脚公司链接菜单树(menu-2) **/
|
||||||
|
footerMenu: MenuNode[];
|
||||||
|
/** # 成员变量 产品分类树(含计数) **/
|
||||||
|
categories: FooterCategory[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** # 函数 Server Function:获取布局数据(菜单 + 分类) */
|
||||||
|
export const getLayoutData = createServerFn({ method: "GET" }).handler(async (): Promise<LayoutData> => {
|
||||||
|
const [menu, footerMenu, cats] = await Promise.all([getMenuTree("max_mega_menu_1"), getMenuTree("menu-2"), getProductCategoryTree()]);
|
||||||
|
return { menu, footerMenu, categories: cats };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 类型 Layout 属性 */
|
||||||
|
export function Layout(props: { layout: LayoutData; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header menu={props.layout.menu} />
|
||||||
|
<main>{props.children}</main>
|
||||||
|
<Footer categories={props.layout.categories} menu={props.layout.footerMenu} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* # 组件 分页 Pagination(基于 shadcn/ui Pagination 链接组件)
|
||||||
|
* 上一页/下一页 + 页码;页码链接由调用方生成(保持当前 URL 的其他参数)。
|
||||||
|
*/
|
||||||
|
import { Pagination as PaginationRoot, PaginationContent, PaginationItem, PaginationLink, PaginationEllipsis } from "~/components/ui/pagination";
|
||||||
|
import { useT } from "~/i18n";
|
||||||
|
|
||||||
|
/** # 类型 Pagination 属性:makeHref(page) 返回对应页 URL */
|
||||||
|
export function Pagination(props: { page: number; total: number; pageSize: number; makeHref: (page: number) => string }) {
|
||||||
|
const t = useT();
|
||||||
|
const totalPages = Math.max(1, Math.ceil(props.total / props.pageSize));
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
// 页码窗口:当前页前后各 2 页
|
||||||
|
const pages: number[] = [];
|
||||||
|
for (let i = Math.max(1, props.page - 2); i <= Math.min(totalPages, props.page + 2); i++) pages.push(i);
|
||||||
|
return (
|
||||||
|
<PaginationRoot>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink href={props.makeHref(props.page - 1)} disabled={props.page <= 1}>
|
||||||
|
{t("pagination_prev")}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
{pages[0] > 1 ? (
|
||||||
|
<>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink href={props.makeHref(1)}>1</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
{pages[0] > 2 ? (
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationEllipsis />
|
||||||
|
</PaginationItem>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{pages.map((p) => (
|
||||||
|
<PaginationItem key={p}>
|
||||||
|
<PaginationLink href={props.makeHref(p)} isActive={p === props.page}>
|
||||||
|
{p}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
))}
|
||||||
|
{pages[pages.length - 1] < totalPages ? (
|
||||||
|
<>
|
||||||
|
{pages[pages.length - 1] < totalPages - 1 ? (
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationEllipsis />
|
||||||
|
</PaginationItem>
|
||||||
|
) : null}
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink href={props.makeHref(totalPages)}>{totalPages}</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink href={props.makeHref(props.page + 1)} disabled={props.page >= totalPages}>
|
||||||
|
{t("pagination_next")}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</PaginationRoot>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* # 组件 产品卡片 ProductCard(Tailwind + shadcn/ui 实现)
|
||||||
|
* 产品网格卡片:图 / 标题 / Read more 链接。首页、列表页、搜索页复用。
|
||||||
|
* 整卡为链接,文字继承品牌橙(与原 .card 于 a 上同色一致)。
|
||||||
|
*/
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { useT } from "~/i18n";
|
||||||
|
|
||||||
|
/** # 类型 产品卡片数据(listProducts.items 元素子集) */
|
||||||
|
export type ProductCardData = {
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
sku: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
imageAlt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** # 组件 产品卡片 */
|
||||||
|
export function ProductCard({ p }: { p: ProductCardData }) {
|
||||||
|
const t = useT();
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to="/product/$slug/"
|
||||||
|
params={{ slug: p.slug }}
|
||||||
|
className="block overflow-hidden rounded-lg border border-line bg-white text-brand transition-[box-shadow,transform] duration-200 hover:-translate-y-0.5 hover:shadow-[0_8px_24px_rgba(16,42,76,0.1)]"
|
||||||
|
>
|
||||||
|
<div className="flex aspect-[4/3] items-center justify-center bg-white p-3">
|
||||||
|
{p.imageUrl ? <img src={p.imageUrl} alt={p.imageAlt || p.title} loading="lazy" className="max-h-full object-contain" /> : null}
|
||||||
|
</div>
|
||||||
|
<div className="px-4 pt-3.5 pb-[18px] text-center">
|
||||||
|
<div className="mb-1.5 text-[15px] leading-[1.45] font-semibold">{p.title}</div>
|
||||||
|
<span className="mt-2.5 inline-block rounded-[4px] border border-brand bg-white px-4 py-1.5 text-sm font-semibold text-brand hover:bg-soft">
|
||||||
|
{t("card_read_more")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* # 组件 TickerBar 公告滚动条(复刻原站 eael-content-ticker / Essential Addons)
|
||||||
|
* 结构还原原站渲染 HTML(React 挂载后由 swapIdx 状态轮播):
|
||||||
|
* .eael-ticker-wrap > .ticker-badge(span 标签文本) + .eael-ticker > .content-ticker
|
||||||
|
* > .swiper-wrapper >(site 枚举) .swiper-slide > .ticker-content > a.ticker-content-link
|
||||||
|
*
|
||||||
|
* 交互:淡入淡出切换(effect=fade),窗口 opacity 过渡时长 = speed,
|
||||||
|
* 轮播间隔 = interval;条目不足 2 条时常显(不轮播)。
|
||||||
|
* 验收参照(2026-10-31 原站头部实测 1436px 视口):
|
||||||
|
* 外层高 45px;badge/内容 padding 8px 12px;badge 文字 18px/600 #000;
|
||||||
|
* 链接 16px #222,hover #ED6C00;内容行高 28.8px(1.8)。
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export interface TickerItem {
|
||||||
|
/** # 成员变量 条目标题(显示文本) */
|
||||||
|
title: string;
|
||||||
|
/** # 成员变量 条目链接(原站为产品页绝对地址,本项目用站内相对地址) */
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TickerBarProps {
|
||||||
|
/** # 成员变量 左侧标签文本(原站 "Latest Products:") */
|
||||||
|
badge: string;
|
||||||
|
/** # 成员变量 轮播条目(原站取最新 3 个产品) */
|
||||||
|
items: TickerItem[];
|
||||||
|
/** # 成员变量 切换过渡时长 ms(原站 data-speed=200) */
|
||||||
|
speed?: number;
|
||||||
|
/** # 成员变量 自动轮播间隔 ms(原站 data-autoplay=5000) */
|
||||||
|
interval?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 TickerBar 带标签的淡入淡出滚动条 */
|
||||||
|
export default function TickerBar({ badge, items, speed = 200, interval = 5000 }: TickerBarProps) {
|
||||||
|
// 变量 indicator 当前展示条目下标(0..items.length-1)
|
||||||
|
const [idx, setIdx] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (items.length < 2) return;
|
||||||
|
const timer = setInterval(() => setIdx(i => (i + 1) % items.length), interval);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [items.length, interval]);
|
||||||
|
|
||||||
|
if (!items.length) return null;
|
||||||
|
return (
|
||||||
|
<div className="eael-ticker-wrap">
|
||||||
|
{badge ? (
|
||||||
|
<div className="ticker-badge">
|
||||||
|
<span>{badge}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="eael-ticker">
|
||||||
|
<div className="content-ticker" data-effect="fade" data-speed={speed} data-autoplay={interval} data-loop="1">
|
||||||
|
<div className="swiper-wrapper">
|
||||||
|
{items.map((t, i) => (
|
||||||
|
<div key={i} className={"swiper-slide" + (i === idx ? " active" : "")} style={{ transitionDuration: `${speed}ms` }}>
|
||||||
|
<div className="ticker-content">
|
||||||
|
<a className="ticker-content-link" href={t.url}>{t.title}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* # 模块 catch-all 页面共享逻辑($.tsx 只导出 Route)
|
||||||
|
*/
|
||||||
|
import { getPublishedContent, listPosts, type PostListItem, type ProductListResult } from "~/server/data";
|
||||||
|
import type { LayoutData } from "~/components/website/Layout";
|
||||||
|
|
||||||
|
/** # 类型 兜底 loader 返回结构 */
|
||||||
|
export type CatchAllData = {
|
||||||
|
/** # 成员变量 布局数据(菜单/分类) **/
|
||||||
|
layout: LayoutData;
|
||||||
|
/** # 成员变量 命中的内容(page/post),null 表示未找到 **/
|
||||||
|
content: null | {
|
||||||
|
slug: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
excerpt: string | null;
|
||||||
|
html: string | null;
|
||||||
|
seoTitle: string | null;
|
||||||
|
seoDescription: string | null;
|
||||||
|
meta: Record<string, unknown> | null;
|
||||||
|
featuredUrl: string | null;
|
||||||
|
featuredAlt: string | null;
|
||||||
|
terms: { name: string; slug: string; taxonomy: string }[];
|
||||||
|
};
|
||||||
|
/** # 成员变量 识别出的语言前缀(如 zh-CN),空串表示英语/无前缀 **/
|
||||||
|
lang: string;
|
||||||
|
/** # 成员变量 404 时的热门产品 **/
|
||||||
|
hotProducts: ProductListResult;
|
||||||
|
/** # 成员变量 WP /%category%/ 归档命中:分类 + 分页文章(content 为 null 时可能命中) **/
|
||||||
|
archive: null | { name: string; slug: string; posts: { items: PostListItem[]; total: number; page: number; pageSize: number } };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** # 类型 归档分页结果(listPosts 返回形状的窄化别名) */
|
||||||
|
export type ArchivePosts = NonNullable<CatchAllData["archive"]>["posts"];
|
||||||
|
export type { PostListItem };
|
||||||
|
export { listPosts };
|
||||||
|
|
||||||
|
/** # 函数 判断路径段是否像语言代码(2 字母,或 zh-CN 格式;排除 en) */
|
||||||
|
export function isLangCode(seg: string): boolean {
|
||||||
|
return seg !== "en" && (/^[a-z]{2}$/.test(seg) || /^[a-z]{2}-[A-Za-z]{2,4}$/.test(seg));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 语言代码 → 显示名(title 标注用) */
|
||||||
|
export const LANG_NAMES: Record<string, string> = {
|
||||||
|
"zh-CN": "中文(简体)",
|
||||||
|
"zh-TW": "中文(繁體)",
|
||||||
|
de: "German",
|
||||||
|
fr: "French",
|
||||||
|
es: "Spanish",
|
||||||
|
it: "Italian",
|
||||||
|
pt: "Portuguese",
|
||||||
|
ru: "Russian",
|
||||||
|
ja: "Japanese",
|
||||||
|
ko: "Korean",
|
||||||
|
ar: "Arabic",
|
||||||
|
nl: "Dutch",
|
||||||
|
pl: "Polish",
|
||||||
|
tr: "Turkish",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** # 函数 把内容行收窄为可序列化结构(meta/terms/jsonb 处理) */
|
||||||
|
export function pickContent(row: NonNullable<Awaited<ReturnType<typeof getPublishedContent>>>): CatchAllData["content"] {
|
||||||
|
return {
|
||||||
|
slug: row.slug,
|
||||||
|
type: row.type,
|
||||||
|
title: row.title,
|
||||||
|
excerpt: row.excerpt,
|
||||||
|
html: row.html,
|
||||||
|
seoTitle: row.seoTitle,
|
||||||
|
seoDescription: row.seoDescription,
|
||||||
|
meta: (row.meta ?? null) as Record<string, unknown> | null,
|
||||||
|
featuredUrl: row.featuredUrl,
|
||||||
|
featuredAlt: row.featuredAlt,
|
||||||
|
terms: row.terms.map((t) => ({ name: t.name, slug: t.slug, taxonomy: t.taxonomy })),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* # 模块 collection 页面共享类型与 search 参数解析
|
||||||
|
* 供 collection.index.tsx 与 collection.$slug.tsx 复用(路由文件只导出 Route)。
|
||||||
|
*
|
||||||
|
* 设计说明:validateSearch 的输出会被 TanStack 序列化回 URL,
|
||||||
|
* 因此 search 状态保持「扁平字符串」形态(属性slug=值1,值2),
|
||||||
|
* 与输入 URL 完全一致,避免路由规范化重定向;
|
||||||
|
* 结构化的 FilterState 在 loader / 交互处理时再从扁平 search 解析。
|
||||||
|
*/
|
||||||
|
import type { FilterState } from "~/server/data";
|
||||||
|
|
||||||
|
/** # 类型 /collection 的扁平 search 参数:page 数字 + 其余属性筛选键(逗号分隔值) */
|
||||||
|
export type CollectionSearch = { page?: number } & Record<string, string | undefined>;
|
||||||
|
|
||||||
|
/** # 函数 解析 collection search 参数(保持扁平原样,仅规范化 page) */
|
||||||
|
export function parseCollectionSearch(search: Record<string, unknown>): CollectionSearch {
|
||||||
|
const out: Record<string, string | undefined> = {};
|
||||||
|
const page = Number(search.page);
|
||||||
|
if (Number.isFinite(page) && page > 1) out.page = String(Math.floor(page));
|
||||||
|
for (const [k, v] of Object.entries(search)) {
|
||||||
|
if (k === "page" || v == null) continue;
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
const joined = v.filter((x) => typeof x === "string" && x.trim()).join(",");
|
||||||
|
if (joined) out[k] = joined;
|
||||||
|
} else if (typeof v === "string" && v.trim()) {
|
||||||
|
out[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out as CollectionSearch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 扁平 search → 结构化筛选状态(每个属性键按逗号拆分为值数组) */
|
||||||
|
export function searchToFilters(search: CollectionSearch): FilterState {
|
||||||
|
const filters: FilterState = {};
|
||||||
|
for (const [k, v] of Object.entries(search)) {
|
||||||
|
if (k === "page" || v == null) continue;
|
||||||
|
const values = String(v)
|
||||||
|
.split(",")
|
||||||
|
.map((x) => x.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (values.length) filters[k] = values;
|
||||||
|
}
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 结构化筛选状态 → 扁平 search(navigate 用,page > 1 时附带) */
|
||||||
|
export function filtersToSearch(filters: FilterState | undefined, page: number): CollectionSearch {
|
||||||
|
const out: Record<string, string | undefined> = {};
|
||||||
|
if (filters) {
|
||||||
|
for (const [k, vals] of Object.entries(filters)) {
|
||||||
|
if (vals.length) out[k] = vals.join(",");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (page > 1) out.page = String(page);
|
||||||
|
return out as CollectionSearch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 把 filters + page 序列化为 URL query string(分页链接用) */
|
||||||
|
export function buildCollectionQuery(filters: FilterState | undefined, page: number): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters) {
|
||||||
|
for (const [k, vals] of Object.entries(filters)) {
|
||||||
|
if (vals.length) params.set(k, vals.join(","));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (page > 1) params.set("page", String(page));
|
||||||
|
const qs = params.toString();
|
||||||
|
return qs ? `?${qs}` : "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* 数据库客户端:Bun.sql + Drizzle(bun-sql 适配器)。
|
||||||
|
* 用法:import { db } from "~/db";
|
||||||
|
*
|
||||||
|
* 注意:db 采用惰性 Proxy。客户端组件会经由 server 函数模块间接引入本
|
||||||
|
* 文件,顶层 `new SQL()` 在浏览器端无法解析 Bun 内置模块而崩溃;因此只在
|
||||||
|
* 服务端首次访问时才初始化连接池(见 initDb / ensureDb)。
|
||||||
|
*/
|
||||||
|
import type { BunSQLDatabase } from "drizzle-orm/bun-sql";
|
||||||
|
import * as schema from "./schema";
|
||||||
|
|
||||||
|
/** # 成员变量 惰性初始化的 Drizzle 实例缓存 **/
|
||||||
|
let _db: BunSQLDatabase<typeof schema> | null = null;
|
||||||
|
|
||||||
|
/** # 成员变量 初始化 Promise(防并发重复构建) **/
|
||||||
|
let _initing: Promise<BunSQLDatabase<typeof schema>> | null = null;
|
||||||
|
|
||||||
|
/** # 函数 构建 Drizzle 数据库实例(仅服务端执行)
|
||||||
|
* 返回值 BunSQLDatabase 实例
|
||||||
|
*/
|
||||||
|
async function build(): Promise<BunSQLDatabase<typeof schema>> {
|
||||||
|
// 变量指示符 + @vite-ignore:Vite/Rolldown 不做静态解析(客户端图不含
|
||||||
|
// "bun"),服务端运行时由 Bun 原生解析内置模块
|
||||||
|
const bunModule = "bun";
|
||||||
|
const { SQL } = await import(/* @vite-ignore */ bunModule);
|
||||||
|
const { drizzle } = await import("drizzle-orm/bun-sql");
|
||||||
|
const sql = new SQL(process.env.DATABASE_URL || "postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot");
|
||||||
|
return drizzle(sql, { schema }) as BunSQLDatabase<typeof schema>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 初始化数据库连接(幂等;仅服务端生效,浏览器端空操作)
|
||||||
|
* 返回值 Drizzle 数据库实例(客户端返回空壳对象)
|
||||||
|
*/
|
||||||
|
export function initDb(): Promise<BunSQLDatabase<typeof schema>> {
|
||||||
|
// start.ts 同时进客户端包,浏览器端绝不能触碰 Bun 内置模块
|
||||||
|
if (typeof window !== "undefined") return Promise.resolve({} as BunSQLDatabase<typeof schema>);
|
||||||
|
if (!_initing) _initing = build().then((d) => { _db = d; return d; });
|
||||||
|
return _initing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 确保数据库已初始化(同步兜底:已初始化返回实例,否则抛出明确错误)
|
||||||
|
* 返回值 Drizzle 数据库实例
|
||||||
|
*/
|
||||||
|
function ensureDb(): BunSQLDatabase<typeof schema> {
|
||||||
|
if (!_db) throw new Error("db 未初始化:服务端入口需先 await initDb()(客户端不应访问 db)");
|
||||||
|
return _db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 成员变量 数据库连接代理(客户端导入安全:不触发连接初始化) **/
|
||||||
|
export const db = new Proxy({} as BunSQLDatabase<typeof schema>, {
|
||||||
|
get(_target, key) {
|
||||||
|
const d = ensureDb();
|
||||||
|
return Reflect.get(d, key, d);
|
||||||
|
},
|
||||||
|
}) as BunSQLDatabase<typeof schema>;
|
||||||
|
|
||||||
|
export { schema };
|
||||||
|
export type Database = BunSQLDatabase<typeof schema>;
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
CREATE TABLE "audit_logs" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"actor_id" integer,
|
||||||
|
"action" varchar(128) NOT NULL,
|
||||||
|
"entity" varchar(64) NOT NULL,
|
||||||
|
"entity_id" varchar(64),
|
||||||
|
"meta" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"ip" varchar(64) DEFAULT '' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "cart_items" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"cart_id" integer NOT NULL,
|
||||||
|
"product_id" integer NOT NULL,
|
||||||
|
"qty" integer DEFAULT 1 NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "carts" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" integer,
|
||||||
|
"session_key" varchar(64),
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "categories" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"taxonomy" varchar(64) NOT NULL,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"slug" varchar(255) NOT NULL,
|
||||||
|
"parent_id" integer,
|
||||||
|
"description" text DEFAULT '' NOT NULL,
|
||||||
|
"image_media_id" integer,
|
||||||
|
"sort" integer DEFAULT 0 NOT NULL,
|
||||||
|
"legacy_id" integer
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "content_revisions" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"content_id" integer NOT NULL,
|
||||||
|
"lang" varchar(16) DEFAULT 'en' NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"html" text NOT NULL,
|
||||||
|
"author_id" integer,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "content_terms" (
|
||||||
|
"content_id" integer NOT NULL,
|
||||||
|
"category_id" integer NOT NULL,
|
||||||
|
CONSTRAINT "content_terms_content_id_category_id_pk" PRIMARY KEY("content_id","category_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "content_translations" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"content_id" integer NOT NULL,
|
||||||
|
"lang" varchar(16) NOT NULL,
|
||||||
|
"slug" varchar(255) NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"excerpt" text DEFAULT '' NOT NULL,
|
||||||
|
"html" text DEFAULT '' NOT NULL,
|
||||||
|
"status" varchar(16) DEFAULT 'draft' NOT NULL,
|
||||||
|
"seo_title" text,
|
||||||
|
"seo_description" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "contents" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"type" varchar(32) NOT NULL,
|
||||||
|
"slug" varchar(255) NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"excerpt" text DEFAULT '' NOT NULL,
|
||||||
|
"html" text DEFAULT '' NOT NULL,
|
||||||
|
"status" varchar(16) DEFAULT 'published' NOT NULL,
|
||||||
|
"author_id" integer,
|
||||||
|
"featured_media_id" integer,
|
||||||
|
"template" varchar(64) DEFAULT 'default' NOT NULL,
|
||||||
|
"menu_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"views" integer DEFAULT 0 NOT NULL,
|
||||||
|
"sticky" boolean DEFAULT false NOT NULL,
|
||||||
|
"seo_title" text,
|
||||||
|
"seo_description" text,
|
||||||
|
"meta" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"legacy_id" integer,
|
||||||
|
"published_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "filters" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar(128) NOT NULL,
|
||||||
|
"slug" varchar(128) NOT NULL,
|
||||||
|
"type" varchar(24) DEFAULT 'attribute' NOT NULL,
|
||||||
|
"attribute_id" integer,
|
||||||
|
"taxonomy" varchar(64),
|
||||||
|
"position" integer DEFAULT 0 NOT NULL,
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"legacy_id" integer
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "inquiries" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"form_name" varchar(255) DEFAULT '' NOT NULL,
|
||||||
|
"form_id" integer,
|
||||||
|
"name" varchar(255) DEFAULT '' NOT NULL,
|
||||||
|
"email" varchar(255) DEFAULT '' NOT NULL,
|
||||||
|
"phone" varchar(128) DEFAULT '' NOT NULL,
|
||||||
|
"company" varchar(255) DEFAULT '' NOT NULL,
|
||||||
|
"country" varchar(128) DEFAULT '' NOT NULL,
|
||||||
|
"message" text DEFAULT '' NOT NULL,
|
||||||
|
"product_interest" varchar(255) DEFAULT '' NOT NULL,
|
||||||
|
"fields" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"status" varchar(16) DEFAULT 'new' NOT NULL,
|
||||||
|
"channel" text DEFAULT '' NOT NULL,
|
||||||
|
"source_page" text DEFAULT '' NOT NULL,
|
||||||
|
"ip" varchar(64) DEFAULT '' NOT NULL,
|
||||||
|
"utm" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"legacy_id" integer,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "inquiry_forms" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"form_key" varchar(128) NOT NULL,
|
||||||
|
"fields" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"mail_to" varchar(255),
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"legacy_id" integer
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "jobs" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"type" varchar(64) NOT NULL,
|
||||||
|
"payload" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"status" varchar(16) DEFAULT 'pending' NOT NULL,
|
||||||
|
"attempts" integer DEFAULT 0 NOT NULL,
|
||||||
|
"max_attempts" integer DEFAULT 3 NOT NULL,
|
||||||
|
"run_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"last_error" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "languages" (
|
||||||
|
"code" varchar(16) PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar(64) NOT NULL,
|
||||||
|
"is_default" boolean DEFAULT false NOT NULL,
|
||||||
|
"enabled" boolean DEFAULT false NOT NULL,
|
||||||
|
"machine" boolean DEFAULT false NOT NULL,
|
||||||
|
"sort" integer DEFAULT 0 NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "legacy_id_mappings" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"entity" varchar(64) NOT NULL,
|
||||||
|
"legacy_id" integer NOT NULL,
|
||||||
|
"new_id" integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "media" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"path" varchar(512) NOT NULL,
|
||||||
|
"url" text NOT NULL,
|
||||||
|
"mime_type" varchar(128) DEFAULT '' NOT NULL,
|
||||||
|
"width" integer,
|
||||||
|
"height" integer,
|
||||||
|
"size" bigint,
|
||||||
|
"alt_text" text DEFAULT '' NOT NULL,
|
||||||
|
"title" text DEFAULT '' NOT NULL,
|
||||||
|
"ref_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"legacy_id" integer,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "menu_items" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"menu_id" integer NOT NULL,
|
||||||
|
"parent_id" integer,
|
||||||
|
"title" varchar(255) NOT NULL,
|
||||||
|
"url" text NOT NULL,
|
||||||
|
"target" varchar(16) DEFAULT '_self' NOT NULL,
|
||||||
|
"sort" integer DEFAULT 0 NOT NULL,
|
||||||
|
"legacy_id" integer
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "menus" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar(128) NOT NULL,
|
||||||
|
"location" varchar(64) NOT NULL,
|
||||||
|
"legacy_id" integer
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "order_items" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"order_id" integer NOT NULL,
|
||||||
|
"product_id" integer,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"sku" varchar(128),
|
||||||
|
"qty" integer DEFAULT 1 NOT NULL,
|
||||||
|
"price" numeric(12, 2) DEFAULT '0' NOT NULL,
|
||||||
|
"total" numeric(12, 2) DEFAULT '0' NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "orders" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"order_number" varchar(32) NOT NULL,
|
||||||
|
"user_id" integer,
|
||||||
|
"status" varchar(24) DEFAULT 'pending' NOT NULL,
|
||||||
|
"currency" varchar(8) DEFAULT 'USD' NOT NULL,
|
||||||
|
"subtotal" numeric(12, 2) DEFAULT '0' NOT NULL,
|
||||||
|
"shipping_total" numeric(12, 2) DEFAULT '0' NOT NULL,
|
||||||
|
"tax_total" numeric(12, 2) DEFAULT '0' NOT NULL,
|
||||||
|
"total" numeric(12, 2) DEFAULT '0' NOT NULL,
|
||||||
|
"payment_method" varchar(64),
|
||||||
|
"payment_status" varchar(24) DEFAULT 'unpaid' NOT NULL,
|
||||||
|
"transaction_id" varchar(128),
|
||||||
|
"billing" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"shipping" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"customer_note" text DEFAULT '' NOT NULL,
|
||||||
|
"legacy_id" integer,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "payments" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"order_id" integer NOT NULL,
|
||||||
|
"method" varchar(64) NOT NULL,
|
||||||
|
"status" varchar(24) DEFAULT 'pending' NOT NULL,
|
||||||
|
"transaction_id" varchar(128),
|
||||||
|
"amount" numeric(12, 2) DEFAULT '0' NOT NULL,
|
||||||
|
"raw" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "product_attribute_map" (
|
||||||
|
"product_id" integer NOT NULL,
|
||||||
|
"value_id" integer NOT NULL,
|
||||||
|
CONSTRAINT "product_attribute_map_product_id_value_id_pk" PRIMARY KEY("product_id","value_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "product_attribute_values" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"attribute_id" integer NOT NULL,
|
||||||
|
"value" varchar(255) NOT NULL,
|
||||||
|
"slug" varchar(255) NOT NULL,
|
||||||
|
"sort" integer DEFAULT 0 NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "product_attributes" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar(128) NOT NULL,
|
||||||
|
"slug" varchar(128) NOT NULL,
|
||||||
|
"filterable" boolean DEFAULT true NOT NULL,
|
||||||
|
"sort" integer DEFAULT 0 NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "product_terms" (
|
||||||
|
"product_id" integer NOT NULL,
|
||||||
|
"category_id" integer NOT NULL,
|
||||||
|
CONSTRAINT "product_terms_product_id_category_id_pk" PRIMARY KEY("product_id","category_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "product_translations" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"product_id" integer NOT NULL,
|
||||||
|
"lang" varchar(16) NOT NULL,
|
||||||
|
"slug" varchar(255) NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"short_description" text DEFAULT '' NOT NULL,
|
||||||
|
"description" text DEFAULT '' NOT NULL,
|
||||||
|
"status" varchar(16) DEFAULT 'draft' NOT NULL,
|
||||||
|
"seo_title" text,
|
||||||
|
"seo_description" text,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "products" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"sku" varchar(128),
|
||||||
|
"slug" varchar(255) NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"short_description" text DEFAULT '' NOT NULL,
|
||||||
|
"description" text DEFAULT '' NOT NULL,
|
||||||
|
"price" numeric(12, 2),
|
||||||
|
"regular_price" numeric(12, 2),
|
||||||
|
"stock" integer,
|
||||||
|
"stock_status" varchar(32) DEFAULT 'instock' NOT NULL,
|
||||||
|
"featured_media_id" integer,
|
||||||
|
"gallery" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"status" varchar(16) DEFAULT 'published' NOT NULL,
|
||||||
|
"featured" boolean DEFAULT false NOT NULL,
|
||||||
|
"views" integer DEFAULT 0 NOT NULL,
|
||||||
|
"seo_title" text,
|
||||||
|
"seo_description" text,
|
||||||
|
"spec_url" text,
|
||||||
|
"meta" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"legacy_id" integer,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "redirects" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"source" text NOT NULL,
|
||||||
|
"target" text NOT NULL,
|
||||||
|
"code" integer DEFAULT 301 NOT NULL,
|
||||||
|
"is_regex" boolean DEFAULT false NOT NULL,
|
||||||
|
"hits" integer DEFAULT 0 NOT NULL,
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"legacy_id" integer
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "sessions" (
|
||||||
|
"token" varchar(64) PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "settings" (
|
||||||
|
"key" varchar(128) PRIMARY KEY NOT NULL,
|
||||||
|
"value" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"email" varchar(255) NOT NULL,
|
||||||
|
"username" varchar(128),
|
||||||
|
"display_name" varchar(255) DEFAULT '' NOT NULL,
|
||||||
|
"password_hash" varchar(255),
|
||||||
|
"legacy_password_hash" varchar(255),
|
||||||
|
"role" varchar(32) DEFAULT 'customer' NOT NULL,
|
||||||
|
"billing" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"shipping" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"legacy_id" integer,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "audit_logs_time_idx" ON "audit_logs" USING btree ("created_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "cart_items_cart_idx" ON "cart_items" USING btree ("cart_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "categories_taxonomy_slug_idx" ON "categories" USING btree ("taxonomy","slug");--> statement-breakpoint
|
||||||
|
CREATE INDEX "categories_parent_idx" ON "categories" USING btree ("parent_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "content_terms_category_idx" ON "content_terms" USING btree ("category_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "content_translations_content_lang_idx" ON "content_translations" USING btree ("content_id","lang");--> statement-breakpoint
|
||||||
|
CREATE INDEX "content_translations_lang_status_idx" ON "content_translations" USING btree ("lang","status");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "contents_slug_type_idx" ON "contents" USING btree ("slug","type");--> statement-breakpoint
|
||||||
|
CREATE INDEX "contents_type_status_idx" ON "contents" USING btree ("type","status");--> statement-breakpoint
|
||||||
|
CREATE INDEX "contents_legacy_idx" ON "contents" USING btree ("legacy_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "filters_slug_idx" ON "filters" USING btree ("slug");--> statement-breakpoint
|
||||||
|
CREATE INDEX "inquiries_status_idx" ON "inquiries" USING btree ("status","created_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "inquiries_email_idx" ON "inquiries" USING btree ("email");--> statement-breakpoint
|
||||||
|
CREATE INDEX "inquiries_created_idx" ON "inquiries" USING btree ("created_at");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "inquiry_forms_key_idx" ON "inquiry_forms" USING btree ("form_key");--> statement-breakpoint
|
||||||
|
CREATE INDEX "jobs_status_runat_idx" ON "jobs" USING btree ("status","run_at");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "legacy_map_entity_id_idx" ON "legacy_id_mappings" USING btree ("entity","legacy_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "media_path_idx" ON "media" USING btree ("path");--> statement-breakpoint
|
||||||
|
CREATE INDEX "media_legacy_idx" ON "media" USING btree ("legacy_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "menu_items_menu_idx" ON "menu_items" USING btree ("menu_id","sort");--> statement-breakpoint
|
||||||
|
CREATE INDEX "order_items_order_idx" ON "order_items" USING btree ("order_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "orders_number_idx" ON "orders" USING btree ("order_number");--> statement-breakpoint
|
||||||
|
CREATE INDEX "orders_status_idx" ON "orders" USING btree ("status");--> statement-breakpoint
|
||||||
|
CREATE INDEX "orders_user_idx" ON "orders" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "payments_order_idx" ON "payments" USING btree ("order_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "payments_txn_idx" ON "payments" USING btree ("transaction_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "product_attr_map_value_idx" ON "product_attribute_map" USING btree ("value_id","product_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "product_attr_values_attr_slug_idx" ON "product_attribute_values" USING btree ("attribute_id","slug");--> statement-breakpoint
|
||||||
|
CREATE INDEX "product_attr_values_attr_idx" ON "product_attribute_values" USING btree ("attribute_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "product_attributes_slug_idx" ON "product_attributes" USING btree ("slug");--> statement-breakpoint
|
||||||
|
CREATE INDEX "product_terms_category_idx" ON "product_terms" USING btree ("category_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "product_translations_product_lang_idx" ON "product_translations" USING btree ("product_id","lang");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "products_slug_idx" ON "products" USING btree ("slug");--> statement-breakpoint
|
||||||
|
CREATE INDEX "products_status_idx" ON "products" USING btree ("status");--> statement-breakpoint
|
||||||
|
CREATE INDEX "products_sku_idx" ON "products" USING btree ("sku");--> statement-breakpoint
|
||||||
|
CREATE INDEX "products_legacy_idx" ON "products" USING btree ("legacy_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "redirects_source_idx" ON "redirects" USING btree ("source");--> statement-breakpoint
|
||||||
|
CREATE INDEX "sessions_user_idx" ON "sessions" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree ("email");--> statement-breakpoint
|
||||||
|
CREATE INDEX "users_legacy_idx" ON "users" USING btree ("legacy_id");
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1793295647791,
|
||||||
|
"tag": "0000_worried_bill_hollister",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,863 @@
|
|||||||
|
/**
|
||||||
|
* DusunIoT 重建版 PostgreSQL 数据模型(Drizzle ORM)
|
||||||
|
*
|
||||||
|
* 按业务域组织:content / products / commerce / inquiries / ops。
|
||||||
|
* 每张表保留 legacy_id 以便对照原 WordPress 数据并支持增量同步。
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
serial,
|
||||||
|
varchar,
|
||||||
|
text,
|
||||||
|
integer,
|
||||||
|
bigint,
|
||||||
|
boolean,
|
||||||
|
timestamp,
|
||||||
|
numeric,
|
||||||
|
jsonb,
|
||||||
|
index,
|
||||||
|
uniqueIndex,
|
||||||
|
primaryKey,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
/* ============================= 内容域 ============================= */
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 内容主表(页面 page / 文章 post / 落地页 landing),默认语言(英文)的正本。
|
||||||
|
* Elementor 原始 JSON 存于 meta.elementor,供 1:1 还原渲染器使用。
|
||||||
|
*/
|
||||||
|
export const contents = pgTable(
|
||||||
|
"contents",
|
||||||
|
{
|
||||||
|
/** # 数据库字段 内容 ID */
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
/** # 数据库字段 内容类型:page | post | landing */
|
||||||
|
type: varchar("type", { length: 32 }).notNull(),
|
||||||
|
/** # 数据库字段 URL 别名(默认语言) */
|
||||||
|
slug: varchar("slug", { length: 255 }).notNull(),
|
||||||
|
/** # 数据库字段 标题 */
|
||||||
|
title: text("title").notNull(),
|
||||||
|
/** # 数据库字段 摘要 */
|
||||||
|
excerpt: text("excerpt").default("").notNull(),
|
||||||
|
/** # 数据库字段 正文 HTML */
|
||||||
|
html: text("html").default("").notNull(),
|
||||||
|
/** # 数据库字段 状态:draft | review | published */
|
||||||
|
status: varchar("status", { length: 16 }).default("published").notNull(),
|
||||||
|
/** # 数据库字段 作者 */
|
||||||
|
authorId: integer("author_id"),
|
||||||
|
/** # 数据库字段 特色图片 */
|
||||||
|
featuredMediaId: integer("featured_media_id"),
|
||||||
|
/** # 数据库字段 页面模板名(home / product / blog / blank …) */
|
||||||
|
template: varchar("template", { length: 64 }).default("default").notNull(),
|
||||||
|
/** # 数据库字段 菜单排序 */
|
||||||
|
menuOrder: integer("menu_order").default(0).notNull(),
|
||||||
|
/** # 数据库字段 浏览次数 */
|
||||||
|
views: integer("views").default(0).notNull(),
|
||||||
|
/** # 数据库字段 是否置顶 */
|
||||||
|
sticky: boolean("sticky").default(false).notNull(),
|
||||||
|
/** # 数据库字段 SEO 标题 */
|
||||||
|
seoTitle: text("seo_title"),
|
||||||
|
/** # 数据库字段 SEO 描述 */
|
||||||
|
seoDescription: text("seo_description"),
|
||||||
|
/** # 数据库字段 扩展数据(elementor JSON 等) */
|
||||||
|
meta: jsonb("meta").default({}).notNull(),
|
||||||
|
/** # 数据库字段 原 WordPress post ID */
|
||||||
|
legacyId: integer("legacy_id"),
|
||||||
|
/** # 数据库字段 发布时间 */
|
||||||
|
publishedAt: timestamp("published_at", { withTimezone: true }),
|
||||||
|
/** # 数据库字段 创建时间 */
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
/** # 数据库字段 更新时间 */
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex("contents_slug_type_idx").on(t.slug, t.type),
|
||||||
|
index("contents_type_status_idx").on(t.type, t.status),
|
||||||
|
index("contents_legacy_idx").on(t.legacyId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 内容多语言翻译版本(每种语言独立 slug/标题/正文/SEO;缺翻译时不生成伪语言页面)。
|
||||||
|
*/
|
||||||
|
export const contentTranslations = pgTable(
|
||||||
|
"content_translations",
|
||||||
|
{
|
||||||
|
/** # 数据库字段 翻译 ID */
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
/** # 数据库字段 内容 ID */
|
||||||
|
contentId: integer("content_id").notNull(),
|
||||||
|
/** # 数据库字段 语言代码(BCP47,如 fr / de) */
|
||||||
|
lang: varchar("lang", { length: 16 }).notNull(),
|
||||||
|
/** # 数据库字段 该语言的 URL 别名 */
|
||||||
|
slug: varchar("slug", { length: 255 }).notNull(),
|
||||||
|
/** # 数据库字段 标题 */
|
||||||
|
title: text("title").notNull(),
|
||||||
|
/** # 数据库字段 摘要 */
|
||||||
|
excerpt: text("excerpt").default("").notNull(),
|
||||||
|
/** # 数据库字段 正文 HTML */
|
||||||
|
html: text("html").default("").notNull(),
|
||||||
|
/** # 数据库字段 状态:draft | review | published */
|
||||||
|
status: varchar("status", { length: 16 }).default("draft").notNull(),
|
||||||
|
/** # 数据库字段 SEO 标题 */
|
||||||
|
seoTitle: text("seo_title"),
|
||||||
|
/** # 数据库字段 SEO 描述 */
|
||||||
|
seoDescription: text("seo_description"),
|
||||||
|
/** # 数据库字段 翻译时间 */
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
/** # 数据库字段 更新时间 */
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex("content_translations_content_lang_idx").on(t.contentId, t.lang),
|
||||||
|
index("content_translations_lang_status_idx").on(t.lang, t.status),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 内容修订快照(发布前留档,支持回滚)。
|
||||||
|
*/
|
||||||
|
export const contentRevisions = pgTable("content_revisions", {
|
||||||
|
/** # 数据库字段 修订 ID */
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
/** # 数据库字段 内容 ID */
|
||||||
|
contentId: integer("content_id").notNull(),
|
||||||
|
/** # 数据库字段 语言(缺省为默认语言) */
|
||||||
|
lang: varchar("lang", { length: 16 }).default("en").notNull(),
|
||||||
|
/** # 数据库字段 标题快照 */
|
||||||
|
title: text("title").notNull(),
|
||||||
|
/** # 数据库字段 正文 HTML 快照 */
|
||||||
|
html: text("html").notNull(),
|
||||||
|
/** # 数据库字段 修订人 */
|
||||||
|
authorId: integer("author_id"),
|
||||||
|
/** # 数据库字段 修订时间 */
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 分类/标签(taxonomy:category / post_tag / product_cat / product_tag / 属性 pa_*)。
|
||||||
|
*/
|
||||||
|
export const categories = pgTable(
|
||||||
|
"categories",
|
||||||
|
{
|
||||||
|
/** # 数据库字段 分类 ID */
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
/** # 数据库字段 taxonomy 名称 */
|
||||||
|
taxonomy: varchar("taxonomy", { length: 64 }).notNull(),
|
||||||
|
/** # 数据库字段 名称 */
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
/** # 数据库字段 URL 别名 */
|
||||||
|
slug: varchar("slug", { length: 255 }).notNull(),
|
||||||
|
/** # 数据库字段 父级分类 */
|
||||||
|
parentId: integer("parent_id"),
|
||||||
|
/** # 数据库字段 描述 */
|
||||||
|
description: text("description").default("").notNull(),
|
||||||
|
/** # 数据库字段 分类图片 */
|
||||||
|
imageMediaId: integer("image_media_id"),
|
||||||
|
/** # 数据库字段 排序 */
|
||||||
|
sort: integer("sort").default(0).notNull(),
|
||||||
|
/** # 数据库字段 原 WordPress term_id */
|
||||||
|
legacyId: integer("legacy_id"),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex("categories_taxonomy_slug_idx").on(t.taxonomy, t.slug),
|
||||||
|
index("categories_parent_idx").on(t.parentId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 内容与分类/标签的关联。
|
||||||
|
*/
|
||||||
|
export const contentTerms = pgTable(
|
||||||
|
"content_terms",
|
||||||
|
{
|
||||||
|
/** # 数据库字段 内容 ID */
|
||||||
|
contentId: integer("content_id").notNull(),
|
||||||
|
/** # 数据库字段 分类 ID */
|
||||||
|
categoryId: integer("category_id").notNull(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
primaryKey({ columns: [t.contentId, t.categoryId] }),
|
||||||
|
index("content_terms_category_idx").on(t.categoryId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 导航菜单定义(header / footer / account 等位置)。
|
||||||
|
*/
|
||||||
|
export const menus = pgTable("menus", {
|
||||||
|
/** # 数据库字段 菜单 ID */
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
/** # 数据库字段 菜单名称 */
|
||||||
|
name: varchar("name", { length: 128 }).notNull(),
|
||||||
|
/** # 数据库字段 位置别名(menu-1 / menu-2 / max_mega_menu_1 …) */
|
||||||
|
location: varchar("location", { length: 64 }).notNull(),
|
||||||
|
/** # 数据库字段 原菜单 term_id */
|
||||||
|
legacyId: integer("legacy_id"),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 菜单项(支持多级树)。
|
||||||
|
*/
|
||||||
|
export const menuItems = pgTable(
|
||||||
|
"menu_items",
|
||||||
|
{
|
||||||
|
/** # 数据库字段 菜单项 ID */
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
/** # 数据库字段 所属菜单 */
|
||||||
|
menuId: integer("menu_id").notNull(),
|
||||||
|
/** # 数据库字段 父菜单项 */
|
||||||
|
parentId: integer("parent_id"),
|
||||||
|
/** # 数据库字段 显示文字 */
|
||||||
|
title: varchar("title", { length: 255 }).notNull(),
|
||||||
|
/** # 数据库字段 链接地址 */
|
||||||
|
url: text("url").notNull(),
|
||||||
|
/** # 数据库字段 打开方式:_self | _blank */
|
||||||
|
target: varchar("target", { length: 16 }).default("_self").notNull(),
|
||||||
|
/** # 数据库字段 排序 */
|
||||||
|
sort: integer("sort").default(0).notNull(),
|
||||||
|
/** # 数据库字段 原 menu_item post_id */
|
||||||
|
legacyId: integer("legacy_id"),
|
||||||
|
},
|
||||||
|
(t) => [index("menu_items_menu_idx").on(t.menuId, t.sort)],
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ============================= 产品域 ============================= */
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 产品主表(WooCommerce 迁移)。
|
||||||
|
*/
|
||||||
|
export const products = pgTable(
|
||||||
|
"products",
|
||||||
|
{
|
||||||
|
/** # 数据库字段 产品 ID */
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
/** # 数据库字段 SKU */
|
||||||
|
sku: varchar("sku", { length: 128 }),
|
||||||
|
/** # 数据库字段 URL 别名 */
|
||||||
|
slug: varchar("slug", { length: 255 }).notNull(),
|
||||||
|
/** # 数据库字段 名称 */
|
||||||
|
title: text("title").notNull(),
|
||||||
|
/** # 数据库字段 短描述 */
|
||||||
|
shortDescription: text("short_description").default("").notNull(),
|
||||||
|
/** # 数据库字段 详细描述 HTML */
|
||||||
|
description: text("description").default("").notNull(),
|
||||||
|
/** # 数据库字段 现价 */
|
||||||
|
price: numeric("price", { precision: 12, scale: 2 }),
|
||||||
|
/** # 数据库字段 原价 */
|
||||||
|
regularPrice: numeric("regular_price", { precision: 12, scale: 2 }),
|
||||||
|
/** # 数据库字段 库存数量 */
|
||||||
|
stock: integer("stock"),
|
||||||
|
/** # 数据库字段 库存状态:instock | outofstock | onbackorder */
|
||||||
|
stockStatus: varchar("stock_status", { length: 32 }).default("instock").notNull(),
|
||||||
|
/** # 数据库字段 特色图片 */
|
||||||
|
featuredMediaId: integer("featured_media_id"),
|
||||||
|
/** # 数据库字段 图廊(媒体 ID 数组) */
|
||||||
|
gallery: jsonb("gallery").default([]).notNull(),
|
||||||
|
/** # 数据库字段 状态:draft | published */
|
||||||
|
status: varchar("status", { length: 16 }).default("published").notNull(),
|
||||||
|
/** # 数据库字段 是否主推产品 */
|
||||||
|
featured: boolean("featured").default(false).notNull(),
|
||||||
|
/** # 数据库字段 浏览次数 */
|
||||||
|
views: integer("views").default(0).notNull(),
|
||||||
|
/** # 数据库字段 SEO 标题 */
|
||||||
|
seoTitle: text("seo_title"),
|
||||||
|
/** # 数据库字段 SEO 描述 */
|
||||||
|
seoDescription: text("seo_description"),
|
||||||
|
/** # 数据库字段 规格书下载地址 */
|
||||||
|
specUrl: text("spec_url"),
|
||||||
|
/** # 数据库字段 Elementor 原始 JSON */
|
||||||
|
meta: jsonb("meta").default({}).notNull(),
|
||||||
|
/** # 数据库字段 原 WordPress post ID */
|
||||||
|
legacyId: integer("legacy_id"),
|
||||||
|
/** # 数据库字段 创建时间 */
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
/** # 数据库字段 更新时间 */
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex("products_slug_idx").on(t.slug),
|
||||||
|
index("products_status_idx").on(t.status),
|
||||||
|
index("products_sku_idx").on(t.sku),
|
||||||
|
index("products_legacy_idx").on(t.legacyId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 产品多语言翻译。
|
||||||
|
*/
|
||||||
|
export const productTranslations = pgTable(
|
||||||
|
"product_translations",
|
||||||
|
{
|
||||||
|
/** # 数据库字段 翻译 ID */
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
/** # 数据库字段 产品 ID */
|
||||||
|
productId: integer("product_id").notNull(),
|
||||||
|
/** # 数据库字段 语言代码 */
|
||||||
|
lang: varchar("lang", { length: 16 }).notNull(),
|
||||||
|
/** # 数据库字段 该语言 slug */
|
||||||
|
slug: varchar("slug", { length: 255 }).notNull(),
|
||||||
|
/** # 数据库字段 名称 */
|
||||||
|
title: text("title").notNull(),
|
||||||
|
/** # 数据库字段 短描述 */
|
||||||
|
shortDescription: text("short_description").default("").notNull(),
|
||||||
|
/** # 数据库字段 详细描述 */
|
||||||
|
description: text("description").default("").notNull(),
|
||||||
|
/** # 数据库字段 状态:draft | review | published */
|
||||||
|
status: varchar("status", { length: 16 }).default("draft").notNull(),
|
||||||
|
/** # 数据库字段 SEO 标题 */
|
||||||
|
seoTitle: text("seo_title"),
|
||||||
|
/** # 数据库字段 SEO 描述 */
|
||||||
|
seoDescription: text("seo_description"),
|
||||||
|
/** # 数据库字段 更新时间 */
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex("product_translations_product_lang_idx").on(t.productId, t.lang)],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 产品属性定义(RAM / Flash / Chip Platform / Downlink 等,原 WooCommerce 全局属性)。
|
||||||
|
*/
|
||||||
|
export const productAttributes = pgTable(
|
||||||
|
"product_attributes",
|
||||||
|
{
|
||||||
|
/** # 数据库字段 属性 ID */
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
/** # 数据库字段 属性名 */
|
||||||
|
name: varchar("name", { length: 128 }).notNull(),
|
||||||
|
/** # 数据库字段 属性 slug(attribute_id,如 ram / chip-platform) */
|
||||||
|
slug: varchar("slug", { length: 128 }).notNull(),
|
||||||
|
/** # 数据库字段 是否作为筛选条件 */
|
||||||
|
filterable: boolean("filterable").default(true).notNull(),
|
||||||
|
/** # 数据库字段 排序 */
|
||||||
|
sort: integer("sort").default(0).notNull(),
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex("product_attributes_slug_idx").on(t.slug)],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 产品属性可选值。
|
||||||
|
*/
|
||||||
|
export const productAttributeValues = pgTable(
|
||||||
|
"product_attribute_values",
|
||||||
|
{
|
||||||
|
/** # 数据库字段 值 ID */
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
/** # 数据库字段 所属属性 */
|
||||||
|
attributeId: integer("attribute_id").notNull(),
|
||||||
|
/** # 数据库字段 显示值 */
|
||||||
|
value: varchar("value", { length: 255 }).notNull(),
|
||||||
|
/** # 数据库字段 值 slug */
|
||||||
|
slug: varchar("slug", { length: 255 }).notNull(),
|
||||||
|
/** # 数据库字段 排序 */
|
||||||
|
sort: integer("sort").default(0).notNull(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex("product_attr_values_attr_slug_idx").on(t.attributeId, t.slug),
|
||||||
|
index("product_attr_values_attr_idx").on(t.attributeId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 产品 ↔ 属性值 关联(筛选查询主路径,建立复合索引)。
|
||||||
|
*/
|
||||||
|
export const productAttributeMap = pgTable(
|
||||||
|
"product_attribute_map",
|
||||||
|
{
|
||||||
|
/** # 数据库字段 产品 ID */
|
||||||
|
productId: integer("product_id").notNull(),
|
||||||
|
/** # 数据库字段 属性值 ID */
|
||||||
|
valueId: integer("value_id").notNull(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
primaryKey({ columns: [t.productId, t.valueId] }),
|
||||||
|
index("product_attr_map_value_idx").on(t.valueId, t.productId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 产品 ↔ 产品分类 关联。
|
||||||
|
*/
|
||||||
|
export const productTerms = pgTable(
|
||||||
|
"product_terms",
|
||||||
|
{
|
||||||
|
/** # 数据库字段 产品 ID */
|
||||||
|
productId: integer("product_id").notNull(),
|
||||||
|
/** # 数据库字段 分类 ID(taxonomy=product_cat) */
|
||||||
|
categoryId: integer("category_id").notNull(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
primaryKey({ columns: [t.productId, t.categoryId] }),
|
||||||
|
index("product_terms_category_idx").on(t.categoryId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ============================= 商城域 ============================= */
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 用户(后台账号 + 商城注册客户)。保留 WordPress phpass 密码哈希用于存量客户登录兼容。
|
||||||
|
*/
|
||||||
|
export const users = pgTable(
|
||||||
|
"users",
|
||||||
|
{
|
||||||
|
/** # 数据库字段 用户 ID */
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
/** # 数据库字段 邮箱(登录名) */
|
||||||
|
email: varchar("email", { length: 255 }).notNull(),
|
||||||
|
/** # 数据库字段 用户名 */
|
||||||
|
username: varchar("username", { length: 128 }),
|
||||||
|
/** # 数据库字段 显示名称 */
|
||||||
|
displayName: varchar("display_name", { length: 255 }).default("").notNull(),
|
||||||
|
/** # 数据库字段 密码哈希(新账号 bcrypt) */
|
||||||
|
passwordHash: varchar("password_hash", { length: 255 }),
|
||||||
|
/** # 数据库字段 WordPress 旧密码哈希(phpass $P$) */
|
||||||
|
legacyPasswordHash: varchar("legacy_password_hash", { length: 255 }),
|
||||||
|
/** # 数据库字段 角色:admin | editor | manager | customer */
|
||||||
|
role: varchar("role", { length: 32 }).default("customer").notNull(),
|
||||||
|
/** # 数据库字段 账单地址 */
|
||||||
|
billing: jsonb("billing").default({}).notNull(),
|
||||||
|
/** # 数据库字段 收货地址 */
|
||||||
|
shipping: jsonb("shipping").default({}).notNull(),
|
||||||
|
/** # 数据库字段 原 WP user ID */
|
||||||
|
legacyId: integer("legacy_id"),
|
||||||
|
/** # 数据库字段 创建时间 */
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex("users_email_idx").on(t.email),
|
||||||
|
index("users_legacy_idx").on(t.legacyId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 服务端会话(HttpOnly Cookie → 会话记录)。
|
||||||
|
*/
|
||||||
|
export const sessions = pgTable(
|
||||||
|
"sessions",
|
||||||
|
{
|
||||||
|
/** # 数据库字段 会话令牌 */
|
||||||
|
token: varchar("token", { length: 64 }).primaryKey(),
|
||||||
|
/** # 数据库字段 用户 ID */
|
||||||
|
userId: integer("user_id").notNull(),
|
||||||
|
/** # 数据库字段 过期时间 */
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||||
|
/** # 数据库字段 创建时间 */
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(t) => [index("sessions_user_idx").on(t.userId)],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 购物车(登录用户或匿名 session_key 维度)。
|
||||||
|
*/
|
||||||
|
export const carts = pgTable("carts", {
|
||||||
|
/** # 数据库字段 购物车 ID */
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
/** # 数据库字段 用户 ID(登录用户) */
|
||||||
|
userId: integer("user_id"),
|
||||||
|
/** # 数据库字段 匿名会话键 */
|
||||||
|
sessionKey: varchar("session_key", { length: 64 }),
|
||||||
|
/** # 数据库字段 创建时间 */
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
/** # 数据库字段 更新时间 */
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 购物车明细。
|
||||||
|
*/
|
||||||
|
export const cartItems = pgTable(
|
||||||
|
"cart_items",
|
||||||
|
{
|
||||||
|
/** # 数据库字段 明细 ID */
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
/** # 数据库字段 购物车 ID */
|
||||||
|
cartId: integer("cart_id").notNull(),
|
||||||
|
/** # 数据库字段 产品 ID */
|
||||||
|
productId: integer("product_id").notNull(),
|
||||||
|
/** # 数据库字段 数量 */
|
||||||
|
qty: integer("qty").default(1).notNull(),
|
||||||
|
},
|
||||||
|
(t) => [index("cart_items_cart_idx").on(t.cartId)],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 订单主表。
|
||||||
|
*/
|
||||||
|
export const orders = pgTable(
|
||||||
|
"orders",
|
||||||
|
{
|
||||||
|
/** # 数据库字段 订单 ID */
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
/** # 数据库字段 订单号(对外展示) */
|
||||||
|
orderNumber: varchar("order_number", { length: 32 }).notNull(),
|
||||||
|
/** # 数据库字段 客户 ID */
|
||||||
|
userId: integer("user_id"),
|
||||||
|
/** # 数据库字段 状态:pending | on-hold | processing | completed | cancelled | refunded */
|
||||||
|
status: varchar("status", { length: 24 }).default("pending").notNull(),
|
||||||
|
/** # 数据库字段 币种 */
|
||||||
|
currency: varchar("currency", { length: 8 }).default("USD").notNull(),
|
||||||
|
/** # 数据库字段 商品小计 */
|
||||||
|
subtotal: numeric("subtotal", { precision: 12, scale: 2 }).default("0").notNull(),
|
||||||
|
/** # 数据库字段 运费 */
|
||||||
|
shippingTotal: numeric("shipping_total", { precision: 12, scale: 2 }).default("0").notNull(),
|
||||||
|
/** # 数据库字段 税费 */
|
||||||
|
taxTotal: numeric("tax_total", { precision: 12, scale: 2 }).default("0").notNull(),
|
||||||
|
/** # 数据库字段 订单总额 */
|
||||||
|
total: numeric("total", { precision: 12, scale: 2 }).default("0").notNull(),
|
||||||
|
/** # 数据库字段 支付方式 */
|
||||||
|
paymentMethod: varchar("payment_method", { length: 64 }),
|
||||||
|
/** # 数据库字段 支付状态 */
|
||||||
|
paymentStatus: varchar("payment_status", { length: 24 }).default("unpaid").notNull(),
|
||||||
|
/** # 数据库字段 支付交易号 */
|
||||||
|
transactionId: varchar("transaction_id", { length: 128 }),
|
||||||
|
/** # 数据库字段 账单信息 */
|
||||||
|
billing: jsonb("billing").default({}).notNull(),
|
||||||
|
/** # 数据库字段 收货信息 */
|
||||||
|
shipping: jsonb("shipping").default({}).notNull(),
|
||||||
|
/** # 数据库字段 客户备注 */
|
||||||
|
customerNote: text("customer_note").default("").notNull(),
|
||||||
|
/** # 数据库字段 原 WP order ID */
|
||||||
|
legacyId: integer("legacy_id"),
|
||||||
|
/** # 数据库字段 下单时间 */
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
/** # 数据库字段 更新时间 */
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex("orders_number_idx").on(t.orderNumber),
|
||||||
|
index("orders_status_idx").on(t.status),
|
||||||
|
index("orders_user_idx").on(t.userId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 订单明细(下单时快照商品名/价格)。
|
||||||
|
*/
|
||||||
|
export const orderItems = pgTable(
|
||||||
|
"order_items",
|
||||||
|
{
|
||||||
|
/** # 数据库字段 明细 ID */
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
/** # 数据库字段 订单 ID */
|
||||||
|
orderId: integer("order_id").notNull(),
|
||||||
|
/** # 数据库字段 产品 ID */
|
||||||
|
productId: integer("product_id"),
|
||||||
|
/** # 数据库字段 商品名快照 */
|
||||||
|
name: text("name").notNull(),
|
||||||
|
/** # 数据库字段 SKU 快照 */
|
||||||
|
sku: varchar("sku", { length: 128 }),
|
||||||
|
/** # 数据库字段 数量 */
|
||||||
|
qty: integer("qty").default(1).notNull(),
|
||||||
|
/** # 数据库字段 单价 */
|
||||||
|
price: numeric("price", { precision: 12, scale: 2 }).default("0").notNull(),
|
||||||
|
/** # 数据库字段 小计 */
|
||||||
|
total: numeric("total", { precision: 12, scale: 2 }).default("0").notNull(),
|
||||||
|
},
|
||||||
|
(t) => [index("order_items_order_idx").on(t.orderId)],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 支付流水(含第三方回调原始报文,幂等键 transaction_id)。
|
||||||
|
*/
|
||||||
|
export const payments = pgTable(
|
||||||
|
"payments",
|
||||||
|
{
|
||||||
|
/** # 数据库字段 支付 ID */
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
/** # 数据库字段 订单 ID */
|
||||||
|
orderId: integer("order_id").notNull(),
|
||||||
|
/** # 数据库字段 支付方式 */
|
||||||
|
method: varchar("method", { length: 64 }).notNull(),
|
||||||
|
/** # 数据库字段 状态:succeeded | failed | refunded | pending */
|
||||||
|
status: varchar("status", { length: 24 }).default("pending").notNull(),
|
||||||
|
/** # 数据库字段 交易号(唯一,回调幂等) */
|
||||||
|
transactionId: varchar("transaction_id", { length: 128 }),
|
||||||
|
/** # 数据库字段 金额 */
|
||||||
|
amount: numeric("amount", { precision: 12, scale: 2 }).default("0").notNull(),
|
||||||
|
/** # 数据库字段 回调原始数据 */
|
||||||
|
raw: jsonb("raw").default({}).notNull(),
|
||||||
|
/** # 数据库字段 创建时间 */
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index("payments_order_idx").on(t.orderId),
|
||||||
|
uniqueIndex("payments_txn_idx").on(t.transactionId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ============================= 询盘域 ============================= */
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 询盘表单定义(Contact Form 7 迁移,字段结构存 JSON)。
|
||||||
|
*/
|
||||||
|
export const inquiryForms = pgTable(
|
||||||
|
"inquiry_forms",
|
||||||
|
{
|
||||||
|
/** # 数据库字段 表单 ID */
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
/** # 数据库字段 表单名称 */
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
/** # 数据库字段 表单 key(slug) */
|
||||||
|
formKey: varchar("form_key", { length: 128 }).notNull(),
|
||||||
|
/** # 数据库字段 字段定义 JSON */
|
||||||
|
fields: jsonb("fields").default([]).notNull(),
|
||||||
|
/** # 数据库字段 收件邮箱 */
|
||||||
|
mailTo: varchar("mail_to", { length: 255 }),
|
||||||
|
/** # 数据库字段 是否启用 */
|
||||||
|
enabled: boolean("enabled").default(true).notNull(),
|
||||||
|
/** # 数据库字段 原 CF7 form_id */
|
||||||
|
legacyId: integer("legacy_id"),
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex("inquiry_forms_key_idx").on(t.formKey)],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 询盘记录(CF7/Flamingo 与 Elementor Form 提交合并归一)。
|
||||||
|
*/
|
||||||
|
export const inquiries = pgTable(
|
||||||
|
"inquiries",
|
||||||
|
{
|
||||||
|
/** # 数据库字段 询盘 ID */
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
/** # 数据库字段 来源表单名 */
|
||||||
|
formName: varchar("form_name", { length: 255 }).default("").notNull(),
|
||||||
|
/** # 数据库字段 表单 ID */
|
||||||
|
formId: integer("form_id"),
|
||||||
|
/** # 数据库字段 姓名 */
|
||||||
|
name: varchar("name", { length: 255 }).default("").notNull(),
|
||||||
|
/** # 数据库字段 邮箱 */
|
||||||
|
email: varchar("email", { length: 255 }).default("").notNull(),
|
||||||
|
/** # 数据库字段 电话 */
|
||||||
|
phone: varchar("phone", { length: 128 }).default("").notNull(),
|
||||||
|
/** # 数据库字段 公司 */
|
||||||
|
company: varchar("company", { length: 255 }).default("").notNull(),
|
||||||
|
/** # 数据库字段 国家/地区 */
|
||||||
|
country: varchar("country", { length: 128 }).default("").notNull(),
|
||||||
|
/** # 数据库字段 询盘内容 */
|
||||||
|
message: text("message").default("").notNull(),
|
||||||
|
/** # 数据库字段 感兴趣的产品 */
|
||||||
|
productInterest: varchar("product_interest", { length: 255 }).default("").notNull(),
|
||||||
|
/** # 数据库字段 完整原始字段(含 annualdemand、utm 等) */
|
||||||
|
fields: jsonb("fields").default({}).notNull(),
|
||||||
|
/** # 数据库字段 状态:new | read | replied | spam | archived */
|
||||||
|
status: varchar("status", { length: 16 }).default("new").notNull(),
|
||||||
|
/** # 数据库字段 来源渠道(channel / subject,可能很长用 text) */
|
||||||
|
channel: text("channel").default("").notNull(),
|
||||||
|
/** # 数据库字段 来源页面 */
|
||||||
|
sourcePage: text("source_page").default("").notNull(),
|
||||||
|
/** # 数据库字段 来源 IP */
|
||||||
|
ip: varchar("ip", { length: 64 }).default("").notNull(),
|
||||||
|
/** # 数据库字段 UTM 参数 */
|
||||||
|
utm: jsonb("utm").default({}).notNull(),
|
||||||
|
/** # 数据库字段 原 Flamingo/Elementor 记录 ID */
|
||||||
|
legacyId: integer("legacy_id"),
|
||||||
|
/** # 数据库字段 提交时间 */
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index("inquiries_status_idx").on(t.status, t.createdAt),
|
||||||
|
index("inquiries_email_idx").on(t.email),
|
||||||
|
index("inquiries_created_idx").on(t.createdAt),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ============================= 运营域 ============================= */
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 媒体库(原 wp-content/uploads 附件)。
|
||||||
|
*/
|
||||||
|
export const media = pgTable(
|
||||||
|
"media",
|
||||||
|
{
|
||||||
|
/** # 数据库字段 媒体 ID */
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
/** # 数据库字段 相对路径(uploads 下) */
|
||||||
|
path: varchar("path", { length: 512 }).notNull(),
|
||||||
|
/** # 数据库字段 访问 URL */
|
||||||
|
url: text("url").notNull(),
|
||||||
|
/** # 数据库字段 MIME 类型 */
|
||||||
|
mimeType: varchar("mime_type", { length: 128 }).default("").notNull(),
|
||||||
|
/** # 数据库字段 宽 */
|
||||||
|
width: integer("width"),
|
||||||
|
/** # 数据库字段 高 */
|
||||||
|
height: integer("height"),
|
||||||
|
/** # 数据库字段 文件大小 */
|
||||||
|
size: bigint("size", { mode: "number" }),
|
||||||
|
/** # 数据库字段 替代文本 */
|
||||||
|
altText: text("alt_text").default("").notNull(),
|
||||||
|
/** # 数据库字段 标题 */
|
||||||
|
title: text("title").default("").notNull(),
|
||||||
|
/** # 数据库字段 引用次数 */
|
||||||
|
refCount: integer("ref_count").default(0).notNull(),
|
||||||
|
/** # 数据库字段 原 WP attachment ID */
|
||||||
|
legacyId: integer("legacy_id"),
|
||||||
|
/** # 数据库字段 上传时间 */
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex("media_path_idx").on(t.path),
|
||||||
|
index("media_legacy_idx").on(t.legacyId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 301/302 重定向(Redirection 插件迁移)。
|
||||||
|
*/
|
||||||
|
export const redirects = pgTable(
|
||||||
|
"redirects",
|
||||||
|
{
|
||||||
|
/** # 数据库字段 重定向 ID */
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
/** # 数据库字段 源路径(支持正则) */
|
||||||
|
source: text("source").notNull(),
|
||||||
|
/** # 数据库字段 目标地址 */
|
||||||
|
target: text("target").notNull(),
|
||||||
|
/** # 数据库字段 状态码 */
|
||||||
|
code: integer("code").default(301).notNull(),
|
||||||
|
/** # 数据库字段 是否正则匹配 */
|
||||||
|
isRegex: boolean("is_regex").default(false).notNull(),
|
||||||
|
/** # 数据库字段 命中次数 */
|
||||||
|
hits: integer("hits").default(0).notNull(),
|
||||||
|
/** # 数据库字段 是否启用 */
|
||||||
|
enabled: boolean("enabled").default(true).notNull(),
|
||||||
|
/** # 数据库字段 原 redirection item id */
|
||||||
|
legacyId: integer("legacy_id"),
|
||||||
|
},
|
||||||
|
(t) => [index("redirects_source_idx").on(t.source)],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 筛选配置(Filter Everything 迁移,前台产品筛选器)。
|
||||||
|
*/
|
||||||
|
export const filters = pgTable(
|
||||||
|
"filters",
|
||||||
|
{
|
||||||
|
/** # 数据库字段 筛选 ID */
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
/** # 数据库字段 筛选名称 */
|
||||||
|
name: varchar("name", { length: 128 }).notNull(),
|
||||||
|
/** # 数据库字段 URL 参数名(如 chip-platform) */
|
||||||
|
slug: varchar("slug", { length: 128 }).notNull(),
|
||||||
|
/** # 数据库字段 类型:attribute | category | price */
|
||||||
|
type: varchar("type", { length: 24 }).default("attribute").notNull(),
|
||||||
|
/** # 数据库字段 关联属性 ID(type=attribute 时) */
|
||||||
|
attributeId: integer("attribute_id"),
|
||||||
|
/** # 数据库字段 关联 taxonomy */
|
||||||
|
taxonomy: varchar("taxonomy", { length: 64 }),
|
||||||
|
/** # 数据库字段 显示位置 */
|
||||||
|
position: integer("position").default(0).notNull(),
|
||||||
|
/** # 数据库字段 是否启用 */
|
||||||
|
enabled: boolean("enabled").default(true).notNull(),
|
||||||
|
/** # 数据库字段 原 filter-field post_id */
|
||||||
|
legacyId: integer("legacy_id"),
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex("filters_slug_idx").on(t.slug)],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 语言配置(GTranslate 迁移:启用的语言与默认语言)。
|
||||||
|
*/
|
||||||
|
export const languages = pgTable(
|
||||||
|
"languages",
|
||||||
|
{
|
||||||
|
/** # 数据库字段 语言代码(BCP47) */
|
||||||
|
code: varchar("code", { length: 16 }).primaryKey(),
|
||||||
|
/** # 数据库字段 显示名 */
|
||||||
|
name: varchar("name", { length: 64 }).notNull(),
|
||||||
|
/** # 数据库字段 是否默认语言 */
|
||||||
|
isDefault: boolean("is_default").default(false).notNull(),
|
||||||
|
/** # 数据库字段 是否启用(可发布真实页面) */
|
||||||
|
enabled: boolean("enabled").default(false).notNull(),
|
||||||
|
/** # 数据库字段 是否机器翻译(GTranslate 存量) */
|
||||||
|
machine: boolean("machine").default(false).notNull(),
|
||||||
|
/** # 数据库字段 排序 */
|
||||||
|
sort: integer("sort").default(0).notNull(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 持久化任务队列(独立于 Web 请求的后台任务,带重试)。
|
||||||
|
*/
|
||||||
|
export const jobs = pgTable(
|
||||||
|
"jobs",
|
||||||
|
{
|
||||||
|
/** # 数据库字段 任务 ID */
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
/** # 数据库字段 任务类型(email.send / media.reindex …) */
|
||||||
|
type: varchar("type", { length: 64 }).notNull(),
|
||||||
|
/** # 数据库字段 任务参数 */
|
||||||
|
payload: jsonb("payload").default({}).notNull(),
|
||||||
|
/** # 数据库字段 状态:pending | running | done | failed */
|
||||||
|
status: varchar("status", { length: 16 }).default("pending").notNull(),
|
||||||
|
/** # 数据库字段 已重试次数 */
|
||||||
|
attempts: integer("attempts").default(0).notNull(),
|
||||||
|
/** # 数据库字段 最大重试次数 */
|
||||||
|
maxAttempts: integer("max_attempts").default(3).notNull(),
|
||||||
|
/** # 数据库字段 计划执行时间 */
|
||||||
|
runAt: timestamp("run_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
/** # 数据库字段 最近错误 */
|
||||||
|
lastError: text("last_error"),
|
||||||
|
/** # 数据库字段 创建时间 */
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
/** # 数据库字段 更新时间 */
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(t) => [index("jobs_status_runat_idx").on(t.status, t.runAt)],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 审计日志(后台敏感操作留痕)。
|
||||||
|
*/
|
||||||
|
export const auditLogs = pgTable(
|
||||||
|
"audit_logs",
|
||||||
|
{
|
||||||
|
/** # 数据库字段 日志 ID */
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
/** # 数据库字段 操作人 */
|
||||||
|
actorId: integer("actor_id"),
|
||||||
|
/** # 数据库字段 动作(如 product.update) */
|
||||||
|
action: varchar("action", { length: 128 }).notNull(),
|
||||||
|
/** # 数据库字段 实体类型 */
|
||||||
|
entity: varchar("entity", { length: 64 }).notNull(),
|
||||||
|
/** # 数据库字段 实体 ID */
|
||||||
|
entityId: varchar("entity_id", { length: 64 }),
|
||||||
|
/** # 数据库字段 附加数据 */
|
||||||
|
meta: jsonb("meta").default({}).notNull(),
|
||||||
|
/** # 数据库字段 操作 IP */
|
||||||
|
ip: varchar("ip", { length: 64 }).default("").notNull(),
|
||||||
|
/** # 数据库字段 操作时间 */
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(t) => [index("audit_logs_time_idx").on(t.createdAt)],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 站点设置(键值 JSON:导航、邮件、支付、缓存开关等)。
|
||||||
|
*/
|
||||||
|
export const settings = pgTable("settings", {
|
||||||
|
/** # 数据库字段 设置键 */
|
||||||
|
key: varchar("key", { length: 128 }).primaryKey(),
|
||||||
|
/** # 数据库字段 设置值 */
|
||||||
|
value: jsonb("value").default({}).notNull(),
|
||||||
|
/** # 数据库字段 更新时间 */
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 数据库表
|
||||||
|
* 旧 ID 映射(WordPress ID → 新系统 ID,迁移校验与增量同步用)。
|
||||||
|
*/
|
||||||
|
export const legacyIdMappings = pgTable(
|
||||||
|
"legacy_id_mappings",
|
||||||
|
{
|
||||||
|
/** # 数据库字段 映射 ID */
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
/** # 数据库字段 实体类型(post/product/user/order/media …) */
|
||||||
|
entity: varchar("entity", { length: 64 }).notNull(),
|
||||||
|
/** # 数据库字段 原 WP ID */
|
||||||
|
legacyId: integer("legacy_id").notNull(),
|
||||||
|
/** # 数据库字段 新系统 ID */
|
||||||
|
newId: integer("new_id").notNull(),
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex("legacy_map_entity_id_idx").on(t.entity, t.legacyId)],
|
||||||
|
);
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* # 模块 i18n 核心(服务端相关)
|
||||||
|
* 词典注册表 / t() 构造 / React 上下文;当前语言从 cookie 读取(见 shared.ts)。
|
||||||
|
*/
|
||||||
|
import { createContext, useContext, type ReactNode } from "react";
|
||||||
|
import { createIsomorphicFn } from "@tanstack/react-start";
|
||||||
|
import { getUiLocale, isSupportedLocale, LOCALE_COOKIE, type LocaleDef } from "~/i18n/shared";
|
||||||
|
import { en, type Dict } from "~/i18n/locales/en";
|
||||||
|
|
||||||
|
// 客户端安全部分原样转出(语言列表、cookie 读写)
|
||||||
|
export { LOCALES, LOCALE_COOKIE, isSupportedLocale, getUiLocale, setUiLocale } from "~/i18n/shared";
|
||||||
|
export type { LocaleDef } from "~/i18n/shared";
|
||||||
|
|
||||||
|
/** # 函数 读取当前语言(同构:服务端读请求 cookie,客户端读 document.cookie;无/非法回退 en) */
|
||||||
|
export const getLocale = createIsomorphicFn()
|
||||||
|
.server(async (): Promise<string> => {
|
||||||
|
// 动态引入避免客户端打包触碰 server 模块(TanStack import-protection 要求)
|
||||||
|
const { getCookie } = await import("@tanstack/react-start/server");
|
||||||
|
const v = getCookie(LOCALE_COOKIE);
|
||||||
|
return v && isSupportedLocale(v) ? v : "en";
|
||||||
|
})
|
||||||
|
.client(async (): Promise<string> => getUiLocale());
|
||||||
|
|
||||||
|
// ---- 词典注册表(locale → Partial<Dict>) ----
|
||||||
|
/** # 成员变量 词典注册表(各语言文件由 registerDict 注册) **/
|
||||||
|
const dicts = new Map<string, Partial<Dict>>([["en", en]]);
|
||||||
|
|
||||||
|
/** # 函数 注册某语言词典(locales/index 聚合时调用) */
|
||||||
|
export function registerDict(code: string, dict: Partial<Dict>): void {
|
||||||
|
dicts.set(code, dict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 取某语言词典(未注册返回空对象 → 全部回退英文) */
|
||||||
|
export function getDict(code: string): Partial<Dict> {
|
||||||
|
return dicts.get(code) ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 构造翻译函数:按词典查键,缺词回退英文,支持 {var} 插值 */
|
||||||
|
export function createT(code: string) {
|
||||||
|
const d = getDict(code);
|
||||||
|
return (key: keyof Dict, vars?: Record<string, string | number>): string => {
|
||||||
|
let s: string = d[key] ?? en[key];
|
||||||
|
if (vars) for (const [k, v] of Object.entries(vars)) s = s.replaceAll(`{${k}}`, String(v));
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 类型 t 函数 **/
|
||||||
|
export type T = ReturnType<typeof createT>;
|
||||||
|
|
||||||
|
// ---- React 上下文 ----
|
||||||
|
/** # 成员变量 语言上下文(root 路由以 loader 读到的 locale 注入) **/
|
||||||
|
const LocaleContext = createContext<T>(createT("en"));
|
||||||
|
|
||||||
|
/** # 组件 语言上下文提供者(locale 来自服务端 cookie) */
|
||||||
|
export function LocaleProvider(props: { locale: string; children: ReactNode }) {
|
||||||
|
const t = createT(props.locale);
|
||||||
|
return <LocaleContext.Provider value={t}>{props.children}</LocaleContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 组件内取翻译函数 */
|
||||||
|
export function useT(): T {
|
||||||
|
return useContext(LocaleContext);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/** # 模块 i18n 阿拉伯语词典 */
|
||||||
|
import type { Dict } from "~/i18n/locales/en";
|
||||||
|
|
||||||
|
export const ar: Partial<Dict> = {
|
||||||
|
topbar_oem: "خدمات OEM/ODM شاملة >>",
|
||||||
|
login: "تسجيل الدخول",
|
||||||
|
contact_us: "اتصل بنا",
|
||||||
|
search_placeholder: "ابحث عن المنتجات...",
|
||||||
|
search_placeholder_m: "بحث...",
|
||||||
|
search_go: "اذهب",
|
||||||
|
nav_home: "الرئيسية",
|
||||||
|
nav_products: "المنتجات",
|
||||||
|
nav_blog: "المدونة",
|
||||||
|
nav_shop: "المتجر",
|
||||||
|
nav_all_products: "جميع المنتجات",
|
||||||
|
select_language: "اختر اللغة",
|
||||||
|
|
||||||
|
pagination_prev: "« السابق",
|
||||||
|
pagination_next: "التالي »",
|
||||||
|
|
||||||
|
inquiry_title: "اطلب عرض سعر",
|
||||||
|
inquiry_name: "الاسم *",
|
||||||
|
inquiry_email: "البريد الإلكتروني *",
|
||||||
|
inquiry_phone: "الهاتف",
|
||||||
|
inquiry_company: "الشركة",
|
||||||
|
inquiry_country: "الدولة",
|
||||||
|
inquiry_interest: "المنتج المطلوب",
|
||||||
|
inquiry_message: "الرسالة *",
|
||||||
|
inquiry_message_ph: "يرجى وصف متطلباتك والكميات ومجال الاستخدام...",
|
||||||
|
inquiry_submit: "إرسال الاستفسار",
|
||||||
|
inquiry_submitting: "جارٍ الإرسال...",
|
||||||
|
inquiry_success: "شكرًا لك! تم إرسال استفسارك بنجاح. سيتواصل معك فريق المبيعات خلال 24 ساعة.",
|
||||||
|
inquiry_error: "فشل الإرسال، حاول مرة أخرى.",
|
||||||
|
inquiry_network: "خطأ في الشبكة، حاول مرة أخرى.",
|
||||||
|
|
||||||
|
product_category: "التصنيف:",
|
||||||
|
product_categories: "التصنيفات:",
|
||||||
|
product_specification: "ورقة المواصفات",
|
||||||
|
product_get_quote: "اطلب عرض سعر",
|
||||||
|
product_quote_title: "اطلب عرض سعر: {name}",
|
||||||
|
product_additional_info: "معلومات إضافية",
|
||||||
|
product_related: "منتجات ذات صلة",
|
||||||
|
product_no_related: "لا توجد منتجات ذات صلة بعد.",
|
||||||
|
product_not_found: "عذرًا، هذا المنتج غير موجود أو لم يعد متوفرًا.",
|
||||||
|
product_browse_all: "تصفح جميع المنتجات",
|
||||||
|
|
||||||
|
card_read_more: "اقرأ المزيد",
|
||||||
|
|
||||||
|
collection_categories: "التصنيفات",
|
||||||
|
collection_no_options: "لا توجد خيارات",
|
||||||
|
collection_products_count: "{total} منتجًا",
|
||||||
|
collection_no_results: "لا توجد منتجات في هذا التصنيف",
|
||||||
|
collection_no_results_filtered: "لا توجد منتجات مطابقة للفلاتر المحددة.",
|
||||||
|
shop_iot_products: "منتجات إنترنت الأشياء",
|
||||||
|
shop_showing: "عرض {from}–{to} من أصل {total} نتيجة",
|
||||||
|
shop_no_results: "لا نتائج",
|
||||||
|
|
||||||
|
blog_badge: "المدونة",
|
||||||
|
blog_read_more: "اقرأ المزيد »",
|
||||||
|
blog_no_posts: "لا توجد مقالات بعد — يتم استيراد المحتوى. تفضل بزيارتنا قريبًا.",
|
||||||
|
blog_toc: "جدول المحتويات",
|
||||||
|
blog_related_posts: "مقالات ذات صلة",
|
||||||
|
blog_not_found: "عذرًا، هذه المقالة غير موجودة.",
|
||||||
|
blog_back: "العودة إلى المدونة",
|
||||||
|
blog_importing: "يتم استيراد المحتوى.",
|
||||||
|
blog_gateways: "بوابات إنترنت الأشياء",
|
||||||
|
blog_contact_title: "اتصل بنا",
|
||||||
|
|
||||||
|
search_results_for: "نتائج البحث عن «{q}»",
|
||||||
|
search_title: "البحث عن المنتجات",
|
||||||
|
search_by_sku: "ابحث باسم المنتج أو رمز SKU...",
|
||||||
|
search_button: "بحث",
|
||||||
|
search_empty: "لا توجد منتجات مطابقة لبحثك.",
|
||||||
|
|
||||||
|
cart_empty: "سلة التسوق فارغة.",
|
||||||
|
cart_continue: "متابعة التسوق",
|
||||||
|
cart_checkout: "إتمام الشراء",
|
||||||
|
checkout_view_orders: "عرض طلباتي",
|
||||||
|
checkout_cart_kept: "سلة التسوق ({count} عنصر) محفوظة.",
|
||||||
|
checkout_browse: "تصفح المنتجات",
|
||||||
|
checkout_same_address: "الشحن إلى نفس العنوان",
|
||||||
|
checkout_notes_ph: "ملاحظات حول طلبك، مثل متطلبات توصيل خاصة",
|
||||||
|
checkout_subtotal: "المجموع الفرعي",
|
||||||
|
acct_login: "تسجيل الدخول",
|
||||||
|
acct_register: "إنشاء حساب",
|
||||||
|
acct_recent_orders: "أحدث الطلبات",
|
||||||
|
acct_no_orders: "لا توجد طلبات بعد.",
|
||||||
|
acct_th_order: "الطلب",
|
||||||
|
acct_th_date: "التاريخ",
|
||||||
|
acct_th_items: "العناصر",
|
||||||
|
acct_th_total: "الإجمالي",
|
||||||
|
acct_th_status: "الحالة",
|
||||||
|
acct_th_payment: "الدفع",
|
||||||
|
|
||||||
|
nf_msg: "الصفحة التي تبحث عنها غير موجودة أو تم نقلها.",
|
||||||
|
nf_contact: "اتصل بنا",
|
||||||
|
solution_title: "الحلول",
|
||||||
|
footer_intro: "DusunIoT هي المورد الرائد لأجهزة إنترنت الأشياء ومزوّد حلول إنترنت الأشياء المتكاملة. برؤى مبتكرة في إنترنت الأشياء، تلتزم الشركة بدفع تقنية إنترنت الأشياء إلى المستوى التالي.",
|
||||||
|
footer_rights: "جميع الحقوق محفوظة",
|
||||||
|
footer_sitemap: "خريطة الموقع",
|
||||||
|
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/** # 模块 i18n 德语词典 */
|
||||||
|
import type { Dict } from "~/i18n/locales/en";
|
||||||
|
|
||||||
|
export const de: Partial<Dict> = {
|
||||||
|
topbar_oem: "Alles aus einer Hand: OEM/ODM-Services >>",
|
||||||
|
login: "Anmelden",
|
||||||
|
contact_us: "KONTAKT",
|
||||||
|
search_placeholder: "Produkte suchen...",
|
||||||
|
search_placeholder_m: "Suchen...",
|
||||||
|
search_go: "Los",
|
||||||
|
nav_home: "Startseite",
|
||||||
|
nav_products: "Produkte",
|
||||||
|
nav_blog: "Blog",
|
||||||
|
nav_shop: "Shop",
|
||||||
|
nav_all_products: "Alle Produkte",
|
||||||
|
select_language: "Sprache wählen",
|
||||||
|
|
||||||
|
pagination_prev: "« Zurück",
|
||||||
|
pagination_next: "Weiter »",
|
||||||
|
|
||||||
|
inquiry_title: "Angebot einholen",
|
||||||
|
inquiry_name: "Name *",
|
||||||
|
inquiry_email: "E-Mail *",
|
||||||
|
inquiry_phone: "Telefon",
|
||||||
|
inquiry_company: "Firma",
|
||||||
|
inquiry_country: "Land",
|
||||||
|
inquiry_interest: "Gewünschtes Produkt",
|
||||||
|
inquiry_message: "Nachricht *",
|
||||||
|
inquiry_message_ph: "Bitte beschreiben Sie Ihre Anforderungen, Stückzahlen und Einsatzbereich...",
|
||||||
|
inquiry_submit: "Anfrage senden",
|
||||||
|
inquiry_submitting: "Wird gesendet...",
|
||||||
|
inquiry_success: "Vielen Dank! Ihre Anfrage wurde erfolgreich übermittelt. Unser Vertriebsteam meldet sich innerhalb von 24 Stunden bei Ihnen.",
|
||||||
|
inquiry_error: "Senden fehlgeschlagen, bitte erneut versuchen.",
|
||||||
|
inquiry_network: "Netzwerkfehler, bitte erneut versuchen.",
|
||||||
|
|
||||||
|
product_category: "Kategorie:",
|
||||||
|
product_categories: "Kategorien:",
|
||||||
|
product_specification: "Datenblatt",
|
||||||
|
product_get_quote: "Angebot einholen",
|
||||||
|
product_quote_title: "Angebot einholen: {name}",
|
||||||
|
product_additional_info: "Zusätzliche Informationen",
|
||||||
|
product_related: "Ähnliche Produkte",
|
||||||
|
product_no_related: "Keine ähnlichen Produkte vorhanden.",
|
||||||
|
product_not_found: "Leider existiert dieses Produkt nicht mehr oder ist nicht mehr verfügbar.",
|
||||||
|
product_browse_all: "Alle Produkte ansehen",
|
||||||
|
|
||||||
|
card_read_more: "Mehr erfahren",
|
||||||
|
|
||||||
|
collection_categories: "Kategorien",
|
||||||
|
collection_no_options: "Keine Optionen",
|
||||||
|
collection_products_count: "{total} Produkte",
|
||||||
|
collection_no_results: "Keine Produkte in dieser Kategorie gefunden",
|
||||||
|
collection_no_results_filtered: "Keine Produkte mit den gewählten Filtern gefunden.",
|
||||||
|
shop_iot_products: "IoT-Produkte",
|
||||||
|
shop_showing: "Zeige {from}–{to} von {total} Ergebnissen",
|
||||||
|
shop_no_results: "Keine Ergebnisse",
|
||||||
|
|
||||||
|
blog_badge: "Blog",
|
||||||
|
blog_read_more: "WEITERLESEN »",
|
||||||
|
blog_no_posts: "Noch keine Blogbeiträge — Inhalte werden importiert. Bitte schauen Sie später wieder vorbei.",
|
||||||
|
blog_toc: "Inhaltsverzeichnis",
|
||||||
|
blog_related_posts: "Ähnliche Beiträge",
|
||||||
|
blog_not_found: "Leider existiert dieser Beitrag nicht.",
|
||||||
|
blog_back: "Zurück zum Blog",
|
||||||
|
blog_importing: "Inhalte werden importiert.",
|
||||||
|
blog_gateways: "IoT-Gateways",
|
||||||
|
blog_contact_title: "KONTAKT",
|
||||||
|
|
||||||
|
search_results_for: "Suchergebnisse für „{q}“",
|
||||||
|
search_title: "Produkte suchen",
|
||||||
|
search_by_sku: "Nach Produktname oder SKU suchen...",
|
||||||
|
search_button: "Suchen",
|
||||||
|
search_empty: "Keine passenden Produkte gefunden.",
|
||||||
|
|
||||||
|
cart_empty: "Ihr Warenkorb ist leer.",
|
||||||
|
cart_continue: "Weiter einkaufen",
|
||||||
|
cart_checkout: "Zur Kasse",
|
||||||
|
checkout_view_orders: "Meine Bestellungen ansehen",
|
||||||
|
checkout_cart_kept: "Ihr Warenkorb ({count} Artikel) bleibt erhalten.",
|
||||||
|
checkout_browse: "Produkte ansehen",
|
||||||
|
checkout_same_address: "An dieselbe Adresse liefern",
|
||||||
|
checkout_notes_ph: "Hinweise zu Ihrer Bestellung, z. B. besondere Lieferwünsche",
|
||||||
|
checkout_subtotal: "Zwischensumme",
|
||||||
|
acct_login: "Anmelden",
|
||||||
|
acct_register: "Registrieren",
|
||||||
|
acct_recent_orders: "Letzte Bestellungen",
|
||||||
|
acct_no_orders: "Noch keine Bestellungen.",
|
||||||
|
acct_th_order: "Bestellung",
|
||||||
|
acct_th_date: "Datum",
|
||||||
|
acct_th_items: "Artikel",
|
||||||
|
acct_th_total: "Gesamt",
|
||||||
|
acct_th_status: "Status",
|
||||||
|
acct_th_payment: "Zahlung",
|
||||||
|
|
||||||
|
nf_msg: "Die gesuchte Seite existiert nicht oder wurde verschoben.",
|
||||||
|
nf_contact: "Kontakt",
|
||||||
|
solution_title: "LÖSUNGEN",
|
||||||
|
footer_intro: "DusunIoT ist ein führender Anbieter von IoT-Hardware und End-to-End-IoT-Lösungen. Mit innovativen IoT-Einblicken treibt das Unternehmen die IoT-Technologie der nächsten Generation voran.",
|
||||||
|
footer_rights: "Alle Rechte vorbehalten",
|
||||||
|
footer_sitemap: "Sitemap",
|
||||||
|
|
||||||
|
};
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* # 模块 i18n 英文词典(UI 界面字符串源字典)
|
||||||
|
* 其它语言文件按此键名提供 Partial<Dict>,缺词回退英文。
|
||||||
|
* 内容(产品/文章/菜单/分类名)暂不做翻译,后续接 AI 翻译接口。
|
||||||
|
*/
|
||||||
|
export const en = {
|
||||||
|
/** 顶栏 / 导航 */
|
||||||
|
topbar_oem: "One Stop OEM/ODM Services >>",
|
||||||
|
login: "Login",
|
||||||
|
contact_us: "CONTACT US",
|
||||||
|
search_placeholder: "Search products...",
|
||||||
|
search_placeholder_m: "Search...",
|
||||||
|
search_go: "Go",
|
||||||
|
nav_home: "Home",
|
||||||
|
nav_products: "Products",
|
||||||
|
nav_blog: "Blog",
|
||||||
|
nav_shop: "Shop",
|
||||||
|
nav_all_products: "All Products",
|
||||||
|
select_language: "Select Language",
|
||||||
|
|
||||||
|
/** 页脚 */
|
||||||
|
footer_intro: "DusunIoT is the leading IoT hardware device supplier and end-to-end IoT solution provider. With innovative IoT insights, the company is committed to driving next-level IoT technology.",
|
||||||
|
footer_rights: "All Rights Reserved",
|
||||||
|
footer_sitemap: "Sitemap",
|
||||||
|
|
||||||
|
/** 分页 */
|
||||||
|
pagination_prev: "« Prev",
|
||||||
|
pagination_next: "Next »",
|
||||||
|
|
||||||
|
/** 询盘表单 */
|
||||||
|
inquiry_title: "Get a Quote",
|
||||||
|
inquiry_name: "Name *",
|
||||||
|
inquiry_email: "Email *",
|
||||||
|
inquiry_phone: "Phone",
|
||||||
|
inquiry_company: "Company",
|
||||||
|
inquiry_country: "Country",
|
||||||
|
inquiry_interest: "Product of Interest",
|
||||||
|
inquiry_message: "Message *",
|
||||||
|
inquiry_message_ph: "Please describe your requirements, quantities, application scenario...",
|
||||||
|
inquiry_submit: "Send Inquiry",
|
||||||
|
inquiry_submitting: "Submitting...",
|
||||||
|
inquiry_success: "Thank you! Your inquiry has been submitted successfully. Our sales team will contact you within 24 hours.",
|
||||||
|
inquiry_error: "Submission failed, please try again.",
|
||||||
|
inquiry_network: "Network error, please try again.",
|
||||||
|
|
||||||
|
/** 产品详情页 */
|
||||||
|
product_category: "Category:",
|
||||||
|
product_categories: "Categories:",
|
||||||
|
product_specification: "Specification",
|
||||||
|
product_get_quote: "Get A Quote",
|
||||||
|
product_quote_title: "Get A Quote: {name}",
|
||||||
|
product_additional_info: "Additional information",
|
||||||
|
product_related: "Related products",
|
||||||
|
product_no_related: "No related products yet.",
|
||||||
|
product_not_found: "Sorry, this product does not exist or is no longer available.",
|
||||||
|
product_browse_all: "Browse All Products",
|
||||||
|
|
||||||
|
/** 产品卡片 */
|
||||||
|
card_read_more: "Read more",
|
||||||
|
|
||||||
|
/** 分类列表 / 商城 */
|
||||||
|
collection_categories: "Categories",
|
||||||
|
collection_no_options: "No options",
|
||||||
|
collection_products_count: "{total} products",
|
||||||
|
collection_no_results: "No products found in this category",
|
||||||
|
collection_no_results_filtered: "No products found with the selected filters.",
|
||||||
|
shop_iot_products: "IoT Products",
|
||||||
|
shop_showing: "Showing {from}–{to} of {total} results",
|
||||||
|
shop_no_results: "No results",
|
||||||
|
|
||||||
|
/** 博客 */
|
||||||
|
blog_badge: "Blog",
|
||||||
|
blog_read_more: "READ MORE »",
|
||||||
|
blog_no_posts: "No blog posts yet — content is being imported. Please check back soon.",
|
||||||
|
blog_toc: "Table of Contents",
|
||||||
|
blog_related_posts: "Related Posts",
|
||||||
|
blog_not_found: "Sorry, this blog post does not exist.",
|
||||||
|
blog_back: "Back to Blog",
|
||||||
|
blog_importing: "Content is being imported.",
|
||||||
|
blog_gateways: "IoT Gateways",
|
||||||
|
blog_contact_title: "CONTACT US",
|
||||||
|
|
||||||
|
/** 搜索页 */
|
||||||
|
search_results_for: "Search results for “{q}”",
|
||||||
|
search_title: "Search Products",
|
||||||
|
search_by_sku: "Search by product name or SKU...",
|
||||||
|
search_button: "Search",
|
||||||
|
search_empty: "No products matched your search.",
|
||||||
|
|
||||||
|
/** 购物车 / 结算 / 账户 */
|
||||||
|
cart_empty: "Your cart is empty.",
|
||||||
|
cart_continue: "Continue shopping",
|
||||||
|
cart_checkout: "Proceed to checkout",
|
||||||
|
checkout_view_orders: "View my orders",
|
||||||
|
checkout_cart_kept: "Your cart ({count} items) will be kept.",
|
||||||
|
checkout_browse: "Browse products",
|
||||||
|
checkout_same_address: "Ship to the same address",
|
||||||
|
checkout_notes_ph: "Notes about your order, e.g. special delivery requirements",
|
||||||
|
checkout_subtotal: "Subtotal",
|
||||||
|
acct_login: "Log in",
|
||||||
|
acct_register: "Register",
|
||||||
|
acct_recent_orders: "Recent orders",
|
||||||
|
acct_no_orders: "No orders yet.",
|
||||||
|
acct_th_order: "Order",
|
||||||
|
acct_th_date: "Date",
|
||||||
|
acct_th_items: "Items",
|
||||||
|
acct_th_total: "Total",
|
||||||
|
acct_th_status: "Status",
|
||||||
|
acct_th_payment: "Payment",
|
||||||
|
|
||||||
|
/** 404 */
|
||||||
|
nf_msg: "The page you are looking for does not exist or has been moved.",
|
||||||
|
nf_contact: "Contact Us",
|
||||||
|
|
||||||
|
/** 方案页 */
|
||||||
|
solution_title: "SOLUTION",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** # 类型 词典形状(其它语言按 Partial<Dict> 提供即可) **/
|
||||||
|
export type Dict = typeof en;
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/** # 模块 i18n 西班牙语词典 */
|
||||||
|
import type { Dict } from "~/i18n/locales/en";
|
||||||
|
|
||||||
|
export const es: Partial<Dict> = {
|
||||||
|
topbar_oem: "Servicios OEM/ODM todo en uno >>",
|
||||||
|
login: "Iniciar sesión",
|
||||||
|
contact_us: "CONTACTO",
|
||||||
|
search_placeholder: "Buscar productos...",
|
||||||
|
search_placeholder_m: "Buscar...",
|
||||||
|
search_go: "Buscar",
|
||||||
|
nav_home: "Inicio",
|
||||||
|
nav_products: "Productos",
|
||||||
|
nav_blog: "Blog",
|
||||||
|
nav_shop: "Tienda",
|
||||||
|
nav_all_products: "Todos los productos",
|
||||||
|
select_language: "Seleccionar idioma",
|
||||||
|
|
||||||
|
pagination_prev: "« Anterior",
|
||||||
|
pagination_next: "Siguiente »",
|
||||||
|
|
||||||
|
inquiry_title: "Solicitar presupuesto",
|
||||||
|
inquiry_name: "Nombre *",
|
||||||
|
inquiry_email: "Correo electrónico *",
|
||||||
|
inquiry_phone: "Teléfono",
|
||||||
|
inquiry_company: "Empresa",
|
||||||
|
inquiry_country: "País",
|
||||||
|
inquiry_interest: "Producto de interés",
|
||||||
|
inquiry_message: "Mensaje *",
|
||||||
|
inquiry_message_ph: "Describa sus requisitos, cantidades y aplicación...",
|
||||||
|
inquiry_submit: "Enviar consulta",
|
||||||
|
inquiry_submitting: "Enviando...",
|
||||||
|
inquiry_success: "¡Gracias! Su consulta se ha enviado correctamente. Nuestro equipo de ventas le contactará en un plazo de 24 horas.",
|
||||||
|
inquiry_error: "No se pudo enviar, inténtelo de nuevo.",
|
||||||
|
inquiry_network: "Error de red, inténtelo de nuevo.",
|
||||||
|
|
||||||
|
product_category: "Categoría:",
|
||||||
|
product_categories: "Categorías:",
|
||||||
|
product_specification: "Ficha técnica",
|
||||||
|
product_get_quote: "Solicitar presupuesto",
|
||||||
|
product_quote_title: "Solicitar presupuesto: {name}",
|
||||||
|
product_additional_info: "Información adicional",
|
||||||
|
product_related: "Productos relacionados",
|
||||||
|
product_no_related: "Aún no hay productos relacionados.",
|
||||||
|
product_not_found: "Lo sentimos, este producto no existe o ya no está disponible.",
|
||||||
|
product_browse_all: "Ver todos los productos",
|
||||||
|
|
||||||
|
card_read_more: "Leer más",
|
||||||
|
|
||||||
|
collection_categories: "Categorías",
|
||||||
|
collection_no_options: "Sin opciones",
|
||||||
|
collection_products_count: "{total} productos",
|
||||||
|
collection_no_results: "No se encontraron productos en esta categoría",
|
||||||
|
collection_no_results_filtered: "No hay productos con los filtros seleccionados.",
|
||||||
|
shop_iot_products: "Productos IoT",
|
||||||
|
shop_showing: "Mostrando {from}–{to} de {total} resultados",
|
||||||
|
shop_no_results: "Sin resultados",
|
||||||
|
|
||||||
|
blog_badge: "Blog",
|
||||||
|
blog_read_more: "LEER MÁS »",
|
||||||
|
blog_no_posts: "Aún no hay entradas — el contenido se está importando. Vuelva pronto.",
|
||||||
|
blog_toc: "Índice de contenidos",
|
||||||
|
blog_related_posts: "Entradas relacionadas",
|
||||||
|
blog_not_found: "Lo sentimos, esta entrada no existe.",
|
||||||
|
blog_back: "Volver al blog",
|
||||||
|
blog_importing: "El contenido se está importando.",
|
||||||
|
blog_gateways: "Pasarelas IoT",
|
||||||
|
blog_contact_title: "CONTACTO",
|
||||||
|
|
||||||
|
search_results_for: "Resultados de búsqueda para «{q}»",
|
||||||
|
search_title: "Buscar productos",
|
||||||
|
search_by_sku: "Buscar por nombre de producto o SKU...",
|
||||||
|
search_button: "Buscar",
|
||||||
|
search_empty: "Ningún producto coincide con su búsqueda.",
|
||||||
|
|
||||||
|
cart_empty: "Su carrito está vacío.",
|
||||||
|
cart_continue: "Seguir comprando",
|
||||||
|
cart_checkout: "Tramitar pedido",
|
||||||
|
checkout_view_orders: "Ver mis pedidos",
|
||||||
|
checkout_cart_kept: "Su carrito ({count} artículos) se conservará.",
|
||||||
|
checkout_browse: "Ver productos",
|
||||||
|
checkout_same_address: "Enviar a la misma dirección",
|
||||||
|
checkout_notes_ph: "Notas sobre su pedido, p. ej. requisitos de entrega especiales",
|
||||||
|
checkout_subtotal: "Subtotal",
|
||||||
|
acct_login: "Iniciar sesión",
|
||||||
|
acct_register: "Registrarse",
|
||||||
|
acct_recent_orders: "Pedidos recientes",
|
||||||
|
acct_no_orders: "Aún no hay pedidos.",
|
||||||
|
acct_th_order: "Pedido",
|
||||||
|
acct_th_date: "Fecha",
|
||||||
|
acct_th_items: "Artículos",
|
||||||
|
acct_th_total: "Total",
|
||||||
|
acct_th_status: "Estado",
|
||||||
|
acct_th_payment: "Pago",
|
||||||
|
|
||||||
|
nf_msg: "La página que busca no existe o ha sido movida.",
|
||||||
|
nf_contact: "Contacto",
|
||||||
|
solution_title: "SOLUCIONES",
|
||||||
|
footer_intro: "DusunIoT es un proveedor líder de hardware IoT y soluciones IoT de extremo a extremo. Con una visión innovadora del IoT, la compañía trabaja para impulsar la tecnología IoT de próxima generación.",
|
||||||
|
footer_rights: "Todos los derechos reservados",
|
||||||
|
footer_sitemap: "Mapa del sitio",
|
||||||
|
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/** # 模块 i18n 法语词典 */
|
||||||
|
import type { Dict } from "~/i18n/locales/en";
|
||||||
|
|
||||||
|
export const fr: Partial<Dict> = {
|
||||||
|
topbar_oem: "Services OEM/ODM tout-en-un >>",
|
||||||
|
login: "Connexion",
|
||||||
|
contact_us: "NOUS CONTACTER",
|
||||||
|
search_placeholder: "Rechercher des produits...",
|
||||||
|
search_placeholder_m: "Rechercher...",
|
||||||
|
search_go: "OK",
|
||||||
|
nav_home: "Accueil",
|
||||||
|
nav_products: "Produits",
|
||||||
|
nav_blog: "Blog",
|
||||||
|
nav_shop: "Boutique",
|
||||||
|
nav_all_products: "Tous les produits",
|
||||||
|
select_language: "Choisir la langue",
|
||||||
|
|
||||||
|
pagination_prev: "« Précédent",
|
||||||
|
pagination_next: "Suivant »",
|
||||||
|
|
||||||
|
inquiry_title: "Demander un devis",
|
||||||
|
inquiry_name: "Nom *",
|
||||||
|
inquiry_email: "E-mail *",
|
||||||
|
inquiry_phone: "Téléphone",
|
||||||
|
inquiry_company: "Société",
|
||||||
|
inquiry_country: "Pays",
|
||||||
|
inquiry_interest: "Produit concerné",
|
||||||
|
inquiry_message: "Message *",
|
||||||
|
inquiry_message_ph: "Décrivez vos besoins, quantités et cas d'application...",
|
||||||
|
inquiry_submit: "Envoyer la demande",
|
||||||
|
inquiry_submitting: "Envoi en cours...",
|
||||||
|
inquiry_success: "Merci ! Votre demande a bien été envoyée. Notre équipe commerciale vous contactera sous 24 heures.",
|
||||||
|
inquiry_error: "Échec de l'envoi, veuillez réessayer.",
|
||||||
|
inquiry_network: "Erreur réseau, veuillez réessayer.",
|
||||||
|
|
||||||
|
product_category: "Catégorie :",
|
||||||
|
product_categories: "Catégories :",
|
||||||
|
product_specification: "Fiche technique",
|
||||||
|
product_get_quote: "Demander un devis",
|
||||||
|
product_quote_title: "Demander un devis : {name}",
|
||||||
|
product_additional_info: "Informations complémentaires",
|
||||||
|
product_related: "Produits similaires",
|
||||||
|
product_no_related: "Aucun produit similaire pour le moment.",
|
||||||
|
product_not_found: "Désolé, ce produit n'existe pas ou n'est plus disponible.",
|
||||||
|
product_browse_all: "Voir tous les produits",
|
||||||
|
|
||||||
|
card_read_more: "En savoir plus",
|
||||||
|
|
||||||
|
collection_categories: "Catégories",
|
||||||
|
collection_no_options: "Aucune option",
|
||||||
|
collection_products_count: "{total} produits",
|
||||||
|
collection_no_results: "Aucun produit trouvé dans cette catégorie",
|
||||||
|
collection_no_results_filtered: "Aucun produit ne correspond aux filtres sélectionnés.",
|
||||||
|
shop_iot_products: "Produits IoT",
|
||||||
|
shop_showing: "Affichage de {from}–{to} sur {total} résultats",
|
||||||
|
shop_no_results: "Aucun résultat",
|
||||||
|
|
||||||
|
blog_badge: "Blog",
|
||||||
|
blog_read_more: "LIRE LA SUITE »",
|
||||||
|
blog_no_posts: "Pas encore d'articles — le contenu est en cours d'importation. Revenez bientôt.",
|
||||||
|
blog_toc: "Sommaire",
|
||||||
|
blog_related_posts: "Articles similaires",
|
||||||
|
blog_not_found: "Désolé, cet article n'existe pas.",
|
||||||
|
blog_back: "Retour au blog",
|
||||||
|
blog_importing: "Contenu en cours d'importation.",
|
||||||
|
blog_gateways: "Passerelles IoT",
|
||||||
|
blog_contact_title: "NOUS CONTACTER",
|
||||||
|
|
||||||
|
search_results_for: "Résultats de recherche pour « {q} »",
|
||||||
|
search_title: "Rechercher des produits",
|
||||||
|
search_by_sku: "Rechercher par nom de produit ou SKU...",
|
||||||
|
search_button: "Rechercher",
|
||||||
|
search_empty: "Aucun produit ne correspond à votre recherche.",
|
||||||
|
|
||||||
|
cart_empty: "Votre panier est vide.",
|
||||||
|
cart_continue: "Continuer les achats",
|
||||||
|
cart_checkout: "Passer la commande",
|
||||||
|
checkout_view_orders: "Voir mes commandes",
|
||||||
|
checkout_cart_kept: "Votre panier ({count} articles) est conservé.",
|
||||||
|
checkout_browse: "Parcourir les produits",
|
||||||
|
checkout_same_address: "Livrer à la même adresse",
|
||||||
|
checkout_notes_ph: "Notes sur votre commande, ex. : instructions de livraison particulières",
|
||||||
|
checkout_subtotal: "Sous-total",
|
||||||
|
acct_login: "Connexion",
|
||||||
|
acct_register: "S'inscrire",
|
||||||
|
acct_recent_orders: "Commandes récentes",
|
||||||
|
acct_no_orders: "Aucune commande pour le moment.",
|
||||||
|
acct_th_order: "Commande",
|
||||||
|
acct_th_date: "Date",
|
||||||
|
acct_th_items: "Articles",
|
||||||
|
acct_th_total: "Total",
|
||||||
|
acct_th_status: "Statut",
|
||||||
|
acct_th_payment: "Paiement",
|
||||||
|
|
||||||
|
nf_msg: "La page que vous recherchez n'existe pas ou a été déplacée.",
|
||||||
|
nf_contact: "Contact",
|
||||||
|
solution_title: "SOLUTIONS",
|
||||||
|
footer_intro: "DusunIoT est un fournisseur de premier plan de matériel IoT et de solutions IoT de bout en bout. Forte d'une expertise innovante en IoT, l'entreprise s'engage à faire progresser la technologie IoT de nouvelle génération.",
|
||||||
|
footer_rights: "Tous droits réservés",
|
||||||
|
footer_sitemap: "Plan du site",
|
||||||
|
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/** # 模块 i18n 印地语词典 */
|
||||||
|
import type { Dict } from "~/i18n/locales/en";
|
||||||
|
|
||||||
|
export const hi: Partial<Dict> = {
|
||||||
|
topbar_oem: "एक ही जगह OEM/ODM सेवाएँ >>",
|
||||||
|
login: "लॉग इन",
|
||||||
|
contact_us: "संपर्क करें",
|
||||||
|
search_placeholder: "उत्पाद खोजें...",
|
||||||
|
search_placeholder_m: "खोजें...",
|
||||||
|
search_go: "जाएँ",
|
||||||
|
nav_home: "होम",
|
||||||
|
nav_products: "उत्पाद",
|
||||||
|
nav_blog: "ब्लॉग",
|
||||||
|
nav_shop: "स्टोर",
|
||||||
|
nav_all_products: "सभी उत्पाद",
|
||||||
|
select_language: "भाषा चुनें",
|
||||||
|
|
||||||
|
pagination_prev: "« पिछला",
|
||||||
|
pagination_next: "अगला »",
|
||||||
|
|
||||||
|
inquiry_title: "कोटेशन प्राप्त करें",
|
||||||
|
inquiry_name: "नाम *",
|
||||||
|
inquiry_email: "ईमेल *",
|
||||||
|
inquiry_phone: "फ़ोन",
|
||||||
|
inquiry_company: "कंपनी",
|
||||||
|
inquiry_country: "देश",
|
||||||
|
inquiry_interest: "रुचि के उत्पाद",
|
||||||
|
inquiry_message: "संदेश *",
|
||||||
|
inquiry_message_ph: "कृपया अपनी आवश्यकताएँ, मात्रा और उपयोग का वर्णन करें...",
|
||||||
|
inquiry_submit: "पूछताछ भेजें",
|
||||||
|
inquiry_submitting: "भेज रहे हैं...",
|
||||||
|
inquiry_success: "धन्यवाद! आपकी पूछताछ सफलतापूर्वक जमा हो गई है। हमारी बिक्री टीम 24 घंटे के भीतर संपर्क करेगी।",
|
||||||
|
inquiry_error: "सबमिट नहीं हो सका, कृपया फिर से प्रयास करें।",
|
||||||
|
inquiry_network: "नेटवर्क त्रुटि, कृपया फिर से प्रयास करें।",
|
||||||
|
|
||||||
|
product_category: "श्रेणी:",
|
||||||
|
product_categories: "श्रेणियाँ:",
|
||||||
|
product_specification: "स्पेसिफिकेशन",
|
||||||
|
product_get_quote: "कोटेशन प्राप्त करें",
|
||||||
|
product_quote_title: "कोटेशन प्राप्त करें: {name}",
|
||||||
|
product_additional_info: "अतिरिक्त जानकारी",
|
||||||
|
product_related: "संबंधित उत्पाद",
|
||||||
|
product_no_related: "अभी कोई संबंधित उत्पाद नहीं है।",
|
||||||
|
product_not_found: "क्षमा करें, यह उत्पाद मौजूद नहीं है या अब उपलब्ध नहीं है।",
|
||||||
|
product_browse_all: "सभी उत्पाद देखें",
|
||||||
|
|
||||||
|
card_read_more: "और पढ़ें",
|
||||||
|
|
||||||
|
collection_categories: "श्रेणियाँ",
|
||||||
|
collection_no_options: "कोई विकल्प नहीं",
|
||||||
|
collection_products_count: "{total} उत्पाद",
|
||||||
|
collection_no_results: "इस श्रेणी में कोई उत्पाद नहीं मिला",
|
||||||
|
collection_no_results_filtered: "चयनित फ़िल्टर के साथ कोई उत्पाद नहीं मिला।",
|
||||||
|
shop_iot_products: "IoT उत्पाद",
|
||||||
|
shop_showing: "{total} में से {from}–{to} दिखा रहे हैं",
|
||||||
|
shop_no_results: "कोई परिणाम नहीं",
|
||||||
|
|
||||||
|
blog_badge: "ब्लॉग",
|
||||||
|
blog_read_more: "और पढ़ें »",
|
||||||
|
blog_no_posts: "अभी कोई पोस्ट नहीं — सामग्री आयात हो रही है। कृपया थोड़ी देर बाद देखें।",
|
||||||
|
blog_toc: "विषय-सूची",
|
||||||
|
blog_related_posts: "संबंधित पोस्ट",
|
||||||
|
blog_not_found: "क्षमा करें, यह पोस्ट मौजूद नहीं है।",
|
||||||
|
blog_back: "ब्लॉग पर वापस जाएँ",
|
||||||
|
blog_importing: "सामग्री आयात हो रही है।",
|
||||||
|
blog_gateways: "IoT गेटवे",
|
||||||
|
blog_contact_title: "संपर्क करें",
|
||||||
|
|
||||||
|
search_results_for: "“{q}” के लिए खोज परिणाम",
|
||||||
|
search_title: "उत्पाद खोजें",
|
||||||
|
search_by_sku: "उत्पाद नाम या SKU से खोजें...",
|
||||||
|
search_button: "खोजें",
|
||||||
|
search_empty: "आपकी खोज से मेल खाता कोई उत्पाद नहीं मिला।",
|
||||||
|
|
||||||
|
cart_empty: "आपकी कार्ट खाली है।",
|
||||||
|
cart_continue: "खरीदारी जारी रखें",
|
||||||
|
cart_checkout: "चेकआउट करें",
|
||||||
|
checkout_view_orders: "मेरे ऑर्डर देखें",
|
||||||
|
checkout_cart_kept: "आपकी कार्ट ({count} आइटम) सुरक्षित रखी जाएगी।",
|
||||||
|
checkout_browse: "उत्पाद देखें",
|
||||||
|
checkout_same_address: "उसी पते पर भेजें",
|
||||||
|
checkout_notes_ph: "ऑर्डर से जुड़े नोट्स, जैसे विशेष डिलीवरी आवश्यकताएँ",
|
||||||
|
checkout_subtotal: "उप-योग",
|
||||||
|
acct_login: "लॉग इन",
|
||||||
|
acct_register: "रजिस्टर",
|
||||||
|
acct_recent_orders: "हाल के ऑर्डर",
|
||||||
|
acct_no_orders: "अभी कोई ऑर्डर नहीं।",
|
||||||
|
acct_th_order: "ऑर्डर",
|
||||||
|
acct_th_date: "तारीख",
|
||||||
|
acct_th_items: "आइटम",
|
||||||
|
acct_th_total: "कुल",
|
||||||
|
acct_th_status: "स्थिति",
|
||||||
|
acct_th_payment: "भुगतान",
|
||||||
|
|
||||||
|
nf_msg: "आप जिस पेज को खोज रहे हैं वह मौजूद नहीं है या हटा दिया गया है।",
|
||||||
|
nf_contact: "संपर्क करें",
|
||||||
|
solution_title: "समाधान",
|
||||||
|
footer_intro: "DusunIoT अग्रणी IoT हार्डवेयर आपूर्तिकर्ता और एंड-टू-एंड IoT समाधान प्रदाता है। IoT में नवीन अंतर्दृष्टि के साथ, कंपनी अगली पीढ़ी की IoT तकनीक को आगे बढ़ाने के लिए प्रतिबद्ध है।",
|
||||||
|
footer_rights: "सर्वाधिकार सुरक्षित",
|
||||||
|
footer_sitemap: "साइटमैप",
|
||||||
|
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/** # 模块 i18n 印尼语词典 */
|
||||||
|
import type { Dict } from "~/i18n/locales/en";
|
||||||
|
|
||||||
|
export const id: Partial<Dict> = {
|
||||||
|
topbar_oem: "Layanan OEM/ODM lengkap >>",
|
||||||
|
login: "Masuk",
|
||||||
|
contact_us: "HUBUNGI KAMI",
|
||||||
|
search_placeholder: "Cari produk...",
|
||||||
|
search_placeholder_m: "Cari...",
|
||||||
|
search_go: "Cari",
|
||||||
|
nav_home: "Beranda",
|
||||||
|
nav_products: "Produk",
|
||||||
|
nav_blog: "Blog",
|
||||||
|
nav_shop: "Toko",
|
||||||
|
nav_all_products: "Semua produk",
|
||||||
|
select_language: "Pilih bahasa",
|
||||||
|
|
||||||
|
pagination_prev: "« Sebelumnya",
|
||||||
|
pagination_next: "Berikutnya »",
|
||||||
|
|
||||||
|
inquiry_title: "Minta penawaran",
|
||||||
|
inquiry_name: "Nama *",
|
||||||
|
inquiry_email: "Email *",
|
||||||
|
inquiry_phone: "Telepon",
|
||||||
|
inquiry_company: "Perusahaan",
|
||||||
|
inquiry_country: "Negara",
|
||||||
|
inquiry_interest: "Produk yang diminati",
|
||||||
|
inquiry_message: "Pesan *",
|
||||||
|
inquiry_message_ph: "Jelaskan kebutuhan, jumlah, dan skenario penggunaan Anda...",
|
||||||
|
inquiry_submit: "Kirim pertanyaan",
|
||||||
|
inquiry_submitting: "Mengirim...",
|
||||||
|
inquiry_success: "Terima kasih! Pertanyaan Anda berhasil dikirim. Tim penjualan kami akan menghubungi Anda dalam 24 jam.",
|
||||||
|
inquiry_error: "Gagal mengirim, silakan coba lagi.",
|
||||||
|
inquiry_network: "Kesalahan jaringan, silakan coba lagi.",
|
||||||
|
|
||||||
|
product_category: "Kategori:",
|
||||||
|
product_categories: "Kategori:",
|
||||||
|
product_specification: "Spesifikasi",
|
||||||
|
product_get_quote: "Minta penawaran",
|
||||||
|
product_quote_title: "Minta penawaran: {name}",
|
||||||
|
product_additional_info: "Informasi tambahan",
|
||||||
|
product_related: "Produk terkait",
|
||||||
|
product_no_related: "Belum ada produk terkait.",
|
||||||
|
product_not_found: "Maaf, produk ini tidak ada atau sudah tidak tersedia.",
|
||||||
|
product_browse_all: "Lihat semua produk",
|
||||||
|
|
||||||
|
card_read_more: "Selengkapnya",
|
||||||
|
|
||||||
|
collection_categories: "Kategori",
|
||||||
|
collection_no_options: "Tidak ada opsi",
|
||||||
|
collection_products_count: "{total} produk",
|
||||||
|
collection_no_results: "Tidak ada produk di kategori ini",
|
||||||
|
collection_no_results_filtered: "Tidak ada produk dengan filter yang dipilih.",
|
||||||
|
shop_iot_products: "Produk IoT",
|
||||||
|
shop_showing: "Menampilkan {from}–{to} dari {total} hasil",
|
||||||
|
shop_no_results: "Tidak ada hasil",
|
||||||
|
|
||||||
|
blog_badge: "Blog",
|
||||||
|
blog_read_more: "BACA SELENGKAPNYA »",
|
||||||
|
blog_no_posts: "Belum ada postingan — konten sedang diimpor. Kunjungi lagi segera.",
|
||||||
|
blog_toc: "Daftar Isi",
|
||||||
|
blog_related_posts: "Postingan terkait",
|
||||||
|
blog_not_found: "Maaf, postingan ini tidak ada.",
|
||||||
|
blog_back: "Kembali ke blog",
|
||||||
|
blog_importing: "Konten sedang diimpor.",
|
||||||
|
blog_gateways: "Gateway IoT",
|
||||||
|
blog_contact_title: "HUBUNGI KAMI",
|
||||||
|
|
||||||
|
search_results_for: "Hasil pencarian untuk “{q}”",
|
||||||
|
search_title: "Cari produk",
|
||||||
|
search_by_sku: "Cari berdasarkan nama produk atau SKU...",
|
||||||
|
search_button: "Cari",
|
||||||
|
search_empty: "Tidak ada produk yang cocok dengan pencarian Anda.",
|
||||||
|
|
||||||
|
cart_empty: "Keranjang Anda kosong.",
|
||||||
|
cart_continue: "Lanjut belanja",
|
||||||
|
cart_checkout: "Lanjut ke pembayaran",
|
||||||
|
checkout_view_orders: "Lihat pesanan saya",
|
||||||
|
checkout_cart_kept: "Keranjang Anda ({count} item) akan disimpan.",
|
||||||
|
checkout_browse: "Lihat produk",
|
||||||
|
checkout_same_address: "Kirim ke alamat yang sama",
|
||||||
|
checkout_notes_ph: "Catatan pesanan, mis. persyaratan pengiriman khusus",
|
||||||
|
checkout_subtotal: "Subtotal",
|
||||||
|
acct_login: "Masuk",
|
||||||
|
acct_register: "Daftar",
|
||||||
|
acct_recent_orders: "Pesanan terbaru",
|
||||||
|
acct_no_orders: "Belum ada pesanan.",
|
||||||
|
acct_th_order: "Pesanan",
|
||||||
|
acct_th_date: "Tanggal",
|
||||||
|
acct_th_items: "Item",
|
||||||
|
acct_th_total: "Total",
|
||||||
|
acct_th_status: "Status",
|
||||||
|
acct_th_payment: "Pembayaran",
|
||||||
|
|
||||||
|
nf_msg: "Halaman yang Anda cari tidak ada atau sudah dipindahkan.",
|
||||||
|
nf_contact: "Kontak",
|
||||||
|
solution_title: "SOLUSI",
|
||||||
|
footer_intro: "DusunIoT adalah pemasok perangkat keras IoT terkemuka dan penyedia solusi IoT menyeluruh. Dengan wawasan IoT yang inovatif, perusahaan berkomitmen mendorong teknologi IoT generasi berikutnya.",
|
||||||
|
footer_rights: "Hak cipta dilindungi",
|
||||||
|
footer_sitemap: "Peta situs",
|
||||||
|
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* # 模块 i18n 词典聚合
|
||||||
|
* 引入全部语言词典并注册进注册表;root 路由导入本模块即可生效。
|
||||||
|
*/
|
||||||
|
import { registerDict } from "~/i18n";
|
||||||
|
import { zhCN } from "~/i18n/locales/zh-CN";
|
||||||
|
import { zhTW } from "~/i18n/locales/zh-TW";
|
||||||
|
import { ja } from "~/i18n/locales/ja";
|
||||||
|
import { ko } from "~/i18n/locales/ko";
|
||||||
|
import { de } from "~/i18n/locales/de";
|
||||||
|
import { fr } from "~/i18n/locales/fr";
|
||||||
|
import { es } from "~/i18n/locales/es";
|
||||||
|
import { pt } from "~/i18n/locales/pt";
|
||||||
|
import { it } from "~/i18n/locales/it";
|
||||||
|
import { ru } from "~/i18n/locales/ru";
|
||||||
|
import { uk } from "~/i18n/locales/uk";
|
||||||
|
import { ar } from "~/i18n/locales/ar";
|
||||||
|
import { th } from "~/i18n/locales/th";
|
||||||
|
import { hi } from "~/i18n/locales/hi";
|
||||||
|
import { tr } from "~/i18n/locales/tr";
|
||||||
|
import { nl } from "~/i18n/locales/nl";
|
||||||
|
import { tl } from "~/i18n/locales/tl";
|
||||||
|
import { id } from "~/i18n/locales/id";
|
||||||
|
import { no } from "~/i18n/locales/no";
|
||||||
|
|
||||||
|
registerDict("zh-CN", zhCN);
|
||||||
|
registerDict("zh-TW", zhTW);
|
||||||
|
registerDict("ja", ja);
|
||||||
|
registerDict("ko", ko);
|
||||||
|
registerDict("de", de);
|
||||||
|
registerDict("fr", fr);
|
||||||
|
registerDict("es", es);
|
||||||
|
registerDict("pt", pt);
|
||||||
|
registerDict("it", it);
|
||||||
|
registerDict("ru", ru);
|
||||||
|
registerDict("uk", uk);
|
||||||
|
registerDict("ar", ar);
|
||||||
|
registerDict("th", th);
|
||||||
|
registerDict("hi", hi);
|
||||||
|
registerDict("tr", tr);
|
||||||
|
registerDict("nl", nl);
|
||||||
|
registerDict("tl", tl);
|
||||||
|
registerDict("id", id);
|
||||||
|
registerDict("no", no);
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/** # 模块 i18n 意大利语词典 */
|
||||||
|
import type { Dict } from "~/i18n/locales/en";
|
||||||
|
|
||||||
|
export const it: Partial<Dict> = {
|
||||||
|
topbar_oem: "Servizi OEM/ODM completi >>",
|
||||||
|
login: "Accedi",
|
||||||
|
contact_us: "CONTATTACI",
|
||||||
|
search_placeholder: "Cerca prodotti...",
|
||||||
|
search_placeholder_m: "Cerca...",
|
||||||
|
search_go: "Vai",
|
||||||
|
nav_home: "Home",
|
||||||
|
nav_products: "Prodotti",
|
||||||
|
nav_blog: "Blog",
|
||||||
|
nav_shop: "Negozio",
|
||||||
|
nav_all_products: "Tutti i prodotti",
|
||||||
|
select_language: "Seleziona lingua",
|
||||||
|
|
||||||
|
pagination_prev: "« Precedente",
|
||||||
|
pagination_next: "Successiva »",
|
||||||
|
|
||||||
|
inquiry_title: "Richiedi un preventivo",
|
||||||
|
inquiry_name: "Nome *",
|
||||||
|
inquiry_email: "E-mail *",
|
||||||
|
inquiry_phone: "Telefono",
|
||||||
|
inquiry_company: "Azienda",
|
||||||
|
inquiry_country: "Paese",
|
||||||
|
inquiry_interest: "Prodotto di interesse",
|
||||||
|
inquiry_message: "Messaggio *",
|
||||||
|
inquiry_message_ph: "Descriva i requisiti, le quantità e l'applicazione...",
|
||||||
|
inquiry_submit: "Invia richiesta",
|
||||||
|
inquiry_submitting: "Invio in corso...",
|
||||||
|
inquiry_success: "Grazie! La richiesta è stata inviata correttamente. Il nostro team commerciale la contatterà entro 24 ore.",
|
||||||
|
inquiry_error: "Invio non riuscito, riprovi.",
|
||||||
|
inquiry_network: "Errore di rete, riprovi.",
|
||||||
|
|
||||||
|
product_category: "Categoria:",
|
||||||
|
product_categories: "Categorie:",
|
||||||
|
product_specification: "Scheda tecnica",
|
||||||
|
product_get_quote: "Richiedi un preventivo",
|
||||||
|
product_quote_title: "Richiedi un preventivo: {name}",
|
||||||
|
product_additional_info: "Informazioni aggiuntive",
|
||||||
|
product_related: "Prodotti correlati",
|
||||||
|
product_no_related: "Ancora nessun prodotto correlato.",
|
||||||
|
product_not_found: "Siamo spiacenti, questo prodotto non esiste o non è più disponibile.",
|
||||||
|
product_browse_all: "Vedi tutti i prodotti",
|
||||||
|
|
||||||
|
card_read_more: "Scopri di più",
|
||||||
|
|
||||||
|
collection_categories: "Categorie",
|
||||||
|
collection_no_options: "Nessuna opzione",
|
||||||
|
collection_products_count: "{total} prodotti",
|
||||||
|
collection_no_results: "Nessun prodotto trovato in questa categoria",
|
||||||
|
collection_no_results_filtered: "Nessun prodotto con i filtri selezionati.",
|
||||||
|
shop_iot_products: "Prodotti IoT",
|
||||||
|
shop_showing: "Visualizzazione di {from}–{to} su {total} risultati",
|
||||||
|
shop_no_results: "Nessun risultato",
|
||||||
|
|
||||||
|
blog_badge: "Blog",
|
||||||
|
blog_read_more: "LEGGI TUTTO »",
|
||||||
|
blog_no_posts: "Ancora nessun articolo — i contenuti sono in fase di importazione. Torni presto.",
|
||||||
|
blog_toc: "Indice dei contenuti",
|
||||||
|
blog_related_posts: "Articoli correlati",
|
||||||
|
blog_not_found: "Siamo spiacenti, questo articolo non esiste.",
|
||||||
|
blog_back: "Torna al blog",
|
||||||
|
blog_importing: "Contenuti in fase di importazione.",
|
||||||
|
blog_gateways: "Gateway IoT",
|
||||||
|
blog_contact_title: "CONTATTACI",
|
||||||
|
|
||||||
|
search_results_for: "Risultati di ricerca per “{q}”",
|
||||||
|
search_title: "Cerca prodotti",
|
||||||
|
search_by_sku: "Cerca per nome prodotto o SKU...",
|
||||||
|
search_button: "Cerca",
|
||||||
|
search_empty: "Nessun prodotto corrisponde alla ricerca.",
|
||||||
|
|
||||||
|
cart_empty: "Il carrello è vuoto.",
|
||||||
|
cart_continue: "Continua lo shopping",
|
||||||
|
cart_checkout: "Vai alla cassa",
|
||||||
|
checkout_view_orders: "Vedi i miei ordini",
|
||||||
|
checkout_cart_kept: "Il carrello ({count} articoli) verrà conservato.",
|
||||||
|
checkout_browse: "Sfoglia i prodotti",
|
||||||
|
checkout_same_address: "Spedisci allo stesso indirizzo",
|
||||||
|
checkout_notes_ph: "Note sull'ordine, es. particolari esigenze di consegna",
|
||||||
|
checkout_subtotal: "Subtotale",
|
||||||
|
acct_login: "Accedi",
|
||||||
|
acct_register: "Registrati",
|
||||||
|
acct_recent_orders: "Ordini recenti",
|
||||||
|
acct_no_orders: "Ancora nessun ordine.",
|
||||||
|
acct_th_order: "Ordine",
|
||||||
|
acct_th_date: "Data",
|
||||||
|
acct_th_items: "Articoli",
|
||||||
|
acct_th_total: "Totale",
|
||||||
|
acct_th_status: "Stato",
|
||||||
|
acct_th_payment: "Pagamento",
|
||||||
|
|
||||||
|
nf_msg: "La pagina che sta cercando non esiste o è stata spostata.",
|
||||||
|
nf_contact: "Contatti",
|
||||||
|
solution_title: "SOLUZIONI",
|
||||||
|
footer_intro: "DusunIoT è un fornitore leader di hardware IoT e di soluzioni IoT end-to-end. Grazie a una visione innovativa dell'IoT, l'azienda si impegna a promuovere la tecnologia IoT di nuova generazione.",
|
||||||
|
footer_rights: "Tutti i diritti riservati",
|
||||||
|
footer_sitemap: "Mappa del sito",
|
||||||
|
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/** # 模块 i18n 日语词典 */
|
||||||
|
import type { Dict } from "~/i18n/locales/en";
|
||||||
|
|
||||||
|
export const ja: Partial<Dict> = {
|
||||||
|
topbar_oem: "ワンストップ OEM/ODM サービス >>",
|
||||||
|
login: "ログイン",
|
||||||
|
contact_us: "お問い合わせ",
|
||||||
|
search_placeholder: "製品を検索...",
|
||||||
|
search_placeholder_m: "検索...",
|
||||||
|
search_go: "検索",
|
||||||
|
nav_home: "ホーム",
|
||||||
|
nav_products: "製品",
|
||||||
|
nav_blog: "ブログ",
|
||||||
|
nav_shop: "ストア",
|
||||||
|
nav_all_products: "全製品",
|
||||||
|
select_language: "言語を選択",
|
||||||
|
|
||||||
|
pagination_prev: "« 前へ",
|
||||||
|
pagination_next: "次へ »",
|
||||||
|
|
||||||
|
inquiry_title: "お見積り",
|
||||||
|
inquiry_name: "お名前 *",
|
||||||
|
inquiry_email: "メールアドレス *",
|
||||||
|
inquiry_phone: "電話番号",
|
||||||
|
inquiry_company: "会社名",
|
||||||
|
inquiry_country: "国・地域",
|
||||||
|
inquiry_interest: "関心のある製品",
|
||||||
|
inquiry_message: "メッセージ *",
|
||||||
|
inquiry_message_ph: "ご要件、数量、用途などを記入してください...",
|
||||||
|
inquiry_submit: "問い合わせ送信",
|
||||||
|
inquiry_submitting: "送信中...",
|
||||||
|
inquiry_success: "ありがとうございます。お問い合わせを受け付けました。営業チームより 24 時間以内にご連絡いたします。",
|
||||||
|
inquiry_error: "送信に失敗しました。もう一度お試しください。",
|
||||||
|
inquiry_network: "ネットワークエラーです。もう一度お試しください。",
|
||||||
|
|
||||||
|
product_category: "カテゴリー:",
|
||||||
|
product_categories: "カテゴリー:",
|
||||||
|
product_specification: "仕様書",
|
||||||
|
product_get_quote: "お見積り依頼",
|
||||||
|
product_quote_title: "お見積り:{name}",
|
||||||
|
product_additional_info: "追加情報",
|
||||||
|
product_related: "関連製品",
|
||||||
|
product_no_related: "関連製品はまだありません。",
|
||||||
|
product_not_found: "申し訳ありません。この製品は存在しないか、販売を終了しました。",
|
||||||
|
product_browse_all: "全製品を見る",
|
||||||
|
|
||||||
|
card_read_more: "詳しく見る",
|
||||||
|
|
||||||
|
collection_categories: "カテゴリー",
|
||||||
|
collection_no_options: "選択肢がありません",
|
||||||
|
collection_products_count: "{total} 製品",
|
||||||
|
collection_no_results: "このカテゴリーには製品がありません",
|
||||||
|
collection_no_results_filtered: "選択した絞り込み条件に一致する製品がありません。",
|
||||||
|
shop_iot_products: "IoT 製品",
|
||||||
|
shop_showing: "{total} 件中 {from}–{to} 件を表示",
|
||||||
|
shop_no_results: "結果なし",
|
||||||
|
|
||||||
|
blog_badge: "ブログ",
|
||||||
|
blog_read_more: "続きを読む »",
|
||||||
|
blog_no_posts: "まだブログ記事はありません — コンテンツをインポート中です。後ほどご確認ください。",
|
||||||
|
blog_toc: "目次",
|
||||||
|
blog_related_posts: "関連記事",
|
||||||
|
blog_not_found: "申し訳ありません。この記事は存在しません。",
|
||||||
|
blog_back: "ブログに戻る",
|
||||||
|
blog_importing: "コンテンツをインポート中です。",
|
||||||
|
blog_gateways: "IoT ゲートウェイ",
|
||||||
|
blog_contact_title: "お問い合わせ",
|
||||||
|
|
||||||
|
search_results_for: "「{q}」の検索結果",
|
||||||
|
search_title: "製品検索",
|
||||||
|
search_by_sku: "製品名または SKU で検索...",
|
||||||
|
search_button: "検索",
|
||||||
|
search_empty: "一致する製品が見つかりませんでした。",
|
||||||
|
|
||||||
|
cart_empty: "カートは空です。",
|
||||||
|
cart_continue: "買い物を続ける",
|
||||||
|
cart_checkout: "レジに進む",
|
||||||
|
checkout_view_orders: "注文履歴を見る",
|
||||||
|
checkout_cart_kept: "カート内の {count} 点は保存されています。",
|
||||||
|
checkout_browse: "製品を見る",
|
||||||
|
checkout_same_address: "請求先と同じ住所に配送する",
|
||||||
|
checkout_notes_ph: "ご注文に関する備考(配送に関する特別なご要望など)",
|
||||||
|
checkout_subtotal: "小計",
|
||||||
|
acct_login: "ログイン",
|
||||||
|
acct_register: "新規登録",
|
||||||
|
acct_recent_orders: "最近の注文",
|
||||||
|
acct_no_orders: "注文はまだありません。",
|
||||||
|
acct_th_order: "注文",
|
||||||
|
acct_th_date: "日付",
|
||||||
|
acct_th_items: "商品",
|
||||||
|
acct_th_total: "合計",
|
||||||
|
acct_th_status: "状態",
|
||||||
|
acct_th_payment: "支払い方法",
|
||||||
|
|
||||||
|
nf_msg: "お探しのページは存在しないか、移動されました。",
|
||||||
|
nf_contact: "お問い合わせ",
|
||||||
|
solution_title: "ソリューション",
|
||||||
|
footer_intro: "DusunIoT は有力な IoT ハードウェアサプライヤーであり、エンドツーエンドの IoT ソリューションプロバイダーです。革新的な IoT の知見をもとに、次世代 IoT 技術の推進に取り組んでいます。",
|
||||||
|
footer_rights: "All Rights Reserved",
|
||||||
|
footer_sitemap: "サイトマップ",
|
||||||
|
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/** # 模块 i18n 韩语词典 */
|
||||||
|
import type { Dict } from "~/i18n/locales/en";
|
||||||
|
|
||||||
|
export const ko: Partial<Dict> = {
|
||||||
|
topbar_oem: "원스톱 OEM/ODM 서비스 >>",
|
||||||
|
login: "로그인",
|
||||||
|
contact_us: "문의하기",
|
||||||
|
search_placeholder: "제품 검색...",
|
||||||
|
search_placeholder_m: "검색...",
|
||||||
|
search_go: "검색",
|
||||||
|
nav_home: "홈",
|
||||||
|
nav_products: "제품",
|
||||||
|
nav_blog: "블로그",
|
||||||
|
nav_shop: "스토어",
|
||||||
|
nav_all_products: "전체 제품",
|
||||||
|
select_language: "언어 선택",
|
||||||
|
|
||||||
|
pagination_prev: "« 이전",
|
||||||
|
pagination_next: "다음 »",
|
||||||
|
|
||||||
|
inquiry_title: "견적 문의",
|
||||||
|
inquiry_name: "이름 *",
|
||||||
|
inquiry_email: "이메일 *",
|
||||||
|
inquiry_phone: "전화번호",
|
||||||
|
inquiry_company: "회사명",
|
||||||
|
inquiry_country: "국가/지역",
|
||||||
|
inquiry_interest: "관심 제품",
|
||||||
|
inquiry_message: "문의 내용 *",
|
||||||
|
inquiry_message_ph: "요구사항, 수량, 적용 분야 등을 적어주세요...",
|
||||||
|
inquiry_submit: "문의 보내기",
|
||||||
|
inquiry_submitting: "전송 중...",
|
||||||
|
inquiry_success: "감사합니다! 문의가 성공적으로 접수되었습니다. 영업팀이 24시간 이내에 연락드리겠습니다.",
|
||||||
|
inquiry_error: "제출에 실패했습니다. 다시 시도해 주세요.",
|
||||||
|
inquiry_network: "네트워크 오류입니다. 다시 시도해 주세요.",
|
||||||
|
|
||||||
|
product_category: "카테고리:",
|
||||||
|
product_categories: "카테고리:",
|
||||||
|
product_specification: "규격서",
|
||||||
|
product_get_quote: "견적 받기",
|
||||||
|
product_quote_title: "견적 문의: {name}",
|
||||||
|
product_additional_info: "추가 정보",
|
||||||
|
product_related: "관련 제품",
|
||||||
|
product_no_related: "관련 제품이 없습니다.",
|
||||||
|
product_not_found: "죄송합니다. 해당 제품이 존재하지 않거나 판매가 종료되었습니다.",
|
||||||
|
product_browse_all: "전체 제품 보기",
|
||||||
|
|
||||||
|
card_read_more: "자세히 보기",
|
||||||
|
|
||||||
|
collection_categories: "카테고리",
|
||||||
|
collection_no_options: "옵션 없음",
|
||||||
|
collection_products_count: "총 {total}개 제품",
|
||||||
|
collection_no_results: "이 카테고리에 제품이 없습니다",
|
||||||
|
collection_no_results_filtered: "선택한 필터 조건에 맞는 제품이 없습니다.",
|
||||||
|
shop_iot_products: "IoT 제품",
|
||||||
|
shop_showing: "총 {total}개 중 {from}–{to}개 표시",
|
||||||
|
shop_no_results: "결과 없음",
|
||||||
|
|
||||||
|
blog_badge: "블로그",
|
||||||
|
blog_read_more: "더 보기 »",
|
||||||
|
blog_no_posts: "아직 블로그 게시물이 없습니다 — 콘텐츠를 가져오는 중입니다. 잠시 후 다시 확인해 주세요.",
|
||||||
|
blog_toc: "목차",
|
||||||
|
blog_related_posts: "관련 게시물",
|
||||||
|
blog_not_found: "죄송합니다. 해당 게시물이 존재하지 않습니다.",
|
||||||
|
blog_back: "블로그로 돌아가기",
|
||||||
|
blog_importing: "콘텐츠를 가져오는 중입니다.",
|
||||||
|
blog_gateways: "IoT 게이트웨이",
|
||||||
|
blog_contact_title: "문의하기",
|
||||||
|
|
||||||
|
search_results_for: "“{q}” 검색 결과",
|
||||||
|
search_title: "제품 검색",
|
||||||
|
search_by_sku: "제품명 또는 SKU로 검색...",
|
||||||
|
search_button: "검색",
|
||||||
|
search_empty: "일치하는 제품이 없습니다.",
|
||||||
|
|
||||||
|
cart_empty: "장바구니가 비어 있습니다.",
|
||||||
|
cart_continue: "쇼핑 계속하기",
|
||||||
|
cart_checkout: "결제하기",
|
||||||
|
checkout_view_orders: "내 주문 보기",
|
||||||
|
checkout_cart_kept: "장바구니의 {count}개 상품이 보관됩니다.",
|
||||||
|
checkout_browse: "제품 둘러보기",
|
||||||
|
checkout_same_address: "청구 주소와 동일한 주소로 배송",
|
||||||
|
checkout_notes_ph: "주문 참고 사항, 예: 특별한 배송 요청",
|
||||||
|
checkout_subtotal: "소계",
|
||||||
|
acct_login: "로그인",
|
||||||
|
acct_register: "회원가입",
|
||||||
|
acct_recent_orders: "최근 주문",
|
||||||
|
acct_no_orders: "아직 주문이 없습니다.",
|
||||||
|
acct_th_order: "주문",
|
||||||
|
acct_th_date: "날짜",
|
||||||
|
acct_th_items: "상품",
|
||||||
|
acct_th_total: "합계",
|
||||||
|
acct_th_status: "상태",
|
||||||
|
acct_th_payment: "결제 수단",
|
||||||
|
|
||||||
|
nf_msg: "찾으시는 페이지가 존재하지 않거나 이동되었습니다.",
|
||||||
|
nf_contact: "문의하기",
|
||||||
|
solution_title: "솔루션",
|
||||||
|
footer_intro: "DusunIoT는 선도적인 IoT 하드웨어 공급업체이자 엔드투엔드 IoT 솔루션 제공업체입니다. 혁신적인 IoT 인사이트를 바탕으로 차세대 IoT 기술을 만들어갑니다.",
|
||||||
|
footer_rights: "All Rights Reserved",
|
||||||
|
footer_sitemap: "사이트맵",
|
||||||
|
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/** # 模块 i18n 荷兰语词典 */
|
||||||
|
import type { Dict } from "~/i18n/locales/en";
|
||||||
|
|
||||||
|
export const nl: Partial<Dict> = {
|
||||||
|
topbar_oem: "Complete OEM/ODM-diensten >>",
|
||||||
|
login: "Inloggen",
|
||||||
|
contact_us: "CONTACT",
|
||||||
|
search_placeholder: "Producten zoeken...",
|
||||||
|
search_placeholder_m: "Zoeken...",
|
||||||
|
search_go: "Ga",
|
||||||
|
nav_home: "Home",
|
||||||
|
nav_products: "Producten",
|
||||||
|
nav_blog: "Blog",
|
||||||
|
nav_shop: "Winkel",
|
||||||
|
nav_all_products: "Alle producten",
|
||||||
|
select_language: "Taal kiezen",
|
||||||
|
|
||||||
|
pagination_prev: "« Vorige",
|
||||||
|
pagination_next: "Volgende »",
|
||||||
|
|
||||||
|
inquiry_title: "Offerte aanvragen",
|
||||||
|
inquiry_name: "Naam *",
|
||||||
|
inquiry_email: "E-mail *",
|
||||||
|
inquiry_phone: "Telefoon",
|
||||||
|
inquiry_company: "Bedrijf",
|
||||||
|
inquiry_country: "Land",
|
||||||
|
inquiry_interest: "Gewenst product",
|
||||||
|
inquiry_message: "Bericht *",
|
||||||
|
inquiry_message_ph: "Beschrijf uw eisen, aantallen en toepassing...",
|
||||||
|
inquiry_submit: "Aanvraag versturen",
|
||||||
|
inquiry_submitting: "Versturen...",
|
||||||
|
inquiry_success: "Bedankt! Uw aanvraag is succesvol verzonden. Ons salesteam neemt binnen 24 uur contact met u op.",
|
||||||
|
inquiry_error: "Verzenden mislukt, probeer het opnieuw.",
|
||||||
|
inquiry_network: "Netwerkfout, probeer het opnieuw.",
|
||||||
|
|
||||||
|
product_category: "Categorie:",
|
||||||
|
product_categories: "Categorieën:",
|
||||||
|
product_specification: "Specificaties",
|
||||||
|
product_get_quote: "Offerte aanvragen",
|
||||||
|
product_quote_title: "Offerte aanvragen: {name}",
|
||||||
|
product_additional_info: "Aanvullende informatie",
|
||||||
|
product_related: "Gerelateerde producten",
|
||||||
|
product_no_related: "Nog geen gerelateerde producten.",
|
||||||
|
product_not_found: "Sorry, dit product bestaat niet of is niet meer beschikbaar.",
|
||||||
|
product_browse_all: "Bekijk alle producten",
|
||||||
|
|
||||||
|
card_read_more: "Meer info",
|
||||||
|
|
||||||
|
collection_categories: "Categorieën",
|
||||||
|
collection_no_options: "Geen opties",
|
||||||
|
collection_products_count: "{total} producten",
|
||||||
|
collection_no_results: "Geen producten gevonden in deze categorie",
|
||||||
|
collection_no_results_filtered: "Geen producten met de geselecteerde filters.",
|
||||||
|
shop_iot_products: "IoT-producten",
|
||||||
|
shop_showing: "Toont {from}–{to} van {total} resultaten",
|
||||||
|
shop_no_results: "Geen resultaten",
|
||||||
|
|
||||||
|
blog_badge: "Blog",
|
||||||
|
blog_read_more: "VERDER LEZEN »",
|
||||||
|
blog_no_posts: "Nog geen berichten — inhoud wordt geïmporteerd. Kom snel terug.",
|
||||||
|
blog_toc: "Inhoudsopgave",
|
||||||
|
blog_related_posts: "Gerelateerde berichten",
|
||||||
|
blog_not_found: "Sorry, dit bericht bestaat niet.",
|
||||||
|
blog_back: "Terug naar de blog",
|
||||||
|
blog_importing: "Inhoud wordt geïmporteerd.",
|
||||||
|
blog_gateways: "IoT-gateways",
|
||||||
|
blog_contact_title: "CONTACT",
|
||||||
|
|
||||||
|
search_results_for: "Zoekresultaten voor “{q}”",
|
||||||
|
search_title: "Producten zoeken",
|
||||||
|
search_by_sku: "Zoek op productnaam of SKU...",
|
||||||
|
search_button: "Zoeken",
|
||||||
|
search_empty: "Geen producten gevonden voor uw zoekopdracht.",
|
||||||
|
|
||||||
|
cart_empty: "Uw winkelwagen is leeg.",
|
||||||
|
cart_continue: "Verder winkelen",
|
||||||
|
cart_checkout: "Afrekenen",
|
||||||
|
checkout_view_orders: "Mijn bestellingen bekijken",
|
||||||
|
checkout_cart_kept: "Uw winkelwagen ({count} artikelen) blijft bewaard.",
|
||||||
|
checkout_browse: "Producten bekijken",
|
||||||
|
checkout_same_address: "Bezorgen op hetzelfde adres",
|
||||||
|
checkout_notes_ph: "Opmerkingen bij uw bestelling, bijv. specifieke bezorgwensen",
|
||||||
|
checkout_subtotal: "Subtotaal",
|
||||||
|
acct_login: "Inloggen",
|
||||||
|
acct_register: "Registreren",
|
||||||
|
acct_recent_orders: "Recente bestellingen",
|
||||||
|
acct_no_orders: "Nog geen bestellingen.",
|
||||||
|
acct_th_order: "Bestelling",
|
||||||
|
acct_th_date: "Datum",
|
||||||
|
acct_th_items: "Artikelen",
|
||||||
|
acct_th_total: "Totaal",
|
||||||
|
acct_th_status: "Status",
|
||||||
|
acct_th_payment: "Betaling",
|
||||||
|
|
||||||
|
nf_msg: "De pagina die u zoekt bestaat niet of is verplaatst.",
|
||||||
|
nf_contact: "Contact",
|
||||||
|
solution_title: "OPLOSSINGEN",
|
||||||
|
footer_intro: "DusunIoT is een toonaangevende leverancier van IoT-hardware en aanbieder van end-to-end IoT-oplossingen. Met innovatieve IoT-inzichten zet het bedrijf zich in voor IoT-technologie van de volgende generatie.",
|
||||||
|
footer_rights: "Alle rechten voorbehouden",
|
||||||
|
footer_sitemap: "Sitemap",
|
||||||
|
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/** # 模块 i18n 挪威语词典 */
|
||||||
|
import type { Dict } from "~/i18n/locales/en";
|
||||||
|
|
||||||
|
export const no: Partial<Dict> = {
|
||||||
|
topbar_oem: "Komplette OEM/ODM-tjenester >>",
|
||||||
|
login: "Logg inn",
|
||||||
|
contact_us: "KONTAKT OSS",
|
||||||
|
search_placeholder: "Søk etter produkter...",
|
||||||
|
search_placeholder_m: "Søk...",
|
||||||
|
search_go: "Søk",
|
||||||
|
nav_home: "Hjem",
|
||||||
|
nav_products: "Produkter",
|
||||||
|
nav_blog: "Blogg",
|
||||||
|
nav_shop: "Butikk",
|
||||||
|
nav_all_products: "Alle produkter",
|
||||||
|
select_language: "Velg språk",
|
||||||
|
|
||||||
|
pagination_prev: "« Forrige",
|
||||||
|
pagination_next: "Neste »",
|
||||||
|
|
||||||
|
inquiry_title: "Be om tilbud",
|
||||||
|
inquiry_name: "Navn *",
|
||||||
|
inquiry_email: "E-post *",
|
||||||
|
inquiry_phone: "Telefon",
|
||||||
|
inquiry_company: "Selskap",
|
||||||
|
inquiry_country: "Land",
|
||||||
|
inquiry_interest: "Ønsket produkt",
|
||||||
|
inquiry_message: "Melding *",
|
||||||
|
inquiry_message_ph: "Beskriv kravene dine, antall og bruksområde...",
|
||||||
|
inquiry_submit: "Send forespørsel",
|
||||||
|
inquiry_submitting: "Sender...",
|
||||||
|
inquiry_success: "Takk! Forespørselen din er sendt. Salgsteamet vårt kontakter deg innen 24 timer.",
|
||||||
|
inquiry_error: "Sending mislyktes, prøv igjen.",
|
||||||
|
inquiry_network: "Nettverksfeil, prøv igjen.",
|
||||||
|
|
||||||
|
product_category: "Kategori:",
|
||||||
|
product_categories: "Kategorier:",
|
||||||
|
product_specification: "Spesifikasjoner",
|
||||||
|
product_get_quote: "Be om tilbud",
|
||||||
|
product_quote_title: "Be om tilbud: {name}",
|
||||||
|
product_additional_info: "Tilleggsinformasjon",
|
||||||
|
product_related: "Relaterte produkter",
|
||||||
|
product_no_related: "Ingen relaterte produkter ennå.",
|
||||||
|
product_not_found: "Beklager, dette produktet finnes ikke eller er ikke lenger tilgjengelig.",
|
||||||
|
product_browse_all: "Se alle produkter",
|
||||||
|
|
||||||
|
card_read_more: "Les mer",
|
||||||
|
|
||||||
|
collection_categories: "Kategorier",
|
||||||
|
collection_no_options: "Ingen alternativer",
|
||||||
|
collection_products_count: "{total} produkter",
|
||||||
|
collection_no_results: "Ingen produkter funnet i denne kategorien",
|
||||||
|
collection_no_results_filtered: "Ingen produkter med de valgte filtrene.",
|
||||||
|
shop_iot_products: "IoT-produkter",
|
||||||
|
shop_showing: "Viser {from}–{to} av {total} resultater",
|
||||||
|
shop_no_results: "Ingen resultater",
|
||||||
|
|
||||||
|
blog_badge: "Blogg",
|
||||||
|
blog_read_more: "LES MER »",
|
||||||
|
blog_no_posts: "Ingen innlegg ennå — innhold importeres. Kom tilbake snart.",
|
||||||
|
blog_toc: "Innholdsfortegnelse",
|
||||||
|
blog_related_posts: "Relaterte innlegg",
|
||||||
|
blog_not_found: "Beklager, dette innlegget finnes ikke.",
|
||||||
|
blog_back: "Tilbake til bloggen",
|
||||||
|
blog_importing: "Innhold importeres.",
|
||||||
|
blog_gateways: "IoT-gatewayer",
|
||||||
|
blog_contact_title: "KONTAKT OSS",
|
||||||
|
|
||||||
|
search_results_for: "Søkeresultater for “{q}”",
|
||||||
|
search_title: "Søk etter produkter",
|
||||||
|
search_by_sku: "Søk etter produktnavn eller SKU...",
|
||||||
|
search_button: "Søk",
|
||||||
|
search_empty: "Ingen produkter samsvarer med søket ditt.",
|
||||||
|
|
||||||
|
cart_empty: "Handlekurven din er tom.",
|
||||||
|
cart_continue: "Fortsett å handle",
|
||||||
|
cart_checkout: "Til kassen",
|
||||||
|
checkout_view_orders: "Se bestillingene mine",
|
||||||
|
checkout_cart_kept: "Handlekurven din ({count} varer) beholdes.",
|
||||||
|
checkout_browse: "Se produkter",
|
||||||
|
checkout_same_address: "Lever til samme adresse",
|
||||||
|
checkout_notes_ph: "Merknader til bestillingen, f.eks. spesielle leveringsønsker",
|
||||||
|
checkout_subtotal: "Delsum",
|
||||||
|
acct_login: "Logg inn",
|
||||||
|
acct_register: "Registrer",
|
||||||
|
acct_recent_orders: "Siste bestillinger",
|
||||||
|
acct_no_orders: "Ingen bestillinger ennå.",
|
||||||
|
acct_th_order: "Bestilling",
|
||||||
|
acct_th_date: "Dato",
|
||||||
|
acct_th_items: "Varer",
|
||||||
|
acct_th_total: "Totalt",
|
||||||
|
acct_th_status: "Status",
|
||||||
|
acct_th_payment: "Betaling",
|
||||||
|
|
||||||
|
nf_msg: "Siden du leter etter finnes ikke eller er flyttet.",
|
||||||
|
nf_contact: "Kontakt oss",
|
||||||
|
solution_title: "LØSNINGER",
|
||||||
|
footer_intro: "DusunIoT er en ledende leverandør av IoT-maskinvare og komplett IoT-løsninger. Med innovative IoT-innsikter forplikter selskapet seg til å drive IoT-teknologi av neste generasjon fremover.",
|
||||||
|
footer_rights: "Alle rettigheter forbeholdes",
|
||||||
|
footer_sitemap: "Nettstedskart",
|
||||||
|
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/** # 模块 i18n 葡萄牙语词典 */
|
||||||
|
import type { Dict } from "~/i18n/locales/en";
|
||||||
|
|
||||||
|
export const pt: Partial<Dict> = {
|
||||||
|
topbar_oem: "Serviços OEM/ODM completos >>",
|
||||||
|
login: "Entrar",
|
||||||
|
contact_us: "CONTATO",
|
||||||
|
search_placeholder: "Pesquisar produtos...",
|
||||||
|
search_placeholder_m: "Pesquisar...",
|
||||||
|
search_go: "Buscar",
|
||||||
|
nav_home: "Início",
|
||||||
|
nav_products: "Produtos",
|
||||||
|
nav_blog: "Blog",
|
||||||
|
nav_shop: "Loja",
|
||||||
|
nav_all_products: "Todos os produtos",
|
||||||
|
select_language: "Selecionar idioma",
|
||||||
|
|
||||||
|
pagination_prev: "« Anterior",
|
||||||
|
pagination_next: "Próximo »",
|
||||||
|
|
||||||
|
inquiry_title: "Solicitar orçamento",
|
||||||
|
inquiry_name: "Nome *",
|
||||||
|
inquiry_email: "E-mail *",
|
||||||
|
inquiry_phone: "Telefone",
|
||||||
|
inquiry_company: "Empresa",
|
||||||
|
inquiry_country: "País",
|
||||||
|
inquiry_interest: "Produto de interesse",
|
||||||
|
inquiry_message: "Mensagem *",
|
||||||
|
inquiry_message_ph: "Descreva seus requisitos, quantidades e aplicação...",
|
||||||
|
inquiry_submit: "Enviar consulta",
|
||||||
|
inquiry_submitting: "Enviando...",
|
||||||
|
inquiry_success: "Obrigado! Sua consulta foi enviada com sucesso. Nossa equipe de vendas entrará em contato em até 24 horas.",
|
||||||
|
inquiry_error: "Falha no envio, tente novamente.",
|
||||||
|
inquiry_network: "Erro de rede, tente novamente.",
|
||||||
|
|
||||||
|
product_category: "Categoria:",
|
||||||
|
product_categories: "Categorias:",
|
||||||
|
product_specification: "Ficha técnica",
|
||||||
|
product_get_quote: "Solicitar orçamento",
|
||||||
|
product_quote_title: "Solicitar orçamento: {name}",
|
||||||
|
product_additional_info: "Informações adicionais",
|
||||||
|
product_related: "Produtos relacionados",
|
||||||
|
product_no_related: "Ainda não há produtos relacionados.",
|
||||||
|
product_not_found: "Desculpe, este produto não existe ou não está mais disponível.",
|
||||||
|
product_browse_all: "Ver todos os produtos",
|
||||||
|
|
||||||
|
card_read_more: "Saiba mais",
|
||||||
|
|
||||||
|
collection_categories: "Categorias",
|
||||||
|
collection_no_options: "Sem opções",
|
||||||
|
collection_products_count: "{total} produtos",
|
||||||
|
collection_no_results: "Nenhum produto encontrado nesta categoria",
|
||||||
|
collection_no_results_filtered: "Nenhum produto com os filtros selecionados.",
|
||||||
|
shop_iot_products: "Produtos IoT",
|
||||||
|
shop_showing: "Exibindo {from}–{to} de {total} resultados",
|
||||||
|
shop_no_results: "Sem resultados",
|
||||||
|
|
||||||
|
blog_badge: "Blog",
|
||||||
|
blog_read_more: "LEIA MAIS »",
|
||||||
|
blog_no_posts: "Ainda não há publicações — o conteúdo está sendo importado. Volte em breve.",
|
||||||
|
blog_toc: "Índice",
|
||||||
|
blog_related_posts: "Publicações relacionadas",
|
||||||
|
blog_not_found: "Desculpe, esta publicação não existe.",
|
||||||
|
blog_back: "Voltar ao blog",
|
||||||
|
blog_importing: "O conteúdo está sendo importado.",
|
||||||
|
blog_gateways: "Gateways IoT",
|
||||||
|
blog_contact_title: "CONTATO",
|
||||||
|
|
||||||
|
search_results_for: "Resultados da busca por “{q}”",
|
||||||
|
search_title: "Pesquisar produtos",
|
||||||
|
search_by_sku: "Pesquisar por nome do produto ou SKU...",
|
||||||
|
search_button: "Pesquisar",
|
||||||
|
search_empty: "Nenhum produto corresponde à sua busca.",
|
||||||
|
|
||||||
|
cart_empty: "Seu carrinho está vazio.",
|
||||||
|
cart_continue: "Continuar comprando",
|
||||||
|
cart_checkout: "Finalizar compra",
|
||||||
|
checkout_view_orders: "Ver meus pedidos",
|
||||||
|
checkout_cart_kept: "Seu carrinho ({count} itens) será mantido.",
|
||||||
|
checkout_browse: "Ver produtos",
|
||||||
|
checkout_same_address: "Enviar para o mesmo endereço",
|
||||||
|
checkout_notes_ph: "Observações sobre o pedido, ex.: requisitos especiais de entrega",
|
||||||
|
checkout_subtotal: "Subtotal",
|
||||||
|
acct_login: "Entrar",
|
||||||
|
acct_register: "Registrar",
|
||||||
|
acct_recent_orders: "Pedidos recentes",
|
||||||
|
acct_no_orders: "Nenhum pedido ainda.",
|
||||||
|
acct_th_order: "Pedido",
|
||||||
|
acct_th_date: "Data",
|
||||||
|
acct_th_items: "Itens",
|
||||||
|
acct_th_total: "Total",
|
||||||
|
acct_th_status: "Status",
|
||||||
|
acct_th_payment: "Pagamento",
|
||||||
|
|
||||||
|
nf_msg: "A página que você procura não existe ou foi movida.",
|
||||||
|
nf_contact: "Contato",
|
||||||
|
solution_title: "SOLUÇÕES",
|
||||||
|
footer_intro: "A DusunIoT é fornecedora líder de dispositivos de hardware IoT e provedora de soluções IoT de ponta a ponta. Com insights inovadores de IoT, a empresa está comprometida em impulsionar a tecnologia IoT de próxima geração.",
|
||||||
|
footer_rights: "Todos os direitos reservados",
|
||||||
|
footer_sitemap: "Mapa do site",
|
||||||
|
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/** # 模块 i18n 俄语词典 */
|
||||||
|
import type { Dict } from "~/i18n/locales/en";
|
||||||
|
|
||||||
|
export const ru: Partial<Dict> = {
|
||||||
|
topbar_oem: "Комплексные услуги OEM/ODM >>",
|
||||||
|
login: "Войти",
|
||||||
|
contact_us: "СВЯЗАТЬСЯ",
|
||||||
|
search_placeholder: "Поиск продукции...",
|
||||||
|
search_placeholder_m: "Поиск...",
|
||||||
|
search_go: "Найти",
|
||||||
|
nav_home: "Главная",
|
||||||
|
nav_products: "Продукция",
|
||||||
|
nav_blog: "Блог",
|
||||||
|
nav_shop: "Магазин",
|
||||||
|
nav_all_products: "Вся продукция",
|
||||||
|
select_language: "Выберите язык",
|
||||||
|
|
||||||
|
pagination_prev: "« Назад",
|
||||||
|
pagination_next: "Далее »",
|
||||||
|
|
||||||
|
inquiry_title: "Запросить предложение",
|
||||||
|
inquiry_name: "Имя *",
|
||||||
|
inquiry_email: "Эл. почта *",
|
||||||
|
inquiry_phone: "Телефон",
|
||||||
|
inquiry_company: "Компания",
|
||||||
|
inquiry_country: "Страна",
|
||||||
|
inquiry_interest: "Интересующий продукт",
|
||||||
|
inquiry_message: "Сообщение *",
|
||||||
|
inquiry_message_ph: "Опишите ваши требования, количество и сферу применения...",
|
||||||
|
inquiry_submit: "Отправить запрос",
|
||||||
|
inquiry_submitting: "Отправка...",
|
||||||
|
inquiry_success: "Спасибо! Ваш запрос успешно отправлен. Наш отдел продаж свяжется с вами в течение 24 часов.",
|
||||||
|
inquiry_error: "Не удалось отправить, попробуйте ещё раз.",
|
||||||
|
inquiry_network: "Ошибка сети, попробуйте ещё раз.",
|
||||||
|
|
||||||
|
product_category: "Категория:",
|
||||||
|
product_categories: "Категории:",
|
||||||
|
product_specification: "Спецификация",
|
||||||
|
product_get_quote: "Запросить предложение",
|
||||||
|
product_quote_title: "Запросить предложение: {name}",
|
||||||
|
product_additional_info: "Дополнительная информация",
|
||||||
|
product_related: "Похожая продукция",
|
||||||
|
product_no_related: "Похожей продукции пока нет.",
|
||||||
|
product_not_found: "К сожалению, этот продукт не существует или больше недоступен.",
|
||||||
|
product_browse_all: "Смотреть всю продукцию",
|
||||||
|
|
||||||
|
card_read_more: "Подробнее",
|
||||||
|
|
||||||
|
collection_categories: "Категории",
|
||||||
|
collection_no_options: "Нет вариантов",
|
||||||
|
collection_products_count: "{total} продуктов",
|
||||||
|
collection_no_results: "В этой категории products не найдено",
|
||||||
|
collection_no_results_filtered: "С выбранными фильтрами products не найдено.",
|
||||||
|
shop_iot_products: "IoT-продукция",
|
||||||
|
shop_showing: "Показано {from}–{to} из {total}",
|
||||||
|
shop_no_results: "Нет результатов",
|
||||||
|
|
||||||
|
blog_badge: "Блог",
|
||||||
|
blog_read_more: "ЧИТАТЬ ДАЛЕЕ »",
|
||||||
|
blog_no_posts: "Записей пока нет — содержимое импортируется. Загляните позже.",
|
||||||
|
blog_toc: "Содержание",
|
||||||
|
blog_related_posts: "Похожие записи",
|
||||||
|
blog_not_found: "К сожалению, эта запись не существует.",
|
||||||
|
blog_back: "Вернуться в блог",
|
||||||
|
blog_importing: "Содержимое импортируется.",
|
||||||
|
blog_gateways: "IoT-шлюзы",
|
||||||
|
blog_contact_title: "СВЯЗАТЬСЯ",
|
||||||
|
|
||||||
|
search_results_for: "Результаты поиска: «{q}»",
|
||||||
|
search_title: "Поиск продукции",
|
||||||
|
search_by_sku: "Поиск по названию продукта или артикулу...",
|
||||||
|
search_button: "Поиск",
|
||||||
|
search_empty: "Продукция по вашему запросу не найдена.",
|
||||||
|
|
||||||
|
cart_empty: "Ваша корзина пуста.",
|
||||||
|
cart_continue: "Продолжить покупки",
|
||||||
|
cart_checkout: "Перейти к оформлению",
|
||||||
|
checkout_view_orders: "Мои заказы",
|
||||||
|
checkout_cart_kept: "Ваша корзина ({count} тов.) сохранена.",
|
||||||
|
checkout_browse: "Смотреть продукцию",
|
||||||
|
checkout_same_address: "Доставить по тому же адресу",
|
||||||
|
checkout_notes_ph: "Примечания к заказу, напр. особые требования к доставке",
|
||||||
|
checkout_subtotal: "Подытог",
|
||||||
|
acct_login: "Войти",
|
||||||
|
acct_register: "Регистрация",
|
||||||
|
acct_recent_orders: "Последние заказы",
|
||||||
|
acct_no_orders: "Заказов пока нет.",
|
||||||
|
acct_th_order: "Заказ",
|
||||||
|
acct_th_date: "Дата",
|
||||||
|
acct_th_items: "Товары",
|
||||||
|
acct_th_total: "Итого",
|
||||||
|
acct_th_status: "Статус",
|
||||||
|
acct_th_payment: "Оплата",
|
||||||
|
|
||||||
|
nf_msg: "Запрашиваемая страница не существует или была перемещена.",
|
||||||
|
nf_contact: "Контакты",
|
||||||
|
solution_title: "РЕШЕНИЯ",
|
||||||
|
footer_intro: "DusunIoT — ведущий поставщик IoT-оборудования и комплексных IoT-решений. Опираясь на инновационные знания в области IoT, компания развивает технологии IoT нового поколения.",
|
||||||
|
footer_rights: "Все права защищены",
|
||||||
|
footer_sitemap: "Карта сайта",
|
||||||
|
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/** # 模块 i18n 泰语词典 */
|
||||||
|
import type { Dict } from "~/i18n/locales/en";
|
||||||
|
|
||||||
|
export const th: Partial<Dict> = {
|
||||||
|
topbar_oem: "บริการ OEM/ODM ครบวงจร >>",
|
||||||
|
login: "เข้าสู่ระบบ",
|
||||||
|
contact_us: "ติดต่อเรา",
|
||||||
|
search_placeholder: "ค้นหาสินค้า...",
|
||||||
|
search_placeholder_m: "ค้นหา...",
|
||||||
|
search_go: "ค้นหา",
|
||||||
|
nav_home: "หน้าแรก",
|
||||||
|
nav_products: "สินค้า",
|
||||||
|
nav_blog: "บล็อก",
|
||||||
|
nav_shop: "ร้านค้า",
|
||||||
|
nav_all_products: "สินค้าทั้งหมด",
|
||||||
|
select_language: "เลือกภาษา",
|
||||||
|
|
||||||
|
pagination_prev: "« ก่อนหน้า",
|
||||||
|
pagination_next: "ถัดไป »",
|
||||||
|
|
||||||
|
inquiry_title: "ขอใบเสนอราคา",
|
||||||
|
inquiry_name: "ชื่อ *",
|
||||||
|
inquiry_email: "อีเมล *",
|
||||||
|
inquiry_phone: "โทรศัพท์",
|
||||||
|
inquiry_company: "บริษัท",
|
||||||
|
inquiry_country: "ประเทศ",
|
||||||
|
inquiry_interest: "สินค้าที่สนใจ",
|
||||||
|
inquiry_message: "ข้อความ *",
|
||||||
|
inquiry_message_ph: "กรุณาอธิบายความต้องการ จำนวน และการใช้งานของคุณ...",
|
||||||
|
inquiry_submit: "ส่งคำถาม",
|
||||||
|
inquiry_submitting: "กำลังส่ง...",
|
||||||
|
inquiry_success: "ขอบคุณค่ะ/ครับ! ส่งคำถามเรียบร้อยแล้ว ทีมงานจะติดต่อกลับภายใน 24 ชั่วโมง",
|
||||||
|
inquiry_error: "ส่งไม่สำเร็จ กรุณาลองอีกครั้ง",
|
||||||
|
inquiry_network: "ข้อผิดพลาดเครือข่าย กรุณาลองอีกครั้ง",
|
||||||
|
|
||||||
|
product_category: "หมวดหมู่:",
|
||||||
|
product_categories: "หมวดหมู่:",
|
||||||
|
product_specification: "สเปก",
|
||||||
|
product_get_quote: "ขอใบเสนอราคา",
|
||||||
|
product_quote_title: "ขอใบเสนอราคา: {name}",
|
||||||
|
product_additional_info: "ข้อมูลเพิ่มเติม",
|
||||||
|
product_related: "สินค้าที่เกี่ยวข้อง",
|
||||||
|
product_no_related: "ยังไม่มีสินค้าที่เกี่ยวข้อง",
|
||||||
|
product_not_found: "ขออภัย สินค้านี้ไม่มีอยู่หรือไม่ว่างขายแล้ว",
|
||||||
|
product_browse_all: "ดูสินค้าทั้งหมด",
|
||||||
|
|
||||||
|
card_read_more: "อ่านเพิ่มเติม",
|
||||||
|
|
||||||
|
collection_categories: "หมวดหมู่",
|
||||||
|
collection_no_options: "ไม่มีตัวเลือก",
|
||||||
|
collection_products_count: "{total} สินค้า",
|
||||||
|
collection_no_results: "ไม่พบสินค้าในหมวดหมู่นี้",
|
||||||
|
collection_no_results_filtered: "ไม่พบสินค้าตามตัวกรองที่เลือก",
|
||||||
|
shop_iot_products: "สินค้า IoT",
|
||||||
|
shop_showing: "แสดง {from}–{to} จาก {total} รายการ",
|
||||||
|
shop_no_results: "ไม่มีผลลัพธ์",
|
||||||
|
|
||||||
|
blog_badge: "บล็อก",
|
||||||
|
blog_read_more: "อ่านต่อ »",
|
||||||
|
blog_no_posts: "ยังไม่มีบทความ — กำลังนำเข้าเนื้อหา โปรดกลับมาใหม่ภายหลัง",
|
||||||
|
blog_toc: "สารบัญ",
|
||||||
|
blog_related_posts: "บทความที่เกี่ยวข้อง",
|
||||||
|
blog_not_found: "ขออภัย ไม่พบบทความนี้",
|
||||||
|
blog_back: "กลับไปหน้าบล็อก",
|
||||||
|
blog_importing: "กำลังนำเข้าเนื้อหา",
|
||||||
|
blog_gateways: "เกตเวย์ IoT",
|
||||||
|
blog_contact_title: "ติดต่อเรา",
|
||||||
|
|
||||||
|
search_results_for: "ผลการค้นหา “{q}”",
|
||||||
|
search_title: "ค้นหาสินค้า",
|
||||||
|
search_by_sku: "ค้นหาตามชื่อสินค้าหรือ SKU...",
|
||||||
|
search_button: "ค้นหา",
|
||||||
|
search_empty: "ไม่พบสินค้าที่ตรงกับการค้นหา",
|
||||||
|
|
||||||
|
cart_empty: "ตะกร้าของคุณว่างเปล่า",
|
||||||
|
cart_continue: "เลือกซื้อสินค้าต่อ",
|
||||||
|
cart_checkout: "ไปที่การชำระเงิน",
|
||||||
|
checkout_view_orders: "ดูคำสั่งซื้อของฉัน",
|
||||||
|
checkout_cart_kept: "ตะกร้าของคุณ ({count} รายการ) จะถูกเก็บไว้",
|
||||||
|
checkout_browse: "ดูสินค้า",
|
||||||
|
checkout_same_address: "จัดส่งไปที่อยู่เดียวกัน",
|
||||||
|
checkout_notes_ph: "หมายเหตุเกี่ยวกับคำสั่งซื้อ เช่น ข้อกำหนดการจัดส่งพิเศษ",
|
||||||
|
checkout_subtotal: "ยอดรวมย่อย",
|
||||||
|
acct_login: "เข้าสู่ระบบ",
|
||||||
|
acct_register: "ลงทะเบียน",
|
||||||
|
acct_recent_orders: "คำสั่งซื้อล่าสุด",
|
||||||
|
acct_no_orders: "ยังไม่มีคำสั่งซื้อ",
|
||||||
|
acct_th_order: "คำสั่งซื้อ",
|
||||||
|
acct_th_date: "วันที่",
|
||||||
|
acct_th_items: "รายการ",
|
||||||
|
acct_th_total: "รวม",
|
||||||
|
acct_th_status: "สถานะ",
|
||||||
|
acct_th_payment: "การชำระเงิน",
|
||||||
|
|
||||||
|
nf_msg: "หน้าที่คุณกำลังหาไม่มีอยู่หรือถูกย้ายแล้ว",
|
||||||
|
nf_contact: "ติดต่อเรา",
|
||||||
|
solution_title: "โซลูชัน",
|
||||||
|
footer_intro: "DusunIoT คือผู้ผลิตอุปกรณ์ IoT ชั้นนำและผู้ให้บริการโซลูชัน IoT แบบครบวงจร ด้วยความเข้าใจเชิงลึกด้าน IoT บริษัทจึงมุ่งมั่นพัฒนาเทคโนโลยี IoT รุ่นถัดไป",
|
||||||
|
footer_rights: "สงวนลิขสิทธิ์",
|
||||||
|
footer_sitemap: "แผนผังเว็บไซต์",
|
||||||
|
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/** # 模块 i18n 菲律宾语词典 */
|
||||||
|
import type { Dict } from "~/i18n/locales/en";
|
||||||
|
|
||||||
|
export const tl: Partial<Dict> = {
|
||||||
|
topbar_oem: "Kompletong OEM/ODM na Serbisyo >>",
|
||||||
|
login: "Mag-log in",
|
||||||
|
contact_us: "MAKIPAG-UGNAYAN",
|
||||||
|
search_placeholder: "Maghanap ng produkto...",
|
||||||
|
search_placeholder_m: "Maghanap...",
|
||||||
|
search_go: "Sige",
|
||||||
|
nav_home: "Home",
|
||||||
|
nav_products: "Mga Produkto",
|
||||||
|
nav_blog: "Blog",
|
||||||
|
nav_shop: "Tindahan",
|
||||||
|
nav_all_products: "Lahat ng produkto",
|
||||||
|
select_language: "Pumili ng wika",
|
||||||
|
|
||||||
|
pagination_prev: "« Nakaraan",
|
||||||
|
pagination_next: "Susunod »",
|
||||||
|
|
||||||
|
inquiry_title: "Humiling ng quote",
|
||||||
|
inquiry_name: "Pangalan *",
|
||||||
|
inquiry_email: "Email *",
|
||||||
|
inquiry_phone: "Telepono",
|
||||||
|
inquiry_company: "Kompanya",
|
||||||
|
inquiry_country: "Bansa",
|
||||||
|
inquiry_interest: "Produktong interesado",
|
||||||
|
inquiry_message: "Mensahe *",
|
||||||
|
inquiry_message_ph: "Ilarawan ang iyong mga kinakailangan, dami at aplikasyon...",
|
||||||
|
inquiry_submit: "Ipadala ang inquiry",
|
||||||
|
inquiry_submitting: "Ipinapadala...",
|
||||||
|
inquiry_success: "Salamat! Matagumpay na naipadala ang iyong inquiry. Tatawagin ka ng aming sales team sa loob ng 24 oras.",
|
||||||
|
inquiry_error: "Hindi naipadala, subukan muli.",
|
||||||
|
inquiry_network: "Error sa network, subukan muli.",
|
||||||
|
|
||||||
|
product_category: "Kategorya:",
|
||||||
|
product_categories: "Mga Kategorya:",
|
||||||
|
product_specification: "Specification",
|
||||||
|
product_get_quote: "Humiling ng quote",
|
||||||
|
product_quote_title: "Humiling ng quote: {name}",
|
||||||
|
product_additional_info: "Karagdagang impormasyon",
|
||||||
|
product_related: "Mga kaugnay na produkto",
|
||||||
|
product_no_related: "Wala pang kaugnay na produkto.",
|
||||||
|
product_not_found: "Paumanhin, ang produktong ito ay wala na o hindi na available.",
|
||||||
|
product_browse_all: "Tingnan lahat ng produkto",
|
||||||
|
|
||||||
|
card_read_more: "Alamin pa",
|
||||||
|
|
||||||
|
collection_categories: "Mga Kategorya",
|
||||||
|
collection_no_options: "Walang pagpipilian",
|
||||||
|
collection_products_count: "{total} na produkto",
|
||||||
|
collection_no_results: "Walang nahanap na produkto sa kategoryang ito",
|
||||||
|
collection_no_results_filtered: "Walang produkto sa mga napiling filter.",
|
||||||
|
shop_iot_products: "Mga IoT Product",
|
||||||
|
shop_showing: "Ipinapakita ang {from}–{to} ng {total} resulta",
|
||||||
|
shop_no_results: "Walang resulta",
|
||||||
|
|
||||||
|
blog_badge: "Blog",
|
||||||
|
blog_read_more: "BASAHIN PA »",
|
||||||
|
blog_no_posts: "Wala pang post — inaangkat pa ang nilalaman. Bumalik sa lalong madaling panahon.",
|
||||||
|
blog_toc: "Talaan ng Nilalaman",
|
||||||
|
blog_related_posts: "Mga kaugnay na post",
|
||||||
|
blog_not_found: "Paumanhin, hindi umiiral ang post na ito.",
|
||||||
|
blog_back: "Bumalik sa blog",
|
||||||
|
blog_importing: "Inaangkat pa ang nilalaman.",
|
||||||
|
blog_gateways: "Mga IoT Gateway",
|
||||||
|
blog_contact_title: "MAKIPAG-UGNAYAN",
|
||||||
|
|
||||||
|
search_results_for: "Mga resulta ng paghahanap para sa “{q}”",
|
||||||
|
search_title: "Maghanap ng produkto",
|
||||||
|
search_by_sku: "Maghanap ayon sa pangalan ng produkto o SKU...",
|
||||||
|
search_button: "Hanapin",
|
||||||
|
search_empty: "Walang produktong tumugma sa iyong paghahanap.",
|
||||||
|
|
||||||
|
cart_empty: "Walang laman ang iyong cart.",
|
||||||
|
cart_continue: "Magpatuloy sa pamimili",
|
||||||
|
cart_checkout: "Mag-checkout",
|
||||||
|
checkout_view_orders: "Tingnan ang mga order ko",
|
||||||
|
checkout_cart_kept: "Mananatili ang iyong cart ({count} item).",
|
||||||
|
checkout_browse: "Tumingin ng produkto",
|
||||||
|
checkout_same_address: "Ipadala sa parehong address",
|
||||||
|
checkout_notes_ph: "Mga tala sa order, hal. espesyal na kahilingan sa paghahatid",
|
||||||
|
checkout_subtotal: "Subtotal",
|
||||||
|
acct_login: "Mag-log in",
|
||||||
|
acct_register: "Magrehistro",
|
||||||
|
acct_recent_orders: "Mga kamakailang order",
|
||||||
|
acct_no_orders: "Wala pang order.",
|
||||||
|
acct_th_order: "Order",
|
||||||
|
acct_th_date: "Petsa",
|
||||||
|
acct_th_items: "Mga item",
|
||||||
|
acct_th_total: "Kabuuan",
|
||||||
|
acct_th_status: "Katayuan",
|
||||||
|
acct_th_payment: "Pagbabayad",
|
||||||
|
|
||||||
|
nf_msg: "Hindi umiiral o inilipat ang pahina na hinahanap mo.",
|
||||||
|
nf_contact: "Makipag-ugnayan",
|
||||||
|
solution_title: "MGA SOLUSYON",
|
||||||
|
footer_intro: "Ang DusunIoT ang nangungunang supplier ng IoT hardware at provider ng end-to-end na mga IoT solution. Sa mga makabagong IoT insight, nakatuon ang kompanya sa pag-unlad ng susunod na henerasyon ng IoT technology.",
|
||||||
|
footer_rights: "All Rights Reserved",
|
||||||
|
footer_sitemap: "Sitemap",
|
||||||
|
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/** # 模块 i18n 土耳其语词典 */
|
||||||
|
import type { Dict } from "~/i18n/locales/en";
|
||||||
|
|
||||||
|
export const tr: Partial<Dict> = {
|
||||||
|
topbar_oem: "Uçtan uca OEM/ODM hizmetleri >>",
|
||||||
|
login: "Giriş yap",
|
||||||
|
contact_us: "BİZE ULAŞIN",
|
||||||
|
search_placeholder: "Ürün arayın...",
|
||||||
|
search_placeholder_m: "Ara...",
|
||||||
|
search_go: "Git",
|
||||||
|
nav_home: "Ana Sayfa",
|
||||||
|
nav_products: "Ürünler",
|
||||||
|
nav_blog: "Blog",
|
||||||
|
nav_shop: "Mağaza",
|
||||||
|
nav_all_products: "Tüm ürünler",
|
||||||
|
select_language: "Dil seçin",
|
||||||
|
|
||||||
|
pagination_prev: "« Önceki",
|
||||||
|
pagination_next: "Sonraki »",
|
||||||
|
|
||||||
|
inquiry_title: "Teklif alın",
|
||||||
|
inquiry_name: "Ad *",
|
||||||
|
inquiry_email: "E-posta *",
|
||||||
|
inquiry_phone: "Telefon",
|
||||||
|
inquiry_company: "Şirket",
|
||||||
|
inquiry_country: "Ülke",
|
||||||
|
inquiry_interest: "İlgilenilen ürün",
|
||||||
|
inquiry_message: "Mesaj *",
|
||||||
|
inquiry_message_ph: "Gereksinimlerinizi, adetleri ve kullanım alanınızı açıklayın...",
|
||||||
|
inquiry_submit: "Talebi gönder",
|
||||||
|
inquiry_submitting: "Gönderiliyor...",
|
||||||
|
inquiry_success: "Teşekkürler! Talebiniz başarıyla gönderildi. Satış ekibimiz 24 saat içinde sizinle iletişime geçecek.",
|
||||||
|
inquiry_error: "Gönderilemedi, lütfen tekrar deneyin.",
|
||||||
|
inquiry_network: "Ağ hatası, lütfen tekrar deneyin.",
|
||||||
|
|
||||||
|
product_category: "Kategori:",
|
||||||
|
product_categories: "Kategoriler:",
|
||||||
|
product_specification: "Teknik özellikler",
|
||||||
|
product_get_quote: "Teklif alın",
|
||||||
|
product_quote_title: "Teklif alın: {name}",
|
||||||
|
product_additional_info: "Ek bilgiler",
|
||||||
|
product_related: "İlgili ürünler",
|
||||||
|
product_no_related: "Henüz ilgili ürün yok.",
|
||||||
|
product_not_found: "Üzgünüz, bu ürün mevcut değil veya artık satışta değil.",
|
||||||
|
product_browse_all: "Tüm ürünlere göz atın",
|
||||||
|
|
||||||
|
card_read_more: "Daha fazla",
|
||||||
|
|
||||||
|
collection_categories: "Kategoriler",
|
||||||
|
collection_no_options: "Seçenek yok",
|
||||||
|
collection_products_count: "{total} ürün",
|
||||||
|
collection_no_results: "Bu kategoride ürün bulunamadı",
|
||||||
|
collection_no_results_filtered: "Seçilen filtrelerle ürün bulunamadı.",
|
||||||
|
shop_iot_products: "IoT Ürünleri",
|
||||||
|
shop_showing: "{total} sonuçtan {from}–{to} gösteriliyor",
|
||||||
|
shop_no_results: "Sonuç yok",
|
||||||
|
|
||||||
|
blog_badge: "Blog",
|
||||||
|
blog_read_more: "DEVAMINI OKU »",
|
||||||
|
blog_no_posts: "Henüz yazı yok — içerikler içe aktarılıyor. Lütfen daha sonra tekrar bakın.",
|
||||||
|
blog_toc: "İçindekiler",
|
||||||
|
blog_related_posts: "İlgili yazılar",
|
||||||
|
blog_not_found: "Üzgünüz, bu yazı mevcut değil.",
|
||||||
|
blog_back: "Bloga dön",
|
||||||
|
blog_importing: "İçerikler içe aktarılıyor.",
|
||||||
|
blog_gateways: "IoT Ağ Geçitleri",
|
||||||
|
blog_contact_title: "BİZE ULAŞIN",
|
||||||
|
|
||||||
|
search_results_for: "“{q}” için arama sonuçları",
|
||||||
|
search_title: "Ürün arayın",
|
||||||
|
search_by_sku: "Ürün adı veya SKU ile arayın...",
|
||||||
|
search_button: "Ara",
|
||||||
|
search_empty: "Aramanızla eşleşen ürün bulunamadı.",
|
||||||
|
|
||||||
|
cart_empty: "Sepetiniz boş.",
|
||||||
|
cart_continue: "Alışverişe devam et",
|
||||||
|
cart_checkout: "Ödemeye geç",
|
||||||
|
checkout_view_orders: "Siparişlerimi gör",
|
||||||
|
checkout_cart_kept: "Sepetinizdeki {count} ürün korunacak.",
|
||||||
|
checkout_browse: "Ürünlere göz at",
|
||||||
|
checkout_same_address: "Aynı adrese gönder",
|
||||||
|
checkout_notes_ph: "Sipariş notları, örn. özel teslimat talepleri",
|
||||||
|
checkout_subtotal: "Ara toplam",
|
||||||
|
acct_login: "Giriş yap",
|
||||||
|
acct_register: "Kayıt ol",
|
||||||
|
acct_recent_orders: "Son siparişler",
|
||||||
|
acct_no_orders: "Henüz sipariş yok.",
|
||||||
|
acct_th_order: "Sipariş",
|
||||||
|
acct_th_date: "Tarih",
|
||||||
|
acct_th_items: "Ürünler",
|
||||||
|
acct_th_total: "Toplam",
|
||||||
|
acct_th_status: "Durum",
|
||||||
|
acct_th_payment: "Ödeme",
|
||||||
|
|
||||||
|
nf_msg: "Aradığınız sayfa mevcut değil veya taşınmış.",
|
||||||
|
nf_contact: "İletişim",
|
||||||
|
solution_title: "ÇÖZÜMLER",
|
||||||
|
footer_intro: "DusunIoT, önde gelen bir IoT donanım tedarikçisi ve uçtan uca IoT çözüm sağlayıcısıdır. Yenilikçi IoT içgörüleriyle şirket, yeni nesil IoT teknolojisini ilerletmeye adanmıştır.",
|
||||||
|
footer_rights: "Tüm hakları saklıdır",
|
||||||
|
footer_sitemap: "Site haritası",
|
||||||
|
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/** # 模块 i18n 乌克兰语词典 */
|
||||||
|
import type { Dict } from "~/i18n/locales/en";
|
||||||
|
|
||||||
|
export const uk: Partial<Dict> = {
|
||||||
|
topbar_oem: "Комплексні послуги OEM/ODM >>",
|
||||||
|
login: "Увійти",
|
||||||
|
contact_us: "ЗВ'ЯЗАТИСЯ",
|
||||||
|
search_placeholder: "Пошук продукції...",
|
||||||
|
search_placeholder_m: "Пошук...",
|
||||||
|
search_go: "Знайти",
|
||||||
|
nav_home: "Головна",
|
||||||
|
nav_products: "Продукція",
|
||||||
|
nav_blog: "Блог",
|
||||||
|
nav_shop: "Магазин",
|
||||||
|
nav_all_products: "Уся продукція",
|
||||||
|
select_language: "Виберіть мову",
|
||||||
|
|
||||||
|
pagination_prev: "« Назад",
|
||||||
|
pagination_next: "Далі »",
|
||||||
|
|
||||||
|
inquiry_title: "Запитати пропозицію",
|
||||||
|
inquiry_name: "Ім'я *",
|
||||||
|
inquiry_email: "Ел. пошта *",
|
||||||
|
inquiry_phone: "Телефон",
|
||||||
|
inquiry_company: "Компанія",
|
||||||
|
inquiry_country: "Країна",
|
||||||
|
inquiry_interest: "Продукт, який цікавить",
|
||||||
|
inquiry_message: "Повідомлення *",
|
||||||
|
inquiry_message_ph: "Опишіть ваші вимоги, кількість і сферу застосування...",
|
||||||
|
inquiry_submit: "Надіслати запит",
|
||||||
|
inquiry_submitting: "Надсилання...",
|
||||||
|
inquiry_success: "Дякуємо! Ваш запит успішно надіслано. Наш відділ продажів зв'яжеться з вами протягом 24 годин.",
|
||||||
|
inquiry_error: "Не вдалося надіслати, спробуйте ще раз.",
|
||||||
|
inquiry_network: "Помилка мережі, спробуйте ще раз.",
|
||||||
|
|
||||||
|
product_category: "Категорія:",
|
||||||
|
product_categories: "Категорії:",
|
||||||
|
product_specification: "Специфікація",
|
||||||
|
product_get_quote: "Запитати пропозицію",
|
||||||
|
product_quote_title: "Запитати пропозицію: {name}",
|
||||||
|
product_additional_info: "Додаткова інформація",
|
||||||
|
product_related: "Схожа продукція",
|
||||||
|
product_no_related: "Схожої продукції поки немає.",
|
||||||
|
product_not_found: "На жаль, цього продукту не існує або він більше недоступний.",
|
||||||
|
product_browse_all: "Переглянути всю продукцію",
|
||||||
|
|
||||||
|
card_read_more: "Докладніше",
|
||||||
|
|
||||||
|
collection_categories: "Категорії",
|
||||||
|
collection_no_options: "Немає варіантів",
|
||||||
|
collection_products_count: "{total} продуктів",
|
||||||
|
collection_no_results: "У цій категорії продуктів не знайдено",
|
||||||
|
collection_no_results_filtered: "З вибраними фільтрами продуктів не знайдено.",
|
||||||
|
shop_iot_products: "IoT-продукція",
|
||||||
|
shop_showing: "Показано {from}–{to} з {total}",
|
||||||
|
shop_no_results: "Немає результатів",
|
||||||
|
|
||||||
|
blog_badge: "Блог",
|
||||||
|
blog_read_more: "ЧИТАТИ ДАЛІ »",
|
||||||
|
blog_no_posts: "Записів ще немає — вміст імпортується. Завітайте пізніше.",
|
||||||
|
blog_toc: "Зміст",
|
||||||
|
blog_related_posts: "Схожі записи",
|
||||||
|
blog_not_found: "На жаль, цей запис не існує.",
|
||||||
|
blog_back: "Повернутися до блогу",
|
||||||
|
blog_importing: "Вміст імпортується.",
|
||||||
|
blog_gateways: "IoT-шлюзи",
|
||||||
|
blog_contact_title: "ЗВ'ЯЗАТИСЯ",
|
||||||
|
|
||||||
|
search_results_for: "Результати пошуку: «{q}»",
|
||||||
|
search_title: "Пошук продукції",
|
||||||
|
search_by_sku: "Пошук за назвою продукту або артикулом...",
|
||||||
|
search_button: "Пошук",
|
||||||
|
search_empty: "Продуктів за вашим запитом не знайдено.",
|
||||||
|
|
||||||
|
cart_empty: "Ваш кошик порожній.",
|
||||||
|
cart_continue: "Продовжити покупки",
|
||||||
|
cart_checkout: "Оформити замовлення",
|
||||||
|
checkout_view_orders: "Мої замовлення",
|
||||||
|
checkout_cart_kept: "Ваш кошик ({count} тов.) збережено.",
|
||||||
|
checkout_browse: "Переглянути продукцію",
|
||||||
|
checkout_same_address: "Доставити за тією ж адресою",
|
||||||
|
checkout_notes_ph: "Примітки до замовлення, напр. особливі вимоги до доставки",
|
||||||
|
checkout_subtotal: "Підсумок",
|
||||||
|
acct_login: "Увійти",
|
||||||
|
acct_register: "Реєстрація",
|
||||||
|
acct_recent_orders: "Останні замовлення",
|
||||||
|
acct_no_orders: "Замовлень ще немає.",
|
||||||
|
acct_th_order: "Замовлення",
|
||||||
|
acct_th_date: "Дата",
|
||||||
|
acct_th_items: "Товари",
|
||||||
|
acct_th_total: "Разом",
|
||||||
|
acct_th_status: "Статус",
|
||||||
|
acct_th_payment: "Оплата",
|
||||||
|
|
||||||
|
nf_msg: "Сторінки, яку ви шукаєте, не існує або її було переміщено.",
|
||||||
|
nf_contact: "Контакти",
|
||||||
|
solution_title: "РІШЕННЯ",
|
||||||
|
footer_intro: "DusunIoT — провідний постачальник IoT-обладнання та комплексних IoT-рішень. Спираючись на інноваційні знання в галузі IoT, компанія розвиває технології IoT нового покоління.",
|
||||||
|
footer_rights: "Усі права захищено",
|
||||||
|
footer_sitemap: "Карта сайту",
|
||||||
|
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/** # 模块 i18n 中文(简体)词典 */
|
||||||
|
import type { Dict } from "~/i18n/locales/en";
|
||||||
|
|
||||||
|
export const zhCN: Partial<Dict> = {
|
||||||
|
topbar_oem: "一站式 OEM/ODM 服务 >>",
|
||||||
|
login: "登录",
|
||||||
|
contact_us: "联系我们",
|
||||||
|
search_placeholder: "搜索产品...",
|
||||||
|
search_placeholder_m: "搜索...",
|
||||||
|
search_go: "搜索",
|
||||||
|
nav_home: "首页",
|
||||||
|
nav_products: "产品",
|
||||||
|
nav_blog: "博客",
|
||||||
|
nav_shop: "商城",
|
||||||
|
nav_all_products: "全部产品",
|
||||||
|
select_language: "选择语言",
|
||||||
|
|
||||||
|
pagination_prev: "« 上一页",
|
||||||
|
pagination_next: "下一页 »",
|
||||||
|
|
||||||
|
inquiry_title: "获取报价",
|
||||||
|
inquiry_name: "姓名 *",
|
||||||
|
inquiry_email: "邮箱 *",
|
||||||
|
inquiry_phone: "电话",
|
||||||
|
inquiry_company: "公司",
|
||||||
|
inquiry_country: "国家/地区",
|
||||||
|
inquiry_interest: "感兴趣的产品",
|
||||||
|
inquiry_message: "留言 *",
|
||||||
|
inquiry_message_ph: "请描述您的需求、数量和应用场景...",
|
||||||
|
inquiry_submit: "提交询盘",
|
||||||
|
inquiry_submitting: "提交中...",
|
||||||
|
inquiry_success: "感谢您的询盘!我们已成功收到您的提交,销售团队将在 24 小时内与您联系。",
|
||||||
|
inquiry_error: "提交失败,请重试。",
|
||||||
|
inquiry_network: "网络错误,请重试。",
|
||||||
|
|
||||||
|
product_category: "分类:",
|
||||||
|
product_categories: "分类:",
|
||||||
|
product_specification: "规格书",
|
||||||
|
product_get_quote: "获取报价",
|
||||||
|
product_quote_title: "获取报价:{name}",
|
||||||
|
product_additional_info: "附加信息",
|
||||||
|
product_related: "相关产品",
|
||||||
|
product_no_related: "暂无相关产品。",
|
||||||
|
product_not_found: "抱歉,该产品不存在或已下架。",
|
||||||
|
product_browse_all: "浏览全部产品",
|
||||||
|
|
||||||
|
card_read_more: "了解更多",
|
||||||
|
|
||||||
|
collection_categories: "分类",
|
||||||
|
collection_no_options: "暂无选项",
|
||||||
|
collection_products_count: "共 {total} 个产品",
|
||||||
|
collection_no_results: "该分类下暂无产品",
|
||||||
|
collection_no_results_filtered: "所选筛选条件下暂无产品。",
|
||||||
|
shop_iot_products: "IoT 产品",
|
||||||
|
shop_showing: "显示第 {from}–{to} 项,共 {total} 项",
|
||||||
|
shop_no_results: "无结果",
|
||||||
|
|
||||||
|
blog_badge: "博客",
|
||||||
|
blog_read_more: "阅读全文 »",
|
||||||
|
blog_no_posts: "暂无博客文章 — 内容导入中,请稍后再来。",
|
||||||
|
blog_toc: "目录",
|
||||||
|
blog_related_posts: "相关文章",
|
||||||
|
blog_not_found: "抱歉,该博客文章不存在。",
|
||||||
|
blog_back: "返回博客",
|
||||||
|
blog_importing: "内容导入中。",
|
||||||
|
blog_gateways: "IoT 网关",
|
||||||
|
blog_contact_title: "联系我们",
|
||||||
|
|
||||||
|
search_results_for: "“{q}” 的搜索结果",
|
||||||
|
search_title: "搜索产品",
|
||||||
|
search_by_sku: "按产品名称或 SKU 搜索...",
|
||||||
|
search_button: "搜索",
|
||||||
|
search_empty: "没有匹配的产品。",
|
||||||
|
|
||||||
|
cart_empty: "购物车是空的。",
|
||||||
|
cart_continue: "继续购物",
|
||||||
|
cart_checkout: "去结算",
|
||||||
|
checkout_view_orders: "查看我的订单",
|
||||||
|
checkout_cart_kept: "购物车中的 {count} 件商品将为您保留。",
|
||||||
|
checkout_browse: "浏览产品",
|
||||||
|
checkout_same_address: "收货地址与账单地址相同",
|
||||||
|
checkout_notes_ph: "订单备注,例如特殊的配送要求",
|
||||||
|
checkout_subtotal: "小计",
|
||||||
|
acct_login: "登录",
|
||||||
|
acct_register: "注册",
|
||||||
|
acct_recent_orders: "最近订单",
|
||||||
|
acct_no_orders: "还没有订单。",
|
||||||
|
acct_th_order: "订单",
|
||||||
|
acct_th_date: "日期",
|
||||||
|
acct_th_items: "商品",
|
||||||
|
acct_th_total: "合计",
|
||||||
|
acct_th_status: "状态",
|
||||||
|
acct_th_payment: "支付方式",
|
||||||
|
|
||||||
|
nf_msg: "您访问的页面不存在或已被移动。",
|
||||||
|
nf_contact: "联系我们",
|
||||||
|
solution_title: "解决方案",
|
||||||
|
footer_intro: "DusunIoT 是领先的 IoT 硬件设备供应商与端到端 IoT 解决方案提供商。凭借对 IoT 的深入洞察,公司致力于推动下一代 IoT 技术。",
|
||||||
|
footer_rights: "保留所有权利",
|
||||||
|
footer_sitemap: "网站地图",
|
||||||
|
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/** # 模块 i18n 中文(繁體)詞典 */
|
||||||
|
import type { Dict } from "~/i18n/locales/en";
|
||||||
|
|
||||||
|
export const zhTW: Partial<Dict> = {
|
||||||
|
topbar_oem: "一站式 OEM/ODM 服務 >>",
|
||||||
|
login: "登入",
|
||||||
|
contact_us: "聯絡我們",
|
||||||
|
search_placeholder: "搜尋產品...",
|
||||||
|
search_placeholder_m: "搜尋...",
|
||||||
|
search_go: "搜尋",
|
||||||
|
nav_home: "首頁",
|
||||||
|
nav_products: "產品",
|
||||||
|
nav_blog: "部落格",
|
||||||
|
nav_shop: "商城",
|
||||||
|
nav_all_products: "全部產品",
|
||||||
|
select_language: "選擇語言",
|
||||||
|
|
||||||
|
pagination_prev: "« 上一頁",
|
||||||
|
pagination_next: "下一頁 »",
|
||||||
|
|
||||||
|
inquiry_title: "取得報價",
|
||||||
|
inquiry_name: "姓名 *",
|
||||||
|
inquiry_email: "電子郵件 *",
|
||||||
|
inquiry_phone: "電話",
|
||||||
|
inquiry_company: "公司",
|
||||||
|
inquiry_country: "國家/地區",
|
||||||
|
inquiry_interest: "感興趣的產品",
|
||||||
|
inquiry_message: "留言 *",
|
||||||
|
inquiry_message_ph: "請描述您的需求、數量與應用場景...",
|
||||||
|
inquiry_submit: "送出詢問",
|
||||||
|
inquiry_submitting: "送出中...",
|
||||||
|
inquiry_success: "感謝您的詢問!我們已成功收到您的提交,業務團隊將於 24 小時內與您聯絡。",
|
||||||
|
inquiry_error: "送出失敗,請重試。",
|
||||||
|
inquiry_network: "網路錯誤,請重試。",
|
||||||
|
|
||||||
|
product_category: "分類:",
|
||||||
|
product_categories: "分類:",
|
||||||
|
product_specification: "規格書",
|
||||||
|
product_get_quote: "取得報價",
|
||||||
|
product_quote_title: "取得報價:{name}",
|
||||||
|
product_additional_info: "附加資訊",
|
||||||
|
product_related: "相關產品",
|
||||||
|
product_no_related: "尚無相關產品。",
|
||||||
|
product_not_found: "抱歉,該產品不存在或已下架。",
|
||||||
|
product_browse_all: "瀏覽全部產品",
|
||||||
|
|
||||||
|
card_read_more: "瞭解更多",
|
||||||
|
|
||||||
|
collection_categories: "分類",
|
||||||
|
collection_no_options: "尚無選項",
|
||||||
|
collection_products_count: "共 {total} 個產品",
|
||||||
|
collection_no_results: "此分類下尚無產品",
|
||||||
|
collection_no_results_filtered: "所選篩選條件下尚無產品。",
|
||||||
|
shop_iot_products: "IoT 產品",
|
||||||
|
shop_showing: "顯示第 {from}–{to} 項,共 {total} 項",
|
||||||
|
shop_no_results: "無結果",
|
||||||
|
|
||||||
|
blog_badge: "部落格",
|
||||||
|
blog_read_more: "閱讀全文 »",
|
||||||
|
blog_no_posts: "尚無文章 — 內容匯入中,請稍後再來。",
|
||||||
|
blog_toc: "目錄",
|
||||||
|
blog_related_posts: "相關文章",
|
||||||
|
blog_not_found: "抱歉,該文章不存在。",
|
||||||
|
blog_back: "返回部落格",
|
||||||
|
blog_importing: "內容匯入中。",
|
||||||
|
blog_gateways: "IoT 閘道器",
|
||||||
|
blog_contact_title: "聯絡我們",
|
||||||
|
|
||||||
|
search_results_for: "「{q}」的搜尋結果",
|
||||||
|
search_title: "搜尋產品",
|
||||||
|
search_by_sku: "依產品名稱或 SKU 搜尋...",
|
||||||
|
search_button: "搜尋",
|
||||||
|
search_empty: "沒有符合的產品。",
|
||||||
|
|
||||||
|
cart_empty: "購物車是空的。",
|
||||||
|
cart_continue: "繼續購物",
|
||||||
|
cart_checkout: "前往結帳",
|
||||||
|
checkout_view_orders: "查看我的訂單",
|
||||||
|
checkout_cart_kept: "購物車中的 {count} 件商品將為您保留。",
|
||||||
|
checkout_browse: "瀏覽產品",
|
||||||
|
checkout_same_address: "收貨地址與帳單地址相同",
|
||||||
|
checkout_notes_ph: "訂單備註,例如特殊的配送需求",
|
||||||
|
checkout_subtotal: "小計",
|
||||||
|
acct_login: "登入",
|
||||||
|
acct_register: "註冊",
|
||||||
|
acct_recent_orders: "最近訂單",
|
||||||
|
acct_no_orders: "尚無訂單。",
|
||||||
|
acct_th_order: "訂單",
|
||||||
|
acct_th_date: "日期",
|
||||||
|
acct_th_items: "商品",
|
||||||
|
acct_th_total: "合計",
|
||||||
|
acct_th_status: "狀態",
|
||||||
|
acct_th_payment: "付款方式",
|
||||||
|
|
||||||
|
nf_msg: "您造訪的頁面不存在或已被移動。",
|
||||||
|
nf_contact: "聯絡我們",
|
||||||
|
solution_title: "解決方案",
|
||||||
|
footer_intro: "DusunIoT 是領先的 IoT 硬體設備供應商與端到端 IoT 解決方案提供商。憑藉對 IoT 的深入洞察,公司致力於推動下一代 IoT 技術。",
|
||||||
|
footer_rights: "保留所有權利",
|
||||||
|
footer_sitemap: "網站地圖",
|
||||||
|
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* # 模块 i18n 客户端安全共享层(不引入任何 server 模块)
|
||||||
|
* 语言列表 / cookie 读写;服务端读取走 i18n/index.ts 的 getLocale。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** # 类型 语言定义(code 为语言码,label 为切换器显示名) **/
|
||||||
|
export type LocaleDef = { code: string; label: string };
|
||||||
|
|
||||||
|
/** # 常量 UI 支持的语言(英文 + 原站 GTranslate 19 语种 + 简体中文) **/
|
||||||
|
export const LOCALES: LocaleDef[] = [
|
||||||
|
{ code: "en", label: "English" },
|
||||||
|
{ code: "zh-CN", label: "中文 (简体)" },
|
||||||
|
{ code: "zh-TW", label: "Chinese (Traditional)" },
|
||||||
|
{ code: "de", label: "German" },
|
||||||
|
{ code: "fr", label: "French" },
|
||||||
|
{ code: "ja", label: "Japanese" },
|
||||||
|
{ code: "ko", label: "Korean" },
|
||||||
|
{ code: "ru", label: "Russian" },
|
||||||
|
{ code: "es", label: "Spanish" },
|
||||||
|
{ code: "th", label: "Thai" },
|
||||||
|
{ code: "ar", label: "Arabic" },
|
||||||
|
{ code: "it", label: "Italian" },
|
||||||
|
{ code: "hi", label: "Hindi" },
|
||||||
|
{ code: "uk", label: "Ukrainian" },
|
||||||
|
{ code: "pt", label: "Portuguese" },
|
||||||
|
{ code: "tr", label: "Turkish" },
|
||||||
|
{ code: "nl", label: "Dutch" },
|
||||||
|
{ code: "tl", label: "Filipino" },
|
||||||
|
{ code: "id", label: "Indonesian" },
|
||||||
|
{ code: "no", label: "Norwegian" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** # 常量 语言 cookie 名 **/
|
||||||
|
export const LOCALE_COOKIE = "ui_lang";
|
||||||
|
|
||||||
|
/** # 函数 校验语言码是否受支持 */
|
||||||
|
export function isSupportedLocale(code: string | undefined | null): boolean {
|
||||||
|
return !!code && LOCALES.some((l) => l.code === code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 客户端读取当前语言(cookie;无/非法回退 en) */
|
||||||
|
export function getUiLocale(): string {
|
||||||
|
if (typeof document === "undefined") return "en";
|
||||||
|
const m = document.cookie.match(new RegExp(`(?:^|;\\s*)${LOCALE_COOKIE}=([^;]+)`));
|
||||||
|
return isSupportedLocale(m?.[1]) ? (m as RegExpMatchArray)[1] : "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 客户端切换语言:写 cookie(1 年)并整页刷新(SSR 按 cookie 渲染) */
|
||||||
|
export function setUiLocale(code: string): void {
|
||||||
|
document.cookie = `${LOCALE_COOKIE}=${code}; Max-Age=31536000; path=/; SameSite=Lax`;
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* # 函数 合并 className(clsx + tailwind-merge)
|
||||||
|
* shadcn/ui 组件约定的 cn 工具:后者覆盖前者的冲突 Tailwind 类。
|
||||||
|
*/
|
||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,719 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
|
||||||
|
// This file was automatically generated by TanStack Router.
|
||||||
|
// You should NOT make any changes in this file as it will be overwritten.
|
||||||
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as SplatRouteImport } from './routes/$'
|
||||||
|
import { Route as AdminRouteImport } from './routes/admin'
|
||||||
|
import { Route as CartRouteImport } from './routes/cart'
|
||||||
|
import { Route as CheckoutRouteImport } from './routes/checkout'
|
||||||
|
import { Route as ContactUsRouteImport } from './routes/contact-us'
|
||||||
|
import { Route as MyAccountRouteImport } from './routes/my-account'
|
||||||
|
import { Route as RobotsDottxtRouteImport } from './routes/robots[.]txt'
|
||||||
|
import { Route as SearchRouteImport } from './routes/search'
|
||||||
|
import { Route as ShopRouteImport } from './routes/shop'
|
||||||
|
import { Route as SitemapDotxmlRouteImport } from './routes/sitemap[.]xml'
|
||||||
|
import { Route as SolutionRouteImport } from './routes/solution'
|
||||||
|
import { Route as AdminIndexRouteImport } from './routes/admin.index'
|
||||||
|
import { Route as AdminFiltersRouteImport } from './routes/admin.filters'
|
||||||
|
import { Route as AdminInquiriesRouteImport } from './routes/admin.inquiries'
|
||||||
|
import { Route as AdminLanguagesRouteImport } from './routes/admin.languages'
|
||||||
|
import { Route as AdminMediaRouteImport } from './routes/admin.media'
|
||||||
|
import { Route as AdminSeoRouteImport } from './routes/admin.seo'
|
||||||
|
import { Route as AdminUsersRouteImport } from './routes/admin.users'
|
||||||
|
import { Route as BlogIndexRouteImport } from './routes/blog.index'
|
||||||
|
import { Route as BlogSlugRouteImport } from './routes/blog.$slug'
|
||||||
|
import { Route as CollectionIndexRouteImport } from './routes/collection.index'
|
||||||
|
import { Route as CollectionSlugRouteImport } from './routes/collection.$slug'
|
||||||
|
import { Route as ProductSlugRouteImport } from './routes/product.$slug'
|
||||||
|
import { Route as SolutionSlugRouteImport } from './routes/solution.$slug'
|
||||||
|
import { Route as AdminContentsIndexRouteImport } from './routes/admin.contents.index'
|
||||||
|
import { Route as AdminContentsIdRouteImport } from './routes/admin.contents.$id'
|
||||||
|
import { Route as AdminOrdersIndexRouteImport } from './routes/admin.orders.index'
|
||||||
|
import { Route as AdminOrdersIdRouteImport } from './routes/admin.orders.$id'
|
||||||
|
import { Route as AdminProductsIndexRouteImport } from './routes/admin.products.index'
|
||||||
|
import { Route as AdminProductsIdRouteImport } from './routes/admin.products.$id'
|
||||||
|
|
||||||
|
const IndexRoute = IndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const SplatRoute = SplatRouteImport.update({
|
||||||
|
id: '/$',
|
||||||
|
path: '/$',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const AdminRoute = AdminRouteImport.update({
|
||||||
|
id: '/admin',
|
||||||
|
path: '/admin',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const CartRoute = CartRouteImport.update({
|
||||||
|
id: '/cart',
|
||||||
|
path: '/cart',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const CheckoutRoute = CheckoutRouteImport.update({
|
||||||
|
id: '/checkout',
|
||||||
|
path: '/checkout',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ContactUsRoute = ContactUsRouteImport.update({
|
||||||
|
id: '/contact-us',
|
||||||
|
path: '/contact-us',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const MyAccountRoute = MyAccountRouteImport.update({
|
||||||
|
id: '/my-account',
|
||||||
|
path: '/my-account',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const RobotsDottxtRoute = RobotsDottxtRouteImport.update({
|
||||||
|
id: '/robots.txt',
|
||||||
|
path: '/robots.txt',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const SearchRoute = SearchRouteImport.update({
|
||||||
|
id: '/search',
|
||||||
|
path: '/search',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ShopRoute = ShopRouteImport.update({
|
||||||
|
id: '/shop',
|
||||||
|
path: '/shop',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const SitemapDotxmlRoute = SitemapDotxmlRouteImport.update({
|
||||||
|
id: '/sitemap.xml',
|
||||||
|
path: '/sitemap.xml',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const SolutionRoute = SolutionRouteImport.update({
|
||||||
|
id: '/solution',
|
||||||
|
path: '/solution',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const AdminIndexRoute = AdminIndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
|
const AdminFiltersRoute = AdminFiltersRouteImport.update({
|
||||||
|
id: '/filters',
|
||||||
|
path: '/filters',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
|
const AdminInquiriesRoute = AdminInquiriesRouteImport.update({
|
||||||
|
id: '/inquiries',
|
||||||
|
path: '/inquiries',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
|
const AdminLanguagesRoute = AdminLanguagesRouteImport.update({
|
||||||
|
id: '/languages',
|
||||||
|
path: '/languages',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
|
const AdminMediaRoute = AdminMediaRouteImport.update({
|
||||||
|
id: '/media',
|
||||||
|
path: '/media',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
|
const AdminSeoRoute = AdminSeoRouteImport.update({
|
||||||
|
id: '/seo',
|
||||||
|
path: '/seo',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
|
const AdminUsersRoute = AdminUsersRouteImport.update({
|
||||||
|
id: '/users',
|
||||||
|
path: '/users',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
|
const BlogIndexRoute = BlogIndexRouteImport.update({
|
||||||
|
id: '/blog/',
|
||||||
|
path: '/blog/',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const BlogSlugRoute = BlogSlugRouteImport.update({
|
||||||
|
id: '/blog/$slug',
|
||||||
|
path: '/blog/$slug',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const CollectionIndexRoute = CollectionIndexRouteImport.update({
|
||||||
|
id: '/collection/',
|
||||||
|
path: '/collection/',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const CollectionSlugRoute = CollectionSlugRouteImport.update({
|
||||||
|
id: '/collection/$slug',
|
||||||
|
path: '/collection/$slug',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ProductSlugRoute = ProductSlugRouteImport.update({
|
||||||
|
id: '/product/$slug',
|
||||||
|
path: '/product/$slug',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const SolutionSlugRoute = SolutionSlugRouteImport.update({
|
||||||
|
id: '/$slug',
|
||||||
|
path: '/$slug',
|
||||||
|
getParentRoute: () => SolutionRoute,
|
||||||
|
} as any)
|
||||||
|
const AdminContentsIndexRoute = AdminContentsIndexRouteImport.update({
|
||||||
|
id: '/contents/',
|
||||||
|
path: '/contents/',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
|
const AdminContentsIdRoute = AdminContentsIdRouteImport.update({
|
||||||
|
id: '/contents/$id',
|
||||||
|
path: '/contents/$id',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
|
const AdminOrdersIndexRoute = AdminOrdersIndexRouteImport.update({
|
||||||
|
id: '/orders/',
|
||||||
|
path: '/orders/',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
|
const AdminOrdersIdRoute = AdminOrdersIdRouteImport.update({
|
||||||
|
id: '/orders/$id',
|
||||||
|
path: '/orders/$id',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
|
const AdminProductsIndexRoute = AdminProductsIndexRouteImport.update({
|
||||||
|
id: '/products/',
|
||||||
|
path: '/products/',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
|
const AdminProductsIdRoute = AdminProductsIdRouteImport.update({
|
||||||
|
id: '/products/$id',
|
||||||
|
path: '/products/$id',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
export interface FileRoutesByFullPath {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/$': typeof SplatRoute
|
||||||
|
'/admin': typeof AdminRouteWithChildren
|
||||||
|
'/cart': typeof CartRoute
|
||||||
|
'/checkout': typeof CheckoutRoute
|
||||||
|
'/contact-us': typeof ContactUsRoute
|
||||||
|
'/my-account': typeof MyAccountRoute
|
||||||
|
'/robots.txt': typeof RobotsDottxtRoute
|
||||||
|
'/search': typeof SearchRoute
|
||||||
|
'/shop': typeof ShopRoute
|
||||||
|
'/sitemap.xml': typeof SitemapDotxmlRoute
|
||||||
|
'/solution': typeof SolutionRouteWithChildren
|
||||||
|
'/admin/filters': typeof AdminFiltersRoute
|
||||||
|
'/admin/inquiries': typeof AdminInquiriesRoute
|
||||||
|
'/admin/languages': typeof AdminLanguagesRoute
|
||||||
|
'/admin/media': typeof AdminMediaRoute
|
||||||
|
'/admin/seo': typeof AdminSeoRoute
|
||||||
|
'/admin/users': typeof AdminUsersRoute
|
||||||
|
'/blog/$slug': typeof BlogSlugRoute
|
||||||
|
'/collection/$slug': typeof CollectionSlugRoute
|
||||||
|
'/product/$slug': typeof ProductSlugRoute
|
||||||
|
'/solution/$slug': typeof SolutionSlugRoute
|
||||||
|
'/admin/': typeof AdminIndexRoute
|
||||||
|
'/blog/': typeof BlogIndexRoute
|
||||||
|
'/collection/': typeof CollectionIndexRoute
|
||||||
|
'/admin/contents/$id': typeof AdminContentsIdRoute
|
||||||
|
'/admin/orders/$id': typeof AdminOrdersIdRoute
|
||||||
|
'/admin/products/$id': typeof AdminProductsIdRoute
|
||||||
|
'/admin/contents/': typeof AdminContentsIndexRoute
|
||||||
|
'/admin/orders/': typeof AdminOrdersIndexRoute
|
||||||
|
'/admin/products/': typeof AdminProductsIndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesByTo {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/$': typeof SplatRoute
|
||||||
|
'/cart': typeof CartRoute
|
||||||
|
'/checkout': typeof CheckoutRoute
|
||||||
|
'/contact-us': typeof ContactUsRoute
|
||||||
|
'/my-account': typeof MyAccountRoute
|
||||||
|
'/robots.txt': typeof RobotsDottxtRoute
|
||||||
|
'/search': typeof SearchRoute
|
||||||
|
'/shop': typeof ShopRoute
|
||||||
|
'/sitemap.xml': typeof SitemapDotxmlRoute
|
||||||
|
'/solution': typeof SolutionRouteWithChildren
|
||||||
|
'/admin/filters': typeof AdminFiltersRoute
|
||||||
|
'/admin/inquiries': typeof AdminInquiriesRoute
|
||||||
|
'/admin/languages': typeof AdminLanguagesRoute
|
||||||
|
'/admin/media': typeof AdminMediaRoute
|
||||||
|
'/admin/seo': typeof AdminSeoRoute
|
||||||
|
'/admin/users': typeof AdminUsersRoute
|
||||||
|
'/blog/$slug': typeof BlogSlugRoute
|
||||||
|
'/collection/$slug': typeof CollectionSlugRoute
|
||||||
|
'/product/$slug': typeof ProductSlugRoute
|
||||||
|
'/solution/$slug': typeof SolutionSlugRoute
|
||||||
|
'/admin': typeof AdminIndexRoute
|
||||||
|
'/blog': typeof BlogIndexRoute
|
||||||
|
'/collection': typeof CollectionIndexRoute
|
||||||
|
'/admin/contents/$id': typeof AdminContentsIdRoute
|
||||||
|
'/admin/orders/$id': typeof AdminOrdersIdRoute
|
||||||
|
'/admin/products/$id': typeof AdminProductsIdRoute
|
||||||
|
'/admin/contents': typeof AdminContentsIndexRoute
|
||||||
|
'/admin/orders': typeof AdminOrdersIndexRoute
|
||||||
|
'/admin/products': typeof AdminProductsIndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesById {
|
||||||
|
__root__: typeof rootRouteImport
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/$': typeof SplatRoute
|
||||||
|
'/admin': typeof AdminRouteWithChildren
|
||||||
|
'/cart': typeof CartRoute
|
||||||
|
'/checkout': typeof CheckoutRoute
|
||||||
|
'/contact-us': typeof ContactUsRoute
|
||||||
|
'/my-account': typeof MyAccountRoute
|
||||||
|
'/robots.txt': typeof RobotsDottxtRoute
|
||||||
|
'/search': typeof SearchRoute
|
||||||
|
'/shop': typeof ShopRoute
|
||||||
|
'/sitemap.xml': typeof SitemapDotxmlRoute
|
||||||
|
'/solution': typeof SolutionRouteWithChildren
|
||||||
|
'/admin/filters': typeof AdminFiltersRoute
|
||||||
|
'/admin/inquiries': typeof AdminInquiriesRoute
|
||||||
|
'/admin/languages': typeof AdminLanguagesRoute
|
||||||
|
'/admin/media': typeof AdminMediaRoute
|
||||||
|
'/admin/seo': typeof AdminSeoRoute
|
||||||
|
'/admin/users': typeof AdminUsersRoute
|
||||||
|
'/blog/$slug': typeof BlogSlugRoute
|
||||||
|
'/collection/$slug': typeof CollectionSlugRoute
|
||||||
|
'/product/$slug': typeof ProductSlugRoute
|
||||||
|
'/solution/$slug': typeof SolutionSlugRoute
|
||||||
|
'/admin/': typeof AdminIndexRoute
|
||||||
|
'/blog/': typeof BlogIndexRoute
|
||||||
|
'/collection/': typeof CollectionIndexRoute
|
||||||
|
'/admin/contents/$id': typeof AdminContentsIdRoute
|
||||||
|
'/admin/orders/$id': typeof AdminOrdersIdRoute
|
||||||
|
'/admin/products/$id': typeof AdminProductsIdRoute
|
||||||
|
'/admin/contents/': typeof AdminContentsIndexRoute
|
||||||
|
'/admin/orders/': typeof AdminOrdersIndexRoute
|
||||||
|
'/admin/products/': typeof AdminProductsIndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRouteTypes {
|
||||||
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/$'
|
||||||
|
| '/admin'
|
||||||
|
| '/cart'
|
||||||
|
| '/checkout'
|
||||||
|
| '/contact-us'
|
||||||
|
| '/my-account'
|
||||||
|
| '/robots.txt'
|
||||||
|
| '/search'
|
||||||
|
| '/shop'
|
||||||
|
| '/sitemap.xml'
|
||||||
|
| '/solution'
|
||||||
|
| '/admin/filters'
|
||||||
|
| '/admin/inquiries'
|
||||||
|
| '/admin/languages'
|
||||||
|
| '/admin/media'
|
||||||
|
| '/admin/seo'
|
||||||
|
| '/admin/users'
|
||||||
|
| '/blog/$slug'
|
||||||
|
| '/collection/$slug'
|
||||||
|
| '/product/$slug'
|
||||||
|
| '/solution/$slug'
|
||||||
|
| '/admin/'
|
||||||
|
| '/blog/'
|
||||||
|
| '/collection/'
|
||||||
|
| '/admin/contents/$id'
|
||||||
|
| '/admin/orders/$id'
|
||||||
|
| '/admin/products/$id'
|
||||||
|
| '/admin/contents/'
|
||||||
|
| '/admin/orders/'
|
||||||
|
| '/admin/products/'
|
||||||
|
fileRoutesByTo: FileRoutesByTo
|
||||||
|
to:
|
||||||
|
| '/'
|
||||||
|
| '/$'
|
||||||
|
| '/cart'
|
||||||
|
| '/checkout'
|
||||||
|
| '/contact-us'
|
||||||
|
| '/my-account'
|
||||||
|
| '/robots.txt'
|
||||||
|
| '/search'
|
||||||
|
| '/shop'
|
||||||
|
| '/sitemap.xml'
|
||||||
|
| '/solution'
|
||||||
|
| '/admin/filters'
|
||||||
|
| '/admin/inquiries'
|
||||||
|
| '/admin/languages'
|
||||||
|
| '/admin/media'
|
||||||
|
| '/admin/seo'
|
||||||
|
| '/admin/users'
|
||||||
|
| '/blog/$slug'
|
||||||
|
| '/collection/$slug'
|
||||||
|
| '/product/$slug'
|
||||||
|
| '/solution/$slug'
|
||||||
|
| '/admin'
|
||||||
|
| '/blog'
|
||||||
|
| '/collection'
|
||||||
|
| '/admin/contents/$id'
|
||||||
|
| '/admin/orders/$id'
|
||||||
|
| '/admin/products/$id'
|
||||||
|
| '/admin/contents'
|
||||||
|
| '/admin/orders'
|
||||||
|
| '/admin/products'
|
||||||
|
id:
|
||||||
|
| '__root__'
|
||||||
|
| '/'
|
||||||
|
| '/$'
|
||||||
|
| '/admin'
|
||||||
|
| '/cart'
|
||||||
|
| '/checkout'
|
||||||
|
| '/contact-us'
|
||||||
|
| '/my-account'
|
||||||
|
| '/robots.txt'
|
||||||
|
| '/search'
|
||||||
|
| '/shop'
|
||||||
|
| '/sitemap.xml'
|
||||||
|
| '/solution'
|
||||||
|
| '/admin/filters'
|
||||||
|
| '/admin/inquiries'
|
||||||
|
| '/admin/languages'
|
||||||
|
| '/admin/media'
|
||||||
|
| '/admin/seo'
|
||||||
|
| '/admin/users'
|
||||||
|
| '/blog/$slug'
|
||||||
|
| '/collection/$slug'
|
||||||
|
| '/product/$slug'
|
||||||
|
| '/solution/$slug'
|
||||||
|
| '/admin/'
|
||||||
|
| '/blog/'
|
||||||
|
| '/collection/'
|
||||||
|
| '/admin/contents/$id'
|
||||||
|
| '/admin/orders/$id'
|
||||||
|
| '/admin/products/$id'
|
||||||
|
| '/admin/contents/'
|
||||||
|
| '/admin/orders/'
|
||||||
|
| '/admin/products/'
|
||||||
|
fileRoutesById: FileRoutesById
|
||||||
|
}
|
||||||
|
export interface RootRouteChildren {
|
||||||
|
IndexRoute: typeof IndexRoute
|
||||||
|
SplatRoute: typeof SplatRoute
|
||||||
|
AdminRoute: typeof AdminRouteWithChildren
|
||||||
|
CartRoute: typeof CartRoute
|
||||||
|
CheckoutRoute: typeof CheckoutRoute
|
||||||
|
ContactUsRoute: typeof ContactUsRoute
|
||||||
|
MyAccountRoute: typeof MyAccountRoute
|
||||||
|
RobotsDottxtRoute: typeof RobotsDottxtRoute
|
||||||
|
SearchRoute: typeof SearchRoute
|
||||||
|
ShopRoute: typeof ShopRoute
|
||||||
|
SitemapDotxmlRoute: typeof SitemapDotxmlRoute
|
||||||
|
SolutionRoute: typeof SolutionRouteWithChildren
|
||||||
|
BlogSlugRoute: typeof BlogSlugRoute
|
||||||
|
CollectionSlugRoute: typeof CollectionSlugRoute
|
||||||
|
ProductSlugRoute: typeof ProductSlugRoute
|
||||||
|
BlogIndexRoute: typeof BlogIndexRoute
|
||||||
|
CollectionIndexRoute: typeof CollectionIndexRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface FileRoutesByPath {
|
||||||
|
'/': {
|
||||||
|
id: '/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/'
|
||||||
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/$': {
|
||||||
|
id: '/$'
|
||||||
|
path: '/$'
|
||||||
|
fullPath: '/$'
|
||||||
|
preLoaderRoute: typeof SplatRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/admin': {
|
||||||
|
id: '/admin'
|
||||||
|
path: '/admin'
|
||||||
|
fullPath: '/admin'
|
||||||
|
preLoaderRoute: typeof AdminRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/cart': {
|
||||||
|
id: '/cart'
|
||||||
|
path: '/cart'
|
||||||
|
fullPath: '/cart'
|
||||||
|
preLoaderRoute: typeof CartRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/checkout': {
|
||||||
|
id: '/checkout'
|
||||||
|
path: '/checkout'
|
||||||
|
fullPath: '/checkout'
|
||||||
|
preLoaderRoute: typeof CheckoutRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/contact-us': {
|
||||||
|
id: '/contact-us'
|
||||||
|
path: '/contact-us'
|
||||||
|
fullPath: '/contact-us'
|
||||||
|
preLoaderRoute: typeof ContactUsRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/my-account': {
|
||||||
|
id: '/my-account'
|
||||||
|
path: '/my-account'
|
||||||
|
fullPath: '/my-account'
|
||||||
|
preLoaderRoute: typeof MyAccountRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/robots.txt': {
|
||||||
|
id: '/robots.txt'
|
||||||
|
path: '/robots.txt'
|
||||||
|
fullPath: '/robots.txt'
|
||||||
|
preLoaderRoute: typeof RobotsDottxtRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/search': {
|
||||||
|
id: '/search'
|
||||||
|
path: '/search'
|
||||||
|
fullPath: '/search'
|
||||||
|
preLoaderRoute: typeof SearchRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/shop': {
|
||||||
|
id: '/shop'
|
||||||
|
path: '/shop'
|
||||||
|
fullPath: '/shop'
|
||||||
|
preLoaderRoute: typeof ShopRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/sitemap.xml': {
|
||||||
|
id: '/sitemap.xml'
|
||||||
|
path: '/sitemap.xml'
|
||||||
|
fullPath: '/sitemap.xml'
|
||||||
|
preLoaderRoute: typeof SitemapDotxmlRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/solution': {
|
||||||
|
id: '/solution'
|
||||||
|
path: '/solution'
|
||||||
|
fullPath: '/solution'
|
||||||
|
preLoaderRoute: typeof SolutionRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/admin/': {
|
||||||
|
id: '/admin/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/admin/'
|
||||||
|
preLoaderRoute: typeof AdminIndexRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
|
'/admin/filters': {
|
||||||
|
id: '/admin/filters'
|
||||||
|
path: '/filters'
|
||||||
|
fullPath: '/admin/filters'
|
||||||
|
preLoaderRoute: typeof AdminFiltersRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
|
'/admin/inquiries': {
|
||||||
|
id: '/admin/inquiries'
|
||||||
|
path: '/inquiries'
|
||||||
|
fullPath: '/admin/inquiries'
|
||||||
|
preLoaderRoute: typeof AdminInquiriesRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
|
'/admin/languages': {
|
||||||
|
id: '/admin/languages'
|
||||||
|
path: '/languages'
|
||||||
|
fullPath: '/admin/languages'
|
||||||
|
preLoaderRoute: typeof AdminLanguagesRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
|
'/admin/media': {
|
||||||
|
id: '/admin/media'
|
||||||
|
path: '/media'
|
||||||
|
fullPath: '/admin/media'
|
||||||
|
preLoaderRoute: typeof AdminMediaRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
|
'/admin/seo': {
|
||||||
|
id: '/admin/seo'
|
||||||
|
path: '/seo'
|
||||||
|
fullPath: '/admin/seo'
|
||||||
|
preLoaderRoute: typeof AdminSeoRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
|
'/admin/users': {
|
||||||
|
id: '/admin/users'
|
||||||
|
path: '/users'
|
||||||
|
fullPath: '/admin/users'
|
||||||
|
preLoaderRoute: typeof AdminUsersRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
|
'/blog/': {
|
||||||
|
id: '/blog/'
|
||||||
|
path: '/blog'
|
||||||
|
fullPath: '/blog/'
|
||||||
|
preLoaderRoute: typeof BlogIndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/blog/$slug': {
|
||||||
|
id: '/blog/$slug'
|
||||||
|
path: '/blog/$slug'
|
||||||
|
fullPath: '/blog/$slug'
|
||||||
|
preLoaderRoute: typeof BlogSlugRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/collection/': {
|
||||||
|
id: '/collection/'
|
||||||
|
path: '/collection'
|
||||||
|
fullPath: '/collection/'
|
||||||
|
preLoaderRoute: typeof CollectionIndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/collection/$slug': {
|
||||||
|
id: '/collection/$slug'
|
||||||
|
path: '/collection/$slug'
|
||||||
|
fullPath: '/collection/$slug'
|
||||||
|
preLoaderRoute: typeof CollectionSlugRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/product/$slug': {
|
||||||
|
id: '/product/$slug'
|
||||||
|
path: '/product/$slug'
|
||||||
|
fullPath: '/product/$slug'
|
||||||
|
preLoaderRoute: typeof ProductSlugRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/solution/$slug': {
|
||||||
|
id: '/solution/$slug'
|
||||||
|
path: '/$slug'
|
||||||
|
fullPath: '/solution/$slug'
|
||||||
|
preLoaderRoute: typeof SolutionSlugRouteImport
|
||||||
|
parentRoute: typeof SolutionRoute
|
||||||
|
}
|
||||||
|
'/admin/contents/': {
|
||||||
|
id: '/admin/contents/'
|
||||||
|
path: '/contents'
|
||||||
|
fullPath: '/admin/contents/'
|
||||||
|
preLoaderRoute: typeof AdminContentsIndexRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
|
'/admin/contents/$id': {
|
||||||
|
id: '/admin/contents/$id'
|
||||||
|
path: '/contents/$id'
|
||||||
|
fullPath: '/admin/contents/$id'
|
||||||
|
preLoaderRoute: typeof AdminContentsIdRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
|
'/admin/orders/': {
|
||||||
|
id: '/admin/orders/'
|
||||||
|
path: '/orders'
|
||||||
|
fullPath: '/admin/orders/'
|
||||||
|
preLoaderRoute: typeof AdminOrdersIndexRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
|
'/admin/orders/$id': {
|
||||||
|
id: '/admin/orders/$id'
|
||||||
|
path: '/orders/$id'
|
||||||
|
fullPath: '/admin/orders/$id'
|
||||||
|
preLoaderRoute: typeof AdminOrdersIdRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
|
'/admin/products/': {
|
||||||
|
id: '/admin/products/'
|
||||||
|
path: '/products'
|
||||||
|
fullPath: '/admin/products/'
|
||||||
|
preLoaderRoute: typeof AdminProductsIndexRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
|
'/admin/products/$id': {
|
||||||
|
id: '/admin/products/$id'
|
||||||
|
path: '/products/$id'
|
||||||
|
fullPath: '/admin/products/$id'
|
||||||
|
preLoaderRoute: typeof AdminProductsIdRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminRouteChildren {
|
||||||
|
AdminFiltersRoute: typeof AdminFiltersRoute
|
||||||
|
AdminInquiriesRoute: typeof AdminInquiriesRoute
|
||||||
|
AdminLanguagesRoute: typeof AdminLanguagesRoute
|
||||||
|
AdminMediaRoute: typeof AdminMediaRoute
|
||||||
|
AdminSeoRoute: typeof AdminSeoRoute
|
||||||
|
AdminUsersRoute: typeof AdminUsersRoute
|
||||||
|
AdminIndexRoute: typeof AdminIndexRoute
|
||||||
|
AdminContentsIdRoute: typeof AdminContentsIdRoute
|
||||||
|
AdminOrdersIdRoute: typeof AdminOrdersIdRoute
|
||||||
|
AdminProductsIdRoute: typeof AdminProductsIdRoute
|
||||||
|
AdminContentsIndexRoute: typeof AdminContentsIndexRoute
|
||||||
|
AdminOrdersIndexRoute: typeof AdminOrdersIndexRoute
|
||||||
|
AdminProductsIndexRoute: typeof AdminProductsIndexRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdminRouteChildren: AdminRouteChildren = {
|
||||||
|
AdminFiltersRoute: AdminFiltersRoute,
|
||||||
|
AdminInquiriesRoute: AdminInquiriesRoute,
|
||||||
|
AdminLanguagesRoute: AdminLanguagesRoute,
|
||||||
|
AdminMediaRoute: AdminMediaRoute,
|
||||||
|
AdminSeoRoute: AdminSeoRoute,
|
||||||
|
AdminUsersRoute: AdminUsersRoute,
|
||||||
|
AdminIndexRoute: AdminIndexRoute,
|
||||||
|
AdminContentsIdRoute: AdminContentsIdRoute,
|
||||||
|
AdminOrdersIdRoute: AdminOrdersIdRoute,
|
||||||
|
AdminProductsIdRoute: AdminProductsIdRoute,
|
||||||
|
AdminContentsIndexRoute: AdminContentsIndexRoute,
|
||||||
|
AdminOrdersIndexRoute: AdminOrdersIndexRoute,
|
||||||
|
AdminProductsIndexRoute: AdminProductsIndexRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdminRouteWithChildren = AdminRoute._addFileChildren(AdminRouteChildren)
|
||||||
|
|
||||||
|
interface SolutionRouteChildren {
|
||||||
|
SolutionSlugRoute: typeof SolutionSlugRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const SolutionRouteChildren: SolutionRouteChildren = {
|
||||||
|
SolutionSlugRoute: SolutionSlugRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SolutionRouteWithChildren = SolutionRoute._addFileChildren(
|
||||||
|
SolutionRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
|
IndexRoute: IndexRoute,
|
||||||
|
SplatRoute: SplatRoute,
|
||||||
|
AdminRoute: AdminRouteWithChildren,
|
||||||
|
CartRoute: CartRoute,
|
||||||
|
CheckoutRoute: CheckoutRoute,
|
||||||
|
ContactUsRoute: ContactUsRoute,
|
||||||
|
MyAccountRoute: MyAccountRoute,
|
||||||
|
RobotsDottxtRoute: RobotsDottxtRoute,
|
||||||
|
SearchRoute: SearchRoute,
|
||||||
|
ShopRoute: ShopRoute,
|
||||||
|
SitemapDotxmlRoute: SitemapDotxmlRoute,
|
||||||
|
SolutionRoute: SolutionRouteWithChildren,
|
||||||
|
BlogSlugRoute: BlogSlugRoute,
|
||||||
|
CollectionSlugRoute: CollectionSlugRoute,
|
||||||
|
ProductSlugRoute: ProductSlugRoute,
|
||||||
|
BlogIndexRoute: BlogIndexRoute,
|
||||||
|
CollectionIndexRoute: CollectionIndexRoute,
|
||||||
|
}
|
||||||
|
export const routeTree = rootRouteImport
|
||||||
|
._addFileChildren(rootRouteChildren)
|
||||||
|
._addFileTypes<FileRouteTypes>()
|
||||||
|
|
||||||
|
import type { getRouter } from './router.tsx'
|
||||||
|
import type { startInstance } from './start.ts'
|
||||||
|
declare module '@tanstack/react-start' {
|
||||||
|
interface Register {
|
||||||
|
ssr: true
|
||||||
|
router: Awaited<ReturnType<typeof getRouter>>
|
||||||
|
config: Awaited<ReturnType<typeof startInstance.getOptions>>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { createRouter } from "@tanstack/react-router";
|
||||||
|
import { routeTree } from "./routeTree.gen";
|
||||||
|
|
||||||
|
export function getRouter() {
|
||||||
|
// trailingSlash: "always" 保持 WordPress 风格的尾斜杠 URL(/product/xxx/)
|
||||||
|
const router = createRouter({ routeTree, defaultPreload: "intent", trailingSlash: "always" });
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@tanstack/react-router" {
|
||||||
|
interface Register {
|
||||||
|
router: ReturnType<typeof getRouter>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
/**
|
||||||
|
* # 路由 兜底 /$.tsx(catch-all)
|
||||||
|
* 处理 WordPress 风格的页面/文章路径:
|
||||||
|
* 1. 语言前缀(如 /zh-CN/xxx/):剥离语言段,渲染主语言内容占位(title 加语言标注)。
|
||||||
|
* 2. 单段路径:按 page 取内容。
|
||||||
|
* 3. 两段路径:先按 post(/%category%/%postname%/)取,再按 page。
|
||||||
|
* 4. 都没有 → 404 NotFound(导航 + 热门产品链接)。
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { createFileRoute, Link, notFound, redirect } from "@tanstack/react-router";
|
||||||
|
import { getLayoutData, Layout, type LayoutData } from "~/components/website/Layout";
|
||||||
|
import { Pagination } from "~/components/website/Pagination";
|
||||||
|
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
|
||||||
|
import { InquiryForm } from "~/components/website/InquiryForm";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from "~/components/ui/breadcrumb";
|
||||||
|
import { ProductCard } from "~/components/website/ProductCard";
|
||||||
|
import { isLangCode, LANG_NAMES, pickContent, type CatchAllData } from "~/components/website/catchall-shared";
|
||||||
|
import {
|
||||||
|
getPublishedContent,
|
||||||
|
getPostCategoryBySlug,
|
||||||
|
getProductChildCategory,
|
||||||
|
hasProductSlug,
|
||||||
|
listPosts,
|
||||||
|
listProducts,
|
||||||
|
resolveRedirect,
|
||||||
|
type ProductListResult,
|
||||||
|
} from "~/server/data";
|
||||||
|
import { useT } from "~/i18n";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/$")({
|
||||||
|
// 归档分页用 ?page=N
|
||||||
|
validateSearch: (search: Record<string, unknown>): { page?: number } => {
|
||||||
|
const page = Number(search.page);
|
||||||
|
return { page: Number.isFinite(page) && page > 0 ? Math.floor(page) : undefined };
|
||||||
|
},
|
||||||
|
loaderDeps: ({ search }) => ({ page: search.page }),
|
||||||
|
loader: async ({ location, deps }) => {
|
||||||
|
// 解析路径段:去首尾斜杠、过滤空段
|
||||||
|
const segments = location.pathname.split("/").filter(Boolean).map(decodeURIComponent);
|
||||||
|
let lang = "";
|
||||||
|
if (segments.length >= 1 && isLangCode(segments[0])) {
|
||||||
|
lang = segments[0];
|
||||||
|
segments.shift();
|
||||||
|
}
|
||||||
|
const layout = await getLayoutData();
|
||||||
|
const hotProducts: ProductListResult = await listProducts({ sort: "views", pageSize: 4 }).catch(
|
||||||
|
() => ({ items: [], total: 0, page: 1, pageSize: 4 }) as ProductListResult,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 语言占位阶段:内容库暂无翻译,取主语言内容渲染(title 加语言标注)
|
||||||
|
let content: CatchAllData["content"] = null;
|
||||||
|
if (segments.length === 1) {
|
||||||
|
// WP 站点文章(post)与页面(page)都可能是单段路径(见原站 sitemap),两类都试
|
||||||
|
const page = await getPublishedContent(segments[0], "page").catch(() => null);
|
||||||
|
if (page) content = pickContent(page);
|
||||||
|
else {
|
||||||
|
const post = await getPublishedContent(segments[0], "post").catch(() => null);
|
||||||
|
if (post) content = pickContent(post);
|
||||||
|
}
|
||||||
|
} else if (segments.length >= 2) {
|
||||||
|
// WP 路径结构 /%category%/.../%postname%/:末段即内容 slug(如 /blog/gateway/<slug>/)
|
||||||
|
const slug = segments[segments.length - 1];
|
||||||
|
// 先匹配文章再匹配页面
|
||||||
|
const post = await getPublishedContent(slug, "post").catch(() => null);
|
||||||
|
if (post) content = pickContent(post);
|
||||||
|
else {
|
||||||
|
const page = await getPublishedContent(slug, "page").catch(() => null);
|
||||||
|
if (page) content = pickContent(page);
|
||||||
|
}
|
||||||
|
} else if (segments.length === 0) {
|
||||||
|
// 语言前缀首页
|
||||||
|
const home = await getPublishedContent("home", "page").catch(() => null);
|
||||||
|
if (home) content = pickContent(home);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未命中内容时:先按 WP 结构化旧路径改判,再查 redirects 表,最后分类归档
|
||||||
|
if (!content && segments.length >= 1) {
|
||||||
|
const last = segments[segments.length - 1]!;
|
||||||
|
// 1) /product/product/<slug>/:导入落地页里的重复前缀 → /product/<slug>/(仅当产品存在)
|
||||||
|
if (segments.length >= 3 && segments[0] === "product" && segments[1] === "product" && (await hasProductSlug(last).catch(() => false))) {
|
||||||
|
const to = `/product/${last}/`;
|
||||||
|
throw redirect({ to, href: to, statusCode: 301 as const });
|
||||||
|
}
|
||||||
|
// 2) /product-specification/...:原站产品规格页。本地等价物是产品页;单段落产品列表
|
||||||
|
if (segments[0] === "product-specification") {
|
||||||
|
if (segments.length === 1) {
|
||||||
|
throw redirect({ to: "/collection/", href: "/collection/", statusCode: 301 as const });
|
||||||
|
}
|
||||||
|
// 已导入的旧链规则优先(可能链向其它规格页 slug),无规则再按 slug 找产品,最后落产品列表
|
||||||
|
const rule = await resolveRedirect("/" + segments.join("/"))
|
||||||
|
.then((r) => r?.target)
|
||||||
|
.catch(() => null);
|
||||||
|
if (rule) throw redirect({ to: rule, href: rule, statusCode: 301 as const });
|
||||||
|
const to = (await hasProductSlug(last).catch(() => false)) ? `/product/${last}/` : "/collection/";
|
||||||
|
throw redirect({ to, href: to, statusCode: 301 as const });
|
||||||
|
}
|
||||||
|
// 3) /collection/<parent>/<child>/:嵌套产品分类路径 → 子分类页(无该子分类则父分类兜底)
|
||||||
|
if (segments[0] === "collection" && segments.length === 3) {
|
||||||
|
const child = await getProductChildCategory(segments[1]!, segments[2]!).catch(() => null);
|
||||||
|
const to = `/collection/${child ? child.slug : segments[1]!}/`;
|
||||||
|
throw redirect({ to, href: to, statusCode: 301 as const });
|
||||||
|
}
|
||||||
|
// 4) 数据库 redirects 表规则(链式解析并跳过自指规则防死循环)
|
||||||
|
const target = await resolveRedirect("/" + segments.join("/"))
|
||||||
|
.then((r) => r?.target)
|
||||||
|
.catch(() => null);
|
||||||
|
if (target) throw redirect({ to: target, href: target, statusCode: 301 as const });
|
||||||
|
// 5) WP /%category%/ 结构:单段/双段路径末段命中文章分类 → 分类归档页
|
||||||
|
if (segments.length <= 2) {
|
||||||
|
const cat = await getPostCategoryBySlug(last).catch(() => null);
|
||||||
|
if (cat) {
|
||||||
|
const posts = await listPosts(deps.page || 1, 12, cat.slug).catch(() => ({ items: [], total: 0, page: deps.page || 1, pageSize: 12 }));
|
||||||
|
return { layout, content: null, lang, hotProducts, archive: { name: cat.name, slug: cat.slug, posts } } satisfies CatchAllData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 未命中任何内容 → 404(SSR 返回 404 状态码,由 notFoundComponent 渲染)
|
||||||
|
throw notFound();
|
||||||
|
}
|
||||||
|
// 语言前缀首页等内容为空时的兜底
|
||||||
|
if (!content) throw notFound();
|
||||||
|
|
||||||
|
return { layout, content, lang, hotProducts, archive: null } satisfies CatchAllData;
|
||||||
|
},
|
||||||
|
head: ({ loaderData }) => {
|
||||||
|
const langLabel = loaderData?.lang ? ` (${LANG_NAMES[loaderData.lang] || loaderData.lang})` : "";
|
||||||
|
if (loaderData?.archive) {
|
||||||
|
return {
|
||||||
|
meta: [
|
||||||
|
{ title: `${loaderData.archive.name} | Dusun IoT` },
|
||||||
|
{ name: "description", content: `Browse ${loaderData.archive.name} articles from Dusun IoT.` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
meta: [
|
||||||
|
{ title: loaderData?.content ? (loaderData.content.seoTitle || loaderData.content.title) + langLabel : "Page Not Found | Dusun IoT" },
|
||||||
|
{ name: "description", content: loaderData?.content?.seoDescription || loaderData?.content?.excerpt || "Dusun IoT - IoT gateway manufacturer and solution provider." },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
component: CatchAllPage,
|
||||||
|
notFoundComponent: NotFoundShell,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 组件 分类归档页(WP /%category%/ 结构):面包屑 + 文章卡片分页列表,样式与 /blog/ 一致 */
|
||||||
|
function ArchivePage() {
|
||||||
|
const t = useT();
|
||||||
|
const data = Route.useLoaderData();
|
||||||
|
const a = data.archive!;
|
||||||
|
const page = a.posts.page;
|
||||||
|
return (
|
||||||
|
<Layout layout={data.layout}>
|
||||||
|
<div className="bg-[#f5f5f6] px-4 pt-[34px] pb-[30px] text-center">
|
||||||
|
<h1 className="m-0 text-[34px] tracking-wide">{a.name.toUpperCase()}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="site-container">
|
||||||
|
<Breadcrumb aria-label="Breadcrumb">
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem><BreadcrumbLink to="/">Home</BreadcrumbLink></BreadcrumbItem>
|
||||||
|
<BreadcrumbSeparator />
|
||||||
|
<BreadcrumbItem><BreadcrumbPage>{a.name}</BreadcrumbPage></BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
<div className="grid grid-cols-1 gap-5 py-6 min-[960px]:grid-cols-3">
|
||||||
|
{a.posts.items.map((post) => (
|
||||||
|
<Link key={post.id} to="/blog/$slug/" params={{ slug: post.slug }} className="block overflow-hidden rounded-lg border border-line bg-white text-brand transition-[box-shadow,transform] duration-200 hover:-translate-y-0.5 hover:shadow-[0_8px_24px_rgba(16,42,76,0.1)]">
|
||||||
|
{/* 与 /blog/ 卡片同构:无特色图时灰色渐变占位 + 右上角分类徽章 */}
|
||||||
|
<div className="relative aspect-[4/3] overflow-hidden">
|
||||||
|
{post.imageUrl ? (
|
||||||
|
<img src={post.imageUrl} alt={post.title} loading="lazy" className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full bg-gradient-to-b from-[#9aa3ad] to-[#c9ced4]" />
|
||||||
|
)}
|
||||||
|
<span className="absolute top-3 right-3 rounded-full bg-brand px-3 py-1 text-xs font-semibold text-white">
|
||||||
|
{post.categoryName || a.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 pt-3.5 pb-[18px]">
|
||||||
|
<div className="mb-1.5 text-[15px] leading-[1.45] font-semibold">{post.title}</div>
|
||||||
|
{post.excerpt ? <p className="my-1.5 text-sm text-muted-foreground">{post.excerpt.slice(0, 140)}…</p> : null}
|
||||||
|
<div className="text-[13px] text-muted-foreground">
|
||||||
|
{post.authorName ? `${post.authorName} · ` : ""}
|
||||||
|
{post.publishedAt ? new Date(post.publishedAt).toISOString().slice(0, 10) : ""}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2.5 text-[13px] font-bold">{t("blog_read_more")}</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{a.posts.items.length === 0 ? <p className="py-[60px] text-center text-muted-foreground">{t("blog_no_posts")}</p> : null}
|
||||||
|
</div>
|
||||||
|
<Pagination page={page} total={a.posts.total} pageSize={a.posts.pageSize} makeHref={(p) => `?page=${p}`} />
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 404 外壳:先渲染静态 404,再客户端补齐布局与热门产品(loader 已抛 notFound,无 loaderData) */
|
||||||
|
function NotFoundShell() {
|
||||||
|
const t = useT();
|
||||||
|
const [state, setState] = useState<{ layout: LayoutData | null; hot: ProductListResult | null }>({ layout: null, hot: null });
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
Promise.all([getLayoutData(), listProducts({ sort: "views", pageSize: 4 }).catch(() => null)])
|
||||||
|
.then(([layout, hot]) => {
|
||||||
|
if (alive) setState({ layout, hot });
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
if (state.layout && state.hot) {
|
||||||
|
return (
|
||||||
|
<Layout layout={state.layout}>
|
||||||
|
<NotFoundView hotProducts={state.hot} />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="site-container pt-[70px] pb-10 text-center">
|
||||||
|
<h1 className="m-0 text-[64px] text-brand">404</h1>
|
||||||
|
<p className="text-lg">{t("nf_msg")}</p>
|
||||||
|
<div className="mx-0 my-[18px] mb-9 flex flex-wrap justify-center gap-2.5">
|
||||||
|
<Button asChild><a href="/">Home</a></Button>
|
||||||
|
<Button asChild variant="outline"><a href="/collection/">{t("nav_all_products")}</a></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 兜底页面(内容模板) */
|
||||||
|
function CatchAllPage() {
|
||||||
|
const t = useT();
|
||||||
|
const data = Route.useLoaderData();
|
||||||
|
// 分类归档命中:走归档模板
|
||||||
|
if (data.archive) return <ArchivePage />;
|
||||||
|
const c = data.content;
|
||||||
|
const elementor = extractElementor(c.meta);
|
||||||
|
const langLabel = data.lang ? ` (${LANG_NAMES[data.lang] || data.lang})` : "";
|
||||||
|
// Elementor 页面(原站 full-canvas 模板):通栏直出,不加面包屑/h1
|
||||||
|
if (elementor) {
|
||||||
|
return (
|
||||||
|
<Layout layout={data.layout}>
|
||||||
|
{data.lang ? (
|
||||||
|
<div className="site-container">
|
||||||
|
<div className="mt-4 rounded-md border border-[#b5e3c4] bg-[#e7f6ec] px-4 py-3 text-[#15803d]">
|
||||||
|
You are viewing the {langLabel} placeholder version. Translated content is coming soon — the English content is shown below.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<ElementorRenderer doc={elementor} />
|
||||||
|
<section className="mx-auto mt-10 mb-14 max-w-[860px] px-4">
|
||||||
|
<InquiryForm formKey="contact-us-form" productInterest={c.type === "post" ? "" : c.title} title="Get in Touch" />
|
||||||
|
</section>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Layout layout={data.layout}>
|
||||||
|
<div className="site-container">
|
||||||
|
<Breadcrumb aria-label="Breadcrumb">
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem><BreadcrumbLink to="/">Home</BreadcrumbLink></BreadcrumbItem>
|
||||||
|
<BreadcrumbSeparator />
|
||||||
|
<BreadcrumbItem><BreadcrumbPage>{c.title}</BreadcrumbPage></BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
{data.lang ? (
|
||||||
|
<div className="mb-4 rounded-md border border-[#b5e3c4] bg-[#e7f6ec] px-4 py-3 text-[#15803d]">
|
||||||
|
You are viewing the {langLabel} placeholder version. Translated content is coming soon — the English content is shown below.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<article className="mx-auto max-w-[1080px]">
|
||||||
|
<h1 className="text-[28px]">
|
||||||
|
{c.title}
|
||||||
|
{langLabel}
|
||||||
|
</h1>
|
||||||
|
{elementor ? (
|
||||||
|
<ElementorRenderer doc={elementor} />
|
||||||
|
) : c.html ? (
|
||||||
|
<div className="content-html" dangerouslySetInnerHTML={{ __html: c.html }} />
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground">{c.excerpt || "This page is being imported. Content will appear here soon."}</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
<section className="my-8 mb-14">
|
||||||
|
<InquiryForm formKey="contact-us-form" productInterest={c.type === "post" ? "" : c.title} title="Get in Touch" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 404 NotFound:导航 + 热门产品链接 */
|
||||||
|
function NotFoundView(props: { hotProducts: ProductListResult }) {
|
||||||
|
const t = useT();
|
||||||
|
return (
|
||||||
|
<div className="site-container pt-[70px] pb-10 text-center">
|
||||||
|
<h1 className="m-0 text-[64px] text-brand">404</h1>
|
||||||
|
<p className="text-lg">{t("nf_msg")}</p>
|
||||||
|
<div className="mx-0 my-[18px] mb-9 flex flex-wrap justify-center gap-2.5">
|
||||||
|
<Button asChild><a href="/">Home</a></Button>
|
||||||
|
<Button asChild variant="outline"><a href="/collection/">{t("nav_all_products")}</a></Button>
|
||||||
|
<Button asChild variant="outline"><a href="/blog/">{t("nav_blog")}</a></Button>
|
||||||
|
<Button asChild variant="outline"><a href="/contact-us/">{t("nf_contact")}</a></Button>
|
||||||
|
</div>
|
||||||
|
{props.hotProducts.items.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<h2 className="text-[22px]">Popular Products</h2>
|
||||||
|
<div className="mt-5 grid grid-cols-2 gap-[18px] text-left min-[960px]:grid-cols-4">
|
||||||
|
{props.hotProducts.items.map((p) => (
|
||||||
|
<ProductCard key={p.id} p={p} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { createRootRoute, HeadContent, Outlet, Scripts } from "@tanstack/react-router";
|
||||||
|
import "~/i18n/locales";
|
||||||
|
import { getLocale, LocaleProvider } from "~/i18n";
|
||||||
|
|
||||||
|
/** # 函数 根路由 loader:从 cookie 读当前 UI 语言(SSR 按语言渲染,无水合不一致) */
|
||||||
|
async function loader() {
|
||||||
|
return { locale: await getLocale() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 根路由文档组件
|
||||||
|
* 应用全局 HTML 骨架,前台与后台共用;各路由通过 head() 注入 meta/link。
|
||||||
|
* LocaleProvider 将语言注入全站(后台不使用 t(),不受影响)。
|
||||||
|
*/
|
||||||
|
function RootDocument() {
|
||||||
|
const { locale } = Route.useLoaderData();
|
||||||
|
return (
|
||||||
|
<LocaleProvider locale={locale}>
|
||||||
|
<html lang={locale}>
|
||||||
|
<head>
|
||||||
|
<HeadContent />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<Outlet />
|
||||||
|
<Scripts />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
</LocaleProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 成员变量 根路由实例 **/
|
||||||
|
export const Route = createRootRoute({ component: RootDocument, loader });
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* # 路由 /admin/contents/$id 内容编辑页
|
||||||
|
* id 为 new 时新建;否则加载正本(默认语言)编辑。HTML 使用 TextArea 明文编辑。
|
||||||
|
*/
|
||||||
|
import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router";
|
||||||
|
import { App, Button, Card, Form, Input, Select, Space } from "antd";
|
||||||
|
import { ArrowLeftOutlined, SaveOutlined } from "@ant-design/icons";
|
||||||
|
import { adminGetContent, adminSaveContent } from "~/server/admin";
|
||||||
|
import { CONTENT_STATUS_OPTIONS, Forbidden, guardAdmin } from "~/components/admin/common";
|
||||||
|
|
||||||
|
/** # 类型 编辑表单数据形状(新建时给默认值) **/
|
||||||
|
type ContentForm = {
|
||||||
|
id: number;
|
||||||
|
type: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
excerpt: string;
|
||||||
|
html: string;
|
||||||
|
status: string;
|
||||||
|
template: string;
|
||||||
|
seoTitle: string;
|
||||||
|
seoDescription: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/admin/contents/$id")({
|
||||||
|
loader: ({ params }) =>
|
||||||
|
guardAdmin(async () => {
|
||||||
|
if (params.id === "new") {
|
||||||
|
// 权限探测 + 返回新建默认值
|
||||||
|
await adminGetContent({ data: { id: 0 } });
|
||||||
|
const fresh: ContentForm = {
|
||||||
|
id: 0,
|
||||||
|
type: "page",
|
||||||
|
slug: "",
|
||||||
|
title: "",
|
||||||
|
excerpt: "",
|
||||||
|
html: "",
|
||||||
|
status: "draft",
|
||||||
|
template: "default",
|
||||||
|
seoTitle: "",
|
||||||
|
seoDescription: "",
|
||||||
|
};
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
const c = await adminGetContent({ data: { id: Number(params.id) } });
|
||||||
|
if (!c) return null;
|
||||||
|
const form: ContentForm = {
|
||||||
|
id: c.id,
|
||||||
|
type: c.type,
|
||||||
|
slug: c.slug,
|
||||||
|
title: c.title,
|
||||||
|
excerpt: c.excerpt,
|
||||||
|
html: c.html,
|
||||||
|
status: c.status,
|
||||||
|
template: c.template,
|
||||||
|
seoTitle: c.seoTitle ?? "",
|
||||||
|
seoDescription: c.seoDescription ?? "",
|
||||||
|
};
|
||||||
|
return form;
|
||||||
|
}),
|
||||||
|
component: ContentEditPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 函数 内容编辑页面组件 */
|
||||||
|
function ContentEditPage() {
|
||||||
|
const data = Route.useLoaderData();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const router = useRouter();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
if (!data) return <Forbidden />;
|
||||||
|
// 收窄后的非空引用(闭包内使用)
|
||||||
|
const content = data;
|
||||||
|
|
||||||
|
/** # 函数 保存内容:提交到服务端 → 提示 → 返回列表 */
|
||||||
|
async function handleSave(values: Omit<ContentForm, "id">) {
|
||||||
|
try {
|
||||||
|
await adminSaveContent({ data: { ...values, id: content.id || undefined } });
|
||||||
|
message.success("保存成功");
|
||||||
|
await router.invalidate();
|
||||||
|
void navigate({ to: "/admin/contents/" });
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : "保存失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="small">
|
||||||
|
<Space style={{ marginBottom: 16 }}>
|
||||||
|
<Button icon={<ArrowLeftOutlined aria-hidden />} onClick={() => void navigate({ to: "/admin/contents/" })}>
|
||||||
|
返回列表
|
||||||
|
</Button>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 18 }}>{content.id ? `编辑内容 #${content.id}` : "新建内容"}</h2>
|
||||||
|
</Space>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={content}
|
||||||
|
style={{ maxWidth: 860 }}
|
||||||
|
onFinish={(v) => void handleSave(v)}
|
||||||
|
>
|
||||||
|
<Space size={16} style={{ display: "flex" }} align="start">
|
||||||
|
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]} style={{ minWidth: 420, flex: 1 }}>
|
||||||
|
<Input placeholder="内容标题" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="slug" label="别名(留空自动生成)">
|
||||||
|
<Input placeholder="url-slug" style={{ width: 260 }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
<Space size={16} style={{ display: "flex" }}>
|
||||||
|
<Form.Item name="type" label="类型">
|
||||||
|
<Select
|
||||||
|
style={{ width: 140 }}
|
||||||
|
options={[
|
||||||
|
{ value: "page", label: "页面 page" },
|
||||||
|
{ value: "post", label: "文章 post" },
|
||||||
|
{ value: "landing", label: "落地页 landing" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态">
|
||||||
|
<Select style={{ width: 140 }} options={CONTENT_STATUS_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="template" label="模板">
|
||||||
|
<Input style={{ width: 200 }} placeholder="default / home / product …" />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
<Form.Item name="excerpt" label="摘要">
|
||||||
|
<Input.TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="html" label="正文 HTML">
|
||||||
|
<Input.TextArea rows={18} style={{ fontFamily: "monospace", fontSize: 12 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="seoTitle" label="SEO 标题">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="seoDescription" label="SEO 描述">
|
||||||
|
<Input.TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" icon={<SaveOutlined aria-hidden />}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* # 路由 /admin/contents 内容列表
|
||||||
|
* 类型 Tab(page/post/landing)+ 状态过滤 + 关键字搜索 + 分页表格,支持删除与跳转编辑。
|
||||||
|
*/
|
||||||
|
import { createFileRoute, Link, useRouter } from "@tanstack/react-router";
|
||||||
|
import { App, Button, Card, Input, Popconfirm, Space, Table, Tabs, Tag } from "antd";
|
||||||
|
import { DeleteOutlined, EditOutlined, PlusOutlined, SearchOutlined } from "@ant-design/icons";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { adminDeleteContent, adminListContents } from "~/server/admin";
|
||||||
|
import {
|
||||||
|
AdminEmpty,
|
||||||
|
CONTENT_STATUS_COLOR,
|
||||||
|
CONTENT_TYPE_LABEL,
|
||||||
|
Forbidden,
|
||||||
|
PageHeader,
|
||||||
|
StatusTag,
|
||||||
|
fmtDateTime,
|
||||||
|
guardAdmin,
|
||||||
|
tablePagination,
|
||||||
|
type AdminSearch,
|
||||||
|
} from "~/components/admin/common";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/admin/contents/")({
|
||||||
|
validateSearch: (search: Record<string, unknown>): AdminSearch => {
|
||||||
|
// 只保留显式传入的参数,缺省时不回写 URL(避免 307 归一化重定向)
|
||||||
|
const out: AdminSearch = {};
|
||||||
|
const page = Math.floor(Number(search.page)) || 0;
|
||||||
|
if (page > 1) out.page = page;
|
||||||
|
if (typeof search.q === "string" && search.q) out.q = search.q;
|
||||||
|
if (typeof search.status === "string" && search.status) out.status = search.status;
|
||||||
|
if (typeof search.type === "string" && search.type) out.type = search.type;
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
loaderDeps: ({ search }) => ({ search }),
|
||||||
|
loader: ({ deps }) =>
|
||||||
|
guardAdmin(() =>
|
||||||
|
adminListContents({
|
||||||
|
data: {
|
||||||
|
page: deps.search.page,
|
||||||
|
type: deps.search.type || undefined,
|
||||||
|
status: deps.search.status || undefined,
|
||||||
|
q: deps.search.q || undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
component: ContentsPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 函数 内容列表页面组件 */
|
||||||
|
function ContentsPage() {
|
||||||
|
const data = Route.useLoaderData();
|
||||||
|
const search = Route.useSearch();
|
||||||
|
const navigate = Route.useNavigate();
|
||||||
|
const router = useRouter();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [q, setQ] = useState(search.q ?? "");
|
||||||
|
|
||||||
|
if (!data) return <Forbidden />;
|
||||||
|
|
||||||
|
/** # 函数 更新查询参数(重置到第 1 页) */
|
||||||
|
function updateSearch(patch: AdminSearch) {
|
||||||
|
void navigate({ search: (prev) => ({ ...prev, ...patch, page: 1 }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 删除内容并刷新 */
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
await adminDeleteContent({ data: { id } });
|
||||||
|
message.success("已删除");
|
||||||
|
await router.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="small">
|
||||||
|
<PageHeader
|
||||||
|
title="内容管理"
|
||||||
|
extra={
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
allowClear
|
||||||
|
placeholder="搜索标题 / 别名"
|
||||||
|
prefix={<SearchOutlined aria-hidden />}
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
onPressEnter={() => updateSearch({ q })}
|
||||||
|
style={{ width: 240 }}
|
||||||
|
/>
|
||||||
|
<Link to="/admin/contents/$id/" params={{ id: "new" }}>
|
||||||
|
<Button type="primary" icon={<PlusOutlined aria-hidden />}>
|
||||||
|
新建内容
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Tabs
|
||||||
|
activeKey={search.type ?? ""}
|
||||||
|
onChange={(key) => updateSearch({ type: key })}
|
||||||
|
items={[
|
||||||
|
{ key: "", label: "全部" },
|
||||||
|
{ key: "page", label: "页面" },
|
||||||
|
{ key: "post", label: "文章" },
|
||||||
|
{ key: "landing", label: "落地页" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
dataSource={data.items}
|
||||||
|
locale={{ emptyText: <AdminEmpty /> }}
|
||||||
|
pagination={tablePagination(data.total, data.page, data.pageSize, (p) => void navigate({ search: (prev) => ({ ...prev, page: p }) }))}
|
||||||
|
columns={[
|
||||||
|
{ title: "ID", dataIndex: "id", width: 70 },
|
||||||
|
{
|
||||||
|
title: "标题",
|
||||||
|
dataIndex: "title",
|
||||||
|
render: (v: string, r) => (
|
||||||
|
<Link to="/admin/contents/$id/" params={{ id: String(r.id) }}>
|
||||||
|
{v}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: "别名", dataIndex: "slug", width: 220, ellipsis: true },
|
||||||
|
{
|
||||||
|
title: "类型",
|
||||||
|
dataIndex: "type",
|
||||||
|
width: 90,
|
||||||
|
render: (v: string) => <Tag>{CONTENT_TYPE_LABEL[v] ?? v}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "状态",
|
||||||
|
dataIndex: "status",
|
||||||
|
width: 100,
|
||||||
|
render: (v: string) => <StatusTag value={v} colorMap={CONTENT_STATUS_COLOR} />,
|
||||||
|
},
|
||||||
|
{ title: "浏览", dataIndex: "views", width: 80 },
|
||||||
|
{ title: "更新时间", dataIndex: "updatedAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
key: "actions",
|
||||||
|
width: 110,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<Link to="/admin/contents/$id/" params={{ id: String(r.id) }}>
|
||||||
|
<Button size="small" type="text" icon={<EditOutlined aria-label="编辑" />} />
|
||||||
|
</Link>
|
||||||
|
<Popconfirm title="确认删除该内容?" onConfirm={() => void handleDelete(r.id)}>
|
||||||
|
<Button size="small" type="text" danger icon={<DeleteOutlined aria-label="删除" />} />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* # 路由 /admin/filters 筛选配置
|
||||||
|
* 前台产品筛选器配置:列表 + 新增/编辑弹窗(name/slug/type/attribute/position/enabled)+ 删除。
|
||||||
|
*/
|
||||||
|
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||||
|
import { App, Button, Card, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Table, Tag } from "antd";
|
||||||
|
import { DeleteOutlined, EditOutlined, PlusOutlined } from "@ant-design/icons";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { adminDeleteFilter, adminListFilters, adminSaveFilter } from "~/server/admin";
|
||||||
|
import { AdminEmpty, Forbidden, PageHeader, guardAdmin } from "~/components/admin/common";
|
||||||
|
import type { filters } from "~/db/schema";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/admin/filters")({
|
||||||
|
loader: () => guardAdmin(() => adminListFilters()),
|
||||||
|
component: FiltersPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 成员变量 筛选类型中文标签 **/
|
||||||
|
const FILTER_TYPE_LABEL: Record<string, string> = { attribute: "属性", category: "分类", price: "价格" };
|
||||||
|
|
||||||
|
/** # 函数 筛选配置页面组件 */
|
||||||
|
function FiltersPage() {
|
||||||
|
const data = Route.useLoaderData();
|
||||||
|
const router = useRouter();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [editing, setEditing] = useState<FilterRow | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
/** # 成员变量 筛选行类型(来自 filters 表结构推断) **/
|
||||||
|
type FilterRow = typeof filters.$inferSelect;
|
||||||
|
|
||||||
|
if (!data) return <Forbidden />;
|
||||||
|
|
||||||
|
/** # 成员变量 attributeId → 属性名映射(表格展示用) **/
|
||||||
|
const attrName = new Map(data.attributes.map((a) => [a.id, a.name]));
|
||||||
|
|
||||||
|
/** # 函数 打开新增/编辑弹窗 */
|
||||||
|
function openEdit(row: FilterRow | null) {
|
||||||
|
setEditing(row);
|
||||||
|
form.setFieldsValue(row ?? { name: "", slug: "", type: "attribute", attributeId: null, position: 0, enabled: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 保存筛选配置 */
|
||||||
|
async function handleSave(values: { name: string; slug: string; type: string; attributeId: number | null; position: number; enabled: boolean }) {
|
||||||
|
await adminSaveFilter({ data: { ...values, id: editing?.id } });
|
||||||
|
message.success("已保存");
|
||||||
|
setEditing(null);
|
||||||
|
await router.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 删除筛选配置 */
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
await adminDeleteFilter({ data: { id } });
|
||||||
|
message.success("已删除");
|
||||||
|
await router.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="small">
|
||||||
|
<PageHeader
|
||||||
|
title="筛选配置"
|
||||||
|
extra={
|
||||||
|
<Button type="primary" icon={<PlusOutlined aria-hidden />} onClick={() => openEdit(null)}>
|
||||||
|
新增筛选
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
dataSource={data.items}
|
||||||
|
locale={{ emptyText: <AdminEmpty /> }}
|
||||||
|
pagination={false}
|
||||||
|
columns={[
|
||||||
|
{ title: "ID", dataIndex: "id", width: 70 },
|
||||||
|
{ title: "名称", dataIndex: "name", width: 180 },
|
||||||
|
{ title: "URL 参数", dataIndex: "slug", width: 180 },
|
||||||
|
{
|
||||||
|
title: "类型",
|
||||||
|
dataIndex: "type",
|
||||||
|
width: 100,
|
||||||
|
render: (v: string) => <Tag>{FILTER_TYPE_LABEL[v] ?? v}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "关联属性",
|
||||||
|
dataIndex: "attributeId",
|
||||||
|
width: 160,
|
||||||
|
render: (v: number | null) => (v ? (attrName.get(v) ?? `#${v}`) : "-"),
|
||||||
|
},
|
||||||
|
{ title: "位置", dataIndex: "position", width: 70 },
|
||||||
|
{
|
||||||
|
title: "启用",
|
||||||
|
dataIndex: "enabled",
|
||||||
|
width: 80,
|
||||||
|
render: (_, r) => <Tag color={r.enabled ? "green" : "default"}>{r.enabled ? "启用" : "停用"}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
key: "actions",
|
||||||
|
width: 110,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<Button size="small" type="text" icon={<EditOutlined aria-label="编辑" />} onClick={() => openEdit(r)} />
|
||||||
|
<Popconfirm title="确认删除该筛选?" onConfirm={() => void handleDelete(r.id)}>
|
||||||
|
<Button size="small" type="text" danger icon={<DeleteOutlined aria-label="删除" />} />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editing ? `编辑筛选 #${editing.id}` : "新增筛选"}
|
||||||
|
open={editing !== null}
|
||||||
|
onCancel={() => setEditing(null)}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" onFinish={(v) => void handleSave(v)}>
|
||||||
|
<Form.Item name="name" label="名称" rules={[{ required: true, message: "请输入名称" }]}>
|
||||||
|
<Input placeholder="如 Chip Platform" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="slug" label="URL 参数名" rules={[{ required: true, message: "请输入 URL 参数名" }]}>
|
||||||
|
<Input placeholder="chip-platform" />
|
||||||
|
</Form.Item>
|
||||||
|
<Space size={16} style={{ display: "flex" }}>
|
||||||
|
<Form.Item name="type" label="类型">
|
||||||
|
<Select
|
||||||
|
style={{ width: 130 }}
|
||||||
|
options={[
|
||||||
|
{ value: "attribute", label: "属性" },
|
||||||
|
{ value: "category", label: "分类" },
|
||||||
|
{ value: "price", label: "价格" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="attributeId" label="关联属性">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
style={{ width: 200 }}
|
||||||
|
placeholder="仅属性类型需要"
|
||||||
|
options={data.attributes.map((a) => ({ value: a.id, label: `${a.name} (${a.slug})` }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="position" label="位置">
|
||||||
|
<InputNumber min={0} style={{ width: 100 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* # 路由 /admin/ 后台首页(Dashboard)
|
||||||
|
* 统计卡片 + 最近 7 天询盘趋势(纯 div 柱图)+ 最新 10 条询盘。
|
||||||
|
*/
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { Card, Col, Row, Statistic, Table, Tag } from "antd";
|
||||||
|
import { FileText, Inbox, Package, ShoppingCart, TimerReset, Image as ImageIcon } from "lucide-react";
|
||||||
|
import { adminStats } from "~/server/admin";
|
||||||
|
import { AdminEmpty, Forbidden, INQUIRY_STATUS_COLOR, INQUIRY_STATUS_LABEL, guardAdmin, fmtDateTime } from "~/components/admin/common";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/admin/")({
|
||||||
|
loader: () => guardAdmin(() => adminStats()),
|
||||||
|
component: DashboardPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 函数 Dashboard 页面组件
|
||||||
|
* 渲染统计卡片、询盘趋势柱图与最新询盘表;未登录时展示占位。
|
||||||
|
*/
|
||||||
|
function DashboardPage() {
|
||||||
|
const stats = Route.useLoaderData();
|
||||||
|
if (!stats) return <Forbidden />;
|
||||||
|
const { counts, trend, recentInquiries } = stats;
|
||||||
|
const max = Math.max(1, ...trend.map((t) => t.count));
|
||||||
|
|
||||||
|
/** # 成员变量 统计卡片配置(图标 + 标题 + 数值) **/
|
||||||
|
const cards = [
|
||||||
|
{ title: "内容", value: counts.contents, icon: <FileText size={20} aria-hidden color="#1677ff" /> },
|
||||||
|
{ title: "产品", value: counts.products, icon: <Package size={20} aria-hidden color="#52c41a" /> },
|
||||||
|
{ title: "订单", value: counts.orders, icon: <ShoppingCart size={20} aria-hidden color="#faad14" /> },
|
||||||
|
{ title: "询盘", value: counts.inquiries, icon: <Inbox size={20} aria-hidden color="#722ed1" /> },
|
||||||
|
{ title: "媒体", value: counts.media, icon: <ImageIcon size={20} aria-hidden color="#13c2c2" /> },
|
||||||
|
{ title: "待处理订单", value: counts.pendingOrders, icon: <TimerReset size={20} aria-hidden color="#fa541c" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{cards.map((c) => (
|
||||||
|
<Col key={c.title} xs={12} sm={8} lg={4}>
|
||||||
|
<Card size="small">
|
||||||
|
<Statistic title={c.title} value={c.value} prefix={c.icon} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Card title="最近 7 天询盘趋势" size="small" style={{ marginTop: 16 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "flex-end", gap: 16, height: 160, padding: "0 8px" }}>
|
||||||
|
{trend.map((t) => (
|
||||||
|
<div key={t.day} style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 4, height: "100%", justifyContent: "flex-end" }}>
|
||||||
|
<span style={{ fontSize: 12, color: "#666" }}>{t.count}</span>
|
||||||
|
<div
|
||||||
|
role="img"
|
||||||
|
aria-label={`${t.day} 询盘 ${t.count} 条`}
|
||||||
|
style={{ width: "60%", maxWidth: 48, height: `${Math.round((t.count / max) * 100)}%`, minHeight: 4, background: "#1677ff", borderRadius: 4, opacity: 0.85 }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 12, color: "#999" }}>{t.day.slice(5)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="最新询盘" size="small" style={{ marginTop: 16 }}>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
dataSource={recentInquiries}
|
||||||
|
pagination={false}
|
||||||
|
locale={{ emptyText: <AdminEmpty /> }}
|
||||||
|
columns={[
|
||||||
|
{ title: "ID", dataIndex: "id", width: 70 },
|
||||||
|
{ title: "姓名", dataIndex: "name", width: 140 },
|
||||||
|
{ title: "邮箱", dataIndex: "email", width: 220 },
|
||||||
|
{ title: "公司", dataIndex: "company", width: 160, ellipsis: true },
|
||||||
|
{ title: "国家", dataIndex: "country", width: 120 },
|
||||||
|
{ title: "感兴趣产品", dataIndex: "productInterest", ellipsis: true },
|
||||||
|
{
|
||||||
|
title: "状态",
|
||||||
|
dataIndex: "status",
|
||||||
|
width: 100,
|
||||||
|
render: (v: string) => <Tag color={INQUIRY_STATUS_COLOR[v] ?? "default"}>{INQUIRY_STATUS_LABEL[v] ?? v}</Tag>,
|
||||||
|
},
|
||||||
|
{ title: "时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* # 路由 /admin/inquiries 询盘管理
|
||||||
|
* 状态 Tab 过滤 + 表格;行点击抽屉查看完整 fields JSON;操作按钮标记已读/已回复/垃圾。
|
||||||
|
* 数据接口复用 src/server/inquiries.ts 的 listInquiries / updateInquiryStatus。
|
||||||
|
*/
|
||||||
|
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||||
|
import { App, Button, Card, Descriptions, Drawer, Input, Space, Table, Tabs, Tag } from "antd";
|
||||||
|
import { CheckOutlined, SearchOutlined, SendOutlined, StopOutlined } from "@ant-design/icons";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { listInquiries, updateInquiryStatus } from "~/server/inquiries";
|
||||||
|
import type { inquiries } from "~/db/schema";
|
||||||
|
import {
|
||||||
|
AdminEmpty,
|
||||||
|
Forbidden,
|
||||||
|
INQUIRY_STATUS_COLOR,
|
||||||
|
INQUIRY_STATUS_LABEL,
|
||||||
|
Json,
|
||||||
|
PageHeader,
|
||||||
|
StatusTag,
|
||||||
|
fmtDateTime,
|
||||||
|
guardAdmin,
|
||||||
|
tablePagination,
|
||||||
|
type AdminSearch,
|
||||||
|
} from "~/components/admin/common";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/admin/inquiries")({
|
||||||
|
validateSearch: (search: Record<string, unknown>): AdminSearch => {
|
||||||
|
// 只保留显式传入的参数,缺省时不回写 URL(避免 307 归一化重定向)
|
||||||
|
const out: AdminSearch = {};
|
||||||
|
const page = Math.floor(Number(search.page)) || 0;
|
||||||
|
if (page > 1) out.page = page;
|
||||||
|
if (typeof search.q === "string" && search.q) out.q = search.q;
|
||||||
|
if (typeof search.status === "string" && search.status) out.status = search.status;
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
loaderDeps: ({ search }) => ({ search }),
|
||||||
|
loader: ({ deps }) =>
|
||||||
|
guardAdmin(() =>
|
||||||
|
listInquiries({ data: { page: deps.search.page, status: deps.search.status || undefined, q: deps.search.q || undefined } }),
|
||||||
|
),
|
||||||
|
component: InquiriesPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 成员变量 询盘行类型(fields/utm 收窄为 Json 后的形状) **/
|
||||||
|
type InquiryRow = Omit<typeof inquiries.$inferSelect, "fields" | "utm"> & { fields: Json; utm: Json };
|
||||||
|
|
||||||
|
/** # 函数 询盘管理页面组件 */
|
||||||
|
function InquiriesPage() {
|
||||||
|
const data = Route.useLoaderData();
|
||||||
|
const search = Route.useSearch();
|
||||||
|
const navigate = Route.useNavigate();
|
||||||
|
const router = useRouter();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [q, setQ] = useState(search.q ?? "");
|
||||||
|
/** # 成员变量 当前抽屉中查看的询盘记录(null = 关闭) **/
|
||||||
|
const [viewing, setViewing] = useState<InquiryRow | null>(null);
|
||||||
|
|
||||||
|
if (!data) return <Forbidden />;
|
||||||
|
|
||||||
|
/** # 函数 更新查询参数 */
|
||||||
|
function updateSearch(patch: AdminSearch) {
|
||||||
|
void navigate({ search: (prev) => ({ ...prev, ...patch, page: 1 }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 更新询盘状态并刷新 */
|
||||||
|
async function handleStatus(id: number, status: string) {
|
||||||
|
await updateInquiryStatus({ data: { id, status } });
|
||||||
|
message.success("状态已更新");
|
||||||
|
await router.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="small">
|
||||||
|
<PageHeader
|
||||||
|
title="询盘管理"
|
||||||
|
extra={
|
||||||
|
<Input
|
||||||
|
allowClear
|
||||||
|
placeholder="搜索姓名 / 邮箱 / 公司"
|
||||||
|
prefix={<SearchOutlined aria-hidden />}
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
onPressEnter={() => updateSearch({ q })}
|
||||||
|
style={{ width: 240 }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Tabs
|
||||||
|
activeKey={search.status ?? ""}
|
||||||
|
onChange={(key) => updateSearch({ status: key })}
|
||||||
|
items={[
|
||||||
|
{ key: "", label: "全部" },
|
||||||
|
...(["new", "read", "replied", "spam", "archived"] as const).map((s) => ({ key: s, label: INQUIRY_STATUS_LABEL[s] })),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
dataSource={data.items}
|
||||||
|
locale={{ emptyText: <AdminEmpty /> }}
|
||||||
|
onRow={(r) => ({ onClick: () => setViewing(r), style: { cursor: "pointer" } })}
|
||||||
|
pagination={tablePagination(data.total, data.page, data.pageSize, (p) =>
|
||||||
|
void navigate({ search: (prev) => ({ ...prev, page: p }) }),
|
||||||
|
)}
|
||||||
|
columns={[
|
||||||
|
{ title: "ID", dataIndex: "id", width: 70 },
|
||||||
|
{ title: "时间", dataIndex: "createdAt", width: 150, render: (v: string) => fmtDateTime(v) },
|
||||||
|
{ title: "姓名", dataIndex: "name", width: 130, ellipsis: true },
|
||||||
|
{ title: "邮箱", dataIndex: "email", width: 200, ellipsis: true },
|
||||||
|
{ title: "公司", dataIndex: "company", width: 160, ellipsis: true },
|
||||||
|
{ title: "国家", dataIndex: "country", width: 110, ellipsis: true },
|
||||||
|
{ title: "感兴趣产品", dataIndex: "productInterest", ellipsis: true },
|
||||||
|
{ title: "来源", dataIndex: "formName", width: 150, ellipsis: true },
|
||||||
|
{
|
||||||
|
title: "状态",
|
||||||
|
dataIndex: "status",
|
||||||
|
width: 100,
|
||||||
|
render: (v: string) => <StatusTag value={v} colorMap={INQUIRY_STATUS_COLOR} labelMap={INQUIRY_STATUS_LABEL} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
key: "actions",
|
||||||
|
width: 130,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Space size={0} onClick={(e) => e.stopPropagation()}>
|
||||||
|
{r.status !== "read" && r.status !== "replied" && (
|
||||||
|
<Button size="small" type="text" icon={<CheckOutlined aria-label="标记已读" />} title="标记已读" onClick={() => void handleStatus(r.id, "read")} />
|
||||||
|
)}
|
||||||
|
{r.status !== "replied" && (
|
||||||
|
<Button size="small" type="text" icon={<SendOutlined aria-label="标记已回复" />} title="标记已回复" onClick={() => void handleStatus(r.id, "replied")} />
|
||||||
|
)}
|
||||||
|
{r.status !== "spam" && (
|
||||||
|
<Button size="small" type="text" danger icon={<StopOutlined aria-label="标记垃圾" />} title="标记垃圾" onClick={() => void handleStatus(r.id, "spam")} />
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
title={viewing ? `询盘 #${viewing.id} · ${viewing.name || viewing.email}` : ""}
|
||||||
|
width={560}
|
||||||
|
open={viewing !== null}
|
||||||
|
onClose={() => setViewing(null)}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
{viewing && (
|
||||||
|
<>
|
||||||
|
<Descriptions size="small" column={1} bordered>
|
||||||
|
<Descriptions.Item label="状态">
|
||||||
|
<Tag color={INQUIRY_STATUS_COLOR[viewing.status] ?? "default"}>{INQUIRY_STATUS_LABEL[viewing.status] ?? viewing.status}</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="时间">{fmtDateTime(viewing.createdAt)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="姓名">{viewing.name || "-"}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="邮箱">{viewing.email || "-"}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="电话">{viewing.phone || "-"}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="公司">{viewing.company || "-"}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="国家">{viewing.country || "-"}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="感兴趣产品">{viewing.productInterest || "-"}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="来源">{viewing.formName || viewing.channel || "-"}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="来源页面">{viewing.sourcePage || "-"}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="IP">{viewing.ip || "-"}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="询盘内容">{viewing.message || "-"}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<h4 style={{ margin: "16px 0 8px" }}>完整字段(fields JSON)</h4>
|
||||||
|
<pre style={{ background: "#f5f5f5", padding: 12, borderRadius: 6, fontSize: 12, maxHeight: 320, overflow: "auto" }}>
|
||||||
|
{JSON.stringify(viewing.fields, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* # 路由 /admin/languages 语言配置
|
||||||
|
* 104 行语言列表;Switch 启用/禁用;默认语言与机翻列用 Tag 展示。
|
||||||
|
*/
|
||||||
|
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||||
|
import { App, Card, Switch, Table, Tag } from "antd";
|
||||||
|
import { adminListLanguages, adminUpdateLanguage } from "~/server/admin";
|
||||||
|
import { AdminEmpty, Forbidden, PageHeader, guardAdmin } from "~/components/admin/common";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/admin/languages")({
|
||||||
|
loader: () => guardAdmin(() => adminListLanguages()),
|
||||||
|
component: LanguagesPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 函数 语言配置页面组件 */
|
||||||
|
function LanguagesPage() {
|
||||||
|
const data = Route.useLoaderData();
|
||||||
|
const router = useRouter();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
|
||||||
|
if (!data) return <Forbidden />;
|
||||||
|
|
||||||
|
/** # 函数 切换语言启用状态并刷新 */
|
||||||
|
async function toggleEnabled(code: string, enabled: boolean) {
|
||||||
|
await adminUpdateLanguage({ data: { code, enabled } });
|
||||||
|
message.success(enabled ? `已启用 ${code}` : `已禁用 ${code}`);
|
||||||
|
await router.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="small">
|
||||||
|
<PageHeader title="语言配置" extra={<span style={{ color: "#999" }}>启用后该语言会生成真实页面;默认语言不可禁用逻辑由前台渲染决定</span>} />
|
||||||
|
<Table
|
||||||
|
rowKey="code"
|
||||||
|
size="small"
|
||||||
|
dataSource={data.items}
|
||||||
|
locale={{ emptyText: <AdminEmpty /> }}
|
||||||
|
pagination={{ pageSize: 20, showTotal: (t: number) => `共 ${t} 种语言` }}
|
||||||
|
columns={[
|
||||||
|
{ title: "代码", dataIndex: "code", width: 110 },
|
||||||
|
{ title: "名称", dataIndex: "name", width: 200 },
|
||||||
|
{
|
||||||
|
title: "默认语言",
|
||||||
|
dataIndex: "isDefault",
|
||||||
|
width: 100,
|
||||||
|
render: (v: boolean) => (v ? <Tag color="blue">默认</Tag> : <Tag>否</Tag>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "机翻",
|
||||||
|
dataIndex: "machine",
|
||||||
|
width: 90,
|
||||||
|
render: (v: boolean) => (v ? <Tag color="purple">机翻</Tag> : "-"),
|
||||||
|
},
|
||||||
|
{ title: "排序", dataIndex: "sort", width: 80 },
|
||||||
|
{
|
||||||
|
title: "启用",
|
||||||
|
dataIndex: "enabled",
|
||||||
|
width: 90,
|
||||||
|
render: (v: boolean, r) => <Switch size="small" checked={v} onChange={(on) => void toggleEnabled(r.code, on)} aria-label={`切换语言 ${r.code} 启用状态`} />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* # 路由 /admin/media 媒体库
|
||||||
|
* 表格 + 缩略图预览;编辑替代文本(alt)与标题。
|
||||||
|
*/
|
||||||
|
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||||
|
import { App, Button, Card, Form, Image, Input, Modal, Space, Table, Typography } from "antd";
|
||||||
|
import { EditOutlined, SearchOutlined } from "@ant-design/icons";
|
||||||
|
import { File as FileIcon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { adminListMedia, adminUpdateMedia } from "~/server/admin";
|
||||||
|
import { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common";
|
||||||
|
import type { media } from "~/db/schema";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/admin/media")({
|
||||||
|
validateSearch: (search: Record<string, unknown>): AdminSearch => {
|
||||||
|
// 只保留显式传入的参数,缺省时不回写 URL(避免 307 归一化重定向)
|
||||||
|
const out: AdminSearch = {};
|
||||||
|
const page = Math.floor(Number(search.page)) || 0;
|
||||||
|
if (page > 1) out.page = page;
|
||||||
|
if (typeof search.q === "string" && search.q) out.q = search.q;
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
loaderDeps: ({ search }) => ({ search }),
|
||||||
|
loader: ({ deps }) => guardAdmin(() => adminListMedia({ data: { page: deps.search.page, q: deps.search.q || undefined } })),
|
||||||
|
component: MediaPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 成员变量 媒体行类型(来自 media 表结构推断) **/
|
||||||
|
type MediaRow = typeof media.$inferSelect;
|
||||||
|
|
||||||
|
/** # 函数 媒体库页面组件 */
|
||||||
|
function MediaPage() {
|
||||||
|
const data = Route.useLoaderData();
|
||||||
|
const search = Route.useSearch();
|
||||||
|
const navigate = Route.useNavigate();
|
||||||
|
const router = useRouter();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [q, setQ] = useState(search.q ?? "");
|
||||||
|
/** # 成员变量 当前编辑的媒体记录(null = 关闭弹窗) **/
|
||||||
|
const [editing, setEditing] = useState<MediaRow | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
if (!data) return <Forbidden />;
|
||||||
|
|
||||||
|
/** # 函数 更新查询参数 */
|
||||||
|
function updateSearch(patch: AdminSearch) {
|
||||||
|
void navigate({ search: (prev) => ({ ...prev, ...patch, page: 1 }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 打开编辑弹窗并回填表单 */
|
||||||
|
function openEdit(row: MediaRow) {
|
||||||
|
setEditing(row);
|
||||||
|
form.setFieldsValue({ altText: row.altText, title: row.title });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 保存媒体 alt/title */
|
||||||
|
async function handleSave(values: { altText?: string; title?: string }) {
|
||||||
|
if (!editing) return;
|
||||||
|
await adminUpdateMedia({ data: { id: editing.id, altText: values.altText, title: values.title } });
|
||||||
|
message.success("已保存");
|
||||||
|
setEditing(null);
|
||||||
|
await router.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 文件大小格式化(字节 → KB/MB) */
|
||||||
|
function fmtSize(n: number | null): string {
|
||||||
|
if (!n) return "-";
|
||||||
|
if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
return `${Math.max(1, Math.round(n / 1024))} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="small">
|
||||||
|
<PageHeader
|
||||||
|
title="媒体库"
|
||||||
|
extra={
|
||||||
|
<Input
|
||||||
|
allowClear
|
||||||
|
placeholder="搜索文件路径"
|
||||||
|
prefix={<SearchOutlined aria-hidden />}
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
onPressEnter={() => updateSearch({ q })}
|
||||||
|
style={{ width: 240 }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
dataSource={data.items}
|
||||||
|
locale={{ emptyText: <AdminEmpty /> }}
|
||||||
|
pagination={tablePagination(data.total, data.page, data.pageSize, (p) =>
|
||||||
|
void navigate({ search: (prev) => ({ ...prev, page: p }) }),
|
||||||
|
)}
|
||||||
|
columns={[
|
||||||
|
{ title: "ID", dataIndex: "id", width: 70 },
|
||||||
|
{
|
||||||
|
title: "预览",
|
||||||
|
dataIndex: "url",
|
||||||
|
width: 88,
|
||||||
|
render: (v: string, r) =>
|
||||||
|
r.mimeType.startsWith("image") ? (
|
||||||
|
<Image src={v} alt={r.altText || r.path} width={56} height={56} style={{ objectFit: "cover", borderRadius: 4 }} />
|
||||||
|
) : (
|
||||||
|
<FileIcon size={36} aria-hidden color="#bfbfbf" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "路径",
|
||||||
|
dataIndex: "path",
|
||||||
|
render: (v: string, r) => (
|
||||||
|
<Typography.Link href={r.url} target="_blank" style={{ wordBreak: "break-all" }}>
|
||||||
|
{v}
|
||||||
|
</Typography.Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: "类型", dataIndex: "mimeType", width: 140 },
|
||||||
|
{ title: "尺寸", 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: "引用", dataIndex: "refCount", width: 70 },
|
||||||
|
{ title: "替代文本", dataIndex: "altText", ellipsis: true },
|
||||||
|
{ title: "上传时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
key: "actions",
|
||||||
|
width: 70,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Button size="small" type="text" icon={<EditOutlined aria-label="编辑" />} onClick={() => openEdit(r)} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal title={`编辑媒体 #${editing?.id ?? ""}`} open={editing !== null} onCancel={() => setEditing(null)} onOk={() => form.submit()} destroyOnHidden>
|
||||||
|
<Form form={form} layout="vertical" onFinish={(v) => void handleSave(v)}>
|
||||||
|
<Form.Item name="altText" label="替代文本(alt)">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="title" label="标题">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* # 路由 /admin/orders/$id 订单详情
|
||||||
|
* 订单信息描述列表 + 明细表格 + 状态流转 Select。
|
||||||
|
*/
|
||||||
|
import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router";
|
||||||
|
import { App, Button, Card, Descriptions, Select, Space, Table, Tag } from "antd";
|
||||||
|
import { ArrowLeftOutlined } from "@ant-design/icons";
|
||||||
|
import { adminGetOrder, adminUpdateOrderStatus } from "~/server/admin";
|
||||||
|
import {
|
||||||
|
AdminEmpty,
|
||||||
|
ORDER_STATUS_COLOR,
|
||||||
|
ORDER_STATUS_LABEL,
|
||||||
|
ORDER_STATUS_OPTIONS,
|
||||||
|
StatusTag,
|
||||||
|
fmtDateTime,
|
||||||
|
Forbidden,
|
||||||
|
guardAdmin,
|
||||||
|
} from "~/components/admin/common";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/admin/orders/$id")({
|
||||||
|
loader: ({ params }) => guardAdmin(() => adminGetOrder({ data: { id: Number(params.id) } })),
|
||||||
|
component: OrderDetailPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 函数 订单详情页面组件 */
|
||||||
|
function OrderDetailPage() {
|
||||||
|
const data = Route.useLoaderData();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const router = useRouter();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
|
||||||
|
if (!data) return <Forbidden />;
|
||||||
|
const { order, userEmail, items } = data;
|
||||||
|
|
||||||
|
/** # 函数 状态流转:更新订单状态并刷新 */
|
||||||
|
async function handleStatus(status: string) {
|
||||||
|
await adminUpdateOrderStatus({ data: { id: order.id, status } });
|
||||||
|
message.success("状态已更新");
|
||||||
|
await router.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="small">
|
||||||
|
<Space style={{ marginBottom: 16 }}>
|
||||||
|
<Button icon={<ArrowLeftOutlined aria-hidden />} onClick={() => void navigate({ to: "/admin/orders/" })}>
|
||||||
|
返回列表
|
||||||
|
</Button>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 18 }}>订单 {order.orderNumber}</h2>
|
||||||
|
<StatusTag value={order.status} colorMap={ORDER_STATUS_COLOR} labelMap={ORDER_STATUS_LABEL} />
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Card type="inner" title="订单信息" size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Descriptions size="small" column={3}>
|
||||||
|
<Descriptions.Item label="状态流转">
|
||||||
|
<Select value={order.status} options={ORDER_STATUS_OPTIONS} style={{ width: 140 }} onChange={(v) => void handleStatus(v)} aria-label="订单状态" />
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="支付状态">
|
||||||
|
<Tag>{order.paymentStatus}</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="支付方式">{order.paymentMethod || "-"}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="总额">
|
||||||
|
{order.currency} {order.total}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="小计">{order.subtotal}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="运费 / 税费">
|
||||||
|
{order.shippingTotal} / {order.taxTotal}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="客户邮箱">{userEmail || "-"}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="客户姓名">{order.customerName || "-"}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="下单时间">{fmtDateTime(order.createdAt)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="客户备注" span={3}>
|
||||||
|
{order.customerNote || "-"}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="账单信息" span={3}>
|
||||||
|
<pre style={{ margin: 0, fontSize: 12 }}>{JSON.stringify(order.billing, null, 2)}</pre>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="收货信息" span={3}>
|
||||||
|
<pre style={{ margin: 0, fontSize: 12 }}>{JSON.stringify(order.shipping, null, 2)}</pre>
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card type="inner" title="订单明细" size="small">
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
dataSource={items}
|
||||||
|
pagination={false}
|
||||||
|
locale={{ emptyText: <AdminEmpty /> }}
|
||||||
|
columns={[
|
||||||
|
{ title: "商品名", dataIndex: "name" },
|
||||||
|
{ title: "SKU", dataIndex: "sku", width: 140 },
|
||||||
|
{ title: "数量", dataIndex: "qty", width: 80 },
|
||||||
|
{ title: "单价", dataIndex: "price", width: 110 },
|
||||||
|
{ title: "小计", dataIndex: "total", width: 110 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* # 路由 /admin/orders 订单列表
|
||||||
|
* 状态 Tab 过滤 + 分页表格;点击订单号进入详情。
|
||||||
|
*/
|
||||||
|
import { createFileRoute, Link, useRouter } from "@tanstack/react-router";
|
||||||
|
import { Button, Card, Space, Table, Tabs, Tag } from "antd";
|
||||||
|
import { EyeOutlined } from "@ant-design/icons";
|
||||||
|
import { adminListOrders } from "~/server/admin";
|
||||||
|
import {
|
||||||
|
AdminEmpty,
|
||||||
|
Forbidden,
|
||||||
|
ORDER_STATUS_COLOR,
|
||||||
|
ORDER_STATUS_LABEL,
|
||||||
|
PageHeader,
|
||||||
|
StatusTag,
|
||||||
|
fmtDateTime,
|
||||||
|
guardAdmin,
|
||||||
|
tablePagination,
|
||||||
|
type AdminSearch,
|
||||||
|
} from "~/components/admin/common";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/admin/orders/")({
|
||||||
|
validateSearch: (search: Record<string, unknown>): AdminSearch => {
|
||||||
|
// 只保留显式传入的参数,缺省时不回写 URL(避免 307 归一化重定向)
|
||||||
|
const out: AdminSearch = {};
|
||||||
|
const page = Math.floor(Number(search.page)) || 0;
|
||||||
|
if (page > 1) out.page = page;
|
||||||
|
if (typeof search.status === "string" && search.status) out.status = search.status;
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
loaderDeps: ({ search }) => ({ search }),
|
||||||
|
loader: ({ deps }) => guardAdmin(() => adminListOrders({ data: { page: deps.search.page, status: deps.search.status || undefined } })),
|
||||||
|
component: OrdersPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 函数 订单列表页面组件 */
|
||||||
|
function OrdersPage() {
|
||||||
|
const data = Route.useLoaderData();
|
||||||
|
const search = Route.useSearch();
|
||||||
|
const navigate = Route.useNavigate();
|
||||||
|
|
||||||
|
if (!data) return <Forbidden />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="small">
|
||||||
|
<PageHeader title="订单管理" />
|
||||||
|
<Tabs
|
||||||
|
activeKey={search.status ?? ""}
|
||||||
|
onChange={(key) => void navigate({ search: (prev) => ({ ...prev, status: key, page: 1 }) })}
|
||||||
|
items={[
|
||||||
|
{ key: "", label: "全部" },
|
||||||
|
...(["pending", "on-hold", "processing", "completed", "cancelled", "refunded"] as const).map((s) => ({
|
||||||
|
key: s,
|
||||||
|
label: ORDER_STATUS_LABEL[s],
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
dataSource={data.items}
|
||||||
|
locale={{ emptyText: <AdminEmpty /> }}
|
||||||
|
pagination={tablePagination(data.total, data.page, data.pageSize, (p) =>
|
||||||
|
void navigate({ search: (prev) => ({ ...prev, page: p }) }),
|
||||||
|
)}
|
||||||
|
columns={[
|
||||||
|
{ title: "ID", dataIndex: "id", width: 70 },
|
||||||
|
{
|
||||||
|
title: "订单号",
|
||||||
|
dataIndex: "orderNumber",
|
||||||
|
width: 160,
|
||||||
|
render: (v: string, r) => (
|
||||||
|
<Link to="/admin/orders/$id/" params={{ id: String(r.id) }}>
|
||||||
|
{v}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: "客户", dataIndex: "customerName", width: 180, ellipsis: true },
|
||||||
|
{
|
||||||
|
title: "金额",
|
||||||
|
dataIndex: "total",
|
||||||
|
width: 120,
|
||||||
|
render: (v: string, r) => `${r.currency} ${v}`,
|
||||||
|
},
|
||||||
|
{ title: "支付状态", dataIndex: "paymentStatus", width: 110, render: (v: string) => <Tag>{v}</Tag> },
|
||||||
|
{
|
||||||
|
title: "订单状态",
|
||||||
|
dataIndex: "status",
|
||||||
|
width: 110,
|
||||||
|
render: (v: string) => <StatusTag value={v} colorMap={ORDER_STATUS_COLOR} labelMap={ORDER_STATUS_LABEL} />,
|
||||||
|
},
|
||||||
|
{ title: "下单时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
key: "actions",
|
||||||
|
width: 80,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<Link to="/admin/orders/$id/" params={{ id: String(r.id) }}>
|
||||||
|
<Button size="small" type="text" icon={<EyeOutlined aria-label="查看详情" />} />
|
||||||
|
</Link>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
/**
|
||||||
|
* # 路由 /admin/products/$id 产品编辑页
|
||||||
|
* 基础字段 + 特色图/图集媒体选择(远程搜索媒体库)+ 属性值多选(保存时重建
|
||||||
|
* product_attribute_map)+ 产品分类多选(product_terms)。id 为 new 时新建。
|
||||||
|
*/
|
||||||
|
import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { App, Button, Card, Form, Input, InputNumber, Select, Space, Switch } from "antd";
|
||||||
|
import { ArrowLeftOutlined, SaveOutlined } from "@ant-design/icons";
|
||||||
|
import { adminGetProduct, adminSaveProduct, adminSearchMedia } from "~/server/admin";
|
||||||
|
import { Forbidden, guardAdmin } from "~/components/admin/common";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/admin/products/$id")({
|
||||||
|
loader: ({ params }) =>
|
||||||
|
guardAdmin(async () => {
|
||||||
|
// new → id 0(同时作为权限探测,未登录抛 FORBIDDEN)
|
||||||
|
const id = params.id === "new" ? 0 : Number(params.id);
|
||||||
|
const [data, mediaOptions] = await Promise.all([adminGetProduct({ data: { id } }), adminSearchMedia({ data: { limit: 30 } })]);
|
||||||
|
return { ...data, mediaOptions };
|
||||||
|
}),
|
||||||
|
component: ProductEditPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 类型 媒体选择器选项 **/
|
||||||
|
type MediaOption = { id: number; path: string; url: string };
|
||||||
|
|
||||||
|
/** # 函数 文件路径转显示名(取 basename) */
|
||||||
|
function mediaLabel(path: string): string {
|
||||||
|
return path.split("/").pop() || path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 媒体选择器(AntD Select 远程搜索媒体库,支持单选/多选)
|
||||||
|
* 参数 options:初始选项;multiple:是否多选;value/onChange 受控由 Form.Item 接管
|
||||||
|
*/
|
||||||
|
function MediaSelect(props: { options: MediaOption[]; multiple?: boolean; placeholder?: string }) {
|
||||||
|
const [opts, setOpts] = useState<MediaOption[]>(props.options);
|
||||||
|
|
||||||
|
/** # 函数 远程搜索媒体(防抖 300ms) */
|
||||||
|
function handleSearch(q: string) {
|
||||||
|
window.clearTimeout((handleSearch as unknown as { _t?: number })._t);
|
||||||
|
(handleSearch as unknown as { _t?: number })._t = window.setTimeout(() => {
|
||||||
|
adminSearchMedia({ data: { q: q || undefined, limit: 30 } })
|
||||||
|
.then((rows) => setOpts(rows))
|
||||||
|
.catch(() => {});
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
mode={props.multiple ? "multiple" : undefined}
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
placeholder={props.placeholder || "搜索媒体文件名"}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
optionFilterProp="label"
|
||||||
|
style={{ maxWidth: 640, width: "100%" }}
|
||||||
|
options={opts.map((m) => ({ value: m.id, label: mediaLabel(m.path) }))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 产品编辑页面组件 */
|
||||||
|
function ProductEditPage() {
|
||||||
|
const data = Route.useLoaderData();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const router = useRouter();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
if (!data) return <Forbidden />;
|
||||||
|
const { product, attributes, categories, selectedValueIds, selectedCategoryIds, selectedGalleryIds, initialGalleryOptions, mediaOptions } = data;
|
||||||
|
|
||||||
|
/** # 成员变量 表单初始值:基础字段 + 每个属性的已选值数组 **/
|
||||||
|
const initialValues = {
|
||||||
|
title: product?.title ?? "",
|
||||||
|
slug: product?.slug ?? "",
|
||||||
|
sku: product?.sku ?? "",
|
||||||
|
price: product?.price ?? "",
|
||||||
|
regularPrice: product?.regularPrice ?? "",
|
||||||
|
stock: product?.stock ?? null,
|
||||||
|
stockStatus: product?.stockStatus ?? "instock",
|
||||||
|
status: product?.status ?? "draft",
|
||||||
|
featured: product?.featured ?? false,
|
||||||
|
shortDescription: product?.shortDescription ?? "",
|
||||||
|
description: product?.description ?? "",
|
||||||
|
specUrl: product?.specUrl ?? "",
|
||||||
|
seoTitle: product?.seoTitle ?? "",
|
||||||
|
seoDescription: product?.seoDescription ?? "",
|
||||||
|
featuredMediaId: product?.featuredMediaId ?? null,
|
||||||
|
galleryIds: selectedGalleryIds,
|
||||||
|
categoryIds: selectedCategoryIds,
|
||||||
|
...Object.fromEntries(
|
||||||
|
attributes.map((a) => [`attr_${a.id}`, selectedValueIds.filter((v) => a.values.some((val) => val.id === v))]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** # 函数 保存产品:提交字段 + 属性值/分类,重建关联表 */
|
||||||
|
async function handleSave(values: Record<string, unknown>) {
|
||||||
|
const valueIds = attributes.flatMap((a) => ((values[`attr_${a.id}`] as number[] | undefined) ?? []));
|
||||||
|
try {
|
||||||
|
await adminSaveProduct({
|
||||||
|
data: {
|
||||||
|
id: product?.id,
|
||||||
|
sku: (values.sku as string) || null,
|
||||||
|
slug: values.slug as string,
|
||||||
|
title: values.title as string,
|
||||||
|
shortDescription: (values.shortDescription as string) ?? "",
|
||||||
|
description: (values.description as string) ?? "",
|
||||||
|
price: (values.price as string) || null,
|
||||||
|
regularPrice: (values.regularPrice as string) || null,
|
||||||
|
stock: (values.stock as number | null) ?? null,
|
||||||
|
stockStatus: (values.stockStatus as string) ?? "instock",
|
||||||
|
status: (values.status as string) ?? "draft",
|
||||||
|
featured: (values.featured as boolean) ?? false,
|
||||||
|
specUrl: (values.specUrl as string) || null,
|
||||||
|
seoTitle: (values.seoTitle as string) || null,
|
||||||
|
seoDescription: (values.seoDescription as string) || null,
|
||||||
|
featuredMediaId: (values.featuredMediaId as number | null) ?? null,
|
||||||
|
galleryIds: (values.galleryIds as number[]) ?? [],
|
||||||
|
valueIds,
|
||||||
|
categoryIds: (values.categoryIds as number[]) ?? [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
message.success("保存成功");
|
||||||
|
await router.invalidate();
|
||||||
|
void navigate({ to: "/admin/products/" });
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : "保存失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="small">
|
||||||
|
<Space style={{ marginBottom: 16 }}>
|
||||||
|
<Button icon={<ArrowLeftOutlined aria-hidden />} onClick={() => void navigate({ to: "/admin/products/" })}>
|
||||||
|
返回列表
|
||||||
|
</Button>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 18 }}>{product ? `编辑产品 #${product.id}` : "新建产品"}</h2>
|
||||||
|
</Space>
|
||||||
|
<Form form={form} layout="vertical" style={{ maxWidth: 900 }} initialValues={initialValues} onFinish={(v) => void handleSave(v)}>
|
||||||
|
<Space size={16} style={{ display: "flex" }} align="start">
|
||||||
|
<Form.Item name="title" label="名称" rules={[{ required: true, message: "请输入产品名称" }]} style={{ minWidth: 420, flex: 1 }}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="slug" label="别名(留空自动生成)">
|
||||||
|
<Input style={{ width: 240 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="sku" label="SKU">
|
||||||
|
<Input style={{ width: 160 }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
<Space size={16} style={{ display: "flex" }} wrap>
|
||||||
|
<Form.Item name="price" label="现价(USD)">
|
||||||
|
<Input style={{ width: 120 }} placeholder="0.00" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="regularPrice" label="原价(USD)">
|
||||||
|
<Input style={{ width: 120 }} placeholder="0.00" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="stock" label="库存数量">
|
||||||
|
<InputNumber min={0} style={{ width: 120 }} placeholder="留空不限" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="stockStatus" label="库存状态">
|
||||||
|
<Select
|
||||||
|
style={{ width: 130 }}
|
||||||
|
options={[
|
||||||
|
{ value: "instock", label: "有货" },
|
||||||
|
{ value: "outofstock", label: "缺货" },
|
||||||
|
{ value: "onbackorder", label: "预订" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态">
|
||||||
|
<Select
|
||||||
|
style={{ width: 130 }}
|
||||||
|
options={[
|
||||||
|
{ value: "draft", label: "草稿" },
|
||||||
|
{ value: "published", label: "已发布" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="featured" label="主推产品" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="specUrl" label="规格书地址">
|
||||||
|
<Input style={{ width: 240 }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
<Space size={16} style={{ display: "flex" }} wrap>
|
||||||
|
<Form.Item name="featuredMediaId" label="特色图" style={{ minWidth: 300 }}>
|
||||||
|
<MediaSelect options={mediaOptions} placeholder="搜索媒体文件名选择特色图" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="galleryIds" label="图集(可多选)" style={{ minWidth: 300 }}>
|
||||||
|
<MediaSelect options={[...initialGalleryOptions, ...mediaOptions.filter((m) => !initialGalleryOptions.some((g) => g.id === m.id))]} multiple />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
<Form.Item name="shortDescription" label="短描述">
|
||||||
|
<Input.TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="详细描述 HTML">
|
||||||
|
<Input.TextArea rows={14} style={{ fontFamily: "monospace", fontSize: 12 }} />
|
||||||
|
</Form.Item>
|
||||||
|
{attributes.length > 0 && (
|
||||||
|
<Card type="inner" title="产品属性" size="small" style={{ marginBottom: 16 }}>
|
||||||
|
{attributes.map((a) => (
|
||||||
|
<Form.Item
|
||||||
|
key={a.id}
|
||||||
|
name={`attr_${a.id}`}
|
||||||
|
label={a.name}
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
allowClear
|
||||||
|
placeholder={`选择 ${a.name} 属性值`}
|
||||||
|
style={{ maxWidth: 640 }}
|
||||||
|
options={a.values.map((v) => ({ value: v.id, label: v.value }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
<Form.Item name="categoryIds" label="产品分类">
|
||||||
|
<Select mode="multiple" allowClear placeholder="选择分类" style={{ maxWidth: 640 }} options={categories.map((c) => ({ value: c.id, label: c.name }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="seoTitle" label="SEO 标题">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="seoDescription" label="SEO 描述">
|
||||||
|
<Input.TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" icon={<SaveOutlined aria-hidden />}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* # 路由 /admin/products 产品列表
|
||||||
|
* 关键字搜索(标题/SKU/别名)+ 分页表格,含缩略图、价格、库存状态与操作列。
|
||||||
|
*/
|
||||||
|
import { createFileRoute, Link, useRouter } from "@tanstack/react-router";
|
||||||
|
import { App, Button, Card, Image, Input, Popconfirm, Space, Table, Tag } from "antd";
|
||||||
|
import { DeleteOutlined, EditOutlined, PlusOutlined, SearchOutlined } from "@ant-design/icons";
|
||||||
|
import { Package } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { adminDeleteProduct, adminListProducts } from "~/server/admin";
|
||||||
|
import { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/admin/products/")({
|
||||||
|
validateSearch: (search: Record<string, unknown>): AdminSearch => {
|
||||||
|
// 只保留显式传入的参数,缺省时不回写 URL(避免 307 归一化重定向)
|
||||||
|
const out: AdminSearch = {};
|
||||||
|
const page = Math.floor(Number(search.page)) || 0;
|
||||||
|
if (page > 1) out.page = page;
|
||||||
|
if (typeof search.q === "string" && search.q) out.q = search.q;
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
loaderDeps: ({ search }) => ({ search }),
|
||||||
|
loader: ({ deps }) => guardAdmin(() => adminListProducts({ data: { page: deps.search.page, q: deps.search.q || undefined } })),
|
||||||
|
component: ProductsPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 成员变量 库存状态中文标签与配色 **/
|
||||||
|
const STOCK_LABEL: Record<string, string> = { instock: "有货", outofstock: "缺货", onbackorder: "预订" };
|
||||||
|
const STOCK_COLOR: Record<string, string> = { instock: "green", outofstock: "red", onbackorder: "orange" };
|
||||||
|
|
||||||
|
/** # 函数 产品列表页面组件 */
|
||||||
|
function ProductsPage() {
|
||||||
|
const data = Route.useLoaderData();
|
||||||
|
const search = Route.useSearch();
|
||||||
|
const navigate = Route.useNavigate();
|
||||||
|
const router = useRouter();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [q, setQ] = useState(search.q ?? "");
|
||||||
|
|
||||||
|
if (!data) return <Forbidden />;
|
||||||
|
|
||||||
|
/** # 函数 更新查询参数 */
|
||||||
|
function updateSearch(patch: AdminSearch) {
|
||||||
|
void navigate({ search: (prev) => ({ ...prev, ...patch, page: 1 }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 删除产品并刷新 */
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
await adminDeleteProduct({ data: { id } });
|
||||||
|
message.success("已删除");
|
||||||
|
await router.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="small">
|
||||||
|
<PageHeader
|
||||||
|
title="产品管理"
|
||||||
|
extra={
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
allowClear
|
||||||
|
placeholder="搜索标题 / SKU / 别名"
|
||||||
|
prefix={<SearchOutlined aria-hidden />}
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
onPressEnter={() => updateSearch({ q })}
|
||||||
|
style={{ width: 240 }}
|
||||||
|
/>
|
||||||
|
<Link to="/admin/products/$id/" params={{ id: "new" }}>
|
||||||
|
<Button type="primary" icon={<PlusOutlined aria-hidden />}>
|
||||||
|
新建产品
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
dataSource={data.items}
|
||||||
|
locale={{ emptyText: <AdminEmpty /> }}
|
||||||
|
pagination={tablePagination(data.total, data.page, data.pageSize, (p) => void navigate({ search: (prev) => ({ ...prev, page: p }) }))}
|
||||||
|
columns={[
|
||||||
|
{ title: "ID", dataIndex: "id", width: 70 },
|
||||||
|
{
|
||||||
|
title: "图",
|
||||||
|
dataIndex: "thumb",
|
||||||
|
width: 72,
|
||||||
|
render: (v: string | null) =>
|
||||||
|
v ? (
|
||||||
|
<Image src={v} alt="产品缩略图" width={44} height={44} style={{ objectFit: "cover", borderRadius: 4 }} />
|
||||||
|
) : (
|
||||||
|
<Package size={36} aria-hidden color="#d9d9d9" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: "SKU", dataIndex: "sku", width: 140 },
|
||||||
|
{
|
||||||
|
title: "标题",
|
||||||
|
dataIndex: "title",
|
||||||
|
render: (v: string, r) => (
|
||||||
|
<Link to="/admin/products/$id/" params={{ id: String(r.id) }}>
|
||||||
|
{v}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "价格",
|
||||||
|
dataIndex: "price",
|
||||||
|
width: 110,
|
||||||
|
render: (v: string | null, r) => (v != null ? `$${v}` : r.regularPrice != null ? `$${r.regularPrice}` : "-"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "库存",
|
||||||
|
dataIndex: "stockStatus",
|
||||||
|
width: 100,
|
||||||
|
render: (v: string, r) => (
|
||||||
|
<Tag color={STOCK_COLOR[v] ?? "default"}>
|
||||||
|
{STOCK_LABEL[v] ?? v}
|
||||||
|
{typeof r.stock === "number" ? ` (${r.stock})` : ""}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "状态",
|
||||||
|
dataIndex: "status",
|
||||||
|
width: 90,
|
||||||
|
render: (v: string) => <Tag color={v === "published" ? "green" : "default"}>{v}</Tag>,
|
||||||
|
},
|
||||||
|
{ title: "更新时间", dataIndex: "updatedAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
key: "actions",
|
||||||
|
width: 110,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<Link to="/admin/products/$id/" params={{ id: String(r.id) }}>
|
||||||
|
<Button size="small" type="text" icon={<EditOutlined aria-label="编辑" />} />
|
||||||
|
</Link>
|
||||||
|
<Popconfirm title="确认删除该产品?" onConfirm={() => void handleDelete(r.id)}>
|
||||||
|
<Button size="small" type="text" danger icon={<DeleteOutlined aria-label="删除" />} />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* # 路由 /admin/seo 重定向管理(SEO)
|
||||||
|
* 重定向列表 + 新增/编辑弹窗(source/target/code/regex/enabled)+ 删除。
|
||||||
|
*/
|
||||||
|
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||||
|
import { App, Button, Card, Form, Input, Modal, Popconfirm, Select, Space, Switch, Table, Tag } from "antd";
|
||||||
|
import { DeleteOutlined, EditOutlined, PlusOutlined, SearchOutlined } from "@ant-design/icons";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { adminDeleteRedirect, adminListRedirects, adminSaveRedirect } from "~/server/admin";
|
||||||
|
import { AdminEmpty, Forbidden, PageHeader, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common";
|
||||||
|
import type { redirects } from "~/db/schema";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/admin/seo")({
|
||||||
|
validateSearch: (search: Record<string, unknown>): AdminSearch => {
|
||||||
|
// 只保留显式传入的参数,缺省时不回写 URL(避免 307 归一化重定向)
|
||||||
|
const out: AdminSearch = {};
|
||||||
|
const page = Math.floor(Number(search.page)) || 0;
|
||||||
|
if (page > 1) out.page = page;
|
||||||
|
if (typeof search.q === "string" && search.q) out.q = search.q;
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
loaderDeps: ({ search }) => ({ search }),
|
||||||
|
loader: ({ deps }) => guardAdmin(() => adminListRedirects({ data: { page: deps.search.page, q: deps.search.q || undefined } })),
|
||||||
|
component: SeoPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 成员变量 重定向行类型(来自 redirects 表结构推断) **/
|
||||||
|
type RedirectRow = typeof redirects.$inferSelect;
|
||||||
|
|
||||||
|
/** # 函数 SEO / 重定向页面组件 */
|
||||||
|
function SeoPage() {
|
||||||
|
const data = Route.useLoaderData();
|
||||||
|
const search = Route.useSearch();
|
||||||
|
const navigate = Route.useNavigate();
|
||||||
|
const router = useRouter();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [q, setQ] = useState(search.q ?? "");
|
||||||
|
/** # 成员变量 编辑弹窗中的重定向记录(null = 关闭;id 缺省为新建) **/
|
||||||
|
const [editing, setEditing] = useState<RedirectRow | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
if (!data) return <Forbidden />;
|
||||||
|
|
||||||
|
/** # 函数 更新查询参数 */
|
||||||
|
function updateSearch(patch: AdminSearch) {
|
||||||
|
void navigate({ search: (prev) => ({ ...prev, ...patch, page: 1 }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 打开新增/编辑弹窗 */
|
||||||
|
function openEdit(row: RedirectRow | null) {
|
||||||
|
setEditing(row);
|
||||||
|
form.setFieldsValue(
|
||||||
|
row ?? { source: "", target: "", code: 301, isRegex: false, enabled: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 保存重定向 */
|
||||||
|
async function handleSave(values: { source: string; target: string; code: number; isRegex: boolean; enabled: boolean }) {
|
||||||
|
await adminSaveRedirect({ data: { ...values, id: editing?.id } });
|
||||||
|
message.success("已保存");
|
||||||
|
setEditing(null);
|
||||||
|
await router.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 删除重定向 */
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
await adminDeleteRedirect({ data: { id } });
|
||||||
|
message.success("已删除");
|
||||||
|
await router.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 切换启用状态 */
|
||||||
|
async function toggleEnabled(row: RedirectRow, enabled: boolean) {
|
||||||
|
await adminSaveRedirect({ data: { id: row.id, source: row.source, target: row.target, code: row.code, isRegex: row.isRegex, enabled } });
|
||||||
|
await router.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="small">
|
||||||
|
<PageHeader
|
||||||
|
title="重定向管理"
|
||||||
|
extra={
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
allowClear
|
||||||
|
placeholder="搜索源路径 / 目标"
|
||||||
|
prefix={<SearchOutlined aria-hidden />}
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
onPressEnter={() => updateSearch({ q })}
|
||||||
|
style={{ width: 240 }}
|
||||||
|
/>
|
||||||
|
<Button type="primary" icon={<PlusOutlined aria-hidden />} onClick={() => openEdit(null)}>
|
||||||
|
新增重定向
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
dataSource={data.items}
|
||||||
|
locale={{ emptyText: <AdminEmpty /> }}
|
||||||
|
pagination={tablePagination(data.total, data.page, data.pageSize, (p) =>
|
||||||
|
void navigate({ search: (prev) => ({ ...prev, page: p }) }),
|
||||||
|
)}
|
||||||
|
columns={[
|
||||||
|
{ title: "ID", dataIndex: "id", width: 70 },
|
||||||
|
{ title: "源路径", dataIndex: "source", ellipsis: true },
|
||||||
|
{ title: "目标", dataIndex: "target", ellipsis: true },
|
||||||
|
{
|
||||||
|
title: "状态码",
|
||||||
|
dataIndex: "code",
|
||||||
|
width: 90,
|
||||||
|
render: (v: number) => <Tag color={v === 301 ? "green" : "blue"}>{v}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "正则",
|
||||||
|
dataIndex: "isRegex",
|
||||||
|
width: 70,
|
||||||
|
render: (v: boolean) => (v ? <Tag color="purple">正则</Tag> : "-"),
|
||||||
|
},
|
||||||
|
{ title: "命中", dataIndex: "hits", width: 70 },
|
||||||
|
{
|
||||||
|
title: "启用",
|
||||||
|
dataIndex: "enabled",
|
||||||
|
width: 80,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Switch size="small" checked={r.enabled} onChange={(on) => void toggleEnabled(r, on)} aria-label={`切换重定向 ${r.source}`} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
key: "actions",
|
||||||
|
width: 110,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<Button size="small" type="text" icon={<EditOutlined aria-label="编辑" />} onClick={() => openEdit(r)} />
|
||||||
|
<Popconfirm title="确认删除该重定向?" onConfirm={() => void handleDelete(r.id)}>
|
||||||
|
<Button size="small" type="text" danger icon={<DeleteOutlined aria-label="删除" />} />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editing ? `编辑重定向 #${editing.id}` : "新增重定向"}
|
||||||
|
open={editing !== null}
|
||||||
|
onCancel={() => setEditing(null)}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" onFinish={(v) => void handleSave(v)}>
|
||||||
|
<Form.Item name="source" label="源路径(支持正则)" rules={[{ required: true, message: "请输入源路径" }]}>
|
||||||
|
<Input placeholder="/old-path 或 /blog/(.*)" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="target" label="目标地址" rules={[{ required: true, message: "请输入目标地址" }]}>
|
||||||
|
<Input placeholder="/new-path 或 https://…" />
|
||||||
|
</Form.Item>
|
||||||
|
<Space size={16} style={{ display: "flex" }}>
|
||||||
|
<Form.Item name="code" label="状态码">
|
||||||
|
<Select style={{ width: 110 }} options={[{ value: 301, label: "301" }, { value: 302, label: "302" }]} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="isRegex" label="正则匹配" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* # 路由 /admin 后台布局
|
||||||
|
* 左侧 Sider 菜单 + 顶部 Header(当前用户 / 退出)。
|
||||||
|
* 未登录或无后台权限:整页渲染登录页(不再展示后台骨架)。
|
||||||
|
*/
|
||||||
|
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 { LockKeyhole } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Files,
|
||||||
|
Inbox,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Languages,
|
||||||
|
LayoutDashboard,
|
||||||
|
LogOut,
|
||||||
|
Package,
|
||||||
|
Route as RouteIcon,
|
||||||
|
Settings,
|
||||||
|
ShoppingCart,
|
||||||
|
SlidersHorizontal,
|
||||||
|
UserRound,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { StyleProvider } from "@ant-design/cssinjs";
|
||||||
|
import { getCurrentUser, loginFn, logoutFn } from "~/server/auth";
|
||||||
|
|
||||||
|
/** # 类型 后台菜单项(key 即目标路由路径) **/
|
||||||
|
const MENU_ITEMS = [
|
||||||
|
{ key: "/admin/", icon: <LayoutDashboard size={16} aria-hidden />, label: "Dashboard" },
|
||||||
|
{ key: "/admin/contents/", icon: <Files size={16} aria-hidden />, label: "内容" },
|
||||||
|
{ 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: "询盘" },
|
||||||
|
{ key: "/admin/media/", icon: <ImageIcon 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/filters/", icon: <SlidersHorizontal size={16} aria-hidden />, label: "筛选" },
|
||||||
|
{ key: "/admin/users/", icon: <Settings size={16} aria-hidden />, label: "系统设置" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** # 类型 合法菜单路径(用于类型安全的导航) **/
|
||||||
|
type MenuKey = (typeof MENU_ITEMS)[number]["key"];
|
||||||
|
|
||||||
|
/** # 类型 拥有后台权限的角色 **/
|
||||||
|
const ADMIN_ROLES = ["admin", "editor", "manager"];
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/admin")({
|
||||||
|
loader: () => getCurrentUser(),
|
||||||
|
component: AdminLayout,
|
||||||
|
// AntD 烘焙静态样式(scripts/gen-antd-css.tsx 生成到 public/):
|
||||||
|
// 用 <link> 而非 JS import —— dev 下 vite 会把 import 的 CSS 注入成 <style> 标签,
|
||||||
|
// 且 dev 预载全部路由模块会把它泄漏到前台每个页面(800KB style 标签导致 F12 卡死)。
|
||||||
|
// <link> 在 head 中渲染阻塞加载,SSR 首屏即有完整 AntD 样式,消除无样式闪烁。
|
||||||
|
head: () => ({
|
||||||
|
links: [{ rel: "stylesheet", href: "/antd-baked.css" }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 函数 后台布局组件
|
||||||
|
* 未登录 / 角色无后台权限 → 渲染全屏登录页;已授权 → 正常布局。
|
||||||
|
*/
|
||||||
|
function AdminLayout() {
|
||||||
|
const user = Route.useLoaderData();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
/** # 函数 登录成功回调:刷新路由数据(loader 重新取用户,进入布局) */
|
||||||
|
async function handleLoginSuccess() {
|
||||||
|
await router.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user || !ADMIN_ROLES.includes(user.role)) {
|
||||||
|
return <AdminLoginPage noPermission={!!user} onOk={handleLoginSuccess} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AdminShell user={user} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 后台登录页(全屏居中卡片,邮箱 + 密码) */
|
||||||
|
function AdminLoginPage(props: { noPermission: boolean; onOk: () => Promise<void> }) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
/** # 函数 登录提交:调用 loginFn,成功回调 onOk,失败展示错误 */
|
||||||
|
async function handleFinish(values: { email: string; password: string }) {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const res = await loginFn({ data: { email: values.email, password: values.password } });
|
||||||
|
if (!res.ok) setError(res.error);
|
||||||
|
else await props.onOk();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "登录失败,请稍后重试");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigProvider theme={{ token: { colorPrimary: "#1677ff" }, cssVar: { key: "dusun" } }}>
|
||||||
|
{/* hashPriority=high:运行时生成的选择器不带哈希前缀,与烘焙 CSS(antd-baked.css)保持一致 */}
|
||||||
|
<StyleProvider hashPriority="high">
|
||||||
|
<App>
|
||||||
|
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "linear-gradient(135deg, #f5f8fb 0%, #e8eef5 100%)" }}>
|
||||||
|
<Card style={{ width: 380, boxShadow: "0 8px 32px rgba(16, 42, 76, 0.12)" }}>
|
||||||
|
<div style={{ textAlign: "center", marginBottom: 24 }}>
|
||||||
|
<LockKeyhole size={34} aria-hidden color="#1677ff" style={{ marginBottom: 10 }} />
|
||||||
|
<h2 style={{ margin: 0, fontSize: 20 }}>DusunIoT 后台</h2>
|
||||||
|
<p style={{ margin: "6px 0 0", color: "#8c8c8c", fontSize: 13 }}>
|
||||||
|
{props.noPermission ? "当前账号无后台权限,请更换管理员账号" : "请使用管理员账号登录"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Form layout="vertical" onFinish={(v) => void handleFinish(v as { email: string; password: string })} requiredMark={false}>
|
||||||
|
<Form.Item name="email" label="邮箱" rules={[{ required: true, message: "请输入邮箱" }]}>
|
||||||
|
<Input type="email" placeholder="admin@example.com" autoComplete="email" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="password" label="密码" rules={[{ required: true, message: "请输入密码" }]}>
|
||||||
|
<Input.Password placeholder="密码" autoComplete="current-password" />
|
||||||
|
</Form.Item>
|
||||||
|
{error ? (
|
||||||
|
<div role="alert" style={{ color: "#cf1322", background: "#fff1f0", border: "1px solid #ffa39e", borderRadius: 6, padding: "8px 12px", marginBottom: 16, fontSize: 13 }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</App>
|
||||||
|
</StyleProvider>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 后台已授权布局外壳
|
||||||
|
* 顶部 Header 展示当前用户与退出按钮;左侧菜单按当前路径高亮;Outlet 渲染子页面。
|
||||||
|
* 注意:App.useApp() 必须在 <App> 内部的子组件中调用,故用户区拆为 UserBadge。
|
||||||
|
*/
|
||||||
|
function AdminShell({ user }: { user: { displayName: string | null; email: string; role: string } }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||||
|
|
||||||
|
/** # 成员变量 当前应高亮的菜单 key:取最长匹配的菜单路径 **/
|
||||||
|
const selectedKey = MENU_ITEMS.map((m) => m.key)
|
||||||
|
.filter((k) => pathname === k || pathname.startsWith(k + "/"))
|
||||||
|
.sort((a, b) => b.length - a.length)[0] ?? "/admin/";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigProvider theme={{ token: { colorPrimary: "#1677ff" }, cssVar: { key: "dusun" } }}>
|
||||||
|
<StyleProvider hashPriority="high">
|
||||||
|
<App>
|
||||||
|
<Layout style={{ minHeight: "100vh" }}>
|
||||||
|
<Layout.Sider collapsible collapsed={collapsed} onCollapse={setCollapsed} width={200}>
|
||||||
|
<div style={{ height: 48, display: "flex", alignItems: "center", justifyContent: "center", color: "#fff", fontWeight: 600, fontSize: 16, letterSpacing: 1 }}>
|
||||||
|
{collapsed ? "D" : "DusunIoT 后台"}
|
||||||
|
</div>
|
||||||
|
<Menu
|
||||||
|
theme="dark"
|
||||||
|
mode="inline"
|
||||||
|
items={MENU_ITEMS.map((m) => ({ key: m.key, icon: m.icon, label: m.label }))}
|
||||||
|
selectedKeys={[selectedKey]}
|
||||||
|
onClick={({ key }) => void navigate({ to: key as MenuKey })}
|
||||||
|
/>
|
||||||
|
</Layout.Sider>
|
||||||
|
<Layout>
|
||||||
|
<Layout.Header style={{ background: "#fff", display: "flex", justifyContent: "flex-end", alignItems: "center", paddingInline: 24, borderBottom: "1px solid #f0f0f0" }}>
|
||||||
|
<UserBadge user={user} />
|
||||||
|
</Layout.Header>
|
||||||
|
<Layout.Content style={{ margin: 16 }}>
|
||||||
|
<Outlet />
|
||||||
|
</Layout.Content>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
</App>
|
||||||
|
</StyleProvider>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 顶部用户区(在 <App> 内渲染,可安全使用 App.useApp 的 message) */
|
||||||
|
function UserBadge({ user }: { user: { displayName: string | null; email: string; role: string } }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const router = useRouter();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
|
||||||
|
/** # 函数 退出登录:注销会话 → 刷新路由数据 → 回到 /admin */
|
||||||
|
async function handleLogout() {
|
||||||
|
await logoutFn();
|
||||||
|
message.success("已退出登录");
|
||||||
|
await router.invalidate();
|
||||||
|
void navigate({ to: "/admin/" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space size={12}>
|
||||||
|
<Avatar size="small" icon={<UserRound size={14} aria-hidden />} />
|
||||||
|
<span>{user.displayName || user.email}</span>
|
||||||
|
<Tag color={user.role === "admin" ? "blue" : "default"}>{user.role}</Tag>
|
||||||
|
<Button size="small" icon={<LogOut size={14} aria-label="退出登录" />} onClick={() => void handleLogout()}>
|
||||||
|
退出
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* # 路由 /admin/users 用户管理(系统设置,只读)
|
||||||
|
* 用户列表:分页 + 关键字搜索;角色用 Tag 展示;不做编辑操作。
|
||||||
|
*/
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { Card, Input, Table, Tag } from "antd";
|
||||||
|
import { SearchOutlined } from "@ant-design/icons";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { adminListUsers } from "~/server/admin";
|
||||||
|
import { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/admin/users")({
|
||||||
|
validateSearch: (search: Record<string, unknown>): AdminSearch => {
|
||||||
|
// 只保留显式传入的参数,缺省时不回写 URL(避免 307 归一化重定向)
|
||||||
|
const out: AdminSearch = {};
|
||||||
|
const page = Math.floor(Number(search.page)) || 0;
|
||||||
|
if (page > 1) out.page = page;
|
||||||
|
if (typeof search.q === "string" && search.q) out.q = search.q;
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
loaderDeps: ({ search }) => ({ search }),
|
||||||
|
loader: ({ deps }) => guardAdmin(() => adminListUsers({ data: { page: deps.search.page, q: deps.search.q || undefined } })),
|
||||||
|
component: UsersPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 成员变量 角色中文标签与配色 **/
|
||||||
|
const ROLE_LABEL: Record<string, string> = { admin: "管理员", editor: "编辑", manager: "经理", customer: "客户" };
|
||||||
|
const ROLE_COLOR: Record<string, string> = { admin: "blue", editor: "cyan", manager: "geekblue", customer: "default" };
|
||||||
|
|
||||||
|
/** # 函数 用户列表页面组件 */
|
||||||
|
function UsersPage() {
|
||||||
|
const data = Route.useLoaderData();
|
||||||
|
const search = Route.useSearch();
|
||||||
|
const navigate = Route.useNavigate();
|
||||||
|
const [q, setQ] = useState(search.q ?? "");
|
||||||
|
|
||||||
|
if (!data) return <Forbidden />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="small">
|
||||||
|
<PageHeader
|
||||||
|
title="用户管理"
|
||||||
|
extra={
|
||||||
|
<Input
|
||||||
|
allowClear
|
||||||
|
placeholder="搜索邮箱 / 显示名"
|
||||||
|
prefix={<SearchOutlined aria-hidden />}
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
onPressEnter={() => void navigate({ search: (prev) => ({ ...prev, q, page: 1 }) })}
|
||||||
|
style={{ width: 240 }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
dataSource={data.items}
|
||||||
|
locale={{ emptyText: <AdminEmpty /> }}
|
||||||
|
pagination={tablePagination(data.total, data.page, data.pageSize, (p) =>
|
||||||
|
void navigate({ search: (prev) => ({ ...prev, page: p }) }),
|
||||||
|
)}
|
||||||
|
columns={[
|
||||||
|
{ title: "ID", dataIndex: "id", width: 80 },
|
||||||
|
{ title: "邮箱", dataIndex: "email", width: 260, ellipsis: true },
|
||||||
|
{ title: "用户名", dataIndex: "username", width: 160, ellipsis: true },
|
||||||
|
{ title: "显示名", dataIndex: "displayName", width: 180, ellipsis: true },
|
||||||
|
{
|
||||||
|
title: "角色",
|
||||||
|
dataIndex: "role",
|
||||||
|
width: 110,
|
||||||
|
render: (v: string) => <Tag color={ROLE_COLOR[v] ?? "default"}>{ROLE_LABEL[v] ?? v}</Tag>,
|
||||||
|
},
|
||||||
|
{ title: "注册时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* # 路由 文章详情 /blog/$slug/
|
||||||
|
* 原站模板形态:左主栏(白卡正文 + 目录)+ 右侧栏(社交图标 / CONTACT US 表单 /
|
||||||
|
* IoT Gateways 产品分组折叠菜单)+ 底部 Related Posts;Elementor 文章通栏直出。
|
||||||
|
*/
|
||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||||
|
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
|
||||||
|
import { InquiryForm } from "~/components/website/InquiryForm";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "~/components/ui/accordion";
|
||||||
|
import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from "~/components/ui/breadcrumb";
|
||||||
|
import { getPublishedContent, getMenuTree, listPosts, type MenuNode, type PostListItem } from "~/server/data";
|
||||||
|
import { useT } from "~/i18n";
|
||||||
|
|
||||||
|
/** # 组件 社交品牌图标(lucide 新版已移除品牌图标,内联 SVG) */
|
||||||
|
function BrandIcon(props: { label: string; bg: string; path: string }) {
|
||||||
|
return (
|
||||||
|
<a href="#" aria-label={props.label} className="flex h-9 w-9 items-center justify-center rounded text-white" style={{ background: props.bg }}>
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden>
|
||||||
|
<path d={props.path} />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/blog/$slug")({
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
const [layout, post, latest, productMenu] = await Promise.all([
|
||||||
|
getLayoutData(),
|
||||||
|
getPublishedContent(params.slug, "post").catch(() => null),
|
||||||
|
listPosts(1, 6).catch(() => ({ items: [] as PostListItem[], total: 0, page: 1, pageSize: 6 })),
|
||||||
|
getMenuTree("menu-1").catch(() => [] as MenuNode[]),
|
||||||
|
]);
|
||||||
|
// 侧栏 IoT Gateways 折叠组 = 主菜单 Products 分支的子分组(By Protocols / Applications ...)
|
||||||
|
const gwGroups = productMenu.find((n) => n.title.trim().toLowerCase() === "products")?.children ?? [];
|
||||||
|
if (!post) return { layout, post: null, related: [] as PostListItem[], gwGroups };
|
||||||
|
// meta 收窄为具体 JSON 类型
|
||||||
|
const meta = (post.meta ?? null) as Record<string, unknown> | null;
|
||||||
|
const terms = post.terms.map((t) => ({ name: t.name, slug: t.slug, taxonomy: t.taxonomy }));
|
||||||
|
const related = latest.items.filter((p) => p.slug !== params.slug).slice(0, 4);
|
||||||
|
return { layout, post: { ...post, meta, terms }, related, gwGroups };
|
||||||
|
},
|
||||||
|
head: ({ loaderData }) => ({
|
||||||
|
meta: [
|
||||||
|
{ title: loaderData?.post ? loaderData.post.seoTitle || `${loaderData.post.title} | Dusun IoT Blog` : "Post Not Found | Dusun IoT" },
|
||||||
|
{ name: "description", content: loaderData?.post?.seoDescription || loaderData?.post?.excerpt || "Blog post from Dusun IoT." },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
component: BlogPostPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 组件 文章详情页 */
|
||||||
|
function BlogPostPage() {
|
||||||
|
const t = useT();
|
||||||
|
const data = Route.useLoaderData();
|
||||||
|
if (!data.post) {
|
||||||
|
return (
|
||||||
|
<Layout layout={data.layout}>
|
||||||
|
<div className="pt-[70px] pb-10 text-center">
|
||||||
|
<h1 className="m-0 text-[64px] text-brand">404</h1>
|
||||||
|
<p>{t("blog_not_found")}</p>
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/blog/">{t("blog_back")}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const post = data.post;
|
||||||
|
const cats = post.terms.filter((t) => t.taxonomy === "category");
|
||||||
|
const elementor = extractElementor(post.meta);
|
||||||
|
// Elementor 文章(原站 full-canvas 模板):正文通栏直出,不加面包屑/h1/日期
|
||||||
|
if (elementor) {
|
||||||
|
return (
|
||||||
|
<Layout layout={data.layout}>
|
||||||
|
<ElementorRenderer doc={elementor} />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// HTML 文章:从正文提取 h2/h3 生成目录(并注入锚点 id)
|
||||||
|
const toc = post.html ? buildToc(post.html) : null;
|
||||||
|
return (
|
||||||
|
<Layout layout={data.layout}>
|
||||||
|
<div className="site-container">
|
||||||
|
<Breadcrumb aria-label="Breadcrumb">
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem><BreadcrumbLink to="/">Home</BreadcrumbLink></BreadcrumbItem>
|
||||||
|
<BreadcrumbSeparator />
|
||||||
|
<BreadcrumbItem><BreadcrumbLink to="/blog/">Blog</BreadcrumbLink></BreadcrumbItem>
|
||||||
|
<BreadcrumbSeparator />
|
||||||
|
<BreadcrumbItem><BreadcrumbPage>{post.title}</BreadcrumbPage></BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
<div className="grid grid-cols-1 items-start gap-7 min-[1080px]:grid-cols-[1fr_340px]">
|
||||||
|
{/* 主栏:白卡正文(目录 + 内容) */}
|
||||||
|
<article className="rounded-lg bg-white p-6 min-[760px]:p-9">
|
||||||
|
<h1 className="mt-0 text-[30px] leading-[1.35]">{post.title}</h1>
|
||||||
|
<div className="mb-5 text-[13px] text-muted-foreground">
|
||||||
|
{post.publishedAt ? new Date(post.publishedAt).toISOString().slice(0, 10) : ""}
|
||||||
|
{cats.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{" · "}
|
||||||
|
{cats.map((c, i) => (
|
||||||
|
<span key={c.slug}>
|
||||||
|
{i > 0 ? ", " : ""}
|
||||||
|
{c.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{post.featuredUrl ? (
|
||||||
|
<img src={post.featuredUrl} alt={post.featuredAlt || post.title} className="my-3 mb-5 w-full rounded-lg" />
|
||||||
|
) : null}
|
||||||
|
{toc && toc.items.length >= 3 ? (
|
||||||
|
<Accordion type="single" collapsible defaultValue="toc" className="mb-6 rounded-md border px-4">
|
||||||
|
<AccordionItem value="toc" className="border-b">
|
||||||
|
<AccordionTrigger>{t("blog_toc")}</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<ul className="m-0 list-none p-0">
|
||||||
|
{toc.items.map((it) => (
|
||||||
|
<li key={it.id} className={it.level === 3 ? "ml-5" : ""}>
|
||||||
|
<a href={`#${it.id}`} className="block py-1 text-[14px] text-brand hover:underline">
|
||||||
|
{it.text}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
) : null}
|
||||||
|
{elementor ? (
|
||||||
|
<ElementorRenderer doc={elementor} />
|
||||||
|
) : post.html ? (
|
||||||
|
<div className="content-html" dangerouslySetInnerHTML={{ __html: toc?.html || post.html }} />
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground">{post.excerpt || t("blog_importing")}</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{/* 侧栏:社交图标 / CONTACT US 表单 / IoT Gateways 产品分组 */}
|
||||||
|
<aside className="space-y-5">
|
||||||
|
<div className="flex justify-center gap-3 rounded-lg bg-white p-4">
|
||||||
|
<BrandIcon label="LinkedIn" bg="#0a66c2" path="M20.45 20.45h-3.55v-5.57c0-1.33-.03-3.04-1.85-3.04-1.86 0-2.14 1.45-2.14 2.94v5.67H9.35V9h3.41v1.56h.05c.47-.9 1.63-1.85 3.36-1.85 3.6 0 4.27 2.37 4.27 5.45v6.29zM5.34 7.43a2.06 2.06 0 1 1 0-4.12 2.06 2.06 0 0 1 0 4.12zM7.12 20.45H3.56V9h3.56v11.45z" />
|
||||||
|
<BrandIcon label="YouTube" bg="#ff0000" path="M23.5 6.19a3.02 3.02 0 0 0-2.12-2.14C19.5 3.55 12 3.55 12 3.55s-7.5 0-9.38.5A3.02 3.02 0 0 0 .5 6.19C0 8.07 0 12 0 12s0 3.93.5 5.81a3.02 3.02 0 0 0 2.12 2.14c1.88.5 9.38.5 9.38.5s7.5 0 9.38-.5a3.02 3.02 0 0 0 2.12-2.14C24 15.93 24 12 24 12s0-3.93-.5-5.81zM9.55 15.57V8.43L15.82 12l-6.27 3.57z" />
|
||||||
|
<BrandIcon label="Facebook" bg="#1877f2" path="M24 12.07C24 5.4 18.63 0 12 0S0 5.4 0 12.07C0 18.1 4.39 23.09 10.13 24v-8.44H7.08v-3.49h3.05V9.41c0-3.02 1.79-4.7 4.53-4.7 1.31 0 2.68.24 2.68.24v2.97h-1.51c-1.49 0-1.95.93-1.95 1.89v2.26h3.32l-.53 3.49h-2.79V24C19.61 23.09 24 18.1 24 12.07z" />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-white p-5">
|
||||||
|
<InquiryForm formKey="contact-us-form-sidebar" productInterest={post.title} title={t("blog_contact_title")} />
|
||||||
|
</div>
|
||||||
|
{data.gwGroups.length > 0 ? (
|
||||||
|
<div className="rounded-lg bg-white p-5">
|
||||||
|
<h3 className="mt-0 text-[20px] font-bold text-brand">{t("blog_gateways")}</h3>
|
||||||
|
<Accordion type="multiple" className="border-t">
|
||||||
|
{data.gwGroups.map((g) => (
|
||||||
|
<AccordionItem key={g.id} value={`gw-${g.id}`}>
|
||||||
|
<AccordionTrigger className="text-[15px] font-medium">{g.title}</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<ul className="m-0 list-none p-0">
|
||||||
|
{g.children.map((c) => (
|
||||||
|
<li key={c.id} className="py-1">
|
||||||
|
<a href={c.url} className="text-[14px] text-muted-foreground hover:text-brand hover:underline">{c.title}</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部 Related Posts(原站:4 列卡片,灰渐变占位 + 标题) */}
|
||||||
|
{data.related.length > 0 ? (
|
||||||
|
<section className="my-12">
|
||||||
|
<h2 className="mb-6 text-center text-[26px]">{t("blog_related_posts")}</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-5 min-[960px]:grid-cols-4">
|
||||||
|
{data.related.map((p) => (
|
||||||
|
<Link key={p.id} to="/blog/$slug/" params={{ slug: p.slug }} className="block">
|
||||||
|
<div className="mb-3 aspect-[4/3] overflow-hidden rounded-md bg-gradient-to-b from-[#9aa3ad] to-[#c9ced4]">
|
||||||
|
{p.imageUrl ? <img src={p.imageUrl} alt={p.title} loading="lazy" className="h-full w-full object-cover" /> : null}
|
||||||
|
</div>
|
||||||
|
<div className="text-[15px] leading-[1.45] font-medium hover:text-brand">{p.title}</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 从 HTML 提取 h2/h3 生成目录条目,并给标题注入锚点 id
|
||||||
|
* 参数 html 文章正文 HTML
|
||||||
|
* 返回值 items 目录条目(level/text/id);html 注入 id 后的正文
|
||||||
|
*/
|
||||||
|
function buildToc(html: string): { items: { level: number; text: string; id: string }[]; html: string } {
|
||||||
|
const items: { level: number; text: string; id: string }[] = [];
|
||||||
|
let i = 0;
|
||||||
|
const out = html.replace(/<h([23])([^>]*)>([\s\S]*?)<\/h\1>/gi, (m, lvl: string, attrs: string, inner: string) => {
|
||||||
|
const text = inner.replace(/<[^>]*>/g, "").trim();
|
||||||
|
if (!text || items.length >= 40) return m;
|
||||||
|
const id = `toc-${i++}`;
|
||||||
|
items.push({ level: Number(lvl), text, id });
|
||||||
|
return `<h${lvl} id="${id}"${attrs}>${inner}</h${lvl}>`;
|
||||||
|
});
|
||||||
|
return { items, html: out };
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* # 路由 博客列表 /blog/
|
||||||
|
* 文章卡片分页列表(图/标题/摘要/日期)。
|
||||||
|
*/
|
||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||||
|
import { Pagination } from "~/components/website/Pagination";
|
||||||
|
import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from "~/components/ui/breadcrumb";
|
||||||
|
import { listPosts, getPostCategories } from "~/server/data";
|
||||||
|
import { useT } from "~/i18n";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/blog/")({
|
||||||
|
validateSearch: (search: Record<string, unknown>): { page?: number } => {
|
||||||
|
const page = Number(search.page);
|
||||||
|
return { page: Number.isFinite(page) && page > 0 ? Math.floor(page) : undefined };
|
||||||
|
},
|
||||||
|
loaderDeps: ({ search }) => ({ page: search.page }),
|
||||||
|
loader: async ({ deps }) => {
|
||||||
|
const [layout, list, cats] = await Promise.all([
|
||||||
|
getLayoutData(),
|
||||||
|
listPosts(deps.page || 1, 12).catch(() => ({ items: [], total: 0, page: 1, pageSize: 12 })),
|
||||||
|
getPostCategories().catch(() => []),
|
||||||
|
]);
|
||||||
|
return { layout, list, cats };
|
||||||
|
},
|
||||||
|
head: () => ({
|
||||||
|
meta: [
|
||||||
|
{ title: "Blog | IoT Knowledge & News | Dusun IoT" },
|
||||||
|
{ name: "description", content: "IoT guides, industry insights, product news and tutorials from Dusun IoT experts." },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
component: BlogIndexPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 组件 博客列表页 */
|
||||||
|
function BlogIndexPage() {
|
||||||
|
const t = useT();
|
||||||
|
const data = Route.useLoaderData();
|
||||||
|
const page = data.list.page;
|
||||||
|
return (
|
||||||
|
<Layout layout={data.layout}>
|
||||||
|
<div className="bg-[#f5f5f6] px-4 pt-[34px] pb-[30px] text-center">
|
||||||
|
<h1 className="m-0 text-[34px] tracking-wide">BLOG</h1>
|
||||||
|
</div>
|
||||||
|
<div className="site-container">
|
||||||
|
<Breadcrumb aria-label="Breadcrumb">
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem><BreadcrumbLink to="/">Home</BreadcrumbLink></BreadcrumbItem>
|
||||||
|
<BreadcrumbSeparator />
|
||||||
|
<BreadcrumbItem><BreadcrumbPage>{t("nav_blog")}</BreadcrumbPage></BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
{data.cats.length > 0 ? (
|
||||||
|
<div className="mt-3 mb-6 flex flex-wrap gap-2.5">
|
||||||
|
{data.cats
|
||||||
|
.filter((c) => Number(c.count) > 0)
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((c) => (
|
||||||
|
<span key={c.slug} className="inline-block rounded-[4px] border border-brand bg-white px-3 py-[5px] text-[13px] font-semibold text-brand">
|
||||||
|
{c.name} ({Number(c.count)})
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="grid grid-cols-1 gap-5 min-[960px]:grid-cols-3">
|
||||||
|
{data.list.items.map((post) => (
|
||||||
|
<Link key={post.id} to="/blog/$slug/" params={{ slug: post.slug }} className="block overflow-hidden rounded-lg border border-line bg-white text-brand transition-[box-shadow,transform] duration-200 hover:-translate-y-0.5 hover:shadow-[0_8px_24px_rgba(16,42,76,0.1)]">
|
||||||
|
{/* 原站卡片:无特色图时显示灰色渐变占位 + 右上角分类徽章 */}
|
||||||
|
<div className="relative aspect-[4/3] overflow-hidden">
|
||||||
|
{post.imageUrl ? (
|
||||||
|
<img src={post.imageUrl} alt={post.title} loading="lazy" className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full bg-gradient-to-b from-[#9aa3ad] to-[#c9ced4]" />
|
||||||
|
)}
|
||||||
|
<span className="absolute top-3 right-3 rounded-full bg-brand px-3 py-1 text-xs font-semibold text-white">
|
||||||
|
{post.categoryName || "Blog"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 pt-3.5 pb-[18px]">
|
||||||
|
<div className="mb-1.5 text-[15px] leading-[1.45] font-semibold">{post.title}</div>
|
||||||
|
{post.excerpt ? <p className="my-1.5 text-sm text-muted-foreground">{post.excerpt.slice(0, 140)}…</p> : null}
|
||||||
|
<div className="text-[13px] text-muted-foreground">
|
||||||
|
{post.authorName ? `${post.authorName} · ` : ""}
|
||||||
|
{post.publishedAt ? new Date(post.publishedAt).toISOString().slice(0, 10) : ""}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2.5 text-[13px] font-bold">{t("blog_read_more")}</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{data.list.items.length === 0 ? <p className="py-[60px] text-center text-muted-foreground">{t("blog_no_posts")}</p> : null}
|
||||||
|
</div>
|
||||||
|
<Pagination page={page} total={data.list.total} pageSize={data.list.pageSize} makeHref={(p) => `/blog/?page=${p}`} />
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* # 路由 /cart 购物车页
|
||||||
|
* client loader 读取购物车;数量增减 / 删除通过 server function + router.invalidate() 刷新。
|
||||||
|
*/
|
||||||
|
import { createFileRoute, Link, useRouter } from "@tanstack/react-router";
|
||||||
|
import { getCart, updateCartItem, removeCartItem } from "~/server/commerce";
|
||||||
|
import { useT } from "~/i18n";
|
||||||
|
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/cart")({
|
||||||
|
loader: async () => {
|
||||||
|
const [layout, cart] = await Promise.all([getLayoutData(), getCart()]);
|
||||||
|
return { layout, cart };
|
||||||
|
},
|
||||||
|
head: () => ({ meta: [{ title: "Shopping Cart - DusunIoT" }] }),
|
||||||
|
component: CartPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 函数 组件 购物车页面 */
|
||||||
|
function CartPage() {
|
||||||
|
const t = useT();
|
||||||
|
const { layout, cart } = Route.useLoaderData();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
/** # 函数 修改数量(qty<=0 删除) */
|
||||||
|
async function changeQty(itemId: number, qty: number) {
|
||||||
|
await updateCartItem({ data: { itemId, qty } });
|
||||||
|
router.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 删除明细 */
|
||||||
|
async function handleRemove(itemId: number) {
|
||||||
|
await removeCartItem({ data: { itemId } });
|
||||||
|
router.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout layout={layout}>
|
||||||
|
<div className="site-container">
|
||||||
|
<h1 className="mt-5 mb-3.5 text-[26px]">Shopping Cart</h1>
|
||||||
|
{cart.items.length === 0 ? (
|
||||||
|
<div className="py-[60px] text-center text-muted-foreground">
|
||||||
|
<p>{t("cart_empty")}</p>
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/shop/">{t("cart_continue")}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<table className="w-full border-collapse bg-white text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="border-b border-line bg-soft px-2.5 py-3 text-left align-middle font-semibold whitespace-nowrap">Product</th>
|
||||||
|
<th className="border-b border-line bg-soft px-2.5 py-3 text-left align-middle font-semibold whitespace-nowrap">Price</th>
|
||||||
|
<th className="border-b border-line bg-soft px-2.5 py-3 text-left align-middle font-semibold whitespace-nowrap">Quantity</th>
|
||||||
|
<th className="border-b border-line bg-soft px-2.5 py-3 text-left align-middle font-semibold whitespace-nowrap">Subtotal</th>
|
||||||
|
<th className="border-b border-line bg-soft px-2.5 py-3 text-left align-middle font-semibold whitespace-nowrap"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{cart.items.map((line) => (
|
||||||
|
<tr key={line.id}>
|
||||||
|
<td className="border-b border-line px-2.5 py-3 text-left align-middle">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<img src={line.imageUrl ?? "/uploads/woocommerce-placeholder.png"} alt={line.title} className="w-14 rounded-md border border-line bg-white object-contain p-[3px]" />
|
||||||
|
<Link to="/product/$slug/" params={{ slug: line.slug }}>
|
||||||
|
{line.title}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="border-b border-line px-2.5 py-3 text-left align-middle">${Number(line.price).toFixed(2)}</td>
|
||||||
|
<td className="border-b border-line px-2.5 py-3 text-left align-middle">
|
||||||
|
<span className="inline-flex items-center overflow-hidden rounded-[4px] border border-line">
|
||||||
|
<button onClick={() => changeQty(line.id, line.qty - 1)} aria-label="Decrease" type="button" className="h-8 w-[30px] cursor-pointer border-0 bg-soft text-base text-ink hover:bg-line">−</button>
|
||||||
|
<span className="min-w-10 text-center text-sm">{line.qty}</span>
|
||||||
|
<button onClick={() => changeQty(line.id, line.qty + 1)} aria-label="Increase" type="button" className="h-8 w-[30px] cursor-pointer border-0 bg-soft text-base text-ink hover:bg-line">+</button>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="border-b border-line px-2.5 py-3 text-left align-middle">${(Number(line.price) * line.qty).toFixed(2)}</td>
|
||||||
|
<td className="border-b border-line px-2.5 py-3 text-left align-middle">
|
||||||
|
<button className="cursor-pointer border-0 bg-none text-[13px] text-muted-foreground underline hover:text-[#b91c1c]" onClick={() => handleRemove(line.id)} type="button">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="mt-[18px] flex flex-wrap items-center justify-end gap-[18px]">
|
||||||
|
<span className="text-[17px] font-bold">Subtotal: ${cart.subtotal}</span>
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/checkout/">{t("cart_checkout")}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
/**
|
||||||
|
* # 路由 /checkout 结账页
|
||||||
|
* 未登录:内联登录 / 注册表单(复用 ~/server/auth 的 loginFn/registerFn)。
|
||||||
|
* 已登录:账单信息表单 → createOrder(银行转账占位支付)→ 成功页展示订单号。
|
||||||
|
*/
|
||||||
|
import { createFileRoute, Link, useRouter } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { getCart, createOrder, type BillingInfo } from "~/server/commerce";
|
||||||
|
import { useT } from "~/i18n";
|
||||||
|
import { getCurrentUser, loginFn, registerFn } from "~/server/auth";
|
||||||
|
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/checkout")({
|
||||||
|
loader: async () => {
|
||||||
|
const [layout, user, cart] = await Promise.all([getLayoutData(), getCurrentUser(), getCart()]);
|
||||||
|
return { layout, user, cart };
|
||||||
|
},
|
||||||
|
head: () => ({ meta: [{ title: "Checkout - DusunIoT" }] }),
|
||||||
|
component: CheckoutPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 函数 组件 结账页面(外壳:公共布局) */
|
||||||
|
function CheckoutPage() {
|
||||||
|
const { layout } = Route.useLoaderData();
|
||||||
|
return (
|
||||||
|
<Layout layout={layout}>
|
||||||
|
<CheckoutInner />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 组件 结账表单区 */
|
||||||
|
function CheckoutInner() {
|
||||||
|
const t = useT();
|
||||||
|
const { user, cart } = Route.useLoaderData();
|
||||||
|
const router = useRouter();
|
||||||
|
const [authError, setAuthError] = useState("");
|
||||||
|
const [orderNumber, setOrderNumber] = useState("");
|
||||||
|
const [orderError, setOrderError] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
/** # 函数 登录提交(成功后 invalidate 以带会话重跑 loader) */
|
||||||
|
async function handleLogin(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setAuthError("");
|
||||||
|
const fd = new FormData(e.currentTarget);
|
||||||
|
const res = await loginFn({ data: { email: String(fd.get("email") ?? ""), password: String(fd.get("password") ?? "") } });
|
||||||
|
if (!res.ok) return setAuthError(res.error);
|
||||||
|
router.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 注册提交 */
|
||||||
|
async function handleRegister(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setAuthError("");
|
||||||
|
const fd = new FormData(e.currentTarget);
|
||||||
|
const res = await registerFn({
|
||||||
|
data: {
|
||||||
|
email: String(fd.get("email") ?? ""),
|
||||||
|
password: String(fd.get("password") ?? ""),
|
||||||
|
displayName: String(fd.get("displayName") ?? "") || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) return setAuthError(res.error);
|
||||||
|
router.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 下单提交 */
|
||||||
|
async function handlePlaceOrder(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setOrderError("");
|
||||||
|
setSubmitting(true);
|
||||||
|
const fd = new FormData(e.currentTarget);
|
||||||
|
const billing: BillingInfo = {
|
||||||
|
firstName: String(fd.get("firstName") ?? ""),
|
||||||
|
lastName: String(fd.get("lastName") ?? ""),
|
||||||
|
email: String(fd.get("email") ?? ""),
|
||||||
|
phone: String(fd.get("phone") ?? ""),
|
||||||
|
address: String(fd.get("address") ?? ""),
|
||||||
|
city: String(fd.get("city") ?? ""),
|
||||||
|
country: String(fd.get("country") ?? ""),
|
||||||
|
postcode: String(fd.get("postcode") ?? ""),
|
||||||
|
};
|
||||||
|
const shippingSame = fd.get("shippingSame") === "on";
|
||||||
|
const shipping = shippingSame
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
firstName: String(fd.get("shipFirstName") ?? ""),
|
||||||
|
lastName: String(fd.get("shipLastName") ?? ""),
|
||||||
|
email: billing.email,
|
||||||
|
address: String(fd.get("shipAddress") ?? ""),
|
||||||
|
city: String(fd.get("shipCity") ?? ""),
|
||||||
|
country: String(fd.get("shipCountry") ?? ""),
|
||||||
|
postcode: String(fd.get("shipPostcode") ?? ""),
|
||||||
|
};
|
||||||
|
const res = await createOrder({
|
||||||
|
data: { billing, shippingSame, shipping, note: String(fd.get("note") ?? "") },
|
||||||
|
});
|
||||||
|
setSubmitting(false);
|
||||||
|
if (!res.ok) return setOrderError(res.error);
|
||||||
|
setOrderNumber(res.orderNumber);
|
||||||
|
router.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下单成功页
|
||||||
|
if (orderNumber) {
|
||||||
|
return (
|
||||||
|
<div className="site-container">
|
||||||
|
<h1 className="mt-5 mb-3.5 text-[26px]">Order received</h1>
|
||||||
|
<div className="max-w-[560px] rounded-md border border-[#b5e3c4] bg-[#e7f6ec] px-4 py-3 text-[#15803d]">
|
||||||
|
Thank you! Your order <strong>{orderNumber}</strong> has been placed.
|
||||||
|
</div>
|
||||||
|
<p className="mt-4">
|
||||||
|
Payment method: bank transfer (our sales team will contact you with payment instructions).
|
||||||
|
</p>
|
||||||
|
<p className="flex gap-2.5">
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/my-account/">{t("checkout_view_orders")}</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link to="/shop/">{t("cart_continue")}</Link>
|
||||||
|
</Button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未登录:内联登录 / 注册
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="site-container">
|
||||||
|
<h1 className="mt-5 mb-3.5 text-[26px]">Checkout</h1>
|
||||||
|
<p>
|
||||||
|
Please log in or create an account to place your order.{" "}
|
||||||
|
{cart.count > 0 && <>{t("checkout_cart_kept", { count: cart.count })}</>}
|
||||||
|
</p>
|
||||||
|
{authError && (
|
||||||
|
<p className="max-w-[420px] rounded-md border border-[#f3c1c1] bg-[#fdeaea] px-4 py-3 text-[#b91c1c]">
|
||||||
|
{authError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 grid grid-cols-1 items-start gap-7 min-[860px]:grid-cols-[minmax(0,7fr)_minmax(0,4fr)]">
|
||||||
|
<div className="rounded-lg border border-line bg-soft p-5">
|
||||||
|
<h3 className="mt-0">Log in</h3>
|
||||||
|
<form className="grid grid-cols-1 gap-3.5 min-[640px]:grid-cols-2" onSubmit={handleLogin}>
|
||||||
|
<Label className="col-span-full">
|
||||||
|
Email
|
||||||
|
<Input name="email" type="email" required autoComplete="email" />
|
||||||
|
</Label>
|
||||||
|
<Label className="col-span-full">
|
||||||
|
Password
|
||||||
|
<Input name="password" type="password" required autoComplete="current-password" />
|
||||||
|
</Label>
|
||||||
|
<div className="col-span-full">
|
||||||
|
<Button type="submit">
|
||||||
|
Log in
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-line bg-soft p-5">
|
||||||
|
<h3 className="mt-0">Create account</h3>
|
||||||
|
<form className="grid grid-cols-1 gap-3.5 min-[640px]:grid-cols-2" onSubmit={handleRegister}>
|
||||||
|
<Label className="col-span-full">
|
||||||
|
Display name (optional)
|
||||||
|
<Input name="displayName" type="text" />
|
||||||
|
</Label>
|
||||||
|
<Label className="col-span-full">
|
||||||
|
Email
|
||||||
|
<Input name="email" type="email" required autoComplete="email" />
|
||||||
|
</Label>
|
||||||
|
<Label className="col-span-full">
|
||||||
|
Password (min 8 chars)
|
||||||
|
<Input name="password" type="password" required minLength={8} autoComplete="new-password" />
|
||||||
|
</Label>
|
||||||
|
<div className="col-span-full">
|
||||||
|
<Button type="submit" variant="outline">
|
||||||
|
Register
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 购物车为空
|
||||||
|
if (cart.items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="site-container">
|
||||||
|
<h1 className="mt-5 mb-3.5 text-[26px]">Checkout</h1>
|
||||||
|
<div className="py-[60px] text-center text-muted-foreground">
|
||||||
|
<p>{t("cart_empty")}</p>
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/shop/">{t("checkout_browse")}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已登录:账单表单 + 订单汇总
|
||||||
|
return (
|
||||||
|
<div className="site-container">
|
||||||
|
<h1 className="mt-5 mb-3.5 text-[26px]">Checkout</h1>
|
||||||
|
{orderError ? <p className="rounded-md border border-[#f3c1c1] bg-[#fdeaea] px-4 py-3 text-[#b91c1c]">{orderError}</p> : null}
|
||||||
|
<div className="mt-4 grid grid-cols-1 items-start gap-7 min-[860px]:grid-cols-[minmax(0,7fr)_minmax(0,4fr)]">
|
||||||
|
<form className="grid grid-cols-1 gap-3.5 rounded-lg border border-line bg-soft p-5 min-[640px]:grid-cols-2" onSubmit={handlePlaceOrder}>
|
||||||
|
<h2 className="col-span-full mt-0">Billing details</h2>
|
||||||
|
<Label>
|
||||||
|
First name *
|
||||||
|
<Input name="firstName" required />
|
||||||
|
</Label>
|
||||||
|
<Label>
|
||||||
|
Last name *
|
||||||
|
<Input name="lastName" required />
|
||||||
|
</Label>
|
||||||
|
<Label>
|
||||||
|
Email *
|
||||||
|
<Input name="email" type="email" required defaultValue={user.email} />
|
||||||
|
</Label>
|
||||||
|
<Label>
|
||||||
|
Phone
|
||||||
|
<Input name="phone" type="tel" />
|
||||||
|
</Label>
|
||||||
|
<Label className="col-span-full">
|
||||||
|
Street address *
|
||||||
|
<Input name="address" required />
|
||||||
|
</Label>
|
||||||
|
<Label>
|
||||||
|
City *
|
||||||
|
<Input name="city" required />
|
||||||
|
</Label>
|
||||||
|
<Label>
|
||||||
|
Country *
|
||||||
|
<Input name="country" required />
|
||||||
|
</Label>
|
||||||
|
<Label>
|
||||||
|
Postcode
|
||||||
|
<Input name="postcode" />
|
||||||
|
</Label>
|
||||||
|
<Label className="col-span-full flex-row items-center gap-2">
|
||||||
|
<input name="shippingSame" type="checkbox" defaultChecked />
|
||||||
|
<span>{t("checkout_same_address")}</span>
|
||||||
|
</Label>
|
||||||
|
<Label className="col-span-full">
|
||||||
|
Order notes
|
||||||
|
<Textarea name="note" rows={3} placeholder={t("checkout_notes_ph")} />
|
||||||
|
</Label>
|
||||||
|
<div className="col-span-full">
|
||||||
|
<Button type="submit" disabled={submitting}>
|
||||||
|
{submitting ? "Placing order…" : "Place order"}
|
||||||
|
</Button>
|
||||||
|
<span className="text-[13px] text-muted-foreground">Payment: bank transfer (pay after confirmation)</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div className="rounded-lg border border-line bg-soft p-5">
|
||||||
|
<h3 className="mt-0">Your order</h3>
|
||||||
|
<div className="overflow-hidden rounded-md border border-line bg-white">
|
||||||
|
{cart.items.map((line) => (
|
||||||
|
<div className="flex justify-between gap-3 border-b border-line px-3.5 py-2.5 text-sm" key={line.id}>
|
||||||
|
<span>
|
||||||
|
{line.title} × {line.qty}
|
||||||
|
</span>
|
||||||
|
<span>${(Number(line.price) * line.qty).toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex justify-between gap-3 px-3.5 py-2.5 text-sm font-bold">
|
||||||
|
<span>{t("checkout_subtotal")}</span>
|
||||||
|
<span>${cart.subtotal}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* # 路由 产品分类列表 /collection/$slug/
|
||||||
|
* 侧栏:分类树 + 属性筛选(复选框,URL search params 驱动);
|
||||||
|
* 主区:分类标题 + 产品网格 + 分页。
|
||||||
|
*/
|
||||||
|
import { createFileRoute, Link, useRouter } from "@tanstack/react-router";
|
||||||
|
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||||
|
import { ProductCard } from "~/components/website/ProductCard";
|
||||||
|
import { Pagination } from "~/components/website/Pagination";
|
||||||
|
import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from "~/components/ui/breadcrumb";
|
||||||
|
import { parseCollectionSearch, searchToFilters, filtersToSearch, buildCollectionQuery, type CollectionSearch } from "~/components/website/collection-shared";
|
||||||
|
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
|
||||||
|
import { getProductCategoryTree, getFilterConfig, listProducts, getPublishedContent, type FilterState, type ProductListResult } from "~/server/data";
|
||||||
|
import { useT } from "~/i18n";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/collection/$slug")({
|
||||||
|
validateSearch: parseCollectionSearch as (search: Record<string, unknown>) => CollectionSearch,
|
||||||
|
// 该版本 loader 拿不到 search,需要 loaderDeps 显式声明依赖
|
||||||
|
loaderDeps: ({ search }) => ({ search }),
|
||||||
|
loader: async ({ params, deps }) => {
|
||||||
|
const [layout, cats, filterConfig] = await Promise.all([
|
||||||
|
getLayoutData(),
|
||||||
|
getProductCategoryTree().catch(() => []),
|
||||||
|
getFilterConfig().catch(() => []),
|
||||||
|
]);
|
||||||
|
const list = await listProducts({
|
||||||
|
category: params.slug,
|
||||||
|
filters: searchToFilters(deps.search),
|
||||||
|
page: deps.search.page,
|
||||||
|
pageSize: 24,
|
||||||
|
}).catch(() => ({ items: [], total: 0, page: 1, pageSize: 24 }) as ProductListResult);
|
||||||
|
// 分类名从分类树里找;找不到(无效 slug)也给出列表页(空态)
|
||||||
|
const cat = cats.find((c) => c.slug === params.slug) ?? null;
|
||||||
|
// 原站 /product-category/<slug>/ 直接渲染该分类的 Elementor 营销落地页(内容以
|
||||||
|
// 同 slug 文章形式导入;smart-gateway 这类单复数变体也试)。无落地页才走筛选列表。
|
||||||
|
let landing: Awaited<ReturnType<typeof getPublishedContent>> = null;
|
||||||
|
for (const s of [params.slug, `${params.slug}s`, params.slug.replace(/s$/, "")]) {
|
||||||
|
const row = await getPublishedContent(s, "post").catch(() => null);
|
||||||
|
if (row && extractElementor(row.meta)) {
|
||||||
|
landing = row;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { layout, cats, filterConfig, list, cat, slug: params.slug, landing };
|
||||||
|
},
|
||||||
|
head: ({ loaderData }) => ({
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
title: loaderData?.landing?.seoTitle || loaderData?.landing?.title || (loaderData?.cat ? `${loaderData.cat.name} | Product Category | Dusun IoT` : "Products | Dusun IoT"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
content: loaderData?.landing?.seoDescription || loaderData?.landing?.excerpt || (loaderData?.cat ? `Browse Dusun IoT ${loaderData.cat.name} products: specifications, features and inquiry.` : "Browse Dusun IoT product categories."),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
component: CollectionCategoryPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 类型 分类树节点 */
|
||||||
|
type Cat = { id: number; name: string; slug: string; parentId: number | null; count: number };
|
||||||
|
|
||||||
|
/** # 组件 分类列表页 */
|
||||||
|
function CollectionCategoryPage() {
|
||||||
|
const t = useT();
|
||||||
|
const data = Route.useLoaderData();
|
||||||
|
const search = Route.useSearch();
|
||||||
|
const router = useRouter();
|
||||||
|
const page = data.list.page;
|
||||||
|
const landingElementor = extractElementor(data.landing?.meta ?? null);
|
||||||
|
|
||||||
|
/** # 函数 更新筛选:切换某属性值后 navigate(重置到第 1 页) */
|
||||||
|
function toggleFilter(attrSlug: string, valueSlug: string) {
|
||||||
|
const filters: FilterState = searchToFilters(search);
|
||||||
|
const cur = filters[attrSlug] || [];
|
||||||
|
const next = cur.includes(valueSlug) ? cur.filter((v) => v !== valueSlug) : [...cur, valueSlug];
|
||||||
|
if (next.length) filters[attrSlug] = next;
|
||||||
|
else delete filters[attrSlug];
|
||||||
|
router.navigate({ to: "/collection/$slug/", params: { slug: data.slug }, search: filtersToSearch(filters, 1) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 生成翻页链接(保留当前筛选) */
|
||||||
|
function pageHref(p: number) {
|
||||||
|
return `/collection/${data.slug}/${buildCollectionQuery(searchToFilters(search), p)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout layout={data.layout}>
|
||||||
|
<div className="site-container">
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink to="/">Home</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
<BreadcrumbSeparator />
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink to="/collection/">Products</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
<BreadcrumbSeparator />
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbPage>{data.cat?.name || data.slug}</BreadcrumbPage>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
|
||||||
|
{landingElementor ? (
|
||||||
|
/* 原站分类页形态:直接通栏渲染 Elementor 营销落地页 */
|
||||||
|
<ElementorRenderer doc={landingElementor} />
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 items-start gap-7 min-[900px]:grid-cols-[250px_1fr]">
|
||||||
|
<aside>
|
||||||
|
<div className="mb-[18px] rounded-lg border border-line bg-soft p-4">
|
||||||
|
<h3 className="m-0 mb-2.5 text-[15px]">{t("collection_categories")}</h3>
|
||||||
|
<ul className="m-0 list-none p-0">
|
||||||
|
{data.cats
|
||||||
|
.filter((c) => !c.parentId && c.slug !== "uncategorized")
|
||||||
|
.map((c) => (
|
||||||
|
<CategoryTreeItem key={c.id} c={c} cats={data.cats} active={data.cat ? c.id === data.cat.id || hasChild(data.cats, c, data.cat) : false} currentSlug={data.slug} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{data.filterConfig.map((f) => (
|
||||||
|
<div className="mb-[18px] rounded-lg border border-line bg-soft p-4" key={f.slug}>
|
||||||
|
<h3 className="m-0 mb-2.5 text-[15px]">{f.name}</h3>
|
||||||
|
<ul className="m-0 list-none p-0">
|
||||||
|
{f.values.map((v) => {
|
||||||
|
const checked = (searchToFilters(search)[f.slug] || []).includes(v.slug);
|
||||||
|
return (
|
||||||
|
<li key={v.slug} className="mb-1.5">
|
||||||
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
|
<input type="checkbox" checked={checked} onChange={() => toggleFilter(f.slug, v.slug)} />
|
||||||
|
<span className="flex-1">{v.value}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">({Number(v.count)})</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{f.values.length === 0 ? <li className="text-muted-foreground">{t("collection_no_options")}</li> : null}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="mt-0 text-[26px]">{data.cat?.name || t("nav_products")}</h1>
|
||||||
|
<p className="text-muted-foreground">{t("collection_products_count", { total: data.list.total })}</p>
|
||||||
|
<div className="grid grid-cols-2 gap-[18px] min-[960px]:grid-cols-4">
|
||||||
|
{data.list.items.map((p) => (
|
||||||
|
<ProductCard key={p.id} p={p} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{data.list.items.length === 0 ? (
|
||||||
|
<p className="py-[60px] text-center text-muted-foreground">{Object.keys(searchToFilters(search)).length ? t("collection_no_results_filtered") : t("collection_no_results")}</p>
|
||||||
|
) : null}
|
||||||
|
<Pagination page={page} total={data.list.total} pageSize={data.list.pageSize} makeHref={pageHref} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 判断某分类是否是 activeCat 的祖先 */
|
||||||
|
function hasChild(cats: Cat[], parent: Cat, activeCat: Cat): boolean {
|
||||||
|
let cur = activeCat as Cat | null;
|
||||||
|
while (cur?.parentId) {
|
||||||
|
if (cur.parentId === parent.id) return true;
|
||||||
|
cur = cats.find((c) => c.id === cur!.parentId) ?? null;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 分类树节点(含一级子分类平铺) */
|
||||||
|
function CategoryTreeItem(props: { c: Cat; cats: Cat[]; active: boolean; currentSlug: string }) {
|
||||||
|
const children = props.cats.filter((x) => x.parentId === props.c.id);
|
||||||
|
return (
|
||||||
|
<li className="mb-1.5">
|
||||||
|
<a
|
||||||
|
href={`/collection/${props.c.slug}/`}
|
||||||
|
className={"flex items-center justify-between text-sm " + (props.currentSlug === props.c.slug ? "font-bold text-brand" : props.active ? "font-bold text-ink" : "font-normal text-ink") + " hover:text-brand"}
|
||||||
|
>
|
||||||
|
<span>{props.c.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">({Number(props.c.count)})</span>
|
||||||
|
</a>
|
||||||
|
{children.length > 0 ? (
|
||||||
|
<ul className="my-1 list-none p-0 pl-4">
|
||||||
|
{children.map((ch) => (
|
||||||
|
<li key={ch.id} className="mb-1.5">
|
||||||
|
<a
|
||||||
|
href={`/collection/${ch.slug}/`}
|
||||||
|
className={"flex items-center justify-between text-sm " + (props.currentSlug === ch.slug ? "font-bold text-brand" : "font-normal text-ink") + " hover:text-brand"}
|
||||||
|
>
|
||||||
|
<span>{ch.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">({Number(ch.count)})</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* # 路由 产品列表 /collection/
|
||||||
|
* 列出全部产品(无分类限定)+ 顶部分类卡片;带分页。
|
||||||
|
*/
|
||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||||
|
import { ProductCard } from "~/components/website/ProductCard";
|
||||||
|
import { Pagination } from "~/components/website/Pagination";
|
||||||
|
import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from "~/components/ui/breadcrumb";
|
||||||
|
import { parseCollectionSearch, searchToFilters, type CollectionSearch } from "~/components/website/collection-shared";
|
||||||
|
import { getProductCategoryTree, listProducts, type ProductListResult } from "~/server/data";
|
||||||
|
import { useT } from "~/i18n";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/collection/")({
|
||||||
|
validateSearch: parseCollectionSearch as (search: Record<string, unknown>) => CollectionSearch,
|
||||||
|
loaderDeps: ({ search }) => ({ search }),
|
||||||
|
loader: async ({ deps }) => {
|
||||||
|
const [layout, cats, list] = await Promise.all([
|
||||||
|
getLayoutData(),
|
||||||
|
getProductCategoryTree().catch(() => []),
|
||||||
|
listProducts({ page: deps.search.page, filters: searchToFilters(deps.search), pageSize: 24 }).catch(
|
||||||
|
() => ({ items: [], total: 0, page: 1, pageSize: 24 }) as ProductListResult,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
return { layout, cats, list };
|
||||||
|
},
|
||||||
|
head: () => ({
|
||||||
|
meta: [
|
||||||
|
{ title: "All Products | IoT Gateways & Devices | Dusun IoT" },
|
||||||
|
{ name: "description", content: "Browse all Dusun IoT products: industrial IoT gateways, single board computers, wireless modules and smart IoT devices." },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
component: CollectionIndexPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 组件 全部产品列表页 */
|
||||||
|
function CollectionIndexPage() {
|
||||||
|
const t = useT();
|
||||||
|
const data = Route.useLoaderData();
|
||||||
|
const topCats = data.cats.filter((c) => !c.parentId && c.slug !== "uncategorized");
|
||||||
|
const page = data.list.page;
|
||||||
|
return (
|
||||||
|
<Layout layout={data.layout}>
|
||||||
|
<div className="site-container">
|
||||||
|
<Breadcrumb aria-label="Breadcrumb">
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem><BreadcrumbLink to="/">{t("nav_home")}</BreadcrumbLink></BreadcrumbItem>
|
||||||
|
<BreadcrumbSeparator />
|
||||||
|
<BreadcrumbItem><BreadcrumbPage>{t("nav_products")}</BreadcrumbPage></BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
<h1 className="text-[28px]">{t("nav_all_products")}</h1>
|
||||||
|
<p className="text-muted-foreground">{t("collection_products_count", { total: data.list.total })}</p>
|
||||||
|
|
||||||
|
<section className="mt-5 mb-8">
|
||||||
|
<div className="grid grid-cols-2 gap-[18px] min-[960px]:grid-cols-4">
|
||||||
|
{topCats.map((c) => (
|
||||||
|
<Link key={c.id} to="/collection/$slug/" params={{ slug: c.slug }} className="block overflow-hidden rounded-lg border border-line bg-white text-brand transition-[box-shadow,transform] duration-200 hover:-translate-y-0.5 hover:shadow-[0_8px_24px_rgba(16,42,76,0.1)]">
|
||||||
|
<div className="px-4 pt-3.5 pb-[18px]">
|
||||||
|
<div className="mb-1.5 text-[15px] leading-[1.45] font-semibold">{c.name}</div>
|
||||||
|
<div className="text-[13px] text-muted-foreground">{t("collection_products_count", { total: Number(c.count) })}</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{topCats.length === 0 ? <p className="py-[60px] text-center text-muted-foreground">Product categories are being updated.</p> : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-[18px] min-[960px]:grid-cols-4">
|
||||||
|
{data.list.items.map((p) => (
|
||||||
|
<ProductCard key={p.id} p={p} />
|
||||||
|
))}
|
||||||
|
{data.list.items.length === 0 ? <p className="py-[60px] text-center text-muted-foreground">No products found. They may still be syncing — please check back soon.</p> : null}
|
||||||
|
</div>
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
total={data.list.total}
|
||||||
|
pageSize={data.list.pageSize}
|
||||||
|
makeHref={(p) => `/collection/?page=${p}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* # 路由 联系页 /contact-us/
|
||||||
|
* 有 Elementor 内容则渲染;否则标准布局:联系方式卡片 + 询盘表单。
|
||||||
|
*/
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||||
|
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
|
||||||
|
import { InquiryForm } from "~/components/website/InquiryForm";
|
||||||
|
import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from "~/components/ui/breadcrumb";
|
||||||
|
import { getPublishedContent } from "~/server/data";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/contact-us")({
|
||||||
|
loader: async () => {
|
||||||
|
const [layout, page] = await Promise.all([
|
||||||
|
getLayoutData(),
|
||||||
|
getPublishedContent("contact-us", "page").catch(() => null),
|
||||||
|
]);
|
||||||
|
const meta = (page?.meta ?? null) as Record<string, unknown> | null;
|
||||||
|
return { layout, page, meta };
|
||||||
|
},
|
||||||
|
head: ({ loaderData }) => ({
|
||||||
|
meta: [
|
||||||
|
{ title: loaderData?.page?.seoTitle || "Contact Us | Dusun IoT" },
|
||||||
|
{ name: "description", content: loaderData?.page?.seoDescription || "Contact Dusun IoT for IoT gateway solutions, OEM/ODM customization and technical support. Email sales@dusuniot.com." },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
component: ContactPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 组件 联系页 */
|
||||||
|
function ContactPage() {
|
||||||
|
const data = Route.useLoaderData();
|
||||||
|
const elementor = extractElementor(data.meta);
|
||||||
|
return (
|
||||||
|
<Layout layout={data.layout}>
|
||||||
|
<div className="site-container">
|
||||||
|
{elementor ? (
|
||||||
|
<ElementorRenderer doc={elementor} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Breadcrumb className="pt-6" aria-label="Breadcrumb">
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem><BreadcrumbLink to="/">Home</BreadcrumbLink></BreadcrumbItem>
|
||||||
|
<BreadcrumbSeparator />
|
||||||
|
<BreadcrumbItem><BreadcrumbPage>Contact Us</BreadcrumbPage></BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
<h1 className="mt-0 text-[28px]">Contact Us</h1>
|
||||||
|
<div className="mb-8 grid grid-cols-1 gap-[18px] min-[800px]:grid-cols-3">
|
||||||
|
<div className="rounded-lg border border-line bg-soft p-5">
|
||||||
|
<h3 className="mt-0">Sales & Inquiries</h3>
|
||||||
|
<p>
|
||||||
|
Email: <a href="mailto:sales@dusuniot.com">sales@dusuniot.com</a>
|
||||||
|
<br />
|
||||||
|
Tel: +86 755 2643 0850
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-line bg-soft p-5">
|
||||||
|
<h3 className="mt-0">Visit Us</h3>
|
||||||
|
<p>
|
||||||
|
5F, Building B, Henglong Industrial Park,
|
||||||
|
<br />
|
||||||
|
Xili, Nanshan District, Shenzhen, Guangdong, China
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-line bg-soft p-5">
|
||||||
|
<h3 className="mt-0">Support</h3>
|
||||||
|
<p>24/7 technical support for OEM/ODM partners. Response within 24 hours on business days.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Elementor 版页面(hero 内已含询盘表单)不再追加表单;标准布局才补一个询盘表单 */}
|
||||||
|
{!elementor ? (
|
||||||
|
<section className="mt-4 mb-14">
|
||||||
|
<InquiryForm formKey="contact-us-form" title="Get a Quote" />
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* # 路由 首页 /
|
||||||
|
* 有 Elementor 内容则渲染渲染器;否则渲染后备首页(Hero + 分类卡片 + 最新产品 + 最新文章 + CTA)。
|
||||||
|
*/
|
||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||||
|
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
|
||||||
|
import { ProductCard } from "~/components/website/ProductCard";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { getHomePage, getProductCategoryTree, listProducts, listPosts } from "~/server/data";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/")({
|
||||||
|
loader: async () => {
|
||||||
|
// 并行取布局 + 首页内容 + 后备数据;DB 为空时各自返回空结构(优雅降级)
|
||||||
|
const [layout, home, cats, products, posts] = await Promise.all([
|
||||||
|
getLayoutData(),
|
||||||
|
getHomePage().catch(() => null),
|
||||||
|
getProductCategoryTree().catch(() => []),
|
||||||
|
listProducts({ page: 1, pageSize: 8 }).catch(() => ({ items: [], total: 0, page: 1, pageSize: 8 })),
|
||||||
|
listPosts(1, 3).catch(() => ({ items: [], total: 0, page: 1, pageSize: 3 })),
|
||||||
|
]);
|
||||||
|
const meta = (home?.meta ?? null) as Record<string, unknown> | null;
|
||||||
|
return { layout, home, meta, cats, products, posts };
|
||||||
|
},
|
||||||
|
head: ({ loaderData }) => ({
|
||||||
|
meta: [
|
||||||
|
{ title: loaderData?.home?.seoTitle || "Dusun IoT | IoT Gateway Manufacturer & Solution Provider" },
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
content:
|
||||||
|
loaderData?.home?.seoDescription ||
|
||||||
|
"Dusun IoT is a leading IoT gateway manufacturer offering Zigbee, LoRaWAN, BLE gateways, single board computers and end-to-end IoT solutions.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
component: HomePage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 组件 首页 */
|
||||||
|
function HomePage() {
|
||||||
|
const data = Route.useLoaderData();
|
||||||
|
const elementor = extractElementor(data.meta);
|
||||||
|
return (
|
||||||
|
<Layout layout={data.layout}>
|
||||||
|
{elementor ? (
|
||||||
|
<ElementorRenderer doc={elementor} />
|
||||||
|
) : (
|
||||||
|
<FallbackHome cats={data.cats} products={data.products} posts={data.posts} />
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 类型 后备首页分类卡片数据 */
|
||||||
|
type CatItem = { id: number; name: string; slug: string; parentId: number | null; count: number };
|
||||||
|
|
||||||
|
/** # 成员变量 卡片通用样式(与 ProductCard 一致:悬停抬升 + 品牌色文字) **/
|
||||||
|
const cardCls =
|
||||||
|
"block overflow-hidden rounded-lg border border-line bg-white text-brand transition-[box-shadow,transform] duration-200 hover:-translate-y-0.5 hover:shadow-[0_8px_24px_rgba(16,42,76,0.1)]";
|
||||||
|
|
||||||
|
/** # 组件 后备首页(无 Elementor 数据时) */
|
||||||
|
function FallbackHome(props: { cats: CatItem[]; products: { items: { id: number; slug: string; title: string; sku: string | null; imageUrl: string | null }[] }; posts: { items: { id: number; slug: string; title: string; excerpt: string | null; publishedAt: Date | null; imageUrl: string | null }[] } }) {
|
||||||
|
const topCats = props.cats.filter((c) => !c.parentId && c.slug !== "uncategorized").slice(0, 8);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="bg-[linear-gradient(120deg,#0b1c2c_0%,#123a63_55%,#1667d9_100%)] py-[72px] text-center text-white">
|
||||||
|
<div className="site-container">
|
||||||
|
<h1 className="mt-0 mb-3.5 text-[38px]">Professional IoT Gateway Manufacturer</h1>
|
||||||
|
<p className="mx-auto mb-[26px] max-w-[760px] text-[17px] text-[#cfe0f5]">
|
||||||
|
Dusun IoT provides industrial-grade IoT gateways, single board computers and edge computing solutions
|
||||||
|
with Zigbee, LoRaWAN, BLE, Wi-Fi and cellular connectivity — from hardware to cloud.
|
||||||
|
</p>
|
||||||
|
<Button asChild>
|
||||||
|
<a href="/collection/">Browse Products</a>
|
||||||
|
</Button>{" "}
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<a href="/contact-us/">Get a Quote</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="py-11">
|
||||||
|
<div className="site-container">
|
||||||
|
<h2 className="m-0 mb-2 text-center text-[28px]">Product Categories</h2>
|
||||||
|
<p className="m-0 mb-7 text-center text-muted-foreground">Explore our full range of IoT hardware</p>
|
||||||
|
<div className="grid grid-cols-2 gap-[18px] min-[960px]:grid-cols-4">
|
||||||
|
{topCats.map((c) => (
|
||||||
|
<Link key={c.id} to="/collection/$slug/" params={{ slug: c.slug }} className={cardCls}>
|
||||||
|
<div className="px-4 pt-3.5 pb-[18px]">
|
||||||
|
<div className="mb-1.5 text-[15px] leading-[1.45] font-semibold">{c.name}</div>
|
||||||
|
<div className="text-[13px] text-muted-foreground">{Number(c.count)} products</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{topCats.length === 0 ? <p className="py-[60px] text-center text-muted-foreground">Product categories are being updated. Please check back soon.</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-soft py-11">
|
||||||
|
<div className="site-container">
|
||||||
|
<h2 className="m-0 mb-2 text-center text-[28px]">Latest Products</h2>
|
||||||
|
<p className="m-0 mb-7 text-center text-muted-foreground">Newly released IoT gateways and devices</p>
|
||||||
|
<div className="grid grid-cols-2 gap-[18px] min-[960px]:grid-cols-4">
|
||||||
|
{props.products.items.map((p) => (
|
||||||
|
<ProductCard key={p.id} p={p} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{props.products.items.length === 0 ? <p className="py-[60px] text-center text-muted-foreground">Products are being updated. Please check back soon.</p> : null}
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<a href="/collection/">View All Products</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="py-11">
|
||||||
|
<div className="site-container">
|
||||||
|
<h2 className="m-0 mb-2 text-center text-[28px]">From the Blog</h2>
|
||||||
|
<p className="m-0 mb-7 text-center text-muted-foreground">IoT knowledge, guides and product news</p>
|
||||||
|
<div className="grid grid-cols-1 gap-5 min-[960px]:grid-cols-3">
|
||||||
|
{props.posts.items.map((post) => (
|
||||||
|
<Link key={post.id} to="/blog/$slug/" params={{ slug: post.slug }} className={cardCls}>
|
||||||
|
<div className="flex aspect-[4/3] items-center justify-center bg-white p-3">
|
||||||
|
{post.imageUrl ? <img src={post.imageUrl} alt={post.title} loading="lazy" className="max-h-full object-contain" /> : null}
|
||||||
|
</div>
|
||||||
|
<div className="px-4 pt-3.5 pb-[18px]">
|
||||||
|
<div className="mb-1.5 text-[15px] leading-[1.45] font-semibold">{post.title}</div>
|
||||||
|
<div className="text-[13px] text-muted-foreground">{post.publishedAt ? new Date(post.publishedAt).toISOString().slice(0, 10) : ""}</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{props.posts.items.length === 0 ? <p className="py-[60px] text-center text-muted-foreground">Blog posts are coming soon.</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-[linear-gradient(120deg,#0b1c2c_0%,#123a63_55%,#1667d9_100%)] py-12 text-center text-white">
|
||||||
|
<div className="site-container">
|
||||||
|
<h2 className="m-0 mb-2.5">Ready to build your IoT solution?</h2>
|
||||||
|
<p className="mx-auto mb-[26px] max-w-[760px] text-[17px] text-[#cfe0f5]">
|
||||||
|
Talk to our experts about hardware customization, OS development and cloud integration.
|
||||||
|
</p>
|
||||||
|
<Button asChild>
|
||||||
|
<a href="/contact-us/">Contact Us</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* # 路由 /my-account 账户页(Tailwind + shadcn/ui 实现)
|
||||||
|
* 未登录:登录 / 注册两个 Tab 表单;已登录:资料卡 + 订单列表 + 退出登录。
|
||||||
|
*/
|
||||||
|
import { createFileRoute, Link, useRouter } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { getCurrentUser, loginFn, registerFn, logoutFn } from "~/server/auth";
|
||||||
|
import { useT } from "~/i18n";
|
||||||
|
import { getMyOrders } from "~/server/commerce";
|
||||||
|
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/my-account")({
|
||||||
|
loader: async () => {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
const [layout, orders] = await Promise.all([getLayoutData(), user ? getMyOrders() : Promise.resolve([])]);
|
||||||
|
return { layout, user, orders };
|
||||||
|
},
|
||||||
|
head: () => ({ meta: [{ title: "My Account - DusunIoT" }] }),
|
||||||
|
component: MyAccountPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** # 函数 组件 账户页面(外壳:公共布局) */
|
||||||
|
function MyAccountPage() {
|
||||||
|
const { layout } = Route.useLoaderData();
|
||||||
|
return (
|
||||||
|
<Layout layout={layout}>
|
||||||
|
<AccountInner />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 组件 账户内容区 */
|
||||||
|
function AccountInner() {
|
||||||
|
const t = useT();
|
||||||
|
const { user, orders } = Route.useLoaderData();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
/** # 函数 退出登录 */
|
||||||
|
async function handleLogout() {
|
||||||
|
await logoutFn();
|
||||||
|
router.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="site-container">
|
||||||
|
<h1 className="mt-5 mb-3.5 text-[26px]">My Account</h1>
|
||||||
|
<div className="max-w-[520px] rounded-lg border border-line bg-soft p-5">
|
||||||
|
<Tabs defaultValue="login">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="login">{t("acct_login")}</TabsTrigger>
|
||||||
|
<TabsTrigger value="register">{t("acct_register")}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="login">
|
||||||
|
<LoginForm onDone={() => router.invalidate()} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="register">
|
||||||
|
<RegisterForm onDone={() => router.invalidate()} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="site-container">
|
||||||
|
<h1 className="mt-5 mb-3.5 text-[26px]">My Account</h1>
|
||||||
|
<div className="mb-5 rounded-lg border border-line bg-soft p-5">
|
||||||
|
<h3 className="mt-0">Profile</h3>
|
||||||
|
<p className="m-0">
|
||||||
|
<strong>{user.displayName || user.email}</strong> ({user.email}) — role: {user.role}
|
||||||
|
<button className="ml-3.5 cursor-pointer border-0 bg-none text-[13px] text-muted-foreground underline hover:text-[#b91c1c]" onClick={() => void handleLogout()} type="button">
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<h2>{t("acct_recent_orders")}</h2>
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<div className="py-[60px] text-center text-muted-foreground">
|
||||||
|
<p>{t("acct_no_orders")}</p>
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/shop/">{t("checkout_browse")}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full border-collapse bg-white text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{(["acct_th_order", "acct_th_date", "acct_th_items", "acct_th_total", "acct_th_status", "acct_th_payment"] as const).map((k) => (
|
||||||
|
<th key={k} className="border-b border-line bg-soft px-3 py-2.5 text-left font-semibold">
|
||||||
|
{t(k)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{orders.map((o) => (
|
||||||
|
<tr key={o.id}>
|
||||||
|
<td className="border-b border-line px-3 py-2.5 text-left">
|
||||||
|
<strong>{o.orderNumber}</strong>
|
||||||
|
</td>
|
||||||
|
<td className="border-b border-line px-3 py-2.5 text-left">{o.createdAt ? new Date(o.createdAt).toLocaleDateString("en-US") : "-"}</td>
|
||||||
|
<td className="border-b border-line px-3 py-2.5 text-left">{Number(o.itemCount)}</td>
|
||||||
|
<td className="border-b border-line px-3 py-2.5 text-left">${Number(o.total).toFixed(2)}</td>
|
||||||
|
<td className="border-b border-line px-3 py-2.5 text-left">
|
||||||
|
<Badge variant={o.status === "completed" ? "success" : "default"}>{o.status}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="border-b border-line px-3 py-2.5 text-left">
|
||||||
|
<Badge variant={o.paymentStatus === "paid" ? "success" : "warning"}>{o.paymentStatus}</Badge>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 登录表单 */
|
||||||
|
function LoginForm({ onDone }: { onDone: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const [authError, setAuthError] = useState("");
|
||||||
|
|
||||||
|
/** # 函数 登录提交 */
|
||||||
|
async function handleLogin(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setAuthError("");
|
||||||
|
const fd = new FormData(e.currentTarget);
|
||||||
|
const res = await loginFn({ data: { email: String(fd.get("email") ?? ""), password: String(fd.get("password") ?? "") } });
|
||||||
|
if (!res.ok) return setAuthError(res.error);
|
||||||
|
onDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="grid grid-cols-1 gap-3.5" onSubmit={handleLogin}>
|
||||||
|
{authError ? <p className="m-0 rounded-md border border-[#f3c1c1] bg-[#fdeaea] px-4 py-3 text-[#b91c1c]">{authError}</p> : null}
|
||||||
|
<Label className="col-span-full">
|
||||||
|
Email
|
||||||
|
<Input name="email" type="email" required autoComplete="email" />
|
||||||
|
</Label>
|
||||||
|
<Label className="col-span-full">
|
||||||
|
Password
|
||||||
|
<Input name="password" type="password" required autoComplete="current-password" />
|
||||||
|
</Label>
|
||||||
|
<div className="col-span-full">
|
||||||
|
<Button type="submit">{t("acct_login")}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 注册表单 */
|
||||||
|
function RegisterForm({ onDone }: { onDone: () => void }) {
|
||||||
|
const [authError, setAuthError] = useState("");
|
||||||
|
|
||||||
|
/** # 函数 注册提交 */
|
||||||
|
async function handleRegister(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setAuthError("");
|
||||||
|
const fd = new FormData(e.currentTarget);
|
||||||
|
const res = await registerFn({
|
||||||
|
data: {
|
||||||
|
email: String(fd.get("email") ?? ""),
|
||||||
|
password: String(fd.get("password") ?? ""),
|
||||||
|
displayName: String(fd.get("displayName") ?? "") || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) return setAuthError(res.error);
|
||||||
|
onDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="grid grid-cols-1 gap-3.5" onSubmit={handleRegister}>
|
||||||
|
{authError ? <p className="m-0 rounded-md border border-[#f3c1c1] bg-[#fdeaea] px-4 py-3 text-[#b91c1c]">{authError}</p> : null}
|
||||||
|
<Label>
|
||||||
|
Display name (optional)
|
||||||
|
<Input name="displayName" type="text" />
|
||||||
|
</Label>
|
||||||
|
<Label>
|
||||||
|
Email
|
||||||
|
<Input name="email" type="email" required autoComplete="email" />
|
||||||
|
</Label>
|
||||||
|
<Label>
|
||||||
|
Password (min 8 chars)
|
||||||
|
<Input name="password" type="password" required minLength={8} autoComplete="new-password" />
|
||||||
|
</Label>
|
||||||
|
<div>
|
||||||
|
<Button type="submit" variant="outline">
|
||||||
|
Register
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user