|
|
@@ -0,0 +1,394 @@
|
|
|
+<template>
|
|
|
+ <FlexCol innerClass="travel-route">
|
|
|
+ <FlexCol position="absolute" :top="0" :left="0" :right="0" :zIndex="100">
|
|
|
+ <StatusBarSpace backgroundColor="transparent">
|
|
|
+ <template #background>
|
|
|
+ <BackgroundBox
|
|
|
+ width="fill"
|
|
|
+ height="300"
|
|
|
+ color1="white"
|
|
|
+ color2="transparent"
|
|
|
+ />
|
|
|
+ </template>
|
|
|
+ </StatusBarSpace>
|
|
|
+ <NavBar
|
|
|
+ title="路线地图"
|
|
|
+ leftButton="back"
|
|
|
+ />
|
|
|
+ </FlexCol>
|
|
|
+ <map
|
|
|
+ id="map"
|
|
|
+ :markers="markers"
|
|
|
+ :latitude="currentLocation[1]"
|
|
|
+ :longitude="currentLocation[0]"
|
|
|
+ :polyline="polyline"
|
|
|
+ :circles="circles"
|
|
|
+ :show-location="true"
|
|
|
+ :enable-3D="true"
|
|
|
+ :show-compass="true"
|
|
|
+ @marker-tap="onMarkerClick"
|
|
|
+ />
|
|
|
+ <BottomSheet
|
|
|
+ ref="bottomSheetRef"
|
|
|
+ :show="true"
|
|
|
+ :dragMaxHeight="700"
|
|
|
+ :dragMinHeight="80"
|
|
|
+ :dragSnapHeights="[ 80, 300, 700 ]"
|
|
|
+ >
|
|
|
+ <template #content>
|
|
|
+ <!-- 景点详情 -->
|
|
|
+ <FlexCol v-if="currentScenicSpot" position="relative" :padding="30" :gap="20">
|
|
|
+ <FlexRow :gap="20" justify="space-between">
|
|
|
+ <IconButton
|
|
|
+ icon="arrow-left-bold"
|
|
|
+ shape="round"
|
|
|
+ width="40"
|
|
|
+ height="40"
|
|
|
+ @click="currentScenicSpot = undefined"
|
|
|
+ />
|
|
|
+ <Image :src="currentScenicSpot.image" :radius="20" :width="80" :height="80" mode="aspectFill" />
|
|
|
+ <FlexCol>
|
|
|
+ <Text>{{ currentScenicSpot.name }}</Text>
|
|
|
+ <Text>{{ currentScenicSpot.address }}</Text>
|
|
|
+ </FlexCol>
|
|
|
+ <Button
|
|
|
+ text="导航"
|
|
|
+ icon="map"
|
|
|
+ size="large"
|
|
|
+ shape="round"
|
|
|
+ :radius="40"
|
|
|
+ @click="openLocation(currentScenicSpot)"
|
|
|
+ />
|
|
|
+ </FlexRow>
|
|
|
+ <scroll-view :scroll-x="true" :style="{ height: '100%' }">
|
|
|
+ <FlexRow :gap="10">
|
|
|
+ <Image
|
|
|
+ v-for="image in currentScenicSpot.images"
|
|
|
+ :key="image"
|
|
|
+ :src="image"
|
|
|
+ :radius="20"
|
|
|
+ :width="600"
|
|
|
+ :height="280"
|
|
|
+ mode="aspectFill"
|
|
|
+ touchable
|
|
|
+ @click="previewImage(image, currentScenicSpot.images)"
|
|
|
+ />
|
|
|
+ </FlexRow>
|
|
|
+ </scroll-view>
|
|
|
+ <Parse :content="currentScenicSpot.intro" />
|
|
|
+ </FlexCol>
|
|
|
+ <!-- 路线规划 -->
|
|
|
+ <FlexCol v-else-if="travalDetail" :padding="25" :gap="20">
|
|
|
+ <FlexCol>
|
|
|
+ <H2>{{ travalDetail.title }}</H2>
|
|
|
+ <Text>{{ travalDetail.desc }}</Text>
|
|
|
+ </FlexCol>
|
|
|
+ <FlexCol :gap="20">
|
|
|
+ <Text color="text.second">导航偏好</Text>
|
|
|
+ <scroll-view :scroll-x="true" :style="{ width: '100%' }">
|
|
|
+ <FlexRow :gap="10">
|
|
|
+ <Button
|
|
|
+ v-for="way in preferredWays"
|
|
|
+ :key="way"
|
|
|
+ :text="getRouteToNextBestWayText(way as RouteToNextBestWay)"
|
|
|
+ :type="way === userPreferredWay ? 'primary' : 'default'"
|
|
|
+ @click="(userPreferredWay = way as RouteToNextBestWay)"
|
|
|
+ />
|
|
|
+ </FlexRow>
|
|
|
+ </scroll-view>
|
|
|
+ </FlexCol>
|
|
|
+ <FlexCol :gap="20">
|
|
|
+ <Text color="text.second">路线规划</Text>
|
|
|
+ <Touchable
|
|
|
+ v-for="item in routeInfo?.items || []" :key="item.id"
|
|
|
+ :gap="20"
|
|
|
+ direction="column"
|
|
|
+ @click="onItemClick(item)"
|
|
|
+ >
|
|
|
+ <FlexRow :gap="20">
|
|
|
+ <Image :src="item.scenicSpot.image" :radius="20" :width="80" :height="80" mode="aspectFill" />
|
|
|
+ <FlexCol>
|
|
|
+ <Text bold :color="routeColors[item.atDay]">第 {{ item.atDay }} 天</Text>
|
|
|
+ <Text>{{ item.scenicSpot.name }}</Text>
|
|
|
+ <Text color="text.second">{{ item.scenicSpot.address }}</Text>
|
|
|
+ </FlexCol>
|
|
|
+ </FlexRow>
|
|
|
+ <FlexRow align="center" :margin="[0,0,10,100]" :gap="12">
|
|
|
+ <template v-if="item.toNextRoute && item.toNextRoute['transit']">
|
|
|
+ <template v-if="userPreferredWay">
|
|
|
+ <Tag :text="getRouteToNextBestWayText(userPreferredWay || 'transit')" touchable @click="focusRoute(item)" />
|
|
|
+ <Text :text="formatDistance(item.toNextRoute[userPreferredWay || 'transit']?.distance)" />
|
|
|
+ <Text :text="formatDuration(item.toNextRoute[userPreferredWay || 'transit']?.duration)" />
|
|
|
+ </template>
|
|
|
+ <template v-else>
|
|
|
+ <Tag :text="'推荐 ' + getRouteToNextBestWayText(item.toNextBestWay || 'transit')" touchable @click="focusRoute(item)" />
|
|
|
+ <Text :text="formatDistance(item.toNextRoute[item.toNextBestWay || 'transit']?.distance)" />
|
|
|
+ <Text :text="formatDuration(item.toNextRoute[item.toNextBestWay || 'transit']?.duration)" />
|
|
|
+ </template>
|
|
|
+ </template>
|
|
|
+ </FlexRow>
|
|
|
+ <!-- 公交地铁路线规划 -->
|
|
|
+ <FlexCol
|
|
|
+ v-if="item.toNextRoute && item.toNextRoute['transit'] && (userPreferredWay === 'transit' || item.toNextBestWay === 'transit' || !item.toNextBestWay)"
|
|
|
+ :gap="8"
|
|
|
+ :margin="[0,0,0,100]"
|
|
|
+ >
|
|
|
+ <FlexCol v-for="(step, stepIdx) in (item.toNextRoute.transit.steps || [])" :key="stepIdx" :gap="4">
|
|
|
+ <template v-if="step.mode === 'WALKING'">
|
|
|
+ <FlexRow :gap="8" align="center">
|
|
|
+ <Text color="text.second">{{ stepIdx + 1 }}. 步行</Text>
|
|
|
+ <Text>{{ formatDistance(step.distance) }} · 约 {{ formatDuration(step.duration) }}</Text>
|
|
|
+ </FlexRow>
|
|
|
+ </template>
|
|
|
+ <template v-else-if="step.mode === 'TRANSIT' && step.lines?.length">
|
|
|
+ <FlexCol v-for="(line, lineIdx) in step.lines" :key="lineIdx" :gap="2">
|
|
|
+ <FlexRow :gap="8" align="center">
|
|
|
+ <Text color="text.second">{{ stepIdx + 1 }}. {{ line.vehicle === 'BUS' ? '公交' : line.vehicle === 'SUBWAY' ? '地铁' : line.vehicle === 'RAIL' ? '火车' : '公交' }}</Text>
|
|
|
+ <Text bold>{{ line.title }}</Text>
|
|
|
+ </FlexRow>
|
|
|
+ <Text color="text.second" :margin="[0,0,0,8]">{{ line.geton?.title }} → {{ line.getoff?.title }}</Text>
|
|
|
+ <Text color="text.second">{{ formatDistance(line.distance) }} · 约 {{ formatDuration(line.duration) }} · {{ line.station_count || 0 }} 站</Text>
|
|
|
+ </FlexCol>
|
|
|
+ </template>
|
|
|
+ </FlexCol>
|
|
|
+ </FlexCol>
|
|
|
+ </Touchable>
|
|
|
+ </FlexCol>
|
|
|
+ </FlexCol>
|
|
|
+ </template>
|
|
|
+ </BottomSheet>
|
|
|
+ </FlexCol>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import type { GetContentDetailItem } from '@/api/CommonContent';
|
|
|
+import { waitTimeOut } from '@imengyu/imengyu-utils';
|
|
|
+import { onMounted, ref, watch } from 'vue';
|
|
|
+import { useLoadQuerys } from '@/common/composeabe/LoadQuerys';
|
|
|
+import TravalContent from '@/api/traval/TravalContent';
|
|
|
+import AppCofig from '@/common/config/AppCofig';
|
|
|
+import Text from '@/components/basic/Text.vue';
|
|
|
+import H2 from '@/components/typography/H2.vue';
|
|
|
+import BottomSheet, { type BottomSheetExpose } from '@/components/dialog/BottomSheet.vue';
|
|
|
+import BackgroundBox from '@/components/display/block/BackgroundBox.vue';
|
|
|
+import FlexCol from '@/components/layout/FlexCol.vue';
|
|
|
+import StatusBarSpace from '@/components/layout/space/StatusBarSpace.vue';
|
|
|
+import NavBar from '@/components/nav/NavBar.vue';
|
|
|
+import FlexRow from '@/components/layout/FlexRow.vue';
|
|
|
+import Image from '@/components/basic/Image.vue';
|
|
|
+import RouteApi, {formatDistance, formatDuration, getRouteToNextBestWayText, type RouteInfo, type RouteToNextBestWay } from '@/api/traval/RouteApi';
|
|
|
+import Tag from '@/components/display/Tag.vue';
|
|
|
+import Button from '@/components/basic/Button.vue';
|
|
|
+import Touchable from '@/components/feedback/Touchable.vue';
|
|
|
+import Parse from '@/components/display/parse/Parse.vue';
|
|
|
+import IconButton from '@/components/basic/IconButton.vue';
|
|
|
+
|
|
|
+const markers = ref<any[]>([]);
|
|
|
+/** 地图折线:每条 toNextRoute 一段,points 来自 toNextRoute.xxx.polyline (number[] 为 经度,纬度 交替) */
|
|
|
+/** 地图折线项,支持微信小程序 map 的 segmentTexts / textStyle 在折线上显示文字 */
|
|
|
+const polyline = ref<Array<{
|
|
|
+ points: { longitude: number; latitude: number }[];
|
|
|
+ color?: string;
|
|
|
+ width?: number;
|
|
|
+ segmentTexts?: { name: string; startIndex: number; endIndex: number }[];
|
|
|
+ textStyle?: { textColor?: string; strokeColor?: string; fontSize?: number };
|
|
|
+}>>([]);
|
|
|
+const circles = ref<any[]>([]);
|
|
|
+
|
|
|
+const mapCtx = uni.createMapContext('map');
|
|
|
+const currentLocation = ref<[number, number]>([0, 0]);
|
|
|
+const travalDetail = ref<GetContentDetailItem>();
|
|
|
+const routeInfo = ref<RouteInfo>();
|
|
|
+const currentScenicSpot = ref<RouteInfo['items'][number]['scenicSpot'] | undefined>();
|
|
|
+const userPreferredWay = ref<RouteToNextBestWay|''>('');
|
|
|
+const bottomSheetRef = ref<BottomSheetExpose>();
|
|
|
+
|
|
|
+const { querys } = useLoadQuerys({
|
|
|
+ id: 8768,
|
|
|
+}, () => {
|
|
|
+ loadRoute();
|
|
|
+});
|
|
|
+
|
|
|
+const routeColors = [ '', '#22ac38', '#00a0e9', '#8957a1', '#eb6877', '#f39800', '#e60012' ];
|
|
|
+const preferredWays = ['', 'driving', 'walking', 'bicycling', 'ebicycling', 'transit'];
|
|
|
+function getMakerImage(day: number) {
|
|
|
+ return `https://mncdn.wenlvti.net/app_static/minnan/images/IcoMaker${day}.png`;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 腾讯地图 polyline 解压并转为地图组件 points
|
|
|
+ * 文档:https://lbs.qq.com/service/webService/webServiceGuide/route/webServiceRoute#8
|
|
|
+ * 格式:[坐标1纬度, 坐标1经度, 坐标2纬度, 坐标2经度, ...],首点为原始坐标,后续为前向差分压缩
|
|
|
+ */
|
|
|
+function polylineToPoints(encoded: number[]): { longitude: number; latitude: number }[] {
|
|
|
+ if (!encoded?.length) return [];
|
|
|
+ const coors = encoded.slice();
|
|
|
+ for (let i = 2; i < coors.length; i++) {
|
|
|
+ coors[i] = coors[i - 2] + coors[i] / 1000000;
|
|
|
+ }
|
|
|
+ const points: { longitude: number; latitude: number }[] = [];
|
|
|
+ for (let i = 0; i + 1 < coors.length; i += 2) {
|
|
|
+ points.push({ latitude: coors[i], longitude: coors[i + 1] });
|
|
|
+ }
|
|
|
+ return points;
|
|
|
+}
|
|
|
+/** 从 transit 的 steps 中收集所有 polyline(步行段 + 每条公交/地铁线路的 polyline) */
|
|
|
+function collectTransitPolylines(transitData: RouteInfo['items'][0]['toNextRoute']['transit']): number[][] {
|
|
|
+ const result: number[][] = [];
|
|
|
+ if (!transitData?.steps?.length) return result;
|
|
|
+ for (const step of transitData.steps) {
|
|
|
+ if (step.mode === 'WALKING' && step.polyline?.length) {
|
|
|
+ result.push(step.polyline);
|
|
|
+ } else if (step.mode === 'TRANSIT' && step.lines?.length) {
|
|
|
+ for (const line of step.lines) {
|
|
|
+ if (line.polyline?.length) result.push(line.polyline);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+}
|
|
|
+
|
|
|
+/** 折线说明文案(用于地图 segmentTexts,不含 emoji) */
|
|
|
+function getSegmentLabel(way: RouteToNextBestWay, atDay: number, duration?: number): string {
|
|
|
+ const wayName = { driving: '驾车', walking: '步行', bicycling: '骑行', ebicycling: '电动车', transit: '公交' }[way];
|
|
|
+ const dur = duration != null ? ` 约${formatDuration(duration)}` : '';
|
|
|
+ return `第${atDay}天 ${wayName}${dur}`;
|
|
|
+}
|
|
|
+
|
|
|
+/** 根据 toNextRoute 与 toNextBestWay 生成地图 polyline 数组(含 segmentTexts 折线说明) */
|
|
|
+function buildPolylineFromRouteInfo(info: RouteInfo) {
|
|
|
+ const segments: Array<{
|
|
|
+ points: { longitude: number; latitude: number }[];
|
|
|
+ color: string;
|
|
|
+ width: number;
|
|
|
+ segmentTexts?: { name: string; startIndex: number; endIndex: number }[];
|
|
|
+ textStyle?: { textColor: string; strokeColor: string; fontSize: number };
|
|
|
+ }> = [];
|
|
|
+ const items = info.items;
|
|
|
+ const textStyle = { textColor: '#ffffff', strokeColor: '#000000', fontSize: 12 };
|
|
|
+
|
|
|
+ for (let i = 0; i < items.length - 1; i++) {
|
|
|
+ const item = items[i];
|
|
|
+ const way: RouteToNextBestWay = (userPreferredWay.value || item.toNextBestWay || 'driving') as RouteToNextBestWay;
|
|
|
+ const routeData = item.toNextRoute?.[way];
|
|
|
+ const color = routeColors[item.atDay];
|
|
|
+ const width = 6;
|
|
|
+
|
|
|
+ if (way === 'transit') {
|
|
|
+ const transitData = item.toNextRoute?.transit;
|
|
|
+ const polylines = collectTransitPolylines(transitData!);
|
|
|
+ const label = getSegmentLabel('transit', item.atDay, transitData?.duration);
|
|
|
+ for (let k = 0; k < polylines.length; k++) {
|
|
|
+ const points = polylineToPoints(polylines[k]);
|
|
|
+ if (points.length < 2) continue;
|
|
|
+ const segmentTexts = k === 0 ? [{ name: label, startIndex: 0, endIndex: points.length - 1 }] : undefined;
|
|
|
+ segments.push({ points, color, width, segmentTexts, textStyle: segmentTexts ? textStyle : undefined });
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ const encoded = (routeData as { polyline?: number[]; duration?: number })?.polyline;
|
|
|
+ if (!encoded?.length) continue;
|
|
|
+ const points = polylineToPoints(encoded);
|
|
|
+ if (points.length < 2) continue;
|
|
|
+ const duration = (routeData as { duration?: number })?.duration;
|
|
|
+ const segmentTexts = [{ name: getSegmentLabel(way, item.atDay, duration), startIndex: 0, endIndex: points.length - 1 }];
|
|
|
+ segments.push({ points, color, width, segmentTexts, textStyle });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ polyline.value = segments;
|
|
|
+}
|
|
|
+
|
|
|
+function onMarkerClick(item: any) {
|
|
|
+ console.log(item);
|
|
|
+}
|
|
|
+function openLocation(item: RouteInfo['items'][number]['scenicSpot']) {
|
|
|
+ uni.openLocation({
|
|
|
+ latitude: item.latitude,
|
|
|
+ longitude: item.longitude,
|
|
|
+ });
|
|
|
+}
|
|
|
+function onItemClick(item: RouteInfo['items'][number]) {
|
|
|
+ currentScenicSpot.value = item.scenicSpot;
|
|
|
+ currentLocation.value = [item.scenicSpot.longitude, item.scenicSpot.latitude];
|
|
|
+ bottomSheetRef.value?.setDragHeightToMax();
|
|
|
+}
|
|
|
+function previewImage(image: string, images: string[]) {
|
|
|
+ uni.previewImage({
|
|
|
+ urls: images,
|
|
|
+ current: images.indexOf(image),
|
|
|
+ });
|
|
|
+}
|
|
|
+function focusRoute(item: RouteInfo['items'][number]) {
|
|
|
+ bottomSheetRef.value?.setDragHeightToMin();
|
|
|
+ currentLocation.value = [item.scenicSpot.longitude, item.scenicSpot.latitude];
|
|
|
+}
|
|
|
+async function loadRoute() {
|
|
|
+ await waitTimeOut(500);
|
|
|
+ travalDetail.value = await TravalContent.getTravalList(querys.value.id);
|
|
|
+ routeInfo.value = await RouteApi.getRouteInfo(querys.value.id);
|
|
|
+ await loadMap();
|
|
|
+}
|
|
|
+async function loadMap() {
|
|
|
+ if (!routeInfo.value) return;
|
|
|
+
|
|
|
+ // 生成标记点
|
|
|
+ const tempMarkers = routeInfo.value.items
|
|
|
+ .map(p => ({
|
|
|
+ latitude: p.scenicSpot.latitude,
|
|
|
+ longitude: p.scenicSpot.longitude,
|
|
|
+ iconPath: getMakerImage(p.atDay),
|
|
|
+ callout: {
|
|
|
+ content: p.scenicSpot.name,
|
|
|
+ color: "#ffffff",
|
|
|
+ fontSize: 15,
|
|
|
+ borderRadius: 15,
|
|
|
+ padding: "10",
|
|
|
+ bgColor: routeColors[p.atDay],
|
|
|
+ display: "ALWAYS",
|
|
|
+ },
|
|
|
+ width: 40,
|
|
|
+ height: 40,
|
|
|
+ }));
|
|
|
+
|
|
|
+ // 根据 toNextRoute 绘制路线
|
|
|
+ buildPolylineFromRouteInfo(routeInfo.value);
|
|
|
+
|
|
|
+ await waitTimeOut(500);
|
|
|
+
|
|
|
+ markers.value = tempMarkers;
|
|
|
+
|
|
|
+ mapCtx.includePoints({
|
|
|
+ points: tempMarkers.map(p => ({
|
|
|
+ latitude: p.latitude,
|
|
|
+ longitude: p.longitude,
|
|
|
+ })),
|
|
|
+ padding: [40, 40, 120, 40],
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+watch(userPreferredWay, () => {
|
|
|
+ loadMap()
|
|
|
+});
|
|
|
+onMounted(() => {
|
|
|
+ currentLocation.value = [AppCofig.defaultLonLat[0], AppCofig.defaultLonLat[1]];
|
|
|
+ uni.getLocation({
|
|
|
+ type: 'wgs84',
|
|
|
+ success: (res) => {
|
|
|
+ currentLocation.value = [res.longitude, res.latitude];
|
|
|
+ },
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss">
|
|
|
+.travel-route {
|
|
|
+ position: relative;
|
|
|
+ width: 100%;
|
|
|
+ height: 100vh;
|
|
|
+
|
|
|
+ map {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|