升级帖子编辑与 Feed 体验:TipTap 富文本、修订历史、排序与编辑时限。

将 Markdown 编辑器替换为 TipTap WYSIWYG,新增帖子修订记录与 diff 展示;首页支持最新/回复排序与本地缓存;后台可配置编辑时限与锁定帖子;侧边栏整合板块导航并优化 Feed 布局。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
freefire
2026-06-16 03:05:45 +08:00
parent d0555de28e
commit 1d273066b0
72 changed files with 3576 additions and 676 deletions

View File

@@ -16,10 +16,19 @@
"@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-switch": "^1.3.0",
"@tanstack/react-virtual": "^3.11.2",
"@tiptap/core": "^3.26.1",
"@tiptap/extension-image": "^3.26.1",
"@tiptap/extension-link": "^3.26.1",
"@tiptap/extension-placeholder": "^3.26.1",
"@tiptap/extension-underline": "^3.26.1",
"@tiptap/pm": "^3.26.1",
"@tiptap/react": "^3.26.1",
"@tiptap/starter-kit": "^3.26.1",
"autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"diff": "^9.0.0",
"dompurify": "^3.4.10",
"lucide-react": "^1.18.0",
"marked": "^18.0.5",
@@ -1921,6 +1930,461 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tiptap/core": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.26.1.tgz",
"integrity": "sha512-TX9PyPqBoix0qDLjtok/bddtdSy54QhzLVha405C07V+WySOpH3s/pWYkywehZQY0SQtcrcY4MNSCeQjCbA28A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "3.26.1"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.26.1.tgz",
"integrity": "sha512-WaKjKmUaadgvZDDBk9JOn/oidlOFr6booqJIWHGL5S0aUUTKHS19oGfKQq/l9Z1y1niaRePk0Y4fy/jxCnfKPA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.26.1.tgz",
"integrity": "sha512-VIlF2sAiV6K009pcIDotfY8mvsPaq90dxeG9Q0ZIqfMD958TUCqjHw4MGYZf0/FgP12xksBfmcR7W312xgUf9Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.26.1.tgz",
"integrity": "sha512-Y3R9wFKP/U9M04JG+0PM/yW3OV+MSbUp6YBKQWZmUu8x6y7TbcNvDsaJ6QEFZt5aRMS6qH1ksYPTOz47JdjcfA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1",
"@tiptap/pm": "3.26.1"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.26.1.tgz",
"integrity": "sha512-JB6bEJJHxXNAXEXTIAN3/j70p1ARHdeMfhzshGZswWKUWtDibTCrspIp7p1VNeiuVtJ/HB6PpFkGi7yWtQ3RTg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.26.1"
}
},
"node_modules/@tiptap/extension-code": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.26.1.tgz",
"integrity": "sha512-t9/VR5k3rGPyhcGau9YvVgaAQ+nP9R9WzS996bQQ7GIrMOTSXb0FWwoQFBiYl83V6VA16Tlj/oScC7SFlA8lvA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.26.1.tgz",
"integrity": "sha512-NY7SYqcrqDVYTSWyaNGdSfCims6pOHoRQ2Rh4DEFb/rb8gLVkqbLZhcHzQCVfinlPqgV3xWF6cYMORwmnlBkXQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1",
"@tiptap/pm": "3.26.1"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.26.1.tgz",
"integrity": "sha512-6W2vZjvi0Mv+4xEtwMDGhWwo7FotWR6eKfmntmduvehWevFpMxOKcTtyotjLigfZv738y50YWmvbaPuAPJG3BA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.26.1.tgz",
"integrity": "sha512-eVq3BvFIa3YD+pBIlj1i72vYEixlegGVKHnSYiVF2ovkQOSAH9sca7pkq6WgV1sMTCyWCU8e+WznTqtydvHUWA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.26.1"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.26.1.tgz",
"integrity": "sha512-xn0g4m/q2bjG+hULPwp6Aqb/6wpzUtc65jOhgJsG/S3Ey3kLJGUvZBuhozwNFu8FcugxM1fMUpNhkJkodCCGFw==",
"license": "MIT",
"optional": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "3.26.1",
"@tiptap/pm": "3.26.1"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.26.1.tgz",
"integrity": "sha512-BWW1yMQQA4TbEU0LLK+4cd9ebLTuZG5KjHwFMBRD/bGiRW9V1gTWFsCqThBbczcANoQiZK9pn5/4Ad/rGM3HUg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.26.1"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.26.1.tgz",
"integrity": "sha512-gzNb1e/fK6HN+ko1axsrasjK7F1q0Bnm0G4ZY/0eq7pV7s1wZuwoCiGbvUx/9LCFKRV6+94FTqlb0A3NbYN36g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.26.1.tgz",
"integrity": "sha512-eRlv9XxzUL8FobKAiF1WjP35CT2QpbcxxeyYFF7BmGEONvKI7r5g7JGwyGli4Cvclh70h8w6JuoXSmGUVEU65A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.26.1.tgz",
"integrity": "sha512-l9lPZYeSmY90y/2GkQcKaICFD5Atr8sx2SzJGkQzpNC9tRxZXyAHnfJE3OjBkspuGzjWIN0DimxBj4ibz58sKw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1",
"@tiptap/pm": "3.26.1"
}
},
"node_modules/@tiptap/extension-image": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.26.1.tgz",
"integrity": "sha512-IjoT+kRK4a1sTImvUz257yfk5l9kMxXxfxCfix5AUKdiWyn8SGUjJZapLICcZVY05UDqXmwsBvBK9lHkKX5ERg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.26.1.tgz",
"integrity": "sha512-cLKYvOLToWEkJkAPspgIZ/PYDzAxacLm1VWcAq1tO1QDQCDe2Kw+y/zsGlyYEq/aKsAgpp4JNopBwAXRXxt2/A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-link": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.26.1.tgz",
"integrity": "sha512-aLLGLgikuhLFHRbjfUC6D4gRg+NUty4uhW7YkyVl8AxxPME47dPbCOX4H6uLCjEZcn3WnfNuCTr6HCTl0KEmGA==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1",
"@tiptap/pm": "3.26.1"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.26.1.tgz",
"integrity": "sha512-06nOjnyXpzMO8Ys5k3IbYsDsKib1mv2OtaxBYX1/1uvRyOKwUX5tqDLb/qigic0LIANNL73lkNC8Z8XPeG4Tkg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1",
"@tiptap/pm": "3.26.1"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.26.1.tgz",
"integrity": "sha512-5gLXJUiP763NA6i4HgrtcwUDXPP8820hsaBQyF1Y1VsXNi02uW9FVLe3RZK8jF0NZUNh9CqD0gogYJCbKOUU8A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.26.1"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.26.1.tgz",
"integrity": "sha512-EReSayePO6SIxtRbxx+7KfBQreWHvoZmMb3O/RemfT8W6J0hCG5N/Rh8Z12+YZOnCDRXJ4RzFpAikYka3E54jQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.26.1"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.26.1.tgz",
"integrity": "sha512-LeFPeFwb7ylkQVuuaHj+niu7WhWHpjDOi1GKZJE/ohOa2lgt7P221HMqhUzPiDlXOExN72oWTNmXUlT0ymCTkw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.26.1"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.26.1.tgz",
"integrity": "sha512-OkBeYUNM3eTzjm3z6IcC3NHryOX8g3eGNI86P/B+tFoFQSRuzLsKZU50ARCfIiLLg812NjcqujeJ1eX3BKDZrw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-placeholder": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.26.1.tgz",
"integrity": "sha512-oJCEVmaaUY1Jn5v8KbRMdgYLFH9aptLkir+M0ZMnl+8TTmvMdLK2H02X9ofZQwAb12qreQgb890hB3PFen7TDg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.26.1"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.26.1.tgz",
"integrity": "sha512-7hmQ2mBsA+75GRrJIKYxb+10H23mblEQSGGsv9Ptl7JLaGmj+8sv2HGQGSUT9QBiBVprxaYTqyWFXQC9akfLWg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.26.1.tgz",
"integrity": "sha512-Gocui5WvcCCJJIX17gdOVCSdYi5H4fDwaR0qkMAUZPq5kJCdrfl+vNpt8BTt53Bk+/QumiUW21fhQ184w7RoeQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.26.1.tgz",
"integrity": "sha512-HUHtQ+DRWDM0opW7Nk3YQwrLzw876hMU7cr1X/ZTG+8Bp+AKHihlwU+bqrPgG5St0mqASyUEhHQ/vK5PlnUYOQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.26.1.tgz",
"integrity": "sha512-PmRaoe6bebTgz/ZQrjmzwZMST1d9js9ZTiKnUXeXl3Fm+V5U/c3TbbKDfqmL63qPQdjtShDMHi9tYuv+c77OFQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.26.1",
"@tiptap/pm": "3.26.1"
}
},
"node_modules/@tiptap/pm": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.26.1.tgz",
"integrity": "sha512-48cJQRbvr9Ux0+IgM1BR5vOLU5hkC+n+uerdQy2JjrIRKpYE/huU8fQFm6PoRppoKYfilklzb29elsQ+n2TA+g==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.7",
"prosemirror-schema-list": "^1.5.0",
"prosemirror-state": "^1.4.4",
"prosemirror-tables": "^1.8.0",
"prosemirror-transform": "^1.12.0",
"prosemirror-view": "^1.41.8"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/react": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.26.1.tgz",
"integrity": "sha512-Gl7AhTJM7pjQ2WFwdIwD736oQeqUcw3GVaXYmCKtwTSO3F9PszLgeKEp6DvM+CmctTNYhu/apRfzkH3vU0h0uA==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"fast-equals": "^5.3.3",
"use-sync-external-store": "^1.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"optionalDependencies": {
"@tiptap/extension-bubble-menu": "^3.26.1",
"@tiptap/extension-floating-menu": "^3.26.1"
},
"peerDependencies": {
"@tiptap/core": "3.26.1",
"@tiptap/pm": "3.26.1",
"@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"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.26.1.tgz",
"integrity": "sha512-A0zsvwGU9exLND34F8e8KqUXFSfs835tNN+VC+ZT3yNeaO/WXnlh/Cgal1F6pHHbcxy7RV2CRwJU5S3cWLPxrA==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^3.26.1",
"@tiptap/extension-blockquote": "^3.26.1",
"@tiptap/extension-bold": "^3.26.1",
"@tiptap/extension-bullet-list": "^3.26.1",
"@tiptap/extension-code": "^3.26.1",
"@tiptap/extension-code-block": "^3.26.1",
"@tiptap/extension-document": "^3.26.1",
"@tiptap/extension-dropcursor": "^3.26.1",
"@tiptap/extension-gapcursor": "^3.26.1",
"@tiptap/extension-hard-break": "^3.26.1",
"@tiptap/extension-heading": "^3.26.1",
"@tiptap/extension-horizontal-rule": "^3.26.1",
"@tiptap/extension-italic": "^3.26.1",
"@tiptap/extension-link": "^3.26.1",
"@tiptap/extension-list": "^3.26.1",
"@tiptap/extension-list-item": "^3.26.1",
"@tiptap/extension-list-keymap": "^3.26.1",
"@tiptap/extension-ordered-list": "^3.26.1",
"@tiptap/extension-paragraph": "^3.26.1",
"@tiptap/extension-strike": "^3.26.1",
"@tiptap/extension-text": "^3.26.1",
"@tiptap/extension-underline": "^3.26.1",
"@tiptap/extensions": "^3.26.1",
"@tiptap/pm": "^3.26.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1987,14 +2451,12 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.31",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -2005,7 +2467,6 @@
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
@@ -2018,6 +2479,12 @@
"license": "MIT",
"optional": true
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -2299,7 +2766,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/dayjs": {
@@ -2338,6 +2804,15 @@
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"license": "Apache-2.0"
},
"node_modules/diff": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz",
"integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@@ -2416,6 +2891,15 @@
"node": ">=6"
}
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -2660,6 +3144,12 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT"
},
"node_modules/linkifyjs": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -2797,6 +3287,12 @@
"node": ">= 6"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -2995,6 +3491,145 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/prosemirror-changeset": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.9",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.9.tgz",
"integrity": "sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"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"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.41.9",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.9.tgz",
"integrity": "sha512-clTunTX+eaLbr87L1V1QPheRlEQJyTlL3gXe9x3jQIk3rL0RVWxviDGz8tFaydwIVm+hKhYCyr+R/zBtWr9s6A==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.8",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -3264,6 +3899,12 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -3599,6 +4240,15 @@
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -3665,6 +4315,12 @@
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -17,10 +17,19 @@
"@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-switch": "^1.3.0",
"@tanstack/react-virtual": "^3.11.2",
"@tiptap/core": "^3.26.1",
"@tiptap/extension-image": "^3.26.1",
"@tiptap/extension-link": "^3.26.1",
"@tiptap/extension-placeholder": "^3.26.1",
"@tiptap/extension-underline": "^3.26.1",
"@tiptap/pm": "^3.26.1",
"@tiptap/react": "^3.26.1",
"@tiptap/starter-kit": "^3.26.1",
"autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"diff": "^9.0.0",
"dompurify": "^3.4.10",
"lucide-react": "^1.18.0",
"marked": "^18.0.5",

