diff --git a/MeAgent/ios/Podfile.lock b/MeAgent/ios/Podfile.lock index cc030802..3b144b08 100644 --- a/MeAgent/ios/Podfile.lock +++ b/MeAgent/ios/Podfile.lock @@ -5,6 +5,9 @@ PODS: - ExpoModulesCore - EXConstants (16.0.2): - ExpoModulesCore + - EXImageLoader (4.7.0): + - ExpoModulesCore + - React-Core - EXNotifications (0.28.19): - ExpoModulesCore - Expo (51.0.39): @@ -21,6 +24,8 @@ PODS: - ExpoModulesCore - ExpoFont (12.0.10): - ExpoModulesCore + - ExpoImagePicker (15.1.0): + - ExpoModulesCore - ExpoKeepAwake (13.0.2): - ExpoModulesCore - ExpoLinearGradient (13.0.2): @@ -78,6 +83,7 @@ PODS: - hermes-engine (0.74.5): - hermes-engine/Pre-built (= 0.74.5) - hermes-engine/Pre-built (0.74.5) + - lottie-ios (4.5.2) - RCT-Folly (2024.01.01.00): - boost - DoubleConversion @@ -1283,6 +1289,9 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga + - RNKLineView (1.0.0): + - lottie-ios (~> 4.5.0) + - React - RNReanimated (3.10.1): - DoubleConversion - glog @@ -1336,6 +1345,7 @@ DEPENDENCIES: - DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) - EXApplication (from `../node_modules/expo-application/ios`) - EXConstants (from `../node_modules/expo-constants/ios`) + - EXImageLoader (from `../node_modules/expo-image-loader/ios`) - EXNotifications (from `../node_modules/expo-notifications/ios`) - Expo (from `../node_modules/expo`) - ExpoAsset (from `../node_modules/expo-asset/ios`) @@ -1344,6 +1354,7 @@ DEPENDENCIES: - ExpoDevice (from `../node_modules/expo-device/ios`) - ExpoFileSystem (from `../node_modules/expo-file-system/ios`) - ExpoFont (from `../node_modules/expo-font/ios`) + - ExpoImagePicker (from `../node_modules/expo-image-picker/ios`) - ExpoKeepAwake (from `../node_modules/expo-keep-awake/ios`) - ExpoLinearGradient (from `../node_modules/expo-linear-gradient/ios`) - ExpoModulesCore (from `../node_modules/expo-modules-core`) @@ -1407,6 +1418,7 @@ DEPENDENCIES: - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" - "RNCMaskedView (from `../node_modules/@react-native-masked-view/masked-view`)" - RNGestureHandler (from `../node_modules/react-native-gesture-handler`) + - RNKLineView (from `../node_modules/react-native-kline-view`) - RNReanimated (from `../node_modules/react-native-reanimated`) - RNScreens (from `../node_modules/react-native-screens`) - RNSVG (from `../node_modules/react-native-svg`) @@ -1414,6 +1426,7 @@ DEPENDENCIES: SPEC REPOS: trunk: + - lottie-ios - SocketRocket EXTERNAL SOURCES: @@ -1425,6 +1438,8 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-application/ios" EXConstants: :path: "../node_modules/expo-constants/ios" + EXImageLoader: + :path: "../node_modules/expo-image-loader/ios" EXNotifications: :path: "../node_modules/expo-notifications/ios" Expo: @@ -1441,6 +1456,8 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-file-system/ios" ExpoFont: :path: "../node_modules/expo-font/ios" + ExpoImagePicker: + :path: "../node_modules/expo-image-picker/ios" ExpoKeepAwake: :path: "../node_modules/expo-keep-awake/ios" ExpoLinearGradient: @@ -1564,6 +1581,8 @@ EXTERNAL SOURCES: :path: "../node_modules/@react-native-masked-view/masked-view" RNGestureHandler: :path: "../node_modules/react-native-gesture-handler" + RNKLineView: + :path: "../node_modules/react-native-kline-view" RNReanimated: :path: "../node_modules/react-native-reanimated" RNScreens: @@ -1578,6 +1597,7 @@ SPEC CHECKSUMS: DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5 EXApplication: ec862905fdab3a15bf6bd8ca1a99df7fc02d7762 EXConstants: 89d35611505a8ce02550e64e43cd05565da35f9a + EXImageLoader: 1fe96c70cdc78bedc985ec4b1fab5dd8e67dc38b EXNotifications: 6ce128c0d3d3d161cd68bfd07d593db40e140396 Expo: ed0a748eb6be0efd2c3df7f6de3f3158a14464c9 ExpoAsset: 286fee7ba711ce66bf20b315e68106b13b8629fc @@ -1586,6 +1606,7 @@ SPEC CHECKSUMS: ExpoDevice: 84b3ed79df1234c17edfbf335f6ecf3c636f74de ExpoFileSystem: 2988caaf68b7cb706e36d382829d99811d9d76a5 ExpoFont: 38dddf823e32740c2a9f37c926a33aeca736b5c4 + ExpoImagePicker: 7038c1740853c170002c491de63f9f9780c8139b ExpoKeepAwake: dd02e65d49f1cfd9194640028ae2857e536eb1c9 ExpoLinearGradient: 4c44b3803b441724874b232e6520b51ca6a50db1 ExpoModulesCore: 9ac73e2f60e0ea1d30137ca96cfc8c2aa34ef2b2 @@ -1595,6 +1616,7 @@ SPEC CHECKSUMS: fmt: 4c2741a687cc09f0634a2e2c72a838b99f1ff120 glog: fdfdfe5479092de0c4bdbebedd9056951f092c4f hermes-engine: 8c1577f3fdb849cbe7729c2e7b5abc4b845e88f8 + lottie-ios: 96784afc26ea031d3e2b6cae342a4b8915072489 RCT-Folly: 5dc73daec3476616d19e8a53f0156176f7b55461 RCTDeprecation: 3afceddffa65aee666dafd6f0116f1d975db1584 RCTRequired: ec1239bc9d8bf63e10fb92bd8b26171a9258e0c1 @@ -1647,6 +1669,7 @@ SPEC CHECKSUMS: RNCAsyncStorage: aa75595c1aefa18f868452091fa0c411a516ce11 RNCMaskedView: de80352547bd4f0d607bf6bab363d826822bd126 RNGestureHandler: 326e35460fb6c8c64a435d5d739bea90d7ed4e49 + RNKLineView: bb63410106d30c7e3a7967c638ef682f9415b2a2 RNReanimated: def444e044c354f38bb0a5926a8583ba19d944c1 RNScreens: a2d8a2555b4653d7a19706eb172f855657ac30d7 RNSVG: 0e7deccab0678200815104223aadd5ca734dd41d diff --git a/MeAgent/ios/app.xcodeproj/project.pbxproj b/MeAgent/ios/app.xcodeproj/project.pbxproj index 59461e5a..6db72d96 100644 --- a/MeAgent/ios/app.xcodeproj/project.pbxproj +++ b/MeAgent/ios/app.xcodeproj/project.pbxproj @@ -280,6 +280,7 @@ "${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/RCTI18nStrings.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/lottie-ios/LottiePrivacyInfo.bundle", ); name = "[CP] Copy Pods Resources"; outputPaths = ( @@ -291,6 +292,7 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCTI18nStrings.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/LottiePrivacyInfo.bundle", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; diff --git a/MeAgent/package.json b/MeAgent/package.json index da111949..93519ecc 100644 --- a/MeAgent/package.json +++ b/MeAgent/package.json @@ -29,6 +29,7 @@ "expo-constants": "~16.0.2", "expo-device": "~6.0.2", "expo-font": "~12.0.10", + "expo-image-picker": "~15.1.0", "expo-linear-gradient": "~13.0.2", "expo-modules-core": "~1.12.24", "expo-notifications": "~0.28.19", diff --git a/MeAgent/src/screens/Community/ChannelDetail.js b/MeAgent/src/screens/Community/ChannelDetail.js index 110e7c83..59fa4830 100644 --- a/MeAgent/src/screens/Community/ChannelDetail.js +++ b/MeAgent/src/screens/Community/ChannelDetail.js @@ -13,6 +13,8 @@ import { TextInput, Image, Dimensions, + Alert, + ActivityIndicator, } from 'react-native'; import { Box, @@ -28,6 +30,7 @@ import { import { Ionicons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useDispatch, useSelector } from 'react-redux'; +import * as ImagePicker from 'expo-image-picker'; import { fetchMessages, @@ -35,6 +38,7 @@ import { addMessage, } from '../../store/slices/communitySlice'; import { useCommunitySocket } from '../../hooks/useCommunitySocket'; +import { uploadService } from '../../services/communityService'; // 消息分组:按日期 const groupMessagesByDate = (messages) => { @@ -170,6 +174,8 @@ const ChannelDetail = ({ route, navigation }) => { const [inputText, setInputText] = useState(''); const [isSending, setIsSending] = useState(false); + const [selectedImages, setSelectedImages] = useState([]); // { uri, mimeType } + const [isUploadingImages, setIsUploadingImages] = useState(false); // WebSocket 连接 const { @@ -211,6 +217,47 @@ const ChannelDetail = ({ route, navigation }) => { } }, [isConnected, channel?.id, startTyping]); + // 选择图片 + const handlePickImage = useCallback(async () => { + const MAX_IMAGES = 9; + if (selectedImages.length >= MAX_IMAGES) { + Alert.alert('提示', `最多添加${MAX_IMAGES}张图片`); + return; + } + + const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!permissionResult.granted) { + Alert.alert('提示', '需要访问相册权限才能选择图片'); + return; + } + + try { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsEditing: false, + quality: 0.8, + allowsMultipleSelection: true, + selectionLimit: MAX_IMAGES - selectedImages.length, + }); + + if (!result.canceled && result.assets?.length > 0) { + const newImages = result.assets.map((asset) => ({ + uri: asset.uri, + mimeType: asset.mimeType, + })); + setSelectedImages((prev) => [...prev, ...newImages].slice(0, MAX_IMAGES)); + } + } catch (error) { + console.warn('[ChannelDetail] 选择图片失败:', error); + Alert.alert('错误', '选择图片失败,请重试'); + } + }, [selectedImages.length]); + + // 删除选中的图片 + const handleRemoveImage = useCallback((index) => { + setSelectedImages((prev) => prev.filter((_, idx) => idx !== index)); + }, []); + // 设置导航标题 useEffect(() => { navigation.setOptions({ @@ -263,8 +310,12 @@ const ChannelDetail = ({ route, navigation }) => { // 发送消息 const handleSend = useCallback(async () => { - const content = inputText.trim(); - if (!content || isSending || !channel?.id) return; + const textContent = inputText.trim(); + const hasImages = selectedImages.length > 0; + + // 必须有文字或图片才能发送 + if (!textContent && !hasImages) return; + if (isSending || !channel?.id) return; setIsSending(true); Keyboard.dismiss(); @@ -273,24 +324,63 @@ const ChannelDetail = ({ route, navigation }) => { stopTyping(channel.id); try { + let finalContent = textContent; + + // 如果有图片,先上传 + if (hasImages) { + setIsUploadingImages(true); + const uploadedUrls = []; + + for (const image of selectedImages) { + try { + const result = await uploadService.uploadImage(image); + if (result.url) { + uploadedUrls.push(result.url); + } + } catch (error) { + console.warn('[ChannelDetail] 图片上传失败:', error); + } + } + + setIsUploadingImages(false); + + // 将图片 URL 转换为 Markdown 格式追加到内容后 + if (uploadedUrls.length > 0) { + const imageMarkdown = uploadedUrls + .map((url) => `![图片](${url})`) + .join('\n'); + finalContent = finalContent + ? finalContent + '\n\n' + imageMarkdown + : imageMarkdown; + } else if (!textContent) { + // 没有文字且图片全部上传失败 + Alert.alert('发送失败', '图片上传失败,请重试'); + setIsSending(false); + return; + } + } + await dispatch( sendMessage({ channelId: channel.id, - data: { content }, + data: { content: finalContent }, }) ).unwrap(); setInputText(''); + setSelectedImages([]); // 滚动到底部 setTimeout(() => { flatListRef.current?.scrollToEnd({ animated: true }); }, 100); } catch (error) { console.error('发送消息失败:', error); + Alert.alert('发送失败', '消息发送失败,请重试'); } finally { setIsSending(false); + setIsUploadingImages(false); } - }, [dispatch, channel?.id, inputText, isSending, stopTyping]); + }, [dispatch, channel?.id, inputText, selectedImages, isSending, stopTyping]); // 渲染日期分隔符 const renderDateDivider = (date) => ( @@ -466,73 +556,114 @@ const ChannelDetail = ({ route, navigation }) => { ); }; + // 渲染选中的图片预览 + const renderSelectedImages = () => { + if (selectedImages.length === 0) return null; + + return ( + + + {selectedImages.map((image, index) => ( + + + {isUploadingImages && ( + + + + )} + handleRemoveImage(index)} + hitSlop={10} + > + + + + ))} + + + ); + }; + // 渲染输入框 - const renderInput = () => ( - - {renderTypingIndicator()} - - {/* 附件按钮 */} - - - + const renderInput = () => { + const canSend = inputText.trim() || selectedImages.length > 0; - {/* 输入框 */} - - - - - {/* 发送按钮 */} - - {isSending ? ( - - ) : ( + return ( + + {renderTypingIndicator()} + {renderSelectedImages()} + + {/* 图片选择按钮 */} + 0 ? 'primary.500' : 'gray.500'} /> - )} - - - - ); + + + {/* 输入框 */} + + + + + {/* 发送按钮 */} + + {isSending ? ( + + ) : ( + + )} + + + + ); + }; return ( { const [tags, setTags] = useState([]); const [customTag, setCustomTag] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); + const [images, setImages] = useState([]); // { uri, uploading, uploaded, url, error } + const [isUploadingImage, setIsUploadingImage] = useState(false); const TITLE_MAX = 100; const CONTENT_MAX = 5000; const TAGS_MAX = 5; + const IMAGES_MAX = 9; // 添加标签 const handleAddTag = useCallback((tag) => { @@ -81,6 +88,138 @@ const CreatePost = ({ route, navigation }) => { setCustomTag(''); }, [customTag, handleAddTag]); + // 选择图片 + const handlePickImage = useCallback(async () => { + if (images.length >= IMAGES_MAX) { + Alert.alert('提示', `最多添加${IMAGES_MAX}张图片`); + return; + } + + // 请求权限 + const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!permissionResult.granted) { + Alert.alert('提示', '需要访问相册权限才能选择图片'); + return; + } + + try { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsEditing: false, + quality: 0.8, + allowsMultipleSelection: true, + selectionLimit: IMAGES_MAX - images.length, + }); + + if (!result.canceled && result.assets?.length > 0) { + // 添加新图片到状态 + const newImages = result.assets.map((asset) => ({ + uri: asset.uri, + mimeType: asset.mimeType, + uploading: false, + uploaded: false, + url: null, + error: null, + })); + setImages((prev) => [...prev, ...newImages].slice(0, IMAGES_MAX)); + } + } catch (error) { + console.warn('[CreatePost] 选择图片失败:', error); + Alert.alert('错误', '选择图片失败,请重试'); + } + }, [images.length]); + + // 上传单张图片 + const uploadSingleImage = useCallback(async (imageIndex) => { + const image = images[imageIndex]; + if (!image || image.uploaded || image.uploading) return; + + setImages((prev) => + prev.map((img, idx) => + idx === imageIndex ? { ...img, uploading: true, error: null } : img + ) + ); + + try { + const result = await uploadService.uploadImage(image); + setImages((prev) => + prev.map((img, idx) => + idx === imageIndex + ? { ...img, uploading: false, uploaded: true, url: result.url } + : img + ) + ); + } catch (error) { + console.warn('[CreatePost] 上传图片失败:', error); + setImages((prev) => + prev.map((img, idx) => + idx === imageIndex + ? { ...img, uploading: false, error: error.message || '上传失败' } + : img + ) + ); + } + }, [images]); + + // 删除图片 + const handleRemoveImage = useCallback((index) => { + setImages((prev) => prev.filter((_, idx) => idx !== index)); + }, []); + + // 重试上传 + const handleRetryUpload = useCallback((index) => { + setImages((prev) => + prev.map((img, idx) => + idx === index ? { ...img, error: null, uploading: false, uploaded: false } : img + ) + ); + // 延迟触发上传 + setTimeout(() => uploadSingleImage(index), 100); + }, [uploadSingleImage]); + + // 上传所有未上传的图片,返回上传结果 + const uploadAllImages = useCallback(async (imagesToUpload) => { + const results = []; + + for (let i = 0; i < imagesToUpload.length; i++) { + const image = imagesToUpload[i]; + if (image.uploaded && image.url) { + results.push({ success: true, url: image.url }); + continue; + } + + try { + setImages((prev) => + prev.map((img, idx) => + idx === i ? { ...img, uploading: true, error: null } : img + ) + ); + + const result = await uploadService.uploadImage(image); + + setImages((prev) => + prev.map((img, idx) => + idx === i ? { ...img, uploading: false, uploaded: true, url: result.url } : img + ) + ); + + results.push({ success: true, url: result.url }); + } catch (error) { + console.warn('[CreatePost] 上传图片失败:', error); + + setImages((prev) => + prev.map((img, idx) => + idx === i ? { ...img, uploading: false, error: error.message || '上传失败' } : img + ) + ); + + results.push({ success: false, error: error.message }); + } + } + + return results; + }, []); + // 验证表单 const validateForm = useCallback(() => { if (!title.trim()) { @@ -102,20 +241,15 @@ const CreatePost = ({ route, navigation }) => { return true; }, [title, content]); - // 提交帖子 - const handleSubmit = useCallback(async () => { - if (!validateForm() || isSubmitting) return; - - setIsSubmitting(true); - Keyboard.dismiss(); - + // 实际提交帖子 + const submitPost = useCallback(async (finalContent) => { try { await dispatch( createPost({ channelId: channel.id, data: { title: title.trim(), - content: content.trim(), + content: finalContent, tags, }, }) @@ -132,7 +266,64 @@ const CreatePost = ({ route, navigation }) => { } finally { setIsSubmitting(false); } - }, [dispatch, channel.id, title, content, tags, validateForm, isSubmitting, navigation]); + }, [dispatch, channel.id, title, tags, navigation]); + + // 提交帖子 + const handleSubmit = useCallback(async () => { + if (!validateForm() || isSubmitting) return; + + setIsSubmitting(true); + Keyboard.dismiss(); + + try { + let finalContent = content.trim(); + let uploadResults = []; + + // 如果有图片,先上传所有图片 + if (images.length > 0) { + setIsUploadingImage(true); + uploadResults = await uploadAllImages(images); + setIsUploadingImage(false); + + // 获取成功上传的图片 URL + const successUrls = uploadResults + .filter((r) => r.success && r.url) + .map((r) => r.url); + + if (successUrls.length > 0) { + // 将图片转换为 Markdown 格式追加到内容后面 + const imageMarkdown = successUrls + .map((url) => `![图片](${url})`) + .join('\n'); + finalContent = finalContent + '\n\n' + imageMarkdown; + } + + // 检查是否有上传失败的图片 + const failedCount = uploadResults.filter((r) => !r.success).length; + if (failedCount > 0) { + Alert.alert( + '部分图片上传失败', + `${failedCount}张图片上传失败,是否继续发布?`, + [ + { text: '取消', style: 'cancel', onPress: () => setIsSubmitting(false) }, + { + text: '继续发布', + onPress: async () => { + await submitPost(finalContent); + }, + }, + ] + ); + return; + } + } + + await submitPost(finalContent); + } catch (error) { + Alert.alert('发布失败', error.message || '请稍后重试'); + setIsSubmitting(false); + } + }, [content, images, validateForm, isSubmitting, uploadAllImages, submitPost]); // 设置导航 React.useEffect(() => { @@ -263,6 +454,77 @@ const CreatePost = ({ route, navigation }) => { /> + {/* 图片选择 */} + + + + 图片 + + + {images.length}/{IMAGES_MAX} + + + + {/* 图片预览网格 */} + + {images.map((image, index) => ( + + + {/* 上传状态遮罩 */} + {image.uploading && ( + + + + )} + {/* 上传成功标记 */} + {image.uploaded && ( + + + + )} + {/* 上传失败标记 */} + {image.error && ( + handleRetryUpload(index)} + > + + + + 点击重试 + + + + )} + {/* 删除按钮 */} + handleRemoveImage(index)} + hitSlop={10} + > + + + + ))} + + {/* 添加图片按钮 */} + {images.length < IMAGES_MAX && ( + + + + + 添加图片 + + + + )} + + + {/* 标签选择 */} @@ -391,6 +653,52 @@ const styles = StyleSheet.create({ content: { flexGrow: 1, }, + imagePreview: { + width: 80, + height: 80, + borderRadius: 8, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + }, + imageOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.6)', + borderRadius: 8, + justifyContent: 'center', + alignItems: 'center', + }, + imageSuccessBadge: { + position: 'absolute', + bottom: 4, + right: 4, + backgroundColor: '#22C55E', + borderRadius: 10, + width: 16, + height: 16, + justifyContent: 'center', + alignItems: 'center', + }, + imageDeleteBtn: { + position: 'absolute', + top: -6, + right: -6, + backgroundColor: '#0F172A', + borderRadius: 10, + }, + addImageBtn: { + width: 80, + height: 80, + borderRadius: 8, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.2)', + borderStyle: 'dashed', + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(255, 255, 255, 0.05)', + }, }); export default CreatePost; diff --git a/MeAgent/src/screens/StockDetail/StockDetailScreen.js b/MeAgent/src/screens/StockDetail/StockDetailScreen.js index bc467ccf..912de249 100644 --- a/MeAgent/src/screens/StockDetail/StockDetailScreen.js +++ b/MeAgent/src/screens/StockDetail/StockDetailScreen.js @@ -37,6 +37,7 @@ import { selectCurrentStock, selectMinuteData, selectMinutePrevClose, + selectIsTrading, selectKlineData, selectChartType, selectOrderBook, @@ -68,6 +69,7 @@ const StockDetailScreen = () => { const currentStock = useSelector(selectCurrentStock); const minuteData = useSelector(selectMinuteData); const minutePrevClose = useSelector(selectMinutePrevClose); + const isTrading = useSelector(selectIsTrading); const klineData = useSelector(selectKlineData); const chartType = useSelector(selectChartType); const orderBook = useSelector(selectOrderBook); @@ -185,6 +187,26 @@ const StockDetailScreen = () => { } }, [chartType, realtimeQuote, fallbackOrderBook, loadOrderBookFallback]); + // 交易时间内自动刷新分时数据和股票详情(每 30 秒) + useEffect(() => { + if (!isTrading || chartType !== 'minute' || !stockCode) { + return; + } + + console.log('[StockDetailScreen] 交易时间,启动自动刷新'); + + const refreshInterval = setInterval(() => { + console.log('[StockDetailScreen] 自动刷新数据'); + dispatch(fetchMinuteData(stockCode)); + dispatch(fetchStockDetail(stockCode)); + }, 30000); // 每 30 秒刷新一次 + + return () => { + console.log('[StockDetailScreen] 停止自动刷新'); + clearInterval(refreshInterval); + }; + }, [isTrading, chartType, stockCode, dispatch]); + // 切换图表类型 const handleChartTypeChange = useCallback((type) => { dispatch(setChartType(type)); diff --git a/MeAgent/src/screens/StockDetail/components/MinuteChart.js b/MeAgent/src/screens/StockDetail/components/MinuteChart.js index 11e6adea..24b42f09 100644 --- a/MeAgent/src/screens/StockDetail/components/MinuteChart.js +++ b/MeAgent/src/screens/StockDetail/components/MinuteChart.js @@ -47,8 +47,14 @@ const formatPrice = (price) => { return Number(price).toFixed(2); }; -// 格式化涨跌幅 -const formatPercent = (current, preClose) => { +// 格式化涨跌幅(优先使用已计算的涨跌幅) +const formatPercent = (current, preClose, changePct = null) => { + // 如果有已计算的涨跌幅,直接使用 + if (changePct !== null && changePct !== undefined) { + const sign = changePct >= 0 ? '+' : ''; + return `${sign}${Number(changePct).toFixed(2)}%`; + } + // 否则根据当前价和昨收价计算 if (!current || !preClose) return '0.00%'; const change = ((current - preClose) / preClose) * 100; const sign = change >= 0 ? '+' : ''; @@ -156,6 +162,7 @@ const MinuteChart = memo(({ data = [], preClose, loading }) => { time: d.time, volume: d.volume, avgPrice: d.avg_price || d.average_price, + changePct: d.change_pct, // 保存 API 返回的涨跌幅 }; }); @@ -302,7 +309,7 @@ const MinuteChart = memo(({ data = [], preClose, loading }) => { 涨跌 - {formatPercent(activePoint.price, chartData.preClose)} + {formatPercent(activePoint.price, chartData.preClose, activePoint.changePct)} {activePoint.avgPrice && ( diff --git a/MeAgent/src/screens/Watchlist/WatchlistStockItem.js b/MeAgent/src/screens/Watchlist/WatchlistStockItem.js index 66865345..ea2eb128 100644 --- a/MeAgent/src/screens/Watchlist/WatchlistStockItem.js +++ b/MeAgent/src/screens/Watchlist/WatchlistStockItem.js @@ -165,17 +165,21 @@ const WatchlistStockItem = memo(({ > {stock_name || stock_code} - - - {stock_code} - - - {/* 价格和涨跌幅 */} - + + {stock_code} + + {/* 现价和涨跌幅 */} + + {formatPrice(current_price)} + + {formatChange(change_percent)} diff --git a/MeAgent/src/services/communityService.js b/MeAgent/src/services/communityService.js index 480dfdd3..ab077a88 100644 --- a/MeAgent/src/services/communityService.js +++ b/MeAgent/src/services/communityService.js @@ -507,6 +507,50 @@ export const memberService = { }, }; +/** + * 上传服务 + */ +export const uploadService = { + /** + * 上传图片 + * @param {object} imageAsset - expo-image-picker 返回的图片资产 + * @returns {Promise} { url, filename, size, type } + */ + uploadImage: async (imageAsset) => { + try { + const formData = new FormData(); + + // 获取文件扩展名 + const uriParts = imageAsset.uri.split('.'); + const fileType = uriParts[uriParts.length - 1] || 'jpg'; + + // 构建文件对象 + formData.append('image', { + uri: imageAsset.uri, + name: `upload_${Date.now()}.${fileType}`, + type: imageAsset.mimeType || `image/${fileType}`, + }); + + const response = await fetch(`${API_BASE_URL}/api/community/upload/image`, { + method: 'POST', + credentials: 'include', + body: formData, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || '图片上传失败'); + } + + const result = await response.json(); + return result.data || result; + } catch (error) { + console.warn('[CommunityService] uploadImage 错误:', error); + throw error; + } + }, +}; + /** * 通知服务 */ @@ -632,6 +676,7 @@ export default { postService, memberService, notificationService, + uploadService, CHANNEL_TYPES, CHANNEL_CATEGORIES, }; diff --git a/MeAgent/src/services/stockService.js b/MeAgent/src/services/stockService.js index b5dabea4..3ffe5de7 100644 --- a/MeAgent/src/services/stockService.js +++ b/MeAgent/src/services/stockService.js @@ -253,7 +253,7 @@ export const stockDetailService = { * 获取分时数据(使用 latest-minute API 获取最新交易日数据) * 同时获取昨收价用于涨跌幅计算 * @param {string} code - 股票代码 - * @returns {Promise} { success: true, data: [...minuteData], prevClose: number } + * @returns {Promise} { success: true, data: [...minuteData], prevClose: number, isTrading: boolean } */ getMinuteData: async (code) => { try { @@ -261,24 +261,20 @@ export const stockDetailService = { console.log('[StockDetailService] 获取分时数据:', { code, pureCode }); - // 并行获取分时数据和昨收价 - // 注意:market/trade API 返回数据按日期升序排列,最新数据在数组末尾 - const [minuteResponse, tradeResponse] = await Promise.all([ - apiRequest(`/api/stock/${pureCode}/latest-minute`), - apiRequest(`/api/market/trade/${pureCode}?limit=60`), // 获取最近60天交易数据 - ]); - - // 从 market/trade 获取昨收价(取最新一天的 pre_close) - let prevClose = 0; - if (tradeResponse.success && tradeResponse.data && tradeResponse.data.length > 0) { - // 取数组最后一条(最新的交易数据),其 pre_close 就是昨收价 - const latestTrade = tradeResponse.data[tradeResponse.data.length - 1]; - prevClose = latestTrade.pre_close || 0; - console.log('[StockDetailService] 昨收价:', prevClose, '日期:', latestTrade.date); - } + // 直接从 latest-minute API 获取数据(已包含 prev_close) + const minuteResponse = await apiRequest(`/api/stock/${pureCode}/latest-minute`); if (minuteResponse && minuteResponse.data) { - console.log('[StockDetailService] 分时数据点数:', minuteResponse.data?.length || 0); + // 使用 API 返回的昨收价 + const prevClose = minuteResponse.prev_close || 0; + const isTrading = minuteResponse.is_trading || false; + + console.log('[StockDetailService] 分时数据:', { + dataLength: minuteResponse.data?.length || 0, + prevClose: prevClose, + isTrading: isTrading, + tradeDate: minuteResponse.trade_date, + }); // 转换分钟K线数据为分时格式,并计算累计均价 let totalAmount = 0; @@ -302,7 +298,8 @@ export const stockDetailService = { high: item.high || 0, low: item.low || 0, close: item.close || 0, - prev_close: prevClose, // 每条数据都带上昨收价 + prev_close: prevClose, + change_pct: item.change_pct || 0, // 使用 API 返回的涨跌幅 }; }); @@ -311,7 +308,8 @@ export const stockDetailService = { data: minuteData, stockName: minuteResponse.name, tradeDate: minuteResponse.trade_date, - prevClose: prevClose, // 返回昨收价 + prevClose: prevClose, + isTrading: isTrading, // 返回是否在交易中 }; } diff --git a/MeAgent/src/store/slices/stockSlice.js b/MeAgent/src/store/slices/stockSlice.js index 33f72ad4..800cea0e 100644 --- a/MeAgent/src/store/slices/stockSlice.js +++ b/MeAgent/src/store/slices/stockSlice.js @@ -14,6 +14,8 @@ const initialState = { minuteData: [], // 分时数据对应的昨收价 minutePrevClose: 0, + // 是否正在交易中(用于判断是否需要轮询刷新) + isTrading: false, // K线数据 klineData: { daily: [], @@ -75,6 +77,7 @@ export const fetchMinuteData = createAsyncThunk( success: response.success, dataLength: response.data?.length || 0, prevClose: response.prevClose, + isTrading: response.isTrading, firstItem: response.data?.[0], }); @@ -82,6 +85,7 @@ export const fetchMinuteData = createAsyncThunk( return { data: response.data || [], prevClose: response.prevClose || 0, + isTrading: response.isTrading || false, // 是否在交易中 }; } else { return rejectWithValue(response.error || '获取分时数据失败'); @@ -146,6 +150,7 @@ const stockSlice = createSlice({ state.currentStock = null; state.minuteData = []; state.minutePrevClose = 0; + state.isTrading = false; state.klineData = { daily: [], weekly: [], monthly: [] }; state.orderBook = { bidPrices: [], bidVolumes: [], askPrices: [], askVolumes: [] }; state.error = null; @@ -228,6 +233,7 @@ const stockSlice = createSlice({ state.loading.minute = false; state.minuteData = action.payload.data; state.minutePrevClose = action.payload.prevClose; + state.isTrading = action.payload.isTrading; // 更新交易状态 }) .addCase(fetchMinuteData.rejected, (state, action) => { state.loading.minute = false; @@ -262,6 +268,7 @@ export const { export const selectCurrentStock = (state) => state.stock.currentStock; export const selectMinuteData = (state) => state.stock.minuteData; export const selectMinutePrevClose = (state) => state.stock.minutePrevClose; +export const selectIsTrading = (state) => state.stock.isTrading; export const selectKlineData = (state) => state.stock.klineData; export const selectChartType = (state) => state.stock.chartType; export const selectOrderBook = (state) => state.stock.orderBook; diff --git a/__pycache__/app.cpython-314.pyc b/__pycache__/app.cpython-314.pyc new file mode 100644 index 00000000..461ac8ff Binary files /dev/null and b/__pycache__/app.cpython-314.pyc differ diff --git a/app.py b/app.py index 7511a7d3..d979bfe6 100755 --- a/app.py +++ b/app.py @@ -6058,6 +6058,10 @@ def get_watchlist_realtime(): if not full_codes: return jsonify({'success': True, 'data': []}) + # 确保交易日数据已加载 + if not trading_days: + load_trading_days() + # 使用批量查询获取最新行情(单次查询) client = get_clickhouse_client() today = datetime.now().date() @@ -6158,6 +6162,40 @@ def get_watchlist_realtime(): 'update_time': latest['timestamp'].strftime('%H:%M:%S') } + # 如果分钟数据为空,从 ea_trade 获取最新日线数据作为 fallback + if not quotes_data: + base_codes = [code.split('.')[0] for code in full_codes] + with engine.connect() as conn: + placeholders = ','.join([f':code{i}' for i in range(len(base_codes))]) + params = {f'code{i}': code for i, code in enumerate(base_codes)} + + ea_result = conn.execute(text(f""" + SELECT t.SECCODE, t.SECNAME, t.F002N as prev_close, t.F003N as open_price, + t.F005N as high, t.F006N as low, t.F007N as close_price, + t.F010N as change_pct, t.F004N as volume, t.F011N as amount + FROM ea_trade t + WHERE t.SECCODE IN ({placeholders}) + AND t.TRADEDATE = (SELECT MAX(TRADEDATE) FROM ea_trade WHERE SECCODE = t.SECCODE) + """), params).fetchall() + + for row in ea_result: + row_dict = row._mapping + code6 = row_dict['SECCODE'] + close_price = float(row_dict['close_price'] or 0) + prev_close = float(row_dict['prev_close'] or close_price) + + quotes_data[code6] = { + 'price': close_price, + 'prev_close': prev_close, + 'change': close_price - prev_close, + 'change_percent': float(row_dict['change_pct'] or 0), + 'high': float(row_dict['high'] or 0), + 'low': float(row_dict['low'] or 0), + 'volume': int(row_dict['volume'] or 0), + 'amount': float(row_dict['amount'] or 0), + 'update_time': '收盘' + } + response_data = [] for item in watchlist: code6, _ = _normalize_stock_input(item.stock_code) @@ -9621,7 +9659,13 @@ def get_batch_kline_data(): @app.route('/api/stock//latest-minute', methods=['GET']) def get_latest_minute_data(stock_code): - """获取最新交易日的分钟频数据""" + """获取最新交易日的分钟频数据 + + 改进: + - 如果是当天交易日且在交易时间内,只返回到当前时间的数据 + - 返回昨收价 prev_close,供前端计算涨跌幅 + - 返回 is_trading 标志指示当前是否在交易中 + """ client = get_clickhouse_client() # 确保股票代码包含后缀 @@ -9633,16 +9677,22 @@ def get_latest_minute_data(stock_code): else: stock_code = f"{stock_code}.SZ" # 深圳 - # 获取股票名称 + base_code = stock_code.split('.')[0] + + # 获取股票名称和昨收价 + prev_close = None + stock_name = 'Unknown' with engine.connect() as conn: result = conn.execute(text( "SELECT SECNAME FROM ea_stocklist WHERE SECCODE = :code" - ), {"code": stock_code.split('.')[0]}).fetchone() + ), {"code": base_code}).fetchone() stock_name = result[0] if result else 'Unknown' # 查找最近30天内有数据的最新交易日 target_date = None - current_date = datetime.now().date() + now = beijing_now() + current_date = now.date() + current_time = now.time() for i in range(30): check_date = current_date - timedelta(days=i) @@ -9673,10 +9723,68 @@ def get_latest_minute_data(stock_code): 'name': stock_name, 'data': [], 'trade_date': current_date.strftime('%Y-%m-%d'), - 'type': 'minute' + 'type': 'minute', + 'prev_close': None, + 'is_trading': False }) - # 获取目标日期的完整交易时段数据 + # 获取昨收价(从 ea_trade 表) + target_date_str = target_date.strftime('%Y%m%d') + with engine.connect() as conn: + prev_close_result = conn.execute(text(""" + SELECT F007N as prev_close FROM ea_trade + WHERE SECCODE = :code AND TRADEDATE = :trade_date AND F007N > 0 + """), {'code': base_code, 'trade_date': target_date_str}).fetchone() + if prev_close_result and prev_close_result[0]: + prev_close = float(prev_close_result[0]) + + # 判断是否在交易时间内 + is_today = target_date == current_date + # 交易时间:9:30-11:30 和 13:00-15:00 + morning_start = dt_time(9, 30) + morning_end = dt_time(11, 30) + afternoon_start = dt_time(13, 0) + afternoon_end = dt_time(15, 0) + + is_trading = False + end_time = dt_time(15, 0) # 默认查询到收盘 + + if is_today and target_date in trading_days_set: + # 当天是交易日,根据当前时间判断 + if morning_start <= current_time <= morning_end: + # 上午交易时段 + is_trading = True + end_time = current_time + elif afternoon_start <= current_time <= afternoon_end: + # 下午交易时段 + is_trading = True + end_time = current_time + elif morning_end < current_time < afternoon_start: + # 午休时间,返回上午数据 + is_trading = True + end_time = morning_end + elif current_time < morning_start: + # 开盘前,返回空或前一日数据 + # 查找前一个交易日 + for i in range(1, 30): + prev_day = current_date - timedelta(days=i) + if prev_day in trading_days_set: + target_date = prev_day + is_trading = False + end_time = dt_time(15, 0) + # 重新获取昨收价 + target_date_str = target_date.strftime('%Y%m%d') + with engine.connect() as conn: + prev_close_result = conn.execute(text(""" + SELECT F007N as prev_close FROM ea_trade + WHERE SECCODE = :code AND TRADEDATE = :trade_date AND F007N > 0 + """), {'code': base_code, 'trade_date': target_date_str}).fetchone() + if prev_close_result and prev_close_result[0]: + prev_close = float(prev_close_result[0]) + break + # else: 收盘后,is_trading = False, end_time = 15:00 + + # 获取分时数据 data = client.execute(""" SELECT timestamp, @@ -9694,19 +9802,30 @@ def get_latest_minute_data(stock_code): """, { 'code': stock_code, 'start': datetime.combine(target_date, dt_time(9, 30)), - 'end': datetime.combine(target_date, dt_time(15, 0)) + 'end': datetime.combine(target_date, end_time) }) - kline_data = [{ - 'time': row[0].strftime('%H:%M'), - 'open': float(row[1]), - 'high': float(row[2]), - 'low': float(row[3]), - 'close': float(row[4]), - 'volume': float(row[5]), - 'amount': float(row[6]), - 'change_pct': float(row[7]) if row[7] else 0 - } for row in data] + # 构建返回数据,使用昨收价重新计算涨跌幅 + kline_data = [] + for row in data: + close_price = float(row[4]) + # 使用昨收价计算涨跌幅(更准确) + if prev_close and prev_close > 0: + calculated_change_pct = ((close_price - prev_close) / prev_close) * 100 + else: + # 使用数据库中的涨跌幅 + calculated_change_pct = float(row[7]) if row[7] else 0 + + kline_data.append({ + 'time': row[0].strftime('%H:%M'), + 'open': float(row[1]), + 'high': float(row[2]), + 'low': float(row[3]), + 'close': close_price, + 'volume': float(row[5]), + 'amount': float(row[6]), + 'change_pct': round(calculated_change_pct, 2) + }) return jsonify({ 'code': stock_code, @@ -9714,7 +9833,9 @@ def get_latest_minute_data(stock_code): 'data': kline_data, 'trade_date': target_date.strftime('%Y-%m-%d'), 'type': 'minute', - 'is_latest': True + 'is_latest': True, + 'prev_close': prev_close, + 'is_trading': is_trading })