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 PodiumComponent from '@/components/owner/podium';
import RankList from '@/components/owner/rankList';
import { findInnermostElement } from '@/components/owner/utils';
import { ThemedText } from '@/components/ThemedText';
import { convertRegions } from '@/components/utils/cascaderData';
import { transformData } from '@/components/utils/objectToCascader';
import { fetchApi } from '@/lib/server-api-util';
import { GroupedData, RankingItem, TargetItem } from '@/types/user';
import { useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from 'react';
import { LayoutChangeEvent, StyleSheet, TouchableOpacity, View } from 'react-native';
import * as SecureStore from 'expo-secure-store';
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";
interface LocationData {
id: number;
name: string;
children: LocationData[];
}
export default function OwnerPage() {
const insets = useSafeAreaInsets();
const router = useRouter();
@ -20,7 +29,8 @@ export default function OwnerPage() {
const [locationModalVisible, setLocationModalVisible] = useState(false);
// 分类弹窗
const [classifyModalVisible, setClassifyModalVisible] = useState(false);
// 地区数据
const [locationData, setLocationData] = useState<CascaderItem[]>([]);
// 在组件内部添加:
const podiumRef = useRef<View>(null);
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 getClassify = () => {
fetchApi<GroupedData>("/title-tags").then((res: GroupedData) => {
setSelectedClassify([transformData(res)?.[0]?.children?.[0]]);
setClassify(transformData(res));
});
}
@ -51,7 +62,6 @@ export default function OwnerPage() {
};
// 地区选择
const handleLocationChange = useCallback((selectedItems: CascaderItem[]) => {
console.log('SelectedLocation:', selectedItems);
if (selectedItems.length > 0) {
const lastItem = selectedItems[selectedItems.length - 1];
// 只有当选择完成时才更新状态
@ -63,7 +73,6 @@ export default function OwnerPage() {
// 分类选择
const handleClassifyChange = useCallback((selectedItems: CascaderItem[]) => {
console.log('SelectedClassify:', selectedItems);
if (selectedItems.length > 0) {
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 getRanking = () => {
fetchApi<RankingItem[]>("/title-rank", {
method: "POST",
body: JSON.stringify({
"title_tag_id": selectedClassify?.length > 0 ? selectedClassify[selectedClassify?.length - 1].value : 3,
"area_id": 1
"title_tag_id": selectedClassify?.length > 0 ? selectedClassify[selectedClassify?.length - 1].value : null,
"area_id": selectedLocation?.length > 0 ? selectedLocation[selectedLocation?.length - 1].value : null
})
}).then((res) => {
setRanking(res);
@ -89,13 +109,61 @@ export default function OwnerPage() {
// 当用户选择发生变化时,重新获取排名
useEffect(() => {
console.log('selectedLocation', selectedLocation);
console.log('selectedClassify', selectedClassify);
if (selectedLocation?.length > 0 && selectedClassify?.length > 0) {
getRanking();
}
}, [selectedLocation, selectedClassify])
// 初始化获取分类
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 (
<View style={[styles.container, { paddingTop: insets.top }]}>
@ -149,6 +217,7 @@ export default function OwnerPage() {
setModalVisible={setLocationModalVisible}
podiumPosition={podiumPosition}
handleChange={handleLocationChange}
data={locationData}
/>
{/* 分类选择弹窗 */}
<ClassifyModal

View File

@ -19,13 +19,22 @@ const ClassifyModal = (props: ClassifyModalProps) => {
<Modal
isVisible={modalVisible}
onBackdropPress={() => setModalVisible(false)}
swipeDirection="right" // 支持向右滑动关闭
swipeDirection={['right']} // 改为数组形式
propagateSwipe={true}
animationIn="slideInRight" // 入场动画
animationOut="slideOutRight" // 出场动画
animationIn="slideInRight"
animationOut="slideOutRight"
backdropOpacity={0.5}
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.modalHeader}>

View File

@ -1,39 +1,41 @@
import locationData from '@/assets/json/location.json';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Modal from 'react-native-modal';
import Cascader, { CascaderItem } from '../cascader';
import { convertRegions } from '../utils/cascaderData';
interface LocationModalProps {
modalVisible: boolean;
setModalVisible: (visible: boolean) => void;
podiumPosition: { x: number, y: number, width: number, height: number };
handleChange: (selectedItems: CascaderItem[]) => void;
data: CascaderItem[];
}
const LocationModal = React.memo((props: LocationModalProps) => {
const { modalVisible, setModalVisible, podiumPosition, handleChange } = props;
const transformed = convertRegions(locationData, {
nameKey: 'name', // 源数据中表示"名称"的字段
valueKey: 'name', // 源数据中作为 value 的字段
regionsKey: 'regions', // 源数据中表示"子级区域"的字段
childrenKey: 'children' // 输出结构中表示"子级区域"的字段
});
const { modalVisible, setModalVisible, podiumPosition, handleChange, data } = props;
const { t } = useTranslation();
return (
<Modal
isVisible={modalVisible}
onBackdropPress={() => setModalVisible(false)}
swipeDirection="right" // 支持向右滑动关闭
swipeDirection={['right']} // 改为数组形式
propagateSwipe={true}
animationIn="slideInRight" // 入场动画
animationOut="slideOutRight" // 出场动画
animationIn="slideInRight"
animationOut="slideOutRight"
backdropOpacity={0.5}
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.modalHeader}>
@ -45,7 +47,7 @@ const LocationModal = React.memo((props: LocationModalProps) => {
</View>
<ScrollView style={styles.modalContent} showsVerticalScrollIndicator={false}>
<Cascader
data={transformed}
data={data}
onChange={handleChange}
/>
</ScrollView>

View File

@ -3,22 +3,34 @@ import FirstSvg from "@/assets/icons/svg/first.svg";
import SecondSvg from "@/assets/icons/svg/second.svg";
import ThirdSvg from "@/assets/icons/svg/third.svg";
import { RankingItem } from "@/types/user";
import { useState } from "react";
import { Image, StyleSheet, View } from "react-native";
import { ThemedText } from "../ThemedText";
interface IPodium {
data: RankingItem[]
}
const PodiumComponent = ({ data }: IPodium) => {
return (
<View style={styles.container}>
<View style={[styles.item, { opacity: data[1]?.user_id ? 1 : 0 }]}>
<SecondSvg />
<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 }} />
: <UserSvg width={30} height={30} />
(() => {
const [imageError, setImageError] = useState(false);
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
numberOfLines={1}
@ -33,9 +45,21 @@ const PodiumComponent = ({ data }: IPodium) => {
<FirstSvg />
<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 }} />
: <UserSvg width={40} height={40} />
(() => {
const [imageError, setImageError] = useState(false);
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
numberOfLines={2}
@ -50,9 +74,21 @@ const PodiumComponent = ({ data }: IPodium) => {
<ThirdSvg />
<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 }} />
: <UserSvg width={20} height={20} />
(() => {
const [imageError, setImageError] = useState(false);
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
numberOfLines={1}

View File

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

View File

@ -1,10 +1,17 @@
// 地理位置逆编码
import { fetchApi } from '@/lib/server-api-util';
import * as ImagePicker from 'expo-image-picker';
import * as Location from 'expo-location';
import * as Notifications from 'expo-notifications';
import * as SecureStore from 'expo-secure-store';
import { Alert, Linking, Platform } from 'react-native';
interface Address {
id: number;
name: string;
// Add other address properties as needed
}
// 配置通知处理器
Notifications.setNotificationHandler({
handleNotification: async () => ({
@ -16,9 +23,11 @@ Notifications.setNotificationHandler({
}),
});
// 逆编码
export const reverseGeocode = async (latitude: number, longitude: number) => {
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) {
console.log('地址:', address);
if (Platform.OS === 'web') {
@ -29,7 +38,7 @@ export const reverseGeocode = async (latitude: number, longitude: number) => {
return address;
}
} catch (error) {
console.error('逆地理编码失败:', error);
console.log('逆地理编码失败:', error);
}
};
@ -300,3 +309,33 @@ export const sendLocalNotification = async (title: string, body: string, data: R
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-image-manipulator": "~13.1.7",
"expo-image-picker": "~16.1.4",
"expo-linear-gradient": "~14.1.5",
"expo-linking": "~7.1.7",
"expo-localization": "^16.1.5",
"expo-location": "~18.1.5",

View File

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