From b2c3d5e54e14896d05617b818e8be57eacbabb57 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Thu, 23 Apr 2026 22:29:58 +0800 Subject: [PATCH] feat(content): add rich content rendering with @mentions and vote segments - Add PostContentRenderer component for rendering posts/comments with segments - Add PostMentionInput component for @mention support in post/comment creation - Add VoteSegmentData and vote segment type for unified post creation API - Update post and comment services to support segments in API requests - Fix auth service to clear tokens before login/register - Improve database initialization with proper close/retry logic - Add verification check before allowing post creation - Add metro web shims for react-native-webrtc full package and requireNativeComponent - Update build config with git hash based version numbers --- app.config.js | 19 +- app.json | 4 +- metro.config.js | 24 ++ package-lock.json | 2 +- package.json | 2 +- src/components/business/CommentItem.tsx | 15 +- src/components/business/PostCard/PostCard.tsx | 26 +- .../business/PostContentRenderer.tsx | 153 ++++++++++ src/components/business/PostMentionInput.tsx | 269 ++++++++++++++++++ src/components/business/index.ts | 2 + src/database/core/DatabaseManager.ts | 35 +-- src/screens/create/CreatePostScreen.tsx | 28 +- src/screens/home/HomeScreen.tsx | 22 +- src/screens/home/PostDetailScreen.tsx | 31 +- src/services/auth/authService.ts | 8 +- src/services/post/commentService.ts | 11 +- src/services/post/postService.ts | 6 +- src/services/post/voteService.ts | 32 ++- src/stores/auth/authStore.ts | 60 +++- src/types/dto.ts | 20 +- src/types/index.ts | 43 +-- web-shims/react-native-webrtc/index.js | 94 ++++++ .../ReactNative/requireNativeComponent.js | 18 ++ 23 files changed, 797 insertions(+), 127 deletions(-) create mode 100644 src/components/business/PostContentRenderer.tsx create mode 100644 src/components/business/PostMentionInput.tsx create mode 100644 web-shims/react-native-webrtc/index.js create mode 100644 web-shims/react-native/Libraries/ReactNative/requireNativeComponent.js diff --git a/app.config.js b/app.config.js index 36c9d22..e985640 100644 --- a/app.config.js +++ b/app.config.js @@ -1,10 +1,21 @@ const appJson = require('./app.json'); +const { execSync } = require('child_process'); const isDevVariant = process.env.APP_VARIANT === 'dev'; const releaseApiBaseUrl = 'https://withyou.littlelan.cn/api/v1'; const releaseUpdatesBaseUrl = 'https://updates.littlelan.cn'; const devApiBaseUrl = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.31.238:8080/api/v1'; +function getGitShortHash() { + try { + return execSync('git rev-parse --short=4 HEAD', { encoding: 'utf-8' }).trim(); + } catch { + return '0000'; + } +} + +const gitBuildSuffix = getGitShortHash(); + function toManifestUrl(baseUrl, portOverride) { const parsed = new URL(baseUrl); if (portOverride != null) { @@ -58,10 +69,14 @@ module.exports = { checkAutomatically: 'ON_LOAD', fallbackToCacheTimeout: 0, }, - ios: expo.ios, + ios: { + ...expo.ios, + buildNumber: gitBuildSuffix, + }, android: { ...expo.android, - package: 'skin.carrot.bbs', + package: 'cn.qczlit.withyou', + versionCode: parseInt(gitBuildSuffix, 16), }, // Web 端使用过滤后的插件 plugins: filteredPlugins, diff --git a/app.json b/app.json index 162f4e3..b70695d 100644 --- a/app.json +++ b/app.json @@ -1,8 +1,8 @@ -{ +{ "expo": { "name": "威友", "slug": "qojo", - "version": "0.1.sha", + "version": "0.0.1", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", diff --git a/metro.config.js b/metro.config.js index e26af8a..469bc57 100644 --- a/metro.config.js +++ b/metro.config.js @@ -29,13 +29,37 @@ const webShimPaths = [ 'react-native/Libraries/Utilities/codegenNativeComponent', 'react-native/Libraries/Utilities/codegenNativeCommands', 'react-native/Libraries/Types/CodegenTypes', + 'react-native/Libraries/ReactNative/requireNativeComponent', ]; +// Packages that should be entirely replaced with web shims on web platform. +// These packages import requireNativeComponent directly from 'react-native' +// (which resolves to react-native-web on web) and don't have web implementations. +const webPackageShims = { + 'react-native-webrtc': path.resolve(__dirname, 'web-shims/react-native-webrtc/index.js'), +}; + const originalResolveRequest = config.resolver.resolveRequest; const shimDir = path.resolve(__dirname, 'web-shims'); config.resolver.resolveRequest = (context, moduleName, platform) => { + // Redirect entire native-only packages to web shims + if (platform === 'web' && webPackageShims[moduleName]) { + return { + type: 'sourceFile', + filePath: webPackageShims[moduleName], + }; + } + + // Redirect specific React Native internal modules to web shims if (platform === 'web' && webShimPaths.some((p) => moduleName.endsWith(p))) { + // For requireNativeComponent, the path doesn't follow the Libraries/* pattern + if (moduleName.endsWith('react-native/Libraries/ReactNative/requireNativeComponent')) { + return { + type: 'sourceFile', + filePath: path.join(shimDir, 'react-native', 'Libraries', 'ReactNative', 'requireNativeComponent.js'), + }; + } const shimName = moduleName.replace('react-native/Libraries/', ''); return { type: 'sourceFile', diff --git a/package-lock.json b/package-lock.json index 985133f..832e5c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "with_you", - "version": "0.1.sha", + "version": "0.0.1", "dependencies": { "@expo/vector-icons": "^15.1.1", "@react-native-async-storage/async-storage": "^2.2.0", diff --git a/package.json b/package.json index 723bf2e..a20d882 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "with_you", - "version": "0.1.sha", + "version": "0.0.1", "main": "expo-router/entry", "scripts": { "start": "expo start", diff --git a/src/components/business/CommentItem.tsx b/src/components/business/CommentItem.tsx index 7ac2302..c0b4c45 100644 --- a/src/components/business/CommentItem.tsx +++ b/src/components/business/CommentItem.tsx @@ -13,6 +13,7 @@ import { Comment, CommentImage } from '../../types'; import Text from '../common/Text'; import Avatar from '../common/Avatar'; import { CompactImageGrid, ImageGridItem } from '../common'; +import PostContentRenderer from './PostContentRenderer'; interface CommentItemProps { comment: Comment; @@ -614,9 +615,17 @@ const CommentItem: React.FC = ({ {/* 评论文本 - 非气泡样式 */} - - {comment.content} - + {comment.segments && comment.segments.length > 0 ? ( + + ) : ( + + {comment.content} + + )} {/* 评论图片 */} diff --git a/src/components/business/PostCard/PostCard.tsx b/src/components/business/PostCard/PostCard.tsx index 6b1e19a..54f0872 100644 --- a/src/components/business/PostCard/PostCard.tsx +++ b/src/components/business/PostCard/PostCard.tsx @@ -23,6 +23,7 @@ import Avatar from '../../common/Avatar'; import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../../theme'; import { getPreviewImageUrl } from '../../../utils/imageHelper'; import PostImages from './components/PostImages'; +import PostContentRenderer from '../PostContentRenderer'; import { useResponsive } from '../../../hooks/useResponsive'; import { formatPostCreatedAtString } from '../../../core/entities/Post'; @@ -208,6 +209,9 @@ function createPostCardStyles(colors: AppColors) { marginBottom: spacing.xs, lineHeight: fontSizes.md * 1.45, }, + contentRenderer: { + marginBottom: spacing.xs, + }, expandBtn: { marginBottom: spacing.sm, }, @@ -649,12 +653,22 @@ const PostCardInner: React.FC = (normalizedProps) => { {!isCompact && !!content && ( <> - - {highlightKeyword ? : content} - + {post.segments && post.segments.length > 0 ? ( + + ) : ( + + {highlightKeyword ? : content} + + )} {shouldTruncate && ( setIsExpanded((prev) => !prev)} style={styles.expandBtn}> {isExpanded ? '收起' : '展开全文'} diff --git a/src/components/business/PostContentRenderer.tsx b/src/components/business/PostContentRenderer.tsx new file mode 100644 index 0000000..deaa462 --- /dev/null +++ b/src/components/business/PostContentRenderer.tsx @@ -0,0 +1,153 @@ +/** + * PostContentRenderer - 帖子内容渲染器 + * 根据 segments 渲染富文本内容(@提及、投票等) + * 降级:如果 segments 为空但 content 非空,直接显示纯文本 + */ + +import React from 'react'; +import { View, TouchableOpacity, StyleSheet } from 'react-native'; + +import Text from '../common/Text'; +import { useAppColors } from '../../theme'; +import { MessageSegment, AtSegmentData, VoteSegmentData } from '../../types'; + +interface PostContentRendererProps { + content?: string; + segments?: MessageSegment[]; + numberOfLines?: number; + onMentionPress?: (userId: string) => void; + style?: any; + textStyle?: any; +} + +const PostContentRenderer: React.FC = ({ + content, + segments, + numberOfLines, + onMentionPress, + style, + textStyle, +}) => { + const colors = useAppColors(); + + if (!segments || segments.length === 0) { + return ( + + {content || ''} + + ); + } + + const elements: React.ReactNode[] = []; + let keyIndex = 0; + + for (const segment of segments) { + const key = `seg_${keyIndex++}`; + + switch (segment.type) { + case 'text': { + const text = segment.data?.text || segment.data?.content || ''; + if (text) { + elements.push( + + {text} + + ); + } + break; + } + + case 'at': { + const atData = segment.data as AtSegmentData; + const userId = atData?.user_id; + const isAtAll = userId === 'all'; + elements.push( + + {isAtAll ? '@所有人 ' : '@某人 '} + + ); + break; + } + + case 'vote': { + const voteData = segment.data as VoteSegmentData; + if (voteData?.options?.length) { + elements.push( + + + 📊 投票({voteData.options.length} 个选项) + + {voteData.options.map((opt, i) => ( + + {i + 1}. {opt.content} + + ))} + + ); + } + break; + } + + case 'image': + case 'link': + case 'face': + default: + break; + } + } + + if (elements.length === 0) { + return ( + + {content || ''} + + ); + } + + if (numberOfLines) { + return ( + + + {elements} + + + ); + } + + return {elements}; +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'flex-start', + }, + voteBlock: { + width: '100%', + borderWidth: 1, + borderRadius: 8, + padding: 10, + marginTop: 4, + marginBottom: 4, + }, + voteTitle: { + fontSize: 13, + marginBottom: 6, + }, + voteOption: { + fontSize: 14, + paddingVertical: 2, + }, +}); + +export default PostContentRenderer; diff --git a/src/components/business/PostMentionInput.tsx b/src/components/business/PostMentionInput.tsx new file mode 100644 index 0000000..ea26219 --- /dev/null +++ b/src/components/business/PostMentionInput.tsx @@ -0,0 +1,269 @@ +/** + * PostMentionInput - 帖子内容输入框(支持 @提及) + * 复用关注列表加载逻辑 + * 检测输入中的 @ 字符,弹出关注者列表供选择 + */ + +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { + View, + TextInput, + FlatList, + TouchableOpacity, + StyleSheet, + Platform, +} from 'react-native'; + +import Text from '../common/Text'; +import Avatar from '../common/Avatar'; +import { useAppColors, spacing, fontSizes, borderRadius } from '../../theme'; +import { useAuthStore } from '../../stores'; +import { authService } from '../../services/auth'; +import { MessageSegment, AtSegmentData } from '../../types'; + +interface MentionUser { + id: string; + nickname: string; + avatar: string | null; +} + +interface PostMentionInputProps { + value: string; + onChangeText: (text: string) => void; + onSegmentsChange: (segments: MessageSegment[]) => void; + placeholder?: string; + maxLength?: number; + style?: any; + minHeight?: number; +} + +const PostMentionInput: React.FC = ({ + value, + onChangeText, + onSegmentsChange, + placeholder = '说点什么...', + maxLength = 2000, + style, + minHeight = 100, +}) => { + const colors = useAppColors(); + const currentUser = useAuthStore(s => s.currentUser); + const [followingUsers, setFollowingUsers] = useState([]); + const [showMentions, setShowMentions] = useState(false); + const [mentionQuery, setMentionQuery] = useState(''); + const [mentionStartIndex, setMentionStartIndex] = useState(-1); + const [loaded, setLoaded] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + if (!loaded && currentUser?.id) { + loadFollowing(); + setLoaded(true); + } + }, [currentUser?.id, loaded]); + + const loadFollowing = async () => { + if (!currentUser?.id) return; + try { + const list = await authService.getFollowingList(currentUser.id, 1, 200); + setFollowingUsers( + list.map(u => ({ + id: u.id, + nickname: u.nickname || u.username || '', + avatar: u.avatar || null, + })) + ); + } catch (_) { + // ignore + } + }; + + const filteredUsers = followingUsers.filter(u => + u.nickname.toLowerCase().includes(mentionQuery.toLowerCase()) + ); + + const activeMentions = useRef(new Map()); + const segmentsRef = useRef([]); + + const rebuildSegments = useCallback( + (text: string) => { + const mentions = activeMentions.current; + if (mentions.size === 0) { + onSegmentsChange(text.trim() ? [{ type: 'text', data: { text } }] : []); + return; + } + + const segments: MessageSegment[] = []; + const sorted = Array.from(mentions.entries()).sort((a, b) => a[0] - b[0]); + let lastEnd = 0; + + for (const [startIdx, mention] of sorted) { + if (startIdx > lastEnd) { + segments.push({ type: 'text', data: { text: text.slice(lastEnd, startIdx) } }); + } + segments.push({ + type: 'at', + data: { user_id: mention.userId, nickname: mention.nickname } as AtSegmentData, + }); + lastEnd = mention.endIndex; + } + + if (lastEnd < text.length) { + segments.push({ type: 'text', data: { text: text.slice(lastEnd) } }); + } + + onSegmentsChange(segments); + }, + [onSegmentsChange] + ); + + const handleChangeText = useCallback( + (text: string) => { + onChangeText(text); + + const cursorPos = text.length; + let atStart = -1; + + for (let i = cursorPos - 1; i >= 0; i--) { + if (text[i] === '@') { + atStart = i; + break; + } + if (text[i] === ' ' || text[i] === '\n') { + break; + } + } + + if (atStart >= 0) { + const query = text.slice(atStart + 1, cursorPos); + if (!query.includes(' ') && !query.includes('\n')) { + setMentionQuery(query); + setMentionStartIndex(atStart); + setShowMentions(true); + return; + } + } + + setShowMentions(false); + rebuildSegments(text); + }, + [onChangeText, rebuildSegments] + ); + + const handleSelectMention = useCallback( + (mentionUser: MentionUser) => { + if (mentionStartIndex < 0) return; + + const before = value.slice(0, mentionStartIndex); + const mentionText = `@${mentionUser.nickname} `; + const newText = before + mentionText; + + activeMentions.current.set(mentionStartIndex, { + endIndex: before.length + mentionText.length, + userId: mentionUser.id, + nickname: mentionUser.nickname, + }); + + onChangeText(newText); + setShowMentions(false); + setMentionQuery(''); + setMentionStartIndex(-1); + + rebuildSegments(newText); + + inputRef.current?.focus(); + }, + [value, mentionStartIndex, onChangeText, rebuildSegments] + ); + + const renderMentionItem = ({ item }: { item: MentionUser }) => ( + handleSelectMention(item)} + > + + + {item.nickname} + + + ); + + return ( + + + + {showMentions && filteredUsers.length > 0 && ( + + item.id} + keyboardShouldPersistTaps="handled" + nestedScrollEnabled + style={styles.mentionList} + /> + + )} + + {showMentions && filteredUsers.length === 0 && ( + + + 没有找到匹配的关注用户 + + + )} + + ); +}; + +const styles = StyleSheet.create({ + input: { + fontSize: fontSizes.md, + padding: spacing.sm, + borderRadius: borderRadius.md, + minHeight: 100, + textAlignVertical: 'top' as const, + }, + mentionPanel: { + borderWidth: 1, + borderRadius: borderRadius.md, + maxHeight: 200, + marginTop: spacing.xs, + }, + mentionList: { + maxHeight: 200, + }, + mentionItem: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + mentionName: { + fontSize: fontSizes.md, + marginLeft: spacing.sm, + }, + emptyText: { + fontSize: fontSizes.sm, + padding: spacing.md, + textAlign: 'center', + }, +}); + +export default PostMentionInput; \ No newline at end of file diff --git a/src/components/business/index.ts b/src/components/business/index.ts index eb245a1..4988e02 100644 --- a/src/components/business/index.ts +++ b/src/components/business/index.ts @@ -22,5 +22,7 @@ export { default as TabBar } from './TabBar'; export { default as VoteCard } from './VoteCard'; export { default as VoteEditor } from './VoteEditor'; export { default as VotePreview } from './VotePreview'; +export { default as PostContentRenderer } from './PostContentRenderer'; +export { default as PostMentionInput } from './PostMentionInput'; export { default as ReportDialog } from './ReportDialog'; export { default as ShareSheet } from './ShareSheet'; diff --git a/src/database/core/DatabaseManager.ts b/src/database/core/DatabaseManager.ts index 789d204..b1ab310 100644 --- a/src/database/core/DatabaseManager.ts +++ b/src/database/core/DatabaseManager.ts @@ -1,5 +1,4 @@ import * as SQLite from 'expo-sqlite'; -import { Platform } from 'react-native'; import { getDbName } from './DatabaseConfig'; import { runMigrations } from '../schema/MigrationManager'; @@ -43,34 +42,30 @@ class DatabaseManager { private async doInitialize(targetUserId: string | null): Promise { if (this.db) { - this.forceClose(this.db); + await this.closeAsync(this.db); this.db = null; this.currentUserId = null; } const dbName = getDbName(targetUserId); - const isWeb = Platform.OS === 'web'; - if (isWeb) { + try { + const db = await this.tryOpenDatabase(dbName); + await runMigrations(db); + this.db = db; + this.currentUserId = targetUserId; + } catch (error) { + console.warn('[DatabaseManager] 数据库初始化失败,使用内存数据库:', error); try { - const db = await this.tryOpenDatabase(dbName); - await runMigrations(db); - this.db = db; - this.currentUserId = targetUserId; - } catch (error) { - console.warn('[DatabaseManager] Web数据库打开失败,使用内存数据库:', error); const db = await SQLite.openDatabaseAsync(':memory:'); await runMigrations(db); this.db = db; this.currentUserId = targetUserId; + } catch (fallbackError) { + console.error('[DatabaseManager] 内存数据库也无法初始化:', fallbackError); + throw error; } - return; } - - const db = await this.tryOpenDatabase(dbName); - await runMigrations(db); - this.db = db; - this.currentUserId = targetUserId; } private async tryOpenDatabase(dbName: string): Promise { @@ -87,12 +82,12 @@ class DatabaseManager { } } - private forceClose(db: SQLiteDatabase): void { + private async closeAsync(db: SQLiteDatabase): Promise { try { - db.closeSync(); + await db.closeAsync(); } catch { try { - db.closeAsync().catch(() => {}); + db.closeSync(); } catch {} } } @@ -103,7 +98,7 @@ class DatabaseManager { } if (this.db) { - this.forceClose(this.db); + await this.closeAsync(this.db); this.db = null; this.currentUserId = null; } diff --git a/src/screens/create/CreatePostScreen.tsx b/src/screens/create/CreatePostScreen.tsx index 3bd3ce4..0448e9d 100644 --- a/src/screens/create/CreatePostScreen.tsx +++ b/src/screens/create/CreatePostScreen.tsx @@ -1,4 +1,4 @@ -/** +/** * 发帖页 CreatePostScreen(响应式适配) * 威友 - 发布新帖子 * 参考微博发帖界面设计 @@ -38,8 +38,10 @@ import { channelService, postService, showPrompt, voteService } from '../../serv import { ApiError } from '@/services/core'; import { uploadService } from '@/services/upload'; import VoteEditor from '../../components/business/VoteEditor'; +import PostMentionInput from '../../components/business/PostMentionInput'; import { useResponsive, useResponsiveValue } from '../../hooks'; import * as hrefs from '../../navigation/hrefs'; +import { MessageSegment } from '../../types'; // Props 接口 interface CreatePostScreenProps { @@ -106,6 +108,7 @@ export const CreatePostScreen: React.FC = (props) => { const [title, setTitle] = useState(''); const [content, setContent] = useState(''); + const [segments, setSegments] = useState([]); const [images, setImages] = useState<{ uri: string; uploading?: boolean }[]>([]); const [channelOptions, setChannelOptions] = useState([]); const [selectedChannelId, setSelectedChannelId] = useState(null); @@ -402,6 +405,7 @@ export const CreatePostScreen: React.FC = (props) => { const updated = await postService.updatePost(editPostID, { title: title.trim(), content: content.trim(), + segments: segments.length > 0 ? segments : undefined, images: imageUrls, }); if (!updated) { @@ -415,10 +419,10 @@ export const CreatePostScreen: React.FC = (props) => { }); router.replace(hrefs.hrefHome()); } else { - // 创建普通帖子 await postService.createPost({ title: title.trim(), content: content.trim(), + segments: segments.length > 0 ? segments : undefined, images: imageUrls, channel_id: selectedChannelId || undefined, }); @@ -598,22 +602,18 @@ export const CreatePostScreen: React.FC = (props) => { maxLength={MAX_TITLE_LENGTH} /> - {/* 正文输入 */} - setSelection(e.nativeEvent.selection)} /> {/* 作为正文容器的子模块,追加在文本后面 */} diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index e488659..0cde7ee 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -1,4 +1,4 @@ -/** +/** * 首页 HomeScreen * 威友 - 首页展示 * 支持列表和多列网格模式(响应式布局) @@ -27,7 +27,7 @@ import { PagerView } from '../../components/common'; import { useAppColors, useResolvedColorScheme, spacing, borderRadius, shadows, type AppColors } from '../../theme'; import { Post } from '../../types'; import { useUserStore, useHomeTabBarVisibilityStore, useHomeTabPressStore } from '../../stores'; -import { useCurrentUser } from '../../stores/auth'; +import { useCurrentUser, useIsAuthenticated, useIsVerified, useVerificationStore } from '../../stores/auth'; import { channelService, postService } from '../../services'; import { PostCard, TabBar, SearchBar, ShareSheet } from '../../components/business'; import type { PostCardAction } from '../../components/business/PostCard'; @@ -169,6 +169,10 @@ export const HomeScreen: React.FC = () => { const insets = useSafeAreaInsets(); const { posts: storePosts } = useUserStore(); const currentUser = useCurrentUser(); + const isAuthenticated = useIsAuthenticated(); + const isVerified = useIsVerified(); + const fetchVerificationStatus = useVerificationStore((s) => s.fetchStatus); + const hasFetchedVerification = useVerificationStore((s) => s.hasFetchedStatus); const colors = useAppColors(); const resolvedScheme = useResolvedColorScheme(); const styles = useMemo(() => createHomeStyles(colors), [colors]); @@ -207,6 +211,12 @@ export const HomeScreen: React.FC = () => { const [showShareSheet, setShowShareSheet] = useState(false); const [sharePost, setSharePost] = useState(null); + useEffect(() => { + if (isAuthenticated && !hasFetchedVerification) { + fetchVerificationStatus(); + } + }, [isAuthenticated, hasFetchedVerification, fetchVerificationStatus]); + useEffect(() => { if (showCreatePost) { blurActiveElement(); @@ -656,6 +666,14 @@ export const HomeScreen: React.FC = () => { // 跳转到发帖页面(使用 Modal 方式) const handleCreatePost = () => { + if (!isAuthenticated) { + router.push('/login'); + return; + } + if (!isVerified) { + router.push(hrefs.hrefVerificationGuide()); + return; + } setShowCreatePost(true); }; diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index 55d3ea7..f7fbe27 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -1,4 +1,4 @@ -/** +/** * 帖子详情页 PostDetailScreen - QQ频道风格(响应式版本) * 威友 - 显示完整帖子内容和评论 * 桌面端使用双栏布局,移动端使用单栏布局 @@ -33,13 +33,13 @@ import { useAppColors, type AppColors, } from '../../theme'; -import { Post, Comment, VoteResultDTO, VoteOptionDTO } from '../../types'; +import { Post, Comment, VoteResultDTO, VoteOptionDTO, MessageSegment } from '../../types'; import { useUserStore } from '../../stores'; import { useCurrentUser } from '../../stores/auth'; import { postService, commentService, uploadService, authService, showPrompt, voteService } from '../../services'; import { postSyncService } from '@/services/post'; import { useCursorPagination } from '../../hooks/useCursorPagination'; -import { CommentItem, VoteCard, ReportDialog, ShareSheet } from '../../components/business'; +import { CommentItem, VoteCard, ReportDialog, ShareSheet, PostContentRenderer, PostMentionInput } from '../../components/business'; import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common'; import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive'; import { handleError } from '@/services/core'; @@ -191,6 +191,7 @@ export const PostDetailScreen: React.FC = () => { } }, [commentsError, postId]); const [commentText, setCommentText] = useState(''); + const [commentSegments, setCommentSegments] = useState([]); const [showImageModal, setShowImageModal] = useState(false); const [selectedImageIndex, setSelectedImageIndex] = useState(0); const [allImages, setAllImages] = useState([]); @@ -741,6 +742,7 @@ export const PostDetailScreen: React.FC = () => { root_id: rootId, target_id: parentId, content: commentText.trim(), + segments: commentSegments.length > 0 ? commentSegments : undefined, images: uploadedImageUrls.map(url => ({ url })), likes_count: 0, replies_count: 0, @@ -798,6 +800,7 @@ export const PostDetailScreen: React.FC = () => { const createdComment = await commentService.createComment({ postId: post.id, content: commentText.trim(), + segments: commentSegments.length > 0 ? commentSegments : undefined, parentId: parentId || undefined, images: uploadedImageUrls, }); @@ -1097,18 +1100,17 @@ export const PostDetailScreen: React.FC = () => { {/* 内容 - 增大字体 */} - - {post.content} - + /> {/* 图片 - 详情页显示所有图片,不限制数量 */} @@ -1442,15 +1444,14 @@ export const PostDetailScreen: React.FC = () => { )} {/* Text Input */} - {/* Image preview */} diff --git a/src/services/auth/authService.ts b/src/services/auth/authService.ts index 43958a5..71569d4 100644 --- a/src/services/auth/authService.ts +++ b/src/services/auth/authService.ts @@ -149,6 +149,9 @@ class AuthService { // 用户登录(只管 API + Token,DB 操作交给 authStore) async login(data: LoginRequest): Promise { + await api.clearToken(); + api.resetAuthState(); + const response = await api.post('/auth/login', data); if (!response.data) { @@ -161,13 +164,15 @@ class AuthService { if (refreshToken) { await api.setRefreshToken(refreshToken); } - api.resetAuthState(); return response.data; } // 用户注册(只管 API + Token,DB 操作交给 authStore) async register(data: RegisterRequest): Promise { + await api.clearToken(); + api.resetAuthState(); + const response = await api.post('/auth/register', data); if (!response.data) { @@ -180,7 +185,6 @@ class AuthService { if (refreshToken) { await api.setRefreshToken(refreshToken); } - api.resetAuthState(); return response.data; } diff --git a/src/services/post/commentService.ts b/src/services/post/commentService.ts index b746dde..2b0038b 100644 --- a/src/services/post/commentService.ts +++ b/src/services/post/commentService.ts @@ -4,7 +4,7 @@ */ import { api, PaginatedData } from '../core/api'; -import { Comment, CreateCommentInput } from '@/types'; +import { Comment, CreateCommentInput, MessageSegment } from '@/types'; import { CursorPaginationRequest, CursorPaginationResponse, CommentDTO } from '@/types/dto'; // 评论列表响应 @@ -23,8 +23,9 @@ type CommentResponse = Comment; interface CreateCommentRequest { postId: string; content: string; + segments?: MessageSegment[]; parentId?: string; - images?: string[]; // 图片URL列表 + images?: string[]; replyToUserId?: string; } @@ -130,12 +131,14 @@ class CommentService { content: data.content, }; - // 如果有父评论ID,才添加 parent_id 字段 + if (data.segments && data.segments.length > 0) { + requestBody.segments = data.segments; + } + if (data.parentId) { requestBody.parent_id = data.parentId; } - // 如果有图片,添加 images 字段 if (data.images && data.images.length > 0) { requestBody.images = data.images; } diff --git a/src/services/post/postService.ts b/src/services/post/postService.ts index 5ca4325..d2b4caf 100644 --- a/src/services/post/postService.ts +++ b/src/services/post/postService.ts @@ -5,7 +5,7 @@ import { api, PaginatedData } from '../core/api'; import { Post, CreatePostInput } from '@/types'; -import { CursorPaginationRequest, CursorPaginationResponse } from '@/types/dto'; +import { CursorPaginationRequest, CursorPaginationResponse, MessageSegment } from '@/types/dto'; // 帖子列表响应 interface PostListResponse { @@ -22,7 +22,8 @@ type PostResponse = Post; // 创建帖子请求 interface CreatePostRequest { title: string; - content: string; + content?: string; + segments?: MessageSegment[]; images?: string[]; channel_id?: string; } @@ -31,6 +32,7 @@ interface CreatePostRequest { interface UpdatePostRequest { title?: string; content?: string; + segments?: MessageSegment[]; images?: string[]; } diff --git a/src/services/post/voteService.ts b/src/services/post/voteService.ts index 491930e..14d044a 100644 --- a/src/services/post/voteService.ts +++ b/src/services/post/voteService.ts @@ -4,7 +4,7 @@ */ import { api } from '../core/api'; -import { Post, VoteResultDTO, CreateVotePostRequest } from '@/types'; +import { Post, VoteResultDTO, CreateVotePostRequest, MessageSegment } from '@/types'; // 投票响应 interface VoteResponse { @@ -23,12 +23,9 @@ interface UpdateVoteOptionResponse { */ class VoteService { /** - * 创建投票帖子 - * @param data 创建投票帖子请求数据 - * @returns 创建的帖子或null + * 创建投票帖子(通过统一的 POST /posts 接口,投票选项作为 vote segment) */ async createVotePost(data: CreateVotePostRequest): Promise { - // 验证投票选项数量 if (!data.vote_options || data.vote_options.length < 2) { console.error('[VoteService] 投票选项至少需要2个'); return null; @@ -38,7 +35,30 @@ class VoteService { return null; } - const response = await api.post('/posts/vote', data); + // 构建 segments:先放文本内容,再放投票 segment + const segments: MessageSegment[] = []; + if (data.content) { + segments.push({ type: 'text', data: { text: data.content } }); + } + segments.push({ + type: 'vote', + data: { + options: data.vote_options.map((opt, i) => ({ + id: `opt_${i + 1}`, + content: typeof opt === 'string' ? opt : opt.content, + })), + max_choices: 1, + is_multi_choice: false, + }, + }); + + const response = await api.post('/posts', { + title: data.title, + content: data.content || '', + segments, + images: data.images, + channel_id: data.channel_id, + }); return response.data; } diff --git a/src/stores/auth/authStore.ts b/src/stores/auth/authStore.ts index b49f2cd..00b9b0e 100644 --- a/src/stores/auth/authStore.ts +++ b/src/stores/auth/authStore.ts @@ -141,8 +141,21 @@ export const useAuthStore = create((set) => ({ const userId = String(user.id); - // 2. 初始化用户专属数据库 - await switchDatabase(userId); + // 2. 关闭旧数据库连接后再初始化用户专属数据库 + try { + await closeDatabase(); + } catch {} + try { + await switchDatabase(userId); + } catch (dbErr) { + console.warn('[AuthStore] switchDatabase 失败,将重试:', dbErr); + try { + await closeDatabase(); + await switchDatabase(userId); + } catch (retryErr) { + console.error('[AuthStore] switchDatabase 重试仍失败:', retryErr); + } + } // 3. 持久化 userId(供下次冷启动提前初始化 DB 用) await saveUserId(userId); @@ -160,8 +173,10 @@ export const useAuthStore = create((set) => ({ error: null, }); - // 6. 启动 SSE - await startRealtime(); + // 6. 启动 SSE(不阻塞登录结果) + startRealtime().catch((err) => { + console.warn('[AuthStore] 启动实时服务失败:', err); + }); return true; } catch (error: any) { @@ -187,7 +202,21 @@ export const useAuthStore = create((set) => ({ const userId = String(user.id); - await switchDatabase(userId); + try { + await closeDatabase(); + } catch {} + try { + await switchDatabase(userId); + } catch (dbErr) { + console.warn('[AuthStore] register switchDatabase 失败,将重试:', dbErr); + try { + await closeDatabase(); + await switchDatabase(userId); + } catch (retryErr) { + console.error('[AuthStore] register switchDatabase 重试仍失败:', retryErr); + } + } + await saveUserId(userId); await cacheUser(user); @@ -201,7 +230,10 @@ export const useAuthStore = create((set) => ({ error: null, }); - await startRealtime(); + // 启动 SSE(不阻塞注册结果) + startRealtime().catch((err) => { + console.warn('[AuthStore] 启动实时服务失败:', err); + }); return true; } catch (error: any) { @@ -275,8 +307,14 @@ export const useAuthStore = create((set) => ({ // 4. 确保 DB 已初始化(如果预初始化失败,这里会重试) const dbReady = isDbInitialized() && getCurrentDbUserId() === userId; if (!dbReady) { -await switchDatabase(userId); - + try { + await closeDatabase(); + } catch {} + try { + await switchDatabase(userId); + } catch (dbErr) { + console.warn('[AuthStore] fetchCurrentUser switchDatabase 失败:', dbErr); + } } // 5. 更新持久化的 userId @@ -296,8 +334,10 @@ await switchDatabase(userId); isLoading: false, }); - // 8. 启动 SSE - await startRealtime(); + // 8. 启动 SSE(不阻塞状态设置) + startRealtime().catch((err) => { + console.warn('[AuthStore] 冷启动实时服务失败:', err); + }); } else { // Token 已失效或不存在 await clearUserId(); diff --git a/src/types/dto.ts b/src/types/dto.ts index 4364f5a..2bb7b34 100644 --- a/src/types/dto.ts +++ b/src/types/dto.ts @@ -68,6 +68,7 @@ export interface PostDTO { user_id: string; title: string; content: string; + segments?: MessageSegment[]; images: PostImageDTO[]; status?: string; likes_count: number; @@ -111,6 +112,7 @@ export interface CommentDTO { parent_id: string | null; root_id: string | null; content: string; + segments?: MessageSegment[]; images: CommentImageDTO[]; likes_count: number; replies_count: number; @@ -148,7 +150,7 @@ export type MessageStatus = 'normal' | 'recalled' | 'deleted'; // ==================== Message Segment Types ==================== // Segment类型 -export type SegmentType = 'text' | 'image' | 'voice' | 'video' | 'file' | 'at' | 'reply' | 'face' | 'link'; +export type SegmentType = 'text' | 'image' | 'voice' | 'video' | 'file' | 'at' | 'reply' | 'face' | 'link' | 'vote'; // 文本Segment数据 export interface TextSegmentData { @@ -217,6 +219,19 @@ export interface LinkSegmentData { image?: string; } +// 投票选项数据 +export interface VoteOptionData { + id: string; + content: string; +} + +// 投票Segment数据 +export interface VoteSegmentData { + options: VoteOptionData[]; + max_choices?: number; + is_multi_choice?: boolean; +} + // MessageSegment 联合类型 - 使用 discriminated union export type MessageSegment = | { type: 'text'; data: TextSegmentData } @@ -227,7 +242,8 @@ export type MessageSegment = | { type: 'at'; data: AtSegmentData } | { type: 'reply'; data: ReplySegmentData } | { type: 'face'; data: FaceSegmentData } - | { type: 'link'; data: LinkSegmentData }; + | { type: 'link'; data: LinkSegmentData } + | { type: 'vote'; data: VoteSegmentData }; // 消息响应 export interface MessageResponse { diff --git a/src/types/index.ts b/src/types/index.ts index 5c6b7ab..6611650 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,4 @@ -// ============================================ +// ============================================ // WithYou - Core Type Definitions // 使用后端DTO类型,保持snake_case命名规范 // ============================================ @@ -37,43 +37,14 @@ import type { VoiceSegmentData, VideoSegmentData, FileSegmentData, - AtSegmentData, +AtSegmentData, ReplySegmentData, FaceSegmentData, LinkSegmentData, + VoteSegmentData, + VoteOptionData, } from './dto'; -// 重新导出新的聊天系统类型 -export type { - ConversationType, - MessageContentType, - MessageStatus, - MessageResponse, - ConversationResponse, - ConversationParticipantResponse, - ConversationDetailResponse, - CreateConversationRequest, - SendMessageRequest, - MarkReadRequest, - UnreadCountResponse, - ConversationUnreadCountResponse, - ConversationListResponse, - MessageListResponse, - MessageSyncResponse, - // Segment 类型 - SegmentType, - MessageSegment, - TextSegmentData, - ImageSegmentData, - VoiceSegmentData, - VideoSegmentData, - FileSegmentData, - AtSegmentData, - ReplySegmentData, - FaceSegmentData, - LinkSegmentData, -}; - // ============================================ // User Types // ============================================ @@ -101,8 +72,9 @@ export type { VoteOptionDTO as VoteOption, VoteResultDTO as VoteResult, CreateVo export interface CreatePostInput { title: string; - content: string; - images?: string[]; // 本地图片路径或 base64 + content?: string; + segments?: MessageSegment[]; + images?: string[]; channel_id?: string; } @@ -122,6 +94,7 @@ export interface Comment { parent_id: string | null; root_id?: string | null; content: string; + segments?: MessageSegment[]; images?: CommentImage[]; likes_count: number; replies_count: number; diff --git a/web-shims/react-native-webrtc/index.js b/web-shims/react-native-webrtc/index.js new file mode 100644 index 0000000..9b10db3 --- /dev/null +++ b/web-shims/react-native-webrtc/index.js @@ -0,0 +1,94 @@ +const React = require('react'); + +const RTCView = React.forwardRef((props, ref) => { + const { streamURL, mirror, objectFit, style, ...rest } = props; + const src = streamURL || (props.stream && props.stream.toURL ? props.stream.toURL() : ''); + + if (src) { + return React.createElement('video', { + ...rest, + ref, + src, + autoPlay: true, + playsInline: true, + muted: props.muted || false, + style: { + ...style, + transform: mirror ? 'scaleX(-1)' : undefined, + objectFit: objectFit || 'cover', + width: '100%', + height: '100%', + }, + }); + } + return React.createElement('div', { ...rest, ref, style }); +}); +RTCView.displayName = 'RTCView'; + +const ScreenCapturePickerView = React.forwardRef((props, ref) => { + return React.createElement('div', { ...props, ref, 'data-native-component': 'ScreenCapturePickerView' }); +}); +ScreenCapturePickerView.displayName = 'ScreenCapturePickerView'; + +const mediaDevices = typeof navigator !== 'undefined' && navigator.mediaDevices + ? navigator.mediaDevices + : { + getUserMedia: () => Promise.reject(new Error('mediaDevices not available')), + getDisplayMedia: () => Promise.reject(new Error('mediaDevices not available')), + enumerateDevices: () => Promise.resolve([]), + }; + +const RTCPeerConnection = typeof window !== 'undefined' ? window.RTCPeerConnection : function () {}; +const RTCSessionDescription = typeof window !== 'undefined' ? window.RTCSessionDescription : function () {}; +const RTCIceCandidate = typeof window !== 'undefined' ? window.RTCIceCandidate : function () {}; +const MediaStream = typeof window !== 'undefined' ? window.MediaStream : function () {}; +const MediaStreamTrack = typeof window !== 'undefined' ? window.MediaStreamTrack : function () {}; + +function registerGlobals() {} + +const permissions = { + request: () => Promise.resolve(true), + check: () => Promise.resolve(true), +}; + +const RTCAudioSession = { + setCategory: () => {}, + setActive: () => {}, + setMode: () => {}, +}; + +const RTCRtpReceiver = typeof window !== 'undefined' ? window.RTCRtpReceiver : function () {}; +const RTCRtpSender = typeof window !== 'undefined' ? window.RTCRtpSender : function () {}; +const RTCRtpTransceiver = typeof window !== 'undefined' ? window.RTCRtpTransceiver : function () {}; +const RTCErrorEvent = typeof window !== 'undefined' ? window.RTCErrorEvent : function () {}; +const MediaStreamTrackEvent = typeof window !== 'undefined' ? window.MediaStreamTrackEvent : function () {}; + +const RTCPIPView = React.forwardRef((props, ref) => { + return React.createElement('div', { ...props, ref, 'data-native-component': 'RTCPIPView' }); +}); +RTCPIPView.displayName = 'RTCPIPView'; + +function startIOSPIP() {} +function stopIOSPIP() {} + +module.exports = { + RTCView, + ScreenCapturePickerView, + RTCPeerConnection, + RTCSessionDescription, + RTCIceCandidate, + MediaStream, + MediaStreamTrack, + MediaStreamTrackEvent, + mediaDevices, + permissions, + registerGlobals, + RTCAudioSession, + RTCRtpReceiver, + RTCRtpSender, + RTCRtpTransceiver, + RTCErrorEvent, + RTCPIPView, + startIOSPIP, + stopIOSPIP, +}; diff --git a/web-shims/react-native/Libraries/ReactNative/requireNativeComponent.js b/web-shims/react-native/Libraries/ReactNative/requireNativeComponent.js new file mode 100644 index 0000000..4c9761b --- /dev/null +++ b/web-shims/react-native/Libraries/ReactNative/requireNativeComponent.js @@ -0,0 +1,18 @@ +const React = require('react'); + +function requireNativeComponent(componentName) { + const componentNameClean = componentName + .replace(/^RNC/, '') + .replace(/^RNS/, '') + .replace(/^RN/, ''); + + const NativeComponent = React.forwardRef((props, ref) => { + const { children, ...rest } = props; + return React.createElement('div', { ...rest, ref, 'data-native-component': componentNameClean }, children); + }); + NativeComponent.displayName = componentName; + + return NativeComponent; +} + +module.exports = requireNativeComponent;