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:
@@ -16,6 +16,14 @@
|
|||||||
"@tanstack/react-router": "^1.170.46",
|
"@tanstack/react-router": "^1.170.46",
|
||||||
"@tanstack/react-router-devtools": "^1.167.2",
|
"@tanstack/react-router-devtools": "^1.167.2",
|
||||||
"@tanstack/react-start": "^1.169.4",
|
"@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": "^19.3.0",
|
||||||
"@types/react-dom": "^19.3.0",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@vitejs/plugin-react": "^6.1.2",
|
"@vitejs/plugin-react": "^6.1.2",
|
||||||
@@ -26,6 +34,7 @@
|
|||||||
"drizzle-kit": "^0.31.11",
|
"drizzle-kit": "^0.31.11",
|
||||||
"drizzle-orm": "^0.45.4",
|
"drizzle-orm": "^0.45.4",
|
||||||
"lucide-react": "^1.64.0",
|
"lucide-react": "^1.64.0",
|
||||||
|
"marked": "^18.1.3",
|
||||||
"mysql2": "^3.24.6",
|
"mysql2": "^3.24.6",
|
||||||
"react": "^19.3.1",
|
"react": "^19.3.1",
|
||||||
"react-dom": "^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=="],
|
"@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/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": ["@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/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-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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
"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=="],
|
"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": ["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=="],
|
"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=="],
|
"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=="],
|
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||||
|
|
||||||
"scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
|
|||||||
@@ -1,9 +1,44 @@
|
|||||||
# 后台(Admin / CRM)与 MCP 说明
|
# 后台(Admin / CRM)与 MCP 说明
|
||||||
|
|
||||||
|
> v0.14.0 起:设置页 Tabs 表单 + 登录安全(reCAPTCHA/失败锁定)、TipTap 富文本编辑器(Markdown/媒体库)、前台移动端自适应(viewport + 溢出收敛)。
|
||||||
> v0.13.0 起:前台导航修复(loader 服务函数化)、全站图片占位图 + 懒加载、favicon。
|
> v0.13.0 起:前台导航修复(loader 服务函数化)、全站图片占位图 + 懒加载、favicon。
|
||||||
> v0.12.0 起:PG 16 → 18.6(增量备份能力)、备份系统(全量/增量/S3)、用户权限分层。
|
> v0.12.0 起:PG 16 → 18.6(增量备份能力)、备份系统(全量/增量/S3)、用户权限分层。
|
||||||
> v0.11.0 起:后台按「功能接近原版 WordPress + 现代化 CRM 界面」重做一轮,并新增站点管理 MCP 服务器。
|
> 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)
|
||||||
|
|
||||||
|
- **根路由补 `<meta name="viewport">`**——此前缺失导致手机按 ~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 起生效)
|
## 前台约定(v0.13.0 起生效)
|
||||||
|
|
||||||
- **TanStack Start loader 是同构运行的**:客户端导航(点击 LOGO / `<Link>` / 分页)会在浏览器重跑一遍
|
- **TanStack Start loader 是同构运行的**:客户端导航(点击 LOGO / `<Link>` / 分页)会在浏览器重跑一遍
|
||||||
|
|||||||
+10
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dusuniot-rebuild",
|
"name": "dusuniot-rebuild",
|
||||||
"version": "0.13.0",
|
"version": "0.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -25,6 +25,14 @@
|
|||||||
"@tanstack/react-router": "^1.170.46",
|
"@tanstack/react-router": "^1.170.46",
|
||||||
"@tanstack/react-router-devtools": "^1.167.2",
|
"@tanstack/react-router-devtools": "^1.167.2",
|
||||||
"@tanstack/react-start": "^1.169.4",
|
"@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": "^19.3.0",
|
||||||
"@types/react-dom": "^19.3.0",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@vitejs/plugin-react": "^6.1.2",
|
"@vitejs/plugin-react": "^6.1.2",
|
||||||
@@ -35,6 +43,7 @@
|
|||||||
"drizzle-kit": "^0.31.11",
|
"drizzle-kit": "^0.31.11",
|
||||||
"drizzle-orm": "^0.45.4",
|
"drizzle-orm": "^0.45.4",
|
||||||
"lucide-react": "^1.64.0",
|
"lucide-react": "^1.64.0",
|
||||||
|
"marked": "^18.1.3",
|
||||||
"mysql2": "^3.24.6",
|
"mysql2": "^3.24.6",
|
||||||
"react": "^19.3.1",
|
"react": "^19.3.1",
|
||||||
"react-dom": "^19.3.1",
|
"react-dom": "^19.3.1",
|
||||||
|
|||||||
@@ -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 }) {
|
function MobileNavItem({ node, onNavigate }: { node: MenuNode; onNavigate: () => void }) {
|
||||||
const href = normalizeUrl(node.url);
|
const href = normalizeUrl(node.url);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const hasKids = node.children.length > 0;
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{href ? (
|
<div className="flex items-center border-b border-line">
|
||||||
<a href={href} onClick={onNavigate} className="block border-b border-line px-1 py-[9px] font-medium text-ink">
|
{href ? (
|
||||||
{node.title}
|
<a href={href} onClick={onNavigate} className="min-w-0 flex-1 px-1 py-[9px] font-medium text-ink">
|
||||||
</a>
|
{node.title}
|
||||||
) : (
|
</a>
|
||||||
<span className="block border-b border-line px-1 py-[9px] font-medium text-ink">{node.title}</span>
|
) : (
|
||||||
)}
|
<span className="min-w-0 flex-1 px-1 py-[9px] font-medium text-ink">{node.title}</span>
|
||||||
{node.children.map((c) => {
|
)}
|
||||||
const ch = normalizeUrl(c.url);
|
{hasKids ? (
|
||||||
return (
|
<button
|
||||||
<div key={c.id}>
|
type="button"
|
||||||
{ch ? (
|
aria-label={expanded ? `收起 ${node.title}` : `展开 ${node.title}`}
|
||||||
<a href={ch} onClick={onNavigate} className="block border-b border-line py-[9px] pl-[18px] font-normal text-muted-foreground">
|
aria-expanded={expanded}
|
||||||
{c.title}
|
onClick={() => setExpanded(!expanded)}
|
||||||
</a>
|
className="flex h-9 w-10 shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent p-0 text-muted-foreground"
|
||||||
) : (
|
>
|
||||||
<span className="block border-b border-line py-[9px] pl-[18px] font-normal text-muted-foreground">{c.title}</span>
|
<ChevronRight size={18} aria-hidden className={"transition-transform " + (expanded ? "rotate-90" : "")} />
|
||||||
)}
|
</button>
|
||||||
</div>
|
) : 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ function RootDocument() {
|
|||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
component: RootDocument,
|
component: RootDocument,
|
||||||
loader,
|
loader,
|
||||||
// 全局 head 资源:favicon / apple-touch-icon(public/ 同名文件)
|
// 全局 head 资源:viewport(移动端自适应的前提)+ favicon / apple-touch-icon
|
||||||
head: () => ({
|
head: () => ({
|
||||||
|
meta: [{ name: "viewport", content: "width=device-width, initial-scale=1" }],
|
||||||
links: [
|
links: [
|
||||||
{ rel: "icon", href: "/favicon.ico", sizes: "32x32" },
|
{ rel: "icon", href: "/favicon.ico", sizes: "32x32" },
|
||||||
{ rel: "apple-touch-icon", href: "/apple-touch-icon.png" },
|
{ 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 { useEffect, useState } from "react";
|
||||||
import { adminGetContent, adminGetRevision, adminListRevisions, adminRestoreRevision, adminSaveContent } from "~/server/admin";
|
import { adminGetContent, adminGetRevision, adminListRevisions, adminRestoreRevision, adminSaveContent } from "~/server/admin";
|
||||||
import { CONTENT_STATUS_OPTIONS, Forbidden, fmtDateTime, guardAdmin } from "~/components/admin/common";
|
import { CONTENT_STATUS_OPTIONS, Forbidden, fmtDateTime, guardAdmin } from "~/components/admin/common";
|
||||||
|
import { RichEditor } from "~/components/admin/RichEditor";
|
||||||
|
|
||||||
/** # 类型 编辑表单数据形状(新建时给默认值) **/
|
/** # 类型 编辑表单数据形状(新建时给默认值) **/
|
||||||
type ContentForm = {
|
type ContentForm = {
|
||||||
@@ -251,10 +252,10 @@ function ContentEditPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="html"
|
name="html"
|
||||||
label="正文 HTML(无 Elementor JSON 时前台按此渲染)"
|
label="正文(富文本编辑,Markdown 快捷语法自动转换)"
|
||||||
tooltip="自定义单页面推荐:留空 HTML、填写 Elementor JSON,前台通栏渲染与原站一致"
|
tooltip="可视化编辑;需要精细控制时可切 HTML 源码。无 Elementor JSON 时前台按此渲染"
|
||||||
>
|
>
|
||||||
<Input.TextArea rows={12} style={{ fontFamily: "monospace", fontSize: 12 }} />
|
<RichEditor minHeight={380} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="elementorJson"
|
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 { ArrowLeftOutlined, SaveOutlined } from "@ant-design/icons";
|
||||||
import { adminGetProduct, adminSaveProduct, adminSearchMedia } from "~/server/admin";
|
import { adminGetProduct, adminSaveProduct, adminSearchMedia } from "~/server/admin";
|
||||||
import { Forbidden, guardAdmin } from "~/components/admin/common";
|
import { Forbidden, guardAdmin } from "~/components/admin/common";
|
||||||
|
import { RichEditor } from "~/components/admin/RichEditor";
|
||||||
|
|
||||||
export const Route = createFileRoute("/admin/products/$id")({
|
export const Route = createFileRoute("/admin/products/$id")({
|
||||||
loader: ({ params }) =>
|
loader: ({ params }) =>
|
||||||
@@ -196,8 +197,8 @@ function ProductEditPage() {
|
|||||||
<Form.Item name="shortDescription" label="短描述">
|
<Form.Item name="shortDescription" label="短描述">
|
||||||
<Input.TextArea rows={2} />
|
<Input.TextArea rows={2} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="description" label="详细描述 HTML">
|
<Form.Item name="description" label="详细描述(富文本编辑)">
|
||||||
<Input.TextArea rows={14} style={{ fontFamily: "monospace", fontSize: 12 }} />
|
<RichEditor minHeight={380} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{attributes.length > 0 && (
|
{attributes.length > 0 && (
|
||||||
<Card type="inner" title="产品属性" size="small" style={{ marginBottom: 16 }}>
|
<Card type="inner" title="产品属性" size="small" style={{ marginBottom: 16 }}>
|
||||||
|
|||||||
+292
-40
@@ -1,12 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* # 路由 /admin/settings 系统设置
|
* # 路由 /admin/settings 系统设置
|
||||||
* settings 表 kv 编辑器:每键一个 JSON 编辑卡片,保存整键覆写。
|
* Tabs 分组表单(v0.14.0 起弃用整键 JSON 编辑器):站点信息 / 商城配置 / 邮件配置 /
|
||||||
|
* SEO / 登录安全,每组一个 antd Form,保存时与现有值合并(未知字段保留)。
|
||||||
|
* 其余键(backup 等由专属页面管理的除外)落到「其他」Tab,保留 JSON 编辑兜底。
|
||||||
* 仅 admin 角色可保存(server 端强制)。
|
* 仅 admin 角色可保存(server 端强制)。
|
||||||
*/
|
*/
|
||||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
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 { Save } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { adminListSettings, adminUpdateSetting } from "~/server/admin";
|
import { adminListSettings, adminUpdateSetting } from "~/server/admin";
|
||||||
import { Forbidden, PageHeader, fmtDateTime, guardAdmin, type Json } from "~/components/admin/common";
|
import { Forbidden, PageHeader, fmtDateTime, guardAdmin, type Json } from "~/components/admin/common";
|
||||||
|
|
||||||
@@ -15,14 +17,164 @@ export const Route = createFileRoute("/admin/settings")({
|
|||||||
component: SettingsPage,
|
component: SettingsPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** # 成员变量 设置键中文说明(未知键回退原键名) **/
|
/** # 类型 设置表单字段描述(配置驱动渲染) **/
|
||||||
const SETTING_LABEL: Record<string, string> = {
|
type FieldDef = {
|
||||||
site: "站点信息",
|
/** # 成员变量 字段名(写入 settings 值对象的键) **/
|
||||||
woocommerce: "商城配置",
|
name: string;
|
||||||
mail: "邮件配置",
|
/** # 成员变量 中文标签 **/
|
||||||
seo: "SEO 配置",
|
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 序列化前的服务端类型) **/
|
/** # 类型 设置行(updatedAt 为 Date,loader 序列化前的服务端类型) **/
|
||||||
type SettingRow = { key: string; value: Json; updatedAt: Date | string };
|
type SettingRow = { key: string; value: Json; updatedAt: Date | string };
|
||||||
|
|
||||||
@@ -31,20 +183,28 @@ function SettingsPage() {
|
|||||||
const data = Route.useLoaderData();
|
const data = Route.useLoaderData();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
/** # 成员变量 各键的草稿文本(键 → JSON 字符串) **/
|
/** # 成员变量 其他键的 JSON 草稿(键 → 文本) **/
|
||||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
const [otherDrafts, setOtherDrafts] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data) return;
|
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]);
|
}, [data]);
|
||||||
|
|
||||||
if (!data) return <Forbidden />;
|
if (!data) return <Forbidden />;
|
||||||
|
|
||||||
/** # 函数 保存单个设置键(JSON 校验失败提示) */
|
const rows = data as SettingRow[];
|
||||||
async function handleSave(key: string) {
|
|
||||||
|
/** # 函数 保存「其他」键的 JSON */
|
||||||
|
async function handleSaveOther(key: string) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(drafts[key] ?? "{}") as Json;
|
const parsed = JSON.parse(otherDrafts[key] ?? "{}") as Json;
|
||||||
await adminUpdateSetting({ data: { key, value: parsed } });
|
await adminUpdateSetting({ data: { key, value: parsed } });
|
||||||
message.success(`已保存 ${key}`);
|
message.success(`已保存 ${key}`);
|
||||||
await router.invalidate();
|
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 (
|
return (
|
||||||
<Card size="small">
|
<Card size="small">
|
||||||
<PageHeader title="系统设置" extra={<Tag color="orange">仅管理员可保存</Tag>} />
|
<PageHeader title="系统设置" extra={<Tag color="orange">仅管理员可保存</Tag>} />
|
||||||
<Space direction="vertical" size={16} style={{ display: "flex" }}>
|
<Tabs
|
||||||
{data.map((r: SettingRow) => (
|
defaultActiveKey={GROUPS[0]!.key}
|
||||||
<Card
|
items={GROUPS.map((g) => ({
|
||||||
key={r.key}
|
key: g.key,
|
||||||
type="inner"
|
label: g.label,
|
||||||
size="small"
|
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} />,
|
||||||
title={`${SETTING_LABEL[r.key] ?? r.key}(${r.key})`}
|
})).concat(
|
||||||
extra={
|
Object.keys(otherDrafts).length
|
||||||
<Button size="small" type="primary" icon={<Save size={13} aria-hidden />} onClick={() => void handleSave(r.key)}>
|
? [
|
||||||
保存
|
{
|
||||||
</Button>
|
key: "__other__",
|
||||||
}
|
label: "其他",
|
||||||
>
|
children: (
|
||||||
<Input.TextArea
|
<Space direction="vertical" size={16} style={{ display: "flex" }}>
|
||||||
rows={Math.min(14, Math.max(4, (drafts[r.key] ?? "").split("\n").length))}
|
<Typography.Text type="secondary">以下键无表单定义(如由脚本写入的配置),按 JSON 编辑保存。</Typography.Text>
|
||||||
value={drafts[r.key] ?? ""}
|
{Object.keys(otherDrafts).map((k) => (
|
||||||
onChange={(e) => setDrafts((d) => ({ ...d, [r.key]: e.target.value }))}
|
<Card key={k} type="inner" size="small" title={k} extra={
|
||||||
style={{ fontFamily: "monospace", fontSize: 12 }}
|
<Button size="small" type="primary" icon={<Save size={13} aria-hidden />} onClick={() => void handleSaveOther(k)}>
|
||||||
/>
|
保存
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
</Button>
|
||||||
最后更新:{fmtDateTime(r.updatedAt)}
|
}>
|
||||||
</Typography.Text>
|
<Input.TextArea
|
||||||
</Card>
|
rows={Math.min(14, Math.max(4, (otherDrafts[k] ?? "").split("\n").length))}
|
||||||
))}
|
value={otherDrafts[k] ?? ""}
|
||||||
</Space>
|
onChange={(e) => setOtherDrafts((d) => ({ ...d, [k]: e.target.value }))}
|
||||||
|
style={{ fontFamily: "monospace", fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</Card>
|
</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,
|
SlidersHorizontal,
|
||||||
UserRound,
|
UserRound,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState, type ReactNode } from "react";
|
import { useState, useEffect, type ReactNode } from "react";
|
||||||
import { StyleProvider } from "@ant-design/cssinjs";
|
import { StyleProvider } from "@ant-design/cssinjs";
|
||||||
import { getCurrentUser, loginFn, logoutFn } from "~/server/auth";
|
import { getCurrentUser, loginFn, logoutFn } from "~/server/auth";
|
||||||
|
import { getLoginSecurityPublic } from "~/server/security";
|
||||||
|
import { RecaptchaField, useRecaptcha, type LoginSecurityPublic } from "~/components/admin/Recaptcha";
|
||||||
|
|
||||||
/** # 类型 后台菜单分组(分组标签不可点;key 即目标路由路径) **/
|
/** # 类型 后台菜单分组(分组标签不可点;key 即目标路由路径) **/
|
||||||
const MENU_GROUPS: { label: string; children: { key: string; icon: ReactNode; label: string }[] }[] = [
|
const MENU_GROUPS: { label: string; children: { key: string; icon: ReactNode; label: string }[] }[] = [
|
||||||
@@ -103,21 +105,40 @@ function AdminLayout() {
|
|||||||
return <AdminShell user={user} />;
|
return <AdminShell user={user} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** # 组件 后台登录页(全屏居中卡片,邮箱 + 密码) */
|
/** # 组件 后台登录页(全屏居中卡片,邮箱 + 密码;reCAPTCHA 按系统设置「登录安全」开关渲染) */
|
||||||
function AdminLoginPage(props: { noPermission: boolean; onOk: () => Promise<void> }) {
|
function AdminLoginPage(props: { noPermission: boolean; onOk: () => Promise<void> }) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
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 }) {
|
async function handleFinish(values: { email: string; password: string }) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const res = await loginFn({ data: { email: values.email, password: values.password } });
|
const token = await recaptcha.getToken();
|
||||||
if (!res.ok) setError(res.error);
|
if (secCfg?.enabled && token === null) {
|
||||||
else await props.onOk();
|
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) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "登录失败,请稍后重试");
|
setError(e instanceof Error ? e.message : "登录失败,请稍后重试");
|
||||||
|
recaptcha.reset();
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -144,6 +165,8 @@ function AdminLoginPage(props: { noPermission: boolean; onOk: () => Promise<void
|
|||||||
<Form.Item name="password" label="密码" rules={[{ required: true, message: "请输入密码" }]}>
|
<Form.Item name="password" label="密码" rules={[{ required: true, message: "请输入密码" }]}>
|
||||||
<Input.Password placeholder="密码" autoComplete="current-password" />
|
<Input.Password placeholder="密码" autoComplete="current-password" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
{/* reCAPTCHA(系统设置 → 登录安全 开启后出现;v2 复选框 / v3 隐形) */}
|
||||||
|
<RecaptchaField cfg={secCfg} rec={recaptcha} />
|
||||||
{error ? (
|
{error ? (
|
||||||
<div role="alert" style={{ color: "#cf1322", background: "#fff1f0", border: "1px solid #ffa39e", borderRadius: 6, padding: "8px 12px", marginBottom: 16, fontSize: 13 }}>
|
<div role="alert" style={{ color: "#cf1322", background: "#fff1f0", border: "1px solid #ffa39e", borderRadius: 6, padding: "8px 12px", marginBottom: 16, fontSize: 13 }}>
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
* 未登录:登录 / 注册两个 Tab 表单;已登录:资料卡 + 订单列表 + 退出登录。
|
* 未登录:登录 / 注册两个 Tab 表单;已登录:资料卡 + 订单列表 + 退出登录。
|
||||||
*/
|
*/
|
||||||
import { createFileRoute, Link, useRouter } from "@tanstack/react-router";
|
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 { 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 { useT } from "~/i18n";
|
||||||
import { getMyOrders } from "~/server/commerce";
|
import { getMyOrders } from "~/server/commerce";
|
||||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||||
@@ -127,14 +129,34 @@ function AccountInner() {
|
|||||||
function LoginForm({ onDone }: { onDone: () => void }) {
|
function LoginForm({ onDone }: { onDone: () => void }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [authError, setAuthError] = useState("");
|
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>) {
|
async function handleLogin(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setAuthError("");
|
setAuthError("");
|
||||||
|
const token = await recaptcha.getToken();
|
||||||
|
if (secCfg?.enabled && token === null) {
|
||||||
|
setAuthError("人机验证未完成,请完成勾选/等待验证脚本加载后重试");
|
||||||
|
recaptcha.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const fd = new FormData(e.currentTarget);
|
const fd = new FormData(e.currentTarget);
|
||||||
const res = await loginFn({ data: { email: String(fd.get("email") ?? ""), password: String(fd.get("password") ?? "") } });
|
const res = await loginFn({ data: { email: String(fd.get("email") ?? ""), password: String(fd.get("password") ?? ""), recaptchaToken: token ?? undefined } });
|
||||||
if (!res.ok) return setAuthError(res.error);
|
if (!res.ok) {
|
||||||
|
setAuthError(res.error);
|
||||||
|
recaptcha.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
onDone();
|
onDone();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +171,8 @@ function LoginForm({ onDone }: { onDone: () => void }) {
|
|||||||
Password
|
Password
|
||||||
<Input name="password" type="password" required autoComplete="current-password" />
|
<Input name="password" type="password" required autoComplete="current-password" />
|
||||||
</Label>
|
</Label>
|
||||||
|
{/* reCAPTCHA(系统设置 → 登录安全 开启后出现;v2 复选框 / v3 隐形) */}
|
||||||
|
<RecaptchaField cfg={secCfg} rec={recaptcha} />
|
||||||
<div className="col-span-full">
|
<div className="col-span-full">
|
||||||
<Button type="submit">{t("acct_login")}</Button>
|
<Button type="submit">{t("acct_login")}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+23
-3
@@ -67,13 +67,33 @@ export const getCurrentUser = createServerFn({ method: "GET" }).handler(async ()
|
|||||||
return getSessionUser(token);
|
return getSessionUser(token);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** # 函数 Server Function:登录 */
|
/** # 函数 Server Function:登录(含登录安全:失败锁定 + reCAPTCHA 服务端校验)
|
||||||
|
* reCAPTCHA 启用时前端必须回传 recaptchaToken(由公开配置 serverFn 驱动组件渲染)。
|
||||||
|
*/
|
||||||
export const loginFn = createServerFn({ method: "POST" })
|
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 }) => {
|
.handler(async ({ data }) => {
|
||||||
const { setCookie } = await import("@tanstack/react-start/server");
|
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);
|
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);
|
const token = await createSession(user.id);
|
||||||
setCookie(SESSION_COOKIE, token, {
|
setCookie(SESSION_COOKIE, token, {
|
||||||
httpOnly: true,
|
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="3361c18"] > div,
|
||||||
[data-el="3317056"] > div,
|
[data-el="3317056"] > div,
|
||||||
[data-el="5938d26"] > div { width: auto !important; }
|
[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