Parcourir la source

⚙ 更新组件库

快乐的梦鱼 il y a 2 semaines
Parent
commit
d3295b46c4
42 fichiers modifiés avec 1421 ajouts et 632 suppressions
  1. 2 2
      src/components/README.md
  2. 1 1
      src/components/basic/ActivityIndicator.vue
  3. 3 3
      src/components/basic/Button.vue
  4. 7 1
      src/components/basic/CellGroup.vue
  5. 26 8
      src/components/basic/IconButton.vue
  6. 51 11
      src/components/basic/Image.vue
  7. 202 0
      src/components/dialog/BottomSheet.vue
  8. 19 7
      src/components/dialog/Popup.vue
  9. 35 0
      src/components/display/Divider.vue
  10. 70 8
      src/components/display/TextEllipsis.vue
  11. 0 1
      src/components/display/block/ImageBlock2.vue
  12. 16 1
      src/components/display/block/ImageBlock3.vue
  13. 41 0
      src/components/display/parse/Parse.ts
  14. 123 486
      src/components/display/parse/Parse.vue
  15. 385 0
      src/components/display/parse/ParseNodeRender.vue
  16. 71 33
      src/components/dynamic/wrappers/CheckBoxList.vue
  17. 4 3
      src/components/feedback/Alert.vue
  18. 93 19
      src/components/feedback/SwipeRow.vue
  19. 3 0
      src/components/form/CalendarField.vue
  20. 2 0
      src/components/form/CascadePickerField.vue
  21. 2 0
      src/components/form/CascaderField.vue
  22. 14 9
      src/components/form/CheckBox.vue
  23. 1 0
      src/components/form/CheckBoxDefaultButton.vue
  24. 2 0
      src/components/form/DatePickerField.vue
  25. 2 0
      src/components/form/DateTimePickerField.vue
  26. 2 1
      src/components/form/Field.vue
  27. 34 0
      src/components/form/Picker.ts
  28. 1 5
      src/components/form/Picker.vue
  29. 3 0
      src/components/form/PickerField.vue
  30. 65 4
      src/components/form/SearchBar.vue
  31. 2 0
      src/components/form/TimePickerField.vue
  32. 0 2
      src/components/form/UploaderField.vue
  33. 2 0
      src/components/layout/space/StatusBarSpace.vue
  34. 26 2
      src/components/nav/NavBar.vue
  35. 47 3
      src/components/nav/Pagination.vue
  36. 1 1
      src/components/nav/Tabs.vue
  37. 4 0
      src/components/theme/Theme.ts
  38. 29 6
      src/components/utils/PageAction.ts
  39. 7 0
      src/pages.json
  40. 15 0
      src/pages/test/topic.vue
  41. 8 1
      src/pages/user/debug/DebugButton.vue
  42. 0 14
      src/pages/user/index.vue

+ 2 - 2
src/components/README.md

@@ -6,11 +6,11 @@ NaEasy UI 是一款简单的 UniApp 移动端UI组件库。
 
 ## 版本
 
-当前版本:INDEV 0.0.7-25121901
+当前版本:1.0.9-26031101
 
 ## 版权说明
 
-© 2025 imengyu. 保留所有权利。
+© 2026 imengyu. 保留所有权利。
 
 本组件库基于 MIT 许可证开源。您可以自由地使用、修改和分发本组件库,但必须保留原始的版权声明和许可证文本。
 

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

