diff --git a/src/components/business/PostCard.tsx b/src/components/business/PostCard.tsx index fb5fabd..7f1e39c 100644 --- a/src/components/business/PostCard.tsx +++ b/src/components/business/PostCard.tsx @@ -135,12 +135,24 @@ const PostCard: React.FC = ({ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; }; - const isPostEdited = (createdAt?: string, updatedAt?: string): boolean => { - if (!createdAt || !updatedAt) return false; - const created = new Date(createdAt).getTime(); - const updated = new Date(updatedAt).getTime(); - if (Number.isNaN(created) || Number.isNaN(updated)) return false; - return updated - created > 1000; + /** 返回应展示的「最后编辑」时间字符串;优先 content_edited_at(与点赞/评论等统计更新解耦) */ + const getPostEditedDisplayAt = ( + createdAt?: string | null, + contentEditedAt?: string | null, + updatedAt?: string | null, + ): string | null => { + if (contentEditedAt) { + const c = new Date(createdAt || '').getTime(); + const e = new Date(contentEditedAt).getTime(); + if (!Number.isNaN(c) && !Number.isNaN(e) && e - c > 1000) return contentEditedAt; + return null; + } + if (createdAt && updatedAt) { + const c = new Date(createdAt).getTime(); + const u = new Date(updatedAt).getTime(); + if (!Number.isNaN(c) && !Number.isNaN(u) && u - c > 1000) return updatedAt; + } + return null; }; const getTruncatedContent = (content: string | undefined | null, maxLength: number = 100): string => { @@ -560,11 +572,18 @@ const PostCard: React.FC = ({ 发布 {formatDateTime(post.created_at)} - {isPostEdited(post.created_at, post.updated_at) && ( + {(() => { + const editedAt = getPostEditedDisplayAt( + post.created_at, + post.content_edited_at, + post.updated_at, + ); + return editedAt ? ( - {' · 修改 '}{formatDateTime(post.updated_at)} + {' · 修改 '}{formatDateTime(editedAt)} - )} + ) : null; + })()} {post.is_pinned && ( diff --git a/src/components/common/ImageGrid.tsx b/src/components/common/ImageGrid.tsx index ca28182..a96fb9b 100644 --- a/src/components/common/ImageGrid.tsx +++ b/src/components/common/ImageGrid.tsx @@ -15,7 +15,7 @@ import { Text, Image, } from 'react-native'; -import { SmartImage, ImageSource } from './SmartImage'; +import { SmartImage } from './SmartImage'; import { colors, spacing, borderRadius } from '../../theme'; import { getPreviewImageUrl, ImageDisplayMode } from '../../utils/imageHelper'; @@ -40,6 +40,8 @@ export interface ImageGridItem { url?: string; thumbnailUrl?: string; thumbnail_url?: string; + preview_url?: string; + preview_url_large?: string; width?: number; height?: number; } @@ -108,6 +110,8 @@ interface SingleImageItemProps { maxHeight: number; borderRadiusValue: number; onPress: () => void; + usePreview: boolean; + displayMode: ImageDisplayMode; } const SingleImageItem: React.FC = ({ @@ -116,23 +120,34 @@ const SingleImageItem: React.FC = ({ maxHeight, borderRadiusValue, onPress, + usePreview, + displayMode, }) => { const [aspectRatio, setAspectRatio] = useState(null); - const uri = image.uri || image.url || ''; + const mainUri = image.uri || image.url || ''; + const thumbnailUri = + image.thumbnail_url || + image.thumbnailUrl || + image.preview_url || + ''; useEffect(() => { - if (!uri) return; + if (image.width && image.height) { + setAspectRatio(image.width / image.height); + return; + } + const probeUri = thumbnailUri || mainUri; + if (!probeUri) return; let cancelled = false; - - // 添加超时处理,防止高分辨率图片加载卡住 + const timeoutId = setTimeout(() => { if (!cancelled && aspectRatio === null) { setAspectRatio(SINGLE_IMAGE_DEFAULT_ASPECT_RATIO); } }, 3000); - + Image.getSize( - uri, + probeUri, (w, h) => { if (!cancelled) { clearTimeout(timeoutId); @@ -146,11 +161,11 @@ const SingleImageItem: React.FC = ({ } }, ); - return () => { - cancelled = true; + return () => { + cancelled = true; clearTimeout(timeoutId); }; - }, [uri]); + }, [image.width, image.height, thumbnailUri, mainUri]); const effectiveContainerWidth = containerWidth || SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING; @@ -189,13 +204,19 @@ const SingleImageItem: React.FC = ({ width = Math.min(SINGLE_IMAGE_MIN_HEIGHT * aspectRatio, effectiveContainerWidth); } + const previewForSmart = + usePreview && displayMode ? getPreviewImageUrl(image as any, displayMode) : ''; + const useSmartPreview = !!(usePreview && previewForSmart && mainUri && previewForSmart !== mainUri); + return ( = ({ const renderSingleImage = () => { const image = displayImages[0]; if (!image) return null; - - // 获取预览图 URL - const previewUrl = usePreview && displayMode - ? getPreviewImageUrl(image as any, displayMode) - : (image.uri || image.url || ''); - + return ( = ({ maxHeight={singleImageMaxHeight} borderRadiusValue={borderRadiusValue} onPress={() => handleImagePress(0)} + usePreview={usePreview} + displayMode={displayMode} /> ); }; diff --git a/src/components/common/SmartImage.tsx b/src/components/common/SmartImage.tsx index da7851c..c2a089a 100644 --- a/src/components/common/SmartImage.tsx +++ b/src/components/common/SmartImage.tsx @@ -13,6 +13,7 @@ import { ActivityIndicator, Pressable, StyleProp, + Platform, } from 'react-native'; import { Image as ExpoImage } from 'expo-image'; import { MaterialCommunityIcons } from '@expo/vector-icons'; @@ -61,6 +62,8 @@ export interface SmartImageProps { usePreview?: boolean; /** 是否懒加载 */ lazyLoad?: boolean; + /** Web 端 IntersectionObserver rootMargin,略提前加载进入视口附近的图 */ + lazyRootMargin?: string; /** 缓存策略 */ cachePolicy?: 'memory' | 'disk' | 'memory-disk' | 'none'; } @@ -85,11 +88,13 @@ export const SmartImage: React.FC = ({ previewUrl, usePreview = false, lazyLoad = false, + lazyRootMargin = '160px', cachePolicy = 'memory-disk', }) => { const [loadState, setLoadState] = useState('loading'); - const [isVisible, setIsVisible] = useState(!lazyLoad); + const [isVisible, setIsVisible] = useState(() => !lazyLoad || Platform.OS !== 'web'); const [shouldLoadOriginal, setShouldLoadOriginal] = useState(false); + const lazyTargetRef = useRef(null); // 解析图片源 - 支持 uri 或 url 字段 const imageUri = typeof source === 'string' @@ -99,6 +104,39 @@ export const SmartImage: React.FC = ({ // 解析预览图 URL const previewUri = typeof previewUrl === 'string' ? previewUrl : ''; + useEffect(() => { + setShouldLoadOriginal(false); + setLoadState('loading'); + }, [imageUri]); + + useEffect(() => { + if (!lazyLoad) { + setIsVisible(true); + return; + } + if (Platform.OS !== 'web') { + setIsVisible(true); + return; + } + setIsVisible(false); + const node = lazyTargetRef.current as unknown as Element | null; + if (!node || typeof IntersectionObserver === 'undefined') { + setIsVisible(true); + return; + } + const observer = new IntersectionObserver( + entries => { + if (entries.some(e => e.isIntersecting)) { + setIsVisible(true); + observer.disconnect(); + } + }, + { rootMargin: lazyRootMargin } + ); + observer.observe(node); + return () => observer.disconnect(); + }, [lazyLoad, lazyRootMargin, imageUri]); + // 智能选择图片 URL const getImageUrl = useCallback((): string => { // 如果应该加载原图(预览图已加载完成或没有预览图) @@ -200,10 +238,12 @@ export const SmartImage: React.FC = ({ ); } - // 如果是懒加载且不可见,显示占位 + // 如果是懒加载且不可见,显示占位(Web 上由 IntersectionObserver 驱动进入视口后再加载) if (lazyLoad && !isVisible) { return ( diff --git a/src/core/entities/Post.ts b/src/core/entities/Post.ts index 0d008dc..7a92309 100644 --- a/src/core/entities/Post.ts +++ b/src/core/entities/Post.ts @@ -81,7 +81,9 @@ export interface Post { createdAt: string; /** 更新时间 */ updatedAt: string; - + /** 用户编辑内容的时间(与统计类更新解耦) */ + contentEditedAt?: string; + // ==================== 向后兼容字段 (snake_case) ==================== // 这些字段用于与旧代码兼容,新代码应使用 camelCase 版本 @@ -107,6 +109,8 @@ export interface Post { created_at?: string; /** @deprecated 请使用 updatedAt */ updated_at?: string; + /** @deprecated 请使用 contentEditedAt */ + content_edited_at?: string; /** @deprecated 请使用 communityId */ community_id?: string; /** @deprecated 请使用 status */ diff --git a/src/data/mappers/PostMapper.ts b/src/data/mappers/PostMapper.ts index 32b5a52..3f4a2e0 100644 --- a/src/data/mappers/PostMapper.ts +++ b/src/data/mappers/PostMapper.ts @@ -27,7 +27,12 @@ export class PostMapper { communityId: response.community_id, tags: [], createdAt: new Date(response.created_at || Date.now()), - updatedAt: new Date(response.updated_at || Date.now()), + // 空字符串/缺省时用 created_at,避免误用 Date.now() 导致全部显示「刚修改」 + updatedAt: new Date( + response.updated_at || + response.created_at || + Date.now() + ), }; } diff --git a/src/data/repositories/PostRepository.ts b/src/data/repositories/PostRepository.ts index 4841a11..a592c98 100644 --- a/src/data/repositories/PostRepository.ts +++ b/src/data/repositories/PostRepository.ts @@ -41,6 +41,7 @@ interface PostApiResponse { tags?: string[]; created_at: string; updated_at: string; + content_edited_at?: string; author?: { id: number; username: string; @@ -122,6 +123,7 @@ export class PostRepository implements IPostRepository { tags: model.tags || [], createdAt: model.createdAt.toISOString(), updatedAt: model.updatedAt.toISOString(), + contentEditedAt: response.content_edited_at, // snake_case 字段(向后兼容) likes_count: model.likeCount, @@ -135,6 +137,7 @@ export class PostRepository implements IPostRepository { user_id: model.authorId, created_at: model.createdAt.toISOString(), updated_at: model.updatedAt.toISOString(), + content_edited_at: response.content_edited_at, community_id: model.communityId, }; } diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index ad5e071..2e8a639 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -318,12 +318,23 @@ export const PostDetailScreen: React.FC = () => { return formatDateTime(dateString); }; - const isPostEdited = (createdAt?: string, updatedAt?: string): boolean => { - if (!createdAt || !updatedAt) return false; - const created = new Date(createdAt).getTime(); - const updated = new Date(updatedAt).getTime(); - if (Number.isNaN(created) || Number.isNaN(updated)) return false; - return updated-created > 1000; + const getPostEditedDisplayAt = ( + createdAt?: string | null, + contentEditedAt?: string | null, + updatedAt?: string | null, + ): string | null => { + if (contentEditedAt) { + const c = new Date(createdAt || '').getTime(); + const e = new Date(contentEditedAt).getTime(); + if (!Number.isNaN(c) && !Number.isNaN(e) && e - c > 1000) return contentEditedAt; + return null; + } + if (createdAt && updatedAt) { + const c = new Date(createdAt).getTime(); + const u = new Date(updatedAt).getTime(); + if (!Number.isNaN(c) && !Number.isNaN(u) && u - c > 1000) return updatedAt; + } + return null; }; // 格式化数字 @@ -1081,14 +1092,21 @@ export const PostDetailScreen: React.FC = () => { 发布 {formatRelativeTime(post.created_at)} - {isPostEdited(post.created_at, post.updated_at) && ( + {(() => { + const editedAt = getPostEditedDisplayAt( + post.created_at, + post.content_edited_at, + post.updated_at, + ); + return editedAt ? ( <> · - 修改 {formatRelativeTime(post.updated_at)} + 修改 {formatRelativeTime(editedAt)} - )} + ) : null; + })()} {post.views_count !== undefined && post.views_count > 0 && ( <> · diff --git a/src/types/dto.ts b/src/types/dto.ts index b8cae24..bf610e5 100644 --- a/src/types/dto.ts +++ b/src/types/dto.ts @@ -74,6 +74,8 @@ export interface PostDTO { is_vote: boolean; created_at: string; updated_at?: string; + /** 用户编辑正文/标题/图片的时间;优先用于「已编辑」展示 */ + content_edited_at?: string; author: UserDTO | null; is_liked: boolean; is_favorited: boolean;