更新ios

This commit is contained in:
2026-01-19 13:29:45 +08:00
parent ce040e6ae7
commit 63828ef602
13 changed files with 814 additions and 121 deletions

View File

@@ -5,6 +5,9 @@ PODS:
- ExpoModulesCore - ExpoModulesCore
- EXConstants (16.0.2): - EXConstants (16.0.2):
- ExpoModulesCore - ExpoModulesCore
- EXImageLoader (4.7.0):
- ExpoModulesCore
- React-Core
- EXNotifications (0.28.19): - EXNotifications (0.28.19):
- ExpoModulesCore - ExpoModulesCore
- Expo (51.0.39): - Expo (51.0.39):
@@ -21,6 +24,8 @@ PODS:
- ExpoModulesCore - ExpoModulesCore
- ExpoFont (12.0.10): - ExpoFont (12.0.10):
- ExpoModulesCore - ExpoModulesCore
- ExpoImagePicker (15.1.0):
- ExpoModulesCore
- ExpoKeepAwake (13.0.2): - ExpoKeepAwake (13.0.2):
- ExpoModulesCore - ExpoModulesCore
- ExpoLinearGradient (13.0.2): - ExpoLinearGradient (13.0.2):
@@ -78,6 +83,7 @@ PODS:
- hermes-engine (0.74.5): - hermes-engine (0.74.5):
- hermes-engine/Pre-built (= 0.74.5) - hermes-engine/Pre-built (= 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): - RCT-Folly (2024.01.01.00):
- boost - boost
- DoubleConversion - DoubleConversion
@@ -1283,6 +1289,9 @@ PODS:
- ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- Yoga - Yoga
- RNKLineView (1.0.0):
- lottie-ios (~> 4.5.0)
- React
- RNReanimated (3.10.1): - RNReanimated (3.10.1):
- DoubleConversion - DoubleConversion
- glog - glog
@@ -1336,6 +1345,7 @@ DEPENDENCIES:
- DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) - DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`)
- EXApplication (from `../node_modules/expo-application/ios`) - EXApplication (from `../node_modules/expo-application/ios`)
- EXConstants (from `../node_modules/expo-constants/ios`) - EXConstants (from `../node_modules/expo-constants/ios`)
- EXImageLoader (from `../node_modules/expo-image-loader/ios`)
- EXNotifications (from `../node_modules/expo-notifications/ios`) - EXNotifications (from `../node_modules/expo-notifications/ios`)
- Expo (from `../node_modules/expo`) - Expo (from `../node_modules/expo`)
- ExpoAsset (from `../node_modules/expo-asset/ios`) - ExpoAsset (from `../node_modules/expo-asset/ios`)
@@ -1344,6 +1354,7 @@ DEPENDENCIES:
- ExpoDevice (from `../node_modules/expo-device/ios`) - ExpoDevice (from `../node_modules/expo-device/ios`)
- ExpoFileSystem (from `../node_modules/expo-file-system/ios`) - ExpoFileSystem (from `../node_modules/expo-file-system/ios`)
- ExpoFont (from `../node_modules/expo-font/ios`) - ExpoFont (from `../node_modules/expo-font/ios`)
- ExpoImagePicker (from `../node_modules/expo-image-picker/ios`)
- ExpoKeepAwake (from `../node_modules/expo-keep-awake/ios`) - ExpoKeepAwake (from `../node_modules/expo-keep-awake/ios`)
- ExpoLinearGradient (from `../node_modules/expo-linear-gradient/ios`) - ExpoLinearGradient (from `../node_modules/expo-linear-gradient/ios`)
- ExpoModulesCore (from `../node_modules/expo-modules-core`) - ExpoModulesCore (from `../node_modules/expo-modules-core`)
@@ -1407,6 +1418,7 @@ DEPENDENCIES:
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
- "RNCMaskedView (from `../node_modules/@react-native-masked-view/masked-view`)" - "RNCMaskedView (from `../node_modules/@react-native-masked-view/masked-view`)"
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`) - RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
- RNKLineView (from `../node_modules/react-native-kline-view`)
- RNReanimated (from `../node_modules/react-native-reanimated`) - RNReanimated (from `../node_modules/react-native-reanimated`)
- RNScreens (from `../node_modules/react-native-screens`) - RNScreens (from `../node_modules/react-native-screens`)
- RNSVG (from `../node_modules/react-native-svg`) - RNSVG (from `../node_modules/react-native-svg`)
@@ -1414,6 +1426,7 @@ DEPENDENCIES:
SPEC REPOS: SPEC REPOS:
trunk: trunk:
- lottie-ios
- SocketRocket - SocketRocket
EXTERNAL SOURCES: EXTERNAL SOURCES:
@@ -1425,6 +1438,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/expo-application/ios" :path: "../node_modules/expo-application/ios"
EXConstants: EXConstants:
:path: "../node_modules/expo-constants/ios" :path: "../node_modules/expo-constants/ios"
EXImageLoader:
:path: "../node_modules/expo-image-loader/ios"
EXNotifications: EXNotifications:
:path: "../node_modules/expo-notifications/ios" :path: "../node_modules/expo-notifications/ios"
Expo: Expo:
@@ -1441,6 +1456,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/expo-file-system/ios" :path: "../node_modules/expo-file-system/ios"
ExpoFont: ExpoFont:
:path: "../node_modules/expo-font/ios" :path: "../node_modules/expo-font/ios"
ExpoImagePicker:
:path: "../node_modules/expo-image-picker/ios"
ExpoKeepAwake: ExpoKeepAwake:
:path: "../node_modules/expo-keep-awake/ios" :path: "../node_modules/expo-keep-awake/ios"
ExpoLinearGradient: ExpoLinearGradient:
@@ -1564,6 +1581,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/@react-native-masked-view/masked-view" :path: "../node_modules/@react-native-masked-view/masked-view"
RNGestureHandler: RNGestureHandler:
:path: "../node_modules/react-native-gesture-handler" :path: "../node_modules/react-native-gesture-handler"
RNKLineView:
:path: "../node_modules/react-native-kline-view"
RNReanimated: RNReanimated:
:path: "../node_modules/react-native-reanimated" :path: "../node_modules/react-native-reanimated"
RNScreens: RNScreens:
@@ -1578,6 +1597,7 @@ SPEC CHECKSUMS:
DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5 DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5
EXApplication: ec862905fdab3a15bf6bd8ca1a99df7fc02d7762 EXApplication: ec862905fdab3a15bf6bd8ca1a99df7fc02d7762
EXConstants: 89d35611505a8ce02550e64e43cd05565da35f9a EXConstants: 89d35611505a8ce02550e64e43cd05565da35f9a
EXImageLoader: 1fe96c70cdc78bedc985ec4b1fab5dd8e67dc38b
EXNotifications: 6ce128c0d3d3d161cd68bfd07d593db40e140396 EXNotifications: 6ce128c0d3d3d161cd68bfd07d593db40e140396
Expo: ed0a748eb6be0efd2c3df7f6de3f3158a14464c9 Expo: ed0a748eb6be0efd2c3df7f6de3f3158a14464c9
ExpoAsset: 286fee7ba711ce66bf20b315e68106b13b8629fc ExpoAsset: 286fee7ba711ce66bf20b315e68106b13b8629fc
@@ -1586,6 +1606,7 @@ SPEC CHECKSUMS:
ExpoDevice: 84b3ed79df1234c17edfbf335f6ecf3c636f74de ExpoDevice: 84b3ed79df1234c17edfbf335f6ecf3c636f74de
ExpoFileSystem: 2988caaf68b7cb706e36d382829d99811d9d76a5 ExpoFileSystem: 2988caaf68b7cb706e36d382829d99811d9d76a5
ExpoFont: 38dddf823e32740c2a9f37c926a33aeca736b5c4 ExpoFont: 38dddf823e32740c2a9f37c926a33aeca736b5c4
ExpoImagePicker: 7038c1740853c170002c491de63f9f9780c8139b
ExpoKeepAwake: dd02e65d49f1cfd9194640028ae2857e536eb1c9 ExpoKeepAwake: dd02e65d49f1cfd9194640028ae2857e536eb1c9
ExpoLinearGradient: 4c44b3803b441724874b232e6520b51ca6a50db1 ExpoLinearGradient: 4c44b3803b441724874b232e6520b51ca6a50db1
ExpoModulesCore: 9ac73e2f60e0ea1d30137ca96cfc8c2aa34ef2b2 ExpoModulesCore: 9ac73e2f60e0ea1d30137ca96cfc8c2aa34ef2b2
@@ -1595,6 +1616,7 @@ SPEC CHECKSUMS:
fmt: 4c2741a687cc09f0634a2e2c72a838b99f1ff120 fmt: 4c2741a687cc09f0634a2e2c72a838b99f1ff120
glog: fdfdfe5479092de0c4bdbebedd9056951f092c4f glog: fdfdfe5479092de0c4bdbebedd9056951f092c4f
hermes-engine: 8c1577f3fdb849cbe7729c2e7b5abc4b845e88f8 hermes-engine: 8c1577f3fdb849cbe7729c2e7b5abc4b845e88f8
lottie-ios: 96784afc26ea031d3e2b6cae342a4b8915072489
RCT-Folly: 5dc73daec3476616d19e8a53f0156176f7b55461 RCT-Folly: 5dc73daec3476616d19e8a53f0156176f7b55461
RCTDeprecation: 3afceddffa65aee666dafd6f0116f1d975db1584 RCTDeprecation: 3afceddffa65aee666dafd6f0116f1d975db1584
RCTRequired: ec1239bc9d8bf63e10fb92bd8b26171a9258e0c1 RCTRequired: ec1239bc9d8bf63e10fb92bd8b26171a9258e0c1
@@ -1647,6 +1669,7 @@ SPEC CHECKSUMS:
RNCAsyncStorage: aa75595c1aefa18f868452091fa0c411a516ce11 RNCAsyncStorage: aa75595c1aefa18f868452091fa0c411a516ce11
RNCMaskedView: de80352547bd4f0d607bf6bab363d826822bd126 RNCMaskedView: de80352547bd4f0d607bf6bab363d826822bd126
RNGestureHandler: 326e35460fb6c8c64a435d5d739bea90d7ed4e49 RNGestureHandler: 326e35460fb6c8c64a435d5d739bea90d7ed4e49
RNKLineView: bb63410106d30c7e3a7967c638ef682f9415b2a2
RNReanimated: def444e044c354f38bb0a5926a8583ba19d944c1 RNReanimated: def444e044c354f38bb0a5926a8583ba19d944c1
RNScreens: a2d8a2555b4653d7a19706eb172f855657ac30d7 RNScreens: a2d8a2555b4653d7a19706eb172f855657ac30d7
RNSVG: 0e7deccab0678200815104223aadd5ca734dd41d RNSVG: 0e7deccab0678200815104223aadd5ca734dd41d

