v0.14.0 设置页 Tabs 表单+登录安全(reCAPTCHA/锁定);TipTap 富文本编辑器(Markdown/媒体库);前台移动端自适应

- /admin/settings 弃用 JSON 编辑器改 antd Tabs 分组表单(site/woocommerce/mail/seo/security),未知键落「其他」JSON 兜底
- 登录安全:reCAPTCHA v2/v3 开关+密钥+阈值(siteverify fail closed),失败次数锁定;/admin 与 /my-account 登录接入
- 内容正文与产品详细描述换 TipTap v3:工具栏/Markdown 输入与导入/HTML 源码/图片上传/媒体库选图
- 根路由补 viewport meta(移动端根因);390px 实测 14 关键页 0 横向溢出
- 首页 Advance Tabs ≤767px 横滑、联系页内联 margin 块收缩、/shop 网格 4→3→2 列、移动菜单手风琴
This commit is contained in:
bigcode
2026-11-04 14:08:49 +08:00
parent 7fd9e75887
commit 62e6bcfcd2
18 changed files with 1530 additions and 81 deletions
+2 -1
View File
@@ -36,8 +36,9 @@ function RootDocument() {
export const Route = createRootRoute({
component: RootDocument,
loader,
// 全局 head 资源:favicon / apple-touch-icon(public/ 同名文件)
// 全局 head 资源:viewport(移动端自适应的前提)+ favicon / apple-touch-icon
head: () => ({
meta: [{ name: "viewport", content: "width=device-width, initial-scale=1" }],
links: [
{ rel: "icon", href: "/favicon.ico", sizes: "32x32" },
{ rel: "apple-touch-icon", href: "/apple-touch-icon.png" },
+4 -3
View File
@@ -12,6 +12,7 @@ import { ArrowLeft, ExternalLink, History, Save } from "lucide-react";
import { useEffect, useState } from "react";
import { adminGetContent, adminGetRevision, adminListRevisions, adminRestoreRevision, adminSaveContent } from "~/server/admin";
import { CONTENT_STATUS_OPTIONS, Forbidden, fmtDateTime, guardAdmin } from "~/components/admin/common";
import { RichEditor } from "~/components/admin/RichEditor";
/** # 类型 编辑表单数据形状(新建时给默认值) **/
type ContentForm = {
@@ -251,10 +252,10 @@ function ContentEditPage() {
</Form.Item>
<Form.Item
name="html"
label="正文 HTML(无 Elementor JSON 时前台按此渲染)"
tooltip="自定义单页面推荐:留空 HTML、填写 Elementor JSON,前台通栏渲染与原站一致"
label="正文(富文本编辑,Markdown 快捷语法自动转换)"
tooltip="可视化编辑;需要精细控制时可切 HTML 源码。无 Elementor JSON 时前台按此渲染"
>
<Input.TextArea rows={12} style={{ fontFamily: "monospace", fontSize: 12 }} />
<RichEditor minHeight={380} />
</Form.Item>
<Form.Item
name="elementorJson"
+3 -2
View File
@@ -9,6 +9,7 @@ import { App, Button, Card, Form, Input, InputNumber, Select, Space, Switch } fr
import { ArrowLeftOutlined, SaveOutlined } from "@ant-design/icons";
import { adminGetProduct, adminSaveProduct, adminSearchMedia } from "~/server/admin";
import { Forbidden, guardAdmin } from "~/components/admin/common";
import { RichEditor } from "~/components/admin/RichEditor";
export const Route = createFileRoute("/admin/products/$id")({
loader: ({ params }) =>
@@ -196,8 +197,8 @@ function ProductEditPage() {
<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 name="description" label="详细描述(富文本编辑)">
<RichEditor minHeight={380} />
</Form.Item>
{attributes.length > 0 && (
<Card type="inner" title="产品属性" size="small" style={{ marginBottom: 16 }}>
+292 -40
View File
@@ -1,12 +1,14 @@
/**
* # 路由 /admin/settings 系统设置
* settings 表 kv 编辑器:每键一个 JSON 编辑卡片,保存整键覆写。
* Tabs 分组表单(v0.14.0 起弃用整键 JSON 编辑器):站点信息 / 商城配置 / 邮件配置 /
* SEO / 登录安全,每组一个 antd Form,保存时与现有值合并(未知字段保留)。
* 其余键(backup 等由专属页面管理的除外)落到「其他」Tab,保留 JSON 编辑兜底。
* 仅 admin 角色可保存(server 端强制)。
*/
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { App, Button, Card, Input, Space, Tag, Typography } from "antd";
import { App, Alert, Button, Card, Form, Input, InputNumber, Select, Space, Switch, Tabs, Tag, Typography } from "antd";
import { Save } from "lucide-react";
import { useEffect, useState } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { adminListSettings, adminUpdateSetting } from "~/server/admin";
import { Forbidden, PageHeader, fmtDateTime, guardAdmin, type Json } from "~/components/admin/common";
@@ -15,14 +17,164 @@ export const Route = createFileRoute("/admin/settings")({
component: SettingsPage,
});
/** # 成员变量 设置键中文说明(未知键回退原键名) **/
const SETTING_LABEL: Record<string, string> = {
site: "站点信息",
woocommerce: "商城配置",
mail: "邮件配置",
seo: "SEO 配置",
/** # 类型 设置表单字段描述(配置驱动渲染) **/
type FieldDef = {
/** # 成员变量 字段名(写入 settings 值对象的键) **/
name: string;
/** # 成员变量 中文标签 **/
label: string;
/** # 成员变量 控件类型 **/
type: "text" | "password" | "number" | "switch" | "select" | "textarea";
/** # 成员变量 说明/占位 **/
placeholder?: string;
/** # 成员变量 select 选项 **/
options?: { value: string | number; label: string }[];
/** # 成员变量 数字步进与范围(type=number) **/
min?: number;
max?: number;
step?: number;
/** # 成员变量 提示(Form.Item extra) **/
extra?: string;
/** # 成员变量 占据栅格跨度(labelCol/wrap 配合) **/
span?: 6 | 8 | 12 | 24;
};
/** # 成员变量 各 Tab 的分组表单配置(key 与 settings 表键一致;中文名 + 字段清单) **/
const GROUPS: { key: string; label: string; desc?: ReactNode; fields: FieldDef[] }[] = [
{
key: "site",
label: "站点信息",
fields: [
{ name: "name", label: "站点名称", type: "text", placeholder: "Dusun IoT" },
{ name: "url", label: "站点 URL", type: "text", placeholder: "https://dusuniot.com" },
{
name: "currency",
label: "货币",
type: "select",
options: [
{ value: "USD", label: "USD 美元" },
{ value: "CNY", label: "CNY 人民币" },
{ value: "EUR", label: "EUR 欧元" },
],
},
{
name: "timezone",
label: "时区",
type: "select",
options: [
{ value: "Asia/Shanghai", label: "Asia/Shanghai(东八区)" },
{ value: "UTC", label: "UTC" },
{ value: "America/New_York", label: "America/New_York" },
{ value: "Europe/London", label: "Europe/London" },
],
},
],
},
{
key: "woocommerce",
label: "商城配置",
fields: [
{ name: "productBase", label: "产品路由前缀", type: "text", placeholder: "product" },
{ name: "categoryBase", label: "分类路由前缀", type: "text", placeholder: "collection" },
{ name: "tagBase", label: "标签路由前缀", type: "text", placeholder: "product-tag" },
{
name: "currency",
label: "货币",
type: "select",
options: [
{ value: "USD", label: "USD 美元" },
{ value: "CNY", label: "CNY 人民币" },
{ value: "EUR", label: "EUR 欧元" },
],
},
{ name: "guestCheckout", label: "游客结账", type: "switch", extra: "允许未注册用户下单" },
{ name: "pricesIncludeTax", label: "价格含税", type: "switch" },
{ name: "signupFromCheckout", label: "结账时引导注册", type: "switch" },
{
name: "allowedCountries",
label: "销售地区",
type: "select",
options: [
{ value: "all", label: "全部国家" },
{ value: "all_except", label: "全部(除特定地区)" },
{ value: "specified", label: "仅指定地区" },
],
},
],
},
{
key: "mail",
label: "邮件配置",
desc: "询盘通知 / 订单邮件的 SMTP 发信配置;修改后建议发一封测试邮件验证。",
fields: [
{ name: "fromName", label: "发件人名称", type: "text", placeholder: "Dusun IoT" },
{ name: "fromEmail", label: "发件邮箱", type: "text", placeholder: "no-reply@dusuniot.com" },
{ name: "smtpHost", label: "SMTP 主机", type: "text", placeholder: "smtp.exmail.qq.com" },
{ name: "smtpPort", label: "SMTP 端口", type: "number", min: 1, max: 65535, placeholder: "465" },
{
name: "smtpSecure",
label: "加密方式",
type: "select",
options: [
{ value: "none", label: "不加密" },
{ value: "ssl", label: "SSL(465)" },
{ value: "tls", label: "STARTTLS(587)" },
],
},
{ name: "smtpUser", label: "SMTP 账号", type: "text" },
{ name: "smtpPass", label: "SMTP 密码/授权码", type: "password" },
],
},
{
key: "seo",
label: "SEO",
fields: [
{ name: "titleTemplate", label: "标题模板", type: "text", placeholder: "%title% | Dusun IoT", extra: "%title% 为页面标题占位符" },
{
name: "robots",
label: "全站 robots",
type: "select",
options: [
{ value: "all", label: "all(允许索引)" },
{ value: "noindex", label: "noindex(禁止索引)" },
],
},
{ name: "sitemap", label: "启用 sitemap.xml", type: "switch" },
],
},
{
key: "security",
label: "登录安全",
desc: (
<>
reCAPTCHA 在 <a href="https://www.google.com/recaptcha/admin" target="_blank" rel="noreferrer">Google reCAPTCHA 控制台</a>
申请;v2 为「我是人类」复选框,v3 为隐形评分。开启后 /admin 与 /my-account 登录均需通过验证;
超过失败次数上限的账号将锁定指定分钟。
</>
),
fields: [
{ name: "recaptchaEnabled", label: "启用 reCAPTCHA", type: "switch", extra: "关闭时登录流程保持现状,不加载 Google 脚本" },
{
name: "recaptchaVersion",
label: "版本",
type: "select",
options: [
{ value: "v2", label: "v2 复选框" },
{ value: "v3", label: "v3 隐形评分" },
],
},
{ name: "recaptchaSiteKey", label: "Site Key(站点密钥)", type: "text", placeholder: "6Le…", span: 12 },
{ name: "recaptchaSecretKey", label: "Secret Key(服务端密钥)", type: "password", placeholder: "6Le…", span: 12 },
{ name: "recaptchaScoreThreshold", label: "v3 评分阈值", type: "number", min: 0, max: 1, step: 0.1, extra: "0~1,低于该分数判定为机器人,默认 0.5", span: 8 },
{ name: "maxLoginAttempts", label: "失败次数上限", type: "number", min: 3, max: 100, extra: "同一账号连续失败达上限即锁定", span: 8 },
{ name: "lockMinutes", label: "锁定时长(分钟)", type: "number", min: 1, max: 1440, span: 8 },
],
},
];
/** # 成员变量 由专属页面管理的键(不在本页出现,避免双头编辑冲突) **/
const MANAGED_ELSEWHERE = new Set(["backup"]);
/** # 类型 设置行(updatedAt 为 Date,loader 序列化前的服务端类型) **/
type SettingRow = { key: string; value: Json; updatedAt: Date | string };
@@ -31,20 +183,28 @@ function SettingsPage() {
const data = Route.useLoaderData();
const router = useRouter();
const { message } = App.useApp();
/** # 成员变量 各键的草稿文本(键 → JSON 字符串) **/
const [drafts, setDrafts] = useState<Record<string, string>>({});
/** # 成员变量 其他键的 JSON 草稿(键 → 文本) **/
const [otherDrafts, setOtherDrafts] = useState<Record<string, string>>({});
useEffect(() => {
if (!data) return;
setDrafts(Object.fromEntries(data.map((r: SettingRow) => [r.key, JSON.stringify(r.value, null, 2)])));
const map: Record<string, string> = {};
for (const r of data as SettingRow[]) {
if (!MANAGED_ELSEWHERE.has(r.key) && !GROUPS.some((g) => g.key === r.key)) {
map[r.key] = JSON.stringify(r.value, null, 2);
}
}
setOtherDrafts(map);
}, [data]);
if (!data) return <Forbidden />;
/** # 函数 保存单个设置键(JSON 校验失败提示) */
async function handleSave(key: string) {
const rows = data as SettingRow[];
/** # 函数 保存「其他」键的 JSON */
async function handleSaveOther(key: string) {
try {
const parsed = JSON.parse(drafts[key] ?? "{}") as Json;
const parsed = JSON.parse(otherDrafts[key] ?? "{}") as Json;
await adminUpdateSetting({ data: { key, value: parsed } });
message.success(`已保存 ${key}`);
await router.invalidate();
@@ -53,34 +213,126 @@ function SettingsPage() {
}
}
/** # 函数 渲染单个字段控件 */
function renderField(f: FieldDef) {
switch (f.type) {
case "switch":
return <Switch />;
case "number":
return <InputNumber min={f.min} max={f.max} step={f.step} style={{ width: 160 }} placeholder={f.placeholder} />;
case "select":
return <Select options={f.options} style={{ width: 260 }} placeholder={f.placeholder} allowClear={false} />;
case "textarea":
return <Input.TextArea rows={3} placeholder={f.placeholder} />;
case "password":
return <Input.Password placeholder={f.placeholder} autoComplete="new-password" />;
default:
return <Input placeholder={f.placeholder} />;
}
}
return (
<Card size="small">
<PageHeader title="系统设置" extra={<Tag color="orange">仅管理员可保存</Tag>} />
<Space direction="vertical" size={16} style={{ display: "flex" }}>
{data.map((r: SettingRow) => (
<Card
key={r.key}
type="inner"
size="small"
title={`${SETTING_LABEL[r.key] ?? r.key}(${r.key})`}
extra={
<Button size="small" type="primary" icon={<Save size={13} aria-hidden />} onClick={() => void handleSave(r.key)}>
保存
</Button>
}
>
<Input.TextArea
rows={Math.min(14, Math.max(4, (drafts[r.key] ?? "").split("\n").length))}
value={drafts[r.key] ?? ""}
onChange={(e) => setDrafts((d) => ({ ...d, [r.key]: e.target.value }))}
style={{ fontFamily: "monospace", fontSize: 12 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
最后更新:{fmtDateTime(r.updatedAt)}
</Typography.Text>
</Card>
))}
</Space>
<Tabs
defaultActiveKey={GROUPS[0]!.key}
items={GROUPS.map((g) => ({
key: g.key,
label: g.label,
children: <SettingsGroupForm group={g} current={(rows.find((r) => r.key === g.key)?.value ?? {}) as Record<string, unknown>} updatedAt={rows.find((r) => r.key === g.key)?.updatedAt} renderField={renderField} />,
})).concat(
Object.keys(otherDrafts).length
? [
{
key: "__other__",
label: "其他",
children: (
<Space direction="vertical" size={16} style={{ display: "flex" }}>
<Typography.Text type="secondary">以下键无表单定义(如由脚本写入的配置),按 JSON 编辑保存。</Typography.Text>
{Object.keys(otherDrafts).map((k) => (
<Card key={k} type="inner" size="small" title={k} extra={
<Button size="small" type="primary" icon={<Save size={13} aria-hidden />} onClick={() => void handleSaveOther(k)}>
保存
</Button>
}>
<Input.TextArea
rows={Math.min(14, Math.max(4, (otherDrafts[k] ?? "").split("\n").length))}
value={otherDrafts[k] ?? ""}
onChange={(e) => setOtherDrafts((d) => ({ ...d, [k]: e.target.value }))}
style={{ fontFamily: "monospace", fontSize: 12 }}
/>
</Card>
))}
</Space>
),
},
]
: [],
)}
/>
</Card>
);
}
/** # 组件 单个设置分组的表单(真实组件承载 Form.useForm,符合 hooks 规则)
* 提交时表单值与现有 JSON 合并(保留未在表单中的字段)后整键覆写。
*/
function SettingsGroupForm(props: {
group: (typeof GROUPS)[number];
current: Record<string, unknown>;
updatedAt?: string | Date;
renderField: (f: FieldDef) => ReactNode;
}) {
const { message } = App.useApp();
const router = useRouter();
const [form] = Form.useForm();
/** # 函数 保存分组 */
async function handleSave() {
try {
const values = await form.validateFields();
const merged = { ...props.current, ...values };
await adminUpdateSetting({ data: { key: props.group.key, value: merged as Json } });
message.success(`已保存「${props.group.label}」`);
await router.invalidate();
} catch (e) {
if (e && typeof e === "object" && "errorFields" in e) return; // 表单校验错误已就地提示
message.error(e instanceof Error ? e.message : "保存失败");
}
}
const g = props.group;
return (
<div>
{g.desc ? <Alert type="info" showIcon message={g.desc} style={{ marginBottom: 16 }} /> : null}
<Form
form={form}
layout="vertical"
onFinish={() => void handleSave()}
initialValues={Object.fromEntries(g.fields.map((f) => [f.name, props.current[f.name] ?? (f.type === "switch" ? false : undefined)]))}
style={{ maxWidth: 780 }}
>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0 16px" }}>
{g.fields.map((f) => (
<Form.Item
key={f.name}
name={f.name}
label={f.label}
extra={f.extra}
valuePropName={f.type === "switch" ? "checked" : "value"}
style={{ width: `calc(${(f.span ?? 8) / 24 * 100}% - 16px)`, minWidth: 220 }}
>
{props.renderField(f)}
</Form.Item>
))}
</div>
<Button type="primary" htmlType="submit" icon={<Save size={14} aria-hidden />}>
保存
</Button>
</Form>
<Typography.Text type="secondary" style={{ fontSize: 12, display: "block", marginTop: 10 }}>
最后更新:{fmtDateTime(props.updatedAt ?? "")}
</Typography.Text>
</div>
);
}
+29 -6
View File
@@ -23,9 +23,11 @@ import {
SlidersHorizontal,
UserRound,
} from "lucide-react";
import { useState, type ReactNode } from "react";
import { useState, useEffect, type ReactNode } from "react";
import { StyleProvider } from "@ant-design/cssinjs";
import { getCurrentUser, loginFn, logoutFn } from "~/server/auth";
import { getLoginSecurityPublic } from "~/server/security";
import { RecaptchaField, useRecaptcha, type LoginSecurityPublic } from "~/components/admin/Recaptcha";
/** # 类型 后台菜单分组(分组标签不可点;key 即目标路由路径) **/
const MENU_GROUPS: { label: string; children: { key: string; icon: ReactNode; label: string }[] }[] = [
@@ -103,21 +105,40 @@ function AdminLayout() {
return <AdminShell user={user} />;
}
/** # 组件 后台登录页(全屏居中卡片,邮箱 + 密码) */
/** # 组件 后台登录页(全屏居中卡片,邮箱 + 密码;reCAPTCHA 按系统设置「登录安全」开关渲染) */
function AdminLoginPage(props: { noPermission: boolean; onOk: () => Promise<void> }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
/** # 成员变量 登录页公开配置(reCAPTCHA 启用状态/版本/站点密钥) **/
const [secCfg, setSecCfg] = useState<LoginSecurityPublic | null>(null);
const recaptcha = useRecaptcha(secCfg);
/** # 函数 登录提交:调用 loginFn,成功回调 onOk,失败展示错误 */
// 登录页挂载后拉取登录安全公开配置(未启用时返回 enabled=false,不注入脚本)
useEffect(() => {
void getLoginSecurityPublic()
.then(setSecCfg)
.catch(() => setSecCfg({ enabled: false, version: "v2", siteKey: "" }));
}, []);
/** # 函数 登录提交:调用 loginFn(附 reCAPTCHA 令牌),成功回调 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();
const token = await recaptcha.getToken();
if (secCfg?.enabled && token === null) {
setError("人机验证未完成,请完成勾选/等待验证脚本加载后重试");
recaptcha.reset();
return;
}
const res = await loginFn({ data: { email: values.email, password: values.password, recaptchaToken: token ?? undefined } });
if (!res.ok) {
setError(res.error);
recaptcha.reset();
} else await props.onOk();
} catch (e) {
setError(e instanceof Error ? e.message : "登录失败,请稍后重试");
recaptcha.reset();
} finally {
setLoading(false);
}
@@ -144,6 +165,8 @@ function AdminLoginPage(props: { noPermission: boolean; onOk: () => Promise<void
<Form.Item name="password" label="密码" rules={[{ required: true, message: "请输入密码" }]}>
<Input.Password placeholder="密码" autoComplete="current-password" />
</Form.Item>
{/* reCAPTCHA(系统设置 → 登录安全 开启后出现;v2 复选框 / v3 隐形) */}
<RecaptchaField cfg={secCfg} rec={recaptcha} />
{error ? (
<div role="alert" style={{ color: "#cf1322", background: "#fff1f0", border: "1px solid #ffa39e", borderRadius: 6, padding: "8px 12px", marginBottom: 16, fontSize: 13 }}>
{error}
+27 -3
View File
@@ -3,8 +3,10 @@
* 未登录:登录 / 注册两个 Tab 表单;已登录:资料卡 + 订单列表 + 退出登录。
*/
import { createFileRoute, Link, useRouter } from "@tanstack/react-router";
import { useState } from "react";
import { useEffect, useState } from "react";
import { getCurrentUser, loginFn, registerFn, logoutFn } from "~/server/auth";
import { getLoginSecurityPublic } from "~/server/security";
import { RecaptchaField, useRecaptcha, type LoginSecurityPublic } from "~/components/admin/Recaptcha";
import { useT } from "~/i18n";
import { getMyOrders } from "~/server/commerce";
import { getLayoutData, Layout } from "~/components/website/Layout";
@@ -127,14 +129,34 @@ function AccountInner() {
function LoginForm({ onDone }: { onDone: () => void }) {
const t = useT();
const [authError, setAuthError] = useState("");
/** # 成员变量 登录页公开配置(reCAPTCHA 启用状态/版本/站点密钥) **/
const [secCfg, setSecCfg] = useState<LoginSecurityPublic | null>(null);
const recaptcha = useRecaptcha(secCfg);
// 挂载后拉取登录安全公开配置(未启用不注入 reCAPTCHA 脚本)
useEffect(() => {
void getLoginSecurityPublic()
.then(setSecCfg)
.catch(() => setSecCfg({ enabled: false, version: "v2", siteKey: "" }));
}, []);
/** # 函数 登录提交 */
async function handleLogin(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setAuthError("");
const token = await recaptcha.getToken();
if (secCfg?.enabled && token === null) {
setAuthError("人机验证未完成,请完成勾选/等待验证脚本加载后重试");
recaptcha.reset();
return;
}
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);
const res = await loginFn({ data: { email: String(fd.get("email") ?? ""), password: String(fd.get("password") ?? ""), recaptchaToken: token ?? undefined } });
if (!res.ok) {
setAuthError(res.error);
recaptcha.reset();
return;
}
onDone();
}
@@ -149,6 +171,8 @@ function LoginForm({ onDone }: { onDone: () => void }) {
Password
<Input name="password" type="password" required autoComplete="current-password" />
</Label>
{/* reCAPTCHA(系统设置 → 登录安全 开启后出现;v2 复选框 / v3 隐形) */}
<RecaptchaField cfg={secCfg} rec={recaptcha} />
<div className="col-span-full">
<Button type="submit">{t("acct_login")}</Button>
</div>