Bladeren bron

📦 更新库和修改细节,添加客服按钮

快乐的梦鱼 1 maand geleden
bovenliggende
commit
da282bc7d6
100 gewijzigde bestanden met toevoegingen van 2355 en 621 verwijderingen
  1. 9 2
      src/App.vue
  2. 1 1
      src/components/README.md
  3. 31 10
      src/components/basic/Button.vue
  4. 73 0
      src/components/basic/ButtonGroup.vue
  5. 4 3
      src/components/basic/Cell.vue
  6. 28 11
      src/components/basic/Icon.vue
  7. 1 1
      src/components/basic/IconUtils.ts
  8. 33 10
      src/components/basic/Image.vue
  9. 8 0
      src/components/basic/Text.vue
  10. 17 8
      src/components/composeabe/ChildItem.ts
  11. 6 1
      src/components/data/DefaultIcon.json
  12. 2 6
      src/components/demo/DemoBlock.vue
  13. 29 36
      src/components/demo/DemoPage.vue
  14. 3 1
      src/components/demo/DemoTitle.vue
  15. 6 4
      src/components/dialog/ActionSheetRoot.vue
  16. 23 23
      src/components/dialog/CommonRoot.ts
  17. 5 2
      src/components/dialog/CommonRoot.vue
  18. 18 1
      src/components/dialog/Dialog.vue
  19. 7 1
      src/components/dialog/DialogButton.vue
  20. 27 5
      src/components/dialog/DialogInner.vue
  21. 3 7
      src/components/dialog/DialogRoot.vue
  22. 17 14
      src/components/dialog/Popup.vue
  23. 1 1
      src/components/dialog/PopupTitle.vue
  24. 174 0
      src/components/display/Card.vue
  25. 9 3
      src/components/display/CollapseBox.vue
  26. 6 15
      src/components/display/CollapseItem.vue
  27. 165 0
      src/components/display/Preview.vue
  28. 72 0
      src/components/display/PreviewItem.vue
  29. 40 55
      src/components/display/block/BackgroundBox.vue
  30. 61 13
      src/components/display/block/IconTextBlock.vue
  31. 50 28
      src/components/display/block/ImageBlock.vue
  32. 37 29
      src/components/display/block/ImageBlock2.vue
  33. 37 42
      src/components/display/block/ImageBlock3.vue
  34. 6 6
      src/components/display/block/TextBlock.vue
  35. 1 0
      src/components/display/block/TextLeftRightBlock.vue
  36. 39 35
      src/components/display/title/SubTitle.vue
  37. 14 0
      src/components/dynamic/DynamicForm.ts
  38. 25 7
      src/components/dynamic/DynamicForm.vue
  39. 45 2
      src/components/dynamic/DynamicFormControl.vue
  40. 2 3
      src/components/dynamic/nest/DynamicFormItemContainer.vue
  41. 89 0
      src/components/dynamic/wrappers/CheckBoxTreeList.vue
  42. 64 0
      src/components/dynamic/wrappers/CheckBoxTreeListItem.vue
  43. 14 0
      src/components/dynamic/wrappers/CheckBoxTreeListItemWrapper.vue
  44. 18 3
      src/components/dynamic/wrappers/PickerAddressField.vue
  45. 14 3
      src/components/dynamic/wrappers/PickerCityField.vue
  46. 5 0
      src/components/dynamic/wrappers/PickerIdField.ts
  47. 3 2
      src/components/dynamic/wrappers/PickerIdField.vue
  48. 4 2
      src/components/dynamic/wrappers/PickerLonlat.vue
  49. 9 9
      src/components/feedback/Alert.vue
  50. 306 0
      src/components/feedback/BubbleBox.vue
  51. 2 2
      src/components/feedback/DropdownMenu.vue
  52. 3 4
      src/components/feedback/DropdownMenuItem.vue
  53. 5 2
      src/components/feedback/Result.vue
  54. 1 1
      src/components/feedback/ShareSheetButtons.vue
  55. 6 2
      src/components/feedback/Toast.vue
  56. 26 0
      src/components/feedback/Touchable.ts
  57. 13 24
      src/components/feedback/Touchable.vue
  58. 1 1
      src/components/form/Calendar.vue
  59. 1 2
      src/components/form/CalendarField.vue
  60. 1 1
      src/components/form/CascadePicker.vue
  61. 2 0
      src/components/form/CascadePickerField.vue
  62. 13 3
      src/components/form/CascaderField.vue
  63. 2 0
      src/components/form/DatePickerField.vue
  64. 2 0
      src/components/form/DateTimePickerField.vue
  65. 24 12
      src/components/form/Field.vue
  66. 3 1
      src/components/form/Form.vue
  67. 26 0
      src/components/form/FormContext.ts
  68. 1 0
      src/components/form/NumberInputBox.vue
  69. 3 2
      src/components/form/Picker.vue
  70. 11 1
      src/components/form/PickerField.vue
  71. 50 6
      src/components/form/PickerUtils.ts
  72. 3 1
      src/components/form/Radio.vue
  73. 2 0
      src/components/form/RadioGroup.vue
  74. 2 2
      src/components/form/Slider.vue
  75. 19 2
      src/components/form/Stepper.vue
  76. 2 2
      src/components/form/Switch.vue
  77. 2 0
      src/components/form/TimePickerField.vue
  78. 4 0
      src/components/form/Uploader.ts
  79. 164 58
      src/components/form/Uploader.vue
  80. 40 9
      src/components/form/UploaderField.vue
  81. 2 2
      src/components/form/UploaderListAddItem.vue
  82. 16 12
      src/components/form/UploaderListItem.vue
  83. BIN
      src/components/images/icons/video-mark.png
  84. 4 3
      src/components/index.scss
  85. 1 1
      src/components/keyboard/NumberKeyBoardInner.vue
  86. 1 1
      src/components/keyboard/NumberKeyBoardKey.vue
  87. 1 1
      src/components/keyboard/PlateKeyBoardKey.vue
  88. 11 3
      src/components/layout/BaseView.ts
  89. 21 1
      src/components/layout/FlexView.vue
  90. 1 0
      src/components/layout/grid/GridItem.vue
  91. 0 2
      src/components/nav/IndexBar.vue
  92. 2 0
      src/components/nav/NavBar.vue
  93. 1 1
      src/components/nav/Pagination.vue
  94. 7 0
      src/components/nav/SegmentedControl.vue
  95. 2 1
      src/components/nav/SegmentedControlItem.vue
  96. 7 8
      src/components/nav/Tabs.vue
  97. 88 6
      src/components/theme/Theme.ts
  98. 92 34
      src/components/theme/ThemeDefine.ts
  99. 3 2
      src/components/typography/HorizontalScrollText.vue
  100. 0 0
      src/components/utils/DialogAction.ts

+ 9 - 2
src/App.vue

@@ -2,6 +2,8 @@
 import { onLaunch } from '@dcloudio/uni-app'
 import { useAuthStore } from './store/auth'
 import { configTheme } from './components/theme/ThemeDefine';
+import { RequestApiConfig } from '@imengyu/imengyu-utils';
+import ApiCofig from './common/config/ApiCofig';
 
 const authStore = useAuthStore();
 
@@ -22,11 +24,16 @@ onLaunch(() => {
   authStore.loadLoginState();
 })
 