View File

@@ -280,6 +280,7 @@
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/RCTI18nStrings.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/RCTI18nStrings.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/lottie-ios/LottiePrivacyInfo.bundle",
); );
name = "[CP] Copy Pods Resources"; name = "[CP] Copy Pods Resources";
outputPaths = ( outputPaths = (
@@ -291,6 +292,7 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle", "${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}/RNCAsyncStorage_resources.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCTI18nStrings.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCTI18nStrings.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/LottiePrivacyInfo.bundle",
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;

View File

@@ -29,6 +29,7 @@
"expo-constants": "~16.0.2", "expo-constants": "~16.0.2",
"expo-device": "~6.0.2", "expo-device": "~6.0.2",
"expo-font": "~12.0.10", "expo-font": "~12.0.10",
"expo-image-picker": "~15.1.0",
"expo-linear-gradient": "~13.0.2", "expo-linear-gradient": "~13.0.2",
"expo-modules-core": "~1.12.24", "expo-modules-core": "~1.12.24",
"expo-notifications": "~0.28.19", "expo-notifications": "~0.28.19",

View File

@@ -13,6 +13,8 @@ import {
TextInput, TextInput,
Image, Image,
Dimensions, Dimensions,
Alert,
ActivityIndicator,
} from 'react-native'; } from 'react-native';
import { import {
Box, Box,
@@ -28,6 +30,7 @@ import {
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import * as ImagePicker from 'expo-image-picker';
import { import {
fetchMessages, fetchMessages,
@@ -35,6 +38,7 @@ import {
addMessage, addMessage,
} from '../../store/slices/communitySlice'; } from '../../store/slices/communitySlice';
import { useCommunitySocket } from '../../hooks/useCommunitySocket'; import { useCommunitySocket } from '../../hooks/useCommunitySocket';
import { uploadService } from '../../services/communityService';
// 消息分组:按日期 // 消息分组:按日期
const groupMessagesByDate = (messages) => { const groupMessagesByDate = (messages) => {
@@ -170,6 +174,8 @@ const ChannelDetail = ({ route, navigation }) => {
const [inputText, setInputText] = useState(''); const [inputText, setInputText] = useState('');
const [isSending, setIsSending] = useState(false); const [isSending, setIsSending] = useState(false);
const [selectedImages, setSelectedImages] = useState([]); // { uri, mimeType }
const [isUploadingImages, setIsUploadingImages] = useState(false);
// WebSocket 连接 // WebSocket 连接
const { const {
@@ -211,6 +217,47 @@ const ChannelDetail = ({ route, navigation }) => {
} }
}, [isConnected, channel?.id, startTyping]); }, [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(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
@@ -263,8 +310,12 @@ const ChannelDetail = ({ route, navigation }) => {
// 发送消息 // 发送消息
const handleSend = useCallback(async () => { const handleSend = useCallback(async () => {
const content = inputText.trim(); const textContent = inputText.trim();
if (!content || isSending || !channel?.id) return; const hasImages = selectedImages.length > 0;
// 必须有文字或图片才能发送
if (!textContent && !hasImages) return;
if (isSending || !channel?.id) return;
setIsSending(true); setIsSending(true);
Keyboard.dismiss(); Keyboard.dismiss();
@@ -273,24 +324,63 @@ const ChannelDetail = ({ route, navigation }) => {
stopTyping(channel.id); stopTyping(channel.id);
try { 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( await dispatch(
sendMessage({ sendMessage({
channelId: channel.id, channelId: channel.id,
data: { content }, data: { content: finalContent },
}) })
).unwrap(); ).unwrap();
setInputText(''); setInputText('');
setSelectedImages([]);
// 滚动到底部 // 滚动到底部
setTimeout(() => { setTimeout(() => {
flatListRef.current?.scrollToEnd({ animated: true }); flatListRef.current?.scrollToEnd({ animated: true });
}, 100); }, 100);
} catch (error) { } catch (error) {
console.error('发送消息失败:', error); console.error('发送消息失败:', error);
Alert.alert('发送失败', '消息发送失败,请重试');
} finally { } finally {
setIsSending(false); setIsSending(false);
setIsUploadingImages(false);
} }
}, [dispatch, channel?.id, inputText, isSending, stopTyping]); }, [dispatch, channel?.id, inputText, selectedImages, isSending, stopTyping]);
// 渲染日期分隔符 // 渲染日期分隔符
const renderDateDivider = (date) => ( const renderDateDivider = (date) => (
@@ -466,73 +556,114 @@ const ChannelDetail = ({ route, navigation }) => {
); );
}; };
// 渲染选中的图片预览
const renderSelectedImages = () => {
if (selectedImages.length === 0) return null;
return (
<Box px={4} py={2} bg="rgba(255, 255, 255, 0.02)">
<HStack space={2} flexWrap="wrap">
{selectedImages.map((image, index) => (
<Box key={index} position="relative" mb={2}>
<Image
source={{ uri: image.uri }}
style={styles.selectedImagePreview}
resizeMode="cover"
/>
{isUploadingImages && (
<Box style={styles.imageUploadOverlay}>
<ActivityIndicator color="#FFFFFF" size="small" />
</Box>
)}
<Pressable
style={styles.imageRemoveBtn}
onPress={() => handleRemoveImage(index)}
hitSlop={10}
>
<Icon as={Ionicons} name="close-circle" size="sm" color="red.500" />
</Pressable>
</Box>
))}
</HStack>
</Box>
);
};
// 渲染输入框 // 渲染输入框
const renderInput = () => ( const renderInput = () => {
<Box const canSend = inputText.trim() || selectedImages.length > 0;
px={4}
py={3}
bg="#0F172A"
borderTopWidth={1}
borderTopColor="rgba(255, 255, 255, 0.1)"
pb={insets.bottom + 12}
>
{renderTypingIndicator()}
<HStack space={2} alignItems="flex-end">
{/* 附件按钮 */}
<Pressable p={2}>
<Icon as={Ionicons} name="add-circle-outline" size="md" color="gray.500" />
</Pressable>
{/* 输入框 */} return (
<Box <Box
flex={1} bg="#0F172A"
bg="rgba(255, 255, 255, 0.05)" borderTopWidth={1}
borderWidth={1} borderTopColor="rgba(255, 255, 255, 0.1)"
borderColor="rgba(255, 255, 255, 0.1)" pb={insets.bottom + 12}
rounded="xl" >
px={3} {renderTypingIndicator()}
py={Platform.OS === 'ios' ? 2 : 0} {renderSelectedImages()}
> <HStack space={2} alignItems="flex-end" px={4} py={3}>
<TextInput {/* 图片选择按钮 */}
placeholder={`发送消息到 #${channel?.name || '频道'}`} <Pressable p={2} onPress={handlePickImage} disabled={isSending}>
value={inputText}
onChangeText={handleTextChange}
style={styles.textInput}
placeholderTextColor="#6B7280"
multiline
maxLength={2000}
textAlignVertical="center"
returnKeyType="default"
blurOnSubmit={false}
autoCapitalize="none"
autoCorrect={false}
editable={true}
keyboardType="default"
contextMenuHidden={false}
/>
</Box>
{/* 发送按钮 */}
<Pressable
onPress={handleSend}
p={2}
opacity={inputText.trim() ? 1 : 0.5}
disabled={!inputText.trim() || isSending}
>
{isSending ? (
<Spinner size="sm" color="primary.500" />
) : (
<Icon <Icon
as={Ionicons} as={Ionicons}
name="send" name="image-outline"
size="md" size="md"
color={inputText.trim() ? 'primary.500' : 'gray.500'} color={selectedImages.length > 0 ? 'primary.500' : 'gray.500'}
/> />
)} </Pressable>
</Pressable>
</HStack> {/* 输入框 */}
</Box> <Box
); flex={1}
bg="rgba(255, 255, 255, 0.05)"
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.1)"
rounded="xl"
px={3}
py={Platform.OS === 'ios' ? 2 : 0}
>
<TextInput
placeholder={`发送消息到 #${channel?.name || '频道'}`}
value={inputText}
onChangeText={handleTextChange}
style={styles.textInput}
placeholderTextColor="#6B7280"
multiline
maxLength={2000}
textAlignVertical="center"
returnKeyType="default"
blurOnSubmit={false}
autoCapitalize="none"
autoCorrect={false}
editable={!isSending}
keyboardType="default"
contextMenuHidden={false}
/>
</Box>
{/* 发送按钮 */}
<Pressable
onPress={handleSend}
p={2}
opacity={canSend ? 1 : 0.5}
disabled={!canSend || isSending}
>
{isSending ? (
<Spinner size="sm" color="primary.500" />
) : (
<Icon
as={Ionicons}
name="send"
size="md"
color={canSend ? 'primary.500' : 'gray.500'}
/>
)}
</Pressable>
</HStack>
</Box>
);
};
return ( return (
<KeyboardAvoidingView <KeyboardAvoidingView
@@ -580,6 +711,30 @@ const styles = StyleSheet.create({
paddingVertical: Platform.OS === 'ios' ? 8 : 10, paddingVertical: Platform.OS === 'ios' ? 8 : 10,
paddingHorizontal: 0, paddingHorizontal: 0,
}, },
selectedImagePreview: {
width: 60,
height: 60,
borderRadius: 8,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
},
imageUploadOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
},
imageRemoveBtn: {
position: 'absolute',
top: -6,
right: -6,
backgroundColor: '#0F172A',
borderRadius: 10,
},
}); });
export default ChannelDetail; export default ChannelDetail;

View File

@@ -11,6 +11,8 @@ import {
StyleSheet, StyleSheet,
Keyboard, Keyboard,
Alert, Alert,
Image,
ActivityIndicator,
} from 'react-native'; } from 'react-native';
import { import {
Box, Box,
@@ -27,8 +29,10 @@ import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import * as ImagePicker from 'expo-image-picker';
import { createPost } from '../../store/slices/communitySlice'; import { createPost } from '../../store/slices/communitySlice';
import { uploadService } from '../../services/communityService';
import { gradients } from '../../theme'; import { gradients } from '../../theme';
// 预设标签 // 预设标签
@@ -47,10 +51,13 @@ const CreatePost = ({ route, navigation }) => {
const [tags, setTags] = useState([]); const [tags, setTags] = useState([]);
const [customTag, setCustomTag] = useState(''); const [customTag, setCustomTag] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [images, setImages] = useState([]); // { uri, uploading, uploaded, url, error }
const [isUploadingImage, setIsUploadingImage] = useState(false);
const TITLE_MAX = 100; const TITLE_MAX = 100;
const CONTENT_MAX = 5000; const CONTENT_MAX = 5000;
const TAGS_MAX = 5; const TAGS_MAX = 5;
const IMAGES_MAX = 9;
// 添加标签 // 添加标签
const handleAddTag = useCallback((tag) => { const handleAddTag = useCallback((tag) => {
@@ -81,6 +88,138 @@ const CreatePost = ({ route, navigation }) => {
setCustomTag(''); setCustomTag('');
}, [customTag, handleAddTag]); }, [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(() => { const validateForm = useCallback(() => {
if (!title.trim()) { if (!title.trim()) {
@@ -102,20 +241,15 @@ const CreatePost = ({ route, navigation }) => {
return true; return true;
}, [title, content]); }, [title, content]);
// 提交帖子 // 实际提交帖子
const handleSubmit = useCallback(async () => { const submitPost = useCallback(async (finalContent) => {
if (!validateForm() || isSubmitting) return;
setIsSubmitting(true);
Keyboard.dismiss();
try { try {
await dispatch( await dispatch(
createPost({ createPost({
channelId: channel.id, channelId: channel.id,
data: { data: {
title: title.trim(), title: title.trim(),
content: content.trim(), content: finalContent,
tags, tags,
}, },
}) })
@@ -132,7 +266,64 @@ const CreatePost = ({ route, navigation }) => {
} finally { } finally {
setIsSubmitting(false); 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(() => { React.useEffect(() => {
@@ -263,6 +454,77 @@ const CreatePost = ({ route, navigation }) => {
/> />
</VStack> </VStack>
{/* 图片选择 */}
<VStack mx={4} mt={4}>
<HStack alignItems="center" justifyContent="space-between" mb={2}>
<Text fontSize="sm" fontWeight="bold" color="gray.400">
图片
</Text>
<Text fontSize="xs" color="gray.500">
{images.length}/{IMAGES_MAX}
</Text>
</HStack>
{/* 图片预览网格 */}
<HStack flexWrap="wrap" space={2}>
{images.map((image, index) => (
<Box key={index} mb={2} position="relative">
<Image
source={{ uri: image.uri }}
style={styles.imagePreview}
resizeMode="cover"
/>
{/* 上传状态遮罩 */}
{image.uploading && (
<Box style={styles.imageOverlay}>
<ActivityIndicator color="#FFFFFF" />
</Box>
)}
{/* 上传成功标记 */}
{image.uploaded && (
<Box style={styles.imageSuccessBadge}>
<Icon as={Ionicons} name="checkmark" size="xs" color="white" />
</Box>
)}
{/* 上传失败标记 */}
{image.error && (
<Pressable
style={styles.imageOverlay}
onPress={() => handleRetryUpload(index)}
>
<VStack alignItems="center">
<Icon as={Ionicons} name="refresh" size="sm" color="white" />
<Text fontSize="2xs" color="white" mt={1}>
点击重试
</Text>
</VStack>
</Pressable>
)}
{/* 删除按钮 */}
<Pressable
style={styles.imageDeleteBtn}
onPress={() => handleRemoveImage(index)}
hitSlop={10}
>
<Icon as={Ionicons} name="close-circle" size="sm" color="red.500" />
</Pressable>
</Box>
))}
{/* 添加图片按钮 */}
{images.length < IMAGES_MAX && (
<Pressable onPress={handlePickImage}>
<Box style={styles.addImageBtn}>
<Icon as={Ionicons} name="camera" size="md" color="gray.500" />
<Text fontSize="xs" color="gray.500" mt={1}>
添加图片
</Text>
</Box>
</Pressable>
)}
</HStack>
</VStack>
{/* 标签选择 */} {/* 标签选择 */}
<VStack mx={4} mt={4}> <VStack mx={4} mt={4}>
<HStack alignItems="center" justifyContent="space-between" mb={2}> <HStack alignItems="center" justifyContent="space-between" mb={2}>
@@ -391,6 +653,52 @@ const styles = StyleSheet.create({
content: { content: {
flexGrow: 1, 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; export default CreatePost;

View File

@@ -37,6 +37,7 @@ import {
selectCurrentStock, selectCurrentStock,
selectMinuteData, selectMinuteData,
selectMinutePrevClose, selectMinutePrevClose,
selectIsTrading,
selectKlineData, selectKlineData,
selectChartType, selectChartType,
selectOrderBook, selectOrderBook,
@@ -68,6 +69,7 @@ const StockDetailScreen = () => {
const currentStock = useSelector(selectCurrentStock); const currentStock = useSelector(selectCurrentStock);
const minuteData = useSelector(selectMinuteData); const minuteData = useSelector(selectMinuteData);
const minutePrevClose = useSelector(selectMinutePrevClose); const minutePrevClose = useSelector(selectMinutePrevClose);
const isTrading = useSelector(selectIsTrading);
const klineData = useSelector(selectKlineData); const klineData = useSelector(selectKlineData);
const chartType = useSelector(selectChartType); const chartType = useSelector(selectChartType);
const orderBook = useSelector(selectOrderBook); const orderBook = useSelector(selectOrderBook);
@@ -185,6 +187,26 @@ const StockDetailScreen = () => {
} }
}, [chartType, realtimeQuote, fallbackOrderBook, loadOrderBookFallback]); }, [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) => { const handleChartTypeChange = useCallback((type) => {
dispatch(setChartType(type)); dispatch(setChartType(type));

View File

@@ -47,8 +47,14 @@ const formatPrice = (price) => {
return Number(price).toFixed(2); 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%'; if (!current || !preClose) return '0.00%';
const change = ((current - preClose) / preClose) * 100; const change = ((current - preClose) / preClose) * 100;
const sign = change >= 0 ? '+' : ''; const sign = change >= 0 ? '+' : '';
@@ -156,6 +162,7 @@ const MinuteChart = memo(({ data = [], preClose, loading }) => {
time: d.time, time: d.time,
volume: d.volume, volume: d.volume,
avgPrice: d.avg_price || d.average_price, avgPrice: d.avg_price || d.average_price,
changePct: d.change_pct, // 保存 API 返回的涨跌幅
}; };
}); });
@@ -302,7 +309,7 @@ const MinuteChart = memo(({ data = [], preClose, loading }) => {
<VStack> <VStack>
<Text color="gray.400" fontSize={9}>涨跌</Text> <Text color="gray.400" fontSize={9}>涨跌</Text>
<Text color={lineColor} fontSize={11}> <Text color={lineColor} fontSize={11}>
{formatPercent(activePoint.price, chartData.preClose)} {formatPercent(activePoint.price, chartData.preClose, activePoint.changePct)}
</Text> </Text>
</VStack> </VStack>
{activePoint.avgPrice && ( {activePoint.avgPrice && (

View File

@@ -165,17 +165,21 @@ const WatchlistStockItem = memo(({
> >
{stock_name || stock_code} {stock_name || stock_code}
</Text> </Text>
<HStack alignItems="center" space={2}> <Text color="gray.500" fontSize={11}>
<Text color="gray.500" fontSize={11}> {stock_code}
{stock_code} </Text>
</Text> {/* 现价和涨跌幅 */}
</HStack> <HStack alignItems="center" space={2} mt={0.5}>
{/* 价格和涨跌幅 */}
<HStack alignItems="center" space={2} mt={1}>
<Text <Text
color={changeColor} color={changeColor}
fontSize={16} fontSize={16}
fontWeight="bold" fontWeight="bold"
>
{formatPrice(current_price)}
</Text>
<Text
color={changeColor}
fontSize={12}
> >
{formatChange(change_percent)} {formatChange(change_percent)}
</Text> </Text>

View File

@@ -507,6 +507,50 @@ export const memberService = {
}, },
}; };
/**
* 上传服务
*/
export const uploadService = {
/**
* 上传图片
* @param {object} imageAsset - expo-image-picker 返回的图片资产
* @returns {Promise<object>} { 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, postService,
memberService, memberService,
notificationService, notificationService,
uploadService,
CHANNEL_TYPES, CHANNEL_TYPES,
CHANNEL_CATEGORIES, CHANNEL_CATEGORIES,
}; };

View File

@@ -253,7 +253,7 @@ export const stockDetailService = {
* 获取分时数据(使用 latest-minute API 获取最新交易日数据) * 获取分时数据(使用 latest-minute API 获取最新交易日数据)
* 同时获取昨收价用于涨跌幅计算 * 同时获取昨收价用于涨跌幅计算
* @param {string} code - 股票代码 * @param {string} code - 股票代码
* @returns {Promise<object>} { success: true, data: [...minuteData], prevClose: number } * @returns {Promise<object>} { success: true, data: [...minuteData], prevClose: number, isTrading: boolean }
*/ */
getMinuteData: async (code) => { getMinuteData: async (code) => {
try { try {
@@ -261,24 +261,20 @@ export const stockDetailService = {
console.log('[StockDetailService] 获取分时数据:', { code, pureCode }); console.log('[StockDetailService] 获取分时数据:', { code, pureCode });
// 并行获取分时数据和昨收价 // 直接从 latest-minute API 获取数据(已包含 prev_close
// 注意market/trade API 返回数据按日期升序排列,最新数据在数组末尾 const minuteResponse = await apiRequest(`/api/stock/${pureCode}/latest-minute`);
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);
}
if (minuteResponse && minuteResponse.data) { 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线数据为分时格式并计算累计均价 // 转换分钟K线数据为分时格式并计算累计均价
let totalAmount = 0; let totalAmount = 0;
@@ -302,7 +298,8 @@ export const stockDetailService = {
high: item.high || 0, high: item.high || 0,
low: item.low || 0, low: item.low || 0,
close: item.close || 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, data: minuteData,
stockName: minuteResponse.name, stockName: minuteResponse.name,
tradeDate: minuteResponse.trade_date, tradeDate: minuteResponse.trade_date,
prevClose: prevClose, // 返回昨收价 prevClose: prevClose,
isTrading: isTrading, // 返回是否在交易中
}; };
} }

View File

@@ -14,6 +14,8 @@ const initialState = {
minuteData: [], minuteData: [],
// 分时数据对应的昨收价 // 分时数据对应的昨收价
minutePrevClose: 0, minutePrevClose: 0,
// 是否正在交易中(用于判断是否需要轮询刷新)
isTrading: false,
// K线数据 // K线数据
klineData: { klineData: {
daily: [], daily: [],
@@ -75,6 +77,7 @@ export const fetchMinuteData = createAsyncThunk(
success: response.success, success: response.success,
dataLength: response.data?.length || 0, dataLength: response.data?.length || 0,
prevClose: response.prevClose, prevClose: response.prevClose,
isTrading: response.isTrading,
firstItem: response.data?.[0], firstItem: response.data?.[0],
}); });
@@ -82,6 +85,7 @@ export const fetchMinuteData = createAsyncThunk(
return { return {
data: response.data || [], data: response.data || [],
prevClose: response.prevClose || 0, prevClose: response.prevClose || 0,
isTrading: response.isTrading || false, // 是否在交易中
}; };
} else { } else {
return rejectWithValue(response.error || '获取分时数据失败'); return rejectWithValue(response.error || '获取分时数据失败');
@@ -146,6 +150,7 @@ const stockSlice = createSlice({
state.currentStock = null; state.currentStock = null;
state.minuteData = []; state.minuteData = [];
state.minutePrevClose = 0; state.minutePrevClose = 0;
state.isTrading = false;
state.klineData = { daily: [], weekly: [], monthly: [] }; state.klineData = { daily: [], weekly: [], monthly: [] };
state.orderBook = { bidPrices: [], bidVolumes: [], askPrices: [], askVolumes: [] }; state.orderBook = { bidPrices: [], bidVolumes: [], askPrices: [], askVolumes: [] };
state.error = null; state.error = null;
@@ -228,6 +233,7 @@ const stockSlice = createSlice({
state.loading.minute = false; state.loading.minute = false;
state.minuteData = action.payload.data; state.minuteData = action.payload.data;
state.minutePrevClose = action.payload.prevClose; state.minutePrevClose = action.payload.prevClose;
state.isTrading = action.payload.isTrading; // 更新交易状态
}) })
.addCase(fetchMinuteData.rejected, (state, action) => { .addCase(fetchMinuteData.rejected, (state, action) => {
state.loading.minute = false; state.loading.minute = false;
@@ -262,6 +268,7 @@ export const {
export const selectCurrentStock = (state) => state.stock.currentStock; export const selectCurrentStock = (state) => state.stock.currentStock;
export const selectMinuteData = (state) => state.stock.minuteData; export const selectMinuteData = (state) => state.stock.minuteData;
export const selectMinutePrevClose = (state) => state.stock.minutePrevClose; export const selectMinutePrevClose = (state) => state.stock.minutePrevClose;
export const selectIsTrading = (state) => state.stock.isTrading;
export const selectKlineData = (state) => state.stock.klineData; export const selectKlineData = (state) => state.stock.klineData;
export const selectChartType = (state) => state.stock.chartType; export const selectChartType = (state) => state.stock.chartType;
export const selectOrderBook = (state) => state.stock.orderBook; export const selectOrderBook = (state) => state.stock.orderBook;

Binary file not shown.

157
app.py
View File

@@ -6058,6 +6058,10 @@ def get_watchlist_realtime():
if not full_codes: if not full_codes:
return jsonify({'success': True, 'data': []}) return jsonify({'success': True, 'data': []})
# 确保交易日数据已加载
if not trading_days:
load_trading_days()
# 使用批量查询获取最新行情(单次查询) # 使用批量查询获取最新行情(单次查询)
client = get_clickhouse_client() client = get_clickhouse_client()
today = datetime.now().date() today = datetime.now().date()
@@ -6158,6 +6162,40 @@ def get_watchlist_realtime():
'update_time': latest['timestamp'].strftime('%H:%M:%S') '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 = [] response_data = []
for item in watchlist: for item in watchlist:
code6, _ = _normalize_stock_input(item.stock_code) code6, _ = _normalize_stock_input(item.stock_code)
@@ -9621,7 +9659,13 @@ def get_batch_kline_data():
@app.route('/api/stock/<stock_code>/latest-minute', methods=['GET']) @app.route('/api/stock/<stock_code>/latest-minute', methods=['GET'])
def get_latest_minute_data(stock_code): def get_latest_minute_data(stock_code):
"""获取最新交易日的分钟频数据""" """获取最新交易日的分钟频数据
改进:
- 如果是当天交易日且在交易时间内,只返回到当前时间的数据
- 返回昨收价 prev_close供前端计算涨跌幅
- 返回 is_trading 标志指示当前是否在交易中
"""
client = get_clickhouse_client() client = get_clickhouse_client()
# 确保股票代码包含后缀 # 确保股票代码包含后缀
@@ -9633,16 +9677,22 @@ def get_latest_minute_data(stock_code):
else: else:
stock_code = f"{stock_code}.SZ" # 深圳 stock_code = f"{stock_code}.SZ" # 深圳
# 获取股票名称 base_code = stock_code.split('.')[0]
# 获取股票名称和昨收价
prev_close = None
stock_name = 'Unknown'
with engine.connect() as conn: with engine.connect() as conn:
result = conn.execute(text( result = conn.execute(text(
"SELECT SECNAME FROM ea_stocklist WHERE SECCODE = :code" "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' stock_name = result[0] if result else 'Unknown'
# 查找最近30天内有数据的最新交易日 # 查找最近30天内有数据的最新交易日
target_date = None target_date = None
current_date = datetime.now().date() now = beijing_now()
current_date = now.date()
current_time = now.time()
for i in range(30): for i in range(30):
check_date = current_date - timedelta(days=i) check_date = current_date - timedelta(days=i)
@@ -9673,10 +9723,68 @@ def get_latest_minute_data(stock_code):
'name': stock_name, 'name': stock_name,
'data': [], 'data': [],
'trade_date': current_date.strftime('%Y-%m-%d'), '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(""" data = client.execute("""
SELECT SELECT
timestamp, timestamp,
@@ -9694,19 +9802,30 @@ def get_latest_minute_data(stock_code):
""", { """, {
'code': stock_code, 'code': stock_code,
'start': datetime.combine(target_date, dt_time(9, 30)), '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'), kline_data = []
'open': float(row[1]), for row in data:
'high': float(row[2]), close_price = float(row[4])
'low': float(row[3]), # 使用昨收价计算涨跌幅(更准确)
'close': float(row[4]), if prev_close and prev_close > 0:
'volume': float(row[5]), calculated_change_pct = ((close_price - prev_close) / prev_close) * 100
'amount': float(row[6]), else:
'change_pct': float(row[7]) if row[7] else 0 # 使用数据库中的涨跌幅
} for row in data] 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({ return jsonify({
'code': stock_code, 'code': stock_code,
@@ -9714,7 +9833,9 @@ def get_latest_minute_data(stock_code):
'data': kline_data, 'data': kline_data,
'trade_date': target_date.strftime('%Y-%m-%d'), 'trade_date': target_date.strftime('%Y-%m-%d'),
'type': 'minute', 'type': 'minute',
'is_latest': True 'is_latest': True,
'prev_close': prev_close,
'is_trading': is_trading
}) })