Prechádzať zdrojové kódy

Merge branch 'master' of http://47.100.2.119:3000/imengyu/minnan

快乐的梦鱼 1 týždeň pred
rodič
commit
6fe5d3f041
35 zmenil súbory, kde vykonal 924 pridanie a 249 odobranie
  1. 16 0
      src/App.vue
  2. 1 0
      src/api/BaseAppServerRequestModule.ts
  3. 3 0
      src/api/system/ConfigurationApi.ts
  4. 5 2
      src/api/traval/TravalContent.ts
  5. 1 2
      src/common/composeabe/SimpleDataLoader.ts
  6. 1 0
      src/common/composeabe/SimplePageContentLoader.ts
  7. 1 0
      src/common/config/ApiCofig.ts
  8. 1 1
      src/components/README.md
  9. 1 1
      src/components/basic/CellGroup.vue
  10. 12 0
      src/components/basic/Icon.vue
  11. 27 3
      src/components/basic/IconUtils.ts
  12. 1 1
      src/components/basic/Image.vue
  13. 9 8
      src/components/dialog/BottomSheet.vue
  14. 10 0
      src/components/display/parse/Parse.ts
  15. 60 18
      src/components/display/parse/Parse.vue
  16. 161 26
      src/components/display/parse/ParseNodeRender.vue
  17. 2 0
      src/components/form/Rate.vue
  18. 7 0
      src/components/keyboard/NumberKeyBoard.vue
  19. 7 0
      src/components/keyboard/PlateKeyBoard.vue
  20. 14 1
      src/components/nav/NavBar.vue
  21. 18 0
      src/components/utils/PageAction.ts
  22. 6 0
      src/pages.json
  23. 18 14
      src/pages/article/data/CommonCategoryBlocks.vue
  24. 45 3
      src/pages/article/data/CommonCategoryDynamicData.ts
  25. 20 9
      src/pages/article/data/CommonCategoryDynamicEvax.ts
  26. 1 1
      src/pages/article/data/CommonCategoryGlobalLoader.ts
  27. 4 0
      src/pages/article/data/defines/List.ts
  28. 38 0
      src/pages/article/data/editor/components/DynamicDataEditor.vue
  29. 3 0
      src/pages/article/data/editor/subpart/NestCategoryEditorItem.vue
  30. 50 1
      src/pages/blocks/RichBlock.vue
  31. 1 1
      src/pages/parts/Box2LineLargeImageUserShadow.vue
  32. 51 0
      src/pages/travel/route/components/NextBestWay.vue
  33. 48 0
      src/pages/travel/route/details.vue
  34. 26 20
      src/pages/travel/route/list.vue
  35. 255 137
      src/pages/travel/route/travel-route.vue

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 16 - 0
src/App.vue


+ 1 - 0
src/api/BaseAppServerRequestModule.ts