-configTheme((theme) => {
+RequestApiConfig.setConfig({
+  ...RequestApiConfig.getConfig(),
+  BaseUrl: ApiCofig.serverProd,
+})
+
+configTheme(false, (theme, darkTheme) => {
   theme.colorConfigs.default.primary = '#d9492e';
   theme.colorConfigs.pressed.primary = '#882d1d';
   theme.colorConfigs.background.primary = '#ffcfc6';
-  return theme;
+  return [theme, darkTheme];
 });
 </script>
 

+ 1 - 1
src/components/README.md

@@ -6,7 +6,7 @@ NaEasy UI 是一款简单的 UniApp 移动端UI组件库。
 
 ## 版本
 
-当前版本:INDEV 0.0.1
+当前版本:INDEV 0.0.4-25121701
 
 ## 版权说明
 

+ 31 - 10
src/components/basic/Button.vue

@@ -11,7 +11,7 @@
     :pressedColor="finalPressedColor"
     :touchable="touchable && !loading"
     @state="(v) => state = v"
-    @click="emit('click', $event)"
+    @click="(e) => emit('click', e)"
   >
     <slot name="leftIcon">
       <ActivityIndicator 
@@ -58,7 +58,7 @@
 </template>
 
 <script setup lang="ts">
-import { computed, ref } from 'vue';
+import { computed, inject, ref } from 'vue';
 import { useTheme, type ViewStyle } from '../theme/ThemeDefine';
 import { configPadding, DynamicColor, DynamicSize, selectStyleType } from '../theme/ThemeTools';
 import type { IconProps } from './Icon.vue';
@@ -67,6 +67,8 @@ import Text from './Text.vue';
 import ActivityIndicator from './ActivityIndicator.vue';
 import Icon from './Icon.vue';
 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 ButtomSizeType = 'small'|'medium'|'large'|'larger'|'mini';
@@ -235,8 +237,8 @@ const themeStyles = themeContext.useThemeStyles({
   plainButtonDefault: {
     borderStyle: 'solid',
     borderWidth: DynamicSize('ButtonBorderWidth', 1.5),
-    borderColor: DynamicColor('ButtonPlainDefaultBorderColor', 'border'),
-    color: DynamicColor('ButtonPlainDefaultColor', 'text'),
+    borderColor: DynamicColor('ButtonPlainDefaultBorderColor', 'border.default'),
+    color: DynamicColor('ButtonPlainDefaultColor', 'text.content'),
   },
   plainButtonPrimary: {
     borderStyle: 'solid',
@@ -264,7 +266,7 @@ const themeStyles = themeContext.useThemeStyles({
   },
   lightButtonDefault: {
     backgroundColor: DynamicColor('ButtonLightDefaultBackgroundColor', 'background.button'),
-    color: DynamicColor('ButtonLightDefaultColor', 'text'),
+    color: DynamicColor('ButtonLightDefaultColor', 'text.content'),
   },
   lightButtonPrimary: {
     backgroundColor: DynamicColor('ButtonLightPrimaryBackgroundColor', 'background.primary'),
@@ -335,7 +337,7 @@ const currentStyle = computed(() => {
         warning: themeStyles.buttonWarning.value,
         danger: themeStyles.buttonDanger.value,
         custom: {
-          backgroundColor: themeContext.resolveThemeColor(props.touchable ? props.color : props.disabledColor),
+          backgroundColor: themeContext.resolveThemeColor(touchable.value ? props.color : props.disabledColor),
           color: themeContext.resolveThemeColor(props.textColor),
         },
         text: {
@@ -365,7 +367,7 @@ const currentStyle = computed(() => {
         warning: themeStyles.lightButtonWarning.value,
         danger: themeStyles.lightButtonDanger.value,
         custom: {
-          backgroundColor: themeContext.resolveThemeColor(props.touchable ? props.color : props.disabledColor),
+          backgroundColor: themeContext.resolveThemeColor(touchable.value ? props.color : props.disabledColor),
           color: themeContext.resolveThemeColor(props.textColor),
         },
         text: {
@@ -376,12 +378,23 @@ const currentStyle = computed(() => {
   );
 
   const speicalStyle : ViewStyle = {
-    opacity: props.touchable ? 1 : themeVars.ButtonDisableOpacity,
+    opacity: touchable.value ? 1 : themeVars.ButtonDisableOpacity,
     borderRadius: props.shape === 'round' ? themeContext.resolveThemeSize(props.radius) : 0,
   };
 
+  if (props.shape === 'round') {
+    if (noLeftRadius.value) {
+      speicalStyle.borderTopLeftRadius = 0;
+      speicalStyle.borderBottomLeftRadius = 0;
+    }
+    if (noRightRadius.value) {
+      speicalStyle.borderTopRightRadius = 0;
+      speicalStyle.borderBottomRightRadius = 0;
+    }
+  }
+
   //自定义状态下的禁用颜色
-  if (props.disabledColor && !props.touchable && props.type === 'custom')
+  if (props.disabledColor && !touchable.value && props.type === 'custom')
     speicalStyle.backgroundColor = themeContext.resolveThemeColor(props.disabledColor);
 
   const sizeStyle = selectStyleType<ViewStyle, ButtomSizeType>(props.size, 'medium', {
@@ -395,7 +408,7 @@ const currentStyle = computed(() => {
   //  sizeStyle.paddingHorizontal = `calc(${sizeStyle.paddingHorizontal} + ${themeContext.resolveSize(props.radius / 4)})`;
 
   //内边距样式的强制设置
-  configPadding(speicalStyle, themeContext.theme, props.padding);
+  configPadding(speicalStyle, themeContext.theme.value, props.padding);
 
   return {
     color: (colorStyle).color,
@@ -428,7 +441,15 @@ const finalPressedColor = computed(() => {
     'pressed.' + props.type))
 });
 
+//按钮组的处理
+
+const buttonGroupContext = inject<ButtonGroupContext>('buttonGroupContext', undefined as any);
+const { position } = useChildLinkChild(() => buttonGroupContext?.getPosition());
+
+const noRightRadius = computed(() => buttonGroupContext?.mergeRadius.value && position.value !== buttonGroupContext.length.value - 1);
+const noLeftRadius = computed(() => buttonGroupContext?.mergeRadius.value && position.value !== 0);
 
+const touchable = computed(() => props.touchable !== false && !buttonGroupContext?.groupDisabled.value);
 </script>
 
 <style>

+ 73 - 0
src/components/basic/ButtonGroup.vue

@@ -0,0 +1,73 @@
+<template>
+  <FlexView v-bind="$props">
+    <slot />
+  </FlexView>
+</template>
+
+<script setup lang="ts">
+import FlexView, { type FlexProps } from '../layout/FlexView.vue';
+import { useChildLinkParent } from '../composeabe/ChildItem';
+import { computed, provide, ref, toRef, type ComputedRef, type Ref } from 'vue';
+
+export interface ButtonGroupProp extends FlexProps {
+  /**
+   * 是否禁用按钮组下的全部按钮
+   * @default false
+   */
+  disabled?: boolean;
+  /**
+   * 按钮组间距。为0时会自动合并按钮,使其圆角自动合并
+   * @default 0
+   */
+  gap?: number;
+}
+
+defineOptions({
+  options: {
+    styleIsolation: "shared",
+    virtualHost: true,
+  }
+})
+
+const props = withDefaults(defineProps<ButtonGroupProp>(), {
+  direction: 'row',
+  disabled: false,
+  gap: 0,
+});
+
+const mergeRadius = computed(() => {
+  return props.gap === 0;
+});
+
+const length = ref(0);
+
+const {
+  getPosition,
+  getLength,
+  resetCounter,
+} = useChildLinkParent({
+  onLengthChanged() {
+    length.value = getLength();
+  },
+});
+
+export interface ButtonGroupContext {
+  groupDisabled: Ref<boolean>;
+  mergeRadius: ComputedRef<boolean>;
+  length: typeof length;
+  resetCounter: typeof resetCounter;
+  getPosition: typeof getPosition;
+}
+
+provide<ButtonGroupContext>('buttonGroupContext', {
+  groupDisabled: toRef(props.disabled),
+  mergeRadius,
+  length,
+  getPosition,
+  resetCounter,
+});
+</script>
+
+<style>
+</style>
+

+ 4 - 3
src/components/basic/Cell.vue

@@ -7,6 +7,7 @@
       ...viewStyle,
       ...style,
     }"
+    :setCursor="false"
     :flex="1" 
     :align="center ? 'center' : 'flex-start'"
     justify="space-between"
@@ -36,10 +37,10 @@
           </FlexRow>
           <FlexCol :innerStyle="leftViewStyle">
             <slot name="title">
-              <Text v-if="title" :innerStyle="{ ...titleStyle, ...textStyle} ">{{ title }}</Text>
+              <Text v-if="title" :innerStyle="{ ...titleStyle, ...textStyle} " :text="title" />
             </slot>
             <slot name="label">
-              <Text v-if="label" :innerStyle="{ ...labelStyle, ...textStyle} ">{{ label }}</Text>
+              <Text v-if="label" :innerStyle="{ ...labelStyle, ...textStyle} " :text="label" />
             </slot>
           </FlexCol>
         </slot>
@@ -304,7 +305,7 @@ const style = computed(() => {
   }
 
   //内边距样式的强制设置
-  configPadding(styleObj, theme.theme, props.padding);
+  configPadding(styleObj, theme.theme.value, props.padding);
 
   //边框设置
   if (props.topBorder)

+ 28 - 11
src/components/basic/Icon.vue

@@ -38,11 +38,12 @@
   />
   <!-- #endif -->
 
-  <image
-    v-else-if="iconData.type == 'image'"
-    :style="style"
-    :class="innerClass"
+  <WrappedImage
+    v-else-if="iconData.type == 'image' && iconData.value"
+    :innerStyle="style"
+    :innerClass="innerClass"
     :src="iconData.value"
+    mode="aspectFill"
   />
 </template>
 
@@ -50,6 +51,7 @@
 import { computed } from 'vue';
 import { IconUtils, type IconItem } from './IconUtils';
 import { useTheme } from '../theme/ThemeDefine';
+import WrappedImage from './Image.vue';
 
 export interface IconProps {
   /**
@@ -57,6 +59,10 @@ export interface IconProps {
    */
   icon?: string;
   /**
+   * 同 icon 属性
+   */
+  name?: string;
+  /**
    * 图标大小
    */
   size?: number|string;
@@ -65,6 +71,10 @@ export interface IconProps {
    */
   color?: string;
   /**
+   * 图标旋转角度
+   */
+  rotate?: number;
+  /**
    * 自定义样式
    */
   innerStyle?: object,
@@ -78,18 +88,24 @@ const theme = useTheme();
 const props = withDefaults(defineProps<IconProps>(), {
   size: 45,
 });
+const icon = computed(() => props.icon || props.name);
 const iconData = computed(() => {
-  const data = props.icon ? IconUtils.getIconDataFromMap(props.icon) : undefined;
-  if (!data && props.icon && props.icon.startsWith('icon-')) {
+  const data = icon.value ? IconUtils.getIconDataFromMap(icon.value) : undefined;
+  if (!data && icon.value && icon.value.startsWith('icon-')) {
     return {
       type: 'iconfont',
-      value: props.icon,
+      value: icon.value,
       fontFamily: 'iconfont',
     } as IconItem
-  } else if (!data) {
+  } else if (!data && icon.value) {
     return {
       type: 'image',
-      value: props.icon,
+      value: icon.value,
+    } as IconItem
+  } else if (!data) {
+    return {
+      type: 'none',
+      value: '',
     } as IconItem
   }
   return data;
@@ -104,6 +120,8 @@ const style = computed(() => {
     height: size,
     color: theme.resolveThemeColor(props.color, 'text.content'),
     fill: theme.resolveThemeColor(props.color, 'text.content'),
+    transform: props.rotate ? `rotate(${props.rotate}deg)` : undefined,
+    overflow: 'visible',
     ...props.innerStyle,
   };
 });
@@ -112,6 +130,5 @@ defineOptions({
   options: {
     virtualHost: true,
   }
-})
-
+});
 </script>

+ 1 - 1
src/components/basic/IconUtils.ts

@@ -1,7 +1,7 @@
 import DefaultIcons from "../data/DefaultIcon.json";
 
 export type IconItem = {
-  type: 'iconfont'|'image'|'svg',
+  type: 'iconfont'|'image'|'svg'|'none',
   value: string,
   rawSvg?: string,
   fontFamily?: string,

+ 33 - 10
src/components/basic/Image.vue

@@ -1,5 +1,6 @@
 <template>
   <view 
+    :id="id"
     class="nana-image-wrapper"
     :style="style"
     :class="innerClass"
@@ -21,9 +22,9 @@
       @load="isLoadState = false"
       @error="isErrorState = true; isLoadState = false"
     />
-    <view v-if="showFailed && isErrorState" class="inner-view error">
-      <!-- todo: failed -->
-      <Text color="second" :text="src ? '暂无图片' : '加载失败'" />
+    <view v-if="showFailed && isErrorState && !failedImage" class="inner-view error">
+      <Icon icon="warning" color="text.second" :size="32" />
+      <Text v-if="realWidth > 50" color="text.second" :text="src ? '加载失败' : '暂无图片'" :fontSize="22" />
     </view>
     <view v-if="showLoading && isLoadState" class="inner-view loading">
       <ActivityIndicator
@@ -31,14 +32,17 @@
         :size="themeContext.resolveThemeSize(loadingSize)"
       />
     </view>
+    <slot />
   </view>
 </template>
 
 <script setup lang="ts">
-import { computed, onMounted, ref, watch } from 'vue';
+import { computed, getCurrentInstance, nextTick, onMounted, ref, watch } from 'vue';
 import { propGetThemeVar, useTheme } from '../theme/ThemeDefine';
 import ActivityIndicator from './ActivityIndicator.vue';
 import Text from './Text.vue';
+import Icon from './Icon.vue';
+import { RandomUtils } from '@imengyu/imengyu-utils';
 
 export interface ImageProps {
   /**
@@ -102,6 +106,8 @@ export interface ImageProps {
   innerClass?: string,
 }
 
+const id = 'img' + RandomUtils.genNonDuplicateID(20);
+
 defineOptions({
   options: {
     virtualHost: true
@@ -109,10 +115,10 @@ defineOptions({
 })
 const props = withDefaults(defineProps<ImageProps>(), {
   src: '',
-  failedImage: '',
-  defaultImage: '',
-  showLoading: true,
-  showFailed: true,
+  failedImage: () => propGetThemeVar('ImageFailedImage', ''),
+  defaultImage: () => propGetThemeVar('ImageDefaultImage', ''),
+  showLoading: () => propGetThemeVar('ImageShowLoading', true),
+  showFailed: () => propGetThemeVar('ImageShowFailed', true),
   showGrey: () => propGetThemeVar('ImageShowGrey', false),
   loading: false,
   loadingColor: () => propGetThemeVar('ImageLoadingColor', 'border.default'),
@@ -126,6 +132,7 @@ const emit = defineEmits([ 'click' ]);
 const isErrorState = ref(false);
 const isLoadState = ref(true);
 const themeContext = useTheme();
+const instance = getCurrentInstance();
 
 const style = computed(() => {
   const o : Record<string, any> = {
@@ -138,6 +145,7 @@ const style = computed(() => {
   }
   return o;
 });
+const realWidth = ref(0);
 
 function handleClick() {
   if (props.clickPreview) {
@@ -149,7 +157,7 @@ function handleClick() {
     emit('click');
 }
 function loadSrcState() {
-  if (props.src) {
+  if (props.src || props.defaultImage) {
     isErrorState.value = false;
     isLoadState.value = true;
   } else {
@@ -157,17 +165,32 @@ function loadSrcState() {
     isLoadState.value = false;
   }
 }
+function measureImage() {
+  uni.createSelectorQuery()
+    .in(instance)
+    .select('#' + id)
+    .boundingClientRect((rect) => {
+      if (rect)
+        realWidth.value = (rect as UniApp.NodeInfo).width || 0;
+    }).exec();
+}
 
 watch(() => props.src, (newVal, oldVal) => {
-  if (newVal) {
+  if (!newVal && !props.defaultImage) {
     isErrorState.value = true;
     isLoadState.value = false;
   } else
     isErrorState.value = false;
+  nextTick(() => {
+    measureImage();
+  });
 })
 
 onMounted(() => {
   loadSrcState();
+  nextTick(() => {
+    measureImage();
+  })
 })
 </script>
 

+ 8 - 0
src/components/basic/Text.vue

@@ -76,6 +76,11 @@ export interface TextProps {
    */
   selectable?: boolean,
   /**
+   * 是否允许换行
+   * @default true
+   */
+  wrap?: boolean,
+  /**
    * 行数限制
    */
   lines?: number,
@@ -136,6 +141,7 @@ const props = withDefaults(defineProps<TextProps>(), {
   lineThrough: false,
   shadow: false,
   touchable: false,
+  wrap: true,
 });
 const emit = defineEmits([ 'click' ])
 
@@ -186,6 +192,8 @@ const style = computed(() => {
     for(const k in fontStyle)
       o.fontStyle = k;
   }
+  if (!props.wrap) o.whiteSpace = 'nowrap';
+
   if (fontFamily) o.fontFamily = fontFamily;
   if (props.textAlign) {
     o.textAlign = props.textAlign;

+ 17 - 8
src/components/composeabe/ChildItem.ts

@@ -1,20 +1,20 @@
-import { computed, onMounted, onUpdated, ref } from "vue";
+import { computed, nextTick, onMounted, onUpdated, ref } from "vue";
 
-export function useChildLinkParent(options: {
-  getPositionExtra?: (index: number) => any,
+export function useChildLinkParent<T extends Record<string, any> = {}>(options: {
+  getPositionExtra?: (index: number) => T,
   onClean?: () => void,
+  onLengthChanged?: () => void,
 }) {
 
   let currentIndex = 0;
   let length = 0;
 
-
-  function getPosition() : Record<string, any> {
+  function getPosition() : { index: number } & Partial<T> {
     const index = currentIndex++;
     length++;
     return {
       index,
-      ...options.getPositionExtra?.(index)
+      ...(options.getPositionExtra?.(index) || {}) as T
     }
   }
   function resetCounter() {
@@ -24,10 +24,16 @@ export function useChildLinkParent(options: {
   onMounted(() => {
     resetCounter();
     options.onClean?.();
+    nextTick(() => {
+      options.onLengthChanged?.();
+    })
   });
   onUpdated(() => {
     resetCounter();
     options.onClean?.();
+    nextTick(() => {
+      options.onLengthChanged?.();
+    })
   });
 
   return {
@@ -37,9 +43,12 @@ export function useChildLinkParent(options: {
   }
 }
 
-export function useChildLinkChild(getPosition: () => any) {
+export function useChildLinkChild(getPosition?: () => {
+  index: number;
+  [key: string]: unknown;
+}|undefined) {
 
-  const position = computed(() => getPosition());
+  const position = computed(() => getPosition?.()?.index ?? -1 );
 
   onMounted(() => {
     position.value;

File diff suppressed because it is too large
+ 6 - 1
src/components/data/DefaultIcon.json


+ 2 - 6
src/components/demo/DemoBlock.vue

@@ -1,4 +1,5 @@
 <script setup lang="ts">
+import Text from '../basic/Text.vue';
 import DemoTitle from './DemoTitle.vue';
 
 defineProps({	
@@ -13,7 +14,7 @@ defineProps({
   <view :class="['nana-demo-block',flat?'flat':'']">
     <view class="header">
       <DemoTitle v-if="title" :title="title" :desc="desc" :small="smallTitle" />
-      <text class="sub-title" v-if="desc">{{ desc }}</text>
+      <Text v-if="desc" color="text.second" :text="desc" />
     </view>
     <slot />
   </view>
@@ -38,9 +39,4 @@ defineProps({
 .nana-demo-block .header {
   margin: 0 0 20rpx 0;
 }
-.nana-demo-block .sub-title {
-  font-size: 28rpx;
-  color: #888;
-  margin-bottom: 20rpx;
-}
 </style>

+ 29 - 36
src/components/demo/DemoPage.vue

@@ -1,56 +1,49 @@
 <script setup lang="ts">
+import Text from '../basic/Text.vue';
 import CommonRoot from '../dialog/CommonRoot.vue';
+import FlexCol from '../layout/FlexCol.vue';
+import StatusBarSpace from '../layout/space/StatusBarSpace.vue';
+import NavBar from '../nav/NavBar.vue';
 
-const props = defineProps({	
-  title: String,
-  desc: String
-})
+const props = withDefaults(defineProps<{	
+  title?: string,
+  desc?: string,
+  isHome?: boolean,
+  customNavBar?: boolean,
+}>(), {
+  customNavBar: true,
+});
 
-if (props.title) {
-  uni.setNavigationBarTitle({
-    title: props.title,
-  })
-}
 </script>
 
 <template>
   <CommonRoot>
-    <view class="nana-demo-page">
-      <view class="header">
-        <text class="title">{{ title }}</text>
-        <text v-if="desc" class="desc">{{ desc }}</text>
+    <FlexCol innerClass="nana-demo-page" backgroundColor="background.page">
+      <StatusBarSpace v-if="customNavBar" />
+      <NavBar v-if="customNavBar" :title="title" leftButton="back" :showLeftButton="!isHome" />
+      <view v-if="title || desc" class="header">
+        <Text color="primary" fontConfig="h3" :text="title" />
+        <Text v-if="desc" color="text.second" :text="desc" />
       </view>
       <slot />
-    </view>
+    </FlexCol>
   </CommonRoot>
 </template>
 
-<style>
+<style lang="scss">
 .nana-demo-page {
-  display: flex;
-  flex-direction: column;
-  background-color: #efefef;
   /* #ifdef H5 */
-  min-height: calc(100vh - 44px - env(safe-area-inset-top));
+  min-height: calc(100vh - env(safe-area-inset-top));
   /* #endif */
   /* #ifndef H5 */
-  min-height: calc(100vh - 44px);
+  min-height: calc(100vh);
   /* #endif */
-}
-.nana-demo-page > .header {
-  display: flex;
-  flex-direction: column;
-  margin: 40rpx 40rpx;
-  flex-shrink: 0;
-}
-.nana-demo-page > .header .title {
-  font-size: 45rpx;
-  font-weight: bold;
-  color: #0079db;
-}
-.nana-demo-page > .header .desc {
-  font-size: 28rpx;
-  color: #888;
-  margin-top: 15rpx;
+
+  & > .header {
+    display: flex;
+    flex-direction: column;
+    margin: 40rpx 40rpx;
+    flex-shrink: 0;
+  }
 }
 </style>

+ 3 - 1
src/components/demo/DemoTitle.vue

@@ -3,11 +3,13 @@
     <view v-if="!small" class="line">
       <view class="line2"></view>
     </view>
-    <text class="title">{{ title }}</text>
+    <Text :fontConfig="small ? 'subText' : 'h5'"  :text="title" />
   </view>
 </template>
 
 <script setup lang="ts">
+import Text from '../basic/Text.vue';
+
 defineProps({	
   title: String	,
   small: Boolean,

+ 6 - 4
src/components/dialog/ActionSheetRoot.vue

@@ -2,6 +2,8 @@
   <ActionSheet 
     v-bind="options"
     :show="show"
+    @close="options?.onClose"
+    @select="options?.onSelect"
   />
 </template>
 
@@ -14,7 +16,7 @@ export interface ActionSheetOptions extends Omit<ActionSheetProps, 'show'> {
   onClose?: () => void;
 }
 export interface ActionSheetRoot {
-  show(options: ActionSheetOptions): Promise<void>;
+  show(options: ActionSheetOptions): Promise<number|undefined>;
 }
 
 const show = ref(false);
@@ -28,16 +30,16 @@ defineExpose<ActionSheetRoot>({
     const onSelect = _options.onSelect;
     const onClose = _options.onClose;
 
-    return new Promise<void>((resolve) => {
+    return new Promise<number|undefined>((resolve) => {
       _options.onClose = () => {
         show.value = false;
         onClose?.();
-        resolve();
+        resolve(undefined);
       };
       _options.onSelect = (i: number, n: string) => {
         show.value = false;
         onSelect?.(i, n);
-        resolve();
+        resolve(i);
       };
       options.value = _options;
       show.value = true;

+ 23 - 23
src/components/dialog/CommonRoot.ts

@@ -4,46 +4,46 @@ import type { DialogAlertOptions } from "./DialogRoot.vue";
 import type { ToastShowProps } from "../feedback/Toast.vue";
 import type { ActionSheetOptions } from "./ActionSheetRoot.vue";
 
-let currentRoot : ICommonRoot|null = null;
+const currentRootStack : ICommonRoot[] = [];
 
 export function setCurrentRoot(root : ICommonRoot) {
-  currentRoot = root;
+  currentRootStack.push(root);
+}
+export function destroyCurrentRoot(root : ICommonRoot) {
+  const index = currentRootStack.indexOf(root);
+  if (index !== -1)
+    currentRootStack.splice(index, 1);
 }
 export function NaDialogRoot() : ICommonRoot {
-  if (!currentRoot)
+  if (!currentRootStack.length)
     throw new Error("No dialog root found.");
-  return currentRoot;
+  return currentRootStack[currentRootStack.length - 1];
 }
 
 export function alert(options: DialogAlertOptions) {
-  if (!currentRoot)
-    throw new Error("No dialog root found.");
-  return currentRoot.alert(options);
+  return NaDialogRoot().alert(options);
 }
 export function confirm(options: DialogAlertOptions) {
-  if (!currentRoot)
-    throw new Error("No dialog root found.");
-  return currentRoot.confirm(options);
+  return NaDialogRoot().confirm(options);
 }
-export function toast(options: ToastShowProps) {
-  if (!currentRoot)
-    throw new Error("No dialog root found.");
-  return currentRoot.toast(options);
+export function toast(options: ToastShowProps|string) {
+  return NaDialogRoot().toast(options);
 }
 export function closeToast() {
-  if (!currentRoot)
-    throw new Error("No dialog root found.");
-  return currentRoot.closeToast();
+  return NaDialogRoot().closeToast();
 }
 export function actionSheet(options: ActionSheetOptions) {
-  if (!currentRoot)
-    throw new Error("No dialog root found.");
-  return currentRoot.actionSheet(options);
+  return NaDialogRoot().actionSheet(options);
 }
 export function notify(options: ToastShowProps) {
-  if (!currentRoot)
-    throw new Error("No dialog root found.");
-  return currentRoot.notify(options);
+  return NaDialogRoot().notify(options);
+}
+
+let startZIndex = 1010;
+
+export function getCurrentZIndex() {
+  startZIndex += 3;
+  return startZIndex;
 }
 
 export default {

+ 5 - 2
src/components/dialog/CommonRoot.vue

@@ -7,8 +7,8 @@
 </template>
 
 <script setup lang="ts">
-import { onMounted, ref } from 'vue';
-import { setCurrentRoot } from './CommonRoot';
+import { onBeforeUnmount, onMounted, ref } from 'vue';
+import { destroyCurrentRoot, setCurrentRoot } from './CommonRoot';
 import Notify, { type NotifyInstance } from '../feedback/Notify.vue';
 import Toast, { type ToastInstance } from '../feedback/Toast.vue';
 import ActionSheet, { type ActionSheetRoot } from './ActionSheetRoot.vue';
@@ -41,6 +41,9 @@ onMounted(() => {
   setCurrentRoot(commonRoot);
   (uni as any).$na = commonRoot;
 });
+onBeforeUnmount(() => {
+  destroyCurrentRoot(commonRoot!);
+});
 
 defineExpose<ICommonRoot>(commonRoot);
 </script>

+ 18 - 1
src/components/dialog/Dialog.vue

@@ -6,9 +6,11 @@
     @close="onClose"
   >
     <DialogInner 
-      v-bind="$props"
+      ref="dialogInner"
+      v-bind="props"
       :onConfirm="$props.onConfirm"
       :onCancel="$props.onCancel"
+      :confirmCountDownTime="props.confirmCountDown"
       :topSlots="{
         default: Boolean($slots?.default),
         bottomContent: Boolean($slots?.bottomContent),
@@ -42,6 +44,7 @@
 </template>
 
 <script setup lang="ts">
+import { ref, watch } from 'vue';
 import DialogInner from './DialogInner.vue';
 import Popup from './Popup.vue';
 import type { PopupProps } from './Popup.vue';
@@ -106,6 +109,11 @@ export interface DialogProps extends Omit<PopupProps, 'onClose'|'position'|'rend
    */
   confirmColor?: string|undefined;
   /**
+   * 确定按扭文字倒计时,单位秒
+   * @default undefined
+   */
+  confirmCountDown?: number|undefined;
+  /**
    * 取消按扭文字的颜色
    * @default text.content
    */
@@ -144,6 +152,7 @@ export interface DialogProps extends Omit<PopupProps, 'onClose'|'position'|'rend
 }
 export type DialogConfirmProps = Omit<DialogProps, 'show'|'showCancel'|'onClose'>;
 
+const dialogInner = ref();
 const props = withDefaults(defineProps<DialogProps>(), {
   mask: true,
   showConfirm: true,
@@ -151,6 +160,14 @@ const props = withDefaults(defineProps<DialogProps>(), {
 });
 const emit = defineEmits([ 'close', 'update:show' ]);
 
+watch(() => props.show, (newVal) => {
+  if (newVal) {
+    setTimeout(() => {
+      dialogInner.value?.startConfirmCountDown();
+    }, 200);
+  }
+});
+
 function onClose() {
   emit('close');
   emit('update:show', false);

+ 7 - 1
src/components/dialog/DialogButton.vue

@@ -5,7 +5,7 @@
       ...vertical ? {} : themeStyles.dialogButtonHorz.value,
     }"
     :pressedColor="themeContext.resolveThemeColor(pressedColor)"
-    touchable
+    :touchable="touchable"
     direction="row"
     @click="loading ? undefined : $emit('click')"
   >
@@ -46,6 +46,11 @@ export interface DialogButtonProps {
   */
   loading?: boolean,
   /**
+  * 是否可点击
+  * @default true
+  */
+  touchable?: boolean|undefined,
+  /**
   * 按钮文字
   * @default false
   */
@@ -94,6 +99,7 @@ const props = withDefaults(defineProps<DialogButtonProps>(), {
   loading: false,
   vertical: false,
   buttonColor: undefined,
+  touchable: true,
   pressedColor: () => propGetThemeVar('DialogButtonPressedColor', 'pressed.white'),
 });
 

+ 27 - 5
src/components/dialog/DialogInner.vue

@@ -60,8 +60,9 @@
         v-if="showConfirm"
         key="confirm"
         :vertical="bottomVertical"
-        :text="confirmText"
+        :text="confirmCountDownValue > 0 ? `${confirmText} (${confirmCountDownValue})` : confirmText"
         :loading="buttomLoadingState.confirm"
+        :touchable="confirmCountDownValue <= 0"
         :buttonColor="confirmColor"
         @click="onConfirmClick('confirm')"
       />
@@ -70,14 +71,14 @@
 </template>
 
 <script setup lang="ts">
-import FlexView from '../layout/FlexView.vue';
-import FlexCol from '../layout/FlexCol.vue';
+import { onMounted, ref } from 'vue';
 import { propGetThemeVar, useTheme } from '../theme/ThemeDefine';
 import { DynamicColor, DynamicSize, DynamicVar } from '../theme/ThemeTools';
-import type { DialogProps } from './Dialog.vue';
+import FlexView from '../layout/FlexView.vue';
+import FlexCol from '../layout/FlexCol.vue';
 import Icon from '../basic/Icon.vue';
-import { ref } from 'vue';
 import DialogButton from './DialogButton.vue';
+import type { DialogProps } from './Dialog.vue';
 
 const themeContext = useTheme();
 const themeStyles = themeContext.useThemeStyles({
@@ -112,6 +113,7 @@ const themeStyles = themeContext.useThemeStyles({
 
 export interface DialogInnerProps extends Omit<DialogProps, 'show'> {
   topSlots?: Record<string, boolean>,
+  confirmCountDownTime?: number,
 }
 
 const emit = defineEmits([ 'close' ]);
@@ -130,6 +132,7 @@ const props = withDefaults(defineProps<DialogInnerProps>(), {
 });
 
 const buttomLoadingState = ref<Record<string, boolean>>({});
+const confirmCountDownValue = ref<number>(0);
 
 function setButtonLoadingStateByName(name: string, state: boolean) {
   buttomLoadingState.value[name] = state;
@@ -141,6 +144,17 @@ function checkAnyButtonLoading() {
   }
   return false;
 }
+function startConfirmCountDown() {
+  if (!props.confirmCountDownTime)
+    return;
+  confirmCountDownValue.value = props.confirmCountDownTime;
+  const interval = setInterval(() => {
+    confirmCountDownValue.value--;
+    if (confirmCountDownValue.value <= 0) {
+      clearInterval(interval);
+    }
+  }, 1000);
+}
   
 function onPopupClose() {
   emit('close');
@@ -182,5 +196,13 @@ function onConfirmClick(name: string) {
   } else onPopupClose();
 }
 
+onMounted(() => {
+  setTimeout(() => {
+    startConfirmCountDown();
+  }, 200);
+});
+defineExpose({
+  startConfirmCountDown,
+})
 
 </script>

+ 3 - 7
src/components/dialog/DialogRoot.vue

@@ -29,12 +29,10 @@ defineExpose<DialogAlertRoot>({
 
     const onConfirm = _options.onConfirm;
     const onCancel = _options.onCancel;
-    const onClose = _options.onClose;
-
+    
     return new Promise<boolean>((resolve) => {
-      _options.onClose = () => {
+      (_options as any).onClose = () => {
         show.value = false;
-        onClose?.();
         resolve(false);
       };
       _options.onCancel = () => {
@@ -57,12 +55,10 @@ defineExpose<DialogAlertRoot>({
     options.value = _options;
 
     const onConfirm = _options.onConfirm;
-    const onClose = _options.onClose;
 
     return new Promise<void>((resolve) => {
-      _options.onClose = () => {
+      (_options as any).onClose = () => {
         show.value = false;
-        onClose?.();
         resolve();
       };
       _options.onConfirm = () => {

+ 17 - 14
src/components/dialog/Popup.vue

@@ -34,6 +34,7 @@
       right: inset[1] ? `${themeContext.resolveThemeSize(inset[1])}` : undefined,
       bottom: inset[2] ? `${themeContext.resolveThemeSize(inset[2])}` : undefined,
       left: inset[3] ? `${themeContext.resolveThemeSize(inset[3])}` : undefined,
+      zIndex: popupZIndex,
     }"
   >
     <view 
@@ -41,6 +42,7 @@
       :style="{
         backgroundColor: mask ? themeContext.resolveThemeColor(maskColor) : '',
         transitionDuration: `${duration}ms`,
+        zIndex: popupZIndex + 1,
       }"
       @mousedown.stop="handleClose"
       @touchstart.stop="handleClose"
@@ -81,6 +83,7 @@
             minWidth: dialogSize,
           },
         }),
+        zIndex: popupZIndex + 2,
         backgroundColor: themeContext.resolveThemeColor(backgroundColor),
         margin: `${themeContext.resolveThemeSize(margin[0])} ${themeContext.resolveThemeSize(margin[1])} ${themeContext.resolveThemeSize(margin[2])} ${themeContext.resolveThemeSize(margin[3])}`,
         ...innerStyle,
@@ -121,6 +124,7 @@ import { selectStyleType } from '../theme/ThemeTools';
 import { SimpleDelay } from '@imengyu/imengyu-utils';
 import PopupTitle from './PopupTitle.vue';
 import SafeAreaPadding from '../layout/space/SafeAreaPadding.vue';
+import { getCurrentZIndex } from './CommonRoot';
 
 /**
  * Popup 的显示位置
@@ -208,6 +212,11 @@ export interface PopupProps {
    * @default '30%'
    */
   size?: string|number;
+  /**
+   * 指定弹出层的 z-index 层级,默认是 1010
+   * @default 0
+   */
+  zIndex?: number;
 }
 
 const emit = defineEmits([ 'update:show', 'close', 'closeAnimFinished' ])
@@ -219,16 +228,11 @@ const props = withDefaults(defineProps<PopupProps>(), {
   maskColor: 'background.mask',
   mask: true,
   margin: () => [0,0,0,0],
-  inset: () => {
-    const arr : (number|undefined|string)[] = [undefined,undefined,undefined,undefined]
-    // #ifdef H5
-    arr[0] = '44px';
-    // #endif
-    return arr;
-  },
+  inset: () => [undefined,undefined,undefined,undefined],
   backgroundColor: 'white',
   safeArea: true,
   duration: 230,
+  zIndex: 0,
   size: '30%',
 });
 
@@ -251,6 +255,7 @@ 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;
 
@@ -266,6 +271,7 @@ watch(() => props.show, (v) => {
     }, props.duration)
     lateStopTimer.start();
   } else {
+    popupZIndex.value = props.zIndex > 0 ? props.zIndex : getCurrentZIndex();
     if (lateStopTimer)
       lateStopTimer.stop();
     lateStopTimer = new SimpleDelay(undefined, () => {
@@ -284,7 +290,6 @@ watch(() => props.show, (v) => {
   left: 0;
   right: 0; 
   bottom: 0;
-  z-index: 110;
   display: flex;
   flex-direction: column;
   pointer-events: none;
@@ -292,12 +297,12 @@ watch(() => props.show, (v) => {
 
   &.show2 {
     pointer-events: auto;
-    .nana-popup-mask {
+    > .nana-popup-mask {
       pointer-events: auto;
     }
   }
   &.no-mask {
-    .nana-popup-mask {
+    > .nana-popup-mask {
       pointer-events: none;
     }
   }
@@ -311,14 +316,12 @@ watch(() => props.show, (v) => {
     left: 0;
     right: 0;
     bottom: 0;
-    z-index: 111;
     pointer-events: none;
     opacity: 0;
     transition: opacity ease-in-out 0.3s;
   }
   .nana-popup-content {
     position: relative;
-    z-index: 112;
     overflow: hidden;
     transition: all ease-in-out 0.3s;
     opacity: 0.3;
@@ -342,10 +345,10 @@ watch(() => props.show, (v) => {
   }
 
   &.show {
-    .nana-popup-mask {
+    > .nana-popup-mask {
       opacity: 1;
     }
-    .nana-popup-content {
+    > .nana-popup-content {
       opacity: 1;
       transform: translateX(0) translateY(0);
     }

+ 1 - 1
src/components/dialog/PopupTitle.vue

@@ -66,7 +66,7 @@ defineOptions({
       v-if="closeable === true && closeIcon"
       :size="closeIconSize || theme.resolveThemeSize('PopupCloseIconSize', 25)"
     />
-    <Text>{{ title }}</Text>
+    <Text :text="title" />
     <IconButton 
       v-if="closeable === true && closeIcon"
       :icon="(closeIcon as string) || theme.resolveThemeSize('PopupCloseIconName', 'close')!"

+ 174 - 0
src/components/display/Card.vue

@@ -0,0 +1,174 @@
+<script setup lang="ts">
+import { computed } from 'vue';
+import Icon, { type IconProps } from '../basic/Icon.vue';
+import Image, { type ImageProps } from '../basic/Image.vue';
+import Text from '../basic/Text.vue';
+import FlexCol from '../layout/FlexCol.vue';
+import FlexRow from '../layout/FlexRow.vue';
+import BackgroundBox from './block/BackgroundBox.vue';
+import Touchable from '../feedback/Touchable.vue';
+import { propGetThemeVar, useTheme } from '../theme/ThemeDefine';
+import type { FlexProps } from '../layout/FlexView.vue';
+import IconTextBlock from './block/IconTextBlock.vue';
+
+const themeContext = useTheme();
+const props = withDefaults(defineProps<{
+  /**
+   * 卡片标题
+   */
+  title?: string;
+  /**
+   * 卡片描述
+   */
+  desc?: string;
+  /**
+   * 卡片标题额外内容
+   */
+  extra?: string;
+  /**
+   * 卡片图标
+   */
+  icon?: string;
+  /**
+   * 卡片图标属性
+   */
+  iconProps?: IconProps;
+  /**
+   * 卡片圆角
+   * @default 20
+   */
+  radius?: number;
+  /**
+   * 卡片背景颜色
+   * @default 'white'
+   */
+  backgroundColor?: string;
+  /**
+   * 卡片视图属性
+   */
+  viewProps?: FlexProps;
+
+  /**
+   * 卡片内容
+   */
+  content?: string;
+
+  /**
+   * 卡片图片
+   */
+  image?: string;
+  /**
+   * 卡片图片属性
+   */
+  imageProps?: ImageProps;
+  /**
+   * 卡片图片高度
+   * @default 320
+   */
+  imageHeight?: number;
+  /**
+   * 卡片图片标题
+   */
+  imageTitle?: number;
+  /**
+   * 卡片图片描述
+   */
+  imageDesc?: string;
+  /**
+   * 卡片图片额外内容
+   */
+  imageExtra?: string;
+  /**
+   * 卡片图片信息位置
+   * * over 图片底部渐变显示
+   * * bottom 图片下方单独显示
+   * @default 'over'
+   */
+  imageInfoPosition?: 'over' | 'bottom';
+}>(), {
+  radius: () => propGetThemeVar('CardRadius', 15),
+  imageHeight: () => propGetThemeVar('CardImageDefaultHeight', 320),
+  imageInfoPosition: () => propGetThemeVar('CardImageInfoPosition', 'over'),
+  backgroundColor: () => propGetThemeVar('CardBackgroundColor', 'white'),
+});
+const emit = defineEmits([ 'click' ]);
+const padding = computed(() => themeContext.getVar('CardTitlePadding', 20));
+
+const hasImageInfo = computed(() => props.imageDesc || props.imageExtra || props.imageTitle);
+const hasTitle = computed(() => props.title || props.desc || props.extra);
+
+</script>
+
+<template>
+  <Touchable 
+    position="relative"
+    direction="column"
+    :backgroundColor="backgroundColor"
+    :radius="radius"
+    shadow="default"
+    overflow="hidden"
+    v-bind="viewProps"
+    @click="emit('click')"
+  >
+    <slot name="prefix" />
+
+    <!-- 标题区域 -->
+    <IconTextBlock
+      v-if="hasTitle" :padding="padding" :gap="20"
+      :icon="icon"
+      :iconProps="iconProps"
+      :title="title"
+      :desc="desc"
+      :extra="extra"
+    />
+    <!-- 图片区域 -->
+    <Image 
+      v-if="image" 
+      :src="image"
+      mode="aspectFill"
+      width="100%"
+      :height="imageHeight"
+      v-bind="imageProps"
+    >
+      <!-- 图片标题区域 -->
+      <BackgroundBox 
+        v-if="hasImageInfo && imageInfoPosition === 'over'"
+        direction="row"
+        align="center" 
+        justify="space-between"
+        position="absolute"
+        color2="background.mask"
+        color1="transparent"
+        :zIndex="2"
+        :bottom="0"
+        :left="0"
+        :right="0"
+        :padding="padding"
+      >
+        <FlexCol>
+          <Text fontConfig="subTitle" color="text.light" :text="imageTitle" />
+          <Text fontConfig="subText" color="text.light" :text="imageDesc" />
+        </FlexCol>
+        <Text fontConfig="subText" color="text.light" :text="imageExtra" />
+      </BackgroundBox>
+    </Image>
+    <!-- 底部区域 -->
+    <FlexCol>
+      <IconTextBlock 
+        v-if="hasImageInfo && imageInfoPosition === 'bottom'"
+        :padding="padding"
+        :title="imageTitle"
+        :desc="imageDesc"
+        :extra="imageExtra"
+      />
+      <!-- 内容区域 -->
+      <FlexCol v-if="content || $slots.default" :padding="padding">
+        <slot>
+          <Text fontConfig="content" :text="content" />
+        </slot>
+      </FlexCol>
+    </FlexCol>
+
+    <slot name="suffix" />
+  </Touchable>
+</template>

+ 9 - 3
src/components/display/CollapseBox.vue

@@ -4,8 +4,8 @@
     class="nana-collapse-box" 
     :style="{
       display: realOpenState ? '' : 'none',
-      height: animDuration > 0 && targetHeight >= 0 ? `${targetHeight}px` : undefined,
-      transition: animDuration > 0 ? `height ${animDuration}ms ease-in-out` : undefined,
+      height: anim && animDuration > 0 && targetHeight >= 0 ? `${targetHeight}px` : undefined,
+      transition: anim && animDuration > 0 ? `height ${animDuration}ms ease-in-out` : undefined,
     }"
   >
     <slot />
@@ -22,6 +22,11 @@ export interface CollapseBoxProps {
    */
   open: boolean;
   /**
+   * 是否开启动画
+   * @default true
+   */
+  anim?: boolean;
+  /**
    * 动画时长(ms),为0时禁用动画
    * @default 300
    */
@@ -35,6 +40,7 @@ export interface CollapseBoxProps {
 const id = computed(() => `nana-collapse-box-${props.name}-${RandomUtils.genNonDuplicateIDHEX(16)}`);
 const props = withDefaults(defineProps<CollapseBoxProps>(), {
   animDuration: 300,
+  anim: true,
 });
 
 const realOpenState = ref(false);
@@ -43,7 +49,7 @@ const instance = getCurrentInstance();
 let isAnimWorking = false;
 
 watch(() => props.open, (newVal) => {
-  if (props.animDuration <= 0) {
+  if (props.animDuration <= 0 || !props.anim) {
     realOpenState.value = newVal;
     return;
   }

+ 6 - 15
src/components/display/CollapseItem.vue

@@ -40,19 +40,20 @@
         <template #rightIcon>
           <Icon 
             icon="arrow-down" 
-            :class="['nana-collapse-item-icon', {'open': state}]"
+            :rotate="state ? 180 : 0"
+            :innerStyle="{ transition: 'transform 0.3s ease-in-out' }"
           />
         </template>
       </Cell>
     </slot>
-    <CollapseBox :open="state" :anim-duration="context.animDuration.value" :name="name || position.value">
+    <CollapseBox :open="state" :anim-duration="context.animDuration.value" :name="'' + (name || position)">
       <slot />
     </CollapseBox>
   </FlexView>
 </template>
 
 <script setup lang="ts">
-import { computed, inject, provide, toRef, type Ref } from 'vue';
+import { computed, inject } from 'vue';
 import FlexView from '../layout/FlexView.vue';
 import type { CollapseContext } from './Collapse.vue';
 import Cell from '../basic/Cell.vue';
@@ -104,15 +105,5 @@ const context = inject<CollapseContext>('CollapseContext')!;
 const id = computed(() => props.name || position.value);
 const state = computed(() => context.activeName.value.includes(id.value));
 
-const { position } = useChildLinkChild(() => context.getPosition(props.name));
-</script>
-
-<style lang="scss">
-.nana-collapse-item-icon {
-  transition: transform 0.3s ease-in-out;
-
-  &.open {
-    transform: rotate(180deg);
-  }
-}
-</style>
+const { position } = useChildLinkChild(() => ({ index: context.getPosition(props.name) }));
+</script>

+ 165 - 0
src/components/display/Preview.vue

@@ -0,0 +1,165 @@
+<template>
+  <FlexCol :backgroundColor="backgroundColor">
+    <!-- 标题区域 -->
+    <slot name="title">
+      <IconTextBlock
+        :padding="padding"
+        :title="title"
+        :titleProps="titleProps"
+        :desc="desc"
+        :extra="extra"
+        :extraProps="extraProps"
+      />
+    </slot>
+    <Divider color="border.light" :size="2" />
+    <!-- 项目区域 -->
+    <slot name="items">
+      <FlexCol :padding="padding">
+        <PreviewItem
+          v-for="(item,k) in items"
+          :key="k"
+          :title="item.title"
+          :value="item.value"
+          :titleProps="titleProps"
+          :titleWidth="titleWidth"
+          :titleColor="titleColor"
+          :valueProps="valueProps"
+          :valueColor="valueColor"
+          :valueType="item.valueType"
+          :gap="itemGap"
+          :margin="itemVerticalPadding"
+        />
+      </FlexCol>
+    </slot>
+    <Divider color="border.light" :size="2" />
+    <!-- 操作区域 -->
+    <slot name="actions">
+      <FlexRow align="stretch">
+        <Button
+          v-for="(item,k) in actions"
+          :key="k"
+          :text="item.text"
+          :textColor="item.color"
+          :innerStyle="{ flex: 1, minHeight: '60rpx' }"
+          :radius="0"
+          @click="item.onClick?.(item)"
+          type="text"
+          v-bind="item.props"
+        />
+      </FlexRow>
+    </slot>
+  </FlexCol>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import { propGetThemeVar, useTheme } from '../theme/ThemeDefine';
+import FlexCol from '../layout/FlexCol.vue';
+import IconTextBlock from './block/IconTextBlock.vue';
+import PreviewItem, { type PreviewItemType } from './PreviewItem.vue';
+import type { TextProps } from '../basic/Text.vue';
+import Divider from './Divider.vue';
+import FlexRow from '../layout/FlexRow.vue';
+import type { ButtonProp } from '../basic/Button.vue';
+import Button from '../basic/Button.vue';
+
+const props = withDefaults(defineProps<{
+  /**
+   * 大标题
+   */
+  title?: string,
+  /**
+   * 副标题
+   */
+  desc?: string,
+  /**
+   * 大标题右侧额外信息
+   */
+  extra?: string,
+  /**
+   * 大标题右侧额外信息文字属性
+   */
+  extraProps?: TextProps,
+  /**
+   * 卡片背景颜色
+   */
+  backgroundColor?: string,
+  /**
+   * 项目列表
+   */
+  items?: {
+    /**
+     * 项目标题
+     */
+    title: string,
+    /**
+     * 项目值
+     */
+    value: string | string[],
+    /**
+     * 项目值类型
+     * * text:普通文本
+     * * image:图片
+     */
+    valueType?: PreviewItemType,
+  }[],
+  /**
+   * 操作按钮列表
+   */
+  actions?: {
+    /**
+     * 操作按钮文字
+     */
+    text: string,
+    /**
+     * 操作按钮文字颜色
+     */
+    color?: string,
+    /**
+     * 操作按钮文字属性
+     */
+    props?: ButtonProp,
+    /**
+     * 操作按钮点击事件
+     */
+    onClick?: (item: { text: string, color?: string, props?: ButtonProp }) => void,
+  }[],
+  /**
+   * 项目标题文字属性
+   */
+  titleProps?: TextProps,
+  /**
+   * 项目标题文字颜色
+   * @default text.second
+   */
+  titleColor?: string,
+  /**
+   * 项目标题宽度
+   * @default 300
+   */
+  titleWidth?: string|number,
+  /**
+   * 项目值文字颜色
+   * @default text.content
+   */
+  valueColor?: string,
+  /**
+   * 项目值文字属性
+   */
+  valueProps?: TextProps,
+
+}>(), {
+  backgroundColor: () => propGetThemeVar('PreviewBackgroundColor', 'white'),
+  extraProps: () => propGetThemeVar('PreviewExtraTextProps', {}),
+  titleColor: () => propGetThemeVar('PreviewTitleColor', 'text.second'),
+  valueColor: () => propGetThemeVar('PreviewValueColor', 'text.content'),
+  titleWidth: () => propGetThemeVar('PreviewItemTitleWidth', 300),
+  titleProps: () => propGetThemeVar('PreviewItemTitleTextProps', {}),
+  valueProps: () => propGetThemeVar('PreviewItemValueTextProps', {}),
+});
+const theme = useTheme();
+const padding = computed(() => theme.getVar('PreviewPadding', 30));
+const itemVerticalPadding = computed(() => theme.getVar('PreviewItemVerticalPadding', 10));
+const itemGap = computed(() => theme.getVar('PreviewItemGap', 20));
+
+</script>

+ 72 - 0
src/components/display/PreviewItem.vue

@@ -0,0 +1,72 @@
+<template>
+  <FlexRow justify="space-between" align="flex-start" :gap="gap" :padding="[margin??0,0]">
+    <FlexRow :width="titleWidth">
+      <Text v-bind="titleProps" :color="titleColor" :text="title" />
+    </FlexRow>
+    <template v-if="valueType === 'text'">
+      <FlexCol v-if="Array.isArray(value)">
+        <Text 
+          v-for="(item,k) in value"
+          :key="k" 
+          v-bind="valueProps"
+          :color="valueColor"
+          :text="item"
+        />
+      </FlexCol>
+      <Text v-else v-bind="valueProps" :color="valueColor" :text="value || emptyText" />
+    </template>
+    
+    <template v-else-if="valueType === 'image'">
+      <FlexRow v-if="Array.isArray(value)" wrap>
+        <Image 
+          v-for="(item,k) in value" 
+          :key="k" 
+          :width="100" 
+          :height="100"
+          :radius="12"
+          round
+          v-bind="(valueProps as ImageProps)" 
+          :src="item"
+          clickPreview
+        />
+      </FlexRow>
+      <Image 
+        v-else
+        :width="100"
+        :height="100"
+        :radius="12"
+        round
+        v-bind="(valueProps as ImageProps)"
+        :src="value"
+        clickPreview
+      />
+    </template>
+  </FlexRow>
+</template>
+
+<script setup lang="ts">
+import FlexRow from '../layout/FlexRow.vue';
+import Text,{ type TextProps } from '../basic/Text.vue';
+import FlexCol from '../layout/FlexCol.vue';
+import Image, { type ImageProps } from '../basic/Image.vue';
+
+export type PreviewItemType = 'text' | 'image';
+
+const props = withDefaults(defineProps<{
+  title?: string,
+  titleColor?: string,
+  titleWidth?: string|number,
+  titleProps?: TextProps,
+  value?: string | string[],
+  valueColor?: string,
+  valueType?: PreviewItemType,
+  valueProps?: TextProps|ImageProps,
+  emptyText?: string,
+  gap?: number,
+  margin?: number,
+}>(), {
+  emptyText: '空',
+  valueType: 'text',
+});
+
+</script>

+ 40 - 55
src/components/display/block/BackgroundBox.vue

@@ -1,9 +1,9 @@
 <template>
   <!-- 组件:背景图显示盒子 -->
   <FlexView
+    v-bind="$props"
     center
     :flexShrink="0"
-    v-bind="$props"
     :innerStyle="style" 
   >
     <slot />
@@ -38,64 +38,39 @@ export default {}
 </script>
 
 <script setup lang="ts">
-import FlexView from '@/components/layout/FlexView.vue';
+import FlexView, { type FlexProps } from '@/components/layout/FlexView.vue';
 import { useTheme, type ViewStyle } from '@/components/theme/ThemeDefine';
 import { solveUrl } from '@/components/theme/ThemeTools';
 import { computed, type PropType } from 'vue';
 
-defineOptions({
-  options: {
-    virtualHost: true,
-    styleIsolation: "shared",
-  },
-});
-
-/**
- * 内容积木组件:背景盒子,
- */
-const props = defineProps({
+export interface BackgroundBoxProps extends FlexProps {
   /**
    * 背景颜色(1)。
    * 
-   * 格式:字符串格式或主题中定义的 background 颜色预设。
+   * 格式:字符串格式或主题中定义的颜色预设。
    */
-  color1: {
-    type: String,
-    default: undefined
-  },
+  color1?: string;
   /**
    * 背景颜色(2)。
    * 
-   * 格式:字符串格式或主题中定义的 background 颜色预设。
+   * 格式:字符串格式或主题中定义的颜色预设。
    */
-  color2: {
-    type: String,
-    default: undefined
-  },
+  color2?: string;
   /**
    * 圆角。
    */
-  radius: {
-    type: [String, Number],
-    default: undefined
-  },
+  radius?: string | number;
   /**
    * 背景渐变角度。
    * 只有 color1 和 color2 都定义时有效。
    *
    * 格式:角度(0-360)。
    */
-  gradientAngle: {
-    type: Number,
-    default: undefined
-  },
+  gradientAngle?: number;
   /**
    * 背景图片。
    */
-  backgroundImage: {
-    type: String,
-    default: undefined
-  },
+  backgroundImage?: string;
   /**
    * 背景填充方式。
    *
@@ -104,50 +79,60 @@ const props = defineProps({
    * - fillH:纵向填充,宽度变化。
    * - none:不填充。
    */
-  backgroundFillType: {
-    type: String as PropType<'none'|'fillH'|'fillW'>,
-    default: "fillW"
-  },
+  backgroundFillType?: 'none'|'fillH'|'fillW';
   /**
    * 背景填充大小。
    */
-  backgroundSize: {
-    type: String,
-    default: "100%"
-  },
+  backgroundSize?: string;
   /**
    * 背景填充位置。
    */
-  backgroundPosition: {
-    type: String,
-    default: undefined
-  },
+  backgroundPosition?: string;
   /**
    * 背景图片九宫格裁剪大小。
    *
    * 格式:
    * - 数组:[ top, right, bottom, left ]
    */
-  backgroundCutBorder: {
-    type: Object as PropType<Array<number|string>>,
-    default: undefined
-  },
+  backgroundCutBorder?: Array<number|string>;
   /**
    * 背景图片九宫格渲染大小。
    *
    * 格式:
    * - 数组:[ top, right, bottom, left ]
    */
-  backgroundCutBorderSize: {
-    type: Object as PropType<Array<number|string>>,
-    default: () => ([ 'auto' ])
+  backgroundCutBorderSize?: Array<number|string>;
+}
+
+defineOptions({
+  options: {
+    virtualHost: true,
+    styleIsolation: "shared",
   },
 });
 
+/**
+ * 内容积木组件:背景盒子,
+ */
+const props = withDefaults(defineProps<BackgroundBoxProps>(), {
+  color1: undefined,
+  color2: undefined,
+  radius: undefined,
+  gradientAngle: undefined,
+  backgroundImage: undefined,
+  backgroundFillType: "fillW",
+  backgroundSize: "100%",
+  backgroundPosition: undefined,
+  backgroundCutBorder: undefined,
+  backgroundCutBorderSize: () => ([ 'auto' ]),
+});
+
 const theme = useTheme();
 
 const style = computed(() => {
-  const o : ViewStyle = {}
+  const o : ViewStyle = {
+    ...props.innerStyle,
+  }
   if (props.radius) {
     o.borderRadius = theme.resolveThemeSize(props.radius);
   }

+ 61 - 13
src/components/display/block/IconTextBlock.vue

@@ -2,9 +2,11 @@
 import Icon from '@/components/basic/Icon.vue';
 import Text from '@/components/basic/Text.vue';
 import FlexRow from '@/components/layout/FlexRow.vue';
+import FlexCol from '@/components/layout/FlexCol.vue';
 import type { IconProps } from '@/components/basic/Icon.vue';
 import type { TextProps } from '@/components/basic/Text.vue';
 import type { PropType } from 'vue';
+import type { FlexProps } from '@/components/layout/FlexView.vue';
 
 defineOptions({
   options: {
@@ -13,38 +15,84 @@ defineOptions({
   },
 });
 defineProps({
+  /**
+   * 图标名称
+   */
   icon: {
     type: String,
     default: '',
   },
+  /**
+   * 图标属性
+   */
   iconProps: {
     type: Object as PropType<IconProps>,
     default: () => ({
       color: 'text.content',
-      size: 30,
+      size: 60,
     }),
   },
-  text: {
+  /**
+   * 标题
+   */
+  title: {
     type: [String,Number],
     default: '',
   },
-  textLines: {
-    type: Number,
-    default: 1,
+  /**
+   * 描述
+   */
+  desc: {
+    type: [String,Number],
+    default: '',
+  },
+  /**
+   * 额外信息
+   */
+  extra: {
+    type: [String,Number],
+    default: '',
   },
-  textProps: {
+  /**
+   * 标题文字属性
+   */
+  titleProps: {
     type: Object as PropType<TextProps>,
-    default: () => ({
-      color: 'text.content',
-      fontSize: '30rpx',
-    }),
+  },
+  /**
+   * 描述文字属性
+   */
+  descProps: {
+    type: Object as PropType<TextProps>,
+  },
+  /**
+   * 额外信息文字属性
+   */ 
+  extraProps: {
+    type: Object as PropType<TextProps>,
+  },  
+  /**
+   * 卡片属性
+   */
+  viewProps: {
+    type: Object as PropType<FlexProps>,
   },
 })
 </script>
 
 <template>
-  <FlexRow gap="10" align="center">
-    <Icon v-bind="iconProps" :icon="icon"  />
-    <Text :lines="textLines" v-bind="textProps" :text="text" />
+  <FlexRow :gap="20" align="center" justify="space-between" v-bind="viewProps">
+    <FlexRow :gap="20" align="center" justify="space-between">
+      <Icon v-if="icon" :name="icon" v-bind="iconProps" />
+      <FlexCol>
+        <Text fontConfig="subTitle" :text="title" v-bind="titleProps" />
+        <Text fontConfig="subText" :text="desc" v-bind="descProps" />
+      </FlexCol>
+    </FlexRow>
+    <slot name="extra">
+      <FlexRow :flexShrink="0">
+        <Text fontConfig="subText" :text="extra" v-bind="extraProps" />
+      </FlexRow>
+    </slot>
   </FlexRow>
 </template>

+ 50 - 28
src/components/display/block/ImageBlock.vue

@@ -10,30 +10,32 @@
     :height="height"
     @click="$emit('click')"
   >
-    <image 
+    <Image 
       :src="src"
       mode="aspectFill"
-      style="width:100%;height:100%;"
-    />
-    <image 
-      v-if="videoMark" 
-      :src="VideoMark" 
-      :width="60"
-      :height="60"
-      class="nana-image-block-video-mark"
-    />
-    <BackgroundBox
-      color1="background.mask"
-      position="absolute"
-      :left="0"
-      :right="0"
-      :bottom="0"
-      :padding="[10,15]"
+      width="100%"
+      height="100%"
     >
-      <slot name="desc">
-        <text class="nana-image-desc">{{ desc }}</text>
-      </slot>
-    </BackgroundBox>
+      <Image 
+        v-if="videoMark" 
+        :src="videoMarkImage" 
+        :width="videoMarkSize"
+        :height="videoMarkSize"
+        innerClass="nana-image-block-video-mark"
+      />
+    </Image>
+    <slot name="desc">
+      <BackgroundBox
+        color1="background.mask"
+        position="absolute"
+        :left="0"
+        :right="0"
+        :bottom="0"
+        :padding="[10,15]"
+      >
+        <Text class="nana-image-desc" color="text.second" v-bind="descProps" :text="desc" />
+      </BackgroundBox>
+    </slot>
   </Touchable>
 </template>
 
@@ -51,8 +53,11 @@ export default {}
 <script setup lang="ts">
 import { useTheme } from '@/components/theme/ThemeDefine';
 import BackgroundBox from './BackgroundBox.vue';
-import VideoMark from '/static/images/VideoMark.png';
+import VideoMark from '../../images/icons/video-mark.png';
 import Touchable from '@/components/feedback/Touchable.vue';
+import Image from '@/components/basic/Image.vue';
+import Text, { type TextProps } from '@/components/basic/Text.vue';
+import type { PropType } from 'vue';
 
 const theme = useTheme();
 
@@ -99,12 +104,33 @@ defineProps({
     default: null
   },
   /**
+   * 图片下方显示描述的文字属性。
+   */
+  descProps: {
+    type: Object as PropType<TextProps>,
+    default: () => ({})
+  },
+  /**
    * 是否显示播放视频标记。
    */
   videoMark: {
     type: Boolean,
     default: false
   },
+  /**
+   * 播放视频标记的图片路径。
+   */
+  videoMarkImage: {
+    type: String,
+    default: VideoMark
+  },
+  /**
+   * 播放视频标记的大小。
+   */
+  videoMarkSize: {
+    type: [ String, Number ],
+    default: 80
+  }
 })
 
 defineEmits([	
@@ -114,19 +140,15 @@ defineEmits([
 
 <style lang="scss">
 .nana-image-desc {
-  color: #fff;
-  font-size: 25rpx;
   white-space: nowrap;
   overflow: hidden;
   text-overflow: ellipsis;
 }
 .nana-image-block-video-mark {
-  width: 44rpx;
-  height: 44rpx;
   position: absolute;
   left: 50%;
   top: 40%;
-  margin-left: -22rpx;
-  margin-top: -22rpx;
+  z-index: 100;
+  transform: translate(-50%, -50%);
 }
 </style>

+ 37 - 29
src/components/display/block/ImageBlock2.vue

@@ -1,11 +1,9 @@
 <template>
   <Touchable
     touchable
-    backgroundColor="white"
-    overflow="hidden"
-    v-bind="$props"
+    v-bind="props"
     :flexShrink="0"
-    :innerStyle="{ borderRadius: theme.resolveThemeSize(radius), overflow: 'hidden', }"
+    :innerStyle="{ borderRadius: theme.resolveThemeSize(imageRadius), overflow: 'hidden', }"
     :width="width"
     @click="$emit('click')"
   >
@@ -13,10 +11,17 @@
       :src="src" 
       width="100%"
       :height="imageHeight"
+      :radius="imageRadius"
       mode="aspectFill"
     />
     <slot name="desc">
-      <Text class="nana-image-desc">{{ desc }}</Text>
+      <FlexCol :padding="15">
+        <IconTextBlock
+          :title="title"
+          :desc="desc"
+          :extra="extra"
+        />
+      </FlexCol>
     </slot>
   </Touchable>
 </template>
@@ -35,47 +40,50 @@ export default {}
 <script setup lang="ts">
 import { useTheme } from '@/components/theme/ThemeDefine';
 import Image from '../../basic/Image.vue';
-import Text from '../../basic/Text.vue';
 import Touchable from '@/components/feedback/Touchable.vue';
+import type { FlexProps } from '../../layout/FlexView.vue';
+import IconTextBlock from './IconTextBlock.vue';
+import FlexCol from '@/components/layout/FlexCol.vue';
 
-const theme = useTheme();
-
-defineProps({	
+export interface ImageBlock2Props extends Partial<FlexProps> {
   /**
    * 宽度。
    */
-  width: {
-    type: [ String, Number ],
-    default: 400
-  },
+  width?: string | number;
   /**
    * 高度。
    */
-  imageHeight: {
-    type: [ String, Number ],
-    default: 250
-  },
+  imageHeight?: string | number;
   /**
    * 图片的路径。
    */
-  src: {
-    type: String,
-    default: null
-  },
+  src?: string;
   /**
    * 图片的圆角。
    */
-  radius: {
-    type: [ String, Number ],
-    default: undefined
-  },
+  imageRadius?: string | number;
+  /**
+   * 图片下方显示标题。
+   */
+  title?: string;
   /**
    * 图片下方显示描述。
    */
-  desc: {
-    type: String,
-    default: null
-  },
+  desc?: string;
+  /**
+   * 图片下方显示额外信息。
+   */
+  extra?: string;
+}
+
+const theme = useTheme();
+const props = withDefaults(defineProps<ImageBlock2Props>(), {
+  width: 400,
+  imageHeight: 250,
+  imageRadius: 0,
+  direction: 'column',
+  backgroundColor: "white",
+  overflow: "hidden",
 })
 
 defineEmits([	

+ 37 - 42
src/components/display/block/ImageBlock3.vue

@@ -1,10 +1,7 @@
 <template>
   <Touchable
     touchable
-    backgroundColor="white"
-    direction="row"
-    overflow="hidden"
-    v-bind="$props"
+    v-bind="props"
     :flexShrink="0"
     :innerStyle="{ borderRadius: theme.resolveThemeSize(radius), overflow: 'hidden', }"
     @click="$emit('click')"
@@ -13,13 +10,17 @@
       :src="src" 
       :width="imageWidth"
       :height="imageHeight"
-      :radius="radius"
+      :radius="imageRadius"
       round
       mode="aspectFill"
     />
     <FlexView direction="column">
       <slot name="desc">
-        <Text class="nana-image-desc">{{ desc }}</Text>
+        <IconTextBlock
+          :title="title"
+          :desc="desc"
+          :extra="extra"
+        />
       </slot>
     </FlexView>
   </Touchable>
@@ -38,62 +39,56 @@ export default {}
 
 <script setup lang="ts">
 import { useTheme } from '@/components/theme/ThemeDefine';
-import FlexView from '../../layout/FlexView.vue';
+import FlexView, { type FlexProps } from '../../layout/FlexView.vue';
 import Image from '../../basic/Image.vue';
-import Text from '../../basic/Text.vue';
 import Touchable from '@/components/feedback/Touchable.vue';
+import IconTextBlock from './IconTextBlock.vue';
 
-const theme = useTheme();
-
-defineProps({	
+export interface ImageBlock3Props extends Partial<FlexProps> {
   /**
    * 宽度
    */
-  imageWidth: {
-    type: [ String, Number ],
-    default: 150
-  },
+  imageWidth?: string | number;
   /**
    * 高度。
    */
-  imageHeight: {
-    type: [ String, Number ],
-    default: 150
-  },
+  imageHeight?: string | number;
   /**
    * 图片的路径。
    */
-  src: {
-    type: String,
-    default: null
-  },
+  src?: string;
   /**
    * 图片的圆角。
    */
-  radius: {
-    type: [ String, Number ],
-    default: undefined
-  },
+  imageRadius?: string | number;
+  /**
+   * 图片下方显示标题。
+   */
+  title?: string | number;
   /**
    * 图片下方显示描述。
    */
-  desc: {
-    type: String,
-    default: null
-  },
+  desc?: string | number;
+  /**
+   * 图片下方显示额外信息。
+   */
+  extra?: string | number;
+}
+
+const theme = useTheme();
+
+const props = withDefaults(defineProps<ImageBlock3Props>(), {
+  imageWidth: 150,
+  imageHeight: 150,
+  imageRadius: 0,
+  backgroundColor: "white",
+  direction: "row",
+  align: "center",
+  overflow: "hidden",
+  gap:20,
 })
 
 defineEmits([	
   "click"	
 ])
-</script>
-
-<style lang="scss">
-.nana-image-desc {
-  color: #fff;
-  font-size: 25rpx;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-}
-</style>
+</script>

+ 6 - 6
src/components/display/block/TextBlock.vue

@@ -137,11 +137,12 @@ defineProps({
     justify="space-between"
     align="center"
     direction="row"
+    :setCursor="false"
     v-bind="viewProps"
     @click="$emit('click')"
   >
     <slot name="prefix">
-      <Text v-if="prefix" class="nana-text-prefix" v-bind="prefixProps">{{ prefix }}</Text>
+      <Text v-if="prefix" class="nana-text-prefix" v-bind="prefixProps" :text="prefix" />
     </slot>
     <slot>
       <FlexCol v-if="text2" class="nana-text">
@@ -170,13 +171,12 @@ defineProps({
           'nana-text',
           wrap ? 'wrap' : '',
         ]" 
-        :v-bind="textProps"
-      >
-        {{ text }}
-      </Text>
+        v-bind="textProps"
+        :text="text"
+      />
     </slot>
     <slot name="suffix">
-      <Text class="nana-text-suffix" v-bind="suffixProps">{{ suffix }}</Text>
+      <Text class="nana-text-suffix" v-bind="suffixProps" :text="suffix" />
     </slot>
   </Touchable>
 </template>

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

@@ -101,6 +101,7 @@ defineProps({
 <template>
   <Touchable
     :touchable="touchable"
+    :setCursor="false"
     justify="space-between"
     align="flex-start"
     width="fill"

+ 39 - 35
src/components/display/title/SubTitle.vue

@@ -1,61 +1,65 @@
 <script setup lang="ts">
+import { useTheme, type ThemePaddingMargin, type ThemePaddingMarginProp } from '@/components/theme/ThemeDefine';
 import Icon from '@/components/basic/Icon.vue';
-import Text from '@/components/basic/Text.vue';
+import Text, { type TextProps } from '@/components/basic/Text.vue';
 import Touchable from '@/components/feedback/Touchable.vue';
 import FlexRow from '@/components/layout/FlexRow.vue';
 import Width from '@/components/layout/space/Width.vue';
-import { useTheme } from '@/components/theme/ThemeDefine';
-import { DynamicColor } from '@/components/theme/ThemeTools';
 
 defineEmits([ 'moreClicked' ])
-defineProps({
-  title: {
-    type: String,
-    default: ''
-  },
-  moreText: {
-    type: String,
-    default: '更多'
-  },
-  showMore: {
-    type: Boolean,
-    default: false
-  },
-  badgeColor: {
-    type: String,
-    default: 'primary'
-  },
+const props = withDefaults(defineProps<{
+  title?: string,
+  moreText?: string,
+  showMore?: boolean,
+  badgeColor?: string,
+  backgroundColor?: string,
+  padding?: ThemePaddingMarginProp,
+  titleProps?: TextProps,
+  badgeStyle?: object,
+}>(), {
+  title: '',
+  moreText: '更多',
+  showMore: false,
+  badgeColor: 'primary',
+  padding: () => [ 0, 0, 30, 0 ],
 })
 
 const theme = useTheme();
 
-const badgeStyle = theme.useThemeStyle({
+const finalBadgeStyle = theme.useThemeStyle({
   width: '10rpx',
   height: '32rpx',
   borderRadius: '5rpx',
   marginRight: '14rpx',
+  ...props.badgeStyle,
 })
 </script>
 
 <template>
-  <FlexRow justify="space-between" align="center" :padding="[ 0, 0, 30, 0 ]">
-    <slot name="left">
-      <FlexRow align="center">
-        <slot name="icon">
-          <view :style="{
-            ...badgeStyle,
-            backgroundColor: theme.resolveThemeColor(badgeColor),
-          }"></view>
-        </slot>
-        <Text fontConfig="h4" :text="title" />
-      </FlexRow>
-    </slot>
-    <slot name="right">
+  <FlexRow 
+    justify="space-between"
+    align="center" 
+    :padding="padding" 
+    :backgroundColor="backgroundColor"
+  >
+    <FlexRow align="center">
+      <slot name="icon">
+        <view :style="{
+          ...finalBadgeStyle,
+          backgroundColor: theme.resolveThemeColor(badgeColor),
+        }"></view>
+      </slot>
+      <slot name="titlePrefix" />
+      <Text fontConfig="h4" :text="title" v-bind="titleProps" />
+      <slot name="titleSuffix" />
+    </FlexRow>
+    <FlexRow align="center">
+      <slot name="right" />
       <Touchable v-if="showMore" align="center" touchable @click="$emit('moreClicked')">
         <Text fontConfig="subText" :text="moreText" />
         <Width :size="10" />
         <Icon icon="arrow-right" :size="26" />
       </Touchable>
-    </slot>
+    </FlexRow>
   </FlexRow>
 </template>

+ 14 - 0
src/components/dynamic/DynamicForm.ts

@@ -30,6 +30,10 @@ export interface IDynamicFormOptions {
    */
   disabled?: boolean;
   /**
+   * 是否只读表单
+   */
+  readonly?: boolean;
+  /**
    * 是否屏蔽所有子条目空错误。默认否
    * @default false
    */
@@ -245,6 +249,12 @@ export interface IDynamicFormRef {
    */
   getFormItemControlRef: <T>(key: string) => T;
   /**
+   * 获取指定类型的所有表单项组件的 Ref
+   * @param type 组件类型
+   * @returns 
+   */
+  getFormItemControlRefsByType: <T>(type: string) => T[];
+  /**
    * 触发提交。同 getFormRef().submit() 。
    * @returns 
    */
@@ -310,7 +320,11 @@ export function configDefaultDynamicFormOptions(options: Omit<IDynamicFormOption
 export type IEvaluateCallback = <T>(val: T | IDynamicFormItemCallback<T>) => T;
 export type IDynamicFormMessageCenterCallback = (messageName: string, data: unknown) => void;
 
+export type IDynamicFormWidgetRef = () => unknown;
+
 export interface IDynamicFormMessageCenter {
   addInstance: (name: string, fn: IDynamicFormMessageCenterCallback) => void,
+  addWidgetRef: (name: string, type: string, ref: IDynamicFormWidgetRef) => void,
+  removeWidgetRef: (name: string, type: string, ref: IDynamicFormWidgetRef) => void,
   removeInstance: (name: string) => void,
 }

+ 25 - 7
src/components/dynamic/DynamicForm.vue

@@ -5,6 +5,8 @@
     v-bind="finalOptions.formAdditionaProps"
     :model="model || {}"
     :rules="finalOptions.formRules"
+    :disabled="finalOptions.disabled"
+    :readonly="finalOptions.readonly"
     @submit="(e) => emit('submit', e)"
     @submitFailed="() => emit('finishFailed')"
   >
@@ -17,14 +19,15 @@
 </template>
 
 <script setup lang="ts">
-import { computed, onMounted, provide, ref, toRef, toRefs, type PropType } from 'vue';
+import { computed, onMounted, provide, ref, shallowRef, toRef, toRefs, type PropType } from 'vue';
 import Form, { type FormInstance } from '../form/Form.vue';
 import { 
   type IDynamicFormOptions, type IDynamicFormItem, type IDynamicFormRef, 
   type IDynamicFormObject, defaultDynamicFormOptions, 
   type IDynamicFormMessageCenter,
   type IDynamicFormMessageCenterCallback,
-  MESSAGE_RELOAD
+  MESSAGE_RELOAD,
+  type IDynamicFormWidgetRef
 } from '.';
 import DynamicFormRoot from './nest/DynamicFormRoot.vue';
 
@@ -70,19 +73,33 @@ provide('rawModel', model);
 provide('globalParams', toRef(props, 'globalParams'));
 provide('finalOptions', finalOptions);
 
+
 const formEditor = ref<FormInstance>();
-const widgetsRefMap = ref<Record<string,() => unknown>>({});
+const widgetsRefMap = new Map<string, IDynamicFormWidgetRef>();
+const widgetsRefTypesMap = new Map<string, IDynamicFormWidgetRef[]>();
 const messageCenterMap = new Map<string, IDynamicFormMessageCenterCallback>();
-  
-provide('widgetsRefMap', widgetsRefMap.value);
+
 provide('messageCenter', {
   addInstance: (name: string, fn: IDynamicFormMessageCenterCallback) => messageCenterMap.set(name, fn),
   removeInstance: (name: string) => messageCenterMap.delete(name),
+  addWidgetRef: (name: string, type: string, ref: IDynamicFormWidgetRef) => {
+    const refs = widgetsRefTypesMap.get(name) || [];
+    refs.push(ref);
+    widgetsRefTypesMap.set(type, refs);
+  },
+  removeWidgetRef: (name: string, type: string, ref: IDynamicFormWidgetRef) => {
+    const refs = widgetsRefTypesMap.get(name) || [];
+    widgetsRefTypesMap.set(type, refs.filter((r) => r !== ref));
+  },
 } as IDynamicFormMessageCenter);
 
 //获取组件引用
 function getFormItemControlRef(key: string) {
-  return widgetsRefMap.value[key]?.();
+  return widgetsRefMap.get(key)?.();
+}
+//获取组件引用组
+function getFormItemControlRefsByType(type: string) {
+  return (widgetsRefTypesMap.get(type) || []).map((ref) => ref());
 }
 
 //通过路径访问
@@ -163,7 +180,7 @@ function initDefaultValuesToModel() {
         const oldValue = accessFormModel(currentKey, false, undefined);
         if (oldValue !== undefined && oldValue !== null)
           continue;
-        accessFormModel(currentKey, true, item.defaultValue);
+        accessFormModel(currentKey, true, typeof item.defaultValue === 'function' ? item.defaultValue() : item.defaultValue);
       }
       i++;
     }
@@ -190,6 +207,7 @@ const formRef : IDynamicFormRef = {
       throw new Error('Form instance is not create.');
     return formEditor.value
   },
+  getFormItemControlRefsByType: getFormItemControlRefsByType as any,
   getFormItemControlRef: getFormItemControlRef as any,
   submit() { return this.getFormRef().validate(); },
   validate() { return this.getFormRef().validate(); },

+ 45 - 2
src/components/dynamic/DynamicFormControl.vue

@@ -20,6 +20,8 @@
     :showBottomBorder="!isLast"
     :required="Boolean(item.rules?.length)"
     :rules="item.rules"
+    :disabled="disabled"
+    :readonly="readonly"
     v-bind="{ 
       ...params,
       ...extraDefine?.itemProps || {},
@@ -36,6 +38,8 @@
     :showBottomBorder="!isLast"
     :requireChildRef="() => itemRef"
     :rules="item.rules"
+    :disabled="disabled"
+    :readonly="readonly"
     v-bind="{ 
       ...extraDefine?.itemProps || {},
       ...item.formProps,
@@ -50,6 +54,7 @@
         <Stepper
           ref="itemRef"
           :modelValue="model"
+          :disabled="disabled || readonly"
           @update:modelValue="onValueChanged"
           v-bind="params"
         />
@@ -58,6 +63,7 @@
         <Switch
           ref="itemRef"
           :modelValue="model"
+          :disabled="disabled || readonly"
           @update:modelValue="onValueChanged"
           v-bind="params"
         />
@@ -66,6 +72,7 @@
         <RadioValue
           ref="itemRef"
           :modelValue="model"
+          :disabled="disabled || readonly"
           @update:modelValue="onValueChanged"
           v-bind="(params as any as RadioValueProps)"
         />
@@ -74,6 +81,7 @@
         <RadioIdField
           ref="itemRef"
           :modelValue="model"
+          :disabled="disabled || readonly"
           @update:modelValue="onValueChanged"
           v-bind="(params as any as RadioIdFieldProps)"
         />
@@ -83,6 +91,7 @@
           <NaPickerField 
             ref="itemRef"
             :modelValue="model"
+            :disabled="disabled || readonly"
             @update:modelValue="onValueChanged"
             v-bind="(params as any as PickerFieldProps)"
           />
@@ -92,6 +101,7 @@
         <Rate
           ref="itemRef"
           :modelValue="model"
+          :disabled="disabled || readonly"
           @update:modelValue="onValueChanged"
           v-bind="(params as any as RateProps)"
         />
@@ -100,6 +110,8 @@
         <UploaderField
           ref="itemRef"
           :modelValue="model"
+          :disabled="disabled"
+          :readonly="readonly"
           @update:modelValue="onValueChanged"
           v-bind="(params as any as UploaderFieldProps)"
         />
@@ -108,6 +120,8 @@
         <PickerIdField 
           ref="itemRef"
           :modelValue="model"
+          :disabled="disabled"
+          :readonly="readonly"
           @update:modelValue="onValueChanged"
           v-bind="(params as any as PickerIdFieldProps)"
         />
@@ -116,6 +130,8 @@
         <PickerCityField
           ref="itemRef"
           :modelValue="model"
+          :disabled="disabled"
+          :readonly="readonly"
           @update:modelValue="onValueChanged"
           v-bind="(params as any)"
         />
@@ -124,6 +140,8 @@
         <PickerAddressField
           ref="itemRef"
           :modelValue="model"
+          :disabled="disabled"
+          :readonly="readonly"
           @update:modelValue="onValueChanged"
           v-bind="(params as any)"
         />
@@ -132,6 +150,7 @@
         <PickerLonlat
           ref="itemRef"
           :modelValue="model"
+          :disabled="disabled || readonly"
           @update:modelValue="(v:any) => onValueChanged(v)"
           v-bind="params"
         />
@@ -140,6 +159,7 @@
         <CheckBox
           ref="itemRef"
           :modelValue="model"
+          :disabled="disabled || readonly"
           @update:modelValue="onValueChanged"
           v-bind="params"
         />
@@ -148,14 +168,25 @@
         <CheckBoxList
           ref="itemRef"
           :modelValue="model"
+          :disabled="disabled || readonly"
           @update:modelValue="onValueChanged"
           v-bind="(params)"
         />
       </template>
+      <template v-else-if="item.type === 'check-box-tree'"> 
+        <CheckBoxTreeList
+          ref="itemRef"
+          :modelValue="model"
+          :disabled="disabled || readonly"
+          @update:modelValue="onValueChanged"
+          v-bind="(params as any as CheckBoxTreeListProps)"
+        />
+      </template>
       <template v-else-if="item.type === 'check-box-int'"> 
         <CheckBoxToInt
           ref="itemRef"
           :modelValue="model"
+          :disabled="disabled || readonly"
           @update:modelValue="onValueChanged"
           v-bind="params"
         />
@@ -193,6 +224,7 @@
       <template v-else-if="item.type === 'button'">
         <Button
           ref="itemRef"
+          :disabled="disabled || readonly"
           v-bind="params"
         />
       </template>
@@ -216,14 +248,16 @@
         :item="item"
         :name="name"
         :isLast="isLast"
+        :disabled="disabled"
+        :readonly="readonly"
       />
     </slot>
   </Field>
 </template>
 
 <script setup lang="ts">
-import { computed, inject, onBeforeUnmount, onMounted, ref, watch, type PropType, type Ref } from 'vue';
-import type { IDynamicFormItem, IDynamicFormItemCallback, IDynamicFormObject, IDynamicFormOptions, IDynamicFormRef } from '.';
+import { computed, inject, onBeforeUnmount, onMounted, ref, type PropType, type Ref } from 'vue';
+import type { IDynamicFormItem, IDynamicFormItemCallback, IDynamicFormMessageCenter, IDynamicFormObject, IDynamicFormOptions, IDynamicFormRef } from '.';
 import Field from '../form/Field.vue';
 import Stepper from '../form/Stepper.vue';
 import NaPickerField, { type PickerFieldProps } from '../form/PickerField.vue';
@@ -251,6 +285,8 @@ import PickerAddressField from './wrappers/PickerAddressField.vue';
 import Button from '../basic/Button.vue';
 import Alert from '../feedback/Alert.vue';
 import Image from '../basic/Image.vue';
+import CheckBoxTreeList, { type CheckBoxTreeListProps } from './wrappers/CheckBoxTreeList.vue';
+import { useInjectFormContext, useInjectFormItemContext } from '../form/FormContext';
 
 export interface FormCeilProps {
   model: unknown,
@@ -325,6 +361,10 @@ const finalOptions = inject<Ref<IDynamicFormOptions>>('finalOptions');
 const globalParams = inject<Ref<IDynamicFormObject>>('globalParams');
 const formRef = inject<IDynamicFormRef>('formRef');
 const formName = inject('formName', '');
+const context = useInjectFormItemContext();
+const formContext = useInjectFormContext();
+const disabled = computed(() => props.disabled || formContext?.disabled.value || context?.disabled.value);
+const readonly = computed(() => formContext?.readonly.value || context?.readonly.value);
 
 function evaluateCallback(val: unknown|IDynamicFormItemCallback<unknown>) {
   if (typeof val === 'object' && typeof (val as IDynamicFormItemCallback<unknown>).callback === 'function')
@@ -377,6 +417,7 @@ const data = computed<FormCeilProps>(() => {
 })
 
 const itemRef = ref();
+const messageCenter = inject<IDynamicFormMessageCenter>('messageCenter');
  
 function onValueChanged(v: any) {
   props.item.watch?.(props.model, v, props.rawModel, getComponentRef());
@@ -390,9 +431,11 @@ function getComponentRef() {
 
 onMounted(() => {
   props.item.mounted?.(props.model, props.rawModel, getComponentRef());
+  messageCenter?.addWidgetRef(props.item.name, props.item.type ?? '', getComponentRef);
 })
 onBeforeUnmount(() => {
   props.item.beforeUnmount?.(props.model, props.rawModel, getComponentRef()); 
+  messageCenter?.removeWidgetRef(props.item.name, props.item.type ?? '', getComponentRef);
 })
 
 defineOptions({

+ 2 - 3
src/components/dynamic/nest/DynamicFormItemContainer.vue

@@ -292,16 +292,15 @@
 </template>
 
 <script lang="ts" setup>
-import { inject, type PropType, type Ref, toRefs, computed, provide } from 'vue';
+import { inject, type Ref, toRefs, computed, provide } from 'vue';
 import type { Rules } from 'async-validator';
+import type { IDynamicFormItem, IDynamicFormItemCallback, IDynamicFormObject, IDynamicFormOptions, IDynamicFormRef, IEvaluateCallback } from '..';
 import DynamicFormItemNormal, { type FormCeilProps } from '../DynamicFormControl.vue';
 import FormGroup from '../group/FormGroup.vue';
 import FormArrayGroup from '../group/FormArrayGroup.vue';;
 import Col, { type ColProps } from '@/components/layout/grid/Col.vue';
 import Row from '@/components/layout/grid/Row.vue';
 import DynamicFormCheckEmpty from './DynamicFormCheckEmpty.vue';
-import type { IDynamicFormItem, IDynamicFormItemCallback, IDynamicFormObject, IDynamicFormOptions, IDynamicFormRef, IEvaluateCallback } from '..';
-import Button from '@/components/basic/Button.vue';
 import DynamicFormItemContainerFuckMp from './DynamicFormItemContainerFuckMp.vue';
 
 /**

+ 89 - 0
src/components/dynamic/wrappers/CheckBoxTreeList.vue

@@ -0,0 +1,89 @@
+<template>
+  <FlexView :direction="vertical ? 'column' : 'row'" align="center" :gap="10" wrap>
+    <ActivityIndicator v-if="loadStatus === 'loading'" />
+    <Alert
+      v-else-if="loadStatus === 'error'" 
+      message="加载失败" 
+      description="点击重新加载" 
+      type="error" 
+      @click="handleLoadData"
+    />
+    <CheckBoxGroup 
+      v-else 
+      :modelValue="modelValue"
+      :disabled="disabled"
+      :multiple="multiple"
+      @update:modelValue="handleChange" 
+    >
+      <FlexCol>
+        <CheckBoxTreeListItem
+          v-for="item in data2"
+          :key="item.value"
+          :item="item"
+        />
+      </FlexCol>
+    </CheckBoxGroup>
+  </FlexView>
+</template>
+
+<script setup lang="ts">
+import { onMounted, provide, ref } from 'vue';
+import ActivityIndicator from '@/components/basic/ActivityIndicator.vue';
+import Alert from '@/components/feedback/Alert.vue';
+import CheckBoxGroup from '@/components/form/CheckBoxGroup.vue';
+import FlexCol from '@/components/layout/FlexCol.vue';
+import FlexView from '@/components/layout/FlexView.vue';
+import CheckBoxTreeListItem from './CheckBoxTreeListItem.vue';
+
+export interface CheckBoxTreeListItem {
+  text: string;
+  value: any;
+  disable?: boolean;
+  hasChildren?: boolean;
+  children?: CheckBoxTreeListItem[];
+}
+export interface CheckBoxTreeListProps {
+  multiple?: boolean,
+  disabled?: boolean,
+  vertical?: boolean,
+  className?: string,
+  loadData: (pid?: number) => Promise<CheckBoxTreeListItem[]>;
+}
+
+const props = defineProps<CheckBoxTreeListProps & {
+  modelValue?: string[],
+}>();
+const emit = defineEmits(['update:modelValue', 'change'])
+
+const loadStatus = ref<'loading' | 'error' | 'success'>('loading');
+const loadError = ref('');
+const data2 = ref<CheckBoxTreeListItem[]>([]);
+
+provide('loadData', props.loadData);
+
+const handleChange = (checkedValues: any[]) => {
+  emit('update:modelValue', checkedValues);
+  emit('change', checkedValues);
+}
+const handleLoadData = () => {
+  loadStatus.value = 'loading';
+  loadError.value = '';
+  
+  props.loadData().then((v) => {
+    data2.value = v;
+    loadStatus.value = 'success';
+  }).catch((e) => {
+    loadError.value = e.message || '加载失败';
+    loadStatus.value = 'error';
+  });
+}
+const reload = () => {
+  handleLoadData();
+}
+
+defineExpose({ reload });
+
+onMounted(() => {
+  handleLoadData();
+});
+</script>

+ 64 - 0
src/components/dynamic/wrappers/CheckBoxTreeListItem.vue

@@ -0,0 +1,64 @@
+<template>
+  <FlexCol>
+    <FlexRow align="center" :gap="20">
+      <IconButton
+        v-if="item.hasChildren !== false"
+        icon="arrow-right" 
+        :rotate="open ? 90 : 0"
+        :innerStyle="{ transition: 'transform 0.3s ease-in-out' }"
+        @click="toggleOpen"
+      />
+      <CheckBox
+        :key="item.value"
+        :name="item.value"
+        :text="item.text" 
+        :disabled="item.disable"
+      />
+    </FlexRow>
+    <CollapseBox :open="open" :anim="false">
+      <FlexCol :padding="[10,0,10,30]">
+        <CheckBoxTreeListItemWrapper
+          v-for="child in item.children"
+          :key="child.value"
+          :item="child"
+        />
+      </FlexCol>
+    </CollapseBox>
+  </FlexCol>
+</template>
+
+<script setup lang="ts">
+import { inject, ref } from 'vue';
+import type { CheckBoxTreeListItem } from './CheckBoxTreeList.vue';
+import CollapseBox from '@/components/display/CollapseBox.vue';
+import CheckBox from '@/components/form/CheckBox.vue';
+import FlexCol from '@/components/layout/FlexCol.vue';
+import FlexRow from '@/components/layout/FlexRow.vue';
+import IconButton from '@/components/basic/IconButton.vue';
+import CheckBoxTreeListItemWrapper from './CheckBoxTreeListItemWrapper.vue';
+
+const props = defineProps<{
+  item: CheckBoxTreeListItem,
+}>();
+
+const open = ref(false);
+const loadData = inject('loadData') as (pid?: number) => Promise<CheckBoxTreeListItem[]>;
+
+function toggleOpen() {
+  open.value = !open.value;
+  if (open.value && (!props.item.children || props.item.children.length === 0))
+    loadData(props.item.value).then((children) => {
+      props.item.children = children;
+    });
+}
+</script>
+
+<style lang="scss">
+.nana-collapse-item-icon {
+  transition: transform 0.3s ease-in-out;
+
+  &.open {
+    transform: rotate(180deg);
+  }
+}
+</style>

+ 14 - 0
src/components/dynamic/wrappers/CheckBoxTreeListItemWrapper.vue

@@ -0,0 +1,14 @@
+<template>
+  <CheckBoxTreeListItemComponent
+    :item="item"
+  />
+</template>
+
+<script setup lang="ts">
+import type { CheckBoxTreeListItem } from './CheckBoxTreeList.vue';
+import CheckBoxTreeListItemComponent from './CheckBoxTreeListItem.vue';
+
+const props = defineProps<{
+  item: CheckBoxTreeListItem,
+}>();
+</script>

+ 18 - 3
src/components/dynamic/wrappers/PickerAddressField.vue

@@ -1,11 +1,12 @@
 <template>
-  <FlexRow width="100%" justify="space-between" align="center">
+  <FlexRow width="100%" align="center">
     <input 
       :value="modelValue"
-      placeholder="输入地址或者选择地图地址"
+      :placeholder="readonly ? '未填写' : '输入地址或者选择地图地址'"
       @input="handleInput"
     />
-    <Button type="primary" size="mini" icon="map" @click="selectFromMap">地图选择</Button>
+    <Button v-if="!disabled && !readonly" type="primary" size="mini" icon="map" @click="selectFromMap">地图选择</Button>
+    <view v-else></view>
   </FlexRow>
 </template>
 
@@ -19,6 +20,20 @@ const props = defineProps({
     type: String,
     default: '',
   },
+  /**
+   * 是否禁用
+   */
+  disabled: {
+    type: Boolean,
+    default: false,
+  },
+  /**
+   * 是否只读
+   */
+  readonly: {
+    type: Boolean,
+    default: false,
+  },
   loadFormattedAddress: {
     type: Function as PropType<(latlon: [number,number]) => Promise<string>>,
     default: null,

+ 14 - 3
src/components/dynamic/wrappers/PickerCityField.vue

@@ -1,5 +1,5 @@
 <template>
-  <FlexRow width="100%" justify="space-between" align="center">
+  <FlexRow width="100%" align="center">
     <CascaderField
       v-if="ChinaCityData.data.value"
       ref="fieldRef"
@@ -8,11 +8,11 @@
       textKey="name"
       :valueKey="stringValue ? 'name' : 'code'"
       childrenKey="children"
-      placeholder="请选择省市区" 
       :data="(ChinaCityData.data.value as CascaderItem[]) || []"
       v-bind="$attrs"
+      :placeholder="readonly ? '未填写' : ($attrs.placeholder as string || '请选择省市区')" 
     />
-    <Button type="primary" size="mini" icon="map" @click="selectCityFromMap">地图选择</Button>
+    <Button v-if="!disabled && !readonly" type="primary" size="mini" icon="map" @click="selectCityFromMap">地图选择</Button>
   </FlexRow>
 </template>
 
@@ -29,6 +29,17 @@ const props = defineProps({
     type: Array as PropType<string[]>,
     default: () => [],
   },
+  /**
+   * 是否禁用
+   */
+  disabled: {
+    type: Boolean,
+    default: false,
+  },
+  readonly: {
+    type: Boolean,
+    default: false,
+  },
   loadCityData: {
     type: Function as PropType<() => Promise<CascaderItem[]>>,
     default: () => Promise.resolve([]),

+ 5 - 0
src/components/dynamic/wrappers/PickerIdField.ts

@@ -11,6 +11,11 @@ export interface PickerIdFieldProps extends Omit<PickerFieldProps, 'columns'> {
    */
   disabled?: boolean;
   /**
+   * 是否只读,只读状态下不能选择数据,默认的请选择选项变为“未选择”。
+   * @default false
+   */
+  readonly?: boolean,
+  /**
    * 加载选项数据
    * @returns 
    */

+ 3 - 2
src/components/dynamic/wrappers/PickerIdField.vue

@@ -2,6 +2,7 @@
   <PickerField 
     :columns="[ loader.data.value || [] ]"
     v-bind="$attrs"
+    :singleValue="true"
     :modelValue="!(modelValue instanceof Array) ? [modelValue || ''] : modelValue"
     @update:modelValue="handleUpdateModelValue"
   />
@@ -9,8 +10,8 @@
 
 <script setup lang="ts">
 import PickerField from '@/components/form/PickerField.vue';
-import type { PickerIdFieldProps, PickerIdFieldOption } from './PickerIdField';
 import { useDataLoader } from '@/components/composeabe/DataLoader';
+import type { PickerIdFieldProps, PickerIdFieldOption } from './PickerIdField';
 
 const props = defineProps<PickerIdFieldProps>();
 const emit = defineEmits(['update:modelValue']);
@@ -19,7 +20,7 @@ const loader = useDataLoader<PickerIdFieldOption[]>(async () => {
   if (res.length === 0)
     return [];
   return ([{
-    text: '请选择',
+    text: props.readonly ? '未选择' : '请选择',
     value: '',
   }] as PickerIdFieldOption[]).concat(res);
 }, {

+ 4 - 2
src/components/dynamic/wrappers/PickerLonlat.vue

@@ -2,7 +2,8 @@
   <Button
     type="primary"
     size="small"
-    :text="props.modelValue ? `${FormatUtils.formatCoordinates(props.modelValue[1], props.modelValue[0])}` : '请选择经纬度'"
+    :touchable="!disabled"
+    :text="props.modelValue ? `${FormatUtils.formatCoordinates(props.modelValue[0], props.modelValue[1])}` : '请选择经纬度'"
     @click="openPicker"
   />
 </template>
@@ -16,7 +17,7 @@ const props = defineProps<PickerLonlatProps & {
   modelValue: number[],
 }>()
 
-const emit = defineEmits(['update:modelValue']);
+const emit = defineEmits(['update:modelValue', 'change']);
 
 function openPicker() {
   if (props.disabled)
@@ -26,6 +27,7 @@ function openPicker() {
     longitude: props.modelValue?.[1] || props.defaultLongLat?.[1],
     success: (res) => {
       console.log(res);
+      emit('change', [res.latitude, res.longitude]);
       emit('update:modelValue', [res.latitude, res.longitude]);
     },
   });

+ 9 - 9
src/components/feedback/Alert.vue

@@ -14,16 +14,16 @@
           info: 'background.info',
         }
       )),
-      borderStyle:'solid',
+      borderStyle: 'solid',
       borderColor: themeContext.resolveThemeColor(borderColor || selectStyleType(
         type, 'default', {
           default: 'border.default',
-          primary: 'primary',
-          success: 'success',
-          warning: 'warning',
-          danger: 'danger',
-          error: 'danger',
-          info: 'info',
+          primary: 'mask.primary',
+          success: 'mask.success',
+          warning: 'mask.warning',
+          danger: 'mask.danger',
+          error: 'mask.danger',
+          info: 'mask.info',
         }
       )),
       borderRadius: themeContext.resolveThemeSize('AlertBorderRadius', 16),
@@ -98,7 +98,7 @@
 <script setup lang="ts">
 import { computed } from 'vue';
 import { useTheme, type TextStyle, type ViewStyle } from '../theme/ThemeDefine';
-import { DynamicSize, DynamicColor, selectStyleType } from '../theme/ThemeTools';
+import { DynamicSize, DynamicColor, selectStyleType, DynamicVar } from '../theme/ThemeTools';
 import Text from '../basic/Text.vue';
 import Icon from '../basic/Icon.vue';
 import IconButton from '../basic/IconButton.vue';
@@ -212,7 +212,7 @@ const themeStyles = themeContext.useThemeStyles({
     alignItems: 'center',
     padding: DynamicSize('AlertPadding', 16),
     borderWidth: DynamicSize('AlertBorderWidth', '1px'),
-    borderStyle: 'solid',
+    borderStyle: DynamicVar('AlertBorderStyle', 'solid'),
   } as ViewStyle,
   icon: {
     fontSize: DynamicSize('AlertIconSize', 20),

+ 306 - 0
src/components/feedback/BubbleBox.vue

@@ -0,0 +1,306 @@
+<template>
+  <view 
+    class="nana-bubble-box"
+    :style="outerStyle"
+    @mouseenter="handleHover(true)"
+    @mouseleave="handleHover(false)"
+  >
+    <view v-if="trigger === 'click'" @click.native.capture="handleClick">
+      <slot />
+    </view>
+    <slot v-else />
+    <SimpleTransition name="bubble-box" :show="showState" :duration="200">
+      <template #show="{ classNames }">
+        <view class="nana-bubble-box-popup-mask" @click="hide" />
+        <FlexView
+          v-if="items?.length"
+          position="absolute"
+          :direction="direction"
+          :backgroundColor="backgroundColor"
+          :radius="radius"
+          :gap="10"
+          :padding="10"
+          :zIndex="1001"
+          :margin="selectObjectByType(position, 'left', {
+            top: [arrowWidth,0],
+            bottom: [arrowWidth,0],
+            left: [0,arrowWidth],
+            right: [0,arrowWidth],
+          })"
+          :innerClass="['nana-bubble-box-popup',position,classNames]"
+          shadow="light"
+          v-bind="innerProps"
+          :innerStyle="innerStyle"
+        >
+          <view 
+            class="nana-bubble-box-arrow" 
+            :style="{ 
+              borderWidth: theme.resolveThemeSize(arrowWidth),
+              borderColor: backgroundColor,
+              borderRightColor: 'transparent',
+              borderBottomColor: 'transparent',
+              borderLeftColor: 'transparent',
+            }"
+          />
+          <Touchable
+            v-for="item in items"
+            :key="item.text"
+            direction="row"
+            align-items="center"
+            :gap="10"
+            :padding="[5, 20]"
+            v-bind="itemProps"
+            @click="handleItemClick(item)"
+          >
+            <Icon
+              :name="item.icon"
+              :size="44"
+              :color="item.textColor || itemTextColor"
+              v-bind="{ ...itemIconProps, ...item.iconProps }"
+            />
+            <Text 
+              :wrap="false" 
+              v-bind="itemTextProps" 
+              :color="item.textColor || itemTextColor"
+              :text="item.text" 
+            />
+          </Touchable>
+        </FlexView>
+      </template>
+    </SimpleTransition>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+import { propGetThemeVar, useTheme } from '../theme/ThemeDefine';
+import { selectObjectByType } from '../theme/ThemeTools';
+import type { FlexProps } from '../layout/FlexView.vue';
+import type { TextProps } from '../basic/Text.vue';
+import Icon, { type IconProps } from '../basic/Icon.vue';
+import FlexView from '../layout/FlexView.vue';
+import Text from '../basic/Text.vue';
+import Touchable from './Touchable.vue';
+import SimpleTransition from '../anim/SimpleTransition.vue';
+
+export interface BubbleBoxItem {
+  text: string,
+  textColor?: string,
+  icon?: string,
+  iconProps?: IconProps,
+  onClick: () => void,
+}
+export interface BubbleBoxProps {
+  /**
+   * 气泡框位置
+   * @default top
+   */
+  position?: 'left' | 'right' | 'top' | 'bottom',
+  /**
+   * 触发点击事件模式
+   * @default click
+   */
+  trigger?: 'click'|'hover'|'none',
+  /**
+   * 气泡框按钮排列方向
+   * @default column
+   */
+  direction?: 'column' | 'row',
+  /**
+   * 是否禁用
+   * @default false
+   */
+  disabled?: boolean,
+  /**
+   * 气泡框按钮数组
+   */
+  items?: BubbleBoxItem[],
+  /**
+   * 气泡框按钮文本颜色
+   * @default 'text.content'
+   */
+  itemTextColor?: string,
+  /**
+   * 气泡框按钮文本样式
+   */
+  itemTextProps?: TextProps,
+  /**
+   * 气泡框按钮图标样式
+   */
+  itemIconProps?: IconProps,
+  /**
+   * 气泡框按钮样式
+   */
+  itemProps?: FlexProps,
+  /**
+   * 气泡框外层容器样式
+   */
+  outerStyle?: Record<string, string>,
+  /**
+   * 气泡框背景颜色
+   * @default white
+   */
+  backgroundColor?: string,
+  /**
+   * 气泡框箭头宽度
+   * @default 12
+   */
+  arrowWidth?: number,
+  /**
+   * 气泡框圆角半径
+   * @default 12
+   */
+  radius?: number,
+  /**
+   * 气泡框内层容器样式
+   */
+  innerProps?: FlexProps,
+  /**
+   * 气泡框内层容器样式
+   */
+  innerStyle?: Record<string, string>,
+}
+export interface BubbleBoxExpose {
+  show: () => void,
+  hide: () => void,
+}
+const theme = useTheme();
+const props = withDefaults(defineProps<BubbleBoxProps>(), {
+  position: 'top',
+  trigger: 'click',
+  direction: 'column',
+  arrowWidth: () => propGetThemeVar('BubbleBoxArrowWidth', 12),
+  items: () => [],
+  itemTextColor: () => propGetThemeVar('BubbleBoxItemTextColor', 'text.content'),
+  backgroundColor: () => propGetThemeVar('BubbleBoxBackgroundColor', 'white'),
+  radius: () => propGetThemeVar('BubbleBoxRadius', 12),
+});
+
+const backgroundColor = computed(() => theme.resolveThemeColor(props.backgroundColor));
+const showState = ref(false);
+const lock = ref(false);
+
+function handleItemClick(item: BubbleBoxItem) {
+  hide();
+  item.onClick();
+}
+function handleClick() {
+  if (lock.value) return;
+  if (props.trigger === 'click' && !props.disabled)  {
+     enterLock();
+    showState.value = !showState.value;
+  }
+}
+function handleHover(show: boolean) {
+  if (lock.value) return;
+  if (props.trigger === 'hover' && !props.disabled)
+    showState.value = show;
+}
+
+function enterLock() {
+  lock.value = true;
+  setTimeout(() => {
+    lock.value = false;
+  }, 300);
+}
+function show() { 
+  showState.value = true;
+  enterLock();
+}
+function hide() { 
+  showState.value = false; 
+  enterLock();
+}
+
+defineExpose<BubbleBoxExpose>({
+  show,
+  hide,
+})
+
+defineOptions({
+  options: {
+    virtualHost: true,
+    styleIsolation: "shared",
+  }
+})
+</script>
+
+<style lang="scss">
+.nana-bubble-box {
+  position: relative;
+  overflow: visible;
+
+  .nana-bubble-box-popup {
+    position: absolute;
+    transition: opacity ease-in-out 0.2s;
+
+    &.left {
+      top: 50%;
+      right: 100%;
+      transform: translateY(-50%) translateX(0);
+
+      .nana-bubble-box-arrow {
+        top: 50%;
+        left: 100%;
+        transform: translateY(-50%) rotate(-90deg);
+      }
+    }
+    &.right {
+      top: 50%;
+      left: 100%;
+      transform: translateY(-50%);
+
+      .nana-bubble-box-arrow {
+        top: 50%;
+        left: 0;
+        transform: translateX(-100%) translateY(-50%) rotate(90deg);
+      }
+    }
+    &.top {
+      bottom: -100%;
+      left: 50%;
+      transform: translateX(-50%) translateY(-100%);
+
+      .nana-bubble-box-arrow {
+        top: 100%;
+        left: 50%;
+        transform: translateX(-50%) rotate(0);
+      }
+    }
+    &.bottom {
+      top: 100%;
+      left: 50%;
+      transform: translateX(-50%);
+
+      .nana-bubble-box-arrow {
+        top: 0;
+        left: 50%;
+        transform: translateX(-50%) translateY(-100%) rotate(180deg);
+      }
+    }
+ 
+    &.bubble-box-enter-active,
+    &.bubble-box-leave-active {
+      opacity: 1;
+    }
+    &.bubble-box-enter-from,
+    &.bubble-box-leave-to {
+      opacity: 0;
+    }
+  }
+  .nana-bubble-box-popup-mask {
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    z-index: 1000;
+  }
+  .nana-bubble-box-arrow {
+    position: absolute;
+    width: 0;
+    height: 0;
+    border-style: solid;
+  }
+}
+</style>

+ 2 - 2
src/components/feedback/DropdownMenu.vue

@@ -5,7 +5,7 @@
     </view>
     <scroll-view scroll-x>
       <DropdownMenuProvide :isExtra="true">
-        <view class="nana-dropdown-menu-extra">
+        <view v-if="$slots.extra" class="nana-dropdown-menu-extra">
           <slot name="extra" />
         </view>
       </DropdownMenuProvide>
@@ -44,7 +44,7 @@ export interface DropdownMenuProps {
    */
   duration?: number;
   /**
-   * 是否包含导航栏空间,这会影响弹出菜单的定位
+   * 是否包含导航栏空间,这会影响弹出菜单的定位。H5并且去掉系统导航栏需要设置为false
    * @default true
    */
   includeNavBarSpace?: boolean;

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

@@ -36,8 +36,8 @@
           v-if="canOpen"
           :innerStyle="{
             transform: topContext.direction.value === 'up' ? 
-              (openState ? 'rotate(0deg)' : 'rotate(180deg)') : 
-              (openState ? 'rotate(180deg)' : 'rotate(0deg)'),
+              (openState ? 'rotate(1deg)' : 'rotate(180deg)') : 
+              (openState ? 'rotate(180deg)' : 'rotate(-1deg)'),
             ...textStyle,
           }"
           :size="iconSize"
@@ -229,9 +229,7 @@ async function updateDialogMargin() {
 
   const d = await new Promise<UniApp.NodeInfo>((resolve, reject) => {
     uni.createSelectorQuery()
-      // #ifdef MP
       .in(instance)
-      // #endif
       .select(`#${id}`)
       .fields({
         id: true,
@@ -248,6 +246,7 @@ async function updateDialogMargin() {
       .exec();
   });
   const systemInfo = uni.getSystemInfoSync();
+  const pages = getCurrentPages();
   let v = 0
   if (topContext.direction.value === 'up') {
     if (topContext.includeNavBarSpace.value) {

+ 5 - 2
src/components/feedback/Result.vue

@@ -8,14 +8,17 @@
       :color="themeVars.ResultTitleColor"
       :bold="themeVars.ResultTitleBold"
       align="center"
-    >{{ title }}</Text>
+      :text="title"
+    />
     <Height :size="themeVars.ResultDescriptionMarginTop" />
     <Text
       :fontSize="themeVars.ResultDescriptionFontSize"
       :color="themeVars.ResultDescriptionColor"
       :bold="themeVars.ResultDescriptionBold"
       align="center"
-    >{{ description }}</Text>
+      :text="description"
+    />
+    <slot />
   </FlexCol>
 </template>
 

+ 1 - 1
src/components/feedback/ShareSheetButtons.vue

@@ -14,7 +14,7 @@
       :buttonStyle="itemStyle"
       @click="emit('click', item)"
     >
-      <Text v-if="item.title" :innerStyle="itemTextStyle">{{ item.title }}</Text>
+      <Text v-if="item.title" :innerStyle="itemTextStyle" :text="item.title" />
     </IconButton>
   </FlexRow>
 </template>

+ 6 - 2
src/components/feedback/Toast.vue

@@ -164,7 +164,11 @@ const showProps = ref<ToastShowProps>({
 });
 let showTimer = 0;
 
-function show(options: ToastShowProps) {
+function show(options: ToastShowProps|string) {
+
+  if (typeof options === 'string')
+    options = { content: options };
+
   const { 
     duration = 0,
     type = 'text',
@@ -223,7 +227,7 @@ function handleClick() {
 }
 
 export interface ToastInstance {
-  show(options: ToastShowProps) : void;
+  show(options: ToastShowProps|string) : void;
   info(options?: ToastShowProps|string): void;
   success(options?: ToastShowProps|string): void;
   fail(options?: ToastShowProps|string): void;

+ 26 - 0
src/components/feedback/Touchable.ts

@@ -0,0 +1,26 @@
+import type { FlexProps } from "../layout/FlexView.vue";
+
+export interface TouchableFlexProps extends FlexProps {
+  /**
+   * 是否可以点击
+   * @default true
+   */
+  touchable?: boolean,
+  /**
+   * 按下时的颜色
+   * @default 'background.press'
+   */
+  pressedColor?: string,
+  /**
+   * 按下时的透明度(仅在 pressedColor 未设置时有效)
+   * @default 0.7
+   */
+  activeOpacity?: number,
+  /**
+   * 是否设置鼠标指针为指针
+   * @default true
+   */
+  setCursor?: boolean,
+}
+
+export const TouchableClickEventInceptorKey = Symbol('TouchableClickEventInceptor');

+ 13 - 24
src/components/feedback/Touchable.vue

@@ -14,7 +14,7 @@
     @touchend="handleTouchEnd"
     @click.native.stop="handleClick"
   >
-    <slot></slot>
+    <slot />
   </view>
 </template>
 
@@ -23,33 +23,16 @@
 /**
  * 组件说明:Flex组件,用于一些布局中快速写容器,是一系列盒子的基础组件。
  */
-import { computed, getCurrentInstance, onMounted, ref } from 'vue';
+import { computed, getCurrentInstance, inject, onMounted, ref, type Ref } from 'vue';
 import { useTheme } from '../theme/ThemeDefine';
 import { RandomUtils } from '@imengyu/imengyu-utils';
 import { useBaseViewStyleBuilder } from '../layout/BaseView';
-import type { FlexProps } from '../layout/FlexView.vue';
-
-export interface TouchableFlexProps extends FlexProps {
-  /**
-   * 是否可以点击
-   * @default true
-   */
-  touchable?: boolean,
-  /**
-   * 按下时的颜色
-   * @default 'background.press'
-   */
-  pressedColor?: string,
-  /**
-   * 按下时的透明度(仅在 pressedColor 未设置时有效)
-   * @default 0.7
-   */
-  activeOpacity?: number,
-}
+import { TouchableClickEventInceptorKey, type TouchableFlexProps } from './Touchable';
 
 const props = withDefaults(defineProps<TouchableFlexProps>(), {
   activeOpacity: 0.7,
   touchable: true,
+  setCursor: true,
 });
 
 const themeContext = useTheme();
@@ -62,9 +45,10 @@ const finalStyle = computed(() => {
       obj.backgroundColor = themeContext.resolveThemeColor(props.pressedColor);
   } else if (props.activeOpacity != undefined) 
     obj.opacity = isPressed.value ? props.activeOpacity : 1;
-  const o = {
+  const o : Record<string, any> = {
     ...commonStyle.value,
-    ...obj
+    ...obj,
+    cursor: props.setCursor ? (props.touchable ? 'pointer' : 'not-allowed') : 'default',
   } 
   for (const key in o) {
     if (o[key] === undefined)
@@ -80,7 +64,8 @@ defineOptions({
   }
 })
 const emit = defineEmits([ "click", "state" ]);
-const isPressed = ref(false)
+const isPressed = ref(false);
+const clickEventInceptor = inject<(Ref<() => void>)|null>(TouchableClickEventInceptorKey, null);
 
 function handleTouchStart() {
   if (props.touchable) {
@@ -97,6 +82,10 @@ function handleMouseLeave() {
     emit('state', 'default')
 }
 function handleClick(e: Event) {
+  if (clickEventInceptor && clickEventInceptor.value) {
+    clickEventInceptor.value();
+    return;
+  }
   if (props.touchable) {
     emit('state', 'default')
     emit('click', e);

+ 1 - 1
src/components/form/Calendar.vue

@@ -35,7 +35,7 @@
           <template #item="{ item }">
             <FlexCol position="relative">
               <FlexRow justify="center">
-                <Text>{{ item.title }}</Text>
+                <Text :text="item.title" />
               </FlexRow>
               <FlexRow innerClass="nana-calendar-days" wrap justify="flex-start">
                 <template

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

@@ -115,8 +115,6 @@ const confirmDisabled = computed(() => {
   }
 })
 
-
-
 const popupShow = ref(false);
 
 const {
@@ -146,6 +144,7 @@ const {
   [],
   props.shouldUpdateValueImmediately,
   props.beforeConfirm,
+  popupShow,
 );
 
 watch(tempValue, (v) => {

+ 1 - 1
src/components/form/CascadePicker.vue

@@ -110,7 +110,7 @@ function loadCols() {
       }
     } 
   }
-  emit('selectTextChange', selectText.join(' '));
+  emit('selectTextChange', selectText.join(' '), true);
 }
 
 watch(() => props.value, (v) => {

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

@@ -116,6 +116,8 @@ const {
   emit as any,
   [],
   props.shouldUpdateValueImmediately,
+  undefined,
+  popupShow,
 );
 
 defineOptions({

+ 13 - 3
src/components/form/CascaderField.vue

@@ -26,18 +26,19 @@
   <Text 
     v-if="showSelectText"
     :size="30"
-    :color="selectText ? 'text.content' : 'text.second'"
+    :color="selectText && !(readonly || disabled) ? 'text.content' : 'text.second'"
     :text="selectText || placeholder" 
     :maxWidth="300"
+    textAlign="left"
     v-bind="textProps"
   />
 </template>
 
 <script setup lang="ts">
-import { nextTick, onMounted, ref, toRef, watch } from 'vue';
+import { ref, toRef, watch } from 'vue';
 import { useFieldChildValueInjector } from './FormContext';
 import { usePickerFieldTempStorageData } from './PickerUtils';
-import type { CascaderItem, CascaderProps } from './Cascader.vue';
+import type { CascaderProps } from './Cascader.vue';
 import Popup from '../dialog/Popup.vue';
 import Cascader from './Cascader.vue';
 import Height from '../layout/space/Height.vue';
@@ -46,6 +47,14 @@ import PopupTitle from '../dialog/PopupTitle.vue';
 import { getCascaderText } from './CascaderUtils';
 
 export interface CascaderFieldProps extends Omit<CascaderProps, 'modelValue'> {
+  /**
+   * 是否禁用
+   */
+  disabled?: boolean,
+  /**
+   * 是否只读
+   */
+  readonly?: boolean,
   
   modelValue?: (string|number)[];
   /**
@@ -127,6 +136,7 @@ const {
   [],
   props.shouldUpdateValueImmediately,
   props.beforeConfirm,
+  popupShow,
 );
 
 function onPickEnd() {

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

@@ -116,6 +116,8 @@ const {
   emit as any,
   new Date(),
   props.shouldUpdateValueImmediately,
+  undefined,
+  popupShow,
 );
 
 defineOptions({

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

@@ -119,6 +119,8 @@ const {
   emit as any,
   new Date(),
   props.shouldUpdateValueImmediately,
+  undefined,
+  popupShow,
 );
 
 defineOptions({

+ 24 - 12
src/components/form/Field.vue

@@ -10,6 +10,7 @@
       ...(focused ? activeFieldStyle : {}),
       ...(error || finalErrorMessage ? errorFieldStyle : {})
     }"
+    :setCursor="false"
     :direction="labelPosition === 'top' ? 'column' : 'row'"
     :justify="labelPosition === 'top' ? 'flex-start' : 'center'"
     @click="onClick"
@@ -100,8 +101,7 @@
             :placeholder-style="`color: ${themeContext.resolveThemeColor(error ? errorTextColor : placeholderTextColor)}`"
             confirm-type="done"
             :maxlength="maxLength"
-            :disabled="disabled"
-            :readonly="readonly"
+            :disabled="disabled || readonly"
             @input="onInput"
             @focus="onFocus"
             @blur="onBlur"
@@ -132,8 +132,7 @@
               email: 'text',
             }) || 'text'"
             :maxlength="maxLength"
-            :disabled="disabled"
-            :readonly="readonly"
+            :disabled="disabled || readonly"
             @input="onInput"
             @focus="onFocus"
             @blur="onBlur"
@@ -162,7 +161,7 @@
       </FlexRow>
       <!-- 额外的提示信息 -->
       <FlexRow 
-        v-if="extraMessage"
+        v-if="extraMessage && !readonly"
         :gap="10"
         :innerStyle="themeStyles.extraMessage.value"
         align="center"
@@ -194,7 +193,7 @@
 </template>
 
 <script setup lang="ts">
-import { computed, inject, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue';
+import { computed, inject, onBeforeUnmount, onMounted, provide, ref, toRef, watch } from 'vue';
 import { propGetThemeVar, useTheme, type TextStyle, type ViewStyle } from '../theme/ThemeDefine';
 import { FormItemContextContextKey, propGetFormContext, type FormContext, type FormItemContext, type FormItemInternalContext } from './FormContext';
 import { DynamicColor, DynamicSize, DynamicVar, selectStyleType } from '../theme/ThemeTools';
@@ -382,7 +381,7 @@ export interface FieldProps {
   errorFieldStyle?: ViewStyle;
   /**
    * 错误时的文字颜色
-   * @default Color.danger
+   * @default danger
    */
   errorTextColor?: string;
 
@@ -496,11 +495,11 @@ const emit = defineEmits([ 'update:modelValue', 'click', 'blur', 'focus', 'clear
 
 const props = withDefaults(defineProps<FieldProps>(), {
   label: '',
-  labelColor: () => propGetThemeVar('FieldLabelColor', propGetFormContext()?.fieldProps.value?.labelColor ?? 'text'),
+  labelColor: () => propGetThemeVar('FieldLabelColor', propGetFormContext()?.fieldProps.value?.labelColor ?? 'text.content'),
   labelDisableColor: () => propGetThemeVar('FieldLabelDisableColor', propGetFormContext()?.fieldProps.value?.labelDisableColor ?? 'grey'),
   labelFlex: () => propGetThemeVar('FieldLabelFlex', propGetFormContext()?.labelFlex.value)!,
   inputDisableColor: () => propGetThemeVar('FieldInputDisableColor', propGetFormContext()?.fieldProps.value?.inputDisableColor ?? 'grey'),
-  inputColor: () => propGetThemeVar('FieldInputColor', propGetFormContext()?.fieldProps.value?.inputColor ?? 'text'),
+  inputColor: () => propGetThemeVar('FieldInputColor', propGetFormContext()?.fieldProps.value?.inputColor ?? 'text.content'),
   inputFlex: () => propGetThemeVar('FieldInputFlex', propGetFormContext()?.inputFlex.value ?? 5),
   placeholderTextColor: () => propGetThemeVar('FieldPlaceholderTextColor', propGetFormContext()?.fieldProps.value?.placeholderTextColor ?? 'text.second'),
   errorTextColor: () => propGetThemeVar('FieldErrorTextColor', propGetFormContext()?.fieldProps.value?.errorTextColor ?? 'danger'),
@@ -588,6 +587,8 @@ const formItemContext : FormItemContext = {
   setOnClickListener(listener: (() => void)|undefined) {
     childOnClickListener.value = listener;
   },
+  disabled: toRef(props, 'disabled'),
+  readonly: toRef(props, 'readonly'),
 }
 provide(FormItemContextContextKey, formItemContext);
 
@@ -668,6 +669,12 @@ const themeStyles = themeContext.useThemeStyles({
     alignItems: 'center',
   },
 });
+const readonly = computed(() => {
+  return props.readonly || formContextProps?.readonly.value || false;
+});
+const disabled = computed(() => {
+  return props.disabled || formContextProps?.disabled.value || false;
+});
 
 const requiredShow = computed(() => {
   return props.required == true || formContextProps?.getItemRequieed(formItemInternalContext);
@@ -740,17 +747,22 @@ function onClear() {
   emit('clear'); 
 }
 function onClick() {
-  childOnClickListener.value?.();
-  emit('click');
   if (props.resetErrorOnClick)
     fieldInstance.clearValidate();
+  if (props.disabled || props.readonly)
+    return;
+  childOnClickListener.value?.();
+  emit('click');
 }
 
 //#region 标签输入框事件处理
 
 const tagInputString = ref('');
 const tagSplited = computed<string[]>(() => {
-  return (props.modelValue  as string|| '').split(props.tagJoinType).filter(x => x);
+  let str = props.modelValue;
+  if (typeof str === 'string')
+    return (props.modelValue  as string|| '').split(props.tagJoinType).filter(x => x);
+  return [];
 });
 
 function onTagDelete(tag: string) {

+ 3 - 1
src/components/form/Form.vue

@@ -173,7 +173,7 @@ function accessFormModel(keyName: string, isSet: boolean|undefined, setValue: un
 } 
 const { 
   labelFlex, inputFlex, colon, labelAlign, labelPosition, labelWidth, model, rules,
-  validateTrigger, showLabel, addRequireMark, name, fieldProps,
+  validateTrigger, showLabel, addRequireMark, name, fieldProps, disabled, readonly,
 } = toRefs(props);
 
 //Context
@@ -224,6 +224,8 @@ const formContext : FormContext = {
   inputFlex,
   showLabel,
   fieldProps,
+  disabled,
+  readonly,
 };
 
 function getItemRule(name: string) {

+ 26 - 0
src/components/form/FormContext.ts

@@ -44,6 +44,14 @@ export type FormItemContext = {
    * @returns 表单组件中的当前值
    */
   getFormModelValue(): any;
+  /**
+   * 表单项是否禁用的状态
+   */
+  disabled: Ref<boolean|undefined>,
+  /**
+   * 表单项是否只读的状态
+   */
+  readonly: Ref<boolean|undefined>,
 };
 export type FormItemInternalContext = {
   /**
@@ -82,6 +90,8 @@ export type FormContext = {
   showLabel: Ref<boolean|undefined>;
   name: Ref<string|undefined>;
   fieldProps: Ref<FieldProps|undefined>,
+  disabled: Ref<boolean|undefined>,
+  readonly: Ref<boolean|undefined>,
   getItemValue: (item: FormItemInternalContext) => unknown;
   getItemRequieed: (item: FormItemInternalContext) => boolean;
 };
@@ -147,6 +157,7 @@ export function useFieldChildValueInjector<T>(
 ) {
   const cellContext = useCellContext();
   const context = useInjectFormItemContext();
+  const formContext = useInjectFormContext();
   const shadowRefValue = ref(propsModelValue.value ?? context.getFormModelValue() ?? initialValue) as Ref<T>;
 
   const value = computed(() => {
@@ -179,6 +190,9 @@ export function useFieldChildValueInjector<T>(
       cellContext.setOnClickListener(fieldClick);
   }
 
+  const disabled = computed(() => formContext.disabled.value || context.disabled.value);
+  const readonly = computed(() => formContext.readonly.value || context.readonly.value);
+
   return {
     /**
      * 临时值
@@ -189,5 +203,17 @@ export function useFieldChildValueInjector<T>(
      * 表单项上下文
      */
     context,
+    /**
+     * 表单上下文
+     */
+    formContext,
+    /**
+     * 指示顶层由表单和表单项设置的禁用状态
+     */
+    disabled,
+    /**
+     * 指示顶层由表单和表单项设置的只读状态
+     */
+    readonly,
   }
 }

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

@@ -7,6 +7,7 @@
       ...finalBoxStyle,
       ...boxStyle,
     }"
+    :setCursor="false"
     :pressedColor="themeContext.resolveThemeColor('pressed.white')"
     :touchable="!disableKeyPad"
     direction="column"

+ 3 - 2
src/components/form/Picker.vue

@@ -11,6 +11,7 @@
         width: themeContext.resolveThemeSize(pickerWidth),
         height: themeContext.resolveThemeSize(pickerHeight),
       }"
+      immediate-change
       @change="bindChange" 
     >
       <picker-view-column v-for="(column,c) in columns" :key="c">
@@ -98,13 +99,13 @@ function loadValues() {
   value.forEach((v,i) => {
     const index = props.columns[i]?.findIndex((item) => item.value === v);
     pickerSelectIndex.value[i] = index < 0 ? 0 : index;
-  })
+  });  
   emit('selectTextChange', pickerSelectIndex.value.map((p, i) => {
     const cols = props.columns[i];
     if (!cols || cols.length === 0) 
       return null;
     return cols[p]?.text ?? cols[0]?.text ?? null;
-  }).join(' '));
+  }).join(' '), true);
 }
 
 watch(() => props.value, (v) => {

+ 11 - 1
src/components/form/PickerField.vue

@@ -21,7 +21,7 @@
   <Text
     v-if="showSelectText"
     :size="30"
-    :color="selectText ? 'text.content' : 'text.second'"
+    :color="selectText && !(readonly || disabled) ? 'text.content' : 'text.second'"
     :text="selectText || placeholder" 
     :maxWidth="300"
     v-bind="textProps"
@@ -68,6 +68,14 @@ export interface PickerFieldProps extends Omit<PickerProps, 'value'> {
    * 显示的文本属性
    */
   textProps?: TextProps,
+  /**
+   * 是否禁用
+   */
+  disabled?: boolean,
+  /**
+   * 是否只读
+   */
+  readonly?: boolean,
 }
 
 const emit = defineEmits([ 'update:modelValue', 'cancel', 'confirm', 'selectTextChange', 'tempValueChange' ]);
@@ -111,6 +119,8 @@ const {
   emit as any,
   [],
   props.shouldUpdateValueImmediately,
+  undefined,
+  popupShow,
 );
 
 defineOptions({

+ 50 - 6
src/components/form/PickerUtils.ts

@@ -1,5 +1,19 @@
-import { ref, watch, type Ref } from "vue";
+import { nextTick, ref, watch, type Ref } from "vue";
 
+/**
+ * 选择器字段临时存储数据的组合式函数
+ * 用于管理选择器组件的临时值、选择文本和交互逻辑
+ * 
+ * @template T - 值的类型
+ * @param 当前值的响应式引用
+ * @param 更新值的回调函数
+ * @param 关闭弹窗的回调函数
+ * @param 事件发射器函数
+ * @param 默认的新值
+ * @param 是否立即更新值
+ * @param 确认前的回调函数,返回true时取消确认
+ * @param 弹窗显示状态的响应式引用
+ */
 export function usePickerFieldTempStorageData<T>(
   value: Ref<T>, 
   updateValue: (d: T) => void,
@@ -8,24 +22,43 @@ export function usePickerFieldTempStorageData<T>(
   defaultNewValue: T,
   shouldUpdateValueImmediately: boolean,
   beforeConfirm?: ((value: T) => Promise<boolean>) | undefined,
+  popupShow?: Ref<boolean>,
 ) {
 
   let tempSelectText = '';
+  let tempLastSelectText = '';
+  // 临时值的响应式引用,初始值为当前值或默认新值
   const tempValue = ref(value.value ?? defaultNewValue) as Ref<T>;
+  // 显示的文本的响应式引用
   const selectText = ref('');
 
+  /**
+   * 当选择文本变化时的处理函数
+   * @param 新的选择文本
+   * @param 是否强制更新显示的选择文本
+   */
   function onSelectTextChange(t: string, forceUpdate = false) {
     tempSelectText = t;
     emit('selectTextChange', t);
     if (forceUpdate)
       selectText.value = t;
   }
+
+  /**
+   * 取消选择的处理函数
+   */
   function onCancel() {
     closePopup();
     emit('cancel');
+    selectText.value = tempLastSelectText;
   }
+
+  /**
+   * 确认选择的处理函数
+   */
   async function onConfirm() {
     closePopup();
+    // 如果有确认前回调且返回true,则取消确认
     if (beforeConfirm && await beforeConfirm(tempValue.value))
       return;
     selectText.value = tempSelectText;
@@ -42,12 +75,23 @@ export function usePickerFieldTempStorageData<T>(
       updateValue(tempValue.value);
   });
 
+  // 如果提供了弹窗显示状态引用,则监听其变化
+  if (popupShow) {
+    watch(popupShow, (v) => {
+      if (v) {
+        // 弹窗显示时,记录当前的选择文本作为上一次的选择文本
+        nextTick(() => {
+          tempLastSelectText = tempSelectText;
+        });
+      }
+    })
+  }
 
   return {
-    onSelectTextChange,
-    onCancel,
-    onConfirm,
-    selectText,
-    tempValue,
+    onSelectTextChange, // 选择文本变化处理函数
+    onCancel, // 取消处理函数
+    onConfirm, // 确认处理函数
+    selectText, // 显示的选择文本
+    tempValue, // 临时值
   }
 }

+ 3 - 1
src/components/form/Radio.vue

@@ -160,6 +160,8 @@ const props = withDefaults(defineProps<RadioBoxProps>(), {
   icon: 'check-mark',
 });
 
+const disabled = computed(() => props.disabled || groupContext?.topDisabled.value);
+
 const themeContext = useTheme();
 const themeStyles = themeContext.useThemeStyles({
   checkBox: {
@@ -191,7 +193,7 @@ const value = computed(() => {
 });
 
 function switchOn() {
-  if (props.disabled)
+  if (disabled.value)
     return;
   groupContext!.onValueChange(props.name, true);
 }

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

@@ -32,6 +32,7 @@ export interface RadioBoxGroupToggleOptions {
 }
 export interface RadioBoxGroupContextInfo {
   value: Ref<string|number|boolean>,
+  topDisabled: Ref<boolean>,
   onValueChange: (name: string|number|boolean|undefined, v: boolean) => void;
   onAddItem: (name: string, disabled: boolean) => void;
 }
@@ -51,6 +52,7 @@ const {
 
 provide<RadioBoxGroupContextInfo>('RadioBoxGroupContext', {
   value: value as any,
+  topDisabled: toRef(props, 'disabled'),
   onValueChange,
   onAddItem: (name, disabled) => {
     allCheckNames.push(name);

+ 2 - 2
src/components/form/Slider.vue

@@ -83,7 +83,7 @@ export interface SliderProps {
   size?: string|number;
   /**
    * 开关点的颜色
-   * @default white
+   * @default lightButton
    */
   dotColor?: string;
   /**
@@ -127,7 +127,7 @@ const props = withDefaults(defineProps<SliderProps>(), {
   modelValue: 50,
   activeColor: () => propGetThemeVar('SliderActiveColor', 'primary'),
   inactiveColor: () => propGetThemeVar('SliderInactiveColor', 'background.switch'),
-  dotColor: () => propGetThemeVar('SliderDotColor', 'white'),
+  dotColor: () => propGetThemeVar('SliderDotColor', 'lightButton'),
   dotSize: () => propGetThemeVar('SliderDotSize', 40),
   disabled: false,
   direction: 'horizontal',

+ 19 - 2
src/components/form/Stepper.vue

@@ -10,13 +10,15 @@
       <IconButton
         :icon="minusIcon"
         :disabled="disabled || value <= min"
+        :color="buttonColor"
         :buttonStyle="{ 
           ...themeStyles.button.value,
           ...selectObjectByType(size, 'medium', {
             small: themeStyles.buttonSmall.value,
             medium: themeStyles.buttonMedium.value,
             large: themeStyles.buttonLarge.value,
-          })
+          }),
+          ...buttonStyle,
         }"
         :padding="5"
         :size="iconSize"
@@ -39,6 +41,7 @@
           medium: themeStyles.inputWrapperMedium.value,
           large: themeStyles.inputWrapperLarge.value,
         }),
+        ...inputStyle,
       }">
         <input
           :style="{
@@ -62,13 +65,15 @@
       <IconButton
         :icon="addIcon"
         :disabled="disabled || (max ? value >= max : undefined)"
+        :color="buttonColor"
         :buttonStyle="{ 
           ...themeStyles.button.value,
           ...selectObjectByType(size, 'medium', {
             small: themeStyles.buttonSmall.value,
             medium: themeStyles.buttonMedium.value,
             large: themeStyles.buttonLarge.value,
-          })
+          }),
+          ...buttonStyle,
         }"
         :padding="5"
         :size="iconSize"
@@ -180,6 +185,18 @@ export interface StepperProps {
    * 组件右侧添加的文本
    */
   addonAfter?: string,
+  /**
+   * 按钮颜色
+   */
+  buttonColor?: string,
+  /**
+   * 自定义按钮样式
+   */
+  buttonStyle?: Record<string, any>,
+  /**
+   * 自定义输入框样式
+   */
+  inputStyle?: Record<string, any>,
 }
 
 const emit = defineEmits([ 'update:modelValue' ])

+ 2 - 2
src/components/form/Switch.vue

@@ -80,7 +80,7 @@ export interface SwitchProps {
   inverseColor?: string;
   /**
    * 开关点的颜色
-   * @default white
+   * @default lightButton
    */
   dotColor?: string;
   /**
@@ -113,7 +113,7 @@ const props = withDefaults(defineProps<SwitchProps>(), {
   native: false,
   color: () => propGetThemeVar('SwitchColor', 'primary'),
   inverseColor: () => propGetThemeVar('SwitchInverseColor', 'background.switch'),
-  dotColor: () => propGetThemeVar('SwitchDotColor', 'white'),
+  dotColor: () => propGetThemeVar('SwitchDotColor', 'lightButton'),
   loading: false,
   disabled: false,
   impactFeedback: () => propGetThemeVar('SwitchImpactFeedback', true),

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

@@ -112,6 +112,8 @@ const {
   emit as any,
   new Date(),
   props.shouldUpdateValueImmediately,
+  undefined,
+  popupShow,
 );
 
 defineOptions({

+ 4 - 0
src/components/form/Uploader.ts

@@ -31,6 +31,10 @@ export interface UploaderItem {
    * 当前上传进度,0-100
    */
   progress?: number;
+  /**
+   * 取消上传回调
+   */
+  cancelUpload?: () => void;
 }
 export interface UploaderAction {
   /**

+ 164 - 58
src/components/form/Uploader.vue

@@ -2,11 +2,13 @@
   <FlexCol>
     <Toast ref="toast" />
     <DialogRoot ref="dialog" />
+    <!-- #ifndef MP -->
     <slot 
       name="uploader" 
       :onClick="onUploadPress"
       :items="currentUpladList"
     >
+    <!-- #endif -->
       <FlexView
         v-if="showUpload"
         :direction="props.listType === 'grid' ? 'row' : 'column'"
@@ -17,6 +19,7 @@
           v-for="(item, index) in currentUpladList"
           :key="index"
         >
+          <!-- #ifndef MP -->
           <slot 
             name="uploadItem"
             :index="index"
@@ -30,11 +33,11 @@
             :itemSize="itemSize"
             :showDelete="showDelete"
             :defaultSource="props.itemDefaultSource"
-              
           >
+          <!-- #endif -->
             <UploaderListItem
               :item="item"
-              :showDelete="showDelete"
+              :showDelete="showDelete && !disabled && !readonly"
               :isListStyle="props.listType === 'list'"
               :style="itemStyle"
               :imageStyle="itemImageStyle"
@@ -45,21 +48,29 @@
               @click="() => onItemPress(item)"
               @delete="() => onItemDeletePress(item)"
             />
+          <!-- #ifndef MP -->
           </slot>
+          <!-- #endif -->
         </template>
-        <slot v-if="currentUpladList.length < maxUploadCount" name="addButton" :onUploadPress="onUploadPress" :itemSize="itemSize">
+        <slot v-if="currentUpladList.length < maxUploadCount && !disabled" name="addButton" :onUploadPress="onUploadPress" :itemSize="itemSize">
           <UploaderListAddItem :itemSize="itemSize" :style="itemStyle" @click="onUploadPress" :isListStyle="props.listType === 'list'" />
         </slot>
+        <slot v-if="readonly && currentUpladList.length === 0">
+          <Text color="text.second">暂无上传文件</Text>
+        </slot>
       </FlexView>
       <slot v-else name="addButton" :onUploadPress="onUploadPress" :itemSize="itemSize">
         <UploaderListAddItem :itemSize="itemSize" :style="itemStyle" @click="onUploadPress" :isListStyle="props.listType === 'list'" />
       </slot>
+      
+    <!-- #ifndef MP -->
     </slot>
+    <!-- #endif -->
   </FlexCol>
 </template>
 
 <script setup lang="ts">
-import { ref } from 'vue';
+import { reactive, ref, watch } from 'vue';
 import { propGetThemeVar, useTheme, type TextStyle, type ViewStyle } from '../theme/ThemeDefine';
 import type { ToastInstance } from '../feedback/Toast.vue';
 import Toast from '../feedback/Toast.vue';
@@ -69,7 +80,9 @@ import UploaderListItem from './UploaderListItem.vue';
 import FlexView from '../layout/FlexView.vue';
 import FlexCol from '../layout/FlexCol.vue';
 import type { UploaderAction, UploaderItem } from './Uploader';
-import { LogUtils } from '@imengyu/imengyu-utils';
+import { Debounce, LogUtils } from '@imengyu/imengyu-utils';
+import Text from '../basic/Text.vue';
+import { actionSheet } from '../dialog/CommonRoot';
 
 const themeContext = useTheme();
 const TAG = 'Uploader';
@@ -91,6 +104,11 @@ export interface UploaderProps {
    */
   disabled?: boolean;
   /**
+   * 是否只读,只读状态下不会显示上传按钮,也不能删除已上传的文件。
+   * @default false
+   */
+  readonly?: boolean;
+  /**
    * 是否显示文件已上传列表。
    * @default true
    */
@@ -159,12 +177,18 @@ export interface UploaderProps {
    * * video:视频
    * @default 'image'
    */
-  chooseType?: 'image'|'video'|'file';
+  chooseType?: 'image'|'video'|'file'|'';
+  /**
+   * 是否是从消息中选择文件
+   * @default true
+   */
+  formMessage?: boolean;
+
   /**
    * 上传处理。不提供则无法上传
    * @required true
    */
-  upload: (item: UploaderAction) => void;
+  upload: (item: UploaderAction) => (() => void);
   /**
    * 自定义选择文件组件,你可以调用自己的文件选择器。默认调用 ImagePicker 选择文件.
    */
@@ -245,14 +269,6 @@ export interface UploaderInstance {
   pick: () => void;
 }
 
-const isImageExt = [
-  '.png',
-  '.jpg',
-  '.jpeg',
-  '.bmp',
-  '.webp',
-];
-
 const toast = ref<ToastInstance>();
 const dialog = ref<DialogAlertRoot>();
 
@@ -264,13 +280,15 @@ const props = withDefaults(defineProps<UploaderProps>(), {
   showDelete: true,
   showUpload: true,
   uploadWhenAdded: true,
+  autoUpdateUploadList: true,
+  formMessage: true,
   uploadQueueMode: 'all',
   listType: 'grid',
   chooseType: 'image',
   itemSize: () => propGetThemeVar('UploaderItemSize', { width: 750 / 4 - 15, height: 750 / 4 - 15 }),
 });
 
-const currentUpladList = ref<UploaderItem[]>(props.intitalItems || []);
+const currentUpladList = ref<UploaderItem[]>(props.intitalItems?.concat() || []);
 
 //上传按钮点击
 function onUploadPress() {
@@ -293,11 +311,8 @@ function onUploadPress() {
       }[]) {
         resolve(res.map((item) => {
           let isImage = typeof (item as any).type === 'string' ? (item as any).type.startsWith('image/') : false;
-          for (const ext of isImageExt) {
-            if (item.path.endsWith(ext)) {
-              isImage = true;
-              break;
-            }
+          if (!isImage) {
+            isImage = isImagePath(item.path);
           }
           return {
             filePath: item.path,
@@ -305,34 +320,80 @@ function onUploadPress() {
             size: item.size,
             state: 'notstart',
             isImage,
-          } as UploaderItem
+            message: '',
+            progress: 0,
+          } as UploaderItem;
         }))
       }
-
-      switch (props.chooseType) {
-        case 'video':
-          uni.chooseVideo().then((res) => handleFiles([
+      //#ifdef MP
+      if (props.formMessage) {
+        actionSheet({
+          title: '选择上传方式',
+          actions: [
             {
-              path: res.tempFilePath,
-              size: res.size,
-            }
-          ])).catch(reject);
-          break;
-        case 'file':
-          uni.chooseFile().then((res) => handleFiles(res.tempFiles as { path: string; size: number; }[])).catch(reject);
-          break;
-        default:
-        case 'image':
-          uni.chooseImage({
-            count: props.maxUploadCount - currentUpladList.value.length,
-          }).then((res) => handleFiles(res.tempFiles as { path: string; size: number; }[])).catch(reject);
-          break;
+              name: '从相册选择',
+              subname: '从相机立即拍摄或者相册中选择照片/视频',
+            },
+            {
+              name: '从微信聊天中选择',
+              subname: '可在录音机或者WPS文档分享给文件传输助手\n然后选择任意文档文件',
+            },
+          ],
+          onSelect(index, name) {
+          },
+        }).then((index) => {
+          if (index === 0) {
+            chooseLocal();
+          } else if (index === 1) {
+            uni.chooseMessageFile({
+              type: props.chooseType || 'all',
+              count: props.maxUploadCount - currentUpladList.value.length,
+              success: (res) => {
+                LogUtils.printLog(TAG, 'info', 'chooseMessageFile', res);
+                handleFiles(res.tempFiles as { path: string; size: number; }[])
+              },
+              fail: (e) => {
+                LogUtils.printLog(TAG, 'error', 'chooseMessageFile', e);
+                reject(e);
+              }
+            });
+          }
+        });
+
+      } else {
+        chooseLocal();
+      }
+      //#endif
+      //#ifndef MP
+      chooseLocal();
+      //#endif
+
+      function chooseLocal() {
+        switch (props.chooseType) {
+          case 'video':
+            uni.chooseVideo().then((res) => handleFiles([
+              {
+                path: res.tempFilePath,
+                size: res.size,
+              }
+            ])).catch(reject);
+            break;
+          case 'file':
+            uni.chooseFile().then((res) => handleFiles(res.tempFiles as { path: string; size: number; }[])).catch(reject);
+            break;
+          default:
+          case 'image':
+            uni.chooseImage({
+              count: props.maxUploadCount - currentUpladList.value.length,
+            }).then((res) => handleFiles(res.tempFiles as { path: string; size: number; }[])).catch(reject);
+            break;
+        }
       }
     });
 
   items
-    .then((res) => {
-      if (props.maxFileSize > 0)
+    .then((res) => { 
+      if (props.maxFileSize > 0) {
         res = res.filter((item) => {
           if (item.size && item.size > props.maxFileSize) {
             props.onOverSize?.(item);
@@ -340,16 +401,36 @@ function onUploadPress() {
           }
           return true;
         });
+        if (res.length === 0) {
+          toast.value?.text('您选择的文件过大,请重新选择!');
+          return;
+        }
+      }
+
+      res = res.map((item) => {
+        item.state = 'notstart';
+        item.message = '';
+        item.progress = 0;
+        return reactive(item);
+      });
+
       //添加条目
       currentUpladList.value = props.maxUploadCount > 1 ? currentUpladList.value.concat(res) : res;
       //自动上传
       if (props.uploadWhenAdded)
-        startUploadMulitItem(res);
+        startUploadMulitItem(currentUpladList.value);
     })
     .catch((e) => console.warn('PickImage failed', e));
 }
 //条目点击
 function onItemPress(item: UploaderItem) {
+  if (props.readonly) {
+    props.onPreviewClick ?
+      props.onPreviewClick(item) :
+      onItemPreview(item); //默认预览
+    return;
+  }
+
   if (item.state === 'fail') {
     props.onRetryClick ?
       props.onRetryClick(item) :
@@ -365,7 +446,7 @@ function onItemPress(item: UploaderItem) {
 function onItemPreview(item: UploaderItem) {
   //判断后缀是不是图片
   const previewPath = item.previewPath || item.uploadedPath || item.filePath;
-  if (item.isImage) {
+  if (item.isImage || isImagePath(previewPath)) {
     uni.previewImage({
       urls: [ 
         previewPath 
@@ -379,6 +460,8 @@ function onItemPreview(item: UploaderItem) {
 }
 //条目删除点击
 function onItemDeletePress(item: UploaderItem) {
+  if (props.disabled || props.readonly)
+    return;
   props.onDeleteClick ?
     props.onDeleteClick(item).then(() => {
       deleteListItem(item);
@@ -393,20 +476,28 @@ function onItemDeletePress(item: UploaderItem) {
 }
 //更新列表条目
 function updateListItem(item: UploaderItem) {
-  currentUpladList.value = ((prev) => {
-    const newList = prev.concat();
-    const index = prev.findIndex((k) => k.filePath === item.filePath);
-    index >= 0 ? newList[index] = { ...item } : newList.push(item);
-    return newList;
-  })(currentUpladList.value);
   emit('updateList', currentUpladList.value);
 }
 //删除列表条目
 function deleteListItem(item: UploaderItem) {
   currentUpladList.value = currentUpladList.value.filter((k) => k.filePath !== item.filePath);
+
+  //如果正在上传,先取消上传
+  if (item.state === 'uploading') {
+    item.cancelUpload?.();
+    item.state = 'fail';
+  }
+}
+
+function isImagePath(path: string) {
+  return path.match(/\.(jpg|jpeg|png|gif|webp)$/) !== null;
 }
+
+
 //开始上传条目
 function startUploadItem(item: UploaderItem) {
+  if (item.state === 'uploading')
+    return;
   return new Promise<void>((resolve, reject) => {
     if (item.state === 'success') {
       resolve();
@@ -414,7 +505,14 @@ function startUploadItem(item: UploaderItem) {
     }
     LogUtils.printLog(TAG, 'message', `调用上传文件 ${item.filePath}`);
 
-    props.upload({
+    const updateProgressDebounce = new Debounce<number>(400, (precent) => {
+      item.state = 'uploading';
+      item.message = precent ? `上传中 ${precent}%` : '上传中...';
+      item.progress = precent;
+      updateListItem(item);
+    });
+
+    item.cancelUpload = props.upload({
       item,
       onError(error) {
         item.state = 'fail';
@@ -424,6 +522,7 @@ function startUploadItem(item: UploaderItem) {
         LogUtils.printLog(TAG, 'error', `上传文件 ${item.filePath} 失败,错误信息:${error}`);
       },
       onFinish(result, message) {
+        updateProgressDebounce.cancel();
         item.state = 'success';
         item.message = message || '上传完成';
         item.progress = 100;
@@ -435,10 +534,7 @@ function startUploadItem(item: UploaderItem) {
         LogUtils.printLog(TAG, 'success', `上传文件 ${item.filePath} 成功,上传路径:${result.uploadedUrl}`);
       },
       onProgress(precent) {
-        item.state = 'uploading';
-        item.message = precent ? `${precent}%` : '上传中...';
-        item.progress = precent;
-        updateListItem(item);
+        updateProgressDebounce.executeWithDelay(200, precent);
       },
       onStart(message) {
         item.state = 'uploading';
@@ -465,11 +561,21 @@ defineExpose<UploaderInstance>({
   startUploadAll() {
     return startUploadMulitItem(currentUpladList.value);
   },
-  startUpload(item) {
-    return startUploadItem(item);
+  async startUpload(item) {
+    return await startUploadItem(item);
   },
   setList(list) {
-    currentUpladList.value = list;
+    const needRemoveItems = [] as string[];
+    for (const item of currentUpladList.value) {
+      if (list.findIndex(k => k.filePath === item.filePath) === -1)
+        needRemoveItems.push(item.filePath);
+    }
+    for (const filePath of needRemoveItems)
+      currentUpladList.value.splice(currentUpladList.value.findIndex(k => k.filePath === filePath), 1);
+    list.forEach(item => {
+      if (currentUpladList.value.findIndex(k => k.filePath === item.filePath) === -1)
+        currentUpladList.value.push(item);
+    });
   },
   getList() {
     return currentUpladList.value;

+ 40 - 9
src/components/form/UploaderField.vue

@@ -2,13 +2,14 @@
   <Uploader
     ref="uploaderRef"
     v-bind="props"
+    :modelValue="[]"
     :maxUploadCount="single ? 1 : maxUploadCount"
     @updateList="handleListChange"
   />
 </template>
 
 <script setup lang="ts">
-import { onMounted, ref, toRef } from 'vue';
+import { onMounted, ref, toRef, watch } from 'vue';
 import { useFieldChildValueInjector } from './FormContext';
 import type { UploaderInstance, UploaderProps } from './Uploader.vue';
 import { stringUrlToUploaderItem, type UploaderItem } from './Uploader';
@@ -16,7 +17,16 @@ import Uploader from './Uploader.vue';
 
 export interface UploaderFieldProps extends Omit<UploaderProps, 'value'> {
   modelValue?: string[]|string;
+  /**
+   * 是否为单文件上传
+   * @default false
+   */
   single?: boolean;
+  /**
+   * 是否根据modelValue自动更新已上传列表,当modelValue变化时,会自动更新已上传列表。
+   * @default true
+   */
+  autoUpdateUploadList?: boolean;
 }
 
 const uploaderRef = ref<UploaderInstance>();
@@ -26,6 +36,8 @@ const props = withDefaults(defineProps<UploaderFieldProps>(), {
   showDelete: true,
   showUpload: true,
   uploadWhenAdded: true,
+  formMessage: true,
+  autoUpdateUploadList: true,
 });
 
 const {
@@ -38,21 +50,40 @@ const {
   //() => { /*uploaderRef.value?.pick()*/ },
 );
 
+let nextNoChangeList = false;
+
+watch(() => props.modelValue, (newVal) => {
+  if (props.autoUpdateUploadList) {
+    if (nextNoChangeList) {
+      nextNoChangeList = false;
+      return;
+    }
+    setUploaderList();
+  }
+})
+
 function handleListChange(list: UploaderItem[]) {
-  updateValue(list.map((item) => item.uploadedPath).filter((item) => item) as string[]);
+  nextNoChangeList = true;
+  const res = list.map((item) => item.uploadedPath).filter((item) => item) as string[];
+  updateValue(props.single ? res[0] : res);
+}
+function setUploaderList() {
+  uploaderRef.value?.setList(props.single || typeof value.value === 'string' 
+    ? (value.value ? [ stringUrlToUploaderItem(value.value as any as string) ] : [])
+    : value.value?.map((item) => stringUrlToUploaderItem(item)) ?? []
+  );
 }
 
 onMounted(() => {
-  setTimeout(() => {
-    uploaderRef.value?.setList(props.single || typeof value.value === 'string' 
-      ? (value.value ? [ stringUrlToUploaderItem(value.value as any as string) ] : [])
-      : value.value?.map((item) => stringUrlToUploaderItem(item)) ?? []
-    )
-  }, 200);
+  setTimeout(() => setUploaderList(), 200);
 });
 
 defineExpose({
-  getUploaderRef: () => uploaderRef.value,
+  getUploaderRef: () => {
+    console.log('getUploaderRef', uploaderRef.value);
+    
+    return uploaderRef.value
+  },
 })
 
 export interface UploaderFieldInstance {

+ 2 - 2
src/components/form/UploaderListAddItem.vue

@@ -3,7 +3,7 @@
     v-if="!isListStyle"
     icon="add"
     :size="themeContext.getVar('UploaderAddIconSize', 60)"
-    :pressedBackgroundColor="themeContext.getVar('UploaderListAddItemPressedBackgroundColor', 'pressed.grey')"
+    :pressedBackgroundColor="themeContext.getVar('UploaderListAddItemPressedBackgroundColor', 'pressed.button')"
     :buttonStyle="{
       ...themeStyles.itemAddButton.value, 
       ...props.style, 
@@ -34,7 +34,7 @@ const themeContext = useTheme();
 const themeStyles = themeContext.useThemeStyles({
   itemAddButton: {
     overflow: 'hidden',
-    backgroundColor: DynamicColor('UploaderListAddItemBackgroundColor', 'grey'),
+    backgroundColor: DynamicColor('UploaderListAddItemBackgroundColor', 'button'),
     borderRadius: DynamicSize('UploaderListAddItemBorderRadius', 20),
     margin: DynamicSize('UploaderListAddItemMargin', 4),
   },

+ 16 - 12
src/components/form/UploaderListItem.vue

@@ -18,7 +18,6 @@
     center
     @click="emit('click')" 
   >
-
     <template v-if="!isListStyle">
       <IconButton 
         v-if="showDelete"
@@ -32,14 +31,14 @@
         backgroundColor: themeContext.resolveThemeColor('UploaderListItemUploadingBackgroundColor', 'mask.primary'),
       }">
         <ActivityIndicator :color="itemMaskTextColor" :size="loadingSize" />
-        <Text :color="itemMaskTextColor" :style="itemMaskTextStyle">{{item.message}}</Text>
+        <Text :color="itemMaskTextColor" :style="itemMaskTextStyle" textAlign="center" :text="item.message" />
       </FlexView>
       <FlexView v-if="item.state === 'fail'" :innerStyle="{
         ...itemMaskStyle,
         backgroundColor: themeContext.resolveThemeColor('UploaderListItemUploadingBackgroundColor', 'mask.danger'),
       }">
         <Icon icon="error" :color="itemMaskTextColor" :size="iconSize" />
-        <Text :color="itemMaskTextColor" :style="itemMaskTextStyle">{{item.message}}</Text>
+        <Text :color="itemMaskTextColor" :style="itemMaskTextStyle" textAlign="center" :text="item.message" />
       </FlexView>
     </template>
 
@@ -52,14 +51,16 @@
       :innerStyle="{ 
         ...(isListStyle ? themeStyles.itemListStyleImage.value : themeStyles.itemImage.value), 
         ...imageStyle,
-      }" />
+      }" 
+      mode="aspectFill" 
+    />
     <Icon v-else :icon="fileIcon" :color="itemMaskTextColor" :size="itemListStyleImageSize" />
 
     <FlexRow v-if="isListStyle" :flex="1" align="center">
       <Width :size="20" />
       <FlexCol :flex="1">
-        <Text :fontSize="26">{{ StringUtils.path.getFileName(item.filePath) }}</Text>
-        <Text :fontSize="22">{{ item.message }}</Text>
+        <Text :fontSize="26" :text="StringUtils.path.getFileName(item.filePath)" />
+        <Text :fontSize="22" :text="item.message" />
         <Height :size="10" /> 
         <Progress :progressColor="selectStyleType(item.state, 'notstart', {
           notstart: 'primary',
@@ -78,7 +79,7 @@
 </template>
 
 <script setup lang="ts">
-import { computed } from 'vue';
+import { computed, watch } from 'vue';
 import { useTheme, type TextStyle, type ViewStyle } from '../theme/ThemeDefine';
 import { DynamicColor, DynamicSize, DynamicSize2, selectStyleType } from '../theme/ThemeTools';
 import { StringUtils } from '@imengyu/imengyu-utils';
@@ -198,10 +199,13 @@ const iconSize = computed(() => themeContext.resolveThemeSize('UploaderListItemI
 const loadingSize = computed(() => themeContext.resolveThemeSize('UploaderListItemLoadingSize', 35));
 
 const imageExts = [ 'jpg', 'jpeg', 'png', 'webp', 'gif' ]
-const isImage = computed(() => props.item.isImage 
-  || (props.item.previewPath && imageExts.includes(StringUtils.path.getFileExt(props.item.previewPath)))
-  || imageExts.includes(StringUtils.path.getFileExt(props.item.filePath || ''))
-);
+const isImage = computed(() => {
+  return props.item.isImage 
+    || (props.item.previewPath && imageExts.includes(StringUtils.path.getFileExt(props.item.previewPath)))
+    || (props.item.filePath && imageExts.includes(StringUtils.path.getFileExt(props.item.filePath || ''))
+  )
+});
+
 const fileIcon = computed(() => {
   const ext = StringUtils.path.getFileExt(props.item.filePath || '');
   switch (ext) {
@@ -212,7 +216,7 @@ const fileIcon = computed(() => {
     case 'wav': 
     case 'wma': 
     case 'ogg': 
-    case 'flac': 
+    case 'flac':
       return IconAudio;
     case 'default': 
       return IconDefault;

BIN
src/components/images/icons/video-mark.png


+ 4 - 3
src/components/index.scss

@@ -22,6 +22,7 @@
 wx-action-sheet-item {
   padding: 0 !important;
 }
+
 .remove-button-style {
   margin: 0;
   padding: 0;
@@ -36,13 +37,13 @@ wx-action-sheet-item {
   text-align: inherit;
   -webkit-tap-highlight-color: transparent;
 
-  &::after {
-    border: none;
-  }
   &.button-hover {
     background: none;
     color: inherit;
   }
+  &::after {
+    border: none;
+  }
 }
 
 :root {

+ 1 - 1
src/components/keyboard/NumberKeyBoardInner.vue

@@ -3,7 +3,7 @@
     <!-- 标题  -->
     <FlexRow v-if="title" :padding="[ 8, 0 ]" justify="space-between" align="center">
       <FlexRow :width="150" />
-      <Text :innerStyle="themeStyles.title.value">{{ title }}</Text>
+      <Text :innerStyle="themeStyles.title.value" :text="title" />
       <FlexRow :width="150">
         <Button
           v-if="showCloseButton"

+ 1 - 1
src/components/keyboard/NumberKeyBoardKey.vue

@@ -8,7 +8,7 @@
     @click="emit('click', action, text)"
   >
     <Icon v-if="icon" :icon="text" :innerStyle="context.keyTextStyle.value" />
-    <Text v-else :innerStyle="action === 'finish' ? context.keyTextStyleFinish.value : context.keyTextStyle.value">{{ text }}</Text>
+    <Text v-else :innerStyle="action === 'finish' ? context.keyTextStyleFinish.value : context.keyTextStyle.value" :text="text" />
   </Touchable>
   <view
     v-else

+ 1 - 1
src/components/keyboard/PlateKeyBoardKey.vue

@@ -8,7 +8,7 @@
     @click="emit('click', text, action)"
   >
     <Icon v-if="icon" :icon="text" :innerStyle="context.keyTextStyle.value" />
-    <Text v-else :innerStyle="context.keyTextStyle.value">{{ text }}</Text>
+    <Text v-else :innerStyle="context.keyTextStyle.value" :text="text" />
   </Touchable>
   <view
     v-else

+ 11 - 3
src/components/layout/BaseView.ts

@@ -2,6 +2,7 @@ import { computed } from "vue";
 import { useTheme } from "../theme/ThemeDefine";
 import type { FlexProps } from "./FlexView.vue";
 import { configMargin, configPadding } from "../theme/ThemeTools";
+import { ObjectUtils } from "@imengyu/imengyu-utils";
 
 export function useBaseViewStyleBuilder(props: FlexProps) {
   
@@ -24,14 +25,21 @@ export function useBaseViewStyleBuilder(props: FlexProps) {
       gap: themeContext.resolveThemeSize(props.gap),
       borderRadius: themeContext.resolveThemeSize(props.radius),
       overflow: props.overflow,
+      border: props.border && !props.border.includes(' ') ? themeContext.getVar('border.' + props.border, undefined) : props.border,
+      borderColor: themeContext.resolveThemeColor(props.borderColor),
+      borderWidth: themeContext.resolveThemeSize(props.borderWidth),
+      borderStyle: props.borderStyle,
       boxShadow: props.shadow ? themeContext.getVar('shadow.' + props.shadow, undefined) : undefined,
-      ...(props.innerStyle ? props.innerStyle : {}),
+      zIndex: props.zIndex,
     }
 
     //内边距样式
-    configPadding(obj, themeContext.theme, props.padding as any);
+    configPadding(obj, themeContext.theme.value, props.padding as any);
     //外边距样式
-    configMargin(obj, themeContext.theme, props.margin as any);
+    configMargin(obj, themeContext.theme.value, props.margin as any);
+
+    if (props.innerStyle)
+      ObjectUtils.cloneValuesToObject(props.innerStyle, obj);
 
     if (obj.paddingVertical) {
       if (obj.paddingTop === undefined)

+ 21 - 1
src/components/layout/FlexView.vue

@@ -111,10 +111,26 @@ export interface FlexProps {
    */
   backgroundColor?: string,
   /**
-   * 阴影。使用主中的阴影预设。
+   * 阴影。使用主中的阴影预设。
    */
   shadow?: string,
   /**
+   * 边框。使用主题中的边框预设。
+   */
+  border?: string,
+  /**
+   * 边框颜色
+   */
+  borderColor?: string,
+  /**
+   * 边框宽度
+   */
+  borderWidth?: number|string,
+  /**
+   * 边框样式
+   */
+  borderStyle?: string,
+  /**
    * 宽度
    */
   width?: number|string,
@@ -123,6 +139,10 @@ export interface FlexProps {
    */
   height?: number|string,
   overflow?: 'visible'|'hidden'|'scroll'|'auto'
+  /**
+   * 层级
+   */
+  zIndex?: number,
 }
 
 const props = withDefaults(defineProps<FlexProps>(), {

+ 1 - 0
src/components/layout/grid/GridItem.vue

@@ -4,6 +4,7 @@
     :direction="flexDirection"
     :innerStyle="style"
     :touchable="touchable"
+    :setCursor="false"
     :radius="themeContext.resolveThemeSize(radius)"
     @click="emit('click')"
   >

+ 0 - 2
src/components/nav/IndexBar.vue

@@ -190,9 +190,7 @@ function handleTouchStart(e: any) {
   e.stopPropagation();
   const query = uni.createSelectorQuery();
   query
-    // #ifdef MP
     .in(instance)
-    // #endif
     .select('#' + id)
     .boundingClientRect((res) => {
       if (res)

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

@@ -104,6 +104,7 @@ export interface NavBarProps {
   rightButton?: NavBarButtonTypes,
   /**
    * 是否显示右侧按钮
+   * @default true
    */
   showRightButton?: boolean;
   /**
@@ -117,6 +118,7 @@ export interface NavBarProps {
   rightPillSpaceForce?: number,
   /**
    * 是否显示左侧按钮
+   * @default true
    */
   showLeftButton?: boolean;
   /**

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

@@ -10,7 +10,7 @@
       />
     </slot>
     <slot v-if="simple" name="simple" :text="`${currentPage + 1}/${props.pageCount}`"">
-      <text :style="themeStyles.simpleText.value">{{ `${currentPage + 1}/${props.pageCount}` }}</Text>
+      <text :style="themeStyles.simpleText.value">{{ `${currentPage + 1}/${props.pageCount}` }}</text>
     </slot>
     <template v-else>
       <template  v-for="index in items" :key="index">

+ 7 - 0
src/components/nav/SegmentedControl.vue

@@ -7,6 +7,7 @@
       :plain="true"
       :maxLen="(values?.length ?? 0) - 1"
       :radius="themeContext.resolveSize(radius)!"
+      :textColor="themeContext.resolveThemeColor(textColor)"
       :activeColor="themeContext.resolveThemeColor(tintColor)"
       :activeTextColor="themeContext.resolveThemeColor(activeTextColor)"
       :normalColor="plain ? 
@@ -73,6 +74,11 @@ export interface SegmentedControlProps {
    */
   tintColor?: string | undefined;
   /**
+   * 条目文字颜色。
+   * @default text.content
+   */
+  textColor?: string | undefined;
+  /**
    * 条目激活时的文字颜色。
    * @default white
    */
@@ -94,6 +100,7 @@ const props = withDefaults(defineProps<SegmentedControlProps>(), {
   fill: true,
   plain: false,
   selectedIndex: 0,
+  textColor: () => propGetThemeVar('SegmentedControlTextColor', 'text.content'),
   tintColor: () => propGetThemeVar('SegmentedControlTintColor', 'primary'),
   activeTextColor: () => propGetThemeVar('SegmentedControlActiveTextColor', 'white'),
   radius: 10,

+ 2 - 1
src/components/nav/SegmentedControlItem.vue

@@ -22,7 +22,7 @@
   >
     <text :style="{
       ...itemTextStyle,
-      color: active ? activeTextColor : undefined,
+      color: active ? activeTextColor : textColor,
     }">{{ label }}</Text>
   </Touchable>
 </template>
@@ -40,6 +40,7 @@ export interface SegmentedControlItemProps {
   maxLen: number,
   activeColor?: string,
   normalColor?: string,
+  textColor?: string,
   activeTextColor?: string,
   pressedColor?: string,
   itemStyle: ViewStyle,

+ 7 - 8
src/components/nav/Tabs.vue

@@ -97,11 +97,6 @@ export interface TabsItemData {
    */
   visible?: boolean;
   /**
-   * 是否仅跳转,不触发点击事件。
-   * @default false
-   */
-  onlyJump?: boolean,
-  /**
    * 是否禁用选择。
    * @default false
    */
@@ -336,7 +331,7 @@ onMounted(() => {
 
 function onTabClick(index: number) {
   emit('click', props.tabs[index]);
-  if (props.tabs[index].onlyJump !== true && index !== props.currentIndex) {
+  if (index !== props.currentIndex) {
     emit('update:currentIndex', index);
   }
 }
@@ -344,12 +339,15 @@ function onTabClick(index: number) {
 
 <style lang="scss">
 .nana-tabs {
+  position: relative;
   display: flex;
   flex-direction: row;
-  position: relative;
+  flex-wrap: nowrap;
   flex-shrink: 0;
-  flex-grow: 1;
+  flex-grow: 0;
   height: auto;
+  width: fit-content;
+  overflow: hidden;
 
   .tab-item {
     position: relative;
@@ -357,6 +355,7 @@ function onTabClick(index: number) {
     padding-bottom: 30rpx;
     box-sizing: content-box;
     flex-shrink: 0;
+    flex-grow: 0;
   }
   .tab-item-text {
     font-size: 15px;

+ 88 - 6
src/components/theme/Theme.ts

@@ -1,5 +1,6 @@
 import type { ThemeConfig } from "./ThemeDefine";
 
+/** 默认主题配置 */
 export const DefaultTheme : ThemeConfig = {
   varOverrides: {
     spaceSize: {
@@ -16,15 +17,21 @@ export const DefaultTheme : ThemeConfig = {
       larger: 46,
     },
     shadow: {
-      default: '0 0 10rpx rgba(0, 0, 0, 0.1)',
-      light: '0 0 10rpx rgba(0, 0, 0, 0.05)',
-      dark: '0 0 10rpx rgba(0, 0, 0, 0.2)',
+      default: '0 0 10px rgba(0, 0, 0, 0.1)',
+      light: '0 0 10px rgba(0, 0, 0, 0.05)',
+      dark: '0 0 10px rgba(0, 0, 0, 0.2)',
+      none: 'none',
+    },
+    border: {
+      default: '1px solid #dddddd',
+      none: 'none',
     },
   },
   colorConfigs: {
     default: {
       default: 'transparent',
       button: '#dddddd',
+      lightButton: '#ffffff',
       primary: '#007AFF',
       secondary: '#0462C7',
       success: '#4CAF50',
@@ -36,9 +43,9 @@ export const DefaultTheme : ThemeConfig = {
       dark: '#212121',
       white: '#FFFFFF',
       black: '#000000',
-      grey: '#dddddd',
-      lightGrey: '#cccccc',
-      darkGrey: '#999999',
+      grey: '#999999',
+      lightGrey: '#c3c3c3',
+      darkGrey: '#8e8e8e',
       skeleton: 'rgba(46,50,56, 0.05)',
     },
     pressed: {
@@ -132,6 +139,10 @@ export const DefaultTheme : ThemeConfig = {
       color: 'text.content',
       fontSize: '24rpx',
     },
+    subTitle: {
+      color: 'text.content',
+      fontSize: '34rpx',
+    },
     subText: {
       color: 'text.content',
       fontSize: '26rpx',
@@ -141,4 +152,75 @@ export const DefaultTheme : ThemeConfig = {
       fontSize: '24rpx',
     },
   }
+}
+
+/** 默认暗黑主题配置 */
+export const DefaultDarkTheme = {
+  ...DefaultTheme,
+  colorConfigs: {
+    ...DefaultTheme.colorConfigs,
+    default: {
+      ...DefaultTheme.colorConfigs.default,
+      button: '#666666',
+      lightButton: '#333333',
+      notice: '#000000',
+      light: '#3e3e3e',
+      dark: '#eeeeee',
+      white: '#000000',
+      black: '#ffffff',
+      grey: '#666666',
+      lightGrey: '#3d3d3d',
+      darkGrey: '#727272',
+      skeleton: 'rgba(255,255,255,0.1)',
+    },
+    pressed: {
+      ...DefaultTheme.colorConfigs.pressed,
+      default: 'rgba(255,255,255,0.1)',
+      white: '#181818',
+      black: '#999999',
+      grey: '#555555',
+      notice: '#222222',
+    },
+    mask: {
+      ...DefaultTheme.colorConfigs.mask,
+      default: 'rgba(0, 0, 0, 0.5)',
+      white: 'rgba(250, 250, 250, 0.9)',
+      primary: 'rgba(9, 96, 172, 0.3)',
+      info: 'rgba(33, 150, 243, 0.3)',
+      success: 'rgba(39, 137, 80, 0.4)',
+      warning: 'rgba(221, 135, 0, 0.3)',
+      danger: 'rgba(215, 9, 32, 0.3)',
+    },
+    background: {
+      ...DefaultTheme.colorConfigs.background,
+      page: '#1a1a1a',
+      imageBox: '#202020',
+      cell: '#000000',
+      bar: '#1a1a1a',
+      box: '#252525',
+      switch: '#252525',
+      notify: '#1a1a1a',
+      toast: 'rgba(0, 0, 0, 0.95)',
+      mask: 'rgba(0, 0, 0, 0.8)',
+      primary: 'rgba(0, 122, 255, 0.2)',
+      success: 'rgba(76, 175, 80, 0.2)',
+      warning: 'rgba(255, 193, 7, 0.2)',
+      danger: 'rgba(244, 67, 54, 0.2)',
+      info: 'rgba(33, 150, 243, 0.2)',
+    },
+    text: {
+      ...DefaultTheme.colorConfigs.text,
+      title: '#ffffff',
+      light: '#000000',
+      content: '#cccccc',
+      second: '#666666',
+    },
+    border: {
+      ...DefaultTheme.colorConfigs.border,
+      input: '#252525',
+      default: '#333333',
+      cell: '#333333',
+      light: '#444444',
+    },
+  },
 }

+ 92 - 34
src/components/theme/ThemeDefine.ts

@@ -1,5 +1,5 @@
-import { computed, inject, provide, type ComputedRef } from "vue";
-import { DefaultTheme } from "./Theme";
+import { computed, inject, provide, ref, shallowRef, type ComputedRef, type Ref } from "vue";
+import { DefaultDarkTheme, DefaultTheme } from "./Theme";
 import type { DynamicVarType } from "./ThemeTools";
 import { ObjectUtils } from "@imengyu/imengyu-utils";
 
@@ -24,6 +24,10 @@ export interface ThemeConfig {
   textConfigs: Record<string, Record<string, string|object>>,
 }
 
+function popInjectTheme() : ThemeConfig {
+  return inject<Ref<ThemeConfig>>(ThemeKey)?.value ?? DefaultTheme;
+}
+
 /**
  * 在PropDefault回调中使用主题默认值的函数
  * @param key 
@@ -31,7 +35,7 @@ export interface ThemeConfig {
  * @returns 
  */
 export function propGetThemeVar<T>(key: string, defaultValue?: T) : T {
-  const theme = (inject(ThemeKey, DefaultTheme) as ThemeConfig);
+  const theme = popInjectTheme();
   return theme?.varOverrides[key] ?? defaultValue;
 }
 /**
@@ -40,33 +44,40 @@ export function propGetThemeVar<T>(key: string, defaultValue?: T) : T {
  * @returns 回调函数的返回值
  */
 export function propGetThemeVar2(cb: (getVar: (key: string, defaultValue: any) => any, theme: ThemeConfig) => any) {
-  const theme = (inject(ThemeKey, DefaultTheme) as ThemeConfig);
+  const theme = popInjectTheme();
   return cb((key, defaultValue) => getVar(theme, key, defaultValue), theme);
 }
 
 
-export function provideTheme(theme: ThemeConfig) {
-  provide(ThemeKey, theme);
-}
 export function provideSomeThemeColor(record: Record<string, any>) {
-  const v = {
-    ...DefaultTheme,
-    colorConfigs: {
-      ...DefaultTheme.colorConfigs,
-      ...record
-    }
-  } as ThemeConfig;
-  provide(ThemeKey, v);
+ const topTheme = inject<Ref<ThemeConfig>>(ThemeKey);
+  const newTheme = computed(() => {
+    const topThemeValue = topTheme?.value ?? DefaultTheme;
+    const v = {
+      ...topThemeValue,
+      colorConfigs: {
+        ...topThemeValue.colorConfigs,
+        ...record
+      }
+    } as ThemeConfig;
+    return v;
+  });
+  provide(ThemeKey, newTheme);
 }
 export function provideSomeThemeVar(record: Record<string, any>) {
-  const v = {
-    ...DefaultTheme,
-    varOverrides: {
-      ...DefaultTheme.varOverrides,
-      ...record
-    }
-  } as ThemeConfig;
-  provide(ThemeKey, v);
+  const topTheme = inject<Ref<ThemeConfig>>(ThemeKey);
+  const newTheme = computed(() => {
+    const topThemeValue = topTheme?.value ?? DefaultTheme;
+    const v = {
+      ...topThemeValue,
+      varOverrides: {
+        ...topThemeValue.varOverrides,
+        ...record
+      }
+    } as ThemeConfig;
+    return v;
+  });
+  provide(ThemeKey, newTheme);
 }
 
 export function getVar<T>(theme : ThemeConfig, key: string, defaultValue: T) : T {
@@ -77,7 +88,8 @@ export function getVar<T>(theme : ThemeConfig, key: string, defaultValue: T) : T
  * 主题的组合代码
  */
 export function useTheme() {
-  const theme = inject(ThemeKey, DefaultTheme) as ThemeConfig;
+  const topTheme = inject<Ref<ThemeConfig>>(ThemeKey);
+  const theme = computed(() => topTheme?.value ?? DefaultTheme);
 
   function resolveThemeSize(inValue?: string|number, defaultValue?: string|number) : string|undefined {    
     const preResolve = resolveSize(inValue);
@@ -128,12 +140,14 @@ export function useTheme() {
     if (key === undefined)
       return defaultValue;
     let type = '';
-    key = getVar(key, key);
+    let keyResolve = getVar(key, key);
+    if (typeof keyResolve === 'string')
+      key = keyResolve;
     if (key.includes('.'))
       [type, key] = key.split('.');
-    let group = theme.colorConfigs[type || 'default'];
+    let group = theme.value.colorConfigs[type || 'default'];
     if (!group) 
-      group = theme.colorConfigs['default'];
+      group = theme.value.colorConfigs['default'];
     return group?.[key] ?? defaultValue;
   }
   function getSize(key: string, defaultValue?: string|number) {
@@ -144,14 +158,14 @@ export function useTheme() {
       [type, key] = key.split('.');
     let v: any = undefined;
     if (type) {
-      const group = theme.varOverrides[type];
+      const group = theme.value.varOverrides[type];
       v = group?.[key];
     } else
-      v = theme.varOverrides[key];
+      v = theme.value.varOverrides[key];
     return resolveSize(v ?? defaultValue);
   }
   function getText(key: string, defaultValue: Record<string, string>) {
-    return theme.textConfigs[key]?? defaultValue;
+    return theme.value.textConfigs[key]?? defaultValue;
   }
   function getVar<T>(key: string, defaultValue: T) : T {
     let rs = undefined;
@@ -159,10 +173,10 @@ export function useTheme() {
     if (key.includes('.'))
       [type, key] = key.split('.');
     if (type) {
-      const group = theme.varOverrides[type];
+      const group = theme.value.varOverrides[type];
       rs = group?.[key];
     } else
-      rs = theme.varOverrides[key];
+      rs = theme.value.varOverrides[key];
     return rs ?? defaultValue;
   } 
 
@@ -268,6 +282,8 @@ export function resolveSize(inValue: string|number|undefined) : string|undefined
   return undefined;
 }
 
+export type ThemePaddingMarginProp = number | number[] | ThemePaddingMargin
+
 export interface ThemePaddingMargin {
   l?: number,
   r?: number,
@@ -278,7 +294,49 @@ export interface ThemePaddingMargin {
 /**
  * 配置主题变量,建议在App.vue中调用
  * @param cb 回调函数,参数为默认主题配置,返回值为新的主题配置
+ * @returns 主题配置对象,返回currentTheme为当前主题,可以修改为其他主题对象,但请注意主题对象为shallowRef,
+ * 直接修改主题对象的属性不会触发主题更新,需要调用currentTheme.value = newTheme新的对象来更新主题。
+ */
+export function configTheme(
+  /** 
+   * 是否自动匹配系统深色主题
+   * @default true
+   */
+  autoMatchSystemDark?: boolean, 
+  /** 
+   * 回调函数,用于配置修改主题,参数为默认主题配置,返回值为新的主题配置
+   * @default undefined
+   * @returns 可以返回新的主题对象或者是传入的对象,第一个为亮色主题,第二个为深色主题
+   */
+  cb?: (defaultTheme: ThemeConfig, defaultDarkTheme: ThemeConfig) => [ThemeConfig,ThemeConfig]
+) {
+  let defaultTheme = ObjectUtils.clone(DefaultTheme);
+  let defaultDarkTheme = ObjectUtils.clone(DefaultDarkTheme);
+  
+  const [theme, darkTheme] = cb?.(defaultTheme, defaultDarkTheme) ?? [defaultTheme, defaultDarkTheme];
+  const currentSystemDark = ref(autoMatchSystemDark !== false && uni.getSystemInfoSync().theme === 'dark');
+  const currentTheme = shallowRef(currentSystemDark.value ? darkTheme : theme);
+
+  provide(ThemeKey, currentTheme);
+
+  if (autoMatchSystemDark !== false) {
+    // 监听系统主题变化
+    uni.onThemeChange((res) => {
+      currentSystemDark.value = res.theme === 'dark';
+      currentTheme.value = currentSystemDark.value ? darkTheme : theme;
+    });
+  }
+
+  return {
+    currentTheme,
+  }
+}
+/**
+ * 克隆默认主题,用于自定义主题
+ * @param cb 回调函数,参数为默认主题配置,返回值为新的主题配置
+ * @param dark 是否为深色主题,默认false为亮色主题
+ * @returns 新的主题配置对象
  */
-export function configTheme(cb: (defaultTheme: ThemeConfig) => ThemeConfig) {
-  provide(ThemeKey, cb(ObjectUtils.clone(DefaultTheme)));
+export function cloneTheme(dark?: boolean, cb?: (defaultTheme: ThemeConfig) => ThemeConfig) {
+  return cb?.(ObjectUtils.clone(dark ? DefaultDarkTheme : DefaultTheme)) ?? ObjectUtils.clone(dark ? DefaultDarkTheme : DefaultTheme);
 }

+ 3 - 2
src/components/typography/HorizontalScrollText.vue

@@ -8,7 +8,7 @@
         center: 'center',
         left: 'flex-start',
         right: 'flex-end',
-        '': '',
+        '': undefined,
       }),
     }"
   >
@@ -83,7 +83,7 @@ async function lodScrollInfo() {
   })
 
   let textWidth = await realTextRef.value.measureTextWidth();
-  console.log('textWidth', textWidth, 'conWidth', conWidth);
+  //console.log('textWidth', textWidth, 'conWidth', conWidth);
   
   if (textWidth == conWidth) { 
     await waitTimeOut(200);
@@ -124,6 +124,7 @@ defineOptions({
   }
   .inner {
     position: absolute;
+    top: 0;
 
     &.scroll {
       animation: 25000ms linear 0s infinite normal none running horizontalScrollText;

+ 0 - 0
src/components/utils/DialogAction.ts


Some files were not shown because too many files changed in this diff