增加用户认证、等级、徽章与积分体系,并优化管理后台体验。
覆盖站长调账与积分解锁内容;后台按审核优先分组导航,仪表盘展示待办,用户管理改为成员目录式布局。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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(
|
||||
<Route path="comments" element={<Suspense fallback={<PageLoader />}><AdminCommentsPage /></Suspense>} />
|
||||
<Route path="reports" element={<Suspense fallback={<PageLoader />}><AdminReportsPage /></Suspense>} />
|
||||
<Route path="users" element={<Suspense fallback={<PageLoader />}><AdminUsersPage /></Suspense>} />
|
||||
<Route path="badges" element={<Suspense fallback={<PageLoader />}><AdminBadgesPage /></Suspense>} />
|
||||
<Route path="media" element={<Suspense fallback={<PageLoader />}><AdminMediaPage /></Suspense>} />
|
||||
<Route path="settings" element={<Suspense fallback={<PageLoader />}><AdminSettingsPage /></Suspense>} />
|
||||
<Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage title="后台页面不存在" /></Suspense>} />
|
||||
|
||||
@@ -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<BadgeDef>) =>
|
||||
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);
|
||||
|
||||
@@ -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 }[];
|
||||
}
|
||||
|
||||
@@ -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<ArticleEditorHandle, Props>(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<ArticleEditorHandle, Props>(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<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
className: 'article-tool-btn--reply',
|
||||
action: wrapReplyOnly,
|
||||
},
|
||||
{
|
||||
icon: <Coins size={15} />,
|
||||
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: <strong>H</strong>, title: '标题', hint: 'H2 → H6 循环', action: withMarkdown(cycleMarkdownHeading) },
|
||||
|
||||
@@ -248,6 +248,7 @@ function CommentItem({
|
||||
<UserLink
|
||||
user={c.user ?? { id: c.user_id, nickname: nick }}
|
||||
className="waline-comment-author"
|
||||
showBadges
|
||||
/>
|
||||
) : (
|
||||
<span className="waline-comment-author">{nick}</span>
|
||||
|
||||
138
frontend/src/components/PointsWalletPanel.tsx
Normal file
138
frontend/src/components/PointsWalletPanel.tsx
Normal file
@@ -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<string, string> = {
|
||||
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<CheckInStatus | null>(null);
|
||||
const [lottery, setLottery] = useState<LotteryStatus | null>(null);
|
||||
const [ledger, setLedger] = useState<PointLedger[]>([]);
|
||||
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 (
|
||||
<div className="points-wallet">
|
||||
<div className="flex justify-center py-8"><Spinner /></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="points-wallet">
|
||||
<div className="points-wallet-head">
|
||||
<h3>
|
||||
<Coins size={18} aria-hidden />
|
||||
积分钱包
|
||||
</h3>
|
||||
<div className="points-wallet-balance">
|
||||
<strong>{points}</strong>
|
||||
<span>可用积分</span>
|
||||
<em title="累计创作分成">创作收入 {income}</em>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="points-wallet-actions">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={busy || !!checkIn?.checked_in}
|
||||
onClick={doCheckIn}
|
||||
>
|
||||
<Gift size={14} />
|
||||
{checkIn?.checked_in
|
||||
? `已签到(连续 ${checkIn.streak} 天)`
|
||||
: `签到 +${checkIn?.today_points ?? 5}`}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy || !!lottery?.drawn}
|
||||
onClick={doLottery}
|
||||
>
|
||||
<Dices size={14} />
|
||||
{lottery?.drawn ? `今日已抽(${lottery.points})` : '每日抽奖'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="points-wallet-ledger">
|
||||
<h4>最近流水</h4>
|
||||
{ledger.length === 0 && <p className="points-wallet-empty">暂无流水</p>}
|
||||
<ul>
|
||||
{ledger.map(row => (
|
||||
<li key={row.id}>
|
||||
<span className={row.delta >= 0 ? 'pos' : 'neg'}>
|
||||
{row.delta >= 0 ? '+' : ''}{row.delta}
|
||||
</span>
|
||||
<span>{REASON_LABEL[row.reason] || row.reason}</span>
|
||||
<time>{new Date(row.created_at).toLocaleString('zh-CN')}</time>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
</UserLink>
|
||||
<div className="widget-author-meta">
|
||||
<div className="widget-author-name-row">
|
||||
<UserLink user={display} className="widget-author-name" />
|
||||
{isAdmin && <Badge variant="green" className="widget-author-badge">管理员</Badge>}
|
||||
<UserLink user={display} className="widget-author-name" showBadges />
|
||||
{display.banned && <Badge variant="destructive" className="widget-author-badge">已禁言</Badge>}
|
||||
</div>
|
||||
{signature ? (
|
||||
|
||||
@@ -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<string | null>(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<HTMLElement>('[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<HTMLImageElement>('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;
|
||||
|
||||
@@ -71,7 +71,7 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
|
||||
<div className="post-body">
|
||||
<div className="post-head">
|
||||
<div className="post-head-meta">
|
||||
<UserLink user={post.user} stopPropagation className="post-author" />
|
||||
<UserLink user={post.user} stopPropagation className="post-author" showBadges />
|
||||
<span className="post-head-dot" aria-hidden>·</span>
|
||||
<span className="post-time">{timeLabel}</span>
|
||||
</div>
|
||||
|
||||
71
frontend/src/components/UserBadges.tsx
Normal file
71
frontend/src/components/UserBadges.tsx
Normal file
@@ -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 (
|
||||
<span className={cn('user-badges', compact && 'user-badges--compact', className)}>
|
||||
{isAdmin && (
|
||||
<span className="user-badge user-badge--owner" title="站长">
|
||||
<Crown size={compact ? 12 : 14} aria-hidden />
|
||||
{!compact && <span>站长</span>}
|
||||
</span>
|
||||
)}
|
||||
{isVerified && (
|
||||
<span className="user-badge user-badge--verified" title="认证用户">
|
||||
<BadgeCheck size={compact ? 12 : 14} aria-hidden />
|
||||
{!compact && <span>认证</span>}
|
||||
</span>
|
||||
)}
|
||||
{showLevel && (
|
||||
<span className={cn('user-badge user-badge--level', `user-badge--level-${levelTone}`)} title={`经验 ${user.exp ?? 0}`}>
|
||||
Lv.{level}
|
||||
</span>
|
||||
)}
|
||||
{achievements.map(b => {
|
||||
const Icon: LucideIcon = badgeIcon(b.icon);
|
||||
return (
|
||||
<span key={b.code} className="user-badge user-badge--ach" title={`${b.name}:${b.description}`}>
|
||||
<Icon size={compact ? 11 : 13} aria-hidden />
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
</span>
|
||||
)}
|
||||
{showName && <span className={cn('user-link-name', nameClassName)}>{nick}</span>}
|
||||
{showBadges && showName && <UserBadges user={user} />}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
215
frontend/src/components/editor/PointsOnlyExtension.tsx
Normal file
215
frontend/src/components/editor/PointsOnlyExtension.tsx
Normal file
@@ -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 (
|
||||
<NodeViewWrapper
|
||||
className={`post-points-only post-points-only--visible editor-points-only${selected ? ' editor-points-only--selected' : ''}${empty ? ' editor-points-only--empty' : ''}`}
|
||||
>
|
||||
<div className="post-points-only__badge" contentEditable={false}>
|
||||
<span className="post-points-only__badge-icon" aria-hidden="true">
|
||||
<Coins size={12} />
|
||||
</span>
|
||||
<span>积分可见</span>
|
||||
<label className="post-points-only__cost">
|
||||
价格
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={9999}
|
||||
value={cost}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onChange={e => {
|
||||
const v = Math.max(1, Math.min(9999, Number(e.target.value) || 1));
|
||||
updateAttributes({ cost: v });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="post-points-only__badge-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="post-points-only__remove-btn"
|
||||
title="删除积分可见区块"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={deleteThisBlock}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<NodeViewContent className="post-points-only__body" data-placeholder="此处内容需积分解锁后可见…" />
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
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;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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 = [
|
||||
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/posts', label: '帖子管理', icon: FileText },
|
||||
{ to: '/admin/comments', label: '评论管理', icon: MessageSquare },
|
||||
{ to: '/admin/reports', label: '举报管理', icon: Flag },
|
||||
{ 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<PendingCounts>({ posts: 0, comments: 0, reports: 0 });
|
||||
const nav = useNavigate();
|
||||
const location = useLocation();
|
||||
const drawerRef = useRef<HTMLElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(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 }) => (
|
||||
const renderNav = () => (
|
||||
NAV_GROUPS.map(group => (
|
||||
<div key={group.label} className="admin-nav-group">
|
||||
<div className="admin-nav-group-label">{group.label}</div>
|
||||
{group.items.map(({ to, label, icon: Icon, badgeKey }) => {
|
||||
const badge = badgeKey ? formatNavBadge(pending[badgeKey]) : null;
|
||||
return (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) => cn('admin-nav-item', isActive && 'active')}
|
||||
onClick={closeNav}
|
||||
>
|
||||
<Icon size={16} aria-hidden />
|
||||
{label}
|
||||
<Icon size={16} aria-hidden className="admin-nav-icon" />
|
||||
<span className="admin-nav-text">{label}</span>
|
||||
{badge && (
|
||||
<span className="admin-nav-badge" aria-label={`${badge} 条待处理`}>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
));
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="admin-shell">
|
||||
@@ -125,8 +205,8 @@ export default function AdminLayout() {
|
||||
|
||||
<div className="admin-body">
|
||||
{!isNarrow && (
|
||||
<aside className="admin-sidebar">
|
||||
{navLinks}
|
||||
<aside className="admin-sidebar" aria-label="管理导航">
|
||||
{renderNav()}
|
||||
</aside>
|
||||
)}
|
||||
<main className="admin-main">
|
||||
@@ -164,7 +244,7 @@ export default function AdminLayout() {
|
||||
</button>
|
||||
</div>
|
||||
<nav className="admin-nav-drawer-body">
|
||||
{navLinks}
|
||||
{renderNav()}
|
||||
</nav>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -457,7 +457,9 @@ export default function MainLayout() {
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => nav(userPath(user.id))}>个人主页</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => nav('/profile')}>账号设置</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => nav('/profile')}>
|
||||
账号设置{typeof user.points === 'number' ? ` · ${user.points} 积分` : ''}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => nav('/messages')}>
|
||||
站内私信{unreadMessages > 0 ? ` (${unreadMessages})` : ''}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -628,7 +628,7 @@ export default function PostDetailPage() {
|
||||
: authorInitial}
|
||||
</UserLink>
|
||||
<div className="post-detail-author-info">
|
||||
<UserLink user={post.user} className="post-detail-author-name" />
|
||||
<UserLink user={post.user} className="post-detail-author-name" showBadges />
|
||||
<span className="post-detail-meta-line">
|
||||
发布于 {formatDateTime(post.created_at)}
|
||||
{showEdited && (
|
||||
@@ -665,8 +665,10 @@ export default function PostDetailPage() {
|
||||
<PostContent
|
||||
html={post.content || ''}
|
||||
isLoggedIn={!!user}
|
||||
postId={post.id}
|
||||
onHeadingsChange={handleHeadingsChange}
|
||||
onRequestReply={scrollToCommentBox}
|
||||
onUnlocked={() => { void reloadPostContent(); }}
|
||||
/>
|
||||
|
||||
<div className="post-detail-actions">
|
||||
|
||||
@@ -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() {
|
||||
<div className="profile-header-main">
|
||||
<div className="profile-name-row">
|
||||
<h2 className="profile-display-name">{user.nickname}</h2>
|
||||
{user.role === 'admin' && <Badge variant="green">管理员</Badge>}
|
||||
<UserBadges user={user} compact={false} maxAchievement={6} />
|
||||
</div>
|
||||
<div className="profile-username">@{user.username}</div>
|
||||
<div className="profile-id-row">
|
||||
@@ -489,9 +491,11 @@ export default function ProfilePage() {
|
||||
onConfirm={onCropConfirm}
|
||||
/>
|
||||
|
||||
<PointsWalletPanel />
|
||||
|
||||
{user.role === 'admin' && (
|
||||
<div className="section-card admin-entry-card">
|
||||
<div className="section-card-title">管理员入口</div>
|
||||
<div className="section-card-title">站长入口</div>
|
||||
<p className="admin-entry-desc">
|
||||
管理板块、用户、帖子及系统设置
|
||||
</p>
|
||||
|
||||
@@ -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() {
|
||||
<div className="profile-header-main">
|
||||
<div className="profile-name-row">
|
||||
<h1 className="profile-display-name">{profile.nickname}</h1>
|
||||
{profile.role === 'admin' && <Badge variant="green">管理员</Badge>}
|
||||
<UserBadges user={profile} compact={false} maxAchievement={6} />
|
||||
{profile.banned && <Badge variant="destructive">已禁言</Badge>}
|
||||
</div>
|
||||
<div className="profile-username">@{profile.username}</div>
|
||||
@@ -199,6 +200,13 @@ export default function UserProfilePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!!profile.badges?.length && (
|
||||
<div className="profile-badge-wall" aria-label="徽章墙">
|
||||
<h3 className="profile-badge-wall-title">徽章</h3>
|
||||
<UserBadges user={profile} compact={false} maxAchievement={20} showLevel={false} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="profile-stat-grid" aria-label="活动统计">
|
||||
<div className="profile-stat">
|
||||
<FileText size={16} aria-hidden />
|
||||
|
||||
483
frontend/src/pages/admin/AdminBadgesPage.tsx
Normal file
483
frontend/src/pages/admin/AdminBadgesPage.tsx
Normal file
@@ -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<BadgeDef> = {
|
||||
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<BadgeDef[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tab, setTab] = useState<KindTab>('all');
|
||||
const [query, setQuery] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState<Partial<BadgeDef>>({ ...EMPTY });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [togglingId, setTogglingId] = useState<number | null>(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 (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<div className="admin-page-head-row">
|
||||
<div>
|
||||
<h1>徽章管理</h1>
|
||||
<p>设计自动成就与限定徽章;限定徽章在「用户管理」中颁发给用户</p>
|
||||
</div>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus size={16} />
|
||||
新建徽章
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-badge-stats" aria-label="徽章统计">
|
||||
<div className="admin-badge-stat">
|
||||
<strong>{counts.all}</strong>
|
||||
<span>全部</span>
|
||||
</div>
|
||||
<div className="admin-badge-stat">
|
||||
<strong>{counts.auto}</strong>
|
||||
<span>自动成就</span>
|
||||
</div>
|
||||
<div className="admin-badge-stat">
|
||||
<strong>{counts.limited}</strong>
|
||||
<span>限定徽章</span>
|
||||
</div>
|
||||
<div className="admin-badge-stat">
|
||||
<strong>{counts.disabled}</strong>
|
||||
<span>已停用</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-badge-toolbar">
|
||||
<div className="admin-tabs" role="tablist" aria-label="徽章类型">
|
||||
{([
|
||||
['all', '全部'],
|
||||
['auto', '自动成就'],
|
||||
['limited', '限定徽章'],
|
||||
] as const).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === key}
|
||||
className={cn('admin-tab', tab === key && 'active')}
|
||||
onClick={() => setTab(key)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="admin-badge-search">
|
||||
<Search size={15} aria-hidden />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="搜索名称、代码、说明…"
|
||||
aria-label="搜索徽章"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16"><Spinner size="lg" /></div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="admin-badge-empty">
|
||||
<Award size={36} strokeWidth={1.25} aria-hidden />
|
||||
<h3>{query ? '没有匹配的徽章' : '还没有徽章'}</h3>
|
||||
<p>
|
||||
{query
|
||||
? '试试其他关键词,或切换类型筛选'
|
||||
: '创建自动成就(达条件发放)或限定徽章(站长颁发)'}
|
||||
</p>
|
||||
{!query && (
|
||||
<Button onClick={openCreate}>
|
||||
<Plus size={16} />
|
||||
创建第一个徽章
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-badge-grid">
|
||||
{filtered.map(b => {
|
||||
const Icon = badgeIcon(b.icon);
|
||||
return (
|
||||
<article
|
||||
key={b.id}
|
||||
className={cn('admin-badge-card', !b.enabled && 'is-disabled')}
|
||||
>
|
||||
<div className="admin-badge-card-top">
|
||||
<div className={cn('admin-badge-preview', b.kind === 'limited' && 'is-limited')}>
|
||||
<Icon size={22} aria-hidden />
|
||||
</div>
|
||||
<div className="admin-badge-card-meta">
|
||||
<div className="admin-badge-card-title-row">
|
||||
<h3>{b.name}</h3>
|
||||
{b.kind === 'auto'
|
||||
? <Badge variant="secondary">自动</Badge>
|
||||
: <Badge variant="orange">限定</Badge>}
|
||||
{!b.enabled && <Badge variant="destructive">停用</Badge>}
|
||||
</div>
|
||||
<code className="admin-badge-code">{b.code}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="admin-badge-desc">
|
||||
{b.description?.trim() || (b.kind === 'limited' ? '站长手动颁发的限定徽章' : '达成条件后自动获得')}
|
||||
</p>
|
||||
|
||||
<div className="admin-badge-card-foot">
|
||||
<span className="admin-badge-condition" title="获得条件">
|
||||
{b.kind === 'auto' ? <Sparkles size={13} aria-hidden /> : <Trophy size={13} aria-hidden />}
|
||||
{formatBadgeCondition(b)}
|
||||
</span>
|
||||
<div className="admin-badge-card-actions">
|
||||
<label className="admin-badge-switch" title={b.enabled ? '点击停用' : '点击启用'}>
|
||||
<span className="sr-only">启用</span>
|
||||
<Switch
|
||||
checked={b.enabled}
|
||||
disabled={togglingId === b.id}
|
||||
onCheckedChange={() => toggleEnabled(b)}
|
||||
/>
|
||||
</label>
|
||||
<Button size="sm" variant="outline" onClick={() => openEdit(b)}>
|
||||
<Pencil size={13} />
|
||||
编辑
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={(open) => {
|
||||
setDialogOpen(open);
|
||||
if (!open) resetForm();
|
||||
}}>
|
||||
<DialogContent className="admin-badge-dialog sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? '编辑徽章' : '新建徽章'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? '修改后立即对展示生效;代码不可更改。'
|
||||
: '自动成就按指标发放,限定徽章需在用户管理中手动颁发。'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="admin-badge-dialog-preview">
|
||||
<div className={cn('admin-badge-preview admin-badge-preview--lg', form.kind === 'limited' && 'is-limited')}>
|
||||
<PreviewIcon size={28} aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<div className="admin-badge-dialog-preview-name">{form.name?.trim() || '徽章名称'}</div>
|
||||
<div className="admin-badge-dialog-preview- Cond">
|
||||
{formatBadgeCondition({
|
||||
kind: form.kind,
|
||||
metric: form.metric,
|
||||
threshold: form.threshold,
|
||||
description: form.description,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-badge-dialog-fields">
|
||||
<div className="admin-badge-kind-seg" role="group" aria-label="徽章类型">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(form.kind !== 'limited' && 'active')}
|
||||
onClick={() => setForm(f => ({ ...f, kind: 'auto', metric: f.metric || 'tenure_days' }))}
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
自动成就
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(form.kind === 'limited' && 'active')}
|
||||
onClick={() => setForm(f => ({ ...f, kind: 'limited' }))}
|
||||
>
|
||||
<Trophy size={14} />
|
||||
限定徽章
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-badge-field-row">
|
||||
<div className="admin-badge-field">
|
||||
<Label htmlFor="badge-name">名称</Label>
|
||||
<Input
|
||||
id="badge-name"
|
||||
value={form.name || ''}
|
||||
onChange={e => {
|
||||
const name = e.target.value;
|
||||
setForm(f => ({
|
||||
...f,
|
||||
name,
|
||||
code: isEdit ? f.code : (f.code?.trim() ? f.code : slugifyCode(name)),
|
||||
}));
|
||||
}}
|
||||
placeholder="例如:资深居民"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-badge-field">
|
||||
<Label htmlFor="badge-code">代码</Label>
|
||||
<Input
|
||||
id="badge-code"
|
||||
value={form.code || ''}
|
||||
onChange={e => setForm(f => ({ ...f, code: e.target.value }))}
|
||||
placeholder="tenure_365"
|
||||
disabled={isEdit}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-badge-field">
|
||||
<Label htmlFor="badge-desc">说明</Label>
|
||||
<Input
|
||||
id="badge-desc"
|
||||
value={form.description || ''}
|
||||
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||||
placeholder="鼠标悬停时显示的获得条件"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-badge-field">
|
||||
<Label>图标</Label>
|
||||
<div className="admin-badge-icon-picker" role="listbox" aria-label="选择图标">
|
||||
{BADGE_ICON_OPTIONS.map(opt => {
|
||||
const active = (form.icon || 'star') === opt.key;
|
||||
return (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
title={opt.label}
|
||||
className={cn('admin-badge-icon-opt', active && 'active')}
|
||||
onClick={() => setForm(f => ({ ...f, icon: opt.key }))}
|
||||
>
|
||||
<opt.Icon size={18} aria-hidden />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.kind === 'auto' && (
|
||||
<div className="admin-badge-field-row">
|
||||
<div className="admin-badge-field">
|
||||
<Label htmlFor="badge-metric">达成指标</Label>
|
||||
<select
|
||||
id="badge-metric"
|
||||
className="admin-select"
|
||||
value={form.metric || 'tenure_days'}
|
||||
onChange={e => setForm(f => ({ ...f, metric: e.target.value }))}
|
||||
>
|
||||
{BADGE_METRIC_OPTIONS.map(m => (
|
||||
<option key={m.value} value={m.value}>{m.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="admin-badge-field-hint">
|
||||
{BADGE_METRIC_OPTIONS.find(m => m.value === (form.metric || 'tenure_days'))?.hint}
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-badge-field">
|
||||
<Label htmlFor="badge-threshold">阈值</Label>
|
||||
<Input
|
||||
id="badge-threshold"
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.threshold ?? 0}
|
||||
onChange={e => setForm(f => ({ ...f, threshold: Number(e.target.value) || 0 }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-badge-field-row admin-badge-field-row--end">
|
||||
<div className="admin-badge-field">
|
||||
<Label htmlFor="badge-sort">排序权重</Label>
|
||||
<Input
|
||||
id="badge-sort"
|
||||
type="number"
|
||||
value={form.sort_order ?? 100}
|
||||
onChange={e => setForm(f => ({ ...f, sort_order: Number(e.target.value) || 0 }))}
|
||||
/>
|
||||
</div>
|
||||
<label className="admin-badge-enable-row">
|
||||
<Switch
|
||||
checked={form.enabled !== false}
|
||||
onCheckedChange={v => setForm(f => ({ ...f, enabled: v }))}
|
||||
/>
|
||||
<span>启用此徽章</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={save} loading={saving}>
|
||||
{isEdit ? '保存修改' : '创建徽章'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h1>仪表盘</h1>
|
||||
<p>论坛运行概览与最新帖子</p>
|
||||
<p>优先处理待办,再查看运行概览</p>
|
||||
</div>
|
||||
|
||||
<section className="admin-queue-section" aria-label="待处理事项">
|
||||
<div className="admin-section-label">
|
||||
<span>待处理</span>
|
||||
{pendingTotal > 0 ? (
|
||||
<Badge variant="orange">{pendingTotal} 项</Badge>
|
||||
) : (
|
||||
<span className="admin-section-muted">暂无积压</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-queue-grid">
|
||||
{queues.map(q => {
|
||||
const Icon = q.icon;
|
||||
const hasWork = q.count > 0;
|
||||
return (
|
||||
<button
|
||||
key={q.key}
|
||||
type="button"
|
||||
className={cn('admin-queue-card', hasWork && 'has-work')}
|
||||
onClick={() => nav(q.to)}
|
||||
>
|
||||
<div className="admin-queue-card-top">
|
||||
<Icon size={18} aria-hidden />
|
||||
<span className="admin-queue-count">{q.count}</span>
|
||||
</div>
|
||||
<div className="admin-queue-label">{q.label}</div>
|
||||
<div className="admin-queue-hint">{q.hint}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-stat-section" aria-label="运行概览">
|
||||
<div className="admin-section-label">
|
||||
<span>运行概览</span>
|
||||
</div>
|
||||
<div className="admin-stat-grid">
|
||||
{stats.map(s => (
|
||||
<div key={s.label} className={`admin-stat-card ${s.cls}`}>
|
||||
<div key={s.label} className="admin-stat-card">
|
||||
<div className="admin-stat-value">{s.value}</div>
|
||||
<div className="admin-stat-label">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
|
||||
@@ -112,8 +112,10 @@ export default function AdminReportsPage() {
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<h1 className="admin-page-title">举报管理</h1>
|
||||
<p className="admin-page-desc">处理用户对帖子与评论的举报;拒绝时将通过站内私信通知作者。</p>
|
||||
<div className="admin-page-head">
|
||||
<h1>举报管理</h1>
|
||||
<p>处理用户对帖子与评论的举报;拒绝时将通过站内私信通知作者。</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-tabs">
|
||||
{tabs.map((t) => (
|
||||
|
||||
@@ -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 <img src={user.avatar} alt="" className="admin-user-avatar" loading="lazy" decoding="async" />;
|
||||
}
|
||||
return <span className="admin-user-avatar admin-user-avatar--fallback" aria-hidden>{initial}</span>;
|
||||
}
|
||||
|
||||
/** 后台用户管理:成员目录式列表 + 详情弹窗 */
|
||||
export default function AdminUsersPage() {
|
||||
const nav = useNavigate();
|
||||
const { ready } = useAdminGuard();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [filter, setFilter] = useState<FilterTab>('all');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [limitedBadges, setLimitedBadges] = useState<BadgeDef[]>([]);
|
||||
|
||||
const load = (p = page) => {
|
||||
const [manageUser, setManageUser] = useState<User | null>(null);
|
||||
const [levelVal, setLevelVal] = useState(1);
|
||||
const [pointsDelta, setPointsDelta] = useState('10');
|
||||
const [pointsNote, setPointsNote] = useState('');
|
||||
const [badgeId, setBadgeId] = useState<number | ''>('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [banTarget, setBanTarget] = useState<User | null>(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<User>) => {
|
||||
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 (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page admin-users-page">
|
||||
<div className="admin-page-head">
|
||||
<h1>用户管理</h1>
|
||||
<p>查看注册用户,禁言或解除禁言</p>
|
||||
<p>查找成员并管理认证、等级与积分</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-users-panel">
|
||||
<div className="admin-users-panel-head">
|
||||
<form className="admin-users-search" onSubmit={onSearch}>
|
||||
<div className="admin-users-search-field">
|
||||
<Search size={16} aria-hidden className="admin-users-search-icon" />
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
placeholder="搜索 ID、用户名、昵称或邮箱"
|
||||
aria-label="搜索用户"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" size="sm">搜索</Button>
|
||||
{search ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setKeyword('');
|
||||
setSearch('');
|
||||
load(1, '', filter);
|
||||
}}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
<div className="admin-users-filters" role="tablist" aria-label="用户筛选">
|
||||
{filters.map(f => (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={filter === f.key}
|
||||
className={cn('admin-users-filter', filter === f.key && 'active')}
|
||||
onClick={() => switchFilter(f.key)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
|
||||
<div className="flex justify-center py-16"><Spinner size="lg" /></div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="admin-users-empty">
|
||||
{search || filter !== 'all' ? '没有符合条件的用户' : '暂无用户'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-table-scroll">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>用户名</th>
|
||||
<th>昵称</th>
|
||||
<th>邮箱</th>
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>上次登录</th>
|
||||
<th>登录 IP</th>
|
||||
<th>注册时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<div className="admin-users-cols" aria-hidden>
|
||||
<span className="admin-users-cols-who">成员</span>
|
||||
<span>等级</span>
|
||||
<span>积分</span>
|
||||
<span>最近登录</span>
|
||||
<span className="admin-users-cols-action" />
|
||||
</div>
|
||||
<ul className="admin-users-list" aria-label="用户列表">
|
||||
{users.map(u => (
|
||||
<tr key={u.id}>
|
||||
<td>{u.id}</td>
|
||||
<td>{u.username}</td>
|
||||
<td>
|
||||
<button type="button" className="admin-text-link" onClick={() => nav(`/user/${u.id}`)}>
|
||||
<li
|
||||
key={u.id}
|
||||
className={cn('admin-users-row', u.banned && 'admin-users-row--banned')}
|
||||
>
|
||||
<div className="admin-users-who">
|
||||
<UserAvatar user={u} />
|
||||
<div className="admin-users-who-text">
|
||||
<div className="admin-users-who-line">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-user-nick"
|
||||
onClick={() => nav(`/user/${u.id}`)}
|
||||
>
|
||||
{u.nickname}
|
||||
</button>
|
||||
</td>
|
||||
<td className="admin-table-email">{u.email || '—'}</td>
|
||||
<td>
|
||||
{u.role === 'admin'
|
||||
? <Badge variant="orange">管理员</Badge>
|
||||
: <Badge variant="secondary">用户</Badge>}
|
||||
</td>
|
||||
<td>{u.banned ? <Badge variant="destructive">已禁言</Badge> : '正常'}</td>
|
||||
<td>{u.last_login_at ? new Date(u.last_login_at).toLocaleString('zh-CN') : '—'}</td>
|
||||
<td className="admin-table-mono">{u.last_login_ip || '—'}</td>
|
||||
<td>{u.created_at ? new Date(u.created_at).toLocaleString('zh-CN') : '—'}</td>
|
||||
<td>
|
||||
{u.role !== 'admin' && (
|
||||
<Button size="sm" variant="outline" onClick={() => toggleBan(u)}>
|
||||
{u.banned ? '解除禁言' : '禁言'}
|
||||
{u.role === 'admin' && <Badge variant="orange">站长</Badge>}
|
||||
{u.role !== 'admin' && u.verified && <Badge variant="green">认证</Badge>}
|
||||
{u.banned && <Badge variant="destructive">禁言</Badge>}
|
||||
</div>
|
||||
<div className="admin-users-handle">@{u.username}</div>
|
||||
{u.email?.trim() ? (
|
||||
<div className="admin-users-mail" title={u.email}>{u.email}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-users-metric" data-label="等级">
|
||||
<span className="admin-users-metric-value">Lv.{resolveUserLevel(u)}</span>
|
||||
</div>
|
||||
<div className="admin-users-metric" data-label="积分">
|
||||
<span className="admin-users-metric-value">{u.points ?? 0}</span>
|
||||
</div>
|
||||
<div
|
||||
className="admin-users-metric admin-users-metric--time"
|
||||
data-label="最近登录"
|
||||
title={fmtAbs(u.last_login_at)}
|
||||
>
|
||||
<span className="admin-users-metric-value">{fmtRel(u.last_login_at)}</span>
|
||||
</div>
|
||||
|
||||
<div className="admin-users-action">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="admin-users-manage-btn"
|
||||
aria-label={`管理 ${u.nickname}`}
|
||||
>
|
||||
管理
|
||||
<MoreHorizontal size={15} aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem onClick={() => openManage(u)}>
|
||||
<UserCog size={14} aria-hidden />
|
||||
账户详情
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => nav(`/user/${u.id}`)}>
|
||||
查看主页
|
||||
</DropdownMenuItem>
|
||||
{u.role !== 'admin' && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => toggleVerify(u)}>
|
||||
<BadgeCheck size={14} aria-hidden />
|
||||
{u.verified ? '取消认证' : '设为认证'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className={u.banned ? undefined : 'text-destructive focus:text-destructive'}
|
||||
onClick={() => setBanTarget(u)}
|
||||
>
|
||||
<Ban size={14} aria-hidden />
|
||||
{u.banned ? '解除禁言' : '禁言'}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{users.length === 0 && <div className="admin-empty">暂无用户</div>}
|
||||
</ul>
|
||||
|
||||
<div className="admin-users-footer">
|
||||
<span className="admin-users-total">{total} 位成员</span>
|
||||
{totalPages > 1 && (
|
||||
<div className="admin-pagination">
|
||||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => load(page - 1)}>上一页</Button>
|
||||
<span>第 {page} / {totalPages} 页</span>
|
||||
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => load(page + 1)}>下一页</Button>
|
||||
<div className="admin-users-pager">
|
||||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => load(page - 1)}>
|
||||
上一页
|
||||
</Button>
|
||||
<span>{page} / {totalPages}</span>
|
||||
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => load(page + 1)}>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={!!manageUser} onOpenChange={open => { if (!open) setManageUser(null); }}>
|
||||
<DialogContent className="admin-user-manage-dialog sm:max-w-md">
|
||||
{manageUser && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>账户详情</DialogTitle>
|
||||
<DialogDescription>
|
||||
@{manageUser.username} · #{manageUser.id}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="admin-user-manage-head">
|
||||
<UserAvatar user={manageUser} />
|
||||
<div>
|
||||
<div className="admin-user-manage-name">{manageUser.nickname}</div>
|
||||
<div className="admin-user-email">{manageUser.email || '未填写邮箱'}</div>
|
||||
<div className="admin-user-badges mt-1.5">
|
||||
{manageUser.role === 'admin' && <Badge variant="orange">站长</Badge>}
|
||||
{manageUser.role !== 'admin' && manageUser.verified && <Badge variant="green">认证</Badge>}
|
||||
{manageUser.banned && <Badge variant="destructive">禁言</Badge>}
|
||||
{manageUser.role !== 'admin' && !manageUser.verified && !manageUser.banned && (
|
||||
<Badge variant="secondary">普通用户</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-user-manage-section">
|
||||
<div className="admin-user-manage-section-title">登录与注册</div>
|
||||
<dl className="admin-user-fact-grid">
|
||||
<div>
|
||||
<dt>上次登录</dt>
|
||||
<dd title={fmtAbs(manageUser.last_login_at)}>{fmtRel(manageUser.last_login_at)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>登录 IP</dt>
|
||||
<dd className="admin-table-mono">{manageUser.last_login_ip || '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>最近访问</dt>
|
||||
<dd title={fmtAbs(manageUser.last_access_at)}>{fmtRel(manageUser.last_access_at)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>注册时间</dt>
|
||||
<dd title={fmtAbs(manageUser.created_at)}>{fmtRel(manageUser.created_at)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{manageUser.role !== 'admin' && (
|
||||
<div className="admin-user-manage-section">
|
||||
<div className="admin-user-manage-section-title">
|
||||
<Shield size={14} aria-hidden />
|
||||
权限与状态
|
||||
</div>
|
||||
<div className="admin-user-manage-actions">
|
||||
<Button size="sm" variant="outline" onClick={() => toggleVerify(manageUser)}>
|
||||
<BadgeCheck size={14} aria-hidden />
|
||||
{manageUser.verified ? '取消认证' : '设为认证'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={manageUser.banned ? 'outline' : 'destructive'}
|
||||
onClick={() => setBanTarget(manageUser)}
|
||||
>
|
||||
<Ban size={14} aria-hidden />
|
||||
{manageUser.banned ? '解除禁言' : '禁言'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-user-manage-section">
|
||||
<div className="admin-user-manage-section-title">
|
||||
等级 · 当前 Lv.{resolveUserLevel(manageUser)}
|
||||
</div>
|
||||
<div className="admin-user-manage-row">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={levelVal}
|
||||
onChange={e => setLevelVal(Number(e.target.value))}
|
||||
aria-label="等级"
|
||||
/>
|
||||
<Button size="sm" loading={saving} onClick={saveLevel}>保存</Button>
|
||||
</div>
|
||||
<p className="admin-user-manage-hint">经验 {manageUser.exp ?? 0};调整等级会同步 Exp</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-user-manage-section">
|
||||
<div className="admin-user-manage-section-title">
|
||||
积分 · 余额 {manageUser.points ?? 0}
|
||||
</div>
|
||||
<div className="admin-user-manage-row">
|
||||
<Input
|
||||
type="number"
|
||||
value={pointsDelta}
|
||||
onChange={e => setPointsDelta(e.target.value)}
|
||||
placeholder="正加负减"
|
||||
aria-label="积分变动"
|
||||
/>
|
||||
<Button size="sm" loading={saving} onClick={savePoints}>调整</Button>
|
||||
</div>
|
||||
<Input
|
||||
className="mt-2"
|
||||
value={pointsNote}
|
||||
onChange={e => setPointsNote(e.target.value)}
|
||||
placeholder="备注(可选)"
|
||||
aria-label="积分备注"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-user-manage-section">
|
||||
<div className="admin-user-manage-section-title">
|
||||
<Award size={14} aria-hidden />
|
||||
限定徽章
|
||||
</div>
|
||||
{limitedBadges.length === 0 ? (
|
||||
<p className="admin-user-manage-hint">暂无可用限定徽章,请先到「徽章管理」创建</p>
|
||||
) : (
|
||||
<div className="admin-user-manage-row">
|
||||
<select
|
||||
className="admin-user-select"
|
||||
value={badgeId}
|
||||
onChange={e => setBadgeId(e.target.value ? Number(e.target.value) : '')}
|
||||
aria-label="选择徽章"
|
||||
>
|
||||
{limitedBadges.map(b => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.name}({b.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button size="sm" loading={saving} onClick={saveBadge}>颁发</Button>
|
||||
</div>
|
||||
)}
|
||||
{badgeId !== '' && limitedBadges.find(b => b.id === badgeId) && (
|
||||
<div className="admin-user-badge-preview">
|
||||
{(() => {
|
||||
const b = limitedBadges.find(x => x.id === badgeId)!;
|
||||
const Icon = badgeIcon(b.icon);
|
||||
return (
|
||||
<>
|
||||
<Icon size={16} aria-hidden />
|
||||
<span>{b.name}</span>
|
||||
<span className="admin-user-manage-hint">{b.description || b.code}</span>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setManageUser(null)}>关闭</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={!!banTarget} onOpenChange={open => { if (!open) setBanTarget(null); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{banTarget?.banned ? '解除禁言' : '确认禁言'}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{banTarget?.banned
|
||||
? `确定解除对 ${banTarget?.nickname} 的禁言吗?`
|
||||
: `确定禁言 ${banTarget?.nickname}?被禁言用户将无法发帖与评论。`}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmBan}>
|
||||
{banTarget?.banned ? '解除禁言' : '确认禁言'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
64
frontend/src/utils/badgeIcons.ts
Normal file
64
frontend/src/utils/badgeIcons.ts
Normal file
@@ -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<string, LucideIcon> = 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() || '由站长手动颁发';
|
||||
}
|
||||
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height
|
||||
|
||||
const REPLY_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 15v4a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h10"/><path d="M20 7V3"/><path d="M22 5h-4"/></svg>`;
|
||||
|
||||
/** 积分解锁门控 */
|
||||
function buildPointsLockedGateHtml(charLength: number, cost: number, blockKey: string, isLoggedIn: boolean): string {
|
||||
const lengthHint = charLength > 0 ? `约 ${charLength} 字` : '付费内容';
|
||||
const actions = isLoggedIn
|
||||
? `<button type="button" class="post-points-only__gate-btn" data-points-unlock data-block-key="${blockKey}" data-cost="${cost}">花费 ${cost} 积分解锁</button>`
|
||||
: `<button type="button" class="post-points-only__gate-btn" data-members-login>登录后解锁</button>
|
||||
<button type="button" class="post-points-only__gate-link" data-members-register>免费注册</button>`;
|
||||
|
||||
return `
|
||||
<div class="post-points-only__locked-wrap">
|
||||
<div class="post-points-only__gate">
|
||||
<span class="post-points-only__gate-icon" aria-hidden="true">${LOCK_ICON_SVG}</span>
|
||||
<div class="post-points-only__gate-text">
|
||||
<p class="post-points-only__gate-title">积分可见(${lengthHint})</p>
|
||||
<p class="post-points-only__gate-desc">解锁需 ${cost} 积分,作者获得分成</p>
|
||||
</div>
|
||||
<div class="post-points-only__gate-actions">
|
||||
${actions}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** 游客看到的锁定区块:流内嵌条 + 登录引导(精简高度) */
|
||||
function buildLockedGateHtml(charLength: number): string {
|
||||
const lengthHint = charLength > 0
|
||||
@@ -150,6 +173,27 @@ export function renderPostContentHtml(
|
||||
el.innerHTML = `<div class="post-reply-only__body">${innerHtml}</div>`;
|
||||
});
|
||||
|
||||
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 = `<div class="post-points-only__body">${innerHtml}</div>`;
|
||||
});
|
||||
|
||||
doc.querySelectorAll('img').forEach(img => {
|
||||
if (!img.getAttribute('loading')) img.setAttribute('loading', 'lazy');
|
||||
if (!img.getAttribute('decoding')) img.setAttribute('decoding', 'async');
|
||||
|
||||
20
frontend/src/utils/userMeta.ts
Normal file
20
frontend/src/utils/userMeta.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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,6 +144,9 @@ func (h *Handlers) APIAdminDashboard(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"users": userCount, "posts": postCount, "boards": boardCount,
|
||||
"comments": commentCount,
|
||||
"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)})
|
||||
}
|
||||
|
||||
|
||||
208
handler/economy.go
Normal file
208
handler/economy.go
Normal file
@@ -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": "已颁发徽章"})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
43
model/db.go
43
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
34
model/level.go
Normal file
34
model/level.go
Normal file
@@ -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)
|
||||
}
|
||||
112
model/models.go
112
model/models.go
@@ -28,7 +28,7 @@ const (
|
||||
)
|
||||
|
||||
// User 用户表
|
||||
// Email / Password / LastLogin* 默认不随帖子等嵌套 User 序列化;
|
||||
// Email / Password / LastLogin* / LastAccessAt 默认不随帖子等嵌套 User 序列化;
|
||||
// 个人中心与后台列表请用 UserSelf / UserAdmin。
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
@@ -39,13 +39,33 @@ type User struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -2,6 +2,15 @@ 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"`
|
||||
@@ -10,6 +19,11 @@ type UserPublic struct {
|
||||
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"`
|
||||
@@ -24,13 +38,19 @@ type UserSelf struct {
|
||||
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"`
|
||||
@@ -39,10 +59,16 @@ type UserAdmin struct {
|
||||
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"`
|
||||
}
|
||||
@@ -56,6 +82,10 @@ func (u *User) ToPublic() UserPublic {
|
||||
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,
|
||||
@@ -72,6 +102,11 @@ func (u *User) ToSelf() UserSelf {
|
||||
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,
|
||||
@@ -89,10 +124,16 @@ func (u *User) ToAdmin() UserAdmin {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -110,9 +116,27 @@ func (s *AuthService) recordLogin(user *model.User, clientIP string) {
|
||||
_ = model.DB.Model(user).Updates(map[string]interface{}{
|
||||
"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
|
||||
|
||||
272
service/badge.go
Normal file
272
service/badge.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
272
service/points.go
Normal file
272
service/points.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
248
service/unlock.go
Normal file
248
service/unlock.go
Normal file
@@ -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)<points-only\b([^>]*)>([\s\S]*?)</points-only>`)
|
||||
|
||||
// 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(`<points-only data-gate="points" data-cost="%d" data-block-key="%s" data-locked="false">%s</points-only>`, cost, key, inner)
|
||||
}
|
||||
cost := parseDataCost(attrs)
|
||||
if cost < 1 {
|
||||
cost = 1
|
||||
}
|
||||
length := gatedContentLength(inner)
|
||||
return fmt.Sprintf(`<points-only data-gate="points" data-cost="%d" data-block-key="%s" data-locked="true" data-length="%d"></points-only>`, 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(`<points-only data-gate="points" data-cost="%d" data-block-key="%s" data-locked="false">%s</points-only>`, cost, key, inner)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user