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
+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>
);
}