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:
@@ -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" },
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user