完善 TipTap 编辑器与个人资料页:头像裁剪、Tab 缩进、文章内链、Markdown 工具与 Tooltip,并更新样式与 Feed 缓存。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-16 05:57:15 +08:00
parent e4d1dd139e
commit 57172eb053
56 changed files with 3128 additions and 531 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate, useOutletContext, useSearchParams } from 'react-router-dom';
import { useNavigate, useOutletContext, useSearchParams, useLocation } from 'react-router-dom';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { PostItem } from '../api/types';
@@ -7,10 +7,18 @@ import type { LayoutCtx } from '../layouts/MainLayout';
import VirtualPostList from '../components/VirtualPostList';
import FeedHeader from '../components/FeedHeader';
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
import { getFeedCache, setFeedCache, clearAllFeedCache } from '../utils/feedCache';
import {
getFeedCache,
setFeedCache,
clearAllFeedCache,
navigateFeed,
FEED_RESET_EVENT,
type FeedNavState,
} from '../utils/feedCache';
export default function HomePage() {
const nav = useNavigate();
const location = useLocation();
const [params] = useSearchParams();
const ctx = useOutletContext<LayoutCtx>();
const boardId = Number(params.get('board')) || ctx?.boardId || 0;
@@ -24,7 +32,24 @@ export default function HomePage() {
const [hasMore, setHasMore] = useState(() => initialCache?.hasMore ?? true);
const [loading, setLoading] = useState(() => !initialCache);
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(() => initialCache?.scrollTop ?? null);
const [listResetKey, setListResetKey] = useState(0);
const scrollTopRef = useRef(initialCache?.scrollTop ?? 0);
const pageWrapRef = useRef<HTMLDivElement>(null);
/** 主动刷新时不把旧列表/滚动位置写回 cache */
const skipCacheSaveRef = useRef(false);
const resetFeedView = useCallback(() => {
setRestoreScrollTop(null);
scrollTopRef.current = 0;
setListResetKey(k => k + 1);
pageWrapRef.current?.scrollTo(0);
}, []);
const beginFeedRefresh = useCallback(() => {
skipCacheSaveRef.current = true;
clearAllFeedCache();
resetFeedView();
}, [resetFeedView]);
const load = useCallback(async (p: number, reset = false) => {
setLoading(true);
@@ -37,7 +62,6 @@ export default function HomePage() {
sort: sort === 'latest' ? '' : sort,
});
const batch = Array.isArray(data.posts) ? data.posts : [];
// 切换筛选时保留旧列表,避免中间区域瞬间空白
setPosts(prev => (reset ? batch : [...prev, ...batch]));
setPostTotal(data.total ?? 0);
setHasMore(!!data.has_more);
@@ -80,6 +104,12 @@ export default function HomePage() {
}, [boardId, keyword, sort]);
useEffect(() => {
const forceRefresh = (location.state as FeedNavState | null)?.refreshFeed;
if (forceRefresh) {
beginFeedRefresh();
load(1, true);
return;
}
const cached = getFeedCache(boardId, keyword, sort);
if (cached) {
setPosts(cached.posts);
@@ -95,11 +125,11 @@ export default function HomePage() {
setRestoreScrollTop(null);
scrollTopRef.current = 0;
load(1, true);
}, [boardId, keyword, sort, load, revalidate]);
}, [boardId, keyword, sort, location.key, location.state, load, revalidate, beginFeedRefresh]);
useEffect(() => {
return () => {
if (posts.length === 0) return;
if (skipCacheSaveRef.current || posts.length === 0) return;
setFeedCache(boardId, keyword, sort, {
posts,
postTotal,
@@ -110,29 +140,42 @@ export default function HomePage() {
};
}, [boardId, keyword, sort, posts, postTotal, page, hasMore]);
useEffect(() => {
if (!loading && posts.length > 0) {
skipCacheSaveRef.current = false;
}
}, [loading, posts.length]);
useEffect(() => {
const onFeedReset = () => {
beginFeedRefresh();
};
window.addEventListener(FEED_RESET_EVENT, onFeedReset);
return () => window.removeEventListener(FEED_RESET_EVENT, onFeedReset);
}, [beginFeedRefresh]);
useEffect(() => {
const fn = () => {
clearAllFeedCache();
setRestoreScrollTop(null);
scrollTopRef.current = 0;
beginFeedRefresh();
load(1, true);
};
window.addEventListener('posts-refresh', fn);
return () => window.removeEventListener('posts-refresh', fn);
}, [boardId, keyword, sort, load]);
}, [beginFeedRefresh, load]);
const handleSortChange = (next: FeedSort) => {
if (next === sort) return;
clearAllFeedCache();
setRestoreScrollTop(null);
scrollTopRef.current = 0;
nav(buildHomeUrl(boardId, next));
if (next === sort) {
beginFeedRefresh();
load(1, true);
return;
}
navigateFeed(nav, buildHomeUrl(boardId, next));
};
const showSortBar = !keyword;
return (
<div className="page-wrap">
<div className="page-wrap" ref={pageWrapRef}>
<div className="feed-top">
<FeedHeader
boardId={boardId}
@@ -153,6 +196,7 @@ export default function HomePage() {
onLoadMore={() => !loading && hasMore && load(page + 1)}
onSelect={(id) => nav(`/post/${id}`)}
restoreScrollTop={restoreScrollTop}
resetScrollKey={listResetKey}
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
onScrollRestored={() => setRestoreScrollTop(null)}
/>

View File

@@ -1,9 +1,9 @@
import { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { ArrowLeft, LayoutDashboard, Settings } from 'lucide-react';
import { ArrowLeft, Camera, LayoutDashboard, Settings, Upload, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
@@ -13,6 +13,8 @@ import { notify } from '@/lib/notify';
import { useAuth } from '../hooks/useAuth';
import { api } from '../api/client';
import { useForumLimits } from '../hooks/useForumLimits';
import AvatarCropDialog from '../components/AvatarCropDialog';
import { AVATAR_ACCEPT, validateAvatarFile } from '../utils/avatarCrop';
const nickSchema = z.object({
nickname: z.string().min(1, '请输入昵称').max(64),
@@ -36,7 +38,14 @@ export default function ProfilePage() {
const [nickLoading, setNickLoading] = useState(false);
const [pwdLoading, setPwdLoading] = useState(false);
const [avatarLoading, setAvatarLoading] = useState(false);
const [pendingAvatar, setPendingAvatar] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [cropOpen, setCropOpen] = useState(false);
const [cropImageSrc, setCropImageSrc] = useState<string | null>(null);
const [cropFileName, setCropFileName] = useState('');
const [dragOver, setDragOver] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const dragCounter = useRef(0);
const { limits } = useForumLimits();
@@ -56,6 +65,30 @@ export default function ProfilePage() {
}
}, [authLoading, user, nav]);
useEffect(() => {
return () => {
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
};
}, [avatarPreview]);
useEffect(() => {
return () => {
if (cropImageSrc) URL.revokeObjectURL(cropImageSrc);
};
}, [cropImageSrc]);
const closeCropDialog = useCallback((open: boolean) => {
if (!open) {
setCropOpen(false);
setCropImageSrc(prev => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
setCropFileName('');
if (fileRef.current) fileRef.current.value = '';
}
}, []);
if (authLoading) {
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
}
@@ -90,26 +123,88 @@ export default function ProfilePage() {
}
};
const onAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > limits.avatar_max_mb * 1024 * 1024) {
notify.error(`头像不能超过 ${limits.avatar_max_mb}MB`);
const clearPendingAvatar = () => {
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
setPendingAvatar(null);
setAvatarPreview(null);
if (fileRef.current) fileRef.current.value = '';
};
const openCropForFile = (file: File) => {
const err = validateAvatarFile(file, limits.avatar_max_mb);
if (err) {
notify.error(err);
if (fileRef.current) fileRef.current.value = '';
return;
}
if (cropImageSrc) URL.revokeObjectURL(cropImageSrc);
setCropFileName(file.name);
setCropImageSrc(URL.createObjectURL(file));
setCropOpen(true);
};
const onAvatarSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) openCropForFile(file);
};
const onCropConfirm = (file: File) => {
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
setPendingAvatar(file);
setAvatarPreview(URL.createObjectURL(file));
};
const onSaveAvatar = async () => {
if (!pendingAvatar) return;
setAvatarLoading(true);
try {
await api.uploadAvatar(file);
await api.uploadAvatar(pendingAvatar);
await refresh();
clearPendingAvatar();
notify.success('头像已更新');
} catch (err: unknown) {
notify.error(err instanceof Error ? err.message : '上传失败');
} finally {
setAvatarLoading(false);
if (fileRef.current) fileRef.current.value = '';
}
};
const onDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current += 1;
if (e.dataTransfer.types.includes('Files')) {
setDragOver(true);
}
};
const onDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current -= 1;
if (dragCounter.current <= 0) {
dragCounter.current = 0;
setDragOver(false);
}
};
const onDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const onDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current = 0;
setDragOver(false);
if (avatarLoading) return;
const file = e.dataTransfer.files?.[0];
if (file) openCropForFile(file);
};
const displayAvatar = avatarPreview ?? user.avatar;
return (
<div className="page-wrap">
<div className="page-inner-wide" style={{ maxWidth: 640 }}>
@@ -117,18 +212,82 @@ export default function ProfilePage() {
<ArrowLeft />
</Button>
<h1 className="page-title mb-4"></h1>
<div className="profile-header">
<div className="profile-avatar-lg">
{user.avatar ? <img src={user.avatar} alt="" /> : user.nickname[0]}
</div>
<div>
<h1 className="page-title" style={{ marginBottom: 4 }}>{user.nickname}</h1>
<div style={{ fontSize: 13, color: 'var(--color-text-3)' }}>@{user.username}</div>
{user.role === 'admin' && <Badge variant="green" className="mt-1.5"></Badge>}
<div
className={`profile-header-card${dragOver ? ' profile-header-card--dragover' : ''}`}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
>
{dragOver && (
<div className="profile-drop-overlay">
<Upload size={28} strokeWidth={1.5} />
<span></span>
</div>
)}
<input
ref={fileRef}
type="file"
accept={AVATAR_ACCEPT}
className="sr-only"
onChange={onAvatarSelect}
/>
<button
type="button"
className="profile-avatar-btn"
title="点击或拖拽图片到此处更换头像"
disabled={avatarLoading}
onClick={() => fileRef.current?.click()}
>
<div className={`profile-avatar-lg${pendingAvatar ? ' profile-avatar-lg--pending' : ''}`}>
{displayAvatar
? <img src={displayAvatar} alt="" />
: user.nickname[0]}
<span className="profile-avatar-overlay">
{avatarLoading
? <Spinner size="sm" className="text-white" />
: <Camera size={22} strokeWidth={1.75} />}
</span>
</div>
</button>
<div className="profile-header-info">
<div className="profile-header-main">
<h1 className="profile-display-name">{user.nickname}</h1>
<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>
{pendingAvatar && (
<div className="profile-avatar-actions">
<span className="profile-avatar-hint"></span>
<Button size="sm" loading={avatarLoading} onClick={onSaveAvatar}>
</Button>
<Button
size="sm"
variant="ghost"
disabled={avatarLoading}
onClick={clearPendingAvatar}
aria-label="取消"
>
<X size={16} />
</Button>
</div>
)}
</div>
</div>
<AvatarCropDialog
open={cropOpen}
imageSrc={cropImageSrc}
fileName={cropFileName}
onOpenChange={closeCropDialog}
onConfirm={onCropConfirm}
/>
{user.role === 'admin' && (
<div className="section-card admin-entry-card">
<div className="section-card-title"></div>
@@ -151,7 +310,7 @@ export default function ProfilePage() {
<div className="section-card">
<div className="section-card-title"></div>
<Form {...nickForm}>
<form onSubmit={nickForm.handleSubmit(onUpdateNick)} className="space-y-4">
<form onSubmit={nickForm.handleSubmit(onUpdateNick)} className="profile-form">
<FormItem>
<FormLabel></FormLabel>
<FormControl>
@@ -171,32 +330,20 @@ export default function ProfilePage() {
</FormItem>
)}
/>
<Button type="submit" loading={nickLoading}></Button>
<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>
<div className="section-card">
<div className="section-card-title"></div>
<p style={{ fontSize: 13, color: 'var(--color-text-3)', margin: '0 0 12px' }}>
JPGPNGGIFWebP 2MB
</p>
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
style={{ display: 'none' }}
onChange={onAvatarChange}
/>
<Button variant="outline" loading={avatarLoading} onClick={() => fileRef.current?.click()}>
</Button>
</div>
<div className="section-card">
<div className="section-card-title"></div>
<Form {...pwdForm}>
<form onSubmit={pwdForm.handleSubmit(onUpdatePwd)} className="space-y-4">
<form onSubmit={pwdForm.handleSubmit(onUpdatePwd)} className="profile-form">
<FormField
control={pwdForm.control}
name="old_password"
@@ -236,9 +383,11 @@ export default function ProfilePage() {
</FormItem>
)}
/>
<Button type="submit" variant="destructive" loading={pwdLoading}>
</Button>
<div className="profile-form-footer profile-form-footer--end">
<Button type="submit" variant="destructive" loading={pwdLoading}>
</Button>
</div>
</form>
</Form>
</div>