From 6b0a1d428141863d680a96cc07237cc00500b296 Mon Sep 17 00:00:00 2001 From: freefire Date: Thu, 6 Aug 2026 02:42:18 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=94=A8=E6=88=B7=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E3=80=81=E7=AD=89=E7=BA=A7=E3=80=81=E5=BE=BD=E7=AB=A0?= =?UTF-8?q?=E4=B8=8E=E7=A7=AF=E5=88=86=E4=BD=93=E7=B3=BB=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=AE=A1=E7=90=86=E5=90=8E=E5=8F=B0=E4=BD=93?= =?UTF-8?q?=E9=AA=8C=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖站长调账与积分解锁内容;后台按审核优先分组导航,仪表盘展示待办,用户管理改为成员目录式布局。 Co-authored-by: Cursor --- frontend/src/App.tsx | 2 + frontend/src/api/client.ts | 56 +- frontend/src/api/types.ts | 63 ++ frontend/src/components/ArticleEditor.tsx | 31 +- frontend/src/components/CommentThreadList.tsx | 1 + frontend/src/components/PointsWalletPanel.tsx | 138 +++ frontend/src/components/PostAuthorCard.tsx | 4 +- frontend/src/components/PostContent.tsx | 35 +- frontend/src/components/PostListItem.tsx | 2 +- frontend/src/components/UserBadges.tsx | 71 ++ frontend/src/components/UserLink.tsx | 10 + .../components/editor/PointsOnlyExtension.tsx | 215 ++++ frontend/src/layouts/AdminLayout.tsx | 130 ++- frontend/src/layouts/MainLayout.tsx | 4 +- frontend/src/pages/ComposePage.tsx | 3 +- frontend/src/pages/PostDetailPage.tsx | 4 +- frontend/src/pages/ProfilePage.tsx | 8 +- frontend/src/pages/UserProfilePage.tsx | 10 +- frontend/src/pages/admin/AdminBadgesPage.tsx | 483 +++++++++ .../src/pages/admin/AdminDashboardPage.tsx | 97 +- frontend/src/pages/admin/AdminReportsPage.tsx | 6 +- frontend/src/pages/admin/AdminUsersPage.tsx | 598 +++++++++-- frontend/src/styles/global.css | 952 +++++++++++++++++- frontend/src/utils/badgeIcons.ts | 64 ++ frontend/src/utils/postContent.ts | 48 +- frontend/src/utils/userMeta.ts | 20 + handler/api.go | 52 +- handler/economy.go | 208 ++++ handler/handlers.go | 42 +- middleware/auth.go | 2 + model/db.go | 43 + model/level.go | 34 + model/models.go | 142 ++- model/user_view.go | 175 ++-- router/router.go | 14 +- service/auth.go | 30 +- service/badge.go | 272 +++++ service/comment.go | 22 +- service/content.go | 4 +- service/points.go | 272 +++++ service/post.go | 38 +- service/sanitize_html.go | 6 +- service/unlock.go | 248 +++++ service/user.go | 50 +- 44 files changed, 4455 insertions(+), 254 deletions(-) create mode 100644 frontend/src/components/PointsWalletPanel.tsx create mode 100644 frontend/src/components/UserBadges.tsx create mode 100644 frontend/src/components/editor/PointsOnlyExtension.tsx create mode 100644 frontend/src/pages/admin/AdminBadgesPage.tsx create mode 100644 frontend/src/utils/badgeIcons.ts create mode 100644 frontend/src/utils/userMeta.ts create mode 100644 handler/economy.go create mode 100644 model/level.go create mode 100644 service/badge.go create mode 100644 service/points.go create mode 100644 service/unlock.go diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 48dfd68..4236363 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -35,6 +35,7 @@ const AdminPostsPage = lazyWithRetry(() => import('./pages/admin/AdminPostsPage' const AdminCommentsPage = lazyWithRetry(() => import('./pages/admin/AdminCommentsPage')); const AdminReportsPage = lazyWithRetry(() => import('./pages/admin/AdminReportsPage')); const AdminUsersPage = lazyWithRetry(() => import('./pages/admin/AdminUsersPage')); +const AdminBadgesPage = lazyWithRetry(() => import('./pages/admin/AdminBadgesPage')); const AdminMediaPage = lazyWithRetry(() => import('./pages/admin/AdminMediaPage')); const AdminSettingsPage = lazyWithRetry(() => import('./pages/admin/AdminSettingsPage')); const NotFoundPage = lazyWithRetry(() => import('./pages/NotFoundPage')); @@ -53,6 +54,7 @@ const router = createBrowserRouter( }>} /> }>} /> }>} /> + }>} /> }>} /> }>} /> }>} /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index cd3ee2b..be41093 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 } 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 } from './types'; const BASE = ''; @@ -217,14 +217,60 @@ export const api = { adminDeleteComment: (id: number) => request(`/api/admin/comments/${id}`, { method: 'DELETE' }), adminCommentRevisions: (id: number) => request<{ revisions: CommentRevision[] }>(`/api/admin/comments/${id}/revisions`), - adminUsers: (page = 1) => - request<{ users: User[]; total: number; page: number; total_pages: number }>( - `/api/admin/users?page=${page}`, - ), + adminUsers: (page = 1, opts?: { keyword?: string; filter?: string }) => { + const q = new URLSearchParams({ page: String(page) }); + if (opts?.keyword?.trim()) q.set('keyword', opts.keyword.trim()); + if (opts?.filter && opts.filter !== 'all') q.set('filter', opts.filter); + return request<{ users: User[]; total: number; page: number; total_pages: number }>( + `/api/admin/users?${q}`, + ); + }, adminBanUser: (id: number, banned: boolean) => request<{ message: string; banned: boolean }>(`/api/admin/users/${id}/ban`, { method: 'POST', body: JSON.stringify({ banned }), }), + adminVerifyUser: (id: number, verified: boolean) => + request<{ message: string; verified: boolean }>(`/api/admin/users/${id}/verify`, { + method: 'POST', body: JSON.stringify({ verified }), + }), + adminSetUserLevel: (id: number, level: number) => + request<{ message: string; level: number; exp: number }>(`/api/admin/users/${id}/level`, { + method: 'POST', body: JSON.stringify({ level }), + }), + adminAdjustPoints: (id: number, delta: number, note?: string) => + request<{ message: string; points: number }>(`/api/admin/users/${id}/points`, { + method: 'POST', body: JSON.stringify({ delta, note: note || '' }), + }), + adminListBadges: () => request<{ badges: BadgeDef[] }>('/api/admin/badges'), + adminUpsertBadge: (badge: Partial) => + request<{ message: string; badge: BadgeDef }>('/api/admin/badges', { + method: 'POST', body: JSON.stringify(badge), + }), + adminAwardBadge: (userId: number, badgeId: number, revoke = false) => + request<{ message: string }>(`/api/admin/users/${userId}/badges`, { + method: 'POST', body: JSON.stringify({ badge_id: badgeId, revoke }), + }), + mePoints: (page = 1) => + request<{ + points: number; + creator_income_total: number; + ledger: PointLedger[]; + total: number; + page: number; + total_pages: number; + check_in: CheckInStatus; + lottery: LotteryStatus; + }>(`/api/me/points?page=${page}`), + checkIn: () => + request<{ message: string; check_in: CheckInStatus; points: number }>('/api/me/check-in', { method: 'POST' }), + lotteryStatus: () => request<{ lottery: LotteryStatus }>('/api/me/lottery'), + lotteryDraw: () => + request<{ message: string; lottery: LotteryStatus; points: number }>('/api/me/lottery', { method: 'POST' }), + unlockPostBlock: (postId: number, blockKey: string) => + request<{ message: string; unlock: { block_key: string; cost: number; points_balance: number; inner_html: string } }>( + `/api/posts/${postId}/unlock`, + { method: 'POST', body: JSON.stringify({ block_key: blockKey }) }, + ), adminMedia: (params?: { category?: string; page?: number; size?: number; q?: string }) => { const sp = new URLSearchParams(); if (params?.category) sp.set('category', params.category); diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 398a1ba..8fb7372 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -1,3 +1,11 @@ +export interface UserBadge { + code: string; + name: string; + description: string; + icon: string; + kind: string; +} + export interface User { id: number; username: string; @@ -6,10 +14,17 @@ export interface User { signature?: string; avatar: string; role: 'user' | 'admin'; + verified?: boolean; + exp?: number; + level?: number; + points?: number; + creator_income_total?: number; + badges?: UserBadge[]; banned?: boolean; banned_at?: string; last_login_at?: string; last_login_ip?: string; + last_access_at?: string; created_at?: string; updated_at?: string; } @@ -22,6 +37,11 @@ export interface UserPublic { signature: string; avatar: string; role: 'user' | 'admin'; + verified?: boolean; + exp?: number; + level?: number; + creator_income_total?: number; + badges?: UserBadge[]; banned?: boolean; banned_at?: string; created_at: string; @@ -144,6 +164,9 @@ export interface AdminDashboard { posts: number; boards: number; comments: number; + pending_posts?: number; + pending_comments?: number; + pending_reports?: number; recent_posts: PostItem[]; } @@ -412,3 +435,43 @@ export interface PostReport { reporter?: User; handler?: User; } + +export interface BadgeDef { + id: number; + code: string; + name: string; + description: string; + icon: string; + kind: 'auto' | 'limited' | string; + metric: string; + threshold: number; + sort_order: number; + enabled: boolean; +} + +export interface PointLedger { + id: number; + user_id: number; + delta: number; + balance: number; + reason: string; + ref_type: string; + ref_id: number; + note: string; + created_at: string; +} + +export interface CheckInStatus { + checked_in: boolean; + streak: number; + today_points: number; + day: string; +} + +export interface LotteryStatus { + drawn: boolean; + points: number; + day: string; + cost: number; + pool?: { points: number; weight: number }[]; +} diff --git a/frontend/src/components/ArticleEditor.tsx b/frontend/src/components/ArticleEditor.tsx index 12b62a0..25d710e 100644 --- a/frontend/src/components/ArticleEditor.tsx +++ b/frontend/src/components/ArticleEditor.tsx @@ -15,7 +15,7 @@ import { FileCode, PenLine, Maximize2, Minimize2, Columns2, PanelLeft, PanelRight, StretchHorizontal, Table as TableIcon, BetweenHorizonalStart, BetweenVerticalStart, Rows3, Columns3, - MessageSquareLock, + MessageSquareLock, Coins, } from 'lucide-react'; import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent'; import { htmlToMarkdown, markdownToHtml } from '../utils/markdownContent'; @@ -34,6 +34,7 @@ import { api } from '../api/client'; import { notify } from '@/lib/notify'; import { MembersOnly } from './editor/MembersOnlyExtension'; import { ReplyOnly } from './editor/ReplyOnlyExtension'; +import { PointsOnly } from './editor/PointsOnlyExtension'; import { TabIndent } from './editor/TabIndentExtension'; import { ArticleImage, type ImageDisplay } from './editor/ArticleImageExtension'; import { ImageGroup, suggestImageGroupLayout } from './editor/ImageGroupExtension'; @@ -264,12 +265,16 @@ const ArticleEditor = forwardRef(function ArticleEdi if (node.type.name === 'paragraph' && node.parent?.type.name === 'replyOnly') { return REPLY_ONLY_PLACEHOLDER; } + if (node.type.name === 'paragraph' && node.parent?.type.name === 'pointsOnly') { + return '此处内容需积分解锁后可见…'; + } return placeholder; }, includeChildren: true, }), MembersOnly, ReplyOnly, + PointsOnly, TabIndent, ], content: sanitizeHtml(value) || '', @@ -541,6 +546,20 @@ const ArticleEditor = forwardRef(function ArticleEdi editor.chain().focus().insertReplyOnly().run(); }, [editor]); + const wrapPointsOnly = useCallback(() => { + if (!editor) return; + if (editor.isActive('pointsOnly')) { + editor.chain().focus().exitPointsOnly().run(); + return; + } + const { from, to, empty } = editor.state.selection; + if (!empty && from !== to) { + editor.chain().focus().wrapPointsOnly(10).run(); + return; + } + editor.chain().focus().insertPointsOnly(10).run(); + }, [editor]); + const switchToMarkdown = useCallback(() => { if (!editor) return; const html = sanitizeHtml(editor.getHTML()); @@ -694,10 +713,18 @@ const ArticleEditor = forwardRef(function ArticleEdi className: 'article-tool-btn--reply', action: wrapReplyOnly, }, + { + icon: , + title: '积分可见', + hint: '读者花费积分解锁;可设价格', + active: editor.isActive('pointsOnly'), + className: 'article-tool-btn--points', + action: wrapPointsOnly, + }, ); return tools; - }, [editor, openLinkDialog, openCodeBlockDialog, openTableDialog, setImage, wrapMembersOnly, wrapReplyOnly, wrapSelectedAsGroup, setImageDisplay]); + }, [editor, openLinkDialog, openCodeBlockDialog, openTableDialog, setImage, wrapMembersOnly, wrapReplyOnly, wrapPointsOnly, wrapSelectedAsGroup, setImageDisplay]); const buildMarkdownTools = useCallback((): ToolBtn[] => [ { icon: H, title: '标题', hint: 'H2 → H6 循环', action: withMarkdown(cycleMarkdownHeading) }, diff --git a/frontend/src/components/CommentThreadList.tsx b/frontend/src/components/CommentThreadList.tsx index 1f7efb6..c65bb3d 100644 --- a/frontend/src/components/CommentThreadList.tsx +++ b/frontend/src/components/CommentThreadList.tsx @@ -248,6 +248,7 @@ function CommentItem({ ) : ( {nick} diff --git a/frontend/src/components/PointsWalletPanel.tsx b/frontend/src/components/PointsWalletPanel.tsx new file mode 100644 index 0000000..41c044d --- /dev/null +++ b/frontend/src/components/PointsWalletPanel.tsx @@ -0,0 +1,138 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Coins, Dices, Gift } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; +import { notify } from '@/lib/notify'; +import { api } from '../api/client'; +import type { CheckInStatus, LotteryStatus, PointLedger } from '../api/types'; +import { useAuth } from '../hooks/useAuth'; + +const REASON_LABEL: Record = { + check_in: '签到', + lottery: '抽奖', + unlock_spend: '解锁内容', + creator_income: '创作分成', + admin_adjust: '站长调账', +}; + +/** 个人中心:积分余额、签到、抽奖、流水 */ +export default function PointsWalletPanel() { + const { refresh } = useAuth(); + const [loading, setLoading] = useState(true); + const [points, setPoints] = useState(0); + const [income, setIncome] = useState(0); + const [checkIn, setCheckIn] = useState(null); + const [lottery, setLottery] = useState(null); + const [ledger, setLedger] = useState([]); + const [busy, setBusy] = useState(false); + + const load = useCallback(() => { + setLoading(true); + api.mePoints(1) + .then(d => { + setPoints(d.points); + setIncome(d.creator_income_total); + setCheckIn(d.check_in); + setLottery(d.lottery); + setLedger(d.ledger ?? []); + }) + .catch(e => notify.error(e.message)) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { load(); }, [load]); + + const doCheckIn = async () => { + setBusy(true); + try { + const r = await api.checkIn(); + notify.success(`签到成功,+${r.check_in.today_points} 积分`); + setPoints(r.points); + setCheckIn(r.check_in); + await refresh(); + load(); + } catch (e: unknown) { + notify.error(e instanceof Error ? e.message : '签到失败'); + } finally { + setBusy(false); + } + }; + + const doLottery = async () => { + setBusy(true); + try { + const r = await api.lotteryDraw(); + notify.success(r.lottery.points > 0 ? `抽中 ${r.lottery.points} 积分` : '未中奖,明天再来'); + setPoints(r.points); + setLottery(r.lottery); + await refresh(); + load(); + } catch (e: unknown) { + notify.error(e instanceof Error ? e.message : '抽奖失败'); + } finally { + setBusy(false); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+

+ + 积分钱包 +

+
+ {points} + 可用积分 + 创作收入 {income} +
+
+ +
+ + +
+ +
+

最近流水

+ {ledger.length === 0 &&

暂无流水

} +
    + {ledger.map(row => ( +
  • + = 0 ? 'pos' : 'neg'}> + {row.delta >= 0 ? '+' : ''}{row.delta} + + {REASON_LABEL[row.reason] || row.reason} + +
  • + ))} +
+
+
+ ); +} diff --git a/frontend/src/components/PostAuthorCard.tsx b/frontend/src/components/PostAuthorCard.tsx index a33f842..a2642ef 100644 --- a/frontend/src/components/PostAuthorCard.tsx +++ b/frontend/src/components/PostAuthorCard.tsx @@ -70,7 +70,6 @@ export default function PostAuthorCard({ const nick = display.nickname || display.username || `用户 #${author.id}`; const initial = nick.charAt(0) || '?'; const signature = (profile?.signature ?? author.signature ?? '').trim(); - const isAdmin = display.role === 'admin'; const isSelf = !!me && me.id === author.id; const profileHref = userPath(author.id); @@ -102,8 +101,7 @@ export default function PostAuthorCard({
- - {isAdmin && 管理员} + {display.banned && 已禁言}
{signature ? ( diff --git a/frontend/src/components/PostContent.tsx b/frontend/src/components/PostContent.tsx index d74e0e7..28604ac 100644 --- a/frontend/src/components/PostContent.tsx +++ b/frontend/src/components/PostContent.tsx @@ -1,10 +1,11 @@ import { useMemo, useCallback, useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import { renderPostContentHtml } from '../utils/postContent'; import { extractHeadingsFromHtml, type PostHeading } from '../utils/postHeadings'; import { loginPath, registerPath } from '../utils/authRedirect'; import { useForumLimits } from '../hooks/useForumLimits'; import { notify } from '@/lib/notify'; +import { api } from '../api/client'; import ImageLightbox from './ImageLightbox'; interface Props { @@ -15,20 +16,28 @@ interface Props { onHeadingsChange?: (headings: PostHeading[]) => void; /** 点击「回复可见」门控的「去回复」 */ onRequestReply?: () => void; + /** 积分解锁成功后刷新正文 */ + onUnlocked?: () => void; + postId?: number; } -/** 帖子正文渲染(含会员专属 / 回复可见区块、代码块美化、图片灯箱) */ +/** 帖子正文渲染(含会员专属 / 回复可见 / 积分可见、代码块美化、图片灯箱) */ export default function PostContent({ html, isLoggedIn, className = 'post-detail-content', onHeadingsChange, onRequestReply, + onUnlocked, + postId: postIdProp, }: Props) { const nav = useNavigate(); + const params = useParams(); + const postId = postIdProp || Number(params.id) || 0; const { limits } = useForumLimits(); const [lightboxSrc, setLightboxSrc] = useState(null); const [lightboxAlt, setLightboxAlt] = useState(''); + const [unlocking, setUnlocking] = useState(false); const prepared = useMemo(() => { const rendered = renderPostContentHtml(html, isLoggedIn, { @@ -68,6 +77,25 @@ export default function PostContent({ nav(registerPath()); return; } + const unlockBtn = target.closest('[data-points-unlock]'); + if (unlockBtn) { + e.preventDefault(); + const blockKey = unlockBtn.getAttribute('data-block-key') || ''; + const cost = unlockBtn.getAttribute('data-cost') || ''; + if (!postId || !blockKey || unlocking) return; + if (!window.confirm(`确认花费 ${cost} 积分解锁该内容?`)) return; + setUnlocking(true); + try { + await api.unlockPostBlock(postId, blockKey); + notify.success('解锁成功'); + onUnlocked?.(); + } catch (err: unknown) { + notify.error(err instanceof Error ? err.message : '解锁失败'); + } finally { + setUnlocking(false); + } + return; + } const zoomImg = target.closest('img.post-content-img--zoomable'); if (zoomImg) { e.preventDefault(); @@ -106,7 +134,6 @@ export default function PostContent({ if (copyBtn) { e.preventDefault(); const block = copyBtn.closest('.md-codeblock'); - // 行号列不参与复制:取各行正文拼接 const bodies = block?.querySelectorAll('.md-code-line__body'); const text = bodies && bodies.length ? [...bodies].map(el => el.textContent ?? '').join('\n') @@ -124,7 +151,7 @@ export default function PostContent({ notify.error('复制失败'); } } - }, [nav, openLightbox, onRequestReply]); + }, [nav, openLightbox, onRequestReply, onUnlocked, postId, unlocking]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key !== 'Enter' && e.key !== ' ') return; diff --git a/frontend/src/components/PostListItem.tsx b/frontend/src/components/PostListItem.tsx index 69c32b7..cf4ddc0 100644 --- a/frontend/src/components/PostListItem.tsx +++ b/frontend/src/components/PostListItem.tsx @@ -71,7 +71,7 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
- + · {timeLabel}
diff --git a/frontend/src/components/UserBadges.tsx b/frontend/src/components/UserBadges.tsx new file mode 100644 index 0000000..e7d44c0 --- /dev/null +++ b/frontend/src/components/UserBadges.tsx @@ -0,0 +1,71 @@ +import { BadgeCheck, Crown, type LucideIcon } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { UserBadge } from '../api/types'; +import { badgeIcon } from '../utils/badgeIcons'; +import { resolveUserLevel } from '../utils/userMeta'; + +type BadgeUser = { + role?: string; + verified?: boolean; + level?: number; + exp?: number; + badges?: UserBadge[]; +} | null | undefined; + +interface Props { + user: BadgeUser; + className?: string; + /** 用户名旁最多展示几枚成就徽章 */ + maxAchievement?: number; + showLevel?: boolean; + compact?: boolean; +} + +/** 用户名旁:站长/认证 + Lv + 成就徽章 */ +export default function UserBadges({ + user, + className, + maxAchievement = 2, + showLevel = true, + compact = true, +}: Props) { + if (!user) return null; + const level = resolveUserLevel(user); + const achievements = (user.badges ?? []).slice(0, maxAchievement); + const isAdmin = user.role === 'admin'; + const isVerified = !!user.verified && !isAdmin; + + if (!isAdmin && !isVerified && !showLevel && achievements.length === 0) return null; + + const levelTone = level >= 9 ? 'gold' : level >= 7 ? 'amber' : level >= 4 ? 'blue' : 'muted'; + + return ( + + {isAdmin && ( + + + {!compact && 站长} + + )} + {isVerified && ( + + + {!compact && 认证} + + )} + {showLevel && ( + + Lv.{level} + + )} + {achievements.map(b => { + const Icon: LucideIcon = badgeIcon(b.icon); + return ( + + + + ); + })} + + ); +} diff --git a/frontend/src/components/UserLink.tsx b/frontend/src/components/UserLink.tsx index 596a87c..64fca9f 100644 --- a/frontend/src/components/UserLink.tsx +++ b/frontend/src/components/UserLink.tsx @@ -1,12 +1,19 @@ import type { MouseEvent, ReactNode } from 'react'; import { Link } from 'react-router-dom'; import { cn } from '@/lib/utils'; +import type { UserBadge } from '../api/types'; import { userPath } from '../utils/userPath'; +import UserBadges from './UserBadges'; export type UserLinkUser = { id?: number; nickname?: string; avatar?: string; + role?: string; + verified?: boolean; + level?: number; + exp?: number; + badges?: UserBadge[]; } | null | undefined; interface Props { @@ -16,6 +23,7 @@ interface Props { nameClassName?: string; showAvatar?: boolean; showName?: boolean; + showBadges?: boolean; /** 嵌在可点击父级内时阻止冒泡(如帖子列表行) */ stopPropagation?: boolean; children?: ReactNode; @@ -30,6 +38,7 @@ export default function UserLink({ nameClassName, showAvatar = false, showName = true, + showBadges = false, stopPropagation = false, children, title, @@ -53,6 +62,7 @@ export default function UserLink({ )} {showName && {nick}} + {showBadges && showName && } ); diff --git a/frontend/src/components/editor/PointsOnlyExtension.tsx b/frontend/src/components/editor/PointsOnlyExtension.tsx new file mode 100644 index 0000000..bf43579 --- /dev/null +++ b/frontend/src/components/editor/PointsOnlyExtension.tsx @@ -0,0 +1,215 @@ +import { Node, mergeAttributes } from '@tiptap/core'; +import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; +import { + ReactNodeViewRenderer, + NodeViewWrapper, + NodeViewContent, + type NodeViewProps, +} from '@tiptap/react'; +import { Coins, Trash2 } from 'lucide-react'; + +function findPointsOnlyDepth($pos: { + depth: number; + node: (d: number) => { type: { name: string }; nodeSize: number }; + before: (d: number) => number; + start: (d: number) => number; +}): number { + for (let d = $pos.depth; d > 0; d -= 1) { + if ($pos.node(d).type.name === 'pointsOnly') return d; + } + return -1; +} + +function isPointsOnlyEmpty(node: ProseMirrorNode): boolean { + return node.textContent.trim().length === 0; +} + +function PointsOnlyView({ selected, editor, node, getPos, updateAttributes }: NodeViewProps) { + const empty = isPointsOnlyEmpty(node); + const cost = Number(node.attrs.cost) || 10; + + const deleteThisBlock = () => { + const pos = getPos(); + if (typeof pos !== 'number') { + editor.chain().focus().removePointsOnly().run(); + return; + } + editor.chain().focus().command(({ tr, dispatch }) => { + if (dispatch) tr.delete(pos, pos + node.nodeSize); + return true; + }).run(); + }; + + return ( + +
+ + 积分可见 + +
+ +
+
+ +
+ ); +} + +declare module '@tiptap/core' { + interface Commands { + pointsOnly: { + insertPointsOnly: (cost?: number) => ReturnType; + wrapPointsOnly: (cost?: number) => ReturnType; + exitPointsOnly: () => ReturnType; + removePointsOnly: () => ReturnType; + }; + } +} + +/** TipTap:积分可见内容区块 */ +export const PointsOnly = Node.create({ + name: 'pointsOnly', + group: 'block', + content: 'block+', + defining: true, + isolating: true, + + addAttributes() { + return { + cost: { + default: 10, + parseHTML: el => { + const v = Number(el.getAttribute('data-cost')); + return v > 0 ? v : 10; + }, + renderHTML: attrs => ({ 'data-cost': String(attrs.cost || 10) }), + }, + }; + }, + + parseHTML() { + return [{ tag: 'points-only' }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['points-only', mergeAttributes({ 'data-gate': 'points' }, HTMLAttributes), 0]; + }, + + addNodeView() { + return ReactNodeViewRenderer(PointsOnlyView); + }, + + addKeyboardShortcuts() { + return { + Backspace: ({ editor }) => { + const { $from, empty } = editor.state.selection; + if (!empty) return false; + const depth = findPointsOnlyDepth($from); + if (depth < 0) return false; + const node = $from.node(depth); + if (!isPointsOnlyEmpty(node)) { + if ($from.parentOffset !== 0) return false; + if ($from.pos !== $from.start(depth)) return false; + } + return editor.commands.removePointsOnly(); + }, + Delete: ({ editor }) => { + const { $from, empty } = editor.state.selection; + if (!empty) return false; + const depth = findPointsOnlyDepth($from); + if (depth < 0) return false; + if (!isPointsOnlyEmpty($from.node(depth))) return false; + return editor.commands.removePointsOnly(); + }, + Enter: ({ editor }) => { + const { $from, empty } = editor.state.selection; + if (!empty) return false; + const depth = findPointsOnlyDepth($from); + if (depth < 0) return false; + const parent = $from.parent; + if ($from.parentOffset !== parent.content.size || parent.textContent.trim().length > 0) return false; + const node = $from.node(depth); + if (isPointsOnlyEmpty(node) && node.childCount <= 1) { + return editor.commands.removePointsOnly(); + } + return editor.commands.exitPointsOnly(); + }, + 'Mod-Enter': ({ editor }) => { + if (!editor.isActive('pointsOnly')) return false; + return editor.commands.exitPointsOnly(); + }, + }; + }, + + addCommands() { + return { + insertPointsOnly: (cost = 10) => ({ chain }) => chain() + .insertContent({ + type: this.name, + attrs: { cost }, + content: [{ type: 'paragraph' }], + }) + .run(), + + wrapPointsOnly: (cost = 10) => ({ tr, state, dispatch }) => { + const { from, to, empty } = state.selection; + if (empty) return false; + const slice = state.doc.slice(from, to); + if (!slice.content.size) return false; + const node = state.schema.nodes.pointsOnly.create({ cost }, slice.content); + if (dispatch) tr.replaceRangeWith(from, to, node); + return true; + }, + + exitPointsOnly: () => ({ state, chain }) => { + const { $from } = state.selection; + const depth = findPointsOnlyDepth($from); + if (depth < 0) return false; + const pos = $from.before(depth); + const node = $from.node(depth); + const end = pos + node.nodeSize; + return chain() + .insertContentAt(end, { type: 'paragraph' }) + .setTextSelection(end + 1) + .run(); + }, + + removePointsOnly: () => ({ tr, state, dispatch }) => { + const { $from } = state.selection; + const depth = findPointsOnlyDepth($from); + if (depth < 0) return false; + const pos = $from.before(depth); + const node = $from.node(depth); + tr.delete(pos, pos + node.nodeSize); + if (dispatch) dispatch(tr); + return true; + }, + }; + }, +}); diff --git a/frontend/src/layouts/AdminLayout.tsx b/frontend/src/layouts/AdminLayout.tsx index 8875f8e..704e3bf 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 } from 'react-router-dom'; +import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'; import { - LayoutDashboard, FolderKanban, FileText, MessageSquare, Flag, Users, Images, Settings, ArrowLeft, Moon, Sun, Menu, X, + LayoutDashboard, FolderKanban, FileText, MessageSquare, Flag, Users, Images, Settings, ArrowLeft, Moon, Sun, Menu, X, Award, } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; import { useAuth } from '../hooks/useAuth'; @@ -14,18 +14,66 @@ import { loginPath } from '../utils/authRedirect'; import { useSiteBranding } from '../hooks/useSiteBranding'; import { useNoIndexSEO } from '../hooks/usePageSEO'; import SiteBrandMark from '../components/SiteBrandMark'; +import { api } from '../api/client'; -const NAV = [ - { to: '/admin/dashboard', label: '仪表盘', icon: LayoutDashboard }, - { to: '/admin/boards', label: '板块管理', icon: FolderKanban }, - { to: '/admin/posts', label: '帖子管理', icon: FileText }, - { to: '/admin/comments', label: '评论管理', icon: MessageSquare }, - { to: '/admin/reports', label: '举报管理', icon: Flag }, - { to: '/admin/users', label: '用户管理', icon: Users }, - { to: '/admin/media', label: '媒体库', icon: Images }, - { to: '/admin/settings', label: '系统设置', icon: Settings }, +type BadgeKey = 'posts' | 'comments' | 'reports'; + +type NavItem = { + to: string; + label: string; + icon: typeof LayoutDashboard; + badgeKey?: BadgeKey; +}; + +type NavGroup = { + label: string; + items: NavItem[]; +}; + +/** 信息架构:概览 → 内容审核 → 社区 → 系统(2026 管理台常见分层) */ +const NAV_GROUPS: NavGroup[] = [ + { + label: '概览', + items: [ + { to: '/admin/dashboard', label: '仪表盘', icon: LayoutDashboard }, + ], + }, + { + label: '内容审核', + items: [ + { to: '/admin/posts', label: '帖子管理', icon: FileText, badgeKey: 'posts' }, + { to: '/admin/comments', label: '评论管理', icon: MessageSquare, badgeKey: 'comments' }, + { to: '/admin/reports', label: '举报管理', icon: Flag, badgeKey: 'reports' }, + ], + }, + { + label: '社区', + items: [ + { to: '/admin/boards', label: '板块管理', icon: FolderKanban }, + { to: '/admin/users', label: '用户管理', icon: Users }, + { to: '/admin/badges', label: '徽章管理', icon: Award }, + ], + }, + { + label: '系统', + items: [ + { to: '/admin/media', label: '媒体库', icon: Images }, + { to: '/admin/settings', label: '系统设置', icon: Settings }, + ], + }, ]; +type PendingCounts = { + posts: number; + comments: number; + reports: number; +}; + +function formatNavBadge(n: number) { + if (n <= 0) return null; + return n > 99 ? '99+' : String(n); +} + /** React 管理后台布局,与前台 SPA 风格统一 */ export default function AdminLayout() { const { user, loading } = useAuth(); @@ -34,7 +82,9 @@ 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 nav = useNavigate(); + const location = useLocation(); const drawerRef = useRef(null); const closeRef = useRef(null); @@ -43,6 +93,16 @@ export default function AdminLayout() { initialFocusRef: closeRef, }); + const refreshPending = useCallback(() => { + api.adminDashboard() + .then(d => setPending({ + posts: d.pending_posts ?? 0, + comments: d.pending_comments ?? 0, + reports: d.pending_reports ?? 0, + })) + .catch(() => {}); + }, []); + useEffect(() => { if (loading) return; if (!user) { @@ -55,6 +115,11 @@ export default function AdminLayout() { } }, [user, loading, nav]); + useEffect(() => { + if (!user || user.role !== 'admin') return; + refreshPending(); + }, [user, location.pathname, refreshPending]); + useEffect(() => { if (!isNarrow) setNavOpen(false); }, [isNarrow]); @@ -71,17 +136,32 @@ export default function AdminLayout() { } if (!user || user.role !== 'admin') return null; - const navLinks = NAV.map(({ to, label, icon: Icon }) => ( - cn('admin-nav-item', isActive && 'active')} - onClick={closeNav} - > - - {label} - - )); + const renderNav = () => ( + NAV_GROUPS.map(group => ( +
+
{group.label}
+ {group.items.map(({ to, label, icon: Icon, badgeKey }) => { + const badge = badgeKey ? formatNavBadge(pending[badgeKey]) : null; + return ( + cn('admin-nav-item', isActive && 'active')} + onClick={closeNav} + > + + {label} + {badge && ( + + {badge} + + )} + + ); + })} +
+ )) + ); return (
@@ -125,8 +205,8 @@ export default function AdminLayout() {
{!isNarrow && ( -
diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 10e721d..79fd00a 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -457,7 +457,9 @@ export default function MainLayout() { onCloseAutoFocus={(e) => e.preventDefault()} > nav(userPath(user.id))}>个人主页 - nav('/profile')}>账号设置 + nav('/profile')}> + 账号设置{typeof user.points === 'number' ? ` · ${user.points} 积分` : ''} + nav('/messages')}> 站内私信{unreadMessages > 0 ? ` (${unreadMessages})` : ''} diff --git a/frontend/src/pages/ComposePage.tsx b/frontend/src/pages/ComposePage.tsx index dc7c3af..14b935e 100644 --- a/frontend/src/pages/ComposePage.tsx +++ b/frontend/src/pages/ComposePage.tsx @@ -17,6 +17,7 @@ import type { LayoutCtx } from '../layouts/MainLayout'; import { loginPath } from '../utils/authRedirect'; import { useNoIndexSEO } from '../hooks/usePageSEO'; import { parsePermalinkID, postPath } from '../utils/permalink'; +import { skipsModeration } from '../utils/userMeta'; interface ComposeBaseline { title: string; @@ -258,7 +259,7 @@ export default function ComposePage() { }; if (isEdit) { await api.updatePost(editId!, payload); - notify.success(user?.role === 'admin' ? '帖子已更新' : '已更新并重新提交审核'); + notify.success(skipsModeration(user) ? '帖子已更新' : '已更新并重新提交审核'); markSaved(); nav(postPath(editId!, limits)); } else { diff --git a/frontend/src/pages/PostDetailPage.tsx b/frontend/src/pages/PostDetailPage.tsx index 9ad1de5..9fced16 100644 --- a/frontend/src/pages/PostDetailPage.tsx +++ b/frontend/src/pages/PostDetailPage.tsx @@ -628,7 +628,7 @@ export default function PostDetailPage() { : authorInitial}
- + 发布于 {formatDateTime(post.created_at)} {showEdited && ( @@ -665,8 +665,10 @@ export default function PostDetailPage() { { void reloadPostContent(); }} />
diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 14a8891..fba0b77 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -23,6 +23,8 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Badge } from '@/components/ui/badge'; +import UserBadges from '../components/UserBadges'; +import PointsWalletPanel from '../components/PointsWalletPanel'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { Spinner } from '@/components/ui/spinner'; import { notify } from '@/lib/notify'; @@ -389,7 +391,7 @@ export default function ProfilePage() {

{user.nickname}

- {user.role === 'admin' && 管理员} +
@{user.username}
@@ -489,9 +491,11 @@ export default function ProfilePage() { onConfirm={onCropConfirm} /> + + {user.role === 'admin' && (
-
管理员入口
+
站长入口

管理板块、用户、帖子及系统设置

diff --git a/frontend/src/pages/UserProfilePage.tsx b/frontend/src/pages/UserProfilePage.tsx index 68eb0e4..c04caf9 100644 --- a/frontend/src/pages/UserProfilePage.tsx +++ b/frontend/src/pages/UserProfilePage.tsx @@ -13,6 +13,7 @@ import { } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; +import UserBadges from '../components/UserBadges'; import { Spinner } from '@/components/ui/spinner'; import { notify } from '@/lib/notify'; import { api } from '../api/client'; @@ -150,7 +151,7 @@ export default function UserProfilePage() {

{profile.nickname}

- {profile.role === 'admin' && 管理员} + {profile.banned && 已禁言}
@{profile.username}
@@ -199,6 +200,13 @@ export default function UserProfilePage() {
+ {!!profile.badges?.length && ( +
+

徽章

+ +
+ )} +
diff --git a/frontend/src/pages/admin/AdminBadgesPage.tsx b/frontend/src/pages/admin/AdminBadgesPage.tsx new file mode 100644 index 0000000..70522bf --- /dev/null +++ b/frontend/src/pages/admin/AdminBadgesPage.tsx @@ -0,0 +1,483 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Award, Pencil, Plus, Search, Sparkles, Trophy, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Switch } from '@/components/ui/switch'; +import { Spinner } from '@/components/ui/spinner'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { notify } from '@/lib/notify'; +import { cn } from '@/lib/utils'; +import { api } from '../../api/client'; +import { useAdminGuard } from '../../layouts/AdminLayout'; +import type { BadgeDef } from '../../api/types'; +import { + BADGE_ICON_OPTIONS, + BADGE_METRIC_OPTIONS, + badgeIcon, + formatBadgeCondition, +} from '../../utils/badgeIcons'; + +type KindTab = 'all' | 'auto' | 'limited'; + +const EMPTY: Partial = { + code: '', + name: '', + description: '', + icon: 'star', + kind: 'limited', + metric: 'tenure_days', + threshold: 30, + sort_order: 100, + enabled: true, +}; + +function slugifyCode(name: string): string { + const ascii = name + .trim() + .toLowerCase() + .replace(/\s+/g, '_') + .replace(/[^a-z0-9_-]/g, ''); + return ascii.slice(0, 32) || `badge_${Date.now().toString(36)}`; +} + +/** 后台:徽章定义(卡片预览 + 弹窗编辑) */ +export default function AdminBadgesPage() { + const { ready } = useAdminGuard(); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [tab, setTab] = useState('all'); + const [query, setQuery] = useState(''); + const [dialogOpen, setDialogOpen] = useState(false); + const [form, setForm] = useState>({ ...EMPTY }); + const [saving, setSaving] = useState(false); + const [togglingId, setTogglingId] = useState(null); + + const load = () => { + setLoading(true); + api.adminListBadges() + .then(d => setRows(d.badges ?? [])) + .catch(e => notify.error(e.message)) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + if (ready) load(); + }, [ready]); + + const counts = useMemo(() => ({ + all: rows.length, + auto: rows.filter(b => b.kind === 'auto').length, + limited: rows.filter(b => b.kind === 'limited').length, + disabled: rows.filter(b => !b.enabled).length, + }), [rows]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + return rows.filter(b => { + if (tab === 'auto' && b.kind !== 'auto') return false; + if (tab === 'limited' && b.kind !== 'limited') return false; + if (!q) return true; + return [b.code, b.name, b.description, b.icon, b.metric] + .join(' ') + .toLowerCase() + .includes(q); + }); + }, [rows, tab, query]); + + const openCreate = () => { + setForm({ ...EMPTY }); + setDialogOpen(true); + }; + + const openEdit = (b: BadgeDef) => { + setForm({ ...b }); + setDialogOpen(true); + }; + + const save = async () => { + const name = form.name?.trim() || ''; + let code = form.code?.trim() || ''; + if (!name) { + notify.warning('请填写徽章名称'); + return; + } + if (!form.id && !code) { + code = slugifyCode(name); + } + if (!code) { + notify.warning('请填写徽章代码'); + return; + } + if (form.kind === 'auto' && !form.metric) { + notify.warning('请选择自动成就指标'); + return; + } + setSaving(true); + try { + const r = await api.adminUpsertBadge({ + ...form, + code, + name, + kind: form.kind || 'limited', + metric: form.kind === 'auto' ? (form.metric || 'tenure_days') : '', + threshold: form.kind === 'auto' ? (form.threshold ?? 0) : 0, + icon: form.icon || 'star', + enabled: form.enabled !== false, + }); + notify.success(r.message); + setDialogOpen(false); + resetForm(); + load(); + } catch (e: unknown) { + notify.error(e instanceof Error ? e.message : '保存失败'); + } finally { + setSaving(false); + } + }; + + const resetForm = () => setForm({ ...EMPTY }); + + const toggleEnabled = async (b: BadgeDef) => { + setTogglingId(b.id); + try { + await api.adminUpsertBadge({ ...b, enabled: !b.enabled }); + notify.success(b.enabled ? '已停用' : '已启用'); + load(); + } catch (e: unknown) { + notify.error(e instanceof Error ? e.message : '操作失败'); + } finally { + setTogglingId(null); + } + }; + + if (!ready) return null; + + const PreviewIcon = badgeIcon(form.icon); + const isEdit = !!form.id; + + return ( +
+
+
+
+

徽章管理

+

设计自动成就与限定徽章;限定徽章在「用户管理」中颁发给用户

+
+ +
+
+ +
+
+ {counts.all} + 全部 +
+
+ {counts.auto} + 自动成就 +
+
+ {counts.limited} + 限定徽章 +
+
+ {counts.disabled} + 已停用 +
+
+ +
+
+ {([ + ['all', '全部'], + ['auto', '自动成就'], + ['limited', '限定徽章'], + ] as const).map(([key, label]) => ( + + ))} +
+
+ + setQuery(e.target.value)} + placeholder="搜索名称、代码、说明…" + aria-label="搜索徽章" + /> +
+
+ + {loading ? ( +
+ ) : filtered.length === 0 ? ( +
+ +

{query ? '没有匹配的徽章' : '还没有徽章'}

+

+ {query + ? '试试其他关键词,或切换类型筛选' + : '创建自动成就(达条件发放)或限定徽章(站长颁发)'} +

+ {!query && ( + + )} +
+ ) : ( +
+ {filtered.map(b => { + const Icon = badgeIcon(b.icon); + return ( +
+
+
+ +
+
+
+

{b.name}

+ {b.kind === 'auto' + ? 自动 + : 限定} + {!b.enabled && 停用} +
+ {b.code} +
+
+ +

+ {b.description?.trim() || (b.kind === 'limited' ? '站长手动颁发的限定徽章' : '达成条件后自动获得')} +

+ +
+ + {b.kind === 'auto' ? : } + {formatBadgeCondition(b)} + +
+ + +
+
+
+ ); + })} +
+ )} + + { + setDialogOpen(open); + if (!open) resetForm(); + }}> + + + {isEdit ? '编辑徽章' : '新建徽章'} + + {isEdit + ? '修改后立即对展示生效;代码不可更改。' + : '自动成就按指标发放,限定徽章需在用户管理中手动颁发。'} + + + +
+
+ +
+
+
{form.name?.trim() || '徽章名称'}
+
+ {formatBadgeCondition({ + kind: form.kind, + metric: form.metric, + threshold: form.threshold, + description: form.description, + })} +
+
+
+ +
+
+ + +
+ +
+
+ + { + const name = e.target.value; + setForm(f => ({ + ...f, + name, + code: isEdit ? f.code : (f.code?.trim() ? f.code : slugifyCode(name)), + })); + }} + placeholder="例如:资深居民" + autoFocus + /> +
+
+ + setForm(f => ({ ...f, code: e.target.value }))} + placeholder="tenure_365" + disabled={isEdit} + className="font-mono text-sm" + /> +
+
+ +
+ + setForm(f => ({ ...f, description: e.target.value }))} + placeholder="鼠标悬停时显示的获得条件" + /> +
+ +
+ +
+ {BADGE_ICON_OPTIONS.map(opt => { + const active = (form.icon || 'star') === opt.key; + return ( + + ); + })} +
+
+ + {form.kind === 'auto' && ( +
+
+ + + + {BADGE_METRIC_OPTIONS.find(m => m.value === (form.metric || 'tenure_days'))?.hint} + +
+
+ + setForm(f => ({ ...f, threshold: Number(e.target.value) || 0 }))} + /> +
+
+ )} + +
+
+ + setForm(f => ({ ...f, sort_order: Number(e.target.value) || 0 }))} + /> +
+ +
+
+ + + + + +
+
+
+ ); +} diff --git a/frontend/src/pages/admin/AdminDashboardPage.tsx b/frontend/src/pages/admin/AdminDashboardPage.tsx index 7b8ec84..59c2cd7 100644 --- a/frontend/src/pages/admin/AdminDashboardPage.tsx +++ b/frontend/src/pages/admin/AdminDashboardPage.tsx @@ -1,10 +1,12 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { FileText, Flag, MessageSquare } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; import { Badge } from '@/components/ui/badge'; import { api } from '../../api/client'; import { useAdminGuard } from '../../layouts/AdminLayout'; import type { AdminDashboard } from '../../api/types'; +import { cn } from '@/lib/utils'; export default function AdminDashboardPage() { const nav = useNavigate(); @@ -24,28 +26,97 @@ export default function AdminDashboardPage() { } if (!data) return null; + const pendingPosts = data.pending_posts ?? 0; + const pendingComments = data.pending_comments ?? 0; + const pendingReports = data.pending_reports ?? 0; + const pendingTotal = pendingPosts + pendingComments + pendingReports; + const stats = [ - { label: '注册用户', value: data.users, cls: 'admin-stat-users' }, - { label: '帖子总数', value: data.posts, cls: 'admin-stat-posts' }, - { label: '板块数量', value: data.boards, cls: 'admin-stat-boards' }, - { label: '评论总数', value: data.comments, cls: 'admin-stat-comments' }, + { label: '注册用户', value: data.users }, + { label: '帖子总数', value: data.posts }, + { label: '板块数量', value: data.boards }, + { label: '评论总数', value: data.comments }, + ]; + + const queues = [ + { + key: 'posts', + label: '待审帖子', + count: pendingPosts, + hint: '新帖与修改待审核', + to: '/admin/posts', + icon: FileText, + }, + { + key: 'comments', + label: '待审评论', + count: pendingComments, + hint: '评论与回复待审核', + to: '/admin/comments', + icon: MessageSquare, + }, + { + key: 'reports', + label: '待处理举报', + count: pendingReports, + hint: '用户举报需人工处理', + to: '/admin/reports', + icon: Flag, + }, ]; return (

仪表盘

-

论坛运行概览与最新帖子

+

优先处理待办,再查看运行概览

-
- {stats.map(s => ( -
-
{s.value}
-
{s.label}
-
- ))} -
+
+
+ 待处理 + {pendingTotal > 0 ? ( + {pendingTotal} 项 + ) : ( + 暂无积压 + )} +
+
+ {queues.map(q => { + const Icon = q.icon; + const hasWork = q.count > 0; + return ( + + ); + })} +
+
+ +
+
+ 运行概览 +
+
+ {stats.map(s => ( +
+
{s.value}
+
{s.label}
+
+ ))} +
+
diff --git a/frontend/src/pages/admin/AdminReportsPage.tsx b/frontend/src/pages/admin/AdminReportsPage.tsx index 739bc5e..5fe390c 100644 --- a/frontend/src/pages/admin/AdminReportsPage.tsx +++ b/frontend/src/pages/admin/AdminReportsPage.tsx @@ -112,8 +112,10 @@ export default function AdminReportsPage() { return (
-

举报管理

-

处理用户对帖子与评论的举报;拒绝时将通过站内私信通知作者。

+
+

举报管理

+

处理用户对帖子与评论的举报;拒绝时将通过站内私信通知作者。

+
{tabs.map((t) => ( diff --git a/frontend/src/pages/admin/AdminUsersPage.tsx b/frontend/src/pages/admin/AdminUsersPage.tsx index 493567c..b386d1f 100644 --- a/frontend/src/pages/admin/AdminUsersPage.tsx +++ b/frontend/src/pages/admin/AdminUsersPage.tsx @@ -1,124 +1,580 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { + Award, Ban, BadgeCheck, MoreHorizontal, Search, Shield, UserCog, +} from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; import { Spinner } from '@/components/ui/spinner'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { notify } from '@/lib/notify'; +import { cn } from '@/lib/utils'; import { api } from '../../api/client'; import { useAdminGuard } from '../../layouts/AdminLayout'; -import type { User } from '../../api/types'; +import type { BadgeDef, User } from '../../api/types'; +import { resolveUserLevel } from '../../utils/userMeta'; +import { formatDateTime, formatTime } from '../../utils/content'; +import { badgeIcon } from '../../utils/badgeIcons'; +type FilterTab = 'all' | 'verified' | 'banned' | 'admin'; + +function fmtAbs(v?: string) { + if (!v) return '—'; + return formatDateTime(v); +} + +function fmtRel(v?: string) { + if (!v) return '—'; + return formatTime(v); +} + +function UserAvatar({ user }: { user: User }) { + const initial = (user.nickname || user.username || '?').slice(0, 1).toUpperCase(); + if (user.avatar) { + return ; + } + return {initial}; +} + +/** 后台用户管理:成员目录式列表 + 详情弹窗 */ export default function AdminUsersPage() { const nav = useNavigate(); const { ready } = useAdminGuard(); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); const [totalPages, setTotalPages] = useState(1); + const [filter, setFilter] = useState('all'); + const [keyword, setKeyword] = useState(''); + const [search, setSearch] = useState(''); + const [limitedBadges, setLimitedBadges] = useState([]); - const load = (p = page) => { + const [manageUser, setManageUser] = useState(null); + const [levelVal, setLevelVal] = useState(1); + const [pointsDelta, setPointsDelta] = useState('10'); + const [pointsNote, setPointsNote] = useState(''); + const [badgeId, setBadgeId] = useState(''); + const [saving, setSaving] = useState(false); + const [banTarget, setBanTarget] = useState(null); + + const load = useCallback((p = 1, kw = search, f = filter) => { setLoading(true); - api.adminUsers(p) + api.adminUsers(p, { keyword: kw, filter: f }) .then(d => { setUsers(d.users ?? []); setPage(d.page); + setTotal(d.total); setTotalPages(d.total_pages); }) .catch(e => notify.error(e.message)) .finally(() => setLoading(false)); - }; + }, [search, filter]); useEffect(() => { - if (ready) load(1); + if (!ready) return; + load(1, search, filter); + }, [ready, filter]); // eslint-disable-line react-hooks/exhaustive-deps -- 鉴权与筛选变化时重载 + + useEffect(() => { + if (!ready) return; + api.adminListBadges() + .then(d => setLimitedBadges((d.badges ?? []).filter(b => b.kind === 'limited' && b.enabled))) + .catch(() => {}); }, [ready]); - const toggleBan = async (user: User) => { - if (user.role === 'admin') { - notify.warning('不能禁言管理员'); - return; + const openManage = (user: User) => { + setManageUser(user); + setLevelVal(resolveUserLevel(user)); + setPointsDelta('10'); + setPointsNote(''); + setBadgeId(limitedBadges[0]?.id ?? ''); + }; + + const refreshManaged = async (patch?: Partial) => { + if (manageUser && patch) { + setManageUser({ ...manageUser, ...patch }); } + load(page); + }; + + const toggleVerify = async (user: User) => { try { - const r = await api.adminBanUser(user.id, !user.banned); + const r = await api.adminVerifyUser(user.id, !user.verified); notify.success(r.message); - load(); + if (manageUser?.id === user.id) { + setManageUser({ ...user, verified: r.verified }); + } + load(page); } catch (e: unknown) { notify.error(e instanceof Error ? e.message : '操作失败'); } }; + const confirmBan = async () => { + if (!banTarget) return; + try { + const r = await api.adminBanUser(banTarget.id, !banTarget.banned); + notify.success(r.message); + if (manageUser?.id === banTarget.id) { + setManageUser({ ...banTarget, banned: r.banned }); + } + setBanTarget(null); + load(page); + } catch (e: unknown) { + notify.error(e instanceof Error ? e.message : '操作失败'); + } + }; + + const saveLevel = async () => { + if (!manageUser) return; + if (!Number.isInteger(levelVal) || levelVal < 1 || levelVal > 10) { + notify.warning('等级须为 1–10 的整数'); + return; + } + setSaving(true); + try { + const r = await api.adminSetUserLevel(manageUser.id, levelVal); + notify.success(r.message); + await refreshManaged({ level: r.level, exp: r.exp }); + } catch (e: unknown) { + notify.error(e instanceof Error ? e.message : '操作失败'); + } finally { + setSaving(false); + } + }; + + const savePoints = async () => { + if (!manageUser) return; + const delta = Number(pointsDelta); + if (!Number.isFinite(delta) || delta === 0) { + notify.warning('请输入非零数字(正加负减)'); + return; + } + setSaving(true); + try { + const r = await api.adminAdjustPoints(manageUser.id, delta, pointsNote.trim() || undefined); + notify.success(`${r.message},余额 ${r.points}`); + setPointsDelta('10'); + setPointsNote(''); + await refreshManaged({ points: r.points }); + } catch (e: unknown) { + notify.error(e instanceof Error ? e.message : '操作失败'); + } finally { + setSaving(false); + } + }; + + const saveBadge = async () => { + if (!manageUser) return; + if (!badgeId) { + notify.warning('请选择要颁发的限定徽章'); + return; + } + setSaving(true); + try { + const r = await api.adminAwardBadge(manageUser.id, Number(badgeId), false); + notify.success(r.message); + } catch (e: unknown) { + notify.error(e instanceof Error ? e.message : '操作失败'); + } finally { + setSaving(false); + } + }; + + const onSearch = (e: React.FormEvent) => { + e.preventDefault(); + const kw = keyword.trim(); + setSearch(kw); + load(1, kw, filter); + }; + + const switchFilter = (f: FilterTab) => { + setFilter(f); + setPage(1); + }; + if (!ready) return null; + const filters: { key: FilterTab; label: string }[] = [ + { key: 'all', label: '全部' }, + { key: 'verified', label: '已认证' }, + { key: 'banned', label: '已禁言' }, + { key: 'admin', label: '站长' }, + ]; + return ( -
+

用户管理

-

查看注册用户,禁言或解除禁言

+

查找成员并管理认证、等级与积分

-
+
+
+
+
+ + setKeyword(e.target.value)} + placeholder="搜索 ID、用户名、昵称或邮箱" + aria-label="搜索用户" + /> +
+ + {search ? ( + + ) : null} +
+ +
+ {filters.map(f => ( + + ))} +
+
+ {loading ? ( -
+
+ ) : users.length === 0 ? ( +
+ {search || filter !== 'all' ? '没有符合条件的用户' : '暂无用户'} +
) : ( <> -
- - - - - - - - - - - - - - - - - {users.map(u => ( - - - - - - - - - - - - - ))} - -
ID用户名昵称邮箱角色状态上次登录登录 IP注册时间操作
{u.id}{u.username} - - {u.email || '—'} - {u.role === 'admin' - ? 管理员 - : 用户} - {u.banned ? 已禁言 : '正常'}{u.last_login_at ? new Date(u.last_login_at).toLocaleString('zh-CN') : '—'}{u.last_login_ip || '—'}{u.created_at ? new Date(u.created_at).toLocaleString('zh-CN') : '—'} - {u.role !== 'admin' && ( - - )} -
+
+ 成员 + 等级 + 积分 + 最近登录 + +
+
    + {users.map(u => ( +
  • +
    + +
    +
    + + {u.role === 'admin' && 站长} + {u.role !== 'admin' && u.verified && 认证} + {u.banned && 禁言} +
    +
    @{u.username}
    + {u.email?.trim() ? ( +
    {u.email}
    + ) : null} +
    +
    + +
    + Lv.{resolveUserLevel(u)} +
    +
    + {u.points ?? 0} +
    +
    + {fmtRel(u.last_login_at)} +
    + +
    + + + + + + openManage(u)}> + + 账户详情 + + nav(`/user/${u.id}`)}> + 查看主页 + + {u.role !== 'admin' && ( + <> + + toggleVerify(u)}> + + {u.verified ? '取消认证' : '设为认证'} + + setBanTarget(u)} + > + + {u.banned ? '解除禁言' : '禁言'} + + + )} + + +
    +
  • + ))} +
+ +
+ {total} 位成员 + {totalPages > 1 && ( +
+ + {page} / {totalPages} + +
+ )}
- {users.length === 0 &&
暂无用户
} - {totalPages > 1 && ( -
- - 第 {page} / {totalPages} 页 - -
- )} )}
+ + { if (!open) setManageUser(null); }}> + + {manageUser && ( + <> + + 账户详情 + + @{manageUser.username} · #{manageUser.id} + + + +
+ +
+
{manageUser.nickname}
+
{manageUser.email || '未填写邮箱'}
+
+ {manageUser.role === 'admin' && 站长} + {manageUser.role !== 'admin' && manageUser.verified && 认证} + {manageUser.banned && 禁言} + {manageUser.role !== 'admin' && !manageUser.verified && !manageUser.banned && ( + 普通用户 + )} +
+
+
+ +
+
登录与注册
+
+
+
上次登录
+
{fmtRel(manageUser.last_login_at)}
+
+
+
登录 IP
+
{manageUser.last_login_ip || '—'}
+
+
+
最近访问
+
{fmtRel(manageUser.last_access_at)}
+
+
+
注册时间
+
{fmtRel(manageUser.created_at)}
+
+
+
+ + {manageUser.role !== 'admin' && ( +
+
+ + 权限与状态 +
+
+ + +
+
+ )} + +
+
+ 等级 · 当前 Lv.{resolveUserLevel(manageUser)} +
+
+ setLevelVal(Number(e.target.value))} + aria-label="等级" + /> + +
+

经验 {manageUser.exp ?? 0};调整等级会同步 Exp

+
+ +
+
+ 积分 · 余额 {manageUser.points ?? 0} +
+
+ setPointsDelta(e.target.value)} + placeholder="正加负减" + aria-label="积分变动" + /> + +
+ setPointsNote(e.target.value)} + placeholder="备注(可选)" + aria-label="积分备注" + /> +
+ +
+
+ + 限定徽章 +
+ {limitedBadges.length === 0 ? ( +

暂无可用限定徽章,请先到「徽章管理」创建

+ ) : ( +
+ + +
+ )} + {badgeId !== '' && limitedBadges.find(b => b.id === badgeId) && ( +
+ {(() => { + const b = limitedBadges.find(x => x.id === badgeId)!; + const Icon = badgeIcon(b.icon); + return ( + <> + + {b.name} + {b.description || b.code} + + ); + })()} +
+ )} +
+ + + + + + )} +
+
+ + { if (!open) setBanTarget(null); }}> + + + + {banTarget?.banned ? '解除禁言' : '确认禁言'} + + + {banTarget?.banned + ? `确定解除对 ${banTarget?.nickname} 的禁言吗?` + : `确定禁言 ${banTarget?.nickname}?被禁言用户将无法发帖与评论。`} + + + + 取消 + + {banTarget?.banned ? '解除禁言' : '确认禁言'} + + + +
); } diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index a2d5bde..0b55af1 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -8353,20 +8353,77 @@ button.profile-stat:hover strong { } .admin-body { display: flex; height: calc(100vh - 56px); overflow: hidden; } .admin-sidebar { - width: 200px; flex-shrink: 0; padding: 16px 10px; border-right: 1px solid var(--j13-border); + width: 220px; flex-shrink: 0; padding: 12px 10px 20px; border-right: 1px solid var(--j13-border); background: hsl(var(--card)); overflow-y: auto; } +.admin-nav-group { margin-bottom: 14px; } +.admin-nav-group:last-child { margin-bottom: 0; } +.admin-nav-group-label { + padding: 6px 12px 6px; + font-size: 11px; font-weight: 600; letter-spacing: 0.04em; + text-transform: uppercase; color: hsl(var(--muted-foreground) / 0.85); +} .admin-nav-item { display: flex; align-items: center; gap: 8px; width: 100%; - padding: 9px 12px; margin-bottom: 2px; border-radius: 8px; font-size: 13px; - color: hsl(var(--muted-foreground)); text-decoration: none; transition: background .15s, color .15s; + padding: 8px 12px; margin-bottom: 2px; border-radius: 8px; font-size: 13px; + color: hsl(var(--muted-foreground)); text-decoration: none; + border-left: 2px solid transparent; + transition: background .15s, color .15s, border-color .15s; } +.admin-nav-icon { flex-shrink: 0; } +.admin-nav-text { flex: 1; min-width: 0; } +.admin-nav-badge { + flex-shrink: 0; min-width: 18px; height: 18px; padding: 0 5px; + border-radius: 999px; font-size: 11px; font-weight: 600; line-height: 18px; text-align: center; + background: hsl(24 90% 48% / 0.16); color: hsl(24 90% 40%); +} +.dark .admin-nav-badge { background: hsl(24 90% 55% / 0.22); color: hsl(24 95% 72%); } .admin-nav-item:hover { background: var(--j13-green-bg); color: var(--j13-green); } -.admin-nav-item.active { background: var(--j13-green-bg); color: var(--j13-green); font-weight: 600; } +.admin-nav-item.active { + background: var(--j13-green-bg); color: var(--j13-green); font-weight: 600; + border-left-color: var(--j13-green); +} .admin-main { flex: 1; min-width: 0; min-height: 0; padding: 24px; overflow-y: auto; } .admin-page-head { margin-bottom: 20px; } .admin-page-head h1 { font-size: 22px; font-weight: 700; margin-bottom: 4px; } .admin-page-head p { font-size: 13px; color: hsl(var(--muted-foreground)); } +.admin-section-label { + display: flex; align-items: center; gap: 8px; + margin-bottom: 10px; font-size: 13px; font-weight: 600; +} +.admin-section-muted { font-size: 12px; font-weight: 500; color: hsl(var(--muted-foreground)); } +.admin-queue-section { margin-bottom: 22px; } +.admin-queue-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 12px; +} +.admin-queue-card { + display: block; width: 100%; text-align: left; + padding: 14px 16px; border-radius: 10px; + border: 1px solid var(--j13-border); background: hsl(var(--card)); + cursor: pointer; transition: border-color .15s, background .15s, box-shadow .15s; +} +.admin-queue-card:hover { + border-color: var(--j13-green); background: var(--j13-green-bg); +} +.admin-queue-card.has-work { + border-color: hsl(24 80% 55% / 0.45); + background: hsl(24 90% 48% / 0.06); +} +.admin-queue-card.has-work:hover { + border-color: hsl(24 80% 50%); + background: hsl(24 90% 48% / 0.1); +} +.admin-queue-card-top { + display: flex; align-items: center; justify-content: space-between; + color: hsl(var(--muted-foreground)); margin-bottom: 8px; +} +.admin-queue-count { font-size: 22px; font-weight: 700; color: hsl(var(--foreground)); line-height: 1; } +.admin-queue-card.has-work .admin-queue-count { color: hsl(24 90% 40%); } +.dark .admin-queue-card.has-work .admin-queue-count { color: hsl(24 95% 68%); } +.admin-queue-label { font-size: 13px; font-weight: 600; } +.admin-queue-hint { font-size: 12px; color: hsl(var(--muted-foreground)); margin-top: 2px; line-height: 1.4; } +.admin-stat-section { margin-bottom: 8px; } .admin-stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; margin-bottom: 20px; @@ -9624,3 +9681,890 @@ a.pm-thread-head__name:hover { display: inline-flex; } } + +/* —— 用户徽章 / 等级 —— */ +.user-badges { + display: inline-flex; + align-items: center; + gap: 0.25rem; + margin-left: 0.35rem; + vertical-align: middle; + flex-wrap: wrap; +} +.user-badges--compact { + gap: 0.2rem; +} +.user-badge { + display: inline-flex; + align-items: center; + gap: 0.15rem; + font-size: 0.7rem; + font-weight: 600; + line-height: 1; + padding: 0.15rem 0.35rem; + border-radius: 0.25rem; + background: color-mix(in srgb, var(--j13-muted, #94a3b8) 18%, transparent); + color: var(--j13-text-secondary, #64748b); +} +.user-badge--owner { + background: color-mix(in srgb, #d97706 22%, transparent); + color: #b45309; +} +.user-badge--verified { + background: color-mix(in srgb, var(--j13-green, #16a34a) 20%, transparent); + color: var(--j13-green, #16a34a); +} +.user-badge--level-muted { color: #64748b; } +.user-badge--level-blue { + background: color-mix(in srgb, #2563eb 18%, transparent); + color: #1d4ed8; +} +.user-badge--level-amber { + background: color-mix(in srgb, #d97706 20%, transparent); + color: #b45309; +} +.user-badge--level-gold { + background: color-mix(in srgb, #ca8a04 22%, transparent); + color: #a16207; +} +.user-badge--ach { + padding: 0.15rem; +} + +/* —— 积分钱包 —— */ +.points-wallet { + margin-top: 1.25rem; + padding: 1rem 1.1rem; + border: 1px solid var(--j13-border, #e2e8f0); + border-radius: 0.5rem; + background: var(--j13-card, #fff); +} +.points-wallet-head { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.85rem; +} +.points-wallet-head h3 { + display: inline-flex; + align-items: center; + gap: 0.4rem; + margin: 0; + font-size: 1rem; +} +.points-wallet-balance { + display: flex; + align-items: baseline; + gap: 0.4rem; +} +.points-wallet-balance strong { + font-size: 1.5rem; + font-variant-numeric: tabular-nums; +} +.points-wallet-balance span { + color: var(--j13-text-secondary, #64748b); + font-size: 0.85rem; +} +.points-wallet-balance em { + font-style: normal; + font-size: 0.8rem; + color: var(--j13-text-secondary, #64748b); + margin-left: 0.5rem; +} +.points-wallet-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1rem; +} +.points-wallet-ledger h4 { + margin: 0 0 0.5rem; + font-size: 0.9rem; +} +.points-wallet-ledger ul { + list-style: none; + margin: 0; + padding: 0; +} +.points-wallet-ledger li { + display: grid; + grid-template-columns: 4rem 1fr auto; + gap: 0.5rem; + padding: 0.35rem 0; + border-top: 1px solid var(--j13-border, #e2e8f0); + font-size: 0.85rem; +} +.points-wallet-ledger .pos { color: var(--j13-green, #16a34a); font-weight: 600; } +.points-wallet-ledger .neg { color: #dc2626; font-weight: 600; } +.points-wallet-empty { + margin: 0; + color: var(--j13-text-secondary, #64748b); + font-size: 0.85rem; +} + +/* —— 积分可见区块 —— */ +.post-points-only__gate, +.post-points-only__badge { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} +.post-points-only--locked .post-points-only__gate { + margin: 0.75rem 0; + padding: 0.75rem 1rem; + border: 1px dashed color-mix(in srgb, #ca8a04 45%, transparent); + border-radius: 0.5rem; + background: color-mix(in srgb, #ca8a04 8%, transparent); +} +.post-points-only__gate-btn, +.post-points-only__remove-btn { + cursor: pointer; + border: 1px solid color-mix(in srgb, #ca8a04 40%, transparent); + background: #fff; + border-radius: 0.35rem; + padding: 0.3rem 0.65rem; + font-size: 0.85rem; +} +.post-points-only__cost { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.8rem; +} +.post-points-only__cost input { + width: 4rem; + padding: 0.15rem 0.3rem; + border: 1px solid var(--j13-border, #e2e8f0); + border-radius: 0.25rem; +} +.editor-points-only { + margin: 0.5rem 0; + padding: 0.5rem; + border: 1px solid color-mix(in srgb, #ca8a04 35%, transparent); + border-radius: 0.4rem; +} +.admin-user-actions { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +/* —— 用户管理:成员目录式布局 —— */ +.admin-users-page { + max-width: 920px; +} +.admin-users-page .admin-page-head { + margin-bottom: 28px; +} +.admin-users-page .admin-page-head h1 { + font-size: 26px; + letter-spacing: -0.02em; +} +.admin-users-page .admin-page-head p { + margin-top: 6px; + font-size: 14px; +} + +.admin-users-panel { + border: 1px solid var(--j13-border); + border-radius: 14px; + background: hsl(var(--card)); + overflow: hidden; +} +.admin-users-panel-head { + display: flex; + flex-direction: column; + gap: 14px; + padding: 18px 20px 16px; + border-bottom: 1px solid var(--j13-border); + background: linear-gradient( + 180deg, + hsl(var(--muted) / 0.35) 0%, + hsl(var(--card)) 100% + ); +} +.admin-users-search { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} +.admin-users-search-field { + position: relative; + flex: 1; + min-width: 200px; +} +.admin-users-search-icon { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + color: hsl(var(--muted-foreground)); + pointer-events: none; +} +.admin-users-search-field input { + height: 40px; + padding-left: 38px; + border-radius: 10px; + background: hsl(var(--background)); +} +.admin-users-filters { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.admin-users-filter { + height: 32px; + padding: 0 12px; + border: 1px solid transparent; + border-radius: 999px; + background: transparent; + font-size: 13px; + font-weight: 500; + color: hsl(var(--muted-foreground)); + cursor: pointer; + transition: background .15s, color .15s, border-color .15s; +} +.admin-users-filter:hover { + color: hsl(var(--foreground)); + background: hsl(var(--muted) / 0.5); +} +.admin-users-filter.active { + color: var(--j13-green); + background: var(--j13-green-bg); + border-color: color-mix(in srgb, var(--j13-green) 28%, transparent); +} + +.admin-users-cols { + display: none; + grid-template-columns: minmax(0, 1fr) 72px 72px 96px 72px; + gap: 12px; + padding: 10px 20px 8px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: hsl(var(--muted-foreground) / 0.9); +} +.admin-users-cols-who { padding-left: 56px; } +.admin-users-cols > span:not(.admin-users-cols-who):not(.admin-users-cols-action) { + text-align: right; +} + +.admin-users-list { + list-style: none; + margin: 0; + padding: 4px 0; +} +.admin-users-row { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 10px 12px; + align-items: center; + padding: 18px 20px; + border-bottom: 1px solid var(--j13-border); + transition: background .15s; +} +.admin-users-row:last-of-type { border-bottom: none; } +.admin-users-row:hover { background: hsl(var(--muted) / 0.28); } +.admin-users-row--banned { + background: hsl(0 70% 50% / 0.035); +} +.admin-users-row--banned:hover { + background: hsl(0 70% 50% / 0.06); +} + +.admin-users-who { + display: flex; + align-items: flex-start; + gap: 14px; + min-width: 0; +} +.admin-users-who .admin-user-avatar { + width: 44px; + height: 44px; + margin-top: 1px; + box-shadow: 0 0 0 1px var(--j13-border); +} +.admin-users-who-text { min-width: 0; } +.admin-users-who-line { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} +.admin-user-nick { + border: none; + background: none; + padding: 0; + font-size: 15px; + font-weight: 600; + letter-spacing: -0.01em; + color: hsl(var(--foreground)); + cursor: pointer; + line-height: 1.3; +} +.admin-user-nick:hover { + color: var(--j13-green); +} +.admin-users-handle { + margin-top: 3px; + font-size: 13px; + color: hsl(var(--muted-foreground)); +} +.admin-users-mail { + margin-top: 2px; + font-size: 12.5px; + color: hsl(var(--muted-foreground) / 0.92); + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-users-metric { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.admin-users-metric::before { + content: attr(data-label); + font-size: 11px; + font-weight: 500; + color: hsl(var(--muted-foreground)); +} +.admin-users-metric-value { + font-size: 14px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: hsl(var(--foreground)); +} +.admin-users-action { + display: flex; + justify-content: flex-start; +} +.admin-users-manage-btn { + gap: 4px; + color: hsl(var(--muted-foreground)); +} +.admin-users-manage-btn:hover { + color: var(--j13-green); + background: var(--j13-green-bg); +} + +.admin-users-empty { + padding: 56px 24px; + text-align: center; + font-size: 14px; + color: hsl(var(--muted-foreground)); +} +.admin-users-footer { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 20px; + border-top: 1px solid var(--j13-border); + background: hsl(var(--muted) / 0.18); +} +.admin-users-total { + font-size: 13px; + color: hsl(var(--muted-foreground)); +} +.admin-users-pager { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: hsl(var(--muted-foreground)); +} + +.admin-user-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; + background: hsl(var(--muted)); +} +.admin-user-avatar--fallback { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 600; + color: var(--j13-green); + background: var(--j13-green-bg); +} +.admin-user-badges { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +.admin-user-email { + font-size: 12px; + color: hsl(var(--muted-foreground)); + margin-top: 2px; + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-user-manage-dialog { gap: 0; } +.admin-user-manage-head { + display: flex; + align-items: center; + gap: 12px; + margin: 8px 0 16px; +} +.admin-user-manage-head .admin-user-avatar { + width: 52px; + height: 52px; +} +.admin-user-manage-name { font-weight: 600; font-size: 16px; letter-spacing: -0.01em; } +.admin-user-manage-section { + padding: 14px 0; + border-top: 1px solid var(--j13-border); +} +.admin-user-manage-section-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 600; + margin-bottom: 10px; +} +.admin-user-fact-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px 14px; + margin: 0; +} +.admin-user-fact-grid dt { + font-size: 11px; + font-weight: 500; + color: hsl(var(--muted-foreground)); + margin-bottom: 2px; +} +.admin-user-fact-grid dd { + margin: 0; + font-size: 13px; + font-weight: 500; +} +.admin-user-manage-actions, +.admin-user-manage-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} +.admin-user-manage-row > input, +.admin-user-manage-row > .admin-user-select { flex: 1; min-width: 120px; } +.admin-user-manage-hint { + margin-top: 6px; + font-size: 12px; + color: hsl(var(--muted-foreground)); + line-height: 1.4; +} +.admin-user-select { + height: 36px; + border-radius: 8px; + border: 1px solid hsl(var(--input)); + background: hsl(var(--background)); + padding: 0 10px; + font-size: 13px; + color: hsl(var(--foreground)); +} +.admin-user-badge-preview { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 8px; + padding: 8px 10px; + border-radius: 8px; + background: hsl(var(--muted) / 0.35); + font-size: 13px; +} +.admin-user-badge-preview .admin-user-manage-hint { margin: 0; } + +@media (min-width: 760px) { + .admin-users-cols { display: grid; } + .admin-users-row { + grid-template-columns: minmax(0, 1fr) 72px 72px 96px 72px; + gap: 12px; + padding: 16px 20px; + } + .admin-users-metric::before { display: none; } + .admin-users-metric { + align-items: flex-end; + text-align: right; + } + .admin-users-metric-value { font-size: 13.5px; font-weight: 500; } + .admin-users-metric--time .admin-users-metric-value { + font-weight: 500; + color: hsl(var(--muted-foreground)); + } + .admin-users-action { justify-content: flex-end; } + .admin-users-mail { max-width: 220px; } +} + +@media (max-width: 759px) { + .admin-users-row { + padding: 16px 16px 18px; + grid-template-columns: 1fr 1fr 1fr; + } + .admin-users-who { + grid-column: 1 / -1; + } + .admin-users-metric { + padding-top: 4px; + } + .admin-users-action { + grid-column: 1 / -1; + justify-content: flex-start; + padding-top: 4px; + } + .admin-users-panel-head { padding: 16px; } +} +.admin-badge-form { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 0.75rem 1rem; +} +.admin-badge-form label { + display: flex; + flex-direction: column; + gap: 0.3rem; + font-size: 0.85rem; +} +.admin-badge-form-check { + flex-direction: row !important; + align-items: center; + gap: 0.4rem !important; + margin-top: 1.4rem; +} +.admin-select { + height: 2.25rem; + border: 1px solid var(--j13-border, #e2e8f0); + border-radius: 0.35rem; + padding: 0 0.5rem; + background: var(--j13-card, #fff); + width: 100%; +} + +/* —— 徽章管理(卡片式) —— */ +.admin-badge-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.75rem; + margin-bottom: 1rem; +} +.admin-badge-stat { + padding: 0.85rem 1rem; + border: 1px solid var(--j13-border, #e2e8f0); + border-radius: 0.55rem; + background: var(--j13-card, #fff); +} +.admin-badge-stat strong { + display: block; + font-size: 1.35rem; + font-variant-numeric: tabular-nums; + line-height: 1.2; +} +.admin-badge-stat span { + font-size: 0.78rem; + color: hsl(var(--muted-foreground)); +} +.admin-badge-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 1rem; +} +.admin-badge-search { + position: relative; + min-width: min(100%, 240px); + flex: 1 1 220px; + max-width: 320px; +} +.admin-badge-search > svg { + position: absolute; + left: 0.7rem; + top: 50%; + transform: translateY(-50%); + color: hsl(var(--muted-foreground)); + pointer-events: none; +} +.admin-badge-search input { + padding-left: 2rem; +} +.admin-badge-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 0.9rem; +} +.admin-badge-card { + display: flex; + flex-direction: column; + gap: 0.7rem; + padding: 1rem 1.05rem; + border: 1px solid var(--j13-border, #e2e8f0); + border-radius: 0.7rem; + background: var(--j13-card, #fff); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} +.admin-badge-card:hover { + border-color: color-mix(in srgb, var(--j13-green, #16a34a) 35%, var(--j13-border, #e2e8f0)); + box-shadow: 0 6px 20px hsl(var(--foreground) / 0.04); +} +.admin-badge-card.is-disabled { + opacity: 0.72; +} +.admin-badge-card-top { + display: flex; + gap: 0.75rem; + align-items: flex-start; +} +.admin-badge-preview { + width: 2.75rem; + height: 2.75rem; + border-radius: 0.7rem; + display: grid; + place-items: center; + flex-shrink: 0; + color: #1d4ed8; + background: color-mix(in srgb, #2563eb 14%, transparent); + border: 1px solid color-mix(in srgb, #2563eb 22%, transparent); +} +.admin-badge-preview.is-limited { + color: #b45309; + background: color-mix(in srgb, #d97706 16%, transparent); + border-color: color-mix(in srgb, #d97706 28%, transparent); +} +.admin-badge-preview--lg { + width: 3.25rem; + height: 3.25rem; + border-radius: 0.85rem; +} +.admin-badge-card-meta { + min-width: 0; + flex: 1; +} +.admin-badge-card-title-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; +} +.admin-badge-card-title-row h3 { + margin: 0; + font-size: 0.98rem; + font-weight: 600; + line-height: 1.3; +} +.admin-badge-code { + display: inline-block; + margin-top: 0.2rem; + font-size: 0.72rem; + color: hsl(var(--muted-foreground)); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} +.admin-badge-desc { + margin: 0; + font-size: 0.84rem; + line-height: 1.45; + color: hsl(var(--muted-foreground)); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.admin-badge-card-foot { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.55rem; + padding-top: 0.35rem; + border-top: 1px solid var(--j13-border, #e2e8f0); +} +.admin-badge-condition { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.78rem; + color: hsl(var(--muted-foreground)); + max-width: 100%; +} +.admin-badge-card-actions { + display: inline-flex; + align-items: center; + gap: 0.45rem; + margin-left: auto; +} +.admin-badge-empty { + text-align: center; + padding: 3.5rem 1.5rem; + border: 1px dashed var(--j13-border, #e2e8f0); + border-radius: 0.75rem; + color: hsl(var(--muted-foreground)); +} +.admin-badge-empty h3 { + margin: 0.75rem 0 0.35rem; + font-size: 1.05rem; + color: hsl(var(--foreground)); +} +.admin-badge-empty p { + margin: 0 0 1rem; + font-size: 0.9rem; +} +.admin-badge-dialog-preview { + display: flex; + align-items: center; + gap: 0.85rem; + padding: 0.85rem 1rem; + border-radius: 0.65rem; + background: hsl(var(--muted) / 0.35); + border: 1px solid var(--j13-border, #e2e8f0); +} +.admin-badge-dialog-preview-name { + font-weight: 600; + font-size: 1rem; +} +.admin-badge-dialog-preview-cond { + margin-top: 0.15rem; + font-size: 0.8rem; + color: hsl(var(--muted-foreground)); +} +.admin-badge-dialog-fields { + display: flex; + flex-direction: column; + gap: 0.9rem; +} +.admin-badge-kind-seg { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.4rem; + padding: 0.25rem; + border-radius: 0.55rem; + background: hsl(var(--muted) / 0.45); +} +.admin-badge-kind-seg button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + height: 2.15rem; + border: none; + border-radius: 0.4rem; + background: transparent; + font-size: 0.86rem; + font-weight: 550; + color: hsl(var(--muted-foreground)); + cursor: pointer; +} +.admin-badge-kind-seg button.active { + background: var(--j13-card, #fff); + color: hsl(var(--foreground)); + box-shadow: 0 1px 3px hsl(var(--foreground) / 0.08); +} +.admin-badge-field { + display: flex; + flex-direction: column; + gap: 0.35rem; + min-width: 0; +} +.admin-badge-field-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} +.admin-badge-field-row--end { + align-items: end; +} +.admin-badge-field-hint { + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); + line-height: 1.35; +} +.admin-badge-icon-picker { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(2.4rem, 1fr)); + gap: 0.35rem; +} +.admin-badge-icon-opt { + height: 2.4rem; + display: grid; + place-items: center; + border-radius: 0.45rem; + border: 1px solid var(--j13-border, #e2e8f0); + background: var(--j13-card, #fff); + color: hsl(var(--muted-foreground)); + cursor: pointer; +} +.admin-badge-icon-opt:hover { + border-color: color-mix(in srgb, var(--j13-green, #16a34a) 40%, var(--j13-border)); + color: hsl(var(--foreground)); +} +.admin-badge-icon-opt.active { + border-color: var(--j13-green, #16a34a); + color: var(--j13-green, #16a34a); + background: color-mix(in srgb, var(--j13-green, #16a34a) 10%, transparent); +} +.admin-badge-enable-row { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-size: 0.88rem; + min-height: 2.25rem; + cursor: pointer; +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (max-width: 720px) { + .admin-badge-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .admin-badge-field-row { + grid-template-columns: 1fr; + } + .admin-badge-toolbar { + flex-direction: column; + align-items: stretch; + } + .admin-badge-search { + max-width: none; + } +} +.article-tool-btn--points.is-active, +.article-tool-btn--points[aria-pressed='true'] { + color: #a16207; +} +.profile-badge-wall { + margin: 0.75rem 0 0.25rem; +} +.profile-badge-wall-title { + margin: 0 0 0.4rem; + font-size: 0.9rem; + font-weight: 600; +} + diff --git a/frontend/src/utils/badgeIcons.ts b/frontend/src/utils/badgeIcons.ts new file mode 100644 index 0000000..8081f53 --- /dev/null +++ b/frontend/src/utils/badgeIcons.ts @@ -0,0 +1,64 @@ +import { + Award, + Calendar, + CalendarHeart, + Coins, + Flame, + Gem, + Heart, + HeartHandshake, + Medal, + Shield, + Sparkles, + Star, + Trophy, + type LucideIcon, +} from 'lucide-react'; + +/** 徽章可用图标(与前端展示、后台选择器一致) */ +export const BADGE_ICON_OPTIONS: { key: string; label: string; Icon: LucideIcon }[] = [ + { key: 'star', label: '星星', Icon: Star }, + { key: 'award', label: '奖章', Icon: Award }, + { key: 'medal', label: '勋章', Icon: Medal }, + { key: 'trophy', label: '奖杯', Icon: Trophy }, + { key: 'sparkles', label: '闪光', Icon: Sparkles }, + { key: 'shield', label: '盾牌', Icon: Shield }, + { key: 'heart', label: '爱心', Icon: Heart }, + { key: 'heart-handshake', label: '人心', Icon: HeartHandshake }, + { key: 'flame', label: '火焰', Icon: Flame }, + { key: 'coins', label: '金币', Icon: Coins }, + { key: 'gem', label: '宝石', Icon: Gem }, + { key: 'calendar', label: '日历', Icon: Calendar }, + { key: 'calendar-heart', label: '纪念日', Icon: CalendarHeart }, +]; + +const ICON_MAP: Record = Object.fromEntries( + BADGE_ICON_OPTIONS.map(o => [o.key, o.Icon]), +); + +export function badgeIcon(key?: string): LucideIcon { + if (!key) return Star; + return ICON_MAP[key] || Star; +} + +export const BADGE_METRIC_OPTIONS: { value: string; label: string; hint: string }[] = [ + { value: 'tenure_days', label: '注册天数', hint: '账号注册满 N 天自动获得' }, + { value: 'likes_received', label: '帖子获赞', hint: '公开帖累计获赞达到 N' }, + { value: 'creator_income', label: '创作收入', hint: '积分解锁分成累计达到 N' }, +]; + +export function metricLabel(metric?: string): string { + return BADGE_METRIC_OPTIONS.find(m => m.value === metric)?.label || metric || '—'; +} + +export function formatBadgeCondition(b: { + kind?: string; + metric?: string; + threshold?: number; + description?: string; +}): string { + if (b.kind === 'auto') { + return `${metricLabel(b.metric)} ≥ ${b.threshold ?? 0}`; + } + return b.description?.trim() || '由站长手动颁发'; +} diff --git a/frontend/src/utils/postContent.ts b/frontend/src/utils/postContent.ts index 7bb6fe5..69debf6 100644 --- a/frontend/src/utils/postContent.ts +++ b/frontend/src/utils/postContent.ts @@ -9,9 +9,9 @@ import { enhanceHeadingAnchors } from './postHeadings'; * 全局选择器仍会污染整页,故显式禁止。 */ export const POST_CONTENT_PURIFY_CONFIG: Config = { - ADD_TAGS: ['members-only', 'reply-only'], + ADD_TAGS: ['members-only', 'reply-only', 'points-only'], ADD_ATTR: [ - 'data-locked', 'data-length', 'data-gate', 'target', 'rel', + 'data-locked', 'data-length', 'data-gate', 'data-cost', 'data-block-key', 'target', 'rel', 'data-code-copy', 'data-code-fold', 'data-lang', 'data-full', 'data-code-style', 'data-line-numbers', 'data-collapsed', 'data-line-count', 'data-lineno-digits', 'data-image-group', 'data-layout', 'data-display', @@ -27,6 +27,29 @@ const LOCK_ICON_SVG = ``; +/** 积分解锁门控 */ +function buildPointsLockedGateHtml(charLength: number, cost: number, blockKey: string, isLoggedIn: boolean): string { + const lengthHint = charLength > 0 ? `约 ${charLength} 字` : '付费内容'; + const actions = isLoggedIn + ? `` + : ` + `; + + return ` +
+
+ +
+

积分可见(${lengthHint})

+

解锁需 ${cost} 积分,作者获得分成

+
+
+ ${actions} +
+
+
`; +} + /** 游客看到的锁定区块:流内嵌条 + 登录引导(精简高度) */ function buildLockedGateHtml(charLength: number): string { const lengthHint = charLength > 0 @@ -150,6 +173,27 @@ export function renderPostContentHtml( el.innerHTML = `
${innerHtml}
`; }); + doc.querySelectorAll('points-only').forEach(el => { + const locked = el.getAttribute('data-locked') === 'true'; + const cost = parseInt(el.getAttribute('data-cost') || '10', 10) || 10; + const blockKey = el.getAttribute('data-block-key') || ''; + + if (locked) { + const charLength = parseInt(el.getAttribute('data-length') || '0', 10) || 0; + el.className = 'post-points-only post-points-only--locked'; + el.innerHTML = buildPointsLockedGateHtml(charLength, cost, blockKey, isLoggedIn); + return; + } + + const innerHtml = extractGatedInnerHtml( + el, + 'post-points-only__body', + 'post-points-only__badge', + ); + el.className = 'post-points-only post-points-only--visible'; + el.innerHTML = `
${innerHtml}
`; + }); + doc.querySelectorAll('img').forEach(img => { if (!img.getAttribute('loading')) img.setAttribute('loading', 'lazy'); if (!img.getAttribute('decoding')) img.setAttribute('decoding', 'async'); diff --git a/frontend/src/utils/userMeta.ts b/frontend/src/utils/userMeta.ts new file mode 100644 index 0000000..0a13fa1 --- /dev/null +++ b/frontend/src/utils/userMeta.ts @@ -0,0 +1,20 @@ +/** 等级门槛(与后端 model.LevelThresholds 一致) */ +export const LEVEL_THRESHOLDS = [0, 20, 50, 100, 200, 400, 800, 1500, 3000, 5000]; + +export function levelFromExp(exp = 0): number { + let level = 1; + for (let i = 0; i < LEVEL_THRESHOLDS.length; i += 1) { + if (exp >= LEVEL_THRESHOLDS[i]) level = i + 1; + } + return level; +} + +export function resolveUserLevel(u?: { level?: number; exp?: number } | null): number { + if (u?.level && u.level > 0) return u.level; + return levelFromExp(u?.exp ?? 0); +} + +/** 是否免审发帖/评论 */ +export function skipsModeration(u?: { role?: string; verified?: boolean } | null): boolean { + return !!u && (u.role === 'admin' || !!u.verified); +} diff --git a/handler/api.go b/handler/api.go index cdba00c..e783d63 100644 --- a/handler/api.go +++ b/handler/api.go @@ -29,8 +29,17 @@ func (h *Handlers) APIMe(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"user": nil}) return } + if h.Badge != nil { + _ = h.Badge.EvaluateAuto(user.ID) + } + view := user.ToSelf() + if h.Badge != nil { + if badges, bErr := h.Badge.ListUserBadges(user.ID); bErr == nil { + view.Badges = service.BadgeViews(badges, 0) + } + } c.JSON(http.StatusOK, gin.H{ - "user": user.ToSelf(), + "user": view, }) } @@ -123,6 +132,9 @@ func (h *Handlers) APIAdminDashboard(c *gin.Context) { model.DB.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished).Count(&postCount) model.DB.Model(&model.Board{}).Count(&boardCount) model.DB.Model(&model.Comment{}).Where("status = ?", model.ContentStatusPublished).Count(&commentCount) + pendingPosts, _ := h.Post.PendingPostCount() + pendingComments, _ := h.Comment.PendingCommentCount() + pendingReports, _ := h.Report.PendingCount() recentPosts, _, _ := h.Post.List(service.PostListQuery{ Page: 1, Size: 8, ViewerIsAdmin: true, Status: "all", }) @@ -132,7 +144,10 @@ func (h *Handlers) APIAdminDashboard(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "users": userCount, "posts": postCount, "boards": boardCount, "comments": commentCount, - "recent_posts": recentPosts, + "pending_posts": pendingPosts, + "pending_comments": pendingComments, + "pending_reports": pendingReports, + "recent_posts": recentPosts, }) } @@ -375,7 +390,11 @@ func (h *Handlers) APIAdminCommentRevisions(c *gin.Context) { func (h *Handlers) APIAdminUsers(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) - users, total, err := h.User.ListUsers(page, size) + keyword := strings.TrimSpace(c.Query("keyword")) + filter := strings.TrimSpace(c.DefaultQuery("filter", "all")) + users, total, err := h.User.ListUsers(service.UserListQuery{ + Page: page, Size: size, Keyword: keyword, Filter: filter, + }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -386,6 +405,8 @@ func (h *Handlers) APIAdminUsers(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "users": model.UsersToAdmin(users), "total": total, "page": page, "total_pages": calcTotalPages(total, size), + "keyword": keyword, + "filter": filter, }) } @@ -853,6 +874,15 @@ func (h *Handlers) APIPosts(c *gin.Context) { if items == nil { items = []service.PostListItem{} } + if h.Badge != nil { + users := make([]*model.User, 0, len(items)) + for i := range items { + if items[i].User.ID > 0 { + users = append(users, &items[i].User) + } + } + h.Badge.AttachBadgeSummaries(users, 2) + } c.JSON(http.StatusOK, gin.H{ "posts": items, "total": total, @@ -889,7 +919,20 @@ func (h *Handlers) APIPostDetail(c *gin.Context) { // 作者与管理员始终可见;其他用户需已回复 post.Content = service.RedactReplyOnlyHTML(post.Content) } + // 积分解锁块:作者/站长全文;其他人按解锁记录 redact + if isAdmin || post.UserID == uid { + post.Content = service.RevealAllPointsOnly(post.Content) + } else { + unlocked, _ := service.ListUnlockedKeys(uid, uint(id)) + post.Content = service.RedactPointsOnlyHTML(post.Content, unlocked) + } comments, _ := h.Comment.ListByPost(uint(id), uid, isAdmin, post.UserID, h.parseGuestCommentIDs(c)) + if h.Badge != nil { + if post.User.ID > 0 { + h.Badge.AttachBadgeSummaries([]*model.User{&post.User}, 3) + } + h.Badge.AttachBadgeSummariesOnComments(comments, 2) + } canEdit := h.Post.CanUserEdit(post, uid, isAdmin) editReason := "" if !canEdit && uid > 0 { @@ -931,6 +974,9 @@ func (h *Handlers) APIPostComments(c *gin.Context) { if comments == nil { comments = []model.Comment{} } + if h.Badge != nil { + h.Badge.AttachBadgeSummariesOnComments(comments, 2) + } c.JSON(http.StatusOK, gin.H{"comments": comments, "total": len(comments)}) } diff --git a/handler/economy.go b/handler/economy.go new file mode 100644 index 0000000..e97d2a6 --- /dev/null +++ b/handler/economy.go @@ -0,0 +1,208 @@ +package handler + +import ( + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "git.iioio.com/freefire/jiang13-forum/model" + "git.iioio.com/freefire/jiang13-forum/service" +) + +// APIMePoints 余额与流水 +func (h *Handlers) APIMePoints(c *gin.Context) { + uid := h.currentUserID(c) + user, err := h.User.GetByID(uid) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) + rows, total, err := h.Points.ListLedger(uid, page, size) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + checkIn, _ := h.Points.GetCheckInStatus(uid) + lottery, _ := h.Points.GetLotteryStatus(uid) + c.JSON(http.StatusOK, gin.H{ + "points": user.Points, + "creator_income_total": user.CreatorIncomeTotal, + "ledger": rows, + "total": total, + "page": page, + "total_pages": calcTotalPages(total, size), + "check_in": checkIn, + "lottery": lottery, + }) +} + +// APIMeCheckIn 每日签到 +func (h *Handlers) APIMeCheckIn(c *gin.Context) { + st, err := h.Points.CheckIn(h.currentUserID(c)) + if err != nil { + if errors.Is(err, service.ErrAlreadyCheckedIn) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + user, _ := h.User.GetByID(h.currentUserID(c)) + pts := 0 + if user != nil { + pts = user.Points + } + c.JSON(http.StatusOK, gin.H{"message": "签到成功", "check_in": st, "points": pts}) +} + +// APIMeLottery GET 状态 / POST 抽奖 +func (h *Handlers) APIMeLotteryGet(c *gin.Context) { + st, err := h.Points.GetLotteryStatus(h.currentUserID(c)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"lottery": st}) +} + +func (h *Handlers) APIMeLotteryDraw(c *gin.Context) { + st, err := h.Points.DrawLottery(h.currentUserID(c)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + user, _ := h.User.GetByID(h.currentUserID(c)) + pts := 0 + if user != nil { + pts = user.Points + } + c.JSON(http.StatusOK, gin.H{"message": "抽奖完成", "lottery": st, "points": pts}) +} + +// APIUnlockPostBlock 积分解锁付费块 +func (h *Handlers) APIUnlockPostBlock(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var req struct { + BlockKey string `json:"block_key"` + } + if err := c.ShouldBindJSON(&req); err != nil || req.BlockKey == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 block_key"}) + return + } + res, err := service.UnlockPointsBlock(h.currentUserID(c), uint(id), req.BlockKey) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "解锁成功", "unlock": res}) +} + +// APIAdminVerifyUser 认证开关 +func (h *Handlers) APIAdminVerifyUser(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var req struct { + Verified bool `json:"verified"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) + return + } + if err := service.SetVerified(uint(id), req.Verified); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + msg := "已取消认证" + if req.Verified { + msg = "已认证" + } + c.JSON(http.StatusOK, gin.H{"message": msg, "verified": req.Verified}) +} + +// APIAdminSetUserLevel 设等级 +func (h *Handlers) APIAdminSetUserLevel(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var req struct { + Level int `json:"level"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) + return + } + if err := service.SetUserLevel(uint(id), req.Level); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "等级已更新", "level": req.Level, "exp": model.ExpForLevel(req.Level)}) +} + +// APIAdminAdjustPoints 调积分 +func (h *Handlers) APIAdminAdjustPoints(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var req struct { + Delta int `json:"delta"` + Note string `json:"note"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) + return + } + bal, err := h.Points.AdminAdjust(uint(id), req.Delta, req.Note) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "积分已调整", "points": bal}) +} + +// APIAdminListBadges 徽章定义列表 +func (h *Handlers) APIAdminListBadges(c *gin.Context) { + rows, err := h.Badge.ListDefs(true) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"badges": rows}) +} + +// APIAdminUpsertBadge 创建/更新徽章定义 +func (h *Handlers) APIAdminUpsertBadge(c *gin.Context) { + var def model.BadgeDef + if err := c.ShouldBindJSON(&def); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) + return + } + if err := h.Badge.UpsertDef(&def); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "已保存", "badge": def}) +} + +// APIAdminAwardBadge 颁发/收回限定徽章 +func (h *Handlers) APIAdminAwardBadge(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var req struct { + BadgeID uint `json:"badge_id"` + Revoke bool `json:"revoke"` + } + if err := c.ShouldBindJSON(&req); err != nil || req.BadgeID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) + return + } + if req.Revoke { + if err := h.Badge.Revoke(uint(id), req.BadgeID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "已收回徽章"}) + return + } + if err := h.Badge.AwardLimited(uint(id), req.BadgeID, h.currentUserID(c)); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "已颁发徽章"}) +} diff --git a/handler/handlers.go b/handler/handlers.go index 20a177b..64bf271 100644 --- a/handler/handlers.go +++ b/handler/handlers.go @@ -2,6 +2,7 @@ package handler import ( "encoding/base64" + "errors" "fmt" "net/http" "strconv" @@ -35,6 +36,8 @@ type Handlers struct { EmailCode *service.EmailCodeService OIDC *service.OIDCService Gitea *service.GiteaService + Points *service.PointsService + Badge *service.BadgeService } func (h *Handlers) setAuthCookie(c *gin.Context, token string) { @@ -55,6 +58,23 @@ func (h *Handlers) isAdmin(c *gin.Context) bool { return false } +// loadCurrentUser 加载当前登录用户完整资料(含认证/积分) +func (h *Handlers) loadCurrentUser(c *gin.Context) (*model.User, error) { + uid := h.currentUserID(c) + if uid == 0 { + return nil, errors.New("未登录") + } + return h.User.GetByID(uid) +} + +func (h *Handlers) skipsModeration(c *gin.Context) bool { + u, err := h.loadCurrentUser(c) + if err != nil { + return h.isAdmin(c) + } + return u.SkipsModeration() +} + func (h *Handlers) parseGuestCommentIDs(c *gin.Context) []uint { raw := c.Query("my_ids") if raw == "" { @@ -226,8 +246,15 @@ func (h *Handlers) APIUserPublic(c *gin.Context) { if viewerID != user.ID { st.FavoriteCount = 0 } + view := user.ToPublic() + if h.Badge != nil { + _ = h.Badge.EvaluateAuto(user.ID) + if badges, bErr := h.Badge.ListUserBadges(user.ID); bErr == nil { + view.Badges = service.BadgeViews(badges, 0) + } + } c.JSON(http.StatusOK, gin.H{ - "user": user.ToPublic(), + "user": view, "stats": st, }) } @@ -315,7 +342,8 @@ func (h *Handlers) APICreatePost(c *gin.Context) { content := c.PostForm("content") tags := c.PostForm("tags") postType := c.PostForm("post_type") - post, err := h.Post.Create(h.currentUserID(c), uint(boardID), title, content, tags, postType, h.isAdmin(c)) + skip := h.skipsModeration(c) + post, err := h.Post.Create(h.currentUserID(c), uint(boardID), title, content, tags, postType, skip) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -334,14 +362,15 @@ func (h *Handlers) APIUpdatePost(c *gin.Context) { id, _ := strconv.ParseUint(c.Param("id"), 10, 64) boardID, _ := strconv.ParseUint(c.PostForm("board_id"), 10, 64) isAdmin := h.isAdmin(c) - err := h.Post.Update(h.currentUserID(c), uint(id), isAdmin, + skip := h.skipsModeration(c) + err := h.Post.Update(h.currentUserID(c), uint(id), isAdmin, skip, c.PostForm("title"), c.PostForm("content"), c.PostForm("tags"), c.PostForm("post_type"), uint(boardID)) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - // 普通用户修改后重新进入审核 - if !isAdmin && h.Notify != nil { + // 非免审用户修改后重新进入审核 + if !skip && h.Notify != nil { if post, getErr := h.Post.FindByID(uint(id)); getErr == nil { h.Notify.AsyncNotifyPendingPost(post) } @@ -461,7 +490,8 @@ func (h *Handlers) APIDeleteComment(c *gin.Context) { func (h *Handlers) APIUpdateComment(c *gin.Context) { id, _ := strconv.ParseUint(c.Param("id"), 10, 64) content := c.PostForm("content") - saved, enteredPending, err := h.Comment.Update(h.currentUserID(c), uint(id), h.isAdmin(c), content) + skip := h.skipsModeration(c) + saved, enteredPending, err := h.Comment.Update(h.currentUserID(c), uint(id), h.isAdmin(c), skip, content) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return diff --git a/middleware/auth.go b/middleware/auth.go index 1d9f1ba..2df0ac4 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -39,6 +39,7 @@ func (m *AuthMiddleware) OptionalAuth() gin.HandlerFunc { c.Set(CtxUserID, user.ID) c.Set(CtxUsername, user.Username) c.Set(CtxRole, user.Role) + m.auth.TouchLastAccess(user.ID) } } } @@ -71,6 +72,7 @@ func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc { c.Set(CtxUserID, claims.UserID) c.Set(CtxUsername, claims.Username) c.Set(CtxRole, claims.Role) + m.auth.TouchLastAccess(claims.UserID) c.Next() } } diff --git a/model/db.go b/model/db.go index 6e5cf61..118299a 100644 --- a/model/db.go +++ b/model/db.go @@ -40,6 +40,8 @@ func InitDB(dbPath string) error { &GiteaRepo{}, &PrivateMessage{}, &PostReport{}, &Media{}, + &PointLedger{}, &CheckIn{}, &LotteryDraw{}, &PostContentUnlock{}, + &BadgeDef{}, &UserBadge{}, ); err != nil { return fmt.Errorf("自动迁移失败: %w", err) } @@ -50,6 +52,47 @@ func InitDB(dbPath string) error { _ = db.Model(&Post{}).Where("post_type = '' OR post_type IS NULL").Update("post_type", PostTypeNormal).Error DB = db + seedDefaultBadges(db) + backfillUserExp(db) log.Println("[model] SQLite 数据库初始化完成:", dbPath) return nil } + +// seedDefaultBadges 写入内置自动徽章(已存在则跳过) +func seedDefaultBadges(db *gorm.DB) { + defs := []BadgeDef{ + {Code: "tenure_30", Name: "初来乍到", Description: "注册满 30 天", Icon: "calendar", Kind: BadgeKindAuto, Metric: BadgeMetricTenureDays, Threshold: 30, SortOrder: 10, Enabled: true}, + {Code: "tenure_365", Name: "资深居民", Description: "注册满 365 天", Icon: "calendar-heart", Kind: BadgeKindAuto, Metric: BadgeMetricTenureDays, Threshold: 365, SortOrder: 20, Enabled: true}, + {Code: "likes_10", Name: "小有人气", Description: "帖子获赞累计 10", Icon: "heart", Kind: BadgeKindAuto, Metric: BadgeMetricLikesReceived, Threshold: 10, SortOrder: 30, Enabled: true}, + {Code: "likes_100", Name: "人气作者", Description: "帖子获赞累计 100", Icon: "heart-handshake", Kind: BadgeKindAuto, Metric: BadgeMetricLikesReceived, Threshold: 100, SortOrder: 40, Enabled: true}, + {Code: "likes_1000", Name: "人气巨星", Description: "帖子获赞累计 1000", Icon: "flame", Kind: BadgeKindAuto, Metric: BadgeMetricLikesReceived, Threshold: 1000, SortOrder: 50, Enabled: true}, + {Code: "income_100", Name: "小有进账", Description: "创作分成累计 100 积分", Icon: "coins", Kind: BadgeKindAuto, Metric: BadgeMetricCreatorIncome, Threshold: 100, SortOrder: 60, Enabled: true}, + {Code: "income_1000", Name: "创作达人", Description: "创作分成累计 1000 积分", Icon: "gem", Kind: BadgeKindAuto, Metric: BadgeMetricCreatorIncome, Threshold: 1000, SortOrder: 70, Enabled: true}, + } + for _, d := range defs { + var n int64 + db.Model(&BadgeDef{}).Where("code = ?", d.Code).Count(&n) + if n == 0 { + _ = db.Create(&d).Error + } + } +} + +// backfillUserExp 对 Exp 仍为 0 的用户按存量公开内容粗算经验(仅补一次量级) +func backfillUserExp(db *gorm.DB) { + var users []User + if err := db.Select("id", "exp").Where("exp = 0").Find(&users).Error; err != nil { + return + } + for _, u := range users { + var posts, comments int64 + var likeSum int64 + _ = db.Model(&Post{}).Where("user_id = ? AND status = ?", u.ID, ContentStatusPublished).Count(&posts).Error + _ = db.Model(&Comment{}).Where("user_id = ? AND status = ?", u.ID, ContentStatusPublished).Count(&comments).Error + _ = db.Model(&Post{}).Select("COALESCE(SUM(like_count), 0)").Where("user_id = ? AND status = ?", u.ID, ContentStatusPublished).Scan(&likeSum).Error + exp := int(posts)*10 + int(comments)*2 + int(likeSum) + if exp > 0 { + _ = db.Model(&User{}).Where("id = ? AND exp = 0", u.ID).Update("exp", exp).Error + } + } +} diff --git a/model/level.go b/model/level.go new file mode 100644 index 0000000..a2644f5 --- /dev/null +++ b/model/level.go @@ -0,0 +1,34 @@ +package model + +// LevelThresholds 各等级所需最低 Exp(下标 0 对应 Lv1) +var LevelThresholds = []int{0, 20, 50, 100, 200, 400, 800, 1500, 3000, 5000} + +// LevelFromExp 由经验推导等级(1–10) +func LevelFromExp(exp int) int { + if exp < 0 { + exp = 0 + } + level := 1 + for i, th := range LevelThresholds { + if exp >= th { + level = i + 1 + } + } + return level +} + +// ExpForLevel 某等级的门槛 Exp(超出范围则钳制) +func ExpForLevel(level int) int { + if level < 1 { + level = 1 + } + if level > len(LevelThresholds) { + level = len(LevelThresholds) + } + return LevelThresholds[level-1] +} + +// MaxLevel 最高等级 +func MaxLevel() int { + return len(LevelThresholds) +} diff --git a/model/models.go b/model/models.go index 66792de..3750a88 100644 --- a/model/models.go +++ b/model/models.go @@ -28,24 +28,44 @@ const ( ) // User 用户表 -// Email / Password / LastLogin* 默认不随帖子等嵌套 User 序列化; +// Email / Password / LastLogin* / LastAccessAt 默认不随帖子等嵌套 User 序列化; // 个人中心与后台列表请用 UserSelf / UserAdmin。 type User struct { - ID uint `gorm:"primaryKey" json:"id"` - Username string `gorm:"uniqueIndex;size:128;not null" json:"username"` - Email string `gorm:"index;size:128;default:''" json:"-"` - Password string `gorm:"size:128;not null" json:"-"` - Nickname string `gorm:"size:64" json:"nickname"` - Signature string `gorm:"size:512;default:''" json:"signature"` // 个人签名 - Avatar string `gorm:"size:512" json:"avatar"` // 兼容 CDN / S3 较长绝对 URL - Role Role `gorm:"size:16;default:user" json:"role"` - Banned bool `gorm:"default:false" json:"banned"` - BannedAt *time.Time `json:"banned_at,omitempty"` - LastLoginAt *time.Time `json:"-"` - LastLoginIP string `gorm:"size:45;default:''" json:"-"` // 兼容 IPv6 - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + ID uint `gorm:"primaryKey" json:"id"` + Username string `gorm:"uniqueIndex;size:128;not null" json:"username"` + Email string `gorm:"index;size:128;default:''" json:"-"` + Password string `gorm:"size:128;not null" json:"-"` + Nickname string `gorm:"size:64" json:"nickname"` + Signature string `gorm:"size:512;default:''" json:"signature"` // 个人签名 + Avatar string `gorm:"size:512" json:"avatar"` // 兼容 CDN / S3 较长绝对 URL + Role Role `gorm:"size:16;default:user" json:"role"` + Verified bool `gorm:"default:false;index" json:"verified"` // 站长认证:免审发帖/评论 + Exp int `gorm:"default:0" json:"exp"` // 经验(不可消费) + Points int `gorm:"default:0" json:"points"` // 可用积分 + CreatorIncomeTotal int `gorm:"default:0" json:"creator_income_total"` // 累计创作分成 + Banned bool `gorm:"default:false" json:"banned"` + BannedAt *time.Time `json:"banned_at,omitempty"` + LastLoginAt *time.Time `json:"-"` + LastLoginIP string `gorm:"size:45;default:''" json:"-"` // 兼容 IPv6 + LastAccessAt *time.Time `json:"-"` // 最近一次带鉴权的访问(与登录分开) + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // 仅序列化展示用,不落库 + Level int `gorm:"-" json:"level"` + Badges []UserBadgeView `gorm:"-" json:"badges,omitempty"` +} + +// AfterFind 填充展示用等级 +func (u *User) AfterFind(tx *gorm.DB) (err error) { + u.Level = LevelFromExp(u.Exp) + return nil +} + +// SkipsModeration 站长或认证用户发帖/评论免审 +func (u *User) SkipsModeration() bool { + return u != nil && (u.Role == RoleAdmin || u.Verified) } // Board 论坛板块 @@ -246,3 +266,93 @@ type Media struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } + +// 积分流水原因 +const ( + PointReasonCheckIn = "check_in" + PointReasonLottery = "lottery" + PointReasonUnlockSpend = "unlock_spend" + PointReasonCreatorIncome = "creator_income" + PointReasonAdminAdjust = "admin_adjust" +) + +// PointLedger 积分流水 +type PointLedger struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"index;not null" json:"user_id"` + Delta int `gorm:"not null" json:"delta"` + Balance int `gorm:"not null" json:"balance"` // 变动后余额 + Reason string `gorm:"size:32;index;not null" json:"reason"` + RefType string `gorm:"size:32;default:''" json:"ref_type"` + RefID uint `gorm:"default:0" json:"ref_id"` + Note string `gorm:"size:256;default:''" json:"note"` + CreatedAt time.Time `json:"created_at"` +} + +// CheckIn 每日签到(用户+自然日唯一) +type CheckIn struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"uniqueIndex:idx_checkin_user_day;not null" json:"user_id"` + Day string `gorm:"uniqueIndex:idx_checkin_user_day;size:10;not null" json:"day"` // YYYY-MM-DD + Points int `gorm:"not null" json:"points"` + Streak int `gorm:"default:1" json:"streak"` + CreatedAt time.Time `json:"created_at"` +} + +// LotteryDraw 每日抽奖记录 +type LotteryDraw struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"uniqueIndex:idx_lottery_user_day;not null" json:"user_id"` + Day string `gorm:"uniqueIndex:idx_lottery_user_day;size:10;not null" json:"day"` + Points int `gorm:"not null" json:"points"` // 抽中积分(可为 0) + CreatedAt time.Time `json:"created_at"` +} + +// PostContentUnlock 帖子积分解锁记录 +type PostContentUnlock struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"uniqueIndex:idx_unlock_user_post_block;not null" json:"user_id"` + PostID uint `gorm:"uniqueIndex:idx_unlock_user_post_block;index;not null" json:"post_id"` + BlockKey string `gorm:"uniqueIndex:idx_unlock_user_post_block;size:64;not null" json:"block_key"` + Cost int `gorm:"not null" json:"cost"` + CreatedAt time.Time `json:"created_at"` +} + +// 徽章类型 +const ( + BadgeKindAuto = "auto" + BadgeKindLimited = "limited" +) + +// 自动徽章指标 +const ( + BadgeMetricTenureDays = "tenure_days" + BadgeMetricLikesReceived = "likes_received" + BadgeMetricCreatorIncome = "creator_income" +) + +// BadgeDef 徽章定义 +type BadgeDef struct { + ID uint `gorm:"primaryKey" json:"id"` + Code string `gorm:"uniqueIndex;size:64;not null" json:"code"` + Name string `gorm:"size:64;not null" json:"name"` + Description string `gorm:"size:256;default:''" json:"description"` + Icon string `gorm:"size:64;default:''" json:"icon"` // lucide / 固定 key + Kind string `gorm:"size:16;index;not null" json:"kind"` // auto|limited + Metric string `gorm:"size:32;default:''" json:"metric"` + Threshold int `gorm:"default:0" json:"threshold"` + SortOrder int `gorm:"default:0" json:"sort_order"` + Enabled bool `gorm:"default:true;index" json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// UserBadge 用户已获徽章 +type UserBadge struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"uniqueIndex:idx_user_badge;not null" json:"user_id"` + BadgeID uint `gorm:"uniqueIndex:idx_user_badge;index;not null" json:"badge_id"` + AwardedAt time.Time `json:"awarded_at"` + AwardedBy uint `gorm:"default:0" json:"awarded_by"` // 0=系统 + Badge BadgeDef `gorm:"foreignKey:BadgeID" json:"badge,omitempty"` +} diff --git a/model/user_view.go b/model/user_view.go index 4a02c6a..4025521 100644 --- a/model/user_view.go +++ b/model/user_view.go @@ -2,99 +2,140 @@ package model import "time" +// UserBadgeView 对外展示的徽章摘要 +type UserBadgeView struct { + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + Icon string `json:"icon"` + Kind string `json:"kind"` +} + // UserPublic 公开用户主页视图(无邮箱与登录信息) type UserPublic struct { - ID uint `json:"id"` - Username string `json:"username"` - Nickname string `json:"nickname"` - Signature string `json:"signature"` - Avatar string `json:"avatar"` - Role Role `json:"role"` - Banned bool `json:"banned"` - BannedAt *time.Time `json:"banned_at,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID uint `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Signature string `json:"signature"` + Avatar string `json:"avatar"` + Role Role `json:"role"` + Verified bool `json:"verified"` + Exp int `json:"exp"` + Level int `json:"level"` + CreatorIncomeTotal int `json:"creator_income_total"` + Badges []UserBadgeView `json:"badges,omitempty"` + Banned bool `json:"banned"` + BannedAt *time.Time `json:"banned_at,omitempty"` + CreatedAt time.Time `json:"created_at"` } // UserSelf 当前登录用户视图(含邮箱,不含登录 IP) type UserSelf struct { - ID uint `json:"id"` - Username string `json:"username"` - Email string `json:"email"` - Nickname string `json:"nickname"` - Signature string `json:"signature"` - Avatar string `json:"avatar"` - Role Role `json:"role"` - Banned bool `json:"banned"` - BannedAt *time.Time `json:"banned_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Nickname string `json:"nickname"` + Signature string `json:"signature"` + Avatar string `json:"avatar"` + Role Role `json:"role"` + Verified bool `json:"verified"` + Exp int `json:"exp"` + Level int `json:"level"` + Points int `json:"points"` + CreatorIncomeTotal int `json:"creator_income_total"` + Badges []UserBadgeView `json:"badges,omitempty"` + Banned bool `json:"banned"` + BannedAt *time.Time `json:"banned_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } -// UserAdmin 后台用户管理视图(含邮箱与上次登录信息) +// UserAdmin 后台用户管理视图(含邮箱、上次登录与最近访问) type UserAdmin struct { - ID uint `json:"id"` - Username string `json:"username"` - Email string `json:"email"` - Nickname string `json:"nickname"` - Signature string `json:"signature"` - Avatar string `json:"avatar"` - Role Role `json:"role"` - Banned bool `json:"banned"` - BannedAt *time.Time `json:"banned_at,omitempty"` - LastLoginAt *time.Time `json:"last_login_at,omitempty"` - LastLoginIP string `json:"last_login_ip,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Nickname string `json:"nickname"` + Signature string `json:"signature"` + Avatar string `json:"avatar"` + Role Role `json:"role"` + Verified bool `json:"verified"` + Exp int `json:"exp"` + Level int `json:"level"` + Points int `json:"points"` + CreatorIncomeTotal int `json:"creator_income_total"` + Banned bool `json:"banned"` + BannedAt *time.Time `json:"banned_at,omitempty"` + LastLoginAt *time.Time `json:"last_login_at,omitempty"` + LastLoginIP string `json:"last_login_ip,omitempty"` + LastAccessAt *time.Time `json:"last_access_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // ToPublic 转为公开主页视图 func (u *User) ToPublic() UserPublic { return UserPublic{ - ID: u.ID, - Username: u.Username, - Nickname: u.Nickname, - Signature: u.Signature, - Avatar: u.Avatar, - Role: u.Role, - Banned: u.Banned, - BannedAt: u.BannedAt, - CreatedAt: u.CreatedAt, + ID: u.ID, + Username: u.Username, + Nickname: u.Nickname, + Signature: u.Signature, + Avatar: u.Avatar, + Role: u.Role, + Verified: u.Verified, + Exp: u.Exp, + Level: LevelFromExp(u.Exp), + CreatorIncomeTotal: u.CreatorIncomeTotal, + Banned: u.Banned, + BannedAt: u.BannedAt, + CreatedAt: u.CreatedAt, } } // ToSelf 转为个人中心 /api/me 视图 func (u *User) ToSelf() UserSelf { return UserSelf{ - ID: u.ID, - Username: u.Username, - Email: u.Email, - Nickname: u.Nickname, - Signature: u.Signature, - Avatar: u.Avatar, - Role: u.Role, - Banned: u.Banned, - BannedAt: u.BannedAt, - CreatedAt: u.CreatedAt, - UpdatedAt: u.UpdatedAt, + ID: u.ID, + Username: u.Username, + Email: u.Email, + Nickname: u.Nickname, + Signature: u.Signature, + Avatar: u.Avatar, + Role: u.Role, + Verified: u.Verified, + Exp: u.Exp, + Level: LevelFromExp(u.Exp), + Points: u.Points, + CreatorIncomeTotal: u.CreatorIncomeTotal, + Banned: u.Banned, + BannedAt: u.BannedAt, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, } } // ToAdmin 转为后台用户列表视图 func (u *User) ToAdmin() UserAdmin { return UserAdmin{ - ID: u.ID, - Username: u.Username, - Email: u.Email, - Nickname: u.Nickname, - Signature: u.Signature, - Avatar: u.Avatar, - Role: u.Role, - Banned: u.Banned, - BannedAt: u.BannedAt, - LastLoginAt: u.LastLoginAt, - LastLoginIP: u.LastLoginIP, - CreatedAt: u.CreatedAt, - UpdatedAt: u.UpdatedAt, + ID: u.ID, + Username: u.Username, + Email: u.Email, + Nickname: u.Nickname, + Signature: u.Signature, + Avatar: u.Avatar, + Role: u.Role, + Verified: u.Verified, + Exp: u.Exp, + Level: LevelFromExp(u.Exp), + Points: u.Points, + CreatorIncomeTotal: u.CreatorIncomeTotal, + Banned: u.Banned, + BannedAt: u.BannedAt, + LastLoginAt: u.LastLoginAt, + LastLoginIP: u.LastLoginIP, + LastAccessAt: u.LastAccessAt, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, } } diff --git a/router/router.go b/router/router.go index 34a8259..0f9b759 100644 --- a/router/router.go +++ b/router/router.go @@ -84,6 +84,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { Filter: filter, Limiter: limiter, Settings: settingsSvc, Captcha: captchaSvc, Mail: mailSvc, EmailCode: emailCodeSvc, OIDC: oidcSvc, Gitea: giteaSvc, + Points: service.NewPointsService(), Badge: service.NewBadgeService(), } authMW := middleware.NewAuthMiddleware(authSvc) @@ -159,6 +160,11 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { api.POST("/comments/:id/report", middleware.RateLimitMiddleware(limiter, "report"), h.APICreateCommentReport) api.DELETE("/comments/:id", h.APIDeleteComment) api.PUT("/comments/:id", h.APIUpdateComment) + api.GET("/me/points", h.APIMePoints) + api.POST("/me/check-in", h.APIMeCheckIn) + api.GET("/me/lottery", h.APIMeLotteryGet) + api.POST("/me/lottery", h.APIMeLotteryDraw) + api.POST("/posts/:id/unlock", middleware.RateLimitMiddleware(limiter, "post"), h.APIUnlockPostBlock) } // 管理员 API(React SPA 后台统一使用 JSON) @@ -204,6 +210,12 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { adminAPI.DELETE("/comments/:id", h.APIAdminDeleteComment) adminAPI.GET("/users", h.APIAdminUsers) adminAPI.POST("/users/:id/ban", h.APIAdminBanUser) + adminAPI.POST("/users/:id/verify", h.APIAdminVerifyUser) + adminAPI.POST("/users/:id/level", h.APIAdminSetUserLevel) + adminAPI.POST("/users/:id/points", h.APIAdminAdjustPoints) + adminAPI.POST("/users/:id/badges", h.APIAdminAwardBadge) + adminAPI.GET("/badges", h.APIAdminListBadges) + adminAPI.POST("/badges", h.APIAdminUpsertBadge) adminAPI.GET("/media", h.APIAdminMedia) adminAPI.POST("/media/delete", h.APIAdminDeleteMedia) adminAPI.POST("/backup", h.APIAdminBackup) @@ -220,7 +232,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { adminAuth := admin.Group("/", authMW.RequireAuth(), authMW.RequireAdmin()) { adminAuth.GET("/", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/dashboard") }) - for _, page := range []string{"dashboard", "boards", "posts", "comments", "reports", "users", "media", "settings"} { + for _, page := range []string{"dashboard", "boards", "posts", "comments", "reports", "users", "badges", "media", "settings"} { adminAuth.GET("/"+page, embed_static.ServeSPANoIndex) } } diff --git a/service/auth.go b/service/auth.go index be6a383..a57c286 100644 --- a/service/auth.go +++ b/service/auth.go @@ -2,12 +2,18 @@ package service import ( "errors" + "sync" "time" "github.com/golang-jwt/jwt/v5" "git.iioio.com/freefire/jiang13-forum/model" ) +// 最近访问写入节流,避免每次 API 都打库 +const lastAccessTouchInterval = 5 * time.Minute + +var lastAccessTouchCache sync.Map // userID(uint) -> time.Time + const TokenExpire = 7 * 24 * time.Hour type Claims struct { @@ -100,7 +106,7 @@ func (s *AuthService) Login(username, password, clientIP string) (string, *model return token, &user, err } -// recordLogin 记录上次登录时间与 IP(失败不影响登录) +// recordLogin 记录上次登录时间与 IP;登录同时视为一次访问(失败不影响登录) func (s *AuthService) recordLogin(user *model.User, clientIP string) { now := time.Now() ip := clientIP @@ -108,11 +114,29 @@ func (s *AuthService) recordLogin(user *model.User, clientIP string) { ip = ip[:45] } _ = model.DB.Model(user).Updates(map[string]interface{}{ - "last_login_at": now, - "last_login_ip": ip, + "last_login_at": now, + "last_login_ip": ip, + "last_access_at": now, }).Error user.LastLoginAt = &now user.LastLoginIP = ip + user.LastAccessAt = &now + lastAccessTouchCache.Store(user.ID, now) +} + +// TouchLastAccess 记录最近访问时间(节流写入,失败忽略) +func (s *AuthService) TouchLastAccess(userID uint) { + if userID == 0 { + return + } + now := time.Now() + if v, ok := lastAccessTouchCache.Load(userID); ok { + if t, ok := v.(time.Time); ok && now.Sub(t) < lastAccessTouchInterval { + return + } + } + lastAccessTouchCache.Store(userID, now) + _ = model.DB.Model(&model.User{}).Where("id = ?", userID).Update("last_access_at", now).Error } // GenerateToken 生成 JWT diff --git a/service/badge.go b/service/badge.go new file mode 100644 index 0000000..7af58b1 --- /dev/null +++ b/service/badge.go @@ -0,0 +1,272 @@ +package service + +import ( + "errors" + "time" + + "git.iioio.com/freefire/jiang13-forum/model" + "gorm.io/gorm" +) + +// BadgeService 徽章定义与发放 +type BadgeService struct{} + +func NewBadgeService() *BadgeService { return &BadgeService{} } + +// ListDefs 列出徽章定义 +func (s *BadgeService) ListDefs(includeDisabled bool) ([]model.BadgeDef, error) { + q := model.DB.Order("sort_order asc, id asc") + if !includeDisabled { + q = q.Where("enabled = ?", true) + } + var rows []model.BadgeDef + err := q.Find(&rows).Error + return rows, err +} + +// UpsertDef 创建或更新徽章定义(按 code) +func (s *BadgeService) UpsertDef(def *model.BadgeDef) error { + if def.Code == "" || def.Name == "" { + return errors.New("徽章代码与名称不能为空") + } + if def.Kind != model.BadgeKindAuto && def.Kind != model.BadgeKindLimited { + return errors.New("无效的徽章类型") + } + var existing model.BadgeDef + err := model.DB.Where("code = ?", def.Code).Limit(1).Find(&existing).Error + if err != nil { + return err + } + if existing.ID == 0 { + return model.DB.Create(def).Error + } + def.ID = existing.ID + return model.DB.Model(&existing).Updates(map[string]interface{}{ + "name": def.Name, + "description": def.Description, + "icon": def.Icon, + "kind": def.Kind, + "metric": def.Metric, + "threshold": def.Threshold, + "sort_order": def.SortOrder, + "enabled": def.Enabled, + }).Error +} + +// AwardLimited 站长颁发限定徽章 +func (s *BadgeService) AwardLimited(userID, badgeID, adminID uint) error { + var def model.BadgeDef + if err := model.DB.First(&def, badgeID).Error; err != nil { + return errors.New("徽章不存在") + } + if def.Kind != model.BadgeKindLimited { + return errors.New("仅可颁发限定徽章") + } + if !def.Enabled { + return errors.New("徽章已停用") + } + var n int64 + model.DB.Model(&model.UserBadge{}).Where("user_id = ? AND badge_id = ?", userID, badgeID).Count(&n) + if n > 0 { + return errors.New("用户已拥有该徽章") + } + return model.DB.Create(&model.UserBadge{ + UserID: userID, + BadgeID: badgeID, + AwardedAt: time.Now(), + AwardedBy: adminID, + }).Error +} + +// Revoke 收回徽章 +func (s *BadgeService) Revoke(userID, badgeID uint) error { + res := model.DB.Where("user_id = ? AND badge_id = ?", userID, badgeID).Delete(&model.UserBadge{}) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return errors.New("用户未拥有该徽章") + } + return nil +} + +// ListUserBadges 用户已获徽章(含定义) +func (s *BadgeService) ListUserBadges(userID uint) ([]model.UserBadge, error) { + var rows []model.UserBadge + err := model.DB.Preload("Badge").Where("user_id = ?", userID). + Order("awarded_at desc").Find(&rows).Error + return rows, err +} + +// BadgeViews 转为展示视图(最多 limit 枚,0=全部) +func BadgeViews(rows []model.UserBadge, limit int) []model.UserBadgeView { + out := make([]model.UserBadgeView, 0, len(rows)) + for _, r := range rows { + if r.Badge.ID == 0 || !r.Badge.Enabled { + continue + } + out = append(out, model.UserBadgeView{ + Code: r.Badge.Code, + Name: r.Badge.Name, + Description: r.Badge.Description, + Icon: r.Badge.Icon, + Kind: r.Badge.Kind, + }) + if limit > 0 && len(out) >= limit { + break + } + } + return out +} + +// EvaluateAuto 检查并授予符合条件的自动徽章 +func (s *BadgeService) EvaluateAuto(userID uint) error { + var user model.User + if err := model.DB.First(&user, userID).Error; err != nil { + return err + } + var defs []model.BadgeDef + if err := model.DB.Where("kind = ? AND enabled = ?", model.BadgeKindAuto, true).Find(&defs).Error; err != nil { + return err + } + tenureDays := int(time.Since(user.CreatedAt).Hours() / 24) + var likes int64 + _ = model.DB.Model(&model.Post{}). + Select("COALESCE(SUM(like_count), 0)"). + Where("user_id = ? AND status = ?", userID, model.ContentStatusPublished). + Scan(&likes).Error + income := user.CreatorIncomeTotal + + owned := map[uint]bool{} + var existing []model.UserBadge + _ = model.DB.Where("user_id = ?", userID).Find(&existing).Error + for _, e := range existing { + owned[e.BadgeID] = true + } + + for _, d := range defs { + if owned[d.ID] { + continue + } + ok := false + switch d.Metric { + case model.BadgeMetricTenureDays: + ok = tenureDays >= d.Threshold + case model.BadgeMetricLikesReceived: + ok = int(likes) >= d.Threshold + case model.BadgeMetricCreatorIncome: + ok = income >= d.Threshold + } + if !ok { + continue + } + _ = model.DB.Create(&model.UserBadge{ + UserID: userID, + BadgeID: d.ID, + AwardedAt: time.Now(), + AwardedBy: 0, + }).Error + } + return nil +} + +// AttachBadgeSummaries 批量为用户填充展示用徽章(最多 perUser 枚) +func (s *BadgeService) AttachBadgeSummaries(users []*model.User, perUser int) { + if len(users) == 0 { + return + } + if perUser <= 0 { + perUser = 3 + } + ids := make([]uint, 0, len(users)) + seen := map[uint]bool{} + for _, u := range users { + if u == nil || u.ID == 0 { + continue + } + u.Level = model.LevelFromExp(u.Exp) + if !seen[u.ID] { + seen[u.ID] = true + ids = append(ids, u.ID) + } + } + if len(ids) == 0 { + return + } + var rows []model.UserBadge + _ = model.DB.Preload("Badge").Where("user_id IN ?", ids). + Order("awarded_at desc").Find(&rows).Error + grouped := map[uint][]model.UserBadgeView{} + for _, r := range rows { + if r.Badge.ID == 0 || !r.Badge.Enabled { + continue + } + list := grouped[r.UserID] + if len(list) >= perUser { + continue + } + list = append(list, model.UserBadgeView{ + Code: r.Badge.Code, + Name: r.Badge.Name, + Description: r.Badge.Description, + Icon: r.Badge.Icon, + Kind: r.Badge.Kind, + }) + grouped[r.UserID] = list + } + for _, u := range users { + if u == nil || u.ID == 0 { + continue + } + u.Badges = grouped[u.ID] + } +} + +// AttachBadgeSummariesOnPosts 给帖子作者填充徽章摘要 +func (s *BadgeService) AttachBadgeSummariesOnPosts(posts []model.Post, perUser int) { + users := make([]*model.User, 0, len(posts)) + for i := range posts { + if posts[i].User.ID > 0 { + users = append(users, &posts[i].User) + } + } + s.AttachBadgeSummaries(users, perUser) +} + +// AttachBadgeSummariesOnComments 给评论作者填充徽章摘要 +func (s *BadgeService) AttachBadgeSummariesOnComments(comments []model.Comment, perUser int) { + users := make([]*model.User, 0, len(comments)) + for i := range comments { + if comments[i].User.ID > 0 { + users = append(users, &comments[i].User) + } + } + s.AttachBadgeSummaries(users, perUser) +} + +// AddExp 增加经验(不可为负消耗;delta<=0 忽略) +func AddExp(userID uint, delta int) { + if userID == 0 || delta <= 0 { + return + } + _ = model.DB.Model(&model.User{}).Where("id = ?", userID). + UpdateColumn("exp", gorm.Expr("exp + ?", delta)).Error +} + +// SetUserLevel 站长设等级(调整 Exp 到门槛) +func SetUserLevel(userID uint, level int) error { + if level < 1 || level > model.MaxLevel() { + return errors.New("等级须在 1–10") + } + exp := model.ExpForLevel(level) + return model.DB.Model(&model.User{}).Where("id = ?", userID).Update("exp", exp).Error +} + +// SetVerified 设置认证 +func SetVerified(userID uint, verified bool) error { + var user model.User + if err := model.DB.First(&user, userID).Error; err != nil { + return errors.New("用户不存在") + } + return model.DB.Model(&user).Update("verified", verified).Error +} diff --git a/service/comment.go b/service/comment.go index a72f764..025efac 100644 --- a/service/comment.go +++ b/service/comment.go @@ -215,7 +215,7 @@ func (s *CommentService) Create(in CommentCreateInput) (*model.Comment, error) { } status := model.ContentStatusPending - if user.Role == model.RoleAdmin { + if user.SkipsModeration() { status = model.ContentStatusPublished } @@ -231,7 +231,13 @@ func (s *CommentService) Create(in CommentCreateInput) (*model.Comment, error) { IsPrivate: in.IsPrivate, Status: status, } - return comment, model.DB.Create(comment).Error + if err := model.DB.Create(comment).Error; err != nil { + return nil, err + } + if status == model.ContentStatusPublished { + AddExp(in.UserID, 2) + } + return comment, nil } // SetStatus 设置评论审核状态 @@ -241,6 +247,11 @@ func (s *CommentService) SetStatus(commentID uint, status string) error { default: return errors.New("无效的审核状态") } + var comment model.Comment + if err := model.DB.Select("id", "user_id", "status").First(&comment, commentID).Error; err != nil { + return ErrCommentNotFound + } + prev := comment.Status res := model.DB.Model(&model.Comment{}).Where("id = ?", commentID).Update("status", status) if res.Error != nil { return res.Error @@ -248,6 +259,9 @@ func (s *CommentService) SetStatus(commentID uint, status string) error { if res.RowsAffected == 0 { return ErrCommentNotFound } + if status == model.ContentStatusPublished && prev != model.ContentStatusPublished && comment.UserID > 0 { + AddExp(comment.UserID, 2) + } return nil } @@ -332,7 +346,7 @@ func (s *CommentService) Delete(userID, commentID uint, isAdmin bool) error { return s.AdminDelete(commentID) } -func (s *CommentService) Update(userID, commentID uint, isAdmin bool, content string) (string, bool, error) { +func (s *CommentService) Update(userID, commentID uint, isAdmin, skipModeration bool, content string) (string, bool, error) { var comment model.Comment if err := model.DB.First(&comment, commentID).Error; err != nil { return "", false, ErrCommentNotFound @@ -369,7 +383,7 @@ func (s *CommentService) Update(userID, commentID uint, isAdmin bool, content st return err } updates := map[string]interface{}{"content": content} - if !isAdmin { + if !skipModeration { updates["status"] = model.ContentStatusPending enteredPending = true } diff --git a/service/content.go b/service/content.go index 72ec436..4c8eb6d 100644 --- a/service/content.go +++ b/service/content.go @@ -25,9 +25,9 @@ func RedactReplyOnlyHTML(html string) string { return redactGatedBlocks(html, replyOnlyBlockRe, "reply-only") } -// RedactGatedPostHTML 搜索/SEO 等场景:同时遮盖登录可见与回复可见正文 +// RedactGatedPostHTML 搜索/SEO 等场景:同时遮盖登录可见、回复可见与积分解锁正文 func RedactGatedPostHTML(html string) string { - return RedactReplyOnlyHTML(RedactMembersOnlyHTML(html)) + return RedactPointsOnlyHTML(RedactReplyOnlyHTML(RedactMembersOnlyHTML(html)), nil) } func redactGatedBlocks(html string, re *regexp.Regexp, tag string) string { diff --git a/service/points.go b/service/points.go new file mode 100644 index 0000000..cd96907 --- /dev/null +++ b/service/points.go @@ -0,0 +1,272 @@ +package service + +import ( + "crypto/rand" + "errors" + "fmt" + "math/big" + "time" + + "git.iioio.com/freefire/jiang13-forum/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ( + ErrInsufficientPoints = errors.New("积分不足") + ErrAlreadyCheckedIn = errors.New("今日已签到") + ErrAlreadyLottery = errors.New("今日已抽奖") + ErrInvalidPointsDelta = errors.New("无效的积分变动") +) + +// PointsService 积分钱包、签到、抽奖 +type PointsService struct{} + +func NewPointsService() *PointsService { return &PointsService{} } + +func todayLocal() string { + return time.Now().Format("2006-01-02") +} + +// AdjustPointsTx 在已有事务内调整积分并写流水;返回变动后余额 +func AdjustPointsTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID uint, note string) (int, error) { + if delta == 0 { + var u model.User + if err := tx.Select("points").First(&u, userID).Error; err != nil { + return 0, err + } + return u.Points, nil + } + var user model.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil { + return 0, err + } + newBal := user.Points + delta + if newBal < 0 { + return 0, ErrInsufficientPoints + } + if err := tx.Model(&user).Update("points", newBal).Error; err != nil { + return 0, err + } + led := model.PointLedger{ + UserID: userID, + Delta: delta, + Balance: newBal, + Reason: reason, + RefType: refType, + RefID: refID, + Note: note, + } + if err := tx.Create(&led).Error; err != nil { + return 0, err + } + return newBal, nil +} + +// AdjustPoints 独立事务调整积分 +func (s *PointsService) AdjustPoints(userID uint, delta int, reason, refType string, refID uint, note string) (int, error) { + var bal int + err := model.DB.Transaction(func(tx *gorm.DB) error { + var e error + bal, e = AdjustPointsTx(tx, userID, delta, reason, refType, refID, note) + return e + }) + return bal, err +} + +// AdminAdjust 站长调账 +func (s *PointsService) AdminAdjust(userID uint, delta int, note string) (int, error) { + if delta == 0 { + return 0, ErrInvalidPointsDelta + } + return s.AdjustPoints(userID, delta, model.PointReasonAdminAdjust, "admin", 0, note) +} + +// CheckInStatus 今日签到状态 +type CheckInStatus struct { + CheckedIn bool `json:"checked_in"` + Streak int `json:"streak"` + TodayPoints int `json:"today_points"` // 若已签到为实得;否则为预计可得 + Day string `json:"day"` +} + +func (s *PointsService) GetCheckInStatus(userID uint) (CheckInStatus, error) { + day := todayLocal() + st := CheckInStatus{Day: day} + var row model.CheckIn + err := model.DB.Where("user_id = ? AND day = ?", userID, day).Limit(1).Find(&row).Error + if err != nil { + return st, err + } + if row.ID > 0 { + st.CheckedIn = true + st.Streak = row.Streak + st.TodayPoints = row.Points + return st, nil + } + streak := s.computeNextStreak(userID, day) + st.Streak = streak + st.TodayPoints = checkInReward(streak) + return st, nil +} + +func (s *PointsService) computeNextStreak(userID uint, today string) int { + yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02") + var prev model.CheckIn + model.DB.Where("user_id = ? AND day = ?", userID, yesterday).Limit(1).Find(&prev) + if prev.ID > 0 { + return prev.Streak + 1 + } + return 1 +} + +func checkInReward(streak int) int { + // 基础 5,连续每日 +1,封顶 15 + pts := 5 + (streak - 1) + if pts > 15 { + pts = 15 + } + if pts < 5 { + pts = 5 + } + return pts +} + +// CheckIn 每日签到 +func (s *PointsService) CheckIn(userID uint) (CheckInStatus, error) { + day := todayLocal() + var out CheckInStatus + err := model.DB.Transaction(func(tx *gorm.DB) error { + var existing model.CheckIn + if err := tx.Where("user_id = ? AND day = ?", userID, day).Limit(1).Find(&existing).Error; err != nil { + return err + } + if existing.ID > 0 { + return ErrAlreadyCheckedIn + } + yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02") + var prev model.CheckIn + _ = tx.Where("user_id = ? AND day = ?", userID, yesterday).Limit(1).Find(&prev).Error + streak := 1 + if prev.ID > 0 { + streak = prev.Streak + 1 + } + pts := checkInReward(streak) + row := model.CheckIn{UserID: userID, Day: day, Points: pts, Streak: streak} + if err := tx.Create(&row).Error; err != nil { + return err + } + if _, err := AdjustPointsTx(tx, userID, pts, model.PointReasonCheckIn, "check_in", row.ID, fmt.Sprintf("连续签到 %d 天", streak)); err != nil { + return err + } + out = CheckInStatus{CheckedIn: true, Streak: streak, TodayPoints: pts, Day: day} + return nil + }) + return out, err +} + +// LotteryPrize 奖池项 +type LotteryPrize struct { + Points int `json:"points"` + Weight int `json:"weight"` +} + +var defaultLotteryPool = []LotteryPrize{ + {Points: 0, Weight: 40}, + {Points: 2, Weight: 30}, + {Points: 5, Weight: 18}, + {Points: 10, Weight: 10}, + {Points: 20, Weight: 2}, +} + +// LotteryStatus 抽奖状态 +type LotteryStatus struct { + Drawn bool `json:"drawn"` + Points int `json:"points"` // 今日已抽中 + Day string `json:"day"` + Pool []LotteryPrize `json:"pool"` + Cost int `json:"cost"` // 抽奖消耗,首版 0 +} + +func (s *PointsService) GetLotteryStatus(userID uint) (LotteryStatus, error) { + day := todayLocal() + st := LotteryStatus{Day: day, Pool: defaultLotteryPool, Cost: 0} + var row model.LotteryDraw + if err := model.DB.Where("user_id = ? AND day = ?", userID, day).Limit(1).Find(&row).Error; err != nil { + return st, err + } + if row.ID > 0 { + st.Drawn = true + st.Points = row.Points + } + return st, nil +} + +func pickLottery(pool []LotteryPrize) (int, error) { + total := 0 + for _, p := range pool { + total += p.Weight + } + if total <= 0 { + return 0, errors.New("奖池无效") + } + n, err := rand.Int(rand.Reader, big.NewInt(int64(total))) + if err != nil { + return 0, err + } + v := int(n.Int64()) + for _, p := range pool { + if v < p.Weight { + return p.Points, nil + } + v -= p.Weight + } + return pool[len(pool)-1].Points, nil +} + +// DrawLottery 每日抽奖 +func (s *PointsService) DrawLottery(userID uint) (LotteryStatus, error) { + day := todayLocal() + var out LotteryStatus + err := model.DB.Transaction(func(tx *gorm.DB) error { + var existing model.LotteryDraw + if err := tx.Where("user_id = ? AND day = ?", userID, day).Limit(1).Find(&existing).Error; err != nil { + return err + } + if existing.ID > 0 { + return ErrAlreadyLottery + } + pts, err := pickLottery(defaultLotteryPool) + if err != nil { + return err + } + row := model.LotteryDraw{UserID: userID, Day: day, Points: pts} + if err := tx.Create(&row).Error; err != nil { + return err + } + if pts > 0 { + if _, err := AdjustPointsTx(tx, userID, pts, model.PointReasonLottery, "lottery", row.ID, "每日抽奖"); err != nil { + return err + } + } + out = LotteryStatus{Drawn: true, Points: pts, Day: day, Pool: defaultLotteryPool, Cost: 0} + return nil + }) + return out, err +} + +// ListLedger 积分流水 +func (s *PointsService) ListLedger(userID uint, page, size int) ([]model.PointLedger, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 || size > 50 { + size = 20 + } + var total int64 + model.DB.Model(&model.PointLedger{}).Where("user_id = ?", userID).Count(&total) + var rows []model.PointLedger + err := model.DB.Where("user_id = ?", userID).Order("id desc"). + Offset((page - 1) * size).Limit(size).Find(&rows).Error + return rows, total, err +} diff --git a/service/post.go b/service/post.go index 47cac41..fa84458 100644 --- a/service/post.go +++ b/service/post.go @@ -330,7 +330,7 @@ func (s *PostService) GetByID(id uint) (*model.Post, error) { return post, nil } -func (s *PostService) Create(userID, boardID uint, title, content, tags, postType string, isAdmin bool) (*model.Post, error) { +func (s *PostService) Create(userID, boardID uint, title, content, tags, postType string, skipModeration bool) (*model.Post, error) { title = s.filter.Filter(strings.TrimSpace(title)) content = s.filter.Filter(SanitizePostHTML(content)) tags = s.filter.Filter(strings.TrimSpace(tags)) @@ -351,7 +351,7 @@ func (s *PostService) Create(userID, boardID uint, title, content, tags, postTyp return nil, err } status := model.ContentStatusPending - if isAdmin { + if skipModeration { status = model.ContentStatusPublished } post := &model.Post{ @@ -365,12 +365,18 @@ func (s *PostService) Create(userID, boardID uint, title, content, tags, postTyp QuestionResolved: false, Status: status, } - return post, model.DB.Create(post).Error + if err := model.DB.Create(post).Error; err != nil { + return nil, err + } + if status == model.ContentStatusPublished { + AddExp(userID, 10) + } + return post, nil } // Update 更新帖子。boardID>0 时可改板块;为 0 时保持原板块。 // postType 为空时保持原类型;改为非 question 时清除已解决标记。 -func (s *PostService) Update(userID, postID uint, isAdmin bool, title, content, tags, postType string, boardID uint) error { +func (s *PostService) Update(userID, postID uint, isAdmin, skipModeration bool, title, content, tags, postType string, boardID uint) error { var post model.Post if err := model.DB.First(&post, postID).Error; err != nil { return ErrPostNotFound @@ -425,8 +431,8 @@ func (s *PostService) Update(userID, postID uint, isAdmin bool, title, content, "post_type": nextType, "question_resolved": nextResolved, } - // 普通用户修改后重新进入审核 - if !isAdmin { + // 非免审用户修改后重新进入审核 + if !skipModeration { updates["status"] = model.ContentStatusPending } return tx.Model(&post).Updates(updates).Error @@ -440,6 +446,11 @@ func (s *PostService) SetStatus(postID uint, status string) error { default: return errors.New("无效的审核状态") } + var post model.Post + if err := model.DB.Select("id", "user_id", "status").First(&post, postID).Error; err != nil { + return ErrPostNotFound + } + prev := post.Status res := model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("status", status) if res.Error != nil { return res.Error @@ -447,6 +458,10 @@ func (s *PostService) SetStatus(postID uint, status string) error { if res.RowsAffected == 0 { return ErrPostNotFound } + // 首次变为已发布时加经验 + if status == model.ContentStatusPublished && prev != model.ContentStatusPublished { + AddExp(post.UserID, 10) + } return nil } @@ -698,6 +713,10 @@ func (s *PostService) SetQuestionResolved(userID, postID uint, isAdmin bool, res } func (s *PostService) ToggleLike(userID, postID uint) (liked bool, err error) { + var post model.Post + if err := model.DB.Select("id", "user_id").First(&post, postID).Error; err != nil { + return false, ErrPostNotFound + } var like model.PostLike result := model.DB.Where("post_id = ? AND user_id = ?", postID, userID).Limit(1).Find(&like) if result.Error != nil { @@ -713,6 +732,13 @@ func (s *PostService) ToggleLike(userID, postID uint) (liked bool, err error) { return false, err } model.DB.Model(&model.Post{}).Where("id = ?", postID).UpdateColumn("like_count", gorm.Expr("like_count + 1")) + // 他人点赞给作者加经验;自赞不计 + if userID != post.UserID { + AddExp(post.UserID, 1) + go func() { + _ = NewBadgeService().EvaluateAuto(post.UserID) + }() + } return true, nil } diff --git a/service/sanitize_html.go b/service/sanitize_html.go index ba905d0..d4de16b 100644 --- a/service/sanitize_html.go +++ b/service/sanitize_html.go @@ -17,16 +17,16 @@ func postContentHTMLPolicy() *bluemonday.Policy { p := bluemonday.UGCPolicy() // TipTap / Markdown 转换会用到的结构 - p.AllowElements("div", "span", "u", "s", "center", "members-only", "reply-only") + p.AllowElements("div", "span", "u", "s", "center", "members-only", "reply-only", "points-only") p.AllowAttrs("class").OnElements( "p", "div", "span", "pre", "code", "img", "a", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "ul", "ol", "li", "table", "thead", "tbody", "tr", "th", "td", - "members-only", "reply-only", + "members-only", "reply-only", "points-only", ) p.AllowAttrs("colspan", "rowspan").OnElements("th", "td") p.AllowAttrs( - "data-locked", "data-length", "data-gate", + "data-locked", "data-length", "data-gate", "data-cost", "data-block-key", "data-code-copy", "data-code-fold", "data-lang", "data-full", "data-code-style", "data-line-numbers", "data-collapsed", "data-line-count", "data-lineno-digits", diff --git a/service/unlock.go b/service/unlock.go new file mode 100644 index 0000000..fe8fdc0 --- /dev/null +++ b/service/unlock.go @@ -0,0 +1,248 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "regexp" + "strconv" + "strings" + "time" + + "git.iioio.com/freefire/jiang13-forum/model" + "gorm.io/gorm" +) + +const ( + // CreatorSharePercent 作者分成比例(读者支付的百分比) + CreatorSharePercent = 70 + // SockpuppetAccountAgeDays 短龄号判定天数(同 IP 互刷拒绝分成) + SockpuppetAccountAgeDays = 7 +) + +var ( + ErrBlockNotFound = errors.New("付费块不存在") + ErrAlreadyUnlocked = errors.New("已解锁") + ErrSuspiciousTrade = errors.New("检测到异常关联账号,无法完成解锁分成") +) + +var pointsOnlyBlockRe = regexp.MustCompile(`(?is)]*)>([\s\S]*?)`) + +// PointsOnlyBlock 解析出的付费块 +type PointsOnlyBlock struct { + Key string + Cost int + Inner string + AttrRaw string +} + +// ParsePointsOnlyBlocks 按出现顺序解析付费块;block_key = sha256(inner)[:16] +func ParsePointsOnlyBlocks(html string) []PointsOnlyBlock { + matches := pointsOnlyBlockRe.FindAllStringSubmatch(html, -1) + out := make([]PointsOnlyBlock, 0, len(matches)) + for _, m := range matches { + attrs := m[1] + inner := m[2] + cost := parseDataCost(attrs) + if cost < 1 { + cost = 1 + } + sum := sha256.Sum256([]byte(inner)) + key := hex.EncodeToString(sum[:])[:16] + out = append(out, PointsOnlyBlock{Key: key, Cost: cost, Inner: inner, AttrRaw: attrs}) + } + return out +} + +func parseDataCost(attrs string) int { + re := regexp.MustCompile(`(?i)data-cost\s*=\s*["']?(\d+)`) + m := re.FindStringSubmatch(attrs) + if len(m) < 2 { + return 0 + } + n, _ := strconv.Atoi(m[1]) + return n +} + +// FindPointsBlock 按 key 查找块 +func FindPointsBlock(html, blockKey string) (PointsOnlyBlock, bool) { + for _, b := range ParsePointsOnlyBlocks(html) { + if b.Key == blockKey { + return b, true + } + } + return PointsOnlyBlock{}, false +} + +// RedactPointsOnlyHTML 遮盖未解锁付费块;unlocked 为已解锁的 block_key 集合 +func RedactPointsOnlyHTML(html string, unlocked map[string]bool) string { + if html == "" { + return html + } + return pointsOnlyBlockRe.ReplaceAllStringFunc(html, func(full string) string { + m := pointsOnlyBlockRe.FindStringSubmatch(full) + if len(m) < 3 { + return full + } + attrs, inner := m[1], m[2] + sum := sha256.Sum256([]byte(inner)) + key := hex.EncodeToString(sum[:])[:16] + if unlocked != nil && unlocked[key] { + cost := parseDataCost(attrs) + if cost < 1 { + cost = 1 + } + return fmt.Sprintf(`%s`, cost, key, inner) + } + cost := parseDataCost(attrs) + if cost < 1 { + cost = 1 + } + length := gatedContentLength(inner) + return fmt.Sprintf(``, cost, key, length) + }) +} + +// ListUnlockedKeys 用户在某帖已解锁的 block_key +func ListUnlockedKeys(userID, postID uint) (map[string]bool, error) { + out := map[string]bool{} + if userID == 0 || postID == 0 { + return out, nil + } + var rows []model.PostContentUnlock + if err := model.DB.Select("block_key").Where("user_id = ? AND post_id = ?", userID, postID).Find(&rows).Error; err != nil { + return out, err + } + for _, r := range rows { + out[r.BlockKey] = true + } + return out, nil +} + +// UnlockResult 解锁结果 +type UnlockResult struct { + BlockKey string `json:"block_key"` + Cost int `json:"cost"` + AuthorShare int `json:"author_share"` + PointsBalance int `json:"points_balance"` + InnerHTML string `json:"inner_html"` +} + +// UnlockPointsBlock 积分解锁付费块 +func UnlockPointsBlock(readerID, postID uint, blockKey string) (*UnlockResult, error) { + var post model.Post + if err := model.DB.First(&post, postID).Error; err != nil { + return nil, errors.New("帖子不存在") + } + block, ok := FindPointsBlock(post.Content, blockKey) + if !ok { + return nil, ErrBlockNotFound + } + + // 作者自己免费解锁记录(无分成) + if readerID == post.UserID { + var n int64 + model.DB.Model(&model.PostContentUnlock{}).Where("user_id = ? AND post_id = ? AND block_key = ?", readerID, postID, blockKey).Count(&n) + if n == 0 { + _ = model.DB.Create(&model.PostContentUnlock{ + UserID: readerID, PostID: postID, BlockKey: blockKey, Cost: 0, + }).Error + } + return &UnlockResult{BlockKey: blockKey, Cost: 0, AuthorShare: 0, InnerHTML: block.Inner}, nil + } + + var existing model.PostContentUnlock + model.DB.Where("user_id = ? AND post_id = ? AND block_key = ?", readerID, postID, blockKey).Limit(1).Find(&existing) + if existing.ID > 0 { + return nil, ErrAlreadyUnlocked + } + + var reader, author model.User + if err := model.DB.First(&reader, readerID).Error; err != nil { + return nil, err + } + if err := model.DB.First(&author, post.UserID).Error; err != nil { + return nil, err + } + + // 短龄号 + 同登录 IP:拒绝整单(防互刷套现) + if suspiciousUnlockPair(&reader, &author) { + return nil, ErrSuspiciousTrade + } + + cost := block.Cost + authorShare := cost * CreatorSharePercent / 100 + var bal int + err := model.DB.Transaction(func(tx *gorm.DB) error { + var again model.PostContentUnlock + if err := tx.Where("user_id = ? AND post_id = ? AND block_key = ?", readerID, postID, blockKey).Limit(1).Find(&again).Error; err != nil { + return err + } + if again.ID > 0 { + return ErrAlreadyUnlocked + } + var e error + bal, e = AdjustPointsTx(tx, readerID, -cost, model.PointReasonUnlockSpend, "post_unlock", postID, "解锁付费内容") + if e != nil { + return e + } + if authorShare > 0 { + if _, e = AdjustPointsTx(tx, author.ID, authorShare, model.PointReasonCreatorIncome, "post_unlock", postID, "创作分成"); e != nil { + return e + } + if e = tx.Model(&model.User{}).Where("id = ?", author.ID). + UpdateColumn("creator_income_total", gorm.Expr("creator_income_total + ?", authorShare)).Error; e != nil { + return e + } + } + return tx.Create(&model.PostContentUnlock{ + UserID: readerID, PostID: postID, BlockKey: blockKey, Cost: cost, + }).Error + }) + if err != nil { + return nil, err + } + // 异步检查作者徽章 + go func() { + _ = NewBadgeService().EvaluateAuto(author.ID) + }() + return &UnlockResult{ + BlockKey: blockKey, Cost: cost, AuthorShare: authorShare, + PointsBalance: bal, InnerHTML: block.Inner, + }, nil +} + +func suspiciousUnlockPair(reader, author *model.User) bool { + if reader == nil || author == nil { + return false + } + ipR := strings.TrimSpace(reader.LastLoginIP) + ipA := strings.TrimSpace(author.LastLoginIP) + if ipR == "" || ipA == "" || ipR != ipA { + return false + } + cutoff := time.Now().AddDate(0, 0, -SockpuppetAccountAgeDays) + return reader.CreatedAt.After(cutoff) && author.CreatedAt.After(cutoff) +} + +// RevealAllPointsOnly 作者/站长:保留正文并写入 block-key,标记未锁定 +func RevealAllPointsOnly(html string) string { + if html == "" { + return html + } + return pointsOnlyBlockRe.ReplaceAllStringFunc(html, func(full string) string { + m := pointsOnlyBlockRe.FindStringSubmatch(full) + if len(m) < 3 { + return full + } + attrs, inner := m[1], m[2] + sum := sha256.Sum256([]byte(inner)) + key := hex.EncodeToString(sum[:])[:16] + cost := parseDataCost(attrs) + if cost < 1 { + cost = 1 + } + return fmt.Sprintf(`%s`, cost, key, inner) + }) +} diff --git a/service/user.go b/service/user.go index 5e8236f..a135354 100644 --- a/service/user.go +++ b/service/user.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "mime/multipart" + "strconv" "strings" "time" @@ -136,12 +137,51 @@ func (s *UserService) UploadAvatar(userID uint, file *multipart.FileHeader, stor } // ListUsers 管理员列出用户 -func (s *UserService) ListUsers(page, size int) ([]model.User, int64, error) { - var users []model.User +// UserListQuery 后台用户列表筛选 +type UserListQuery struct { + Page int + Size int + Keyword string // 匹配用户名/昵称/邮箱 + Filter string // all | verified | banned | admin +} + +func (s *UserService) ListUsers(q UserListQuery) ([]model.User, int64, error) { + if q.Page < 1 { + q.Page = 1 + } + if q.Size < 1 { + q.Size = 20 + } + if q.Size > 100 { + q.Size = 100 + } + + db := model.DB.Model(&model.User{}) + kw := strings.TrimSpace(q.Keyword) + if kw != "" { + like := "%" + kw + "%" + if id, err := strconv.ParseUint(kw, 10, 64); err == nil { + db = db.Where("id = ? OR username LIKE ? OR nickname LIKE ? OR email LIKE ?", id, like, like, like) + } else { + db = db.Where("username LIKE ? OR nickname LIKE ? OR email LIKE ?", like, like, like) + } + } + switch strings.TrimSpace(q.Filter) { + case "verified": + db = db.Where("verified = ? AND role <> ?", true, model.RoleAdmin) + case "banned": + db = db.Where("banned = ?", true) + case "admin": + db = db.Where("role = ?", model.RoleAdmin) + } + var total int64 - model.DB.Model(&model.User{}).Count(&total) - offset := (page - 1) * size - err := model.DB.Order("id desc").Offset(offset).Limit(size).Find(&users).Error + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + var users []model.User + offset := (q.Page - 1) * q.Size + err := db.Order("id desc").Offset(offset).Limit(q.Size).Find(&users).Error return users, total, err }