From 2208af7070d92cfcbbde4ffe56ae382d48b8747f Mon Sep 17 00:00:00 2001 From: freefire Date: Thu, 27 Aug 2026 06:23:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=8F=8B=E9=93=BE?= =?UTF-8?q?=E7=94=B3=E8=AF=B7=E3=80=81=E7=8B=AC=E7=AB=8B=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E3=80=81=E6=8A=95=E7=A5=A8/=E6=82=AC=E8=B5=8F/=E6=8A=BD?= =?UTF-8?q?=E5=A5=96=E5=B8=96=E4=B8=8E=E4=BE=A7=E6=A0=8F=E7=AD=BE=E5=88=B0?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E7=BB=9F=E4=B8=80=E5=BC=80=E5=8F=91=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- Makefile | 12 +- README.md | 2 + build.ps1 | 8 +- frontend/package-lock.json | 56 + frontend/package.json | 3 + frontend/src/App.tsx | 9 + frontend/src/api/client.ts | 98 +- frontend/src/api/types.ts | 116 +- frontend/src/components/AsideCheckInStrip.tsx | 104 + frontend/src/components/CommentThreadList.tsx | 45 +- frontend/src/components/FeedHeader.tsx | 3 +- frontend/src/components/FeedPageSkeleton.tsx | 5 +- frontend/src/components/FeedSortBar.tsx | 10 +- .../src/components/FriendLinkApplyDialog.tsx | 276 +++ .../src/components/FriendLinkSiteInfo.tsx | 56 + frontend/src/components/PostBountyBanner.tsx | 182 ++ frontend/src/components/PostListItem.tsx | 213 +- frontend/src/components/PostListSkeleton.tsx | 82 +- frontend/src/components/PostLotteryCard.tsx | 98 + frontend/src/components/PostPollCard.tsx | 175 ++ frontend/src/components/RightPanel.tsx | 322 +-- frontend/src/components/Sidebar.tsx | 97 +- frontend/src/components/SiteFooter.tsx | 59 +- frontend/src/components/VirtualPostList.tsx | 10 +- .../components/admin/AdminSortableList.tsx | 215 ++ .../src/components/admin/AsideWidgetList.tsx | 71 + .../components/compose/ComposeContextBar.tsx | 103 +- .../compose/ComposeSpecialFields.tsx | 259 +++ frontend/src/hooks/useCheckIn.ts | 53 + frontend/src/hooks/useForumLimits.ts | 6 + frontend/src/hooks/useSitePages.ts | 41 + frontend/src/layouts/AdminLayout.tsx | 22 +- frontend/src/layouts/MainLayout.tsx | 69 +- frontend/src/pages/BoardsManagePage.tsx | 129 +- frontend/src/pages/ComposePage.tsx | 104 +- frontend/src/pages/HomePage.tsx | 60 +- frontend/src/pages/LinksPage.tsx | 300 +++ frontend/src/pages/PostDetailPage.tsx | 123 +- frontend/src/pages/SitePageView.tsx | 55 + frontend/src/pages/admin/AdminBadgesPage.tsx | 57 +- .../src/pages/admin/AdminDashboardPage.tsx | 13 +- frontend/src/pages/admin/AdminLinksPage.tsx | 712 +++++++ frontend/src/pages/admin/AdminPagesPage.tsx | 251 +++ .../src/pages/admin/AdminSettingsPage.tsx | 237 ++- frontend/src/styles/global.css | 1846 ++++++++++++++++- frontend/src/utils/asideWidgets.ts | 70 + frontend/src/utils/board.ts | 29 + frontend/src/utils/bounty.ts | 29 + frontend/src/utils/composeDraft.ts | 43 +- frontend/src/utils/friendLink.ts | 38 + frontend/src/utils/layoutCache.ts | 15 +- frontend/src/utils/permalink.ts | 27 +- frontend/src/utils/sortOrder.ts | 69 + handler/api.go | 37 +- handler/economy.go | 10 + handler/friend_link.go | 244 +++ handler/handlers.go | 14 + handler/seo.go | 142 +- handler/seo_bot.go | 12 + handler/special.go | 158 ++ model/db.go | 3 +- model/models.go | 108 +- router/router.go | 29 +- service/aside_widgets_test.go | 36 + service/bounty.go | 150 ++ service/bounty_test.go | 175 ++ service/friend_link.go | 473 +++++ service/friend_link_enrich.go | 93 + service/friend_link_reciprocal.go | 269 +++ service/friend_link_reciprocal_async.go | 62 + service/friend_link_reciprocal_test.go | 46 + service/lottery_post.go | 150 ++ service/permalink.go | 79 + service/poll.go | 280 +++ service/post.go | 22 + service/post_special.go | 69 + service/ratelimit.go | 22 +- service/seo.go | 12 +- service/settings.go | 303 ++- service/site_page.go | 176 ++ 80 files changed, 9620 insertions(+), 641 deletions(-) create mode 100644 frontend/src/components/AsideCheckInStrip.tsx create mode 100644 frontend/src/components/FriendLinkApplyDialog.tsx create mode 100644 frontend/src/components/FriendLinkSiteInfo.tsx create mode 100644 frontend/src/components/PostBountyBanner.tsx create mode 100644 frontend/src/components/PostLotteryCard.tsx create mode 100644 frontend/src/components/PostPollCard.tsx create mode 100644 frontend/src/components/admin/AdminSortableList.tsx create mode 100644 frontend/src/components/admin/AsideWidgetList.tsx create mode 100644 frontend/src/components/compose/ComposeSpecialFields.tsx create mode 100644 frontend/src/hooks/useCheckIn.ts create mode 100644 frontend/src/hooks/useSitePages.ts create mode 100644 frontend/src/pages/LinksPage.tsx create mode 100644 frontend/src/pages/SitePageView.tsx create mode 100644 frontend/src/pages/admin/AdminLinksPage.tsx create mode 100644 frontend/src/pages/admin/AdminPagesPage.tsx create mode 100644 frontend/src/utils/asideWidgets.ts create mode 100644 frontend/src/utils/bounty.ts create mode 100644 frontend/src/utils/friendLink.ts create mode 100644 frontend/src/utils/sortOrder.ts create mode 100644 handler/friend_link.go create mode 100644 handler/special.go create mode 100644 service/aside_widgets_test.go create mode 100644 service/bounty.go create mode 100644 service/bounty_test.go create mode 100644 service/friend_link.go create mode 100644 service/friend_link_enrich.go create mode 100644 service/friend_link_reciprocal.go create mode 100644 service/friend_link_reciprocal_async.go create mode 100644 service/friend_link_reciprocal_test.go create mode 100644 service/lottery_post.go create mode 100644 service/poll.go create mode 100644 service/post_special.go create mode 100644 service/site_page.go diff --git a/Makefile b/Makefile index 61b56be..041f219 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ APP_NAME := jiang13 MAIN_PKG := ./cmd/jiang13 BUILD_DIR := dist +DEV_DATA_DIR := dist/data VERSION := 1.0.0 LDFLAGS := -s -w -X main.version=$(VERSION) REGISTRY_IMAGE := hangzhang714128/jiang13-forum @@ -55,16 +56,19 @@ build-all: frontend-build tidy: $(GO) mod tidy -## 本地运行(仅后端,使用已 embed 的前端) +## 本地运行(仅后端,使用已 embed 的前端;数据目录与 dist 二进制一致) run: - $(GO) run $(MAIN_PKG) + @mkdir -p $(DEV_DATA_DIR) + $(GO) run $(MAIN_PKG) --data $(DEV_DATA_DIR) -## 前端热更新开发(后端 :3000 + Vite :5173,Ctrl+C 同时退出) +## 前端热更新开发(后端 :3000 + Vite :5173,Ctrl+C 同时退出;数据目录与 dist 二进制一致) dev: @echo "前端热更新: http://localhost:5173" @echo "后端 API : http://localhost:3000" + @echo "数据目录 : $(DEV_DATA_DIR) (与 dist 二进制一致)" + @mkdir -p $(DEV_DATA_DIR) @trap 'kill 0' INT; \ - $(GO) run $(MAIN_PKG) & \ + $(GO) run $(MAIN_PKG) --dev --data $(DEV_DATA_DIR) & \ cd frontend && (test -d node_modules || npm install) && npm run dev ## 清理编译产物 diff --git a/README.md b/README.md index c40e27c..6cd2576 100644 --- a/README.md +++ b/README.md @@ -346,6 +346,8 @@ make dev 浏览器访问 `http://localhost:5173`,API 自动代理到 `http://localhost:3000`。 +开发后端与 `dist/jiang13` 共用数据目录 `dist/data`(SQLite、上传、JWT 密钥等),避免 dev 与 dist 运行数据不一致。 + **何时需要完整构建:** - 修改 Go 代码或要发布单二进制 → `build.bat` / `make build` diff --git a/build.ps1 b/build.ps1 index b13a7d2..ad983b8 100644 --- a/build.ps1 +++ b/build.ps1 @@ -11,6 +11,7 @@ $ErrorActionPreference = 'Stop' $AppName = 'jiang13' $MainPkg = './cmd/jiang13' $BuildDir = 'dist' +$DevDataDir = 'dist/data' $Version = '1.0.0' $RegistryImage = 'hangzhang714128/jiang13-forum' $Ldlags = "-s -w -X main.version=$Version" @@ -90,18 +91,21 @@ switch ($Target) { Write-Host '[ok] cleaned dist' -ForegroundColor Green } 'run' { - go run $MainPkg + Ensure-Dir $DevDataDir + go run $MainPkg --data $DevDataDir } 'dev' { $root = (Get-Location).Path + Ensure-Dir $DevDataDir Write-Host '' Write-Host '[dev] 前端开发 : http://localhost:5173 (Vite HMR)' -ForegroundColor Green Write-Host '[dev] 后端 API : http://localhost:3000 (Go)' -ForegroundColor Green + Write-Host "[dev] 数据目录 : $DevDataDir (与 dist 二进制一致)" -ForegroundColor Green Write-Host '[dev] 提示 : 请访问 5173 端口,Vite 会自动代理 API 到 3000' -ForegroundColor Yellow Write-Host '[dev] 正在新窗口启动 Go 后端 (仅 API)...' -ForegroundColor Cyan Start-Process powershell -ArgumentList @( '-NoExit', '-Command', - "Set-Location '$root'; Write-Host '[backend] Go API on :3000' -ForegroundColor Cyan; go run $MainPkg --dev" + "Set-Location '$root'; Write-Host '[backend] Go API on :3000' -ForegroundColor Cyan; go run $MainPkg --dev --data '$DevDataDir'" ) | Out-Null Start-Sleep -Seconds 2 Push-Location frontend diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2b268e9..a11ae09 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,9 @@ "name": "jiang13-forum-web", "version": "1.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.4.0", "@radix-ui/react-alert-dialog": "^1.1.16", "@radix-ui/react-dialog": "^1.1.16", @@ -352,6 +355,59 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 18ac317..70460e7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,9 @@ "preview": "vite preview" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.4.0", "@radix-ui/react-alert-dialog": "^1.1.16", "@radix-ui/react-dialog": "^1.1.16", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 38fac5d..13ff3bc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -31,6 +31,7 @@ const UserProfilePage = lazyWithRetry(() => import('./pages/UserProfilePage')); const FavoritesPage = lazyWithRetry(() => import('./pages/FavoritesPage')); const MessagesPage = lazyWithRetry(() => import('./pages/MessagesPage')); const ProjectsPage = lazyWithRetry(() => import('./pages/ProjectsPage')); +const LinksPage = lazyWithRetry(() => import('./pages/LinksPage')); const AdminDashboardPage = lazyWithRetry(() => import('./pages/admin/AdminDashboardPage')); const AdminPostsPage = lazyWithRetry(() => import('./pages/admin/AdminPostsPage')); const AdminCommentsPage = lazyWithRetry(() => import('./pages/admin/AdminCommentsPage')); @@ -38,6 +39,9 @@ const AdminReportsPage = lazyWithRetry(() => import('./pages/admin/AdminReportsP const AdminUsersPage = lazyWithRetry(() => import('./pages/admin/AdminUsersPage')); const AdminBadgesPage = lazyWithRetry(() => import('./pages/admin/AdminBadgesPage')); const AdminMediaPage = lazyWithRetry(() => import('./pages/admin/AdminMediaPage')); +const AdminPagesPage = lazyWithRetry(() => import('./pages/admin/AdminPagesPage')); +const AdminLinksPage = lazyWithRetry(() => import('./pages/admin/AdminLinksPage')); +const SitePageView = lazyWithRetry(() => import('./pages/SitePageView')); const AdminSettingsPage = lazyWithRetry(() => import('./pages/admin/AdminSettingsPage')); const NotFoundPage = lazyWithRetry(() => import('./pages/NotFoundPage')); @@ -52,6 +56,8 @@ const router = createBrowserRouter( } /> }>} /> }>} /> + }>} /> + }>} /> }>} /> }>} /> }>} /> @@ -63,6 +69,7 @@ const router = createBrowserRouter( }> } /> + } /> {/* :id 可为 123 或 123.html(伪静态后缀由后台配置) */} } /> } /> @@ -71,7 +78,9 @@ const router = createBrowserRouter( } /> } /> } /> + } /> } /> + }>} /> }>} /> }>} /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 768f434..5fd0167 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,4 +1,4 @@ -import type { User, UserPublic, UserActivityStats, Board, PostItem, Comment, RecentComment, ForumStats, TagCount, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision, CommentRevision, MailConfig, OIDCConfig, OAuthClient, OAuthClientInput, GiteaProject, GiteaSyncConfig, StorageConfig, MediaListResult, SiteBranding, RegisterConfig, PrivateMessage, MessageConversation, PostReport, ReportReason, ReportStatus, BadgeDef, PointLedger, CheckInStatus, LotteryStatus } from './types'; +import type { User, UserPublic, UserActivityStats, Board, PostItem, Comment, RecentComment, ForumStats, TagCount, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision, CommentRevision, MailConfig, OIDCConfig, OAuthClient, OAuthClientInput, GiteaProject, GiteaSyncConfig, StorageConfig, MediaListResult, SiteBranding, RegisterConfig, PrivateMessage, MessageConversation, PostReport, ReportReason, ReportStatus, BadgeDef, PointLedger, CheckInStatus, LotteryStatus, SitePage, SitePageSummary, PollView, PostLotteryView, FriendLinkApply } from './types'; const BASE = ''; @@ -26,6 +26,8 @@ export const api = { stats: () => request('/api/stats'), forumLimits: () => request('/api/forum-limits'), siteBranding: () => request('/api/site-branding'), + pages: () => request<{ pages: SitePageSummary[] }>('/api/pages'), + page: (slug: string) => request<{ page: SitePage }>(`/api/pages/${encodeURIComponent(slug)}`), boards: () => request<{ boards: Board[] }>('/api/boards'), projects: (params?: { page?: number; limit?: number }) => { const q = new URLSearchParams(); @@ -40,7 +42,6 @@ export const api = { const q = new URLSearchParams(params as Record).toString(); return request<{ posts: PostItem[]; total: number; page: number; has_more: boolean }>(`/api/posts?${q}`); }, - hotPosts: () => request<{ posts: PostItem[] }>('/api/posts/hot'), tags: (limit = 40) => request<{ tags: TagCount[] }>(`/api/tags?limit=${limit}`), post: (id: number, opts?: { skipView?: boolean }) => { const q = opts?.skipView ? '?skip_view=1' : ''; @@ -285,6 +286,7 @@ export const api = { check_in: CheckInStatus; lottery: LotteryStatus; }>(`/api/me/points?page=${page}`), + checkInStatus: () => request<{ check_in: CheckInStatus }>('/api/me/check-in'), checkIn: () => request<{ message: string; check_in: CheckInStatus; points: number }>('/api/me/check-in', { method: 'POST' }), lotteryStatus: () => request<{ lottery: LotteryStatus }>('/api/me/lottery'), @@ -341,13 +343,19 @@ export const api = { fd.append('image', file); return request<{ url: string }>('/api/uploads/image', { method: 'POST', body: fd, headers: {} }); }, - createPost: (data: { board_id: string; title: string; content: string; tags?: string; post_type?: string }) => { + createPost: (data: { + board_id: string; title: string; content: string; tags?: string; post_type?: string; + poll_options?: string; bounty_points?: number; lottery_winner_count?: number; + }) => { const fd = new FormData(); fd.append('board_id', data.board_id); fd.append('title', data.title); fd.append('content', data.content); fd.append('tags', data.tags || ''); fd.append('post_type', data.post_type || 'normal'); + if (data.poll_options) fd.append('poll_options', data.poll_options); + if (data.bounty_points != null) fd.append('bounty_points', String(data.bounty_points)); + if (data.lottery_winner_count != null) fd.append('lottery_winner_count', String(data.lottery_winner_count)); return request<{ post_id: number; message?: string; status?: string }>('/api/posts', { method: 'POST', body: fd, headers: {} }); }, updatePost: (id: number, data: { title: string; content: string; tags?: string; board_id?: string | number; post_type?: string }) => { @@ -372,6 +380,59 @@ export const api = { headers: {}, }); }, + pollVote: (id: number, optionIds: number[]) => + request<{ message: string; poll: PollView }>(`/api/posts/${id}/poll/vote`, { + method: 'POST', body: JSON.stringify({ option_ids: optionIds }), + }), + pollClose: (id: number) => + request<{ message: string; poll: PollView }>(`/api/posts/${id}/poll/close`, { method: 'POST', body: '{}' }), + bountyAward: (id: number, commentId: number) => { + const fd = new FormData(); + fd.append('comment_id', String(commentId)); + return request<{ message: string }>(`/api/posts/${id}/bounty/award`, { method: 'POST', body: fd, headers: {} }); + }, + bountyRefund: (id: number) => + request<{ message: string }>(`/api/posts/${id}/bounty/refund`, { method: 'POST', body: '{}' }), + postLotteryDraw: (id: number) => + request<{ message: string; lottery: PostLotteryView }>(`/api/posts/${id}/lottery/draw`, { method: 'POST', body: '{}' }), + adminPages: () => request<{ pages: SitePage[] }>('/api/admin/pages'), + adminCreatePage: (data: Partial) => + request<{ message: string; page: SitePage }>('/api/admin/pages', { method: 'POST', body: JSON.stringify(data) }), + adminUpdatePage: (id: number, data: Partial) => + request<{ message: string }>(`/api/admin/pages/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + adminDeletePage: (id: number) => + request<{ message: string }>(`/api/admin/pages/${id}`, { method: 'DELETE' }), + adminFriendLinkApplies: (params?: { page?: number; size?: number; status?: string }) => { + const q = new URLSearchParams(); + if (params?.page) q.set('page', String(params.page)); + if (params?.size) q.set('size', String(params.size)); + if (params?.status) q.set('status', params.status); + const qs = q.toString(); + return request<{ + applies: FriendLinkApply[]; + total: number; + page: number; + pending_count: number; + reciprocal_check_enabled?: boolean; + }>(`/api/admin/friend-link-applies${qs ? `?${qs}` : ''}`); + }, + adminUpdateFriendLinkSettings: (body: { reciprocal_check_enabled: boolean }) => + request<{ message: string; reciprocal_check_enabled: boolean }>('/api/admin/friend-link-settings', { + method: 'PUT', + body: JSON.stringify(body), + }), + adminApproveFriendLinkApply: (id: number) => + request<{ message: string; apply: FriendLinkApply }>(`/api/admin/friend-link-applies/${id}/approve`, { + method: 'POST', body: '{}', + }), + adminRejectFriendLinkApply: (id: number, body?: { note?: string }) => + request<{ message: string; apply: FriendLinkApply }>(`/api/admin/friend-link-applies/${id}/reject`, { + method: 'POST', body: JSON.stringify(body ?? {}), + }), + adminRecheckFriendLinkApply: (id: number) => + request<{ message: string; apply: FriendLinkApply }>(`/api/admin/friend-link-applies/${id}/recheck`, { + method: 'POST', + }), deletePost: (id: number) => request<{ message: string }>(`/api/posts/${id}`, { method: 'DELETE' }), login: (username: string, password: string) => { const fd = new FormData(); @@ -425,6 +486,37 @@ export const api = { like: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/posts/${id}/like`, { method: 'POST' }), likeComment: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/comments/${id}/like`, { method: 'POST' }), favorite: (id: number) => request<{ favorited: boolean }>(`/api/posts/${id}/favorite`, { method: 'POST' }), + applyFriendLink: (body: { + name: string; + url: string; + logo: string; + link_on_homepage: boolean; + reciprocal_page_url?: string; + }) => + request<{ message: string; apply: FriendLinkApply; warning?: string }>('/api/friend-links/apply', { + method: 'POST', body: JSON.stringify(body), + }), + uploadFriendLinkLogo: (file: File) => { + const fd = new FormData(); + fd.append('logo', file); + return request<{ message: string; url: string }>('/api/friend-links/logo', { + method: 'POST', body: fd, headers: {}, + }); + }, + myFriendLinkApplies: () => + request<{ applies: FriendLinkApply[] }>('/api/friend-links/my-applies'), + updateFriendLinkApply: (id: number, body: { + name: string; + url: string; + logo: string; + link_on_homepage: boolean; + reciprocal_page_url?: string; + }) => + request<{ message: string; apply: FriendLinkApply; warning?: string }>(`/api/friend-links/applies/${id}`, { + method: 'PUT', body: JSON.stringify(body), + }), + cancelFriendLinkApply: (id: number) => + request<{ message: string }>(`/api/friend-links/applies/${id}`, { method: 'DELETE' }), reportPost: (id: number, body: { reason: ReportReason; detail?: string }) => request<{ message: string; report: PostReport }>(`/api/posts/${id}/report`, { method: 'POST', body: JSON.stringify(body), diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 543ef63..b23dba0 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -69,6 +69,7 @@ export interface ForumStats { users: number; posts: number; boards: number; + comments: number; } /** 标签云单项 */ @@ -84,10 +85,15 @@ export interface PostItem { title: string; content?: string; tags: string; - /** normal=讨论 | question=问答 */ - post_type?: 'normal' | 'question' | string; + /** normal=讨论 | question=问答 | poll=投票 | bounty=悬赏 | lottery=抽奖 */ + post_type?: 'normal' | 'question' | 'poll' | 'bounty' | 'lottery' | string; /** 仅问答帖有意义 */ question_resolved?: boolean; + bounty_points?: number; + bounty_status?: 'open' | 'awarded' | 'refunded' | string; + bounty_comment_id?: number; + lottery_winner_count?: number; + lottery_status?: 'open' | 'drawn' | string; pinned: boolean; /** 板块内置顶(仅板块列表抬升,首页不抬升) */ board_pinned?: boolean; @@ -131,12 +137,18 @@ export interface PostDetailResponse { comment_count: number; liked: boolean; favorited: boolean; - /** 当前用户是否已在本帖发表过评论(含审核中) */ has_replied?: boolean; can_edit?: boolean; edit_block_reason?: string; is_edited?: boolean; post_edit_window_hours?: number; + poll?: PollView; + lottery?: PostLotteryView; + /** 悬赏进行中:当前用户是否可取消悬赏 */ + bounty_can_refund?: boolean; + bounty_refund_block_reason?: string; + /** 他人已发布有效回复数 */ + bounty_eligible_reply_count?: number; } export interface Comment { @@ -171,9 +183,23 @@ export interface AdminDashboard { pending_posts?: number; pending_comments?: number; pending_reports?: number; + pending_friend_links?: number; recent_posts: PostItem[]; } +export type AsideWidgetId = 'tag_cloud' | 'recent_comments' | 'friend_links'; + +export interface AsideWidget { + id: AsideWidgetId; + enabled: boolean; +} + +export const DEFAULT_ASIDE_WIDGETS: AsideWidget[] = [ + { id: 'tag_cloud', enabled: false }, + { id: 'recent_comments', enabled: false }, + { id: 'friend_links', enabled: true }, +]; + export interface ForumLimits { post_edit_window_hours: number; comment_edit_window_minutes: number; @@ -194,6 +220,16 @@ export interface ForumLimits { signature_max: number; open_posts_in_new_tab: boolean; open_content_links_in_new_tab: boolean; + /** 右侧栏标签云 */ + aside_show_tag_cloud: boolean; + /** 右侧栏最新评论 */ + aside_show_recent_comments: boolean; + /** 右侧栏友情链接 */ + aside_show_friend_links: boolean; + /** 右侧栏可选组件顺序与开关 */ + aside_widgets: AsideWidget[]; + /** 首页列表样式:title 仅标题 / thumbnail 缩略图 */ + feed_list_style: 'title' | 'excerpt' | 'thumbnail'; /** 伪静态(固定链接)开关 */ permalink_enabled: boolean; /** 伪静态后缀,不含点,如 html / htm */ @@ -214,6 +250,11 @@ export interface ForumLimitsPublic { signature_max: number; open_posts_in_new_tab: boolean; open_content_links_in_new_tab: boolean; + aside_show_tag_cloud: boolean; + aside_show_recent_comments: boolean; + aside_show_friend_links: boolean; + aside_widgets: AsideWidget[]; + feed_list_style: 'title' | 'excerpt' | 'thumbnail'; permalink_enabled: boolean; permalink_ext: string; } @@ -221,12 +262,77 @@ export interface ForumLimitsPublic { export interface FriendLink { name: string; url: string; + logo?: string; +} + +export interface FriendLinkApply { + id: number; + user_id: number; + name: string; + url: string; + description?: string; + logo?: string; + reciprocal_page_url?: string; + link_on_homepage?: boolean; + reciprocal_verified?: boolean; + reciprocal_check_note?: string; + reciprocal_checked_at?: string; + status: 'pending' | 'approved' | 'rejected'; + review_note?: string; + reviewed_at?: string; + created_at: string; + user?: User; +} + +export interface SitePageSummary { + id: number; + title: string; + slug: string; + show_in_footer?: boolean; + show_in_nav?: boolean; + sort_order?: number; +} + +export interface SitePage extends SitePageSummary { + content: string; + published: boolean; +} + +export interface PollOptionView { + id: number; + text: string; + vote_count: number; + percent?: number; +} + +export interface PollView { + multi: boolean; + max_choices: number; + closed: boolean; + ends_at?: string; + options: PollOptionView[]; + my_option_ids?: number[]; + total_votes: number; +} + +export interface PostLotteryWinnerView { + user_id: number; + username: string; + nickname: string; + comment_id: number; +} + +export interface PostLotteryView { + winner_count: number; + status: string; + participant_count: number; + winners?: PostLotteryWinnerView[]; } export interface SiteBranding { name: string; slogan: string; - /** 站点简介(首页可见 + SEO description) */ + /** 站点简介(右侧栏顶部 + SEO description) */ description?: string; /** SEO keywords,逗号分隔 */ keywords?: string; @@ -241,6 +347,8 @@ export interface SiteBranding { icp_beian_url?: string; /** 页脚友情链接 */ friend_links?: FriendLink[]; + /** 公开站点根 URL(API 动态填充) */ + site_url?: string; } export interface AdminSettings { diff --git a/frontend/src/components/AsideCheckInStrip.tsx b/frontend/src/components/AsideCheckInStrip.tsx new file mode 100644 index 0000000..d424d06 --- /dev/null +++ b/frontend/src/components/AsideCheckInStrip.tsx @@ -0,0 +1,104 @@ +import { CalendarCheck, Check, Gift, Loader2 } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useCheckIn } from '../hooks/useCheckIn'; +import { useAuth } from '../hooks/useAuth'; +import { loginPath } from '../utils/authRedirect'; + +/** 右侧栏首块底部:每日签到 */ +export default function AsideCheckInStrip() { + const nav = useNavigate(); + const { user } = useAuth(); + const { status, loading, busy, doCheckIn } = useCheckIn(); + + if (!user) { + return ( +
+
+
+
+ +
+
+ 每日签到 + 登录后每日可得 5–15 积分 +
+
+ +
+
+ ); + } + + if (loading && !status) { + return ( +
+ +
+ ); + } + + const checkedIn = !!status?.checked_in; + const streak = status?.streak ?? 0; + const todayPoints = status?.today_points ?? 5; + + const meta = checkedIn + ? (streak > 0 ? `连续 ${streak} 天 · 今日已获得 ${todayPoints} 积分` : `今日已获得 ${todayPoints} 积分`) + : (streak > 0 ? `连续 ${streak} 天 · 今日可得 ${todayPoints} 积分` : `今日签到可得 ${todayPoints} 积分`); + + return ( +
+
+
+
+ {checkedIn ? ( + + ) : ( + + )} +
+
+ + {checkedIn ? '今日已签到' : '每日签到'} + + {meta} +
+ {!checkedIn && ( + + {todayPoints} + + )} +
+ {!checkedIn && ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/CommentThreadList.tsx b/frontend/src/components/CommentThreadList.tsx index dac0b60..1a12450 100644 --- a/frontend/src/components/CommentThreadList.tsx +++ b/frontend/src/components/CommentThreadList.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { - Check, Clock, History, MessageSquare, X, Pencil, Trash2, + Check, Award, Clock, History, MessageSquare, X, Pencil, Trash2, ThumbsUp, MoreHorizontal, Flag, } from 'lucide-react'; import type { ReactNode } from 'react'; @@ -51,6 +51,7 @@ import { isHtmlEmpty } from '../utils/postContent'; import { Tooltip } from './ui/Tooltip'; import UserLink from './UserLink'; import { cn } from '@/lib/utils'; +import { pinAwardedCommentTree } from '../utils/bounty'; function isCommentAuthor(c: Comment, user?: User | null): boolean { return !!user && c.user_id > 0 && c.user_id === user.id; @@ -83,6 +84,13 @@ interface ItemProps { onRequireLogin?: (actionLabel: string) => void; onLikeUpdate?: (commentId: number, liked: boolean, likeCount: number) => void; renderReplyBox?: (comment: Comment) => ReactNode; + bountyAward?: { + open: boolean; + awardedCommentId?: number; + postAuthorId: number; + canAward: boolean; + onAward: (commentId: number) => void; + }; } /** 单条评论(支持嵌套子回复 + 内联回复框 + 编辑/删除) */ @@ -103,12 +111,14 @@ function CommentItem({ onRequireLogin, onLikeUpdate, renderReplyBox, + bountyAward, }: ItemProps) { const { limits } = useForumLimits(); const c = node.comment; const nick = commentNick(c); const guest = isGuestComment(c); const isHighlighted = highlightFloor === c.floor; + const isBountyAwarded = bountyAward?.awardedCommentId === c.id; const hidden = !!c.content_hidden; const isReplying = replyToId === c.id; const isEditing = editingId === c.id; @@ -215,7 +225,12 @@ function CommentItem({ return (
{!guest && c.user_id ? ( {nick} )} + {isBountyAwarded && ( + + + 已采纳 + + )} {!hidden && ( + )} {!hidden && !isEditing && isAdmin && showEdited && (
@@ -506,6 +539,7 @@ interface Props { onRequireLogin?: (actionLabel: string) => void; onLikeUpdate?: (commentId: number, liked: boolean, likeCount: number) => void; renderReplyBox?: (comment: Comment) => ReactNode; + bountyAward?: ItemProps['bountyAward']; } /** Waline 嵌套楼层评论列表 */ @@ -525,8 +559,12 @@ export default function CommentThreadList({ onRequireLogin, onLikeUpdate, renderReplyBox, + bountyAward, }: Props) { - const tree = buildCommentTree(comments); + const tree = pinAwardedCommentTree( + buildCommentTree(comments), + bountyAward?.awardedCommentId, + ); return (
@@ -548,6 +586,7 @@ export default function CommentThreadList({ onRequireLogin={onRequireLogin} onLikeUpdate={onLikeUpdate} renderReplyBox={renderReplyBox} + bountyAward={bountyAward} /> ))}
diff --git a/frontend/src/components/FeedHeader.tsx b/frontend/src/components/FeedHeader.tsx index fbbed12..b200335 100644 --- a/frontend/src/components/FeedHeader.tsx +++ b/frontend/src/components/FeedHeader.tsx @@ -1,6 +1,7 @@ import { Users, FileText, LayoutGrid } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import type { Board, ForumStats } from '../api/types'; +import { navigateFeed } from '../utils/feedCache'; interface Props { boardId: number; @@ -81,7 +82,7 @@ export default function FeedHeader({ diff --git a/frontend/src/components/FeedPageSkeleton.tsx b/frontend/src/components/FeedPageSkeleton.tsx index 2b58b1f..3215a0b 100644 --- a/frontend/src/components/FeedPageSkeleton.tsx +++ b/frontend/src/components/FeedPageSkeleton.tsx @@ -1,8 +1,11 @@ import { Skeleton } from '@/components/ui/skeleton'; import PostListSkeleton from './PostListSkeleton'; +import { useForumLimits } from '../hooks/useForumLimits'; /** 首页 Feed 初始骨架(标题区 + 排序栏 + 列表) */ export default function FeedPageSkeleton() { + const { limits } = useForumLimits(); + return (
@@ -27,7 +30,7 @@ export default function FeedPageSkeleton() {
- +
diff --git a/frontend/src/components/FeedSortBar.tsx b/frontend/src/components/FeedSortBar.tsx index 340d2db..d7e7e02 100644 --- a/frontend/src/components/FeedSortBar.tsx +++ b/frontend/src/components/FeedSortBar.tsx @@ -1,4 +1,6 @@ import { useRef } from 'react'; +import { boardPath, type PermalinkOpts } from '../utils/permalink'; +import { getCachedForumLimits } from '../hooks/useForumLimits'; import { Clock, MessageCircle, Flame } from 'lucide-react'; import { cn } from '@/lib/utils'; import { moveTabIndex } from '../hooks/useOverlayA11y'; @@ -30,10 +32,9 @@ export function parseFeedSort(raw: string | null): FeedSort { export function buildHomeUrl( boardId: number, sort: FeedSort = 'latest', - opts?: { keyword?: string; tag?: string; author?: string; titleOnly?: boolean }, + opts?: { keyword?: string; tag?: string; author?: string; titleOnly?: boolean; permalink?: PermalinkOpts }, ) { const p = new URLSearchParams(); - if (boardId) p.set('board', String(boardId)); const tag = opts?.tag?.trim(); const keyword = opts?.keyword?.trim(); const author = opts?.author?.trim(); @@ -48,6 +49,11 @@ export function buildHomeUrl( } if (sort !== 'latest') p.set('sort', sort); const qs = p.toString(); + + if (boardId) { + const base = boardPath(boardId, opts?.permalink ?? getCachedForumLimits()); + return qs ? `${base}?${qs}` : base; + } return qs ? `/?${qs}` : '/'; } diff --git a/frontend/src/components/FriendLinkApplyDialog.tsx b/frontend/src/components/FriendLinkApplyDialog.tsx new file mode 100644 index 0000000..c2a4f0c --- /dev/null +++ b/frontend/src/components/FriendLinkApplyDialog.tsx @@ -0,0 +1,276 @@ +import { useEffect, useRef, useState } from 'react'; +import { Link2, Upload } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Spinner } from '@/components/ui/spinner'; +import { notify } from '@/lib/notify'; +import { cn } from '@/lib/utils'; +import { api } from '../api/client'; +import type { FriendLinkApply } from '../api/types'; +import { getCachedSiteBranding } from '../hooks/useSiteBranding'; +import { resolveFriendLinkLogo } from '../utils/friendLink'; +import FriendLinkSiteInfo from './FriendLinkSiteInfo'; + +type LinkPlacement = 'homepage' | 'custom'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + editApply?: FriendLinkApply | null; + onSubmitted?: () => void; +} + +/** 友链申请 / 修改弹窗 */ +export default function FriendLinkApplyDialog({ open, onOpenChange, editApply, onSubmitted }: Props) { + const [name, setName] = useState(''); + const [url, setUrl] = useState(''); + const [logo, setLogo] = useState(''); + const [linkPlacement, setLinkPlacement] = useState('homepage'); + const [reciprocalPageURL, setReciprocalPageURL] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [uploadingLogo, setUploadingLogo] = useState(false); + const fileRef = useRef(null); + const isEdit = !!editApply?.id; + + const reset = () => { + setName(''); + setUrl(''); + setLogo(''); + setLinkPlacement('homepage'); + setReciprocalPageURL(''); + }; + + useEffect(() => { + if (!open) return; + if (editApply) { + setName(editApply.name ?? ''); + setUrl(editApply.url ?? ''); + setLogo(editApply.logo ?? ''); + const onHomepage = editApply.link_on_homepage !== false; + setLinkPlacement(onHomepage ? 'homepage' : 'custom'); + setReciprocalPageURL(onHomepage ? '' : (editApply.reciprocal_page_url ?? '')); + } else { + reset(); + } + }, [open, editApply]); + + const handleOpenChange = (next: boolean) => { + if (!next) reset(); + onOpenChange(next); + }; + + const uploadLogo = async (file: File | undefined) => { + if (!file) return; + setUploadingLogo(true); + try { + const r = await api.uploadFriendLinkLogo(file); + setLogo(r.url); + notify.success('LOGO 已上传'); + } catch (e: unknown) { + notify.error(e instanceof Error ? e.message : '上传失败'); + } finally { + setUploadingLogo(false); + if (fileRef.current) fileRef.current.value = ''; + } + }; + + const submit = async () => { + const trimmedName = name.trim(); + const trimmedURL = url.trim(); + const trimmedLogo = logo.trim(); + const trimmedReciprocal = reciprocalPageURL.trim(); + if (!trimmedName || !trimmedURL) { + notify.warning('请填写网站名称与网站链接'); + return; + } + if (!/^https?:\/\//i.test(trimmedURL)) { + notify.warning('网站链接需以 http:// 或 https:// 开头'); + return; + } + if (!trimmedLogo) { + notify.warning('请填写或上传网站 LOGO'); + return; + } + if (linkPlacement === 'custom') { + if (!trimmedReciprocal) { + notify.warning('请填写添加本站链接的页面地址'); + return; + } + if (!/^https?:\/\//i.test(trimmedReciprocal)) { + notify.warning('回链页地址需以 http:// 或 https:// 开头'); + return; + } + } + + const linkOnHomepage = linkPlacement === 'homepage'; + const body = { + name: trimmedName, + url: trimmedURL, + logo: trimmedLogo, + link_on_homepage: linkOnHomepage, + reciprocal_page_url: linkOnHomepage ? trimmedURL : trimmedReciprocal, + }; + + setSubmitting(true); + try { + if (isEdit) { + const r = await api.updateFriendLinkApply(editApply!.id, body); + notify.success( + editApply?.status === 'approved' + ? `已更新并重新提交审核,友链已暂时从列表移除${r.message.includes('回链检测') ? ',回链检测将在后台进行' : ''}` + : r.message, + ); + } else { + const r = await api.applyFriendLink(body); + notify.success(r.message); + } + onSubmitted?.(); + handleOpenChange(false); + } catch (e: unknown) { + notify.error(e instanceof Error ? e.message : '提交失败'); + } finally { + setSubmitting(false); + } + }; + + const logoPreview = resolveFriendLinkLogo(logo.trim(), getCachedSiteBranding().site_url); + + return ( + + + + {isEdit ? '修改友链申请' : '申请本页友情链接'} + + + + +
+
+ + setName(e.target.value)} + placeholder="请输入您的网站名称" + maxLength={32} + /> +
+
+ + setUrl(e.target.value)} + placeholder="请输入您的网站地址(以 http 开头)" + maxLength={512} + /> +
+
+ +
+ + +
+
+ {linkPlacement === 'custom' && ( +
+ + setReciprocalPageURL(e.target.value)} + placeholder="如:https://您的域名/link.htm" + maxLength={512} + /> +

+ 请填写实际放置本站友链的页面,提交后将在后台检测该页面 +

+
+ )} +
+ +
+
+ {logoPreview ? ( + + ) : ( + 预览 + )} +
+ setLogo(e.target.value)} + placeholder="LOGO 图片地址" + maxLength={512} + /> + uploadLogo(e.target.files?.[0])} + /> + +
+
+
+ + +

+ + 提交后立即返回,回链检测在后台进行,结果供管理员参考 +

+
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/FriendLinkSiteInfo.tsx b/frontend/src/components/FriendLinkSiteInfo.tsx new file mode 100644 index 0000000..ec46257 --- /dev/null +++ b/frontend/src/components/FriendLinkSiteInfo.tsx @@ -0,0 +1,56 @@ +import { useSiteBranding } from '../hooks/useSiteBranding'; + +function resolveSiteURL(siteURL?: string): string { + const fromApi = siteURL?.trim(); + if (fromApi) return fromApi; + if (typeof window !== 'undefined') return window.location.origin; + return ''; +} + +function resolveSiteLogoURL(logo?: string, favicon?: string, siteURL?: string): string { + const raw = logo?.trim() || favicon?.trim() || ''; + if (!raw) return ''; + if (/^https?:\/\//i.test(raw)) return raw; + const base = resolveSiteURL(siteURL); + if (!base) return raw; + return raw.startsWith('/') ? `${base}${raw}` : `${base}/${raw}`; +} + +/** 申请弹窗内:本站友链信息(名称 / 地址 / LOGO 链接) */ +export default function FriendLinkSiteInfo() { + const { branding } = useSiteBranding(); + const siteURL = resolveSiteURL(branding.site_url); + const siteLogoURL = resolveSiteLogoURL(branding.logo, branding.favicon, branding.site_url); + + return ( +
+

本站信息(请添加本站链接后再申请)

+
+
+
名称
+
{branding.name}
+
+
+
地址
+
+ {siteURL ? ( + {siteURL} + ) : ( + '—' + )} +
+
+
+
LOGO
+
+ {siteLogoURL ? ( + {siteLogoURL} + ) : ( + '—' + )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/PostBountyBanner.tsx b/frontend/src/components/PostBountyBanner.tsx new file mode 100644 index 0000000..6570b64 --- /dev/null +++ b/frontend/src/components/PostBountyBanner.tsx @@ -0,0 +1,182 @@ +import { useState } from 'react'; +import { CheckCircle2, Coins } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { notify } from '@/lib/notify'; +import { api } from '../api/client'; +import type { PostItem } from '../api/types'; + +interface Props { + post: PostItem; + isOwnerOrAdmin: boolean; + isAdmin?: boolean; + /** 是否可取消悬赏(楼主有他人回复时为 false;管理员为 true) */ + canRefund?: boolean; + refundBlockReason?: string; + eligibleReplyCount?: number; + onUpdate?: () => void; + /** 跳转到被采纳的评论楼层 */ + onJumpToAwarded?: () => void; + /** 被采纳评论是否仍在当前评论列表中 */ + canJumpToAwarded?: boolean; +} + +/** 悬赏帖状态卡 */ +export default function PostBountyBanner({ + post, + isOwnerOrAdmin, + isAdmin = false, + canRefund = true, + refundBlockReason, + eligibleReplyCount = 0, + onUpdate, + onJumpToAwarded, + canJumpToAwarded, +}: Props) { + const [refundOpen, setRefundOpen] = useState(false); + const [refunding, setRefunding] = useState(false); + + if (post.post_type !== 'bounty') return null; + + const points = post.bounty_points ?? 0; + const open = post.bounty_status === 'open' && points > 0; + const awarded = post.bounty_status === 'awarded'; + const refunded = post.bounty_status === 'refunded'; + + if (refunded && !isOwnerOrAdmin) return null; + + const showRefundButton = isOwnerOrAdmin && canRefund; + const ownerRefundBlocked = isOwnerOrAdmin && !isAdmin && !canRefund; + + const confirmRefund = async () => { + setRefunding(true); + try { + await api.bountyRefund(post.id); + notify.success('悬赏已退回'); + setRefundOpen(false); + onUpdate?.(); + } catch (e: unknown) { + notify.error(e instanceof Error ? e.message : '操作失败'); + } finally { + setRefunding(false); + } + }; + + const refundDialogDescription = isAdmin && eligibleReplyCount > 0 + ? `将强制取消悬赏并把 ${points} 积分退回楼主账户。当前已有 ${eligibleReplyCount} 条他人回复,请确认已审慎处理。` + : `确定取消悬赏并将 ${points} 积分退回你的账户?取消后他人将无法再参与本帖悬赏。`; + + if (open) { + let ownerHint = '在评论上点击「采纳」发放积分'; + if (ownerRefundBlocked) { + ownerHint = refundBlockReason || '已有用户回复,请从评论中采纳发放积分'; + } + + return ( + <> +
+
+
+ +
+ 悬赏进行中 + {points} 积分 +
+
+ {showRefundButton && ( +
+ +
+ )} +
+

+ {isOwnerOrAdmin + ? ownerHint + : `回复本帖,优质回答可获得这 ${points} 积分`} +

+
+ {showRefundButton && ( + { if (!next && !refunding) setRefundOpen(false); }} + > + + + {isAdmin && eligibleReplyCount > 0 ? '强制取消悬赏?' : '取消悬赏?'} + + {refundDialogDescription} + + + + 返回 + { + e.preventDefault(); + void confirmRefund(); + }} + > + {refunding ? '退回中…' : '确认取消'} + + + + + )} + + ); + } + + if (awarded) { + return ( +
+
+
+ +
+ 已采纳 + + {points} 积分已发放给被采纳的回复 + +
+
+
+ {canJumpToAwarded && onJumpToAwarded && ( +
+ +
+ )} +
+ ); + } + + if (refunded) { + return ( +
+
+
+ +
+ 悬赏已取消 + 积分已退回 +
+
+
+
+ ); + } + + return null; +} diff --git a/frontend/src/components/PostListItem.tsx b/frontend/src/components/PostListItem.tsx index b0edfa9..af54ed3 100644 --- a/frontend/src/components/PostListItem.tsx +++ b/frontend/src/components/PostListItem.tsx @@ -1,13 +1,14 @@ import { memo } from 'react'; import { useNavigate } from 'react-router-dom'; import { Eye, Image as ImageIcon, MessageCircle, ThumbsUp } from 'lucide-react'; -import BoardBadge from '@/components/BoardBadge'; import FeaturedIcon from '@/components/FeaturedIcon'; import UserLink from '@/components/UserLink'; import type { PostItem } from '../api/types'; import type { FeedSort } from './FeedSortBar'; +import { useForumLimits } from '../hooks/useForumLimits'; import { formatTime } from '../utils/content'; import { postPath } from '../utils/permalink'; +import { toPostImageThumbSrc } from '../utils/postContent'; import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText'; import { parseTags } from './TagInput'; @@ -19,6 +20,11 @@ interface Props { function PostListItem({ post, sort = 'latest', onSelect }: Props) { const nav = useNavigate(); + const { limits } = useForumLimits(); + const feedStyle = limits.feed_list_style ?? 'title'; + const showExcerpt = feedStyle === 'excerpt' || feedStyle === 'thumbnail'; + const showThumb = feedStyle === 'thumbnail'; + const initial = post.user?.nickname?.[0] || '?'; const timeLabel = sort === 'reply' ? (post.last_reply_at @@ -29,8 +35,10 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) { const likeCount = post.like_count ?? 0; const viewCount = post.view_count ?? 0; const href = postPath(post.id); - const excerpt = excerptFromHTML(post.content || '', 72); - const hasImage = !!firstImageFromHTML(post.content || ''); + const firstImage = firstImageFromHTML(post.content || ''); + const thumbSrc = showThumb && firstImage ? toPostImageThumbSrc(firstImage) : null; + const excerpt = showExcerpt ? excerptFromHTML(post.content || '', 60) : ''; + const showImageIcon = !!firstImage && !thumbSrc; const tagList = parseTags(post.tags || '').slice(0, 3); const openPost = () => onSelect(post.id); @@ -41,7 +49,6 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) { } }; const onTitleClick = (e: React.MouseEvent) => { - // 修饰键 / 非左键:交给浏览器(新标签等) if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) { e.stopPropagation(); return; @@ -51,9 +58,107 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) { openPost(); }; + const titleRow = ( +
+ {post.pinned && ( + 全局置顶 + )} + {post.board_pinned && ( + 板块置顶 + )} + {post.featured && ( + + + 精华 + + )} + {post.status === 'pending' && ( + 审核中 + )} + {post.status === 'rejected' && ( + 未通过 + )} + {post.post_type === 'question' && ( + + {post.question_resolved ? '已解决' : '未解决'} + + )} + {post.post_type === 'poll' && ( + 投票 + )} + {post.post_type === 'bounty' && post.bounty_status === 'open' && (post.bounty_points ?? 0) > 0 && ( + 悬赏 {post.bounty_points} + )} + {post.post_type === 'bounty' && post.bounty_status === 'awarded' && ( + 已采纳 + )} + {post.post_type === 'lottery' && ( + + {post.lottery_status === 'drawn' ? '已开奖' : '抽奖'} + + )} + + {post.title} + +
+ ); + + const metaLeft = ( +
+ + · + {timeLabel} + {post.board && ( + <> + · + {post.board.name} + + )} + {tagList.map(t => ( + + ))} +
+ ); + + const stats = ( +
+ {showImageIcon && ( + + + + )} + + + {commentCount} + + + + {likeCount} + + + + {viewCount} + +
+ ); + return (
-
-
-
- - · - {timeLabel} + {thumbSrc ? ( +
+
+ {titleRow} + {excerpt &&

{excerpt}

} +
{metaLeft}
+
+
+
+ +
+ {stats}
- -
- {post.pinned && ( - 全局置顶 - )} - {post.board_pinned && ( - 板块置顶 - )} - {post.featured && ( - - - 精华 - - )} - {post.status === 'pending' && ( - 审核中 - )} - {post.status === 'rejected' && ( - 未通过 - )} - {post.post_type === 'question' && ( - - {post.question_resolved ? '已解决' : '未解决'} - - )} - - {post.title} - -
- - {excerpt &&

{excerpt}

} - -
-
- {post.board && } - {tagList.map(t => ( - - ))} + ) : ( +
+
+ {titleRow} + {excerpt &&

{excerpt}

}
-
- {hasImage && ( - - - - )} - - - {commentCount} - - - - {likeCount} - - - - {viewCount} - +
+ {metaLeft} + {stats}
-
+ )}
); } diff --git a/frontend/src/components/PostListSkeleton.tsx b/frontend/src/components/PostListSkeleton.tsx index 5d597b4..7d907f1 100644 --- a/frontend/src/components/PostListSkeleton.tsx +++ b/frontend/src/components/PostListSkeleton.tsx @@ -1,37 +1,73 @@ import { Skeleton } from '@/components/ui/skeleton'; +import type { ForumLimitsPublic } from '../api/types'; + +export type FeedListStyle = ForumLimitsPublic['feed_list_style']; + +/** 虚拟列表行高预估值 */ +export function feedListRowEstimate(style: FeedListStyle): number { + switch (style) { + case 'excerpt': return 68; + case 'thumbnail': return 72; + default: return 52; + } +} interface Props { count?: number; + listStyle?: FeedListStyle; } -/** 帖子列表加载骨架屏(对齐卡片式列表) */ -export default function PostListSkeleton({ count = 8 }: Props) { +/** 帖子列表加载骨架屏(对齐 v2 紧凑列表) */ +export default function PostListSkeleton({ count = 8, listStyle = 'title' }: Props) { + const showExcerpt = listStyle === 'excerpt' || listStyle === 'thumbnail'; + const showThumb = listStyle === 'thumbnail'; + return (
- {Array.from({ length: count }, (_, i) => ( -
- -
-
-
- - + {Array.from({ length: count }, (_, i) => { + const hasThumb = showThumb && i % 3 === 0; + return ( +
+ + {hasThumb ? ( +
+
+ + {showExcerpt && ( + + )} + +
+
+ +
+ + + +
+
- {i % 4 === 0 && } -
- - -
- -
- - - + ) : ( +
+
+ + {showExcerpt && ( + + )} +
+
+ +
+ + + +
+
-
+ )}
-
- ))} + ); + })}
); } diff --git a/frontend/src/components/PostLotteryCard.tsx b/frontend/src/components/PostLotteryCard.tsx new file mode 100644 index 0000000..80c260b --- /dev/null +++ b/frontend/src/components/PostLotteryCard.tsx @@ -0,0 +1,98 @@ +import { useState } from 'react'; +import { Gift } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { notify } from '@/lib/notify'; +import { api } from '../api/client'; +import type { PostLotteryView } from '../api/types'; +import { userPath } from '../utils/userPath'; + +interface Props { + postId: number; + lottery: PostLotteryView; + isOwnerOrAdmin: boolean; + onUpdate: (lottery: PostLotteryView) => void; +} + +/** 抽奖帖信息卡 */ +export default function PostLotteryCard({ postId, lottery, isOwnerOrAdmin, onUpdate }: Props) { + const [drawOpen, setDrawOpen] = useState(false); + const [drawing, setDrawing] = useState(false); + const drawn = lottery.status === 'drawn'; + const canDraw = !drawn && isOwnerOrAdmin && lottery.participant_count >= lottery.winner_count; + + const confirmDraw = async () => { + setDrawing(true); + try { + const r = await api.postLotteryDraw(postId); + onUpdate(r.lottery); + notify.success('开奖完成'); + setDrawOpen(false); + } catch (e: unknown) { + notify.error(e instanceof Error ? e.message : '开奖失败'); + } finally { + setDrawing(false); + } + }; + + return ( + <> +
+
+ + {drawn ? '已开奖' : '抽奖进行中'} + 抽取 {lottery.winner_count} 人 · 当前 {lottery.participant_count} 人参与 +
+ {!drawn && ( +

回帖即可参与(楼主除外),由作者或管理员手动开奖

+ )} + {drawn && lottery.winners && lottery.winners.length > 0 && ( + + )} + {canDraw && ( + + )} +
+ { if (!next && !drawing) setDrawOpen(false); }} + > + + + 立即开奖? + + 将从 {lottery.participant_count} 位参与者中抽取 {lottery.winner_count} 名中奖者。此操作不可撤销。 + + + + 取消 + { + e.preventDefault(); + void confirmDraw(); + }} + > + {drawing ? '开奖中…' : '确认开奖'} + + + + + + ); +} diff --git a/frontend/src/components/PostPollCard.tsx b/frontend/src/components/PostPollCard.tsx new file mode 100644 index 0000000..f5294d5 --- /dev/null +++ b/frontend/src/components/PostPollCard.tsx @@ -0,0 +1,175 @@ +import { useState } from 'react'; +import { BarChart3 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { notify } from '@/lib/notify'; +import { api } from '../api/client'; +import type { PollView } from '../api/types'; +import { useAuth } from '../hooks/useAuth'; +import { formatDateTime } from '../utils/content'; + +interface Props { + postId: number; + poll: PollView; + isOwnerOrAdmin: boolean; + onUpdate: (poll: PollView) => void; +} + +/** 投票帖投票卡 */ +export default function PostPollCard({ postId, poll, isOwnerOrAdmin, onUpdate }: Props) { + const { user } = useAuth(); + const [selected, setSelected] = useState(poll.my_option_ids ?? []); + const [busy, setBusy] = useState(false); + const voted = (poll.my_option_ids?.length ?? 0) > 0; + const showResults = poll.closed || voted; + const canVote = !voted && !poll.closed; + + const headTitle = poll.closed + ? '投票已结束' + : poll.multi + ? `多选(最多 ${poll.max_choices} 项)` + : '单选投票'; + + const endsAtMs = poll.ends_at ? new Date(poll.ends_at).getTime() : NaN; + const expiredByDeadline = poll.closed && poll.ends_at && !Number.isNaN(endsAtMs) && endsAtMs <= Date.now(); + + const deadlineHint = poll.ends_at && !Number.isNaN(endsAtMs) + ? poll.closed + ? (expiredByDeadline ? `已于 ${formatDateTime(poll.ends_at)} 截止` : undefined) + : `截止于 ${formatDateTime(poll.ends_at)}` + : undefined; + + const toggle = (id: number) => { + if (!canVote) return; + if (!user) { + notify.warning('请先登录'); + return; + } + if (poll.multi) { + setSelected(prev => ( + prev.includes(id) + ? prev.filter(x => x !== id) + : [...prev, id].slice(0, poll.max_choices) + )); + } else { + setSelected([id]); + } + }; + + const submit = async () => { + if (!user) { + notify.warning('请先登录'); + return; + } + if (selected.length === 0) { + notify.warning('请选择选项'); + return; + } + setBusy(true); + try { + const r = await api.pollVote(postId, selected); + onUpdate(r.poll); + notify.success('投票成功'); + } catch (e: unknown) { + notify.error(e instanceof Error ? e.message : '投票失败'); + } finally { + setBusy(false); + } + }; + + const closePoll = async () => { + setBusy(true); + try { + const r = await api.pollClose(postId); + onUpdate(r.poll); + notify.success('投票已结束'); + } catch (e: unknown) { + notify.error(e instanceof Error ? e.message : '操作失败'); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ + {headTitle} + {deadlineHint && ( + {deadlineHint} + )} + {poll.total_votes} 票 +
+
    + {poll.options.map(opt => { + const isActive = selected.includes(opt.id); + const isMine = poll.my_option_ids?.includes(opt.id); + return ( +
  • + +
  • + ); + })} +
+
+ {canVote && user && ( + + )} + {canVote && user && selected.length === 0 && ( +

+ {poll.multi ? '请选择后提交' : '请选择一项后提交'} +

+ )} + {!user && canVote && ( +

登录后可参与投票

+ )} + {isOwnerOrAdmin && !poll.closed && ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/RightPanel.tsx b/frontend/src/components/RightPanel.tsx index 7f85f16..db1a6ef 100644 --- a/frontend/src/components/RightPanel.tsx +++ b/frontend/src/components/RightPanel.tsx @@ -1,14 +1,18 @@ -import { ListTree, MessageCircle, MessagesSquare, Tags, Sparkles } from 'lucide-react'; -import { useLocation, useSearchParams } from 'react-router-dom'; +import { useMemo } from 'react'; +import { ListTree, MessageCircle, Tags, Link2 } from 'lucide-react'; +import { useLocation, useSearchParams, useNavigate } from 'react-router-dom'; import { Skeleton } from '@/components/ui/skeleton'; -import type { PostItem, RecentComment, TagCount, User } from '../api/types'; +import { Button } from '@/components/ui/button'; +import type { AsideWidget, RecentComment, TagCount, User, ForumStats, FriendLink } from '../api/types'; import type { PostHeading } from '../utils/postHeadings'; import { useSiteBranding } from '../hooks/useSiteBranding'; import { formatShortDateTime, formatTime } from '../utils/content'; +import { resolveAsideWidgets } from '../utils/asideWidgets'; import TagCloud from './TagCloud'; import UserLink from './UserLink'; import ArticleOutline from './ArticleOutline'; import PostAuthorCard from './PostAuthorCard'; +import AsideCheckInStrip from './AsideCheckInStrip'; export type PostDetailAside = { author?: User | null; @@ -20,38 +24,29 @@ export type PostDetailAside = { }; interface Props { - hot: PostItem[]; recentComments: RecentComment[]; tags?: TagCount[]; tagsLoading?: boolean; + stats?: ForumStats | null; onPostClick: (id: number, opts?: { floor?: number }) => void; /** 首次拉取中,显示骨架避免空态闪烁 */ loading?: boolean; + /** 右侧栏可选组件顺序与开关 */ + asideWidgets: AsideWidget[]; /** 帖子详情:右侧顶部展示作者与目录 */ postDetail?: PostDetailAside | null; } -function ActiveSkeleton() { - return ( -
- {Array.from({ length: 6 }, (_, i) => ( -
- - -
- ))} -
- ); -} - function CommentSkeleton() { return (
{Array.from({ length: 5 }, (_, i) => (
- - +
+ + +
))}
@@ -59,21 +54,24 @@ function CommentSkeleton() { } export default function RightPanel({ - hot, recentComments, tags = [], tagsLoading = false, + stats = null, onPostClick, loading = false, + asideWidgets, postDetail = null, }: Props) { const { branding } = useSiteBranding(); + const nav = useNavigate(); const loc = useLocation(); const [params] = useSearchParams(); const activeTag = params.get('tag') || ''; - const hotList = hot?.slice(0, 8) ?? []; const commentList = recentComments?.slice(0, 6) ?? []; - // 站点首页:右侧品牌块承担唯一 h1;板块/搜索等页面由 Feed 标题作 h1 + const friendLinks = (branding.friend_links ?? []).filter( + (l: FriendLink) => l.name?.trim() && l.url?.trim(), + ); const isSiteHome = loc.pathname === '/' && !params.get('board') && !params.get('keyword') @@ -81,13 +79,144 @@ export default function RightPanel({ && !params.get('author'); const description = branding.description?.trim() || ''; const slogan = branding.slogan?.trim() || ''; - // 有独立简介时展示简介;否则用欢迎语,避免与页脚 slogan 三连重复 - const aboutText = description || '欢迎参与讨论,发帖、评论,一起把小圈子聊热。'; - // 有近期讨论则展示「正在聊」;否则显示欢迎引导 - const showActive = loading || hotList.length > 0; - const showWelcome = !loading && hotList.length === 0; + const introText = description || slogan; const isPostDetail = !!postDetail; + const enabledWidgets = useMemo( + () => resolveAsideWidgets({ + aside_widgets: asideWidgets, + aside_show_tag_cloud: false, + aside_show_recent_comments: false, + aside_show_friend_links: false, + }).filter(w => w.enabled), + [asideWidgets], + ); + + const handleApplyClick = () => { + nav('/links?apply=1'); + }; + + const renderWidget = (widget: AsideWidget) => { + switch (widget.id) { + case 'friend_links': + return ( +
+
+ + + + + +
+
+ {friendLinks.length === 0 ? ( +
暂无友情链接
+ ) : ( + <> +
    + {friendLinks.slice(0, 8).map((link: FriendLink) => ( +
  • + {link.name} +
  • + ))} +
+ {friendLinks.length > 8 && ( + + )} + + )} +
+
+ ); + case 'tag_cloud': + return ( +
+
+ + 标签云 +
+
+ +
+
+ ); + case 'recent_comments': + return ( +
+
+ + 最新评论 +
+
+ {loading && commentList.length === 0 ? ( + + ) : commentList.length === 0 ? ( +
暂无评论
+ ) : commentList.map(item => ( +
+ {item.user_id ? ( + + {item.avatar + ? + : (item.author?.[0] || '?')} + + ) : ( + + {item.avatar + ? + : (item.author?.[0] || '?')} + + )} + +
+ ))} +
+
+ ); + default: + return null; + } + }; + return (
{isPostDetail && ( @@ -114,121 +243,6 @@ export default function RightPanel({ )} - {!isPostDetail && showWelcome && ( -
-
- - 加入讨论 -
-
-

社区还在起步,每条回复都很珍贵。

-
    -
  • 逛逛板块,找到感兴趣的话题
  • -
  • 游客也能评论,登录可点赞收藏
  • -
  • 发一篇帖,留下你的痕迹
  • -
-
-
- )} - - {!isPostDetail && showActive && ( -
-
- - 正在聊 -
-
- {loading && hotList.length === 0 ? ( - - ) : hotList.length === 0 ? ( -
近 7 日暂无新回复
- ) : hotList.map((item) => { - const replyLabel = item.last_reply_at - ? `${formatTime(item.last_reply_at)}有人回` - : '近期有讨论'; - const count = item.comment_count ?? 0; - return ( - - ); - })} -
-
- )} - - {!isPostDetail && ( -
-
- - 标签云 -
-
- -
-
- )} - - {!isPostDetail && ( -
-
- - 最新评论 -
-
- {loading && commentList.length === 0 ? ( - - ) : commentList.length === 0 ? ( -
暂无评论
- ) : commentList.map(item => ( -
- {item.user_id ? ( - - {item.avatar - ? - : (item.author?.[0] || '?')} - - ) : ( - - {item.avatar - ? - : (item.author?.[0] || '?')} - - )} - -
- ))} -
-
- )} - {!isPostDetail && (
@@ -238,14 +252,32 @@ export default function RightPanel({ ) : (

{branding.name}

)} -

{aboutText}

- {description && slogan && slogan !== description && ( -

{slogan}

+ {introText && ( +

{introText}

)}
+ {stats && ( +
+
+ {stats.posts} + 帖子 +
+
+ {stats.comments} + 回复 +
+
+ {stats.users} + 用户 +
+
+ )} +
)} + + {!isPostDetail && enabledWidgets.map(renderWidget)}
); } diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index e7157e9..fdfe5ad 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,7 +1,7 @@ import { - Home, Star, LayoutDashboard, FolderGit2, FolderKanban, ArrowLeft, + Home, Star, LayoutDashboard, FolderGit2, FolderKanban, ArrowLeft, FileText, Link2, } from 'lucide-react'; -import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'; +import { useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'; import type { Board } from '../api/types'; import type { PostHeading } from '../utils/postHeadings'; import { useAuth } from '../hooks/useAuth'; @@ -12,9 +12,12 @@ import { navigateFeed } from '../utils/feedCache'; import BoardIconDisplay from './BoardIconDisplay'; import { getBoardThemeIndex } from '../utils/boardTheme'; import ArticleOutline from './ArticleOutline'; +import { useSitePages } from '../hooks/useSitePages'; +import { pagePath } from '../utils/permalink'; +import { useForumLimits } from '../hooks/useForumLimits'; // 内容页不参与左侧栏高亮(非 feed 浏览上下文) -const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile', '/user/']; +const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile', '/user/', '/page/']; export function isNeutralSidebarRoute(pathname: string): boolean { return NEUTRAL_SIDEBAR_PREFIXES.some(prefix => pathname.startsWith(prefix)); @@ -26,6 +29,8 @@ function resolveMenuKey(pathname: string, activeBoard: number, keyword = ''): st if (keyword.trim()) return null; if (pathname.startsWith('/favorites')) return 'favorites'; if (pathname.startsWith('/projects')) return 'projects'; + if (pathname.startsWith('/links')) return 'links'; + if (pathname.startsWith('/page/')) return 'pages'; if (pathname.startsWith('/admin')) return 'admin'; return activeBoard === 0 ? 'all' : String(activeBoard); } @@ -59,9 +64,37 @@ export default function Sidebar({ const sort = parseFeedSort(params.get('sort')); const { user } = useAuth(); const isAdmin = user?.role === 'admin'; + const { navPages } = useSitePages(); + const { limits } = useForumLimits(); const keyword = params.get('keyword') || ''; const menuKey = resolveMenuKey(loc.pathname, activeBoard, keyword); + const permalinkOpts = { permalink: limits }; + + const feedNavLink = ( + key: string, + to: string, + label: React.ReactNode, + icon: React.ReactNode, + selectId: number, + className?: string, + trailing?: React.ReactNode, + ) => ( + { + e.preventDefault(); + onSelectBoard(selectId); + navigateFeed(nav, to); + }} + > + {icon} + {label} + {trailing} + + ); const navItem = (key: string, label: React.ReactNode, icon?: React.ReactNode, onClick?: () => void) => ( + const boardUrl = buildHomeUrl(b.id, sort, permalinkOpts); + const postMeta = (b.post_count ?? 0) > 0 ? ( + + {b.post_count} 帖 + + ) : null; + return feedNavLink( + String(b.id), + boardUrl, + b.name, + , + b.id, + cn( + 'sidebar-nav-item--board', + isActive && 'active', + isActive && `sidebar-nav-item--board-${themeIdx}`, + ), + postMeta, ); })} @@ -159,6 +191,17 @@ export default function Sidebar({ ) : null} + {(navPages.length > 0) && ( + <> +
站点
+ + + )} + {isAdmin && ( <>
管理
diff --git a/frontend/src/components/SiteFooter.tsx b/frontend/src/components/SiteFooter.tsx index 3185be3..dc61b10 100644 --- a/frontend/src/components/SiteFooter.tsx +++ b/frontend/src/components/SiteFooter.tsx @@ -1,16 +1,20 @@ import { useSiteBranding } from '../hooks/useSiteBranding'; import { useMediaQuery } from '../hooks/useTheme'; -import type { FriendLink } from '../api/types'; +import { Link } from 'react-router-dom'; +import { useSitePages } from '../hooks/useSitePages'; +import { pagePath } from '../utils/permalink'; +import { useForumLimits } from '../hooks/useForumLimits'; function FooterSep() { return ·; } -/** 站点页脚:版权、Sitemap、友链、备案号 */ +/** 站点页脚:版权、Sitemap、备案号 */ export default function SiteFooter() { const { branding } = useSiteBranding(); + const { footerPages } = useSitePages(); + const { limits } = useForumLimits(); const year = new Date().getFullYear(); - const links = Array.isArray(branding.friend_links) ? branding.friend_links : []; const icp = branding.icp_beian?.trim() || ''; const icpURL = branding.icp_beian_url?.trim() || 'https://beian.miit.gov.cn/'; @@ -29,31 +33,30 @@ export default function SiteFooter() { )}
- {(links.length > 0 || icp) && ( - - )} +
); diff --git a/frontend/src/components/VirtualPostList.tsx b/frontend/src/components/VirtualPostList.tsx index 3a0305e..5d88436 100644 --- a/frontend/src/components/VirtualPostList.tsx +++ b/frontend/src/components/VirtualPostList.tsx @@ -4,10 +4,11 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import { Inbox, SearchX } from 'lucide-react'; import { Button } from '@/components/ui/button'; import PostListItem from './PostListItem'; -import PostListSkeleton from './PostListSkeleton'; +import PostListSkeleton, { feedListRowEstimate } from './PostListSkeleton'; import FeedPagination from './FeedPagination'; import { InFlowSiteFooter } from './SiteFooter'; import { useAuth } from '../hooks/useAuth'; +import { useForumLimits } from '../hooks/useForumLimits'; import { useMediaQuery } from '../hooks/useTheme'; import { loginPath } from '../utils/authRedirect'; import type { PostItem } from '../api/types'; @@ -67,7 +68,10 @@ export default function VirtualPostList({ }: Props) { const nav = useNavigate(); const { user } = useAuth(); + const { limits } = useForumLimits(); const isMobile = useMediaQuery('(max-width: 768px)'); + const feedStyle = limits.feed_list_style ?? 'title'; + const rowEstimate = feedListRowEstimate(feedStyle); const parentRef = useRef(null); const restoredRef = useRef(false); const onScrollTopChangeRef = useRef(onScrollTopChange); @@ -116,7 +120,7 @@ export default function VirtualPostList({ const virtualizer = useVirtualizer({ count: posts.length, getScrollElement, - estimateSize: () => 108, + estimateSize: () => rowEstimate, overscan: 8, scrollMargin: isMobile ? scrollMargin : 0, measureElement: @@ -214,7 +218,7 @@ export default function VirtualPostList({ return (
{isInitialLoad ? ( - + ) : isEmpty ? (
{isSearchEmpty diff --git a/frontend/src/components/admin/AdminSortableList.tsx b/frontend/src/components/admin/AdminSortableList.tsx new file mode 100644 index 0000000..f4b72a5 --- /dev/null +++ b/frontend/src/components/admin/AdminSortableList.tsx @@ -0,0 +1,215 @@ +import { + type CSSProperties, + type ElementType, + type HTMLAttributes, + type ReactNode, +} from 'react'; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core'; +import { + SortableContext, + rectSortingStrategy, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, + type SortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { ArrowDown, ArrowUp, GripVertical } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { reorderItems, shouldShowSortableMoveButtons } from '../../utils/sortOrder'; + +export type SortableItemControls = { + setNodeRef: (node: HTMLElement | null) => void; + style: CSSProperties; + isDragging: boolean; + dragHandleProps: HTMLAttributes; + moveUp: () => void; + moveDown: () => void; + canMoveUp: boolean; + canMoveDown: boolean; +}; + +type AdminSortableListProps = { + items: T[]; + getId: (item: T) => string | number; + onReorder: (items: T[]) => void; + renderItem: (item: T, index: number, controls: SortableItemControls) => ReactNode; + showMoveButtons?: boolean | 'auto'; + strategy?: 'vertical' | 'grid'; + as?: ElementType; + className?: string; + ariaLabel?: string; +}; + +function resolveStrategy(mode: 'vertical' | 'grid'): SortingStrategy { + return mode === 'grid' ? rectSortingStrategy : verticalListSortingStrategy; +} + +function SortableItem({ + item, + index, + items, + getId, + onReorder, + showMoveButtons, + renderItem, +}: { + item: T; + index: number; + items: T[]; + getId: (item: T) => string | number; + onReorder: (items: T[]) => void; + showMoveButtons: boolean; + renderItem: AdminSortableListProps['renderItem']; +}) { + const id = getId(item); + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }); + + const style: CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + }; + + const moveUp = () => { + if (index > 0) onReorder(reorderItems(items, index, index - 1)); + }; + const moveDown = () => { + if (index < items.length - 1) onReorder(reorderItems(items, index, index + 1)); + }; + + const controls: SortableItemControls = { + setNodeRef, + style, + isDragging, + dragHandleProps: { ...attributes, ...listeners }, + moveUp, + moveDown, + canMoveUp: index > 0, + canMoveDown: index < items.length - 1, + }; + + return ( + <> + {renderItem(item, index, controls)} + {showMoveButtons && ( + + {controls.canMoveUp ? '可上移' : ''}{controls.canMoveDown ? '可下移' : ''} + + )} + + ); +} + +export function SortableDragHandle({ + label = '拖拽调整顺序', + className = 'admin-sortable-row__handle', + ...props +}: HTMLAttributes & { label?: string }) { + return ( + + ); +} + +export function SortableMoveButtons({ + controls, + className = 'admin-sortable-row__order', +}: { + controls: Pick; + className?: string; +}) { + return ( +
+ + +
+ ); +} + +export default function AdminSortableList({ + items, + getId, + onReorder, + renderItem, + showMoveButtons = 'auto', + strategy = 'vertical', + as: Wrapper = 'div', + className, + ariaLabel, +}: AdminSortableListProps) { + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + const moveButtons = shouldShowSortableMoveButtons(items.length, showMoveButtons); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + const oldIndex = items.findIndex(item => getId(item) === active.id); + const newIndex = items.findIndex(item => getId(item) === over.id); + if (oldIndex < 0 || newIndex < 0) return; + onReorder(reorderItems(items, oldIndex, newIndex)); + }; + + return ( + + + + {items.map((item, index) => ( + + ))} + + + + ); +} diff --git a/frontend/src/components/admin/AsideWidgetList.tsx b/frontend/src/components/admin/AsideWidgetList.tsx new file mode 100644 index 0000000..c03f18f --- /dev/null +++ b/frontend/src/components/admin/AsideWidgetList.tsx @@ -0,0 +1,71 @@ +import AdminSortableList, { SortableDragHandle } from './AdminSortableList'; +import type { AsideWidget, AsideWidgetId } from '../../api/types'; + +const WIDGET_META: Record = { + tag_cloud: { + label: '标签云', + hint: '在右侧栏展示热门标签', + }, + recent_comments: { + label: '最新评论', + hint: '在右侧栏展示最近回复', + }, + friend_links: { + label: '友情链接', + hint: '关闭后不在右侧栏展示,友链仍可在「友情链接」页面查看与申请', + }, +}; + +type Props = { + widgets: AsideWidget[]; + onChange: (next: AsideWidget[]) => void; +}; + +export default function AsideWidgetList({ widgets, onChange }: Props) { + const handleToggle = (id: AsideWidgetId, enabled: boolean) => { + onChange(widgets.map(w => (w.id === id ? { ...w, enabled } : w))); + }; + + return ( + widget.id} + onReorder={onChange} + showMoveButtons={false} + className="admin-sortable-list admin-sortable-list--boxed" + ariaLabel="右侧栏组件" + renderItem={(widget, _index, controls) => { + const meta = WIDGET_META[widget.id]; + return ( +
+ +
+ + {meta.label} + + {meta.hint} +
+ +
+ ); + }} + /> + ); +} diff --git a/frontend/src/components/compose/ComposeContextBar.tsx b/frontend/src/components/compose/ComposeContextBar.tsx index fd1ee9d..8757b6a 100644 --- a/frontend/src/components/compose/ComposeContextBar.tsx +++ b/frontend/src/components/compose/ComposeContextBar.tsx @@ -3,7 +3,13 @@ import BoardIconDisplay from '../BoardIconDisplay'; import { getBoardThemeIndex } from '../../utils/boardTheme'; import type { Board, ForumLimitsPublic } from '../../api/types'; -export type PostType = 'normal' | 'question'; +export type PostType = 'normal' | 'question' | 'poll' | 'bounty' | 'lottery'; + +const SPECIAL_TYPE_LABELS: Record<'poll' | 'bounty' | 'lottery', string> = { + poll: '投票', + bounty: '悬赏', + lottery: '抽奖', +}; interface Props { isEdit: boolean; @@ -32,34 +38,93 @@ export default function ComposeContextBar({ onTagsChange, limits, }: Props) { + const isSpecialEdit = isEdit && (postType === 'poll' || postType === 'bounty' || postType === 'lottery'); + return (
类型
- - + {isSpecialEdit ? ( + + ) : ( + <> + + + {!isEdit && ( + <> + + + + + )} + + )}
{postType === 'question' && ( 问答可标记解决状态 )} + {postType === 'poll' && ( + + {isEdit ? '投票选项发布后不可修改' : '发布后选项不可修改'} + + )} + {postType === 'bounty' && ( + 发布成功即扣除积分;有人回复后不可自行取消,需采纳或联系管理员 + )} + {postType === 'lottery' && ( + 回帖参与,手动开奖 + )}
diff --git a/frontend/src/components/compose/ComposeSpecialFields.tsx b/frontend/src/components/compose/ComposeSpecialFields.tsx new file mode 100644 index 0000000..f114217 --- /dev/null +++ b/frontend/src/components/compose/ComposeSpecialFields.tsx @@ -0,0 +1,259 @@ +import { Plus, Trash2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import type { PostType } from './ComposeContextBar'; + +interface Props { + postType: PostType; + pollOptions: string[]; + onPollOptionsChange: (opts: string[]) => void; + pollMulti: boolean; + onPollMultiChange: (v: boolean) => void; + pollMaxChoices: number; + onPollMaxChoicesChange: (v: number) => void; + pollEndsAt: string; + onPollEndsAtChange: (v: string) => void; + pollNoEndTime: boolean; + onPollNoEndTimeChange: (v: boolean) => void; + bountyPoints: number; + onBountyPointsChange: (v: number) => void; + userPointsBalance?: number; + lotteryWinners: number; + onLotteryWinnersChange: (v: number) => void; + disabled?: boolean; +} + +/** 默认截止时间:7 天后,分钟进位到下一整点 */ +export function defaultPollEndsAtLocal(): string { + const d = new Date(Date.now() + 7 * 24 * 3600_000); + d.setMinutes(0, 0, 0); + if (d.getTime() <= Date.now()) { + d.setHours(d.getHours() + 1); + } + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +/** datetime-local → ISO8601(UTC) */ +export function pollEndsAtLocalToISO(local: string): string | undefined { + const trimmed = local.trim(); + if (!trimmed) return undefined; + const d = new Date(trimmed); + if (Number.isNaN(d.getTime())) return undefined; + return d.toISOString(); +} + +/** ISO8601 → datetime-local */ +export function pollEndsAtISOToLocal(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +/** 特殊帖类型附加字段(投票/悬赏/抽奖) */ +export default function ComposeSpecialFields({ + postType, + pollOptions, + onPollOptionsChange, + pollMulti, + onPollMultiChange, + pollMaxChoices, + onPollMaxChoicesChange, + pollEndsAt, + onPollEndsAtChange, + pollNoEndTime, + onPollNoEndTimeChange, + bountyPoints, + onBountyPointsChange, + userPointsBalance, + lotteryWinners, + onLotteryWinnersChange, + disabled, +}: Props) { + if (postType === 'normal' || postType === 'question') return null; + + if (postType === 'poll') { + return ( +
+ +
+ + + {pollMulti && ( + <> + 最多可选 + onPollMaxChoicesChange(Number(e.target.value) || 1)} + disabled={disabled} + className="compose-special__max-choices" + aria-label="最多可选" + /> + + + )} +
+
+
+ + +
+ {!pollNoEndTime && ( +
+ + onPollEndsAtChange(e.target.value)} + disabled={disabled} + className="compose-special__poll-deadline-input w-auto" + /> +
+ )} +
+
+ {pollOptions.map((opt, i) => ( +
+ { + const next = [...pollOptions]; + next[i] = e.target.value; + onPollOptionsChange(next); + }} + /> + +
+ ))} +
+ {pollOptions.length < 10 && ( + + )} +
+ ); + } + + if (postType === 'bounty') { + const showBalance = !disabled && typeof userPointsBalance === 'number'; + const balance = userPointsBalance ?? 0; + const overBudget = showBalance && bountyPoints > balance; + const remaining = showBalance && bountyPoints > 0 && !overBudget + ? balance - bountyPoints + : null; + + return ( +
+ +
+ onBountyPointsChange(Math.max(0, Number(e.target.value) || 0))} + /> + {showBalance && ( +

+ {overBudget + ? '积分不足' + : remaining != null + ? `当前余额 ${balance} · 发布后剩余 ${remaining}` + : `当前余额 ${balance}`} +

+ )} +
+
+ ); + } + + if (postType === 'lottery') { + return ( +
+ + onLotteryWinnersChange(Math.min(20, Math.max(1, Number(e.target.value) || 1)))} + /> +

参与者为已回帖用户(不含楼主),由作者或管理员开奖

+
+ ); + } + + return null; +} + +export function buildPollOptionsPayload( + options: string[], + multi: boolean, + maxChoices: number, + endsAtISO?: string | null, +): string { + const payload: Record = { + multi, + max_choices: maxChoices, + options: options.filter(o => o.trim()).map(text => ({ text: text.trim() })), + }; + if (endsAtISO) payload.ends_at = endsAtISO; + return JSON.stringify(payload); +} + +/** 统计有效(非空)投票选项数量 */ +export function countValidPollOptions(options: string[]): number { + return options.filter(o => o.trim()).length; +} + +/** 前端校验投票截止时间(非不限时时) */ +export function validatePollEndsAtLocal(local: string): string | null { + const iso = pollEndsAtLocalToISO(local); + if (!iso) return '请选择有效的投票截止时间'; + if (new Date(iso).getTime() <= Date.now() + 5 * 60_000) { + return '投票截止时间须晚于当前时间至少 5 分钟'; + } + if (new Date(iso).getTime() > Date.now() + 365 * 24 * 3600_000) { + return '投票截止时间不能超过 365 天'; + } + return null; +} diff --git a/frontend/src/hooks/useCheckIn.ts b/frontend/src/hooks/useCheckIn.ts new file mode 100644 index 0000000..5cd1e11 --- /dev/null +++ b/frontend/src/hooks/useCheckIn.ts @@ -0,0 +1,53 @@ +import { useCallback, useEffect, useState } from 'react'; +import { notify } from '@/lib/notify'; +import { api } from '../api/client'; +import type { CheckInStatus } from '../api/types'; +import { useAuth } from './useAuth'; + +/** 每日签到状态与操作(侧栏、积分页等复用) */ +export function useCheckIn(enabled = true) { + const { user, refresh } = useAuth(); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(false); + + const load = useCallback(() => { + if (!user || !enabled) { + setStatus(null); + setLoading(false); + return; + } + setLoading(true); + api.checkInStatus() + .then(d => setStatus(d.check_in)) + .catch(e => notify.error(e instanceof Error ? e.message : '加载签到状态失败')) + .finally(() => setLoading(false)); + }, [user, enabled]); + + useEffect(() => { + load(); + }, [load]); + + const doCheckIn = useCallback(async () => { + if (!user) return; + setBusy(true); + try { + const r = await api.checkIn(); + notify.success(`签到成功,+${r.check_in.today_points} 积分`); + setStatus(r.check_in); + await refresh(); + } catch (e: unknown) { + notify.error(e instanceof Error ? e.message : '签到失败'); + } finally { + setBusy(false); + } + }, [user, refresh]); + + return { + status, + loading, + busy, + doCheckIn, + reload: load, + }; +} diff --git a/frontend/src/hooks/useForumLimits.ts b/frontend/src/hooks/useForumLimits.ts index f08e62a..c3df62b 100644 --- a/frontend/src/hooks/useForumLimits.ts +++ b/frontend/src/hooks/useForumLimits.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { api } from '../api/client'; import type { ForumLimitsPublic } from '../api/types'; +import { DEFAULT_ASIDE_WIDGETS } from '../api/types'; const DEFAULT_LIMITS: ForumLimitsPublic = { post_title_max: 128, @@ -16,6 +17,11 @@ const DEFAULT_LIMITS: ForumLimitsPublic = { signature_max: 200, open_posts_in_new_tab: true, open_content_links_in_new_tab: true, + aside_show_tag_cloud: false, + aside_show_recent_comments: false, + aside_show_friend_links: true, + aside_widgets: DEFAULT_ASIDE_WIDGETS, + feed_list_style: 'title', permalink_enabled: false, permalink_ext: 'html', }; diff --git a/frontend/src/hooks/useSitePages.ts b/frontend/src/hooks/useSitePages.ts new file mode 100644 index 0000000..504b103 --- /dev/null +++ b/frontend/src/hooks/useSitePages.ts @@ -0,0 +1,41 @@ +import { useEffect, useState } from 'react'; +import { api } from '../api/client'; +import type { SitePageSummary } from '../api/types'; + +let cache: SitePageSummary[] | null = null; +let pending: Promise | null = null; + +/** 已发布单页摘要(页脚/侧栏导航) */ +export function useSitePages() { + const [pages, setPages] = useState(cache ?? []); + + useEffect(() => { + if (cache) { + setPages(cache); + return; + } + if (!pending) { + pending = api.pages() + .then(d => { + cache = d.pages ?? []; + return cache; + }) + .catch(() => { + cache = []; + return cache; + }) + .finally(() => { pending = null; }); + } + pending.then(setPages); + }, []); + + return { + pages, + footerPages: pages.filter(p => p.show_in_footer), + navPages: pages.filter(p => p.show_in_nav), + }; +} + +export function invalidateSitePagesCache() { + cache = null; +} diff --git a/frontend/src/layouts/AdminLayout.tsx b/frontend/src/layouts/AdminLayout.tsx index 704e3bf..cf7f79f 100644 --- a/frontend/src/layouts/AdminLayout.tsx +++ b/frontend/src/layouts/AdminLayout.tsx @@ -1,7 +1,7 @@ import { useEffect, useState, useCallback, useRef } from 'react'; import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'; import { - LayoutDashboard, FolderKanban, FileText, MessageSquare, Flag, Users, Images, Settings, ArrowLeft, Moon, Sun, Menu, X, Award, + LayoutDashboard, FolderKanban, FileText, MessageSquare, Flag, Users, Images, Settings, ArrowLeft, Moon, Sun, Menu, X, Award, Link2, BookOpen, } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; import { useAuth } from '../hooks/useAuth'; @@ -16,7 +16,7 @@ import { useNoIndexSEO } from '../hooks/usePageSEO'; import SiteBrandMark from '../components/SiteBrandMark'; import { api } from '../api/client'; -type BadgeKey = 'posts' | 'comments' | 'reports'; +type BadgeKey = 'posts' | 'comments' | 'reports' | 'links'; type NavItem = { to: string; @@ -50,8 +50,10 @@ const NAV_GROUPS: NavGroup[] = [ label: '社区', items: [ { to: '/admin/boards', label: '板块管理', icon: FolderKanban }, + { to: '/admin/pages', label: '单页管理', icon: BookOpen }, { to: '/admin/users', label: '用户管理', icon: Users }, { to: '/admin/badges', label: '徽章管理', icon: Award }, + { to: '/admin/links', label: '友情链接', icon: Link2, badgeKey: 'links' }, ], }, { @@ -67,6 +69,7 @@ type PendingCounts = { posts: number; comments: number; reports: number; + links: number; }; function formatNavBadge(n: number) { @@ -82,7 +85,7 @@ export default function AdminLayout() { useNoIndexSEO('管理后台'); const isNarrow = useMediaQuery('(max-width: 768px)'); const [navOpen, setNavOpen] = useState(false); - const [pending, setPending] = useState({ posts: 0, comments: 0, reports: 0 }); + const [pending, setPending] = useState({ posts: 0, comments: 0, reports: 0, links: 0 }); const nav = useNavigate(); const location = useLocation(); const drawerRef = useRef(null); @@ -99,10 +102,23 @@ export default function AdminLayout() { posts: d.pending_posts ?? 0, comments: d.pending_comments ?? 0, reports: d.pending_reports ?? 0, + links: d.pending_friend_links ?? 0, })) .catch(() => {}); }, []); + useEffect(() => { + if (loading || !user || user.role !== 'admin') return; + refreshPending(); + const onRefresh = () => refreshPending(); + window.addEventListener('admin-pending-refresh', onRefresh); + const timer = window.setInterval(refreshPending, 60_000); + return () => { + window.removeEventListener('admin-pending-refresh', onRefresh); + window.clearInterval(timer); + }; + }, [loading, user, refreshPending]); + useEffect(() => { if (loading) return; if (!user) { diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index a8c881a..a9de33a 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -14,13 +14,14 @@ import { useAuth } from '../hooks/useAuth'; import { useTheme, useMediaQuery } from '../hooks/useTheme'; import { useOverlayA11y, moveTabIndex } from '../hooks/useOverlayA11y'; import { api } from '../api/client'; -import type { Board, PostItem, RecentComment, ForumStats, TagCount, User } from '../api/types'; +import type { Board, RecentComment, ForumStats, TagCount, User } from '../api/types'; import type { PostHeading } from '../utils/postHeadings'; -import { getCachedBoards, getCachedStats, getCachedHot, getCachedRecentComments, getCachedTags, hasCachedAside, setCachedBoards, setCachedStats, setCachedHot, setCachedRecentComments, setCachedTags } from '../utils/layoutCache'; +import { getCachedBoards, getCachedStats, getCachedRecentComments, getCachedTags, hasCachedAside, setCachedBoards, setCachedStats, setCachedRecentComments, setCachedTags } from '../utils/layoutCache'; import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar'; import RightPanel from '../components/RightPanel'; import BackToTop from '../components/BackToTop'; import { useForumLimits } from '../hooks/useForumLimits'; +import { resolveAsideWidgets } from '../utils/asideWidgets'; import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar'; import { navigateFeed } from '../utils/feedCache'; import { notify } from '@/lib/notify'; @@ -32,6 +33,7 @@ import { useSiteBranding } from '../hooks/useSiteBranding'; import SiteBrandMark from '../components/SiteBrandMark'; import SiteFooter from '../components/SiteFooter'; import { userPath } from '../utils/userPath'; +import { parsePermalinkID } from '../utils/permalink'; export default function MainLayout() { const { user, loading: authLoading, logout } = useAuth(); @@ -46,7 +48,6 @@ export default function MainLayout() { const [boards, setBoards] = useState(() => getCachedBoards()); const [stats, setStats] = useState(() => getCachedStats()); - const [hot, setHot] = useState(() => getCachedHot()); const [recentComments, setRecentComments] = useState(() => getCachedRecentComments()); const [unreadMessages, setUnreadMessages] = useState(0); const [tags, setTags] = useState(() => getCachedTags()); @@ -66,7 +67,11 @@ export default function MainLayout() { const [asideLoading, setAsideLoading] = useState(() => !hasCachedAside()); const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0); const asideEverLoaded = useRef(false); - const [boardId, setBoardId] = useState(Number(params.get('board')) || 0); + const [boardId, setBoardId] = useState(() => { + const m = loc.pathname.match(/^\/board\/(\d+(?:\.[A-Za-z0-9]{1,16})?)$/); + if (m) return parsePermalinkID(m[1]) || 0; + return Number(params.get('board')) || 0; + }); const [keyword, setKeyword] = useState(params.get('keyword') || ''); const [searchAuthor, setSearchAuthor] = useState(params.get('author') || ''); const [searchTitleOnly, setSearchTitleOnly] = useState(params.get('title_only') === '1'); @@ -74,6 +79,9 @@ export default function MainLayout() { const [searchAdvanced, setSearchAdvanced] = useState(false); const feedSort = parseFeedSort(params.get('sort')); const { limits: forumLimits } = useForumLimits(); + const asideWidgets = useMemo(() => resolveAsideWidgets(forumLimits), [forumLimits]); + const showTagCloud = asideWidgets.some(w => w.id === 'tag_cloud' && w.enabled); + const showRecentComments = asideWidgets.some(w => w.id === 'recent_comments' && w.enabled); const asideDrawerRef = useRef(null); const asideCloseRef = useRef(null); @@ -99,7 +107,14 @@ export default function MainLayout() { initialFocusRef: sidebarCloseRef, }); - useEffect(() => { setBoardId(Number(params.get('board')) || 0); }, [params]); + useEffect(() => { + const m = loc.pathname.match(/^\/board\/(\d+(?:\.[A-Za-z0-9]{1,16})?)$/); + if (m) { + setBoardId(parsePermalinkID(m[1]) || 0); + return; + } + setBoardId(Number(params.get('board')) || 0); + }, [loc.pathname, params]); useEffect(() => { setKeyword(params.get('keyword') || ''); setSearchAuthor(params.get('author') || ''); @@ -183,7 +198,7 @@ export default function MainLayout() { // 标签云:进页/离开发帖页时拉取;不跟 posts-refresh 联动(置顶/精华等不改标签) useEffect(() => { - if (isCompose) return; + if (isCompose || !showTagCloud) return; let cancelled = false; if (getCachedTags().length === 0) setTagsLoading(true); api.tags(40).then(d => { @@ -197,31 +212,24 @@ export default function MainLayout() { return () => { cancelled = true; }; - }, [isCompose]); + }, [isCompose, showTagCloud]); const needAsideData = !isCompose && (!hideAside || asideOpen); + const needRecentComments = needAsideData && showRecentComments; useEffect(() => { - if (!needAsideData) return; + if (!needRecentComments) return; let cancelled = false; // 无缓存时才显示加载态,有缓存则静默刷新,避免抽屉高度跳动 if (!asideEverLoaded.current && !hasCachedAside()) { setAsideLoading(true); } - Promise.all([ - api.hotPosts().then(d => { - if (cancelled) return; - const next = Array.isArray(d.posts) ? d.posts : []; - setHot(next); - setCachedHot(next); - }).catch(() => {}), - api.recentComments().then(d => { - if (cancelled) return; - const next = Array.isArray(d.comments) ? d.comments : []; - setRecentComments(next); - setCachedRecentComments(next); - }).catch(() => {}), - ]).finally(() => { + api.recentComments().then(d => { + if (cancelled) return; + const next = Array.isArray(d.comments) ? d.comments : []; + setRecentComments(next); + setCachedRecentComments(next); + }).catch(() => {}).finally(() => { if (!cancelled) { asideEverLoaded.current = true; setAsideLoading(false); @@ -231,7 +239,7 @@ export default function MainLayout() { return () => { cancelled = true; }; - }, [needAsideData]); + }, [needRecentComments]); const doSearch = () => { const kw = keyword.trim(); @@ -239,7 +247,7 @@ export default function MainLayout() { const activeKw = (params.get('keyword') || '').trim(); const activeAuthor = (params.get('author') || '').trim(); const activeTitleOnly = params.get('title_only') === '1'; - const activeBoard = Number(params.get('board')) || 0; + const activeBoard = boardId; if (!kw && !author) { if (activeKw || activeAuthor) navigateFeed(nav, '/'); return; @@ -260,9 +268,10 @@ export default function MainLayout() { keyword: kw, author, titleOnly: !!kw && searchTitleOnly, + permalink: forumLimits, }); const same = - loc.pathname === '/' + (loc.pathname === '/' || /^\/board\/\d+/.test(loc.pathname)) && activeKw === kw && activeAuthor === author && activeTitleOnly === (!!kw && searchTitleOnly) @@ -280,7 +289,7 @@ export default function MainLayout() { }, [nav, forumLimits.open_posts_in_new_tab]); const userInitial = user?.nickname?.charAt(0) || '?'; - const isFeedHome = loc.pathname === '/'; + const isFeedHome = loc.pathname === '/' || /^\/board\/\d+/.test(loc.pathname); const outletKeyword = params.get('keyword') || ''; const outletTag = params.get('tag') || ''; const outletAuthor = params.get('author') || ''; @@ -318,7 +327,7 @@ export default function MainLayout() { const selectBoardChip = (id: number) => { setBoardId(id); - navigateFeed(nav, buildHomeUrl(id, feedSort)); + navigateFeed(nav, buildHomeUrl(id, feedSort, { permalink: forumLimits })); }; const onBoardBarKeyDown = (e: React.KeyboardEvent) => { @@ -632,11 +641,12 @@ export default function MainLayout() { {!isCompose && (
+ { if (!open && !bountyAwarding) setBountyAwardTarget(null); }} + > + + + 采纳该回复? + + 确定采纳该回复并发放 {post.bounty_points ?? 0} 悬赏积分?此操作不可撤销。 + + + + 取消 + { + e.preventDefault(); + void confirmBountyAward(); + }} + > + {bountyAwarding ? '发放中…' : '确认采纳'} + + + + ); diff --git a/frontend/src/pages/SitePageView.tsx b/frontend/src/pages/SitePageView.tsx new file mode 100644 index 0000000..cd4eb87 --- /dev/null +++ b/frontend/src/pages/SitePageView.tsx @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import NotFoundPage from './NotFoundPage'; +import { api } from '../api/client'; +import type { SitePage } from '../api/types'; +import PostContent from '../components/PostContent'; +import PageLoader from '../components/PageLoader'; +import { usePageSEO } from '../hooks/usePageSEO'; +import { parsePermalinkSlug, pagePath } from '../utils/permalink'; +import { useForumLimits } from '../hooks/useForumLimits'; + +/** 自定义单页(关于我们、版规等) */ +export default function SitePageView() { + const { slug: rawSlug } = useParams(); + const slug = parsePermalinkSlug(rawSlug); + const { limits } = useForumLimits(); + const [page, setPage] = useState(null); + const [loading, setLoading] = useState(true); + const [notFound, setNotFound] = useState(false); + + useEffect(() => { + if (!slug) { + setNotFound(true); + setLoading(false); + return; + } + setLoading(true); + api.page(slug) + .then(d => setPage(d.page)) + .catch(() => setNotFound(true)) + .finally(() => setLoading(false)); + }, [slug]); + + usePageSEO({ + title: page?.title, + description: page?.title, + canonicalPath: slug ? pagePath(slug, limits) : undefined, + ogType: 'article', + }); + + if (!slug) return ; + if (loading) return ; + if (notFound || !page) return ; + + return ( +
+
+
+

{page.title}

+
+ +
+
+ ); +} diff --git a/frontend/src/pages/admin/AdminBadgesPage.tsx b/frontend/src/pages/admin/AdminBadgesPage.tsx index 70522bf..1fcb41a 100644 --- a/frontend/src/pages/admin/AdminBadgesPage.tsx +++ b/frontend/src/pages/admin/AdminBadgesPage.tsx @@ -27,6 +27,8 @@ import { badgeIcon, formatBadgeCondition, } from '../../utils/badgeIcons'; +import AdminSortableList, { SortableDragHandle, SortableMoveButtons } from '../../components/admin/AdminSortableList'; +import { mergeReorderedSubset, persistSortOrderChanges, shouldShowSortableMoveButtons } from '../../utils/sortOrder'; type KindTab = 'all' | 'auto' | 'limited'; @@ -62,6 +64,7 @@ export default function AdminBadgesPage() { const [form, setForm] = useState>({ ...EMPTY }); const [saving, setSaving] = useState(false); const [togglingId, setTogglingId] = useState(null); + const [reordering, setReordering] = useState(false); const load = () => { setLoading(true); @@ -161,6 +164,27 @@ export default function AdminBadgesPage() { } }; + const handleBadgeReorder = async (reorderedFiltered: BadgeDef[]) => { + const subsetBefore = [...filtered]; + const before = [...rows]; + setReordering(true); + try { + const merged = mergeReorderedSubset(before, subsetBefore, reorderedFiltered); + const after = await persistSortOrderChanges(before, merged, badge => + api.adminUpsertBadge({ ...badge, sort_order: badge.sort_order }), + ); + setRows(after); + notify.success('徽章排序已更新'); + } catch (e: unknown) { + setRows(before); + notify.error(e instanceof Error ? e.message : '排序保存失败'); + } finally { + setReordering(false); + } + }; + + const showMoveButtons = shouldShowSortableMoveButtons(filtered.length); + if (!ready) return null; const PreviewIcon = badgeIcon(form.icon); @@ -249,14 +273,30 @@ export default function AdminBadgesPage() { )}
) : ( -
- {filtered.map(b => { + b.id} + onReorder={handleBadgeReorder} + showMoveButtons="auto" + ariaLabel="徽章列表排序" + renderItem={(b, _index, controls) => { const Icon = badgeIcon(b.icon); return (
+
+ +
@@ -283,15 +323,16 @@ export default function AdminBadgesPage() { {formatBadgeCondition(b)}
+ {showMoveButtons && } - @@ -299,8 +340,8 @@ export default function AdminBadgesPage() {
); - })} -
+ }} + /> )} { diff --git a/frontend/src/pages/admin/AdminDashboardPage.tsx b/frontend/src/pages/admin/AdminDashboardPage.tsx index 5fb66f0..aa49fff 100644 --- a/frontend/src/pages/admin/AdminDashboardPage.tsx +++ b/frontend/src/pages/admin/AdminDashboardPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { FileText, Flag, MessageSquare } from 'lucide-react'; +import { FileText, Flag, MessageSquare, Link2 } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; import { Badge } from '@/components/ui/badge'; import { api } from '../../api/client'; @@ -29,7 +29,8 @@ export default function AdminDashboardPage() { const pendingPosts = data.pending_posts ?? 0; const pendingComments = data.pending_comments ?? 0; const pendingReports = data.pending_reports ?? 0; - const pendingTotal = pendingPosts + pendingComments + pendingReports; + const pendingFriendLinks = data.pending_friend_links ?? 0; + const pendingTotal = pendingPosts + pendingComments + pendingReports + pendingFriendLinks; const stats = [ { label: '注册用户', value: data.users }, @@ -63,6 +64,14 @@ export default function AdminDashboardPage() { to: '/admin/reports', icon: Flag, }, + { + key: 'links', + label: '待审友链', + count: pendingFriendLinks, + hint: '用户提交的友情链接申请', + to: '/admin/links', + icon: Link2, + }, ]; return ( diff --git a/frontend/src/pages/admin/AdminLinksPage.tsx b/frontend/src/pages/admin/AdminLinksPage.tsx new file mode 100644 index 0000000..5bf47ef --- /dev/null +++ b/frontend/src/pages/admin/AdminLinksPage.tsx @@ -0,0 +1,712 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { + Link2, Plus, Trash2, Check, X, ExternalLink, Eye, RotateCcw, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Spinner } from '@/components/ui/spinner'; +import { Badge } from '@/components/ui/badge'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Textarea } from '@/components/ui/textarea'; +import { notify } from '@/lib/notify'; +import { cn } from '@/lib/utils'; +import { api } from '../../api/client'; +import { useAdminGuard } from '../../layouts/AdminLayout'; +import type { FriendLink, FriendLinkApply, SiteBranding } from '../../api/types'; +import { useSiteBranding, seedSiteBrandingCache, invalidateSiteBrandingCache } from '../../hooks/useSiteBranding'; +import { formatTime } from '../../utils/content'; +import { resolveFriendLinkLogo, isReciprocalChecking, reciprocalStatusLabel } from '../../utils/friendLink'; +import AdminSortableList, { SortableDragHandle, SortableMoveButtons } from '../../components/admin/AdminSortableList'; +import { shouldShowSortableMoveButtons } from '../../utils/sortOrder'; + +type ApplyStatusTab = 'pending' | 'approved' | 'rejected' | 'all'; + +type LinkRow = FriendLink & { _key: string }; + +const APPLY_PAGE_SIZE = 20; + +function applyStatusBadge(status: FriendLinkApply['status']) { + switch (status) { + case 'pending': + return 待审核; + case 'approved': + return 已通过; + case 'rejected': + return 已拒绝; + default: + return null; + } +} + +function linkInitial(name: string): string { + const t = name.trim(); + return t ? t.charAt(0).toUpperCase() : '?'; +} + +function normalizeLinks(list: FriendLink[]): FriendLink[] { + return list + .map(l => ({ + name: l.name.trim(), + url: l.url.trim(), + logo: l.logo?.trim() || '', + })) + .filter(l => l.name && l.url); +} + +function linksEqual(a: FriendLink[], b: FriendLink[]): boolean { + return JSON.stringify(normalizeLinks(a)) === JSON.stringify(normalizeLinks(b)); +} + +function toLinkRows(list: FriendLink[]): LinkRow[] { + return list.map((l, i) => ({ + ...l, + _key: `${l.url}-${l.name}-${i}`, + })); +} + +function ApplyLogo({ apply, className, siteURL }: { apply: FriendLinkApply; className?: string; siteURL?: string }) { + const logo = resolveFriendLinkLogo(apply.logo, siteURL); + if (logo) { + return ( +
+ +
+ ); + } + return ( +
+ {linkInitial(apply.name)} +
+ ); +} + +function ReciprocalAddress({ apply }: { apply: FriendLinkApply }) { + const href = apply.reciprocal_page_url?.trim(); + if (!href) { + return 未填写; + } + return ( + <> + {href} + {apply.link_on_homepage ? '(首页)' : ''} + + ); +} + +/** 后台:友情链接独立管理 */ +export default function AdminLinksPage() { + const nav = useNavigate(); + const { ready } = useAdminGuard(); + const { branding } = useSiteBranding(); + const rowKeyRef = useRef(0); + + const [links, setLinks] = useState([]); + const [saving, setSaving] = useState(false); + + const [applyStatus, setApplyStatus] = useState('pending'); + const [applies, setApplies] = useState([]); + const [applyPage, setApplyPage] = useState(1); + const [applyTotal, setApplyTotal] = useState(0); + const [pendingCount, setPendingCount] = useState(0); + const [appliesLoading, setAppliesLoading] = useState(true); + + const [detailApply, setDetailApply] = useState(null); + const [rejectTarget, setRejectTarget] = useState(null); + const [rejectNote, setRejectNote] = useState(''); + const [rejecting, setRejecting] = useState(false); + const [handlingId, setHandlingId] = useState(null); + const [reciprocalCheckEnabled, setReciprocalCheckEnabled] = useState(false); + const [reciprocalCheckSaving, setReciprocalCheckSaving] = useState(false); + + const baselineLinks = branding.friend_links ?? []; + const siteURL = branding.site_url; + const isDirty = useMemo( + () => !linksEqual(links, baselineLinks), + [links, baselineLinks], + ); + + useEffect(() => { + setLinks(toLinkRows(baselineLinks)); + }, [baselineLinks]); + + const loadApplies = useCallback(async (status: ApplyStatusTab = applyStatus, page = 1) => { + setAppliesLoading(true); + try { + const r = await api.adminFriendLinkApplies({ status, page, size: APPLY_PAGE_SIZE }); + setApplies(r.applies ?? []); + setApplyTotal(r.total ?? 0); + setApplyPage(r.page ?? page); + setPendingCount(r.pending_count ?? 0); + setReciprocalCheckEnabled(!!r.reciprocal_check_enabled); + } catch (e: unknown) { + notify.error(e instanceof Error ? e.message : '加载失败'); + } finally { + setAppliesLoading(false); + } + }, [applyStatus]); + + useEffect(() => { + if (ready) loadApplies(applyStatus, 1); + }, [ready, applyStatus, loadApplies]); + + useEffect(() => { + if (!ready || !reciprocalCheckEnabled || !applies.some(isReciprocalChecking)) return; + const timer = window.setInterval(() => loadApplies(applyStatus, applyPage), 3000); + return () => window.clearInterval(timer); + }, [ready, applies, applyStatus, applyPage, loadApplies, reciprocalCheckEnabled]); + + useEffect(() => { + if (!detailApply) return; + const updated = applies.find(a => a.id === detailApply.id); + if (updated) setDetailApply(updated); + }, [applies, detailApply]); + + const applyTabs: { key: ApplyStatusTab; label: string }[] = [ + { key: 'pending', label: `待审${pendingCount ? ` (${pendingCount})` : ''}` }, + { key: 'approved', label: '已通过' }, + { key: 'rejected', label: '已拒绝' }, + { key: 'all', label: '全部' }, + ]; + + const applyTotalPages = Math.max(1, Math.ceil(applyTotal / APPLY_PAGE_SIZE)); + const showMoveButtons = shouldShowSortableMoveButtons(links.length); + + const addLinkRow = () => { + rowKeyRef.current += 1; + setLinks(prev => [...prev, { name: '', url: '', logo: '', _key: `new-${rowKeyRef.current}` }]); + }; + + const resetLinks = () => { + setLinks(toLinkRows(baselineLinks)); + notify.success('已恢复为已保存的版本'); + }; + + const save = async () => { + const cleaned = normalizeLinks(links); + const urls = new Set(); + for (const l of cleaned) { + if (!/^https?:\/\//i.test(l.url)) { + notify.warning('友情链接需完整 http/https URL'); + return; + } + const key = l.url.toLowerCase(); + if (urls.has(key)) { + notify.warning(`存在重复 URL:${l.url}`); + return; + } + urls.add(key); + } + setSaving(true); + try { + const payload: SiteBranding = { ...branding, friend_links: cleaned }; + const r = await api.adminUpdateBranding(payload); + seedSiteBrandingCache({ ...branding, ...r.branding, friend_links: cleaned }); + setLinks(toLinkRows(cleaned)); + notify.success('友情链接已保存'); + } catch (e: unknown) { + notify.error(e instanceof Error ? e.message : '保存失败'); + } finally { + setSaving(false); + } + }; + + const approveApply = async (apply: FriendLinkApply) => { + setHandlingId(apply.id); + try { + const r = await api.adminApproveFriendLinkApply(apply.id); + invalidateSiteBrandingCache(); + notify.success(r.message); + setDetailApply(null); + loadApplies(applyStatus, applyPage); + window.dispatchEvent(new Event('admin-pending-refresh')); + } catch (e: unknown) { + notify.error(e instanceof Error ? e.message : '操作失败'); + } finally { + setHandlingId(null); + } + }; + + const openReject = (apply: FriendLinkApply) => { + setDetailApply(null); + setRejectTarget(apply); + setRejectNote(''); + }; + + const recheckReciprocal = async (apply: FriendLinkApply) => { + setHandlingId(apply.id); + try { + const r = await api.adminRecheckFriendLinkApply(apply.id); + notify.success(r.message); + setDetailApply(r.apply); + loadApplies(applyStatus, applyPage); + } catch (e: unknown) { + notify.error(e instanceof Error ? e.message : '操作失败'); + } finally { + setHandlingId(null); + } + }; + + const toggleReciprocalCheck = async () => { + if (reciprocalCheckSaving) return; + const next = !reciprocalCheckEnabled; + setReciprocalCheckEnabled(next); + setReciprocalCheckSaving(true); + try { + const r = await api.adminUpdateFriendLinkSettings({ reciprocal_check_enabled: next }); + setReciprocalCheckEnabled(r.reciprocal_check_enabled); + notify.success(r.message); + } catch (e: unknown) { + setReciprocalCheckEnabled(!next); + notify.error(e instanceof Error ? e.message : '保存失败'); + } finally { + setReciprocalCheckSaving(false); + } + }; + + const submitReject = async () => { + if (!rejectTarget) return; + setRejecting(true); + try { + const r = await api.adminRejectFriendLinkApply(rejectTarget.id, { note: rejectNote.trim() }); + notify.success(r.message); + setRejectTarget(null); + setRejectNote(''); + loadApplies(applyStatus, applyPage); + window.dispatchEvent(new Event('admin-pending-refresh')); + } catch (e: unknown) { + notify.error(e instanceof Error ? e.message : '操作失败'); + } finally { + setRejecting(false); + } + }; + + const renderApplicant = (apply: FriendLinkApply) => { + const label = apply.user?.nickname || apply.user?.username || `用户 #${apply.user_id}`; + if (apply.user_id) { + return ( + + ); + } + return label; + }; + + const renderPendingCard = (apply: FriendLinkApply) => { + const reciprocal = reciprocalStatusLabel(apply); + return ( +
+ +
+
+ {apply.name} + {apply.url} +
+

+ 回链地址: + +

+

+ {reciprocal.text} + {' · '} + {renderApplicant(apply)} + {' · '} + {formatTime(apply.created_at)} +

+
+
+ +
+
+ ); + }; + + const renderApplyTable = () => ( +
+ + + + + + + + + + + {applyStatus === 'rejected' || applyStatus === 'all' ? : null} + + + + {applies.map(apply => { + const reciprocal = reciprocalStatusLabel(apply); + return ( + + + + + + + + + {(applyStatus === 'rejected' || applyStatus === 'all') && ( + + )} + + ); + })} + +
ID站点申请人回链地址回链检测状态时间备注
{apply.id} +
+ +
+ {apply.name} + {apply.url} +
+
+
{renderApplicant(apply)}{reciprocal.text}{applyStatusBadge(apply.status)}{formatTime(apply.created_at)} + {apply.review_note?.trim() || '—'} +
+
+ ); + + if (!ready) return null; + + return ( +
+
+
+

友情链接

+

管理已发布友链(最多 20 条)并审核用户申请;通过后将展示在友情链接页面

+
+
+ + +
+
+ +
+
+
+
+ 申请审核 + {pendingCount > 0 && {pendingCount}} +
+
+ 回链检测 + +
+
+

+ {reciprocalCheckEnabled + ? '回链检测结果仅供参考,未检测到回链仍可手动通过。' + : '回链检测已关闭,申请仍可手动审核通过。'} +

+
+
+ {applyTabs.map(tab => ( + + ))} +
+ +
+ {appliesLoading ? ( +
+ ) : applies.length === 0 ? ( +

+ {applyStatus === 'pending' ? '暂无待审申请' : '暂无记录'} +

+ ) : applyStatus === 'pending' ? ( +
+ {applies.map(renderPendingCard)} +
+ ) : ( + renderApplyTable() + )} +
+ + {!appliesLoading && applyTotal > APPLY_PAGE_SIZE && ( +
+ + {applyPage} / {applyTotalPages} + +
+ )} +
+
+ +
+
+ 已发布友链 + {links.filter(l => l.name.trim() && l.url.trim()).length}/20 +
+

+ 保存后立即生效;右侧栏展示可在 + 系统设置 → 右侧栏组件 + 中配置。 +

+
+
+ {links.length > 0 && ( +
+ 排序 + 图标 + 名称 + 链接 + LOGO 地址 + +
+ )} + link._key} + onReorder={setLinks} + showMoveButtons="auto" + className="admin-sortable-list admin-links-table-body" + ariaLabel="已发布友链" + renderItem={(link, idx, controls) => ( +
+
+ + {showMoveButtons && ( + + )} +
+
+ {resolveFriendLinkLogo(link.logo, siteURL) ? ( + + ) : ( + {linkInitial(link.name)} + )} +
+
+ + { + const next = [...links]; + next[idx] = { ...next[idx], name: e.target.value }; + setLinks(next); + }} + /> +
+
+ + { + const next = [...links]; + next[idx] = { ...next[idx], url: e.target.value }; + setLinks(next); + }} + /> +
+
+ + { + const next = [...links]; + next[idx] = { ...next[idx], logo: e.target.value }; + setLinks(next); + }} + /> +
+
+ +
+
+ )} + /> + {links.length === 0 &&

暂无友情链接,可手动添加或审核通过用户申请

} +
+
+ + +
+
+
+
+ + { if (!open) setDetailApply(null); }}> + + + 友链申请详情 + + 确认信息无误后再通过;通过后将写入已发布友链列表。 + + + {detailApply && ( +
+
+ +
+ {detailApply.name} + {detailApply.url} +
+
+
+
申请人
{renderApplicant(detailApply)}
+
提交时间
{formatTime(detailApply.created_at)}
+
回链地址
+ +
+
回链检测
+ + {reciprocalStatusLabel(detailApply).text} + +
+ {detailApply.logo?.trim() && ( +
LOGO
{detailApply.logo}
+ )} +
+
+ )} + + + {detailApply && ( + <> + + + + + )} + +
+
+ + { if (!open) setRejectTarget(null); }}> + + + 拒绝友链申请 + + {rejectTarget ? `拒绝「${rejectTarget.name}」的申请,可选填原因通知申请人。` : ''} + + +