瀏覽代碼

📦 非遗路线规划功能

快乐的梦鱼 3 周之前
父節點
當前提交
b888ea4330

+ 68 - 1
src/api/RequestModules.ts

@@ -6,7 +6,8 @@
 
 import type { DataModel, NewDataModel } from "@imengyu/js-request-transform";
 import { BaseAppServerRequestModule } from "./BaseAppServerRequestModule";
-import { defaultResponseDataHandlerCatch, RequestApiError, RequestApiResult, RequestCoreInstance, RequestOptions, RequestResponse, type RequestApiInfoStruct } from "@imengyu/imengyu-utils";
+import { appendGetUrlParams, defaultResponseDataHandlerCatch, RequestApiError, RequestApiResult, RequestCoreInstance, RequestOptions, RequestResponse, type RequestApiInfoStruct } from "@imengyu/imengyu-utils";
+import ApiCofig from "@/common/config/ApiCofig";
 
 /**
  * 主应用服务请求模块
@@ -78,4 +79,70 @@ export class UpdateServerRequestModule<T extends DataModel> extends BaseAppServe
       }
     };
   }
+}
+
+/**
+ * 腾讯地图服务请求模块
+ */
+export class MapServerRequestModule<T extends DataModel> extends BaseAppServerRequestModule<T> {
+  constructor() {
+    super("https://apis.map.qq.com");
+    this.config.requestInterceptor = (url, req) => {
+      url = appendGetUrlParams(url, 'key', ApiCofig.mapKey);
+      return { newUrl: url, newReq: req };
+    };
+    this.config.responseDataHandler = async function responseDataHandler<T extends DataModel>(response: RequestResponse, req: RequestOptions, resultModelClass: NewDataModel | undefined, instance: RequestCoreInstance<T>, apiInfo: RequestApiInfoStruct): Promise<RequestApiResult<T>> {
+      const method = req.method || 'GET';
+      try {
+        const json = await response.json();
+        if (response.ok) {
+          if (!json) {
+            throw new RequestApiError(
+              'businessError',
+              '后端未返回数据',
+              '',
+              response.status,
+              null,
+              null,
+              response.headers,
+              apiInfo
+            );
+          }
+          if (json.status !== 0)
+            throw new RequestApiError(
+              'businessError',
+              json.message,
+              json.status.toString(),
+              json.status,
+              json,
+              json,
+              response.headers,
+              apiInfo
+            );
+          
+          return new RequestApiResult(
+            resultModelClass ?? instance.config.modelClassCreator,
+            json?.status || response.status,
+            json.message,
+            json.result,
+            json,
+            response.headers,
+            apiInfo
+          );
+        }
+        else {
+          throw json;
+        }
+
+      } catch (err) {
+        if (err instanceof RequestApiError) {
+          throw response;
+        }
+        //错误统一处理
+        return new Promise<RequestApiResult<T>>((resolve, reject) => {
+          defaultResponseDataHandlerCatch(method, req, response, null, err as any, apiInfo, response.url, reject, instance);
+        });
+      }
+    };
+  }
 }

+ 265 - 0
src/api/map/MapApi.ts

