v0.10.5 首页 H2 产品线入口 ×6:ImageBoxWidget 重构为 Elementor 忠实 DOM + slim 白名单补齐 + H2 scoped CSS(post-18 对齐,桌面/移动/hover 实测一致)

This commit is contained in:
BigCode
2026-11-01 00:15:29 +08:00
commit 5b83e71ad9
128 changed files with 20008 additions and 0 deletions
+164
View File
@@ -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>
);
}
+981
View File
@@ -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}</>;
}
+157
View File
@@ -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;
}
+47
View File
@@ -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 };
+32
View File
@@ -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 };
+44
View File
@@ -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 };
+44
View File
@@ -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 };
+18
View File
@@ -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 };
+24
View File
@@ -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 };
+13
View File
@@ -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 };
+109
View File
@@ -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 };
+53
View File
@@ -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 };
+78
View File
@@ -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 };
+38
View File
@@ -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 };
+23
View File
@@ -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 };
+156
View File
@@ -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>
);
}
+525
View File
@@ -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 };
}
+119
View File
@@ -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>
);
}
+59
View File
@@ -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>
);
}
+37
View File
@@ -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} />
</>
);
}
+63
View File
@@ -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>
);
}
+39
View File
@@ -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>
);
}
+68
View File
@@ -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>
);
}
+76
View File
@@ -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}` : "";
}
+58
View File
@@ -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
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1793295647791,
"tag": "0000_worried_bill_hollister",
"breakpoints": true
}
]
}
+863
View File
@@ -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)],
);
+64
View File
@@ -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);
}
+102
View File
@@ -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: "خريطة الموقع",
};
+102
View File
@@ -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",
};
+120
View File
@@ -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;
+102
View File
@@ -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",
};
+102
View File
@@ -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",
};
+102
View File
@@ -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: "साइटमैप",
};
+102
View File
@@ -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",
};
+44
View File
@@ -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);
+102
View File
@@ -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",
};
+102
View File
@@ -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: "サイトマップ",
};
+102
View File
@@ -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: "사이트맵",
};
+102
View File
@@ -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",
};
+102
View File
@@ -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",
};
+102
View File
@@ -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",
};
+102
View File
@@ -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: "Карта сайта",
};
+102
View File
@@ -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: "แผนผังเว็บไซต์",
};
+102
View File
@@ -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",
};
+102
View File
@@ -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ı",
};
+102
View File
@@ -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: "Карта сайту",
};
+102
View File
@@ -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: "网站地图",
};
+102
View File
@@ -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: "網站地圖",
};
+52
View File
@@ -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();
}
+10
View File
@@ -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));
}
+719
View File
@@ -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>>
}
}
+14
View File
@@ -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>;
}
}
+320
View File
@@ -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>
);
}
+32
View File
@@ -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 });
+147
View File
@@ -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>
);
}
+156
View File
@@ -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>
);
}
+157
View File
@@ -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>
);
}
+89
View File
@@ -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>
);
}
+175
View File
@@ -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>
);
}
+65
View File
@@ -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>
);
}
+147
View File
@@ -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>
);
}
+101
View File
@@ -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>
);
}
+109
View File
@@ -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>
);
}
+237
View File
@@ -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>
);
}
+149
View File
@@ -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>
);
}
+177
View File
@@ -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>
);
}
+206
View File
@@ -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>
);
}
+79
View File
@@ -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>
);
}
+213
View File
@@ -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 };
}
+96
View File
@@ -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>
);
}
+101
View File
@@ -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>
);
}
+282
View File
@@ -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>
);
}
+203
View File
@@ -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>
);
}
+84
View File
@@ -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>
);
}
+82
View File
@@ -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>
);
}
+152
View File
@@ -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>
</>
);
}
+201
View File
@@ -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>
);
}
+228
View File
@@ -0,0 +1,228 @@
/**
* # 路由 产品详情 /product/$slug/
* 对齐原站产品页模板:无面包屑;左图廊 + 右标题/Category/短描述/分隔线/
* Specification 与 Get A Quote 按钮;描述(Elementor 或 HTML)+
* Additional information(属性表)+ 相关产品。询盘走 Get A Quote 弹窗。
*/
import { createFileRoute, Link } from "@tanstack/react-router";
import { useState } from "react";
import { getLayoutData, Layout } from "~/components/website/Layout";
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
import { InquiryForm } from "~/components/website/InquiryForm";
import { ProductCard } from "~/components/website/ProductCard";
import { Button } from "~/components/ui/button";
import { getProductDetail, getFilterConfig, listProducts, type ProductListResult } from "~/server/data";
import { useT } from "~/i18n";
export const Route = createFileRoute("/product/$slug")({
loader: async ({ params }) => {
const [layout, detail, filterConfig] = await Promise.all([
getLayoutData(),
getProductDetail(params.slug).catch(() => null),
getFilterConfig().catch(() => []),
]);
// 未找到产品:返回 notFound 标记(组件里渲染 404)
if (!detail) return { layout, detail: null, filterConfig, related: { items: [], total: 0, page: 1, pageSize: 4 } as ProductListResult };
const related = await listProducts({ category: detail.categories[0]?.slug, page: 1, pageSize: 8 }).catch(() => ({ items: [], total: 0, page: 1, pageSize: 8 }) as ProductListResult);
// meta 转为具体 JSON 类型(jsonb 列推断为 unknown,需显式收窄)
const meta = (detail.meta ?? null) as Record<string, unknown> | null;
const gallery = detail.gallery.map((g) => ({ url: g.url, alt: g.alt }));
return {
layout,
detail: { ...detail, meta, gallery },
filterConfig,
related: { ...related, items: related.items.filter((r) => r.slug !== detail.slug).slice(0, 4) },
};
},
head: ({ loaderData }) => ({
meta: [
{ title: loaderData?.detail ? loaderData.detail.seoTitle || `${loaderData.detail.title} | Dusun IoT` : "Product Not Found | Dusun IoT" },
{ name: "description", content: loaderData?.detail?.seoDescription || loaderData?.detail?.shortDescription || "IoT gateway and device product details from Dusun IoT." },
],
}),
component: ProductPage,
});
/** # 组件 产品详情页 */
function ProductPage() {
const t = useT();
const data = Route.useLoaderData();
const d = data.detail;
const [quoteOpen, setQuoteOpen] = useState(false);
if (!d) return <Layout layout={data.layout}><NotFound /></Layout>;
return (
<Layout layout={data.layout}>
<div className="site-container">
<div className="my-3 mb-8 grid grid-cols-1 gap-9 min-[900px]:grid-cols-[minmax(0,5fr)_minmax(0,6fr)]">
<Gallery images={[...(d.featuredUrl ? [{ url: d.featuredUrl, alt: d.featuredAlt }] : []), ...d.gallery]} title={d.title} />
<div>
<h1 className="mt-0 text-[26px] leading-[1.35]">{d.title}</h1>
{d.categories.length > 0 ? (
<p className="my-1.5 text-muted-foreground">
{d.categories.length > 1 ? t("product_categories") : t("product_category")}{" "}
{d.categories.map((cat, i) => (
<span key={cat.slug}>
{i > 0 ? ", " : ""}
<Link to="/collection/$slug/" params={{ slug: cat.slug }} className="text-brand">{cat.name}</Link>
</span>
))}
</p>
) : null}
{d.shortDescription ? (
<div className="content-html" dangerouslySetInnerHTML={{ __html: d.shortDescription }} />
) : null}
{/* 分隔线 + 操作按钮(对齐原站 Elementor 模板:Specification / Get A Quote) */}
<hr className="my-5 border-t border-line" />
<div className="flex flex-wrap gap-3">
<Button variant="outline" className="border-brand px-4 py-2 text-brand hover:bg-brand hover:text-white" onClick={() => scrollToSpec(d)}>
<SpecGlyph /> {t("product_specification")}
</Button>
<Button variant="outline" className="border-brand px-4 py-2 text-brand hover:bg-brand hover:text-white" onClick={() => setQuoteOpen(true)}>
{t("product_get_quote")}
</Button>
</div>
</div>
</div>
<Description detail={d} />
{d.attributes.length > 0 ? (
<section id="additional-information" className="my-10 scroll-mt-24">
<h2>{t("product_additional_info")}</h2>
<table className="my-3 w-full border-collapse text-sm">
<tbody>
{d.attributes.map((a, i) => (
<tr key={i}>
<th className="w-[32%] border border-line bg-soft px-3 py-2 text-left font-semibold">{a.attr}</th>
<td className="border border-line px-3 py-2 text-left">{a.value}</td>
</tr>
))}
</tbody>
</table>
</section>
) : null}
<section className="my-10">
<h2>{t("product_related")}</h2>
<div className="mt-[18px] grid grid-cols-2 gap-[18px] min-[960px]:grid-cols-4">
{data.related.items.map((p) => (
<ProductCard key={p.id} p={p} />
))}
{data.related.items.length === 0 ? <p className="py-[60px] text-center text-muted-foreground">{t("product_no_related")}</p> : null}
</div>
</section>
</div>
{/* Get A Quote 弹窗(对齐原站 Elementor popup 询盘表单) */}
{quoteOpen ? (
<div className="fixed inset-0 z-[300] flex items-start justify-center overflow-auto bg-black/55 p-4 py-[60px]" onClick={() => setQuoteOpen(false)}>
<div className="relative w-full max-w-[560px] rounded-lg bg-white p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<button
type="button"
aria-label="Close"
onClick={() => setQuoteOpen(false)}
className="absolute top-2.5 right-3 cursor-pointer border-0 bg-transparent text-2xl leading-none text-muted-foreground hover:text-ink"
>
×
</button>
<InquiryForm formKey="product-inquiry-form" productInterest={d.title} title={t("product_quote_title", { name: d.title })} />
</div>
</div>
) : null}
</Layout>
);
}
/** # 函数 Specification 按钮:有属性表滚动到 Additional information,有规格书则下载 */
function scrollToSpec(d: { attributes: unknown[]; specUrl?: string | null }) {
if (d.attributes.length > 0) {
document.getElementById("additional-information")?.scrollIntoView({ behavior: "smooth" });
return;
}
if (d.specUrl) window.open(d.specUrl, "_blank", "noreferrer");
}
/** # 组件 Specification 按钮文件图标(对齐原站 far-file-alt) */
function SpecGlyph() {
return (
<svg width="15" height="15" viewBox="0 0 384 512" fill="currentColor" aria-hidden="true">
<path d="M288 248v28c0 6.6-5.4 12-12 12H108c-6.6 0-12-5.4-12-12v-28c0-6.6 5.4-12 12-12h168c6.6 0 12 5.4 12 12zm-12 72H108c-6.6 0-12 5.4-12 12v28c0 6.6 5.4 12 12 12h168c6.6 0 12-5.4 12-12v-28c0-6.6-5.4-12-12-12zm108-188.1V464c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V48C0 21.5 21.5 0 48 0h204.1C264.8 0 277 5.1 286 14.1L369.9 98c9 8.9 14.1 21.2 14.1 33.9zm-128-80V128h76.1L256 51.9zM336 464V176H232c-13.3 0-24-10.7-24-24V48H48v416h288z" />
</svg>
);
}
/** # 组件 图廊:主图 + 缩略图切换(纯 React state) */function Gallery(props: { images: { url: string; alt: string | null }[]; title: string }) {
const [active, setActive] = useState(0);
const [zoomOpen, setZoomOpen] = useState(false);
if (!props.images.length) {
return <div className="flex aspect-[4/3] items-center justify-center overflow-hidden rounded-lg border border-line bg-white" aria-label={props.title} />;
}
const img = props.images[Math.min(active, props.images.length - 1)];
return (
<div>
<div className="relative flex aspect-[4/3] items-center justify-center overflow-hidden rounded-lg border border-line bg-white">
<img src={img.url} alt={img.alt || props.title} className="max-h-full max-w-full object-contain" />
{/* 全屏查看触发图标(对齐原站 gallery trigger) */}
<button
type="button"
aria-label="View full-screen image gallery"
onClick={() => setZoomOpen(true)}
className="absolute top-2 right-2 cursor-pointer border-0 bg-transparent text-[15px] text-muted-foreground hover:text-brand"
>
🔍
</button>
</div>
{props.images.length > 1 ? (
<div className="mt-2.5 flex flex-wrap gap-2">
{props.images.map((t, i) => (
<img
key={i}
src={t.url}
alt={t.alt || `${props.title} thumbnail ${i + 1}`}
className={"size-[72px] cursor-pointer rounded-md border-2 bg-white object-contain p-1 " + (i === active ? "border-brand" : "border-line")}
onClick={() => setActive(i)}
loading="lazy"
/>
))}
</div>
) : null}
{zoomOpen ? (
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/80 p-6" onClick={() => setZoomOpen(false)}>
<img src={img.url} alt={img.alt || props.title} className="max-h-full max-w-full rounded bg-white object-contain" onClick={(e) => e.stopPropagation()} />
<button
type="button"
aria-label="Close"
onClick={() => setZoomOpen(false)}
className="absolute top-4 right-5 cursor-pointer border-0 bg-transparent text-3xl leading-none text-white"
>
×
</button>
</div>
) : null}
</div>
);
}
/** # 组件 产品描述:有 Elementor 用渲染器,否则输出 description HTML */
function Description(props: { detail: { meta: Record<string, unknown> | null; description: string | null } }) {
const elementor = extractElementor(props.detail.meta);
if (elementor) return <ElementorRenderer doc={elementor} />;
if (props.detail.description) {
return <div className="content-html" dangerouslySetInnerHTML={{ __html: props.detail.description }} />;
}
return null;
}
/** # 组件 产品未找到 */
function NotFound() {
const t = useT();
return (
<div className="pt-[70px] pb-10 text-center">
<h1 className="m-0 text-[64px] text-brand">404</h1>
<p>{t("product_not_found")}</p>
<Button asChild>
<Link to="/collection/">{t("product_browse_all")}</Link>
</Button>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
/**
* # 路由 /robots.txt(server route)
* 全站允许抓取,屏蔽 /admin,声明 sitemap 地址。
*/
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/robots.txt")({
server: {
handlers: {
/** # 函数 GET:输出 robots.txt */
GET: async () => {
const body = "User-agent: *\nAllow: /\nDisallow: /admin\nSitemap: https://dusuniot.com/sitemap.xml\n";
return new Response(body, {
headers: { "content-type": "text/plain; charset=utf-8" },
});
},
},
},
});
+79
View File
@@ -0,0 +1,79 @@
/**
* # 路由 搜索结果 /search?q=
* 服务端 listProducts({q}) 全文匹配产品标题/SKU。
*/
import { createFileRoute } from "@tanstack/react-router";
import { getLayoutData, Layout } from "~/components/website/Layout";
import { ProductCard } from "~/components/website/ProductCard";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { listProducts, type ProductListResult } from "~/server/data";
import { useT } from "~/i18n";
export const Route = createFileRoute("/search")({
validateSearch: (search: Record<string, unknown>): { q?: string } => {
// 无关键词时返回空对象,避免 URL 被规范化为 /search/?q= 产生多余重定向
const q = typeof search.q === "string" ? search.q.trim().slice(0, 80) : "";
return q ? { q } : {};
},
loaderDeps: ({ search }) => ({ q: search.q }),
loader: async ({ deps }) => {
const layout = await getLayoutData();
const empty: ProductListResult = { items: [], total: 0, page: 1, pageSize: 48 };
if (!deps.q) return { layout, list: empty, q: deps.q };
// PostgreSQL LIKE 区分大小写(data.ts 不可改):先按原词查,
// 无结果时尝试首字母大写 / 每个单词首字母大写的变体(产品标题多为首字母大写)。
let list = await listProducts({ q: deps.q, pageSize: 48 }).catch(() => empty);
if (list.total === 0) {
const cap = deps.q.charAt(0).toUpperCase() + deps.q.slice(1);
list = await listProducts({ q: cap, pageSize: 48 }).catch(() => empty);
}
if (list.total === 0) {
const capWords = deps.q
.split(/\s+/)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
list = await listProducts({ q: capWords, pageSize: 48 }).catch(() => empty);
}
return { layout, list, q: deps.q };
},
head: ({ loaderData }) => ({
meta: [
{ title: loaderData?.q ? `Search: ${loaderData.q} | Dusun IoT` : "Search | Dusun IoT" },
{ name: "description", content: "Search Dusun IoT products by keyword or SKU." },
],
}),
component: SearchPage,
});
/** # 组件 搜索结果页 */
function SearchPage() {
const t = useT();
const data = Route.useLoaderData();
return (
<Layout layout={data.layout}>
<div className="site-container">
<h1 className="mt-6 text-[26px]">
{data.q ? (
<>
{t("search_results_for", { q: data.q })} <span className="text-[18px] text-muted-foreground">({data.list.total})</span>
</>
) : (
t("search_title")
)}
</h1>
<form action="/search" method="get" className="mx-0 my-4 mb-7 flex max-w-[480px] gap-2">
<Input type="search" name="q" defaultValue={data.q || ""} placeholder={t("search_by_sku")} className="flex-1" />
<Button type="submit">{t("search_button")}</Button>
</form>
<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.q && data.list.items.length === 0 ? <p className="py-[60px] text-center text-muted-foreground">No products matched “{data.q}”. Try another keyword or contact us directly.</p> : null}
{!data.q ? <p className="py-[60px] text-center text-muted-foreground">Enter a keyword above to search our product catalog.</p> : null}
</div>
</Layout>
);
}
+118
View File
@@ -0,0 +1,118 @@
/**
* # 路由 /shop/ 全部产品列表页
* 原站 /shop/ 形态:左栏属性筛选(复选框,URL search params 驱动)+ 主区
* 「IoT Products / Showing x–y of z results」+ 产品网格 + 分页。
*/
import { createFileRoute, 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, type CollectionSearch } from "~/components/website/collection-shared";
import { getFilterConfig, listProducts, type FilterState, type ProductListResult } from "~/server/data";
import { useT } from "~/i18n";
export const Route = createFileRoute("/shop")({
validateSearch: parseCollectionSearch as (search: Record<string, unknown>) => CollectionSearch,
loaderDeps: ({ search }) => ({ search }),
loader: async ({ deps }) => {
const [layout, filterConfig] = await Promise.all([
getLayoutData(),
getFilterConfig().catch(() => []),
]);
const list = await listProducts({
filters: searchToFilters(deps.search),
page: deps.search.page,
pageSize: 24,
}).catch(() => ({ items: [], total: 0, page: 1, pageSize: 24 }) as ProductListResult);
return { layout, filterConfig, list };
},
head: () => ({
meta: [
{ title: "All Products - DusunIoT" },
{ name: "description", content: "Browse all Dusun IoT gateways and modules." },
],
}),
component: ShopPage,
});
/** # 函数 组件 /shop/ 页面 */
function ShopPage() {
const t = useT();
const data = Route.useLoaderData();
const search = Route.useSearch();
const router = useRouter();
const page = data.list.page;
const from = (page - 1) * data.list.pageSize + (data.list.items.length ? 1 : 0);
const to = (page - 1) * data.list.pageSize + data.list.items.length;
/** # 函数 更新筛选:切换某属性值后 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: "/shop/", search: filtersToSearch(filters, 1) });
}
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_shop")}</BreadcrumbPage></BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="grid grid-cols-1 items-start gap-7 min-[900px]:grid-cols-[250px_1fr]">
{/* 左栏属性筛选(与原站 /shop/ 一致:仅属性过滤器,无分类树) */}
<aside>
{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]">{t("shop_iot_products")}</h1>
<p className="text-muted-foreground">
{data.list.total > 0 ? t("shop_showing", { from, to, total: data.list.total }) : t("shop_no_results")}
</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">No products found with the selected filters.</p>
) : null}
<Pagination
page={page}
total={data.list.total}
pageSize={data.list.pageSize}
makeHref={(n) => (n > 1 ? `/shop/?page=${n}` : "/shop/")}
/>
</div>
</div>
</div>
</Layout>
);
}
+77
View File
@@ -0,0 +1,77 @@
/**
* # 路由 /sitemap.xml(server route)
* 输出全站 sitemap:首页 + /blog/ + pages/posts + products + product categories,
* 每条 URL 带 x-default / en 两种 hreflang 交替链接。
*/
import { createFileRoute } from "@tanstack/react-router";
import { getSitemapUrls } from "~/server/data";
/** # 成员变量 站点根地址(生产域名) **/
const BASE = "https://dusuniot.com";
/** # 函数 XML 文本转义 */
function esc(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/** # 函数 时间转 W3C 日期(YYYY-MM-DD) */
function w3cDate(d: Date | string | null): string | undefined {
if (!d) return undefined;
const date = typeof d === "string" ? new Date(d) : d;
if (Number.isNaN(date.getTime())) return undefined;
return date.toISOString().slice(0, 10);
}
export const Route = createFileRoute("/sitemap.xml")({
server: {
handlers: {
/** # 函数 GET:输出 sitemap XML */
GET: async () => {
const { pages, prods, cats } = await getSitemapUrls();
// 组装 URL 条目并去重
const seen = new Set<string>();
const urls: { loc: string; lastmod?: string }[] = [];
const push = (loc: string, lastmod?: string) => {
if (seen.has(loc)) return;
seen.add(loc);
urls.push({ loc, lastmod });
};
push("/", w3cDate(new Date()));
push("/blog/");
for (const p of pages) push(`/${p.slug}/`, w3cDate(p.publishedAt));
for (const p of prods) push(`/product/${p.slug}/`, w3cDate(p.updatedAt));
for (const c of cats) push(`/collection/${c.slug}/`);
const entries = urls
.map(({ loc, lastmod }) => {
const abs = `${BASE}${esc(loc)}`;
return [
" <url>",
` <loc>${abs}</loc>`,
lastmod ? ` <lastmod>${lastmod}</lastmod>` : null,
` <xhtml:link rel="alternate" hreflang="x-default" href="${abs}"/>`,
` <xhtml:link rel="alternate" hreflang="en" href="${abs}"/>`,
" </url>",
]
.filter(Boolean)
.join("\n");
})
.join("\n");
const body =
`<?xml version="1.0" encoding="UTF-8"?>\n` +
`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">\n` +
`${entries}\n</urlset>\n`;
return new Response(body, {
headers: { "content-type": "application/xml; charset=utf-8" },
});
},
},
},
});
+48
View File
@@ -0,0 +1,48 @@
/**
* # 路由 方案落地页 /solution/$slug/
* 方案营销页(Elementor 通栏直出);无内容回退 404。
*/
import { createFileRoute } from "@tanstack/react-router";
import { getLayoutData, Layout } from "~/components/website/Layout";
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
import { getPublishedContent } from "~/server/data";
export const Route = createFileRoute("/solution/$slug")({
loader: async ({ params }) => {
const [layout, page] = await Promise.all([
getLayoutData(),
getPublishedContent(params.slug, "post").catch(() => null),
]);
if (!page || !extractElementor(page.meta)) return { layout, page: null };
const meta = (page.meta ?? null) as Record<string, unknown> | null;
return { layout, page: { ...page, meta } };
},
head: ({ loaderData }) => ({
meta: [
{ title: loaderData?.page ? loaderData.page.seoTitle || loaderData.page.title : "Solution Not Found | Dusun IoT" },
{ name: "description", content: loaderData?.page?.seoDescription || loaderData?.page?.excerpt || "Dusun IoT solution." },
],
}),
component: SolutionPage,
});
/** # 组件 方案落地页 */
function SolutionPage() {
const data = Route.useLoaderData();
if (!data.page) {
return (
<Layout layout={data.layout}>
<div className="pt-[70px] pb-10 text-center">
<h1 className="m-0 text-[64px] text-brand">404</h1>
<p>Sorry, this solution does not exist.</p>
</div>
</Layout>
);
}
const elementor = extractElementor(data.page.meta);
return (
<Layout layout={data.layout}>
{elementor ? <ElementorRenderer doc={elementor} /> : null}
</Layout>
);
}
+59
View File
@@ -0,0 +1,59 @@
/**
* # 路由 方案索引 /solution/
* 原站形态:SOLUTION 大标题 + 3 列方案卡片网格(图 / 标题 / 摘要)。
* 卡片数据 = listSolutionLandings()(同 slug 方案落地页)。
*/
import { createFileRoute, Link } from "@tanstack/react-router";
import { getLayoutData, Layout } from "~/components/website/Layout";
import { listSolutionLandings } from "~/server/data";
import { useT } from "~/i18n";
export const Route = createFileRoute("/solution")({
loader: async () => {
const [layout, solutions] = await Promise.all([
getLayoutData(),
listSolutionLandings().catch(() => []),
]);
return { layout, solutions };
},
head: () => ({
meta: [
{ title: "IoT Solutions | Dusun IoT" },
{ name: "description", content: "Dusun IoT hardware solutions for smart home, smart apartment, energy storage, vehicle IoT, retail, building and more." },
],
}),
component: SolutionIndexPage,
});
/** # 组件 方案索引页 */
function SolutionIndexPage() {
const t = useT();
const data = Route.useLoaderData();
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">{t("solution_title")}</h1>
</div>
<div className="site-container my-10">
<div className="grid grid-cols-1 gap-6 min-[760px]:grid-cols-2 min-[1080px]:grid-cols-3">
{data.solutions.map((s) => (
<Link
key={s.id}
to="/solution/$slug/"
params={{ slug: s.slug }}
className="block overflow-hidden rounded-lg border border-line bg-white transition-[box-shadow,transform] duration-200 hover:-translate-y-0.5 hover:shadow-[0_8px_24px_rgba(16,42,76,0.1)]"
>
<div className="aspect-[4/3] overflow-hidden bg-gradient-to-b from-[#9aa3ad] to-[#c9ced4]">
{s.imageUrl ? <img src={s.imageUrl} alt={s.imageAlt || s.title} loading="lazy" className="h-full w-full object-cover" /> : null}
</div>
<div className="p-5">
<h2 className="mt-0 mb-2 text-[18px] font-semibold text-brand">{s.title}</h2>
{s.excerpt ? <p className="m-0 text-sm leading-relaxed text-muted-foreground">{s.excerpt.slice(0, 160)}</p> : null}
</div>
</Link>
))}
</div>
</div>
</Layout>
);
}
+719
View File
@@ -0,0 +1,719 @@
/**
* # 模块 后台管理接口
* 集中存放 /admin 后台使用的全部 server functions。
* 规范:每个函数第一步必须 await requireAdmin();写操作写入 auditLogs 留痕。
*/
import { db } from "~/db";
import {
contents,
products,
productAttributes,
productAttributeValues,
productAttributeMap,
productTerms,
categories,
orders,
orderItems,
users,
media,
redirects,
filters,
languages,
inquiries,
auditLogs,
} from "~/db/schema";
import { createServerFn } from "@tanstack/react-start";
import { and, asc, desc, eq, ilike, inArray, or, sql } from "drizzle-orm";
import type { PgTable } from "drizzle-orm/pg-core";
import type { Json } from "~/components/admin/common";
/** # 类型 后台通用分页入参 **/
type PageInput = { page?: number; pageSize?: number };
/** # 函数 内部工具:安全解析分页参数
* 参数 d:原始分页输入
* 返回值 { page, pageSize, offset }
*/
function parsePage(d: PageInput) {
const page = Math.max(1, Math.floor(d.page || 1));
const pageSize = Math.min(100, Math.floor(d.pageSize || 20));
return { page, pageSize, offset: (page - 1) * pageSize };
}
/** # 函数 内部工具:写入审计日志
* 参数 adminId:操作人 ID;action:动作名;entity:实体类型;entityId:实体 ID;meta:附加数据
*/
async function writeAudit(adminId: number, action: string, entity: string, entityId: string | number, meta?: unknown) {
await db.insert(auditLogs).values({
actorId: adminId,
action,
entity,
entityId: String(entityId),
meta: (meta ?? {}) as Record<string, unknown>,
});
}
/** # 函数 内部工具:把标题转成 URL 别名(slug)
* 参数 title:原始标题
* 返回值 小写短横线 slug
*/
function slugify(title: string): string {
return (
title
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 200) || `item-${Date.now()}`
);
}
/* ============================= 内容 Contents ============================= */
/** # 函数 Server Function:内容列表(分页 + 类型/状态/关键字过滤) */
export const adminListContents = createServerFn({ method: "GET" })
.validator((d: PageInput & { type?: string; status?: string; q?: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const { page, pageSize, offset } = parsePage(data);
const conds = [];
if (data.type) conds.push(eq(contents.type, data.type));
if (data.status) conds.push(eq(contents.status, data.status));
if (data.q) conds.push(or(ilike(contents.title, `%${data.q}%`), ilike(contents.slug, `%${data.q}%`)));
const where = conds.length ? and(...conds) : undefined;
const items = await db
.select({
id: contents.id,
type: contents.type,
slug: contents.slug,
title: contents.title,
status: contents.status,
template: contents.template,
views: contents.views,
publishedAt: contents.publishedAt,
updatedAt: contents.updatedAt,
})
.from(contents)
.where(where)
.orderBy(desc(contents.updatedAt))
.limit(pageSize)
.offset(offset);
const [{ total }] = await db.select({ total: sql<number>`count(*)` }).from(contents).where(where);
return { items, total: Number(total), page, pageSize };
});
/** # 函数 Server Function:读取单条内容(编辑页用) */
export const adminGetContent = createServerFn({ method: "GET" })
.validator((d: { id: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const [item] = await db.select().from(contents).where(eq(contents.id, data.id));
// meta 为 jsonb(unknown),收窄为 Json 以满足 loader 可序列化校验
return item ? { ...item, meta: item.meta as Json } : null;
});
/** # 函数 Server Function:保存内容(新建或更新,slug 为空时自动生成) */
export const adminSaveContent = createServerFn({ method: "POST" })
.validator(
(d: {
id?: number;
type: string;
slug?: string;
title: string;
excerpt?: string;
html?: string;
status?: string;
template?: string;
seoTitle?: string | null;
seoDescription?: string | null;
}) => d,
)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const slug = (data.slug || "").trim() || slugify(data.title);
const values = {
type: data.type,
slug,
title: data.title,
excerpt: data.excerpt ?? "",
html: data.html ?? "",
status: data.status || "draft",
template: data.template || "default",
seoTitle: data.seoTitle ?? null,
seoDescription: data.seoDescription ?? null,
updatedAt: new Date(),
};
let id = data.id;
if (id) {
const [existing] = await db.select({ publishedAt: contents.publishedAt, status: contents.status }).from(contents).where(eq(contents.id, id));
if (existing && values.status === "published" && !existing.publishedAt) {
Object.assign(values, { publishedAt: new Date() });
}
await db.update(contents).set(values).where(eq(contents.id, id));
} else {
const [row] = await db
.insert(contents)
.values({ ...values, publishedAt: values.status === "published" ? new Date() : null, authorId: admin.id })
.returning({ id: contents.id });
id = row!.id;
}
await writeAudit(admin.id, "content.save", "content", id!, { title: data.title, type: data.type });
return { ok: true as const, id };
});
/** # 函数 Server Function:删除内容 */
export const adminDeleteContent = createServerFn({ method: "POST" })
.validator((d: { id: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
await db.delete(contents).where(eq(contents.id, data.id));
await writeAudit(admin.id, "content.delete", "content", data.id);
return { ok: true as const };
});
/* ============================= 产品 Products ============================= */
/** # 函数 Server Function:产品列表(分页 + 关键字搜索,附特色图 URL) */
export const adminListProducts = createServerFn({ method: "GET" })
.validator((d: PageInput & { q?: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const { page, pageSize, offset } = parsePage(data);
const conds = [];
if (data.q) {
conds.push(or(ilike(products.title, `%${data.q}%`), ilike(products.sku, `%${data.q}%`), ilike(products.slug, `%${data.q}%`)));
}
const where = conds.length ? and(...conds) : undefined;
const rows = await db
.select({
id: products.id,
sku: products.sku,
slug: products.slug,
title: products.title,
price: products.price,
regularPrice: products.regularPrice,
stock: products.stock,
stockStatus: products.stockStatus,
status: products.status,
featured: products.featured,
thumb: media.url,
updatedAt: products.updatedAt,
})
.from(products)
.leftJoin(media, eq(media.id, products.featuredMediaId))
.where(where)
.orderBy(desc(products.updatedAt))
.limit(pageSize)
.offset(offset);
const [{ total }] = await db.select({ total: sql<number>`count(*)` }).from(products).where(where);
return { items: rows, total: Number(total), page, pageSize };
});
/** # 函数 Server Function:读取产品编辑数据(产品 + 属性树 + 分类 + 已选值/分类)
* 返回值 attributes 内嵌可选值;selectedValueIds / selectedCategoryIds 供表单回显;
* selectedGalleryIds / initialGalleryOptions 供图集选择器回显
*/
export const adminGetProduct = createServerFn({ method: "GET" })
.validator((d: { id: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const [product] = await db.select().from(products).where(eq(products.id, data.id));
const attrs = await db
.select()
.from(productAttributes)
.orderBy(asc(productAttributes.sort), asc(productAttributes.name));
const vals = await db
.select()
.from(productAttributeValues)
.orderBy(asc(productAttributeValues.sort), asc(productAttributeValues.value));
const cats = await db
.select({ id: categories.id, name: categories.name })
.from(categories)
.where(eq(categories.taxonomy, "product_cat"))
.orderBy(asc(categories.sort), asc(categories.name));
const selectedValues = await db
.select({ valueId: productAttributeMap.valueId })
.from(productAttributeMap)
.where(eq(productAttributeMap.productId, data.id));
const selectedCats = await db
.select({ categoryId: productTerms.categoryId })
.from(productTerms)
.where(eq(productTerms.productId, data.id));
// 已存图集(url 对象数组)反查媒体 ID,供编辑回显
const galleryArr = Array.isArray(product?.gallery) ? (product!.gallery as { url?: unknown }[]) : [];
const galleryUrls = galleryArr.map((g) => g.url).filter((u): u is string => typeof u === "string" && !!u);
const galleryRows = galleryUrls.length
? await db.select({ id: media.id, path: media.path, url: media.url }).from(media).where(inArray(media.url, galleryUrls))
: [];
return {
product: product ? { ...product, gallery: product.gallery as Json, meta: product.meta as Json } : null,
attributes: attrs.map((a) => ({ ...a, values: vals.filter((v) => v.attributeId === a.id) })),
categories: cats,
selectedValueIds: selectedValues.map((s) => s.valueId),
selectedCategoryIds: selectedCats.map((s) => s.categoryId),
selectedGalleryIds: galleryRows.map((r) => r.id),
initialGalleryOptions: galleryRows,
};
});
/** # 函数 Server Function:媒体搜索(产品编辑页特色图/图集选择器用,按文件名模糊匹配) */
export const adminSearchMedia = createServerFn({ method: "GET" })
.validator((d: { q?: string; limit?: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const limit = Math.min(50, Math.max(1, Math.floor(data.limit ?? 30)));
const where = data.q ? ilike(media.path, `%${data.q}%`) : undefined;
return db
.select({ id: media.id, path: media.path, url: media.url })
.from(media)
.where(where)
.orderBy(desc(media.id))
.limit(limit);
});
/** # 函数 Server Function:保存产品(含重建属性值关联与分类关联、特色图与图集) */
export const adminSaveProduct = createServerFn({ method: "POST" })
.validator(
(d: {
id?: number;
sku?: string | null;
slug: string;
title: string;
shortDescription?: string;
description?: string;
price?: string | null;
regularPrice?: string | null;
stock?: number | null;
stockStatus?: string;
status?: string;
featured?: boolean;
specUrl?: string | null;
seoTitle?: string | null;
seoDescription?: string | null;
featuredMediaId?: number | null;
galleryIds?: number[];
valueIds: number[];
categoryIds: number[];
}) => d,
)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const slug = (data.slug || "").trim() || slugify(data.title);
// 图集媒体 ID 解析为 {url, alt} 对象数组(与导入数据结构一致)
const galleryRows = data.galleryIds?.length
? await db.select({ url: media.url, altText: media.altText }).from(media).where(inArray(media.id, data.galleryIds))
: [];
const values = {
sku: data.sku ?? null,
slug,
title: data.title,
shortDescription: data.shortDescription ?? "",
description: data.description ?? "",
price: data.price ?? null,
regularPrice: data.regularPrice ?? null,
stock: data.stock ?? null,
stockStatus: data.stockStatus || "instock",
status: data.status || "draft",
featured: data.featured ?? false,
specUrl: data.specUrl ?? null,
seoTitle: data.seoTitle ?? null,
seoDescription: data.seoDescription ?? null,
featuredMediaId: data.featuredMediaId ?? null,
gallery: galleryRows.map((r) => ({ url: r.url, alt: r.altText })),
updatedAt: new Date(),
};
let id = data.id;
if (id) {
await db.update(products).set(values).where(eq(products.id, id));
} else {
const [row] = await db.insert(products).values(values).returning({ id: products.id });
id = row!.id;
}
// 重建属性值与分类关联
await db.delete(productAttributeMap).where(eq(productAttributeMap.productId, id));
if (data.valueIds.length) {
await db
.insert(productAttributeMap)
.values(data.valueIds.map((valueId) => ({ productId: id!, valueId })))
.onConflictDoNothing();
}
await db.delete(productTerms).where(eq(productTerms.productId, id));
if (data.categoryIds.length) {
await db
.insert(productTerms)
.values(data.categoryIds.map((categoryId) => ({ productId: id!, categoryId })))
.onConflictDoNothing();
}
await writeAudit(admin.id, "product.save", "product", id!, { title: data.title });
return { ok: true as const, id };
});
/** # 函数 Server Function:删除产品(级联清理关联) */
export const adminDeleteProduct = createServerFn({ method: "POST" })
.validator((d: { id: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
await db.delete(productAttributeMap).where(eq(productAttributeMap.productId, data.id));
await db.delete(productTerms).where(eq(productTerms.productId, data.id));
await db.delete(products).where(eq(products.id, data.id));
await writeAudit(admin.id, "product.delete", "product", data.id);
return { ok: true as const };
});
/* ============================= 订单 Orders ============================= */
/** # 函数 内部工具:从订单账单 JSON 中取客户显示名 */
function billingName(billing: unknown): string {
const b = (billing ?? {}) as Record<string, unknown>;
const name = [b.first_name, b.last_name].filter((v) => typeof v === "string" && v).join(" ").trim();
if (name) return name;
if (typeof b.company === "string" && b.company) return b.company;
return "";
}
/** # 函数 Server Function:订单列表(状态过滤 + 分页) */
export const adminListOrders = createServerFn({ method: "GET" })
.validator((d: PageInput & { status?: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const { page, pageSize, offset } = parsePage(data);
const conds = [];
if (data.status) conds.push(eq(orders.status, data.status));
const where = conds.length ? and(...conds) : undefined;
const items = await db
.select({
id: orders.id,
orderNumber: orders.orderNumber,
status: orders.status,
currency: orders.currency,
total: orders.total,
paymentStatus: orders.paymentStatus,
paymentMethod: orders.paymentMethod,
billing: orders.billing,
createdAt: orders.createdAt,
})
.from(orders)
.where(where)
.orderBy(desc(orders.createdAt))
.limit(pageSize)
.offset(offset);
const [{ total }] = await db.select({ total: sql<number>`count(*)` }).from(orders).where(where);
return {
items: items.map((o) => ({ ...o, customerName: billingName(o.billing), billing: o.billing as Json })),
total: Number(total),
page,
pageSize,
};
});
/** # 函数 Server Function:订单详情(含明细与客户邮箱) */
export const adminGetOrder = createServerFn({ method: "GET" })
.validator((d: { id: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const rows = await db
.select({ order: orders, userEmail: users.email })
.from(orders)
.leftJoin(users, eq(users.id, orders.userId))
.where(eq(orders.id, data.id));
const [row] = rows;
if (!row) return null;
const items = await db.select().from(orderItems).where(eq(orderItems.orderId, data.id));
return {
order: {
...row.order,
customerName: billingName(row.order.billing),
billing: row.order.billing as Json,
shipping: row.order.shipping as Json,
},
userEmail: row.userEmail,
items,
};
});
/** # 函数 Server Function:更新订单状态 */
export const adminUpdateOrderStatus = createServerFn({ method: "POST" })
.validator((d: { id: number; status: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
await db.update(orders).set({ status: data.status, updatedAt: new Date() }).where(eq(orders.id, data.id));
await writeAudit(admin.id, "order.status", "order", data.id, { status: data.status });
return { ok: true as const };
});
/* ============================= 媒体 Media ============================= */
/** # 函数 Server Function:媒体列表(分页 + 文件名搜索) */
export const adminListMedia = createServerFn({ method: "GET" })
.validator((d: PageInput & { q?: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const { page, pageSize, offset } = parsePage(data);
const conds = [];
if (data.q) conds.push(ilike(media.path, `%${data.q}%`));
const where = conds.length ? and(...conds) : undefined;
const items = await db
.select()
.from(media)
.where(where)
.orderBy(desc(media.id))
.limit(pageSize)
.offset(offset);
const [{ total }] = await db.select({ total: sql<number>`count(*)` }).from(media).where(where);
return { items, total: Number(total), page, pageSize };
});
/** # 函数 Server Function:更新媒体 alt/title */
export const adminUpdateMedia = createServerFn({ method: "POST" })
.validator((d: { id: number; altText?: string; title?: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const patch: Record<string, string> = {};
if (data.altText !== undefined) patch.altText = data.altText;
if (data.title !== undefined) patch.title = data.title;
if (Object.keys(patch).length) await db.update(media).set(patch).where(eq(media.id, data.id));
await writeAudit(admin.id, "media.update", "media", data.id, patch);
return { ok: true as const };
});
/* ============================= 重定向 Redirects ============================= */
/** # 函数 Server Function:重定向列表 */
export const adminListRedirects = createServerFn({ method: "GET" })
.validator((d: PageInput & { q?: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const { page, pageSize, offset } = parsePage(data);
const conds = [];
if (data.q) conds.push(or(ilike(redirects.source, `%${data.q}%`), ilike(redirects.target, `%${data.q}%`)));
const where = conds.length ? and(...conds) : undefined;
const items = await db.select().from(redirects).where(where).orderBy(desc(redirects.id)).limit(pageSize).offset(offset);
const [{ total }] = await db.select({ total: sql<number>`count(*)` }).from(redirects).where(where);
return { items, total: Number(total), page, pageSize };
});
/** # 函数 Server Function:保存重定向(新建或更新) */
export const adminSaveRedirect = createServerFn({ method: "POST" })
.validator(
(d: { id?: number; source: string; target: string; code?: number; isRegex?: boolean; enabled?: boolean }) => d,
)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const values = {
source: data.source,
target: data.target,
code: data.code || 301,
isRegex: data.isRegex ?? false,
enabled: data.enabled ?? true,
};
let id = data.id;
if (id) {
await db.update(redirects).set(values).where(eq(redirects.id, id));
} else {
const [row] = await db.insert(redirects).values(values).returning({ id: redirects.id });
id = row!.id;
}
await writeAudit(admin.id, "redirect.save", "redirect", id!, { source: data.source });
return { ok: true as const, id };
});
/** # 函数 Server Function:删除重定向 */
export const adminDeleteRedirect = createServerFn({ method: "POST" })
.validator((d: { id: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
await db.delete(redirects).where(eq(redirects.id, data.id));
await writeAudit(admin.id, "redirect.delete", "redirect", data.id);
return { ok: true as const };
});
/* ============================= 筛选配置 Filters ============================= */
/** # 函数 Server Function:筛选配置列表(附属性定义供下拉选择) */
export const adminListFilters = createServerFn({ method: "GET" }).handler(async () => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const items = await db.select().from(filters).orderBy(asc(filters.position), asc(filters.id));
const attributes = await db
.select({ id: productAttributes.id, name: productAttributes.name, slug: productAttributes.slug })
.from(productAttributes)
.orderBy(asc(productAttributes.sort));
return { items, attributes };
});
/** # 函数 Server Function:保存筛选配置 */
export const adminSaveFilter = createServerFn({ method: "POST" })
.validator(
(d: {
id?: number;
name: string;
slug: string;
type?: string;
attributeId?: number | null;
position?: number;
enabled?: boolean;
}) => d,
)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const values = {
name: data.name,
slug: data.slug,
type: data.type || "attribute",
attributeId: data.attributeId ?? null,
position: data.position ?? 0,
enabled: data.enabled ?? true,
};
let id = data.id;
if (id) {
await db.update(filters).set(values).where(eq(filters.id, id));
} else {
const [row] = await db.insert(filters).values(values).returning({ id: filters.id });
id = row!.id;
}
await writeAudit(admin.id, "filter.save", "filter", id!, { name: data.name });
return { ok: true as const, id };
});
/** # 函数 Server Function:删除筛选配置 */
export const adminDeleteFilter = createServerFn({ method: "POST" })
.validator((d: { id: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
await db.delete(filters).where(eq(filters.id, data.id));
await writeAudit(admin.id, "filter.delete", "filter", data.id);
return { ok: true as const };
});
/* ============================= 语言 Languages ============================= */
/** # 函数 Server Function:语言配置列表 */
export const adminListLanguages = createServerFn({ method: "GET" }).handler(async () => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const items = await db.select().from(languages).orderBy(asc(languages.sort), asc(languages.code));
return { items, total: items.length, page: 1, pageSize: items.length };
});
/** # 函数 Server Function:更新语言启用状态 */
export const adminUpdateLanguage = createServerFn({ method: "POST" })
.validator((d: { code: string; enabled: boolean }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
await db.update(languages).set({ enabled: data.enabled }).where(eq(languages.code, data.code));
await writeAudit(admin.id, "language.update", "language", data.code, { enabled: data.enabled });
return { ok: true as const };
});
/* ============================= 用户 Users ============================= */
/** # 函数 Server Function:用户列表(只读,分页 + 关键字搜索) */
export const adminListUsers = createServerFn({ method: "GET" })
.validator((d: PageInput & { q?: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const { page, pageSize, offset } = parsePage(data);
const conds = [];
if (data.q) conds.push(or(ilike(users.email, `%${data.q}%`), ilike(users.displayName, `%${data.q}%`)));
const where = conds.length ? and(...conds) : undefined;
const items = await db
.select({
id: users.id,
email: users.email,
username: users.username,
displayName: users.displayName,
role: users.role,
createdAt: users.createdAt,
})
.from(users)
.where(where)
.orderBy(desc(users.id))
.limit(pageSize)
.offset(offset);
const [{ total }] = await db.select({ total: sql<number>`count(*)` }).from(users).where(where);
return { items, total: Number(total), page, pageSize };
});
/* ============================= Dashboard 统计 ============================= */
/** # 函数 Server Function:Dashboard 统计
* 返回值 各实体计数、待处理订单数、最近 7 天询盘趋势、最新 10 条询盘
*/
export const adminStats = createServerFn({ method: "GET" }).handler(async () => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const countOf = async (table: PgTable) => Number(await db.$count(table));
const [contentsCount, productsCount, ordersCount, inquiriesCount, mediaCount, pendingOrders] = await Promise.all([
countOf(contents),
countOf(products),
countOf(orders),
countOf(inquiries),
countOf(media),
db
.select({ n: sql<number>`count(*)` })
.from(orders)
.where(or(eq(orders.status, "pending"), eq(orders.status, "on-hold"))),
]);
// 最近 7 天询盘趋势(按天分组,补齐空白天)
const trendRows = await db
.select({
day: sql<string>`to_char(date_trunc('day', ${inquiries.createdAt}), 'YYYY-MM-DD')`,
n: sql<number>`count(*)`,
})
.from(inquiries)
.where(sql`${inquiries.createdAt} > now() - interval '7 days'`)
.groupBy(sql`1`);
const trendMap = new Map(trendRows.map((r) => [r.day, Number(r.n)]));
const trend: { day: string; count: number }[] = [];
for (let i = 6; i >= 0; i--) {
const d = new Date(Date.now() - i * 86400_000);
const key = d.toISOString().slice(0, 10);
trend.push({ day: key, count: trendMap.get(key) ?? 0 });
}
const recentInquiries = await db
.select({
id: inquiries.id,
name: inquiries.name,
email: inquiries.email,
company: inquiries.company,
country: inquiries.country,
productInterest: inquiries.productInterest,
status: inquiries.status,
createdAt: inquiries.createdAt,
})
.from(inquiries)
.orderBy(desc(inquiries.createdAt))
.limit(10);
return {
counts: {
contents: contentsCount,
products: productsCount,
orders: ordersCount,
inquiries: inquiriesCount,
media: mediaCount,
pendingOrders: Number(pendingOrders[0]?.n ?? 0),
},
trend,
recentInquiries,
};
});
+131
View File
@@ -0,0 +1,131 @@
/**
* # 函数 认证与会话模块
* 服务端会话(HttpOnly Secure Cookie)+ 登录校验(bcrypt 与旧版 phpass 双兼容)。
*/
import { db } from "~/db";
import { users, sessions } from "~/db/schema";
import { eq, and, gt } from "drizzle-orm";
import { phpassVerify } from "./phpass";
import { createServerFn } from "@tanstack/react-start";
export const SESSION_COOKIE = "dusun_session";
const SESSION_DAYS = 14;
/** # 函数 创建会话并返回 Cookie 值
* 参数 userId:用户 ID
* 返回值 会话令牌
*/
export async function createSession(userId: number): Promise<string> {
const token = Bun.randomUUIDv7().replace(/-/g, "") + Bun.randomUUIDv7().replace(/-/g, "");
const expiresAt = new Date(Date.now() + SESSION_DAYS * 86400_000);
await db.insert(sessions).values({ token, userId, expiresAt });
return token;
}
/** # 函数 注销会话 */
export async function destroySession(token: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.token, token));
}
/** # 函数 登录校验(邮箱 + 密码)
* 兼容两条路径:bcrypt(新账号)与 WordPress phpass(存量客户,验证成功后升级 bcrypt)。
* 返回值 用户对象或 null
*/
export async function verifyLogin(email: string, password: string) {
const [user] = await db.select().from(users).where(eq(users.email, email.toLowerCase().trim()));
if (!user) return null;
let ok = false;
if (user.passwordHash) ok = await Bun.password.verify(password, user.passwordHash);
else if (user.legacyPasswordHash) {
ok = await phpassVerify(password, user.legacyPasswordHash);
if (ok) {
// 透明升级为 bcrypt
await db.update(users).set({ passwordHash: Bun.password.hashSync(password, { algorithm: "bcrypt", cost: 10 }) }).where(eq(users.id, user.id));
}
}
return ok ? user : null;
}
/** # 函数 读取当前会话用户(服务端用)
* 参数 token:Cookie 中的会话令牌
* 返回值 用户(不含密码哈希)或 null
*/
export async function getSessionUser(token: string | undefined) {
if (!token) return null;
const rows = await db
.select({ id: users.id, email: users.email, displayName: users.displayName, role: users.role })
.from(sessions)
.innerJoin(users, eq(users.id, sessions.userId))
.where(and(eq(sessions.token, token), gt(sessions.expiresAt, new Date())));
return rows[0] ?? null;
}
/** # 函数 Server Function:获取当前登录用户(前台/后台共用) */
export const getCurrentUser = createServerFn({ method: "GET" }).handler(async () => {
const { getCookie } = await import("@tanstack/react-start/server");
const token = getCookie(SESSION_COOKIE);
return getSessionUser(token);
});
/** # 函数 Server Function:登录 */
export const loginFn = createServerFn({ method: "POST" })
.validator((d: { email: string; password: string }) => d)
.handler(async ({ data }) => {
const { setCookie } = await import("@tanstack/react-start/server");
const user = await verifyLogin(data.email, data.password);
if (!user) return { ok: false as const, error: "邮箱或密码错误" };
const token = await createSession(user.id);
setCookie(SESSION_COOKIE, token, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: SESSION_DAYS * 86400,
});
return { ok: true as const, role: user.role, displayName: user.displayName };
});
/** # 函数 Server Function:注销 */
export const logoutFn = createServerFn({ method: "POST" }).handler(async () => {
const { getCookie, setCookie } = await import("@tanstack/react-start/server");
const token = getCookie(SESSION_COOKIE);
if (token) await destroySession(token);
setCookie(SESSION_COOKIE, "", { httpOnly: true, path: "/", maxAge: 0 });
return { ok: true as const };
});
/** # 函数 Server Function:注册商城账号 */
export const registerFn = createServerFn({ method: "POST" })
.validator((d: { email: string; password: string; displayName?: string }) => d)
.handler(async ({ data }) => {
const { setCookie } = await import("@tanstack/react-start/server");
const email = data.email.toLowerCase().trim();
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return { ok: false as const, error: "邮箱格式不正确" };
if (data.password.length < 8) return { ok: false as const, error: "密码至少 8 位" };
const [existing] = await db.select({ id: users.id }).from(users).where(eq(users.email, email));
if (existing) return { ok: false as const, error: "该邮箱已注册" };
const [user] = await db
.insert(users)
.values({
email,
displayName: data.displayName || email.split("@")[0]!,
passwordHash: Bun.password.hashSync(data.password, { algorithm: "bcrypt", cost: 10 }),
role: "customer",
})
.returning();
const token = await createSession(user.id);
setCookie(SESSION_COOKIE, token, { httpOnly: true, secure: true, sameSite: "lax", path: "/", maxAge: SESSION_DAYS * 86400 });
return { ok: true as const, displayName: user.displayName };
});
/** # 函数 Server Function:后台权限校验(所有 /admin 接口强制调用)
* 返回值 有管理权限的用户;否则抛错(由路由层转 403)
*/
export async function requireAdmin() {
const { getCookie } = await import("@tanstack/react-start/server");
const user = await getSessionUser(getCookie(SESSION_COOKIE));
if (!user || !["admin", "editor", "manager"].includes(user.role)) {
throw new Error("FORBIDDEN");
}
return user;
}
+421
View File
@@ -0,0 +1,421 @@
/**
* # 模块 商城服务层(购物车 / 订单)
* 匿名购物车用 Cookie "dusun_cart"(uuid)关联 carts.session_key;
* 登录用户按 carts.user_id 关联,登录时自动合并匿名购物车。
* 订单类接口要求登录;购物车类接口匿名可用。
*/
import { db } from "~/db";
import { carts, cartItems, orders, orderItems, products, media } from "~/db/schema";
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import { createServerFn } from "@tanstack/react-start";
import { getCookie, setCookie } from "@tanstack/react-start/server";
import { getSessionUser, SESSION_COOKIE } from "./auth";
/** # 成员变量 匿名购物车 Cookie 名 **/
export const CART_COOKIE = "dusun_cart";
/** # 成员变量 匿名购物车 Cookie 有效期(30 天) **/
const CART_COOKIE_MAX_AGE = 30 * 86400;
/** # 类型 购物车明细行 */
export type CartLine = {
/** # 成员变量 明细 ID(cart_items.id) **/
id: number;
/** # 成员变量 产品 ID **/
productId: number;
/** # 成员变量 产品名 **/
title: string;
/** # 成员变量 产品 slug **/
slug: string;
/** # 成员变量 单价(USD 字符串) **/
price: string;
/** # 成员变量 数量 **/
qty: number;
/** # 成员变量 特色图 URL **/
imageUrl: string | null;
/** # 成员变量 库存状态 **/
stockStatus: string;
};
/** # 类型 购物车返回结构 */
export type CartResult = {
items: CartLine[];
/** # 成员变量 小计金额(字符串,保留 2 位) **/
subtotal: string;
/** # 成员变量 商品总件数 **/
count: number;
};
/** # 函数 读取当前会话用户(内部复用) */
async function currentUser() {
return getSessionUser(getCookie(SESSION_COOKIE));
}
/** # 函数 计算购物车明细与汇总(联表产品与特色图) */
async function loadCartLines(cartId: number): Promise<CartLine[]> {
return db
.select({
id: cartItems.id,
productId: products.id,
title: products.title,
slug: products.slug,
price: sql<string>`coalesce(${products.price}, '0')`,
qty: cartItems.qty,
imageUrl: media.url,
stockStatus: products.stockStatus,
})
.from(cartItems)
.innerJoin(products, eq(products.id, cartItems.productId))
.leftJoin(media, eq(media.id, products.featuredMediaId))
.where(eq(cartItems.cartId, cartId))
.orderBy(cartItems.id);
}
/** # 函数 汇总购物车(小计 / 件数) */
function summarizeCart(lines: CartLine[]): CartResult {
const subtotal = lines.reduce((s, l) => s + Number(l.price) * l.qty, 0);
return {
items: lines,
subtotal: subtotal.toFixed(2),
count: lines.reduce((s, l) => s + l.qty, 0),
};
}
/** # 类型 ensureCart 返回:购物车 ID + 新建的匿名 sessionKey(如生成) */
type EnsureCartResult = { cartId: number; newSessionKey?: string };
/** # 函数 购物车核心逻辑:按用户或匿名 sessionKey 查找 / 创建 / 合并购物车(无 Cookie 副作用,供测试复用)
* 参数 opts.userId:登录用户 ID(优先);opts.sessionKey:匿名会话键
* 返回值 购物车 ID(必要时新建);若新生成匿名键,通过 newSessionKey 返回由调用方写 Cookie
*/
export async function ensureCart(opts: { userId?: number | null; sessionKey?: string | null }): Promise<EnsureCartResult> {
if (opts.userId) {
const [userCart] = await db.select().from(carts).where(eq(carts.userId, opts.userId));
const anonKey = opts.sessionKey ?? null;
// 匿名购物车合并:有登录购物车时把匿名车商品挪进去
if (anonKey) {
const [anonCart] = await db.select().from(carts).where(eq(carts.sessionKey, anonKey));
if (anonCart && (!userCart || userCart.id !== anonCart.id)) {
if (userCart) {
// 已有用户购物车:逐行合并(同产品累加数量)
const anonLines = await db.select().from(cartItems).where(eq(cartItems.cartId, anonCart.id));
for (const line of anonLines) {
const [same] = await db
.select()
.from(cartItems)
.where(and(eq(cartItems.cartId, userCart.id), eq(cartItems.productId, line.productId)));
if (same) {
await db.update(cartItems).set({ qty: same.qty + line.qty }).where(eq(cartItems.id, same.id));
} else {
await db.insert(cartItems).values({ cartId: userCart.id, productId: line.productId, qty: line.qty });
}
}
await db.delete(cartItems).where(eq(cartItems.cartId, anonCart.id));
await db.delete(carts).where(eq(carts.id, anonCart.id));
} else {
// 用户还没有购物车:直接把匿名车过户给用户
await db
.update(carts)
.set({ userId: opts.userId, sessionKey: null, updatedAt: new Date() })
.where(eq(carts.id, anonCart.id));
return { cartId: anonCart.id };
}
}
}
if (userCart) return { cartId: userCart.id };
const [created] = await db.insert(carts).values({ userId: opts.userId }).returning({ id: carts.id });
return { cartId: created!.id };
}
// 匿名路径:沿用传入 sessionKey,否则生成新键(新键由调用方写 Cookie)
if (opts.sessionKey) {
const [existing] = await db.select().from(carts).where(eq(carts.sessionKey, opts.sessionKey));
if (existing) return { cartId: existing.id };
}
const key = opts.sessionKey ?? crypto.randomUUID().replace(/-/g, "");
const [created] = await db.insert(carts).values({ sessionKey: key }).returning({ id: carts.id });
return { cartId: created!.id, newSessionKey: opts.sessionKey ? undefined : key };
}
/** # 函数 获取或创建当前购物车(Cookie 胶水层,内部函数)
* 登录用户按 user_id;匿名按 dusun_cart Cookie 的 session_key;登录时自动合并匿名车。
*/
async function getOrCreateCart(): Promise<{ id: number; userId: number | null }> {
const user = await currentUser();
const anonKey = getCookie(CART_COOKIE);
const res = await ensureCart({ userId: user?.id ?? null, sessionKey: user ? (anonKey ?? null) : null });
if (!user) {
// 匿名:复用已有键或写新 Cookie
if (!anonKey && res.newSessionKey) {
setCookie(CART_COOKIE, res.newSessionKey, { httpOnly: true, sameSite: "lax", path: "/", maxAge: CART_COOKIE_MAX_AGE });
}
return { id: res.cartId, userId: null };
}
if (anonKey) setCookie(CART_COOKIE, "", { path: "/", maxAge: 0 }); // 匿名车已合并/清空
return { id: res.cartId, userId: user.id };
}
/** # 函数 购物车核心逻辑:向购物车加商品(校验已发布 + 库存状态可售,供测试复用)
* 参数 cartId:购物车 ID;productId:产品 ID;qty:数量(1-99)
* 返回值 { ok, count } 或 { ok:false, error }
*/
export async function addProductToCart(
cartId: number,
productId: number,
qty: number,
): Promise<{ ok: true; count: number } | { ok: false; error: string }> {
const q = Math.max(1, Math.min(99, Math.floor(qty || 1)));
const [product] = await db
.select({ id: products.id, status: products.status, stockStatus: products.stockStatus })
.from(products)
.where(eq(products.id, productId));
if (!product || product.status !== "published") return { ok: false, error: "产品不存在或未上架" };
if (!["instock", "onbackorder"].includes(product.stockStatus)) return { ok: false, error: "该商品已售罄" };
const [same] = await db
.select()
.from(cartItems)
.where(and(eq(cartItems.cartId, cartId), eq(cartItems.productId, productId)));
if (same) {
await db.update(cartItems).set({ qty: Math.min(99, same.qty + q) }).where(eq(cartItems.id, same.id));
} else {
await db.insert(cartItems).values({ cartId, productId, qty: q });
}
const [{ n }] = await db
.select({ n: sql<number>`coalesce(sum(${cartItems.qty}), 0)` })
.from(cartItems)
.where(eq(cartItems.cartId, cartId));
return { ok: true, count: Number(n) };
}
/** # 函数 Server Function:加入购物车
* 校验产品已发布且库存状态为 instock/onbackorder 才可加。
* 返回值 { ok, count } 或 { ok:false, error }
*/
export const addToCart = createServerFn({ method: "POST" })
.validator((d: { productId: number; qty?: number }) => d)
.handler(async ({ data }): Promise<{ ok: true; count: number } | { ok: false; error: string }> => {
const cart = await getOrCreateCart();
return addProductToCart(cart.id, data.productId, data.qty ?? 1);
});
/** # 函数 Server Function:读取购物车 */
export const getCart = createServerFn({ method: "GET" }).handler(async (): Promise<CartResult> => {
const user = await currentUser();
const anonKey = getCookie(CART_COOKIE);
let cartId: number | null = null;
if (user) {
const [userCart] = await db.select().from(carts).where(eq(carts.userId, user.id));
if (userCart) cartId = userCart.id;
} else if (anonKey) {
const [anonCart] = await db.select().from(carts).where(eq(carts.sessionKey, anonKey));
if (anonCart) cartId = anonCart.id;
}
if (!cartId) return { items: [], subtotal: "0.00", count: 0 };
return summarizeCart(await loadCartLines(cartId));
});
/** # 函数 Server Function:修改购物车明细数量(qty<=0 删除该行) */
export const updateCartItem = createServerFn({ method: "POST" })
.validator((d: { itemId: number; qty: number }) => d)
.handler(async ({ data }): Promise<{ ok: boolean; error?: string }> => {
const cart = await getOrCreateCart();
const [line] = await db
.select()
.from(cartItems)
.where(and(eq(cartItems.id, data.itemId), eq(cartItems.cartId, cart.id)));
if (!line) return { ok: false, error: "明细不存在" };
if (data.qty <= 0) {
await db.delete(cartItems).where(eq(cartItems.id, line.id));
} else {
await db.update(cartItems).set({ qty: Math.min(99, Math.floor(data.qty)) }).where(eq(cartItems.id, line.id));
}
return { ok: true };
});
/** # 函数 Server Function:删除购物车明细 */
export const removeCartItem = createServerFn({ method: "POST" })
.validator((d: { itemId: number }) => d)
.handler(async ({ data }): Promise<{ ok: boolean; error?: string }> => {
const cart = await getOrCreateCart();
await db.delete(cartItems).where(and(eq(cartItems.id, data.itemId), eq(cartItems.cartId, cart.id)));
return { ok: true };
});
/** # 类型 账单/收货信息 */
export type BillingInfo = {
/** # 成员变量 名 **/
firstName: string;
/** # 成员变量 姓 **/
lastName: string;
/** # 成员变量 邮箱 **/
email: string;
/** # 成员变量 电话 **/
phone?: string;
/** # 成员变量 街道地址 **/
address: string;
/** # 成员变量 城市 **/
city: string;
/** # 成员变量 国家 **/
country: string;
/** # 成员变量 邮编 **/
postcode?: string;
};
/** # 函数 生成订单号:DS + 时间戳 36 进制 + 随机 2 位 */
function genOrderNumber(): string {
const rand = () => Math.floor(Math.random() * 36).toString(36);
return `DS${Date.now().toString(36)}${rand()}${rand()}`.toUpperCase();
}
/** # 类型 下单入参(server fn 与核心函数共用) */
export type PlaceOrderInput = {
/** # 成员变量 账单信息 **/
billing: BillingInfo;
/** # 成员变量 收货地址是否与账单相同 **/
shippingSame: boolean;
/** # 成员变量 独立收货地址(shippingSame=false 时生效) **/
shipping?: Partial<BillingInfo>;
/** # 成员变量 客户备注 **/
note?: string;
};
/** # 函数 下单核心逻辑(事务,供测试复用)
* 事务内:重校验可售 → 生成订单(bank_transfer / unpaid)→ 明细快照 →
* 扣库存(stock 为 NULL 的产品跳过)→ 清空购物车。
* 参数 userId:下单用户;cartId:购物车;input:账单/收货/备注
* 返回值 订单号;不可售时抛 Error(中文提示)
*/
export async function placeOrderCore(userId: number, cartId: number, input: PlaceOrderInput): Promise<string> {
const lines = await loadCartLines(cartId);
if (lines.length === 0) throw new Error("购物车为空");
const shipping = input.shippingSame ? input.billing : input.shipping ?? input.billing;
const subtotal = lines.reduce((s, l) => s + Number(l.price) * l.qty, 0);
return db.transaction(async (tx) => {
// 事务内重新校验产品可售并锁定价格
const rows = await tx
.select({
id: products.id,
title: products.title,
sku: products.sku,
price: products.price,
stock: products.stock,
status: products.status,
stockStatus: products.stockStatus,
})
.from(products)
.where(inArray(products.id, lines.map((l) => l.productId)));
const byId = new Map(rows.map((r) => [r.id, r]));
for (const line of lines) {
const p = byId.get(line.productId);
if (!p || p.status !== "published") throw new Error("包含不可购买的商品,请更新购物车");
if (p.stockStatus === "outofstock") throw new Error(`「${p.title}」已售罄,请移除后再结算`);
}
let number = genOrderNumber();
// 订单号唯一索引兜底重试
for (let i = 0; i < 3; i++) {
const [dupe] = await tx.select({ id: orders.id }).from(orders).where(eq(orders.orderNumber, number));
if (!dupe) break;
number = genOrderNumber();
}
const [order] = await tx
.insert(orders)
.values({
orderNumber: number,
userId,
status: "pending",
currency: "USD",
subtotal: subtotal.toFixed(2),
total: subtotal.toFixed(2),
paymentMethod: "bank_transfer",
paymentStatus: "unpaid",
billing: input.billing,
shipping,
customerNote: input.note ?? "",
})
.returning({ id: orders.id, orderNumber: orders.orderNumber });
for (const line of lines) {
const p = byId.get(line.productId)!;
const unit = Number(p.price ?? 0);
await tx.insert(orderItems).values({
orderId: order!.id,
productId: p.id,
name: p.title,
sku: p.sku,
qty: line.qty,
price: unit.toFixed(2),
total: (unit * line.qty).toFixed(2),
});
// 扣库存:stock 字段为 NULL 的产品跳过
if (p.stock !== null && p.stock !== undefined) {
await tx
.update(products)
.set({ stock: sql`greatest(${products.stock} - ${line.qty}, 0)`, updatedAt: new Date() })
.where(eq(products.id, p.id));
}
}
// 清空购物车
await tx.delete(cartItems).where(eq(cartItems.cartId, cartId));
return order!.orderNumber;
});
}
/** # 函数 Server Function:由购物车创建订单
* 必须登录。返回值 { ok:true, orderNumber } 或 { ok:false, error }
*/
export const createOrder = createServerFn({ method: "POST" })
.validator((d: PlaceOrderInput) => d)
.handler(async ({ data }): Promise<{ ok: true; orderNumber: string } | { ok: false; error: string }> => {
const user = await currentUser();
if (!user) return { ok: false, error: "请先登录" };
const cart = await getOrCreateCart();
try {
const orderNumber = await placeOrderCore(user.id, cart.id, data);
return { ok: true, orderNumber };
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : "下单失败" };
}
});
/** # 函数 Server Function:当前用户的订单列表 */
export const getMyOrders = createServerFn({ method: "GET" }).handler(async () => {
const user = await currentUser();
if (!user) return [];
return db
.select({
id: orders.id,
orderNumber: orders.orderNumber,
status: orders.status,
paymentStatus: orders.paymentStatus,
total: orders.total,
createdAt: orders.createdAt,
itemCount: sql<number>`(SELECT coalesce(sum(oi.qty),0) FROM order_items oi WHERE oi.order_id = ${orders.id})`,
})
.from(orders)
.where(eq(orders.userId, user.id))
.orderBy(desc(orders.createdAt))
.limit(50);
});
/** # 函数 Server Function:订单详情(校验属于当前用户) */
export const getOrderDetail = createServerFn({ method: "GET" })
.validator((d: { orderNumber: string }) => d)
.handler(async ({ data }) => {
const user = await currentUser();
if (!user) return null;
const [row] = await db
.select()
.from(orders)
.where(and(eq(orders.orderNumber, data.orderNumber), eq(orders.userId, user.id)));
if (!row) return null;
// jsonb 列推断为 unknown,序列化前显式 cast 为可序列化字段类型
const order = {
...row,
billing: row.billing as Record<string, string | number | boolean | null>,
shipping: row.shipping as Record<string, string | number | boolean | null>,
};
const items = await db.select().from(orderItems).where(eq(orderItems.orderId, order.id));
return { order, items };
});
+562
View File
@@ -0,0 +1,562 @@
/**
* # 函数 前台数据查询层
* 提供菜单树、内容、产品列表(含筛选)、产品详情等 SSR 查询。
* 全部走索引 + 稳定分页,供 Server Function / SSR loader 使用。
*/
import { db } from "~/db";
import {
contents,
categories,
contentTerms,
products,
productTerms,
productAttributes,
productAttributeValues,
productAttributeMap,
menuItems,
menus,
media,
redirects,
inquiries,
inquiryForms,
users,
} from "~/db/schema";
import { and, desc, eq, inArray, sql, asc, like, or, isNotNull, count } from "drizzle-orm";
import { deepRewriteMedia, localSiteUrl } from "~/utils/media";
import { decodeHtmlEntities } from "~/utils/text";
import { slimMetaElementor } from "~/components/elementor/slim";
import { createServerFn } from "@tanstack/react-start";
/** # 类型 菜单项树节点 */
export type MenuNode = {
id: number;
title: string;
url: string;
target: string;
children: MenuNode[];
};
/** # 函数 读取指定位置的菜单树
* 参数 location:菜单位置(menu-1 / menu-2 / max_mega_menu_1 / max_mega_menu_2)
* 返回值 顶层菜单项数组(含子树)
*/
export async function getMenuTree(location: string): Promise<MenuNode[]> {
const [menu] = await db.select().from(menus).where(eq(menus.location, location));
if (!menu) return [];
const items = await db.select().from(menuItems).where(eq(menuItems.menuId, menu.id)).orderBy(asc(menuItems.sort));
const byId = new Map<number, MenuNode>();
const roots: MenuNode[] = [];
// 菜单 URL 导入时多为原站绝对地址,展示层统一改写为本地路径
for (const it of items) byId.set(it.id, { id: it.id, title: it.title, url: localSiteUrl(it.url), target: it.target, children: [] });
for (const it of items) {
const node = byId.get(it.id)!;
if (it.parentId && byId.has(it.parentId)) byId.get(it.parentId)!.children.push(node);
else roots.push(node);
}
return roots;
}
/** # 函数 按别名取已发布内容(页面/文章)
* 返回值 内容与特色图 URL,未发布返回 null
*/
export async function getPublishedContent(slug: string, type?: string) {
const conds = [eq(contents.slug, slug), eq(contents.status, "published")];
if (type) conds.push(eq(contents.type, type));
const [row] = await db
.select({
id: contents.id,
type: contents.type,
slug: contents.slug,
title: contents.title,
excerpt: contents.excerpt,
html: contents.html,
template: contents.template,
views: contents.views,
seoTitle: contents.seoTitle,
seoDescription: contents.seoDescription,
meta: contents.meta,
publishedAt: contents.publishedAt,
featuredUrl: media.url,
featuredAlt: media.altText,
})
.from(contents)
.leftJoin(media, eq(media.id, contents.featuredMediaId))
.where(and(...conds));
if (!row) return null;
// 内容的分类/标签
const terms = await db
.select({ name: categories.name, slug: categories.slug, taxonomy: categories.taxonomy })
.from(contentTerms)
.innerJoin(categories, eq(categories.id, contentTerms.categoryId))
.where(eq(contentTerms.contentId, row.id));
// 标题/SEO/摘要为纯文本字段,解码 WP 实体后返回
// meta.elementor 先瘦身(脱水体积优化);有 elementor 时 html 不再下发给客户端(路由只按 elementor 渲染)
const meta = slimMetaElementor(row.meta);
const hasElementor = !!(meta && typeof meta === "object" && (meta as Record<string, unknown>).elementor);
return deepRewriteMedia({
...row,
html: hasElementor ? null : row.html,
meta,
title: decodeHtmlEntities(row.title),
seoTitle: row.seoTitle ? decodeHtmlEntities(row.seoTitle) : row.seoTitle,
excerpt: row.excerpt ? decodeHtmlEntities(row.excerpt) : row.excerpt,
terms,
});
}
/** # 函数 首页内容(legacy page id=18) */
export async function getHomePage() {
return getPublishedContent("home", "page");
}
/** # 类型 产品筛选状态:每个筛选字段已选值列表 */
export type FilterState = Record<string, string[]>;
/** # 类型 产品列表返回结构 */
export type ProductListResult = {
items: {
id: number;
slug: string;
title: string;
sku: string | null;
price: string | null;
regularPrice: string | null;
stockStatus: string;
imageUrl: string | null;
imageAlt: string | null;
excerpt: string;
}[];
total: number;
page: number;
pageSize: number;
};
/** # 函数 产品列表(支持筛选 + 分类 + 分页 + 排序)
* 筛选查询:product_attribute_map 复合索引,IN 组合条件取交集(AND 语义)。
* 参数 opts.category:产品分类 slug;opts.filters:{属性slug: [值slug]}
*/
export async function listProducts(opts: {
category?: string;
filters?: FilterState;
q?: string;
page?: number;
pageSize?: number;
sort?: "latest" | "title" | "views";
}): Promise<ProductListResult> {
const page = Math.max(1, opts.page || 1);
const pageSize = Math.min(48, Math.max(8, opts.pageSize || 24));
const conds = [eq(products.status, "published")];
if (opts.category) {
const conds2 = [...conds];
const [cat] = await db.select({ id: categories.id }).from(categories).where(and(eq(categories.taxonomy, "product_cat"), eq(categories.slug, opts.category)));
if (!cat) return { items: [], total: 0, page, pageSize };
conds2.push(sql`EXISTS (SELECT 1 FROM product_terms pt WHERE pt.product_id=${products.id} AND pt.category_id=${cat.id})`);
return runProductQuery(conds2, page, pageSize, opts.sort);
}
if (opts.q) {
conds.push(or(like(products.title, `%${opts.q}%`), like(products.sku, `%${opts.q}%`))!);
}
// 属性筛选:每个字段一个 EXISTS 交集
const filterEntries = Object.entries(opts.filters || {}).filter(([, v]) => v.length > 0);
for (const [attrSlug, valueSlugs] of filterEntries) {
const valueIds = await db
.select({ id: productAttributeValues.id })
.from(productAttributeValues)
.innerJoin(productAttributes, eq(productAttributes.id, productAttributeValues.attributeId))
.where(and(eq(productAttributes.slug, attrSlug), inArray(productAttributeValues.slug, valueSlugs)));
if (valueIds.length === 0) return { items: [], total: 0, page, pageSize };
conds.push(
sql`EXISTS (SELECT 1 FROM product_attribute_map m WHERE m.product_id=${products.id} AND m.value_id IN ${valueIds.map((v) => v.id)})`,
);
}
return runProductQuery(conds, page, pageSize, opts.sort);
}
/** # 函数 产品查询执行(公共部分:JOIN 特色图 + 计数 + 排序分页) */
async function runProductQuery(conds: unknown[], page: number, pageSize: number, sort?: string): Promise<ProductListResult> {
const where = and(...(conds as never[]));
const [{ total }] = await db.select({ total: count() }).from(products).where(where);
const order = sort === "title" ? asc(products.title) : sort === "views" ? desc(products.views) : desc(products.createdAt);
const items = await db
.select({
id: products.id,
slug: products.slug,
title: products.title,
sku: products.sku,
price: products.price,
regularPrice: products.regularPrice,
stockStatus: products.stockStatus,
imageUrl: media.url,
imageAlt: media.altText,
excerpt: products.shortDescription,
})
.from(products)
.leftJoin(media, eq(media.id, products.featuredMediaId))
.where(where)
.orderBy(order)
.limit(pageSize)
.offset((page - 1) * pageSize);
return deepRewriteMedia({
items: items.map((it) => ({ ...it, title: decodeHtmlEntities(it.title) })),
total,
page,
pageSize,
});
}
/** # 函数 产品详情(含图廊、属性、分类) */
export async function getProductDetail(slug: string) {
const [row] = await db
.select({
id: products.id,
slug: products.slug,
title: products.title,
sku: products.sku,
shortDescription: products.shortDescription,
description: products.description,
price: products.price,
regularPrice: products.regularPrice,
stockStatus: products.stockStatus,
stock: products.stock,
specUrl: products.specUrl,
seoTitle: products.seoTitle,
seoDescription: products.seoDescription,
meta: products.meta,
views: products.views,
featuredUrl: media.url,
featuredAlt: media.altText,
publishedAt: products.createdAt,
})
.from(products)
.leftJoin(media, eq(media.id, products.featuredMediaId))
.where(and(eq(products.slug, slug), eq(products.status, "published")));
if (!row) return null;
const galleryUrls = row.meta && Array.isArray((row.meta as any).gallery)
? await db.select({ url: media.url, alt: media.altText }).from(media).where(inArray(media.id, (row.meta as any).gallery))
: [];
const attributes = await db
.select({ attr: productAttributes.name, attrSlug: productAttributes.slug, value: productAttributeValues.value, valueSlug: productAttributeValues.slug })
.from(productAttributeMap)
.innerJoin(productAttributeValues, eq(productAttributeValues.id, productAttributeMap.valueId))
.innerJoin(productAttributes, eq(productAttributes.id, productAttributeValues.attributeId))
.where(eq(productAttributeMap.productId, row.id))
.orderBy(asc(productAttributes.sort), asc(productAttributeValues.sort));
const cats = await db
.select({ name: categories.name, slug: categories.slug })
.from(productTerms)
.innerJoin(categories, eq(categories.id, productTerms.categoryId))
.where(eq(productTerms.productId, row.id));
// meta.elementor 瘦身;有 elementor 时 description(html) 不下发(路由按 elementor 渲染)
const meta = slimMetaElementor(row.meta);
const hasElementor = !!(meta && typeof meta === "object" && (meta as Record<string, unknown>).elementor);
return deepRewriteMedia({
...row,
description: hasElementor ? null : row.description,
meta,
title: decodeHtmlEntities(row.title),
seoTitle: row.seoTitle ? decodeHtmlEntities(row.seoTitle) : row.seoTitle,
gallery: galleryUrls,
attributes,
categories: cats,
});
}
/** # 函数 筛选器定义(前台侧栏渲染用)+ 每个值的产品计数 */
export async function getFilterConfig() {
const attrs = await db.select().from(productAttributes).where(eq(productAttributes.filterable, true)).orderBy(asc(productAttributes.sort));
const configs: {
name: string;
slug: string;
values: { value: string; slug: string; count: number }[];
}[] = [];
for (const a of attrs) {
const values = await db
.select({ value: productAttributeValues.value, slug: productAttributeValues.slug, count: sql<number>`count(${productAttributeMap.productId})` })
.from(productAttributeValues)
.leftJoin(productAttributeMap, eq(productAttributeMap.valueId, productAttributeValues.id))
.where(eq(productAttributeValues.attributeId, a.id))
.groupBy(productAttributeValues.id)
.orderBy(asc(productAttributeValues.sort), asc(productAttributeValues.value));
configs.push({ name: a.name, slug: a.slug, values: values.filter((v) => v.count > 0) });
}
return configs;
}
/** # 函数 产品分类树(侧栏/导航用,含计数) */
export async function getProductCategoryTree() {
const rows = await db
.select({ id: categories.id, name: categories.name, slug: categories.slug, parentId: categories.parentId, count: sql<number>`(SELECT count(*) FROM product_terms pt WHERE pt.category_id=${categories.id})` })
.from(categories)
.where(eq(categories.taxonomy, "product_cat"))
.orderBy(asc(categories.sort), asc(categories.name));
return rows.filter((r) => Number(r.count) > 0);
}
/** # 函数 文章列表(分页) */
/** # 函数 从内容 meta(elementor 文档或 HTML)中提取第一张上传图片 URL
* 参数 meta 任意 meta JSON
* 返回值 图片 URL(含原站绝对地址,展示层统一改写)或 null
*/
function firstUploadedImage(meta: unknown): string | null {
if (!meta) return null;
const seen = new Set<unknown>();
const stack: unknown[] = [meta];
while (stack.length) {
const cur = stack.pop();
if (cur == null || typeof cur !== "object" || seen.has(cur)) continue;
seen.add(cur);
if (typeof cur === "string") {
const m = /(?:https?:\/\/[^\s"']*\/wp-content\/uploads\/[^\s"'\)]+|\/uploads\/[^\s"'\)]+\.(?:png|jpe?g|webp|gif))/i.exec(cur);
if (m) return m[0];
continue;
}
for (const v of Object.values(cur as Record<string, unknown>)) stack.push(v);
}
return null;
}
/** # 类型 博客列表项(列表卡片 + 文章页侧栏/相关文章复用) */
export type PostListItem = Awaited<ReturnType<typeof listPosts>>["items"][number];
/** # 成员变量 /solution/ 索引页展示的方案落地页 slug(与原站 SOLUTION 索引卡片一一对应) **/
export const SOLUTION_SLUGS = [
"smart-apartment-solution",
"smart-apartment-gateway-oem",
"oil-gas-management-solution",
"energy-storage-ems-solution",
"smart-robot-controller-solution",
"vehicle-iot-solution",
"smart-building-solution",
"smart-retail-solution",
"smart-home-solution",
"smart-elderly-care-solution",
"ble-positioning-solution",
] as const;
/** # 函数 方案落地页列表(/solution/ 索引卡片:标题 + 摘要 + 图)
* 返回值 按 SOLUTION_SLUGS 顺序的已发布 elementor 方案页(缺省跳过)
*/
export async function listSolutionLandings() {
const rows = await db
.select({
id: contents.id,
slug: contents.slug,
title: contents.title,
excerpt: contents.excerpt,
imageUrl: media.url,
imageAlt: media.altText,
hasElementor: sql<boolean>`(meta -> 'elementor') is not null`,
})
.from(contents)
.leftJoin(media, eq(media.id, contents.featuredMediaId))
.where(and(eq(contents.type, "post"), eq(contents.status, "published"), inArray(contents.slug, [...SOLUTION_SLUGS])));
const bySlug = new Map(rows.map((r) => [r.slug, r]));
return [...SOLUTION_SLUGS]
.map((s) => bySlug.get(s))
.filter((r) => !!r)
.map((r) => ({ ...r, title: decodeHtmlEntities(r.title) }));
}
export async function listPosts(page = 1, pageSize = 12, categorySlug?: string) {
const conds = [eq(contents.type, "post"), eq(contents.status, "published")];
if (categorySlug) {
const [cat] = await db.select({ id: categories.id }).from(categories).where(and(eq(categories.taxonomy, "category"), eq(categories.slug, categorySlug)));
if (!cat) return { items: [], total: 0, page, pageSize };
conds.push(sql`EXISTS (SELECT 1 FROM content_terms ct WHERE ct.content_id=${contents.id} AND ct.category_id=${cat.id})`);
}
const where = and(...conds);
const [{ total }] = await db.select({ total: count() }).from(contents).where(where);
const items = await db
.select({
id: contents.id,
slug: contents.slug,
title: contents.title,
excerpt: contents.excerpt,
publishedAt: contents.publishedAt,
views: contents.views,
imageUrl: media.url,
// 列表卡片元信息:作者显示名 + 第一个分类名(原站卡片带作者·日期与分类徽章)
authorName: users.displayName,
categoryName: sql<string | null>`(SELECT cat.name FROM content_terms ct JOIN categories cat ON cat.id = ct.category_id WHERE ct.content_id = ${contents.id} AND cat.taxonomy = 'category' LIMIT 1)`,
})
.from(contents)
.leftJoin(media, eq(media.id, contents.featuredMediaId))
.leftJoin(users, eq(users.id, contents.authorId))
.where(where)
.orderBy(desc(contents.publishedAt))
.limit(pageSize)
.offset((page - 1) * pageSize);
// 无特色图的文章:从 elementor 内容里提取第一张上传图片作列表图回退
const needFallback = items.filter((it) => !it.imageUrl);
if (needFallback.length) {
const metas = await db
.select({ id: contents.id, meta: contents.meta })
.from(contents)
.where(inArray(contents.id, needFallback.map((it) => it.id)));
const firstImg = new Map<number, string>();
for (const m of metas) {
const url = firstUploadedImage(m.meta);
if (url) firstImg.set(m.id, url);
}
for (const it of items) if (!it.imageUrl && firstImg.has(it.id)) it.imageUrl = firstImg.get(it.id)!;
}
return deepRewriteMedia({
items: items.map((it) => ({ ...it, title: decodeHtmlEntities(it.title) })),
total,
page,
pageSize,
});
}
/** # 函数 URL 重定向查询(301/302 兼容旧链接) */
export async function findRedirect(source: string) {
const [exact] = await db.select().from(redirects).where(and(eq(redirects.source, source), eq(redirects.enabled, true)));
return exact ?? null;
}
/** # 函数 URL 重定向链式解析(自指跳过 + 跳数上限)
* 库里同时存在「无斜杠→有斜杠」规范化规则和「路径→真实目标」规则(如
* /dusun-partner-ziroom → /dusun-partner-ziroom/ 与 /dusun-partner-ziroom/ → /partners/...)。
* 只按去斜杠形式精确匹配会命中前者并把浏览器 301 回原路径形成死循环。
* 这里按 请求原样 → 去尾斜杠 → 带尾斜杠 依次精确匹配;目标与当前路径相同视为
* 自指,前进到目标形式继续找下一条;最多跟随 6 跳。
* 参数 path 请求路径
* 返回值 最终应重定向到的 { target, code, id };无命中返回 null
*/
export async function resolveRedirect(path: string): Promise<{ target: string; code: number; id: number } | null> {
const norm = (p: string) => (p === "" || p === "/" ? "/" : "/" + p.replace(/^\/+|\/+$/g, ""));
const origin = norm(path);
const visited = new Set<string>([origin]);
let current = path;
let result: { target: string; code: number; id: number } | null = null;
for (let i = 0; i < 6; i++) {
const k = norm(current);
const rule =
(await findRedirect(current)) ??
(await findRedirect(k)) ??
(k !== "/" ? await findRedirect(k + "/") : null);
if (!rule) break;
const t = norm(rule.target);
if (t === k || t === origin) {
// 自指/回到起点:前进到目标形式,继续查下一条规则
current = rule.target;
continue;
}
if (visited.has(t)) break; // 目标已走过(A→B→A 环):停止跟随,沿用已有结果
visited.add(t);
result = { target: rule.target, code: rule.code, id: rule.id };
current = rule.target;
}
return result;
}
/** # 函数 判断 slug 是否存在已发布内容(page/post 均算,供重定向「内容优先」判断)
* 参数 slug 内容 slug(不含路径前缀)
* 返回值 true=存在已发布内容
*/
export async function hasPublishedSlug(slug: string): Promise<boolean> {
const [row] = await db
.select({ id: contents.id })
.from(contents)
.where(and(eq(contents.slug, slug), eq(contents.status, "published")))
.limit(1);
return !!row;
}
/** # 函数 文章分类列表(博客导航) */
export async function getPostCategories() {
return db
.select({ name: categories.name, slug: categories.slug, count: sql<number>`(SELECT count(*) FROM content_terms ct WHERE ct.category_id=${categories.id})` })
.from(categories)
.where(eq(categories.taxonomy, "category"))
.orderBy(asc(categories.sort), asc(categories.name));
}
/** # 函数 按 slug 取文章分类(WP /%category%/ 归档路径用;仅 taxonomy=category)
* 参数 slug 分类 slug(不含路径前缀)
* 返回值 { id, name, slug };无命中返回 null
*/
export async function getPostCategoryBySlug(slug: string) {
const [row] = await db
.select({ id: categories.id, name: categories.name, slug: categories.slug })
.from(categories)
.where(and(eq(categories.taxonomy, "category"), eq(categories.slug, slug)))
.limit(1);
return row ?? null;
}
/** # 函数 判断产品 slug 是否存在已发布产品(产品规格旧路径改判用)
* 参数 slug 产品 slug
* 返回值 true=存在已发布产品
*/
export async function hasProductSlug(slug: string) {
const [row] = await db.select({ id: products.id }).from(products).where(and(eq(products.slug, slug), eq(products.status, "published"))).limit(1);
return !!row;
}
/** # 函数 按父分类 slug + 子分类 slug 取产品子分类(嵌套分类路径改判用)
* 参数 parentSlug 父分类 slug;childSlug 子分类 slug
* 返回值 子分类 { id, name, slug };无命中返回 null
*/
export async function getProductChildCategory(parentSlug: string, childSlug: string) {
const [row] = await db
.select({ id: categories.id, name: categories.name, slug: categories.slug })
.from(categories)
.where(
and(
eq(categories.taxonomy, "product_cat"),
eq(categories.slug, childSlug),
sql`${categories.parentId} = (SELECT id FROM categories WHERE slug = ${parentSlug} AND taxonomy = 'product_cat' ORDER BY id LIMIT 1)`,
),
)
.limit(1);
return row ?? null;
}
/** # 函数 活跃询盘表单定义(按 key) */
export async function getInquiryForm(formKey: string) {
const [row] = await db.select().from(inquiryForms).where(and(eq(inquiryForms.formKey, formKey), eq(inquiryForms.enabled, true)));
// 表单字段可能内嵌原站绝对地址(CF7 导入内容),展示层统一改写
return row ? deepRewriteMedia(row) : null;
}
/** # 函数 Sitemap 数据:全部已发布 URL */
export async function getSitemapUrls() {
const pages = await db.select({ slug: contents.slug, type: contents.type, publishedAt: contents.publishedAt }).from(contents).where(eq(contents.status, "published"));
const prods = await db.select({ slug: products.slug, updatedAt: products.updatedAt }).from(products).where(eq(products.status, "published"));
const cats = await db.select({ slug: categories.slug }).from(categories).where(eq(categories.taxonomy, "product_cat"));
return { pages, prods, cats };
}
/** # 类型 Ticker/Posts widget 用的轻量条目 */
export type LatestItem = { title: string; url: string };
/** # Server Function 最新产品标题(eael-content-ticker widget,product 类型)
* 取证(2026-10-31 原站实测):ticker 三条 = 最新发布的 3 个产品
* (dsgw-512 → dsom-110r → dsgw-510,与 created_at 降序一致),非按浏览量。
* 参数 data.limit 取条数(原站 posts_per_page=3)
* 返回值 {title,url}[],url 形如 /product/<slug>/
*/
export const getLatestProducts = createServerFn({ method: "GET" })
.validator((d?: { limit?: number }) => d)
.handler(async ({ data }): Promise<LatestItem[]> => {
const rows = await db
.select({ slug: products.slug, title: products.title })
.from(products)
.where(eq(products.status, "published"))
.orderBy(desc(products.createdAt))
.limit(Math.max(1, Math.min(20, data?.limit ?? 8)));
return rows.map((r) => ({ title: r.title, url: "/product/" + r.slug + "/" }));
});
/** # Server Function 最新文章卡片(posts widget / ticker post 类型) */
export const getLatestPosts = createServerFn({ method: "GET" }).handler(async (): Promise<LatestItem[]> => {
const rows = await db
.select({ slug: contents.slug, title: contents.title })
.from(contents)
.where(and(eq(contents.type, "post"), eq(contents.status, "published")))
.orderBy(desc(contents.publishedAt))
.limit(8);
return rows.map((r) => ({ title: r.title, url: "/blog/" + r.slug + "/" }));
});
+114
View File
@@ -0,0 +1,114 @@
/**
* # 函数 询盘模块(Contact Form 7 / Elementor Form 功能等价实现)
* 前台提交 → 入库(inquiries)→ 投递邮件任务(jobs 队列)→ 通知。
* 反垃圾:蜜罐字段 + 提交频率限制 + 简单规则。
*/
import { db } from "~/db";
import { inquiries, inquiryForms, jobs, auditLogs } from "~/db/schema";
import { createServerFn } from "@tanstack/react-start";
import { desc, eq, sql } from "drizzle-orm";
import type { Json } from "~/components/admin/common";
/** # 类型 询盘提交载荷(通用字段 + 任意表单扩展字段) */
export type InquiryPayload = {
formKey: string;
name: string;
email: string;
phone?: string;
company?: string;
country?: string;
message: string;
productInterest?: string;
/** # 成员变量 蜜罐字段:正常用户不会填写 */
_gotcha?: string;
/** # 成员变量 UTM 参数(由提交端透传) */
utm?: Record<string, string>;
/** # 成员变量 其余表单字段(annualdemand / utm 等) */
extra?: Record<string, string>;
};
/** # 函数 Server Function:提交询盘
* 流程:蜜罐检测 → 频率限制(同 IP 每分钟 5 条)→ 入库 → 邮件任务入队。
*/
export const submitInquiry = createServerFn({ method: "POST" })
.validator((d: InquiryPayload) => d)
.handler(async ({ data }) => {
const { getRequest } = await import("@tanstack/react-start/server");
const req = getRequest();
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip") || "";
if (data._gotcha) return { ok: true as const, spam: true }; // 蜜罐命中:假装成功
// 频率限制
const [{ n }] = await db
.select({ n: sql<number>`count(*)` })
.from(inquiries)
.where(sql`${inquiries.ip} = ${ip} AND ${inquiries.createdAt} > now() - interval '1 minute'`);
if (Number(n) >= 5) return { ok: false as const, error: "提交过于频繁,请稍后再试" };
const [form] = await db.select().from(inquiryForms).where(eq(inquiryForms.formKey, data.formKey));
const { utm, _gotcha, extra, ...core } = data;
await db.insert(inquiries).values({
formName: form?.name || "Website Form",
formId: form?.id ?? null,
name: (core.name || "").slice(0, 255),
email: (core.email || "").slice(0, 255),
phone: (core.phone || "").slice(0, 128),
company: (core.company || "").slice(0, 255),
country: (core.country || "").slice(0, 128),
message: core.message || "",
productInterest: (core.productInterest || "").slice(0, 255),
fields: { ...(extra || {}), ...(core.productInterest ? { product: core.productInterest } : {}) },
status: "new",
channel: form?.name || "",
sourcePage: req.headers.get("referer") || "",
ip,
utm: Object.fromEntries(
Object.entries(extra || {})
.filter(([k]) => k.startsWith("utm_") || k === "gclid")
.map(([k, v]) => [k, v]),
),
});
// 邮件通知任务(持久化队列,独立于请求执行)
await db.insert(jobs).values({
type: "email.send",
payload: {
to: form?.mailTo || process.env.INQUIRY_NOTIFY_EMAIL || "sales@dusuniot.com",
subject: `[DusunIoT 询盘] ${core.name || core.email} - ${core.productInterest || "General"}`,
body: JSON.stringify(data),
},
});
return { ok: true as const };
});
/** # 函数 Server Function:询盘列表(后台) */
export const listInquiries = createServerFn({ method: "GET" })
.validator((d: { status?: string; page?: number; pageSize?: number; q?: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const page = Math.max(1, data.page || 1);
const pageSize = Math.min(100, data.pageSize || 20);
const conds = [];
if (data.status) conds.push(eq(inquiries.status, data.status));
if (data.q) conds.push(sql`(${inquiries.email} ILIKE ${"%" + data.q + "%"} OR ${inquiries.name} ILIKE ${"%" + data.q + "%"} OR ${inquiries.company} ILIKE ${"%" + data.q + "%"})`);
const where = conds.length ? sql.join(conds, sql` AND `) : undefined;
const rows = await db
.select()
.from(inquiries)
.where(where)
.orderBy(desc(inquiries.createdAt))
.limit(pageSize)
.offset((page - 1) * pageSize);
const [{ total }] = await db.select({ total: sql<number>`count(*)` }).from(inquiries).where(where);
// fields / utm 为 jsonb(unknown),收窄为 Json 以满足 server fn 可序列化校验
return { items: rows.map((r) => ({ ...r, fields: r.fields as Json, utm: r.utm as Json })), total, page, pageSize };
});
/** # 函数 Server Function:更新询盘状态(后台) */
export const updateInquiryStatus = createServerFn({ method: "POST" })
.validator((d: { id: number; status: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
await db.update(inquiries).set({ status: data.status }).where(eq(inquiries.id, data.id));
await db.insert(auditLogs).values({ actorId: admin.id, action: "inquiry.status", entity: "inquiry", entityId: String(data.id), meta: { status: data.status } });
return { ok: true as const };
});
+80
View File
@@ -0,0 +1,80 @@
import { initDb } from "~/db";
/**
* # 函数 后台任务队列 Worker
* 消费 jobs 表(pending → running → done/failed),带失败重试与可观测性。
* 独立进程运行:bun run scripts/jobs-worker.ts(不依赖 Web 请求触发)。
*/
import { db } from "~/db";
import { jobs, inquiries } from "~/db/schema";
import { and, eq, lte, or, sql } from "drizzle-orm";
// 服务端预热数据库连接(worker/测试直接运行本模块)
await initDb();
/** # 类型 任务处理器映射 */
const handlers: Record<string, (payload: Record<string, unknown>) => Promise<void>> = {
/** 邮件发送:当前为可观测的 stub(落日志),接入 SMTP 时替换实现 */
"email.send": async (payload) => {
console.log(`[email.send] to=${payload.to} subject=${payload.subject}`);
// TODO: 接入 SMTP(原站 wp-mail-smtp 配置迁移后启用)
},
};
/** # 函数 领取一个到期任务(FOR UPDATE SKIP LOCKED 防止多 worker 重复消费) */
async function claimJob() {
const rows = await db.execute(sql`
UPDATE jobs SET status='running', attempts=attempts+1, updated_at=now()
WHERE id = (
SELECT id FROM jobs
WHERE status='pending' AND run_at <= now()
ORDER BY run_at LIMIT 1 FOR UPDATE SKIP LOCKED
)
RETURNING id, type, payload, attempts, max_attempts`);
return (rows as any).rows?.[0] ?? (rows as any)[0] ?? null;
}
/** # 函数 处理单个任务(失败按 maxAttempts 重试,指数退避) */
async function processJob(job: { id: number; type: string; payload: unknown; attempts: number; max_attempts: number }) {
const handler = handlers[job.type];
try {
if (!handler) throw new Error(`没有注册处理器: ${job.type}`);
await handler((job.payload || {}) as Record<string, unknown>);
await db.update(jobs).set({ status: "done", updatedAt: new Date() }).where(eq(jobs.id, job.id));
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
const failed = job.attempts >= job.max_attempts;
const backoff = new Date(Date.now() + Math.min(30 * 60_000, 30_000 * 2 ** job.attempts));
await db
.update(jobs)
.set({ status: failed ? "failed" : "pending", lastError: msg, runAt: backoff, updatedAt: new Date() })
.where(eq(jobs.id, job.id));
console.error(`[worker] job ${job.id} (${job.type}) ${failed ? "最终失败" : "重试中"}: ${msg}`);
}
}
/** # 函数 Worker 主循环 */
export async function runWorkerLoop(stopAfterMs = Number(process.env.WORKER_MAX_MS || 0)) {
const started = Date.now();
console.log("[worker] 启动,轮询间隔 5s");
while (true) {
try {
const job = await claimJob();
if (job) {
await processJob(job);
continue; // 立即看下一个
}
} catch (e) {
console.error("[worker] 轮询错误:", e);
}
if (stopAfterMs && Date.now() - started > stopAfterMs) break;
await Bun.sleep(5000);
}
}
// 直接运行时进入主循环
if (import.meta.main) {
runWorkerLoop().catch((e) => {
console.error("[worker] 致命错误:", e);
process.exit(1);
});
}
+86
View File
@@ -0,0 +1,86 @@
/**
* # 模块 全局请求中间件:旧链接 301/302 重定向
* 在 router 请求阶段查 redirects 表:先精确匹配(findRedirect),
* 再对 is_regex 的条目做正则匹配;命中即返回 301/302 Response。
* 正则条目内存缓存 60 秒,避免每请求全表扫描。
*/
import { createMiddleware } from "@tanstack/react-start";
import { db } from "~/db";
import { redirects } from "~/db/schema";
import { and, eq, sql } from "drizzle-orm";
import { hasPublishedSlug, resolveRedirect } from "./data";
/** # 类型 已启用的正则重定向条目(缓存行) **/
type RegexEntry = { source: string; target: string; code: number };
/** # 成员变量 正则重定向缓存 **/
let regexCache: { entries: RegexEntry[]; fetchedAt: number } | null = null;
const REGEX_CACHE_TTL = 60_000;
/** # 函数 读取启用的正则重定向条目(带缓存) */
async function getRegexEntries(): Promise<RegexEntry[]> {
if (regexCache && Date.now() - regexCache.fetchedAt < REGEX_CACHE_TTL) return regexCache.entries;
const rows = await db
.select({ source: redirects.source, target: redirects.target, code: redirects.code })
.from(redirects)
.where(and(eq(redirects.isRegex, true), eq(redirects.enabled, true)));
regexCache = { entries: rows, fetchedAt: Date.now() };
return rows;
}
/** # 函数 命中计数(fire-and-forget,不阻塞响应) */
function bumpHits(id: number) {
db.update(redirects)
.set({ hits: sql`${redirects.hits} + 1` })
.where(eq(redirects.id, id))
.catch(() => {});
}
/** # 函数 请求重定向中间件
* 仅处理 router 类型请求;路径先带斜杠再无斜杠各查一次精确匹配。
*/
export const redirectMiddleware = createMiddleware({ type: "request" }).server(async ({ request, pathname, next, handlerType }) => {
// server function RPC 与静态资源不查重定向
if (handlerType !== "router") return next();
const path = decodeURI(pathname).replace(/\/+$/, "") || "/";
// 1) 链式解析(原样/规范化/带斜杠变体依次匹配,自指规则跳过防死循环)。
// 内容优先:路径末段能命中已发布内容时直接出内容不重定向(与 sitemap 的
// canonical 一致;导入的重定向表存在 A→B 且 B→A 的矛盾规则对,内容优先可破环)
const resolved = await resolveRedirect(path);
if (resolved) {
const lastSeg = path.split("/").filter(Boolean).pop() ?? "";
if (!(await hasPublishedSlug(decodeURIComponent(lastSeg)).catch(() => false))) {
return toRedirect(request, resolved.target, resolved.code, resolved.id);
}
}
// 2) 正则匹配(目标里的 $1..$9 替换为捕获组;部分条目匹配带查询串的完整路径)
const search = new URL(request.url).search;
const full = path + search;
const entries = await getRegexEntries();
for (const entry of entries) {
try {
const re = new RegExp(entry.source);
const m = re.exec(path) ?? re.exec(pathname) ?? re.exec(full);
if (m) return toRedirect(request, entry.target, entry.code, undefined, m);
} catch {
// 非法正则跳过
}
}
return next();
});
/** # 函数 构造重定向响应(相对目标解析为绝对 URL;$n 替换捕获组) */
function toRedirect(request: Request, target: string, code: number, id?: number, match?: RegExpExecArray): Response {
let dest = target;
if (match) {
dest = target.replace(/\$(\d)/g, (_, d: string) => match[Number(d)] ?? "");
}
if (id) bumpHits(id);
const abs = new URL(dest, request.url).toString();
const headers = new Headers({ location: abs });
return new Response(null, { status: code, headers });
}
+43
View File
@@ -0,0 +1,43 @@
/**
* # 函数 WordPress phpass(Portable Hash)密码校验
* 存量 WooCommerce 客户的密码是 $P$ 开头的 phpass 哈希;
* 迁移后不重置用户密码,登录时先用本函数校验,成功后透明升级为 bcrypt。
* 实现:MD5 迭代(iter + 1 次),与 PHP phpass 算法一致。
*/
const ITOA64 = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
/** # 函数 phpass 编码 64 进制(内部用) */
function encode64(input: Buffer, count: number): string {
let out = "";
let i = 0;
do {
const value = input[i]!;
out += ITOA64[value & 0x3f];
i++;
if (i < count) out += ITOA64[((input[i - 1]! << 8) | input[i]!) & 0x3f];
i++;
if (i < count) out += ITOA64[(input[i - 1]! >> 4) & 0x3f];
i++;
if (i < count) out += ITOA64[((input[i - 1]! << 4) | input[i]!) & 0x3f];
i++;
} while (i < count);
return out;
}
/** # 函数 校验明文密码是否匹配 phpass 哈希
* 参数 hash:$P$ 开头的 WordPress 密码哈希
* 返回值 匹配返回 true
*/
export async function phpassVerify(password: string, hash: string): Promise<boolean> {
if (!hash.startsWith("$P$")) return false;
const countLog2 = ITOA64.indexOf(hash[3]!);
if (countLog2 < 7 || countLog2 > 30) return false;
const count = 1 << countLog2;
const salt = hash.slice(4, 12);
// 函数内动态导入:顶层 import 会在客户端路由树求值时崩溃(node:crypto 被
// externalize),服务端运行时由 Bun 原生解析
const { createHash } = await import("node:crypto");
let h = createHash("md5").update(salt + password).digest();
for (let i = 0; i < count; i++) h = createHash("md5").update(h).update(password).digest();
return encode64(h, h.length) === hash.slice(12);
}
+131
View File
@@ -0,0 +1,131 @@
/**
* # 函数 多语言模块(GTranslate 功能等价)
* 三层数据模型:
* 1. 默认语言(en):contents/products 正本。
* 2. 真实翻译:content_translations / product_translations(人工审核后发布,独立 slug/URL/SEO)。
* 3. 机翻层:GTranslate 存量的 104 种语言按 /<lang>/<path>/ URL 动态渲染;
* 仅返回壳层翻译(导航/按钮等 UI 字符串),正文回退默认语言并标注,不生成伪语言静态页。
*/
import { db } from "~/db";
import { languages, contentTranslations, productTranslations, contents, products, media } from "~/db/schema";
import { and, eq, asc } from "drizzle-orm";
/** # 成员变量 机翻语言常用 UI 字符串(壳层翻译,未收录的回退英文) */
const UI_STRINGS: Record<string, Record<string, string>> = {
"zh-CN": { "Get a Quote": "获取报价", "Search": "搜索", "Contact Us": "联系我们", "View Product": "查看产品", "Read More": "阅读更多", "Home": "首页", "Products": "产品", "Blog": "博客", "Shop": "商城", "Cart": "购物车", "Checkout": "结账", "Inquiry Form": "询盘表单", "Send Inquiry": "发送询盘", "Name": "姓名", "Email": "邮箱", "Phone": "电话", "Company": "公司", "Country": "国家", "Message": "留言", "Related Products": "相关产品", "Specifications": "规格参数" },
"zh-TW": { "Get a Quote": "獲取報價", "Search": "搜索", "Contact Us": "聯繫我們", "View Product": "查看產品", "Home": "首頁", "Products": "產品", "Blog": "博客" },
de: { "Get a Quote": "Angebot einholen", "Search": "Suche", "Contact Us": "Kontakt", "View Product": "Produkt ansehen", "Home": "Startseite", "Products": "Produkte", "Blog": "Blog" },
fr: { "Get a Quote": "Obtenir un devis", "Search": "Recherche", "Contact Us": "Contactez-nous", "View Product": "Voir le produit", "Home": "Accueil", "Products": "Produits", "Blog": "Blog" },
es: { "Get a Quote": "Solicitar presupuesto", "Search": "Buscar", "Contact Us": "Contáctenos", "View Product": "Ver producto", "Home": "Inicio", "Products": "Productos", "Blog": "Blog" },
ja: { "Get a Quote": "見積りを依頼", "Search": "検索", "Contact Us": "お問い合わせ", "View Product": "製品を見る", "Home": "ホーム", "Products": "製品", "Blog": "ブログ" },
ru: { "Get a Quote": "Запросить предложение", "Search": "Поиск", "Contact Us": "Связаться", "View Product": "Открыть продукт", "Home": "Главная", "Products": "Продукты", "Blog": "Блог" },
};
/** # 函数 获取启用的语言列表(语言切换器用) */
export async function getEnabledLanguages() {
return db.select().from(languages).where(eq(languages.enabled, true)).orderBy(asc(languages.sort));
}
/** # 函数 获取机翻 UI 字符串表
* 参数 lang:语言代码
* 返回值 字符串映射(未知语言返回空表,前端回退英文)
*/
export function getUiStrings(lang: string): Record<string, string> {
return UI_STRINGS[lang] || UI_STRINGS[lang.split("-")[0]!] || {};
}
/** # 函数 解析 URL 中的语言前缀
* 参数 segments:路径段数组
* 返回值 { lang, rest };lang=null 表示默认语言
*/
export function parseLangPrefix(segments: string[]): { lang: string | null; rest: string[] } {
if (segments.length === 0) return { lang: null, rest: segments };
const first = segments[0]!;
if (first === "en") return { lang: null, rest: segments.slice(1) };
const langPattern = /^(?:[a-z]{2,3}|zh-CN|zh-TW|pt-BR|es-419)$/i;
if (langPattern.test(first)) return { lang: first.toLowerCase(), rest: segments.slice(1) };
return { lang: null, rest: segments };
}
/** # 函数 读取内容的真实翻译(已发布状态)
* 返回值 翻译版本或 null(缺翻译时不生成伪语言页面,由调用方回退)
*/
export async function getPublishedContentTranslation(contentId: number, lang: string) {
const [row] = await db
.select()
.from(contentTranslations)
.where(and(eq(contentTranslations.contentId, contentId), eq(contentTranslations.lang, lang), eq(contentTranslations.status, "published")));
return row ?? null;
}
/** # 函数 读取产品的真实翻译(已发布状态) */
export async function getPublishedProductTranslation(productId: number, lang: string) {
const [row] = await db
.select()
.from(productTranslations)
.where(and(eq(productTranslations.productId, productId), eq(productTranslations.lang, lang), eq(productTranslations.status, "published")));
return row ?? null;
}
/** # 函数 保存/更新内容翻译(后台用;状态 draft|review|published) */
export async function saveContentTranslation(input: {
contentId: number;
lang: string;
slug: string;
title: string;
excerpt?: string;
html?: string;
seoTitle?: string;
seoDescription?: string;
status: string;
}) {
await db
.insert(contentTranslations)
.values({
contentId: input.contentId,
lang: input.lang,
slug: input.slug,
title: input.title,
excerpt: input.excerpt || "",
html: input.html || "",
seoTitle: input.seoTitle || null,
seoDescription: input.seoDescription || null,
status: input.status,
})
.onConflictDoUpdate({
target: [contentTranslations.contentId, contentTranslations.lang],
set: {
slug: input.slug,
title: input.title,
excerpt: input.excerpt || "",
html: input.html || "",
seoTitle: input.seoTitle || null,
seoDescription: input.seoDescription || null,
status: input.status,
updatedAt: new Date(),
},
});
// 原文更新提醒:若正文与默认语言最近修订差异大,标记 review(简化:发布时强制需要 status=published 显式传入)
return { ok: true as const };
}
/** # 函数 hreflang 交替链接(SEO 模块用)
* 返回值 [{lang, url}],含 x-default 指向默认语言
*/
export async function getHreflangAlternates(path: string) {
const alternates: { lang: string; url: string }[] = [{ lang: "x-default", url: `https://dusuniot.com${path}` }, { lang: "en", url: `https://dusuniot.com${path}` }];
const enabled = await db.select({ code: languages.code }).from(languages).where(eq(languages.enabled, true));
for (const l of enabled) {
if (l.code === "en") continue;
// 真实翻译已发布 → 输出该语言 URL;仅机翻 → 输出 GTranslate 风格前缀 URL
alternates.push({ lang: l.code, url: `https://dusuniot.com/${l.code}${path === "/" ? "/" : path}` });
}
return alternates;
}
/** # 函数 多语言 Sitemap 条目 */
export async function getSitemapTranslationEntries() {
const enabled = await db.select({ code: languages.code }).from(languages).where(eq(languages.enabled, true));
const machine = await db.select({ code: languages.code }).from(languages).where(and(eq(languages.machine, true), eq(languages.enabled, false)));
return { enabled: enabled.map((l) => l.code), machine: machine.map((l) => l.code) };
}
+21
View File
@@ -0,0 +1,21 @@
/**
* # 文件 Start 入口(src/start.ts)
* 注册全局 request middleware:CSRF 保护(server fn RPC)+ 旧链接重定向。
* 该文件由 TanStack Start 插件按约定扫描(默认 entry 名 start)。
*/
import { createStart, createCsrfMiddleware } from "@tanstack/react-start";
import { redirectMiddleware } from "./server/middleware";
import { initDb } from "./db";
// 服务端启动即初始化数据库连接池(惰性 db 的预热;客户端包不含此逻辑)
await initDb();
/** # 成员变量 CSRF 中间件(仅作用于 serverFn RPC 请求) **/
const csrfMiddleware = createCsrfMiddleware({
filter: (ctx) => ctx.handlerType === "serverFn",
});
/** # 成员变量 Start 实例(全局中间件链) **/
export const startInstance = createStart(() => ({
requestMiddleware: [csrfMiddleware, redirectMiddleware],
}));
+7
View File
@@ -0,0 +1,7 @@
/**
* # 文件 客户端 bun 模块 stub
* 浏览器端模块图解析 `import("bun")` 时的占位实现(仅解析期使用,
* 客户端代码不应执行到 SQL 相关逻辑)。
*/
export const SQL = undefined;
export default {};
+280
View File
@@ -0,0 +1,280 @@
/* # 文件 前台样式入口(Tailwind CSS v4 + shadcn/ui 主题令牌)
* 配色保持原模板:品牌橙 #ed6c00 / 墨蓝 #1c2b3a / 分隔线 #e3e9f0 / 浅底 #f5f8fb。
* 刻意不引入 preflight(按需引入 theme + utilities),保留浏览器默认排版,
* 使数据库导入的 Elementor / 正文 HTML 视觉与迁移前完全一致。
* 末尾「内容兼容样式」仅服务于数据库导入的 HTML(ele-ban-* 等)与带状态
* 过渡的 Elementor 结构(轮播/ticker),React 组件一律使用 Tailwind 工具类。
*/
@layer theme, base, components, utilities;
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);
/* tw-animate-css:shadcn/ui 动画工具类(animate-in/fade-in/zoom-in 等),
* NavigationMenu 巨幕面板的 data-[motion=…] 切换动画依赖它 */
@import "tw-animate-css";
/* # 成员变量 Roboto(原站 Elementor kit 全局字体,自托管可变字重 latin 子集) **/
@font-face {
font-family: "Roboto";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("/fonts/roboto-var-latin.woff2") format("woff2");
}
/* # 成员变量 Tailwind 主题令牌(与原 website.css 配色一一对应) **/
@theme {
--font-sans: Roboto, system-ui, -apple-system, "Segoe UI", Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
--color-brand: #ed6c00;
--color-brand-dark: #c85600;
--color-ink: #1c2b3a;
--color-muted-foreground: #5f7183;
--color-line: #e3e9f0;
--color-soft: #f5f8fb;
--color-footer: #3e3e3f;
--color-footer-dark: #333334;
--color-primary: #ed6c00;
--color-primary-foreground: #ffffff;
--color-background: #ffffff;
--color-foreground: #1c2b3a;
--color-border: #e3e9f0;
--color-input: #e3e9f0;
--color-ring: #ed6c00;
--color-destructive: #b91c1c;
/* 原站 Max Mega Menu 主题色(megamenu 内联 CSS 实测值) */
--color-panel: #fcfbfa;
--color-panel-grey: #f4f5f8;
--color-panel-line: #f5b279;
--color-group-line: #fad3b3;
--color-subline: #e0e5f9;
--radius: 0.25rem;
}
/* # 成员变量 兼容变量:数据库 HTML 兼容样式与少量内联 style 仍引用旧变量名 **/
:root {
--brand: #ed6c00;
--brand-dark: #c85600;
--ink: #1c2b3a;
--muted: #5f7183;
--line: #e3e9f0;
--bg-soft: #f5f8fb;
--footer-bg: #0b1c2c;
}
@layer base {
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
/* 基准取原站实测(2026-10-31 headless):16px / #333 / 行高 24px(hello-elementor 默认) */
font-family: var(--font-sans);
font-size: 16px;
line-height: 24px;
color: #333333;
background: #fff;
}
img { max-width: 100%; }
a { color: var(--brand); text-decoration: none; }
a:hover { color: var(--brand-dark); }
}
/* # 函数 站点容器(1200px 居中 + 两侧 16px) **/
@utility site-container {
margin-inline: auto;
max-width: 1200px;
padding-inline: 16px;
}
@layer components {
/* ===== 内容排版(Elementor / 正文 HTML,数据库导入内容) ===== */
.content-html img { max-width: 100%; height: auto; }
.content-html table { border-collapse: collapse; }
.content-html td, .content-html th { border: 1px solid var(--line); padding: 6px 10px; }
.content-html h1, .content-html h2, .content-html h3 { line-height: 1.35; }
/* ===== Elementor slides 轮播(首页 hero;全部数值取自原站实测)
* 取证链(1436px 视口 headless-Chrome 计算样式 + 原站 post-18.css):
* - 轮播高 560px(≤767px 为 340px,原站 per-widget 规则);背景图 cover 居中;
* - inner 为 flex 行容器:align-items:center(垂直居中实测 active slide
* contents y=169 = (560-439)/2)、左内边距 10%(实测 143.6px);
* - 内容块 max-width 720px + fit-content(SoM 实测 720 / 图标列实测 669);
* - 字号/行高:slide 描述基准 17px/1.4(实测继承值,标题/细节另有显式字号);
* - 箭头:40px eicon 实心 V、无底色、rgba(237,237,237,.9)、距边 10px、≤768 隐藏;
* - 圆点:12×12 圆、未激活黑 20% 透明度、激活 #ed6c00(e-global-color-primary)、
* 左右边距 6px、容器距底 5px(实测 pag y639..663,slide 底 668);
* - 切换:swiper transition=slide(水平推移 500ms)+ 内容 fadeInUp
* (data-animation=fadeInUp);自动播放 5000ms、悬停暂停(pause_on_hover)。
*/
.el-slides { position: relative; overflow: hidden; background: #bbbbbb; }
.el-slide {
position: absolute; inset: 0; transition: transform .5s ease;
background-size: cover; background-position: 50% 50%; pointer-events: none;
}
.el-slide.active { pointer-events: auto; }
.el-slide-inner {
position: relative; display: flex; align-items: center; height: 100%;
padding: 0 0 0 10%; text-align: start;
}
.el-slide-contents { width: fit-content; max-width: 720px; }
.el-slide-desc { font-size: 17px; line-height: 1.4; animation: el-fade-up 1s ease both; }
@keyframes el-fade-up {
from { opacity: 0; transform: translateY(40px); }
to { opacity: 1; transform: none; }
}
.el-slide-arrow {
position: absolute; top: 50%; transform: translateY(-50%); left: 10px; z-index: 3;
width: 40px; height: 40px; border: 0; background: transparent; color: rgba(237,237,237,.9);
display: flex; align-items: center; justify-content: center; padding: 0; cursor: pointer;
}
.el-slide-arrow:hover { color: #fff; }
.el-slide-arrow.next { left: auto; right: 10px; }
.el-slide-arrow svg { width: 40px; height: 40px; fill: currentColor; }
.el-slide-dots {
position: absolute; bottom: 5px; left: 0; right: 0; display: flex; justify-content: center; z-index: 3;
}
.el-slide-dots button {
width: 12px; height: 12px; border-radius: 50%; border: 0; background: #000; opacity: .2;
margin: 0 6px; padding: 0; cursor: pointer;
}
.el-slide-dots button.on { background: #ed6c00; opacity: 1; }
/* ===== hero description 内嵌的 ele-ban-* 结构(原站 post-18.css 全量转写,数据库 HTML) ===== */
.ele-ban-tit-color-1 { color: #000000; }
.ele-ban-tit-color-2 { color: #ffffff; }
.ele-ban-tit-color-3 { color: #a5a5a5; }
.ele-ban-tit-color-4 { color: #5b5b5b; }
.ele-ban-title div { font-size: 48px !important; padding: 0; margin: 0 0 1rem; color: inherit; font-weight: bold; }
.ele-ban-details { font-size: 20px; margin-bottom: 1.5rem; }
/* note:原站含 hello-elementor reset(ul 默认 margin 全清零),本项目无 preflight,须显式 margin:0 */
.ele-ban1-ul ul { list-style: none; padding-left: 0; padding-bottom: 4rem; margin: 0; }
.ele-ban-slide-1 .ele-ban1-ul li { display: inline-block; max-width: 164px; text-align: center; }
.ele-ban-slide-1 .ele-ban1-li1 { background: url('/uploads/2023/09/ban-line-gray.png') no-repeat center right; padding: 0 10px 0 0; }
.ele-ban-slide-1 .ele-ban1-li2 { background: url('/uploads/2023/09/ban-line-gray.png') no-repeat center right; padding: 0 10px 0 0; }
.ele-ban-slide-1 .ele-ban1-li3 { background: url('/uploads/2023/09/ban-line-gray.png') no-repeat center right; padding: 0 10px 0 0; }
.ele-ban-slide-1 .ele-ban1-ul li img { margin: 0 auto 10px; }
.ele-ban-slide-1 .ele-ban1-ul li div { color: #5c5d5f; font-size: 18px; }
.ele-ban-btn a { background-color: transparent; background-image: linear-gradient(90deg, #f78400 0%, #fd5300 100%); color: #ffffff; font-size: 20px; font-weight: bold; text-decoration: none; padding: 1rem 3rem; border-radius: 4px; transition: all 0.3s ease; }
.ele-ban-btn a:hover { color: #ffffff !important; box-shadow: 0 10px 20px rgba(237,108,0,0.5); background-color: transparent; background-image: linear-gradient(90deg, #f78400 50%, #fd5300 100%); }
.ele-ban-slide-4 .ele-ban1-ul li { display: inline-block; text-align: center; }
.ele-ban-slide-4 .ele-ban1-li1, .ele-ban-slide-4 .ele-ban1-li2, .ele-ban-slide-4 .ele-ban1-li3 { color: #ffffff; border-radius: 20px; padding: 10px 16px; border: 1px solid #ffffff; margin-right: 10px; }
.ele-ban-bBox ul { list-style: none; padding-left: 0; padding-bottom: 4rem; margin: 0; }
.ele-ban-bBox li { display: inline-block; text-align: center; margin-right: 10px; }
.ele-ban-btn2 a { color: #ffffff; font-size: 20px; font-weight: bold; text-decoration: none; padding: 0.875rem 3rem; border-radius: 4px; transition: all 0.3s ease; border: 1px solid #ffffff; box-sizing: border-box; }
.ele-ban-btn2 a:hover { background: #ffffff; color: #ed6c00; }
.ele-ban-slide-2 .ele-ban-title { overflow: hidden; }
.ele-silde3-det { width: 473px; height: 44px; background: url('/uploads/2023/09/dusun-orange-line.png') no-repeat center center; color: #ffffff; font-size: 24px !important; line-height: 44px; display: inline-block; }
.ele-ban-slide-3 .ele-ban1-ul li { display: inline-block; text-align: center; padding: 0 2rem 0.5rem 0; }
.ele-ban-slide-3 .ele-ban1-ul li img { margin: 0 10px 0 0; display: inline-block; float: left; }
.ele-ban-slide-3 .ele-ban1-ul li div { color: #5c5d5f; font-size: 18px; display: inline-block; float: left; line-height: 58px; color: #000000; }
.ele-ban-slide-3 .ele-ban-btn2 a { color: #000000; border: 1px solid #000000; }
.ele-ban-slide-3 .ele-ban-btn2 a:hover { background: #000000; color: #ed6c00; }
.ele-ban-slide-1 .ele-ban-btn2 a { color: #000000; border: 1px solid #000000; padding: 0.875rem 1rem; }
.ele-ban-slide-1 .ele-ban-btn2 a:hover { background: #000000; color: #ed6c00; }
.ele-ban-bt-top3 { margin-top: 3rem; }
.ele-ban-bt-top1 { margin-top: 1rem; }
/* reason:原站 @media(max-width:767px) 覆写(343340 高、6% 左距、隐藏箭头、
* 字号收缩、logo 图 90% 宽、隐藏图标列、按钮 padding 收缩) */
@media only screen and (max-width: 768px) {
.ele-ban-title div { font-size: 2rem !important; font-weight: bold; }
.ele-ban-details { font-size: 1.125rem; clear: both; }
.ele-ban-bBox li { margin-right: 8px; margin-bottom: 10px; }
.ele-ban-slide-4 .ele-ban1-ul li { margin-right: 10px; margin-bottom: 10px; }
.ele-ban-slide-4 .ele-ban1-li1, .ele-ban-slide-4 .ele-ban1-li2, .ele-ban-slide-4 .ele-ban1-li3 { padding: 10px 6px; }
.ele-ban-slide-1 .ele-ban1-ul li { max-width: 150px; }
.ele-ban1-ul ul { padding-bottom: 2rem; }
.ele-ban-btn a { padding: 0.75rem 1.5rem; }
.ele-ban-btn2 a { padding: 0.75rem 1.25rem; }
.ele-ban-slide-2 .ele-ban1-ul img { width: 90%; margin-bottom: 3rem; }
.ele-silde3-det { width: 300px; height: 44px; font-size: 1rem !important; padding-left: 0.5rem; margin: 0.5rem 0 0 0; float: none; }
.ele-ban-slide-3 .ele-ban1-ul li { display: inline-block; text-align: center; padding: 0 1rem 0.5rem 0; }
.ele-ban-slide-3 .ele-ban1-ul li img { width: 40px; margin-right: 6px; }
.ele-ban1-ul { display: none; }
.ele-ban-bBox ul { padding-bottom: 0; }
.el-slides { height: 340px !important; }
.el-slide-inner { padding-left: 6%; }
.el-slide-arrow { display: none; }
}
/* ===== content ticker(首页 H1 公告滚动条;数值取原站 2026-10-31 headless 实测)
* 原站渲染: .eael-ticker-wrap(flex 行)> .ticker-badge(span "Latest Products:") +
* .eael-ticker > .content-ticker(fade swiper) ;外层高 45px(badge 27px 行盒 + 16px 内边距)。
* badge 透明底、文字 18px/600 #000;内容链接 16px #222 hover #ED6C00(--e-global-color-primary);
* badge 与 ticker-content 同 padding 8px 12px;ticker-content 行高 1.8(EAEL 默认);
* 交叉淡入淡出时长 200ms(data-speed)、自动轮播 5000ms(data-autoplay)、不暂停(无 pause 属性)。
* slide 层:grid 叠放(grid-area 1/1)实现容器高按最高条目自适应,替代 swiper 绝对定位。
*/
.eael-ticker-wrap {
display: flex; flex-flow: row nowrap; align-items: center; width: 100%;
position: relative; overflow: hidden;
}
.eael-ticker-wrap .ticker-badge { flex: 0 0 auto; padding: 8px 12px; }
.eael-ticker-wrap .ticker-badge span { color: #000000; font-size: 18px; font-weight: 600; line-height: 1.5; }
.eael-ticker-wrap .eael-ticker { overflow: hidden; flex: 1 1 auto; min-width: 0; }
.eael-ticker-wrap .eael-ticker .content-ticker { width: 100%; }
.eael-ticker-wrap .content-ticker .swiper-wrapper {
display: grid; grid-template-columns: 1fr;
}
.eael-ticker-wrap .content-ticker .swiper-wrapper .swiper-slide {
grid-area: 1 / 1; opacity: 0; transition: opacity .2s ease;
}
.eael-ticker-wrap .content-ticker .swiper-wrapper .swiper-slide.active { opacity: 1; }
.eael-ticker-wrap .ticker-content { padding: 8px 12px; line-height: 1.8; }
.eael-ticker-wrap .ticker-content a { color: #222222; text-decoration: none; }
.eael-ticker-wrap .ticker-content a:hover { color: #ed6c00; }
/* ===== Elementor image-box widget(复刻原站 widget-image-box.min.css,数值 2026-10-31 实测)
* 结构:.el-imagebox > .el-imagebox-wrapper(块、text-align center) >
* figure.el-imagebox-img(inline-block;≥768 且图定位 top 时 margin auto 水平居中)
* > [a > img(block、line-height 0、max-width 100%——native 56px 图按原始尺寸呈现)]
* + .el-imagebox-content > .el-imagebox-title(标签随 title_size,默认 h3)+ p 描述(margin 0);
* title 颜色继承 body(原站 .elementor-image-box-title a color:inherit,实测 #333/16px/400),
* 因此标题不落品牌橙;figure 与标题间约 7px inline-block 基线空隙由排版自然形成。
* hover 滤镜(首页 H2 等页面级)放在 [data-el] 范围规则里,不在通用层。
*/
.el-imagebox-wrapper { text-align: center; }
/* margin:0 复位 UA figure 默认(原站 hello-elementor reset 后实测 figure margin 0px,
* 水平居中靠 wrapper text-align:center 的 inline-block 排版) */
.el-imagebox-img { display: inline-block; margin: 0; }
.el-imagebox-img img { display: block; max-width: 100%; line-height: 0; }
.el-imagebox-title a { color: inherit; }
.el-imagebox-description { margin: 0; }
.el-imagebox-img img { transition: filter 0.3s ease; }
@media (min-width: 768px) {
/* 图在左/右的横向布局(原站 min-width:768px 分支) */
.el-imagebox-pos-left .el-imagebox-wrapper { display: flex; text-align: start; }
.el-imagebox-pos-left .el-imagebox-img { margin-right: 0; }
.el-imagebox-pos-right .el-imagebox-wrapper { display: flex; flex-direction: row-reverse; text-align: end; }
.el-imagebox-pos-right .el-imagebox-img { margin-left: 0; }
}
@media (max-width: 767px) {
.el-imagebox-img { margin-inline: auto !important; margin-bottom: 15px; }
}
/* ===== 首页 H2 产品线入口(post-18.css 对应块;1436px 实测 2026-10-31)
* 外层 section:白→#F4F5F8 线性渐变(180deg)+ 1360 容器(SectionNode 背景已渲染);
* 中列 0.5rem 内边距;内层 e38a898 六列均分 224px,列内容 wrap padding 10px(高 107);
* 前五列右侧 v-line.png(2×50) 细竖线分隔(background-position center right,末列无线);
* 图占 figure 30% 宽、hover 亮度降至 90%(0.3s 过渡);≤767 列改 50% 宽两列三行。
*/
[data-el="0cfea23"], [data-el="91befe3"], [data-el="5ecac70"],
[data-el="bf3c723"], [data-el="13275e9"] {
background-image: url("/uploads/2023/09/v-line.png");
background-repeat: no-repeat;
background-position: center right;
}
[data-el="e38a898"] > div > [data-el] { padding: 10px; }
[data-el="e38a898"] .el-imagebox-img { width: 30%; }
[data-el="e38a898"] .el-imagebox:hover .el-imagebox-img img {
filter: brightness(90%) contrast(100%) saturate(100%) blur(0px) hue-rotate(0deg);
}
@media (max-width: 767px) {
[data-el="91befe3"], [data-el="bf3c723"] { background-position: -2px 0; }
/* 列宽是行内样式,需 !important 压写在声明层之后 */
[data-el="e38a898"] > div > [data-el] { flex: 0 0 50% !important; max-width: 50% !important; }
/* post-18.css 对六个 widget 各有 margin-bottom:0,特异性高于基础 CSS 的 15px(实测原站 0px) */
[data-el="e38a898"] .el-imagebox-img { margin-bottom: 0 !important; }
}
}
@media (max-width: 900px) {
.ele-ban-title div { font-size: 30px; }
}

Some files were not shown because too many files have changed in this diff Show More