增加用户认证、等级、徽章与积分体系,并优化管理后台体验。

覆盖站长调账与积分解锁内容;后台按审核优先分组导航,仪表盘展示待办,用户管理改为成员目录式布局。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-06 02:42:18 +08:00
parent b075495540
commit 6b0a1d4281
44 changed files with 4455 additions and 254 deletions

View File

@@ -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>} />

View File

@@ -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);

View File

@@ -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 }[];
}

View File

@@ -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) },

View File

@@ -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>

View 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>
);
}

View File

@@ -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 ? (

View File

@@ -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;

View File

@@ -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>

View 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>
);
}

View File

@@ -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} />}
</>
);

View 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;
},
};
},
});

View File

@@ -1,7 +1,7 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
import {
LayoutDashboard, FolderKanban, FileText, MessageSquare, Flag, Users, Images, Settings, ArrowLeft, Moon, Sun, Menu, X,
LayoutDashboard, FolderKanban, FileText, MessageSquare, Flag, Users, Images, Settings, ArrowLeft, Moon, Sun, Menu, X, Award,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { useAuth } from '../hooks/useAuth';
@@ -14,18 +14,66 @@ import { loginPath } from '../utils/authRedirect';
import { useSiteBranding } from '../hooks/useSiteBranding';
import { useNoIndexSEO } from '../hooks/usePageSEO';
import SiteBrandMark from '../components/SiteBrandMark';
import { api } from '../api/client';
const NAV = [
{ to: '/admin/dashboard', label: '仪表盘', icon: LayoutDashboard },
{ to: '/admin/boards', label: '板块管理', icon: FolderKanban },
{ to: '/admin/posts', label: '帖子管理', icon: FileText },
{ to: '/admin/comments', label: '评论管理', icon: MessageSquare },
{ to: '/admin/reports', label: '举报管理', icon: Flag },
{ to: '/admin/users', label: '用户管理', icon: Users },
{ to: '/admin/media', label: '媒体库', icon: Images },
{ to: '/admin/settings', label: '系统设置', icon: Settings },
type BadgeKey = 'posts' | 'comments' | 'reports';
type NavItem = {
to: string;
label: string;
icon: typeof LayoutDashboard;
badgeKey?: BadgeKey;
};
type NavGroup = {
label: string;
items: NavItem[];
};
/** 信息架构:概览 → 内容审核 → 社区 → 系统2026 管理台常见分层) */
const NAV_GROUPS: NavGroup[] = [
{
label: '概览',
items: [
{ to: '/admin/dashboard', label: '仪表盘', icon: LayoutDashboard },
],
},
{
label: '内容审核',
items: [
{ to: '/admin/posts', label: '帖子管理', icon: FileText, badgeKey: 'posts' },
{ to: '/admin/comments', label: '评论管理', icon: MessageSquare, badgeKey: 'comments' },
{ to: '/admin/reports', label: '举报管理', icon: Flag, badgeKey: 'reports' },
],
},
{
label: '社区',
items: [
{ to: '/admin/boards', label: '板块管理', icon: FolderKanban },
{ to: '/admin/users', label: '用户管理', icon: Users },
{ to: '/admin/badges', label: '徽章管理', icon: Award },
],
},
{
label: '系统',
items: [
{ to: '/admin/media', label: '媒体库', icon: Images },
{ to: '/admin/settings', label: '系统设置', icon: Settings },
],
},
];
type PendingCounts = {
posts: number;
comments: number;
reports: number;
};
function formatNavBadge(n: number) {
if (n <= 0) return null;
return n > 99 ? '99+' : String(n);
}
/** React 管理后台布局,与前台 SPA 风格统一 */
export default function AdminLayout() {
const { user, loading } = useAuth();
@@ -34,7 +82,9 @@ export default function AdminLayout() {
useNoIndexSEO('管理后台');
const isNarrow = useMediaQuery('(max-width: 768px)');
const [navOpen, setNavOpen] = useState(false);
const [pending, setPending] = useState<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 }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) => cn('admin-nav-item', isActive && 'active')}
onClick={closeNav}
>
<Icon size={16} aria-hidden />
{label}
</NavLink>
));
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 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>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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">

View File

@@ -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>

View File

@@ -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 />

View 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>
);
}

View File

@@ -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>
<div className="admin-stat-grid">
{stats.map(s => (
<div key={s.label} className={`admin-stat-card ${s.cls}`}>
<div className="admin-stat-value">{s.value}</div>
<div className="admin-stat-label">{s.label}</div>
</div>
))}
</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">
<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">

View File

@@ -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) => (

View File

@@ -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('等级须为 110 的整数');
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-card">
<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>
{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>
{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}`)}>
{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 ? '解除禁言' : '禁言'}
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
<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 => (
<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>
{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>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</li>
))}
</ul>
<div className="admin-users-footer">
<span className="admin-users-total">{total} </span>
{totalPages > 1 && (
<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>
{users.length === 0 && <div className="admin-empty"></div>}
{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>
)}
</>
)}
</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>
);
}

View File

@@ -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;
}

View 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() || '由站长手动颁发';
}

View File

@@ -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');

View 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);
}