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:
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* # 组件 媒体库选择弹窗(编辑器/产品特色图等复用)
|
||||
* 搜索 + 分页网格 + 本地上传;点选图片回调 onSelect(url, alt) 并关闭。
|
||||
*/
|
||||
import { App, Button, Image, Input, Modal, Pagination, Space, Spin, Upload } from "antd";
|
||||
import { SearchOutlined, UploadOutlined } from "@ant-design/icons";
|
||||
import { useEffect, useState } from "react";
|
||||
import { adminListMedia } from "~/server/admin";
|
||||
import { uploadMediaFile } from "~/utils/media-upload";
|
||||
|
||||
/** # 类型 媒体行(弹窗网格所需最小字段) **/
|
||||
type MediaItem = { id: number; url: string; path: string; mimeType: string; altText: string | null; width: number | null; height: number | null };
|
||||
|
||||
/** # 类型 弹窗属性:open 受控;onSelect 选中回调;onlyImages 仅显示图片(默认 true) **/
|
||||
export function MediaPickerModal(props: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (url: string, alt: string) => void;
|
||||
onlyImages?: boolean;
|
||||
}) {
|
||||
const { message } = App.useApp();
|
||||
const [items, setItems] = useState<MediaItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [q, setQ] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const pageSize = 24;
|
||||
|
||||
/** # 函数 拉取媒体列表(图片过滤在服务端无参数,前端按 mime 过滤) */
|
||||
async function load(p = page, query = q) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminListMedia({ data: { page: p, pageSize, q: query || undefined } });
|
||||
const list = (res.items as MediaItem[]).filter((it) => (props.onlyImages === false ? true : it.mimeType.startsWith("image")));
|
||||
setItems(list);
|
||||
setTotal(res.total);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : "加载媒体失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 弹窗打开时拉首页;搜索/翻页重新拉取
|
||||
useEffect(() => {
|
||||
if (props.open) void load(1, "");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.open]);
|
||||
|
||||
/** # 函数 本地上传后直接选中插入 */
|
||||
async function handleUpload(file: File) {
|
||||
try {
|
||||
const res = await uploadMediaFile(file);
|
||||
message.success(`已上传 ${file.name}`);
|
||||
props.onSelect(res.url, file.name.replace(/\.[^.]+$/, ""));
|
||||
props.onClose();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : "上传失败");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="选择媒体" open={props.open} onCancel={props.onClose} footer={null} width={860} destroyOnHidden>
|
||||
<Space style={{ marginBottom: 14, display: "flex" }}>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="搜索文件路径"
|
||||
prefix={<SearchOutlined aria-hidden />}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onPressEnter={() => {
|
||||
setPage(1);
|
||||
void load(1, q);
|
||||
}}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
<Upload multiple showUploadList={false} beforeUpload={(f) => void handleUpload(f as File)}>
|
||||
<Button icon={<UploadOutlined aria-hidden />}>上传图片</Button>
|
||||
</Upload>
|
||||
</Space>
|
||||
<Spin spinning={loading}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))", gap: 10, minHeight: 220 }}>
|
||||
{items.map((it) => (
|
||||
<button
|
||||
key={it.id}
|
||||
type="button"
|
||||
title={it.path}
|
||||
onClick={() => {
|
||||
props.onSelect(it.url, it.altText || it.path.split("/").pop() || "");
|
||||
props.onClose();
|
||||
}}
|
||||
style={{ cursor: "pointer", border: "1px solid #f0f0f0", borderRadius: 6, padding: 4, background: "#fff", textAlign: "center" }}
|
||||
>
|
||||
<Image src={it.url} alt={it.altText || it.path} width="100%" height={92} style={{ objectFit: "cover", borderRadius: 4 }} preview={false} />
|
||||
<div style={{ fontSize: 11, color: "#8c8c8c", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{it.path.split("/").pop()}</div>
|
||||
</button>
|
||||
))}
|
||||
{!loading && items.length === 0 ? <div style={{ gridColumn: "1/-1", color: "#8c8c8c", textAlign: "center", padding: 40 }}>暂无媒体,先上传</div> : null}
|
||||
</div>
|
||||
<div style={{ marginTop: 14, textAlign: "right" }}>
|
||||
<Pagination
|
||||
size="small"
|
||||
current={page}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onChange={(p) => {
|
||||
setPage(p);
|
||||
void load(p);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Spin>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* # 组件 reCAPTCHA 登录验证组件(后台 /admin 与前台 /my-account 登录共用)
|
||||
* 依据服务端公开配置(getLoginSecurityPublic)渲染:
|
||||
* - v2:显式渲染复选框 widget,getToken 取 getResponse()
|
||||
* - v3:按 siteKey 加载脚本,getToken 时 execute(action=login)
|
||||
* Google 脚本被墙/加载失败时 getToken 返回 null(服务端会给出明确报错)。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getLoginSecurityPublic } from "~/server/security";
|
||||
|
||||
/** # 类型 登录页公开配置(服务端 getLoginSecurityPublic 返回) **/
|
||||
export type LoginSecurityPublic = { enabled: boolean; version: "v2" | "v3"; siteKey: string };
|
||||
|
||||
/** # 类型 window.grecaptcha 最小接口(v2 render/getResponse/reset + v3 execute) **/
|
||||
type GrecaptchaApi = {
|
||||
render: (el: HTMLElement, opts: { sitekey: string; theme?: string }) => number;
|
||||
getResponse: (id?: number) => string;
|
||||
reset: (id?: number) => void;
|
||||
ready: (cb: () => void) => void;
|
||||
execute: (siteKey: string, opts: { action: string }) => Promise<string>;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
grecaptcha?: GrecaptchaApi;
|
||||
}
|
||||
}
|
||||
|
||||
/** # 成员变量 脚本注入去重(两个登录页/严格模式重渲染只注一次) **/
|
||||
let scriptState: "idle" | "loading" | "loaded" | "error" = "idle";
|
||||
let scriptPromise: Promise<void> | null = null;
|
||||
|
||||
/** # 函数 注入 reCAPTCHA api.js(v2 与 v3 同一脚本;v3 需 render=siteKey 参数)
|
||||
* v2:https://www.google.com/recaptcha/api.js
|
||||
* v3:https://www.google.com/recaptcha/api.js?render=<siteKey>
|
||||
*/
|
||||
function loadRecaptchaScript(siteKey: string, version: "v2" | "v3"): Promise<void> {
|
||||
if (scriptState === "loaded") return Promise.resolve();
|
||||
if (scriptState === "error") return Promise.reject(new Error("recaptcha script failed"));
|
||||
if (!scriptPromise) {
|
||||
scriptPromise = new Promise<void>((resolve, reject) => {
|
||||
const s = document.createElement("script");
|
||||
const src = version === "v3" ? `https://www.google.com/recaptcha/api.js?render=${encodeURIComponent(siteKey)}` : "https://www.google.com/recaptcha/api.js";
|
||||
s.src = src;
|
||||
s.async = true;
|
||||
s.defer = true;
|
||||
s.id = "grecaptcha-api";
|
||||
s.onload = () => {
|
||||
scriptState = "loaded";
|
||||
resolve();
|
||||
};
|
||||
s.onerror = () => {
|
||||
scriptState = "error";
|
||||
scriptPromise = null;
|
||||
reject(new Error("recaptcha script failed"));
|
||||
};
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
return scriptPromise;
|
||||
}
|
||||
|
||||
/** # 函数 useRecaptcha:管理脚本加载 / v2 widget 渲染 / 取令牌 / 重置
|
||||
* 参数 cfg:登录页公开配置(null 或 enabled=false 时不注入脚本)
|
||||
* 返回值 containerRef(v2 复选框容器)、getToken、reset、ready(脚本就绪标志)
|
||||
*/
|
||||
export function useRecaptcha(cfg: LoginSecurityPublic | null) {
|
||||
/** # 成员变量 v2 复选框容器(cfg.version=v2 时渲染到该节点) **/
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
/** # 成员变量 v2 widget id(render 后缓存,reset 用) **/
|
||||
const widgetId = useRef<number | null>(null);
|
||||
/** # 成员变量 脚本加载失败标志(UI 提示 + getToken 直接返回 null) **/
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
const enabled = Boolean(cfg?.enabled);
|
||||
const version = cfg?.version ?? "v2";
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !cfg?.siteKey) return;
|
||||
let cancelled = false;
|
||||
loadRecaptchaScript(cfg.siteKey, version)
|
||||
.then(() => {
|
||||
if (cancelled) return;
|
||||
if (version === "v2" && containerRef.current && window.grecaptcha && widgetId.current === null) {
|
||||
// v2 显式渲染到容器(container 内不写 data- 属性,由 render 参数指定)
|
||||
window.grecaptcha.ready(() => {
|
||||
if (!cancelled && containerRef.current && window.grecaptcha) {
|
||||
widgetId.current = window.grecaptcha.render(containerRef.current, { sitekey: cfg.siteKey });
|
||||
}
|
||||
});
|
||||
}
|
||||
setReady(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFailed(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled, version, cfg?.siteKey]);
|
||||
|
||||
/** # 函数 获取人机验证令牌(未启用返回 "";失败/未勾选返回 null) */
|
||||
const getToken = useCallback(async (): Promise<string | null> => {
|
||||
if (!enabled) return "";
|
||||
if (scriptState !== "loaded" || !window.grecaptcha) return null;
|
||||
try {
|
||||
if (version === "v3") return (await window.grecaptcha.execute(cfg!.siteKey, { action: "login" })) || null;
|
||||
const resp = window.grecaptcha.getResponse(widgetId.current ?? undefined);
|
||||
return resp || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [enabled, version, cfg?.siteKey]);
|
||||
|
||||
/** # 函数 重置 v2 复选框(登录失败后调用,重新勾选) */
|
||||
const reset = useCallback(() => {
|
||||
if (version === "v2" && window.grecaptcha && widgetId.current !== null) {
|
||||
try {
|
||||
window.grecaptcha.reset(widgetId.current);
|
||||
} catch {
|
||||
/* widget 未渲染时忽略 */
|
||||
}
|
||||
}
|
||||
}, [version]);
|
||||
|
||||
return { containerRef, getToken, reset, ready, failed };
|
||||
}
|
||||
|
||||
/** # 组件 登录表单内嵌字段:v2 渲染复选框容器,v3 隐藏执行;脚本加载失败给提示 */
|
||||
export function RecaptchaField(props: { cfg: LoginSecurityPublic | null; rec: ReturnType<typeof useRecaptcha> }) {
|
||||
if (!props.cfg?.enabled) return null;
|
||||
return (
|
||||
<>
|
||||
{props.cfg.version === "v2" ? <div ref={props.rec.containerRef} style={{ marginBottom: 16, minHeight: 78 }} /> : null}
|
||||
{props.rec.failed ? (
|
||||
<p style={{ margin: "0 0 16px", fontSize: 12, color: "#cf1322" }}>
|
||||
reCAPTCHA 脚本加载失败,请检查网络(需可访问 google.com)后刷新重试。
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* # 组件 RichEditor 现代富文本编辑器(TipTap v3 / ProseMirror)
|
||||
* 体验对齐 WordPress 经典编辑器:
|
||||
* - 工具栏:撤销重做 / 标题 / 加粗斜体下划线删除线行内代码 / 列表 / 引用 / 分隔线 /
|
||||
* 链接 / 图片(本地上传 + 媒体库选图)/ 表格 / 代码块 / 清除格式
|
||||
* - Markdown:输入即时转换(# 、**、- 等 ProseMirror input rules)+「Markdown 导入」
|
||||
* 弹窗(marked 解析为 HTML 后插入光标处或替换正文)
|
||||
* - HTML 源码模式:与 WordPress「可视化 / 文本」双 Tab 等价
|
||||
* - 图片上传走媒体库(uploadMediaFile → /uploads/YYYY/MM/),插入时带 alt
|
||||
* antd Form.Item 受控:value/onChange 由外部传入。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { EditorContent, useEditor, type Editor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import { TableKit } from "@tiptap/extension-table";
|
||||
import { marked } from "marked";
|
||||
import {
|
||||
Bold, Italic, Underline, Strikethrough, Code, CodeXml, List, ListOrdered, Quote, Minus,
|
||||
Link2, Link2Off, ImagePlus, LibraryBig, Table as TableIcon, Trash2, RemoveFormatting,
|
||||
Undo2, Redo2, FileCode2, FileText,
|
||||
} from "lucide-react";
|
||||
import { Button, Dropdown, Input, Modal, Select, Space, Tooltip } from "antd";
|
||||
import { MediaPickerModal } from "./MediaPickerModal";
|
||||
import { uploadMediaFile } from "~/utils/media-upload";
|
||||
import "~/styles/admin-editor.css";
|
||||
|
||||
/** # 类型 组件属性(value 为 HTML 字符串;受控) **/
|
||||
export type RichEditorProps = {
|
||||
/** # 成员变量 受控 HTML 值 **/
|
||||
value?: string;
|
||||
/** # 成员变量 变更回调(antd Form.Item 注入) **/
|
||||
onChange?: (html: string) => void;
|
||||
/** # 成员变量 编辑区最小高度(px,默认 320) **/
|
||||
minHeight?: number;
|
||||
};
|
||||
|
||||
/** # 成员变量 工具栏小按钮通用类(样式见 admin-editor.css) **/
|
||||
const TIP_CLS = "re-btn";
|
||||
|
||||
/** # 函数 工具栏按钮(激活态高亮) */
|
||||
function TbButton(props: { onAct: () => void; active?: boolean; disabled?: boolean; tip: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Tooltip title={props.tip} mouseEnterDelay={0.4}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={props.tip}
|
||||
className={TIP_CLS + (props.active ? " is-active" : "")}
|
||||
disabled={props.disabled}
|
||||
onClick={props.onAct}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 函数 RichEditor 主组件 */
|
||||
export function RichEditor(props: RichEditorProps) {
|
||||
/** # 成员变量 最近一次向外发出的 HTML(防止受控回写与内部更新打环) **/
|
||||
const lastHtml = useRef<string>("");
|
||||
/** # 成员变量 HTML 源码模式开关与草稿 **/
|
||||
const [srcMode, setSrcMode] = useState(false);
|
||||
const [srcDraft, setSrcDraft] = useState("");
|
||||
/** # 成员变量 链接输入弹层(url 草稿) **/
|
||||
const [linkOpen, setLinkOpen] = useState(false);
|
||||
const [linkUrl, setLinkUrl] = useState("");
|
||||
/** # 成员变量 媒体库弹窗 / Markdown 弹窗 **/
|
||||
const [mediaOpen, setMediaOpen] = useState(false);
|
||||
const [mdOpen, setMdOpen] = useState(false);
|
||||
const [mdText, setMdText] = useState("");
|
||||
/** # 成员变量 隐藏的本地图片选择 input **/
|
||||
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||
/** # 成员变量 提示错误(上传失败等) **/
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const editor = useEditor({
|
||||
// SSR 环境必须 false:等客户端水合后创建,避免双端不一致
|
||||
immediatelyRender: false,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
// 站内跳转不走编辑器内打开
|
||||
link: { openOnClick: false, autolink: true },
|
||||
}),
|
||||
Image.configure({ inline: false, allowBase64: false }),
|
||||
TableKit.configure({ table: { resizable: false } }),
|
||||
],
|
||||
content: props.value ?? "",
|
||||
editorProps: {
|
||||
attributes: { class: "rich-content", spellcheck: "false" },
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
const html = editor.isEmpty ? "" : editor.getHTML();
|
||||
lastHtml.current = html;
|
||||
props.onChange?.(html);
|
||||
},
|
||||
});
|
||||
|
||||
// 受控值外部变化时写回编辑器(如修订恢复后 router.invalidate → loader 新值)
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
if ((props.value ?? "") !== lastHtml.current) {
|
||||
editor.commands.setContent(props.value ?? "", { emitUpdate: false });
|
||||
lastHtml.current = props.value ?? "";
|
||||
}
|
||||
}, [props.value, editor]);
|
||||
|
||||
/** # 函数 通用链式命令执行(聚焦 + 命令 + run) */
|
||||
const act = useCallback((fn: (chain: ReturnType<Editor["chain"]>) => void) => {
|
||||
if (!editor) return;
|
||||
const chain = editor.chain().focus();
|
||||
fn(chain);
|
||||
chain.run();
|
||||
}, [editor]);
|
||||
|
||||
/** # 函数 打开链接弹层(回填现有 href;无选区时以 URL 文本插入) */
|
||||
function openLink() {
|
||||
if (!editor) return;
|
||||
const existing = editor.getAttributes("link").href as string | undefined;
|
||||
setLinkUrl(existing ?? "https://");
|
||||
setLinkOpen(true);
|
||||
}
|
||||
|
||||
/** # 函数 应用链接:有选区 setLink,无选区插入文本链接 */
|
||||
function applyLink() {
|
||||
if (!editor) return;
|
||||
const url = linkUrl.trim();
|
||||
if (!url || url === "https://") {
|
||||
setLinkOpen(false);
|
||||
return;
|
||||
}
|
||||
if (editor.state.selection.empty) {
|
||||
act((c) => c.insertContent(`<a href="${url}">${url}</a>`));
|
||||
} else {
|
||||
act((c) => c.setLink({ href: url }));
|
||||
}
|
||||
setLinkOpen(false);
|
||||
}
|
||||
|
||||
/** # 函数 本地图片选择后上传并插入(带宽高提示) */
|
||||
async function handlePickFile(file: File) {
|
||||
setErr("");
|
||||
try {
|
||||
const res = await uploadMediaFile(file);
|
||||
act((c) => c.setImage({ src: res.url, alt: file.name.replace(/\.[^.]+$/, "") }));
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : "上传失败");
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数 媒体库选中后插入 */
|
||||
function insertMedia(url: string, alt: string) {
|
||||
act((c) => c.setImage({ src: url, alt }));
|
||||
}
|
||||
|
||||
/** # 函数 Markdown 解析插入(insert 到光标处 / 替换全文) */
|
||||
function applyMarkdown(replace: boolean) {
|
||||
const html = marked.parse(mdText) as string;
|
||||
if (replace) {
|
||||
editor?.commands.setContent(html, { emitUpdate: true });
|
||||
lastHtml.current = editor?.isEmpty ? "" : (editor?.getHTML() ?? "");
|
||||
props.onChange?.(lastHtml.current);
|
||||
} else {
|
||||
act((c) => c.insertContent(html));
|
||||
}
|
||||
setMdOpen(false);
|
||||
setMdText("");
|
||||
}
|
||||
|
||||
/** # 函数 切换到 HTML 源码模式(草稿取当前 HTML) */
|
||||
function enterSrc() {
|
||||
if (!editor) return;
|
||||
setSrcDraft(editor.isEmpty ? "" : editor.getHTML());
|
||||
setSrcMode(true);
|
||||
}
|
||||
|
||||
/** # 函数 源码模式保存回编辑器 */
|
||||
function applySrc() {
|
||||
if (!editor) return;
|
||||
editor.commands.setContent(srcDraft, { emitUpdate: true });
|
||||
lastHtml.current = editor.isEmpty ? "" : editor.getHTML();
|
||||
props.onChange?.(lastHtml.current);
|
||||
setSrcMode(false);
|
||||
}
|
||||
|
||||
const inTable = Boolean(editor?.isActive("table"));
|
||||
|
||||
return (
|
||||
<div className="rich-editor">
|
||||
<div className="re-toolbar" role="toolbar" aria-label="富文本工具栏">
|
||||
<TbButton tip="撤销" onAct={() => act((c) => c.undo())} disabled={!editor?.can().undo()}><Undo2 size={15} aria-hidden /></TbButton>
|
||||
<TbButton tip="重做" onAct={() => act((c) => c.redo())} disabled={!editor?.can().redo()}><Redo2 size={15} aria-hidden /></TbButton>
|
||||
<i className="re-sep" aria-hidden />
|
||||
<Select
|
||||
size="small"
|
||||
style={{ width: 108 }}
|
||||
value={editor?.isActive("heading", { level: 2 }) ? "h2" : editor?.isActive("heading", { level: 3 }) ? "h3" : editor?.isActive("heading", { level: 4 }) ? "h4" : "p"}
|
||||
onChange={(v) => {
|
||||
if (v === "p") act((c) => c.setParagraph());
|
||||
else act((c) => c.setHeading({ level: Number(v.slice(1)) as 2 | 3 | 4 }));
|
||||
}}
|
||||
options={[
|
||||
{ value: "p", label: "正文" },
|
||||
{ value: "h2", label: "标题 H2" },
|
||||
{ value: "h3", label: "标题 H3" },
|
||||
{ value: "h4", label: "标题 H4" },
|
||||
]}
|
||||
/>
|
||||
<TbButton tip="加粗" onAct={() => act((c) => c.toggleBold())} active={editor?.isActive("bold")}><Bold size={15} aria-hidden /></TbButton>
|
||||
<TbButton tip="斜体" onAct={() => act((c) => c.toggleItalic())} active={editor?.isActive("italic")}><Italic size={15} aria-hidden /></TbButton>
|
||||
<TbButton tip="下划线" onAct={() => act((c) => c.toggleUnderline())} active={editor?.isActive("underline")}><Underline size={15} aria-hidden /></TbButton>
|
||||
<TbButton tip="删除线" onAct={() => act((c) => c.toggleStrike())} active={editor?.isActive("strike")}><Strikethrough size={15} aria-hidden /></TbButton>
|
||||
<TbButton tip="行内代码" onAct={() => act((c) => c.toggleCode())} active={editor?.isActive("code")}><Code size={15} aria-hidden /></TbButton>
|
||||
<i className="re-sep" aria-hidden />
|
||||
<TbButton tip="无序列表" onAct={() => act((c) => c.toggleBulletList())} active={editor?.isActive("bulletList")}><List size={15} aria-hidden /></TbButton>
|
||||
<TbButton tip="有序列表" onAct={() => act((c) => c.toggleOrderedList())} active={editor?.isActive("orderedList")}><ListOrdered size={15} aria-hidden /></TbButton>
|
||||
<TbButton tip="引用" onAct={() => act((c) => c.toggleBlockquote())} active={editor?.isActive("blockquote")}><Quote size={15} aria-hidden /></TbButton>
|
||||
<TbButton tip="分隔线" onAct={() => act((c) => c.setHorizontalRule())}><Minus size={15} aria-hidden /></TbButton>
|
||||
<TbButton tip="代码块" onAct={() => act((c) => c.toggleCodeBlock())} active={editor?.isActive("codeBlock")}><CodeXml size={15} aria-hidden /></TbButton>
|
||||
<i className="re-sep" aria-hidden />
|
||||
<TbButton tip="链接" onAct={openLink} active={editor?.isActive("link")}><Link2 size={15} aria-hidden /></TbButton>
|
||||
<TbButton tip="取消链接" onAct={() => act((c) => c.unsetLink())} disabled={!editor?.isActive("link")}><Link2Off size={15} aria-hidden /></TbButton>
|
||||
<TbButton tip="插入图片(本地上传)" onAct={() => fileRef.current?.click()}><ImagePlus size={15} aria-hidden /></TbButton>
|
||||
<TbButton tip="媒体库选图" onAct={() => setMediaOpen(true)}><LibraryBig size={15} aria-hidden /></TbButton>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: "ins", label: "插入表格 3×3" },
|
||||
{ key: "row+", label: "下方插入行", disabled: !inTable },
|
||||
{ key: "col+", label: "右侧插入列", disabled: !inTable },
|
||||
{ key: "row-", label: "删除当前行", disabled: !inTable },
|
||||
{ key: "col-", label: "删除当前列", disabled: !inTable },
|
||||
{ key: "del", label: "删除表格", disabled: !inTable },
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === "ins") act((c) => c.insertTable({ rows: 3, cols: 3, withHeaderRow: true }));
|
||||
else if (key === "row+") act((c) => c.addRowAfter());
|
||||
else if (key === "col+") act((c) => c.addColumnAfter());
|
||||
else if (key === "row-") act((c) => c.deleteRow());
|
||||
else if (key === "col-") act((c) => c.deleteColumn());
|
||||
else if (key === "del") act((c) => c.deleteTable());
|
||||
},
|
||||
}}
|
||||
>
|
||||
<button type="button" className={TIP_CLS} aria-label="表格" title="表格">
|
||||
<TableIcon size={15} aria-hidden />
|
||||
</button>
|
||||
</Dropdown>
|
||||
<i className="re-sep" aria-hidden />
|
||||
<TbButton tip="清除格式" onAct={() => act((c) => c.unsetAllMarks().clearNodes())}><RemoveFormatting size={15} aria-hidden /></TbButton>
|
||||
<TbButton tip="Markdown 导入" onAct={() => setMdOpen(true)}><FileText size={15} aria-hidden /></TbButton>
|
||||
<TbButton tip={srcMode ? "返回可视化编辑" : "HTML 源码"} onAct={() => (srcMode ? applySrc() : enterSrc())} active={srcMode}><FileCode2 size={15} aria-hidden /></TbButton>
|
||||
</div>
|
||||
|
||||
{/* 隐藏文件选择(本地图片上传) */}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void handlePickFile(f);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
{err ? <div className="re-error" role="alert">{err}</div> : null}
|
||||
|
||||
{srcMode ? (
|
||||
<Input.TextArea
|
||||
value={srcDraft}
|
||||
onChange={(e) => setSrcDraft(e.target.value)}
|
||||
rows={16}
|
||||
style={{ fontFamily: "ui-monospace, monospace", fontSize: 12.5, borderRadius: 0 }}
|
||||
aria-label="HTML 源码"
|
||||
/>
|
||||
) : (
|
||||
<EditorContent editor={editor} style={{ minHeight: props.minHeight ?? 320 }} />
|
||||
)}
|
||||
|
||||
{/* 链接弹层 */}
|
||||
<Modal title="插入 / 编辑链接" open={linkOpen} onOk={applyLink} onCancel={() => setLinkOpen(false)} okText="确定" cancelText="取消" width={440}>
|
||||
<Space.Compact style={{ width: "100%" }}>
|
||||
<Input value={linkUrl} onChange={(e) => setLinkUrl(e.target.value)} placeholder="https://…" onPressEnter={applyLink} />
|
||||
<Button danger onClick={() => { act((c) => c.unsetLink()); setLinkOpen(false); }}>
|
||||
移除
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Modal>
|
||||
|
||||
{/* 媒体库选图 */}
|
||||
<MediaPickerModal open={mediaOpen} onClose={() => setMediaOpen(false)} onSelect={insertMedia} />
|
||||
|
||||
{/* Markdown 导入 */}
|
||||
<Modal
|
||||
title="导入 Markdown"
|
||||
open={mdOpen}
|
||||
onCancel={() => setMdOpen(false)}
|
||||
width={680}
|
||||
footer={[
|
||||
<Button key="c" onClick={() => setMdOpen(false)}>取消</Button>,
|
||||
<Button key="i" type="primary" disabled={!mdText.trim()} onClick={() => applyMarkdown(false)}>插入到光标处</Button>,
|
||||
<Button key="r" danger disabled={!mdText.trim()} onClick={() => applyMarkdown(true)}>替换正文</Button>,
|
||||
]}
|
||||
>
|
||||
<p style={{ margin: "0 0 8px", fontSize: 12, color: "#8c8c8c" }}>
|
||||
支持 # 标题、**加粗**、- 列表、[链接](url)、、表格等(marked 解析)。日常输入时编辑器已自动转换 Markdown 快捷语法。
|
||||
</p>
|
||||
<Input.TextArea value={mdText} onChange={(e) => setMdText(e.target.value)} rows={12} placeholder={"## 标题\n\n正文 **加粗** …"} aria-label="Markdown 内容" />
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -466,32 +466,53 @@ function LinkedinGlyph() {
|
||||
);
|
||||
}
|
||||
|
||||
/** # 组件 移动端导航项(子菜单平铺展示) */
|
||||
/** # 组件 移动端导航项(手风琴:顶级项可点跳转,子菜单默认收起、点箭头展开)
|
||||
* 全量平铺在完整菜单数据下过长(Product 一项 20+ 子项),改为按组折叠。
|
||||
*/
|
||||
function MobileNavItem({ node, onNavigate }: { node: MenuNode; onNavigate: () => void }) {
|
||||
const href = normalizeUrl(node.url);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasKids = node.children.length > 0;
|
||||
return (
|
||||
<div>
|
||||
{href ? (
|
||||
<a href={href} onClick={onNavigate} className="block border-b border-line px-1 py-[9px] font-medium text-ink">
|
||||
{node.title}
|
||||
</a>
|
||||
) : (
|
||||
<span className="block border-b border-line px-1 py-[9px] font-medium text-ink">{node.title}</span>
|
||||
)}
|
||||
{node.children.map((c) => {
|
||||
const ch = normalizeUrl(c.url);
|
||||
return (
|
||||
<div key={c.id}>
|
||||
{ch ? (
|
||||
<a href={ch} onClick={onNavigate} className="block border-b border-line py-[9px] pl-[18px] font-normal text-muted-foreground">
|
||||
{c.title}
|
||||
</a>
|
||||
) : (
|
||||
<span className="block border-b border-line py-[9px] pl-[18px] font-normal text-muted-foreground">{c.title}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex items-center border-b border-line">
|
||||
{href ? (
|
||||
<a href={href} onClick={onNavigate} className="min-w-0 flex-1 px-1 py-[9px] font-medium text-ink">
|
||||
{node.title}
|
||||
</a>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 px-1 py-[9px] font-medium text-ink">{node.title}</span>
|
||||
)}
|
||||
{hasKids ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={expanded ? `收起 ${node.title}` : `展开 ${node.title}`}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex h-9 w-10 shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent p-0 text-muted-foreground"
|
||||
>
|
||||
<ChevronRight size={18} aria-hidden className={"transition-transform " + (expanded ? "rotate-90" : "")} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{hasKids && expanded ? (
|
||||
<div className="bg-soft">
|
||||
{node.children.map((c) => {
|
||||
const ch = normalizeUrl(c.url);
|
||||
return (
|
||||
<div key={c.id}>
|
||||
{ch ? (
|
||||
<a href={ch} onClick={onNavigate} className="block border-b border-line py-[9px] pl-[18px] pr-3 font-normal text-muted-foreground">
|
||||
{c.title}
|
||||
</a>
|
||||
) : (
|
||||
<span className="block border-b border-line py-[9px] pl-[18px] pr-3 font-normal text-muted-foreground">{c.title}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+23
-3
@@ -67,13 +67,33 @@ export const getCurrentUser = createServerFn({ method: "GET" }).handler(async ()
|
||||
return getSessionUser(token);
|
||||
});
|
||||
|
||||
/** # 函数 Server Function:登录 */
|
||||
/** # 函数 Server Function:登录(含登录安全:失败锁定 + reCAPTCHA 服务端校验)
|
||||
* reCAPTCHA 启用时前端必须回传 recaptchaToken(由公开配置 serverFn 驱动组件渲染)。
|
||||
*/
|
||||
export const loginFn = createServerFn({ method: "POST" })
|
||||
.validator((d: { email: string; password: string }) => d)
|
||||
.validator((d: { email: string; password: string; recaptchaToken?: string }) => d)
|
||||
.handler(async ({ data }) => {
|
||||
const { setCookie } = await import("@tanstack/react-start/server");
|
||||
const { getSecurityConfig, verifyRecaptcha, loginLockRemaining, recordLoginFail, clearLoginFails } = await import("./security");
|
||||
const cfg = await getSecurityConfig();
|
||||
const email = data.email.toLowerCase().trim();
|
||||
// 失败锁定:锁定期内直接拒绝,不做密码校验
|
||||
const remain = loginLockRemaining(email);
|
||||
if (remain > 0) {
|
||||
return { ok: false as const, error: `登录失败次数过多,已锁定,请 ${Math.ceil(remain / 60)} 分钟后再试` };
|
||||
}
|
||||
// reCAPTCHA 校验(开关开启且密钥配齐才生效;服务不可用 fail closed)
|
||||
if (cfg.recaptchaEnabled) {
|
||||
const r = await verifyRecaptcha(data.recaptchaToken ?? "", cfg);
|
||||
if (!r.ok) return { ok: false as const, error: r.error ?? "人机验证未通过" };
|
||||
}
|
||||
const user = await verifyLogin(data.email, data.password);
|
||||
if (!user) return { ok: false as const, error: "邮箱或密码错误" };
|
||||
if (!user) {
|
||||
const lockedSec = recordLoginFail(email, cfg);
|
||||
if (lockedSec > 0) return { ok: false as const, error: `连续失败 ${cfg.maxLoginAttempts} 次,账号已锁定 ${cfg.lockMinutes} 分钟` };
|
||||
return { ok: false as const, error: "邮箱或密码错误" };
|
||||
}
|
||||
clearLoginFails(email);
|
||||
const token = await createSession(user.id);
|
||||
setCookie(SESSION_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* # 模块 登录安全(settings 表 key=security)
|
||||
* reCAPTCHA(v2 复选框 / v3 透明)服务端校验 + 登录失败次数锁定(进程内存实现)。
|
||||
* 配置结构:{ recaptchaEnabled, recaptchaVersion, recaptchaSiteKey, recaptchaSecretKey,
|
||||
* recaptchaScoreThreshold, maxLoginAttempts, lockMinutes }
|
||||
*/
|
||||
import { db } from "~/db";
|
||||
import { settings } from "~/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
|
||||
/** # 类型 登录安全配置(settings.security,带缺省值兜底) **/
|
||||
export type SecurityConfig = {
|
||||
/** # 成员变量 reCAPTCHA 总开关(缺省关闭,不影响现有登录) **/
|
||||
recaptchaEnabled: boolean;
|
||||
/** # 成员变量 reCAPTCHA 版本:v2 复选框 / v3 透明评分 **/
|
||||
recaptchaVersion: "v2" | "v3";
|
||||
/** # 成员变量 站点密钥(公开,前端组件用) **/
|
||||
recaptchaSiteKey: string;
|
||||
/** # 成员变量 服务端密钥(仅服务端使用,不回传前端) **/
|
||||
recaptchaSecretKey: string;
|
||||
/** # 成员变量 v3 评分阈值(0~1,低于判定为机器人) **/
|
||||
recaptchaScoreThreshold: number;
|
||||
/** # 成员变量 连续登录失败上限(达到即锁定) **/
|
||||
maxLoginAttempts: number;
|
||||
/** # 成员变量 锁定时长(分钟) **/
|
||||
lockMinutes: number;
|
||||
};
|
||||
|
||||
/** # 成员变量 登录安全缺省配置 **/
|
||||
export const DEFAULT_SECURITY: SecurityConfig = {
|
||||
recaptchaEnabled: false,
|
||||
recaptchaVersion: "v2",
|
||||
recaptchaSiteKey: "",
|
||||
recaptchaSecretKey: "",
|
||||
recaptchaScoreThreshold: 0.5,
|
||||
maxLoginAttempts: 10,
|
||||
lockMinutes: 15,
|
||||
};
|
||||
|
||||
/** # 函数 读取登录安全配置(settings.security 与缺省值合并;写坏/缺失均兜底) */
|
||||
export async function getSecurityConfig(): Promise<SecurityConfig> {
|
||||
try {
|
||||
const [row] = await db.select().from(settings).where(eq(settings.key, "security"));
|
||||
const raw = (row?.value ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
recaptchaEnabled: raw.recaptchaEnabled === true,
|
||||
recaptchaVersion: raw.recaptchaVersion === "v3" ? "v3" : "v2",
|
||||
recaptchaSiteKey: typeof raw.recaptchaSiteKey === "string" ? raw.recaptchaSiteKey : "",
|
||||
recaptchaSecretKey: typeof raw.recaptchaSecretKey === "string" ? raw.recaptchaSecretKey : "",
|
||||
recaptchaScoreThreshold: Number.isFinite(Number(raw.recaptchaScoreThreshold))
|
||||
? Math.min(1, Math.max(0, Number(raw.recaptchaScoreThreshold)))
|
||||
: DEFAULT_SECURITY.recaptchaScoreThreshold,
|
||||
maxLoginAttempts: Number.isFinite(Number(raw.maxLoginAttempts)) && Number(raw.maxLoginAttempts) > 0
|
||||
? Math.floor(Number(raw.maxLoginAttempts))
|
||||
: DEFAULT_SECURITY.maxLoginAttempts,
|
||||
lockMinutes: Number.isFinite(Number(raw.lockMinutes)) && Number(raw.lockMinutes) > 0
|
||||
? Math.floor(Number(raw.lockMinutes))
|
||||
: DEFAULT_SECURITY.lockMinutes,
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_SECURITY };
|
||||
}
|
||||
}
|
||||
|
||||
/** # 类型 reCAPTCHA 校验结果 **/
|
||||
export type RecaptchaResult = { ok: boolean; error?: string; score?: number };
|
||||
|
||||
/** # 函数 reCAPTCHA 服务端校验(Google siteverify,6s 超时)
|
||||
* 参数 token:前端组件回传的令牌;cfg:登录安全配置
|
||||
* 返回值 ok=true 校验通过;ok=false 带 error 说明(网络失败按失败处理,fail closed)
|
||||
*/
|
||||
export async function verifyRecaptcha(token: string, cfg: SecurityConfig): Promise<RecaptchaResult> {
|
||||
if (!cfg.recaptchaSecretKey) return { ok: false, error: "未配置 reCAPTCHA 服务端密钥" };
|
||||
if (!token) return { ok: false, error: "请先完成人机验证" };
|
||||
try {
|
||||
const body = new URLSearchParams({ secret: cfg.recaptchaSecretKey, response: token });
|
||||
const res = await fetch("https://www.google.com/recaptcha/api/siteverify", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
signal: AbortSignal.timeout(6000),
|
||||
});
|
||||
const json = (await res.json()) as { success?: boolean; score?: number; "error-codes"?: string[] };
|
||||
if (!json.success) {
|
||||
return { ok: false, error: "人机验证未通过,请重试" };
|
||||
}
|
||||
// v3 额外要求评分达到阈值
|
||||
if (cfg.recaptchaVersion === "v3" && typeof json.score === "number" && json.score < cfg.recaptchaScoreThreshold) {
|
||||
return { ok: false, error: "人机验证评分过低,请稍后重试", score: json.score };
|
||||
}
|
||||
return { ok: true, score: json.score };
|
||||
} catch {
|
||||
return { ok: false, error: "验证服务暂时不可用,请稍后重试" };
|
||||
}
|
||||
}
|
||||
|
||||
/** # 类型 登录失败记录(邮箱 → 计数与锁定截止时间;进程内存,重启即清零) **/
|
||||
const loginFails = new Map<string, { count: number; lockedUntil: number }>();
|
||||
|
||||
/** # 成员变量 失败记录清理周期(10 分钟批量清一次过期项,防 Map 无限增长) **/
|
||||
let lastSweep = 0;
|
||||
|
||||
/** # 函数 判断邮箱是否处于登录锁定中(锁定返回剩余秒数) */
|
||||
export function loginLockRemaining(email: string): number {
|
||||
const rec = loginFails.get(email.toLowerCase().trim());
|
||||
if (!rec) return 0;
|
||||
const remainMs = rec.lockedUntil - Date.now();
|
||||
if (remainMs <= 0) {
|
||||
loginFails.delete(email.toLowerCase().trim());
|
||||
return 0;
|
||||
}
|
||||
return Math.ceil(remainMs / 1000);
|
||||
}
|
||||
|
||||
/** # 函数 记录一次登录失败;达到上限写入锁定截止时间
|
||||
* 参数 email:登录邮箱;cfg:配置(取上限与锁定分钟数)
|
||||
* 返回值 锁定中的剩余秒数(>0 表示本次失败触发了锁定)
|
||||
*/
|
||||
export function recordLoginFail(email: string, cfg: SecurityConfig): number {
|
||||
const key = email.toLowerCase().trim();
|
||||
const now = Date.now();
|
||||
// 周期清理过期记录
|
||||
if (now - lastSweep > 600_000) {
|
||||
lastSweep = now;
|
||||
for (const [k, v] of loginFails) if (v.lockedUntil <= now) loginFails.delete(k);
|
||||
}
|
||||
const rec = loginFails.get(key) ?? { count: 0, lockedUntil: 0 };
|
||||
rec.count += 1;
|
||||
let lockedSec = 0;
|
||||
if (rec.count >= cfg.maxLoginAttempts) {
|
||||
rec.lockedUntil = now + cfg.lockMinutes * 60_000;
|
||||
lockedSec = cfg.lockMinutes * 60;
|
||||
}
|
||||
loginFails.set(key, rec);
|
||||
return lockedSec;
|
||||
}
|
||||
|
||||
/** # 函数 登录成功后清除失败计数 */
|
||||
export function clearLoginFails(email: string): void {
|
||||
loginFails.delete(email.toLowerCase().trim());
|
||||
}
|
||||
|
||||
/** # 函数 Server Function:登录页公开配置(仅暴露启用状态/版本/站点密钥,不含服务端密钥)
|
||||
* 任何未登录访客可读(登录页渲染 reCAPTCHA 组件需要)。
|
||||
*/
|
||||
export const getLoginSecurityPublic = createServerFn({ method: "GET" }).handler(async () => {
|
||||
const cfg = await getSecurityConfig();
|
||||
const usable = cfg.recaptchaEnabled && cfg.recaptchaSiteKey && cfg.recaptchaSecretKey;
|
||||
return { enabled: Boolean(usable), version: cfg.recaptchaVersion, siteKey: cfg.recaptchaSiteKey };
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* RichEditor(TipTap)后台样式 —— 仅作用域 .rich-editor,不依赖 app.css。
|
||||
* 工具栏:浅灰底、分组分隔线、小方按钮(lucide 15px)、激活态蓝;
|
||||
* 正文:贴近 WordPress 可视化区的排版(标题/列表/引用/表格/代码/图片)。
|
||||
*/
|
||||
|
||||
.rich-editor {
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rich-editor:focus-within {
|
||||
border-color: #1677ff;
|
||||
}
|
||||
|
||||
.rich-editor .re-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
}
|
||||
.rich-editor .re-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.rich-editor .re-btn:hover {
|
||||
background: #eee;
|
||||
}
|
||||
.rich-editor .re-btn.is-active {
|
||||
background: #e6f4ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
.rich-editor .re-btn:disabled {
|
||||
color: #bbb;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.rich-editor .re-sep {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: #e0e0e0;
|
||||
margin: 0 5px;
|
||||
}
|
||||
.rich-editor .re-error {
|
||||
background: #fff1f0;
|
||||
color: #cf1322;
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.rich-editor .rich-content {
|
||||
min-height: 320px;
|
||||
padding: 12px 16px;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
color: #333;
|
||||
}
|
||||
.rich-editor .rich-content p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.rich-editor .rich-content h2 {
|
||||
font-size: 22px;
|
||||
margin: 18px 0 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rich-editor .rich-content h3 {
|
||||
font-size: 18px;
|
||||
margin: 16px 0 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rich-editor .rich-content h4 {
|
||||
font-size: 16px;
|
||||
margin: 14px 0 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rich-editor .rich-content ul,
|
||||
.rich-editor .rich-content ol {
|
||||
padding-left: 24px;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.rich-editor .rich-content blockquote {
|
||||
margin: 0 0 10px;
|
||||
padding: 6px 14px;
|
||||
border-left: 3px solid #d9d9d9;
|
||||
color: #666;
|
||||
background: #fafafa;
|
||||
}
|
||||
.rich-editor .rich-content pre {
|
||||
background: #f6f8fa;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
font-size: 12.5px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.rich-editor .rich-content code {
|
||||
background: #f1f3f5;
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
.rich-editor .rich-content pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
.rich-editor .rich-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.rich-editor .rich-content a {
|
||||
color: #1677ff;
|
||||
}
|
||||
.rich-editor .rich-content hr {
|
||||
border: 0;
|
||||
border-top: 1px solid #e8e8e8;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.rich-editor .rich-content table {
|
||||
border-collapse: collapse;
|
||||
margin: 0 0 12px;
|
||||
width: 100%;
|
||||
}
|
||||
.rich-editor .rich-content th,
|
||||
.rich-editor .rich-content td {
|
||||
border: 1px solid #e0e0e0;
|
||||
padding: 6px 10px;
|
||||
min-width: 60px;
|
||||
text-align: left;
|
||||
}
|
||||
.rich-editor .rich-content th {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rich-editor .rich-content .selectedCell::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(22, 119, 255, 0.08);
|
||||
pointer-events: none;
|
||||
}
|
||||
.rich-editor .rich-content th,
|
||||
.rich-editor .rich-content td {
|
||||
position: relative;
|
||||
}
|
||||
.rich-editor .rich-content p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
color: #bbb;
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -1187,3 +1187,40 @@ body.dialog-prevent-scroll { overflow: hidden; }
|
||||
[data-el="3361c18"] > div,
|
||||
[data-el="3317056"] > div,
|
||||
[data-el="5938d26"] > div { width: auto !important; }
|
||||
|
||||
/* ===== 移动端自适应护栏(v0.14.0;viewport meta 落地后按 390px 实测收敛) =====
|
||||
* 1) 首页 Elementor Advance Tabs 横向标签条 min-content 撑到 720:≤767px 改横滑
|
||||
* 2) 内容模板里 width:100% + 左 margin 的内联信息块(联系页地址等 46px 溢出):
|
||||
* ≤767px 收缩为自适应(!important 覆盖内联样式)
|
||||
* 3) iframe(视频/地图嵌入)不许撑破正文列
|
||||
*/
|
||||
@media (max-width: 767px) {
|
||||
.eael-advance-tabs .eael-tabs-nav {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.eael-advance-tabs .eael-tabs-nav > ul {
|
||||
min-width: max-content;
|
||||
}
|
||||
.eael-advance-tabs .eael-tabs-nav > ul li {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* width:100% 且带水平 margin 的块(Elementor 内联样式)在窄屏改自适应宽 */
|
||||
.content-html div[style*="width:100%"][style*="margin"] {
|
||||
width: auto !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
.content-html iframe {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Shop 产品网格响应式降列(原为固定 4 列,手机每卡仅 ~90px) */
|
||||
@media (max-width: 1024px) {
|
||||
[data-el="d761846"] ul.products { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
[data-el="d761846"] ul.products { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
[data-el="d761846"] .woocommerce-ordering { position: static; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* # 模块 媒体上传(后台共用)
|
||||
* adminUploadMedia 走 base64 JSON RPC(server fn 不走 multipart);大文件需分块 btoa。
|
||||
*/
|
||||
import { adminUploadMedia } from "~/server/admin";
|
||||
|
||||
/** # 类型 上传结果(url 为 /uploads/... 相对路径) **/
|
||||
export type MediaUploadResult = { id: number; url: string; width: number | null; height: number | null };
|
||||
|
||||
/** # 函数 上传本地文件到媒体库(写 public/uploads/YYYY/MM/ 并登记 media 表)
|
||||
* 参数 file:浏览器 File 对象(≤20MB)
|
||||
* 返回值 { id, url, width, height };失败抛错(调用方 message.error)
|
||||
*/
|
||||
export async function uploadMediaFile(file: File): Promise<MediaUploadResult> {
|
||||
const buf = new Uint8Array(await file.arrayBuffer());
|
||||
// 分块 btoa:直接 btoa(二进制串) 在大文件下栈溢出,按 0x8000 分块
|
||||
let bin = "";
|
||||
const CHUNK = 0x8000;
|
||||
for (let i = 0; i < buf.length; i += CHUNK) bin += String.fromCharCode(...buf.subarray(i, i + CHUNK));
|
||||
return adminUploadMedia({ data: { filename: file.name, mime: file.type || "", dataBase64: btoa(bin) } });
|
||||
}
|
||||
Reference in New Issue
Block a user