@@ -0,0 +1,265 @@
+import type { DataModel } from "@imengyu/js-request-transform";
+import { MapServerRequestModule } from "../RequestModules";
+
+/** 路线规划 - 阶段路线步骤(驾车/步行/骑行等通用) */
+export interface DirectionStep {
+  instruction: string;
+  polyline_idx: number[] | [number, number];
+  road_name?: string;
+  dir_desc?: string;
+  distance: number;
+  duration?: number;
+  act_desc?: string;
+  accessorial_desc?: string;
+  type?: number; // 步行设施类型:0普通道路 1过街天桥 2地下通道 3人行横道
+  speed?: { polyline_idx: number[]; distance: number; level: number }[];
+}
+
+/** 路线规划 - 单条方案(驾车) */
+export interface DrivingRoute {
+  mode: 'DRIVING';
+  tags?: string[];
+  distance: number;
+  duration: number;
+  traffic_light_count?: number;
+  toll?: number;
+  restriction?: { status: number };
+  polyline: number[];
+  waypoints?: { title?: string; location: { lat: number; lng: number }; polyline_idx?: number; distance?: number; duration?: number; input_order_idx?: number }[];
+  steps: DirectionStep[];
+  taxi_fare?: { fare: number };
+}
+
+/** 路线规划 - 单条方案(步行/骑行/电动车) */
+export interface WalkingRoute {
+  mode: 'WALKING' | 'BICYCLING' | 'EBICYCLING';
+  distance: number;
+  duration: number;
+  direction?: string;
+  polyline: number[];
+  steps: DirectionStep[];
+}
+
+/** 路线规划 - 驾车结果 */
+export interface DirectionDrivingResult {
+  routes: DrivingRoute[];
+}
+
+/** 路线规划 - 步行/骑行/电动车结果 */
+export interface DirectionWalkingResult {
+  routes: WalkingRoute[];
+}
+
+/** 路线规划 - 公交步骤(步行为 WALKING,公交为 TRANSIT,结构不同) */
+export type TransitStep = (
+  | { mode: 'WALKING'; distance: number; duration: number; direction: string; polyline: number[]; steps?: DirectionStep[] }
+  | { mode: 'TRANSIT'; vehicle: string; [key: string]: unknown }
+);
+
+/** 路线规划 - 公交结果 */
+export interface DirectionTransitResult {
+  routes: { distance: number; duration: number; bounds: string; steps: TransitStep[] }[];
+}
+
+export class MapApi extends MapServerRequestModule<DataModel> {
+  constructor() {
+    super();
+  }
+
+  /**
+   * 逆地址解析(坐标位置描述)
+   * 
+   * 文档:https://lbs.qq.com/service/webService/webServiceGuide/address/Gcoder
+   * 
+   * 说明:
+   * 本接口提供由经纬度到文字地址及相关位置信息的转换能力,广泛应用于物流、出行、O2O、社交等场景。服务响应速度快、稳定,支撑亿级调用。
+   * 支持根据输入经纬度,获取:
+   * 1 . 经纬度所在省、市、区、乡镇、门牌号、行政区划代码,及周边参考位置信息,如道路及交叉口、河流、湖泊、桥等
+   * 2 . 通过知名地点、地标组合形成的易于理解的地址,如:北京市海淀区中钢国际广场(欧美汇购物中心北)。
+   * 3 . 商圈、附近知名的一级地标、代表当前位置的二级地标等。
+   * 4 . 周边POI(AOI)列表。
+   * 
+   * @param location 经纬度(GCJ02坐标系),格式:location=lat<纬度>,lng<经度>
+   * @param radius 半径,单位:米
+   * @param get_poi 是否获取周边POI(AOI)列表
+   * @param poi_options 周边POI(AOI)列表选项,格式:poi_options=type<POI类型>,radius<半径>,数量<数量>
+   * @returns 
+   */
+  async geocoder(location: string, radius?: number, get_poi?: boolean, poi_options?: string) {
+    return (await this.get<{
+      address: string;
+      formatted_addresses: {
+        recommend: string;
+        rough: string;
+        standard_address: string;
+      };
+      address_component: {
+        nation: string;
+        province: string;
+        city: string;
+        district: string;
+        street: string;
+        street_number: string;
+      };
+    }>('/ws/geocoder/v1/', '逆地址解析(坐标位置描述)', {
+      location,
+      radius,
+      get_poi: get_poi ? 1 : 0,
+      poi_options,
+    })).data;
+  }
+
+  /**
+   * 地址解析(地址转坐标)
+   *
+   * 文档:https://lbs.qq.com/service/webService/webServiceGuide/address/Geocoder
+   *
+   * 说明:
+   * 本接口提供由文字地址到经纬度的转换能力,并同时提供结构化的省市区地址信息。
+   * 为提升解析准确率,地址中请至少包含城市名称;地址请尽量完整、具体(包括省市区乡镇/街道门牌及详细地点信息)。
+   *
+   * @param address 要解析的输入地址(建议包含城市,尽量完整;若包含 # 等特殊字符需先 URL 编码)
+   * @param policy 解析策略:0 标准(默认,地址须包含城市);1 宽松(允许缺失城市,准确性可能受影响)
+   * @returns 解析结果,含坐标(GCJ02)、省市区等结构化信息及可信度、解析级别
+   */
+  async geocodeAddress(
+    address: string,
+    policy?: 0 | 1
+  ) {
+    return (await this.get<{
+      /** [废弃] 最终用于坐标解析的地址或地点名称,仅供参考 */
+      title: string;
+      /** 解析到的坐标(GCJ02 坐标系) */
+      location: { lat: number; lng: number };
+      /** 解析后的地址部件 */
+      address_components: {
+        province: string;
+        city: string;
+        district: string;
+        street: string;
+        street_number: string;
+      };
+      /** 行政区划信息 */
+      ad_info: { adcode: string };
+      /** 可信度 1–10,>=7 时解析结果较为准确 */
+      reliability: number;
+      /** 解析精度级别,一般 >=9 即可采用(定位到点) */
+      level?: number;
+    }>('/ws/geocoder/v1/', '地址解析(地址转坐标)', {
+      address,
+      ...(policy !== undefined && { policy }),
+    })).data;
+  }
+
+  /**
+   * 路线规划 - 驾车
+   *
+   * 文档:https://lbs.qq.com/service/webService/webServiceGuide/route/webServiceRoute
+   *
+   * @param from 起点坐标,格式:纬度,经度(如 39.915285,116.403857)
+   * @param to 终点坐标,格式:纬度,经度
+   * @param options 可选:waypoints 途经点、policy 策略、get_mp 多方案、get_speed 路况、no_step 不返回引导等
+   */
+  async directionDriving(
+    from: string,
+    to: string,
+    options?: {
+      from_poi?: string;
+      to_poi?: string;
+      to_poiname?: string;
+      waypoints?: string;
+      waypoint_order?: 0 | 1;
+      policy?: string;
+      plate_number?: string;
+      cartype?: 0 | 1;
+      get_mp?: 0 | 1;
+      get_speed?: 0 | 1;
+      no_step?: 0 | 1;
+      avoid_polygons?: string;
+      [key: string]: string | number | undefined;
+    }
+  ) {
+    return (await this.get<DirectionDrivingResult>('/ws/direction/v1/driving/', '路线规划-驾车', {
+      from,
+      to,
+      ...options,
+    })).data;
+  }
+
+  /**
+   * 路线规划 - 步行
+   *
+   * 文档:https://lbs.qq.com/service/webService/webServiceGuide/route/webServiceRoute
+   *
+   * @param from 起点坐标,格式:纬度,经度
+   * @param to 终点坐标,格式:纬度,经度(直线距离 10米–300公里)
+   */
+  async directionWalking(from: string, to: string, options?: { to_poi?: string }) {
+    return (await this.get<DirectionWalkingResult>('/ws/direction/v1/walking/', '路线规划-步行', {
+      from,
+      to,
+      ...options,
+    })).data;
+  }
+
+  /**
+   * 路线规划 - 骑行
+   *
+   * 文档:https://lbs.qq.com/service/webService/webServiceGuide/route/webServiceRoute
+   *
+   * @param from 起点坐标,格式:纬度,经度
+   * @param to 终点坐标,格式:纬度,经度(直线距离 10米–500公里)
+   */
+  async directionBicycling(from: string, to: string, options?: { to_poi?: string }) {
+    return (await this.get<DirectionWalkingResult>('/ws/direction/v1/bicycling/', '路线规划-骑行', {
+      from,
+      to,
+      ...options,
+    })).data;
+  }
+
+  /**
+   * 路线规划 - 电动车
+   *
+   * 文档:https://lbs.qq.com/service/webService/webServiceGuide/route/webServiceRoute
+   *
+   * @param from 起点坐标,格式:纬度,经度
+   * @param to 终点坐标,格式:纬度,经度
+   */
+  async directionEbicycling(from: string, to: string, options?: { to_poi?: string }) {
+    return (await this.get<DirectionWalkingResult>('/ws/direction/v1/ebicycling/', '路线规划-电动车', {
+      from,
+      to,
+      ...options,
+    })).data;
+  }
+
+  /**
+   * 路线规划 - 公交(含地铁、火车等)
+   *
+   * 文档:https://lbs.qq.com/service/webService/webServiceGuide/route/webServiceRoute
+   *
+   * @param from 起点坐标,格式:纬度,经度
+   * @param to 终点坐标,格式:纬度,经度
+   * @param options 可选:departure_time 出发时间戳、policy 偏好(LEAST_TIME/LEAST_TRANSFER/LEAST_WALKING/RECOMMEND 等)
+   */
+  async directionTransit(
+    from: string,
+    to: string,
+    options?: {
+      from_poi?: string;
+      to_poi?: string;
+      departure_time?: number;
+      policy?: string;
+      added_fields?: string;
+    }
+  ) {
+    return (await this.get<DirectionTransitResult>('/ws/direction/v1/transit/', '路线规划-公交', {
+      from,
+      to,
+      ...options,
+    })).data;
+  }
+}
+
+export default new MapApi();