@@ -24,7 +24,7 @@ export interface ActivityIndicatorProps {
    */
   color?: string,
   /**
-   * 加载中圆圈大小
+   * 加载中圆圈颜色
    */
   size?: string|number,
   /**

+ 3 - 3
src/components/basic/Button.vue

@@ -70,7 +70,7 @@ import Touchable from '../feedback/Touchable.vue';
 import type { ButtonGroupContext } from './ButtonGroup.vue';
 import { useChildLinkChild } from '../composeabe/ChildItem';
 
-export type ButtomType = 'default'|'primary'|'success'|'warning'|'danger'|'custom'|'text';
+export type ButtonType = 'default'|'primary'|'success'|'warning'|'danger'|'custom'|'text';
 export type ButtomSizeType = 'small'|'medium'|'large'|'larger'|'mini';
 
 export interface ButtonProp {
@@ -82,7 +82,7 @@ export interface ButtonProp {
    * 按钮支持 default、primary、success、warning、danger、custom 自定义 六种类型
    * @default 'default'
    */
-  type?: ButtomType,
+  type?: ButtonType,
   /**
    * 占满父级主轴
    * @default false
@@ -328,7 +328,7 @@ const themeStyles = themeContext.useThemeStyles({
 
 //按钮样式生成
 const currentStyle = computed(() => {
-  const colorStyle = selectStyleType<ViewStyle, ButtomType>(props.type, 'default', 
+  const colorStyle = selectStyleType<ViewStyle, ButtonType>(props.type, 'default', 
     selectStyleType(props.scheme, 'default', {
       default: {
         default: themeStyles.buttonDefault.value,

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

@@ -1,5 +1,5 @@
 <template>
-  <FlexCol :style="{ width: '100%' }">
+  <FlexCol :flex="1">
     <text v-if="title" :style="(titleSpeicalStyle as any)">
       {{ title }}
     </text>
@@ -87,4 +87,10 @@ const insetViewStyle = computed(() => ({
   flexDirection: 'column',
 }));
 
+
+defineOptions({
+  options: {
+    virtualHost: true,
+  }
+})
 </script>

+ 26 - 8
src/components/basic/IconButton.vue

@@ -2,7 +2,7 @@
   <Touchable
     :pressedColor="pressedBackgroundColor"
     :innerStyle="style"
-    touchable
+    :touchable="touchable"
     @click="(e) => emit('click', e)"
     v-bind="$attrs"
   >
@@ -19,7 +19,14 @@ import type { IconProps } from './Icon.vue';
 import Icon from './Icon.vue';
 import Touchable from '../feedback/Touchable.vue';
 
-export type IconButtonShapeType = 'round'|'square-full'|'custom';
+/**
+ * 图标按钮形状预设
+ * * round: 圆角按钮
+ * * round-full: 圆角按钮,宽度和高度相等
+ * * square-full: 方角按钮,宽度和高度相等
+ * * custom: 自定义按钮形状
+ */
+export type IconButtonShapeType = 'round'|'round-full'|'square-full'|'custom';
 
 export interface IconButtonProps extends IconProps {
   /**
@@ -32,6 +39,11 @@ export interface IconButtonProps extends IconProps {
    */
   padding?: number,
   /**
+   * 是否可点击
+   * @default true
+   */
+  touchable?: boolean|undefined;
+  /**
    * 按钮形状预设
    * @default round
    */
@@ -46,9 +58,9 @@ export interface IconButtonProps extends IconProps {
    */
   buttonStyle?: ViewStyle;
   /**
-   * 按钮大小
+   * 按钮大小。可以是一个数字,也可以是一个数组,数组的第一个元素是宽度,第二个元素是高度。
    */
-  buttonSize?: number|string;
+  buttonSize?: number|((number|string)[])|string;
   /**
    * 按钮背景颜色
    */
@@ -59,7 +71,8 @@ const emit = defineEmits(['click']);
 const theme = useTheme();
 const props = withDefaults(defineProps<IconButtonProps>(), {
   shape: 'custom',
-  pressedBackgroundColor: () => propGetThemeVar('IconButtonPressedColor', 'pressed.white')
+  pressedBackgroundColor: () => propGetThemeVar('IconButtonPressedColor', 'pressed.white'),
+  touchable: true,
 });
 const style = computed(() => {
   return {
@@ -67,12 +80,17 @@ const style = computed(() => {
     justifyContent: 'center',
     alignItems: 'center',
     padding: props.padding,
-    width: theme.resolveThemeSize(props.buttonSize),
-    height: theme.resolveThemeSize(props.buttonSize),
+    width: Array.isArray(props.buttonSize) ? theme.resolveThemeSize(props.buttonSize[0]) : theme.resolveThemeSize(props.buttonSize),
+    height: Array.isArray(props.buttonSize) ? theme.resolveThemeSize(props.buttonSize[1]) : theme.resolveThemeSize(props.buttonSize),
     backgroundColor: theme.resolveThemeColor(props.backgroundColor),
     ...selectStyleType(props.shape, 'round', {
       "round": {
-        borderRadius: theme.resolveThemeSize('IconButtonRoundBorderRadius', 50),
+        borderRadius: theme.resolveThemeSize('IconButtonRoundBorderRadius', 20),
+      },
+      "round-full": {
+        height: '100%',
+        aspectRatio: 1,
+        borderRadius: '50%',
       },
       "custom": {},
       "square-full": {

+ 51 - 11
src/components/basic/Image.vue

@@ -6,12 +6,16 @@
     :class="innerClass"
     @click="handleClick"
   >
+    <view v-if="showBackgroundEffect" :style="(backgroundEffectStyle as any)">
+    </view>
+    <view v-if="showBackgroundEffect" :style="(backgroundEffectStyle2 as any)">
+    </view>
     <image 
       :style="{
         width: precentOrFull(style.width),
         height: precentOrFull(style.height),
       }"
-      :mode="($attrs.mode as any)"
+      :mode="(mode as any)"
       :lazyLoad="$attrs.lazyLoad"
       :fadeShow="$attrs.fadeShow"
       :webp="$attrs.webp"
@@ -69,6 +73,10 @@ export interface ImageProps {
    * 是否显示灰色占位,默认是
    */
   showGrey?: boolean,
+  /**
+   * 在填充模式下是否显示背景模糊样式,默认是
+   */
+  showBackgroundEffect?: boolean,
   width?: string|number,
   height?: string|number,
   /**
@@ -92,17 +100,20 @@ export interface ImageProps {
    */
   touchable?: boolean,
   /**
-   * 图片是否有圆角
+   * 图片是否有圆型(50%圆角)
+   * @default false
    */
   round?: boolean,
   /**
-   * 当round为true的圆角大小,默认是50%
+   * 圆角大小
+   * @default 0
    */
   radius?: string|number,
   /**
    * 内部样式
    */
   innerStyle?: object;
+  mode?: 'aspectFill' | 'aspectFit' | 'widthFix' | 'heightFix' | 'top' | 'bottom' | 'left' | 'right' | 'center',
   innerClass?: string,
 }
 
@@ -120,14 +131,17 @@ const props = withDefaults(defineProps<ImageProps>(), {
   showLoading: () => propGetThemeVar('ImageShowLoading', true),
   showFailed: () => propGetThemeVar('ImageShowFailed', true),
   showGrey: () => propGetThemeVar('ImageShowGrey', false),
+  showBackgroundEffect: () => propGetThemeVar('ImageShowBackgroundEffect', true),
   loading: false,
   loadingColor: () => propGetThemeVar('ImageLoadingColor', 'border.default'),
   loadingSize: () => propGetThemeVar('ImageLoadingSize', 50),
   touchable: false,
   round: () => propGetThemeVar('ImageRound', false),
-  radius: () => propGetThemeVar('ImageRadius', '50%'),
+  radius: () => propGetThemeVar('ImageRadius', ''),
 })
 const emit = defineEmits([ 'click' ]);
+const showBackgroundEffect = computed(() => props.showBackgroundEffect && props.mode === 'aspectFit');
+
 
 const isErrorState = ref(false);
 const isLoadState = ref(true);
@@ -136,7 +150,7 @@ const instance = getCurrentInstance();
 
 const style = computed(() => {
   const o : Record<string, any> = {
-    borderRadius: props.round ? themeContext.resolveThemeSize(props.radius) : '', 
+    borderRadius: props.round ? '50%' : themeContext.resolveThemeSize(props.radius), 
     backgroundColor: isErrorState.value || props.showGrey ? themeContext.resolveThemeColor('background.imageBox') : 'transparent',
     overflow: 'hidden',
     width: themeContext.resolveThemeSize(props.width),
@@ -145,8 +159,40 @@ const style = computed(() => {
   }
   return o;
 });
+const backgroundEffectStyle = computed(() => ({
+  position: 'absolute',
+  left: 0,
+  right: 0,
+  top: 0,
+  bottom: 0,
+  zIndex: 1,
+  backgroundPosition: 'center',
+  backgroundSize: 'cover',
+  backgroundImage: `url('${props.src || props.defaultImage}')`,
+  filter: 'blur(3px)',
+}))
+const backgroundEffectStyle2 = computed(() => ({
+  position: 'absolute',
+  left: 0,
+  right: 0,
+  top: 0,
+  bottom: 0,
+  zIndex: 1,
+  backgroundRepeat: 'no-repeat',
+  backgroundPosition: 'center',
+  backgroundSize: 'contain',
+  backgroundImage: `url('${props.src || props.defaultImage}')`,
+}))
 const realWidth = ref(0);
 
+function precentOrFull(value: string|number) {
+  if (typeof value === 'number')
+    return value;
+  if (typeof value === 'string' && value.endsWith('%'))
+    return '100%';
+  return value;
+}
+
 function handleClick() {
   if (props.clickPreview) {
     uni.previewImage({
@@ -175,12 +221,6 @@ function measureImage() {
     }).exec();
 }
 
-function precentOrFull(size: string|number) {
-  if (!size || typeof size === 'number')
-    return size;
-  return size.endsWith('%') ? '100%' : size;
-}
-
 watch(() => props.src, (newVal, oldVal) => {
   if (!newVal && !props.defaultImage) {
     isErrorState.value = true;

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

@@ -0,0 +1,202 @@
+<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="themeStyles.dragHandleContainer.value"
+        @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', 200),
+  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'),
+  },
+  dragHandleContainer: {
+    display: 'flex',
+    justifyContent: 'center',
+    alignItems: 'center',
+  },
+  dragHandle: {
+    height: DynamicSize('BottomSheetDragHandleSize', 10),
+    borderRadius: DynamicSize('BottomSheetDragHandleBorderRadius', 10),
+    marginTop: DynamicSize('BottomSheetDragHandleMarginVertical', 25),
+    marginBottom: DynamicSize('BottomSheetDragHandleMarginVertical', 25),
+  },
+});
+
+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>

+ 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);
     }

+ 35 - 0
src/components/display/Divider.vue

@@ -21,6 +21,10 @@
     </template>
     <view v-else class="line" :style="lineStyle">
       <view class="bar" :style="barStyle" />
+      <view v-if="centerDot" class="dot" :style="{
+        ...dotStyle,
+        ...centerDotStyle,
+      }" />
     </view>
   </view>
 </template>
@@ -63,6 +67,20 @@ export interface DividerProps {
    */
   type?: 'horizontal' | 'vertical';
   /**
+   * 是否在线中间添加一个点
+   * @default false
+   */
+  centerDot?: boolean;
+  /**
+   * 分割线中间点的大小
+   * @default 26
+   */
+  centerDotSize?: number;
+  /**
+   * 分割线中间点的样式
+   */
+  centerDotStyle?: object,
+  /**
    * 分割线上面的文字(仅水平状态有效)
    */
   text?: string,
@@ -89,6 +107,7 @@ const props = withDefaults(defineProps<DividerProps>(), {
   backgroundColor: () => propGetThemeVar('DividerBackgroundColor', undefined)!,
   width: () => propGetThemeVar('DividerWidth', 2),
   size: () => propGetThemeVar('DividerSize', 36),
+  centerDotSize: () => propGetThemeVar('DividerCenterDotSize', 26),
   type: 'horizontal',
   orientation: 'center',
 });
@@ -98,6 +117,13 @@ const outStyle = computed(() => {
     backgroundColor: theme.resolveThemeColor(props.backgroundColor),
   }
 })
+const dotStyle = computed(() => {
+  return {
+    width: theme.resolveThemeSize(props.centerDotSize),
+    height: theme.resolveThemeSize(props.centerDotSize),
+    backgroundColor: theme.resolveThemeColor(props.color),
+  }
+})
 const lineStyle = computed(() => {
   return {
     fontSize: '22rpx',
@@ -125,6 +151,14 @@ const barStyle = computed(() => {
   align-items: center;
   align-self: stretch;
 
+  .dot {
+    position: absolute;
+    left: 50%;
+    top: 50%;
+    transform: translate(-50%, -50%);
+    border-radius: 50%;
+  }
+  
   .line {
     position: relative;
     display: flex;
@@ -152,6 +186,7 @@ const barStyle = computed(() => {
 
   &.vertical {
     flex-direction: column;
+    height: 100%;
   }
   &.horizontal {
     flex-direction: row;

+ 70 - 8
src/components/display/TextEllipsis.vue

@@ -3,13 +3,55 @@ import { computed, ref } from 'vue';
 import type { TextProps } from '../basic/Text.vue';
 import Text from '../basic/Text.vue';
 import FlexCol from '../layout/FlexCol.vue';
+import BackgroundBox from './block/BackgroundBox.vue';
 
 export interface TextEllipsisProps extends TextProps {
+  /**
+   * 显示的行数
+   * @default 1
+   */
   lines?: number;
+  /**
+   * 是否可展开
+   * @default true
+   */
   expandable?: boolean;
+  /**
+   * 是否默认展开
+   * @default false
+   */
   startOpen?: boolean;
+  /**
+   * 展开按钮文字
+   * @default '展开'
+   */
   openText?: string;
+  /**
+   * 收起按钮文字
+   * @default '收起'
+   */ 
   closeText?: string;
+  /**
+   * 是否显示遮罩层,当定义了该属性时,折叠时将会显示一个遮罩层。
+   * @default false
+   */
+  showMask?: boolean;
+  /**
+   * 遮罩层颜色
+   * @default white
+   */
+  maskColor?: string;
+  /**
+   * 折叠时的高度
+   * @default 140
+   */
+  collapsedHeight?: number;
+  /**
+   * 是否自定义内容,当定义了该属性时,可以通过slot自定义内容渲染,
+   * 折叠时将会设置高度为collapsedHeight。
+   * @default false
+   */
+  customContent?: boolean;
 }
 
 
@@ -17,7 +59,11 @@ const emit = defineEmits(['expand', 'collapse']);
 const props = withDefaults(defineProps<TextEllipsisProps>(), {
   lines: 1,
   startOpen: false,
-  expandable: false,
+  expandable: true,
+  customContent: false,
+  showMask: false,
+  maskColor: 'white',
+  collapsedHeight: 140,
   openText: '展开',
   closeText: '收起',
 });
@@ -43,16 +89,32 @@ defineOptions({
 </script>
 
 <template>
-  <FlexCol>
-    <Text 
-      :ellipsis="true" 
-      :lines="currentLines"
-      v-bind="$attrs"
+  <FlexCol position="relative">
+    <FlexCol 
+      :height="customContent && !open ? collapsedHeight : undefined"
+      overflow="hidden"
     >
       <slot>
-        {{ text }}
+        <Text 
+          :ellipsis="true" 
+          :lines="currentLines"
+          v-bind="$attrs"
+          :text="text"
+        />
       </slot>
-    </Text>
+      <BackgroundBox 
+        v-if="!open && (showMask || customContent)"
+        :height="collapsedHeight"
+        color1="transparent"
+        :color2="maskColor"
+        :innerStyle="{ 
+          position: 'absolute',
+          bottom: '36rpx',
+          left: 0,
+          right: 0,
+        }"
+      />
+    </FlexCol>
     <slot v-if="expandable" name="button" :onClick="handleClick">
       <Text 
         innerClass="nana-text-ellipsis-expand"

+ 0 - 1
src/components/display/block/ImageBlock2.vue

@@ -12,7 +12,6 @@
       :height="imageHeight"
       :radius="imageRadius"
       width="100%"
-      round
       mode="aspectFill"
     />
     <slot name="desc">

+ 16 - 1
src/components/display/block/ImageBlock3.vue

@@ -11,15 +11,17 @@
       :width="imageWidth"
       :height="imageHeight"
       :radius="imageRadius"
-      round
       mode="aspectFill"
     />
     <FlexView direction="column">
       <slot name="desc">
         <IconTextBlock
           :title="title"
+          :titleProps="titleProps"
           :desc="desc"
+          :descProps="descProps"
           :extra="extra"
+          :extraProps="extraProps"
           :extraMpSlotState="Boolean($slots.extra)"
         >
           <template v-if="$slots.extra" #extra>
@@ -49,6 +51,7 @@ import FlexView, { type FlexProps } from '../../layout/FlexView.vue';
 import Image from '../../basic/Image.vue';
 import Touchable from '@/components/feedback/Touchable.vue';
 import IconTextBlock from './IconTextBlock.vue';
+import type { TextProps } from '@/components/basic/Text.vue';
 
 export interface ImageBlock3Props extends Partial<FlexProps> {
   /**
@@ -79,6 +82,18 @@ export interface ImageBlock3Props extends Partial<FlexProps> {
    * 图片下方显示额外信息。
    */
   extra?: string | number;
+  /**
+   * 图片下方显示标题的文字属性。
+   */
+  titleProps?: TextProps;
+  /**
+   * 图片下方显示描述的文字属性。
+   */
+  descProps?: TextProps;
+  /**
+   * 图片下方显示额外信息的文字属性。
+   */
+  extraProps?: TextProps;
 }
 
 const theme = useTheme();

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

@@ -0,0 +1,41 @@
+export interface ParseNode {
+  /**
+   * 标签名
+   */
+  tag: string;
+  
+  /**
+   * 父标签名
+   */
+  parentTag?: string;
+  /**
+   * 在父节点中的索引
+   */
+  index?: number;
+
+  /**
+   * 标签属性
+   */
+  attrs?: {
+    /**
+     * 元素id
+     */
+    id?: string;
+    /**
+     * 内容
+     */
+    content?: string;
+    /**
+     * 样式
+     */
+    style?: Record<string, string>;
+    /**
+     * 其他属性
+     */
+    [key: string]: unknown;
+  };
+  /**
+   * 子节点
+   */
+  children?: ParseNode[];
+}

+ 123 - 486
src/components/display/parse/Parse.vue

@@ -1,499 +1,136 @@
 <template>
-	<view id="_root" :class="(selectable ? '_select ' : '') + '_root'" :style="containerStyle">
-		<slot v-if="!nodes[0]" />
-		<!-- #ifndef APP-PLUS-NVUE -->
-		<node v-else :childs="nodes" :opts="[lazyLoad, loadingImg, errorImg, showImgMenu, selectable]" name="span" />
-		<!-- #endif -->
-		<!-- #ifdef APP-PLUS-NVUE -->
-		<web-view ref="web" src="https://mncdn.wenlvti.net/app_static/minnan/app-plus/mp-html/local.html" :style="'margin-top:-2px;height:' + height + 'px'" @onPostMessage="_onMessage" />
-		<!-- #endif -->
-	</view>
+  <view class="nana-Parse-container" :style="contentStyle">
+    <ParseNodeRender v-for="(node, index) in nodes" :key="index" :node="node" />
+  </view>
 </template>
 
-<script>
-/**
- * mp-html v2.4.1
- * @description 富文本组件
- * @tutorial https://github.com/jin-yufeng/mp-html
- * @property {String} container-style 容器的样式
- * @property {String} content 用于渲染的 html 字符串
- * @property {Boolean} copy-link 是否允许外部链接被点击时自动复制
- * @property {String} domain 主域名,用于拼接链接
- * @property {String} error-img 图片出错时的占位图链接
- * @property {Boolean} lazy-load 是否开启图片懒加载
- * @property {string} loading-img 图片加载过程中的占位图链接
- * @property {Boolean} pause-video 是否在播放一个视频时自动暂停其他视频
- * @property {Boolean} preview-img 是否允许图片被点击时自动预览
- * @property {Boolean} scroll-table 是否给每个表格添加一个滚动层使其能单独横向滚动
- * @property {Boolean | String} selectable 是否开启长按复制
- * @property {Boolean} set-title 是否将 title 标签的内容设置到页面标题
- * @property {Boolean} show-img-menu 是否允许图片被长按时显示菜单
- * @property {Object} tag-style 标签的默认样式
- * @property {Boolean | Number} use-anchor 是否使用锚点链接
- * @event {Function} load dom 结构加载完毕时触发
- * @event {Function} ready 所有图片加载完毕时触发
- * @event {Function} imgTap 图片被点击时触发
- * @event {Function} linkTap 链接被点击时触发
- * @event {Function} play 音视频播放时触发
- * @event {Function} error 媒体加载出错时触发
- */
-// #ifndef APP-PLUS-NVUE
-import node from './node/node'
-// #endif
-import Parser from './parser'
-const plugins = []
-// #ifdef APP-PLUS-NVUE
-const dom = weex.requireModule('dom')
-// #endif
-export default {
-	name: 'u-parse',
-	data() {
-		return {
-			nodes: [],
-			// #ifdef APP-PLUS-NVUE
-			height: 3
-			// #endif
-		}
-	},
-	props: {
-		containerStyle: {
-			type: String,
-			default: ''
-		},
-		content: {
-			type: String,
-			default: ''
-		},
-		copyLink: {
-			type: [Boolean, String],
-			default: true
-		},
-		domain: String,
-		errorImg: {
-			type: String,
-			default: ''
-		},
-		lazyLoad: {
-			type: [Boolean, String],
-			default: false
-		},
-		loadingImg: {
-			type: String,
-			default: ''
-		},
-		pauseVideo: {
-			type: [Boolean, String],
-			default: true
-		},
-		previewImg: {
-			type: [Boolean, String],
-			default: true
-		},
-		scrollTable: [Boolean, String],
-		selectable: [Boolean, String],
-		setTitle: {
-			type: [Boolean, String],
-			default: true
-		},
-		showImgMenu: {
-			type: [Boolean, String],
-			default: true
-		},
-		tagStyle: Object,
-		useAnchor: [Boolean, Number]
-	},
-	// #ifdef VUE3
-	emits: ['load', 'ready', 'imgTap', 'linkTap', 'play', 'error'],
-	// #endif
-	// #ifndef APP-PLUS-NVUE
-	components: {
-		node
-	},
-	// #endif
-	watch: {
-		content(content) {
-			this.setContent(content)
-		}
-	},
-	created() {
-		this.plugins = []
-		for (let i = plugins.length; i--;) {
-			this.plugins.push(new plugins[i](this))
-		}
-	},
-	mounted() {
-		if (this.content && !this.nodes.length) {
-			this.setContent(this.content)
-		}
-	},
-	beforeUnmount() {
-		this._hook('onDetached')
-	},
-	methods: {
-		/**
-		 * @description 将锚点跳转的范围限定在一个 scroll-view 内
-		 * @param {Object} page scroll-view 所在页面的示例
-		 * @param {String} selector scroll-view 的选择器
-		 * @param {String} scrollTop scroll-view scroll-top 属性绑定的变量名
-		 */
-		in(page, selector, scrollTop) {
-			// #ifndef APP-PLUS-NVUE
-			if (page && selector && scrollTop) {
-				this._in = {
-					page,
-					selector,
-					scrollTop
-				}
-			}
-			// #endif
-		},
+<script setup lang="ts">
+import ParseNodeRender from './ParseNodeRender.vue'
+import { parse, type DefaultTreeAdapterTypes } from 'parse5'
+import { computed, provide, ref, toRef } from 'vue';
+import type { ParseNode } from './Parse'
 
-		/**
-		 * @description 锚点跳转
-		 * @param {String} id 要跳转的锚点 id
-		 * @param {Number} offset 跳转位置的偏移量
-		 * @returns {Promise}
-		 */
-		navigateTo(id, offset) {
-			return new Promise((resolve, reject) => {
-				if (!this.useAnchor) {
-					reject(Error('Anchor is disabled'))
-					return
-				}
-				offset = offset || parseInt(this.useAnchor) || 0
-				// #ifdef APP-PLUS-NVUE
-				if (!id) {
-					dom.scrollToElement(this.$refs.web, {
-						offset
-					})
-					resolve()
-				} else {
-					this._navigateTo = {
-						resolve,
-						reject,
-						offset
-					}
-					this.$refs.web.evalJs('uni.postMessage({data:{action:"getOffset",offset:(document.getElementById(' + id + ')||{}).offsetTop}})')
-				}
-				// #endif
-				// #ifndef APP-PLUS-NVUE
-				let deep = ' '
-				// #ifdef MP-WEIXIN || MP-QQ || MP-TOUTIAO
-				deep = '>>>'
-				// #endif
-				const selector = uni.createSelectorQuery()
-					// #ifndef MP-ALIPAY
-					.in(this._in ? this._in.page : this)
-					// #endif
-					.select((this._in ? this._in.selector : '._root') + (id ? `${deep}#${id}` : '')).boundingClientRect()
-				if (this._in) {
-					selector.select(this._in.selector).scrollOffset()
-						.select(this._in.selector).boundingClientRect()
-				} else {
-					// 获取 scroll-view 的位置和滚动距离
-					selector.selectViewport().scrollOffset() // 获取窗口的滚动距离
-				}
-				selector.exec(res => {
-					if (!res[0]) {
-						reject(Error('Label not found'))
-						return
-					}
-					const scrollTop = res[1].scrollTop + res[0].top - (res[2] ? res[2].top : 0) + offset
-					if (this._in) {
-						// scroll-view 跳转
-						this._in.page[this._in.scrollTop] = scrollTop
-					} else {
-						// 页面跳转
-						uni.pageScrollTo({
-							scrollTop,
-							duration: 300
-						})
-					}
-					resolve()
-				})
-				// #endif
-			})
-		},
-
-		/**
-		 * @description 获取文本内容
-		 * @return {String}
-		 */
-		getText(nodes) {
-			let text = '';
-			(function traversal(nodes) {
-				for (let i = 0; i < nodes.length; i++) {
-					const node = nodes[i]
-					if (node.type === 'text') {
-						text += node.text.replace(/&amp;/g, '&')
-					} else if (node.name === 'br') {
-						text += '\n'
-					} else {
-						// 块级标签前后加换行
-						const isBlock = node.name === 'p' || node.name === 'div' || node.name === 'tr' || node.name === 'li' || (node.name[0] === 'h' && node.name[1] > '0' && node.name[1] < '7')
-						if (isBlock && text && text[text.length - 1] !== '\n') {
-							text += '\n'
-						}
-						// 递归获取子节点的文本
-						if (node.children) {
-							traversal(node.children)
-						}
-						if (isBlock && text[text.length - 1] !== '\n') {
-							text += '\n'
-						} else if (node.name === 'td' || node.name === 'th') {
-							text += '\t'
-						}
-					}
-				}
-			})(nodes || this.nodes)
-			return text
-		},
-
-		/**
-		 * @description 获取内容大小和位置
-		 * @return {Promise}
-		 */
-		getRect() {
-			return new Promise((resolve, reject) => {
-				uni.createSelectorQuery()
-					// #ifndef MP-ALIPAY
-					.in(this)
-					// #endif
-					.select('#_root').boundingClientRect().exec(res => res[0] ? resolve(res[0]) : reject(Error('Root label not found')))
-			})
-		},
-
-		/**
-		 * @description 暂停播放媒体
-		 */
-		pauseMedia() {
-			for (let i = (this._videos || []).length; i--;) {
-				this._videos[i].pause()
-			}
-			// #ifdef APP-PLUS
-			const command = 'for(var e=document.getElementsByTagName("video"),i=e.length;i--;)e[i].pause()'
-			// #ifndef APP-PLUS-NVUE
-			let page = this.$parent
-			while (!page.$scope) page = page.$parent
-			page.$scope.$getAppWebview().evalJS(command)
-			// #endif
-			// #ifdef APP-PLUS-NVUE
-			this.$refs.web.evalJs(command)
-			// #endif
-			// #endif
-		},
-
-		/**
-		 * @description 设置媒体播放速率
-		 * @param {Number} rate 播放速率
-		 */
-		setPlaybackRate(rate) {
-			this.playbackRate = rate
-			for (let i = (this._videos || []).length; i--;) {
-				this._videos[i].playbackRate(rate)
-			}
-			// #ifdef APP-PLUS
-			const command = 'for(var e=document.getElementsByTagName("video"),i=e.length;i--;)e[i].playbackRate=' + rate
-			// #ifndef APP-PLUS-NVUE
-			let page = this.$parent
-			while (!page.$scope) page = page.$parent
-			page.$scope.$getAppWebview().evalJS(command)
-			// #endif
-			// #ifdef APP-PLUS-NVUE
-			this.$refs.web.evalJs(command)
-			// #endif
-			// #endif
-		},
+export interface ParseProps {
+  /**
+   * HTML解析内容
+   */
+  content?: string|null|undefined;
+  /**
+   * 标签样式。键为标签名,值为样式
+   */
+  tagStyle?: Record<string, string>;
+  /**
+   * 类样式。键为类名,值为样式
+   */
+  classStyle?: Record<string, string>;
+  /**
+   * 容器样式
+   */
+  contentStyle?: any;
+}
 
-		/**
-		 * @description 设置内容
-		 * @param {String} content html 内容
-		 * @param {Boolean} append 是否在尾部追加
-		 */
-		setContent(content, append) {
-			if (!append || !this.imgList) {
-				this.imgList = []
-			}
-			const nodes = new Parser(this).parse(content)
-			// #ifdef APP-PLUS-NVUE
-			if (this._ready) {
-				this._set(nodes, append)
-			}
-			// #endif
-			this.$set(this, 'nodes', append ? (this.nodes || []).concat(nodes) : nodes)
+const props = withDefaults(defineProps<ParseProps>(), {
+  tagStyle: () => ({}),
+  classStyle: () => ({}),
+  contentStyle: () => ({})
+});
 
-			// #ifndef APP-PLUS-NVUE
-			this._videos = []
-			this.$nextTick(() => {
-				this._hook('onLoad')
-				this.$emit('load')
-			})
+const praseImages = ref<string[]>([]);
 
-			if (this.lazyLoad || this.imgList._unloadimgs < this.imgList.length / 2) {
-				// 设置懒加载,每 350ms 获取高度,不变则认为加载完毕
-				let height = 0
-				const callback = rect => {
-					if (!rect || !rect.height) rect = {}
-					// 350ms 总高度无变化就触发 ready 事件
-					if (rect.height === height) {
-						this.$emit('ready', rect)
-					} else {
-						height = rect.height
-						setTimeout(() => {
-							this.getRect().then(callback).catch(callback)
-						}, 350)
-					}
-				}
-				this.getRect().then(callback).catch(callback)
-			} else {
-				// 未设置懒加载,等待所有图片加载完毕
-				if (!this.imgList._unloadimgs) {
-					this.getRect().then(rect => {
-						this.$emit('ready', rect)
-					}).catch(() => {
-						this.$emit('ready', {})
-					})
-				}
-			}
-			// #endif
-		},
+provide('tagStyle', toRef(props, 'tagStyle'));
+provide('classStyle', toRef(props, 'classStyle'));
+provide('praseImages', praseImages);
 
-		/**
-		 * @description 调用插件钩子函数
-		 */
-		_hook(name) {
-			for (let i = plugins.length; i--;) {
-				if (this.plugins[i][name]) {
-					this.plugins[i][name]()
-				}
-			}
-		},
+const toObj = (attrs: DefaultTreeAdapterTypes.Element['attrs']) => {
+  const obj: Record<string, string> = {};
+  for (const attr of attrs) {
+    obj[attr.name] = attr.value;
+  }
+  return obj;
+}
+// 解析HTML为节点树
+const parseHtml = (html: string): ParseNode[] => {
+  const nodes: ParseNode[] = [];
+  const doc = parse(html);
+  praseImages.value = [];
+  
+  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: [],
+      parentTag,
+      index: 0
+    };
+    
+    // 解析子节点
+    if (element.childNodes) {
+      let index = 0;
+      for (const child of element.childNodes) {
+        if (child.nodeName === '#text') {
+          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, element.tagName);
+          childNode.index = index++;
+          node.children?.push(childNode);
+          if (childNode.tag === 'img') {
+            praseImages.value.push(childNode.attrs?.src as string);
+          }
+        }
+      }
+    }
+    
+    return node;
+  };
 
-		// #ifdef APP-PLUS-NVUE
-		/**
-		 * @description 设置内容
-		 */
-		_set(nodes, append) {
-			this.$refs.web.evalJs('setContent(' + JSON.stringify(nodes).replace(/%22/g, '') + ',' + JSON.stringify([this.containerStyle.replace(/(?:margin|padding)[^;]+/g, ''), this.errorImg, this.loadingImg, this.pauseVideo, this.scrollTable, this.selectable]) + ',' + append + ')')
-		},
+  let index = 0;
+  for (const child of doc.childNodes) {
+    if (child.nodeName === '#text') {
+      const textNode = solveTextNode(child as DefaultTreeAdapterTypes.TextNode, nodes, 'body');
+      if (textNode)
+        textNode.index = index++;
+    } else if (child.nodeName !== '#documentType') {
+      const childNode = traverse(child as DefaultTreeAdapterTypes.Element, 'body');
+      childNode.index = index++;
+      nodes.push(childNode);
+    }
+  }
+  console.log(doc);
+  
+  return nodes;
+};
 
-		/**
-		 * @description 接收到 web-view 消息
-		 */
-		_onMessage(e) {
-			const message = e.detail.data[0]
-			switch (message.action) {
-				// web-view 初始化完毕
-				case 'onJSBridgeReady':
-					this._ready = true
-					if (this.nodes) {
-						this._set(this.nodes)
-					}
-					break
-				// 内容 dom 加载完毕
-				case 'onLoad':
-					this.height = message.height
-					this._hook('onLoad')
-					this.$emit('load')
-					break
-				// 所有图片加载完毕
-				case 'onReady':
-					this.getRect().then(res => {
-						this.$emit('ready', res)
-					}).catch(() => {
-						this.$emit('ready', {})
-					})
-					break
-				// 总高度发生变化
-				case 'onHeightChange':
-					this.height = message.height
-					break
-				// 图片点击
-				case 'onImgTap':
-					this.$emit('imgTap', message.attrs)
-					if (this.previewImg) {
-						uni.previewImage({
-							current: parseInt(message.attrs.i),
-							urls: this.imgList
-						})
-					}
-					break
-				// 链接点击
-				case 'onLinkTap': {
-					const href = message.attrs.href
-					this.$emit('linkTap', message.attrs)
-					if (href) {
-						// 锚点跳转
-						if (href[0] === '#') {
-							if (this.useAnchor) {
-								dom.scrollToElement(this.$refs.web, {
-									offset: message.offset
-								})
-							}
-						} else if (href.includes('://')) {
-							// 打开外链
-							if (this.copyLink) {
-								plus.runtime.openWeb(href)
-							}
-						} else {
-							uni.navigateTo({
-								url: href,
-								fail() {
-									uni.switchTab({
-										url: href
-									})
-								}
-							})
-						}
-					}
-					break
-				}
-				case 'onPlay':
-					this.$emit('play')
-					break
-				// 获取到锚点的偏移量
-				case 'getOffset':
-					if (typeof message.offset === 'number') {
-						dom.scrollToElement(this.$refs.web, {
-							offset: message.offset + this._navigateTo.offset
-						})
-						this._navigateTo.resolve()
-					} else {
-						this._navigateTo.reject(Error('Label not found'))
-					}
-					break
-				// 点击
-				case 'onClick':
-					this.$emit('tap')
-					this.$emit('click')
-					break
-				// 出错
-				case 'onError':
-					this.$emit('error', {
-						source: message.source,
-						attrs: message.attrs
-					})
-			}
-		}
-		// #endif
-	}
-}
+// 计算属性,获取解析后的节点树
+const nodes = computed(() => parseHtml(props.content || ''));
 </script>
 
-<style>
-/* #ifndef APP-PLUS-NVUE */
-/* 根节点样式 */
-._root {
-	padding: 1px 0;
-	overflow-x: auto;
-	overflow-y: hidden;
-	-webkit-overflow-scrolling: touch;
+<style scoped>
+.nana-Parse-container {
+  width: 100%;
 }
-
-/* 长按复制 */
-._select {
-	user-select: text;
-}
-
-/* #endif */
-</style>
+</style>

+ 385 - 0
src/components/display/parse/ParseNodeRender.vue

@@ -0,0 +1,385 @@
+<template>
+  <!-- 节点渲染 -->
+
+  <!-- 图片 -->
+  <image 
+    v-if="node.tag === 'img'" 
+    :id="node.attrs?.id" 
+    :class="'_img ' + (node.attrs?.class || '')" 
+    :style="node.attrs?.style || {}" 
+    :src="node.attrs?.src || ''" 
+    mode="widthFix" 
+    @click="preview(node.attrs?.src as string)"
+  />
+  
+  <!-- 换行 -->
+  <!-- #ifdef H5 -->
+  <br v-else-if="node.tag === 'br'" />
+  <!-- #endif -->
+  <!-- #ifndef H5 -->
+  <text v-else-if="node.tag === 'br'">\n</text>
+  <!-- #endif -->
+  
+  <!-- 链接 -->
+  <view 
+    v-else-if="node.tag === 'a'" 
+    :id="node.attrs?.id" 
+    :class="(node.attrs?.href ? '_a ' : '') + (node.attrs?.class || '')" 
+    hover-class="_hover" 
+    :style="'display:inline;' + (node.attrs?.style || '')" 
+    @tap.stop="linkTap" 
+  >
+    <ParseNodeRender 
+      v-for="(child, index) in node.children" 
+      :key="index" 
+      :node="child" 
+    />
+  </view>
+  
+  <!-- 视频 -->
+  <video 
+    v-else-if="node.tag === 'video'" 
+    :id="node.attrs?.id" 
+    :class="node.attrs?.class || ''" 
+    :style="node.attrs?.style || {}" 
+    :autoplay="Boolean(node.attrs?.autoplay || false)" 
+    :controls="Boolean(node.attrs?.controls || true)" 
+    :loop="Boolean(node.attrs?.loop || false)" 
+    :muted="Boolean(node.attrs?.muted || false)" 
+    :object-fit="node.attrs?.['object-fit'] || 'contain'" 
+    :poster="node.attrs?.poster as string || ''" 
+    :src="node.attrs?.src as string || ''" 
+  />
+  
+  <!-- 音频 -->
+  <!-- #ifndef H5 -->
+  <audio 
+    v-else-if="node.tag === 'audio'" 
+    :id="node.attrs?.id" 
+    :class="node.attrs?.class || ''" 
+    :style="node.attrs?.style || {}" 
+    :author="node.attrs?.author || ''" 
+    :controls="Boolean(node.attrs?.controls || true)" 
+    :loop="Boolean(node.attrs?.loop || false)" 
+    :name="node.attrs?.name || ''" 
+    :poster="node.attrs?.poster || ''" 
+    :src="node.attrs?.src as string || ''" 
+  />
+  <!-- #endif -->
+  
+  <!-- 嵌入小程序内容 -->
+  <view v-else-if="node.tag === 'inject-mp'">
+    <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'"
+    :id="node.attrs?.id"
+    :data-tag="node.tag"
+    :class="node.attrs?.class || ''"
+    :style="style"
+  >
+    <ParseNodeRender
+      v-for="(child, index) in node.children"
+      :key="index"
+      :node="child"
+    />
+  </view>
+
+  <!-- 文本 -->
+  <text 
+    v-else
+    :style="style"
+    :class="node.attrs?.class || ''" 
+  >
+    {{ node.attrs?.content }}
+  </text>
+</template>
+
+<script setup lang="ts">
+import { computed, inject, ref, type Ref } from 'vue';
+import ParseNodeRender from './ParseNodeRender.vue';
+import type { ParseNode } from './Parse';
+import InjectMPRender from '@/common/components/rich/InjectMPRender.vue';
+
+const props = withDefaults(defineProps<{
+  node: ParseNode;
+}>(), {
+});
+
+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.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; border-left: 4px solid #ddd; padding-left: 1em; color: #666;',
+
+  // 列表
+  '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;',
+  'i': 'font-style: italic;',
+  'em': 'font-style: italic;',
+  'u': 'text-decoration: underline;',
+  'del': 'text-decoration: line-through;',
+
+  // 代码相关 (UA 仅 monospace,无背景)
+  'code': 'font-family: monospace;',
+  'pre': 'font-family: monospace; white-space: pre; 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(() => 
+  [
+    (builtInStyles[props.node.tag] || ''),
+    (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(() => [
+  'span', 'a', 'large','small',
+  'i', 'b', 'em', 'strong', 'u', 'del',
+  'code', 'sup', 'sub', 'mark'
+].includes(props.node.tag));
+
+// 链接点击事件
+const linkTap = (e: any) => {
+  const href = props.node.attrs?.href as string;
+  if (href) {
+    if (href[0] === '#') {
+      // 跳转锚点
+      // 实现锚点跳转逻辑
+    } else if (href.includes('://')) {
+      // 外部链接
+      uni.showModal({
+        title: '打开链接',
+        content: href,
+        success: (res) => {
+          if (res.confirm) {
+            // #ifdef H5
+            window.open(href);
+            // #endif
+            // #ifdef MP
+            uni.setClipboardData({
+              data: href,
+              success: () => {
+                uni.showToast({
+                  title: '链接已复制',
+                  duration: 2000
+                });
+              }
+            });
+            // #endif
+            // #ifdef APP-PLUS
+            plus.runtime.openWeb(href);
+            // #endif
+          }
+        }
+      });
+    } else {
+      // 跳转页面
+      uni.navigateTo({
+        url: href,
+        fail: () => {
+          uni.switchTab({
+            url: href,
+            fail: () => {}
+          });
+        }
+      });
+    }
+  }
+};
+
+function preview(url: string) {
+  if (url) {
+    if (praseImages.value.includes(url)) {
+      uni.previewImage({
+        urls: praseImages.value,
+        current: praseImages.value.indexOf(url),
+      })
+    } else {
+      uni.previewImage({
+        urls: [url],
+      })
+    }
+  }
+}
+
+defineOptions({
+  options: {
+    inheritAttrs: false,
+    virtualHost: true,
+  }
+})
+</script>
+
+<style scoped>
+/* a 标签默认效果 */
+._a {
+  padding: 1.5px 0;
+  color: #366092;
+  word-break: break-all;
+}
+
+/* a 标签点击态效果 */
+._hover {
+  text-decoration: underline;
+  opacity: 0.7;
+}
+
+/* 图片默认效果 */
+._img {
+  max-width: 100%;
+  -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;
+  height: 225px;
+}
+</style>

+ 71 - 33
src/components/dynamic/wrappers/CheckBoxList.vue

@@ -1,5 +1,14 @@
 <template>
-  <FlexView :direction="vertical ? 'column' : 'row'" align="center" :gap="10" wrap>
+  <FlexView 
+    position="relative"
+    :direction="vertical ? 'column' : 'row'" 
+    :align="vertical ? undefined : 'center'" 
+    :justify="vertical ? 'center' : undefined"
+    :gap="10"
+    :flexGrow="1"
+    :wrap="!vertical"
+    :innerStyle="innerStyle"
+  >
     <ActivityIndicator v-if="loadStatus === 'loading'" />
     <Alert
       v-else-if="loadStatus === 'error'" 
@@ -15,19 +24,55 @@
       :multiple="multiple"
       @update:modelValue="handleChange" 
     >
-      <CheckBox
-        v-for="value in data2"
-        :key="value.value"
-        :name="value.value"
-        :text="value.text" 
-        :disabled="value.disable"
-      />
+      <template v-if="useCell">
+        <Cell
+          v-for="value in data2"
+          :key="value.value"
+          :name="value.value"
+          :disabled="value.disable"
+        >
+          <CheckBox
+            checkPosition="right" 
+            block 
+            :name="value.value"
+            :text="value.text"
+            :disabled="value.disable"
+          >
+            <template #extraText>
+              <Text
+                fontConfig="subSecondText"
+                :text="value.desc" 
+                :innerStyle="{ maxWidth: '100%' }"
+              />
+            </template>
+          </CheckBox>
+        </Cell>
+      </template>
+      <template v-else>
+        <CheckBox
+          v-for="value in data2"
+          :key="value.value"
+          :name="value.value"
+          :text="value.text" 
+          :disabled="value.disable"
+        >
+          <template #extraText>
+            <Text
+              fontConfig="subSecondText"
+              :text="value.desc" 
+              :innerStyle="{ maxWidth: '100%' }"
+            />
+          </template>
+        </CheckBox>
+      </template>
     </CheckBoxGroup>
   </FlexView>
 </template>
 
 <script setup lang="ts">
 import ActivityIndicator from '@/components/basic/ActivityIndicator.vue';
+import Cell from '@/components/basic/Cell.vue';
+import Text from '@/components/basic/Text.vue';
 import Alert from '@/components/feedback/Alert.vue';
 import CheckBox from '@/components/form/CheckBox.vue';
 import CheckBoxGroup from '@/components/form/CheckBoxGroup.vue';
@@ -36,42 +81,28 @@ import { onMounted, ref, type PropType } from 'vue';
 
 export interface CheckBoxListItem {
   text: string;
+  desc?: string;
   value: any;
   disable?: boolean;
 }
 export interface CheckBoxListProps {
+  modelValue?: any[],
   multiple?: boolean,
   disabled?: boolean,
+  useCell?: boolean,
   vertical?: boolean,
   className?: string,
+  innerStyle?: Record<string, any>,
   loadData: () => Promise<CheckBoxListItem[]>;
 }
 
-const props = defineProps({
-  modelValue: {
-    type: Array as PropType<string[]>,
-    default: () => []
-  },
-  loadData: {
-    type: Function as PropType<CheckBoxListProps['loadData']>,
-    default: () => Promise.resolve([])
-  },
-  disabled: {
-    type: Boolean,
-    default: false
-  },
-  multiple: {
-    type: Boolean,
-    default: false
-  },
-  className: {
-    type: String,
-    default: ''
-  },
-  vertical: {
-    type: Boolean,
-    default: false
-  },
+const props = withDefaults(defineProps<CheckBoxListProps>(), {
+  modelValue: () => ([]),
+  loadData: () => Promise.resolve([]),
+  disabled: false,
+  multiple: false,
+  className: '',
+  vertical: false,
 })
 const emit = defineEmits(['update:modelValue', 'change'])
 
@@ -93,6 +124,8 @@ const handleLoadData = () => {
   }).catch((e) => {
     loadError.value = e.message || '加载失败';
     loadStatus.value = 'error';
+    console.error(e);
+    
   });
 }
 const reload = () => {
@@ -100,6 +133,11 @@ const reload = () => {
 }
 
 defineExpose({ reload });
+defineOptions({
+  options: {
+    virtualHost: true,
+  }
+})
 
 onMounted(() => {
   handleLoadData();

+ 4 - 3
src/components/feedback/Alert.vue

@@ -1,5 +1,6 @@
 <template>
-  <FlexRow 
+  <Touchable 
+    direction="row"
     :innerClass="['nana-alert', `nana-alert-${type}`]"
     :innerStyle="{
       ...themeStyles.container.value,
@@ -92,7 +93,7 @@
         @click.stop="handleClose"
       />
     </slot>
-  </FlexRow>
+  </Touchable>
 </template>
 
 <script setup lang="ts">
@@ -102,8 +103,8 @@ import { DynamicSize, DynamicColor, selectStyleType, DynamicVar } from '../theme
 import Text from '../basic/Text.vue';
 import Icon from '../basic/Icon.vue';
 import IconButton from '../basic/IconButton.vue';
-import FlexRow from '../layout/FlexRow.vue';
 import FlexCol from '../layout/FlexCol.vue';
+import Touchable from './Touchable.vue';
 
 export type AlertType = 'primary' | 'success' | 'warning' | 'danger' | 'error' | 'info' | 'default';
 export interface AlertProps {

+ 93 - 19
src/components/feedback/SwipeRow.vue

@@ -18,22 +18,40 @@
     >
       <view 
         :style="{
-          width: `${props.leftWidth}px`,
-          marginLeft: `-${props.leftWidth}px`,
+          width: leftWidth ? `${leftWidth}px` : 'auto',
+          marginLeft: `-${leftWidth}px`,
         }"
         class="left"
+        id="left"
       >
-        <slot name="left" />
+        <slot name="left">
+          <Button 
+            v-for="action in leftActions" 
+            :key="action.text" shape="square" 
+            :type="action.type" 
+            :text="action.text" 
+            @click="action.onClick" 
+          />
+        </slot>
       </view>
       <slot />
       <view 
+        id="right"
         :style="{
-          width: `${props.rightWidth}px`,
-          marginRight: `-${props.rightWidth}px`,
+          width: rightWidth ? `${rightWidth}px` : 'auto',
+          marginRight: `-${rightWidth}px`,
         }"
         class="right"
       >
-        <slot name="right" />
+        <slot name="right">
+          <Button 
+            v-for="action in rightActions" 
+            :key="action.text" shape="square" 
+            :type="action.type" 
+            :text="action.text" 
+            @click="action.onClick" 
+          />
+        </slot>
       </view>
     </view>
   </view>
@@ -41,21 +59,45 @@
 
 <script setup lang="ts">
 
-import { nextTick, ref } from 'vue';
+import { computed, getCurrentInstance, nextTick, onMounted, onUpdated, ref } from 'vue';
 import { useTheme } from '../theme/ThemeDefine';
 import { RandomUtils } from '@imengyu/imengyu-utils';
+import Button, { type ButtonType } from '../basic/Button.vue';
 
 const themeContext = useTheme();
 
+export interface SwipeRowAction {
+  /**
+   * 操作按钮的文本
+   */
+  text: string,
+  /**
+   * 操作按钮的类型
+   */
+  type: ButtonType,
+  /**
+   * 操作按钮的点击事件
+   */
+  onClick?: (e: any) => void,
+}
+
 export interface SwipeRowProps {
   /**
-   * 左侧宽度
+   * 手动设置左侧宽度,单位为px
    */
-  leftWidth?: number,
+  leftWidth?: number|undefined,
   /**
-   * 右侧宽度
+   * 左侧操作按钮
    */
-  rightWidth?: number,
+  leftActions?: SwipeRowAction[],
+  /**
+   * 手动设置右侧宽度,单位为px
+   */
+  rightWidth?: number|undefined,
+  /**
+   * 右侧操作按钮
+   */
+  rightActions?: SwipeRowAction[],
   /**
    * 是否禁用
    */
@@ -77,6 +119,31 @@ const props = withDefaults(defineProps<SwipeRowProps>(), {
 const themeStyles = themeContext.useThemeStyles({
 });
 
+const instance = getCurrentInstance();
+const measuredLeftWidth = ref(0);
+const measuredRightWidth = ref(0);
+
+function measureWidth() {
+  function measuredEle(id: string) {
+    uni.createSelectorQuery()
+      .in(instance)
+      .select(`#${id}`)
+      .boundingClientRect()
+      .exec((res) => {
+        if (res[0]) {
+          if (id === 'left')
+            measuredLeftWidth.value = res[0].width;
+          else if (id === 'right')
+            measuredRightWidth.value = res[0].width;
+        }
+      });
+  }
+  nextTick(() => {
+    measuredEle('left');
+    measuredEle('right');
+  });
+}
+
 const currentAnim = ref(false);
 const currentOffset = ref(0);
 
@@ -86,15 +153,16 @@ let pressed = false;
 let startOffset = 0;
 let lastMovedSize = 0;
 
+const leftWidth = computed(() => props.leftWidth && props.leftWidth > 0 ? props.leftWidth : measuredLeftWidth.value);
+const rightWidth = computed(() => props.rightWidth && props.rightWidth > 0 ? props.rightWidth : measuredRightWidth.value);
+
 function handleDrag(x: number) {
   const movedSize = x - startX;
   lastMovedSize = movedSize;
   if (movedSize > 0) {
-    if (props.leftWidth > 0)
-      currentOffset.value = Math.min(startOffset + movedSize, props.leftWidth);
+    currentOffset.value = Math.min(startOffset + movedSize, leftWidth.value);
   } else {
-    if (props.rightWidth > 0)
-      currentOffset.value = Math.max(startOffset + movedSize, -props.rightWidth);
+    currentOffset.value = Math.max(startOffset + movedSize, -rightWidth.value);
   }
 }
 
@@ -121,10 +189,10 @@ function handleTouchEnd(e: any) {
   currentAnim.value = true;
   nextTick(() => {
     if (Math.abs(lastMovedSize) > 10) {
-      if (currentOffset.value > props.leftWidth / 2)
-        currentOffset.value = props.leftWidth;
-      else if (currentOffset.value < -props.rightWidth / 2)
-        currentOffset.value = -props.rightWidth;
+      if (currentOffset.value > leftWidth.value / 2)
+        currentOffset.value = leftWidth.value;
+      else if (currentOffset.value < -rightWidth.value / 2)
+        currentOffset.value = -rightWidth.value;
       else 
         currentOffset.value = 0;
     } else if (props.autoClose)
@@ -132,6 +200,12 @@ function handleTouchEnd(e: any) {
   });
 }
 
+onMounted(() => {
+  measureWidth();
+});
+onUpdated(() => {
+  measureWidth();
+});
 </script>
 
 <style lang="scss">

+ 3 - 0
src/components/form/CalendarField.vue

@@ -45,6 +45,7 @@ import ActionSheetTitle, { type ActionSheetTitleProps } from '../dialog/ActionSh
 import Calendar from './Calendar.vue';
 import Height from '../layout/space/Height.vue';
 import Text, { type TextProps } from '../basic/Text.vue';
+import { usePickerFieldInstance, type PickerFieldInstance } from './Picker';
 
 export interface CalendarFieldProps extends Omit<CalendarProps, 'modelValue'> {
   
@@ -167,6 +168,8 @@ watch(tempValue, (v) => {
   }
 })
 
+defineExpose<PickerFieldInstance>(usePickerFieldInstance(popupShow));
+  
 defineOptions({
   options: {
     styleIsolation: "shared",

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

@@ -37,6 +37,7 @@ import ActionSheetTitle, { type ActionSheetTitleProps } from '../dialog/ActionSh
 import CascadePicker from './CascadePicker.vue';
 import { usePickerFieldTempStorageData } from './PickerUtils';
 import Text, { type TextProps } from '../basic/Text.vue';
+import { usePickerFieldInstance, type PickerFieldInstance } from './Picker';
 
 export interface CascadePickerFieldProps extends Omit<CascadePickerProps, 'value'> {
   /**
@@ -120,6 +121,7 @@ const {
   popupShow,
 );
 
+defineExpose<PickerFieldInstance>(usePickerFieldInstance(popupShow));
 defineOptions({
   options: {
     styleIsolation: "shared",

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

@@ -45,6 +45,7 @@ import Height from '../layout/space/Height.vue';
 import Text, { type TextProps } from '../basic/Text.vue';
 import PopupTitle from '../dialog/PopupTitle.vue';
 import { getCascaderText } from './CascaderUtils';
+import { usePickerFieldInstance } from './Picker';
 
 export interface CascaderFieldProps extends Omit<CascaderProps, 'modelValue'> {
   /**
@@ -158,6 +159,7 @@ watch(tempValue, (v) => {
 defineExpose({
   confirm: onConfirm,
   cancel: onCancel,
+  ...usePickerFieldInstance(popupShow),
 })
 defineOptions({
   options: {

+ 14 - 9
src/components/form/CheckBox.vue

@@ -25,15 +25,19 @@
       />
     </slot>
     <slot>
-      <Text 
-        :innerStyle="{
-          ...themeStyles.checkText.value,
-          ...textStyle,
-          color: themeContext.resolveThemeColor(props.disabled === true ? disabledTextColor : textColor),
-          display: StringUtils.isNullOrEmpty(text) ? 'none' : 'flex',
-        }"
-        :text="text" 
-      />
+      <FlexCol>
+        <Text 
+          :innerStyle="{
+            ...themeStyles.checkText.value,
+            ...textStyle,
+            color: themeContext.resolveThemeColor(props.disabled === true ? disabledTextColor : textColor),
+            display: StringUtils.isNullOrEmpty(text) ? 'none' : 'flex',
+          }"
+          :text="text" 
+        />
+        <slot name="extraText">
+        </slot>
+      </FlexCol>
     </slot>
     <slot name="check" v-if="checkPosition === 'right'" icon="check" :on="value" :disabled="disabled" :shape="shape">
       <CheckBoxDefaultButton 
@@ -62,6 +66,7 @@ import type { CheckBoxGroupContextInfo } from './CheckBoxGroup.vue';
 import CheckBoxDefaultButton from './CheckBoxDefaultButton.vue';
 import Text from '../basic/Text.vue';
 import Touchable from '../feedback/Touchable.vue';
+import FlexCol from '../layout/FlexCol.vue';
 
 export interface CheckBoxProps {
   /**

+ 1 - 0
src/components/form/CheckBoxDefaultButton.vue

@@ -184,5 +184,6 @@ defineOptions({
   flex-direction: row;
   align-items: center;
   justify-content: center;
+  flex-shrink: 0;
 }
 </style>

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

@@ -37,6 +37,7 @@ import ActionSheetTitle, { type ActionSheetTitleProps } from '../dialog/ActionSh
 import DatePicker from './DatePicker.vue';
 import { usePickerFieldTempStorageData } from './PickerUtils';
 import Text, { type TextProps } from '../basic/Text.vue';
+import { usePickerFieldInstance, type PickerFieldInstance } from './Picker';
 
 export interface DatePickerFieldProps extends Omit<DatePickerProps, 'modelValue'> {
   modelValue?: Date;
@@ -120,6 +121,7 @@ const {
   popupShow,
 );
 
+defineExpose<PickerFieldInstance>(usePickerFieldInstance(popupShow));
 defineOptions({
   options: {
     styleIsolation: "shared",

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

@@ -37,6 +37,7 @@ import ActionSheetTitle, { type ActionSheetTitleProps } from '../dialog/ActionSh
 import DateTimePicker from './DateTimePicker.vue';
 import { usePickerFieldTempStorageData } from './PickerUtils';
 import Text, { type TextProps } from '../basic/Text.vue';
+import { usePickerFieldInstance, type PickerFieldInstance } from './Picker';
 
 export interface DateTimePickerFieldProps extends Omit<DateTimePickerProps, 'modelValue'> {
   modelValue?: Date;
@@ -122,6 +123,7 @@ const {
   undefined,
   popupShow,
 );
+defineExpose<PickerFieldInstance>(usePickerFieldInstance(popupShow));
 
 defineOptions({
   options: {

+ 2 - 1
src/components/form/Field.vue

@@ -130,6 +130,7 @@
               decimal: 'digit',
               tel: 'tel',
               email: 'text',
+              nickname: 'nickname',
             }) || 'text'"
             :maxlength="maxLength"
             :disabled="disabled || readonly"
@@ -248,7 +249,7 @@ export interface FieldProps {
    * 输入框类型
    * @default 'text'
    */
-  type?: 'text'|'tel'|'number'|'password'|'number'|'email'|'decimal';
+  type?: 'text'|'tel'|'number'|'password'|'number'|'email'|'decimal'|'nickname';
   /**
    * 输入的最大字符数
    */

+ 34 - 0
src/components/form/Picker.ts

@@ -0,0 +1,34 @@
+import type { Ref } from "vue";
+
+export interface PickerItem {
+  /**
+   * 显示文本
+   */
+  text: string;
+  /**
+   * 选中值
+   */
+  value: string|number;
+}
+
+export interface PickerFieldInstance {
+  /**
+   * 显示
+   */
+  show(): void;
+  /**
+   * 隐藏
+   */
+  hide(): void;
+}
+
+export function usePickerFieldInstance(show: Ref<boolean>) : PickerFieldInstance {
+  return {
+    show() {
+      show.value = true;
+    },
+    hide() {
+      show.value = false;
+    }
+  }
+}

+ 1 - 5
src/components/form/Picker.vue

@@ -25,14 +25,10 @@
 import { nextTick, onMounted, ref, watch } from 'vue';
 import { useTheme } from '../theme/ThemeDefine';
 import Empty from '../feedback/Empty.vue';
+import type { PickerItem } from './Picker';
 
 const themeContext = useTheme();
 
-export interface PickerItem {
-  text: string;
-  value: string|number;
-}
-
 export interface PickerProps {
   /**
    * 选择器列

+ 3 - 0
src/components/form/PickerField.vue

@@ -37,6 +37,7 @@ import Popup from '../dialog/Popup.vue';
 import ActionSheetTitle, { type ActionSheetTitleProps } from '../dialog/ActionSheetTitle.vue';
 import Picker from './Picker.vue';
 import Text, { type TextProps } from '../basic/Text.vue';
+import { usePickerFieldInstance, type PickerFieldInstance } from './Picker';
 
 export interface PickerFieldProps extends Omit<PickerProps, 'value'> {
   modelValue?: (number|string)[];
@@ -123,6 +124,8 @@ const {
   popupShow,
 );
 
+defineExpose<PickerFieldInstance>(usePickerFieldInstance(popupShow));
+
 defineOptions({
   options: {
     styleIsolation: "shared",

+ 65 - 4
src/components/form/SearchBar.vue

@@ -3,7 +3,8 @@
     center 
     :innerStyle="{
       ...themeStyles.view.value, 
-      ...innerStyle
+      ...innerStyle,
+      background: theme.resolveThemeColor(inputBackgroundColor),
     }"
     :flexShrink="0"
     overflow="hidden"
@@ -43,7 +44,6 @@
       />
       <slot name="rightIcon" />
     </FlexRow>
-    <slot name="rightButton" :onCustomButtonClick="onCustomButtonClick" />
     <slot 
       v-if="(
         cancelState === 'show' || 
@@ -64,6 +64,24 @@
         @click="onCancelPressed"
       />
     </slot>
+    <slot
+      v-if="(
+        searchState === 'show' || 
+        (inputFocus && (searchState === 'show-active' || searchState === 'show-active-or-no-empty')) || 
+        (valueTemp !== '' && (searchState === 'show-no-empty' || searchState === 'show-active-or-no-empty'))
+      )"
+      name="searchButton" 
+      :onSearchButtonClick="onSearchButtonClick"
+    >
+      <IconButton
+        :icon="searchIcon"
+        :size="searchIconSize"
+        :color="searchIconColor"
+        v-bind="searchIconProps"
+        @click="onSearchButtonClick"
+      />
+    </slot>
+    <slot name="rightButton" :onCustomButtonClick="onCustomButtonClick"/>
   </FlexRow>
 </template>
 
@@ -73,8 +91,9 @@ import type { IconProps } from '../basic/Icon.vue';
 import { type ViewStyle, type TextStyle, useTheme, propGetThemeVar } from '../theme/ThemeDefine';
 import FlexRow from '../layout/FlexRow.vue';
 import Button from '../basic/Button.vue';
-import { DynamicColor, DynamicSize } from '../theme/ThemeTools';
+import { DynamicSize } from '../theme/ThemeTools';
 import Icon from '../basic/Icon.vue';
+import IconButton from '../basic/IconButton.vue';
 
 export interface SearchBarProp {
   /**
@@ -87,6 +106,11 @@ export interface SearchBarProp {
    */
   inputColor?: string;
   /**
+   * 输入框的背景颜色
+   * @default white
+   */
+  inputBackgroundColor?: string;
+  /**
    * 输入框的placeholder
    */
   placeholder?: string;
@@ -137,6 +161,35 @@ export interface SearchBarProp {
    */
   cancelState?: 'hidden'|'show'|'show-active'|'show-no-empty'|'show-active-or-no-empty';
   /**
+   * 搜索按钮显示
+   * * hidden 不显示
+   * * show 一直显示
+   * * show-active 当输入框激活时显示
+   * * show-no-empty 当输入框有文字时显示
+   * * show-active-or-no-empty 当输入框激活或者有文字时显示
+   * @default 'hidden'
+   */
+  searchState?: 'hidden'|'show'|'show-active'|'show-no-empty'|'show-active-or-no-empty';
+  /**
+   * 搜索按钮图标
+   * @default 'search'
+   */
+  searchIcon?: string;
+  /**
+   * 搜索按钮图标大小
+   * @default 40
+   */
+  searchIconSize?: string|number;
+  /**
+   * 搜索按钮图标颜色
+   * @default text.content
+   */
+  searchIconColor?: string;
+  /**
+   * 图标自定义属性
+   */
+  searchIconProps?: Partial<IconProps>;
+  /**
    * 自定义样式
    */
   innerStyle?: ViewStyle;
@@ -164,6 +217,11 @@ const theme = useTheme();
 
 const props = withDefaults(defineProps<SearchBarProp>(), {
   inputColor: () => propGetThemeVar('SearchBarTextColor', 'text.content'),
+  inputBackgroundColor: () => propGetThemeVar('SearchBarBackgroundColor', 'white'),
+  searchState: 'hidden',
+  searchIcon: () => propGetThemeVar('SearchBarSearchIcon', 'search'),
+  searchIconSize: () => propGetThemeVar('SearchBarSearchIconSize', 40),
+  searchIconColor: () => propGetThemeVar('SearchBarSearchIconColor', 'text.content'),
   placeholder: '',
   placeholderTextColor: () => propGetThemeVar('SearchBarCancelTextColor', 'text.second'),
   cancelText: '取消',
@@ -189,7 +247,6 @@ function updateValue(v: string) {
 const themeStyles = theme.useThemeStyles({
   view: {
     position: 'relative',
-    backgroundColor: DynamicColor('SearchBarBackgroundColor', 'white'),
     borderRadius: DynamicSize('SearchBarBorderRadius', 40),
     paddingHorizontal: DynamicSize('SearchBarPaddingHorizontal', 28),
     paddingVertical: DynamicSize('SearchBarPaddingVertical', 16),
@@ -231,6 +288,10 @@ function onCancelPressed() {
     updateValue('');
   emit('cancel');
 }
+function onSearchButtonClick() {
+  emit('search', valueTemp.value);
+  uni.hideKeyboard()
+}
 function onSumit() {
   emit('search', valueTemp.value);
   uni.hideKeyboard()

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

@@ -37,6 +37,7 @@ import ActionSheetTitle, { type ActionSheetTitleProps } from '../dialog/ActionSh
 import TimePicker from './TimePicker.vue';
 import { usePickerFieldTempStorageData } from './PickerUtils';
 import Text, { type TextProps } from '../basic/Text.vue';
+import { usePickerFieldInstance, type PickerFieldInstance } from './Picker';
 
 export interface TimePickerFieldProps extends Omit<TimePickerProps, 'modelValue'> {
   modelValue?: Date;
@@ -115,6 +116,7 @@ const {
   undefined,
   popupShow,
 );
+defineExpose<PickerFieldInstance>(usePickerFieldInstance(popupShow));
 
 defineOptions({
   options: {

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

@@ -80,8 +80,6 @@ onMounted(() => {
 
 defineExpose({
   getUploaderRef: () => {
-    console.log('getUploaderRef', uploaderRef.value);
-    
     return uploaderRef.value
   },
 })

+ 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,

+ 26 - 2
src/components/nav/NavBar.vue

@@ -1,6 +1,6 @@
 <template>
   <view 
-    class="nana-nav-title"
+    :class="['nana-nav-title', innerClass]"
     :style="{
       backgroundColor: theme.resolveThemeColor(backgroundColor),
       height: theme.resolveThemeSize(height),
@@ -23,6 +23,7 @@
         />
       </slot>
       <IconButton v-else icon="space" shape="square-full" />
+      <slot name="left-custom" />
     </view>
     <slot name="center">
       <HorizontalScrollText
@@ -33,6 +34,7 @@
           paddingLeft: 0,
           paddingRight: 0,
         }"
+        :innerClass="titleClass"
         :color="textColor"
         :textAlign="align"
         :text="title"
@@ -44,6 +46,7 @@
           ...props.titleStyle,
           flex: 1,
         }"
+        :innerClass="titleClass"
         :textAlign="align"
         :color="textColor"
         :text="title"
@@ -76,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';
 
@@ -139,6 +143,10 @@ export interface NavBarProps {
    */
   titleStyle?: object;
   /**
+   * 自定义标题文字样式
+   */
+  titleClass?: any;
+  /**
    * 标题文字超出时,是否自动滚动
    * @default true
    */
@@ -147,6 +155,10 @@ export interface NavBarProps {
    * 图标透传样式
    */
   iconProps?: IconProps;
+  /**
+   * 自定义类名
+   */
+  innerClass?: any;
 }
 
 function getButton(type: NavBarButtonTypes) {
@@ -193,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();
   }

+ 47 - 3
src/components/nav/Pagination.vue

@@ -10,10 +10,27 @@
       />
     </slot>
     <slot v-if="simple" name="simple" :text="`${currentPage + 1}/${props.pageCount}`"">
+      <PaginationItem
+        v-if="showFirstAndLast"
+        v-bind="$props"
+        :text="firstText"
+        :touchable="canPrev"
+        :active="false"
+        @click="onFirstPress"
+      />
       <text :style="themeStyles.simpleText.value">{{ `${currentPage + 1}/${props.pageCount}` }}</text>
+      <PaginationItem
+        v-if="showFirstAndLast"
+        v-bind="$props"
+        :text="lastText"
+        :touchable="canNext"
+        :active="false"
+        @click="onLastPress"
+      />
     </slot>
-    <template v-else>
-      <template  v-for="index in items" :key="index">
+    <slot v-else name="items" :items="items" :currentPage="currentPage" :onChange="emitChange">
+      <template v-for="index in items" :key="index">
+        <!-- #ifndef MP -->
         <slot
           name="item"
           :text="(index + 1).toString()"
@@ -21,6 +38,7 @@
           :active="index === currentPage"
           :onClick="() => emitChange(index)"
         >
+        <!-- #endif -->
           <PaginationItem
             v-bind="$props"
             :key="index"
@@ -29,9 +47,11 @@
             :touchable="true"
             @click="() => emitChange(index)"
           />
+        <!-- #ifndef MP -->
         </slot>
+        <!-- #endif -->
       </template>
-    </template>
+    </slot>
     <slot v-if="showNextPrev" name="next" :onClick="onNextPress" :touchable="canNext">
       <PaginationItem 
         v-bind="$props"
@@ -63,10 +83,17 @@ export interface PaginationProps {
   pageCount: number;
   /**
    * 是否显示上一页下一页按钮,默认是
+   * @default true
    */
   showNextPrev?: boolean;
   /**
+   * 是否显示第一页和最后一页按钮,默认是
+   * @default true
+   */
+  showFirstAndLast?: boolean;
+  /**
    * 同时显示的页面按钮数量
+   * @default 5
    */
   showPageCount?: number;
   /**
@@ -75,13 +102,25 @@ export interface PaginationProps {
   simple?: boolean;
   /**
    * 上一页按钮文字
+   * @default '上一页'
    */
   prevText?: string;
   /**
    * 下一页按钮文字
+   * @default '下一页'
    */
   nextText?: string;
   /**
+   * 第一页按钮文字,仅在 simple 模式下显示
+   * @default '首页'
+   */
+  firstText?: string;
+  /**
+   * 最后一页按钮文字,仅在 simple 模式下显示
+   * @default '末页'
+   */
+  lastText?: string;
+  /**
    * 按钮按下颜色
    */
   pressedColor?: string;
@@ -123,6 +162,9 @@ const props = withDefaults(defineProps<PaginationProps>(), {
   simple: false,
   prevText: '上一页',
   nextText: '下一页',
+  firstText: '首页',
+  lastText: '末页',
+  showFirstAndLast: true,
   pressedColor: () => propGetThemeVar('PaginationPressedColor', 'pressed.white'),
   pressedTextColor: () => propGetThemeVar('PaginationPressedTextColor', 'text.second'),
   activeColor: () => propGetThemeVar('PaginationActiveColor', 'primary'),
@@ -159,6 +201,8 @@ function emitChange(num: number) {
 }
 const onPrevPress = () => emitChange(props.currentPage - 1);
 const onNextPress = () => emitChange(props.currentPage + 1);
+const onFirstPress = () => emitChange(0);
+const onLastPress = () => emitChange(props.pageCount - 1);
 
 const themeContext = useTheme();
 const themeStyles = themeContext.useThemeStyles({

+ 1 - 1
src/components/nav/Tabs.vue

@@ -235,7 +235,7 @@ const props = withDefaults(defineProps<TabsProps>(), {
 
 const theme = useTheme();
 
-const tabPaddingHorizontal = computed(() => theme.getVar('TabsItemPaddingHorizontal', 10));
+const tabPaddingHorizontal = computed(() => theme.getVar('TabsItemPaddingHorizontal', 0));
 const themedUnderlayColor = computed(() => theme.resolveThemeColor(props.underlayColor));
 const themedActiveTextColor = computed(() => theme.resolveThemeColor(props.activeTextColor));
 const themedTextColor = computed(() => theme.resolveThemeColor(props.textColor));

+ 4 - 0
src/components/theme/Theme.ts

@@ -147,6 +147,10 @@ export const DefaultTheme : ThemeConfig = {
       color: 'text.content',
       fontSize: '26rpx',
     },
+    subSecondText: {
+      color: 'text.second',
+      fontSize: '26rpx',
+    },
     footerText: {
       color: 'text.second',
       fontSize: '24rpx',

+ 29 - 6
src/components/utils/PageAction.ts

@@ -35,12 +35,12 @@ function redirectTo(url: string, data: Record<string, unknown> = {}) {
   var dataString = '';
 
   for (const key in data) {
-    if (Object.prototype.hasOwnProperty.call(data, key))
+    if (Object.prototype.hasOwnProperty.call(data, key) && data[key] !== undefined && data[key] !== null)
       dataString += `&${key}=${data[key]}`;
   }
 
   uni.redirectTo({ 
-    url: url + '?' + dataString,
+    url: url + (url.includes('?') ? '&' : '?') + dataString,
     fail: (err) => {
       console.error('页面跳转失败:', err);
     },
@@ -53,14 +53,19 @@ function redirectTo(url: string, data: Record<string, unknown> = {}) {
  */
 function navTo(url: string, data: Record<string, unknown> = {}) {
   var dataString = '';
-
+  
+  if (Array.isArray(url)) {
+    data = url[1]
+    url = url[0]
+  }
   for (const key in data) {
-    if (Object.prototype.hasOwnProperty.call(data, key))
-      dataString += `&${key}=${data[key]}`;
+    const d = data[key]
+    if (d !== undefined && d !== null)
+      dataString += `&${key}=${encodeURIComponent('' + d)}`;
   }
 
   uni.navigateTo({ 
-    url: url + '?' + dataString,
+    url: url + (url.includes('?') ? '&' : '?') + dataString,
     fail: (err) => {
       console.error('页面跳转失败:', err);
     },
@@ -93,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,
@@ -100,6 +122,7 @@ export {
   backAndCallOnPageBack, 
   navTo,
   callPrevOnPageBack,
+  isTopLevelPage,
 }
 
 export function getCurrentPageUrl() {

+ 7 - 0
src/pages.json

@@ -199,6 +199,13 @@
         "navigationBarTitleText": "商务合作",
         "enablePullDownRefresh": false
       }
+    },
+    {
+      "path": "pages/test/topic",
+      "style": {
+        "navigationBarTitleText": "测试话题页",
+        "enablePullDownRefresh": false
+      }
     }
   ],
   "globalStyle": {

+ 15 - 0
src/pages/test/topic.vue

@@ -0,0 +1,15 @@
+<template>
+  <official-account-publish 
+    path="/pages/index" 
+    @error="onError"
+  />
+</template>
+
+<script setup lang="ts">  
+
+function onError(e: any) {
+  console.error(e);
+}
+
+
+</script>

+ 8 - 1
src/pages/user/debug/DebugButton.vue

@@ -15,6 +15,7 @@ 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';
+import { navTo } from '@/components/utils/PageAction';
 
 const showAct = isDev || isTestEnv;
 
@@ -29,6 +30,10 @@ function showBuildInfo() {
     iconColor: 'primary',
     customButtons: showAct ? [
       {
+        text: '测试话题页',
+        name: 'testTopic',
+      },
+      {
         text: '测试提交BUG(1)',
         name: 'testBug',
       },
@@ -40,7 +45,9 @@ function showBuildInfo() {
     bottomVertical: true,
     width: 560,
   }).then((res) => {
-    if (res == 'testBug') {
+    if (res == 'testTopic') {
+      navTo('/pages/test/topic');
+    } else if (res == 'testBug') {
       uni.showToast({
         title: '测试提交BUG(1)',
         icon: 'none',

+ 0 - 14
src/pages/user/index.vue

@@ -38,9 +38,6 @@
       <Cell v-if="userInfo" icon="/static/images/user/icon-quit.png" title="退出登录" showArrow touchable @click="doLogout" />
     </CellGroup>
     <DebugButton />
-    <view class="test">
-      <official-account-publish path="/pages/index" @error="onError"></official-account-publish>
-    </view>
   </FlexCol>
 </template>
 
@@ -67,10 +64,6 @@ const userInfo = computed(() => authStore.isLogged ? authStore.userInfo : null);
 const isBindWx = computed(() => Boolean(userInfo.value?.openId));
 const volunteerInfoLoader = useSimpleDataLoader(async () => await VillageApi.getVolunteerInfo(), true);
 
-function onError(e: any) {
-  console.error(e);
-}
-
 function goUserProfile() {
   userInfo.value ? navTo('/pages/user/update/profile') : navTo('/pages/user/login');
 }
@@ -86,10 +79,3 @@ function doLogout() {
   });
 }
 </script>
-
-<style scoped lang="scss">
-.test {
-  width: 100%;
-  min-height: 50rpx;
-}
-</style>