更新ios
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => ``)
|
||||
.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,21 +556,61 @@ 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 = () => {
|
||||
const canSend = inputText.trim() || selectedImages.length > 0;
|
||||
|
||||
return (
|
||||
<Box
|
||||
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" />
|
||||
{renderSelectedImages()}
|
||||
<HStack space={2} alignItems="flex-end" px={4} py={3}>
|
||||
{/* 图片选择按钮 */}
|
||||
<Pressable p={2} onPress={handlePickImage} disabled={isSending}>
|
||||
<Icon
|
||||
as={Ionicons}
|
||||
name="image-outline"
|
||||
size="md"
|
||||
color={selectedImages.length > 0 ? 'primary.500' : 'gray.500'}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{/* 输入框 */}
|
||||
@@ -506,7 +636,7 @@ const ChannelDetail = ({ route, navigation }) => {
|
||||
blurOnSubmit={false}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={true}
|
||||
editable={!isSending}
|
||||
keyboardType="default"
|
||||
contextMenuHidden={false}
|
||||
/>
|
||||
@@ -516,8 +646,8 @@ const ChannelDetail = ({ route, navigation }) => {
|
||||
<Pressable
|
||||
onPress={handleSend}
|
||||
p={2}
|
||||
opacity={inputText.trim() ? 1 : 0.5}
|
||||
disabled={!inputText.trim() || isSending}
|
||||
opacity={canSend ? 1 : 0.5}
|
||||
disabled={!canSend || isSending}
|
||||
>
|
||||
{isSending ? (
|
||||
<Spinner size="sm" color="primary.500" />
|
||||
@@ -526,13 +656,14 @@ const ChannelDetail = ({ route, navigation }) => {
|
||||
as={Ionicons}
|
||||
name="send"
|
||||
size="md"
|
||||
color={inputText.trim() ? 'primary.500' : 'gray.500'}
|
||||
color={canSend ? 'primary.500' : 'gray.500'}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
@@ -580,6 +711,30 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: Platform.OS === 'ios' ? 8 : 10,
|
||||
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;
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
StyleSheet,
|
||||
Keyboard,
|
||||
Alert,
|
||||
Image,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import {
|
||||
Box,
|
||||
@@ -27,8 +29,10 @@ import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
|
||||
import { createPost } from '../../store/slices/communitySlice';
|
||||
import { uploadService } from '../../services/communityService';
|
||||
import { gradients } from '../../theme';
|
||||
|
||||
// 预设标签
|
||||
@@ -47,10 +51,13 @@ const CreatePost = ({ route, navigation }) => {
|
||||
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) => ``)
|
||||
.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 }) => {
|
||||
/>
|
||||
</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}>
|
||||
<HStack alignItems="center" justifyContent="space-between" mb={2}>
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 }) => {
|
||||
<VStack>
|
||||
<Text color="gray.400" fontSize={9}>涨跌</Text>
|
||||
<Text color={lineColor} fontSize={11}>
|
||||
{formatPercent(activePoint.price, chartData.preClose)}
|
||||
{formatPercent(activePoint.price, chartData.preClose, activePoint.changePct)}
|
||||
</Text>
|
||||
</VStack>
|
||||
{activePoint.avgPrice && (
|
||||
|
||||
@@ -165,17 +165,21 @@ const WatchlistStockItem = memo(({
|
||||
>
|
||||
{stock_name || stock_code}
|
||||
</Text>
|
||||
<HStack alignItems="center" space={2}>
|
||||
<Text color="gray.500" fontSize={11}>
|
||||
{stock_code}
|
||||
</Text>
|
||||
</HStack>
|
||||
{/* 价格和涨跌幅 */}
|
||||
<HStack alignItems="center" space={2} mt={1}>
|
||||
{/* 现价和涨跌幅 */}
|
||||
<HStack alignItems="center" space={2} mt={0.5}>
|
||||
<Text
|
||||
color={changeColor}
|
||||
fontSize={16}
|
||||
fontWeight="bold"
|
||||
>
|
||||
{formatPrice(current_price)}
|
||||
</Text>
|
||||
<Text
|
||||
color={changeColor}
|
||||
fontSize={12}
|
||||
>
|
||||
{formatChange(change_percent)}
|
||||
</Text>
|
||||
|
||||
@@ -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,
|
||||
memberService,
|
||||
notificationService,
|
||||
uploadService,
|
||||
CHANNEL_TYPES,
|
||||
CHANNEL_CATEGORIES,
|
||||
};
|
||||
|
||||
@@ -253,7 +253,7 @@ export const stockDetailService = {
|
||||
* 获取分时数据(使用 latest-minute API 获取最新交易日数据)
|
||||
* 同时获取昨收价用于涨跌幅计算
|
||||
* @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) => {
|
||||
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, // 返回是否在交易中
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
BIN
__pycache__/app.cpython-314.pyc
Normal file
BIN
__pycache__/app.cpython-314.pyc
Normal file
Binary file not shown.
145
app.py
145
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/<stock_code>/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 = [{
|
||||
# 构建返回数据,使用昨收价重新计算涨跌幅
|
||||
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': float(row[4]),
|
||||
'close': close_price,
|
||||
'volume': float(row[5]),
|
||||
'amount': float(row[6]),
|
||||
'change_pct': float(row[7]) if row[7] else 0
|
||||
} for row in data]
|
||||
'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
|
||||
})
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user