dev #5

Merged
lan merged 38 commits from dev into master 2026-03-25 20:54:10 +08:00
8 changed files with 148 additions and 39 deletions
Showing only changes of commit 7305254e11 - Show all commits

View File

@@ -135,12 +135,24 @@ const PostCard: React.FC<PostCardProps> = ({
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<PostCardProps> = ({
<Text variant="caption" color={colors.text.hint} style={styles.timeText}>
{formatDateTime(post.created_at)}
</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}>
{' · 修改 '}{formatDateTime(post.updated_at)}
{' · 修改 '}{formatDateTime(editedAt)}
</Text>
)}
) : null;
})()}
</View>
</View>
{post.is_pinned && (

View File

@@ -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<SingleImageItemProps> = ({
@@ -116,23 +120,34 @@ const SingleImageItem: React.FC<SingleImageItemProps> = ({
maxHeight,
borderRadiusValue,
onPress,
usePreview,
displayMode,
}) => {
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(() => {
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<SingleImageItemProps> = ({
}
},
);
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<SingleImageItemProps> = ({
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 (
<Pressable
onPress={onPress}
style={[styles.singleContainer, { width, height, borderRadius: borderRadiusValue }]}
>
<SmartImage
source={{ uri }}
source={{ uri: mainUri }}
previewUrl={useSmartPreview ? previewForSmart : undefined}
usePreview={useSmartPreview}
style={styles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
@@ -265,12 +286,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
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 (
<SingleImageItem
image={image}
@@ -278,6 +294,8 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
maxHeight={singleImageMaxHeight}
borderRadiusValue={borderRadiusValue}
onPress={() => handleImagePress(0)}
usePreview={usePreview}
displayMode={displayMode}
/>
);
};

View File

@@ -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<SmartImageProps> = ({
previewUrl,
usePreview = false,
lazyLoad = false,
lazyRootMargin = '160px',
cachePolicy = 'memory-disk',
}) => {
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 lazyTargetRef = useRef<View>(null);
// 解析图片源 - 支持 uri 或 url 字段
const imageUri = typeof source === 'string'
@@ -99,6 +104,39 @@ export const SmartImage: React.FC<SmartImageProps> = ({
// 解析预览图 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<SmartImageProps> = ({
);
}
// 如果是懒加载且不可见,显示占位
// 如果是懒加载且不可见,显示占位Web 上由 IntersectionObserver 驱动进入视口后再加载)
if (lazyLoad && !isVisible) {
return (
<View
ref={Platform.OS === 'web' ? lazyTargetRef : undefined}
collapsable={false}
style={[containerStyle, style as ViewStyle, { backgroundColor: '#F5F5F5' }]}
testID={testID}
/>

View File

@@ -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 */

View File

@@ -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()
),
};
}

View File

@@ -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,
};
}

View File

@@ -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 = () => {
<Text variant="caption" color={colors.text.hint} style={styles.metaInfoText}>
{formatRelativeTime(post.created_at)}
</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 variant="caption" color={colors.text.hint} style={styles.metaInfoText}>
{formatRelativeTime(post.updated_at)}
{formatRelativeTime(editedAt)}
</Text>
</>
)}
) : null;
})()}
{post.views_count !== undefined && post.views_count > 0 && (
<>
<Text style={styles.metaInfoDot}>·</Text>

View File

@@ -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;