feat: 地址,逆编码接口联调

This commit is contained in:
jinyaqiu 2025-07-18 15:41:38 +08:00
parent fea5af96fa
commit 6bc7d8b362
8 changed files with 214 additions and 49 deletions

View File

@ -5,14 +5,23 @@ import ClassifyModal from '@/components/owner/classify';
import LocationModal from '@/components/owner/location'; import LocationModal from '@/components/owner/location';
import PodiumComponent from '@/components/owner/podium'; import PodiumComponent from '@/components/owner/podium';
import RankList from '@/components/owner/rankList'; import RankList from '@/components/owner/rankList';
import { findInnermostElement } from '@/components/owner/utils';
import { ThemedText } from '@/components/ThemedText'; import { ThemedText } from '@/components/ThemedText';
import { convertRegions } from '@/components/utils/cascaderData';
import { transformData } from '@/components/utils/objectToCascader'; import { transformData } from '@/components/utils/objectToCascader';
import { fetchApi } from '@/lib/server-api-util'; import { fetchApi } from '@/lib/server-api-util';
import { GroupedData, RankingItem, TargetItem } from '@/types/user'; import { GroupedData, RankingItem, TargetItem } from '@/types/user';
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from 'react'; import * as SecureStore from 'expo-secure-store';
import { LayoutChangeEvent, StyleSheet, TouchableOpacity, View } from 'react-native'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { LayoutChangeEvent, Platform, StyleSheet, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
interface LocationData {
id: number;
name: string;
children: LocationData[];
}
export default function OwnerPage() { export default function OwnerPage() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const router = useRouter(); const router = useRouter();
@ -20,7 +29,8 @@ export default function OwnerPage() {
const [locationModalVisible, setLocationModalVisible] = useState(false); const [locationModalVisible, setLocationModalVisible] = useState(false);
// 分类弹窗 // 分类弹窗
const [classifyModalVisible, setClassifyModalVisible] = useState(false); const [classifyModalVisible, setClassifyModalVisible] = useState(false);
// 地区数据
const [locationData, setLocationData] = useState<CascaderItem[]>([]);
// 在组件内部添加: // 在组件内部添加:
const podiumRef = useRef<View>(null); const podiumRef = useRef<View>(null);
const [podiumPosition, setPodiumPosition] = useState({ x: 0, y: 0, width: 0, height: 0 }); const [podiumPosition, setPodiumPosition] = useState({ x: 0, y: 0, width: 0, height: 0 });
@ -28,6 +38,7 @@ export default function OwnerPage() {
const [classify, setClassify] = useState<TargetItem[]>([]); const [classify, setClassify] = useState<TargetItem[]>([]);
const getClassify = () => { const getClassify = () => {
fetchApi<GroupedData>("/title-tags").then((res: GroupedData) => { fetchApi<GroupedData>("/title-tags").then((res: GroupedData) => {
setSelectedClassify([transformData(res)?.[0]?.children?.[0]]);
setClassify(transformData(res)); setClassify(transformData(res));
}); });
} }
@ -51,7 +62,6 @@ export default function OwnerPage() {
}; };
// 地区选择 // 地区选择
const handleLocationChange = useCallback((selectedItems: CascaderItem[]) => { const handleLocationChange = useCallback((selectedItems: CascaderItem[]) => {
console.log('SelectedLocation:', selectedItems);
if (selectedItems.length > 0) { if (selectedItems.length > 0) {
const lastItem = selectedItems[selectedItems.length - 1]; const lastItem = selectedItems[selectedItems.length - 1];
// 只有当选择完成时才更新状态 // 只有当选择完成时才更新状态
@ -63,7 +73,6 @@ export default function OwnerPage() {
// 分类选择 // 分类选择
const handleClassifyChange = useCallback((selectedItems: CascaderItem[]) => { const handleClassifyChange = useCallback((selectedItems: CascaderItem[]) => {
console.log('SelectedClassify:', selectedItems);
if (selectedItems.length > 0) { if (selectedItems.length > 0) {
const lastItem = selectedItems[selectedItems.length - 1]; const lastItem = selectedItems[selectedItems.length - 1];
// 只有当选择完成时才更新状态 // 只有当选择完成时才更新状态
@ -72,15 +81,26 @@ export default function OwnerPage() {
} }
} }
}, []); }, []);
// 获取本地存储的地址信息
const getLocation = async () => {
let location;
if (Platform.OS === 'web') {
location = localStorage.getItem('location');
} else {
location = await SecureStore.getItemAsync('location');
}
return location;
};
// 获取排名信息 // 获取排名信息
const [ranking, setRanking] = useState<RankingItem[]>([]); const [ranking, setRanking] = useState<RankingItem[]>([]);
const getRanking = () => { const getRanking = () => {
fetchApi<RankingItem[]>("/title-rank", { fetchApi<RankingItem[]>("/title-rank", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
"title_tag_id": selectedClassify?.length > 0 ? selectedClassify[selectedClassify?.length - 1].value : 3, "title_tag_id": selectedClassify?.length > 0 ? selectedClassify[selectedClassify?.length - 1].value : null,
"area_id": 1 "area_id": selectedLocation?.length > 0 ? selectedLocation[selectedLocation?.length - 1].value : null
}) })
}).then((res) => { }).then((res) => {
setRanking(res); setRanking(res);
@ -89,13 +109,61 @@ export default function OwnerPage() {
// 当用户选择发生变化时,重新获取排名 // 当用户选择发生变化时,重新获取排名
useEffect(() => { useEffect(() => {
getRanking(); console.log('selectedLocation', selectedLocation);
console.log('selectedClassify', selectedClassify);
if (selectedLocation?.length > 0 && selectedClassify?.length > 0) {
getRanking();
}
}, [selectedLocation, selectedClassify]) }, [selectedLocation, selectedClassify])
// 初始化获取分类 // 初始化获取分类
useEffect(() => { useEffect(() => {
getClassify(); const start = async () => {
await getClassify();
}
start();
}, []) }, [])
const fetchLocationData = useMemo(() => async () => {
try {
const res = await fetchApi<LocationData>("/area/tree");
const transformed = convertRegions(res?.children, {
nameKey: 'name', // 源数据中表示"名称"的字段
valueKey: 'id', // 源数据中作为 value 的字段
regionsKey: 'children', // 源数据中表示"子级区域"的字段
childrenKey: 'children' // 输出结构中表示"子级区域"的字段
});
return transformed;
} catch (error) {
return [];
}
}, []);
useEffect(() => {
let isMounted = true;
const loadLocationData = async () => {
const data = await fetchLocationData();
if (isMounted) {
setLocationData(data);
// 获取本地存储的地址信息
const location = await getLocation();
const xuhuiElement = findInnermostElement(data?.filter((item) => {
return item.name === JSON.parse(location || "").city
}) || [], JSON.parse(location || "").district);
if (location) {
console.log("xuhuiElement", xuhuiElement);
setSelectedLocation([xuhuiElement]);
}
}
};
loadLocationData();
return () => {
isMounted = false;
};
}, [fetchLocationData]);
return ( return (
<View style={[styles.container, { paddingTop: insets.top }]}> <View style={[styles.container, { paddingTop: insets.top }]}>
@ -149,6 +217,7 @@ export default function OwnerPage() {
setModalVisible={setLocationModalVisible} setModalVisible={setLocationModalVisible}
podiumPosition={podiumPosition} podiumPosition={podiumPosition}
handleChange={handleLocationChange} handleChange={handleLocationChange}
data={locationData}
/> />
{/* 分类选择弹窗 */} {/* 分类选择弹窗 */}
<ClassifyModal <ClassifyModal

View File

@ -19,13 +19,22 @@ const ClassifyModal = (props: ClassifyModalProps) => {
<Modal <Modal
isVisible={modalVisible} isVisible={modalVisible}
onBackdropPress={() => setModalVisible(false)} onBackdropPress={() => setModalVisible(false)}
swipeDirection="right" // 支持向右滑动关闭 swipeDirection={['right']} // 改为数组形式
propagateSwipe={true} propagateSwipe={true}
animationIn="slideInRight" // 入场动画 animationIn="slideInRight"
animationOut="slideOutRight" // 出场动画 animationOut="slideOutRight"
backdropOpacity={0.5} backdropOpacity={0.5}
onSwipeComplete={() => setModalVisible(false)} onSwipeComplete={() => setModalVisible(false)}
style={{ margin: 0, justifyContent: 'flex-start', marginTop: podiumPosition.height + podiumPosition.y }} onModalHide={() => {
// 确保动画完全结束后再更新状态
}}
useNativeDriver={true} // 启用原生驱动
hideModalContentWhileAnimating={true} // 动画时隐藏内容
style={{
margin: 0,
justifyContent: 'flex-start',
marginTop: podiumPosition.height + podiumPosition.y,
}}
> >
<View style={styles.modalView}> <View style={styles.modalView}>
<View style={styles.modalHeader}> <View style={styles.modalHeader}>

View File

@ -1,39 +1,41 @@
import locationData from '@/assets/json/location.json';
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Modal from 'react-native-modal'; import Modal from 'react-native-modal';
import Cascader, { CascaderItem } from '../cascader'; import Cascader, { CascaderItem } from '../cascader';
import { convertRegions } from '../utils/cascaderData';
interface LocationModalProps { interface LocationModalProps {
modalVisible: boolean; modalVisible: boolean;
setModalVisible: (visible: boolean) => void; setModalVisible: (visible: boolean) => void;
podiumPosition: { x: number, y: number, width: number, height: number }; podiumPosition: { x: number, y: number, width: number, height: number };
handleChange: (selectedItems: CascaderItem[]) => void; handleChange: (selectedItems: CascaderItem[]) => void;
data: CascaderItem[];
} }
const LocationModal = React.memo((props: LocationModalProps) => { const LocationModal = React.memo((props: LocationModalProps) => {
const { modalVisible, setModalVisible, podiumPosition, handleChange } = props; const { modalVisible, setModalVisible, podiumPosition, handleChange, data } = props;
const transformed = convertRegions(locationData, {
nameKey: 'name', // 源数据中表示"名称"的字段
valueKey: 'name', // 源数据中作为 value 的字段
regionsKey: 'regions', // 源数据中表示"子级区域"的字段
childrenKey: 'children' // 输出结构中表示"子级区域"的字段
});
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Modal <Modal
isVisible={modalVisible} isVisible={modalVisible}
onBackdropPress={() => setModalVisible(false)} onBackdropPress={() => setModalVisible(false)}
swipeDirection="right" // 支持向右滑动关闭 swipeDirection={['right']} // 改为数组形式
propagateSwipe={true} propagateSwipe={true}
animationIn="slideInRight" // 入场动画 animationIn="slideInRight"
animationOut="slideOutRight" // 出场动画 animationOut="slideOutRight"
backdropOpacity={0.5} backdropOpacity={0.5}
onSwipeComplete={() => setModalVisible(false)} onSwipeComplete={() => setModalVisible(false)}
style={{ margin: 0, justifyContent: 'flex-start', marginTop: podiumPosition.height + podiumPosition.y }} onModalHide={() => {
// 确保动画完全结束后再更新状态
}}
useNativeDriver={true} // 启用原生驱动
hideModalContentWhileAnimating={true} // 动画时隐藏内容
style={{
margin: 0,
justifyContent: 'flex-start',
marginTop: podiumPosition.height + podiumPosition.y,
}}
> >
<View style={styles.modalView}> <View style={styles.modalView}>
<View style={styles.modalHeader}> <View style={styles.modalHeader}>
@ -45,7 +47,7 @@ const LocationModal = React.memo((props: LocationModalProps) => {
</View> </View>
<ScrollView style={styles.modalContent} showsVerticalScrollIndicator={false}> <ScrollView style={styles.modalContent} showsVerticalScrollIndicator={false}>
<Cascader <Cascader
data={transformed} data={data}
onChange={handleChange} onChange={handleChange}
/> />
</ScrollView> </ScrollView>

View File

@ -3,22 +3,34 @@ import FirstSvg from "@/assets/icons/svg/first.svg";
import SecondSvg from "@/assets/icons/svg/second.svg"; import SecondSvg from "@/assets/icons/svg/second.svg";
import ThirdSvg from "@/assets/icons/svg/third.svg"; import ThirdSvg from "@/assets/icons/svg/third.svg";
import { RankingItem } from "@/types/user"; import { RankingItem } from "@/types/user";
import { useState } from "react";
import { Image, StyleSheet, View } from "react-native"; import { Image, StyleSheet, View } from "react-native";
import { ThemedText } from "../ThemedText"; import { ThemedText } from "../ThemedText";
interface IPodium { interface IPodium {
data: RankingItem[] data: RankingItem[]
} }
const PodiumComponent = ({ data }: IPodium) => { const PodiumComponent = ({ data }: IPodium) => {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={[styles.item, { opacity: data[1]?.user_id ? 1 : 0 }]}> <View style={[styles.item, { opacity: data[1]?.user_id ? 1 : 0 }]}>
<SecondSvg /> <SecondSvg />
<View style={[styles.titleContainer, { backgroundColor: '#FFB645', borderTopRightRadius: 0, height: 60 }]}> <View style={[styles.titleContainer, { backgroundColor: '#FFB645', borderTopRightRadius: 0, height: 60 }]}>
{ {
data[1]?.user_avatar_url (() => {
? <Image source={{ uri: data[1]?.user_avatar_url }} style={{ width: 30, height: 30, borderRadius: 30 }} /> const [imageError, setImageError] = useState(false);
: <UserSvg width={30} height={30} />
if (!data[1]?.user_avatar_url || imageError) {
return <UserSvg width={30} height={30} />;
}
return (
<Image
source={{ uri: data[1].user_avatar_url }}
style={{ width: 30, height: 30, borderRadius: 30 }}
onError={() => setImageError(true)}
/>
);
})()
} }
<ThemedText <ThemedText
numberOfLines={1} numberOfLines={1}
@ -33,9 +45,21 @@ const PodiumComponent = ({ data }: IPodium) => {
<FirstSvg /> <FirstSvg />
<View style={[styles.titleContainer, { backgroundColor: '#E2793F', height: 90 }]}> <View style={[styles.titleContainer, { backgroundColor: '#E2793F', height: 90 }]}>
{ {
data[0]?.user_avatar_url (() => {
? <Image source={{ uri: data[0]?.user_avatar_url }} style={{ width: 40, height: 40, borderRadius: 40 }} /> const [imageError, setImageError] = useState(false);
: <UserSvg width={40} height={40} />
if (!data[0]?.user_avatar_url || imageError) {
return <UserSvg width={40} height={40} />;
}
return (
<Image
source={{ uri: data[0].user_avatar_url }}
style={{ width: 40, height: 40, borderRadius: 40 }}
onError={() => setImageError(true)}
/>
);
})()
} }
<ThemedText <ThemedText
numberOfLines={2} numberOfLines={2}
@ -50,9 +74,21 @@ const PodiumComponent = ({ data }: IPodium) => {
<ThirdSvg /> <ThirdSvg />
<View style={[styles.titleContainer, { backgroundColor: '#FFD18A', borderTopLeftRadius: 0, height: 50 }]}> <View style={[styles.titleContainer, { backgroundColor: '#FFD18A', borderTopLeftRadius: 0, height: 50 }]}>
{ {
data[2]?.user_avatar_url (() => {
? <Image source={{ uri: data[2]?.user_avatar_url }} style={{ width: 20, height: 20, borderRadius: 20 }} /> const [imageError, setImageError] = useState(false);
: <UserSvg width={20} height={20} />
if (!data[2]?.user_avatar_url || imageError) {
return <UserSvg width={20} height={20} />;
}
return (
<Image
source={{ uri: data[2].user_avatar_url }}
style={{ width: 20, height: 20, borderRadius: 20 }}
onError={() => setImageError(true)}
/>
);
})()
} }
<ThemedText <ThemedText
numberOfLines={1} numberOfLines={1}

View File

@ -1,6 +1,7 @@
import AtaverSvg from "@/assets/icons/svg/ataver.svg"; import AtaverSvg from "@/assets/icons/svg/ataver.svg";
import OwnerSvg from "@/assets/icons/svg/owner.svg"; import OwnerSvg from "@/assets/icons/svg/owner.svg";
import { RankingItem } from "@/types/user"; import { RankingItem } from "@/types/user";
import { useState } from "react";
import { Image, ScrollView, StyleSheet, View } from "react-native"; import { Image, ScrollView, StyleSheet, View } from "react-native";
import { ThemedText } from "../ThemedText"; import { ThemedText } from "../ThemedText";
interface IRankList { interface IRankList {
@ -37,14 +38,21 @@ const RankList = (props: IRankList) => {
<ThemedText style={styles.itemRank}>{index + 1}</ThemedText> <ThemedText style={styles.itemRank}>{index + 1}</ThemedText>
<ThemedText style={styles.itemName}>{item.user_nick_name}</ThemedText> <ThemedText style={styles.itemName}>{item.user_nick_name}</ThemedText>
<View style={{ opacity: index == 1 ? 0 : 1 }}> <View style={{ opacity: index == 1 ? 0 : 1 }}>
{item.user_avatar_url ? ( {(() => {
<Image const [imageError, setImageError] = useState(false);
source={{ uri: item.user_avatar_url }}
style={{ width: 40, height: 40, borderRadius: 40 }} if (!item.user_avatar_url || imageError) {
/> return <AtaverSvg width={40} height={40} />;
) : ( }
<AtaverSvg width={40} height={40} />
)} return (
<Image
source={{ uri: item.user_avatar_url }}
style={{ width: 40, height: 40, borderRadius: 40 }}
onError={() => setImageError(true)}
/>
);
})()}
</View> </View>
</View> </View>
))} ))}

View File

@ -1,10 +1,17 @@
// 地理位置逆编码 // 地理位置逆编码
import { fetchApi } from '@/lib/server-api-util';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import * as Location from 'expo-location'; import * as Location from 'expo-location';
import * as Notifications from 'expo-notifications'; import * as Notifications from 'expo-notifications';
import * as SecureStore from 'expo-secure-store'; import * as SecureStore from 'expo-secure-store';
import { Alert, Linking, Platform } from 'react-native'; import { Alert, Linking, Platform } from 'react-native';
interface Address {
id: number;
name: string;
// Add other address properties as needed
}
// 配置通知处理器 // 配置通知处理器
Notifications.setNotificationHandler({ Notifications.setNotificationHandler({
handleNotification: async () => ({ handleNotification: async () => ({
@ -16,9 +23,11 @@ Notifications.setNotificationHandler({
}), }),
}); });
// 逆编码
export const reverseGeocode = async (latitude: number, longitude: number) => { export const reverseGeocode = async (latitude: number, longitude: number) => {
try { try {
const addressResults = await Location.reverseGeocodeAsync({ latitude, longitude }); const addressResults = await fetchApi<Address[]>(`/area/gecoding?latitude=${latitude}&longitude=${longitude}`);
console.log('地址:', addressResults);
for (let address of addressResults) { for (let address of addressResults) {
console.log('地址:', address); console.log('地址:', address);
if (Platform.OS === 'web') { if (Platform.OS === 'web') {
@ -29,7 +38,7 @@ export const reverseGeocode = async (latitude: number, longitude: number) => {
return address; return address;
} }
} catch (error) { } catch (error) {
console.error('逆地理编码失败:', error); console.log('逆地理编码失败:', error);
} }
}; };
@ -300,3 +309,33 @@ export const sendLocalNotification = async (title: string, body: string, data: R
return false; return false;
} }
}; };
// 获取定位信息 -- 最子集元素
export function findInnermostElement(data: any[], targetName: string): { name: string; value: any } | null {
let result: { name: string; value: any } | null = null;
function search(nodes: any[]): boolean {
for (const node of nodes) {
if (node.name === targetName) {
result = { name: node.name, value: node.value };
// Keep searching to see if there's a deeper match
let foundDeeper = false;
if (node.children && node.children.length > 0) {
foundDeeper = search(node.children);
}
// If no deeper match was found, this is the innermost one
if (!foundDeeper) {
return true;
}
} else if (node.children && node.children.length > 0) {
const found = search(node.children);
if (found) return true;
}
}
return false;
}
search(data);
return result;
}

1
package-lock.json generated
View File

@ -28,6 +28,7 @@
"expo-haptics": "~14.1.4", "expo-haptics": "~14.1.4",
"expo-image-manipulator": "~13.1.7", "expo-image-manipulator": "~13.1.7",
"expo-image-picker": "~16.1.4", "expo-image-picker": "~16.1.4",
"expo-linear-gradient": "~14.1.5",
"expo-linking": "~7.1.7", "expo-linking": "~7.1.7",
"expo-localization": "^16.1.5", "expo-localization": "^16.1.5",
"expo-location": "~18.1.5", "expo-location": "~18.1.5",

View File

@ -73,7 +73,8 @@
"react-native-web": "~0.20.0", "react-native-web": "~0.20.0",
"react-native-webview": "13.13.5", "react-native-webview": "13.13.5",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"expo-clipboard": "~7.1.5" "expo-clipboard": "~7.1.5",
"expo-linear-gradient": "~14.1.5"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",