+ 122 - 0
src/api/traval/RouteApi.ts

@@ -0,0 +1,122 @@
+import { UpdateServerRequestModule } from '@/api/RequestModules';
+import { DataModel } from '@imengyu/js-request-transform';
+
+export class RouteApi extends UpdateServerRequestModule<DataModel> {
+
+  constructor() {
+    super();
+  }
+
+  async getRouteInfo(outlinkId: number) {
+    return (await this.get<RouteInfo>('/scenic-spot-route/get-by-outlink-id/' + outlinkId, '获取路线信息')).data;
+  }
+}
+
+export interface RouteInfo {
+  id: number;
+  intro: string;
+  outlinkId: number;
+  items: {
+    id: number;
+    atDay: number;
+    costDays: number;
+    order: number;
+    routeId: number;
+    scenicSpot: {
+      id: number;
+      name: string;
+      address: string;
+      latitude: number;
+      longitude: number;
+      image: string;
+      images: string[];
+      intro: string;
+      outlinkId: number;
+    },
+    scenicSpotId: number;
+    toNextRoute: {
+      bicycling: RouteToNextRouteData;
+      driving: RouteToNextRouteData;
+      walking: RouteToNextRouteData;
+      ebicycling: RouteToNextRouteData;
+      transit: {
+        distance: number
+        duration: number;
+        steps: ({
+          direction: string;
+          distance: number;
+          duration: number;
+          mode: 'WALKING';
+          polyline: number[];
+          steps: RouteToNextRouteData;
+        }|{
+          mode: 'TRANSIT';
+          lines: {
+            destination: {id: string, title: string};
+            distance: number;
+            duration: number;
+            end_time: string;
+            getoff: {id: string, title: string, location: {lat: number, lng: number}};
+            geton: {id: string, title: string, location: {lat: number, lng: number}};
+            id: string;
+            polyline: number[];
+            price: number;
+            running_status: number;
+            start_time: string;
+            station_count: number;
+            stations: {id: string, title: string, location: {lat: number, lng: number}}[];
+            title: string;
+            vehicle: string;
+          }[];
+        })[];
+      };
+    };
+    toNextBestWay: '' | RouteToNextBestWay;
+  }[];
+}
+export type RouteToNextBestWay = 'driving' | 'walking' | 'bicycling' | 'ebicycling' | 'transit';
+interface RouteToNextRouteData {
+  distance: number
+  duration: number;
+  polyline: number[];
+  steps: {
+    act_desc: string;
+    dir_desc: string;
+    distance: number;
+    instruction: string;
+    polyline_idx: number[];
+    road_class: number;
+    road_name: string;
+  }[];
+}
+
+export function formatDistance(distance: number) {
+  if (distance < 1000) {
+    return distance + '米';
+  } else {
+    return (distance / 1000).toFixed(2) + 'km';
+  }
+}
+export function formatDuration(duration: number) {
+  if (duration >= 60)
+    return Math.floor(duration / 60) + '小时' + (duration % 60) + '分钟';
+  return duration + '分钟';
+}
+export function getRouteToNextBestWayText(way: RouteToNextBestWay|'') {
+  switch (way) {
+    case 'driving':
+      return '🚗 驾车';
+    case 'walking':
+      return '🚶 步行';
+    case 'ebicycling':
+      return '🚴 电动车';
+    case 'bicycling':
+      return '🚴 自行车';
+    case 'transit':
+      return '🚌 公交';
+    default:
+      return '🔄 灵活选择';
+  }
+}
+
+export default new RouteApi();

