refactor(PostCard, ImageGrid, SmartImage): enhance post editing display and image handling
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 4m39s
Frontend CI / ota-android (push) Successful in 10m59s
Frontend CI / build-android-apk (push) Successful in 59m29s

- Introduced a new method to determine the last edited time for posts, prioritizing content_edited_at for better accuracy in display.
- Updated PostCard and PostDetailScreen components to utilize the new editing display logic.
- Enhanced ImageGrid and SmartImage components to support additional preview URLs and improve image loading behavior, including lazy loading optimizations for web.
- Modified Post and PostDTO interfaces to include contentEditedAt for better tracking of post edits.
This commit is contained in:
lafay
2026-03-23 14:21:22 +08:00
parent d97df0b2d4
commit 7305254e11
8 changed files with 148 additions and 39 deletions

View File

@@ -135,12 +135,24 @@ const PostCard: React.FC<PostCardProps> = ({
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}; };
const isPostEdited = (createdAt?: string, updatedAt?: string): boolean => { /** 返回应展示的「最后编辑」时间字符串;优先 content_edited_at与点赞/评论等统计更新解耦) */
if (!createdAt || !updatedAt) return false; const getPostEditedDisplayAt = (
const created = new Date(createdAt).getTime(); createdAt?: string | null,
const updated = new Date(updatedAt).getTime(); contentEditedAt?: string | null,
if (Number.isNaN(created) || Number.isNaN(updated)) return false; updatedAt?: string | null,
return updated - created > 1000; ): 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 => { const getTruncatedContent = (content: string | undefined | null, maxLength: number = 100): string => {
@@ -560,11 +572,18 @@ const PostCard: React.FC<PostCardProps> = ({
<Text variant="caption" color={colors.text.hint} style={styles.timeText}> <Text variant="caption" color={colors.text.hint} style={styles.timeText}>
{formatDateTime(post.created_at)} {formatDateTime(post.created_at)}
</Text> </Text>
{isPostEdited(post.created_at, post.updated_at) && ( {(() => {
const editedAt = getPostEditedDisplayAt(
post.created_at,
post.content_edited_at,
post.updated_at,
);
return editedAt ? (
<Text variant="caption" color={colors.text.hint} style={styles.timeText}> <Text variant="caption" color={colors.text.hint} style={styles.timeText}>
{' · 修改 '}{formatDateTime(post.updated_at)} {' · 修改 '}{formatDateTime(editedAt)}
</Text> </Text>
)} ) : null;
})()}
</View> </View>
</View> </View>
{post.is_pinned && ( {post.is_pinned && (

View File

@@ -15,7 +15,7 @@ import {
Text, Text,
Image, Image,
} from 'react-native'; } from 'react-native';
import { SmartImage, ImageSource } from './SmartImage'; import { SmartImage } from './SmartImage';
import { colors, spacing, borderRadius } from '../../theme'; import { colors, spacing, borderRadius } from '../../theme';
import { getPreviewImageUrl, ImageDisplayMode } from '../../utils/imageHelper'; import { getPreviewImageUrl, ImageDisplayMode } from '../../utils/imageHelper';
@@ -40,6 +40,8 @@ export interface ImageGridItem {
url?: string; url?: string;
thumbnailUrl?: string; thumbnailUrl?: string;
thumbnail_url?: string; thumbnail_url?: string;
preview_url?: string;
preview_url_large?: string;
width?: number; width?: number;
height?: number; height?: number;
} }
@@ -108,6 +110,8 @@ interface SingleImageItemProps {
maxHeight: number; maxHeight: number;
borderRadiusValue: number; borderRadiusValue: number;
onPress: () => void; onPress: () => void;
usePreview: boolean;
displayMode: ImageDisplayMode;
} }
const SingleImageItem: React.FC<SingleImageItemProps> = ({ const SingleImageItem: React.FC<SingleImageItemProps> = ({
@@ -116,15 +120,26 @@ const SingleImageItem: React.FC<SingleImageItemProps> = ({
maxHeight, maxHeight,
borderRadiusValue, borderRadiusValue,
onPress, onPress,
usePreview,
displayMode,
}) => { }) => {
const [aspectRatio, setAspectRatio] = useState<number | null>(null); const [aspectRatio, setAspectRatio] = useState<number | null>(null);
const uri = image.uri || image.url || ''; const mainUri = image.uri || image.url || '';
const thumbnailUri =
image.thumbnail_url ||
image.thumbnailUrl ||
image.preview_url ||
'';
useEffect(() => { 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; let cancelled = false;
// 添加超时处理,防止高分辨率图片加载卡住
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
if (!cancelled && aspectRatio === null) { if (!cancelled && aspectRatio === null) {
setAspectRatio(SINGLE_IMAGE_DEFAULT_ASPECT_RATIO); setAspectRatio(SINGLE_IMAGE_DEFAULT_ASPECT_RATIO);
@@ -132,7 +147,7 @@ const SingleImageItem: React.FC<SingleImageItemProps> = ({
}, 3000); }, 3000);
Image.getSize( Image.getSize(
uri, probeUri,
(w, h) => { (w, h) => {
if (!cancelled) { if (!cancelled) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
@@ -150,7 +165,7 @@ const SingleImageItem: React.FC<SingleImageItemProps> = ({
cancelled = true; cancelled = true;
clearTimeout(timeoutId); clearTimeout(timeoutId);
}; };
}, [uri]); }, [image.width, image.height, thumbnailUri, mainUri]);
const effectiveContainerWidth = containerWidth || SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING; const effectiveContainerWidth = containerWidth || SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING;
@@ -189,13 +204,19 @@ const SingleImageItem: React.FC<SingleImageItemProps> = ({
width = Math.min(SINGLE_IMAGE_MIN_HEIGHT * aspectRatio, effectiveContainerWidth); 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 ( return (
<Pressable <Pressable
onPress={onPress} onPress={onPress}
style={[styles.singleContainer, { width, height, borderRadius: borderRadiusValue }]} style={[styles.singleContainer, { width, height, borderRadius: borderRadiusValue }]}
> >
<SmartImage <SmartImage
source={{ uri }} source={{ uri: mainUri }}
previewUrl={useSmartPreview ? previewForSmart : undefined}
usePreview={useSmartPreview}
style={styles.fullSize} style={styles.fullSize}
resizeMode="cover" resizeMode="cover"
borderRadius={borderRadiusValue} borderRadius={borderRadiusValue}
@@ -266,11 +287,6 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
const image = displayImages[0]; const image = displayImages[0];
if (!image) return null; if (!image) return null;
// 获取预览图 URL
const previewUrl = usePreview && displayMode
? getPreviewImageUrl(image as any, displayMode)
: (image.uri || image.url || '');
return ( return (
<SingleImageItem <SingleImageItem
image={image} image={image}
@@ -278,6 +294,8 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
maxHeight={singleImageMaxHeight} maxHeight={singleImageMaxHeight}
borderRadiusValue={borderRadiusValue} borderRadiusValue={borderRadiusValue}
onPress={() => handleImagePress(0)} onPress={() => handleImagePress(0)}
usePreview={usePreview}
displayMode={displayMode}
/> />
); );
}; };

View File

@@ -13,6 +13,7 @@ import {
ActivityIndicator, ActivityIndicator,
Pressable, Pressable,
StyleProp, StyleProp,
Platform,
} from 'react-native'; } from 'react-native';
import { Image as ExpoImage } from 'expo-image'; import { Image as ExpoImage } from 'expo-image';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
@@ -61,6 +62,8 @@ export interface SmartImageProps {
usePreview?: boolean; usePreview?: boolean;
/** 是否懒加载 */ /** 是否懒加载 */
lazyLoad?: boolean; lazyLoad?: boolean;
/** Web 端 IntersectionObserver rootMargin略提前加载进入视口附近的图 */
lazyRootMargin?: string;
/** 缓存策略 */ /** 缓存策略 */
cachePolicy?: 'memory' | 'disk' | 'memory-disk' | 'none'; cachePolicy?: 'memory' | 'disk' | 'memory-disk' | 'none';
} }
@@ -85,11 +88,13 @@ export const SmartImage: React.FC<SmartImageProps> = ({
previewUrl, previewUrl,
usePreview = false, usePreview = false,
lazyLoad = false, lazyLoad = false,
lazyRootMargin = '160px',
cachePolicy = 'memory-disk', cachePolicy = 'memory-disk',
}) => { }) => {
const [loadState, setLoadState] = useState<ImageLoadState>('loading'); const [loadState, setLoadState] = useState<ImageLoadState>('loading');
const [isVisible, setIsVisible] = useState(!lazyLoad); const [isVisible, setIsVisible] = useState(() => !lazyLoad || Platform.OS !== 'web');
const [shouldLoadOriginal, setShouldLoadOriginal] = useState(false); const [shouldLoadOriginal, setShouldLoadOriginal] = useState(false);
const lazyTargetRef = useRef<View>(null);
// 解析图片源 - 支持 uri 或 url 字段 // 解析图片源 - 支持 uri 或 url 字段
const imageUri = typeof source === 'string' const imageUri = typeof source === 'string'
@@ -99,6 +104,39 @@ export const SmartImage: React.FC<SmartImageProps> = ({
// 解析预览图 URL // 解析预览图 URL
const previewUri = typeof previewUrl === 'string' ? previewUrl : ''; 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 // 智能选择图片 URL
const getImageUrl = useCallback((): string => { const getImageUrl = useCallback((): string => {
// 如果应该加载原图(预览图已加载完成或没有预览图) // 如果应该加载原图(预览图已加载完成或没有预览图)
@@ -200,10 +238,12 @@ export const SmartImage: React.FC<SmartImageProps> = ({
); );
} }
// 如果是懒加载且不可见,显示占位 // 如果是懒加载且不可见,显示占位Web 上由 IntersectionObserver 驱动进入视口后再加载)
if (lazyLoad && !isVisible) { if (lazyLoad && !isVisible) {
return ( return (
<View <View
ref={Platform.OS === 'web' ? lazyTargetRef : undefined}
collapsable={false}
style={[containerStyle, style as ViewStyle, { backgroundColor: '#F5F5F5' }]} style={[containerStyle, style as ViewStyle, { backgroundColor: '#F5F5F5' }]}
testID={testID} testID={testID}
/> />

View File

@@ -81,6 +81,8 @@ export interface Post {
createdAt: string; createdAt: string;
/** 更新时间 */ /** 更新时间 */
updatedAt: string; updatedAt: string;
/** 用户编辑内容的时间(与统计类更新解耦) */
contentEditedAt?: string;
// ==================== 向后兼容字段 (snake_case) ==================== // ==================== 向后兼容字段 (snake_case) ====================
// 这些字段用于与旧代码兼容,新代码应使用 camelCase 版本 // 这些字段用于与旧代码兼容,新代码应使用 camelCase 版本
@@ -107,6 +109,8 @@ export interface Post {
created_at?: string; created_at?: string;
/** @deprecated 请使用 updatedAt */ /** @deprecated 请使用 updatedAt */
updated_at?: string; updated_at?: string;
/** @deprecated 请使用 contentEditedAt */
content_edited_at?: string;
/** @deprecated 请使用 communityId */ /** @deprecated 请使用 communityId */
community_id?: string; community_id?: string;
/** @deprecated 请使用 status */ /** @deprecated 请使用 status */

View File

@@ -27,7 +27,12 @@ export class PostMapper {
communityId: response.community_id, communityId: response.community_id,
tags: [], tags: [],
createdAt: new Date(response.created_at || Date.now()), 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()
),
}; };
} }

View File

@@ -41,6 +41,7 @@ interface PostApiResponse {
tags?: string[]; tags?: string[];
created_at: string; created_at: string;
updated_at: string; updated_at: string;
content_edited_at?: string;
author?: { author?: {
id: number; id: number;
username: string; username: string;
@@ -122,6 +123,7 @@ export class PostRepository implements IPostRepository {
tags: model.tags || [], tags: model.tags || [],
createdAt: model.createdAt.toISOString(), createdAt: model.createdAt.toISOString(),
updatedAt: model.updatedAt.toISOString(), updatedAt: model.updatedAt.toISOString(),
contentEditedAt: response.content_edited_at,
// snake_case 字段(向后兼容) // snake_case 字段(向后兼容)
likes_count: model.likeCount, likes_count: model.likeCount,
@@ -135,6 +137,7 @@ export class PostRepository implements IPostRepository {
user_id: model.authorId, user_id: model.authorId,
created_at: model.createdAt.toISOString(), created_at: model.createdAt.toISOString(),
updated_at: model.updatedAt.toISOString(), updated_at: model.updatedAt.toISOString(),
content_edited_at: response.content_edited_at,
community_id: model.communityId, community_id: model.communityId,
}; };
} }

View File

@@ -318,12 +318,23 @@ export const PostDetailScreen: React.FC = () => {
return formatDateTime(dateString); return formatDateTime(dateString);
}; };
const isPostEdited = (createdAt?: string, updatedAt?: string): boolean => { const getPostEditedDisplayAt = (
if (!createdAt || !updatedAt) return false; createdAt?: string | null,
const created = new Date(createdAt).getTime(); contentEditedAt?: string | null,
const updated = new Date(updatedAt).getTime(); updatedAt?: string | null,
if (Number.isNaN(created) || Number.isNaN(updated)) return false; ): string | null => {
return updated-created > 1000; 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 = () => {
<Text variant="caption" color={colors.text.hint} style={styles.metaInfoText}> <Text variant="caption" color={colors.text.hint} style={styles.metaInfoText}>
{formatRelativeTime(post.created_at)} {formatRelativeTime(post.created_at)}
</Text> </Text>
{isPostEdited(post.created_at, post.updated_at) && ( {(() => {
const editedAt = getPostEditedDisplayAt(
post.created_at,
post.content_edited_at,
post.updated_at,
);
return editedAt ? (
<> <>
<Text style={styles.metaInfoDot}>·</Text> <Text style={styles.metaInfoDot}>·</Text>
<Text variant="caption" color={colors.text.hint} style={styles.metaInfoText}> <Text variant="caption" color={colors.text.hint} style={styles.metaInfoText}>
{formatRelativeTime(post.updated_at)} {formatRelativeTime(editedAt)}
</Text> </Text>
</> </>
)} ) : null;
})()}
{post.views_count !== undefined && post.views_count > 0 && ( {post.views_count !== undefined && post.views_count > 0 && (
<> <>
<Text style={styles.metaInfoDot}>·</Text> <Text style={styles.metaInfoDot}>·</Text>

View File

@@ -74,6 +74,8 @@ export interface PostDTO {
is_vote: boolean; is_vote: boolean;
created_at: string; created_at: string;
updated_at?: string; updated_at?: string;
/** 用户编辑正文/标题/图片的时间;优先用于「已编辑」展示 */
content_edited_at?: string;
author: UserDTO | null; author: UserDTO | null;
is_liked: boolean; is_liked: boolean;
is_favorited: boolean; is_favorited: boolean;