@@ -96,6 +96,7 @@ export class BaseAppServerRequestModule<T extends DataModel> extends RequestCore
   constructor(baseUrl: string) {
     super(UniappImplementer);
     if (!baseUrl.startsWith('http')) {
+      this.config.baseUrl = ApiCofig.baseUrl;
       BaseAppServerRequestUrlManager.pushUrlConfigListener(baseUrl, (baseUrl: string) => {
         this.config.baseUrl = baseUrl;
       });

+ 3 - 0
src/api/system/ConfigurationApi.ts

@@ -21,6 +21,9 @@ export const CommonConfigurationConfig = {
 export interface IConfigurationItem {
   baseServerUrl: string,
   articleMark: string,
+  routeListImage: string,
+  routeListImageStyle: Record<string, any>,
+  routeListMarginTop: number,
 }
 
 export class ConfigurationApi extends UpdateServerRequestModule<DataModel> {

+ 5 - 2
src/api/traval/TravalContent.ts

@@ -33,7 +33,10 @@ export class TravalContentApi extends CommonContentApi {
     const res = await this.getContentDetail(id, GetContentDetailItem, 17, {
       'scenic_spots': '1',
     });
-    res.scenicSpotsList = (res.scenicSpotsList as any[]).map(p => transformDataModel<TravalListItem>(TravalListItem, p));
+    if (res.scenicSpotsList && Array.isArray(res.scenicSpotsList)) {
+      res.scenicSpotsList = (res.scenicSpotsList as any[]).map(p => transformDataModel<TravalListItem>(TravalListItem, p));
+      res.keywords = (res.scenicSpotsList as any[]).map(p => p.name);
+    }
     return res;
   }
   async getTravalRouteList(params: GetContentListParams) {
@@ -41,7 +44,7 @@ export class TravalContentApi extends CommonContentApi {
       pid: 8768,
       modelId: 17,
     });
-    const res = await this.getContentList(params, 1, 100, GetContentDetailItem);
+    const res = await this.getContentList(params, 1, 100);
     res.list.forEach((p) => {
       if (p.scenicSpotsList) {
         p.scenicSpotsList = transformArrayDataModel<TravalListItem>(TravalListItem, p.scenicSpotsList as any[], '路线景点列表', true);

+ 1 - 2
src/common/composeabe/SimpleDataLoader.ts

@@ -39,8 +39,7 @@ export function useSimpleDataLoader<T, P = any>(
     } catch(e) {
       loadError.value = formatError(e);
       loadStatus.value = 'error';
-      console.log(e);
-      
+      console.error(e);
     } finally {
       if (showGlobalLoading)
         uni.hideLoading();

+ 1 - 0
src/common/composeabe/SimplePageContentLoader.ts

@@ -27,6 +27,7 @@ export function useSimplePageContentLoader<T, P = any>(
       loadStatus.value = 'finished';
       loadError.value = '';
     } catch(e) {
+      console.error(e);
       loadError.value = formatError(e);
       loadStatus.value = 'error';
     }

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

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

+ 1 - 1
src/components/README.md

@@ -6,7 +6,7 @@ NaEasy UI 是一款简单的 UniApp 移动端UI组件库。
 
 ## 版本
 
-当前版本:1.0.8-26012501
+当前版本:1.0.9-26031103
 
 ## 版权说明
 

+ 1 - 1
src/components/basic/CellGroup.vue

@@ -1,5 +1,5 @@
 <template>
-  <FlexCol :flex="1">
+  <FlexCol>
     <text v-if="title" :style="(titleSpeicalStyle as any)">
       {{ title }}
     </text>

+ 12 - 0
src/components/basic/Icon.vue

@@ -43,6 +43,7 @@
     :innerStyle="style"
     :innerClass="innerClass"
     :src="iconData.value"
+    :showFailed="!noError"
     mode="aspectFill"
   />
 </template>
@@ -82,14 +83,25 @@ export interface IconProps {
    * 自定义类名
    */
   innerClass?: string,
+  /**
+   * 是否不显示错误图标
+   */
+  noError?: boolean,
 }
 
 const theme = useTheme();
 const props = withDefaults(defineProps<IconProps>(), {
   size: 45,
+  noError: false,
 });
 const icon = computed(() => props.icon || props.name);
 const iconData = computed(() => {
+  if (IconUtils.iconCount.value === 0) {
+    return {
+      type: 'none',
+      value: '',
+    } as IconItem
+  }
   const data = icon.value ? IconUtils.getIconDataFromMap(icon.value) : undefined;
   if (!data && icon.value && icon.value.startsWith('icon-')) {
     return {

+ 27 - 3
src/components/basic/IconUtils.ts

@@ -1,4 +1,4 @@
-import DefaultIcons from "../data/DefaultIcon.json";
+import { ref } from "vue";
 
 export type IconItem = {
   type: 'iconfont'|'image'|'svg'|'none',
@@ -13,6 +13,7 @@ type IconMap = Record<string, IconItem>;
 const iconMap = {} as IconMap;
 
 export const IconUtils = {
+  iconCount: ref(0),
   /**
    * 设置 Icon 组件的图标名称映射。
    * 如果已存在同名数据,则会覆盖之前的。
@@ -53,6 +54,7 @@ export const IconUtils = {
       } 
       iconMap[key] = result;
     }
+    this.iconCount.value = Object.keys(iconMap).length;
   },
   getColoredSvg(svg: string, color: string) {
     return toDataSvg(
@@ -74,6 +76,30 @@ export const IconUtils = {
   getIconDataFromMap(key: string) {
     return iconMap[key];
   },
+  /**
+   * 加载默认图标。可选从本地或者网络加载。
+   * @param urlOrJson 图标URL或者JSON字符串
+   */
+  loadDefaultIcons(urlOrJson: string | Record<string, string>) {
+    if (typeof urlOrJson === 'object') {
+      this.configIconMap(urlOrJson);
+      return;
+    }
+    if (urlOrJson.startsWith('http') || urlOrJson.startsWith('https')) {
+      uni.request({
+        url: urlOrJson,
+        method: 'GET',
+        success: (res) => {
+          this.configIconMap(typeof res.data === 'string' ? JSON.parse(res.data) : res.data);
+        },
+        fail: (err) => {
+          console.error('加载默认图标失败', err);
+        },
+      });
+    } else {
+      this.configIconMap(JSON.parse(urlOrJson));
+    }
+  },
 }
 
 function toDataSvg(str: string) {
@@ -81,5 +107,3 @@ function toDataSvg(str: string) {
     str = str.replace("<svg ", "<svg xmlns='http://www.w3.org/2000/svg' ");
   return `data:image/svg+xml,${encodeURIComponent(str.replace(/\'/g, '"'))}`.replace(/\#/g, '%23');
 }
-
-IconUtils.configIconMap(DefaultIcons);

+ 1 - 1
src/components/basic/Image.vue

@@ -27,7 +27,7 @@
       @error="isErrorState = true; isLoadState = false"
     />
     <view v-if="showFailed && isErrorState && !failedImage" class="inner-view error">
-      <Icon icon="warning" color="text.second" :size="32" />
+      <Icon icon="warning" color="text.second" :size="32" noError />
       <Text v-if="realWidth > 50" color="text.second" :text="src ? '加载失败' : '暂无图片'" :fontSize="22" />
     </view>
     <view v-if="showLoading && isLoadState" class="inner-view loading">

+ 9 - 8
src/components/dialog/BottomSheet.vue

@@ -20,11 +20,7 @@
     >
       <view 
         v-if="enableDrag"
-        :style="{
-          display: 'flex',
-          justifyContent: 'center',
-          alignItems: 'center',
-        }"
+        :style="themeStyles.dragHandleContainer.value"
         @touchstart="onDragStart"
         @touchmove="onDragMove"
         @touchend="onDragEnd"
@@ -113,7 +109,7 @@ const emit = defineEmits([ 'close', 'select' ]);
 const props = withDefaults(defineProps<BottomSheetProps>(), {
   enableDrag: true,
   dragHandleColor: () => propGetThemeVar('BottomSheetDragHandleColor', 'grey'),
-  dragHandleSize: () => propGetThemeVar('BottomSheetDragHandleSize', 100),
+  dragHandleSize: () => propGetThemeVar('BottomSheetDragHandleSize', 200),
   centerWidth: () => propGetThemeVar('BottomSheetCenterWidth', '600rpx'),
   height: () => propGetThemeVar('BottomSheetHeight', 300),
   dragMaxHeight: () => propGetThemeVar('BottomSheetDragMaxHeight', 1000),
@@ -126,11 +122,16 @@ const themeStyles = themeContext.useThemeStyles({
     borderTopRightRadius: DynamicSize('BottomSheetBorderRadius', 10),
     backgroundColor: DynamicColor('BottomSheetBackgroundColor', 'white'),
   },
+  dragHandleContainer: {
+    display: 'flex',
+    justifyContent: 'center',
+    alignItems: 'center',
+  },
   dragHandle: {
     height: DynamicSize('BottomSheetDragHandleSize', 10),
     borderRadius: DynamicSize('BottomSheetDragHandleBorderRadius', 10),
-    marginTop: DynamicSize('BottomSheetDragHandleMarginVertical', 15),
-    marginBottom: DynamicSize('BottomSheetDragHandleMarginVertical', 15),
+    marginTop: DynamicSize('BottomSheetDragHandleMarginVertical', 25),
+    marginBottom: DynamicSize('BottomSheetDragHandleMarginVertical', 25),
   },
 });
 

+ 10 - 0
src/components/display/parse/Parse.ts

@@ -3,6 +3,16 @@ export interface ParseNode {
    * 标签名
    */
   tag: string;
+  
+  /**
+   * 父标签名
+   */
+  parentTag?: string;
+  /**
+   * 在父节点中的索引
+   */
+  index?: number;
+
   /**
    * 标签属性
    */

+ 60 - 18
src/components/display/parse/Parse.vue

@@ -1,6 +1,12 @@
 <template>
   <view class="nana-Parse-container" :style="contentStyle">
-    <ParseNodeRender v-for="(node, index) in nodes" :key="index" :node="node" />
+    <ParseNodeRender 
+      v-for="(node, index) in nodes" 
+      :key="index" 
+      :node="node" 
+      @linkTap="emit('linkTap', $event)"
+      @viewTap="emit('viewTap', $event)"
+    />
   </view>
 </template>
 
@@ -20,18 +26,27 @@ export interface ParseProps {
    */
   tagStyle?: Record<string, string>;
   /**
+   * 类样式。键为类名,值为样式
+   */
+  classStyle?: Record<string, string>;
+  /**
    * 容器样式
    */
   contentStyle?: any;
 }
 
+const emit = defineEmits(['linkTap', 'viewTap']);
+
 const props = withDefaults(defineProps<ParseProps>(), {
-  tagStyle: () => ({})
+  tagStyle: () => ({}),
+  classStyle: () => ({}),
+  contentStyle: () => ({})
 });
 
 const praseImages = ref<string[]>([]);
 
 provide('tagStyle', toRef(props, 'tagStyle'));
+provide('classStyle', toRef(props, 'classStyle'));
 provide('praseImages', praseImages);
 
 const toObj = (attrs: DefaultTreeAdapterTypes.Element['attrs']) => {
@@ -47,25 +62,49 @@ const parseHtml = (html: string): ParseNode[] => {
   const doc = parse(html);
   praseImages.value = [];
   
-  const traverse = (element: DefaultTreeAdapterTypes.Element): ParseNode => {
+  const solveTextNode = (child: DefaultTreeAdapterTypes.TextNode, nodes: ParseNode[], parentTag?: string) => {
+    const value = child.value;
+    if (value.trim() === '') {
+      return null;
+    }
+    if (value.trim() === '\n') {
+      const node: ParseNode = {
+        tag: 'br',
+      };
+      nodes.push(node);
+      return node;
+    }
+    const node: ParseNode = {
+      tag: 'text',
+      attrs: {
+        content: value
+      },
+      parentTag,
+      index: 0
+    };
+    nodes.push(node);
+    return node;
+  }
+  const traverse = (element: DefaultTreeAdapterTypes.Element, parentTag?: string): ParseNode => {
     const node: ParseNode = {
       tag: element.tagName,
       attrs: toObj(element.attrs),
-      children: []
+      children: [],
+      parentTag,
+      index: 0
     };
     
     // 解析子节点
     if (element.childNodes) {
+      let index = 0;
       for (const child of element.childNodes) {
         if (child.nodeName === '#text') {
-          node.children?.push({
-            tag: 'text',
-            attrs: {
-              content: (child as DefaultTreeAdapterTypes.TextNode).value
-            }
-          });
+          const textNode = solveTextNode(child as DefaultTreeAdapterTypes.TextNode, node.children || [], element.tagName);
+          if (textNode)
+            textNode.index = index++;
         } else if (child.nodeName !== '#comment' && child.nodeName !== '#documentType') {
-          const childNode = traverse(child as DefaultTreeAdapterTypes.Element);
+          const childNode = traverse(child as DefaultTreeAdapterTypes.Element, element.tagName);
+          childNode.index = index++;
           node.children?.push(childNode);
           if (childNode.tag === 'img') {
             praseImages.value.push(childNode.attrs?.src as string);
@@ -76,18 +115,21 @@ const parseHtml = (html: string): ParseNode[] => {
     
     return node;
   };
+
+  let index = 0;
   for (const child of doc.childNodes) {
     if (child.nodeName === '#text') {
-      nodes.push({
-        tag: 'text',
-        attrs: {
-          content: (child as DefaultTreeAdapterTypes.TextNode).value
-        }
-      });
+      const textNode = solveTextNode(child as DefaultTreeAdapterTypes.TextNode, nodes, 'body');
+      if (textNode)
+        textNode.index = index++;
     } else if (child.nodeName !== '#documentType') {
-      nodes.push(traverse(child as DefaultTreeAdapterTypes.Element));
+      const childNode = traverse(child as DefaultTreeAdapterTypes.Element, 'body');
+      childNode.index = index++;
+      nodes.push(childNode);
     }
   }
+  console.log(doc);
+  
   return nodes;
 };
 

+ 161 - 26
src/components/display/parse/ParseNodeRender.vue

@@ -1,4 +1,6 @@
 <template>
+  <!-- 节点渲染 -->
+
   <!-- 图片 -->
   <image 
     v-if="node.tag === 'img'" 
@@ -11,7 +13,12 @@
   />
   
   <!-- 换行 -->
+  <!-- #ifdef H5 -->
+  <br v-else-if="node.tag === 'br'" />
+  <!-- #endif -->
+  <!-- #ifndef H5 -->
   <text v-else-if="node.tag === 'br'">\n</text>
+  <!-- #endif -->
   
   <!-- 链接 -->
   <view 
@@ -65,6 +72,80 @@
     <InjectMPRender :type="node.attrs?.type as string || ''" v-bind="node.attrs" />
   </view>
 
+  <!-- 表格 -->
+  <view
+    v-else-if="node.tag === 'table'"
+    :id="node.attrs?.id"
+    :class="'_parse_table ' + (node.attrs?.class || '')"
+    :style="style"
+  >
+    <ParseNodeRender
+      v-for="(child, index) in node.children"
+      :key="index"
+      :node="child"
+    />
+  </view>
+  <view
+    v-else-if="node.tag === 'thead' || node.tag === 'tbody'"
+    :class="node.tag === 'thead' ? '_parse_thead' : '_parse_tbody'"
+    :style="style"
+  >
+    <ParseNodeRender
+      v-for="(child, index) in node.children"
+      :key="index"
+      :node="child"
+    />
+  </view>
+  <view
+    v-else-if="node.tag === 'tr'"
+    :class="'_parse_tr ' + (node.attrs?.class || '')"
+    :style="style"
+  >
+    <ParseNodeRender
+      v-for="(child, index) in node.children"
+      :key="index"
+      :node="child"
+    />
+  </view>
+  <view
+    v-else-if="node.tag === 'th'"
+    :class="'_parse_th ' + (node.attrs?.class || '')"
+    :style="style"
+  >
+    <ParseNodeRender
+      v-for="(child, index) in node.children"
+      :key="index"
+      :node="child"
+    />
+  </view>
+  <view
+    v-else-if="node.tag === 'td'"
+    :class="'_parse_td ' + (node.attrs?.class || '')"
+    :style="style"
+  >
+    <ParseNodeRender
+      v-for="(child, index) in node.children"
+      :key="index"
+      :node="child"
+    />
+  </view>
+
+  <!-- 特殊标签,例如 ol ul 需要在前缀加序号 -->
+  <view v-else-if="node.tag === 'li'" 
+    class="_ol_ul_container"
+    :style="style"
+    :class="node.attrs?.class || ''"
+  >
+    <text v-if="node.parentTag === 'ol'" class="_ol_prefix">·</text>
+    <text v-else class="_ul_prefix">{{ (node.index || 0) + 1 }}.</text>
+    
+    <ParseNodeRender
+      v-for="(child, index) in node.children"
+      :key="index"
+      :node="child"
+    />
+  </view>
+
   <!-- 其他标签 -->
   <view
     v-else-if="node.tag !== 'text'"
@@ -72,6 +153,7 @@
     :data-tag="node.tag"
     :class="node.attrs?.class || ''"
     :style="style"
+    @click="viewTap(node.attrs?.id)"
   >
     <ParseNodeRender
       v-for="(child, index) in node.children"
@@ -101,26 +183,29 @@ const props = withDefaults(defineProps<{
 }>(), {
 });
 
+const emit = defineEmits(['linkTap', 'viewTap']);
 const tagStyle = inject<Ref<Record<string, string>>>('tagStyle', ref({}));
+const classStyle = inject<Ref<Record<string, string>>>('classStyle', ref({}));
 const praseImages = inject<Ref<string[]>>('praseImages', ref([]));
+// 与 HTML 标准默认样式(UA 样式)一致,参考 CSS 2.1 / HTML5 规范
 const builtInStyles = {
-  // 标题标签
+  // 标题标签 (CSS 2.1 suggested defaults)
   'h1': 'font-size: 2em; font-weight: bold; margin: 0.67em 0;',
-  'h2': 'font-size: 1.5em; font-weight: bold; margin: 0.83em 0;',
-  'h3': 'font-size: 1.17em; font-weight: bold; margin: 1em 0;',
-  'h4': 'font-size: 1em; font-weight: bold; margin: 1.33em 0;',
-  'h5': 'font-size: 0.83em; font-weight: bold; margin: 1.67em 0;',
+  'h2': 'font-size: 1.5em; font-weight: bold; margin: 0.75em 0;',
+  'h3': 'font-size: 1.17em; font-weight: bold; margin: 0.83em 0;',
+  'h4': 'font-size: 1em; font-weight: bold; margin: 1.12em 0;',
+  'h5': 'font-size: 0.83em; font-weight: bold; margin: 1.5em 0;',
   'h6': 'font-size: 0.67em; font-weight: bold; margin: 2.33em 0;',
-  
+
   // 段落和引用
   'p': 'margin: 1em 0;',
-  'blockquote': 'margin: 1em 0; padding: 0 1em; border-left: 4px solid #ddd; color: #666;',
-  
+  'blockquote': 'margin: 1em 0; border-left: 4px solid #ddd; padding-left: 1em; color: #666;',
+
   // 列表
-  'ul': 'margin: 1em 0; padding-left: 2em;',
-  'ol': 'margin: 1em 0; padding-left: 2em;',
+  'ul': 'margin: 1em 0; padding-left: 40px;',
+  'ol': 'margin: 1em 0; padding-left: 40px;',
   'li': 'margin: 0.5em 0;',
-  
+
   // 强调标签
   'b': 'font-weight: bold;',
   'strong': 'font-weight: bold;',
@@ -128,27 +213,36 @@ const builtInStyles = {
   'em': 'font-style: italic;',
   'u': 'text-decoration: underline;',
   'del': 'text-decoration: line-through;',
-  
-  // 代码相关
-  'code': 'font-family: monospace; background: #f5f5f5; padding: 0.2em 0.4em; border-radius: 3px;',
-  'pre': 'font-family: monospace; background: #f5f5f5; padding: 1em; border-radius: 3px; overflow: auto;',
-  
+
+  // 代码相关 (UA 仅 monospace,无背景)
+  'code': 'font-family: monospace;',
+  'pre': 'font-family: monospace; white-space: pre; margin: 1em 0;',
+
   // 其他内联标签
-  'mark': 'background-color: #ffeb3b; padding: 0.2em;',
-  'sup': 'font-size: 0.83em; vertical-align: super;',
-  'sub': 'font-size: 0.83em; vertical-align: sub;',
-  'small': 'font-size: 0.83em;',
-  'large': 'font-size: 1.17em;',
-  
-  // 分隔线
-  'hr': 'border: 0; border-top: 1px solid #ddd; margin: 1em 0;'
+  'mark': 'background-color: yellow; color: black;',
+  'sup': 'font-size: smaller; vertical-align: super;',
+  'sub': 'font-size: smaller; vertical-align: sub;',
+  'small': 'font-size: smaller;',
+  'large': 'font-size: larger;',
+
+  // 分隔线 (CSS 2.1: margin 0.5em 0,inset 在小程序端用 solid 兼容)
+  'hr': 'border: none; border-top: 1px solid #ccc; margin: 0.5em 0;',
+
+  // 表格(简单布局,无 table-layout)
+  'table': 'display: block; width: 100%; margin: 1em 0; overflow-x: auto;',
+  'thead': 'display: block;',
+  'tbody': 'display: block;',
+  'tr': 'display: flex; flex-direction: row;',
+  'th': 'font-weight: bold; padding: 8px 10px; border-right: 1px solid #ddd; border-bottom: 1px solid #ddd; flex: 1; min-width: 0; box-sizing: border-box;',
+  'td': 'padding: 8px 10px; border-right: 1px solid #ddd; border-bottom: 1px solid #ddd; flex: 1; min-width: 0; box-sizing: border-box;'
 } as Record<string, string>;
 const style = computed(() => 
   [
-    (props.node.attrs?.style || ''), 
     (builtInStyles[props.node.tag] || ''),
-    isInline.value ? 'display:inline' : '',
     (tagStyle.value[props.node.tag] || ''),
+    (classStyle.value[props.node.attrs?.class as string] || ''),
+    isInline.value ? 'display:inline' : '',
+    (props.node.attrs?.style || ''), 
   ].join(';'),
 );
 const isInline = computed(() => [
@@ -160,6 +254,7 @@ const isInline = computed(() => [
 // 链接点击事件
 const linkTap = (e: any) => {
   const href = props.node.attrs?.href as string;
+  emit('linkTap', href);
   if (href) {
     if (href[0] === '#') {
       // 跳转锚点
@@ -206,6 +301,10 @@ const linkTap = (e: any) => {
   }
 };
 
+const viewTap = (e: any) => {
+  emit('viewTap', e);
+};
+
 function preview(url: string) {
   if (url) {
     if (praseImages.value.includes(url)) {
@@ -249,6 +348,42 @@ defineOptions({
   -webkit-touch-callout: none;
 }
 
+/* ol ul 容器 */
+._ol_ul_container {
+  display: block;
+}
+/* ol/ul 前缀 */
+._ol_prefix,
+._ul_prefix {
+  display: inline-block;
+  padding-right: 10px;
+}
+
+/* 表格:用 view + flex 模拟,无 table 标签;仅画右、下边框,首列补左边框,首行补上边框 */
+._parse_table {
+  border: 1px solid #ddd;
+  border-radius: 4px;
+}
+._parse_thead ._parse_tr {
+  background-color: #f5f5f5;
+}
+._parse_thead ._parse_tr ._parse_th,
+._parse_thead ._parse_tr ._parse_td {
+  border-top: 1px solid #ddd;
+}
+._parse_tbody ._parse_tr:first-child ._parse_th,
+._parse_tbody ._parse_tr:first-child ._parse_td {
+  border-top: 1px solid #ddd;
+}
+._parse_tr ._parse_th:first-child,
+._parse_tr ._parse_td:first-child {
+  border-left: 1px solid #ddd;
+}
+._parse_th,
+._parse_td {
+  word-break: break-all;
+}
+
 /* 视频默认效果 */
 ._video {
   width: 300px;

+ 2 - 0
src/components/form/Rate.vue

@@ -202,6 +202,8 @@ function handleDrag(x: number) {
   if (v === 0 && !props.canbeZero)
     v = props.half ? 0.5 : 1;
 
+  v = Math.max(0, Math.min(v, props.count));
+
   if (v !== value.value) {
     updateValue(v);
   }

+ 7 - 0
src/components/keyboard/NumberKeyBoard.vue

@@ -4,6 +4,7 @@
     closeable
     :closeIcon="false"
     :mask="mask === true"
+    :size="size"
     position="bottom"
     @close="onClose"
   >
@@ -39,6 +40,11 @@ export interface NumberKeyBoardProps extends NumberKeyBoardInnerProps {
    * @default false
    */
   mask?: boolean;
+  /**
+   * 键盘大小
+   * @default 'auto'
+   */
+  size?: string|number;
 }
 
 const emit = defineEmits([ 'delete', 'input', 'finish', 'update:show' ]);
@@ -47,6 +53,7 @@ const props = withDefaults(defineProps<NumberKeyBoardProps>(), {
   keyPressedImpactFeedback: true,
   keyRandomOrder: false,
   showCloseButton: true,
+  size: 'auto',
 });
 
 const onClose = () => {

+ 7 - 0
src/components/keyboard/PlateKeyBoard.vue

@@ -4,6 +4,7 @@
     closeable
     :closeIcon="false"
     :mask="mask === true"
+    :size="size"
     position="bottom"
     @close="onClose"
   >
@@ -41,6 +42,11 @@ export interface PlateKeyBoardProps extends PlateKeyBoardInnerProps {
    * @default false
    */
   mask?: boolean;
+  /**
+   * 键盘大小
+   * @default 'auto'
+   */
+  size?: string|number;
 }
 
 const emit = defineEmits([ 'delete', 'input', 'finish', 'cancel', 'update:show' ]);
@@ -48,6 +54,7 @@ const props = withDefaults(defineProps<PlateKeyBoardProps>(), {
   showFinishButton: () => propGetThemeVar('PlateKeyBoardShowFinishButton', true),
   showDeleteButton: () => propGetThemeVar('PlateKeyBoardShowDeleteButton', true),
   keyPressedImpactFeedback: () => propGetThemeVar('PlateKeyBoardKeyPressedImpactFeedback', true),
+  size: 'auto',
 });
 
 const onClose = () => {

+ 14 - 1
src/components/nav/NavBar.vue

@@ -79,6 +79,7 @@ import { DynamicSize } from '../theme/ThemeTools';
 import HorizontalScrollText from '../typography/HorizontalScrollText.vue';
 import Text from '../basic/Text.vue';
 import IconButton from '../basic/IconButton.vue';
+import { isTopLevelPage } from '../utils/PageAction';
 
 export type NavBarButtonTypes = 'back'|'menu'|'search'|'setting'|'custom';
 
@@ -204,7 +205,19 @@ const titleTextStyle = theme.useThemeStyle({
 
 function handleButtonNavBack(button: NavBarButtonTypes, callback: () => void) {
   if (button === 'back') {
-    uni.navigateBack();
+    if (isTopLevelPage()) {
+      uni.reLaunch({
+        url: theme.getVar('AppHomePage', '/pages/index/index'),
+      });
+    } else {
+      uni.navigateBack({
+        fail() {
+          uni.reLaunch({
+            url: theme.getVar('AppHomePage', '/pages/index/index'),
+          }); 
+        },
+      });
+    }
   } else {
     callback();
   }

+ 18 - 0
src/components/utils/PageAction.ts

@@ -98,6 +98,23 @@ function backAndCallOnPageBack(name: string, data: Record<string, unknown>) {
   uni.navigateBack({ delta: 1 });
 }
 
+function isTopLevelPage() {
+  // 获取当前页面栈
+  const pages = getCurrentPages();
+  
+  if (!pages || pages.length === 0) {
+    return false; // 极端情况,没有页面
+  }
+
+  // 当前页面实例
+  const currentPage = pages[pages.length - 1];
+  
+  // 判断当前页面是否是栈中的第一个页面 (索引为 0)
+  // 注意:pages[0] 通常是 tabBar 页面或启动页
+  return pages.length === 1 || currentPage === pages[0];
+}
+
+
 export {
   redirectTo,
   back,
@@ -105,6 +122,7 @@ export {
   backAndCallOnPageBack, 
   navTo,
   callPrevOnPageBack,
+  isTopLevelPage,
 }
 
 export function getCurrentPageUrl() {

+ 6 - 0
src/pages.json

@@ -63,6 +63,12 @@
       }
     },
     {
+      "path": "pages/travel/route/details",
+      "style": {
+        "navigationBarTitleText": "非遗路线详情"
+      }
+    },
+    {
       "path": "pages/travel/route/travel-route",
       "style": {
         "navigationBarTitleText": "路线地图",

+ 18 - 14
src/pages/article/data/CommonCategoryBlocks.vue

@@ -2,7 +2,10 @@
   <!--通用内容首页小分块组件-->
   <FlexCol width="100%">
     <!-- 分类 -->
-    <template v-for="(category,i) in categoryDatas" :key="i">
+    <FlexCol 
+      v-for="(category,i) in categoryDatas" :key="i"
+      :innerStyle="category.style"
+    >
       <HomeTitle 
         v-if="category.showTitle"
         :title="category.title"
@@ -188,7 +191,7 @@
           </template>
         </FlexCol>
       </SimplePageContentLoader>  
-    </template>
+    </FlexCol>
   </FlexCol>
 </template>
 
@@ -263,12 +266,6 @@ const categoryDatas = computed(() => props.categoryDefine.map(item => {
           mainBodyColumnId = params.mainBodyColumnId;
           break;
         }
-        case 'detailContent':
-        case 'parentKey':
-        case 'request':
-          toast(`此配置选项不支持默认的详情页面跳转!`);
-          console.warn(`此配置选项不支持默认的详情页面跳转!`);
-          return;
       }  
 
       const params = { id, mainBodyColumnId, modelId };
@@ -303,13 +300,16 @@ const categoryDatas = computed(() => props.categoryDefine.map(item => {
             mainBodyColumnId = params.mainBodyColumnId;
             break;
           }
-          case 'detailContent':
-          case 'parentKey':
-          case 'request':
-            toast(`此配置选项不支持默认的更多页面跳转!`);
-            console.warn(`此配置选项不支持默认的更多页面跳转!`);
-            return;
         }  
+        if (item.morePage?.startsWith('dynamic:')) {
+          doCallDynamicFunction(item.morePage.substring(8), {
+            sourceData: {
+              main: {},
+              customData: {},
+            },
+          });
+          return;
+        }
         navCommonList({
           title: item.title,
           mainBodyColumnId: mainBodyColumnId,
@@ -356,6 +356,10 @@ const categoryDatas = computed(() => props.categoryDefine.map(item => {
           res = props.parentData[item.data.key];
           break;
         }
+        case 'staticData': {
+          res = item.data.data;
+          break;
+        }
         case 'request': {
           res = (await CommonContent.request(
             item.data.url, 

+ 45 - 3
src/pages/article/data/CommonCategoryDynamicData.ts

@@ -29,6 +29,8 @@ import ResultContent from "@/api/research/ResultContent";
 import { CommonCategorDynamicDropDownValuesToParams } from "./data-defines/Dropdown";
 import NewsIndexContent from "@/api/news/NewsIndexContent";
 import VillageContent from "@/api/fusion/VillageContent";
+import NotConfigue from "@/api/NotConfigue";
+import { doEvaluateDynamicDataExpression } from "./CommonCategoryDynamicEvax";
 
 export * from './data-defines/Category';
 export * from './data-defines/Dropdown';
@@ -106,8 +108,23 @@ export interface IHomeCommonCategoryDynamicDataRequest {
   type: 'request',
   method: "OPTIONS" | "GET" | "HEAD" | "POST" | "PUT" | "DELETE",
   url: string,
+  /**
+   * 请求参数
+   */
   querys?: Record<string, any>,
   /**
+   * 请求头
+   */
+  headers?: Record<string, string>,
+  /**
+   * 是否添加token
+   */
+  addToken?: boolean,
+  /**
+   * 自定义数据处理脚本
+   */
+  dataSolve?: string,
+  /**
    * 其他参数
    */
   otherParams?: Record<string, any>,
@@ -119,12 +136,20 @@ export interface IHomeCommonCategoryDynamicDataParentKey {
   type: 'parentKey',
   key: string,
 }
+/**
+ * 默认列表动态数据接口定义 - 静态数据
+ */
+export interface IHomeCommonCategoryDynamicDataStaticData {
+  type: 'staticData',
+  data: Record<string, any>[],
+}
 
 export type IHomeCommonCategoryDynamicData = IHomeCommonCategoryDynamicDataCommonContent 
   | IHomeCommonCategoryDynamicDataSerializedApi 
   | IHomeCommonCategoryDynamicDataRequest
   | IHomeCommonCategoryDynamicDataParentKey
-  | IHomeCommonCategoryDynamicDataDetailContent;
+  | IHomeCommonCategoryDynamicDataDetailContent
+  | IHomeCommonCategoryDynamicDataStaticData;
 
 /**
  * 序列化接口映射表
@@ -219,12 +244,16 @@ export async function doLoadDynamicListData(
         }) 
       , page, pageSize));
     }
-    case 'request':
-      return (await CommonContent.request(
+    case 'staticData': {
+      return item.data;
+    }
+    case 'request': {
+      let data = (await (item.addToken ? CommonContent : NotConfigue).request<any>(
         item.url, 
         { ...item.querys, page, pageSize }, 
         {
           method: item.method, 
+          headers: item.headers,
           data: {
             ...item.otherParams,
             ...CommonCategorDynamicDropDownValuesToParams(dropDownValues, dropdownDefines || [])
@@ -233,6 +262,16 @@ export async function doLoadDynamicListData(
         '',
         undefined,
       )).data;
+      if (item.dataSolve) {
+        data = doEvaluateDynamicDataExpression(item.dataSolve, {
+          sourceData: {
+            main: data,
+            customData: {},
+          },
+        });
+      }
+      return data;
+    }
   }
 }
 export async function doLoadDynamicDetailData(
@@ -249,6 +288,9 @@ export async function doLoadDynamicDetailData(
     case 'detailContent': {
       return await CommonContent.getContentDetail(item.params.id, undefined, item.params.modelId || undefined, item.otherParams);
     }
+    case 'staticData': {
+      return item.data;
+    }
     case 'parentKey': {
       if (!parentData)
         throw new Error(`此处不允许加载父级数据`);

+ 20 - 9
src/pages/article/data/CommonCategoryDynamicEvax.ts

@@ -40,14 +40,14 @@ function parseMapExpression(mapExpression: string) {
  * CT,[key]        [N]: 暂存变量:删除
  * OP,[R],[OP],[R] [Y]: 执行操作,A为操作数,OP为操作符,B为操作数
  * 
- * 多个表达式用 / 分隔,会按顺序依次评估,并返回最后一个表达式的结果
+ * 多个表达式用换行符或者‼分隔,会按顺序依次评估,并返回最后一个表达式的结果
  * @param expression 
  * @returns 
  */
 export function doEvaluateDynamicDataExpression(expressions: string, context: IDynamicCompareExpressionContext) {
   if (!expressions)
     return undefined;
-  const expressionArr = expressions.replace(/\n/g, '/').split('/');
+  const expressionArr = expressions.replace(/\n/g, '‼').split('‼');
   const tempData = new Map<string, any>();
   
   function evaluateExpression(expression: string, prevResult: any) : any {
@@ -240,7 +240,7 @@ export function doEvaluateDynamicDataExpression(expressions: string, context: ID
   for (const expression of expressionArr)
     result = evaluateExpression(expression, result);
 
-  //console.log('doEvaluateDynamicDataExpression: ', expressions, 'result: ', result);
+  console.log('doEvaluateDynamicDataExpression: ', expressions, 'result: ', result);
   return result;
 }
 /**
@@ -320,7 +320,7 @@ export function doEvaluateDynamicCompareExpression(expression: string, context:
  * #jumpMp#openOfficialAccountArticle:A,K#main.externalLink/
  * #default#navigateToAutoContent:K#main
  * 
- * 多个函数用 / 分隔,会按顺序依次调用,并返回最后一个函数的返回值
+ * 多个函数用换行符分隔,会按顺序依次调用,并返回最后一个函数的返回值
  * 
  * @param functions 
  * @param context 
@@ -328,7 +328,7 @@ export function doEvaluateDynamicCompareExpression(expression: string, context:
 export async function doCallDynamicFunction(functions: string, context: IDynamicCompareExpressionContext) {
   if (functions.startsWith('dynamic:'))
     functions = functions.substring(8);
-  const functionArr = functions.replace(/\n/g, '/').split('/');
+  const functionArr = functions.replace(/\n/g, '‼').split('‼');
   let result = null;
   let currentPointer = 0;
   let breakFlag = false;
@@ -359,6 +359,8 @@ export async function doCallDynamicFunction(functions: string, context: IDynamic
     const arr = expression.split(':');
     const functionName = arr[0];
     const params = arr.slice(1);
+    console.log('22222', expression, functionName, params);
+    
     function assertParamCount(count: number) {
       if (params.length !== count)
         throw new Error('doCallDynamicFunction: invalid param count: ' + functionName);
@@ -429,12 +431,21 @@ export async function doCallDynamicFunction(functions: string, context: IDynamic
           doEvaluateDynamicDataExpression(params[1], context);
         break;
       case 'navTo': 
-        assertParamCount(2);
-        navTo(doEvaluateDynamicDataExpression(params[0], context), doEvaluateDynamicDataExpression(params[1], context));
+        assertParamCount(1);
+        console.log('11111', params[0]);
+        
+        navTo(doEvaluateDynamicDataExpression(params[0], context), params[1] ? doEvaluateDynamicDataExpression(params[1], context) : undefined);
         break;
       case 'redirectTo': 
-        assertParamCount(2);
-        redirectTo(doEvaluateDynamicDataExpression(params[0], context), doEvaluateDynamicDataExpression(params[1], context));
+        assertParamCount(1);
+        redirectTo(doEvaluateDynamicDataExpression(params[0], context), params[1] ? doEvaluateDynamicDataExpression(params[1], context) : undefined);
+        break;
+      case 'reLaunch': 
+        assertParamCount(1);
+        uni.reLaunch({
+          url: doEvaluateDynamicDataExpression(params[0], context), 
+          ...(params[1] ? doEvaluateDynamicDataExpression(params[1], context) : {})
+        })
         break;
       case 'back':
         back();

+ 1 - 1
src/pages/article/data/CommonCategoryGlobalLoader.ts

@@ -26,7 +26,7 @@ export function useCommonCategoryGlobalLoader() {
     uni.showLoading({ title: '加载中' });
     try {
       //本地开发时,使用默认配置
-      if (getIsDevtoolsPlatform()) {
+      if (false && getIsDevtoolsPlatform()) {
         commonCategoryData.value = DefaultCofig as IHomeCommonCategoryDefine;
         return;
       }

+ 4 - 0
src/pages/article/data/defines/List.ts

@@ -117,6 +117,10 @@ export interface IHomeCommonCategoryListTabNestCategoryDefine {
  */
 export interface IHomeCommonCategoryListTabNestCategoryItemDefine {
   /**
+   * 子分类外层样式
+   */
+  style?: Record<string, any>,
+  /**
    * 是否可见
    * @default true
    */

+ 38 - 0
src/pages/article/data/editor/components/DynamicDataEditor.vue

@@ -102,6 +102,12 @@
       </template>
 
       <template v-else-if="currentType === 'request'">
+        <a-form-item label="是否添加token (addToken)">
+          <a-switch
+            :checked="(modelValue as IHomeCommonCategoryDynamicDataRequest)?.addToken"
+            @change="(v: boolean) => setRequestAddToken(v)"
+          />
+        </a-form-item>
         <a-form-item label="请求方法 (method)">
           <a-select
             :value="(modelValue as IHomeCommonCategoryDynamicDataRequest)?.method"
@@ -129,8 +135,22 @@
             @update:modelValue="(v: Record<string, any>) => setRequest('otherParams', v)"
           />
         </a-form-item>
+        <a-form-item label="自定义数据处理脚本 (dataSolve)">
+          <a-input
+            :value="(modelValue as IHomeCommonCategoryDynamicDataRequest)?.dataSolve"
+            @change="(e: Event) => setRequestDataSolve((e.target as HTMLInputElement)?.value)"
+          />
+        </a-form-item>
       </template>
 
+      <template v-else-if="currentType === 'staticData'">
+        <a-form-item label="静态数据 (data)">
+          <KeyValueEditor
+            :modelValue="{ array: (modelValue as IHomeCommonCategoryDynamicDataStaticData)?.data }"
+            @update:modelValue="(v: Record<string, any>) => setStaticData('data', v.array)"
+          />
+        </a-form-item>
+      </template>
 
     </a-form>
   </div>
@@ -145,6 +165,7 @@ import type {
   IHomeCommonCategoryDynamicDataRequest,
   IHomeCommonCategoryDynamicDataDetailContent,
   IHomeCommonCategoryDynamicDataParentKey,
+  IHomeCommonCategoryDynamicDataStaticData,
 } from '@/pages/article/data/CommonCategoryDynamicData';
 import { SerializedApiMap } from '@/pages/article/data/CommonCategoryDynamicData';
 import KeyValueEditor from './KeyValueEditor.vue';
@@ -299,6 +320,23 @@ function setDetailContent(key: 'id' | 'modelId', value: number | undefined) {
   emit('update:modelValue', next);
 }
 
+function setRequestDataSolve(value: string) {
+  const cur = props.modelValue as IHomeCommonCategoryDynamicDataRequest | undefined;
+  if (!cur || cur.type !== 'request') return;
+  emit('update:modelValue', { ...cur, dataSolve: value });
+}
+function setRequestAddToken(value: boolean) {
+  const cur = props.modelValue as IHomeCommonCategoryDynamicDataRequest | undefined;
+  if (!cur || cur.type !== 'request') return;
+  emit('update:modelValue', { ...cur, addToken: value });
+}
+
+function setStaticData(key: 'data', value: Record<string, any>[]) {
+  const cur = props.modelValue as IHomeCommonCategoryDynamicDataStaticData | undefined;
+  if (!cur || cur.type !== 'staticData') return;
+  emit('update:modelValue', { ...cur, [key]: value });
+}
+
 </script>
 
 <style lang="scss" scoped>

+ 3 - 0
src/pages/article/data/editor/subpart/NestCategoryEditorItem.vue

@@ -22,6 +22,9 @@
         :indeterminate="cat.showMore === undefined"
       />
     </a-form-item>
+    <a-form-item label="外层样式">
+      <KeyValueEditor v-model="cat.style" />
+    </a-form-item>
     <a-form-item v-if="cat.showTitle !== false && cat.showMore !== false" label="更多页">
       <LinkPathEditor v-model="cat.morePage" />
     </a-form-item>

+ 50 - 1
src/pages/blocks/RichBlock.vue

@@ -1,6 +1,13 @@
 <template>
   <view>
-    <Parse :content="content" />
+    <Parse 
+      :content="content" 
+      :tagStyle="tagStyle"
+      :classStyle="classStyle"
+      :contentStyle="contentStyle"
+      @linkTap="linkTap"
+      @viewTap="viewTap"
+    />
   </view>
 </template>
 
@@ -8,12 +15,33 @@
 import Parse from '@/components/display/parse/Parse.vue';
 import { onMounted, ref } from 'vue';
 import { doLoadDynamicDetailData, type IHomeCommonCategoryDynamicData } from '../article/data/CommonCategoryDynamicData';
+import { doEvaluateDynamicDataExpression } from '../article/data/CommonCategoryDynamicEvax';
 
 const props = defineProps({
   content: {
     type: null,
     default: ''
   },
+  linkTapScript: {
+    type: String,
+    default: '',
+  },
+  viewTapScript: {
+    type: String,
+    default: '',
+  },
+  tagStyle: {
+    type: Object,
+    default: () => ({}),
+  },
+  classStyle: {
+    type: Object,
+    default: () => ({}),
+  },
+  contentStyle: {
+    type: Object,
+    default: () => ({}),
+  },
 });
 
 const finalContent = ref('');
@@ -26,6 +54,27 @@ async function loadContent() {
   }
 }
 
+function linkTap(href: string) {
+  if (props.linkTapScript) {
+    doEvaluateDynamicDataExpression(props.linkTapScript, {
+      sourceData: {
+        main: { href },
+        customData: {},
+      },
+    });
+  }
+}
+function viewTap(id: string) {
+  if (props.viewTapScript) {
+    doEvaluateDynamicDataExpression(props.viewTapScript, {
+      sourceData: {
+        main: { id },
+        customData: {},
+      },
+    });
+  }
+}
+
 onMounted(() => {
   loadContent();
 });

+ 1 - 1
src/pages/parts/Box2LineLargeImageUserShadow.vue

@@ -17,7 +17,7 @@
     <NaImage 
       v-if="image" 
       width="100%"
-      :height="300"
+      :height="320"
       :radius="15"
       :src="image" 
       mode="aspectFit" 

+ 51 - 0
src/pages/travel/route/components/NextBestWay.vue

@@ -0,0 +1,51 @@
+<template>
+  <FlexRow align="center" :margin="[0,0,10,100]" :gap="12">
+    <template v-if="canShow">
+      <template v-if="userPreferredWay">
+        <Tag :text="getRouteToNextBestWayText(finalWay)" touchable @click="emit('focusRoute')" />
+        <Text :text="formatDistance(toNextRoute[finalWay]?.distance)" />
+        <Text :text="formatDuration(toNextRoute[finalWay]?.duration)" />
+      </template>
+      <template v-else>
+        <Tag :text="'推荐 ' + getRouteToNextBestWayText(finalWay)" touchable @click="emit('focusRoute')" />
+        <Text :text="formatDistance(toNextRoute[finalWay]?.distance)" />
+        <Text :text="formatDuration(toNextRoute[finalWay]?.duration)" />
+      </template>
+    </template>
+  </FlexRow>
+  <slot name="extra" :finalWay="finalWay" />
+</template>
+
+<script setup lang="ts">
+import { formatDistance, formatDuration, getRouteToNextBestWayText, type RouteInfo, type RouteToNextBestWay } from '@/api/traval/RouteApi';
+import Text from '@/components/basic/Text.vue';
+import Tag from '@/components/display/Tag.vue';
+import FlexRow from '@/components/layout/FlexRow.vue';
+import { computed } from 'vue';
+
+const props = defineProps<{
+  toNextRoute: RouteInfo['items'][number]['toNextRoute'],
+  toNextBestWay?: RouteToNextBestWay|'',
+  userPreferredWay?: RouteToNextBestWay|'',
+}>();
+
+const emit = defineEmits([ 'focusRoute' ]);
+
+const canShow = computed(() => props.toNextRoute && Object.keys(props.toNextRoute).length > 0);
+const finalWay = computed(() => {
+  let way = props.userPreferredWay || props.toNextBestWay || 'transit';
+  if (!props.toNextRoute[way] 
+    || !props.toNextRoute[way].steps?.length 
+    || (props.toNextRoute[way].steps.length ) < 2
+  ) {
+    const keys = Object.keys(props.toNextRoute);
+    for (const key of keys) {
+      if ((props.toNextRoute[key as RouteToNextBestWay].steps?.length || 0) > 1) {
+        way = key as RouteToNextBestWay;
+        break;
+      }
+    }
+  }
+  return way;
+});
+</script>

+ 48 - 0
src/pages/travel/route/details.vue

@@ -0,0 +1,48 @@
+<template>
+  <!-- 景点详情 -->
+  <FlexCol v-if="currentScenicSpot" position="relative" :padding="30" :gap="20">
+    <FlexRow :gap="20" justify="space-between" align="center">
+      <Image :src="currentScenicSpot.image" :radius="20" :width="80" :height="80" mode="aspectFill" />
+      <FlexCol>
+        <Text :fontSize="33" bold>{{ currentScenicSpot.name }}</Text>
+        <Text :fontSize="26" color="text.second">{{ currentScenicSpot.address }}</Text>
+      </FlexCol>
+      <Button
+        text="导航"
+        icon="map"
+        size="small"
+        :radius="40"
+        @click="openLocation(currentScenicSpot)"
+      />
+    </FlexRow>
+    <ImageSwiper :images="currentScenicSpot.images" />
+    <Empty v-if="!currentScenicSpot.intro" :description="currentScenicSpot.name + '暂无简介'" />
+    <Parse v-else :content="currentScenicSpot.intro" />
+  </FlexCol>
+</template>
+
+<script setup lang="ts">
+import type { RouteInfo } from '@/api/traval/RouteApi';
+import Button from '@/components/basic/Button.vue';
+import Image from '@/components/basic/Image.vue';
+import Text from '@/components/basic/Text.vue';
+import Parse from '@/components/display/parse/Parse.vue';
+import Empty from '@/components/feedback/Empty.vue';
+import FlexCol from '@/components/layout/FlexCol.vue';
+import FlexRow from '@/components/layout/FlexRow.vue';
+import ImageSwiper from '@/pages/parts/ImageSwiper.vue';
+import { onMounted, ref } from 'vue';
+
+const currentScenicSpot = ref<RouteInfo['items'][number]['scenicSpot']|null>(null);
+
+function openLocation(item: RouteInfo['items'][number]['scenicSpot']) {
+  uni.openLocation({
+    latitude: item.latitude,
+    longitude: item.longitude,
+  });
+}
+
+onMounted(() => {
+  currentScenicSpot.value = uni.getStorageSync('travelRouteItem');
+});
+</script>

+ 26 - 20
src/pages/travel/route/list.vue

@@ -1,24 +1,23 @@
 <template>
-  <FlexCol innerClass="nana-travel-route-list">
+  <FlexCol 
+    v-if="appConfiguration"
+    innerClass="nana-travel-route-list" 
+    :innerStyle="{ 
+      backgroundImage: `url('${appConfiguration.routeListImage}')`,
+      ...appConfiguration.routeListImageStyle
+    }"
+  >
     <StatusBarSpace backgroundColor="transparent" />
     <NavBar
       title=""
       leftButton="back"
       backgroundColor="transparent"
-    >
-      <template #center>
-        <Image
-          src="https://mncdn.wenlvti.net/app_static/minnan/images/travel/RouteTitle.png"
-          mode="heightFix"
-          :height="45"
-        />
-      </template>
-    </NavBar>
+    />
     <SimplePageContentLoader :loader="listLoader">
-      <FlexCol backgroundColor="#fdfefe" :gap="30" :radius="35" :padding="40" :margin="[300,0,0,0]">
-        <FlexRow>
+      <FlexCol backgroundColor="#fdfefe" :gap="30" :radius="35" :padding="40" :margin="[appConfiguration.routeListMarginTop,0,0,0]">
+        <FlexRow justify="space-between">
           <SimpleDropDownPicker v-model="selectedRegion" :columns="regionData.content.value" />
-          <SimpleDropDownPicker v-model="selectedType" :columns="typeData.content.value" />
+          <!-- <SimpleDropDownPicker v-model="selectedType" :columns="typeData.content.value" /> -->
           <SearchBar 
             v-model="searchValue"
             placeholder="搜索路线" 
@@ -28,14 +27,16 @@
             @cancel="doSearch" 
           />
         </FlexRow>
-        <FlexCol 
+        <Touchable           
           v-for="(item,index) in listLoader.content.value"
           backgroundColor="white"
           :key="item.id"
           :radius="20"
           :padding="[25,30]"
           :gap="10"
+          direction="column"
           shadow="default"
+          @click="handleGo(item)"
         >
           <FlexRow :gap="20">
             <FlexCol position="relative">
@@ -59,10 +60,10 @@
           </FlexRow>
           <Divider color="border.light" />
           <FlexRow justify="space-between">
-            <Text fontConfig="subSecondText" :text="`热度:${item.views}`" />
+            <Text fontConfig="subSecondText" :text="`${item.regionText} · ${item.keywords?.length || 0} 个景点`" />
             <Button size="small" type="primary" shape="round" :radius="40" text="Let's Go" @click="handleGo(item)" />
           </FlexRow>
-        </FlexCol>
+        </Touchable>
         <Empty
           v-if="listLoader.content.value?.length == 0"
           :description="listLoader.content.value?.length == 0 ? '暂无数据' : ''"
@@ -76,6 +77,9 @@
 import { onMounted, ref, watch } from 'vue';
 import { navTo } from '@/components/utils/PageAction';
 import { useSimplePageContentLoader } from '@/common/composeabe/SimplePageContentLoader';
+import { useSimpleDataLoader } from '@/common/composeabe/SimpleDataLoader';
+import { waitTimeOut } from '@imengyu/imengyu-utils';
+import { injectAppConfiguration } from '@/api/system/useAppConfiguration';
 import type { GetContentListItem } from '@/api/CommonContent';
 import SimplePageContentLoader from '@/common/components/SimplePageContentLoader.vue';
 import FlexCol from '@/components/layout/FlexCol.vue';
@@ -90,9 +94,11 @@ import Button from '@/components/basic/Button.vue';
 import Tag from '@/components/display/Tag.vue';
 import SearchBar from '@/components/form/SearchBar.vue';
 import SimpleDropDownPicker from '@/common/components/SimpleDropDownPicker.vue';
-import { useSimpleDataLoader } from '@/common/composeabe/SimpleDataLoader';
 import CommonContent, { GetContentListParams } from '@/api/CommonContent';
 import Empty from '@/components/feedback/Empty.vue';
+import Touchable from '@/components/feedback/Touchable.vue';
+
+const appConfiguration = injectAppConfiguration();
 
 const listLoader = useSimplePageContentLoader(async () => {
   const res = (await TravalContent.getTravalRouteList(
@@ -149,14 +155,14 @@ const handleGo = (item: GetContentListItem) => {
   navTo('./travel-route', { id: item.id });
 };
 
-onMounted(() => {
-  listLoader.loadData(undefined, true);
+onMounted(async () => {
+  await waitTimeOut(250);
+  await listLoader.loadData(undefined, true);
 });
 </script>
 
 <style lang="scss">
 .nana-travel-route-list {
-  background-image: url('https://mncdn.wenlvti.net/app_static/minnan/images/travel/RouteHeader.jpg');
   background-size: 100% auto;
   background-position: 0 60rpx;
   background-repeat: no-repeat;

+ 255 - 137
src/pages/travel/route/travel-route.vue

@@ -16,6 +16,9 @@
         leftButton="back"
       />
     </FlexCol>
+    <FlexCol position="absolute" :bottom="630" :right="30" :zIndex="100">
+      <Button icon="my-location" :iconProps="{ size: 40 }" @click="goToCurrentLocation" />
+    </FlexCol>
     <map
       id="map"
       :markers="markers"
@@ -36,123 +39,122 @@
       :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 v-if="travalDetail" :padding="25" :gap="20">
+          <H2>{{ travalDetail.title }}</H2>
+          <Text>{{ travalDetail.desc }}</Text>
+          <Tabs 
+            v-model:currentIndex="currentTabIndex"
+            :autoItemWidth="false"
+            :tabs="[
+              { text: '路线规划', width: 140 },
+              { text: '路线介绍', width: 140 },
+            ]"
+          />
+          <!-- 路线规划 -->
+          <FlexCol v-if="currentTabIndex === 0" :gap="20">
+            <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>
-            <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 :gap="20">
+              <Text color="text.second">路线规划</Text>
               <FlexCol 
-                v-if="item.toNextRoute && item.toNextRoute['transit'] && (userPreferredWay === 'transit' || item.toNextBestWay === 'transit' || !item.toNextBestWay)" 
-                :gap="8" 
-                :margin="[0,0,0,100]"
+                v-for="(item, index) in routeInfo?.items || []" :key="item.id" 
+                :gap="20"
               >
-                <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>
+                <Touchable direction="row" justify="space-between" :gap="20" @click="onItemClick(item)">
+                  <FlexRow :gap="20">
+                    <Image :src="item.scenicSpot.image" :radius="20" :width="80" :height="80" mode="aspectFill" />
+                    <FlexCol>
+                      <Text bold :color="routeColors[index]">打卡点 {{ (index + 1) }}</Text><!-- //如果显示天数则用 item.atDay -->
+                      <Text>{{ item.scenicSpot.name }}</Text>
+                      <Text color="text.second">{{ item.scenicSpot.address }}</Text>
+                    </FlexCol>
+                  </FlexRow>
+                  <FlexCol center :gap="10" :innerStyle="{ minWidth: '150rpx' }">
+                    <Button type="text" icon="navigation" text="导航" @click="openLocation(item)" />
+                    <template v-if="currentLocationGeted">
+                      <Text color="text.second" :fontSize="22">距您约</Text>
+                      <Text color="text.second" :fontSize="28" :text="getDistance(item)" />
+                    </template>
+                  </FlexCol>
+                </Touchable>
+                <NextBestWay 
+                  :toNextRoute="item.toNextRoute"
+                  :toNextBestWay="item.toNextBestWay"
+                  :userPreferredWay="userPreferredWay"
+                  @focusRoute="focusRoute(item)"
+                >
+                  <template #extra="{ finalWay }">
+                    <!-- 公交地铁路线规划 -->
+                    <FlexCol 
+                      v-if="item.toNextRoute && item.toNextRoute['transit'] && finalWay === 'transit'" 
+                      :gap="8" 
+                      :margin="[10,0,10,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">
+                            <Tag type="primary" :text="(stepIdx + 1).toString()" />
+                            <Icon icon="walk" :size="40" />
+                            <Text color="text.second">步行</Text>
+                            <Text>{{ formatDistance(step.distance) }} · 约 {{ formatDuration(step.duration) }}</Text>
+                          </FlexRow>
+                        </template>
+                        <template v-else-if="step.mode === 'TRANSIT' && step.lines?.length">
+                          <FlexRow :gap="8" align="center">
+                            <Tag type="success" :text="(stepIdx + 1).toString()" />
+                            <Icon icon="bus" :size="40" />
+                            <Button 
+                              v-if="step.lines?.length > 1" 
+                              type="text"
+                              rightIcon="arrow-down"
+                              :text="step.lines?.length > 1 ? `可搭乘 ${step.lines[0].title} 等 ${step.lines?.length} 条线路` : ''"
+                              @click="(step as any).open=true"
+                            />
+                          </FlexRow>
+                          <FlexCol v-for="(line, lineIdx) in step.lines" :key="lineIdx" :gap="10" :padding="[0,0,0,50]">
+                            <FlexRow :gap="8" align="center">
+                              <template v-if="line.vehicle === 'BUS'">
+                                <Icon v-if="line.vehicle === 'BUS'" icon="bus" :size="40" />
+                                <Text color="text.second">公交</Text>
+                              </template>
+                              <template v-else-if="line.vehicle === 'SUBWAY'">
+                                <Icon icon="subway" :size="40" />
+                                <Text color="text.second">地铁</Text>
+                              </template>
+                              <template v-else-if="line.vehicle === 'RAIL'">
+                                <Icon icon="rail" :size="40" />
+                                <Text color="text.second">火车</Text>
+                              </template>
+                              <Text bold :color="(line as any).line_color || '#000'">{{ line.title }}</Text>
+                              <Text color="text.second" :margin="[0,0,0,8]">{{ line.geton?.title }} → {{ line.getoff?.title }}</Text>
+                            </FlexRow>
+                            <Text color="text.second">约 {{ formatDuration(line.duration) }} · {{ line.station_count || 0 }} 站</Text>
+                          </FlexCol>
+                        </template>
+                      </FlexCol>
                     </FlexCol>
                   </template>
-                </FlexCol>
+                </NextBestWay>
               </FlexCol>
-            </Touchable>
+            </FlexCol>
+            <Height :height="100" />
+          </FlexCol>
+          <!-- 路线介绍 -->
+          <FlexCol v-if="currentTabIndex === 1">
+            <Parse :content="travalDetail.content" />
+            <Height :height="100" />
           </FlexCol>
         </FlexCol>
       </template>
@@ -183,6 +185,12 @@ import Touchable from '@/components/feedback/Touchable.vue';
 import Parse from '@/components/display/parse/Parse.vue';
 import IconButton from '@/components/basic/IconButton.vue';
 import { alert } from '@/components/utils/DialogAction';
+import Tabs from '@/components/nav/Tabs.vue';
+import Icon from '@/components/basic/Icon.vue';
+import CollapseBox from '@/components/display/CollapseBox.vue';
+import NextBestWay from './components/NextBestWay.vue';
+import Height from '@/components/layout/space/Height.vue';
+import { navTo } from '@/components/utils/PageAction';
 
 const markers = ref<any[]>([]);
 /** 地图折线:每条 toNextRoute 一段,points 来自 toNextRoute.xxx.polyline (number[] 为 经度,纬度 交替) */
@@ -196,11 +204,13 @@ const polyline = ref<Array<{
 }>>([]);
 const circles = ref<any[]>([]);
 
+const currentTabIndex = ref(0);
 const mapCtx = uni.createMapContext('map');
+const myLocation = ref<[number, number]>([0, 0]);
 const currentLocation = ref<[number, number]>([0, 0]);
+const currentLocationGeted = ref(false);
 const travalDetail = ref<GetContentDetailItem>();
 const routeInfo = ref<RouteInfo>();
-const currentScenicSpot = ref<RouteInfo['items'][number]['scenicSpot'] | undefined>();
 const userPreferredWay = ref<RouteToNextBestWay|''>('');
 const bottomSheetRef = ref<BottomSheetExpose>();
 
@@ -210,10 +220,65 @@ const { querys } = useLoadQuerys({
   loadRoute();
 });
 
-const routeColors = [ '', '#22ac38', '#00a0e9', '#8957a1', '#eb6877', '#f39800', '#e60012' ];
+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`;
+  return `https://mncdn.wenlvti.net/app_static/minnan/images/IcoMaker${(day + 1)}.png`;
+}
+
+/** 根据两点经纬度计算直线距离(米),Haversine 公式 */
+function getDistanceMeters(
+  lon1: number, lat1: number,
+  lon2: number, lat2: number
+): number {
+  const R = 6371000; // 地球半径 米
+  const dLat = (lat2 - lat1) * Math.PI / 180;
+  const dLon = (lon2 - lon1) * Math.PI / 180;
+  const a =
+    Math.sin(dLat / 2) * Math.sin(dLat / 2) +
+    Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
+    Math.sin(dLon / 2) * Math.sin(dLon / 2);
+  const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+  return R * c;
+}
+
+function goToCurrentLocation() {
+  uni.showLoading({
+    title: '获取位置中...',
+  });
+  uni.getLocation({
+    type: 'wgs84',
+    success: (res) => {
+      uni.hideLoading();
+      myLocation.value = [res.longitude, res.latitude];
+      currentLocation.value = myLocation.value;
+      mapCtx.moveToLocation({
+        latitude: myLocation.value[1],
+        longitude: myLocation.value[0],
+      });
+    },
+    fail: (err) => {
+      uni.hideLoading();
+      console.log(err);
+      alert({
+        title: '获取位置失败',
+        content: '请检查是否开启定位权限',
+      });
+    },
+  });
+}
+function getDistance(item: RouteInfo['items'][number]): string {
+  const [lng, lat] = myLocation.value;
+  const meters = getDistanceMeters(lng, lat, item.scenicSpot.longitude, item.scenicSpot.latitude);
+  if (meters < 100) return '一百米内';
+  if (meters < 1000) return `${Math.round(meters / 100) * 100}米`;
+  return `${Math.round(meters / 1000)}km`;
+}
+function openLocation(item: RouteInfo['items'][number]) {
+  uni.openLocation({
+    latitude: item.scenicSpot.latitude,
+    longitude: item.scenicSpot.longitude,
+  });
 }
 
 /**
@@ -248,20 +313,62 @@ function collectTransitPolylines(transitData: RouteInfo['items'][0]['toNextRoute
   }
   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}`;
 }
+function getSegmentTransitLabel(transitData: RouteInfo['items'][0]['toNextRoute']['transit'], index: number): string {
+  if (!transitData?.steps?.length) return `公交路段${index + 1}`;
+
+  let segIndex = 0;
+  for (const step of transitData.steps) {
+    if (step.mode === 'WALKING') {
+      if (!step.polyline?.length) continue;
+      const current = segIndex++;
+      if (current !== index) continue;
+
+      const d = step.distance != null ? formatDistance(step.distance) : '';
+      const t = step.duration != null ? formatDuration(step.duration) : '';
+      if (d && t) return `步行 ${d}·约${t}`;
+      if (d) return `步行 ${d}`;
+      if (t) return `步行 约${t}`;
+      return '步行';
+    }
+
+    if (step.mode === 'TRANSIT') {
+      for (const line of (step.lines || [])) {
+        if (!line.polyline?.length) continue;
+        const current = segIndex++;
+        if (current !== index) continue;
+
+        const vehicle = (line.vehicle || '').toUpperCase();
+        const vehicleName =
+          vehicle === 'BUS' ? '公交' :
+          vehicle === 'SUBWAY' ? '地铁' :
+          vehicle === 'RAIL' ? '火车' :
+          '线路';
+
+        const title = line.title ? `${vehicleName} ${line.title}` : vehicleName;
+        const fromTo = (line.geton?.title && line.getoff?.title) ? ` ${line.geton.title}→${line.getoff.title}` : '';
+        const stations = line.station_count != null ? `·${line.station_count}站` : '';
+        const d = line.distance != null ? `·${formatDistance(line.distance)}` : '';
+        const t = line.duration != null ? `·约${formatDuration(line.duration)}` : '';
+        return `${title}${fromTo}${stations}${d}${t}`.trim();
+      }
+    }
+  }
 
+  return `公交路段${index + 1}`;
+}
 /** 根据 toNextRoute 与 toNextBestWay 生成地图 polyline 数组(含 segmentTexts 折线说明) */
 function buildPolylineFromRouteInfo(info: RouteInfo) {
   const segments: Array<{
     points: { longitude: number; latitude: number }[];
     color: string;
     width: number;
+    level: string;
     segmentTexts?: { name: string; startIndex: number; endIndex: number }[];
     textStyle?: { textColor: string; strokeColor: string; fontSize: number };
   }> = [];
@@ -272,7 +379,7 @@ function buildPolylineFromRouteInfo(info: RouteInfo) {
     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 color = routeColors[i]; //如果显示天数则用 item.atDay
     const width = 6;
 
     if (way === 'transit') {
@@ -282,8 +389,19 @@ function buildPolylineFromRouteInfo(info: RouteInfo) {
       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 });
+        const segmentTexts = k === 0 ? [
+          { name: label, startIndex: 0, endIndex: points.length - 1 }
+        ] : [
+          { name: getSegmentTransitLabel(transitData!, k), startIndex: 0, endIndex: points.length - 1 }
+        ];
+        segments.push({ 
+          points, 
+          color, 
+          width, 
+          level: 'aboveroads', 
+          segmentTexts, 
+          textStyle: segmentTexts ? textStyle : undefined 
+        });
       }
     } else {
       const encoded = (routeData as { polyline?: number[]; duration?: number })?.polyline;
@@ -292,7 +410,14 @@ function buildPolylineFromRouteInfo(info: RouteInfo) {
       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 });
+      segments.push({ 
+        points, 
+        color, 
+        width, 
+        level: 'aboveroads',
+        segmentTexts, 
+        textStyle 
+      });
     }
   }
   polyline.value = segments;
@@ -301,22 +426,10 @@ function buildPolylineFromRouteInfo(info: RouteInfo) {
 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),
-  });
+  uni.setStorageSync('travelRouteItem', item.scenicSpot);
+  navTo('./details');
 }
 function focusRoute(item: RouteInfo['items'][number]) {
   bottomSheetRef.value?.setDragHeightToMin();
@@ -332,7 +445,7 @@ async function loadRoute() {
       if (error.code == 404) {
         alert({
           title: '路线不存在',
-          content: '路线未生成,请在服务器端生成路线',
+          content: '路线未生成,请在服务器端生成路线,ID: ' + querys.value.id,
         });
         return;
       }
@@ -345,17 +458,17 @@ async function loadMap() {
 
   // 生成标记点
   const tempMarkers = routeInfo.value.items
-    .map(p => ({
+    .map((p, index) => ({
       latitude: p.scenicSpot.latitude,
       longitude: p.scenicSpot.longitude,
-      iconPath: getMakerImage(p.atDay),
+      iconPath: getMakerImage(index),
       callout: {
         content: p.scenicSpot.name,
         color: "#ffffff",
         fontSize: 15,
         borderRadius: 15,
         padding: "10",
-        bgColor: routeColors[p.atDay],
+        bgColor: routeColors[index],
         display: "ALWAYS",
       },
       width: 40,
@@ -386,7 +499,12 @@ onMounted(() => {
   uni.getLocation({
     type: 'wgs84',
     success: (res) => {
+      myLocation.value = [res.longitude, res.latitude];
       currentLocation.value = [res.longitude, res.latitude];
+      currentLocationGeted.value = true;
+    },
+    fail: (err) => {
+      console.log(err);
     },
   });
 });