+ 41 - 0
src/api/traval/TravalContent.ts

@@ -0,0 +1,41 @@
+import { DataModel, transformDataModel } from '@imengyu/js-request-transform';
+import { CommonContentApi, GetContentDetailItem, GetContentListItem, GetContentListParams } from '../CommonContent';
+
+export class TravalListItem extends DataModel<TravalListItem> {
+  constructor() {
+    super(TravalListItem, "路线列表");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      longitude: { clientSide: 'number', serverSide: 'number' },
+      latitude: { clientSide: 'number', serverSide: 'number' },
+    }
+    const old = this._afterSolveServer;
+    this._afterSolveServer = (data) => {
+      old?.(data);
+    }
+  }
+  id = 0;
+  longitude = 0;
+  latitude = 0;
+  title = '';
+  desc = '';
+  image = '';
+  thumbnail = '';
+}
+
+export class TravalContentApi extends CommonContentApi {
+
+  constructor() {
+    super(undefined, 17, "路线");
+  }
+
+  async getTravalList(id: number) {
+    const res = await this.getContentDetail(id, GetContentDetailItem, 17, {
+      'scenic_spots': '1',
+    });
+    res.scenicSpotsList = (res.scenicSpotsList as any[]).map(p => transformDataModel<TravalListItem>(TravalListItem, p));
+    return res;
+  }
+}
+
+export default new TravalContentApi();

+ 1 - 0
src/common/config/ApiCofig.ts

