更新ios
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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) => ``)
|
||||||
|
.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,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
|
<Box
|
||||||
px={4}
|
|
||||||
py={3}
|
|
||||||
bg="#0F172A"
|
bg="#0F172A"
|
||||||
borderTopWidth={1}
|
borderTopWidth={1}
|
||||||
borderTopColor="rgba(255, 255, 255, 0.1)"
|
borderTopColor="rgba(255, 255, 255, 0.1)"
|
||||||
pb={insets.bottom + 12}
|
pb={insets.bottom + 12}
|
||||||
>
|
>
|
||||||
{renderTypingIndicator()}
|
{renderTypingIndicator()}
|
||||||
<HStack space={2} alignItems="flex-end">
|
{renderSelectedImages()}
|
||||||
{/* 附件按钮 */}
|
<HStack space={2} alignItems="flex-end" px={4} py={3}>
|
||||||
<Pressable p={2}>
|
{/* 图片选择按钮 */}
|
||||||
<Icon as={Ionicons} name="add-circle-outline" size="md" color="gray.500" />
|
<Pressable p={2} onPress={handlePickImage} disabled={isSending}>
|
||||||
|
<Icon
|
||||||
|
as={Ionicons}
|
||||||
|
name="image-outline"
|
||||||
|
size="md"
|
||||||
|
color={selectedImages.length > 0 ? 'primary.500' : 'gray.500'}
|
||||||
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
{/* 输入框 */}
|
{/* 输入框 */}
|
||||||
@@ -506,7 +636,7 @@ const ChannelDetail = ({ route, navigation }) => {
|
|||||||
blurOnSubmit={false}
|
blurOnSubmit={false}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
editable={true}
|
editable={!isSending}
|
||||||
keyboardType="default"
|
keyboardType="default"
|
||||||
contextMenuHidden={false}
|
contextMenuHidden={false}
|
||||||
/>
|
/>
|
||||||
@@ -516,8 +646,8 @@ const ChannelDetail = ({ route, navigation }) => {
|
|||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleSend}
|
onPress={handleSend}
|
||||||
p={2}
|
p={2}
|
||||||
opacity={inputText.trim() ? 1 : 0.5}
|
opacity={canSend ? 1 : 0.5}
|
||||||
disabled={!inputText.trim() || isSending}
|
disabled={!canSend || isSending}
|
||||||
>
|
>
|
||||||
{isSending ? (
|
{isSending ? (
|
||||||
<Spinner size="sm" color="primary.500" />
|
<Spinner size="sm" color="primary.500" />
|
||||||
@@ -526,13 +656,14 @@ const ChannelDetail = ({ route, navigation }) => {
|
|||||||
as={Ionicons}
|
as={Ionicons}
|
||||||
name="send"
|
name="send"
|
||||||
size="md"
|
size="md"
|
||||||
color={inputText.trim() ? 'primary.500' : 'gray.500'}
|
color={canSend ? 'primary.500' : 'gray.500'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Box>
|
</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;
|
||||||
|
|||||||
@@ -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) => ``)
|
||||||
|
.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;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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, // 返回是否在交易中
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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:
|
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 = [{
|
# 构建返回数据,使用昨收价重新计算涨跌幅
|
||||||
|
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'),
|
'time': row[0].strftime('%H:%M'),
|
||||||
'open': float(row[1]),
|
'open': float(row[1]),
|
||||||
'high': float(row[2]),
|
'high': float(row[2]),
|
||||||
'low': float(row[3]),
|
'low': float(row[3]),
|
||||||
'close': float(row[4]),
|
'close': close_price,
|
||||||
'volume': float(row[5]),
|
'volume': float(row[5]),
|
||||||
'amount': float(row[6]),
|
'amount': float(row[6]),
|
||||||
'change_pct': float(row[7]) if row[7] else 0
|
'change_pct': round(calculated_change_pct, 2)
|
||||||
} for row in data]
|
})
|
||||||
|
|
||||||
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
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user