View File

@@ -1,4 +1,4 @@
import type { User, Board, PostItem, Comment, Notification, OnlineUser, OnlineStats, ForumStats, AdminDashboard, AdminSettings } from './types';
import type { User, Board, PostItem, Comment, Notification, OnlineUser, OnlineStats, ForumStats, AdminDashboard, AdminSettings, PostDetailResponse, PostRevision } from './types';
const BASE = '';
@@ -30,7 +30,7 @@ export const api = {
return request<{ posts: PostItem[]; total: number; page: number; has_more: boolean }>(`/api/posts?${q}`);
},
hotPosts: () => request<{ posts: PostItem[] }>('/api/posts/hot'),
post: (id: number) => request<{ post: PostItem; comment_count: number; liked: boolean; favorited: boolean }>(`/api/posts/${id}`),
post: (id: number) => request<PostDetailResponse>(`/api/posts/${id}`),
comments: (id: number, myIds?: number[]) => {
const q = myIds?.length ? `?my_ids=${myIds.join(',')}` : '';
return request<{ comments: Comment[]; total: number }>(`/api/posts/${id}/comments${q}`);
@@ -60,6 +60,18 @@ export const api = {
request<{ message: string; pinned: boolean }>(`/api/admin/posts/${id}/pin`, {
method: 'POST', body: JSON.stringify({ pinned }),
}),
adminLockPost: (id: number, locked: boolean) =>
request<{ message: string; edit_locked: boolean }>(`/api/admin/posts/${id}/lock`, {
method: 'POST', body: JSON.stringify({ locked }),
}),
adminUpdateForumSettings: (body: { post_edit_window_hours: number }) =>
request<{ message: string; post_edit_window_hours: number }>('/api/admin/settings/forum', {
method: 'PUT', body: JSON.stringify(body),
}),
postRevisions: (id: number) =>
request<{ revisions: PostRevision[] }>(`/api/posts/${id}/revisions`),
postRevision: (id: number, revId: number) =>
request<{ revision: PostRevision }>(`/api/posts/${id}/revisions/${revId}`),
adminDeletePost: (id: number) => request(`/api/admin/posts/${id}`, { method: 'DELETE' }),
adminComments: (page = 1) =>
request<{ comments: Comment[]; total: number; page: number; total_pages: number }>(

View File

@@ -30,14 +30,39 @@ export interface PostItem {
content?: string;
tags: string;
pinned: boolean;
edit_locked?: boolean;
like_count: number;
view_count: number;
comment_count: number;
last_reply_at?: string;
created_at: string;
updated_at?: string;
board?: Board;
user?: User;
}
export interface PostRevision {
id: number;
post_id: number;
editor_id: number;
title: string;
content: string;
tags: string;
created_at: string;
editor?: User;
}
export interface PostDetailResponse {
post: PostItem;
comment_count: number;
liked: boolean;
favorited: boolean;
can_edit?: boolean;
edit_block_reason?: string;
is_edited?: boolean;
post_edit_window_hours?: number;
}
export interface Comment {
id: number;
post_id: number;
@@ -70,6 +95,7 @@ export interface AdminSettings {
data_dir: string;
db_path: string;
port: number;
post_edit_window_hours: number;
}
export interface Paginated<T> {

View File

@@ -1,16 +1,23 @@
import {
useState, useRef, useEffect, useCallback, useImperativeHandle, forwardRef, useMemo, type ReactNode,
useRef, useEffect, useImperativeHandle, forwardRef, useCallback, useState, type ReactNode,
} from 'react';
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import Placeholder from '@tiptap/extension-placeholder';
import Underline from '@tiptap/extension-underline';
import DOMPurify from 'dompurify';
import {
Bold, Italic, Strikethrough, Link, Code, Quote,
List, ListOrdered, Image, Eye, Pencil, Minus, LockKeyhole,
Bold, Italic, Strikethrough, Link as LinkIcon, Code, Quote,
List, ListOrdered, Image as ImageIcon, Minus, LockKeyhole,
} from 'lucide-react';
import { markdownToHtml, countWords } from '../utils/markdown';
import { renderPostContentHtml } from '../utils/postContent';
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
import { countWords } from '../utils/markdown';
import { MembersOnly } from './editor/MembersOnlyExtension';
export interface ArticleEditorHandle {
getHTML: () => string;
getMarkdown: () => string;
isEmpty: () => boolean;
focus: () => void;
}
@@ -21,181 +28,206 @@ interface Props {
placeholder?: string;
}
type ViewMode = 'edit' | 'preview' | 'split';
interface ToolBtn {
icon: ReactNode;
title: string;
active?: boolean;
className?: string;
action: () => void;
}
/** 去掉行首已有的 Markdown 块级前缀 */
function stripLinePrefix(line: string): string {
return line
.replace(/^\r/, '')
.replace(/^#{1,6}\s*/, '')
.replace(/^>\s*/, '')
.replace(/^[-*+]\s*/, '')
.replace(/^\d+\.\s*/, '');
/** 净化编辑器 HTML保留 members-only 自定义标签 */
function sanitizeHtml(html: string): string {
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
}
/** 判断编辑器内容是否为空 */
function isEditorEmpty(editor: Editor): boolean {
return editor.state.doc.textContent.trim().length === 0;
}
/** 标题循环:无标题 → H2 → H3 → … → H6 → 正文 */
function cycleHeading(editor: Editor) {
for (let level = 2; level <= 6; level += 1) {
if (editor.isActive('heading', { level })) {
if (level === 6) {
editor.chain().focus().setParagraph().run();
} else {
editor.chain().focus().toggleHeading({ level: (level + 1) as 2 | 3 | 4 | 5 | 6 }).run();
}
return;
}
}
editor.chain().focus().toggleHeading({ level: 2 }).run();
}
const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEditor(
{ value, onChange, placeholder = '在此撰写正文…' },
ref,
) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const selectionRef = useRef({ start: 0, end: 0 });
const [viewMode, setViewMode] = useState<ViewMode>('split');
const [previewHtml, setPreviewHtml] = useState('');
const isInternalUpdate = useRef(false);
const lastValueRef = useRef(value);
const [, setEditorTick] = useState(0);
useImperativeHandle(ref, () => ({
getHTML: () => markdownToHtml(value),
getMarkdown: () => value,
isEmpty: () => value.trim().length === 0,
focus: () => textareaRef.current?.focus(),
}));
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3, 4, 5, 6] },
}),
Underline,
Link.configure({
openOnClick: false,
autolink: true,
defaultProtocol: 'https',
}),
Image.configure({ inline: false, allowBase64: false }),
Placeholder.configure({ placeholder }),
MembersOnly,
],
content: sanitizeHtml(value) || '',
onUpdate: ({ editor: ed }) => {
const html = sanitizeHtml(ed.getHTML());
isInternalUpdate.current = true;
lastValueRef.current = html;
onChange(html);
},
onSelectionUpdate: () => setEditorTick(t => t + 1),
onTransaction: () => setEditorTick(t => t + 1),
editorProps: {
attributes: {
class: 'article-prosemirror post-detail-content',
spellcheck: 'false',
},
},
});
const saveSelection = useCallback(() => {
const ta = textareaRef.current;
if (!ta) return;
selectionRef.current = { start: ta.selectionStart, end: ta.selectionEnd };
}, []);
const getSelection = useCallback(() => {
const ta = textareaRef.current;
if (ta && document.activeElement === ta) {
return { start: ta.selectionStart, end: ta.selectionEnd };
}
return selectionRef.current;
}, []);
const restoreSelection = useCallback((start: number, end = start) => {
requestAnimationFrame(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.focus();
ta.setSelectionRange(start, end);
selectionRef.current = { start, end };
});
}, []);
// 实时预览,短 debounce 保证流畅
// 外部 value 变更时同步到编辑器(如加载已有帖子)
useEffect(() => {
const t = setTimeout(() => setPreviewHtml(markdownToHtml(value)), 60);
return () => clearTimeout(t);
}, [value]);
// 编辑区随内容向下延伸,最小高度撑满视口剩余空间
const adjustTextareaHeight = useCallback(() => {
const ta = textareaRef.current;
if (!ta || viewMode === 'preview') return;
ta.style.height = '0px';
const contentHeight = ta.scrollHeight;
const top = ta.getBoundingClientRect().top;
const minHeight = Math.max(280, window.innerHeight - top - 56);
ta.style.height = `${Math.max(minHeight, contentHeight)}px`;
}, [viewMode]);
useEffect(() => {
adjustTextareaHeight();
}, [value, viewMode, adjustTextareaHeight]);
useEffect(() => {
const onResize = () => adjustTextareaHeight();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [adjustTextareaHeight]);
const insertAtCursor = useCallback((before: string, after = '', placeholderText = '') => {
const ta = textareaRef.current;
if (!ta) return;
const { start, end } = getSelection();
const selected = value.slice(start, end) || placeholderText;
const next = value.slice(0, start) + before + selected + after + value.slice(end);
onChange(next);
restoreSelection(start + before.length + selected.length);
}, [value, onChange, getSelection, restoreSelection]);
const wrapLine = useCallback((prefix: string) => {
const ta = textareaRef.current;
if (!ta) return;
const { start } = getSelection();
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const lineEnd = value.indexOf('\n', start);
const end = lineEnd === -1 ? value.length : lineEnd;
const line = value.slice(lineStart, end);
const stripped = stripLinePrefix(line);
const next = value.slice(0, lineStart) + prefix + stripped + value.slice(end);
onChange(next);
restoreSelection(lineStart + prefix.length + stripped.length);
}, [value, onChange, getSelection, restoreSelection]);
/** 标题:无 → H2 → H3 → … → H6 → 取消 */
const toggleHeading = useCallback(() => {
const ta = textareaRef.current;
if (!ta) return;
const { start } = getSelection();
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const lineEnd = value.indexOf('\n', start);
const end = lineEnd === -1 ? value.length : lineEnd;
const line = value.slice(lineStart, end);
const normalized = line.replace(/^\r/, '');
const match = normalized.match(/^(#{1,6})\s+(.*)$/);
let nextLine: string;
if (match) {
const level = match[1].length;
const text = match[2];
nextLine = level >= 6 ? text : `${'#'.repeat(level + 1)} ${text}`;
} else {
nextLine = `## ${stripLinePrefix(normalized)}`;
}
const next = value.slice(0, lineStart) + nextLine + value.slice(end);
onChange(next);
const cursor = lineStart + nextLine.length;
restoreSelection(cursor);
}, [value, onChange, getSelection, restoreSelection]);
/** 包裹为仅登录用户可见区块 */
const wrapMembersOnly = useCallback(() => {
const { start, end } = getSelection();
if (start !== end) {
const selected = value.slice(start, end);
const block = `\n:::members\n${selected}\n:::\n`;
const next = value.slice(0, start) + block + value.slice(end);
onChange(next);
restoreSelection(start + ':::members\n'.length + 1);
if (!editor) return;
if (isInternalUpdate.current) {
isInternalUpdate.current = false;
return;
}
insertAtCursor('\n:::members\n', '\n:::\n', '在此输入仅登录用户可见的内容…');
}, [value, onChange, getSelection, restoreSelection, insertAtCursor]);
const next = sanitizeHtml(value);
if (next === lastValueRef.current) return;
lastValueRef.current = next;
editor.commands.setContent(next || '', { emitUpdate: false });
}, [value, editor]);
const tools: ToolBtn[] = [
{ icon: <strong>H</strong>, title: '标题H2再次点击升级', action: toggleHeading },
{ icon: <Bold size={15} />, title: '加粗', action: () => insertAtCursor('**', '**', '加粗') },
{ icon: <Italic size={15} />, title: '斜体', action: () => insertAtCursor('*', '*', '斜体') },
{ icon: <Strikethrough size={15} />, title: '删除线', action: () => insertAtCursor('~~', '~~', '删除') },
{ icon: <Minus size={15} />, title: '分割线', action: () => insertAtCursor('\n\n---\n\n') },
{ icon: <Quote size={15} />, title: '引用', action: () => wrapLine('> ') },
{ icon: <List size={15} />, title: '无序列表', action: () => wrapLine('- ') },
{ icon: <ListOrdered size={15} />, title: '有序列表', action: () => wrapLine('1. ') },
{ icon: <Code size={15} />, title: '代码块', action: () => insertAtCursor('\n```\n', '\n```\n', 'code') },
{ icon: <Link size={15} />, title: '链接', action: () => insertAtCursor('[', '](url)', '链接文字') },
{ icon: <Image size={15} />, title: '图片', action: () => insertAtCursor('![', '](url)', '描述') },
{ icon: <LockKeyhole size={15} />, title: '登录可见(选中文字后点击可包裹)', action: wrapMembersOnly },
];
useImperativeHandle(ref, () => ({
getHTML: () => (editor ? sanitizeHtml(editor.getHTML()) : value),
isEmpty: () => (editor ? isEditorEmpty(editor) : !value.trim()),
focus: () => editor?.commands.focus(),
}), [editor, value]);
const displayPreviewHtml = useMemo(() => {
if (!value.trim()) {
return `<p class="article-preview-placeholder">${placeholder}</p>`;
const setLink = useCallback(() => {
if (!editor) return;
const prev = editor.getAttributes('link').href as string | undefined;
const url = window.prompt('链接地址', prev ?? 'https://');
if (url === null) return;
if (!url.trim()) {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
return renderPostContentHtml(previewHtml, true);
}, [value, previewHtml, placeholder]);
editor.chain().focus().extendMarkRange('link').setLink({ href: url.trim() }).run();
}, [editor]);
const words = countWords(value);
const showEdit = viewMode === 'edit' || viewMode === 'split';
const showPreview = viewMode === 'preview' || viewMode === 'split';
const setImage = useCallback(() => {
if (!editor) return;
const url = window.prompt('图片地址');
if (!url?.trim()) return;
editor.chain().focus().setImage({ src: url.trim() }).run();
}, [editor]);
const wrapMembersOnly = useCallback(() => {
if (!editor) return;
const { from, to, empty } = editor.state.selection;
if (!empty && from !== to) {
editor.chain().focus().wrapMembersOnly().run();
return;
}
editor.chain().focus().insertMembersOnly().run();
}, [editor]);
const buildTools = useCallback((): ToolBtn[] => {
if (!editor) return [];
return [
{
icon: <strong>H</strong>,
title: '标题H2再次点击升级',
active: editor.isActive('heading'),
action: () => cycleHeading(editor),
},
{
icon: <Bold size={15} />,
title: '加粗',
active: editor.isActive('bold'),
action: () => editor.chain().focus().toggleBold().run(),
},
{
icon: <Italic size={15} />,
title: '斜体',
active: editor.isActive('italic'),
action: () => editor.chain().focus().toggleItalic().run(),
},
{
icon: <Strikethrough size={15} />,
title: '删除线',
active: editor.isActive('strike'),
action: () => editor.chain().focus().toggleStrike().run(),
},
{
icon: <Minus size={15} />,
title: '分割线',
action: () => editor.chain().focus().setHorizontalRule().run(),
},
{
icon: <Quote size={15} />,
title: '引用',
active: editor.isActive('blockquote'),
action: () => editor.chain().focus().toggleBlockquote().run(),
},
{
icon: <List size={15} />,
title: '无序列表',
active: editor.isActive('bulletList'),
action: () => editor.chain().focus().toggleBulletList().run(),
},
{
icon: <ListOrdered size={15} />,
title: '有序列表',
active: editor.isActive('orderedList'),
action: () => editor.chain().focus().toggleOrderedList().run(),
},
{
icon: <Code size={15} />,
title: '代码块',
active: editor.isActive('codeBlock'),
action: () => editor.chain().focus().toggleCodeBlock().run(),
},
{
icon: <LinkIcon size={15} />,
title: '链接',
active: editor.isActive('link'),
action: setLink,
},
{
icon: <ImageIcon size={15} />,
title: '图片',
action: setImage,
},
{
icon: <LockKeyhole size={15} />,
title: '登录可见(选中文字后点击可包裹)',
active: editor.isActive('membersOnly'),
className: 'article-tool-btn--members',
action: wrapMembersOnly,
},
];
}, [editor, setLink, setImage, wrapMembersOnly]);
const tools = buildTools();
const words = editor ? countWords(editor.getText()) : 0;
return (
<div className="article-editor">
@@ -205,7 +237,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
<button
key={i}
type="button"
className={`article-tool-btn${i === tools.length - 1 ? ' article-tool-btn--members' : ''}`}
className={`article-tool-btn${t.active ? ' active' : ''}${t.className ? ` ${t.className}` : ''}`}
title={t.title}
onMouseDown={e => e.preventDefault()}
onClick={t.action}
@@ -214,65 +246,15 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
</button>
))}
</div>
<div className="article-editor-modes">
<button
type="button"
className={`article-mode-btn${viewMode === 'edit' ? ' active' : ''}`}
onClick={() => setViewMode('edit')}
title="仅编辑"
>
<Pencil size={14} />
</button>
<button
type="button"
className={`article-mode-btn${viewMode === 'split' ? ' active' : ''}`}
onClick={() => setViewMode('split')}
title="分栏预览"
>
</button>
<button
type="button"
className={`article-mode-btn${viewMode === 'preview' ? ' active' : ''}`}
onClick={() => setViewMode('preview')}
title="仅预览"
>
<Eye size={14} />
</button>
</div>
</div>
<div className={`article-editor-panes article-editor-panes--${viewMode}`}>
{showEdit && (
<div className="article-pane article-pane--edit">
<textarea
ref={textareaRef}
className="article-textarea"
value={value}
onChange={e => onChange(e.target.value)}
onSelect={saveSelection}
onKeyUp={saveSelection}
onClick={saveSelection}
onFocus={saveSelection}
placeholder={placeholder}
spellCheck={false}
/>
</div>
)}
{showPreview && (
<div className="article-pane article-pane--preview">
{viewMode === 'split' && <div className="article-pane-label"></div>}
<div
className={`article-preview post-detail-content${!value.trim() ? ' article-preview--empty' : ''}`}
dangerouslySetInnerHTML={{ __html: displayPreviewHtml }}
/>
</div>
)}
<div className="article-editor-body">
<EditorContent editor={editor} className="article-editor-content" />
</div>
<div className="article-editor-status">
<span>{words} </span>
<span>Markdown</span>
<span></span>
</div>
</div>
);

View File

@@ -1,70 +0,0 @@
import { Button } from '@/components/ui/button';
import { LayoutGrid, Folder, Plus } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import type { Board } from '../api/types';
import { useAuth } from '../hooks/useAuth';
interface Props {
boards: Board[];
loading?: boolean;
selectedId?: number;
onSelect: (id: number) => void;
}
export default function BoardGrid({ boards, loading = false, selectedId = 0, onSelect }: Props) {
const nav = useNavigate();
const { user } = useAuth();
if (loading) {
return (
<div className="board-grid board-grid--skeleton" aria-hidden>
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="board-tab board-tab--skeleton" />
))}
</div>
);
}
if (boards.length === 0) {
return (
<div className="board-grid-empty">
<p className="text-sm text-muted-foreground py-4 text-center">
{user?.role === 'admin' ? '还没有板块,请先创建' : '管理员尚未创建板块'}
</p>
{user?.role === 'admin' && (
<div style={{ textAlign: 'center', marginTop: 12 }}>
<Button onClick={() => nav('/admin/boards')}>
<Plus />
</Button>
</div>
)}
</div>
);
}
return (
<div className="board-grid">
<button
type="button"
className={`board-tab ${selectedId === 0 ? 'active' : ''}`}
onClick={() => onSelect(0)}
>
<span className="board-tab-icon"><LayoutGrid size={16} /></span>
<span className="board-tab-name"></span>
</button>
{boards.map(b => (
<button
key={b.id}
type="button"
className={`board-tab ${selectedId === b.id ? 'active' : ''}`}
title={b.description ? `${b.name}${b.description}` : b.name}
onClick={() => onSelect(b.id)}
>
<span className="board-tab-icon"><Folder size={16} /></span>
<span className="board-tab-name">{b.name}</span>
</button>
))}
</div>
);
}

