v0.10.5 首页 H2 产品线入口 ×6:ImageBoxWidget 重构为 Elementor 忠实 DOM + slim 白名单补齐 + H2 scoped CSS(post-18 对齐,桌面/移动/hover 实测一致)
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* # 路由 兜底 /$.tsx(catch-all)
|
||||
* 处理 WordPress 风格的页面/文章路径:
|
||||
* 1. 语言前缀(如 /zh-CN/xxx/):剥离语言段,渲染主语言内容占位(title 加语言标注)。
|
||||
* 2. 单段路径:按 page 取内容。
|
||||
* 3. 两段路径:先按 post(/%category%/%postname%/)取,再按 page。
|
||||
* 4. 都没有 → 404 NotFound(导航 + 热门产品链接)。
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { createFileRoute, Link, notFound, redirect } from "@tanstack/react-router";
|
||||
import { getLayoutData, Layout, type LayoutData } from "~/components/website/Layout";
|
||||
import { Pagination } from "~/components/website/Pagination";
|
||||
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
|
||||
import { InquiryForm } from "~/components/website/InquiryForm";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from "~/components/ui/breadcrumb";
|
||||
import { ProductCard } from "~/components/website/ProductCard";
|
||||
import { isLangCode, LANG_NAMES, pickContent, type CatchAllData } from "~/components/website/catchall-shared";
|
||||
import {
|
||||
getPublishedContent,
|
||||
getPostCategoryBySlug,
|
||||
getProductChildCategory,
|
||||
hasProductSlug,
|
||||
listPosts,
|
||||
listProducts,
|
||||
resolveRedirect,
|
||||
type ProductListResult,
|
||||
} from "~/server/data";
|
||||
import { useT } from "~/i18n";
|
||||
|
||||
export const Route = createFileRoute("/$")({
|
||||
// 归档分页用 ?page=N
|
||||
validateSearch: (search: Record<string, unknown>): { page?: number } => {
|
||||
const page = Number(search.page);
|
||||
return { page: Number.isFinite(page) && page > 0 ? Math.floor(page) : undefined };
|
||||
},
|
||||
loaderDeps: ({ search }) => ({ page: search.page }),
|
||||
loader: async ({ location, deps }) => {
|
||||
// 解析路径段:去首尾斜杠、过滤空段
|
||||
const segments = location.pathname.split("/").filter(Boolean).map(decodeURIComponent);
|
||||
let lang = "";
|
||||
if (segments.length >= 1 && isLangCode(segments[0])) {
|
||||
lang = segments[0];
|
||||
segments.shift();
|
||||
}
|
||||
const layout = await getLayoutData();
|
||||
const hotProducts: ProductListResult = await listProducts({ sort: "views", pageSize: 4 }).catch(
|
||||
() => ({ items: [], total: 0, page: 1, pageSize: 4 }) as ProductListResult,
|
||||
);
|
||||
|
||||
// 语言占位阶段:内容库暂无翻译,取主语言内容渲染(title 加语言标注)
|
||||
let content: CatchAllData["content"] = null;
|
||||
if (segments.length === 1) {
|
||||
// WP 站点文章(post)与页面(page)都可能是单段路径(见原站 sitemap),两类都试
|
||||
const page = await getPublishedContent(segments[0], "page").catch(() => null);
|
||||
if (page) content = pickContent(page);
|
||||
else {
|
||||
const post = await getPublishedContent(segments[0], "post").catch(() => null);
|
||||
if (post) content = pickContent(post);
|
||||
}
|
||||
} else if (segments.length >= 2) {
|
||||
// WP 路径结构 /%category%/.../%postname%/:末段即内容 slug(如 /blog/gateway/<slug>/)
|
||||
const slug = segments[segments.length - 1];
|
||||
// 先匹配文章再匹配页面
|
||||
const post = await getPublishedContent(slug, "post").catch(() => null);
|
||||
if (post) content = pickContent(post);
|
||||
else {
|
||||
const page = await getPublishedContent(slug, "page").catch(() => null);
|
||||
if (page) content = pickContent(page);
|
||||
}
|
||||
} else if (segments.length === 0) {
|
||||
// 语言前缀首页
|
||||
const home = await getPublishedContent("home", "page").catch(() => null);
|
||||
if (home) content = pickContent(home);
|
||||
}
|
||||
|
||||
// 未命中内容时:先按 WP 结构化旧路径改判,再查 redirects 表,最后分类归档
|
||||
if (!content && segments.length >= 1) {
|
||||
const last = segments[segments.length - 1]!;
|
||||
// 1) /product/product/<slug>/:导入落地页里的重复前缀 → /product/<slug>/(仅当产品存在)
|
||||
if (segments.length >= 3 && segments[0] === "product" && segments[1] === "product" && (await hasProductSlug(last).catch(() => false))) {
|
||||
const to = `/product/${last}/`;
|
||||
throw redirect({ to, href: to, statusCode: 301 as const });
|
||||
}
|
||||
// 2) /product-specification/...:原站产品规格页。本地等价物是产品页;单段落产品列表
|
||||
if (segments[0] === "product-specification") {
|
||||
if (segments.length === 1) {
|
||||
throw redirect({ to: "/collection/", href: "/collection/", statusCode: 301 as const });
|
||||
}
|
||||
// 已导入的旧链规则优先(可能链向其它规格页 slug),无规则再按 slug 找产品,最后落产品列表
|
||||
const rule = await resolveRedirect("/" + segments.join("/"))
|
||||
.then((r) => r?.target)
|
||||
.catch(() => null);
|
||||
if (rule) throw redirect({ to: rule, href: rule, statusCode: 301 as const });
|
||||
const to = (await hasProductSlug(last).catch(() => false)) ? `/product/${last}/` : "/collection/";
|
||||
throw redirect({ to, href: to, statusCode: 301 as const });
|
||||
}
|
||||
// 3) /collection/<parent>/<child>/:嵌套产品分类路径 → 子分类页(无该子分类则父分类兜底)
|
||||
if (segments[0] === "collection" && segments.length === 3) {
|
||||
const child = await getProductChildCategory(segments[1]!, segments[2]!).catch(() => null);
|
||||
const to = `/collection/${child ? child.slug : segments[1]!}/`;
|
||||
throw redirect({ to, href: to, statusCode: 301 as const });
|
||||
}
|
||||
// 4) 数据库 redirects 表规则(链式解析并跳过自指规则防死循环)
|
||||
const target = await resolveRedirect("/" + segments.join("/"))
|
||||
.then((r) => r?.target)
|
||||
.catch(() => null);
|
||||
if (target) throw redirect({ to: target, href: target, statusCode: 301 as const });
|
||||
// 5) WP /%category%/ 结构:单段/双段路径末段命中文章分类 → 分类归档页
|
||||
if (segments.length <= 2) {
|
||||
const cat = await getPostCategoryBySlug(last).catch(() => null);
|
||||
if (cat) {
|
||||
const posts = await listPosts(deps.page || 1, 12, cat.slug).catch(() => ({ items: [], total: 0, page: deps.page || 1, pageSize: 12 }));
|
||||
return { layout, content: null, lang, hotProducts, archive: { name: cat.name, slug: cat.slug, posts } } satisfies CatchAllData;
|
||||
}
|
||||
}
|
||||
// 未命中任何内容 → 404(SSR 返回 404 状态码,由 notFoundComponent 渲染)
|
||||
throw notFound();
|
||||
}
|
||||
// 语言前缀首页等内容为空时的兜底
|
||||
if (!content) throw notFound();
|
||||
|
||||
return { layout, content, lang, hotProducts, archive: null } satisfies CatchAllData;
|
||||
},
|
||||
head: ({ loaderData }) => {
|
||||
const langLabel = loaderData?.lang ? ` (${LANG_NAMES[loaderData.lang] || loaderData.lang})` : "";
|
||||
if (loaderData?.archive) {
|
||||
return {
|
||||
meta: [
|
||||
{ title: `${loaderData.archive.name} | Dusun IoT` },
|
||||
{ name: "description", content: `Browse ${loaderData.archive.name} articles from Dusun IoT.` },
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
meta: [
|
||||
{ title: loaderData?.content ? (loaderData.content.seoTitle || loaderData.content.title) + langLabel : "Page Not Found | Dusun IoT" },
|
||||
{ name: "description", content: loaderData?.content?.seoDescription || loaderData?.content?.excerpt || "Dusun IoT - IoT gateway manufacturer and solution provider." },
|
||||
],
|
||||
};
|
||||
},
|
||||
component: CatchAllPage,
|
||||
notFoundComponent: NotFoundShell,
|
||||
});
|
||||
|
||||
/** # 组件 分类归档页(WP /%category%/ 结构):面包屑 + 文章卡片分页列表,样式与 /blog/ 一致 */
|
||||
function ArchivePage() {
|
||||
const t = useT();
|
||||
const data = Route.useLoaderData();
|
||||
const a = data.archive!;
|
||||
const page = a.posts.page;
|
||||
return (
|
||||
<Layout layout={data.layout}>
|
||||
<div className="bg-[#f5f5f6] px-4 pt-[34px] pb-[30px] text-center">
|
||||
<h1 className="m-0 text-[34px] tracking-wide">{a.name.toUpperCase()}</h1>
|
||||
</div>
|
||||
<div className="site-container">
|
||||
<Breadcrumb aria-label="Breadcrumb">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem><BreadcrumbLink to="/">Home</BreadcrumbLink></BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem><BreadcrumbPage>{a.name}</BreadcrumbPage></BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="grid grid-cols-1 gap-5 py-6 min-[960px]:grid-cols-3">
|
||||
{a.posts.items.map((post) => (
|
||||
<Link key={post.id} to="/blog/$slug/" params={{ slug: post.slug }} className="block overflow-hidden rounded-lg border border-line bg-white text-brand transition-[box-shadow,transform] duration-200 hover:-translate-y-0.5 hover:shadow-[0_8px_24px_rgba(16,42,76,0.1)]">
|
||||
{/* 与 /blog/ 卡片同构:无特色图时灰色渐变占位 + 右上角分类徽章 */}
|
||||
<div className="relative aspect-[4/3] overflow-hidden">
|
||||
{post.imageUrl ? (
|
||||
<img src={post.imageUrl} alt={post.title} loading="lazy" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="h-full w-full bg-gradient-to-b from-[#9aa3ad] to-[#c9ced4]" />
|
||||
)}
|
||||
<span className="absolute top-3 right-3 rounded-full bg-brand px-3 py-1 text-xs font-semibold text-white">
|
||||
{post.categoryName || a.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 pt-3.5 pb-[18px]">
|
||||
<div className="mb-1.5 text-[15px] leading-[1.45] font-semibold">{post.title}</div>
|
||||
{post.excerpt ? <p className="my-1.5 text-sm text-muted-foreground">{post.excerpt.slice(0, 140)}…</p> : null}
|
||||
<div className="text-[13px] text-muted-foreground">
|
||||
{post.authorName ? `${post.authorName} · ` : ""}
|
||||
{post.publishedAt ? new Date(post.publishedAt).toISOString().slice(0, 10) : ""}
|
||||
</div>
|
||||
<div className="mt-2.5 text-[13px] font-bold">{t("blog_read_more")}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{a.posts.items.length === 0 ? <p className="py-[60px] text-center text-muted-foreground">{t("blog_no_posts")}</p> : null}
|
||||
</div>
|
||||
<Pagination page={page} total={a.posts.total} pageSize={a.posts.pageSize} makeHref={(p) => `?page=${p}`} />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 组件 404 外壳:先渲染静态 404,再客户端补齐布局与热门产品(loader 已抛 notFound,无 loaderData) */
|
||||
function NotFoundShell() {
|
||||
const t = useT();
|
||||
const [state, setState] = useState<{ layout: LayoutData | null; hot: ProductListResult | null }>({ layout: null, hot: null });
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
Promise.all([getLayoutData(), listProducts({ sort: "views", pageSize: 4 }).catch(() => null)])
|
||||
.then(([layout, hot]) => {
|
||||
if (alive) setState({ layout, hot });
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
if (state.layout && state.hot) {
|
||||
return (
|
||||
<Layout layout={state.layout}>
|
||||
<NotFoundView hotProducts={state.hot} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="site-container pt-[70px] pb-10 text-center">
|
||||
<h1 className="m-0 text-[64px] text-brand">404</h1>
|
||||
<p className="text-lg">{t("nf_msg")}</p>
|
||||
<div className="mx-0 my-[18px] mb-9 flex flex-wrap justify-center gap-2.5">
|
||||
<Button asChild><a href="/">Home</a></Button>
|
||||
<Button asChild variant="outline"><a href="/collection/">{t("nav_all_products")}</a></Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 组件 兜底页面(内容模板) */
|
||||
function CatchAllPage() {
|
||||
const t = useT();
|
||||
const data = Route.useLoaderData();
|
||||
// 分类归档命中:走归档模板
|
||||
if (data.archive) return <ArchivePage />;
|
||||
const c = data.content;
|
||||
const elementor = extractElementor(c.meta);
|
||||
const langLabel = data.lang ? ` (${LANG_NAMES[data.lang] || data.lang})` : "";
|
||||
// Elementor 页面(原站 full-canvas 模板):通栏直出,不加面包屑/h1
|
||||
if (elementor) {
|
||||
return (
|
||||
<Layout layout={data.layout}>
|
||||
{data.lang ? (
|
||||
<div className="site-container">
|
||||
<div className="mt-4 rounded-md border border-[#b5e3c4] bg-[#e7f6ec] px-4 py-3 text-[#15803d]">
|
||||
You are viewing the {langLabel} placeholder version. Translated content is coming soon — the English content is shown below.
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<ElementorRenderer doc={elementor} />
|
||||
<section className="mx-auto mt-10 mb-14 max-w-[860px] px-4">
|
||||
<InquiryForm formKey="contact-us-form" productInterest={c.type === "post" ? "" : c.title} title="Get in Touch" />
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Layout layout={data.layout}>
|
||||
<div className="site-container">
|
||||
<Breadcrumb aria-label="Breadcrumb">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem><BreadcrumbLink to="/">Home</BreadcrumbLink></BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem><BreadcrumbPage>{c.title}</BreadcrumbPage></BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
{data.lang ? (
|
||||
<div className="mb-4 rounded-md border border-[#b5e3c4] bg-[#e7f6ec] px-4 py-3 text-[#15803d]">
|
||||
You are viewing the {langLabel} placeholder version. Translated content is coming soon — the English content is shown below.
|
||||
</div>
|
||||
) : null}
|
||||
<article className="mx-auto max-w-[1080px]">
|
||||
<h1 className="text-[28px]">
|
||||
{c.title}
|
||||
{langLabel}
|
||||
</h1>
|
||||
{elementor ? (
|
||||
<ElementorRenderer doc={elementor} />
|
||||
) : c.html ? (
|
||||
<div className="content-html" dangerouslySetInnerHTML={{ __html: c.html }} />
|
||||
) : (
|
||||
<p className="text-muted-foreground">{c.excerpt || "This page is being imported. Content will appear here soon."}</p>
|
||||
)}
|
||||
</article>
|
||||
<section className="my-8 mb-14">
|
||||
<InquiryForm formKey="contact-us-form" productInterest={c.type === "post" ? "" : c.title} title="Get in Touch" />
|
||||
</section>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 组件 404 NotFound:导航 + 热门产品链接 */
|
||||
function NotFoundView(props: { hotProducts: ProductListResult }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="site-container pt-[70px] pb-10 text-center">
|
||||
<h1 className="m-0 text-[64px] text-brand">404</h1>
|
||||
<p className="text-lg">{t("nf_msg")}</p>
|
||||
<div className="mx-0 my-[18px] mb-9 flex flex-wrap justify-center gap-2.5">
|
||||
<Button asChild><a href="/">Home</a></Button>
|
||||
<Button asChild variant="outline"><a href="/collection/">{t("nav_all_products")}</a></Button>
|
||||
<Button asChild variant="outline"><a href="/blog/">{t("nav_blog")}</a></Button>
|
||||
<Button asChild variant="outline"><a href="/contact-us/">{t("nf_contact")}</a></Button>
|
||||
</div>
|
||||
{props.hotProducts.items.length > 0 ? (
|
||||
<>
|
||||
<h2 className="text-[22px]">Popular Products</h2>
|
||||
<div className="mt-5 grid grid-cols-2 gap-[18px] text-left min-[960px]:grid-cols-4">
|
||||
{props.hotProducts.items.map((p) => (
|
||||
<ProductCard key={p.id} p={p} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createRootRoute, HeadContent, Outlet, Scripts } from "@tanstack/react-router";
|
||||
import "~/i18n/locales";
|
||||
import { getLocale, LocaleProvider } from "~/i18n";
|
||||
|
||||
/** # 函数 根路由 loader:从 cookie 读当前 UI 语言(SSR 按语言渲染,无水合不一致) */
|
||||
async function loader() {
|
||||
return { locale: await getLocale() };
|
||||
}
|
||||
|
||||
/** # 函数 根路由文档组件
|
||||
* 应用全局 HTML 骨架,前台与后台共用;各路由通过 head() 注入 meta/link。
|
||||
* LocaleProvider 将语言注入全站(后台不使用 t(),不受影响)。
|
||||
*/
|
||||
function RootDocument() {
|
||||
const { locale } = Route.useLoaderData();
|
||||
return (
|
||||
<LocaleProvider locale={locale}>
|
||||
<html lang={locale}>
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
<Outlet />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
</LocaleProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 成员变量 根路由实例 **/
|
||||
export const Route = createRootRoute({ component: RootDocument, loader });
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* # 路由 /admin/contents/$id 内容编辑页
|
||||
* id 为 new 时新建;否则加载正本(默认语言)编辑。HTML 使用 TextArea 明文编辑。
|
||||
*/
|
||||
import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { App, Button, Card, Form, Input, Select, Space } from "antd";
|
||||
import { ArrowLeftOutlined, SaveOutlined } from "@ant-design/icons";
|
||||
import { adminGetContent, adminSaveContent } from "~/server/admin";
|
||||
import { CONTENT_STATUS_OPTIONS, Forbidden, guardAdmin } from "~/components/admin/common";
|
||||
|
||||
/** # 类型 编辑表单数据形状(新建时给默认值) **/
|
||||
type ContentForm = {
|
||||
id: number;
|
||||
type: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
html: string;
|
||||
status: string;
|
||||
template: string;
|
||||
seoTitle: string;
|
||||
seoDescription: string;
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/admin/contents/$id")({
|
||||
loader: ({ params }) =>
|
||||
guardAdmin(async () => {
|
||||
if (params.id === "new") {
|
||||
// 权限探测 + 返回新建默认值
|
||||
await adminGetContent({ data: { id: 0 } });
|
||||
const fresh: ContentForm = {
|
||||
id: 0,
|
||||
type: "page",
|
||||
slug: "",
|
||||
title: "",
|
||||
excerpt: "",
|
||||
html: "",
|
||||
status: "draft",
|
||||
template: "default",
|
||||
seoTitle: "",
|
||||
seoDescription: "",
|
||||
};
|
||||
return fresh;
|
||||
}
|
||||
const c = await adminGetContent({ data: { id: Number(params.id) } });
|
||||
if (!c) return null;
|
||||
const form: ContentForm = {
|
||||
id: c.id,
|
||||
type: c.type,
|
||||
slug: c.slug,
|
||||
title: c.title,
|
||||
excerpt: c.excerpt,
|
||||
html: c.html,
|
||||
status: c.status,
|
||||
template: c.template,
|
||||
seoTitle: c.seoTitle ?? "",
|
||||
seoDescription: c.seoDescription ?? "",
|
||||
};
|
||||
return form;
|
||||
}),
|
||||
component: ContentEditPage,
|
||||
});
|
||||
|
||||
/** # 函数 内容编辑页面组件 */
|
||||
function ContentEditPage() {
|
||||
const data = Route.useLoaderData();
|
||||
const navigate = useNavigate();
|
||||
const router = useRouter();
|
||||
const { message } = App.useApp();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
if (!data) return <Forbidden />;
|
||||
// 收窄后的非空引用(闭包内使用)
|
||||
const content = data;
|
||||
|
||||
/** # 函数 保存内容:提交到服务端 → 提示 → 返回列表 */
|
||||
async function handleSave(values: Omit<ContentForm, "id">) {
|
||||
try {
|
||||
await adminSaveContent({ data: { ...values, id: content.id || undefined } });
|
||||
message.success("保存成功");
|
||||
await router.invalidate();
|
||||
void navigate({ to: "/admin/contents/" });
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : "保存失败");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button icon={<ArrowLeftOutlined aria-hidden />} onClick={() => void navigate({ to: "/admin/contents/" })}>
|
||||
返回列表
|
||||
</Button>
|
||||
<h2 style={{ margin: 0, fontSize: 18 }}>{content.id ? `编辑内容 #${content.id}` : "新建内容"}</h2>
|
||||
</Space>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={content}
|
||||
style={{ maxWidth: 860 }}
|
||||
onFinish={(v) => void handleSave(v)}
|
||||
>
|
||||
<Space size={16} style={{ display: "flex" }} align="start">
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]} style={{ minWidth: 420, flex: 1 }}>
|
||||
<Input placeholder="内容标题" />
|
||||
</Form.Item>
|
||||
<Form.Item name="slug" label="别名(留空自动生成)">
|
||||
<Input placeholder="url-slug" style={{ width: 260 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space size={16} style={{ display: "flex" }}>
|
||||
<Form.Item name="type" label="类型">
|
||||
<Select
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: "page", label: "页面 page" },
|
||||
{ value: "post", label: "文章 post" },
|
||||
{ value: "landing", label: "落地页 landing" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select style={{ width: 140 }} options={CONTENT_STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="template" label="模板">
|
||||
<Input style={{ width: 200 }} placeholder="default / home / product …" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="excerpt" label="摘要">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="html" label="正文 HTML">
|
||||
<Input.TextArea rows={18} style={{ fontFamily: "monospace", fontSize: 12 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="seoTitle" label="SEO 标题">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="seoDescription" label="SEO 描述">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SaveOutlined aria-hidden />}>
|
||||
保存
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* # 路由 /admin/contents 内容列表
|
||||
* 类型 Tab(page/post/landing)+ 状态过滤 + 关键字搜索 + 分页表格,支持删除与跳转编辑。
|
||||
*/
|
||||
import { createFileRoute, Link, useRouter } from "@tanstack/react-router";
|
||||
import { App, Button, Card, Input, Popconfirm, Space, Table, Tabs, Tag } from "antd";
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { useState } from "react";
|
||||
import { adminDeleteContent, adminListContents } from "~/server/admin";
|
||||
import {
|
||||
AdminEmpty,
|
||||
CONTENT_STATUS_COLOR,
|
||||
CONTENT_TYPE_LABEL,
|
||||
Forbidden,
|
||||
PageHeader,
|
||||
StatusTag,
|
||||
fmtDateTime,
|
||||
guardAdmin,
|
||||
tablePagination,
|
||||
type AdminSearch,
|
||||
} from "~/components/admin/common";
|
||||
|
||||
export const Route = createFileRoute("/admin/contents/")({
|
||||
validateSearch: (search: Record<string, unknown>): AdminSearch => {
|
||||
// 只保留显式传入的参数,缺省时不回写 URL(避免 307 归一化重定向)
|
||||
const out: AdminSearch = {};
|
||||
const page = Math.floor(Number(search.page)) || 0;
|
||||
if (page > 1) out.page = page;
|
||||
if (typeof search.q === "string" && search.q) out.q = search.q;
|
||||
if (typeof search.status === "string" && search.status) out.status = search.status;
|
||||
if (typeof search.type === "string" && search.type) out.type = search.type;
|
||||
return out;
|
||||
},
|
||||
loaderDeps: ({ search }) => ({ search }),
|
||||
loader: ({ deps }) =>
|
||||
guardAdmin(() =>
|
||||
adminListContents({
|
||||
data: {
|
||||
page: deps.search.page,
|
||||
type: deps.search.type || undefined,
|
||||
status: deps.search.status || undefined,
|
||||
q: deps.search.q || undefined,
|
||||
},
|
||||
}),
|
||||
),
|
||||
component: ContentsPage,
|
||||
});
|
||||
|
||||
/** # 函数 内容列表页面组件 */
|
||||
function ContentsPage() {
|
||||
const data = Route.useLoaderData();
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
const router = useRouter();
|
||||
const { message } = App.useApp();
|
||||
const [q, setQ] = useState(search.q ?? "");
|
||||
|
||||
if (!data) return <Forbidden />;
|
||||
|
||||
/** # 函数 更新查询参数(重置到第 1 页) */
|
||||
function updateSearch(patch: AdminSearch) {
|
||||
void navigate({ search: (prev) => ({ ...prev, ...patch, page: 1 }) });
|
||||
}
|
||||
|
||||
/** # 函数 删除内容并刷新 */
|
||||
async function handleDelete(id: number) {
|
||||
await adminDeleteContent({ data: { id } });
|
||||
message.success("已删除");
|
||||
await router.invalidate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<PageHeader
|
||||
title="内容管理"
|
||||
extra={
|
||||
<>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="搜索标题 / 别名"
|
||||
prefix={<SearchOutlined aria-hidden />}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onPressEnter={() => updateSearch({ q })}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
<Link to="/admin/contents/$id/" params={{ id: "new" }}>
|
||||
<Button type="primary" icon={<PlusOutlined aria-hidden />}>
|
||||
新建内容
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Tabs
|
||||
activeKey={search.type ?? ""}
|
||||
onChange={(key) => updateSearch({ type: key })}
|
||||
items={[
|
||||
{ key: "", label: "全部" },
|
||||
{ key: "page", label: "页面" },
|
||||
{ key: "post", label: "文章" },
|
||||
{ key: "landing", label: "落地页" },
|
||||
]}
|
||||
/>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={data.items}
|
||||
locale={{ emptyText: <AdminEmpty /> }}
|
||||
pagination={tablePagination(data.total, data.page, data.pageSize, (p) => void navigate({ search: (prev) => ({ ...prev, page: p }) }))}
|
||||
columns={[
|
||||
{ title: "ID", dataIndex: "id", width: 70 },
|
||||
{
|
||||
title: "标题",
|
||||
dataIndex: "title",
|
||||
render: (v: string, r) => (
|
||||
<Link to="/admin/contents/$id/" params={{ id: String(r.id) }}>
|
||||
{v}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{ title: "别名", dataIndex: "slug", width: 220, ellipsis: true },
|
||||
{
|
||||
title: "类型",
|
||||
dataIndex: "type",
|
||||
width: 90,
|
||||
render: (v: string) => <Tag>{CONTENT_TYPE_LABEL[v] ?? v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
width: 100,
|
||||
render: (v: string) => <StatusTag value={v} colorMap={CONTENT_STATUS_COLOR} />,
|
||||
},
|
||||
{ title: "浏览", dataIndex: "views", width: 80 },
|
||||
{ title: "更新时间", dataIndex: "updatedAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 110,
|
||||
render: (_, r) => (
|
||||
<Space size={4}>
|
||||
<Link to="/admin/contents/$id/" params={{ id: String(r.id) }}>
|
||||
<Button size="small" type="text" icon={<EditOutlined aria-label="编辑" />} />
|
||||
</Link>
|
||||
<Popconfirm title="确认删除该内容?" onConfirm={() => void handleDelete(r.id)}>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined aria-label="删除" />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* # 路由 /admin/filters 筛选配置
|
||||
* 前台产品筛选器配置:列表 + 新增/编辑弹窗(name/slug/type/attribute/position/enabled)+ 删除。
|
||||
*/
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||
import { App, Button, Card, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Table, Tag } from "antd";
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import { useState } from "react";
|
||||
import { adminDeleteFilter, adminListFilters, adminSaveFilter } from "~/server/admin";
|
||||
import { AdminEmpty, Forbidden, PageHeader, guardAdmin } from "~/components/admin/common";
|
||||
import type { filters } from "~/db/schema";
|
||||
|
||||
export const Route = createFileRoute("/admin/filters")({
|
||||
loader: () => guardAdmin(() => adminListFilters()),
|
||||
component: FiltersPage,
|
||||
});
|
||||
|
||||
/** # 成员变量 筛选类型中文标签 **/
|
||||
const FILTER_TYPE_LABEL: Record<string, string> = { attribute: "属性", category: "分类", price: "价格" };
|
||||
|
||||
/** # 函数 筛选配置页面组件 */
|
||||
function FiltersPage() {
|
||||
const data = Route.useLoaderData();
|
||||
const router = useRouter();
|
||||
const { message } = App.useApp();
|
||||
const [editing, setEditing] = useState<FilterRow | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
/** # 成员变量 筛选行类型(来自 filters 表结构推断) **/
|
||||
type FilterRow = typeof filters.$inferSelect;
|
||||
|
||||
if (!data) return <Forbidden />;
|
||||
|
||||
/** # 成员变量 attributeId → 属性名映射(表格展示用) **/
|
||||
const attrName = new Map(data.attributes.map((a) => [a.id, a.name]));
|
||||
|
||||
/** # 函数 打开新增/编辑弹窗 */
|
||||
function openEdit(row: FilterRow | null) {
|
||||
setEditing(row);
|
||||
form.setFieldsValue(row ?? { name: "", slug: "", type: "attribute", attributeId: null, position: 0, enabled: true });
|
||||
}
|
||||
|
||||
/** # 函数 保存筛选配置 */
|
||||
async function handleSave(values: { name: string; slug: string; type: string; attributeId: number | null; position: number; enabled: boolean }) {
|
||||
await adminSaveFilter({ data: { ...values, id: editing?.id } });
|
||||
message.success("已保存");
|
||||
setEditing(null);
|
||||
await router.invalidate();
|
||||
}
|
||||
|
||||
/** # 函数 删除筛选配置 */
|
||||
async function handleDelete(id: number) {
|
||||
await adminDeleteFilter({ data: { id } });
|
||||
message.success("已删除");
|
||||
await router.invalidate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<PageHeader
|
||||
title="筛选配置"
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined aria-hidden />} onClick={() => openEdit(null)}>
|
||||
新增筛选
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={data.items}
|
||||
locale={{ emptyText: <AdminEmpty /> }}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: "ID", dataIndex: "id", width: 70 },
|
||||
{ title: "名称", dataIndex: "name", width: 180 },
|
||||
{ title: "URL 参数", dataIndex: "slug", width: 180 },
|
||||
{
|
||||
title: "类型",
|
||||
dataIndex: "type",
|
||||
width: 100,
|
||||
render: (v: string) => <Tag>{FILTER_TYPE_LABEL[v] ?? v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "关联属性",
|
||||
dataIndex: "attributeId",
|
||||
width: 160,
|
||||
render: (v: number | null) => (v ? (attrName.get(v) ?? `#${v}`) : "-"),
|
||||
},
|
||||
{ title: "位置", dataIndex: "position", width: 70 },
|
||||
{
|
||||
title: "启用",
|
||||
dataIndex: "enabled",
|
||||
width: 80,
|
||||
render: (_, r) => <Tag color={r.enabled ? "green" : "default"}>{r.enabled ? "启用" : "停用"}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 110,
|
||||
render: (_, r) => (
|
||||
<Space size={4}>
|
||||
<Button size="small" type="text" icon={<EditOutlined aria-label="编辑" />} onClick={() => openEdit(r)} />
|
||||
<Popconfirm title="确认删除该筛选?" onConfirm={() => void handleDelete(r.id)}>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined aria-label="删除" />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? `编辑筛选 #${editing.id}` : "新增筛选"}
|
||||
open={editing !== null}
|
||||
onCancel={() => setEditing(null)}
|
||||
onOk={() => form.submit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={(v) => void handleSave(v)}>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: "请输入名称" }]}>
|
||||
<Input placeholder="如 Chip Platform" />
|
||||
</Form.Item>
|
||||
<Form.Item name="slug" label="URL 参数名" rules={[{ required: true, message: "请输入 URL 参数名" }]}>
|
||||
<Input placeholder="chip-platform" />
|
||||
</Form.Item>
|
||||
<Space size={16} style={{ display: "flex" }}>
|
||||
<Form.Item name="type" label="类型">
|
||||
<Select
|
||||
style={{ width: 130 }}
|
||||
options={[
|
||||
{ value: "attribute", label: "属性" },
|
||||
{ value: "category", label: "分类" },
|
||||
{ value: "price", label: "价格" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="attributeId" label="关联属性">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 200 }}
|
||||
placeholder="仅属性类型需要"
|
||||
options={data.attributes.map((a) => ({ value: a.id, label: `${a.name} (${a.slug})` }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="position" label="位置">
|
||||
<InputNumber min={0} style={{ width: 100 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* # 路由 /admin/ 后台首页(Dashboard)
|
||||
* 统计卡片 + 最近 7 天询盘趋势(纯 div 柱图)+ 最新 10 条询盘。
|
||||
*/
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Card, Col, Row, Statistic, Table, Tag } from "antd";
|
||||
import { FileText, Inbox, Package, ShoppingCart, TimerReset, Image as ImageIcon } from "lucide-react";
|
||||
import { adminStats } from "~/server/admin";
|
||||
import { AdminEmpty, Forbidden, INQUIRY_STATUS_COLOR, INQUIRY_STATUS_LABEL, guardAdmin, fmtDateTime } from "~/components/admin/common";
|
||||
|
||||
export const Route = createFileRoute("/admin/")({
|
||||
loader: () => guardAdmin(() => adminStats()),
|
||||
component: DashboardPage,
|
||||
});
|
||||
|
||||
/** # 函数 Dashboard 页面组件
|
||||
* 渲染统计卡片、询盘趋势柱图与最新询盘表;未登录时展示占位。
|
||||
*/
|
||||
function DashboardPage() {
|
||||
const stats = Route.useLoaderData();
|
||||
if (!stats) return <Forbidden />;
|
||||
const { counts, trend, recentInquiries } = stats;
|
||||
const max = Math.max(1, ...trend.map((t) => t.count));
|
||||
|
||||
/** # 成员变量 统计卡片配置(图标 + 标题 + 数值) **/
|
||||
const cards = [
|
||||
{ title: "内容", value: counts.contents, icon: <FileText size={20} aria-hidden color="#1677ff" /> },
|
||||
{ title: "产品", value: counts.products, icon: <Package size={20} aria-hidden color="#52c41a" /> },
|
||||
{ title: "订单", value: counts.orders, icon: <ShoppingCart size={20} aria-hidden color="#faad14" /> },
|
||||
{ title: "询盘", value: counts.inquiries, icon: <Inbox size={20} aria-hidden color="#722ed1" /> },
|
||||
{ title: "媒体", value: counts.media, icon: <ImageIcon size={20} aria-hidden color="#13c2c2" /> },
|
||||
{ title: "待处理订单", value: counts.pendingOrders, icon: <TimerReset size={20} aria-hidden color="#fa541c" /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={[16, 16]}>
|
||||
{cards.map((c) => (
|
||||
<Col key={c.title} xs={12} sm={8} lg={4}>
|
||||
<Card size="small">
|
||||
<Statistic title={c.title} value={c.value} prefix={c.icon} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Card title="最近 7 天询盘趋势" size="small" style={{ marginTop: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 16, height: 160, padding: "0 8px" }}>
|
||||
{trend.map((t) => (
|
||||
<div key={t.day} style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 4, height: "100%", justifyContent: "flex-end" }}>
|
||||
<span style={{ fontSize: 12, color: "#666" }}>{t.count}</span>
|
||||
<div
|
||||
role="img"
|
||||
aria-label={`${t.day} 询盘 ${t.count} 条`}
|
||||
style={{ width: "60%", maxWidth: 48, height: `${Math.round((t.count / max) * 100)}%`, minHeight: 4, background: "#1677ff", borderRadius: 4, opacity: 0.85 }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: "#999" }}>{t.day.slice(5)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="最新询盘" size="small" style={{ marginTop: 16 }}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={recentInquiries}
|
||||
pagination={false}
|
||||
locale={{ emptyText: <AdminEmpty /> }}
|
||||
columns={[
|
||||
{ title: "ID", dataIndex: "id", width: 70 },
|
||||
{ title: "姓名", dataIndex: "name", width: 140 },
|
||||
{ title: "邮箱", dataIndex: "email", width: 220 },
|
||||
{ title: "公司", dataIndex: "company", width: 160, ellipsis: true },
|
||||
{ title: "国家", dataIndex: "country", width: 120 },
|
||||
{ title: "感兴趣产品", dataIndex: "productInterest", ellipsis: true },
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
width: 100,
|
||||
render: (v: string) => <Tag color={INQUIRY_STATUS_COLOR[v] ?? "default"}>{INQUIRY_STATUS_LABEL[v] ?? v}</Tag>,
|
||||
},
|
||||
{ title: "时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* # 路由 /admin/inquiries 询盘管理
|
||||
* 状态 Tab 过滤 + 表格;行点击抽屉查看完整 fields JSON;操作按钮标记已读/已回复/垃圾。
|
||||
* 数据接口复用 src/server/inquiries.ts 的 listInquiries / updateInquiryStatus。
|
||||
*/
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||
import { App, Button, Card, Descriptions, Drawer, Input, Space, Table, Tabs, Tag } from "antd";
|
||||
import { CheckOutlined, SearchOutlined, SendOutlined, StopOutlined } from "@ant-design/icons";
|
||||
import { useState } from "react";
|
||||
import { listInquiries, updateInquiryStatus } from "~/server/inquiries";
|
||||
import type { inquiries } from "~/db/schema";
|
||||
import {
|
||||
AdminEmpty,
|
||||
Forbidden,
|
||||
INQUIRY_STATUS_COLOR,
|
||||
INQUIRY_STATUS_LABEL,
|
||||
Json,
|
||||
PageHeader,
|
||||
StatusTag,
|
||||
fmtDateTime,
|
||||
guardAdmin,
|
||||
tablePagination,
|
||||
type AdminSearch,
|
||||
} from "~/components/admin/common";
|
||||
|
||||
export const Route = createFileRoute("/admin/inquiries")({
|
||||
validateSearch: (search: Record<string, unknown>): AdminSearch => {
|
||||
// 只保留显式传入的参数,缺省时不回写 URL(避免 307 归一化重定向)
|
||||
const out: AdminSearch = {};
|
||||
const page = Math.floor(Number(search.page)) || 0;
|
||||
if (page > 1) out.page = page;
|
||||
if (typeof search.q === "string" && search.q) out.q = search.q;
|
||||
if (typeof search.status === "string" && search.status) out.status = search.status;
|
||||
return out;
|
||||
},
|
||||
loaderDeps: ({ search }) => ({ search }),
|
||||
loader: ({ deps }) =>
|
||||
guardAdmin(() =>
|
||||
listInquiries({ data: { page: deps.search.page, status: deps.search.status || undefined, q: deps.search.q || undefined } }),
|
||||
),
|
||||
component: InquiriesPage,
|
||||
});
|
||||
|
||||
/** # 成员变量 询盘行类型(fields/utm 收窄为 Json 后的形状) **/
|
||||
type InquiryRow = Omit<typeof inquiries.$inferSelect, "fields" | "utm"> & { fields: Json; utm: Json };
|
||||
|
||||
/** # 函数 询盘管理页面组件 */
|
||||
function InquiriesPage() {
|
||||
const data = Route.useLoaderData();
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
const router = useRouter();
|
||||
const { message } = App.useApp();
|
||||
const [q, setQ] = useState(search.q ?? "");
|
||||
/** # 成员变量 当前抽屉中查看的询盘记录(null = 关闭) **/
|
||||
const [viewing, setViewing] = useState<InquiryRow | null>(null);
|
||||
|
||||
if (!data) return <Forbidden />;
|
||||
|
||||
/** # 函数 更新查询参数 */
|
||||
function updateSearch(patch: AdminSearch) {
|
||||
void navigate({ search: (prev) => ({ ...prev, ...patch, page: 1 }) });
|
||||
}
|
||||
|
||||
/** # 函数 更新询盘状态并刷新 */
|
||||
async function handleStatus(id: number, status: string) {
|
||||
await updateInquiryStatus({ data: { id, status } });
|
||||
message.success("状态已更新");
|
||||
await router.invalidate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<PageHeader
|
||||
title="询盘管理"
|
||||
extra={
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="搜索姓名 / 邮箱 / 公司"
|
||||
prefix={<SearchOutlined aria-hidden />}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onPressEnter={() => updateSearch({ q })}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Tabs
|
||||
activeKey={search.status ?? ""}
|
||||
onChange={(key) => updateSearch({ status: key })}
|
||||
items={[
|
||||
{ key: "", label: "全部" },
|
||||
...(["new", "read", "replied", "spam", "archived"] as const).map((s) => ({ key: s, label: INQUIRY_STATUS_LABEL[s] })),
|
||||
]}
|
||||
/>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={data.items}
|
||||
locale={{ emptyText: <AdminEmpty /> }}
|
||||
onRow={(r) => ({ onClick: () => setViewing(r), style: { cursor: "pointer" } })}
|
||||
pagination={tablePagination(data.total, data.page, data.pageSize, (p) =>
|
||||
void navigate({ search: (prev) => ({ ...prev, page: p }) }),
|
||||
)}
|
||||
columns={[
|
||||
{ title: "ID", dataIndex: "id", width: 70 },
|
||||
{ title: "时间", dataIndex: "createdAt", width: 150, render: (v: string) => fmtDateTime(v) },
|
||||
{ title: "姓名", dataIndex: "name", width: 130, ellipsis: true },
|
||||
{ title: "邮箱", dataIndex: "email", width: 200, ellipsis: true },
|
||||
{ title: "公司", dataIndex: "company", width: 160, ellipsis: true },
|
||||
{ title: "国家", dataIndex: "country", width: 110, ellipsis: true },
|
||||
{ title: "感兴趣产品", dataIndex: "productInterest", ellipsis: true },
|
||||
{ title: "来源", dataIndex: "formName", width: 150, ellipsis: true },
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
width: 100,
|
||||
render: (v: string) => <StatusTag value={v} colorMap={INQUIRY_STATUS_COLOR} labelMap={INQUIRY_STATUS_LABEL} />,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 130,
|
||||
render: (_, r) => (
|
||||
<Space size={0} onClick={(e) => e.stopPropagation()}>
|
||||
{r.status !== "read" && r.status !== "replied" && (
|
||||
<Button size="small" type="text" icon={<CheckOutlined aria-label="标记已读" />} title="标记已读" onClick={() => void handleStatus(r.id, "read")} />
|
||||
)}
|
||||
{r.status !== "replied" && (
|
||||
<Button size="small" type="text" icon={<SendOutlined aria-label="标记已回复" />} title="标记已回复" onClick={() => void handleStatus(r.id, "replied")} />
|
||||
)}
|
||||
{r.status !== "spam" && (
|
||||
<Button size="small" type="text" danger icon={<StopOutlined aria-label="标记垃圾" />} title="标记垃圾" onClick={() => void handleStatus(r.id, "spam")} />
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title={viewing ? `询盘 #${viewing.id} · ${viewing.name || viewing.email}` : ""}
|
||||
width={560}
|
||||
open={viewing !== null}
|
||||
onClose={() => setViewing(null)}
|
||||
destroyOnHidden
|
||||
>
|
||||
{viewing && (
|
||||
<>
|
||||
<Descriptions size="small" column={1} bordered>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={INQUIRY_STATUS_COLOR[viewing.status] ?? "default"}>{INQUIRY_STATUS_LABEL[viewing.status] ?? viewing.status}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{fmtDateTime(viewing.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="姓名">{viewing.name || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="邮箱">{viewing.email || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="电话">{viewing.phone || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="公司">{viewing.company || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="国家">{viewing.country || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="感兴趣产品">{viewing.productInterest || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="来源">{viewing.formName || viewing.channel || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="来源页面">{viewing.sourcePage || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="IP">{viewing.ip || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="询盘内容">{viewing.message || "-"}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<h4 style={{ margin: "16px 0 8px" }}>完整字段(fields JSON)</h4>
|
||||
<pre style={{ background: "#f5f5f5", padding: 12, borderRadius: 6, fontSize: 12, maxHeight: 320, overflow: "auto" }}>
|
||||
{JSON.stringify(viewing.fields, null, 2)}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* # 路由 /admin/languages 语言配置
|
||||
* 104 行语言列表;Switch 启用/禁用;默认语言与机翻列用 Tag 展示。
|
||||
*/
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||
import { App, Card, Switch, Table, Tag } from "antd";
|
||||
import { adminListLanguages, adminUpdateLanguage } from "~/server/admin";
|
||||
import { AdminEmpty, Forbidden, PageHeader, guardAdmin } from "~/components/admin/common";
|
||||
|
||||
export const Route = createFileRoute("/admin/languages")({
|
||||
loader: () => guardAdmin(() => adminListLanguages()),
|
||||
component: LanguagesPage,
|
||||
});
|
||||
|
||||
/** # 函数 语言配置页面组件 */
|
||||
function LanguagesPage() {
|
||||
const data = Route.useLoaderData();
|
||||
const router = useRouter();
|
||||
const { message } = App.useApp();
|
||||
|
||||
if (!data) return <Forbidden />;
|
||||
|
||||
/** # 函数 切换语言启用状态并刷新 */
|
||||
async function toggleEnabled(code: string, enabled: boolean) {
|
||||
await adminUpdateLanguage({ data: { code, enabled } });
|
||||
message.success(enabled ? `已启用 ${code}` : `已禁用 ${code}`);
|
||||
await router.invalidate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<PageHeader title="语言配置" extra={<span style={{ color: "#999" }}>启用后该语言会生成真实页面;默认语言不可禁用逻辑由前台渲染决定</span>} />
|
||||
<Table
|
||||
rowKey="code"
|
||||
size="small"
|
||||
dataSource={data.items}
|
||||
locale={{ emptyText: <AdminEmpty /> }}
|
||||
pagination={{ pageSize: 20, showTotal: (t: number) => `共 ${t} 种语言` }}
|
||||
columns={[
|
||||
{ title: "代码", dataIndex: "code", width: 110 },
|
||||
{ title: "名称", dataIndex: "name", width: 200 },
|
||||
{
|
||||
title: "默认语言",
|
||||
dataIndex: "isDefault",
|
||||
width: 100,
|
||||
render: (v: boolean) => (v ? <Tag color="blue">默认</Tag> : <Tag>否</Tag>),
|
||||
},
|
||||
{
|
||||
title: "机翻",
|
||||
dataIndex: "machine",
|
||||
width: 90,
|
||||
render: (v: boolean) => (v ? <Tag color="purple">机翻</Tag> : "-"),
|
||||
},
|
||||
{ title: "排序", dataIndex: "sort", width: 80 },
|
||||
{
|
||||
title: "启用",
|
||||
dataIndex: "enabled",
|
||||
width: 90,
|
||||
render: (v: boolean, r) => <Switch size="small" checked={v} onChange={(on) => void toggleEnabled(r.code, on)} aria-label={`切换语言 ${r.code} 启用状态`} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* # 路由 /admin/media 媒体库
|
||||
* 表格 + 缩略图预览;编辑替代文本(alt)与标题。
|
||||
*/
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||
import { App, Button, Card, Form, Image, Input, Modal, Space, Table, Typography } from "antd";
|
||||
import { EditOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { File as FileIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { adminListMedia, adminUpdateMedia } from "~/server/admin";
|
||||
import { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common";
|
||||
import type { media } from "~/db/schema";
|
||||
|
||||
export const Route = createFileRoute("/admin/media")({
|
||||
validateSearch: (search: Record<string, unknown>): AdminSearch => {
|
||||
// 只保留显式传入的参数,缺省时不回写 URL(避免 307 归一化重定向)
|
||||
const out: AdminSearch = {};
|
||||
const page = Math.floor(Number(search.page)) || 0;
|
||||
if (page > 1) out.page = page;
|
||||
if (typeof search.q === "string" && search.q) out.q = search.q;
|
||||
return out;
|
||||
},
|
||||
loaderDeps: ({ search }) => ({ search }),
|
||||
loader: ({ deps }) => guardAdmin(() => adminListMedia({ data: { page: deps.search.page, q: deps.search.q || undefined } })),
|
||||
component: MediaPage,
|
||||
});
|
||||
|
||||
/** # 成员变量 媒体行类型(来自 media 表结构推断) **/
|
||||
type MediaRow = typeof media.$inferSelect;
|
||||
|
||||
/** # 函数 媒体库页面组件 */
|
||||
function MediaPage() {
|
||||
const data = Route.useLoaderData();
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
const router = useRouter();
|
||||
const { message } = App.useApp();
|
||||
const [q, setQ] = useState(search.q ?? "");
|
||||
/** # 成员变量 当前编辑的媒体记录(null = 关闭弹窗) **/
|
||||
const [editing, setEditing] = useState<MediaRow | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
if (!data) return <Forbidden />;
|
||||
|
||||
/** # 函数 更新查询参数 */
|
||||
function updateSearch(patch: AdminSearch) {
|
||||
void navigate({ search: (prev) => ({ ...prev, ...patch, page: 1 }) });
|
||||
}
|
||||
|
||||
/** # 函数 打开编辑弹窗并回填表单 */
|
||||
function openEdit(row: MediaRow) {
|
||||
setEditing(row);
|
||||
form.setFieldsValue({ altText: row.altText, title: row.title });
|
||||
}
|
||||
|
||||
/** # 函数 保存媒体 alt/title */
|
||||
async function handleSave(values: { altText?: string; title?: string }) {
|
||||
if (!editing) return;
|
||||
await adminUpdateMedia({ data: { id: editing.id, altText: values.altText, title: values.title } });
|
||||
message.success("已保存");
|
||||
setEditing(null);
|
||||
await router.invalidate();
|
||||
}
|
||||
|
||||
/** # 函数 文件大小格式化(字节 → KB/MB) */
|
||||
function fmtSize(n: number | null): string {
|
||||
if (!n) return "-";
|
||||
if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${Math.max(1, Math.round(n / 1024))} KB`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<PageHeader
|
||||
title="媒体库"
|
||||
extra={
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="搜索文件路径"
|
||||
prefix={<SearchOutlined aria-hidden />}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onPressEnter={() => updateSearch({ q })}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={data.items}
|
||||
locale={{ emptyText: <AdminEmpty /> }}
|
||||
pagination={tablePagination(data.total, data.page, data.pageSize, (p) =>
|
||||
void navigate({ search: (prev) => ({ ...prev, page: p }) }),
|
||||
)}
|
||||
columns={[
|
||||
{ title: "ID", dataIndex: "id", width: 70 },
|
||||
{
|
||||
title: "预览",
|
||||
dataIndex: "url",
|
||||
width: 88,
|
||||
render: (v: string, r) =>
|
||||
r.mimeType.startsWith("image") ? (
|
||||
<Image src={v} alt={r.altText || r.path} width={56} height={56} style={{ objectFit: "cover", borderRadius: 4 }} />
|
||||
) : (
|
||||
<FileIcon size={36} aria-hidden color="#bfbfbf" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "路径",
|
||||
dataIndex: "path",
|
||||
render: (v: string, r) => (
|
||||
<Typography.Link href={r.url} target="_blank" style={{ wordBreak: "break-all" }}>
|
||||
{v}
|
||||
</Typography.Link>
|
||||
),
|
||||
},
|
||||
{ title: "类型", dataIndex: "mimeType", width: 140 },
|
||||
{ title: "尺寸", key: "dim", width: 110, render: (_, r) => (r.width ? `${r.width} x ${r.height}` : "-") },
|
||||
{ title: "大小", dataIndex: "size", width: 90, render: (v: number | null) => fmtSize(v) },
|
||||
{ title: "引用", dataIndex: "refCount", width: 70 },
|
||||
{ title: "替代文本", dataIndex: "altText", ellipsis: true },
|
||||
{ title: "上传时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 70,
|
||||
render: (_, r) => (
|
||||
<Button size="small" type="text" icon={<EditOutlined aria-label="编辑" />} onClick={() => openEdit(r)} />
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal title={`编辑媒体 #${editing?.id ?? ""}`} open={editing !== null} onCancel={() => setEditing(null)} onOk={() => form.submit()} destroyOnHidden>
|
||||
<Form form={form} layout="vertical" onFinish={(v) => void handleSave(v)}>
|
||||
<Form.Item name="altText" label="替代文本(alt)">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label="标题">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* # 路由 /admin/orders/$id 订单详情
|
||||
* 订单信息描述列表 + 明细表格 + 状态流转 Select。
|
||||
*/
|
||||
import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { App, Button, Card, Descriptions, Select, Space, Table, Tag } from "antd";
|
||||
import { ArrowLeftOutlined } from "@ant-design/icons";
|
||||
import { adminGetOrder, adminUpdateOrderStatus } from "~/server/admin";
|
||||
import {
|
||||
AdminEmpty,
|
||||
ORDER_STATUS_COLOR,
|
||||
ORDER_STATUS_LABEL,
|
||||
ORDER_STATUS_OPTIONS,
|
||||
StatusTag,
|
||||
fmtDateTime,
|
||||
Forbidden,
|
||||
guardAdmin,
|
||||
} from "~/components/admin/common";
|
||||
|
||||
export const Route = createFileRoute("/admin/orders/$id")({
|
||||
loader: ({ params }) => guardAdmin(() => adminGetOrder({ data: { id: Number(params.id) } })),
|
||||
component: OrderDetailPage,
|
||||
});
|
||||
|
||||
/** # 函数 订单详情页面组件 */
|
||||
function OrderDetailPage() {
|
||||
const data = Route.useLoaderData();
|
||||
const navigate = useNavigate();
|
||||
const router = useRouter();
|
||||
const { message } = App.useApp();
|
||||
|
||||
if (!data) return <Forbidden />;
|
||||
const { order, userEmail, items } = data;
|
||||
|
||||
/** # 函数 状态流转:更新订单状态并刷新 */
|
||||
async function handleStatus(status: string) {
|
||||
await adminUpdateOrderStatus({ data: { id: order.id, status } });
|
||||
message.success("状态已更新");
|
||||
await router.invalidate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button icon={<ArrowLeftOutlined aria-hidden />} onClick={() => void navigate({ to: "/admin/orders/" })}>
|
||||
返回列表
|
||||
</Button>
|
||||
<h2 style={{ margin: 0, fontSize: 18 }}>订单 {order.orderNumber}</h2>
|
||||
<StatusTag value={order.status} colorMap={ORDER_STATUS_COLOR} labelMap={ORDER_STATUS_LABEL} />
|
||||
</Space>
|
||||
|
||||
<Card type="inner" title="订单信息" size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions size="small" column={3}>
|
||||
<Descriptions.Item label="状态流转">
|
||||
<Select value={order.status} options={ORDER_STATUS_OPTIONS} style={{ width: 140 }} onChange={(v) => void handleStatus(v)} aria-label="订单状态" />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="支付状态">
|
||||
<Tag>{order.paymentStatus}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="支付方式">{order.paymentMethod || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="总额">
|
||||
{order.currency} {order.total}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="小计">{order.subtotal}</Descriptions.Item>
|
||||
<Descriptions.Item label="运费 / 税费">
|
||||
{order.shippingTotal} / {order.taxTotal}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="客户邮箱">{userEmail || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="客户姓名">{order.customerName || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="下单时间">{fmtDateTime(order.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="客户备注" span={3}>
|
||||
{order.customerNote || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="账单信息" span={3}>
|
||||
<pre style={{ margin: 0, fontSize: 12 }}>{JSON.stringify(order.billing, null, 2)}</pre>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="收货信息" span={3}>
|
||||
<pre style={{ margin: 0, fontSize: 12 }}>{JSON.stringify(order.shipping, null, 2)}</pre>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card type="inner" title="订单明细" size="small">
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={items}
|
||||
pagination={false}
|
||||
locale={{ emptyText: <AdminEmpty /> }}
|
||||
columns={[
|
||||
{ title: "商品名", dataIndex: "name" },
|
||||
{ title: "SKU", dataIndex: "sku", width: 140 },
|
||||
{ title: "数量", dataIndex: "qty", width: 80 },
|
||||
{ title: "单价", dataIndex: "price", width: 110 },
|
||||
{ title: "小计", dataIndex: "total", width: 110 },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* # 路由 /admin/orders 订单列表
|
||||
* 状态 Tab 过滤 + 分页表格;点击订单号进入详情。
|
||||
*/
|
||||
import { createFileRoute, Link, useRouter } from "@tanstack/react-router";
|
||||
import { Button, Card, Space, Table, Tabs, Tag } from "antd";
|
||||
import { EyeOutlined } from "@ant-design/icons";
|
||||
import { adminListOrders } from "~/server/admin";
|
||||
import {
|
||||
AdminEmpty,
|
||||
Forbidden,
|
||||
ORDER_STATUS_COLOR,
|
||||
ORDER_STATUS_LABEL,
|
||||
PageHeader,
|
||||
StatusTag,
|
||||
fmtDateTime,
|
||||
guardAdmin,
|
||||
tablePagination,
|
||||
type AdminSearch,
|
||||
} from "~/components/admin/common";
|
||||
|
||||
export const Route = createFileRoute("/admin/orders/")({
|
||||
validateSearch: (search: Record<string, unknown>): AdminSearch => {
|
||||
// 只保留显式传入的参数,缺省时不回写 URL(避免 307 归一化重定向)
|
||||
const out: AdminSearch = {};
|
||||
const page = Math.floor(Number(search.page)) || 0;
|
||||
if (page > 1) out.page = page;
|
||||
if (typeof search.status === "string" && search.status) out.status = search.status;
|
||||
return out;
|
||||
},
|
||||
loaderDeps: ({ search }) => ({ search }),
|
||||
loader: ({ deps }) => guardAdmin(() => adminListOrders({ data: { page: deps.search.page, status: deps.search.status || undefined } })),
|
||||
component: OrdersPage,
|
||||
});
|
||||
|
||||
/** # 函数 订单列表页面组件 */
|
||||
function OrdersPage() {
|
||||
const data = Route.useLoaderData();
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
if (!data) return <Forbidden />;
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<PageHeader title="订单管理" />
|
||||
<Tabs
|
||||
activeKey={search.status ?? ""}
|
||||
onChange={(key) => void navigate({ search: (prev) => ({ ...prev, status: key, page: 1 }) })}
|
||||
items={[
|
||||
{ key: "", label: "全部" },
|
||||
...(["pending", "on-hold", "processing", "completed", "cancelled", "refunded"] as const).map((s) => ({
|
||||
key: s,
|
||||
label: ORDER_STATUS_LABEL[s],
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={data.items}
|
||||
locale={{ emptyText: <AdminEmpty /> }}
|
||||
pagination={tablePagination(data.total, data.page, data.pageSize, (p) =>
|
||||
void navigate({ search: (prev) => ({ ...prev, page: p }) }),
|
||||
)}
|
||||
columns={[
|
||||
{ title: "ID", dataIndex: "id", width: 70 },
|
||||
{
|
||||
title: "订单号",
|
||||
dataIndex: "orderNumber",
|
||||
width: 160,
|
||||
render: (v: string, r) => (
|
||||
<Link to="/admin/orders/$id/" params={{ id: String(r.id) }}>
|
||||
{v}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{ title: "客户", dataIndex: "customerName", width: 180, ellipsis: true },
|
||||
{
|
||||
title: "金额",
|
||||
dataIndex: "total",
|
||||
width: 120,
|
||||
render: (v: string, r) => `${r.currency} ${v}`,
|
||||
},
|
||||
{ title: "支付状态", dataIndex: "paymentStatus", width: 110, render: (v: string) => <Tag>{v}</Tag> },
|
||||
{
|
||||
title: "订单状态",
|
||||
dataIndex: "status",
|
||||
width: 110,
|
||||
render: (v: string) => <StatusTag value={v} colorMap={ORDER_STATUS_COLOR} labelMap={ORDER_STATUS_LABEL} />,
|
||||
},
|
||||
{ title: "下单时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 80,
|
||||
render: (_, r) => (
|
||||
<Space size={4}>
|
||||
<Link to="/admin/orders/$id/" params={{ id: String(r.id) }}>
|
||||
<Button size="small" type="text" icon={<EyeOutlined aria-label="查看详情" />} />
|
||||
</Link>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* # 路由 /admin/products/$id 产品编辑页
|
||||
* 基础字段 + 特色图/图集媒体选择(远程搜索媒体库)+ 属性值多选(保存时重建
|
||||
* product_attribute_map)+ 产品分类多选(product_terms)。id 为 new 时新建。
|
||||
*/
|
||||
import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { App, Button, Card, Form, Input, InputNumber, Select, Space, Switch } from "antd";
|
||||
import { ArrowLeftOutlined, SaveOutlined } from "@ant-design/icons";
|
||||
import { adminGetProduct, adminSaveProduct, adminSearchMedia } from "~/server/admin";
|
||||
import { Forbidden, guardAdmin } from "~/components/admin/common";
|
||||
|
||||
export const Route = createFileRoute("/admin/products/$id")({
|
||||
loader: ({ params }) =>
|
||||
guardAdmin(async () => {
|
||||
// new → id 0(同时作为权限探测,未登录抛 FORBIDDEN)
|
||||
const id = params.id === "new" ? 0 : Number(params.id);
|
||||
const [data, mediaOptions] = await Promise.all([adminGetProduct({ data: { id } }), adminSearchMedia({ data: { limit: 30 } })]);
|
||||
return { ...data, mediaOptions };
|
||||
}),
|
||||
component: ProductEditPage,
|
||||
});
|
||||
|
||||
/** # 类型 媒体选择器选项 **/
|
||||
type MediaOption = { id: number; path: string; url: string };
|
||||
|
||||
/** # 函数 文件路径转显示名(取 basename) */
|
||||
function mediaLabel(path: string): string {
|
||||
return path.split("/").pop() || path;
|
||||
}
|
||||
|
||||
/** # 组件 媒体选择器(AntD Select 远程搜索媒体库,支持单选/多选)
|
||||
* 参数 options:初始选项;multiple:是否多选;value/onChange 受控由 Form.Item 接管
|
||||
*/
|
||||
function MediaSelect(props: { options: MediaOption[]; multiple?: boolean; placeholder?: string }) {
|
||||
const [opts, setOpts] = useState<MediaOption[]>(props.options);
|
||||
|
||||
/** # 函数 远程搜索媒体(防抖 300ms) */
|
||||
function handleSearch(q: string) {
|
||||
window.clearTimeout((handleSearch as unknown as { _t?: number })._t);
|
||||
(handleSearch as unknown as { _t?: number })._t = window.setTimeout(() => {
|
||||
adminSearchMedia({ data: { q: q || undefined, limit: 30 } })
|
||||
.then((rows) => setOpts(rows))
|
||||
.catch(() => {});
|
||||
}, 300);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
mode={props.multiple ? "multiple" : undefined}
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder={props.placeholder || "搜索媒体文件名"}
|
||||
onSearch={handleSearch}
|
||||
optionFilterProp="label"
|
||||
style={{ maxWidth: 640, width: "100%" }}
|
||||
options={opts.map((m) => ({ value: m.id, label: mediaLabel(m.path) }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 函数 产品编辑页面组件 */
|
||||
function ProductEditPage() {
|
||||
const data = Route.useLoaderData();
|
||||
const navigate = useNavigate();
|
||||
const router = useRouter();
|
||||
const { message } = App.useApp();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
if (!data) return <Forbidden />;
|
||||
const { product, attributes, categories, selectedValueIds, selectedCategoryIds, selectedGalleryIds, initialGalleryOptions, mediaOptions } = data;
|
||||
|
||||
/** # 成员变量 表单初始值:基础字段 + 每个属性的已选值数组 **/
|
||||
const initialValues = {
|
||||
title: product?.title ?? "",
|
||||
slug: product?.slug ?? "",
|
||||
sku: product?.sku ?? "",
|
||||
price: product?.price ?? "",
|
||||
regularPrice: product?.regularPrice ?? "",
|
||||
stock: product?.stock ?? null,
|
||||
stockStatus: product?.stockStatus ?? "instock",
|
||||
status: product?.status ?? "draft",
|
||||
featured: product?.featured ?? false,
|
||||
shortDescription: product?.shortDescription ?? "",
|
||||
description: product?.description ?? "",
|
||||
specUrl: product?.specUrl ?? "",
|
||||
seoTitle: product?.seoTitle ?? "",
|
||||
seoDescription: product?.seoDescription ?? "",
|
||||
featuredMediaId: product?.featuredMediaId ?? null,
|
||||
galleryIds: selectedGalleryIds,
|
||||
categoryIds: selectedCategoryIds,
|
||||
...Object.fromEntries(
|
||||
attributes.map((a) => [`attr_${a.id}`, selectedValueIds.filter((v) => a.values.some((val) => val.id === v))]),
|
||||
),
|
||||
};
|
||||
|
||||
/** # 函数 保存产品:提交字段 + 属性值/分类,重建关联表 */
|
||||
async function handleSave(values: Record<string, unknown>) {
|
||||
const valueIds = attributes.flatMap((a) => ((values[`attr_${a.id}`] as number[] | undefined) ?? []));
|
||||
try {
|
||||
await adminSaveProduct({
|
||||
data: {
|
||||
id: product?.id,
|
||||
sku: (values.sku as string) || null,
|
||||
slug: values.slug as string,
|
||||
title: values.title as string,
|
||||
shortDescription: (values.shortDescription as string) ?? "",
|
||||
description: (values.description as string) ?? "",
|
||||
price: (values.price as string) || null,
|
||||
regularPrice: (values.regularPrice as string) || null,
|
||||
stock: (values.stock as number | null) ?? null,
|
||||
stockStatus: (values.stockStatus as string) ?? "instock",
|
||||
status: (values.status as string) ?? "draft",
|
||||
featured: (values.featured as boolean) ?? false,
|
||||
specUrl: (values.specUrl as string) || null,
|
||||
seoTitle: (values.seoTitle as string) || null,
|
||||
seoDescription: (values.seoDescription as string) || null,
|
||||
featuredMediaId: (values.featuredMediaId as number | null) ?? null,
|
||||
galleryIds: (values.galleryIds as number[]) ?? [],
|
||||
valueIds,
|
||||
categoryIds: (values.categoryIds as number[]) ?? [],
|
||||
},
|
||||
});
|
||||
message.success("保存成功");
|
||||
await router.invalidate();
|
||||
void navigate({ to: "/admin/products/" });
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : "保存失败");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button icon={<ArrowLeftOutlined aria-hidden />} onClick={() => void navigate({ to: "/admin/products/" })}>
|
||||
返回列表
|
||||
</Button>
|
||||
<h2 style={{ margin: 0, fontSize: 18 }}>{product ? `编辑产品 #${product.id}` : "新建产品"}</h2>
|
||||
</Space>
|
||||
<Form form={form} layout="vertical" style={{ maxWidth: 900 }} initialValues={initialValues} onFinish={(v) => void handleSave(v)}>
|
||||
<Space size={16} style={{ display: "flex" }} align="start">
|
||||
<Form.Item name="title" label="名称" rules={[{ required: true, message: "请输入产品名称" }]} style={{ minWidth: 420, flex: 1 }}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="slug" label="别名(留空自动生成)">
|
||||
<Input style={{ width: 240 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sku" label="SKU">
|
||||
<Input style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space size={16} style={{ display: "flex" }} wrap>
|
||||
<Form.Item name="price" label="现价(USD)">
|
||||
<Input style={{ width: 120 }} placeholder="0.00" />
|
||||
</Form.Item>
|
||||
<Form.Item name="regularPrice" label="原价(USD)">
|
||||
<Input style={{ width: 120 }} placeholder="0.00" />
|
||||
</Form.Item>
|
||||
<Form.Item name="stock" label="库存数量">
|
||||
<InputNumber min={0} style={{ width: 120 }} placeholder="留空不限" />
|
||||
</Form.Item>
|
||||
<Form.Item name="stockStatus" label="库存状态">
|
||||
<Select
|
||||
style={{ width: 130 }}
|
||||
options={[
|
||||
{ value: "instock", label: "有货" },
|
||||
{ value: "outofstock", label: "缺货" },
|
||||
{ value: "onbackorder", label: "预订" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
style={{ width: 130 }}
|
||||
options={[
|
||||
{ value: "draft", label: "草稿" },
|
||||
{ value: "published", label: "已发布" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="featured" label="主推产品" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="specUrl" label="规格书地址">
|
||||
<Input style={{ width: 240 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space size={16} style={{ display: "flex" }} wrap>
|
||||
<Form.Item name="featuredMediaId" label="特色图" style={{ minWidth: 300 }}>
|
||||
<MediaSelect options={mediaOptions} placeholder="搜索媒体文件名选择特色图" />
|
||||
</Form.Item>
|
||||
<Form.Item name="galleryIds" label="图集(可多选)" style={{ minWidth: 300 }}>
|
||||
<MediaSelect options={[...initialGalleryOptions, ...mediaOptions.filter((m) => !initialGalleryOptions.some((g) => g.id === m.id))]} multiple />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="shortDescription" label="短描述">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="详细描述 HTML">
|
||||
<Input.TextArea rows={14} style={{ fontFamily: "monospace", fontSize: 12 }} />
|
||||
</Form.Item>
|
||||
{attributes.length > 0 && (
|
||||
<Card type="inner" title="产品属性" size="small" style={{ marginBottom: 16 }}>
|
||||
{attributes.map((a) => (
|
||||
<Form.Item
|
||||
key={a.id}
|
||||
name={`attr_${a.id}`}
|
||||
label={a.name}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
placeholder={`选择 ${a.name} 属性值`}
|
||||
style={{ maxWidth: 640 }}
|
||||
options={a.values.map((v) => ({ value: v.id, label: v.value }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
<Form.Item name="categoryIds" label="产品分类">
|
||||
<Select mode="multiple" allowClear placeholder="选择分类" style={{ maxWidth: 640 }} options={categories.map((c) => ({ value: c.id, label: c.name }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="seoTitle" label="SEO 标题">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="seoDescription" label="SEO 描述">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SaveOutlined aria-hidden />}>
|
||||
保存
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* # 路由 /admin/products 产品列表
|
||||
* 关键字搜索(标题/SKU/别名)+ 分页表格,含缩略图、价格、库存状态与操作列。
|
||||
*/
|
||||
import { createFileRoute, Link, useRouter } from "@tanstack/react-router";
|
||||
import { App, Button, Card, Image, Input, Popconfirm, Space, Table, Tag } from "antd";
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { Package } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { adminDeleteProduct, adminListProducts } from "~/server/admin";
|
||||
import { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common";
|
||||
|
||||
export const Route = createFileRoute("/admin/products/")({
|
||||
validateSearch: (search: Record<string, unknown>): AdminSearch => {
|
||||
// 只保留显式传入的参数,缺省时不回写 URL(避免 307 归一化重定向)
|
||||
const out: AdminSearch = {};
|
||||
const page = Math.floor(Number(search.page)) || 0;
|
||||
if (page > 1) out.page = page;
|
||||
if (typeof search.q === "string" && search.q) out.q = search.q;
|
||||
return out;
|
||||
},
|
||||
loaderDeps: ({ search }) => ({ search }),
|
||||
loader: ({ deps }) => guardAdmin(() => adminListProducts({ data: { page: deps.search.page, q: deps.search.q || undefined } })),
|
||||
component: ProductsPage,
|
||||
});
|
||||
|
||||
/** # 成员变量 库存状态中文标签与配色 **/
|
||||
const STOCK_LABEL: Record<string, string> = { instock: "有货", outofstock: "缺货", onbackorder: "预订" };
|
||||
const STOCK_COLOR: Record<string, string> = { instock: "green", outofstock: "red", onbackorder: "orange" };
|
||||
|
||||
/** # 函数 产品列表页面组件 */
|
||||
function ProductsPage() {
|
||||
const data = Route.useLoaderData();
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
const router = useRouter();
|
||||
const { message } = App.useApp();
|
||||
const [q, setQ] = useState(search.q ?? "");
|
||||
|
||||
if (!data) return <Forbidden />;
|
||||
|
||||
/** # 函数 更新查询参数 */
|
||||
function updateSearch(patch: AdminSearch) {
|
||||
void navigate({ search: (prev) => ({ ...prev, ...patch, page: 1 }) });
|
||||
}
|
||||
|
||||
/** # 函数 删除产品并刷新 */
|
||||
async function handleDelete(id: number) {
|
||||
await adminDeleteProduct({ data: { id } });
|
||||
message.success("已删除");
|
||||
await router.invalidate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<PageHeader
|
||||
title="产品管理"
|
||||
extra={
|
||||
<>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="搜索标题 / SKU / 别名"
|
||||
prefix={<SearchOutlined aria-hidden />}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onPressEnter={() => updateSearch({ q })}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
<Link to="/admin/products/$id/" params={{ id: "new" }}>
|
||||
<Button type="primary" icon={<PlusOutlined aria-hidden />}>
|
||||
新建产品
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={data.items}
|
||||
locale={{ emptyText: <AdminEmpty /> }}
|
||||
pagination={tablePagination(data.total, data.page, data.pageSize, (p) => void navigate({ search: (prev) => ({ ...prev, page: p }) }))}
|
||||
columns={[
|
||||
{ title: "ID", dataIndex: "id", width: 70 },
|
||||
{
|
||||
title: "图",
|
||||
dataIndex: "thumb",
|
||||
width: 72,
|
||||
render: (v: string | null) =>
|
||||
v ? (
|
||||
<Image src={v} alt="产品缩略图" width={44} height={44} style={{ objectFit: "cover", borderRadius: 4 }} />
|
||||
) : (
|
||||
<Package size={36} aria-hidden color="#d9d9d9" />
|
||||
),
|
||||
},
|
||||
{ title: "SKU", dataIndex: "sku", width: 140 },
|
||||
{
|
||||
title: "标题",
|
||||
dataIndex: "title",
|
||||
render: (v: string, r) => (
|
||||
<Link to="/admin/products/$id/" params={{ id: String(r.id) }}>
|
||||
{v}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "价格",
|
||||
dataIndex: "price",
|
||||
width: 110,
|
||||
render: (v: string | null, r) => (v != null ? `$${v}` : r.regularPrice != null ? `$${r.regularPrice}` : "-"),
|
||||
},
|
||||
{
|
||||
title: "库存",
|
||||
dataIndex: "stockStatus",
|
||||
width: 100,
|
||||
render: (v: string, r) => (
|
||||
<Tag color={STOCK_COLOR[v] ?? "default"}>
|
||||
{STOCK_LABEL[v] ?? v}
|
||||
{typeof r.stock === "number" ? ` (${r.stock})` : ""}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
width: 90,
|
||||
render: (v: string) => <Tag color={v === "published" ? "green" : "default"}>{v}</Tag>,
|
||||
},
|
||||
{ title: "更新时间", dataIndex: "updatedAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 110,
|
||||
render: (_, r) => (
|
||||
<Space size={4}>
|
||||
<Link to="/admin/products/$id/" params={{ id: String(r.id) }}>
|
||||
<Button size="small" type="text" icon={<EditOutlined aria-label="编辑" />} />
|
||||
</Link>
|
||||
<Popconfirm title="确认删除该产品?" onConfirm={() => void handleDelete(r.id)}>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined aria-label="删除" />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* # 路由 /admin/seo 重定向管理(SEO)
|
||||
* 重定向列表 + 新增/编辑弹窗(source/target/code/regex/enabled)+ 删除。
|
||||
*/
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||
import { App, Button, Card, Form, Input, Modal, Popconfirm, Select, Space, Switch, Table, Tag } from "antd";
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { useState } from "react";
|
||||
import { adminDeleteRedirect, adminListRedirects, adminSaveRedirect } from "~/server/admin";
|
||||
import { AdminEmpty, Forbidden, PageHeader, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common";
|
||||
import type { redirects } from "~/db/schema";
|
||||
|
||||
export const Route = createFileRoute("/admin/seo")({
|
||||
validateSearch: (search: Record<string, unknown>): AdminSearch => {
|
||||
// 只保留显式传入的参数,缺省时不回写 URL(避免 307 归一化重定向)
|
||||
const out: AdminSearch = {};
|
||||
const page = Math.floor(Number(search.page)) || 0;
|
||||
if (page > 1) out.page = page;
|
||||
if (typeof search.q === "string" && search.q) out.q = search.q;
|
||||
return out;
|
||||
},
|
||||
loaderDeps: ({ search }) => ({ search }),
|
||||
loader: ({ deps }) => guardAdmin(() => adminListRedirects({ data: { page: deps.search.page, q: deps.search.q || undefined } })),
|
||||
component: SeoPage,
|
||||
});
|
||||
|
||||
/** # 成员变量 重定向行类型(来自 redirects 表结构推断) **/
|
||||
type RedirectRow = typeof redirects.$inferSelect;
|
||||
|
||||
/** # 函数 SEO / 重定向页面组件 */
|
||||
function SeoPage() {
|
||||
const data = Route.useLoaderData();
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
const router = useRouter();
|
||||
const { message } = App.useApp();
|
||||
const [q, setQ] = useState(search.q ?? "");
|
||||
/** # 成员变量 编辑弹窗中的重定向记录(null = 关闭;id 缺省为新建) **/
|
||||
const [editing, setEditing] = useState<RedirectRow | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
if (!data) return <Forbidden />;
|
||||
|
||||
/** # 函数 更新查询参数 */
|
||||
function updateSearch(patch: AdminSearch) {
|
||||
void navigate({ search: (prev) => ({ ...prev, ...patch, page: 1 }) });
|
||||
}
|
||||
|
||||
/** # 函数 打开新增/编辑弹窗 */
|
||||
function openEdit(row: RedirectRow | null) {
|
||||
setEditing(row);
|
||||
form.setFieldsValue(
|
||||
row ?? { source: "", target: "", code: 301, isRegex: false, enabled: true },
|
||||
);
|
||||
}
|
||||
|
||||
/** # 函数 保存重定向 */
|
||||
async function handleSave(values: { source: string; target: string; code: number; isRegex: boolean; enabled: boolean }) {
|
||||
await adminSaveRedirect({ data: { ...values, id: editing?.id } });
|
||||
message.success("已保存");
|
||||
setEditing(null);
|
||||
await router.invalidate();
|
||||
}
|
||||
|
||||
/** # 函数 删除重定向 */
|
||||
async function handleDelete(id: number) {
|
||||
await adminDeleteRedirect({ data: { id } });
|
||||
message.success("已删除");
|
||||
await router.invalidate();
|
||||
}
|
||||
|
||||
/** # 函数 切换启用状态 */
|
||||
async function toggleEnabled(row: RedirectRow, enabled: boolean) {
|
||||
await adminSaveRedirect({ data: { id: row.id, source: row.source, target: row.target, code: row.code, isRegex: row.isRegex, enabled } });
|
||||
await router.invalidate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<PageHeader
|
||||
title="重定向管理"
|
||||
extra={
|
||||
<>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="搜索源路径 / 目标"
|
||||
prefix={<SearchOutlined aria-hidden />}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onPressEnter={() => updateSearch({ q })}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined aria-hidden />} onClick={() => openEdit(null)}>
|
||||
新增重定向
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={data.items}
|
||||
locale={{ emptyText: <AdminEmpty /> }}
|
||||
pagination={tablePagination(data.total, data.page, data.pageSize, (p) =>
|
||||
void navigate({ search: (prev) => ({ ...prev, page: p }) }),
|
||||
)}
|
||||
columns={[
|
||||
{ title: "ID", dataIndex: "id", width: 70 },
|
||||
{ title: "源路径", dataIndex: "source", ellipsis: true },
|
||||
{ title: "目标", dataIndex: "target", ellipsis: true },
|
||||
{
|
||||
title: "状态码",
|
||||
dataIndex: "code",
|
||||
width: 90,
|
||||
render: (v: number) => <Tag color={v === 301 ? "green" : "blue"}>{v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "正则",
|
||||
dataIndex: "isRegex",
|
||||
width: 70,
|
||||
render: (v: boolean) => (v ? <Tag color="purple">正则</Tag> : "-"),
|
||||
},
|
||||
{ title: "命中", dataIndex: "hits", width: 70 },
|
||||
{
|
||||
title: "启用",
|
||||
dataIndex: "enabled",
|
||||
width: 80,
|
||||
render: (_, r) => (
|
||||
<Switch size="small" checked={r.enabled} onChange={(on) => void toggleEnabled(r, on)} aria-label={`切换重定向 ${r.source}`} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 110,
|
||||
render: (_, r) => (
|
||||
<Space size={4}>
|
||||
<Button size="small" type="text" icon={<EditOutlined aria-label="编辑" />} onClick={() => openEdit(r)} />
|
||||
<Popconfirm title="确认删除该重定向?" onConfirm={() => void handleDelete(r.id)}>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined aria-label="删除" />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? `编辑重定向 #${editing.id}` : "新增重定向"}
|
||||
open={editing !== null}
|
||||
onCancel={() => setEditing(null)}
|
||||
onOk={() => form.submit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={(v) => void handleSave(v)}>
|
||||
<Form.Item name="source" label="源路径(支持正则)" rules={[{ required: true, message: "请输入源路径" }]}>
|
||||
<Input placeholder="/old-path 或 /blog/(.*)" />
|
||||
</Form.Item>
|
||||
<Form.Item name="target" label="目标地址" rules={[{ required: true, message: "请输入目标地址" }]}>
|
||||
<Input placeholder="/new-path 或 https://…" />
|
||||
</Form.Item>
|
||||
<Space size={16} style={{ display: "flex" }}>
|
||||
<Form.Item name="code" label="状态码">
|
||||
<Select style={{ width: 110 }} options={[{ value: 301, label: "301" }, { value: 302, label: "302" }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="isRegex" label="正则匹配" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* # 路由 /admin 后台布局
|
||||
* 左侧 Sider 菜单 + 顶部 Header(当前用户 / 退出)。
|
||||
* 未登录或无后台权限:整页渲染登录页(不再展示后台骨架)。
|
||||
*/
|
||||
import { createFileRoute, Outlet, useNavigate, useRouter, useRouterState } from "@tanstack/react-router";
|
||||
import { App, Avatar, Button, Card, ConfigProvider, Form, Input, Layout, Menu, Space, Tag } from "antd";
|
||||
import { LockKeyhole } from "lucide-react";
|
||||
import {
|
||||
Files,
|
||||
Inbox,
|
||||
Image as ImageIcon,
|
||||
Languages,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Package,
|
||||
Route as RouteIcon,
|
||||
Settings,
|
||||
ShoppingCart,
|
||||
SlidersHorizontal,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { StyleProvider } from "@ant-design/cssinjs";
|
||||
import { getCurrentUser, loginFn, logoutFn } from "~/server/auth";
|
||||
|
||||
/** # 类型 后台菜单项(key 即目标路由路径) **/
|
||||
const MENU_ITEMS = [
|
||||
{ key: "/admin/", icon: <LayoutDashboard size={16} aria-hidden />, label: "Dashboard" },
|
||||
{ key: "/admin/contents/", icon: <Files size={16} aria-hidden />, label: "内容" },
|
||||
{ key: "/admin/products/", icon: <Package size={16} aria-hidden />, label: "产品" },
|
||||
{ key: "/admin/orders/", icon: <ShoppingCart size={16} aria-hidden />, label: "订单" },
|
||||
{ key: "/admin/inquiries/", icon: <Inbox size={16} aria-hidden />, label: "询盘" },
|
||||
{ key: "/admin/media/", icon: <ImageIcon size={16} aria-hidden />, label: "媒体" },
|
||||
{ key: "/admin/languages/", icon: <Languages size={16} aria-hidden />, label: "语言" },
|
||||
{ key: "/admin/seo/", icon: <RouteIcon size={16} aria-hidden />, label: "SEO / 重定向" },
|
||||
{ key: "/admin/filters/", icon: <SlidersHorizontal size={16} aria-hidden />, label: "筛选" },
|
||||
{ key: "/admin/users/", icon: <Settings size={16} aria-hidden />, label: "系统设置" },
|
||||
] as const;
|
||||
|
||||
/** # 类型 合法菜单路径(用于类型安全的导航) **/
|
||||
type MenuKey = (typeof MENU_ITEMS)[number]["key"];
|
||||
|
||||
/** # 类型 拥有后台权限的角色 **/
|
||||
const ADMIN_ROLES = ["admin", "editor", "manager"];
|
||||
|
||||
export const Route = createFileRoute("/admin")({
|
||||
loader: () => getCurrentUser(),
|
||||
component: AdminLayout,
|
||||
// AntD 烘焙静态样式(scripts/gen-antd-css.tsx 生成到 public/):
|
||||
// 用 <link> 而非 JS import —— dev 下 vite 会把 import 的 CSS 注入成 <style> 标签,
|
||||
// 且 dev 预载全部路由模块会把它泄漏到前台每个页面(800KB style 标签导致 F12 卡死)。
|
||||
// <link> 在 head 中渲染阻塞加载,SSR 首屏即有完整 AntD 样式,消除无样式闪烁。
|
||||
head: () => ({
|
||||
links: [{ rel: "stylesheet", href: "/antd-baked.css" }],
|
||||
}),
|
||||
});
|
||||
|
||||
/** # 函数 后台布局组件
|
||||
* 未登录 / 角色无后台权限 → 渲染全屏登录页;已授权 → 正常布局。
|
||||
*/
|
||||
function AdminLayout() {
|
||||
const user = Route.useLoaderData();
|
||||
const router = useRouter();
|
||||
|
||||
/** # 函数 登录成功回调:刷新路由数据(loader 重新取用户,进入布局) */
|
||||
async function handleLoginSuccess() {
|
||||
await router.invalidate();
|
||||
}
|
||||
|
||||
if (!user || !ADMIN_ROLES.includes(user.role)) {
|
||||
return <AdminLoginPage noPermission={!!user} onOk={handleLoginSuccess} />;
|
||||
}
|
||||
|
||||
return <AdminShell user={user} />;
|
||||
}
|
||||
|
||||
/** # 组件 后台登录页(全屏居中卡片,邮箱 + 密码) */
|
||||
function AdminLoginPage(props: { noPermission: boolean; onOk: () => Promise<void> }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
/** # 函数 登录提交:调用 loginFn,成功回调 onOk,失败展示错误 */
|
||||
async function handleFinish(values: { email: string; password: string }) {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await loginFn({ data: { email: values.email, password: values.password } });
|
||||
if (!res.ok) setError(res.error);
|
||||
else await props.onOk();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "登录失败,请稍后重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={{ token: { colorPrimary: "#1677ff" }, cssVar: { key: "dusun" } }}>
|
||||
{/* hashPriority=high:运行时生成的选择器不带哈希前缀,与烘焙 CSS(antd-baked.css)保持一致 */}
|
||||
<StyleProvider hashPriority="high">
|
||||
<App>
|
||||
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "linear-gradient(135deg, #f5f8fb 0%, #e8eef5 100%)" }}>
|
||||
<Card style={{ width: 380, boxShadow: "0 8px 32px rgba(16, 42, 76, 0.12)" }}>
|
||||
<div style={{ textAlign: "center", marginBottom: 24 }}>
|
||||
<LockKeyhole size={34} aria-hidden color="#1677ff" style={{ marginBottom: 10 }} />
|
||||
<h2 style={{ margin: 0, fontSize: 20 }}>DusunIoT 后台</h2>
|
||||
<p style={{ margin: "6px 0 0", color: "#8c8c8c", fontSize: 13 }}>
|
||||
{props.noPermission ? "当前账号无后台权限,请更换管理员账号" : "请使用管理员账号登录"}
|
||||
</p>
|
||||
</div>
|
||||
<Form layout="vertical" onFinish={(v) => void handleFinish(v as { email: string; password: string })} requiredMark={false}>
|
||||
<Form.Item name="email" label="邮箱" rules={[{ required: true, message: "请输入邮箱" }]}>
|
||||
<Input type="email" placeholder="admin@example.com" autoComplete="email" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="密码" rules={[{ required: true, message: "请输入密码" }]}>
|
||||
<Input.Password placeholder="密码" autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
{error ? (
|
||||
<div role="alert" style={{ color: "#cf1322", background: "#fff1f0", border: "1px solid #ffa39e", borderRadius: 6, padding: "8px 12px", marginBottom: 16, fontSize: 13 }}>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||
登录
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
</App>
|
||||
</StyleProvider>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 函数 后台已授权布局外壳
|
||||
* 顶部 Header 展示当前用户与退出按钮;左侧菜单按当前路径高亮;Outlet 渲染子页面。
|
||||
* 注意:App.useApp() 必须在 <App> 内部的子组件中调用,故用户区拆为 UserBadge。
|
||||
*/
|
||||
function AdminShell({ user }: { user: { displayName: string | null; email: string; role: string } }) {
|
||||
const navigate = useNavigate();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||
|
||||
/** # 成员变量 当前应高亮的菜单 key:取最长匹配的菜单路径 **/
|
||||
const selectedKey = MENU_ITEMS.map((m) => m.key)
|
||||
.filter((k) => pathname === k || pathname.startsWith(k + "/"))
|
||||
.sort((a, b) => b.length - a.length)[0] ?? "/admin/";
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={{ token: { colorPrimary: "#1677ff" }, cssVar: { key: "dusun" } }}>
|
||||
<StyleProvider hashPriority="high">
|
||||
<App>
|
||||
<Layout style={{ minHeight: "100vh" }}>
|
||||
<Layout.Sider collapsible collapsed={collapsed} onCollapse={setCollapsed} width={200}>
|
||||
<div style={{ height: 48, display: "flex", alignItems: "center", justifyContent: "center", color: "#fff", fontWeight: 600, fontSize: 16, letterSpacing: 1 }}>
|
||||
{collapsed ? "D" : "DusunIoT 后台"}
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
items={MENU_ITEMS.map((m) => ({ key: m.key, icon: m.icon, label: m.label }))}
|
||||
selectedKeys={[selectedKey]}
|
||||
onClick={({ key }) => void navigate({ to: key as MenuKey })}
|
||||
/>
|
||||
</Layout.Sider>
|
||||
<Layout>
|
||||
<Layout.Header style={{ background: "#fff", display: "flex", justifyContent: "flex-end", alignItems: "center", paddingInline: 24, borderBottom: "1px solid #f0f0f0" }}>
|
||||
<UserBadge user={user} />
|
||||
</Layout.Header>
|
||||
<Layout.Content style={{ margin: 16 }}>
|
||||
<Outlet />
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</App>
|
||||
</StyleProvider>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 组件 顶部用户区(在 <App> 内渲染,可安全使用 App.useApp 的 message) */
|
||||
function UserBadge({ user }: { user: { displayName: string | null; email: string; role: string } }) {
|
||||
const navigate = useNavigate();
|
||||
const router = useRouter();
|
||||
const { message } = App.useApp();
|
||||
|
||||
/** # 函数 退出登录:注销会话 → 刷新路由数据 → 回到 /admin */
|
||||
async function handleLogout() {
|
||||
await logoutFn();
|
||||
message.success("已退出登录");
|
||||
await router.invalidate();
|
||||
void navigate({ to: "/admin/" });
|
||||
}
|
||||
|
||||
return (
|
||||
<Space size={12}>
|
||||
<Avatar size="small" icon={<UserRound size={14} aria-hidden />} />
|
||||
<span>{user.displayName || user.email}</span>
|
||||
<Tag color={user.role === "admin" ? "blue" : "default"}>{user.role}</Tag>
|
||||
<Button size="small" icon={<LogOut size={14} aria-label="退出登录" />} onClick={() => void handleLogout()}>
|
||||
退出
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* # 路由 /admin/users 用户管理(系统设置,只读)
|
||||
* 用户列表:分页 + 关键字搜索;角色用 Tag 展示;不做编辑操作。
|
||||
*/
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Card, Input, Table, Tag } from "antd";
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import { useState } from "react";
|
||||
import { adminListUsers } from "~/server/admin";
|
||||
import { AdminEmpty, Forbidden, PageHeader, fmtDateTime, guardAdmin, tablePagination, type AdminSearch } from "~/components/admin/common";
|
||||
|
||||
export const Route = createFileRoute("/admin/users")({
|
||||
validateSearch: (search: Record<string, unknown>): AdminSearch => {
|
||||
// 只保留显式传入的参数,缺省时不回写 URL(避免 307 归一化重定向)
|
||||
const out: AdminSearch = {};
|
||||
const page = Math.floor(Number(search.page)) || 0;
|
||||
if (page > 1) out.page = page;
|
||||
if (typeof search.q === "string" && search.q) out.q = search.q;
|
||||
return out;
|
||||
},
|
||||
loaderDeps: ({ search }) => ({ search }),
|
||||
loader: ({ deps }) => guardAdmin(() => adminListUsers({ data: { page: deps.search.page, q: deps.search.q || undefined } })),
|
||||
component: UsersPage,
|
||||
});
|
||||
|
||||
/** # 成员变量 角色中文标签与配色 **/
|
||||
const ROLE_LABEL: Record<string, string> = { admin: "管理员", editor: "编辑", manager: "经理", customer: "客户" };
|
||||
const ROLE_COLOR: Record<string, string> = { admin: "blue", editor: "cyan", manager: "geekblue", customer: "default" };
|
||||
|
||||
/** # 函数 用户列表页面组件 */
|
||||
function UsersPage() {
|
||||
const data = Route.useLoaderData();
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
const [q, setQ] = useState(search.q ?? "");
|
||||
|
||||
if (!data) return <Forbidden />;
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<PageHeader
|
||||
title="用户管理"
|
||||
extra={
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="搜索邮箱 / 显示名"
|
||||
prefix={<SearchOutlined aria-hidden />}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onPressEnter={() => void navigate({ search: (prev) => ({ ...prev, q, page: 1 }) })}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={data.items}
|
||||
locale={{ emptyText: <AdminEmpty /> }}
|
||||
pagination={tablePagination(data.total, data.page, data.pageSize, (p) =>
|
||||
void navigate({ search: (prev) => ({ ...prev, page: p }) }),
|
||||
)}
|
||||
columns={[
|
||||
{ title: "ID", dataIndex: "id", width: 80 },
|
||||
{ title: "邮箱", dataIndex: "email", width: 260, ellipsis: true },
|
||||
{ title: "用户名", dataIndex: "username", width: 160, ellipsis: true },
|
||||
{ title: "显示名", dataIndex: "displayName", width: 180, ellipsis: true },
|
||||
{
|
||||
title: "角色",
|
||||
dataIndex: "role",
|
||||
width: 110,
|
||||
render: (v: string) => <Tag color={ROLE_COLOR[v] ?? "default"}>{ROLE_LABEL[v] ?? v}</Tag>,
|
||||
},
|
||||
{ title: "注册时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* # 路由 文章详情 /blog/$slug/
|
||||
* 原站模板形态:左主栏(白卡正文 + 目录)+ 右侧栏(社交图标 / CONTACT US 表单 /
|
||||
* IoT Gateways 产品分组折叠菜单)+ 底部 Related Posts;Elementor 文章通栏直出。
|
||||
*/
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
|
||||
import { InquiryForm } from "~/components/website/InquiryForm";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "~/components/ui/accordion";
|
||||
import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from "~/components/ui/breadcrumb";
|
||||
import { getPublishedContent, getMenuTree, listPosts, type MenuNode, type PostListItem } from "~/server/data";
|
||||
import { useT } from "~/i18n";
|
||||
|
||||
/** # 组件 社交品牌图标(lucide 新版已移除品牌图标,内联 SVG) */
|
||||
function BrandIcon(props: { label: string; bg: string; path: string }) {
|
||||
return (
|
||||
<a href="#" aria-label={props.label} className="flex h-9 w-9 items-center justify-center rounded text-white" style={{ background: props.bg }}>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden>
|
||||
<path d={props.path} />
|
||||
</svg>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/blog/$slug")({
|
||||
loader: async ({ params }) => {
|
||||
const [layout, post, latest, productMenu] = await Promise.all([
|
||||
getLayoutData(),
|
||||
getPublishedContent(params.slug, "post").catch(() => null),
|
||||
listPosts(1, 6).catch(() => ({ items: [] as PostListItem[], total: 0, page: 1, pageSize: 6 })),
|
||||
getMenuTree("menu-1").catch(() => [] as MenuNode[]),
|
||||
]);
|
||||
// 侧栏 IoT Gateways 折叠组 = 主菜单 Products 分支的子分组(By Protocols / Applications ...)
|
||||
const gwGroups = productMenu.find((n) => n.title.trim().toLowerCase() === "products")?.children ?? [];
|
||||
if (!post) return { layout, post: null, related: [] as PostListItem[], gwGroups };
|
||||
// meta 收窄为具体 JSON 类型
|
||||
const meta = (post.meta ?? null) as Record<string, unknown> | null;
|
||||
const terms = post.terms.map((t) => ({ name: t.name, slug: t.slug, taxonomy: t.taxonomy }));
|
||||
const related = latest.items.filter((p) => p.slug !== params.slug).slice(0, 4);
|
||||
return { layout, post: { ...post, meta, terms }, related, gwGroups };
|
||||
},
|
||||
head: ({ loaderData }) => ({
|
||||
meta: [
|
||||
{ title: loaderData?.post ? loaderData.post.seoTitle || `${loaderData.post.title} | Dusun IoT Blog` : "Post Not Found | Dusun IoT" },
|
||||
{ name: "description", content: loaderData?.post?.seoDescription || loaderData?.post?.excerpt || "Blog post from Dusun IoT." },
|
||||
],
|
||||
}),
|
||||
component: BlogPostPage,
|
||||
});
|
||||
|
||||
/** # 组件 文章详情页 */
|
||||
function BlogPostPage() {
|
||||
const t = useT();
|
||||
const data = Route.useLoaderData();
|
||||
if (!data.post) {
|
||||
return (
|
||||
<Layout layout={data.layout}>
|
||||
<div className="pt-[70px] pb-10 text-center">
|
||||
<h1 className="m-0 text-[64px] text-brand">404</h1>
|
||||
<p>{t("blog_not_found")}</p>
|
||||
<Button asChild>
|
||||
<Link to="/blog/">{t("blog_back")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
const post = data.post;
|
||||
const cats = post.terms.filter((t) => t.taxonomy === "category");
|
||||
const elementor = extractElementor(post.meta);
|
||||
// Elementor 文章(原站 full-canvas 模板):正文通栏直出,不加面包屑/h1/日期
|
||||
if (elementor) {
|
||||
return (
|
||||
<Layout layout={data.layout}>
|
||||
<ElementorRenderer doc={elementor} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
// HTML 文章:从正文提取 h2/h3 生成目录(并注入锚点 id)
|
||||
const toc = post.html ? buildToc(post.html) : null;
|
||||
return (
|
||||
<Layout layout={data.layout}>
|
||||
<div className="site-container">
|
||||
<Breadcrumb aria-label="Breadcrumb">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem><BreadcrumbLink to="/">Home</BreadcrumbLink></BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem><BreadcrumbLink to="/blog/">Blog</BreadcrumbLink></BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem><BreadcrumbPage>{post.title}</BreadcrumbPage></BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="grid grid-cols-1 items-start gap-7 min-[1080px]:grid-cols-[1fr_340px]">
|
||||
{/* 主栏:白卡正文(目录 + 内容) */}
|
||||
<article className="rounded-lg bg-white p-6 min-[760px]:p-9">
|
||||
<h1 className="mt-0 text-[30px] leading-[1.35]">{post.title}</h1>
|
||||
<div className="mb-5 text-[13px] text-muted-foreground">
|
||||
{post.publishedAt ? new Date(post.publishedAt).toISOString().slice(0, 10) : ""}
|
||||
{cats.length > 0 ? (
|
||||
<>
|
||||
{" · "}
|
||||
{cats.map((c, i) => (
|
||||
<span key={c.slug}>
|
||||
{i > 0 ? ", " : ""}
|
||||
{c.name}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{post.featuredUrl ? (
|
||||
<img src={post.featuredUrl} alt={post.featuredAlt || post.title} className="my-3 mb-5 w-full rounded-lg" />
|
||||
) : null}
|
||||
{toc && toc.items.length >= 3 ? (
|
||||
<Accordion type="single" collapsible defaultValue="toc" className="mb-6 rounded-md border px-4">
|
||||
<AccordionItem value="toc" className="border-b">
|
||||
<AccordionTrigger>{t("blog_toc")}</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<ul className="m-0 list-none p-0">
|
||||
{toc.items.map((it) => (
|
||||
<li key={it.id} className={it.level === 3 ? "ml-5" : ""}>
|
||||
<a href={`#${it.id}`} className="block py-1 text-[14px] text-brand hover:underline">
|
||||
{it.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
) : null}
|
||||
{elementor ? (
|
||||
<ElementorRenderer doc={elementor} />
|
||||
) : post.html ? (
|
||||
<div className="content-html" dangerouslySetInnerHTML={{ __html: toc?.html || post.html }} />
|
||||
) : (
|
||||
<p className="text-muted-foreground">{post.excerpt || t("blog_importing")}</p>
|
||||
)}
|
||||
</article>
|
||||
|
||||
{/* 侧栏:社交图标 / CONTACT US 表单 / IoT Gateways 产品分组 */}
|
||||
<aside className="space-y-5">
|
||||
<div className="flex justify-center gap-3 rounded-lg bg-white p-4">
|
||||
<BrandIcon label="LinkedIn" bg="#0a66c2" path="M20.45 20.45h-3.55v-5.57c0-1.33-.03-3.04-1.85-3.04-1.86 0-2.14 1.45-2.14 2.94v5.67H9.35V9h3.41v1.56h.05c.47-.9 1.63-1.85 3.36-1.85 3.6 0 4.27 2.37 4.27 5.45v6.29zM5.34 7.43a2.06 2.06 0 1 1 0-4.12 2.06 2.06 0 0 1 0 4.12zM7.12 20.45H3.56V9h3.56v11.45z" />
|
||||
<BrandIcon label="YouTube" bg="#ff0000" path="M23.5 6.19a3.02 3.02 0 0 0-2.12-2.14C19.5 3.55 12 3.55 12 3.55s-7.5 0-9.38.5A3.02 3.02 0 0 0 .5 6.19C0 8.07 0 12 0 12s0 3.93.5 5.81a3.02 3.02 0 0 0 2.12 2.14c1.88.5 9.38.5 9.38.5s7.5 0 9.38-.5a3.02 3.02 0 0 0 2.12-2.14C24 15.93 24 12 24 12s0-3.93-.5-5.81zM9.55 15.57V8.43L15.82 12l-6.27 3.57z" />
|
||||
<BrandIcon label="Facebook" bg="#1877f2" path="M24 12.07C24 5.4 18.63 0 12 0S0 5.4 0 12.07C0 18.1 4.39 23.09 10.13 24v-8.44H7.08v-3.49h3.05V9.41c0-3.02 1.79-4.7 4.53-4.7 1.31 0 2.68.24 2.68.24v2.97h-1.51c-1.49 0-1.95.93-1.95 1.89v2.26h3.32l-.53 3.49h-2.79V24C19.61 23.09 24 18.1 24 12.07z" />
|
||||
</div>
|
||||
<div className="rounded-lg bg-white p-5">
|
||||
<InquiryForm formKey="contact-us-form-sidebar" productInterest={post.title} title={t("blog_contact_title")} />
|
||||
</div>
|
||||
{data.gwGroups.length > 0 ? (
|
||||
<div className="rounded-lg bg-white p-5">
|
||||
<h3 className="mt-0 text-[20px] font-bold text-brand">{t("blog_gateways")}</h3>
|
||||
<Accordion type="multiple" className="border-t">
|
||||
{data.gwGroups.map((g) => (
|
||||
<AccordionItem key={g.id} value={`gw-${g.id}`}>
|
||||
<AccordionTrigger className="text-[15px] font-medium">{g.title}</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<ul className="m-0 list-none p-0">
|
||||
{g.children.map((c) => (
|
||||
<li key={c.id} className="py-1">
|
||||
<a href={c.url} className="text-[14px] text-muted-foreground hover:text-brand hover:underline">{c.title}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* 底部 Related Posts(原站:4 列卡片,灰渐变占位 + 标题) */}
|
||||
{data.related.length > 0 ? (
|
||||
<section className="my-12">
|
||||
<h2 className="mb-6 text-center text-[26px]">{t("blog_related_posts")}</h2>
|
||||
<div className="grid grid-cols-2 gap-5 min-[960px]:grid-cols-4">
|
||||
{data.related.map((p) => (
|
||||
<Link key={p.id} to="/blog/$slug/" params={{ slug: p.slug }} className="block">
|
||||
<div className="mb-3 aspect-[4/3] overflow-hidden rounded-md bg-gradient-to-b from-[#9aa3ad] to-[#c9ced4]">
|
||||
{p.imageUrl ? <img src={p.imageUrl} alt={p.title} loading="lazy" className="h-full w-full object-cover" /> : null}
|
||||
</div>
|
||||
<div className="text-[15px] leading-[1.45] font-medium hover:text-brand">{p.title}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 函数 从 HTML 提取 h2/h3 生成目录条目,并给标题注入锚点 id
|
||||
* 参数 html 文章正文 HTML
|
||||
* 返回值 items 目录条目(level/text/id);html 注入 id 后的正文
|
||||
*/
|
||||
function buildToc(html: string): { items: { level: number; text: string; id: string }[]; html: string } {
|
||||
const items: { level: number; text: string; id: string }[] = [];
|
||||
let i = 0;
|
||||
const out = html.replace(/<h([23])([^>]*)>([\s\S]*?)<\/h\1>/gi, (m, lvl: string, attrs: string, inner: string) => {
|
||||
const text = inner.replace(/<[^>]*>/g, "").trim();
|
||||
if (!text || items.length >= 40) return m;
|
||||
const id = `toc-${i++}`;
|
||||
items.push({ level: Number(lvl), text, id });
|
||||
return `<h${lvl} id="${id}"${attrs}>${inner}</h${lvl}>`;
|
||||
});
|
||||
return { items, html: out };
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* # 路由 博客列表 /blog/
|
||||
* 文章卡片分页列表(图/标题/摘要/日期)。
|
||||
*/
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||
import { Pagination } from "~/components/website/Pagination";
|
||||
import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from "~/components/ui/breadcrumb";
|
||||
import { listPosts, getPostCategories } from "~/server/data";
|
||||
import { useT } from "~/i18n";
|
||||
|
||||
export const Route = createFileRoute("/blog/")({
|
||||
validateSearch: (search: Record<string, unknown>): { page?: number } => {
|
||||
const page = Number(search.page);
|
||||
return { page: Number.isFinite(page) && page > 0 ? Math.floor(page) : undefined };
|
||||
},
|
||||
loaderDeps: ({ search }) => ({ page: search.page }),
|
||||
loader: async ({ deps }) => {
|
||||
const [layout, list, cats] = await Promise.all([
|
||||
getLayoutData(),
|
||||
listPosts(deps.page || 1, 12).catch(() => ({ items: [], total: 0, page: 1, pageSize: 12 })),
|
||||
getPostCategories().catch(() => []),
|
||||
]);
|
||||
return { layout, list, cats };
|
||||
},
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "Blog | IoT Knowledge & News | Dusun IoT" },
|
||||
{ name: "description", content: "IoT guides, industry insights, product news and tutorials from Dusun IoT experts." },
|
||||
],
|
||||
}),
|
||||
component: BlogIndexPage,
|
||||
});
|
||||
|
||||
/** # 组件 博客列表页 */
|
||||
function BlogIndexPage() {
|
||||
const t = useT();
|
||||
const data = Route.useLoaderData();
|
||||
const page = data.list.page;
|
||||
return (
|
||||
<Layout layout={data.layout}>
|
||||
<div className="bg-[#f5f5f6] px-4 pt-[34px] pb-[30px] text-center">
|
||||
<h1 className="m-0 text-[34px] tracking-wide">BLOG</h1>
|
||||
</div>
|
||||
<div className="site-container">
|
||||
<Breadcrumb aria-label="Breadcrumb">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem><BreadcrumbLink to="/">Home</BreadcrumbLink></BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem><BreadcrumbPage>{t("nav_blog")}</BreadcrumbPage></BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
{data.cats.length > 0 ? (
|
||||
<div className="mt-3 mb-6 flex flex-wrap gap-2.5">
|
||||
{data.cats
|
||||
.filter((c) => Number(c.count) > 0)
|
||||
.slice(0, 12)
|
||||
.map((c) => (
|
||||
<span key={c.slug} className="inline-block rounded-[4px] border border-brand bg-white px-3 py-[5px] text-[13px] font-semibold text-brand">
|
||||
{c.name} ({Number(c.count)})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid grid-cols-1 gap-5 min-[960px]:grid-cols-3">
|
||||
{data.list.items.map((post) => (
|
||||
<Link key={post.id} to="/blog/$slug/" params={{ slug: post.slug }} className="block overflow-hidden rounded-lg border border-line bg-white text-brand transition-[box-shadow,transform] duration-200 hover:-translate-y-0.5 hover:shadow-[0_8px_24px_rgba(16,42,76,0.1)]">
|
||||
{/* 原站卡片:无特色图时显示灰色渐变占位 + 右上角分类徽章 */}
|
||||
<div className="relative aspect-[4/3] overflow-hidden">
|
||||
{post.imageUrl ? (
|
||||
<img src={post.imageUrl} alt={post.title} loading="lazy" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="h-full w-full bg-gradient-to-b from-[#9aa3ad] to-[#c9ced4]" />
|
||||
)}
|
||||
<span className="absolute top-3 right-3 rounded-full bg-brand px-3 py-1 text-xs font-semibold text-white">
|
||||
{post.categoryName || "Blog"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 pt-3.5 pb-[18px]">
|
||||
<div className="mb-1.5 text-[15px] leading-[1.45] font-semibold">{post.title}</div>
|
||||
{post.excerpt ? <p className="my-1.5 text-sm text-muted-foreground">{post.excerpt.slice(0, 140)}…</p> : null}
|
||||
<div className="text-[13px] text-muted-foreground">
|
||||
{post.authorName ? `${post.authorName} · ` : ""}
|
||||
{post.publishedAt ? new Date(post.publishedAt).toISOString().slice(0, 10) : ""}
|
||||
</div>
|
||||
<div className="mt-2.5 text-[13px] font-bold">{t("blog_read_more")}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{data.list.items.length === 0 ? <p className="py-[60px] text-center text-muted-foreground">{t("blog_no_posts")}</p> : null}
|
||||
</div>
|
||||
<Pagination page={page} total={data.list.total} pageSize={data.list.pageSize} makeHref={(p) => `/blog/?page=${p}`} />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* # 路由 /cart 购物车页
|
||||
* client loader 读取购物车;数量增减 / 删除通过 server function + router.invalidate() 刷新。
|
||||
*/
|
||||
import { createFileRoute, Link, useRouter } from "@tanstack/react-router";
|
||||
import { getCart, updateCartItem, removeCartItem } from "~/server/commerce";
|
||||
import { useT } from "~/i18n";
|
||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
export const Route = createFileRoute("/cart")({
|
||||
loader: async () => {
|
||||
const [layout, cart] = await Promise.all([getLayoutData(), getCart()]);
|
||||
return { layout, cart };
|
||||
},
|
||||
head: () => ({ meta: [{ title: "Shopping Cart - DusunIoT" }] }),
|
||||
component: CartPage,
|
||||
});
|
||||
|
||||
/** # 函数 组件 购物车页面 */
|
||||
function CartPage() {
|
||||
const t = useT();
|
||||
const { layout, cart } = Route.useLoaderData();
|
||||
const router = useRouter();
|
||||
|
||||
/** # 函数 修改数量(qty<=0 删除) */
|
||||
async function changeQty(itemId: number, qty: number) {
|
||||
await updateCartItem({ data: { itemId, qty } });
|
||||
router.invalidate();
|
||||
}
|
||||
|
||||
/** # 函数 删除明细 */
|
||||
async function handleRemove(itemId: number) {
|
||||
await removeCartItem({ data: { itemId } });
|
||||
router.invalidate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout layout={layout}>
|
||||
<div className="site-container">
|
||||
<h1 className="mt-5 mb-3.5 text-[26px]">Shopping Cart</h1>
|
||||
{cart.items.length === 0 ? (
|
||||
<div className="py-[60px] text-center text-muted-foreground">
|
||||
<p>{t("cart_empty")}</p>
|
||||
<Button asChild>
|
||||
<Link to="/shop/">{t("cart_continue")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full border-collapse bg-white text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border-b border-line bg-soft px-2.5 py-3 text-left align-middle font-semibold whitespace-nowrap">Product</th>
|
||||
<th className="border-b border-line bg-soft px-2.5 py-3 text-left align-middle font-semibold whitespace-nowrap">Price</th>
|
||||
<th className="border-b border-line bg-soft px-2.5 py-3 text-left align-middle font-semibold whitespace-nowrap">Quantity</th>
|
||||
<th className="border-b border-line bg-soft px-2.5 py-3 text-left align-middle font-semibold whitespace-nowrap">Subtotal</th>
|
||||
<th className="border-b border-line bg-soft px-2.5 py-3 text-left align-middle font-semibold whitespace-nowrap"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cart.items.map((line) => (
|
||||
<tr key={line.id}>
|
||||
<td className="border-b border-line px-2.5 py-3 text-left align-middle">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={line.imageUrl ?? "/uploads/woocommerce-placeholder.png"} alt={line.title} className="w-14 rounded-md border border-line bg-white object-contain p-[3px]" />
|
||||
<Link to="/product/$slug/" params={{ slug: line.slug }}>
|
||||
{line.title}
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
<td className="border-b border-line px-2.5 py-3 text-left align-middle">${Number(line.price).toFixed(2)}</td>
|
||||
<td className="border-b border-line px-2.5 py-3 text-left align-middle">
|
||||
<span className="inline-flex items-center overflow-hidden rounded-[4px] border border-line">
|
||||
<button onClick={() => changeQty(line.id, line.qty - 1)} aria-label="Decrease" type="button" className="h-8 w-[30px] cursor-pointer border-0 bg-soft text-base text-ink hover:bg-line">−</button>
|
||||
<span className="min-w-10 text-center text-sm">{line.qty}</span>
|
||||
<button onClick={() => changeQty(line.id, line.qty + 1)} aria-label="Increase" type="button" className="h-8 w-[30px] cursor-pointer border-0 bg-soft text-base text-ink hover:bg-line">+</button>
|
||||
</span>
|
||||
</td>
|
||||
<td className="border-b border-line px-2.5 py-3 text-left align-middle">${(Number(line.price) * line.qty).toFixed(2)}</td>
|
||||
<td className="border-b border-line px-2.5 py-3 text-left align-middle">
|
||||
<button className="cursor-pointer border-0 bg-none text-[13px] text-muted-foreground underline hover:text-[#b91c1c]" onClick={() => handleRemove(line.id)} type="button">
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-[18px] flex flex-wrap items-center justify-end gap-[18px]">
|
||||
<span className="text-[17px] font-bold">Subtotal: ${cart.subtotal}</span>
|
||||
<Button asChild>
|
||||
<Link to="/checkout/">{t("cart_checkout")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* # 路由 /checkout 结账页
|
||||
* 未登录:内联登录 / 注册表单(复用 ~/server/auth 的 loginFn/registerFn)。
|
||||
* 已登录:账单信息表单 → createOrder(银行转账占位支付)→ 成功页展示订单号。
|
||||
*/
|
||||
import { createFileRoute, Link, useRouter } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { getCart, createOrder, type BillingInfo } from "~/server/commerce";
|
||||
import { useT } from "~/i18n";
|
||||
import { getCurrentUser, loginFn, registerFn } from "~/server/auth";
|
||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
|
||||
export const Route = createFileRoute("/checkout")({
|
||||
loader: async () => {
|
||||
const [layout, user, cart] = await Promise.all([getLayoutData(), getCurrentUser(), getCart()]);
|
||||
return { layout, user, cart };
|
||||
},
|
||||
head: () => ({ meta: [{ title: "Checkout - DusunIoT" }] }),
|
||||
component: CheckoutPage,
|
||||
});
|
||||
|
||||
/** # 函数 组件 结账页面(外壳:公共布局) */
|
||||
function CheckoutPage() {
|
||||
const { layout } = Route.useLoaderData();
|
||||
return (
|
||||
<Layout layout={layout}>
|
||||
<CheckoutInner />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 函数 组件 结账表单区 */
|
||||
function CheckoutInner() {
|
||||
const t = useT();
|
||||
const { user, cart } = Route.useLoaderData();
|
||||
const router = useRouter();
|
||||
const [authError, setAuthError] = useState("");
|
||||
const [orderNumber, setOrderNumber] = useState("");
|
||||
const [orderError, setOrderError] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
/** # 函数 登录提交(成功后 invalidate 以带会话重跑 loader) */
|
||||
async function handleLogin(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setAuthError("");
|
||||
const fd = new FormData(e.currentTarget);
|
||||
const res = await loginFn({ data: { email: String(fd.get("email") ?? ""), password: String(fd.get("password") ?? "") } });
|
||||
if (!res.ok) return setAuthError(res.error);
|
||||
router.invalidate();
|
||||
}
|
||||
|
||||
/** # 函数 注册提交 */
|
||||
async function handleRegister(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setAuthError("");
|
||||
const fd = new FormData(e.currentTarget);
|
||||
const res = await registerFn({
|
||||
data: {
|
||||
email: String(fd.get("email") ?? ""),
|
||||
password: String(fd.get("password") ?? ""),
|
||||
displayName: String(fd.get("displayName") ?? "") || undefined,
|
||||
},
|
||||
});
|
||||
if (!res.ok) return setAuthError(res.error);
|
||||
router.invalidate();
|
||||
}
|
||||
|
||||
/** # 函数 下单提交 */
|
||||
async function handlePlaceOrder(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setOrderError("");
|
||||
setSubmitting(true);
|
||||
const fd = new FormData(e.currentTarget);
|
||||
const billing: BillingInfo = {
|
||||
firstName: String(fd.get("firstName") ?? ""),
|
||||
lastName: String(fd.get("lastName") ?? ""),
|
||||
email: String(fd.get("email") ?? ""),
|
||||
phone: String(fd.get("phone") ?? ""),
|
||||
address: String(fd.get("address") ?? ""),
|
||||
city: String(fd.get("city") ?? ""),
|
||||
country: String(fd.get("country") ?? ""),
|
||||
postcode: String(fd.get("postcode") ?? ""),
|
||||
};
|
||||
const shippingSame = fd.get("shippingSame") === "on";
|
||||
const shipping = shippingSame
|
||||
? undefined
|
||||
: {
|
||||
firstName: String(fd.get("shipFirstName") ?? ""),
|
||||
lastName: String(fd.get("shipLastName") ?? ""),
|
||||
email: billing.email,
|
||||
address: String(fd.get("shipAddress") ?? ""),
|
||||
city: String(fd.get("shipCity") ?? ""),
|
||||
country: String(fd.get("shipCountry") ?? ""),
|
||||
postcode: String(fd.get("shipPostcode") ?? ""),
|
||||
};
|
||||
const res = await createOrder({
|
||||
data: { billing, shippingSame, shipping, note: String(fd.get("note") ?? "") },
|
||||
});
|
||||
setSubmitting(false);
|
||||
if (!res.ok) return setOrderError(res.error);
|
||||
setOrderNumber(res.orderNumber);
|
||||
router.invalidate();
|
||||
}
|
||||
|
||||
// 下单成功页
|
||||
if (orderNumber) {
|
||||
return (
|
||||
<div className="site-container">
|
||||
<h1 className="mt-5 mb-3.5 text-[26px]">Order received</h1>
|
||||
<div className="max-w-[560px] rounded-md border border-[#b5e3c4] bg-[#e7f6ec] px-4 py-3 text-[#15803d]">
|
||||
Thank you! Your order <strong>{orderNumber}</strong> has been placed.
|
||||
</div>
|
||||
<p className="mt-4">
|
||||
Payment method: bank transfer (our sales team will contact you with payment instructions).
|
||||
</p>
|
||||
<p className="flex gap-2.5">
|
||||
<Button asChild>
|
||||
<Link to="/my-account/">{t("checkout_view_orders")}</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link to="/shop/">{t("cart_continue")}</Link>
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 未登录:内联登录 / 注册
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="site-container">
|
||||
<h1 className="mt-5 mb-3.5 text-[26px]">Checkout</h1>
|
||||
<p>
|
||||
Please log in or create an account to place your order.{" "}
|
||||
{cart.count > 0 && <>{t("checkout_cart_kept", { count: cart.count })}</>}
|
||||
</p>
|
||||
{authError && (
|
||||
<p className="max-w-[420px] rounded-md border border-[#f3c1c1] bg-[#fdeaea] px-4 py-3 text-[#b91c1c]">
|
||||
{authError}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 grid grid-cols-1 items-start gap-7 min-[860px]:grid-cols-[minmax(0,7fr)_minmax(0,4fr)]">
|
||||
<div className="rounded-lg border border-line bg-soft p-5">
|
||||
<h3 className="mt-0">Log in</h3>
|
||||
<form className="grid grid-cols-1 gap-3.5 min-[640px]:grid-cols-2" onSubmit={handleLogin}>
|
||||
<Label className="col-span-full">
|
||||
Email
|
||||
<Input name="email" type="email" required autoComplete="email" />
|
||||
</Label>
|
||||
<Label className="col-span-full">
|
||||
Password
|
||||
<Input name="password" type="password" required autoComplete="current-password" />
|
||||
</Label>
|
||||
<div className="col-span-full">
|
||||
<Button type="submit">
|
||||
Log in
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="rounded-lg border border-line bg-soft p-5">
|
||||
<h3 className="mt-0">Create account</h3>
|
||||
<form className="grid grid-cols-1 gap-3.5 min-[640px]:grid-cols-2" onSubmit={handleRegister}>
|
||||
<Label className="col-span-full">
|
||||
Display name (optional)
|
||||
<Input name="displayName" type="text" />
|
||||
</Label>
|
||||
<Label className="col-span-full">
|
||||
Email
|
||||
<Input name="email" type="email" required autoComplete="email" />
|
||||
</Label>
|
||||
<Label className="col-span-full">
|
||||
Password (min 8 chars)
|
||||
<Input name="password" type="password" required minLength={8} autoComplete="new-password" />
|
||||
</Label>
|
||||
<div className="col-span-full">
|
||||
<Button type="submit" variant="outline">
|
||||
Register
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 购物车为空
|
||||
if (cart.items.length === 0) {
|
||||
return (
|
||||
<div className="site-container">
|
||||
<h1 className="mt-5 mb-3.5 text-[26px]">Checkout</h1>
|
||||
<div className="py-[60px] text-center text-muted-foreground">
|
||||
<p>{t("cart_empty")}</p>
|
||||
<Button asChild>
|
||||
<Link to="/shop/">{t("checkout_browse")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 已登录:账单表单 + 订单汇总
|
||||
return (
|
||||
<div className="site-container">
|
||||
<h1 className="mt-5 mb-3.5 text-[26px]">Checkout</h1>
|
||||
{orderError ? <p className="rounded-md border border-[#f3c1c1] bg-[#fdeaea] px-4 py-3 text-[#b91c1c]">{orderError}</p> : null}
|
||||
<div className="mt-4 grid grid-cols-1 items-start gap-7 min-[860px]:grid-cols-[minmax(0,7fr)_minmax(0,4fr)]">
|
||||
<form className="grid grid-cols-1 gap-3.5 rounded-lg border border-line bg-soft p-5 min-[640px]:grid-cols-2" onSubmit={handlePlaceOrder}>
|
||||
<h2 className="col-span-full mt-0">Billing details</h2>
|
||||
<Label>
|
||||
First name *
|
||||
<Input name="firstName" required />
|
||||
</Label>
|
||||
<Label>
|
||||
Last name *
|
||||
<Input name="lastName" required />
|
||||
</Label>
|
||||
<Label>
|
||||
Email *
|
||||
<Input name="email" type="email" required defaultValue={user.email} />
|
||||
</Label>
|
||||
<Label>
|
||||
Phone
|
||||
<Input name="phone" type="tel" />
|
||||
</Label>
|
||||
<Label className="col-span-full">
|
||||
Street address *
|
||||
<Input name="address" required />
|
||||
</Label>
|
||||
<Label>
|
||||
City *
|
||||
<Input name="city" required />
|
||||
</Label>
|
||||
<Label>
|
||||
Country *
|
||||
<Input name="country" required />
|
||||
</Label>
|
||||
<Label>
|
||||
Postcode
|
||||
<Input name="postcode" />
|
||||
</Label>
|
||||
<Label className="col-span-full flex-row items-center gap-2">
|
||||
<input name="shippingSame" type="checkbox" defaultChecked />
|
||||
<span>{t("checkout_same_address")}</span>
|
||||
</Label>
|
||||
<Label className="col-span-full">
|
||||
Order notes
|
||||
<Textarea name="note" rows={3} placeholder={t("checkout_notes_ph")} />
|
||||
</Label>
|
||||
<div className="col-span-full">
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? "Placing order…" : "Place order"}
|
||||
</Button>
|
||||
<span className="text-[13px] text-muted-foreground">Payment: bank transfer (pay after confirmation)</span>
|
||||
</div>
|
||||
</form>
|
||||
<div className="rounded-lg border border-line bg-soft p-5">
|
||||
<h3 className="mt-0">Your order</h3>
|
||||
<div className="overflow-hidden rounded-md border border-line bg-white">
|
||||
{cart.items.map((line) => (
|
||||
<div className="flex justify-between gap-3 border-b border-line px-3.5 py-2.5 text-sm" key={line.id}>
|
||||
<span>
|
||||
{line.title} × {line.qty}
|
||||
</span>
|
||||
<span>${(Number(line.price) * line.qty).toFixed(2)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between gap-3 px-3.5 py-2.5 text-sm font-bold">
|
||||
<span>{t("checkout_subtotal")}</span>
|
||||
<span>${cart.subtotal}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* # 路由 产品分类列表 /collection/$slug/
|
||||
* 侧栏:分类树 + 属性筛选(复选框,URL search params 驱动);
|
||||
* 主区:分类标题 + 产品网格 + 分页。
|
||||
*/
|
||||
import { createFileRoute, Link, useRouter } from "@tanstack/react-router";
|
||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||
import { ProductCard } from "~/components/website/ProductCard";
|
||||
import { Pagination } from "~/components/website/Pagination";
|
||||
import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from "~/components/ui/breadcrumb";
|
||||
import { parseCollectionSearch, searchToFilters, filtersToSearch, buildCollectionQuery, type CollectionSearch } from "~/components/website/collection-shared";
|
||||
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
|
||||
import { getProductCategoryTree, getFilterConfig, listProducts, getPublishedContent, type FilterState, type ProductListResult } from "~/server/data";
|
||||
import { useT } from "~/i18n";
|
||||
|
||||
export const Route = createFileRoute("/collection/$slug")({
|
||||
validateSearch: parseCollectionSearch as (search: Record<string, unknown>) => CollectionSearch,
|
||||
// 该版本 loader 拿不到 search,需要 loaderDeps 显式声明依赖
|
||||
loaderDeps: ({ search }) => ({ search }),
|
||||
loader: async ({ params, deps }) => {
|
||||
const [layout, cats, filterConfig] = await Promise.all([
|
||||
getLayoutData(),
|
||||
getProductCategoryTree().catch(() => []),
|
||||
getFilterConfig().catch(() => []),
|
||||
]);
|
||||
const list = await listProducts({
|
||||
category: params.slug,
|
||||
filters: searchToFilters(deps.search),
|
||||
page: deps.search.page,
|
||||
pageSize: 24,
|
||||
}).catch(() => ({ items: [], total: 0, page: 1, pageSize: 24 }) as ProductListResult);
|
||||
// 分类名从分类树里找;找不到(无效 slug)也给出列表页(空态)
|
||||
const cat = cats.find((c) => c.slug === params.slug) ?? null;
|
||||
// 原站 /product-category/<slug>/ 直接渲染该分类的 Elementor 营销落地页(内容以
|
||||
// 同 slug 文章形式导入;smart-gateway 这类单复数变体也试)。无落地页才走筛选列表。
|
||||
let landing: Awaited<ReturnType<typeof getPublishedContent>> = null;
|
||||
for (const s of [params.slug, `${params.slug}s`, params.slug.replace(/s$/, "")]) {
|
||||
const row = await getPublishedContent(s, "post").catch(() => null);
|
||||
if (row && extractElementor(row.meta)) {
|
||||
landing = row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { layout, cats, filterConfig, list, cat, slug: params.slug, landing };
|
||||
},
|
||||
head: ({ loaderData }) => ({
|
||||
meta: [
|
||||
{
|
||||
title: loaderData?.landing?.seoTitle || loaderData?.landing?.title || (loaderData?.cat ? `${loaderData.cat.name} | Product Category | Dusun IoT` : "Products | Dusun IoT"),
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
content: loaderData?.landing?.seoDescription || loaderData?.landing?.excerpt || (loaderData?.cat ? `Browse Dusun IoT ${loaderData.cat.name} products: specifications, features and inquiry.` : "Browse Dusun IoT product categories."),
|
||||
},
|
||||
],
|
||||
}),
|
||||
component: CollectionCategoryPage,
|
||||
});
|
||||
|
||||
/** # 类型 分类树节点 */
|
||||
type Cat = { id: number; name: string; slug: string; parentId: number | null; count: number };
|
||||
|
||||
/** # 组件 分类列表页 */
|
||||
function CollectionCategoryPage() {
|
||||
const t = useT();
|
||||
const data = Route.useLoaderData();
|
||||
const search = Route.useSearch();
|
||||
const router = useRouter();
|
||||
const page = data.list.page;
|
||||
const landingElementor = extractElementor(data.landing?.meta ?? null);
|
||||
|
||||
/** # 函数 更新筛选:切换某属性值后 navigate(重置到第 1 页) */
|
||||
function toggleFilter(attrSlug: string, valueSlug: string) {
|
||||
const filters: FilterState = searchToFilters(search);
|
||||
const cur = filters[attrSlug] || [];
|
||||
const next = cur.includes(valueSlug) ? cur.filter((v) => v !== valueSlug) : [...cur, valueSlug];
|
||||
if (next.length) filters[attrSlug] = next;
|
||||
else delete filters[attrSlug];
|
||||
router.navigate({ to: "/collection/$slug/", params: { slug: data.slug }, search: filtersToSearch(filters, 1) });
|
||||
}
|
||||
|
||||
/** # 函数 生成翻页链接(保留当前筛选) */
|
||||
function pageHref(p: number) {
|
||||
return `/collection/${data.slug}/${buildCollectionQuery(searchToFilters(search), p)}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout layout={data.layout}>
|
||||
<div className="site-container">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink to="/">Home</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink to="/collection/">Products</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{data.cat?.name || data.slug}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
{landingElementor ? (
|
||||
/* 原站分类页形态:直接通栏渲染 Elementor 营销落地页 */
|
||||
<ElementorRenderer doc={landingElementor} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 items-start gap-7 min-[900px]:grid-cols-[250px_1fr]">
|
||||
<aside>
|
||||
<div className="mb-[18px] rounded-lg border border-line bg-soft p-4">
|
||||
<h3 className="m-0 mb-2.5 text-[15px]">{t("collection_categories")}</h3>
|
||||
<ul className="m-0 list-none p-0">
|
||||
{data.cats
|
||||
.filter((c) => !c.parentId && c.slug !== "uncategorized")
|
||||
.map((c) => (
|
||||
<CategoryTreeItem key={c.id} c={c} cats={data.cats} active={data.cat ? c.id === data.cat.id || hasChild(data.cats, c, data.cat) : false} currentSlug={data.slug} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{data.filterConfig.map((f) => (
|
||||
<div className="mb-[18px] rounded-lg border border-line bg-soft p-4" key={f.slug}>
|
||||
<h3 className="m-0 mb-2.5 text-[15px]">{f.name}</h3>
|
||||
<ul className="m-0 list-none p-0">
|
||||
{f.values.map((v) => {
|
||||
const checked = (searchToFilters(search)[f.slug] || []).includes(v.slug);
|
||||
return (
|
||||
<li key={v.slug} className="mb-1.5">
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<input type="checkbox" checked={checked} onChange={() => toggleFilter(f.slug, v.slug)} />
|
||||
<span className="flex-1">{v.value}</span>
|
||||
<span className="text-xs text-muted-foreground">({Number(v.count)})</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{f.values.length === 0 ? <li className="text-muted-foreground">{t("collection_no_options")}</li> : null}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
<div>
|
||||
<h1 className="mt-0 text-[26px]">{data.cat?.name || t("nav_products")}</h1>
|
||||
<p className="text-muted-foreground">{t("collection_products_count", { total: data.list.total })}</p>
|
||||
<div className="grid grid-cols-2 gap-[18px] min-[960px]:grid-cols-4">
|
||||
{data.list.items.map((p) => (
|
||||
<ProductCard key={p.id} p={p} />
|
||||
))}
|
||||
</div>
|
||||
{data.list.items.length === 0 ? (
|
||||
<p className="py-[60px] text-center text-muted-foreground">{Object.keys(searchToFilters(search)).length ? t("collection_no_results_filtered") : t("collection_no_results")}</p>
|
||||
) : null}
|
||||
<Pagination page={page} total={data.list.total} pageSize={data.list.pageSize} makeHref={pageHref} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 函数 判断某分类是否是 activeCat 的祖先 */
|
||||
function hasChild(cats: Cat[], parent: Cat, activeCat: Cat): boolean {
|
||||
let cur = activeCat as Cat | null;
|
||||
while (cur?.parentId) {
|
||||
if (cur.parentId === parent.id) return true;
|
||||
cur = cats.find((c) => c.id === cur!.parentId) ?? null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** # 组件 分类树节点(含一级子分类平铺) */
|
||||
function CategoryTreeItem(props: { c: Cat; cats: Cat[]; active: boolean; currentSlug: string }) {
|
||||
const children = props.cats.filter((x) => x.parentId === props.c.id);
|
||||
return (
|
||||
<li className="mb-1.5">
|
||||
<a
|
||||
href={`/collection/${props.c.slug}/`}
|
||||
className={"flex items-center justify-between text-sm " + (props.currentSlug === props.c.slug ? "font-bold text-brand" : props.active ? "font-bold text-ink" : "font-normal text-ink") + " hover:text-brand"}
|
||||
>
|
||||
<span>{props.c.name}</span>
|
||||
<span className="text-xs text-muted-foreground">({Number(props.c.count)})</span>
|
||||
</a>
|
||||
{children.length > 0 ? (
|
||||
<ul className="my-1 list-none p-0 pl-4">
|
||||
{children.map((ch) => (
|
||||
<li key={ch.id} className="mb-1.5">
|
||||
<a
|
||||
href={`/collection/${ch.slug}/`}
|
||||
className={"flex items-center justify-between text-sm " + (props.currentSlug === ch.slug ? "font-bold text-brand" : "font-normal text-ink") + " hover:text-brand"}
|
||||
>
|
||||
<span>{ch.name}</span>
|
||||
<span className="text-xs text-muted-foreground">({Number(ch.count)})</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* # 路由 产品列表 /collection/
|
||||
* 列出全部产品(无分类限定)+ 顶部分类卡片;带分页。
|
||||
*/
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||
import { ProductCard } from "~/components/website/ProductCard";
|
||||
import { Pagination } from "~/components/website/Pagination";
|
||||
import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from "~/components/ui/breadcrumb";
|
||||
import { parseCollectionSearch, searchToFilters, type CollectionSearch } from "~/components/website/collection-shared";
|
||||
import { getProductCategoryTree, listProducts, type ProductListResult } from "~/server/data";
|
||||
import { useT } from "~/i18n";
|
||||
|
||||
export const Route = createFileRoute("/collection/")({
|
||||
validateSearch: parseCollectionSearch as (search: Record<string, unknown>) => CollectionSearch,
|
||||
loaderDeps: ({ search }) => ({ search }),
|
||||
loader: async ({ deps }) => {
|
||||
const [layout, cats, list] = await Promise.all([
|
||||
getLayoutData(),
|
||||
getProductCategoryTree().catch(() => []),
|
||||
listProducts({ page: deps.search.page, filters: searchToFilters(deps.search), pageSize: 24 }).catch(
|
||||
() => ({ items: [], total: 0, page: 1, pageSize: 24 }) as ProductListResult,
|
||||
),
|
||||
]);
|
||||
return { layout, cats, list };
|
||||
},
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "All Products | IoT Gateways & Devices | Dusun IoT" },
|
||||
{ name: "description", content: "Browse all Dusun IoT products: industrial IoT gateways, single board computers, wireless modules and smart IoT devices." },
|
||||
],
|
||||
}),
|
||||
component: CollectionIndexPage,
|
||||
});
|
||||
|
||||
/** # 组件 全部产品列表页 */
|
||||
function CollectionIndexPage() {
|
||||
const t = useT();
|
||||
const data = Route.useLoaderData();
|
||||
const topCats = data.cats.filter((c) => !c.parentId && c.slug !== "uncategorized");
|
||||
const page = data.list.page;
|
||||
return (
|
||||
<Layout layout={data.layout}>
|
||||
<div className="site-container">
|
||||
<Breadcrumb aria-label="Breadcrumb">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem><BreadcrumbLink to="/">{t("nav_home")}</BreadcrumbLink></BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem><BreadcrumbPage>{t("nav_products")}</BreadcrumbPage></BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<h1 className="text-[28px]">{t("nav_all_products")}</h1>
|
||||
<p className="text-muted-foreground">{t("collection_products_count", { total: data.list.total })}</p>
|
||||
|
||||
<section className="mt-5 mb-8">
|
||||
<div className="grid grid-cols-2 gap-[18px] min-[960px]:grid-cols-4">
|
||||
{topCats.map((c) => (
|
||||
<Link key={c.id} to="/collection/$slug/" params={{ slug: c.slug }} className="block overflow-hidden rounded-lg border border-line bg-white text-brand transition-[box-shadow,transform] duration-200 hover:-translate-y-0.5 hover:shadow-[0_8px_24px_rgba(16,42,76,0.1)]">
|
||||
<div className="px-4 pt-3.5 pb-[18px]">
|
||||
<div className="mb-1.5 text-[15px] leading-[1.45] font-semibold">{c.name}</div>
|
||||
<div className="text-[13px] text-muted-foreground">{t("collection_products_count", { total: Number(c.count) })}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{topCats.length === 0 ? <p className="py-[60px] text-center text-muted-foreground">Product categories are being updated.</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-2 gap-[18px] min-[960px]:grid-cols-4">
|
||||
{data.list.items.map((p) => (
|
||||
<ProductCard key={p.id} p={p} />
|
||||
))}
|
||||
{data.list.items.length === 0 ? <p className="py-[60px] text-center text-muted-foreground">No products found. They may still be syncing — please check back soon.</p> : null}
|
||||
</div>
|
||||
<Pagination
|
||||
page={page}
|
||||
total={data.list.total}
|
||||
pageSize={data.list.pageSize}
|
||||
makeHref={(p) => `/collection/?page=${p}`}
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* # 路由 联系页 /contact-us/
|
||||
* 有 Elementor 内容则渲染;否则标准布局:联系方式卡片 + 询盘表单。
|
||||
*/
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
|
||||
import { InquiryForm } from "~/components/website/InquiryForm";
|
||||
import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from "~/components/ui/breadcrumb";
|
||||
import { getPublishedContent } from "~/server/data";
|
||||
|
||||
export const Route = createFileRoute("/contact-us")({
|
||||
loader: async () => {
|
||||
const [layout, page] = await Promise.all([
|
||||
getLayoutData(),
|
||||
getPublishedContent("contact-us", "page").catch(() => null),
|
||||
]);
|
||||
const meta = (page?.meta ?? null) as Record<string, unknown> | null;
|
||||
return { layout, page, meta };
|
||||
},
|
||||
head: ({ loaderData }) => ({
|
||||
meta: [
|
||||
{ title: loaderData?.page?.seoTitle || "Contact Us | Dusun IoT" },
|
||||
{ name: "description", content: loaderData?.page?.seoDescription || "Contact Dusun IoT for IoT gateway solutions, OEM/ODM customization and technical support. Email sales@dusuniot.com." },
|
||||
],
|
||||
}),
|
||||
component: ContactPage,
|
||||
});
|
||||
|
||||
/** # 组件 联系页 */
|
||||
function ContactPage() {
|
||||
const data = Route.useLoaderData();
|
||||
const elementor = extractElementor(data.meta);
|
||||
return (
|
||||
<Layout layout={data.layout}>
|
||||
<div className="site-container">
|
||||
{elementor ? (
|
||||
<ElementorRenderer doc={elementor} />
|
||||
) : (
|
||||
<>
|
||||
<Breadcrumb className="pt-6" aria-label="Breadcrumb">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem><BreadcrumbLink to="/">Home</BreadcrumbLink></BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem><BreadcrumbPage>Contact Us</BreadcrumbPage></BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<h1 className="mt-0 text-[28px]">Contact Us</h1>
|
||||
<div className="mb-8 grid grid-cols-1 gap-[18px] min-[800px]:grid-cols-3">
|
||||
<div className="rounded-lg border border-line bg-soft p-5">
|
||||
<h3 className="mt-0">Sales & Inquiries</h3>
|
||||
<p>
|
||||
Email: <a href="mailto:sales@dusuniot.com">sales@dusuniot.com</a>
|
||||
<br />
|
||||
Tel: +86 755 2643 0850
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-line bg-soft p-5">
|
||||
<h3 className="mt-0">Visit Us</h3>
|
||||
<p>
|
||||
5F, Building B, Henglong Industrial Park,
|
||||
<br />
|
||||
Xili, Nanshan District, Shenzhen, Guangdong, China
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-line bg-soft p-5">
|
||||
<h3 className="mt-0">Support</h3>
|
||||
<p>24/7 technical support for OEM/ODM partners. Response within 24 hours on business days.</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* Elementor 版页面(hero 内已含询盘表单)不再追加表单;标准布局才补一个询盘表单 */}
|
||||
{!elementor ? (
|
||||
<section className="mt-4 mb-14">
|
||||
<InquiryForm formKey="contact-us-form" title="Get a Quote" />
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* # 路由 首页 /
|
||||
* 有 Elementor 内容则渲染渲染器;否则渲染后备首页(Hero + 分类卡片 + 最新产品 + 最新文章 + CTA)。
|
||||
*/
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
|
||||
import { ProductCard } from "~/components/website/ProductCard";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { getHomePage, getProductCategoryTree, listProducts, listPosts } from "~/server/data";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
loader: async () => {
|
||||
// 并行取布局 + 首页内容 + 后备数据;DB 为空时各自返回空结构(优雅降级)
|
||||
const [layout, home, cats, products, posts] = await Promise.all([
|
||||
getLayoutData(),
|
||||
getHomePage().catch(() => null),
|
||||
getProductCategoryTree().catch(() => []),
|
||||
listProducts({ page: 1, pageSize: 8 }).catch(() => ({ items: [], total: 0, page: 1, pageSize: 8 })),
|
||||
listPosts(1, 3).catch(() => ({ items: [], total: 0, page: 1, pageSize: 3 })),
|
||||
]);
|
||||
const meta = (home?.meta ?? null) as Record<string, unknown> | null;
|
||||
return { layout, home, meta, cats, products, posts };
|
||||
},
|
||||
head: ({ loaderData }) => ({
|
||||
meta: [
|
||||
{ title: loaderData?.home?.seoTitle || "Dusun IoT | IoT Gateway Manufacturer & Solution Provider" },
|
||||
{
|
||||
name: "description",
|
||||
content:
|
||||
loaderData?.home?.seoDescription ||
|
||||
"Dusun IoT is a leading IoT gateway manufacturer offering Zigbee, LoRaWAN, BLE gateways, single board computers and end-to-end IoT solutions.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
component: HomePage,
|
||||
});
|
||||
|
||||
/** # 组件 首页 */
|
||||
function HomePage() {
|
||||
const data = Route.useLoaderData();
|
||||
const elementor = extractElementor(data.meta);
|
||||
return (
|
||||
<Layout layout={data.layout}>
|
||||
{elementor ? (
|
||||
<ElementorRenderer doc={elementor} />
|
||||
) : (
|
||||
<FallbackHome cats={data.cats} products={data.products} posts={data.posts} />
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 类型 后备首页分类卡片数据 */
|
||||
type CatItem = { id: number; name: string; slug: string; parentId: number | null; count: number };
|
||||
|
||||
/** # 成员变量 卡片通用样式(与 ProductCard 一致:悬停抬升 + 品牌色文字) **/
|
||||
const cardCls =
|
||||
"block overflow-hidden rounded-lg border border-line bg-white text-brand transition-[box-shadow,transform] duration-200 hover:-translate-y-0.5 hover:shadow-[0_8px_24px_rgba(16,42,76,0.1)]";
|
||||
|
||||
/** # 组件 后备首页(无 Elementor 数据时) */
|
||||
function FallbackHome(props: { cats: CatItem[]; products: { items: { id: number; slug: string; title: string; sku: string | null; imageUrl: string | null }[] }; posts: { items: { id: number; slug: string; title: string; excerpt: string | null; publishedAt: Date | null; imageUrl: string | null }[] } }) {
|
||||
const topCats = props.cats.filter((c) => !c.parentId && c.slug !== "uncategorized").slice(0, 8);
|
||||
return (
|
||||
<>
|
||||
<section className="bg-[linear-gradient(120deg,#0b1c2c_0%,#123a63_55%,#1667d9_100%)] py-[72px] text-center text-white">
|
||||
<div className="site-container">
|
||||
<h1 className="mt-0 mb-3.5 text-[38px]">Professional IoT Gateway Manufacturer</h1>
|
||||
<p className="mx-auto mb-[26px] max-w-[760px] text-[17px] text-[#cfe0f5]">
|
||||
Dusun IoT provides industrial-grade IoT gateways, single board computers and edge computing solutions
|
||||
with Zigbee, LoRaWAN, BLE, Wi-Fi and cellular connectivity — from hardware to cloud.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<a href="/collection/">Browse Products</a>
|
||||
</Button>{" "}
|
||||
<Button asChild variant="outline">
|
||||
<a href="/contact-us/">Get a Quote</a>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-11">
|
||||
<div className="site-container">
|
||||
<h2 className="m-0 mb-2 text-center text-[28px]">Product Categories</h2>
|
||||
<p className="m-0 mb-7 text-center text-muted-foreground">Explore our full range of IoT hardware</p>
|
||||
<div className="grid grid-cols-2 gap-[18px] min-[960px]:grid-cols-4">
|
||||
{topCats.map((c) => (
|
||||
<Link key={c.id} to="/collection/$slug/" params={{ slug: c.slug }} className={cardCls}>
|
||||
<div className="px-4 pt-3.5 pb-[18px]">
|
||||
<div className="mb-1.5 text-[15px] leading-[1.45] font-semibold">{c.name}</div>
|
||||
<div className="text-[13px] text-muted-foreground">{Number(c.count)} products</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{topCats.length === 0 ? <p className="py-[60px] text-center text-muted-foreground">Product categories are being updated. Please check back soon.</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-soft py-11">
|
||||
<div className="site-container">
|
||||
<h2 className="m-0 mb-2 text-center text-[28px]">Latest Products</h2>
|
||||
<p className="m-0 mb-7 text-center text-muted-foreground">Newly released IoT gateways and devices</p>
|
||||
<div className="grid grid-cols-2 gap-[18px] min-[960px]:grid-cols-4">
|
||||
{props.products.items.map((p) => (
|
||||
<ProductCard key={p.id} p={p} />
|
||||
))}
|
||||
</div>
|
||||
{props.products.items.length === 0 ? <p className="py-[60px] text-center text-muted-foreground">Products are being updated. Please check back soon.</p> : null}
|
||||
<div className="mt-6 text-center">
|
||||
<Button asChild variant="outline">
|
||||
<a href="/collection/">View All Products</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-11">
|
||||
<div className="site-container">
|
||||
<h2 className="m-0 mb-2 text-center text-[28px]">From the Blog</h2>
|
||||
<p className="m-0 mb-7 text-center text-muted-foreground">IoT knowledge, guides and product news</p>
|
||||
<div className="grid grid-cols-1 gap-5 min-[960px]:grid-cols-3">
|
||||
{props.posts.items.map((post) => (
|
||||
<Link key={post.id} to="/blog/$slug/" params={{ slug: post.slug }} className={cardCls}>
|
||||
<div className="flex aspect-[4/3] items-center justify-center bg-white p-3">
|
||||
{post.imageUrl ? <img src={post.imageUrl} alt={post.title} loading="lazy" className="max-h-full object-contain" /> : null}
|
||||
</div>
|
||||
<div className="px-4 pt-3.5 pb-[18px]">
|
||||
<div className="mb-1.5 text-[15px] leading-[1.45] font-semibold">{post.title}</div>
|
||||
<div className="text-[13px] text-muted-foreground">{post.publishedAt ? new Date(post.publishedAt).toISOString().slice(0, 10) : ""}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{props.posts.items.length === 0 ? <p className="py-[60px] text-center text-muted-foreground">Blog posts are coming soon.</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-[linear-gradient(120deg,#0b1c2c_0%,#123a63_55%,#1667d9_100%)] py-12 text-center text-white">
|
||||
<div className="site-container">
|
||||
<h2 className="m-0 mb-2.5">Ready to build your IoT solution?</h2>
|
||||
<p className="mx-auto mb-[26px] max-w-[760px] text-[17px] text-[#cfe0f5]">
|
||||
Talk to our experts about hardware customization, OS development and cloud integration.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<a href="/contact-us/">Contact Us</a>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* # 路由 /my-account 账户页(Tailwind + shadcn/ui 实现)
|
||||
* 未登录:登录 / 注册两个 Tab 表单;已登录:资料卡 + 订单列表 + 退出登录。
|
||||
*/
|
||||
import { createFileRoute, Link, useRouter } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { getCurrentUser, loginFn, registerFn, logoutFn } from "~/server/auth";
|
||||
import { useT } from "~/i18n";
|
||||
import { getMyOrders } from "~/server/commerce";
|
||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
|
||||
export const Route = createFileRoute("/my-account")({
|
||||
loader: async () => {
|
||||
const user = await getCurrentUser();
|
||||
const [layout, orders] = await Promise.all([getLayoutData(), user ? getMyOrders() : Promise.resolve([])]);
|
||||
return { layout, user, orders };
|
||||
},
|
||||
head: () => ({ meta: [{ title: "My Account - DusunIoT" }] }),
|
||||
component: MyAccountPage,
|
||||
});
|
||||
|
||||
/** # 函数 组件 账户页面(外壳:公共布局) */
|
||||
function MyAccountPage() {
|
||||
const { layout } = Route.useLoaderData();
|
||||
return (
|
||||
<Layout layout={layout}>
|
||||
<AccountInner />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 函数 组件 账户内容区 */
|
||||
function AccountInner() {
|
||||
const t = useT();
|
||||
const { user, orders } = Route.useLoaderData();
|
||||
const router = useRouter();
|
||||
|
||||
/** # 函数 退出登录 */
|
||||
async function handleLogout() {
|
||||
await logoutFn();
|
||||
router.invalidate();
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="site-container">
|
||||
<h1 className="mt-5 mb-3.5 text-[26px]">My Account</h1>
|
||||
<div className="max-w-[520px] rounded-lg border border-line bg-soft p-5">
|
||||
<Tabs defaultValue="login">
|
||||
<TabsList>
|
||||
<TabsTrigger value="login">{t("acct_login")}</TabsTrigger>
|
||||
<TabsTrigger value="register">{t("acct_register")}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="login">
|
||||
<LoginForm onDone={() => router.invalidate()} />
|
||||
</TabsContent>
|
||||
<TabsContent value="register">
|
||||
<RegisterForm onDone={() => router.invalidate()} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="site-container">
|
||||
<h1 className="mt-5 mb-3.5 text-[26px]">My Account</h1>
|
||||
<div className="mb-5 rounded-lg border border-line bg-soft p-5">
|
||||
<h3 className="mt-0">Profile</h3>
|
||||
<p className="m-0">
|
||||
<strong>{user.displayName || user.email}</strong> ({user.email}) — role: {user.role}
|
||||
<button className="ml-3.5 cursor-pointer border-0 bg-none text-[13px] text-muted-foreground underline hover:text-[#b91c1c]" onClick={() => void handleLogout()} type="button">
|
||||
Log out
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
<h2>{t("acct_recent_orders")}</h2>
|
||||
{orders.length === 0 ? (
|
||||
<div className="py-[60px] text-center text-muted-foreground">
|
||||
<p>{t("acct_no_orders")}</p>
|
||||
<Button asChild>
|
||||
<Link to="/shop/">{t("checkout_browse")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full border-collapse bg-white text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
{(["acct_th_order", "acct_th_date", "acct_th_items", "acct_th_total", "acct_th_status", "acct_th_payment"] as const).map((k) => (
|
||||
<th key={k} className="border-b border-line bg-soft px-3 py-2.5 text-left font-semibold">
|
||||
{t(k)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.map((o) => (
|
||||
<tr key={o.id}>
|
||||
<td className="border-b border-line px-3 py-2.5 text-left">
|
||||
<strong>{o.orderNumber}</strong>
|
||||
</td>
|
||||
<td className="border-b border-line px-3 py-2.5 text-left">{o.createdAt ? new Date(o.createdAt).toLocaleDateString("en-US") : "-"}</td>
|
||||
<td className="border-b border-line px-3 py-2.5 text-left">{Number(o.itemCount)}</td>
|
||||
<td className="border-b border-line px-3 py-2.5 text-left">${Number(o.total).toFixed(2)}</td>
|
||||
<td className="border-b border-line px-3 py-2.5 text-left">
|
||||
<Badge variant={o.status === "completed" ? "success" : "default"}>{o.status}</Badge>
|
||||
</td>
|
||||
<td className="border-b border-line px-3 py-2.5 text-left">
|
||||
<Badge variant={o.paymentStatus === "paid" ? "success" : "warning"}>{o.paymentStatus}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 组件 登录表单 */
|
||||
function LoginForm({ onDone }: { onDone: () => void }) {
|
||||
const t = useT();
|
||||
const [authError, setAuthError] = useState("");
|
||||
|
||||
/** # 函数 登录提交 */
|
||||
async function handleLogin(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setAuthError("");
|
||||
const fd = new FormData(e.currentTarget);
|
||||
const res = await loginFn({ data: { email: String(fd.get("email") ?? ""), password: String(fd.get("password") ?? "") } });
|
||||
if (!res.ok) return setAuthError(res.error);
|
||||
onDone();
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="grid grid-cols-1 gap-3.5" onSubmit={handleLogin}>
|
||||
{authError ? <p className="m-0 rounded-md border border-[#f3c1c1] bg-[#fdeaea] px-4 py-3 text-[#b91c1c]">{authError}</p> : null}
|
||||
<Label className="col-span-full">
|
||||
Email
|
||||
<Input name="email" type="email" required autoComplete="email" />
|
||||
</Label>
|
||||
<Label className="col-span-full">
|
||||
Password
|
||||
<Input name="password" type="password" required autoComplete="current-password" />
|
||||
</Label>
|
||||
<div className="col-span-full">
|
||||
<Button type="submit">{t("acct_login")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 组件 注册表单 */
|
||||
function RegisterForm({ onDone }: { onDone: () => void }) {
|
||||
const [authError, setAuthError] = useState("");
|
||||
|
||||
/** # 函数 注册提交 */
|
||||
async function handleRegister(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setAuthError("");
|
||||
const fd = new FormData(e.currentTarget);
|
||||
const res = await registerFn({
|
||||
data: {
|
||||
email: String(fd.get("email") ?? ""),
|
||||
password: String(fd.get("password") ?? ""),
|
||||
displayName: String(fd.get("displayName") ?? "") || undefined,
|
||||
},
|
||||
});
|
||||
if (!res.ok) return setAuthError(res.error);
|
||||
onDone();
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="grid grid-cols-1 gap-3.5" onSubmit={handleRegister}>
|
||||
{authError ? <p className="m-0 rounded-md border border-[#f3c1c1] bg-[#fdeaea] px-4 py-3 text-[#b91c1c]">{authError}</p> : null}
|
||||
<Label>
|
||||
Display name (optional)
|
||||
<Input name="displayName" type="text" />
|
||||
</Label>
|
||||
<Label>
|
||||
Email
|
||||
<Input name="email" type="email" required autoComplete="email" />
|
||||
</Label>
|
||||
<Label>
|
||||
Password (min 8 chars)
|
||||
<Input name="password" type="password" required minLength={8} autoComplete="new-password" />
|
||||
</Label>
|
||||
<div>
|
||||
<Button type="submit" variant="outline">
|
||||
Register
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/** # 函数 时间转 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" },
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user