@@ -3,6 +3,7 @@
  * 说明:后端接口配置
  */
 export default {
+  mapKey: 'LDXBZ-JIWWC-IXW2S-AUDZS-26VC2-GRBC4',
   mainBodyId: 1,
   platformId: 327,
   /**

+ 14 - 5
src/common/config/AppCofig.ts

@@ -4,16 +4,25 @@
  */
 export default {
   version: '0.0.1',
+  buildTime: (globalThis as any).__BUILD_TIMESTAMP__ as number,
+  buildInfo: (globalThis as any).__BUILD_GUID__ as string,
   appId: 'wx1845c7dab9e8b236',
   defaultLonLat: [ 118.161270, 24.529196 ],
   defaultImage: 'https://mncdn.wenlvti.net/app_static/minnan/EmptyImage.png',
   shareTitle: '',
 }
 
+const accountInfo = uni.getAccountInfoSync();
+
 /**
- * 图炫地图配置
+ * 获取当前环境版本
  */
-export function configAiMap() {
-}
-
-export const isDev = process.env.NODE_ENV === 'development';
+export const envVersion = accountInfo.miniProgram.envVersion;
+/**
+ * 获取当前环境是否为测试环境
+ */
+export const isTestEnv = envVersion === 'develop' || envVersion === 'trial';
+/**
+ * 获取当前环境是否为开发环境
+ */
+export const isDev = envVersion === 'develop';

+ 201 - 0
src/components/dialog/BottomSheet.vue

@@ -0,0 +1,201 @@
+<template>
+  <Popup
+    v-bind="props"
+    position="bottom"
+    round
+    :size="`${currentHeight}px`"
+    :show="show"
+    :noTransition="touching"
+    @close="onCancelClick"
+  >
+    <FlexCol 
+      position="relative"
+      backgroundColor="white" 
+      :innerStyle="{
+        ...themeStyles.bottomSheet.value,
+        height: `100%`,
+        width: `100%`,
+        ...innerStyle,
+      }"
+    >
+      <view 
+        v-if="enableDrag"
+        :style="{
+          display: 'flex',
+          justifyContent: 'center',
+          alignItems: 'center',
+        }"
+        @touchstart="onDragStart"
+        @touchmove="onDragMove"
+        @touchend="onDragEnd"
+      >
+        <view 
+          :style="{
+            ...themeStyles.dragHandle.value,
+            width: themeContext.resolveSize(dragHandleSize),
+            backgroundColor: themeContext.resolveThemeColor(dragHandleColor),
+          }" 
+        />
+      </view>
+      <scroll-view 
+        :scroll-y="true"
+        :style="{
+          height: `100%`,
+        }"
+      >
+        <slot name="content" :close="onCancelClick" />
+      </scroll-view>
+    </FlexCol>
+  </Popup>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+import { propGetThemeVar, useTheme, type ViewStyle } from '../theme/ThemeDefine';
+import { DynamicColor, DynamicSize } from '../theme/ThemeTools';
+import Popup from './Popup.vue';
+import FlexCol from '../layout/FlexCol.vue';
+import type { PopupProps } from './Popup.vue';
+
+export interface BottomSheetProps extends Omit<PopupProps, 'onClose'|'position'|'position'|'size'> {
+  /**
+   * 是否显示
+   * @default true
+   */
+  show: boolean;
+  /**
+   * 拖动把手颜色
+   * @default 'grey'
+   */
+  dragHandleColor?: string;
+  /**
+   * 拖动把手大小
+   * @default 100
+   */
+  dragHandleSize?: number;
+  /**
+   * 拖动时使高度吸附到指定高度
+   * @default undefined
+   */
+  dragSnapHeights?: number[]|undefined;
+  /**
+   * 是否启用拖动
+   * @default true
+   */
+  enableDrag?: boolean;
+  /**
+   * 底部弹窗大小(px)
+   * @default '300
+   */
+  height?: number;
+  /**
+   * 拖动最大高度(px)
+   * @default 1000
+   */
+  dragMaxHeight?: number;
+  /**
+   * 拖动最小高度(px)
+   * @default 100
+   */
+  dragMinHeight?: number;
+
+  innerStyle?: ViewStyle;
+}
+
+export interface BottomSheetExpose {
+  setDragHeight: (height: number) => void;
+  getDragHeight: () => number;
+  setDragHeightToMax: () => void;
+  setDragHeightToMin: () => void;
+}
+
+const emit = defineEmits([ 'close', 'select' ]);
+const props = withDefaults(defineProps<BottomSheetProps>(), {
+  enableDrag: true,
+  dragHandleColor: () => propGetThemeVar('BottomSheetDragHandleColor', 'grey'),
+  dragHandleSize: () => propGetThemeVar('BottomSheetDragHandleSize', 100),
+  centerWidth: () => propGetThemeVar('BottomSheetCenterWidth', '600rpx'),
+  height: () => propGetThemeVar('BottomSheetHeight', 300),
+  dragMaxHeight: () => propGetThemeVar('BottomSheetDragMaxHeight', 1000),
+  dragMinHeight: () => propGetThemeVar('BottomSheetDragMinHeight', 100),
+});
+const themeContext = useTheme();
+const themeStyles = themeContext.useThemeStyles({
+  bottomSheet: {
+    borderTopLeftRadius: DynamicSize('BottomSheetBorderRadius', 10),
+    borderTopRightRadius: DynamicSize('BottomSheetBorderRadius', 10),
+    backgroundColor: DynamicColor('BottomSheetBackgroundColor', 'white'),
+  },
+  dragHandle: {
+    height: DynamicSize('BottomSheetDragHandleSize', 10),
+    borderRadius: DynamicSize('BottomSheetDragHandleBorderRadius', 10),
+    marginTop: DynamicSize('BottomSheetDragHandleMarginVertical', 15),
+    marginBottom: DynamicSize('BottomSheetDragHandleMarginVertical', 15),
+  },
+});
+
+const currentHeight = ref(props.height);
+const touching = ref(false);
+let touchStartY = 0;
+let touchStartHeight = 0;
+
+function onDragStart(e: TouchEvent) {
+  if (!props.enableDrag) return;
+  e.preventDefault();
+  e.stopPropagation();
+  touchStartY = e.touches[0].clientY;
+  touchStartHeight = currentHeight.value;
+  touching.value = true;
+}
+function onDragMove(e: TouchEvent) {
+  if (!props.enableDrag || !touching.value) return;
+  e.preventDefault();
+  e.stopPropagation();
+  const deltaY = e.touches[0].clientY - touchStartY;
+  currentHeight.value = Math.max(props.dragMinHeight, 
+    Math.min(props.dragMaxHeight, touchStartHeight - deltaY)
+  );
+}
+function onDragEnd(e: TouchEvent) {
+  if (!props.enableDrag) return;
+  e.preventDefault();
+  e.stopPropagation();
+  touching.value = false;
+
+  //吸附到指定高度
+  if (props.dragSnapHeights) {
+    // 使当前高度吸附到最近的指定高度
+    const snapHeights = props.dragSnapHeights.slice().sort((a, b) => a - b);
+    let nearest = snapHeights[0];
+    let minDist = Math.abs(currentHeight.value - snapHeights[0]);
+    for (let i = 1; i < snapHeights.length; i++) {
+      const dist = Math.abs(currentHeight.value - snapHeights[i]);
+      if (dist < minDist) {
+        minDist = dist;
+        nearest = snapHeights[i];
+      }
+    }
+    currentHeight.value = nearest;
+  }
+}
+
+function onCancelClick() {
+  emit('close');
+}
+
+defineExpose<BottomSheetExpose>({
+  setDragHeight: (height: number) => {
+    currentHeight.value = height;
+  },
+  setDragHeightToMax: () => {
+    currentHeight.value = props.dragMaxHeight;
+  },
+  setDragHeightToMin: () => {
+    currentHeight.value = props.dragMinHeight;
+  },
+  getDragHeight: () => {
+    return currentHeight.value;
+  },
+});
+
+</script>

+ 5 - 5
src/components/dialog/DialogRoot.vue

@@ -15,7 +15,7 @@ export interface DialogAlertOptions extends Omit<DialogProps, 'show'> {
 }
 export interface DialogAlertRoot {
   confirm(options: DialogAlertOptions): Promise<boolean>;
-  alert(options: DialogAlertOptions): Promise<void>;
+  alert(options: DialogAlertOptions): Promise<string|undefined>;
 }
 
 const show = ref(false);