View File

@@ -1,8 +1,5 @@
import { Button } from '@/components/ui/button';
import { Plus, Settings } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import type { Board, ForumStats } from '../api/types';
import { useAuth } from '../hooks/useAuth';
interface Props {
boardId: number;
@@ -14,55 +11,40 @@ interface Props {
export default function FeedHeader({ boardId, keyword, boards, stats, postTotal }: Props) {
const nav = useNavigate();
const { user, loading: authLoading } = useAuth();
const board = boards.find(b => b.id === boardId);
const title = keyword
? `搜索:${keyword}`
: (boardId && board ? board.name : '全部帖子');
const hint = keyword
? `找到 ${postTotal} 篇相关帖子`
: boardId && board
? (board.description || '欢迎在本板块交流讨论')
: '姜十三论坛 · 拾三一隅,自在交流';
const boardHint = boardId && board ? (board.description || '') : '';
return (
<div className="feed-banner">
<div className="feed-banner-row">
<div className="feed-banner-title">
<h2>{title}</h2>
<p>{hint}</p>
</div>
{!keyword && (
<div className="feed-actions flex flex-wrap items-center gap-2">
{authLoading ? (
<span className="feed-action-slot" aria-hidden />
) : user ? (
<Button
size="sm"
onClick={() => nav(boardId ? `/compose?board=${boardId}` : '/compose')}
>
<Plus />
{boardId ? '在此发帖' : '发布帖子'}
</Button>
) : (
<Button size="sm" onClick={() => nav('/login')}></Button>
)}
{!authLoading && user?.role === 'admin' && (
<Button size="sm" variant="outline" onClick={() => nav('/admin/boards')}>
<Settings />
</Button>
)}
</div>
<div className={`feed-head${keyword ? ' feed-head--solo' : ''}`}>
<div className="feed-head__title">
<h2 title={boardHint || undefined}>{title}</h2>
{!keyword && stats && (
<span className="feed-head__meta">
<strong>{stats.users}</strong>
<span className="feed-head__dot" aria-hidden>·</span>
<strong>{stats.posts}</strong>
<span className="feed-head__dot" aria-hidden>·</span>
<strong>{stats.boards}</strong>
</span>
)}
</div>
<div className="feed-stats">
<span> <strong>{stats?.users ?? '—'}</strong></span>
<span> <strong>{stats?.posts ?? '—'}</strong></span>
<span> <strong>{stats?.boards ?? '—'}</strong></span>
</div>
{keyword && (
<button
type="button"
className="feed-head__clear"
onClick={() => nav('/')}
>
</button>
)}
{keyword && (
<span className="feed-toolbar__count"> {postTotal} </span>
)}
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { Clock, MessageCircle, Flame } from 'lucide-react';
import { cn } from '@/lib/utils';
export type FeedSort = 'latest' | 'reply' | 'hot';
const SORT_OPTIONS: {
key: FeedSort;
label: string;
hint: string;
icon: typeof Clock;
}[] = [
{ key: 'latest', label: '最新发帖', hint: '按发布时间', icon: Clock },
{ key: 'reply', label: '最新回复', hint: '最近有人回复', icon: MessageCircle },
{ key: 'hot', label: '热门讨论', hint: '按互动热度', icon: Flame },
];
interface Props {
value: FeedSort;
onChange: (sort: FeedSort) => void;
postTotal?: number;
}
export function parseFeedSort(raw: string | null): FeedSort {
if (raw === 'reply' || raw === 'hot') return raw;
return 'latest';
}
export function buildHomeUrl(boardId: number, sort: FeedSort = 'latest') {
const p = new URLSearchParams();
if (boardId) p.set('board', String(boardId));
if (sort !== 'latest') p.set('sort', sort);
const qs = p.toString();
return qs ? `/?${qs}` : '/';
}
export function feedSortLabel(sort: FeedSort): string {
return SORT_OPTIONS.find(o => o.key === sort)?.label ?? '帖子列表';
}
export default function FeedSortBar({ value, onChange, postTotal }: Props) {
return (
<div className="feed-toolbar">
<div className="feed-sort-bar" role="tablist" aria-label="帖子排序">
{SORT_OPTIONS.map(({ key, label, hint, icon: Icon }) => (
<button
key={key}
type="button"
role="tab"
aria-selected={value === key}
title={`${label} · ${hint}`}
className={cn('feed-sort-tab', value === key && 'active')}
onClick={() => onChange(key)}
>
<Icon aria-hidden />
<span className="feed-sort-tab__label">{label}</span>
</button>
))}
</div>
{postTotal != null && (
<span className="feed-toolbar__count"> {postTotal} </span>
)}
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { Pin } from 'lucide-react';
import { cn } from '@/lib/utils';
interface Props {
className?: string;
size?: number;
}
/** 置顶图钉标识 */
export default function PinnedIcon({ className, size = 16 }: Props) {
return (
<Pin
className={cn('post-pinned-icon', className)}
size={size}
fill="currentColor"
aria-label="置顶"
role="img"
/>
);
}

View File

@@ -1,14 +1,22 @@
import { Badge } from '@/components/ui/badge';
import PinnedIcon from '@/components/PinnedIcon';
import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar';
import { formatTime } from '../utils/content';
interface Props {
post: PostItem;
sort?: FeedSort;
onClick: () => void;
}
export default function PostListItem({ post, onClick }: Props) {
export default function PostListItem({ post, sort = 'latest', onClick }: Props) {
const initial = post.user?.nickname?.[0] || '?';
const timeLabel = sort === 'reply'
? (post.last_reply_at
? `${formatTime(post.last_reply_at)} 回复`
: '暂无回复')
: formatTime(post.created_at);
return (
<div className="post-row" onClick={onClick}>
@@ -17,13 +25,13 @@ export default function PostListItem({ post, onClick }: Props) {
</div>
<div className="post-body">
<div className="post-title">
{post.pinned && <Badge variant="orange" className="mr-1.5"></Badge>}
{post.pinned && <PinnedIcon className="mr-1.5" />}
{post.title}
</div>
<div className="post-meta">
{post.board && <Badge variant="green">{post.board.name}</Badge>}
<span>{post.user?.nickname || '匿名'}</span>
<span>{formatTime(post.created_at)}</span>
<span>{timeLabel}</span>
</div>
</div>
<div className="post-stats">

View File

@@ -0,0 +1,334 @@
import { useEffect, useMemo, useState } from 'react';
import {
History, X, Maximize2, Minimize2, GitCompare, FileText, ArrowRight,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { PostRevision } from '../api/types';
import PostContent from './PostContent';
import { formatDateTime } from '../utils/content';
import {
type PostSnapshot,
htmlToDiffText,
summarizeChange,
diffTextLines,
diffTextWords,
countLineChanges,
} from '../utils/revisionDiff';
interface Props {
postId: number;
currentPost: PostSnapshot;
open: boolean;
onClose: () => void;
isLoggedIn: boolean;
}
type ViewMode = 'diff' | 'before' | 'after';
interface RevisionEntry {
rev: PostRevision;
after: PostSnapshot;
summary: ReturnType<typeof summarizeChange>;
index: number;
}
function DiffWords({ before, after }: { before: string; after: string }) {
const parts = diffTextWords(before, after);
if (before === after) {
return <span className="revision-diff-unchanged">{before || '(空)'}</span>;
}
return (
<span className="revision-diff-inline">
{parts.map((part, i) => {
if (part.added) return <ins key={i}>{part.value}</ins>;
if (part.removed) return <del key={i}>{part.value}</del>;
return <span key={i}>{part.value}</span>;
})}
</span>
);
}
function DiffLines({ before, after }: { before: string; after: string }) {
const parts = diffTextLines(before, after);
const { added, removed } = countLineChanges(parts);
if (before === after) {
return <p className="revision-diff-unchanged"></p>;
}
return (
<div className="revision-diff-lines">
<div className="revision-diff-stats">
{removed > 0 && <span className="revision-diff-stat revision-diff-stat--del"> {removed} </span>}
{added > 0 && <span className="revision-diff-stat revision-diff-stat--add"> {added} </span>}
</div>
<pre className="revision-diff-pre">
{parts.map((part, i) => {
const lines = part.value.split('\n');
return lines.map((line, j) => {
if (j === lines.length - 1 && line === '') return null;
const cls = part.added
? 'revision-diff-line revision-diff-line--add'
: part.removed
? 'revision-diff-line revision-diff-line--del'
: 'revision-diff-line revision-diff-line--same';
const prefix = part.added ? '+' : part.removed ? '' : ' ';
return (
<div key={`${i}-${j}`} className={cls}>
<span className="revision-diff-gutter" aria-hidden="true">{prefix}</span>
<span className="revision-diff-text">{line || ' '}</span>
</div>
);
});
})}
</pre>
</div>
);
}
function ChangeBadges({ summary }: { summary: ReturnType<typeof summarizeChange> }) {
if (!summary.hasChanges) return <span className="revision-badge revision-badge--none"></span>;
return (
<>
{summary.titleChanged && <span className="revision-badge"></span>}
{summary.contentChanged && <span className="revision-badge"></span>}
{summary.tagsChanged && <span className="revision-badge"></span>}
</>
);
}
export default function PostRevisionPanel({ postId, currentPost, open, onClose, isLoggedIn }: Props) {
const [revisions, setRevisions] = useState<PostRevision[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>('diff');
const [fullscreen, setFullscreen] = useState(false);
useEffect(() => {
if (!open) {
setSelectedId(null);
setViewMode('diff');
setFullscreen(false);
return;
}
setLoading(true);
api.postRevisions(postId)
.then(d => {
const list = d.revisions ?? [];
setRevisions(list);
if (list.length > 0) setSelectedId(list[0].id);
})
.catch(e => notify.error(e instanceof Error ? e.message : '加载历史失败'))
.finally(() => setLoading(false));
}, [open, postId]);
// 阻止背景滚动
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, [open]);
const entries: RevisionEntry[] = useMemo(() => {
return revisions.map((rev, index) => {
const after: PostSnapshot = index === 0
? currentPost
: {
title: revisions[index - 1].title,
content: revisions[index - 1].content,
tags: revisions[index - 1].tags,
};
const before: PostSnapshot = {
title: rev.title,
content: rev.content,
tags: rev.tags,
};
return {
rev,
after,
summary: summarizeChange(before, after),
index: revisions.length - index,
};
});
}, [revisions, currentPost]);
const selected = entries.find(e => e.rev.id === selectedId) ?? null;
if (!open) return null;
const beforeSnap: PostSnapshot | null = selected
? { title: selected.rev.title, content: selected.rev.content, tags: selected.rev.tags }
: null;
return (
<div
className={`post-revision-overlay${fullscreen ? ' post-revision-overlay--fullscreen' : ''}`}
onClick={onClose}
>
<div
className={`post-revision-panel${fullscreen ? ' post-revision-panel--fullscreen' : ''}`}
onClick={e => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="编辑历史"
>
<header className="post-revision-head">
<div className="post-revision-head-left">
<History size={18} />
<h3></h3>
{selected && (
<span className="post-revision-head-sub">
{selected.index}
</span>
)}
</div>
<div className="post-revision-head-actions">
<div className="post-revision-view-tabs" role="tablist">
{([
['diff', GitCompare, '变更对比'],
['before', FileText, '编辑前'],
['after', ArrowRight, '编辑后'],
] as const).map(([mode, Icon, label]) => (
<button
key={mode}
type="button"
role="tab"
aria-selected={viewMode === mode}
className={`post-revision-tab${viewMode === mode ? ' active' : ''}`}
onClick={() => setViewMode(mode)}
>
<Icon size={14} />
<span className="post-revision-tab-label">{label}</span>
</button>
))}
</div>
<button
type="button"
className="post-revision-icon-btn"
onClick={() => setFullscreen(f => !f)}
title={fullscreen ? '退出全屏' : '全屏显示'}
aria-label={fullscreen ? '退出全屏' : '全屏显示'}
>
{fullscreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
</button>
<button type="button" className="post-revision-icon-btn" onClick={onClose} aria-label="关闭">
<X size={18} />
</button>
</div>
</header>
{loading ? (
<div className="post-revision-loading"><Spinner size="lg" /></div>
) : entries.length === 0 ? (
<p className="post-revision-empty"></p>
) : (
<div className="post-revision-body">
<aside className="post-revision-sidebar">
<div className="post-revision-sidebar-label">线</div>
<ul className="post-revision-list">
{entries.map(entry => (
<li key={entry.rev.id}>
<button
type="button"
className={`post-revision-item${selectedId === entry.rev.id ? ' active' : ''}`}
onClick={() => setSelectedId(entry.rev.id)}
>
<div className="post-revision-item-head">
<span className="post-revision-item-num">#{entry.index}</span>
<ChangeBadges summary={entry.summary} />
</div>
<span className="post-revision-item-title">{entry.rev.title}</span>
<span className="post-revision-item-meta">
{entry.rev.editor?.nickname ?? '未知'} · {formatDateTime(entry.rev.created_at)}
</span>
</button>
</li>
))}
</ul>
<div className="post-revision-current">
<span className="post-revision-current-label"></span>
<span className="post-revision-current-title">{currentPost.title}</span>
</div>
</aside>
<main className="post-revision-main">
{selected && beforeSnap ? (
<>
<div className="post-revision-main-head">
<div>
<span className="post-revision-main-editor">
{selected.rev.editor?.nickname ?? '未知'}
</span>
<span className="post-revision-main-time">
{formatDateTime(selected.rev.created_at)}
</span>
</div>
<ChangeBadges summary={selected.summary} />
</div>
<div className="post-revision-scroll">
{viewMode === 'diff' && (
<div className="revision-diff-view">
<section className="revision-diff-section">
<h4></h4>
<div className="revision-diff-block">
<DiffWords before={beforeSnap.title} after={selected.after.title} />
</div>
</section>
{(beforeSnap.tags || selected.after.tags) && (
<section className="revision-diff-section">
<h4></h4>
<div className="revision-diff-block">
<DiffWords
before={beforeSnap.tags || '(无)'}
after={selected.after.tags || '(无)'}
/>
</div>
</section>
)}
<section className="revision-diff-section revision-diff-section--content">
<h4></h4>
<DiffLines
before={htmlToDiffText(beforeSnap.content)}
after={htmlToDiffText(selected.after.content)}
/>
</section>
</div>
)}
{viewMode === 'before' && (
<div className="revision-full-view">
<h4>{beforeSnap.title}</h4>
{beforeSnap.tags && (
<p className="revision-full-tags">{beforeSnap.tags}</p>
)}
<PostContent html={beforeSnap.content} isLoggedIn={isLoggedIn} />
</div>
)}
{viewMode === 'after' && (
<div className="revision-full-view">
<h4>{selected.after.title}</h4>
{selected.after.tags && (
<p className="revision-full-tags">{selected.after.tags}</p>
)}
<PostContent html={selected.after.content} isLoggedIn={isLoggedIn} />
</div>
)}
</div>
</>
) : (
<div className="post-revision-empty"></div>
)}
</main>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,10 +1,11 @@
import {
Home, Star, LayoutDashboard,
} from 'lucide-react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import type { Board } from '../api/types';
import { useAuth } from '../hooks/useAuth';
import { cn } from '@/lib/utils';
import { buildHomeUrl, parseFeedSort } from './FeedSortBar';
// 内容页不参与左侧栏高亮(非 feed 浏览上下文)
const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile'];
@@ -29,6 +30,8 @@ interface Props {
export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
const nav = useNavigate();
const loc = useLocation();
const [params] = useSearchParams();
const sort = parseFeedSort(params.get('sort'));
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
@@ -50,7 +53,7 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
<aside className="sidebar">
<div className="sidebar-section"></div>
<nav className="sidebar-nav">
{navItem('all', '全部帖子', <Home />, () => { onSelectBoard(0); nav('/'); })}
{navItem('all', '全部帖子', <Home />, () => { onSelectBoard(0); nav(buildHomeUrl(0, sort)); })}
{user && navItem('favorites', '我的收藏', <Star />, () => nav('/favorites'))}
</nav>
@@ -63,7 +66,7 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
type="button"
key={b.id}
className={cn('sidebar-nav-item', menuKey != null && menuKey === String(b.id) && 'active')}
onClick={() => { onSelectBoard(b.id); nav(`/?board=${b.id}`); }}
onClick={() => { onSelectBoard(b.id); nav(buildHomeUrl(b.id, sort)); }}
>
<span className="flex-1 truncate">{b.name}</span>
</button>

View File

@@ -1,19 +1,36 @@
import { useRef, useEffect } from 'react';
import { useRef, useEffect, useLayoutEffect } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Spinner } from '@/components/ui/spinner';
import PostListItem from './PostListItem';
import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar';
interface Props {
posts: PostItem[];
sort?: FeedSort;
loading: boolean;
hasMore: boolean;
onLoadMore: () => void;
onSelect: (id: number) => void;
/** 返回列表时恢复的滚动位置 */
restoreScrollTop?: number | null;
onScrollTopChange?: (top: number) => void;
onScrollRestored?: () => void;
}
export default function VirtualPostList({ posts, loading, hasMore, onLoadMore, onSelect }: Props) {
export default function VirtualPostList({
posts,
sort = 'latest',
loading,
hasMore,
onLoadMore,
onSelect,
restoreScrollTop,
onScrollTopChange,
onScrollRestored,
}: Props) {
const parentRef = useRef<HTMLDivElement>(null);
const restoredRef = useRef(false);
const virtualizer = useVirtualizer({
count: posts.length,
@@ -22,17 +39,29 @@ export default function VirtualPostList({ posts, loading, hasMore, onLoadMore, o
overscan: 8,
});
useLayoutEffect(() => {
if (restoreScrollTop == null || restoredRef.current || posts.length === 0) return;
virtualizer.scrollToOffset(restoreScrollTop);
restoredRef.current = true;
onScrollRestored?.();
}, [restoreScrollTop, posts.length, virtualizer, onScrollRestored]);
useEffect(() => {
restoredRef.current = false;
}, [restoreScrollTop]);
useEffect(() => {
const el = parentRef.current;
if (!el) return;
const onScroll = () => {
onScrollTopChange?.(el.scrollTop);
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 120 && hasMore && !loading) {
onLoadMore();
}
};
el.addEventListener('scroll', onScroll);
return () => el.removeEventListener('scroll', onScroll);
}, [hasMore, loading, onLoadMore]);
}, [hasMore, loading, onLoadMore, onScrollTopChange]);
return (
<div className="post-list-scroll" ref={parentRef}>
@@ -50,7 +79,7 @@ export default function VirtualPostList({ posts, loading, hasMore, onLoadMore, o
transform: `translateY(${vi.start}px)`,
}}
>
<PostListItem post={post} onClick={() => onSelect(post.id)} />
<PostListItem post={post} sort={sort} onClick={() => onSelect(post.id)} />
</div>
);
})}

View File

@@ -0,0 +1,84 @@
import { Node, mergeAttributes } from '@tiptap/core';
import {
ReactNodeViewRenderer,
NodeViewWrapper,
NodeViewContent,
type NodeViewProps,
} from '@tiptap/react';
import { LockKeyhole } from 'lucide-react';
/** 编辑态「登录可见」区块视图 */
function MembersOnlyView({ selected }: NodeViewProps) {
return (
<NodeViewWrapper
as="members-only"
className={`post-members-only post-members-only--visible editor-members-only${selected ? ' editor-members-only--selected' : ''}`}
>
<div className="post-members-only__badge" contentEditable={false}>
<span className="post-members-only__badge-icon" aria-hidden="true">
<LockKeyhole size={12} />
</span>
<span></span>
</div>
<NodeViewContent className="post-members-only__body" />
</NodeViewWrapper>
);
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
membersOnly: {
insertMembersOnly: () => ReturnType;
wrapMembersOnly: () => ReturnType;
};
}
}
/** TipTap 自定义节点:登录可见内容区块 */
export const MembersOnly = Node.create({
name: 'membersOnly',
group: 'block',
content: 'block+',
defining: true,
isolating: true,
parseHTML() {
return [{ tag: 'members-only' }];
},
renderHTML({ HTMLAttributes }) {
return ['members-only', mergeAttributes(HTMLAttributes), 0];
},
addNodeView() {
return ReactNodeViewRenderer(MembersOnlyView);
},
addCommands() {
return {
insertMembersOnly: () => ({ chain }) => chain()
.insertContent({
type: this.name,
content: [{
type: 'paragraph',
content: [{ type: 'text', text: '在此输入仅登录用户可见的内容…' }],
}],
})
.run(),
wrapMembersOnly: () => ({ tr, state, dispatch }) => {
const { from, to, empty } = state.selection;
if (empty) return false;
const slice = state.doc.slice(from, to);
if (!slice.content.size) return false;
const node = state.schema.nodes.membersOnly.create(null, slice.content);
if (dispatch) {
tr.replaceRangeWith(from, to, node);
}
return true;
},
};
},
});

View File

@@ -35,6 +35,9 @@ export function useGlobalWheelScroll(scrollRef: RefObject<HTMLElement | null>, e
if (!root) return;
const onWheel = (e: WheelEvent) => {
// Ctrl/⌘ + 滚轮用于浏览器缩放,不拦截
if (e.ctrlKey || e.metaKey) return;
const target = e.target instanceof HTMLElement ? e.target : null;
if (!target) return;

View File

@@ -16,6 +16,7 @@ import type { Board, PostItem, Notification, OnlineStats, ForumStats } from '../
import { getCachedBoards, getCachedStats, setCachedBoards, setCachedStats } from '../utils/layoutCache';
import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar';
import RightPanel from '../components/RightPanel';
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
export default function MainLayout() {
const { user, loading: authLoading, logout } = useAuth();
@@ -34,6 +35,7 @@ export default function MainLayout() {
const [online, setOnline] = useState<OnlineStats | null>(null);
const [boardId, setBoardId] = useState(Number(params.get('board')) || 0);
const [keyword, setKeyword] = useState(params.get('keyword') || '');
const feedSort = parseFeedSort(params.get('sort'));
useEffect(() => { setBoardId(Number(params.get('board')) || 0); }, [params]);
useEffect(() => { setKeyword(params.get('keyword') || ''); }, [params]);
@@ -195,13 +197,13 @@ export default function MainLayout() {
<div className="mobile-board-bar">
<span
className={`board-chip ${mobileActiveBoard === 0 ? 'active' : ''}`}
onClick={() => { setBoardId(0); nav('/'); }}
onClick={() => { setBoardId(0); nav(buildHomeUrl(0, feedSort)); }}
></span>
{boards.map(b => (
<span
key={b.id}
className={`board-chip ${mobileActiveBoard === b.id ? 'active' : ''}`}
onClick={() => { setBoardId(b.id); nav(`/?board=${b.id}`); }}
onClick={() => { setBoardId(b.id); nav(buildHomeUrl(b.id, feedSort)); }}
>{b.name}</span>
))}
</div>

View File

@@ -5,9 +5,9 @@ import { notify } from '@/lib/notify';
import { api } from '../api/client';
import { useAuth } from '../hooks/useAuth';
import type { Board } from '../api/types';
import { isHtmlEmpty } from '../utils/postContent';
import ArticleEditor from '../components/ArticleEditor';
import { Spinner } from '@/components/ui/spinner';
import { markdownToHtml, htmlToMarkdown } from '../utils/markdown';
export default function ComposePage() {
const nav = useNavigate();
@@ -37,16 +37,21 @@ export default function ComposePage() {
const list = boardsData.boards ?? [];
setBoards(list);
const post = postData.post;
const canEdit = user.role === 'admin' || post.user_id === user.id;
if (!canEdit) {
const isOwnerOrAdmin = user.role === 'admin' || post.user_id === user.id;
if (!isOwnerOrAdmin) {
notify.error('无权编辑此帖子');
nav(`/post/${editId}`);
return;
}
if (!postData.can_edit) {
notify.error(postData.edit_block_reason || '当前无法编辑此帖子');
nav(`/post/${editId}`);
return;
}
setBoardId(String(post.board_id));
setTitle(post.title);
setTags(post.tags ?? '');
setContent(htmlToMarkdown(post.content ?? ''));
setContent(post.content ?? '');
})
.catch((e: unknown) => {
notify.error(e instanceof Error ? e.message : '加载帖子失败');
@@ -108,13 +113,13 @@ export default function ComposePage() {
const trimmedTitle = title.trim();
if (!isEdit && !boardId) { notify.warning('请选择板块'); return; }
if (!trimmedTitle) { notify.warning('请输入标题'); return; }
if (!content.trim()) { notify.warning('请输入正文内容'); return; }
if (isHtmlEmpty(content)) { notify.warning('请输入正文内容'); return; }
setPublishing(true);
try {
const payload = {
title: trimmedTitle,
content: markdownToHtml(content),
content: content.trim(),
tags: tags.trim(),
};
if (isEdit) {
@@ -204,7 +209,7 @@ export default function ComposePage() {
<ArticleEditor
value={content}
onChange={setContent}
placeholder="开始写作。支持 Markdown 语法,右侧可实时预览渲染效果。"
placeholder="开始写作。所见即所得,选中文字后使用工具栏设置格式。"
/>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate, useOutletContext, useSearchParams } from 'react-router-dom';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
@@ -6,7 +6,8 @@ import type { PostItem } from '../api/types';
import type { LayoutCtx } from '../layouts/MainLayout';
import VirtualPostList from '../components/VirtualPostList';
import FeedHeader from '../components/FeedHeader';
import BoardGrid from '../components/BoardGrid';
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
import { getFeedCache, setFeedCache, clearAllFeedCache } from '../utils/feedCache';
export default function HomePage() {
const nav = useNavigate();
@@ -14,17 +15,27 @@ export default function HomePage() {
const ctx = useOutletContext<LayoutCtx>();
const boardId = Number(params.get('board')) || ctx?.boardId || 0;
const keyword = params.get('keyword') || '';
const sort = parseFeedSort(params.get('sort'));
const initialCache = getFeedCache(boardId, keyword, sort);
const [posts, setPosts] = useState<PostItem[]>([]);
const [postTotal, setPostTotal] = useState(0);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(true);
const [posts, setPosts] = useState<PostItem[]>(() => initialCache?.posts ?? []);
const [postTotal, setPostTotal] = useState(() => initialCache?.postTotal ?? 0);
const [page, setPage] = useState(() => initialCache?.page ?? 1);
const [hasMore, setHasMore] = useState(() => initialCache?.hasMore ?? true);
const [loading, setLoading] = useState(() => !initialCache);
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(() => initialCache?.scrollTop ?? null);
const scrollTopRef = useRef(initialCache?.scrollTop ?? 0);
const load = useCallback(async (p: number, reset = false) => {
setLoading(true);
try {
const data = await api.posts({ page: p, size: 30, board_id: boardId || '', keyword });
const data = await api.posts({
page: p,
size: 30,
board_id: boardId || '',
keyword,
sort: sort === 'latest' ? '' : sort,
});
const batch = Array.isArray(data.posts) ? data.posts : [];
// 切换筛选时保留旧列表,避免中间区域瞬间空白
setPosts(prev => (reset ? batch : [...prev, ...batch]));
@@ -37,50 +48,113 @@ export default function HomePage() {
} finally {
setLoading(false);
}
}, [boardId, keyword]);
}, [boardId, keyword, sort]);
/** 有缓存时静默拉取已加载页,更新置顶等状态同时保留滚动位置 */
const revalidate = useCallback(async (maxPage: number) => {
try {
const all: PostItem[] = [];
let total = 0;
let hasMore = true;
for (let p = 1; p <= maxPage; p++) {
const data = await api.posts({
page: p,
size: 30,
board_id: boardId || '',
keyword,
sort: sort === 'latest' ? '' : sort,
});
const batch = Array.isArray(data.posts) ? data.posts : [];
all.push(...batch);
total = data.total ?? 0;
hasMore = !!data.has_more;
if (!data.has_more) break;
}
setPosts(all);
setPostTotal(total);
setHasMore(hasMore);
setPage(maxPage);
} catch {
// 静默失败,保留缓存数据
}
}, [boardId, keyword, sort]);
useEffect(() => {
const cached = getFeedCache(boardId, keyword, sort);
if (cached) {
setPosts(cached.posts);
setPostTotal(cached.postTotal);
setPage(cached.page);
setHasMore(cached.hasMore);
setRestoreScrollTop(cached.scrollTop);
scrollTopRef.current = cached.scrollTop;
setLoading(false);
revalidate(cached.page);
return;
}
setRestoreScrollTop(null);
scrollTopRef.current = 0;
load(1, true);
}, [boardId, keyword, load]);
}, [boardId, keyword, sort, load, revalidate]);
useEffect(() => {
const fn = () => load(1, true);
return () => {
if (posts.length === 0) return;
setFeedCache(boardId, keyword, sort, {
posts,
postTotal,
page,
hasMore,
scrollTop: scrollTopRef.current,
});
};
}, [boardId, keyword, sort, posts, postTotal, page, hasMore]);
useEffect(() => {
const fn = () => {
clearAllFeedCache();
setRestoreScrollTop(null);
scrollTopRef.current = 0;
load(1, true);
};
window.addEventListener('posts-refresh', fn);
return () => window.removeEventListener('posts-refresh', fn);
}, [load]);
}, [boardId, keyword, sort, load]);
const showBoardGrid = !keyword;
const handleSortChange = (next: FeedSort) => {
if (next === sort) return;
clearAllFeedCache();
setRestoreScrollTop(null);
scrollTopRef.current = 0;
nav(buildHomeUrl(boardId, next));
};
const showSortBar = !keyword;
return (
<div className="page-wrap">
<FeedHeader
boardId={boardId}
keyword={keyword}
boards={ctx?.boards ?? []}
stats={ctx?.stats ?? null}
postTotal={postTotal}
/>
{showBoardGrid && (
<BoardGrid
<div className="feed-top">
<FeedHeader
boardId={boardId}
keyword={keyword}
boards={ctx?.boards ?? []}
loading={!ctx?.layoutReady}
selectedId={boardId}
onSelect={(id) => {
ctx?.setBoardId(id);
nav(id ? `/?board=${id}` : '/');
}}
stats={ctx?.stats ?? null}
postTotal={postTotal}
/>
)}
<div className="post-list-bar">
<span>{keyword ? '搜索结果' : '帖子列表'}</span>
<span> {postTotal} </span>
{showSortBar && (
<FeedSortBar value={sort} onChange={handleSortChange} postTotal={postTotal} />
)}
</div>
<VirtualPostList
posts={posts}
sort={sort}
loading={loading}
hasMore={hasMore}
onLoadMore={() => !loading && hasMore && load(page + 1)}
onSelect={(id) => nav(`/post/${id}`)}
restoreScrollTop={restoreScrollTop}
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
onScrollRestored={() => setRestoreScrollTop(null)}
/>
</div>
);

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin } from 'lucide-react';
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock } from 'lucide-react';
import PinnedIcon from '@/components/PinnedIcon';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
@@ -10,9 +11,11 @@ import type { PostItem, Comment } from '../api/types';
import CommentThreadList from '../components/CommentThreadList';
import CommentBox, { type CommentSubmitData } from '../components/CommentBox';
import PostContent from '../components/PostContent';
import PostRevisionPanel from '../components/PostRevisionPanel';
import { useAuth } from '../hooks/useAuth';
import { formatTime } from '../utils/content';
import { formatDateTime, isTimeDiffSignificant } from '../utils/content';
import { loadMyCommentIds, addMyCommentId } from '../utils/guest';
import { clearAllFeedCache } from '../utils/feedCache';
import { useGlobalWheelScroll } from '../hooks/useGlobalWheelScroll';
export default function PostDetailPage() {
@@ -30,6 +33,10 @@ export default function PostDetailPage() {
const [loading, setLoading] = useState(true);
const [highlightFloor, setHighlightFloor] = useState<number | null>(null);
const [submitCount, setSubmitCount] = useState(0);
const [canEdit, setCanEdit] = useState(false);
const [isEdited, setIsEdited] = useState(false);
const [editBlockReason, setEditBlockReason] = useState('');
const [showRevisions, setShowRevisions] = useState(false);
const pageRef = useRef<HTMLDivElement>(null);
const commentSectionRef = useRef<HTMLDivElement>(null);
@@ -55,6 +62,9 @@ export default function PostDetailPage() {
setPost(detail.post);
setLiked(detail.liked);
setFavorited(detail.favorited);
setCanEdit(detail.can_edit ?? false);
setIsEdited(detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at));
setEditBlockReason(detail.edit_block_reason ?? '');
setComments(commList);
await refresh();
} catch (e: unknown) {
@@ -161,14 +171,34 @@ export default function PostDetailPage() {
const authorInitial = post.user?.nickname?.[0] || '?';
const tags = post.tags?.split(/[,]/).map(t => t.trim()).filter(Boolean) ?? [];
const canEdit = user && (user.role === 'admin' || user.id === post.user_id);
const isOwnerOrAdmin = user && (user.role === 'admin' || user.id === post.user_id);
const isAdmin = user?.role === 'admin';
const showEdited = isEdited && post.updated_at;
const handlePin = async () => {
if (!post) return;
try {
const r = await api.adminPinPost(postId, !post.pinned);
setPost(p => p ? { ...p, pinned: r.pinned } : p);
clearAllFeedCache();
window.dispatchEvent(new Event('posts-refresh'));
notify.success(r.message);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
};
const handleLock = async () => {
if (!post) return;
try {
const r = await api.adminLockPost(postId, !post.edit_locked);
setPost(p => p ? { ...p, edit_locked: r.edit_locked } : p);
if (user?.role === 'admin') {
setCanEdit(true);
} else if (user?.id === post.user_id && r.edit_locked) {
setCanEdit(false);
setEditBlockReason('帖子已被管理员锁定,无法编辑');
}
notify.success(r.message);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
@@ -190,7 +220,7 @@ export default function PostDetailPage() {
<div className="post-detail-head">
<h1 className="post-detail-title">
{post.pinned && <Badge variant="orange" className="mr-2 align-middle"></Badge>}
{post.pinned && <PinnedIcon className="mr-2" size={18} />}
{post.title}
</h1>
<div className="post-detail-author-row">
@@ -200,7 +230,16 @@ export default function PostDetailPage() {
<div className="post-detail-author-info">
<span className="post-detail-author-name">{post.user?.nickname}</span>
<span className="post-detail-meta-line">
{formatTime(post.created_at)} · {post.view_count}
{formatDateTime(post.created_at)}
{showEdited && (
<> · {formatDateTime(post.updated_at!)}</>
)}
{' · '}{post.view_count}
{post.edit_locked && (
<span className="post-detail-locked-tag" title="管理员已锁定编辑">
<Lock size={12} />
</span>
)}
</span>
</div>
</div>
@@ -229,15 +268,40 @@ export default function PostDetailPage() {
</Button>
)}
{isAdmin && (
<Button variant="outline" size="sm" onClick={handlePin}>
<Pin />
{post.pinned ? '取消置顶' : '置顶'}
{isOwnerOrAdmin && isEdited && (
<Button variant="outline" size="sm" onClick={() => setShowRevisions(true)}>
<History />
</Button>
)}
{isOwnerOrAdmin && !canEdit && editBlockReason && (
<span className="post-detail-edit-hint" title={editBlockReason}>
{editBlockReason}
</span>
)}
{isAdmin && (
<>
<Button variant="outline" size="sm" onClick={handlePin}>
<Pin />
{post.pinned ? '取消置顶' : '置顶'}
</Button>
<Button variant="outline" size="sm" onClick={handleLock}>
<Lock />
{post.edit_locked ? '解锁编辑' : '锁定编辑'}
</Button>
</>
)}
</div>
</div>
<PostRevisionPanel
postId={postId}
currentPost={{ title: post.title, content: post.content ?? '', tags: post.tags ?? '' }}
open={showRevisions}
onClose={() => setShowRevisions(false)}
isLoggedIn={!!user}
/>
<div className="comment-section" ref={commentSectionRef}>
<div className="comment-section-bar">
<span className="comment-section-title"></span>

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search } from 'lucide-react';
import { Search, Lock, LockOpen } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
@@ -14,6 +14,7 @@ import { notify } from '@/lib/notify';
import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout';
import type { PostItem } from '../../api/types';
import { clearAllFeedCache } from '../../utils/feedCache';
export default function AdminPostsPage() {
const nav = useNavigate();
@@ -44,6 +45,18 @@ export default function AdminPostsPage() {
const togglePin = async (post: PostItem) => {
try {
const r = await api.adminPinPost(post.id, !post.pinned);
clearAllFeedCache();
window.dispatchEvent(new Event('posts-refresh'));
notify.success(r.message);
load();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
};
const toggleLock = async (post: PostItem) => {
try {
const r = await api.adminLockPost(post.id, !post.edit_locked);
notify.success(r.message);
load();
} catch (e: unknown) {
@@ -100,6 +113,7 @@ export default function AdminPostsPage() {
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
@@ -118,6 +132,7 @@ export default function AdminPostsPage() {
<td>{p.board?.name ?? '—'}</td>
<td>{p.user?.nickname ?? '—'}</td>
<td>{p.pinned ? <Badge variant="orange"></Badge> : '—'}</td>
<td>{p.edit_locked ? <Badge variant="destructive"></Badge> : '—'}</td>
<td>{p.like_count}</td>
<td>{p.view_count}</td>
<td>{new Date(p.created_at).toLocaleString('zh-CN')}</td>
@@ -126,6 +141,9 @@ export default function AdminPostsPage() {
<Button size="sm" variant="outline" onClick={() => togglePin(p)}>
{p.pinned ? '取消置顶' : '置顶'}
</Button>
<Button size="sm" variant="outline" onClick={() => toggleLock(p)}>
{p.edit_locked ? <><LockOpen size={14} /> </> : <><Lock size={14} /> </>}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="ghost" className="text-destructive"></Button>

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { Database } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../../api/client';
@@ -12,14 +13,37 @@ export default function AdminSettingsPage() {
const [settings, setSettings] = useState<AdminSettings | null>(null);
const [loading, setLoading] = useState(true);
const [backing, setBacking] = useState(false);
const [editWindowHours, setEditWindowHours] = useState('24');
const [savingForum, setSavingForum] = useState(false);
useEffect(() => {
if (!ready) return;
api.adminSettings()
.then(setSettings)
.then(s => {
setSettings(s);
setEditWindowHours(String(s.post_edit_window_hours ?? 24));
})
.finally(() => setLoading(false));
}, [ready]);
const handleSaveForumSettings = async () => {
const hours = parseInt(editWindowHours, 10);
if (Number.isNaN(hours) || hours < 0) {
notify.warning('请输入有效的小时数0 表示不限)');
return;
}
setSavingForum(true);
try {
const r = await api.adminUpdateForumSettings({ post_edit_window_hours: hours });
notify.success(r.message);
setSettings(s => s ? { ...s, post_edit_window_hours: r.post_edit_window_hours } : s);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSavingForum(false);
}
};
const handleBackup = async () => {
setBacking(true);
try {
@@ -45,6 +69,27 @@ export default function AdminSettingsPage() {
<p></p>
</div>
<div className="admin-card">
<div className="admin-card-head"></div>
<p className="admin-card-desc">
0
</p>
<div className="admin-form-row">
<label htmlFor="edit-window-hours"></label>
<Input
id="edit-window-hours"
type="number"
min={0}
value={editWindowHours}
onChange={e => setEditWindowHours(e.target.value)}
className="max-w-[120px]"
/>
<Button onClick={handleSaveForumSettings} loading={savingForum}>
</Button>
</div>
</div>
<div className="admin-card">
<div className="admin-card-head"></div>
<dl className="admin-dl">

View File

@@ -513,7 +513,7 @@ a:hover { text-decoration: underline; }
.header-inner { padding: 0 12px; gap: 10px; }
.header-search-wrap { max-width: none; }
.header-compose-btn { width: 34px; padding: 0; justify-content: center; }
.feed-banner-row { flex-direction: column; gap: 10px; }
.feed-banner-row { flex-direction: row; gap: 10px; }
.board-grid { flex-wrap: nowrap; overflow-x: auto; padding: 8px 12px; scrollbar-width: none; }
.board-grid::-webkit-scrollbar { display: none; }
}
@@ -533,7 +533,8 @@ a:hover { text-decoration: underline; }
display: none;
}
.virtual-list-wrap {
.virtual-list-wrap,
.post-list-scroll {
flex: 1;
overflow: auto;
min-height: 0;
@@ -541,7 +542,8 @@ a:hover { text-decoration: underline; }
-ms-overflow-style: none;
}
.virtual-list-wrap::-webkit-scrollbar {
.virtual-list-wrap::-webkit-scrollbar,
.post-list-scroll::-webkit-scrollbar {
display: none;
}
.page-inner { padding: 20px 24px; max-width: 720px; }
@@ -574,31 +576,131 @@ a:hover { text-decoration: underline; }
border-bottom: 1px solid var(--j13-border-light);
}
.feed-top {
flex-shrink: 0;
border-bottom: 1px solid var(--j13-border-light);
background: var(--j13-bg-block);
}
@media (min-width: 900px) {
.feed-top:not(:has(.feed-head--solo)) {
display: flex;
align-items: center;
gap: 16px;
padding: 6px 16px;
}
.feed-top:not(:has(.feed-head--solo)) .feed-head {
padding: 0;
flex-shrink: 0;
}
.feed-top:not(:has(.feed-head--solo)) .feed-toolbar {
padding: 0;
flex: 1;
min-width: 0;
}
}
.feed-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 16px 6px;
}
.feed-head--solo {
padding-bottom: 8px;
}
.feed-head__title {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
flex: 1;
}
.feed-head h2 {
margin: 0;
font-size: 15px;
font-weight: 600;
white-space: nowrap;
}
.feed-head__meta {
font-size: 12px;
color: var(--color-text-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.feed-head__meta strong {
color: var(--j13-green);
font-weight: 600;
}
.feed-head__dot {
margin: 0 4px;
opacity: 0.55;
}
.feed-head__clear {
flex-shrink: 0;
border: none;
background: transparent;
padding: 0;
font-size: 12px;
color: var(--j13-green);
cursor: pointer;
font-family: inherit;
}
.feed-head__clear:hover {
text-decoration: underline;
}
.feed-toolbar {
display: flex;
align-items: center;
gap: 10px;
padding: 0 16px 8px;
}
.feed-toolbar__count {
flex-shrink: 0;
font-size: 12px;
color: var(--color-text-3);
white-space: nowrap;
}
/* 兼容旧类名,避免其他页面引用失效 */
.feed-banner {
padding: 14px 20px 12px;
padding: 8px 16px;
border-bottom: 1px solid var(--j13-border-light);
}
.feed-banner-row {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 16px;
gap: 12px;
}
.feed-banner-title { flex: 1; min-width: 0; }
.feed-banner h2 { margin: 0 0 4px; font-size: 16px; font-weight: 600; }
.feed-banner h2 { margin: 0; font-size: 15px; font-weight: 600; }
.feed-banner p { margin: 0; font-size: 12px; color: var(--color-text-3); line-height: 1.45; }
.feed-stats {
display: flex;
flex-wrap: wrap;
gap: 12px 16px;
margin-top: 10px;
min-height: 20px;
font-size: 13px;
color: var(--color-text-2);
gap: 8px 12px;
margin-top: 6px;
font-size: 12px;
color: var(--color-text-3);
}
.feed-stats strong { color: var(--j13-green); font-weight: 600; }
@@ -637,9 +739,10 @@ a:hover { text-decoration: underline; }
}
.board-chip.active {
background: var(--j13-green);
border-color: var(--j13-green);
color: #fff;
background: var(--j13-green-bg);
border-color: transparent;
color: var(--j13-green);
font-weight: 600;
}
.board-grid {
@@ -650,6 +753,67 @@ a:hover { text-decoration: underline; }
border-bottom: 1px solid var(--j13-border-light);
}
/* 帖子排序栏:与左侧栏 active 样式保持一致 */
.feed-sort-bar {
display: flex;
align-items: center;
gap: 2px;
flex: 1;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
}
.feed-sort-bar::-webkit-scrollbar { display: none; }
.feed-sort-tab {
display: inline-flex;
align-items: center;
gap: 5px;
height: 28px;
padding: 0 10px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--color-text-2);
font-family: inherit;
font-size: 12px;
cursor: pointer;
flex-shrink: 0;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.feed-sort-tab:hover {
background: var(--j13-bg-block-muted);
color: var(--color-text-1);
}
.feed-sort-tab.active {
background: var(--j13-green-bg);
color: var(--j13-green);
font-weight: 600;
}
.feed-sort-tab svg {
width: 14px;
height: 14px;
flex-shrink: 0;
}
@media (max-width: 768px) {
.feed-head {
flex-wrap: wrap;
padding: 8px 12px 6px;
}
.feed-head__meta { display: none; }
.feed-toolbar {
padding: 0 12px 8px;
}
}
.board-grid-empty {
padding: 20px;
border-bottom: 1px solid var(--j13-border-light);
@@ -772,6 +936,13 @@ a:hover { text-decoration: underline; }
.post-avatar img { width: 100%; height: 100%; object-fit: cover; }
.post-body { flex: 1; min-width: 0; }
.post-title { font-size: 14px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-bottom: 4px; }
.post-pinned-icon {
display: inline-block;
vertical-align: -0.15em;
flex-shrink: 0;
color: #e53935;
stroke-width: 1.5;
}
.post-meta { font-size: 12px; color: var(--color-text-3); display: flex; flex-wrap: wrap; gap: 8px; }
.post-stats { display: flex; gap: 12px; font-size: 12px; color: var(--color-text-3); flex-shrink: 0; padding-top: 4px; }
@@ -857,6 +1028,497 @@ a:hover { text-decoration: underline; }
.post-detail-meta-line {
font-size: 12px;
color: var(--color-text-3);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.post-detail-locked-tag {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: 6px;
color: hsl(var(--destructive));
font-size: 11px;
}
.post-detail-edit-hint {
font-size: 12px;
color: var(--color-text-3);
align-self: center;
}
.post-revision-overlay {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.post-revision-overlay--fullscreen {
padding: 0;
}
.post-revision-panel {
background: hsl(var(--background));
border-radius: 12px;
width: min(1080px, 100%);
height: min(88vh, 900px);
display: flex;
flex-direction: column;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.22);
overflow: hidden;
}
.post-revision-panel--fullscreen {
width: 100%;
height: 100%;
max-height: none;
border-radius: 0;
}
.post-revision-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--j13-border-light);
flex-shrink: 0;
}
.post-revision-head-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.post-revision-head-left h3 {
font-size: 15px;
font-weight: 600;
margin: 0;
}
.post-revision-head-sub {
font-size: 12px;
color: var(--color-text-3);
padding-left: 8px;
border-left: 1px solid var(--j13-border-light);
}
.post-revision-head-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.post-revision-view-tabs {
display: flex;
gap: 2px;
background: hsl(var(--muted));
border-radius: 8px;
padding: 3px;
}
.post-revision-tab {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 10px;
border: none;
background: none;
border-radius: 6px;
font-size: 12px;
color: var(--color-text-2);
cursor: pointer;
white-space: nowrap;
}
.post-revision-tab:hover {
color: var(--color-text-1);
}
.post-revision-tab.active {
background: hsl(var(--background));
color: var(--color-text-1);
font-weight: 500;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.post-revision-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: none;
border-radius: 8px;
cursor: pointer;
color: var(--color-text-3);
}
.post-revision-icon-btn:hover {
background: hsl(var(--muted));
color: var(--color-text-1);
}
.post-revision-loading,
.post-revision-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 48px 24px;
color: var(--color-text-3);
font-size: 14px;
}
.post-revision-body {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.post-revision-sidebar {
width: 260px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border-right: 1px solid var(--j13-border-light);
min-height: 0;
}
.post-revision-sidebar-label {
padding: 10px 14px 6px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-3);
}
.post-revision-list {
list-style: none;
margin: 0;
padding: 4px 8px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.post-revision-item {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
text-align: left;
padding: 10px 12px;
border: none;
background: none;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
margin-bottom: 2px;
}
.post-revision-item:hover,
.post-revision-item.active {
background: hsl(var(--muted));
}
.post-revision-item-head {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.post-revision-item-num {
font-size: 11px;
font-weight: 600;
color: var(--color-text-3);
}
.post-revision-item-title {
font-weight: 500;
color: var(--color-text-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.post-revision-item-meta {
font-size: 11px;
color: var(--color-text-3);
}
.post-revision-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
background: hsl(var(--primary) / 0.12);
color: hsl(var(--primary));
font-weight: 500;
}
.post-revision-badge--none {
background: hsl(var(--muted));
color: var(--color-text-3);
}
.post-revision-current {
padding: 12px 14px;
border-top: 1px solid var(--j13-border-light);
background: hsl(var(--muted) / 0.4);
flex-shrink: 0;
}
.post-revision-current-label {
display: block;
font-size: 11px;
color: var(--color-text-3);
margin-bottom: 4px;
}
.post-revision-current-title {
font-size: 13px;
font-weight: 500;
color: var(--color-text-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.post-revision-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.post-revision-main-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 20px;
border-bottom: 1px solid var(--j13-border-light);
flex-shrink: 0;
}
.post-revision-main-editor {
font-size: 14px;
font-weight: 600;
color: var(--color-text-1);
margin-right: 8px;
}
.post-revision-main-time {
font-size: 12px;
color: var(--color-text-3);
}
.post-revision-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
padding: 16px 20px 24px;
-webkit-overflow-scrolling: touch;
}
/* Diff 视图 */
.revision-diff-view {
display: flex;
flex-direction: column;
gap: 20px;
}
.revision-diff-section h4 {
font-size: 12px;
font-weight: 600;
color: var(--color-text-3);
text-transform: uppercase;
letter-spacing: 0.04em;
margin: 0 0 8px;
}
.revision-diff-block {
padding: 12px 14px;
background: hsl(var(--muted) / 0.35);
border-radius: 8px;
font-size: 14px;
line-height: 1.6;
word-break: break-word;
}
.revision-diff-inline del {
background: hsl(0 70% 95%);
color: hsl(0 60% 35%);
text-decoration: line-through;
border-radius: 2px;
padding: 0 2px;
}
.revision-diff-inline ins {
background: hsl(140 60% 93%);
color: hsl(140 50% 28%);
text-decoration: none;
border-radius: 2px;
padding: 0 2px;
}
.revision-diff-unchanged {
color: var(--color-text-3);
font-size: 13px;
}
.revision-diff-stats {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.revision-diff-stat {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 500;
}
.revision-diff-stat--add {
background: hsl(140 60% 93%);
color: hsl(140 50% 28%);
}
.revision-diff-stat--del {
background: hsl(0 70% 95%);
color: hsl(0 60% 35%);
}
.revision-diff-pre {
margin: 0;
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, monospace;
font-size: 13px;
line-height: 1.55;
border: 1px solid var(--j13-border-light);
border-radius: 8px;
overflow: hidden;
}
.revision-diff-line {
display: flex;
padding: 1px 0;
white-space: pre-wrap;
word-break: break-word;
}
.revision-diff-line--add {
background: hsl(140 60% 95%);
}
.revision-diff-line--del {
background: hsl(0 70% 96%);
}
.revision-diff-line--same {
background: hsl(var(--background));
}
.revision-diff-gutter {
flex-shrink: 0;
width: 24px;
text-align: center;
color: var(--color-text-3);
user-select: none;
font-size: 12px;
}
.revision-diff-line--add .revision-diff-gutter { color: hsl(140 50% 40%); }
.revision-diff-line--del .revision-diff-gutter { color: hsl(0 60% 45%); }
.revision-diff-text {
flex: 1;
padding-right: 12px;
}
.revision-diff-line--add .revision-diff-text { color: hsl(140 45% 22%); }
.revision-diff-line--del .revision-diff-text {
color: hsl(0 55% 30%);
text-decoration: line-through;
}
/* 完整版本视图 */
.revision-full-view h4 {
font-size: 18px;
font-weight: 600;
margin: 0 0 8px;
line-height: 1.4;
}
.revision-full-tags {
font-size: 13px;
color: var(--color-text-3);
margin: 0 0 16px;
}
@media (max-width: 768px) {
.post-revision-overlay:not(.post-revision-overlay--fullscreen) {
padding: 0;
align-items: stretch;
}
.post-revision-panel:not(.post-revision-panel--fullscreen) {
width: 100%;
height: 100%;
border-radius: 0;
}
.post-revision-head {
flex-wrap: wrap;
}
.post-revision-head-actions {
width: 100%;
justify-content: space-between;
}
.post-revision-view-tabs {
flex: 1;
}
.post-revision-tab-label {
display: none;
}
.post-revision-tab.active .post-revision-tab-label {
display: inline;
}
.post-revision-body {
flex-direction: column;
}
.post-revision-sidebar {
width: 100%;
max-height: 180px;
border-right: none;
border-bottom: 1px solid var(--j13-border-light);
}
.post-revision-current {
display: none;
}
}
.post-detail-tags {
@@ -2173,112 +2835,64 @@ a.waline-comment-author:hover { color: var(--j13-green); }
color: var(--j13-green);
}
.article-tool-btn.active {
background: rgba(24, 160, 88, 0.12);
color: var(--j13-green);
}
.article-tool-btn strong {
font-size: 12px;
font-weight: 700;
}
.article-editor-modes {
display: flex;
gap: 4px;
padding: 3px;
background: var(--j13-bg-block-muted);
border-radius: 8px;
}
.article-mode-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 12px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--color-text-3);
font-size: 12px;
cursor: pointer;
transition: background 0.12s, color 0.12s, box-shadow 0.12s;
}
.article-mode-btn.active {
background: var(--j13-bg-surface);
color: var(--j13-green);
font-weight: 500;
box-shadow: var(--j13-shadow-soft);
}
.article-editor-panes {
display: flex;
align-items: stretch;
gap: 0;
overflow: visible;
}
.article-editor-panes--edit .article-pane--edit,
.article-editor-panes--preview .article-pane--preview {
.article-editor-body {
flex: 1;
}
.article-editor-panes--split .article-pane--edit,
.article-editor-panes--split .article-pane--preview {
flex: 1;
min-width: 0;
}
.article-editor-panes--split .article-pane--edit {
border-right: 1px solid var(--j13-border-light);
}
.article-pane {
display: flex;
flex-direction: column;
overflow: visible;
min-height: 280px;
}
.article-pane-label {
padding: 6px 0 8px;
font-size: 11px;
font-weight: 600;
color: var(--color-text-4);
text-transform: uppercase;
letter-spacing: 0.06em;
flex-shrink: 0;
.article-editor-content {
flex: 1;
display: flex;
flex-direction: column;
}
.article-textarea {
width: 100%;
.article-editor-content .tiptap,
.article-prosemirror {
flex: 1;
min-height: 280px;
padding: 12px 0;
border: none;
background: transparent;
resize: none;
outline: none;
font-family: ui-monospace, 'Cascadia Code', 'SF Mono', Consolas, monospace;
font-size: 14px;
line-height: 1.8;
color: var(--color-text-1);
overflow: hidden;
box-sizing: border-box;
}
.article-textarea::placeholder {
.article-prosemirror p.is-editor-empty:first-child::before {
color: var(--color-text-4);
font-family: var(--j13-font-family);
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
.article-preview {
padding: 12px 0 12px 16px;
overflow: visible;
.article-prosemirror a {
color: var(--j13-green);
text-decoration: underline;
text-underline-offset: 2px;
}
.article-editor-panes--edit .article-preview,
.article-editor-panes--preview .article-preview {
padding-left: 0;
.article-prosemirror img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 12px 0;
}
.article-preview--empty .article-preview-placeholder {
color: var(--color-text-4);
font-style: italic;
.editor-members-only {
margin: 16px 0;
}
.editor-members-only--selected {
box-shadow: 0 0 0 2px rgba(24, 160, 88, 0.35);
}
.article-editor-status {
@@ -2292,21 +2906,6 @@ a.waline-comment-author:hover { color: var(--j13-green); }
flex-shrink: 0;
}
@media (max-width: 900px) {
.article-editor-panes--split {
flex-direction: column;
}
.article-editor-panes--split .article-pane--edit {
border-right: none;
border-bottom: 1px solid var(--j13-border-light);
}
.article-preview {
padding-left: 0;
}
}
@media (max-width: 768px) {
.compose-canvas {
padding: 0 16px;
@@ -2424,6 +3023,15 @@ a.waline-comment-author:hover { color: var(--j13-green); }
}
.admin-card-desc { padding: 12px 16px 0; font-size: 13px; color: hsl(var(--muted-foreground)); }
.admin-card .admin-card-desc + button { margin: 12px 16px 16px; }
.admin-form-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 12px 16px 16px;
font-size: 13px;
}
.admin-form-row label { color: hsl(var(--muted-foreground)); white-space: nowrap; }
.admin-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.admin-table th, .admin-table td { padding: 10px 14px; text-align: left; border-bottom: 1px solid var(--j13-border); }
.admin-table th { font-weight: 600; color: hsl(var(--muted-foreground)); background: hsl(var(--muted) / 0.3); }

View File

@@ -17,10 +17,46 @@ export function highlightMentions(text: string, _onClick?: (name: string) => voi
export function formatTime(iso: string) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const now = new Date();
const diff = (now.getTime() - d.getTime()) / 1000;
if (diff < 60) return '刚刚';
if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`;
if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`;
return `${d.getMonth() + 1}-${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
const pad = (n: number) => String(n).padStart(2, '0');
const clock = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (
d.getFullYear() === yesterday.getFullYear()
&& d.getMonth() === yesterday.getMonth()
&& d.getDate() === yesterday.getDate()
) {
return `昨天 ${clock}`;
}
if (d.getFullYear() === now.getFullYear()) {
return `${d.getMonth() + 1}${d.getDate()}${clock}`;
}
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}${clock}`;
}
/** 完整日期时间(用于帖子发布/修改时间展示) */
export function formatDateTime(iso: string) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** 判断两个 ISO 时间是否相差超过 1 分钟 */
export function isTimeDiffSignificant(a: string, b: string) {
const da = new Date(a).getTime();
const db = new Date(b).getTime();
if (Number.isNaN(da) || Number.isNaN(db)) return false;
return Math.abs(da - db) > 60_000;
}

View File

@@ -0,0 +1,112 @@
import type { PostItem } from '../api/types';
import type { FeedSort } from '../components/FeedSortBar';
export type FeedCache = {
posts: PostItem[];
postTotal: number;
page: number;
hasMore: boolean;
scrollTop: number;
};
const PREFIX = 'j13-feed-cache:';
function cacheKey(boardId: number, keyword: string, sort: FeedSort) {
return `${PREFIX}${boardId}:${keyword}:${sort}`;
}
/** 读取帖子列表缓存,用于从详情页返回时恢复浏览位置 */
export function getFeedCache(boardId: number, keyword: string, sort: FeedSort): FeedCache | null {
try {
const raw = sessionStorage.getItem(cacheKey(boardId, keyword, sort));
return raw ? (JSON.parse(raw) as FeedCache) : null;
} catch {
return null;
}
}
/** 保存帖子列表缓存 */
export function setFeedCache(boardId: number, keyword: string, sort: FeedSort, data: FeedCache) {
try {
sessionStorage.setItem(cacheKey(boardId, keyword, sort), JSON.stringify(data));
} catch {
// sessionStorage 不可用时忽略
}
}
/** 清除指定筛选条件下的列表缓存 */
export function clearFeedCache(boardId: number, keyword: string, sort: FeedSort) {
try {
sessionStorage.removeItem(cacheKey(boardId, keyword, sort));
} catch {
// ignore
}
}
/** 清除所有帖子列表缓存(置顶等操作后列表需全量刷新) */
export function clearAllFeedCache() {
try {
for (let i = sessionStorage.length - 1; i >= 0; i--) {
const key = sessionStorage.key(i);
if (key?.startsWith(PREFIX)) sessionStorage.removeItem(key);
}
} catch {
// ignore
}
}

View File

@@ -51,6 +51,16 @@ function buildLockedGateHtml(charLength: number): string {
</div>`;
}
/** 判断 HTML 正文是否为空(忽略空段落等) */
export function isHtmlEmpty(html: string): boolean {
if (!html.trim()) return true;
const doc = new DOMParser().parseFromString(
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
'text/html',
);
return (doc.body.textContent ?? '').trim().length === 0;
}
/** 根据登录状态渲染帖子正文 HTML */
export function renderPostContentHtml(html: string, isLoggedIn: boolean): string {
if (!html.trim()) return '';

View File

@@ -0,0 +1,77 @@
import { diffLines, diffWords } from 'diff';
import DOMPurify from 'dompurify';
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
export interface PostSnapshot {
title: string;
content: string;
tags: string;
}
export interface RevisionChangeSummary {
titleChanged: boolean;
tagsChanged: boolean;
contentChanged: boolean;
hasChanges: boolean;
}
export interface DiffPart {
value: string;
added?: boolean;
removed?: boolean;
}
/** 将 HTML 正文转为适合 diff 的纯文本(保留段落换行) */
export function htmlToDiffText(html: string): string {
if (!html.trim()) return '';
const doc = new DOMParser().parseFromString(
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
'text/html',
);
doc.querySelectorAll('br').forEach(br => br.replaceWith('\n'));
const blocks = doc.querySelectorAll('p, div, li, h1, h2, h3, h4, h5, h6, blockquote, pre, members-only');
blocks.forEach(el => {
el.prepend(doc.createTextNode('\n'));
el.append(doc.createTextNode('\n'));
});
return (doc.body.textContent ?? '')
.replace(/\r\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
export function summarizeChange(before: PostSnapshot, after: PostSnapshot): RevisionChangeSummary {
const titleChanged = before.title.trim() !== after.title.trim();
const tagsChanged = normalizeTags(before.tags) !== normalizeTags(after.tags);
const contentChanged = htmlToDiffText(before.content) !== htmlToDiffText(after.content);
return {
titleChanged,
tagsChanged,
contentChanged,
hasChanges: titleChanged || tagsChanged || contentChanged,
};
}
function normalizeTags(tags: string) {
return tags.split(/[,]/).map(t => t.trim()).filter(Boolean).join(',');
}
export function diffTextLines(before: string, after: string): DiffPart[] {
return diffLines(before || '', after || '');
}
export function diffTextWords(before: string, after: string): DiffPart[] {
return diffWords(before || '', after || '');
}
/** 统计 diff 片段中的增删行数 */
export function countLineChanges(parts: DiffPart[]) {
let added = 0;
let removed = 0;
for (const p of parts) {
const lines = p.value.split('\n').filter(l => l.length > 0);
if (p.added) added += lines.length;
else if (p.removed) removed += lines.length;
}
return { added, removed };
}