支持公开用户主页、帖子图缩略图与编辑器图组排版。
新增用户签名与活动统计、图片灯箱;正文按需生成缩略图;TipTap 支持多图分组与环绕排版,并注入站点标题避免刷新闪烁。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
支持 JPG、PNG、GIF、WebP,头像不超过 {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>
|
||||
);
|
||||
|
||||
211
frontend/src/pages/UserProfilePage.tsx
Normal file
211
frontend/src/pages/UserProfilePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user