@@ -56,15 +56,15 @@ defineExpose<DialogAlertRoot>({
 
     const onConfirm = _options.onConfirm;
 
-    return new Promise<void>((resolve) => {
+    return new Promise<string|undefined>((resolve) => {
       (_options as any).onClose = () => {
         show.value = false;
-        resolve();
+        resolve(undefined);
       };
-      _options.onConfirm = () => {
+      _options.onConfirm = (buttonName?: string) => {
         show.value = false;
         onConfirm?.();
-        resolve();
+        resolve(buttonName);
       };
       options.value = _options;
       show.value = true;

+ 19 - 7
src/components/dialog/Popup.vue

@@ -51,7 +51,11 @@
     </view>
     <view 
       v-if="show2"
-      :class="[ 'nana-popup-content', position] "
+      :class="[ 
+        'nana-popup-content',
+        noTransition ? 'no-transition' : '',
+        position,
+      ] "
       :style="{
         ...selectStyleType(position, 'bottom', {
           center: {
@@ -62,25 +66,25 @@
             borderBottomLeftRadius: radius,
             borderBottomRightRadius: radius,
             width: '100%',
-            minHeight: dialogSize,
+            height: themeContext.resolveThemeSize(props.size),
           },
           bottom: {
             borderTopLeftRadius: radius,
             borderTopRightRadius: radius,
             width: '100%',
-            minHeight: dialogSize,
+            height: themeContext.resolveThemeSize(props.size),
           },
           left: {
             borderTopRightRadius: radius,
             borderBottomRightRadius: radius,
             height: '100%',
-            minWidth: dialogSize,
+            width: themeContext.resolveThemeSize(props.size),
           },
           right: {
             borderTopLeftRadius: radius,
             borderBottomLeftRadius: radius,
             height: '100%',
-            minWidth: dialogSize,
+            width: themeContext.resolveThemeSize(props.size),
           },
         }),
         zIndex: popupZIndex + 2,
@@ -217,6 +221,11 @@ export interface PopupProps {
    * @default 0
    */
   zIndex?: number;
+  /**
+   * 是否禁用动画,默认是 false
+   * @default false
+   */
+  noTransition?: boolean;
 }
 
 const emit = defineEmits([ 'update:show', 'close', 'closeAnimFinished' ])
@@ -254,7 +263,6 @@ const themeContext = useTheme();
 const show2 = ref(false);
 const showAnimState = ref(false);
 const radius = computed(() => props.round ? themeContext.resolveThemeSize('PopupRadius', 30) : 0);
-const dialogSize = computed(() => themeContext.resolveThemeSize(props.size));
 const popupZIndex = ref(props.zIndex);
 
 let lateStopTimer : SimpleDelay|undefined;
@@ -280,7 +288,7 @@ watch(() => props.show, (v) => {
     }, 20)
     lateStopTimer.start();
   }
-});
+}, { immediate: true });
 </script>
 
 <style lang="scss">
@@ -327,6 +335,10 @@ watch(() => props.show, (v) => {
     opacity: 0.3;
     pointer-events: auto;
 
+    &.no-transition {
+      transition: none;
+    }
+
     &.center {
       transform: translateY(-10px);
     }

+ 2 - 0
src/components/layout/space/StatusBarSpace.vue

@@ -1,10 +1,12 @@
 <template>
   <slot>
     <view :style="{
+      position: 'relative',
       height: `${height}px`,
       width: '100%',
       backgroundColor: themeContext.resolveThemeColor(props.backgroundColor),
     }">
+      <slot name="background" />
       <slot v-if="extendsBackgroundColorHeight > 0" name="extendsBackground">
         <view class="ana-extends-view" :style="{
           top: -extendsBackgroundColorHeight,

+ 3 - 0
src/env.d.ts

@@ -6,3 +6,6 @@ declare module '*.vue' {
   const component: DefineComponent<{}, {}, any>
   export default component
 }
+
+declare const __BUILD_GUID__: string;
+declare const __BUILD_TIMESTAMP__: number;

+ 7 - 0
src/pages.json

@@ -55,6 +55,13 @@
       }
     },
     {
+      "path": "pages/travel/route/travel-route",
+      "style": {
+        "navigationBarTitleText": "路线地图",
+        "navigationStyle": "custom"
+      }
+    },
+    {
       "path": "pages/inhert/language/list",
       "style": {
         "navigationBarTitleText": "闽南话"

+ 1 - 4
src/pages/article/data/CommonCategoryDetailIntroBlocks.vue

@@ -18,10 +18,7 @@
             class="flex-shrink-0"
           />
           <view class="d-flex flex-col ml-2">
-            <view class="d-flex flex-row align-center">
-              <text>{{ item.title }}</text>
-              <text v-if="item.regionText" class="ml-2">({{ item.regionText }})</text>
-            </view>
+            <text>{{ item.title }} ({{ item.regionText }})</text>
             <text v-if="item.unit" class="size-s color-second">{{ item.unit }}</text>
           </view>
         </view>

+ 394 - 0
src/pages/travel/route/travel-route.vue

@@ -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>

+ 67 - 0
src/pages/user/debug/DebugButton.vue

@@ -0,0 +1,67 @@
+<template>
+  <Touchable direction="column" center :padding="40" :gap="10" @click="showBuildInfo">
+    <Text 
+      color="text.second"
+      :fontSize="22"
+      :text="`软件版本 ${AppCofig.version}`"
+    />
+  </Touchable>
+</template>
+
+<script setup lang="ts">
+import { DateUtils } from '@imengyu/imengyu-utils';
+import { alert } from '@/components/dialog/CommonRoot';
+import Text from '@/components/basic/Text.vue';
+import Touchable from '@/components/feedback/Touchable.vue';
+import BugReporter from '@/common/BugReporter';
+import AppCofig, { isDev, isTestEnv } from '@/common/config/AppCofig';
+
+const showAct = isDev || isTestEnv;
+
+function showBuildInfo() {
+  alert({
+    title: '关于程序',
+    content: '版本: ' + AppCofig.version + (showAct ?
+     ('\n构建时间:' + DateUtils.formatDate(new Date(AppCofig.buildTime), 'yyyy-MM-dd HH:mm:ss') + 
+        ' (' + AppCofig.buildTime + ')' + 
+     '\n构建GUID:' + AppCofig.buildInfo) : ''),
+    icon: 'prompt-filling',
+    iconColor: 'primary',
+    customButtons: showAct ? [
+      {
+        text: '测试非遗路线页面',
+        name: 'testRoutePage',
+      },
+      {
+        text: '测试提交BUG(1)',
+        name: 'testBug',
+      },
+      {
+        text: '测试异常BUG(2)',
+        name: 'testBug2',
+      },
+    ] : [],
+    bottomVertical: true,
+    width: 560,
+  }).then((res) => {
+    if (res == 'testRoutePage') {
+      uni.navigateTo({
+        url: '/pages/travel/route/travel-route?id=8768',
+      });
+    }
+    if (res == 'testBug') {
+      uni.showToast({
+        title: '测试提交BUG(1)',
+        icon: 'none',
+      });
+      BugReporter.reportError(new Error('测试提交BUG(1)'));
+    } else if (res == 'testBug2') {
+      uni.showToast({
+        title: '测试异常BUG(2)',
+        icon: 'none',
+      });
+      throw new Error('测试异常BUG(2)');
+    }
+  });
+}
+</script>

+ 92 - 86
src/pages/user/index.vue

@@ -1,96 +1,99 @@
 <template>
-  <view class="home-container h-100vh d-flex flex-col bg-base page-user-index">
-    <Image 
-      :innerStyle="{ position:'absolute' }"
-      width="100%"
-      src="https://mncdn.wenlvti.net/app_static/minnan/images/mine/Banner.png"
-      mode="widthFix"
-    />
-    <Image 
-      :innerStyle="{ position:'absolute', }"
-      innerClass="title"
-      width="100rpx"
-      src="https://mncdn.wenlvti.net/app_static/minnan/images/mine/Title.png"
-      mode="widthFix"
-    />
-    <view class="content h-100 d-flex flex-col wing-l">
-      <Touchable 
-        direction="row"
-        justify="space-between" 
-        align="center"  
-        touchable 
-        :gap="25"
-        :margin="[36,0]"
-        @click="goUserProfile"
-      >
-        <FlexRow>
-          <Image 
-            v-if="userInfo"
-            :src="userInfo.avatar"
-            :defaultImage="UserHead"
-            :failedImage="UserHead"
-            :showFailed="false"
-            mode="aspectFill" 
-            class="avatar" 
-            width="100rpx"
-            height="100rpx"
-            round
-          />
-          <Image 
-            v-else
-            :src="UserHead"
-            mode="aspectFill" 
-            class="avatar" 
-            width="100rpx"
-            height="100rpx"
-            round
-          />
-          <Width :size="20" />
-          <FlexCol>
-            <Text fontConfig="h4" v-if="userInfo" color="white" :text="userInfo.nickname || `用户${userInfo.id}`" />
-            <Text fontConfig="h4" v-else color="white" text="欢迎登录" />
-            <Text color="white" v-if="userInfo" :text="`守护编号 ${userInfo.id} 积分 ${userInfo.totalCheckins}`" />
-          </FlexCol>
-        </FlexRow>
-        <Icon icon="arrow-right-bold" color="white" />
-      </Touchable>
+  <CommonRoot>
+    <view class="home-container h-100vh d-flex flex-col bg-base page-user-index">
+      <Image 
+        :innerStyle="{ position:'absolute' }"
+        width="100%"
+        src="https://mncdn.wenlvti.net/app_static/minnan/images/mine/Banner.png"
+        mode="widthFix"
+      />
+      <Image 
+        :innerStyle="{ position:'absolute', }"
+        innerClass="title"
+        width="100rpx"
+        src="https://mncdn.wenlvti.net/app_static/minnan/images/mine/Title.png"
+        mode="widthFix"
+      />
+      <view class="content h-100 d-flex flex-col wing-l">
+        <Touchable 
+          direction="row"
+          justify="space-between" 
+          align="center"  
+          touchable 
+          :gap="25"
+          :margin="[36,0]"
+          @click="goUserProfile"
+        >
+          <FlexRow>
+            <Image 
+              v-if="userInfo"
+              :src="userInfo.avatar"
+              :defaultImage="UserHead"
+              :failedImage="UserHead"
+              :showFailed="false"
+              mode="aspectFill" 
+              class="avatar" 
+              width="100rpx"
+              height="100rpx"
+              round
+            />
+            <Image 
+              v-else
+              :src="UserHead"
+              mode="aspectFill" 
+              class="avatar" 
+              width="100rpx"
+              height="100rpx"
+              round
+            />
+            <Width :size="20" />
+            <FlexCol>
+              <Text fontConfig="h4" v-if="userInfo" color="white" :text="userInfo.nickname || `用户${userInfo.id}`" />
+              <Text fontConfig="h4" v-else color="white" text="欢迎登录" />
+              <Text color="white" v-if="userInfo" :text="`守护编号 ${userInfo.id} 积分 ${userInfo.totalCheckins}`" />
+            </FlexCol>
+          </FlexRow>
+          <Icon icon="arrow-right-bold" color="white" />
+        </Touchable>
 
-      <CellGroup round>
-        <Cell icon="https://mncdn.wenlvti.net/uploads/20250313/07f750b4cf4959654c40171fdae91c3a.png" title="投稿" showArrow touchable @click="goContribute">
-          <template #value>
-            <FlexRow>
-              <NButton shape="round" type="success" size="small" :radius="40" @click="goContribute">去投稿</NButton>
-              <Width :width="20" />
-            </FlexRow>
-          </template>
-        </Cell>
-        <Cell icon="https://mncdn.wenlvti.net/uploads/20250313/66d4665b1da5075e60148312469b2630.png" title="我的投稿" showArrow touchable @click="goContributeList" />
-        <Cell icon="https://mncdn.wenlvti.net/uploads/20250313/042236758da5aaed21c1010e5b9440ce.png" title="我的收藏" showArrow touchable @click="goCollectList" />
-        <button open-type="contact" class="remove-button-style">
-          <Cell icon="https://mncdn.wenlvti.net/uploads/20250313/d2e9010323d098aa51e268fc32f14d3d.png" title="在线客服" showArrow touchable />
-        </button>
-        <Cell v-if="userInfo" icon="https://mncdn.wenlvti.net/uploads/20250313/cbc47d0b9cad7891e6154359952858c6.png" title="退出登录" showArrow touchable @click="doLogout" />
+        <CellGroup round>
+          <Cell icon="https://mncdn.wenlvti.net/uploads/20250313/07f750b4cf4959654c40171fdae91c3a.png" title="投稿" showArrow touchable @click="goContribute">
+            <template #value>
+              <FlexRow>
+                <NButton shape="round" type="success" size="small" :radius="40" @click="goContribute">去投稿</NButton>
+                <Width :width="20" />
+              </FlexRow>
+            </template>
+          </Cell>
+          <Cell icon="https://mncdn.wenlvti.net/uploads/20250313/66d4665b1da5075e60148312469b2630.png" title="我的投稿" showArrow touchable @click="goContributeList" />
+          <Cell icon="https://mncdn.wenlvti.net/uploads/20250313/042236758da5aaed21c1010e5b9440ce.png" title="我的收藏" showArrow touchable @click="goCollectList" />
+          <button open-type="contact" class="remove-button-style">
+            <Cell icon="https://mncdn.wenlvti.net/uploads/20250313/d2e9010323d098aa51e268fc32f14d3d.png" title="在线客服" showArrow touchable />
+          </button>
+          <Cell v-if="userInfo" icon="https://mncdn.wenlvti.net/uploads/20250313/cbc47d0b9cad7891e6154359952858c6.png" title="退出登录" showArrow touchable @click="doLogout" />
 
-        <view class="list">
-          <!-- 
-          https://mncdn.wenlvti.net/uploads/20250313/042236758da5aaed21c1010e5b9440ce.png
-          我的好友
-          https://mncdn.wenlvti.net/uploads/20250313/9fb29e8bdb66490034145c90f892773a.png
-          邀请好友
-          https://mncdn.wenlvti.net/uploads/20250313/1366973c061bf98594036e42c0344593.png
-          积分日志
-          https://mncdn.wenlvti.net/uploads/20250313/acd97ca7b3f7736942495c7aec1dd65b.png
-          加入我们
-          https://mncdn.wenlvti.net/uploads/20250313/d2e9010323d098aa51e268fc32f14d3d.png
-          我的预约 -->
-        </view>
-      </CellGroup>
+          <view class="list">
+            <!-- 
+            https://mncdn.wenlvti.net/uploads/20250313/042236758da5aaed21c1010e5b9440ce.png
+            我的好友
+            https://mncdn.wenlvti.net/uploads/20250313/9fb29e8bdb66490034145c90f892773a.png
+            邀请好友
+            https://mncdn.wenlvti.net/uploads/20250313/1366973c061bf98594036e42c0344593.png
+            积分日志
+            https://mncdn.wenlvti.net/uploads/20250313/acd97ca7b3f7736942495c7aec1dd65b.png
+            加入我们
+            https://mncdn.wenlvti.net/uploads/20250313/d2e9010323d098aa51e268fc32f14d3d.png
+            我的预约 -->
+          </view>
+
+          <DebugButton />
+        </CellGroup>
+      </view>
     </view>
-  </view>
+  </CommonRoot>
 </template>
 
 <script setup lang="ts">
-import { confirm } from '@/components/utils/DialogAction';
 import { navTo } from '@/components/utils/PageAction';
 import { useAuthStore } from '@/store/auth';
 import { computed } from 'vue';
@@ -105,6 +108,9 @@ import Image from '@/components/basic/Image.vue';
 import Width from '@/components/layout/space/Width.vue';
 import Text from '@/components/basic/Text.vue';
 import NButton from '@/components/basic/Button.vue';
+import DebugButton from './debug/DebugButton.vue';
+import CommonRoot from '@/components/dialog/CommonRoot.vue';
+import { confirm } from '@/components/dialog/CommonRoot';
 
 const UserHead = 'https://mncdn.wenlvti.net/app_static/minnan/images/home/UserHead.png';
 

+ 17 - 0
vite.config.ts

@@ -1,5 +1,18 @@
 import { defineConfig } from "vite";
 import uni from "@dcloudio/vite-plugin-uni";
+import { execSync } from 'child_process';
+
+// 获取当前git提交的ID
+function getGitCommitId() {
+  try {
+    // 执行git命令获取当前提交的SHA值
+    const commitId = execSync('git rev-parse HEAD').toString().trim();
+    return commitId;
+  } catch (error) {
+    console.error('获取git提交ID失败:', error);
+    return '';
+  }
+}
 
 // https://vitejs.dev/config/
 export default defineConfig({
@@ -11,4 +24,8 @@ export default defineConfig({
       },
     },
   },
+  define: {
+    __BUILD_GUID__: JSON.stringify(getGitCommitId()), //当前 git 提交的 id
+    __BUILD_TIMESTAMP__: Date.now() //时间戳(数字)
+  }
 });