From 62e6bcfcd209efd8bcc5900fd0318f5876dce63c Mon Sep 17 00:00:00 2001 From: bigcode Date: Wed, 4 Nov 2026 14:08:49 +0800 Subject: [PATCH] =?UTF-8?q?v0.14.0=20=E8=AE=BE=E7=BD=AE=E9=A1=B5=20Tabs=20?= =?UTF-8?q?=E8=A1=A8=E5=8D=95+=E7=99=BB=E5=BD=95=E5=AE=89=E5=85=A8(reCAPTC?= =?UTF-8?q?HA/=E9=94=81=E5=AE=9A)=EF=BC=9BTipTap=20=E5=AF=8C=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E7=BC=96=E8=BE=91=E5=99=A8(Markdown/=E5=AA=92?= =?UTF-8?q?=E4=BD=93=E5=BA=93)=EF=BC=9B=E5=89=8D=E5=8F=B0=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E7=AB=AF=E8=87=AA=E9=80=82=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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 列、移动菜单手风琴 --- bun.lock | 115 ++++++++ docs/admin-crm.md | 35 +++ package.json | 11 +- src/components/admin/MediaPickerModal.tsx | 116 ++++++++ src/components/admin/Recaptcha.tsx | 142 +++++++++ src/components/admin/RichEditor.tsx | 314 ++++++++++++++++++++ src/components/website/Header.tsx | 65 +++-- src/routes/__root.tsx | 3 +- src/routes/admin.contents.$id.tsx | 7 +- src/routes/admin.products.$id.tsx | 5 +- src/routes/admin.settings.tsx | 332 +++++++++++++++++++--- src/routes/admin.tsx | 35 ++- src/routes/my-account.tsx | 30 +- src/server/auth.ts | 26 +- src/server/security.ts | 151 ++++++++++ src/styles/admin-editor.css | 166 +++++++++++ src/styles/app.css | 37 +++ src/utils/media-upload.ts | 21 ++ 18 files changed, 1530 insertions(+), 81 deletions(-) create mode 100644 src/components/admin/MediaPickerModal.tsx create mode 100644 src/components/admin/Recaptcha.tsx create mode 100644 src/components/admin/RichEditor.tsx create mode 100644 src/server/security.ts create mode 100644 src/styles/admin-editor.css create mode 100644 src/utils/media-upload.ts diff --git a/bun.lock b/bun.lock index b35c07dd2..677106db6 100644 --- a/bun.lock +++ b/bun.lock @@ -16,6 +16,14 @@ "@tanstack/react-router": "^1.170.46", "@tanstack/react-router-devtools": "^1.167.2", "@tanstack/react-start": "^1.169.4", + "@tiptap/extension-image": "^3.32.2", + "@tiptap/extension-table": "^3.32.2", + "@tiptap/extension-table-cell": "^3.32.2", + "@tiptap/extension-table-header": "^3.32.2", + "@tiptap/extension-table-row": "^3.32.2", + "@tiptap/pm": "^3.32.2", + "@tiptap/react": "^3.32.2", + "@tiptap/starter-kit": "^3.32.2", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.2", @@ -26,6 +34,7 @@ "drizzle-kit": "^0.31.11", "drizzle-orm": "^0.45.4", "lucide-react": "^1.64.0", + "marked": "^18.1.3", "mysql2": "^3.24.6", "react": "^19.3.1", "react-dom": "^19.3.1", @@ -436,12 +445,80 @@ "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="], + "@tiptap/core": ["@tiptap/core@3.32.2", "", { "peerDependencies": { "@tiptap/pm": "3.32.2" } }, "sha512-EitM8KY3agYfnp/bqhfxcaReVq4CUqVzMqoSIl7xSfYABTGYoTDtVXUdYSGutcCBFjc86BbXjrOlDlqkgC8BsQ=="], + + "@tiptap/extension-blockquote": ["@tiptap/extension-blockquote@3.32.2", "", { "peerDependencies": { "@tiptap/core": "3.32.2", "@tiptap/pm": "3.32.2" } }, "sha512-VLcdnbOm7G52NpUhgbyo3XIOz6Eky7/DyGYuYnCpEUe0TE5xk7Qs6ukrh9vaf2XhyeLDHsmVZuzybGrdtqoC0w=="], + + "@tiptap/extension-bold": ["@tiptap/extension-bold@3.32.2", "", { "peerDependencies": { "@tiptap/core": "3.32.2" } }, "sha512-5Q8bScZEZbIBB2tBgnc7Cr6XuH2Nt/6l1HP3FO0wWO7nqXPOcodEbjslePI3v1iag+LNWX16BZ8fDrJr/PbxXA=="], + + "@tiptap/extension-bubble-menu": ["@tiptap/extension-bubble-menu@3.32.2", "", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "@tiptap/core": "3.32.2", "@tiptap/pm": "3.32.2" } }, "sha512-mwxWtLHiwhtzXpvdjoegH10eIA8CJr5INGAzUSfzRp4v13zH3rDbsoafljwEi+V5JfcSY/bbXf1akOtsny4yXw=="], + + "@tiptap/extension-bullet-list": ["@tiptap/extension-bullet-list@3.32.2", "", { "peerDependencies": { "@tiptap/extension-list": "3.32.2" } }, "sha512-YP7IlC3swrNPlnFHIgVDsDl70geqqWtcAkbuncQEs5FJ9UQg432VwVfwCUW4NsG532p5AqlTTwOKcSLS7p/pqA=="], + + "@tiptap/extension-code": ["@tiptap/extension-code@3.32.2", "", { "peerDependencies": { "@tiptap/core": "3.32.2" } }, "sha512-jm7qXuDmBzQYPemm/9chLepQjCNVtZhaoU9N2f4GxdUi8u/fi3AQLP7xlXjRCQUyam50FVqIIWgD567mLQ3sMg=="], + + "@tiptap/extension-code-block": ["@tiptap/extension-code-block@3.32.2", "", { "peerDependencies": { "@tiptap/core": "3.32.2", "@tiptap/pm": "3.32.2" } }, "sha512-KFkPMMgXEgXImGpbUsrHNaERtvOGbJ/JiWn0zHNGSJCf6frn44OLeRFPpEMptmwMqtJxftBVFbEbcPOlATYqDw=="], + + "@tiptap/extension-document": ["@tiptap/extension-document@3.32.2", "", { "peerDependencies": { "@tiptap/core": "3.32.2" } }, "sha512-zKWj8jh1kpckfSwle+kqFn0ZnYvkhb7nz89nrNvpnnsQMbbGU2/+bUAfYU3XSEKZr3VBvW176FtlMPD8ggC3Cg=="], + + "@tiptap/extension-dropcursor": ["@tiptap/extension-dropcursor@3.32.2", "", { "peerDependencies": { "@tiptap/extensions": "3.32.2" } }, "sha512-mE9muf4KSY3s+LCaHG/G957pVHVF2q46i+YkZF3ZG70errQcyZ5YFKnce9PSL1fiL0VLL+2UhMAL2QDM5oDiNA=="], + + "@tiptap/extension-floating-menu": ["@tiptap/extension-floating-menu@3.32.2", "", { "peerDependencies": { "@floating-ui/dom": "^1.0.0", "@tiptap/core": "3.32.2", "@tiptap/pm": "3.32.2" } }, "sha512-JPyJQvJlGDxn2v7Mr8Yft2BmcXaHiapVsXBMYiOJh5BeWbnwUYFWNH9h5PU/xTOz2zVEV79Kh4HmpgXZS3FCbg=="], + + "@tiptap/extension-gapcursor": ["@tiptap/extension-gapcursor@3.32.2", "", { "peerDependencies": { "@tiptap/extensions": "3.32.2" } }, "sha512-7W/iX8h6KQ2HK2SfvzCIgySOEJwTM4owqz1tXVOPiLPvKSvDDa9GQsp60+xNyqDNHuplE8bRsZ5dH1nilgAaAg=="], + + "@tiptap/extension-hard-break": ["@tiptap/extension-hard-break@3.32.2", "", { "peerDependencies": { "@tiptap/core": "3.32.2" } }, "sha512-oIdXmya2XBzp3Cw9Nr36agPvr9ZXP3p2fQXaFflxYYBKOG3UUzw38CId7J6K1jYxBf8c1zodbiKFEL7rttJ6fQ=="], + + "@tiptap/extension-heading": ["@tiptap/extension-heading@3.32.2", "", { "peerDependencies": { "@tiptap/core": "3.32.2" } }, "sha512-AE2WNA3jVnPV/cVyTwTmtmGZqX8LiiGicDHXX4bdvfzv+Aez4sgzdkuFjOGWtnEy43NnXdoI1UX7y/984NM0iA=="], + + "@tiptap/extension-horizontal-rule": ["@tiptap/extension-horizontal-rule@3.32.2", "", { "peerDependencies": { "@tiptap/core": "3.32.2", "@tiptap/pm": "3.32.2" } }, "sha512-Pl0YRS60R339yHrAWGhPT9WHIlD72BO6SGVdj8lBs2OAaYw5RQL/1BGZi+7UvAyc2yJxaRC4XN9X4jdQHDUq9A=="], + + "@tiptap/extension-image": ["@tiptap/extension-image@3.32.2", "", { "peerDependencies": { "@tiptap/core": "3.32.2" } }, "sha512-TRjp//2r1MTE1emLXxEki6+D0bOPYaX41CnM1hrUnp1iw/cS0a4XBXAWXxK/EFEl94zrqWrafkarU9rXpnBHeg=="], + + "@tiptap/extension-italic": ["@tiptap/extension-italic@3.32.2", "", { "peerDependencies": { "@tiptap/core": "3.32.2" } }, "sha512-aqk8OK4Xo59qZni7YSy42/ltQFb2IOmli3YERIq1XPQ4vl5sxoYhL+sFoOPmvbjHvaHZpK6n8vPh1SmAnB570A=="], + + "@tiptap/extension-link": ["@tiptap/extension-link@3.32.2", "", { "dependencies": { "linkifyjs": "^4.3.3" }, "peerDependencies": { "@tiptap/core": "3.32.2", "@tiptap/pm": "3.32.2" } }, "sha512-EwQtMXsU1Vc03n+yud7lfaR+RjHTPwFyI/hcEylvJba7iIXDSLA/ey4mnpwt2od8CEx/ZcOutIryORKVR9Py/Q=="], + + "@tiptap/extension-list": ["@tiptap/extension-list@3.32.2", "", { "peerDependencies": { "@tiptap/core": "3.32.2", "@tiptap/pm": "3.32.2" } }, "sha512-rLYk3GXSswrCakyZwo8VW0+yjnYhLJSBRvzClneIrlPhqQ2r/oDUjeqkpm8lGvwREw1lDSh7wefGhkhTRcQkAw=="], + + "@tiptap/extension-list-item": ["@tiptap/extension-list-item@3.32.2", "", { "peerDependencies": { "@tiptap/extension-list": "3.32.2" } }, "sha512-vTdPfzZODLVXog/NMs6zcaTOhtlh6ptJidlMg8l1TLhO+YQyEyO/K45aV429D9xTo/BOVazreav/C+hgemSnYg=="], + + "@tiptap/extension-list-keymap": ["@tiptap/extension-list-keymap@3.32.2", "", { "peerDependencies": { "@tiptap/extension-list": "3.32.2" } }, "sha512-SioQO8TqRb8aq9L+GpI/a/F7HCSBarm3Uvqtt2YttyazjSjEedu1zSnaa93V+WEFmlqyXnxVa24vv2zj6qlavQ=="], + + "@tiptap/extension-ordered-list": ["@tiptap/extension-ordered-list@3.32.2", "", { "peerDependencies": { "@tiptap/extension-list": "3.32.2" } }, "sha512-WQw2fLtJAxCYftKAk+YNlPDc+OxtXK4nNfXRBwr6oC67SoJtoLe0bo5jS6vF+cEqzHjDaboJi+O3MSNp2L2VQA=="], + + "@tiptap/extension-paragraph": ["@tiptap/extension-paragraph@3.32.2", "", { "peerDependencies": { "@tiptap/core": "3.32.2" } }, "sha512-3bqC//4GEhUiux3dhW/TJuveBp9ADv4nHlqBdVQa3A+USGbaBvE9YvXxA32wZL0leSRykxEnHbNHZ1J4KjIynQ=="], + + "@tiptap/extension-strike": ["@tiptap/extension-strike@3.32.2", "", { "peerDependencies": { "@tiptap/core": "3.32.2" } }, "sha512-i3d4vCrZySS9g5nGa5W0LS6LPMKxcAnJO6uADTnGVlKK0ewklcWWZbZ9GB/Fpx/IjKAvXWeayjfsNpTae/yVZg=="], + + "@tiptap/extension-table": ["@tiptap/extension-table@3.32.2", "", { "peerDependencies": { "@tiptap/core": "3.32.2", "@tiptap/pm": "3.32.2" } }, "sha512-CIFQaKOwdNFPaApymZRNhAnpGpl3Y53V1sqTgQV9FtI4qi6H3mAR032GtasUzo7DpBaOHpNNbMgph0yyVCJF9g=="], + + "@tiptap/extension-table-cell": ["@tiptap/extension-table-cell@3.32.2", "", { "peerDependencies": { "@tiptap/extension-table": "3.32.2" } }, "sha512-Qu37Px9pkKXyPzjS6RKnXJw6FlR5AltC6d0F3nArAW8xVQLeYIciWwcovEkKaGTcvFNExrlWszMUUmfiuUO/fA=="], + + "@tiptap/extension-table-header": ["@tiptap/extension-table-header@3.32.2", "", { "peerDependencies": { "@tiptap/extension-table": "3.32.2" } }, "sha512-hpOxkBBaIlXk99Q7MhwzOCUAuWPwDKWbas/iEpQeeBuLk9GhbrpNztF0HpKPEwtlOC5s/fgS4Z8hZGYaeIE6FA=="], + + "@tiptap/extension-table-row": ["@tiptap/extension-table-row@3.32.2", "", { "peerDependencies": { "@tiptap/extension-table": "3.32.2" } }, "sha512-rlylXglVNwDUvXgn0bKZW2HuceSu2xb00fGhAlvniUlGEFAn7/6buEjVrJJKKmbc2OXsmfh4+MnwMPpd7rCnIg=="], + + "@tiptap/extension-text": ["@tiptap/extension-text@3.32.2", "", { "peerDependencies": { "@tiptap/core": "3.32.2" } }, "sha512-W6G7UbQ71TyvZvORDdj6u8By8B/Dymy8KoaKGaNHqB82SpM42ZK2TmeFBHI0Lo4wTkZJL3LuI+QthaDvh0USsA=="], + + "@tiptap/extension-underline": ["@tiptap/extension-underline@3.32.2", "", { "peerDependencies": { "@tiptap/core": "3.32.2" } }, "sha512-kY7B5ocKx3JzqIl2kLXGvnLGZRxuYEFzMkpgJ6Vh6/VTTK+JCeOVItYEQGFrAxrodIMmhIrXpi1jF57s6iZswg=="], + + "@tiptap/extensions": ["@tiptap/extensions@3.32.2", "", { "peerDependencies": { "@tiptap/core": "3.32.2", "@tiptap/pm": "3.32.2" } }, "sha512-TUE1JedPjYQ/T527f2a4xz+4Xi1a5nIgW8KwC0DjmqbaHirlz0eCNI2nMEN70pu5WPpqgXmki3lCF3LScQxIGw=="], + + "@tiptap/pm": ["@tiptap/pm@3.32.2", "", { "dependencies": { "prosemirror-changeset": "^2.4.4", "prosemirror-commands": "^1.7.2", "prosemirror-dropcursor": "^1.8.4", "prosemirror-gapcursor": "^1.4.1", "prosemirror-history": "^1.5.1", "prosemirror-inputrules": "^1.5.1", "prosemirror-keymap": "^1.2.3", "prosemirror-model": "^1.25.12", "prosemirror-schema-list": "^1.5.1", "prosemirror-state": "^1.4.4", "prosemirror-tables": "^1.8.5", "prosemirror-transform": "^1.12.2", "prosemirror-view": "^1.42.6" } }, "sha512-4cuUbYf55NnYBIvDBK/K1VFVQ4pocKwAZC5TAy4s9dptvazxVHkQVMbiQsyhwfIbM4rxmHWkM/6omaFfTB++/g=="], + + "@tiptap/react": ["@tiptap/react@3.32.2", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "fast-equals": "^5.3.3", "use-sync-external-store": "^1.4.0" }, "optionalDependencies": { "@tiptap/extension-bubble-menu": "3.32.2", "@tiptap/extension-floating-menu": "3.32.2" }, "peerDependencies": { "@tiptap/core": "3.32.2", "@tiptap/pm": "3.32.2", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-IrraXFTp6S9R+GlCzavMUQel0wK9wafXU3VnaujeC/REi6sYWNuyBe0jICimdbEk1dU32UvlkvPCuIDRPKlzaQ=="], + + "@tiptap/starter-kit": ["@tiptap/starter-kit@3.32.2", "", { "dependencies": { "@tiptap/core": "3.32.2", "@tiptap/extension-blockquote": "3.32.2", "@tiptap/extension-bold": "3.32.2", "@tiptap/extension-bullet-list": "3.32.2", "@tiptap/extension-code": "3.32.2", "@tiptap/extension-code-block": "3.32.2", "@tiptap/extension-document": "3.32.2", "@tiptap/extension-dropcursor": "3.32.2", "@tiptap/extension-gapcursor": "3.32.2", "@tiptap/extension-hard-break": "3.32.2", "@tiptap/extension-heading": "3.32.2", "@tiptap/extension-horizontal-rule": "3.32.2", "@tiptap/extension-italic": "3.32.2", "@tiptap/extension-link": "3.32.2", "@tiptap/extension-list": "3.32.2", "@tiptap/extension-list-item": "3.32.2", "@tiptap/extension-list-keymap": "3.32.2", "@tiptap/extension-ordered-list": "3.32.2", "@tiptap/extension-paragraph": "3.32.2", "@tiptap/extension-strike": "3.32.2", "@tiptap/extension-text": "3.32.2", "@tiptap/extension-underline": "3.32.2", "@tiptap/extensions": "3.32.2", "@tiptap/pm": "3.32.2" } }, "sha512-6K+oOMF4orhnOxivgUTKiSpNAEftHyWOcfFIvIEMUP044b34obBUogzMTfM3RjtewZtQXVafO9zmGU30IllC1g=="], + "@types/node": ["@types/node@26.6.6", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog=="], "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], + "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], @@ -544,6 +621,8 @@ "exsolve": ["exsolve@1.2.0", "", {}, "sha512-h1pQPR9XoeSMdntRNCjyTznaume4UpfXrULBnlkZ2CUM/G5eSa+pBByGldO5uR8dxbPC+OuJXsPPNf+JZ81ADg=="], + "fast-equals": ["fast-equals@5.4.5", "", {}, "sha512-yKEpcFuRPu/pTQL2VB8YKE2yFldrLlIsoWtJeBnAdqEhvu21OgkFT0C8njf/xYc2LSZGEr3K9JGjthHJxnVLvQ=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fetchdts": ["fetchdts@0.2.0", "", {}, "sha512-PJb4ZTGT2/wK78RmVrxZITStWabhZlKBsL52VOgq+c7clEkdHdEB+0rtzrXCbZJqeYJj+IwNWYqB5/G6vouA6A=="], @@ -608,6 +687,8 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + "linkifyjs": ["linkifyjs@4.4.0", "", {}, "sha512-InTexvtbai+2c+dEVGcc6O6P2DMVIiwD9a/i4zFroazOIYLOS6FSVmAJ7kFeumnCMx6/gQqNAkbUvgMubV4+MA=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], @@ -618,6 +699,8 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "marked": ["marked@18.1.3", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-9ChMH8EVVgjgID49nsL6IWxWk5c6GtAsq/xw7U1ggkjkU5AGuv68oLTVlFRtvcUYZtpbNyjyvGo3YDu0fGapiw=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "mysql2": ["mysql2@3.24.6", "", { "dependencies": { "aws-ssl-profiles": "^1.1.2", "generate-function": "^2.3.1", "iconv-lite": "^0.7.3", "long": "^5.3.2", "lru.min": "^1.1.4", "named-placeholders": "^1.1.6", "sql-escaper": "^1.5.1" }, "peerDependencies": { "@types/node": ">= 8" } }, "sha512-enu73KhbAua2L3dIxjc6qg6ZJ7H3pt1BImZASWNWY5xSsmJ1ZuUPULTDGXgmJbI61vb+nJAG19IZclIq19UidQ=="], @@ -628,6 +711,8 @@ "node-releases": ["node-releases@2.0.61", "", {}, "sha512-5NUN6QCxNFrS9oEA9RPS+d0AP+ooGBzzovY2JhIpDRHacPvOhHjXjUsUMX9qp4nLHZ+5jVgXB2jj/P4arGOE/g=="], + "orderedmap": ["orderedmap@2.1.1", "", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -640,6 +725,32 @@ "prettier": ["prettier@3.9.10", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA=="], + "prosemirror-changeset": ["prosemirror-changeset@2.4.6", "", { "dependencies": { "prosemirror-transform": "^1.0.0" } }, "sha512-P0cY3DSLm4M2boUOCmHDyaID5o6HgWmb21Bvs9p8lLqfJjKBBEHG5Ma5qW5SCmwOYamtWOLf2k+yvt6077zlTg=="], + + "prosemirror-commands": ["prosemirror-commands@1.7.2", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.10.2" } }, "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw=="], + + "prosemirror-dropcursor": ["prosemirror-dropcursor@1.8.4", "", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0", "prosemirror-view": "^1.1.0" } }, "sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg=="], + + "prosemirror-gapcursor": ["prosemirror-gapcursor@1.4.1", "", { "dependencies": { "prosemirror-keymap": "^1.0.0", "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-view": "^1.0.0" } }, "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw=="], + + "prosemirror-history": ["prosemirror-history@1.5.1", "", { "dependencies": { "prosemirror-state": "^1.2.2", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.31.0", "rope-sequence": "^1.3.0" } }, "sha512-onlwnqKvgtFg6srZOmDOAhlAy1FIHQpOExORybX0S+DvJepE5tPALrB9iGet/2sqqqrelSnB10OoiOJqAvu2DA=="], + + "prosemirror-inputrules": ["prosemirror-inputrules@1.5.1", "", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.0.0" } }, "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw=="], + + "prosemirror-keymap": ["prosemirror-keymap@1.2.3", "", { "dependencies": { "prosemirror-state": "^1.0.0", "w3c-keyname": "^2.2.0" } }, "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw=="], + + "prosemirror-model": ["prosemirror-model@1.25.12", "", { "dependencies": { "orderedmap": "^2.0.0" } }, "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg=="], + + "prosemirror-schema-list": ["prosemirror-schema-list@1.6.0", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.7.3" } }, "sha512-G1srDtan+VqWn+qpmWFj/6j0YsZ5zMU+ZIlGVIW41a5JF2jAI3jzfMRWmSABmEH2Qoq1PmAG/o2mucMMdLOsLg=="], + + "prosemirror-state": ["prosemirror-state@1.4.4", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.27.0" } }, "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw=="], + + "prosemirror-tables": ["prosemirror-tables@1.8.5", "", { "dependencies": { "prosemirror-keymap": "^1.2.3", "prosemirror-model": "^1.25.4", "prosemirror-state": "^1.4.4", "prosemirror-transform": "^1.10.5", "prosemirror-view": "^1.41.4" } }, "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw=="], + + "prosemirror-transform": ["prosemirror-transform@1.12.4", "", { "dependencies": { "prosemirror-model": "^1.21.0" } }, "sha512-2/WuPum3pn+O/Vi2vzpi8210ICUe2kIPXdLYjQ09Pv5DtT5bcKWMJhAKPBwvpPjpp5iOYUVwVEr0gOM1BY1Mgw=="], + + "prosemirror-view": ["prosemirror-view@1.42.9", "", { "dependencies": { "prosemirror-model": "^1.25.12", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, "sha512-bKbfzeKZOu2H2JQU2TaJmYu6EBcor3VnGIR4jR31cbEt1ztUnfUB6bKGV1+fJzOJdrVWR+tRU0208zM3oQx4wA=="], + "react": ["react@19.3.1", "", {}, "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw=="], "react-dom": ["react-dom@19.3.1", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.1" } }, "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ=="], @@ -658,6 +769,8 @@ "rolldown": ["rolldown@1.2.16", "", { "dependencies": { "@oxc-project/types": "=0.156.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.16", "@rolldown/binding-android-arm64": "1.2.16", "@rolldown/binding-darwin-arm64": "1.2.16", "@rolldown/binding-darwin-x64": "1.2.16", "@rolldown/binding-freebsd-x64": "1.2.16", "@rolldown/binding-linux-arm-gnueabihf": "1.2.16", "@rolldown/binding-linux-arm64-gnu": "1.2.16", "@rolldown/binding-linux-arm64-musl": "1.2.16", "@rolldown/binding-linux-ppc64-gnu": "1.2.16", "@rolldown/binding-linux-s390x-gnu": "1.2.16", "@rolldown/binding-linux-x64-gnu": "1.2.16", "@rolldown/binding-linux-x64-musl": "1.2.16", "@rolldown/binding-openharmony-arm64": "1.2.16", "@rolldown/binding-win32-arm64-msvc": "1.2.16", "@rolldown/binding-win32-x64-msvc": "1.2.16" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw=="], + "rope-sequence": ["rope-sequence@1.3.4", "", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], @@ -724,6 +837,8 @@ "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "xmlbuilder2": ["xmlbuilder2@4.0.3", "", { "dependencies": { "@oozcitak/dom": "^2.0.2", "@oozcitak/infra": "^2.0.2", "@oozcitak/util": "^10.0.0", "js-yaml": "^4.1.1" } }, "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA=="], diff --git a/docs/admin-crm.md b/docs/admin-crm.md index 61d0b4403..f24253673 100644 --- a/docs/admin-crm.md +++ b/docs/admin-crm.md @@ -1,9 +1,44 @@ # 后台(Admin / CRM)与 MCP 说明 +> v0.14.0 起:设置页 Tabs 表单 + 登录安全(reCAPTCHA/失败锁定)、TipTap 富文本编辑器(Markdown/媒体库)、前台移动端自适应(viewport + 溢出收敛)。 > v0.13.0 起:前台导航修复(loader 服务函数化)、全站图片占位图 + 懒加载、favicon。 > v0.12.0 起:PG 16 → 18.6(增量备份能力)、备份系统(全量/增量/S3)、用户权限分层。 > v0.11.0 起:后台按「功能接近原版 WordPress + 现代化 CRM 界面」重做一轮,并新增站点管理 MCP 服务器。 +## 系统设置与登录安全(v0.14.0) + +- `/admin/settings` 弃用整键 JSON 编辑器,改为 **antd Tabs 分组表单**:站点信息 / 商城配置 / + 邮件配置 / SEO / **登录安全**;保存时与现有 JSON 合并(表单未覆盖的字段保留)。 + 无表单定义的键(backup 等专属页管理的除外)落在「其他」Tab 仍以 JSON 编辑兜底。 +- **登录安全**(settings 表 key=security,`src/server/security.ts`): + - reCAPTCHA 开关 + 版本(v2 复选框 / v3 隐形评分)+ Site/Secret Key + v3 阈值; + 开启后 `/admin` 与 `/my-account` 登录均需通过 Google siteverify(6s 超时,fail closed), + 前端组件 `src/components/admin/Recaptcha.tsx`(未开启不注入 Google 脚本)。 + - 登录失败锁定:同邮箱连续失败 ≥ 上限(默认 10)锁定(默认 15 分钟);进程内存计数,重启清零。 + - 公开配置经 `getLoginSecurityPublic` 下发(仅 enabled/version/siteKey,Secret 不回传)。 + +## 富文本编辑器(v0.14.0) + +- 内容编辑页(正文)与产品编辑页(详细描述)换 **TipTap v3**(`src/components/admin/RichEditor.tsx`): + - 工具栏:撤销重做 / 标题 H2-H4 / 加粗斜体下划线删除线行内代码 / 列表 / 引用 / 分隔线 / + 代码块 / 链接 / 图片 / 表格 / 清除格式 / HTML 源码切换; + - **Markdown**:输入即时转换(`## `→H2、`**`加粗、`-` 列表等 input rules)+「Markdown 导入」 + 弹窗(marked 解析,可插入光标处或替换正文); + - **媒体库**:图片按钮本地上传(`uploadMediaFile` → /uploads/YYYY/MM/)或「媒体库选图」弹窗 + (`MediaPickerModal`,搜索/分页/缩略图); + - antd Form 受控(value/onChange),SSR 需 `immediatelyRender: false`(已内置)。 +- 编辑器样式 `src/styles/admin-editor.css`(仅 `.rich-editor` 作用域,随组件 import)。 + +## 前台移动端自适应(v0.14.0) + +- **根路由补 ``**——此前缺失导致手机按 ~980px 布局再缩小,是「移动端没有」的根因。 +- 溢出收敛(390px 实测 14 个关键页全部 0 横向溢出,`scripts` 外的诊断脚本见 /tmp): + - 首页 Elementor Advance Tabs 横向标签条 ≤767px 改横滑(`min-width: max-content` + overflow-x); + - 内容模板 `width:100%`+左 margin 的内联信息块(联系页地址等)≤767px 收缩为自适应; + - /shop/ 产品网格 4→3(≤1024)→2(≤767)列; + - `content-html iframe` max-width 100%。 +- Header 移动端菜单改**手风琴**(顶级项可跳转 + 箭头展开子菜单,默认收起)。 + ## 前台约定(v0.13.0 起生效) - **TanStack Start loader 是同构运行的**:客户端导航(点击 LOGO / `` / 分页)会在浏览器重跑一遍 diff --git a/package.json b/package.json index caf9dd0de..b9f4b6902 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dusuniot-rebuild", - "version": "0.13.0", + "version": "0.14.0", "private": true, "type": "module", "scripts": { @@ -25,6 +25,14 @@ "@tanstack/react-router": "^1.170.46", "@tanstack/react-router-devtools": "^1.167.2", "@tanstack/react-start": "^1.169.4", + "@tiptap/extension-image": "^3.32.2", + "@tiptap/extension-table": "^3.32.2", + "@tiptap/extension-table-cell": "^3.32.2", + "@tiptap/extension-table-header": "^3.32.2", + "@tiptap/extension-table-row": "^3.32.2", + "@tiptap/pm": "^3.32.2", + "@tiptap/react": "^3.32.2", + "@tiptap/starter-kit": "^3.32.2", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.2", @@ -35,6 +43,7 @@ "drizzle-kit": "^0.31.11", "drizzle-orm": "^0.45.4", "lucide-react": "^1.64.0", + "marked": "^18.1.3", "mysql2": "^3.24.6", "react": "^19.3.1", "react-dom": "^19.3.1", diff --git a/src/components/admin/MediaPickerModal.tsx b/src/components/admin/MediaPickerModal.tsx new file mode 100644 index 000000000..f9e50a1ca --- /dev/null +++ b/src/components/admin/MediaPickerModal.tsx @@ -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([]); + 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 ( + + + } + value={q} + onChange={(e) => setQ(e.target.value)} + onPressEnter={() => { + setPage(1); + void load(1, q); + }} + style={{ width: 240 }} + /> + void handleUpload(f as File)}> + + + + +
+ {items.map((it) => ( + + ))} + {!loading && items.length === 0 ?
暂无媒体,先上传
: null} +
+
+ { + setPage(p); + void load(p); + }} + /> +
+
+
+ ); +} diff --git a/src/components/admin/Recaptcha.tsx b/src/components/admin/Recaptcha.tsx new file mode 100644 index 000000000..5d84efde8 --- /dev/null +++ b/src/components/admin/Recaptcha.tsx @@ -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; +}; + +declare global { + interface Window { + grecaptcha?: GrecaptchaApi; + } +} + +/** # 成员变量 脚本注入去重(两个登录页/严格模式重渲染只注一次) **/ +let scriptState: "idle" | "loading" | "loaded" | "error" = "idle"; +let scriptPromise: Promise | 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= + */ +function loadRecaptchaScript(siteKey: string, version: "v2" | "v3"): Promise { + if (scriptState === "loaded") return Promise.resolve(); + if (scriptState === "error") return Promise.reject(new Error("recaptcha script failed")); + if (!scriptPromise) { + scriptPromise = new Promise((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(null); + /** # 成员变量 v2 widget id(render 后缓存,reset 用) **/ + const widgetId = useRef(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 => { + 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 }) { + if (!props.cfg?.enabled) return null; + return ( + <> + {props.cfg.version === "v2" ?
: null} + {props.rec.failed ? ( +

+ reCAPTCHA 脚本加载失败,请检查网络(需可访问 google.com)后刷新重试。 +

+ ) : null} + + ); +} diff --git a/src/components/admin/RichEditor.tsx b/src/components/admin/RichEditor.tsx new file mode 100644 index 000000000..2248c6228 --- /dev/null +++ b/src/components/admin/RichEditor.tsx @@ -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 ( + + + + ); +} + +/** # 函数 RichEditor 主组件 */ +export function RichEditor(props: RichEditorProps) { + /** # 成员变量 最近一次向外发出的 HTML(防止受控回写与内部更新打环) **/ + const lastHtml = useRef(""); + /** # 成员变量 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(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) => 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(`${url}`)); + } 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 ( +
+
+ act((c) => c.undo())} disabled={!editor?.can().undo()}> + act((c) => c.redo())} disabled={!editor?.can().redo()}> + + { + const f = e.target.files?.[0]; + if (f) void handlePickFile(f); + e.target.value = ""; + }} + /> + + {err ?
{err}
: null} + + {srcMode ? ( + setSrcDraft(e.target.value)} + rows={16} + style={{ fontFamily: "ui-monospace, monospace", fontSize: 12.5, borderRadius: 0 }} + aria-label="HTML 源码" + /> + ) : ( + + )} + + {/* 链接弹层 */} + setLinkOpen(false)} okText="确定" cancelText="取消" width={440}> + + setLinkUrl(e.target.value)} placeholder="https://…" onPressEnter={applyLink} /> + + + + + {/* 媒体库选图 */} + setMediaOpen(false)} onSelect={insertMedia} /> + + {/* Markdown 导入 */} + setMdOpen(false)} + width={680} + footer={[ + , + , + , + ]} + > +

