支持公开用户主页、帖子图缩略图与编辑器图组排版。

新增用户签名与活动统计、图片灯箱;正文按需生成缩略图;TipTap 支持多图分组与环绕排版,并注入站点标题避免刷新闪烁。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-01 12:29:50 +08:00
parent 822eef96be
commit 060b7707cb
51 changed files with 3563 additions and 397 deletions

View File

@@ -323,85 +323,90 @@ export default function ComposePage() {
return (
<div className="compose-page">
<div className="compose-canvas">
<header className="compose-header">
<button
type="button"
className="compose-back"
onClick={() => requestLeave(() => {
if (isEdit) nav(`/post/${editId}`);
else nav(-1);
})}
>
<ArrowLeft size={16} />
<span></span>
</button>
<div className="compose-header-actions">
{(draftHint || editWindowHint) && (
<span className="compose-draft-hint" title={editWindowHint || draftHint}>
{editWindowHint || draftHint}
</span>
)}
<button
type="button"
className="compose-publish-btn"
disabled={publishing}
onClick={handleSubmit}
>
<Send size={16} />
{publishing ? (isEdit ? '保存中…' : '发布中…') : (isEdit ? '保存修改' : '发布帖子')}
</button>
</div>
</header>
<div className="compose-meta">
{!isEdit ? (
<div className="compose-board-pills">
{boards.map(b => (
<button
key={b.id}
type="button"
className={`compose-board-pill${String(b.id) === boardId ? ' active' : ''}`}
onClick={() => setBoardId(String(b.id))}
>
{b.name}
</button>
))}
</div>
) : currentBoard && (
<div className="compose-board-pills">
<span className="compose-board-pill active">{currentBoard.name}</span>
</div>
)}
<TagInput
value={tags}
onChange={setTags}
placeholder="输入标签后回车"
maxLength={limits.post_tags_max > 0 ? limits.post_tags_max : undefined}
/>
</div>
<div className="compose-writing">
<input
className="compose-title"
type="text"
placeholder="输入文章标题…"
value={title}
onChange={e => setTitle(e.target.value)}
maxLength={limits.post_title_max > 0 ? limits.post_title_max : undefined}
/>
{currentBoard && (
<div className="compose-subtitle">
{isEdit ? '编辑于' : '发布至'} <strong>{currentBoard.name}</strong>
{editWindowHint && (
<span className="compose-edit-window"> · {editWindowHint}</span>
<div className="compose-shell">
<header className="compose-header">
<div className="compose-header-left">
<button
type="button"
className="compose-back"
onClick={() => requestLeave(() => {
if (isEdit) nav(`/post/${editId}`);
else nav(-1);
})}
>
<ArrowLeft size={16} />
<span></span>
</button>
<h1 className="compose-header-title">{isEdit ? '编辑帖子' : '写新帖'}</h1>
{(draftHint || editWindowHint) && (
<span className="compose-draft-hint" title={editWindowHint || draftHint}>
{editWindowHint || draftHint}
</span>
)}
</div>
)}
<ArticleEditor
value={content}
onChange={setContent}
placeholder="开始写作。所见即所得,选中文字后使用工具栏设置格式。"
/>
<div className="compose-header-actions">
<button
type="button"
className="compose-publish-btn"
disabled={publishing}
onClick={handleSubmit}
>
<Send size={16} />
{publishing ? (isEdit ? '保存中…' : '发布中…') : (isEdit ? '保存修改' : '发布')}
</button>
</div>
</header>
<section className="compose-context" aria-label="发布设置">
<div className="compose-context-row">
<span className="compose-context-label"></span>
{!isEdit ? (
<div className="compose-board-pills" role="listbox" aria-label="选择板块">
{boards.map(b => (
<button
key={b.id}
type="button"
role="option"
aria-selected={String(b.id) === boardId}
className={`compose-board-pill${String(b.id) === boardId ? ' active' : ''}`}
onClick={() => setBoardId(String(b.id))}
>
{b.name}
</button>
))}
</div>
) : currentBoard ? (
<div className="compose-board-pills">
<span className="compose-board-pill active">{currentBoard.name}</span>
</div>
) : null}
</div>
<div className="compose-context-row compose-context-row--tags">
<span className="compose-context-label"></span>
<TagInput
value={tags}
onChange={setTags}
placeholder="添加标签,回车确认"
maxLength={limits.post_tags_max > 0 ? limits.post_tags_max : undefined}
/>
</div>
</section>
<div className="compose-document">
<input
className="compose-title"
type="text"
placeholder="输入文章标题…"
value={title}
onChange={e => setTitle(e.target.value)}
maxLength={limits.post_title_max > 0 ? limits.post_title_max : undefined}
/>
<ArticleEditor
value={content}
onChange={setContent}
placeholder="开始写作。按回车分段,选中文字后用工具栏设置格式。"
/>
</div>
</div>
</div>
<UnsavedChangesDialog

View File

@@ -5,6 +5,7 @@ import PinnedIcon from '@/components/PinnedIcon';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import BoardBadge from '@/components/BoardBadge';
import UserLink from '@/components/UserLink';
import { Spinner } from '@/components/ui/spinner';
import {
AlertDialog,
@@ -340,11 +341,18 @@ export default function PostDetailPage() {
{post.title}
</h1>
<div className="post-detail-author-row">
<div className="post-avatar post-avatar-lg">
{post.user?.avatar ? <img src={post.user.avatar} alt="" loading="lazy" decoding="async" /> : authorInitial}
</div>
<UserLink
user={post.user}
showAvatar={false}
showName={false}
className="post-avatar post-avatar-lg user-link--avatar-only"
>
{post.user?.avatar
? <img src={post.user.avatar} alt="" loading="lazy" decoding="async" />
: authorInitial}
</UserLink>
<div className="post-detail-author-info">
<span className="post-detail-author-name">{post.user?.nickname}</span>
<UserLink user={post.user} className="post-detail-author-name" />
<span className="post-detail-meta-line">
{formatDateTime(post.created_at)}
{showEdited && (

View File

@@ -1,26 +1,52 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { ArrowLeft, Camera, LayoutDashboard, Settings, Upload, X } from 'lucide-react';
import {
ArrowLeft,
Camera,
Check,
Copy,
FileText,
Hash,
Heart,
LayoutDashboard,
MessageCircle,
PenLine,
Settings,
Star,
Upload,
X,
} from 'lucide-react';
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 { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { useAuth } from '../hooks/useAuth';
import { api } from '../api/client';
import type { PostItem, UserActivityStats } from '../api/types';
import { useForumLimits } from '../hooks/useForumLimits';
import AvatarCropDialog from '../components/AvatarCropDialog';
import PostListItem from '../components/PostListItem';
import FeedPagination from '../components/FeedPagination';
import { AVATAR_ACCEPT, validateAvatarFile } from '../utils/avatarCrop';
import { loginPath } from '../utils/authRedirect';
import { openForumPost } from '../utils/openPost';
import { formatDateTime } from '../utils/content';
import { userPath } from '../utils/userPath';
const nickSchema = z.object({
nickname: z.string().min(1, '请输入昵称').max(64),
});
const sigSchema = (maxLen: number) => z.object({
signature: z.string().max(maxLen > 0 ? maxLen : 512, `签名不能超过 ${maxLen || 512}`),
});
const pwdSchema = (minLen: number) => z.object({
old_password: z.string().min(1, '请输入当前密码'),
new_password: z.string().min(minLen, `新密码至少 ${minLen}`),
@@ -31,12 +57,22 @@ const pwdSchema = (minLen: number) => z.object({
});
type NickValues = z.infer<typeof nickSchema>;
type SigValues = z.infer<ReturnType<typeof sigSchema>>;
type PwdValues = z.infer<ReturnType<typeof pwdSchema>>;
type ProfileTab = 'posts' | 'settings' | 'security';
function parseTab(raw: string | null): ProfileTab {
if (raw === 'settings' || raw === 'security' || raw === 'posts') return raw;
return 'posts';
}
export default function ProfilePage() {
const nav = useNavigate();
const [params, setParams] = useSearchParams();
const tab = parseTab(params.get('tab'));
const { user, loading: authLoading, refresh } = useAuth();
const [nickLoading, setNickLoading] = useState(false);
const [sigLoading, setSigLoading] = useState(false);
const [pwdLoading, setPwdLoading] = useState(false);
const [avatarLoading, setAvatarLoading] = useState(false);
const [pendingAvatar, setPendingAvatar] = useState<File | null>(null);
@@ -45,21 +81,44 @@ export default function ProfilePage() {
const [cropImageSrc, setCropImageSrc] = useState<string | null>(null);
const [cropFileName, setCropFileName] = useState('');
const [dragOver, setDragOver] = useState(false);
const [idCopied, setIdCopied] = useState(false);
const [stats, setStats] = useState<UserActivityStats | null>(null);
const [statsLoading, setStatsLoading] = useState(true);
const [posts, setPosts] = useState<PostItem[]>([]);
const [postsLoading, setPostsLoading] = useState(false);
const [postPage, setPostPage] = useState(1);
const [postTotal, setPostTotal] = useState(0);
const fileRef = useRef<HTMLInputElement>(null);
const dragCounter = useRef(0);
const copyTimer = useRef<ReturnType<typeof setTimeout>>();
const { limits } = useForumLimits();
const pageSize = limits.page_size_default > 0 ? limits.page_size_default : 20;
const totalPages = Math.max(1, Math.ceil(postTotal / pageSize));
const nickForm = useForm<NickValues>({
resolver: zodResolver(nickSchema),
values: { nickname: user?.nickname ?? '' },
});
const sigMax = limits.signature_max > 0 ? limits.signature_max : 200;
const sigForm = useForm<SigValues>({
resolver: zodResolver(sigSchema(sigMax)),
values: { signature: user?.signature ?? '' },
});
const pwdForm = useForm<PwdValues>({
resolver: zodResolver(pwdSchema(limits.password_min_len)),
defaultValues: { old_password: '', new_password: '', confirm_password: '' },
});
const setTab = (next: ProfileTab) => {
const nextParams = new URLSearchParams(params);
if (next === 'posts') nextParams.delete('tab');
else nextParams.set('tab', next);
setParams(nextParams, { replace: true });
};
useEffect(() => {
if (!authLoading && !user) {
nav(loginPath('/profile'));
@@ -78,6 +137,42 @@ export default function ProfilePage() {
};
}, [cropImageSrc]);
useEffect(() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
}, []);
const loadStats = useCallback(() => {
setStatsLoading(true);
api.profileStats()
.then(d => setStats(d.stats))
.catch(() => setStats(null))
.finally(() => setStatsLoading(false));
}, []);
useEffect(() => {
if (!user) return;
loadStats();
}, [user, loadStats]);
useEffect(() => {
if (!user || tab !== 'posts') return;
let cancelled = false;
setPostsLoading(true);
api.posts({ user_id: user.id, page: postPage, size: pageSize, sort: 'latest' })
.then(d => {
if (cancelled) return;
setPosts(Array.isArray(d.posts) ? d.posts : []);
setPostTotal(d.total ?? 0);
})
.catch(e => {
if (!cancelled) notify.error(e instanceof Error ? e.message : '加载帖子失败');
})
.finally(() => {
if (!cancelled) setPostsLoading(false);
});
return () => { cancelled = true; };
}, [user, tab, postPage, pageSize]);
const closeCropDialog = useCallback((open: boolean) => {
if (!open) {
setCropOpen(false);
@@ -109,6 +204,19 @@ export default function ProfilePage() {
}
};
const onUpdateSig = async (values: SigValues) => {
setSigLoading(true);
try {
await api.updateSignature(values.signature);
await refresh();
notify.success('签名已更新');
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '更新失败');
} finally {
setSigLoading(false);
}
};
const onUpdatePwd = async (values: PwdValues) => {
setPwdLoading(true);
try {
@@ -132,7 +240,7 @@ export default function ProfilePage() {
};
const openCropForFile = (file: File) => {
const err = validateAvatarFile(file, limits.avatar_max_mb);
const err = validateAvatarFile(file);
if (err) {
notify.error(err);
if (fileRef.current) fileRef.current.value = '';
@@ -204,7 +312,26 @@ export default function ProfilePage() {
if (file) openCropForFile(file);
};
const copyUserId = async () => {
try {
await navigator.clipboard.writeText(String(user.id));
setIdCopied(true);
notify.success('已复制用户 ID');
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setIdCopied(false), 1600);
} catch {
notify.error('复制失败,请手动选择');
}
};
const displayAvatar = avatarPreview ?? user.avatar;
const joinedAt = user.created_at ? formatDateTime(user.created_at) : '';
const tabs: { key: ProfileTab; label: string; count?: number }[] = [
{ key: 'posts', label: '我的帖子', count: stats?.post_count },
{ key: 'settings', label: '资料设置' },
{ key: 'security', label: '安全设置' },
];
return (
<div className="page-wrap">
@@ -254,12 +381,58 @@ export default function ProfilePage() {
</span>
</div>
</button>
<div className="profile-header-info">
<div className="profile-header-main">
<h1 className="profile-display-name">{user.nickname}</h1>
<div className="profile-name-row">
<h2 className="profile-display-name">{user.nickname}</h2>
{user.role === 'admin' && <Badge variant="green"></Badge>}
</div>
<div className="profile-username">@{user.username}</div>
<p className="profile-avatar-tip"></p>
{user.role === 'admin' && <Badge variant="green" className="mt-1.5"></Badge>}
<div className="profile-id-row">
<span className="profile-id-chip" title="用户 ID">
<Hash size={13} aria-hidden />
UID {user.id}
</span>
<button
type="button"
className="profile-id-copy"
onClick={copyUserId}
aria-label="复制用户 ID"
>
{idCopied ? <Check size={14} /> : <Copy size={14} />}
{idCopied ? '已复制' : '复制'}
</button>
<button
type="button"
className="profile-id-copy"
onClick={() => nav(userPath(user.id))}
>
</button>
</div>
{user.signature?.trim() ? (
<p className="profile-signature">{user.signature}</p>
) : (
<p className="profile-signature profile-signature--empty"></p>
)}
<dl className="profile-meta-list">
<div>
<dt></dt>
<dd>{user.username}</dd>
</div>
<div>
<dt></dt>
<dd>{user.email || '未设置'}</dd>
</div>
{joinedAt && (
<div>
<dt></dt>
<dd>{joinedAt}</dd>
</div>
)}
</dl>
<p className="profile-avatar-tip"></p>
</div>
{pendingAvatar && (
<div className="profile-avatar-actions">
@@ -279,12 +452,36 @@ export default function ProfilePage() {
</div>
)}
</div>
<div className="profile-stat-grid" aria-label="活动统计">
<button type="button" className="profile-stat" onClick={() => setTab('posts')}>
<FileText size={16} aria-hidden />
<strong>{statsLoading ? '—' : (stats?.post_count ?? 0)}</strong>
<span></span>
</button>
<div className="profile-stat">
<MessageCircle size={16} aria-hidden />
<strong>{statsLoading ? '—' : (stats?.comment_count ?? 0)}</strong>
<span></span>
</div>
<button type="button" className="profile-stat" onClick={() => nav('/favorites')}>
<Star size={16} aria-hidden />
<strong>{statsLoading ? '—' : (stats?.favorite_count ?? 0)}</strong>
<span></span>
</button>
<div className="profile-stat">
<Heart size={16} aria-hidden />
<strong>{statsLoading ? '—' : (stats?.like_received ?? 0)}</strong>
<span></span>
</div>
</div>
</div>
<AvatarCropDialog
open={cropOpen}
imageSrc={cropImageSrc}
fileName={cropFileName}
maxMb={limits.avatar_max_mb}
onOpenChange={closeCropDialog}
onConfirm={onCropConfirm}
/>
@@ -308,96 +505,190 @@ export default function ProfilePage() {
</div>
)}
<div className="section-card">
<div className="section-card-title"></div>
<Form {...nickForm}>
<form onSubmit={nickForm.handleSubmit(onUpdateNick)} className="profile-form">
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input value={user.username} disabled />
</FormControl>
</FormItem>
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input value={user.email || '未设置'} disabled />
</FormControl>
</FormItem>
<FormField
control={nickForm.control}
name="nickname"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input maxLength={64} placeholder="显示名称" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="profile-form-footer">
<span className="profile-form-hint">
JPGPNGGIFWebP {limits.avatar_max_mb}MB
</span>
<Button type="submit" loading={nickLoading}></Button>
</div>
</form>
</Form>
<div className="profile-tabs" role="tablist" aria-label="个人中心分区">
{tabs.map(t => (
<button
key={t.key}
type="button"
role="tab"
aria-selected={tab === t.key}
className={`profile-tab${tab === t.key ? ' active' : ''}`}
onClick={() => setTab(t.key)}
>
{t.label}
{typeof t.count === 'number' && (
<span className="profile-tab-count">{t.count}</span>
)}
</button>
))}
</div>
<div className="section-card">
<div className="section-card-title"></div>
<Form {...pwdForm}>
<form onSubmit={pwdForm.handleSubmit(onUpdatePwd)} className="profile-form">
<FormField
control={pwdForm.control}
name="old_password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="输入当前密码" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={pwdForm.control}
name="new_password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder={`至少 ${limits.password_min_len}`} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={pwdForm.control}
name="confirm_password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="再次输入新密码" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="profile-form-footer profile-form-footer--end">
<Button type="submit" variant="destructive" loading={pwdLoading}>
</Button>
{tab === 'posts' && (
<div className="profile-panel">
{postsLoading ? (
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
) : posts.length === 0 ? (
<div className="empty-state">
<PenLine className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
<p></p>
<Button onClick={() => nav('/compose')}></Button>
</div>
</form>
</Form>
</div>
) : (
<>
<div className="content-surface">
{posts.map(post => (
<PostListItem
key={post.id}
post={post}
onSelect={(id) => openForumPost(nav, id, limits.open_posts_in_new_tab)}
/>
))}
</div>
{totalPages > 1 && (
<FeedPagination
page={postPage}
totalPages={totalPages}
postTotal={postTotal}
loading={postsLoading}
onPageChange={setPostPage}
/>
)}
</>
)}
</div>
)}
{tab === 'settings' && (
<div className="section-card">
<div className="section-card-title"></div>
<Form {...nickForm}>
<form onSubmit={nickForm.handleSubmit(onUpdateNick)} className="profile-form">
<FormItem>
<FormLabel> ID</FormLabel>
<FormControl>
<Input value={String(user.id)} disabled />
</FormControl>
</FormItem>
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input value={user.username} disabled />
</FormControl>
</FormItem>
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input value={user.email || '未设置'} disabled />
</FormControl>
</FormItem>
<FormField
control={nickForm.control}
name="nickname"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input maxLength={64} placeholder="显示名称" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="profile-form-footer">
<span className="profile-form-hint">
ID JPG / PNG / GIF / WebP {limits.avatar_max_mb}MB
</span>
<Button type="submit" loading={nickLoading}></Button>
</div>
</form>
</Form>
<div className="profile-form-divider" />
<Form {...sigForm}>
<form onSubmit={sigForm.handleSubmit(onUpdateSig)} className="profile-form">
<FormField
control={sigForm.control}
name="signature"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Textarea
rows={3}
maxLength={sigMax}
placeholder="写一句介绍自己的话,会显示在公开主页"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="profile-form-footer">
<span className="profile-form-hint">
{(sigForm.watch('signature') || '').length}/{sigMax}
</span>
<Button type="submit" loading={sigLoading}></Button>
</div>
</form>
</Form>
</div>
)}
{tab === 'security' && (
<div className="section-card">
<div className="section-card-title"></div>
<Form {...pwdForm}>
<form onSubmit={pwdForm.handleSubmit(onUpdatePwd)} className="profile-form">
<FormField
control={pwdForm.control}
name="old_password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="输入当前密码" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={pwdForm.control}
name="new_password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder={`至少 ${limits.password_min_len}`} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={pwdForm.control}
name="confirm_password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="再次输入新密码" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="profile-form-footer profile-form-footer--end">
<Button type="submit" variant="destructive" loading={pwdLoading}>
</Button>
</div>
</form>
</Form>
</div>
)}
</div>
</div>
);

View File

@@ -0,0 +1,211 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
ArrowLeft,
FileText,
Hash,
Heart,
MessageCircle,
PenLine,
Settings,
Star,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { PostItem, UserActivityStats, UserPublic } from '../api/types';
import { useAuth } from '../hooks/useAuth';
import { useForumLimits } from '../hooks/useForumLimits';
import PostListItem from '../components/PostListItem';
import FeedPagination from '../components/FeedPagination';
import { openForumPost } from '../utils/openPost';
import { formatDateTime } from '../utils/content';
export default function UserProfilePage() {
const { id: idParam } = useParams();
const userId = Number(idParam);
const nav = useNavigate();
const { user: me } = useAuth();
const { limits } = useForumLimits();
const pageSize = limits.page_size_default > 0 ? limits.page_size_default : 20;
const [profile, setProfile] = useState<UserPublic | null>(null);
const [stats, setStats] = useState<UserActivityStats | null>(null);
const [loading, setLoading] = useState(true);
const [posts, setPosts] = useState<PostItem[]>([]);
const [postsLoading, setPostsLoading] = useState(false);
const [postPage, setPostPage] = useState(1);
const [postTotal, setPostTotal] = useState(0);
const isSelf = !!me && me.id === userId;
const totalPages = Math.max(1, Math.ceil(postTotal / pageSize));
useEffect(() => {
if (!userId || Number.isNaN(userId)) {
notify.error('无效用户');
nav('/');
return;
}
setLoading(true);
setPostPage(1);
api.userProfile(userId)
.then(d => {
setProfile(d.user);
setStats(d.stats);
})
.catch(e => {
notify.error(e instanceof Error ? e.message : '用户不存在');
nav('/');
})
.finally(() => setLoading(false));
}, [userId, nav]);
useEffect(() => {
if (!userId || Number.isNaN(userId) || !profile) return;
let cancelled = false;
setPostsLoading(true);
api.posts({ user_id: userId, page: postPage, size: pageSize, sort: 'latest' })
.then(d => {
if (cancelled) return;
setPosts(Array.isArray(d.posts) ? d.posts : []);
setPostTotal(d.total ?? 0);
})
.catch(e => {
if (!cancelled) notify.error(e instanceof Error ? e.message : '加载帖子失败');
})
.finally(() => {
if (!cancelled) setPostsLoading(false);
});
return () => { cancelled = true; };
}, [userId, profile, postPage, pageSize]);
if (loading) {
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
}
if (!profile) return null;
const joinedAt = profile.created_at ? formatDateTime(profile.created_at) : '';
const signature = profile.signature?.trim() || '';
return (
<div className="page-wrap">
<div className="page-inner-wide page-inner-wide--profile">
<Button variant="ghost" className="mb-3" onClick={() => nav(-1)}>
<ArrowLeft />
</Button>
<div className="profile-header-card profile-header-card--public">
<div className="profile-avatar-lg" aria-hidden>
{profile.avatar
? <img src={profile.avatar} alt="" loading="lazy" decoding="async" />
: profile.nickname[0]}
</div>
<div className="profile-header-info">
<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>}
{profile.banned && <Badge variant="destructive"></Badge>}
</div>
<div className="profile-username">@{profile.username}</div>
<div className="profile-id-row">
<span className="profile-id-chip" title="用户 ID">
<Hash size={13} aria-hidden />
UID {profile.id}
</span>
</div>
{signature ? (
<p className="profile-signature">{signature}</p>
) : (
<p className="profile-signature profile-signature--empty"></p>
)}
<dl className="profile-meta-list">
{joinedAt && (
<div>
<dt></dt>
<dd>{joinedAt}</dd>
</div>
)}
</dl>
</div>
{isSelf && (
<div className="profile-avatar-actions">
<Button size="sm" variant="outline" onClick={() => nav('/profile?tab=settings')}>
<Settings size={14} />
</Button>
</div>
)}
</div>
<div className="profile-stat-grid" aria-label="活动统计">
<div className="profile-stat">
<FileText size={16} aria-hidden />
<strong>{stats?.post_count ?? 0}</strong>
<span></span>
</div>
<div className="profile-stat">
<MessageCircle size={16} aria-hidden />
<strong>{stats?.comment_count ?? 0}</strong>
<span></span>
</div>
<div className="profile-stat">
<Heart size={16} aria-hidden />
<strong>{stats?.like_received ?? 0}</strong>
<span></span>
</div>
{isSelf && (
<button type="button" className="profile-stat" onClick={() => nav('/favorites')}>
<Star size={16} aria-hidden />
<strong>{stats?.favorite_count ?? 0}</strong>
<span></span>
</button>
)}
</div>
</div>
<div className="section-card-title profile-posts-heading">
{isSelf ? '我的帖子' : `${profile.nickname} 的帖子`}
{postTotal > 0 && <span className="profile-tab-count">{postTotal}</span>}
</div>
<div className="profile-panel">
{postsLoading ? (
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
) : posts.length === 0 ? (
<div className="empty-state">
<PenLine className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
<p>{isSelf ? '还没有发布过帖子' : '暂无公开帖子'}</p>
{isSelf && <Button onClick={() => nav('/compose')}></Button>}
</div>
) : (
<>
<div className="content-surface">
{posts.map(post => (
<PostListItem
key={post.id}
post={post}
onSelect={(id) => openForumPost(nav, id, limits.open_posts_in_new_tab)}
/>
))}
</div>
{totalPages > 1 && (
<FeedPagination
page={postPage}
totalPages={totalPages}
postTotal={postTotal}
loading={postsLoading}
onPageChange={setPostPage}
/>
)}
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -84,7 +84,13 @@ export default function AdminCommentsPage() {
{c.post?.title ?? `#${c.post_id}`}
</button>
</td>
<td>{c.user?.nickname || c.guest_nick || '游客'}</td>
<td>
{c.user_id && c.user ? (
<button type="button" className="admin-text-link" onClick={() => nav(`/user/${c.user_id}`)}>
{c.user.nickname}
</button>
) : (c.guest_nick || '游客')}
</td>
<td className="max-w-[200px] truncate">{c.content}</td>
<td>{c.is_private ? <Badge variant="secondary"></Badge> : '—'}</td>
<td>{new Date(c.created_at).toLocaleString('zh-CN')}</td>

View File

@@ -73,7 +73,13 @@ export default function AdminDashboardPage() {
{p.title}
</button>
</td>
<td>{p.user?.nickname ?? '—'}</td>
<td>
{p.user?.id ? (
<button type="button" className="admin-text-link" onClick={() => nav(`/user/${p.user!.id}`)}>
{p.user.nickname}
</button>
) : '—'}
</td>
<td>{p.pinned ? <Badge variant="orange"></Badge> : '—'}</td>
<td>{new Date(p.created_at).toLocaleString('zh-CN')}</td>
</tr>

View File

@@ -145,7 +145,13 @@ export default function AdminPostsPage() {
{edited && <Badge variant="secondary" className="ml-1"></Badge>}
</td>
<td>{p.board?.name ?? '—'}</td>
<td>{p.user?.nickname ?? '—'}</td>
<td>
{p.user?.id ? (
<button type="button" className="admin-text-link" onClick={() => nav(`/user/${p.user!.id}`)}>
{p.user.nickname}
</button>
) : '—'}
</td>
<td className="max-w-[120px] truncate text-muted-foreground">{p.tags || '—'}</td>
<td>{p.comment_count ?? 0}</td>
<td>{p.pinned ? <Badge variant="orange"></Badge> : '—'}</td>

View File

@@ -78,10 +78,11 @@ const SETTING_SECTIONS: SettingSection[] = [
{
id: 'user',
title: '用户账号',
summary: '注册、改密头像上传限制',
summary: '注册、改密头像与签名限制',
rows: [
{ key: 'password_min_len', label: '密码最短', unit: '位', min: 4 },
{ key: 'avatar_max_mb', label: '头像上限', unit: 'MB', min: 1 },
{ key: 'signature_max', label: '签名上限', unit: '字', min: 0 },
],
},
];

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
@@ -8,6 +9,7 @@ import { useAdminGuard } from '../../layouts/AdminLayout';
import type { User } from '../../api/types';
export default function AdminUsersPage() {
const nav = useNavigate();
const { ready } = useAdminGuard();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
@@ -79,7 +81,11 @@ export default function AdminUsersPage() {
<tr key={u.id}>
<td>{u.id}</td>
<td>{u.username}</td>
<td>{u.nickname}</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'