diff --git a/frontend/src/components/PinnedIcon.tsx b/frontend/src/components/PinnedIcon.tsx deleted file mode 100644 index 8448971..0000000 --- a/frontend/src/components/PinnedIcon.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Pin } from 'lucide-react'; -import { cn } from '@/lib/utils'; - -interface Props { - className?: string; - size?: number; -} - -/** 置顶图钉标识 */ -export default function PinnedIcon({ className, size = 16 }: Props) { - return ( - - ); -} diff --git a/frontend/src/components/PostListItem.tsx b/frontend/src/components/PostListItem.tsx index cf4ddc0..de57803 100644 --- a/frontend/src/components/PostListItem.tsx +++ b/frontend/src/components/PostListItem.tsx @@ -2,7 +2,6 @@ import { memo } from 'react'; import { Eye, Image as ImageIcon, MessageCircle, ThumbsUp } from 'lucide-react'; import BoardBadge from '@/components/BoardBadge'; import FeaturedIcon from '@/components/FeaturedIcon'; -import PinnedIcon from '@/components/PinnedIcon'; import UserLink from '@/components/UserLink'; import type { PostItem } from '../api/types'; import type { FeedSort } from './FeedSortBar'; @@ -79,9 +78,7 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
{post.pinned && ( - - - + 置顶 )} {post.featured && ( diff --git a/frontend/src/components/PullToRefresh.tsx b/frontend/src/components/PullToRefresh.tsx index 62c04fc..7e27f73 100644 --- a/frontend/src/components/PullToRefresh.tsx +++ b/frontend/src/components/PullToRefresh.tsx @@ -7,12 +7,19 @@ const REFRESH_THRESHOLD = 68; const PULL_MAX = 108; /** 判定为「下拉」意图的最小位移,避免误触滚动 */ const ARM_DELTA = 10; +/** 指示器休息位在视口上方的隐藏量,下拉时再滑入 */ +const INDICATOR_HIDE = 40; /** * 定位当前真正滚动的容器。 * SPA 使用内部滚动(body overflow:hidden),原生下拉刷新不可用,需挂到此容器。 + * 发帖/编辑页不参与:主栏 overflow:hidden,滚动在编辑器内层,绑 PTR 会误触并打断编辑。 */ function pickScrollEl(): HTMLElement | null { + if (document.querySelector('.main-content--compose, .compose-page')) { + return null; + } + // 手机 Feed 整栏滚动(板块 / 排序栏可滚走) const mobileFeed = document.querySelector('.main-content--feed-mobile-scroll'); if (mobileFeed) return mobileFeed; @@ -23,9 +30,6 @@ function pickScrollEl(): HTMLElement | null { const page = document.querySelector('.page-wrap:not(.page-wrap--feed)'); if (page) return page; - const compose = document.querySelector('.main-content--compose'); - if (compose) return compose; - const admin = document.querySelector('.admin-main'); if (admin) return admin; @@ -40,6 +44,37 @@ function isTouchDevice(): boolean { || navigator.maxTouchPoints > 0; } +/** 输入框 / 富文本编辑中不触发下拉刷新 */ +function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof Element)) return false; + return Boolean( + target.closest( + 'textarea, input, select, [contenteditable="true"], .ProseMirror, .article-editor, .compose-page', + ), + ); +} + +/** + * 触摸落在内层可滚动区域且该区域未到顶时,交给内层滚动,不武装 PTR。 + */ +function isNestedScrollBlocking(target: EventTarget | null, bound: HTMLElement): boolean { + let node: Element | null = target instanceof Element ? target : null; + while (node && node !== bound) { + if (node instanceof HTMLElement) { + const { overflowY } = getComputedStyle(node); + if ( + (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') + && node.scrollHeight > node.clientHeight + 1 + && node.scrollTop > 1 + ) { + return true; + } + } + node = node.parentElement; + } + return false; +} + /** * 手机端下拉刷新:在内部滚动容器顶部下拉后整页重载。 * (浏览器原生 PTR 依赖 document 滚动,与本站 app-shell 布局不兼容。) @@ -49,6 +84,7 @@ function isTouchDevice(): boolean { export default function PullToRefresh() { const [pull, setPull] = useState(0); const [refreshing, setRefreshing] = useState(false); + const [settling, setSettling] = useState(false); const pullRef = useRef(0); const refreshingRef = useRef(false); const startYRef = useRef(0); @@ -75,7 +111,10 @@ export default function PullToRefresh() { trackingRef.current = false; pullingRef.current = false; startYRef.current = 0; - if (!refreshingRef.current) setPull(0); + if (!refreshingRef.current) { + setSettling(false); + setPull(0); + } }; const onTouchStart = (e: TouchEvent) => { @@ -87,8 +126,11 @@ export default function PullToRefresh() { )) { return; } + if (isEditableTarget(e.target)) return; + if (isNestedScrollBlocking(e.target, el)) return; trackingRef.current = true; pullingRef.current = false; + setSettling(false); startYRef.current = e.touches[0].clientY; }; @@ -109,6 +151,7 @@ export default function PullToRefresh() { } pullingRef.current = true; + setSettling(false); setPull(Math.min(PULL_MAX, dy * 0.55)); if (e.cancelable) e.preventDefault(); }; @@ -121,12 +164,14 @@ export default function PullToRefresh() { if (shouldRefresh) { setRefreshing(true); + setSettling(true); setPull(REFRESH_THRESHOLD * 0.7); window.setTimeout(() => { window.location.reload(); }, 180); return; } + setSettling(true); setPull(0); }; @@ -153,7 +198,17 @@ export default function PullToRefresh() { const tryBind = () => { if (cancelled) return; const next = pickScrollEl(); - if (next) bind(next); + if (next) { + bind(next); + return; + } + // 发帖页等:卸掉旧绑定并收起指示器,避免残留气泡 / 误触 + if (!bound && !scrollElRef.current && pullRef.current <= 0 && !trackingRef.current) { + return; + } + unbind(); + scrollElRef.current = null; + resetGesture(); }; const scheduleBind = () => { @@ -187,8 +242,13 @@ export default function PullToRefresh() { return (
diff --git a/frontend/src/pages/PostDetailPage.tsx b/frontend/src/pages/PostDetailPage.tsx index 9fced16..8af4081 100644 --- a/frontend/src/pages/PostDetailPage.tsx +++ b/frontend/src/pages/PostDetailPage.tsx @@ -2,7 +2,6 @@ import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react import { useParams, useNavigate, useOutletContext, useLocation } from 'react-router-dom'; import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, MessageSquare, Trash2, Sparkles, Flag, Ban, CircleCheck, CircleHelp, MoreHorizontal } from 'lucide-react'; import FeaturedIcon from '@/components/FeaturedIcon'; -import PinnedIcon from '@/components/PinnedIcon'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import BoardBadge from '@/components/BoardBadge'; @@ -600,9 +599,7 @@ export default function PostDetailPage() {

{post.pinned && ( - - - + 置顶 )} {post.featured && } {post.status === 'pending' && 审核中} diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index fba0b77..932471f 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -7,6 +7,7 @@ import { ArrowLeft, Camera, Check, + Coins, Copy, FileText, Hash, @@ -22,7 +23,7 @@ import { 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 { Label } from '@/components/ui/label'; import UserBadges from '../components/UserBadges'; import PointsWalletPanel from '../components/PointsWalletPanel'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; @@ -63,10 +64,10 @@ const pwdSchema = (minLen: number) => z.object({ type NickValues = z.infer; type SigValues = z.infer>; type PwdValues = z.infer>; -type ProfileTab = 'posts' | 'settings' | 'security'; +type ProfileTab = 'posts' | 'points' | 'settings' | 'security'; function parseTab(raw: string | null): ProfileTab { - if (raw === 'settings' || raw === 'security' || raw === 'posts') return raw; + if (raw === 'settings' || raw === 'security' || raw === 'points' || raw === 'posts') return raw; return 'posts'; } @@ -96,6 +97,8 @@ export default function ProfilePage() { const fileRef = useRef(null); const dragCounter = useRef(0); const copyTimer = useRef>(); + const sigSectionRef = useRef(null); + const pendingFocusSig = useRef(false); const { limits } = useForumLimits(); const pageSize = limits.page_size_default > 0 ? limits.page_size_default : 20; @@ -124,6 +127,38 @@ export default function ProfilePage() { setParams(nextParams, { replace: true }); }; + const focusSignatureField = useCallback(() => { + sigSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + window.setTimeout(() => { + sigSectionRef.current?.querySelector('textarea')?.focus(); + }, 80); + }, []); + + /** 顶部签名 /「编辑资料」:跳到资料 Tab 并聚焦签名框 */ + const goEditSignature = () => { + if (tab !== 'settings') { + pendingFocusSig.current = true; + setTab('settings'); + return; + } + focusSignatureField(); + }; + + const goEditProfile = () => { + if (tab !== 'settings') { + setTab('settings'); + return; + } + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + useEffect(() => { + if (tab !== 'settings' || !pendingFocusSig.current) return; + pendingFocusSig.current = false; + const t = window.setTimeout(focusSignatureField, 40); + return () => window.clearTimeout(t); + }, [tab, focusSignatureField]); + useEffect(() => { if (!authLoading && !user) { nav(loginPath('/profile')); @@ -334,8 +369,9 @@ export default function ProfilePage() { const tabs: { key: ProfileTab; label: string; count?: number }[] = [ { key: 'posts', label: '我的帖子', count: stats?.post_count }, - { key: 'settings', label: '资料设置' }, - { key: 'security', label: '安全设置' }, + { key: 'points', label: '积分' }, + { key: 'settings', label: '资料' }, + { key: 'security', label: '安全' }, ]; return ( @@ -415,29 +451,38 @@ export default function ProfilePage() { > 公开主页 +

{user.signature?.trim() ? ( -

{user.signature}

+ ) : ( -

尚未设置签名

+ )} -
-
-
用户名
-
{user.username}
-
-
-
邮箱
-
{user.email || '未设置'}
-
- {joinedAt && ( -
-
注册时间
-
{joinedAt}
-
- )} -
-

点击头像选择图片,或拖拽到此处更换

{pendingAvatar && (
@@ -491,22 +536,19 @@ export default function ProfilePage() { onConfirm={onCropConfirm} /> - - {user.role === 'admin' && ( -
-
站长入口
-

- 管理板块、用户、帖子及系统设置 -

-
- -
@@ -522,6 +564,7 @@ export default function ProfilePage() { className={`profile-tab${tab === t.key ? ' active' : ''}`} onClick={() => setTab(t.key)} > + {t.key === 'points' && } {t.label} {typeof t.count === 'number' && ( {t.count} @@ -565,29 +608,20 @@ export default function ProfilePage() {
)} + {tab === 'points' && ( +
+ +
+ )} + {tab === 'settings' && (
-
基本资料
+
展示资料
+

+ 昵称与个性签名会显示在公开主页和帖子旁 +

- - 用户 ID - - - - - - 用户名 - - - - - - 邮箱 - - - -
- 用户名与 ID 不可修改;头像支持 JPG / PNG / GIF / WebP,服务端保留原图并生成 WebP,裁剪后不超过 {limits.avatar_max_mb}MB + 头像可在上方点击或拖拽更换;支持 JPG / PNG / GIF / WebP,裁剪后不超过 {limits.avatar_max_mb}MB
@@ -612,34 +646,61 @@ export default function ProfilePage() {
- - - ( - - 个人签名 - -