+ 支持 # 标题、**加粗**、- 列表、[链接](url)、![图片](url)、表格等(marked 解析)。日常输入时编辑器已自动转换 Markdown 快捷语法。 +

+ setMdText(e.target.value)} rows={12} placeholder={"## 标题\n\n正文 **加粗** …"} aria-label="Markdown 内容" /> +
+
+ ); +} diff --git a/src/components/website/Header.tsx b/src/components/website/Header.tsx index 599966b47..c29f15067 100644 --- a/src/components/website/Header.tsx +++ b/src/components/website/Header.tsx @@ -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 (
- {href ? ( - - {node.title} - - ) : ( - {node.title} - )} - {node.children.map((c) => { - const ch = normalizeUrl(c.url); - return ( -
- {ch ? ( - - {c.title} - - ) : ( - {c.title} - )} -
- ); - })} +
+ {href ? ( + + {node.title} + + ) : ( + {node.title} + )} + {hasKids ? ( + + ) : null} +
+ {hasKids && expanded ? ( +
+ {node.children.map((c) => { + const ch = normalizeUrl(c.url); + return ( +
+ {ch ? ( + + {c.title} + + ) : ( + {c.title} + )} +
+ ); + })} +
+ ) : null}
); } diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 78823b76e..74bd93572 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -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" }, diff --git a/src/routes/admin.contents.$id.tsx b/src/routes/admin.contents.$id.tsx index a2122193a..d31a73808 100644 --- a/src/routes/admin.contents.$id.tsx +++ b/src/routes/admin.contents.$id.tsx @@ -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() { - + @@ -196,8 +197,8 @@ function ProductEditPage() { - - + + {attributes.length > 0 && ( diff --git a/src/routes/admin.settings.tsx b/src/routes/admin.settings.tsx index 8793c7bc5..119cb7b01 100644 --- a/src/routes/admin.settings.tsx +++ b/src/routes/admin.settings.tsx @@ -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 = { - 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 在 Google reCAPTCHA 控制台 + 申请;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>({}); + /** # 成员变量 其他键的 JSON 草稿(键 → 文本) **/ + const [otherDrafts, setOtherDrafts] = useState>({}); useEffect(() => { if (!data) return; - setDrafts(Object.fromEntries(data.map((r: SettingRow) => [r.key, JSON.stringify(r.value, null, 2)]))); + const map: Record = {}; + 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 ; - /** # 函数 保存单个设置键(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 ; + case "number": + return ; + case "select": + return ; + } + } + return ( 仅管理员可保存} /> - - {data.map((r: SettingRow) => ( - } onClick={() => void handleSave(r.key)}> - 保存 - - } - > - setDrafts((d) => ({ ...d, [r.key]: e.target.value }))} - style={{ fontFamily: "monospace", fontSize: 12 }} - /> - - 最后更新:{fmtDateTime(r.updatedAt)} - - - ))} - + ({ + key: g.key, + label: g.label, + children: r.key === g.key)?.value ?? {}) as Record} updatedAt={rows.find((r) => r.key === g.key)?.updatedAt} renderField={renderField} />, + })).concat( + Object.keys(otherDrafts).length + ? [ + { + key: "__other__", + label: "其他", + children: ( + + 以下键无表单定义(如由脚本写入的配置),按 JSON 编辑保存。 + {Object.keys(otherDrafts).map((k) => ( + } onClick={() => void handleSaveOther(k)}> + 保存 + + }> + setOtherDrafts((d) => ({ ...d, [k]: e.target.value }))} + style={{ fontFamily: "monospace", fontSize: 12 }} + /> + + ))} + + ), + }, + ] + : [], + )} + /> ); } + +/** # 组件 单个设置分组的表单(真实组件承载 Form.useForm,符合 hooks 规则) + * 提交时表单值与现有 JSON 合并(保留未在表单中的字段)后整键覆写。 + */ +function SettingsGroupForm(props: { + group: (typeof GROUPS)[number]; + current: Record; + 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 ( +
+ {g.desc ? : null} +
void handleSave()} + initialValues={Object.fromEntries(g.fields.map((f) => [f.name, props.current[f.name] ?? (f.type === "switch" ? false : undefined)]))} + style={{ maxWidth: 780 }} + > +
+ {g.fields.map((f) => ( + + {props.renderField(f)} + + ))} +
+ +
+ + 最后更新:{fmtDateTime(props.updatedAt ?? "")} + +
+ ); +} diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index fa60592b2..93a6543e5 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -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 ; } -/** # 组件 后台登录页(全屏居中卡片,邮箱 + 密码) */ +/** # 组件 后台登录页(全屏居中卡片,邮箱 + 密码;reCAPTCHA 按系统设置「登录安全」开关渲染) */ function AdminLoginPage(props: { noPermission: boolean; onOk: () => Promise }) { const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + /** # 成员变量 登录页公开配置(reCAPTCHA 启用状态/版本/站点密钥) **/ + const [secCfg, setSecCfg] = useState(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
+ {/* reCAPTCHA(系统设置 → 登录安全 开启后出现;v2 复选框 / v3 隐形) */} + {error ? (
{error} diff --git a/src/routes/my-account.tsx b/src/routes/my-account.tsx index 95fc5926b..4416a9138 100644 --- a/src/routes/my-account.tsx +++ b/src/routes/my-account.tsx @@ -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(null); + const recaptcha = useRecaptcha(secCfg); + + // 挂载后拉取登录安全公开配置(未启用不注入 reCAPTCHA 脚本) + useEffect(() => { + void getLoginSecurityPublic() + .then(setSecCfg) + .catch(() => setSecCfg({ enabled: false, version: "v2", siteKey: "" })); + }, []); /** # 函数 登录提交 */ async function handleLogin(e: React.FormEvent) { 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 + {/* reCAPTCHA(系统设置 → 登录安全 开启后出现;v2 复选框 / v3 隐形) */} +
diff --git a/src/server/auth.ts b/src/server/auth.ts index 5ae6973af..2d57e46ab 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -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, diff --git a/src/server/security.ts b/src/server/security.ts new file mode 100644 index 000000000..f4157fd34 --- /dev/null +++ b/src/server/security.ts @@ -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 { + try { + const [row] = await db.select().from(settings).where(eq(settings.key, "security")); + const raw = (row?.value ?? {}) as Record; + 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 { + 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(); + +/** # 成员变量 失败记录清理周期(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 }; +}); diff --git a/src/styles/admin-editor.css b/src/styles/admin-editor.css new file mode 100644 index 000000000..4d23326cf --- /dev/null +++ b/src/styles/admin-editor.css @@ -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; +} diff --git a/src/styles/app.css b/src/styles/app.css index 98273bdfb..f40d4e870 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -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; } +} diff --git a/src/utils/media-upload.ts b/src/utils/media-upload.ts new file mode 100644 index 000000000..cd1db8815 --- /dev/null +++ b/src/utils/media-upload.ts @@ -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 { + 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) } }); +}