优化个人中心信息架构与手机编辑体验,置顶改为文字标识。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-06 16:41:34 +08:00
parent 6b0a1d4281
commit e6e7ed73e3
6 changed files with 394 additions and 148 deletions

View File

@@ -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 (
<Pin
className={cn('post-pinned-icon', className)}
size={size}
fill="currentColor"
aria-label="置顶"
role="img"
/>
);
}

View File

@@ -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) {
<div className="post-title-row">
{post.pinned && (
<span className="post-pin-badge post-pin-badge--icon" title="置顶">
<PinnedIcon size={13} />
</span>
<span className="post-pin-badge" title="置顶"></span>
)}
{post.featured && (
<span className="post-feature-badge" title="精华">

View File

@@ -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<HTMLElement>('.main-content--feed-mobile-scroll');
if (mobileFeed) return mobileFeed;
@@ -23,9 +30,6 @@ function pickScrollEl(): HTMLElement | null {
const page = document.querySelector<HTMLElement>('.page-wrap:not(.page-wrap--feed)');
if (page) return page;
const compose = document.querySelector<HTMLElement>('.main-content--compose');
if (compose) return compose;
const admin = document.querySelector<HTMLElement>('.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 (
<div
className={`ptr-indicator${ready ? ' ptr-indicator--ready' : ''}${refreshing ? ' ptr-indicator--refreshing' : ''}`}
style={{ transform: `translateY(${offset}px)` }}
className={[
'ptr-indicator',
ready ? 'ptr-indicator--ready' : '',
refreshing ? 'ptr-indicator--refreshing' : '',
settling ? 'ptr-indicator--settling' : '',
].filter(Boolean).join(' ')}
style={{ transform: `translateY(${offset - INDICATOR_HIDE}px)` }}
aria-hidden
>
<div className="ptr-indicator__chip">