Prechádzať zdrojové kódy

📦 修改组件库

快乐的梦鱼 2 mesiacov pred
rodič
commit
d99d80001a
100 zmenil súbory, kde vykonal 53767 pridanie a 1180 odobranie
  1. 0 27
      package-lock.json
  2. 0 1
      package.json
  3. 8 1
      src/App.vue
  4. 0 25
      src/common/components/ImageWrapper.vue
  5. 2 1
      src/common/components/RequireLogin.vue
  6. 6 12
      src/common/components/SimpleDropDownPicker.vue
  7. 19 22
      src/common/components/SimplePageContentLoader.vue
  8. 3 2
      src/common/components/SimplePageListLoader.vue
  9. 21 0
      src/common/components/dynamicf/ComponentConfigs.ts
  10. 69 0
      src/common/components/dynamicf/ComponentRender.vue
  11. 132 0
      src/common/components/form/Recorder.vue
  12. 75 0
      src/common/components/form/RichTextEditor.vue
  13. 0 90
      src/common/components/form/SimpleDynamicFormCate.vue
  14. 0 84
      src/common/components/form/SimpleDynamicFormCateInner.vue
  15. 0 223
      src/common/components/form/SimpleDynamicFormControl.vue
  16. 0 156
      src/common/components/form/SimpleDynamicFormUni.vue
  17. 0 37
      src/common/components/form/components/CityPicker.vue
  18. 0 45
      src/common/components/form/components/DynamicCheckbox.vue
  19. 0 45
      src/common/components/form/components/DynamicSelect.vue
  20. 0 38
      src/common/components/form/components/LonlatPicker.vue
  21. 0 50
      src/common/components/form/components/RichTextEditor.vue
  22. 0 42
      src/common/components/form/form/Form.vue
  23. 0 14
      src/common/components/form/form/FormItem.vue
  24. 0 159
      src/common/components/form/index.ts
  25. 52 105
      src/common/components/tabs/tabbar.vue
  26. 3 1
      src/common/composeabe/TabControl.ts
  27. 44 0
      src/components/README.md
  28. 108 0
      src/components/anim/SimpleTransition.vue
  29. 125 0
      src/components/basic/ActivityIndicator.vue
  30. 467 0
      src/components/basic/Button.vue
  31. 336 0
      src/components/basic/Cell.vue
  32. 20 0
      src/components/basic/CellContext.ts
  33. 90 0
      src/components/basic/CellGroup.vue
  34. 117 0
      src/components/basic/Icon.vue
  35. 95 0
      src/components/basic/IconButton.vue
  36. 85 0
      src/components/basic/IconUtils.ts
  37. 188 0
      src/components/basic/Image.vue
  38. 11 0
      src/components/basic/ImageButton.vue
  39. 261 0
      src/components/basic/Text.vue
  40. 54 0
      src/components/composeabe/ChildItem.ts
  41. 29 0
      src/components/composeabe/DataLoader.ts
  42. 19 0
      src/components/composeabe/LoadingAction.ts
  43. 45055 0
      src/components/data/ChinaCityData.json
  44. 223 0
      src/components/data/DefaultIcon.json
  45. 46 0
      src/components/demo/DemoBlock.vue
  46. 56 0
      src/components/demo/DemoPage.vue
  47. 51 0
      src/components/demo/DemoTitle.vue
  48. 153 0
      src/components/dialog/ActionSheet.vue
  49. 58 0
      src/components/dialog/ActionSheetItem.vue
  50. 47 0
      src/components/dialog/ActionSheetRoot.vue
  51. 95 0
      src/components/dialog/ActionSheetTitle.vue
  52. 61 0
      src/components/dialog/CommonRoot.ts
  53. 46 0
      src/components/dialog/CommonRoot.vue
  54. 158 0
      src/components/dialog/Dialog.vue
  55. 105 0
      src/components/dialog/DialogButton.vue
  56. 186 0
      src/components/dialog/DialogInner.vue
  57. 78 0
      src/components/dialog/DialogRoot.vue
  58. 32 0
      src/components/dialog/Overlay.vue
  59. 355 0
      src/components/dialog/Popup.vue
  60. 88 0
      src/components/dialog/PopupTitle.vue
  61. 134 0
      src/components/display/Avatar.vue
  62. 176 0
      src/components/display/AvatarStack.vue
  63. 257 0
      src/components/display/Badge.vue
  64. 116 0
      src/components/display/Collapse.vue
  65. 92 0
      src/components/display/CollapseBox.vue
  66. 118 0
      src/components/display/CollapseItem.vue
  67. 160 0
      src/components/display/Divider.vue
  68. 62 0
      src/components/display/Footer.vue
  69. 140 0
      src/components/display/NoticeBar.vue
  70. 289 0
      src/components/display/Progress.vue
  71. 93 0
      src/components/display/Skeleton.vue
  72. 48 0
      src/components/display/Status.vue
  73. 167 0
      src/components/display/Step.vue
  74. 203 0
      src/components/display/StepItem.vue
  75. 287 0
      src/components/display/Tag.vue
  76. 75 0
      src/components/display/TextEllipsis.vue
  77. 241 0
      src/components/display/Watermark.vue
  78. 191 0
      src/components/display/block/BackgroundBox.vue
  79. 50 0
      src/components/display/block/IconTextBlock.vue
  80. 132 0
      src/components/display/block/ImageBlock.vue
  81. 94 0
      src/components/display/block/ImageBlock2.vue
  82. 99 0
      src/components/display/block/ImageBlock3.vue
  83. 207 0
      src/components/display/block/TextBlock.vue
  84. 162 0
      src/components/display/block/TextLeftRightBlock.vue
  85. 92 0
      src/components/display/countdown/CountDown.vue
  86. 115 0
      src/components/display/countdown/CountDownButton.vue
  87. 149 0
      src/components/display/countdown/CountTo.vue
  88. 127 0
      src/components/display/countdown/CountdownHook.ts
  89. 63 0
      src/components/display/loading/LoadingPage.vue
  90. 80 0
      src/components/display/loading/Loadmore.vue
  91. 0 0
      src/components/display/parse/Parse.vue
  92. 0 0
      src/components/display/parse/node/node.vue
  93. 0 0
      src/components/display/parse/parse.js
  94. 0 0
      src/components/display/parse/parser.js
  95. 0 0
      src/components/display/parse/props.js
  96. 70 0
      src/components/display/skeleton/SkeletonAvatar.vue
  97. 47 0
      src/components/display/skeleton/SkeletonBaseBox.vue
  98. 61 0
      src/components/display/skeleton/SkeletonButton.vue
  99. 38 0
      src/components/display/skeleton/SkeletonImage.vue
  100. 0 0
      src/components/display/skeleton/SkeletonParagraph.vue

+ 0 - 27
package-lock.json

@@ -25,7 +25,6 @@
         "@dcloudio/uni-quickapp-webview": "3.0.0-4030620241128001",
         "@imengyu/imengyu-utils": "^0.0.19",
         "@imengyu/js-request-transform": "^0.3.3",
-        "@imengyu/vue-dynamic-form": "^0.1.1",
         "async-validator": "^4.2.5",
         "pinia": "^3.0.1",
         "tslib": "^2.8.1",
@@ -2908,17 +2907,6 @@
         "dayjs": "^1.11.7"
       }
     },
-    "node_modules/@imengyu/vue-dynamic-form": {
-      "version": "0.1.1",
-      "resolved": "https://registry.npmmirror.com/@imengyu/vue-dynamic-form/-/vue-dynamic-form-0.1.1.tgz",
-      "integrity": "sha512-xyzO7hSwAjp/B8ROwZEMHK4m3Id94ViTb0JSpD/Z7QYb78m72/Io0LkeKkL2t/qBAyo3dvvi3Dewv+Y+ljWP9Q==",
-      "license": "MIT",
-      "dependencies": {
-        "async-validator": "^4.2.5",
-        "scroll-into-view-if-needed": "^3.0.3",
-        "vue": "^3.2.45"
-      }
-    },
     "node_modules/@intlify/core-base": {
       "version": "9.1.9",
       "resolved": "https://registry.npmmirror.com/@intlify/core-base/-/core-base-9.1.9.tgz",
@@ -5997,12 +5985,6 @@
       "integrity": "sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA==",
       "license": "MIT"
     },
-    "node_modules/compute-scroll-into-view": {
-      "version": "3.1.1",
-      "resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz",
-      "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==",
-      "license": "MIT"
-    },
     "node_modules/computeds": {
       "version": "0.0.1",
       "resolved": "https://registry.npmmirror.com/computeds/-/computeds-0.0.1.tgz",
@@ -10360,15 +10342,6 @@
         "node": ">=10"
       }
     },
-    "node_modules/scroll-into-view-if-needed": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz",
-      "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==",
-      "license": "MIT",
-      "dependencies": {
-        "compute-scroll-into-view": "^3.0.2"
-      }
-    },
     "node_modules/scule": {
       "version": "1.3.0",
       "resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz",

+ 0 - 1
package.json

@@ -52,7 +52,6 @@
     "@dcloudio/uni-quickapp-webview": "3.0.0-4030620241128001",
     "@imengyu/imengyu-utils": "^0.0.19",
     "@imengyu/js-request-transform": "^0.3.3",
-    "@imengyu/vue-dynamic-form": "^0.1.1",
     "async-validator": "^4.2.5",
     "pinia": "^3.0.1",
     "tslib": "^2.8.1",

+ 8 - 1
src/App.vue

@@ -1,6 +1,7 @@
 <script setup lang="ts">
 import { onLaunch } from '@dcloudio/uni-app'
 import { useAuthStore } from './store/auth'
+import { configTheme } from './components/theme/ThemeDefine';
 
 const authStore = useAuthStore();
 
@@ -15,12 +16,18 @@ onLaunch(() => {
 
   authStore.loadLoginState();
 })
+
+configTheme((theme) => {
+  theme.colorConfigs.default.primary = '#d9492e';
+  theme.colorConfigs.pressed.primary = '#882d1d';
+  theme.colorConfigs.background.primary = '#ffcfc6';
+  return theme;
+});
 </script>
 
 <style lang="scss">
 @use "@/common/scss/fonts.scss" as *;
 @use "@/common/scss/common.scss" as *;
 @use "@/common/scss/global/base.scss" as *;
-@import "@/uni_modules/uview-plus/index.scss";
 </style>
 

+ 0 - 25
src/common/components/ImageWrapper.vue

@@ -1,25 +0,0 @@
-<template>
-  <view class="image-wrapper">
-    <u-image 
-      :showLoading="true"
-      v-bind="$props"
-      :src="src || 'https://mncdn.wenlvti.net/app_static/minnan/EmptyImage.png'"
-    >
-      <template #loading>
-        <u-loading-icon color="red"></u-loading-icon>
-      </template>
-      <template #error>
-        <u-empty mode="page" text="图片加载失败" />
-      </template>
-    </u-image>
-  </view>
-</template>
-
-<script setup lang="ts">
-const props = defineProps({	
-  src: {
-    type: String,
-    required: true,
-  },
-})
-</script>

+ 2 - 1
src/common/components/RequireLogin.vue

@@ -2,7 +2,7 @@
   <slot v-if="isLogged" />
   <view v-else class="d-flex flex-column align-center justify-center height-300">
     <text>{{unLoginMessage}}</text>
-    <u-button type="primary" @click="goLogin">去登录</u-button>
+    <Button type="primary" text="去登录" @click="goLogin" />
   </view>
 </template>
 
@@ -10,6 +10,7 @@
 import { useAuthStore } from '@/store/auth';
 import { navTo } from '@/components/utils/PageAction';
 import { computed } from 'vue';
+import Button from '@/components/basic/Button.vue';
 
 const authStore = useAuthStore();
 const isLogged = computed(() => authStore.isLogged);

+ 6 - 12
src/common/components/SimpleDropDownPicker.vue

@@ -3,16 +3,10 @@
     class="simple-dropdown-box" 
     @click="show=true"
   >
-    {{ dispayText }} ▼
+    <picker @change="bindPickerChange" :value="selectedIndex" :range="columns" range-key="name">
+      {{ dispayText }} ▼
+    </picker>
   </view>
-  <u-picker 
-    :show="show" 
-    :columns="[columns]" 
-    :defaultIndex="[defaultIndex]"
-    keyName="name"
-    @cancel="show=false"
-    @confirm="confirm"
-  />
 </template>
 
 <script setup lang="ts">
@@ -48,16 +42,16 @@ const dispayText = computed(() => {
     return props.columns.find(item => item.id == props.modelValue)?.name || props.defaultText;
   return props.defaultText;
 });
-const defaultIndex = computed(() => {
+const selectedIndex = computed(() => {
   let index = -1;
   if (props.columns) 
     index = props.columns.findIndex(item => item.id == props.modelValue);
   return index >= 0 ? index : 0;
 });
 
-function confirm(e: { value: SimpleDropDownPickerItem[] }) {
+function bindPickerChange(e:{ detail: { value: number }}) {
   show.value = false;
-  emit('update:modelValue', e.value[0].id);
+  emit('update:modelValue', props.columns?.[e.detail.value].id || null);
 }
 </script>
 

+ 19 - 22
src/common/components/SimplePageContentLoader.vue

@@ -3,23 +3,19 @@
     v-if="loader?.loadStatus.value == 'loading'"
     style="min-height: 200rpx;display: flex;justify-content: center;align-items: center;"
   >
-    <u-loading-icon text="加载中" textSize="18" />
+    <ActivityIndicator text="加载中" textSize="18" />
   </view>
   <view
     v-else-if="loader?.loadStatus.value == 'error'"
     style="min-height: 200rpx"
   >
-    <u-empty
+    <Empty
       mode="page"
       :text="loader.loadError.value"
-    />
-    <view style="margin-top: 20rpx">
-      <u-row justify="center">
-        <u-col span="3">
-          <u-button text="重试" @click="handleRetry" />
-        </u-col>
-      </u-row>
-    </view>
+    >
+      <Height :height="20" />
+      <Button text="重试" @click="handleRetry" />
+    </Empty>
   </view>
   <template v-else-if="loader?.loadStatus.value == 'finished' || loader?.loadStatus.value == 'nomore'">
     <slot />
@@ -28,20 +24,17 @@
     v-if="showEmpty || loader?.loadStatus.value == 'nomore'"
     style="min-height: 200rpx"
   >
-    <u-empty
+    <Empty
       mode="data"
       :text="emptyView?.text ?? '暂无数据'"
-    />
-    <view v-if="emptyView?.button" style="margin-top: 20rpx">
-      <u-row justify="center">
-        <u-col span="3">
-          <u-button
-            :text="emptyView?.buttonText ?? '刷新'" 
-            @click="emptyView?.buttonClick ?? handleRetry"
-          />
-        </u-col>
-      </u-row>
-    </view>
+    >
+      <view v-if="emptyView?.button" style="margin-top: 20rpx">
+        <Button
+          :text="emptyView?.buttonText ?? '刷新'" 
+          @click="emptyView?.buttonClick ?? handleRetry"
+        />
+      </view>
+    </Empty>
   </view>
   <image 
     v-if="lazy && !loaded"
@@ -56,6 +49,10 @@
 <script setup lang="ts">
 import { onMounted, ref, type PropType } from 'vue';
 import type { ISimplePageContentLoader } from '../composeabe/SimplePageContentLoader';
+import ActivityIndicator from '@/components/basic/ActivityIndicator.vue';
+import Empty from '@/components/feedback/Empty.vue';
+import Button from '@/components/basic/Button.vue';
+import Height from '@/components/layout/space/Height.vue';
 
 const props = defineProps({	
   loader: {

+ 3 - 2
src/common/components/SimplePageListLoader.vue

@@ -1,18 +1,19 @@
 <template>
-  <u-loadmore 
+  <Loadmore
     v-if="
     loader.loadStatus.value == 'loading' 
     || (loader.loadStatus.value == 'nomore' && !$slots.empty)" 
     :status="loader.loadStatus.value" 
   />
   <slot v-else-if="loader.loadStatus.value == 'nomore' && $slots.empty" name="empty" />
-  <u-loadmore v-else-if="loader.loadStatus.value == 'error'" status="loadmore" :loadmoreText="loader.loadError.value" @loadmore="handleRetry" />
+  <Loadmore v-else-if="loader.loadStatus.value == 'error'" status="loadmore" :loadmoreText="loader.loadError.value" @loadmore="handleRetry" />
 
 </template>
 
 <script setup lang="ts">
 import type { PropType } from 'vue';
 import type { ISimplePageListLoader } from '../composeabe/SimplePageListLoader';
+import Loadmore from '@/components/display/loading/Loadmore.vue';
 
 const props = defineProps({	
   loader: {

+ 21 - 0
src/common/components/dynamicf/ComponentConfigs.ts

@@ -0,0 +1,21 @@
+//import MapApi from "@/api/map/MapApi";
+import type { IDynamicFormComponentAdditionalDefine } from "@/components/dynamic";
+
+//添加自定义组件默认配置
+
+export default [
+  {
+    name: 'select-city',
+    needArrow: true,
+    props: {
+      //loadCityData: () => MapApi.loadCityData(),
+      //loadDistrictInfo: (latlon: [number, number]) => MapApi.regeo(latlon[0], latlon[1]),
+    },
+  },
+  {
+    name: 'select-address',
+    props: {
+      //loadFormattedAddress: (latlon: [number, number]) => MapApi.regeoAddress(latlon[0], latlon[1]),
+    },
+  },
+] as IDynamicFormComponentAdditionalDefine[];

+ 69 - 0
src/common/components/dynamicf/ComponentRender.vue

@@ -0,0 +1,69 @@
+<template>
+  <!-- 在下方添加自定义组件 -->
+  <!-- 业务代码开始 -->
+  <template v-if="item.type === 'richtext'">
+    <RichTextEditor
+      ref="itemRef"
+      :modelValue="modelValue"
+      @update:modelValue="onValueChanged"
+      v-bind="params"
+    />
+  </template>
+  <template v-else-if="item.type === 'recorder'">
+    <Recorder
+      ref="itemRef"
+      :modelValue="modelValue"
+      @update:modelValue="onValueChanged"
+      v-bind="params"
+    />
+  </template>
+  <!-- 业务代码结束 -->
+  <template v-else>
+    <text>Fallback: unknow form type '{{ item.type }}' item: {{ name }}</text>
+  </template>
+</template>
+
+<script setup lang="ts">
+import { ref, type PropType } from 'vue';
+import type { IDynamicFormItem } from '@/components/dynamic';
+import RichTextEditor from '@/common/components/form/RichTextEditor.vue';
+import Recorder from '@/common/components/form/Recorder.vue';
+
+const props = defineProps({	
+  modelValue: {
+    type: null
+  },
+  item: {
+    type: Object as PropType<IDynamicFormItem>,
+    default: () => ({})
+  },
+  isLast: {
+    type: Boolean,
+    default: false,
+  },
+  params: {
+    type: Object,
+    default: () => ({})
+  },
+  name: {
+    type: String,
+    default: '',
+  },
+});
+const emit = defineEmits(['update:modelValue']);
+
+function onValueChanged(v: any) {
+  emit('update:modelValue', v);
+}
+
+const itemRef = ref();
+
+defineExpose({
+  getItemRef: () => itemRef.value,
+})
+defineOptions({
+  options: {
+    virtualHost: true,
+  }
+})
+</script>

+ 132 - 0
src/common/components/form/Recorder.vue

@@ -0,0 +1,132 @@
+<script setup lang="ts">
+import IconButton from '@/components/basic/IconButton.vue';
+import Text from '@/components/basic/Text.vue';
+import FlexRow from '@/components/layout/FlexRow.vue';
+import { FormatUtils, LogUtils, SimpleTimer, TimeUtils } from '@imengyu/imengyu-utils';
+import { computed, ref } from 'vue';
+
+const emit = defineEmits(['update:modelValue', 'recordDone'])
+const props = defineProps({
+  modelValue: { 
+    type: String,
+    default: null 
+  },
+});
+
+
+const TAG = 'Recorder'; 
+const manager = uni.getRecorderManager();
+const state = ref(false);
+const paused = ref(false);
+const recordTime = ref(0);
+const recordTimeInterval = new SimpleTimer(undefined, () => {
+  recordTime.value++;
+}, 1000);
+const recordTimeString = computed(() => {
+  const times = TimeUtils.splitMillSeconds(recordTime.value * 1000);
+  return `${FormatUtils.formatNumberWithZero(times.minutes, 2)}:${FormatUtils.formatNumberWithZero(times.seconds, 2)}`;
+});
+
+manager.onError((err) => {
+  LogUtils.printLog(TAG, 'error', '录音错误', err)
+  uni.showModal({
+    title: '录音错误',
+    content: err.errMsg,
+  })
+})
+manager.onPause(() => {
+  LogUtils.printLog(TAG, 'info', '录音暂停')
+})
+manager.onStart((result) => {
+  uni.hideLoading();
+  state.value = true;
+  paused.value = false;
+  recordTimeInterval.start();
+  LogUtils.printLog(TAG, 'info', '录音开始')
+})
+manager.onStop((result) => {
+  uni.hideLoading();
+  state.value = false;
+  paused.value = false;
+  recordTime.value = 0;
+  recordTimeInterval.stop();
+  LogUtils.printLog(TAG, 'info', '录音结束', result);
+  uni.showToast({ title: '录音成功' });
+  emit('update:modelValue', result.tempFilePath);
+  emit('recordDone', result.tempFilePath);
+})
+
+function startRecord() {
+  if (state.value)
+    return;
+  uni.showLoading();
+  recordTime.value = 0;
+  state.value = true;
+  manager.start({
+    format: 'mp3',
+    duration: 60000,
+  })
+}
+function stopRecord() {
+  if (!state.value)
+    return;
+  uni.showLoading();
+  manager.stop();
+}
+function toggleRecord() {
+  if (!state.value)
+    return;
+  if (!paused.value) {
+    manager.pause();
+    recordTimeInterval.stop();
+    paused.value = true;
+  } else {
+    manager.resume();
+    recordTimeInterval.start();
+    paused.value = false;
+  }
+}
+
+defineOptions({
+  options: {
+    styleIsolation: "shared",
+    virtualHost: true,
+  }
+})
+</script>
+
+<template>
+  <FlexRow flex="1 1 100%" align="center" justify="space-between">
+    <Text :text="`${state?(paused?'暂停中':'录音中'):'点击开始录音'} ${ recordTimeString }/10:00 `"/>
+    <FlexRow align="center" :gap="15">
+      <IconButton v-if="!state" 
+        icon="record-filling"
+        :size="40" 
+        :buttonSize="60"
+        backgroundColor="button"
+        color="danger"
+        shape="round"
+        @click="startRecord" 
+      />
+      <template v-else>
+        <IconButton 
+          icon="stop-filling" 
+          shape="round"
+          :size="30" 
+          :buttonSize="50"
+          backgroundColor="button"
+          color="danger"
+          @click="stopRecord" 
+        />
+        <IconButton 
+          :icon="paused?'play-filling':'pause-filling'" shape="round" 
+          :size="30" 
+          backgroundColor="button"
+          :buttonSize="50"
+          color="danger"
+          @click="toggleRecord" 
+        />
+      </template>
+    </FlexRow>
+  </FlexRow>
+</template>

+ 75 - 0
src/common/components/form/RichTextEditor.vue

@@ -0,0 +1,75 @@
+<template>
+  <view class="d-flex flex-col">
+    <view class="richtext-preview-box" @click="edit">
+      <Parse v-if="modelValue" :content="modelValue" containerStyle="max-height:400px" />
+      <Text v-else color="text.second">{{placeholder}}</Text>
+    </view>
+    <view class="d-flex flex-row gap-sss align-center mt-2">
+      <Button icon="browse" text="预览" size="small" @click="preview" />
+      <Button icon="edit" text="编辑" size="small" @click="edit" type="primary" />
+      <Text v-if="maxLength > 0">{{ modelValue?.length || 0 }}/{{ maxLength }} 字</Text>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { onPageShow } from '@dcloudio/uni-app';
+import { navTo } from '@/components/utils/PageAction';
+import Parse from '@/components/display/parse/Parse.vue';
+import Button from '@/components/basic/Button.vue';
+import Text from '@/components/basic/Text.vue';
+
+const props = defineProps({	
+  modelValue: { 
+    type: String,
+    default: null 
+  },
+  maxLength: {
+    type: Number,
+    default: -1,
+  },
+  placeholder: {
+    type: String,
+    default: '未编写内容,点击编写',
+  },
+})
+const emit = defineEmits(['update:modelValue'])
+let editorOpened = false;
+
+function preview() {
+  uni.setStorage({
+    key: 'editorContent',
+    data: props.modelValue,
+    success: () => navTo('/pages/editor/preview'),
+  })
+}
+function edit() {
+  editorOpened = true;
+  uni.setStorage({
+    key: 'editorMaxLength',
+    data: props.maxLength,
+  })
+  uni.setStorage({
+    key: 'editorContent',
+    data: props.modelValue,
+    success: () => navTo('/pages/editor/editor'),
+  })
+}
+
+onPageShow(() => {
+  if (editorOpened) {
+    editorOpened = false;
+    uni.getStorage({
+      key: 'editorContent',
+      success: (success) => emit('update:modelValue', success.data),
+    })
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.richtext-preview-box {
+  flex: 1;
+  min-height: 400rpx;
+}
+</style>

+ 0 - 90
src/common/components/form/SimpleDynamicFormCate.vue

@@ -1,90 +0,0 @@
-<template>
-  <view 
-    v-if="formDefine.type === 'group'" 
-    :class="`form-group ${formDefine.props.type}`"
-  >
-    <text class="form-group-title" v-if="formDefineParentLabel">
-      {{ formDefineParentLabel }}
-    </text>
-    <SimpleDynamicFormCateInner
-      :formDefine="formDefine"
-      :formModel="formModel" 
-      :groupType="formDefine.props.type"
-    />
-  </view>
-  <SimpleDynamicFormCateInner
-    v-else
-    :formDefine="formDefine"
-    :formModel="formModel" 
-  />
-</template>
-
-<script setup lang="ts">
-import type { PropType } from 'vue';
-import type { FormDefine } from '.';
-import SimpleDynamicFormCateInner from './SimpleDynamicFormCateInner.vue';
-
-export interface FormGroupProps {
-  type: 'row' | 'column' | 'block';
-
-}
-
-const props = defineProps({
-  formDefineParentLabel: {
-    type: null,
-    default: '' 
-  },
-  formDefineParentKey: {
-    type: String,
-    default: '' 
-  },
-  formModel: {
-    type: Object,
-    default: () => ({})
-  },
-  formDefine: {
-    type: Object as PropType<FormDefine>,
-    default: () => ({})
-  },
-})
-</script>
-
-<style lang="scss">
-.form-group {
-  display: flex;
-  flex-direction: column;
-
-  &.block {
-    margin-bottom: 32rpx;
-    padding: 24rpx 26rpx;
-    background: #fff;
-    border-radius: 10rpx;
-
-    .form-group-title {
-      display: block;
-      font-size: 28rpx;
-      color: #333;
-      margin-bottom: 16rpx;
-    }
-  }
-
-  .form-group-title {
-    display: block;
-    flex-shrink: 0;
-    font-size: 28rpx;
-    color: #333;
-    margin-bottom: 16rpx;
-  }
-
-  &.row {
-    flex-direction: row;
-    justify-content: space-between;
-    align-items: center;
-
-    .form-group-title {
-      display: inline-block;
-      margin-left: 10rpx;
-    }
-  }
-}
-</style>

+ 0 - 84
src/common/components/form/SimpleDynamicFormCateInner.vue

@@ -1,84 +0,0 @@
-<template>
-  <view 
-    v-for="(item, key) in formDefine.items"
-    :key="key"
-    :class="[
-      'form-cate-inner',
-      groupType
-    ]"
-  >
-    <SimpleDynamicFormCate
-      v-if="item.children"
-      :formDefine="item.children"
-      :formModel="children" 
-      :formDefineParentKey="item.name"
-      :formDefineParentLabel="item.label"
-      :parentModel="formModel"
-      :topModel="topModel"
-    />
-    <SimpleDynamicFormControl
-      v-else
-      :modelValue="formModel[item.name] ?? null"
-      :formDefineItem="item"
-      :parentModel="formModel"
-      :topModel="topModel"
-      @update:modelValue="(v: any) => formModel[item.name] = v"
-    />
-  </view>
-</template>
-
-<script setup lang="ts">
-import { computed, type PropType } from 'vue';
-import type { FormDefine } from '.';
-import SimpleDynamicFormControl from './SimpleDynamicFormControl.vue';
-import SimpleDynamicFormCate from './SimpleDynamicFormCate.vue';
-
-const props = defineProps({	
-  topModel: {
-    type: Object,
-    default: () => ({})
-  },
-  parentModel: {
-    type: null,
-  },
-  formModel: {
-    type: Object,
-    default: () => ({})
-  },
-  formDefineParentKey: {
-    type: String,
-    default: ''
-  },
-  formDefine: {
-    type: Object as PropType<FormDefine>,
-    default: () => ({})
-  },
-  groupType: {
-    type: String,
-    default: '' 
-  }
-})
-
-const children = computed(() => {
-  if (props.formDefineParentKey && props.formDefine.propNestType == 'nest')
-    return props.formModel[props.formDefineParentKey];
-  return props.formModel;
-});
-
-</script>
-
-<style lang="scss">
-.form-cate-inner {
-  display: flex;
-  flex-direction: column;
-
-  &.row {
-    display: flex;
-    flex-direction: row;
-    align-items: center;
-  }
-}
-.form-static-text {
-  margin: 0 10rpx 20rpx 10px;
-}
-</style>

+ 0 - 223
src/common/components/form/SimpleDynamicFormControl.vue

@@ -1,223 +0,0 @@
-<template>
-  <template v-if="show">
-    <text
-      v-if="formDefineItem.type === 'static-text' "
-      class="form-static-text"
-      :style="(params.style as any)"
-      :class="(params.class as any)"
-    >
-      {{ params?.text ?? modelValue ?? null }}
-    </text>
-    <uni-forms-item 
-      v-else
-      ref="formItemRef"
-      :label="label"
-      :name="formDefineItem.fullName"
-      :required="Boolean(formDefineItem.rules?.length)"
-      v-bind="formDefineItem.itemParams"
-    >
-      <!-- <text>fullName: {{formDefineItem.fullName}}</text> -->
-      <template v-if="formDefineItem.type === 'text'">
-        <uni-easyinput 
-          ref="itemRef"
-          :modelValue="modelValue"
-          @update:modelValue="onValueChanged"
-          :maxlength="260"
-          v-bind="params"
-        />
-      </template>
-      <template v-else-if="formDefineItem.type === 'number'">
-        <uni-number-box
-          ref="itemRef"
-          :modelValue="modelValue"
-          @update:modelValue="onValueChanged"
-          v-bind="params"
-        />
-      </template>
-      <template v-else-if="formDefineItem.type === 'radio'">
-        <uni-data-checkbox
-          ref="itemRef"
-          selectedColor="#ff8719"
-          :modelValue="modelValue"
-          @update:modelValue="onValueChanged"
-          v-bind="params"
-        />
-      </template>
-      <template v-else-if="formDefineItem.type === 'select'">
-        <uni-data-select 
-          ref="itemRef"
-          :modelValue="modelValue"
-          @update:modelValue="onValueChanged"
-          v-bind="params"
-        />
-      </template>
-      <template v-else-if="formDefineItem.type === 'checkbox'">
-        <uni-data-checkbox 
-          ref="itemRef"
-          selectedColor="#ff8719"
-          :modelValue="modelValue"
-          @update:modelValue="onValueChanged"
-          v-bind="params"
-        />
-      </template>
-      <template v-else-if="formDefineItem.type === 'bool-checkbox'"> 
-        <uni-data-checkbox 
-          ref="itemRef"
-          selectedColor="#ff8719"
-          :modelValue="modelValue"
-          :multiple="false"
-          :localdata="[{text: '是', value: true}, {text: '否', value: false}]"
-          @update:modelValue="onValueChanged"
-          v-bind="params"
-        />
-      </template>
-      <template v-else-if="formDefineItem.type === 'boolint-checkbox'"> 
-        <uni-data-checkbox 
-          ref="itemRef"
-          selectedColor="#ff8719"
-          :modelValue="modelValue"
-          :multiple="false"
-          :localdata="[{text: '是', value: 1}, {text: '否', value: 0}]"
-          @update:modelValue="onValueChanged"
-          v-bind="params"
-        />
-      </template>
-      <template v-else-if="formDefineItem.type === 'dynamic-checkbox'">
-        <DynamicCheckbox
-          ref="itemRef"
-          :modelValue="modelValue"
-          @update:modelValue="onValueChanged"
-          v-bind="params"
-        />
-      </template>
-      <template v-else-if="formDefineItem.type === 'dynamic-select'">
-        <DynamicSelect
-          ref="itemRef"
-          :modelValue="modelValue"
-          @update:modelValue="onValueChanged"
-          v-bind="params"
-        />
-      </template>
-      <template v-else-if="formDefineItem.type === 'city-select'">
-        <CityPicker
-          ref="itemRef"
-          :modelValue="modelValue"
-          @update:modelValue="onValueChanged"
-          v-bind="params"
-        />
-      </template>
-      <template v-else-if="formDefineItem.type === 'picker'">
-        <uni-data-picker
-          ref="itemRef"
-          :modelValue="modelValue"
-          @update:modelValue="onValueChanged"
-          v-bind="params"
-        />
-      </template>
-      <template v-else-if="formDefineItem.type === 'lonlat-picker'">
-        <LonlatPicker
-          ref="itemRef"
-          :modelValue="modelValue"
-          @update:modelValue="(v:any) =>{onValueChanged(v);formItemRef.onFieldChange(v)}"
-          v-bind="params"
-        />
-      </template>
-      <template v-else-if="formDefineItem.type === 'textarea'">
-        <uni-easyinput 
-          ref="itemRef"
-          type="textarea" 
-          :modelValue="modelValue"
-          @update:modelValue="onValueChanged"
-          v-bind="params"
-        />
-      </template>
-      <template v-else-if="formDefineItem.type === 'richtext'">
-        <RichTextEditor
-          ref="itemRef"
-          :modelValue="modelValue"
-          @update:modelValue="onValueChanged"
-          v-bind="params"
-        />
-      </template>
-      <template v-else-if="formDefineItem.type === 'datetime-picker'">
-        <uni-datetime-picker
-          ref="itemRef"
-          :value="modelValue"
-          v-bind="params"
-          @change="(e: any) => onValueChanged(e)"
-        />
-      </template>
-      <!-- More components can be added here... -->
-      <template v-else>
-        <text>Fallback: unknow form type {{ formDefineItem.type }}</text>
-      </template>
-    </uni-forms-item>
-  </template>
-</template>
-
-<script setup lang="ts">
-import { computed, inject, onBeforeUnmount, onMounted, ref, type PropType } from 'vue';
-import type { FormDefineItem, IFormItemCallback } from '.';
-import DynamicSelect from './components/DynamicSelect.vue';
-import CityPicker from './components/CityPicker.vue';
-import LonlatPicker from './components/LonlatPicker.vue';
-import DynamicCheckbox from './components/DynamicCheckbox.vue';
-import RichTextEditor from './components/RichTextEditor.vue';
-
-const props = defineProps({	
-  parentModel: {
-    type: null, //TODO: parentModel
-  },
-  modelValue: {
-    type: null
-  },
-  formDefineItem: {
-    type: Object as PropType<FormDefineItem>,
-    default: () => ({})
-  },
-});
-
-const formItemRef = ref();
-const topModel = inject<any>('formTopModel', {});
-const formGlobalParams = inject<any>('formGlobalParams', {});
-
-function evaluateCallback(val: unknown|IFormItemCallback<unknown>) {
-  if (typeof val === 'object' && typeof (val as IFormItemCallback<unknown>).callback === 'function')
-    return (val as IFormItemCallback<unknown>).callback(
-      props.modelValue, 
-      topModel.value, 
-      props.parentModel, 
-      formGlobalParams.value,
-      props.formDefineItem,
-    );
-  return val as unknown;
-}
-function evaluateCallbackObj(val: Record<string, unknown|IFormItemCallback<unknown>>) {
-  const newObj = {} as Record<string, unknown>;
-  for (const key in val) {
-    if (Object.prototype.hasOwnProperty.call(val, key))
-      newObj[key] = evaluateCallback(val[key]);
-  }
-  return newObj;
-}
-
-const params = computed(() => evaluateCallbackObj(props.formDefineItem.params as any))
-const label = computed(() => evaluateCallback(props.formDefineItem.label))
-const show = computed(() => props.formDefineItem.show == undefined || evaluateCallback(props.formDefineItem.show))
-
-const itemRef = ref();
-const emit = defineEmits([ 'update:modelValue' ]);
- 
-function onValueChanged(v: any) {
-  props.formDefineItem.onChange?.(props.modelValue, v, topModel.value, itemRef.value);
-  emit('update:modelValue', v);
-}
-
-onMounted(() => {
-  props.formDefineItem.onMounted?.(topModel.value, itemRef.value);
-})
-onBeforeUnmount(() => {
-  props.formDefineItem.onBeforeUnMount?.(topModel.value, itemRef.value); 
-})
-
-</script>

+ 0 - 156
src/common/components/form/SimpleDynamicFormUni.vue

@@ -1,156 +0,0 @@
-<template>
-  <uni-forms 
-    ref="formRef"
-    v-bind="formProps"
-    :model="formModel"
-    :rules="formRules"
-  >
-    <SimpleDynamicFormCate
-      v-if="formModel"
-      :formModel="formModel"
-      :formDefine="formDefine"
-      :formDefineParentKey="''"
-      :formDefineParentLabel="''"
-    />
-  </uni-forms>
-</template>
-
-<script setup lang="ts">
-import { computed, onMounted, provide, reactive, ref, toRef, watch, type PropType } from 'vue';
-import type { FormDefine, FormDefineItem, FormExport } from '.';
-import SimpleDynamicFormCate from './SimpleDynamicFormCate.vue';
-import { waitTimeOut } from '@imengyu/imengyu-utils';
-import { toast } from '@/components/utils/DialogAction';
-
-const props = defineProps({	
-  formDefine: {
-    type: Object as PropType<FormDefine>,
-    default: () => ({})
-  },
-  formProps: {
-    type: Object,
-    default: () => ({}) 
-  },
-  formGlobalParams: {
-    type: Object,
-    default: () => ({})
-  },
-  formModelInit: {
-    type: Function,
-    default: () => ({})
-  },
-});
-
-const formRef = ref<any>();
-const formModel = ref<any>(null);
-const formRules = computed(() => {
-  const rules: Record<string, any> = {};
-  function loop(prevKey: string, arr: FormDefineItem[]) {
-    if (!arr || !(arr instanceof Array))
-     return;
-    for (const item of arr) {
-      const key = prevKey ? `${prevKey}.${item.name}` : item.name;
-      if (key)
-        rules[key] = { 
-          label: item.label,
-		      validateTrigger: 'submit',
-          rules: item.rules
-        };
-      if (item.children) {
-        loop(
-          item.children.propNestType === 'flat' ? key : prevKey, 
-          item.children.items
-        );
-      }
-    }
-  }
-  loop('', props.formDefine.items);
-  return rules;
-});
-const formGlobalParams = toRef(props.formGlobalParams);
-
-provide('formTopModel', formModel);
-provide('formGlobalParams', formGlobalParams);
-
-watch(formRules, (v) => {
-  formRef.value?.setRules(v);
-});
-watch(() => props.formDefine, (v) => {
-  reloadFormData();
-});
-
-let isErrorState = false;
-let initCb : () => any = () => {
-  return {};
-};
-
-function initFormData(data: () => any) {
-  initCb = data;
-}
-function loadFormData(value?: Record<string, any>) {
-  const obj = reactive(initCb());
-
-  function loop(prevKey: string, arr: FormDefineItem[]) {
-    if (!arr || !(arr instanceof Array))
-     return;
-    for (let index = 0; index < arr.length; index++) {
-      const item = arr[index];
-      const key = prevKey ? `${prevKey}.${item.name}` : item.name;
-      if (key) {
-        const valueProvided = value?.[key] ;
-        obj[key] = valueProvided == null || valueProvided == undefined ? 
-          (typeof item.defaultValue === 'function' ? item.defaultValue() : item.defaultValue)  
-          : valueProvided ?? null;
-        item.fullName = key;
-      } else {
-        item.fullName = '';
-      }
-      if (item.children)
-        loop(
-          item.children.propNestType === 'flat' ? key : prevKey, 
-          item.children.items
-        );
-    }
-  }
-
-  loop('', props.formDefine.items);
-  formModel.value = obj;
-}
-async function submitForm<T = Record<string, any>>() : Promise<T|null> {
-  await formRef.value.clearValidate();
-  await waitTimeOut(50);
-  
-  try {
-    await formRef.value.validate();
-  } catch (e) {
-    if (isErrorState)
-      toast('请将表单填写完整');
-    console.log(e);
-    isErrorState = true;
-    return null;
-  }
-  isErrorState = false;
-  return formModel.value;
-}
-function resetForm() {
-  loadFormData();
-}
-
-function reloadFormData() {
-  if (!formModel.value)
-    loadFormData();
-  formRef.value.setRules(formRules.value);
-}
-
-onMounted(() => {
-  setTimeout(() => reloadFormData(), 300);
-});
-
-defineExpose<FormExport>({
-  initFormData,
-  loadFormData,
-  submitForm,
-  resetForm,
-})
-
-</script>

+ 0 - 37
src/common/components/form/components/CityPicker.vue

@@ -1,37 +0,0 @@
-<template>
-  <uni-data-picker
-    :modelValue="modelValue"
-    :localdata="data"
-    :map="{ text: 'text', value: useCode ? 'value' : 'text' }"
-    @change="onChange"
-  >
-  </uni-data-picker>
-</template>
-
-<script setup lang="ts">
-import NotConfigue from '@/api/NotConfigue';
-import { onMounted, ref } from 'vue';
-
-const data = ref();
-const props = defineProps({	
-  modelValue: { 
-    type: Array,
-    default: null 
-  },
-  useCode: {
-    type: Boolean,
-    default: false,
-  },
-})
-const emit = defineEmits(['update:modelValue'])
-
-onMounted(() => {
-  NotConfigue.get('https://mncdn.wenlvti.net/app_static/xiangan/city-data.json', '', undefined).then((res) => {
-    data.value = res.data; 
-  })
-});
-
-function onChange(e: any) {
-  emit('update:modelValue', e.detail.value.map((x: any) => props.useCode ? x.value : x.text));
-}
-</script>

+ 0 - 45
src/common/components/form/components/DynamicCheckbox.vue

@@ -1,45 +0,0 @@
-<template>
-  <u-loading-icon v-if="data2.loadStatus.value === 'loading'" />
-  <view 
-    v-else-if="data2.loadStatus.value === 'error'" 
-    class="d-flex flex-row align-center"
-    @click="data2.loadData(undefined, true)"
-  >
-    <u-icon name="error-circle-fill"></u-icon>
-    <text class="ml-2">{{ data2.loadError.value }}</text>
-  </view>
-  <uni-data-checkbox
-    v-else
-    :modelValue="modelValue"
-    selectedColor="#ff8719"
-    @update:modelValue="(v: any) => $emit('update:modelValue', v)"
-    :localdata="data2.content.value"
-    v-bind="$attrs"
-  >
-  </uni-data-checkbox>
-</template>
-
-<script setup lang="ts">
-import { useSimpleDataLoader } from '@/common/composeabe/SimpleDataLoader';
-import type { PropType } from 'vue';
-
-export interface DynamicCheckboxProps {
-  loadData: () => Promise<{
-    text: string
-    value: any
-    disable?: boolean
-  }[]>;
-}
-
-const props = defineProps({	
-  modelValue : { type: null }	,
-  loadData: { 
-    type: Function as PropType<DynamicCheckboxProps['loadData']>, 
-    default: () => {} 
-  },
-})
-const data2 = useSimpleDataLoader(props.loadData, true);
-
-defineEmits(['update:modelValue'])
-
-</script>

+ 0 - 45
src/common/components/form/components/DynamicSelect.vue

@@ -1,45 +0,0 @@
-<template>
-  <uni-data-select 
-    :modelValue="modelValue"
-    @update:modelValue="(v: any) => $emit('update:modelValue', v)"
-    :localdata="data2.content.value"
-    v-bind="$attrs"
-  >
-    <template #prefix>
-      <u-loading-icon v-if="data2.loadStatus.value === 'loading'" />
-      <view 
-        v-else-if="data2.loadStatus.value === 'error'" 
-        class="d-flex flex-row align-center"
-        @click="data2.loadData(undefined, true)"
-      >
-        <u-icon name="error-circle-fill"></u-icon>
-        <text class="ml-2">{{ data2.loadError.value }}</text>
-      </view>
-    </template>
-  </uni-data-select>
-</template>
-
-<script setup lang="ts">
-import { useSimpleDataLoader } from '@/common/composeabe/SimpleDataLoader';
-import type { PropType } from 'vue';
-
-export interface DynamicSelectProps {
-  loadData: () => Promise<{
-    text: string
-    value: any
-    disable?: boolean
-  }[]>;
-}
-
-const props = defineProps({	
-  modelValue : { type: null }	,
-  loadData: { 
-    type: Function as PropType<DynamicSelectProps['loadData']>, 
-    default: () => {} 
-  },
-})
-const data2 = useSimpleDataLoader(props.loadData, true);
-
-defineEmits(['update:modelValue'])
-
-</script>

+ 0 - 38
src/common/components/form/components/LonlatPicker.vue

@@ -1,38 +0,0 @@
-<template>
-  <u-button 
-    type="primary"
-    :plain="true"
-    :text="dispayText"
-    @click="onPick"
-  >
-  
-  </u-button>
-</template>
-
-<script setup lang="ts">
-import { computed } from 'vue';
-
-const props = defineProps({	
-  modelValue: { 
-    type: Array,
-    default: null 
-  },
-})
-const emit = defineEmits(['update:modelValue'])
-
-const dispayText = computed(() => {
-  return `经度:${props.modelValue[0] || '请填写'} 纬度:${props.modelValue[1] || '请填写'}`;
-});
-function onPick() {
-  uni.chooseLocation({
-    latitude: props.modelValue[1] as number,
-    longitude: props.modelValue[0] as number,
-    success: (res) => {
-      emit('update:modelValue', [res.longitude, res.latitude]);
-    },
-    fail: (e) => {
-      console.log(e)
-    },
-  });
-}
-</script>

+ 0 - 50
src/common/components/form/components/RichTextEditor.vue

@@ -1,50 +0,0 @@
-<template>
-  <view class="d-flex flex-col">
-    <text v-if="modelValue">已编写内容,总字数 {{ modelValue.length }} 字</text>
-    <text v-else>未编写内容,点击编写</text>
-    <view class="d-flex flex-row align-center gap-s mt-3">
-      <u-button @click="preview">预览内容</u-button>
-      <u-button @click="edit" type="primary">编辑内容</u-button>
-    </view>
-  </view>
-</template>
-
-<script setup lang="ts">
-import { onPageShow } from '@dcloudio/uni-app';
-import { navTo } from '@/components/utils/PageAction';
-
-const props = defineProps({	
-  modelValue: { 
-    type: String,
-    default: null 
-  },
-})
-const emit = defineEmits(['update:modelValue'])
-let editorOpened = false;
-
-function preview() {
-  uni.setStorage({
-    key: 'editorContent',
-    data: props.modelValue,
-    success: () => navTo('/pages/article/editor/preview'),
-  })
-}
-function edit() {
-  editorOpened = true;
-  uni.setStorage({
-    key: 'editorContent',
-    data: props.modelValue,
-    success: () => navTo('/pages/article/editor/editor'),
-  })
-}
-
-onPageShow(() => {
-  if (editorOpened) {
-    editorOpened = false;
-    uni.getStorage({
-      key: 'editorContent',
-      success: (success) => emit('update:modelValue', success.data),
-    })
-  }
-})
-</script>

+ 0 - 42
src/common/components/form/form/Form.vue

@@ -1,42 +0,0 @@
-<template>
-  <view class="nana-form">
-    <slot />
-  </view>
-</template>
-
-<script setup lang="ts">
-import type { PropType } from 'vue';
-import type { FormDefineItem } from '..';
-
-const props = defineProps({	
-  model: {
-    type: Object,
-    default: () => ({})
-  },
-  rules: {
-    type: Object as PropType<FormDefineItem['rules']>,
-    default: () => ({}) 
-  }
-});
-
-const formContext = {
-  addFormItem: (item: {
-    key: string,
-  }) => {
-    console.log('addFormItem', item);
-  }
-}
-
-defineExpose({
-
-})
-
-</script>
-
-<style lang="scss">
-.nana-form {
-  display: flex;
-  flex-direction: column;
-  gap: 20rpx;
-}
-</style>

+ 0 - 14
src/common/components/form/form/FormItem.vue

@@ -1,14 +0,0 @@
-<template>
-
-
-</template>
-
-<script setup lang="ts">
-
-</script>
-
-<style lang="scss">
-.nana-form-item {
-
-}
-</style>

+ 0 - 159
src/common/components/form/index.ts

@@ -1,159 +0,0 @@
-export interface FormDefine {
-  /**
-   * Todo: page
-   */
-  type?: 'flat'|'page'|'group',
-  props?: any;
-  propNestType?: 'flat'|'nest'|'array',
-  items: FormDefineItem[];
-}
-
-
-/**
- * 表单动态属性定义
- */
-export declare type IFormItemCallback<T> = {
-  /**
-   * 预留,暂未使用
-   */
-  type?: string;
-  /**
-   * @param model 当前表单条目的值
-   * @param rawModel 整个 form 的值 (最常用,当两个关联组件距离较远时,可以从顶层的 rawModel 里获取)
-   * @param parentModel 父表单元素的值 (上一级的值,只在列表场景的使用,例如列表某个元素的父级就是整个 item)
-   * @param item 当前表单条目信息
-   */
-  callback: (model: any, rawModel: any, parentModel: any, formGlobalParams: any, item: FormDefineItem) => T;
-};
-export type IFormItemCallbackAdditionalProps<T> = { [P in keyof T]?: T[P]|IFormItemCallback<T[P]> }
-
-export interface FormRulesItem {
-  /**
-   * 是否必填,默认false
-   */
-  required?: boolean;
-  /**
-   * 数组至少要有一个元素,且数组内的每一个元素都是唯一的。
-   */
-  range?: any[];
-  /**
-   * 内置校验规则,如这些规则无法满足需求,可以使用正则匹配或者自定义规则
-   */
-  format?: string;
-  /**
-   * 正则表达式,注意事项见下方说明
-   */
-  pattern?: RegExp;
-  /**
-   * 校验最大值(大于)
-   */
-  maximum?: number;
-  /**
-   * 校验最小值(小于)
-   */
-  minimum?: number;
-  /**
-   * 校验数据最小长度
-   */
-  minLength?: number;
-  /**
-   * 校验数据最大长度
-   */
-  maxLength?: number;
-  /**
-   * 校验失败提示信息语,可添加属性占位符,当前表格内属性都可用作占位符
-   */
-  errorMessage?: string;
-  /**
-   * 自定义校验规则
-   */
-  validateFunction?: (rule: any, value: any, data: any, callback: (e: any) => void) => boolean|undefined|void;
-}
-
-export interface FormDefineItem {
-  /**
-   * 表单项显示标签
-   */
-  label?: string|IFormItemCallback<string>;
-  /**
-   * 属性名称
-   */
-  name: string;
-  fullName?: string;
-  /**
-   * 表单项组件类型
-   */
-  type?: string;
-  /**
-   * 传递给条目组件的参数。(允许动态回调)
-   */
-  params?: Record<string, unknown|IFormItemCallback<unknown>>|unknown;
-  /**
-   * 传递给FormItem组件的参数
-   */
-  itemParams?: any;
-  /**
-   * 默认值,用于默认数据生成
-   */
-  defaultValue?: any;
-  /**
-   * 当前条目的校验规则
-   */
-  rules?: FormRulesItem[],
-  /**
-   * 子条目,在对象中为对象子属性,在数组中为数组条目(单条目按单项控制,多条目按对象看待控制)
-   */
-  children?: FormDefine,
-
-  //todo:联动
-
-  /**
-   * 是否显示。当为undefined时,默认显示。
-   */
-  show?: boolean|IFormItemCallback<boolean>|undefined,
-
-  /**
-   * 当前条目组件加载时发生事件
-   * @param topModel 顶层数据对象
-   * @param ref 组件实例
-   * @returns 
-   */
-  onMounted?: (topModel: any, ref: any) => void;
-
-  /**
-   * 当前条目组件卸载时发生事件
-   * @param topModel 顶层数据对象
-   * @param ref 组件实例
-   * @returns 
-   */
-  onBeforeUnMount?: (topModel: any, ref: any) => void;
-
-  /**
-   * 当前条目数据更改时发生事件
-   * @param oldValue 旧值
-   * @param newValue 新值
-   * @param topModel 顶层数据对象
-   * @param ref 组件实例
-   * @returns 
-   */
-  onChange?: (oldValue: any, newValue: any, topModel: any, ref: any) => void;
-}
-export interface FormExport {
-  /**
-   * 初始化表单数据对象
-   */
-  initFormData(data: () => any): void;
-  /**
-   * 加载表单数据
-   * @param value 表单数据
-   */
-  loadFormData(value?: Record<string, any>): void;
-  /**
-   * 提交表单
-   */
-  submitForm<T = Record<string, any>>(): Promise<T|null>;
-  /**
-   * 重置整个表单数据
-   */
-  resetForm(): void;
-}

+ 52 - 105
src/common/components/tabs/tabbar.vue

@@ -1,112 +1,59 @@
 <template>
-  <view class="custom-tabbar">
-    <view class="row">
-      <view class="tabbar-item" @click="switchTab('/pages/home', 0)"  :class="{active: current === 0}">
-        <image :src="current === 0 ? 'https://mncdn.wenlvti.net/app_static/minnan/images/tabs/icon_home_on.png' : 'https://mncdn.wenlvti.net/app_static/minnan/images/tabs/icon_home_off.png'" mode="aspectFit"></image>
-        <text>首页</text>
-      </view>
-      <view class="tabbar-item" @click="switchTab('/pages/discover', 1)" :class="{active: current === 1}">
-        <image :src="current === 1 ? 'https://mncdn.wenlvti.net/app_static/minnan/images/tabs/icon_discover_on.png' : 'https://mncdn.wenlvti.net/app_static/minnan/images/tabs/icon_discover_off.png'" mode="aspectFit"></image>
-        <text>发现</text>
-      </view>
-      <view class="tabbar-item center" @click="switchTab('/pages/inhert', 2)" :class="{active: current === 2}">
-        <image :src="current === 2 ? 'https://mncdn.wenlvti.net/app_static/minnan/images/tabs/icon_inhert_on.png' : 'https://mncdn.wenlvti.net/app_static/minnan/images/tabs/icon_inhert_off.png'" mode="aspectFit"></image>
-        <text>传承</text>
-      </view>
-      <view class="tabbar-item" @click="switchTab('/pages/travel', 3)" :class="{active: current === 3}">
-        <image :src="current === 3 ? 'https://mncdn.wenlvti.net/app_static/minnan/images/tabs/icon_shop_on.png' : 'https://mncdn.wenlvti.net/app_static/minnan/images/tabs/icon_shop_off.png'" mode="aspectFit"></image>
-        <text>文旅</text>
-      </view>
-      <view class="tabbar-item" @click="switchTab('/pages/user/index', 4)" :class="{active: current === 4}">
-        <image :src="current === 4 ? 'https://mncdn.wenlvti.net/app_static/minnan/images/tabs/icon_profile_on.png' : 'https://mncdn.wenlvti.net/app_static/minnan/images/tabs/icon_profile_off.png'" mode="aspectFit"></image>
-        <text>我的</text>
-      </view>
-    </view>
-    <u-safe-bottom />
-  </view>
+  <TabBar
+    :selectedTabIndex="current"
+    @update:selectedTabIndex="changeTab"
+    fixed
+    xbarSpace
+    :innerStyle="{ 
+      zIndex: 999 ,
+      boxShadow: '0 -2rpx 4rpx rgba(0, 0, 0, 0.1)',
+      backgroundColor: '#f7f3e8',
+    }"
+  >
+    <TabBarItem icon="https://mncdn.wenlvti.net/app_static/minnan/images/tabs/icon_home_off.png" activeIcon="https://mncdn.wenlvti.net/app_static/minnan/images/tabs/icon_home_on.png" text="首页" />
+    <TabBarItem icon="https://mncdn.wenlvti.net/app_static/minnan/images/tabs/icon_discover_off.png" activeIcon="https://mncdn.wenlvti.net/app_static/minnan/images/tabs/icon_discover_on.png" text="发现" />
+    <TabBarItem icon="https://mncdn.wenlvti.net/app_static/minnan/images/tabs/icon_inhert_off.png" activeIcon="https://mncdn.wenlvti.net/app_static/minnan/images/tabs/icon_inhert_on.png" hump :humpHeight="[0,0]" :humpSpace="[20,20]" :iconSize="140" text="传承" />
+    <TabBarItem icon="https://mncdn.wenlvti.net/app_static/minnan/images/tabs/icon_shop_off.png" activeIcon="https://mncdn.wenlvti.net/app_static/minnan/images/tabs/icon_shop_on.png" text="文旅" />
+    <TabBarItem icon="https://mncdn.wenlvti.net/app_static/minnan/images/tabs/icon_profile_off.png" activeIcon="https://mncdn.wenlvti.net/app_static/minnan/images/tabs/icon_profile_on.png" text="我的" />
+  </TabBar>
 </template>
 
-<script>
-export default {
-  props: {
-    current: {
-      type: Number,
-      default: 0
-    }
-  },
-  data() {
-    return {
-    }
-  },
-  methods: {
-    switchTab(path, index) {
-      if (this.current === index) return
-      uni.switchTab({
-        url: path
-      });
-    }
-  }
-}
-</script>
+<script setup lang="ts">
+import TabBar from '@/components/nav/TabBar.vue';
+import TabBarItem from '@/components/nav/TabBarItem.vue';
+import { watch } from 'vue';
 
-<style lang="scss" scoped>
-.custom-tabbar {
-  position: fixed;
-  bottom: 0;
-  left: 0;
-  right: 0;
-  z-index: 999;
-  height: auto;
-  background-color: #f7f3e8;
-  box-shadow: 0 -2rpx 4rpx rgba(0, 0, 0, 0.1);
-
-  display: flex;
-  flex-direction: column;
-
-  .row {
-    display: flex;
-    align-items: center;
-    justify-content: space-around;
-    padding-top: 20rpx;
+const props = defineProps({
+  current: {
+    type: Number,
+    default: 0
   }
-  .tabbar-item {
-    display: flex;
-    flex-direction: column;
-    align-items: center;
-    justify-content: center;
-
-    &.center{
-      margin-top: -58rpx;
-      image{
-        width: 120rpx;
-        height: 120rpx;
-      }
-      text {
-        margin-top: 0rpx;
-        z-index: 99;
-      }
-      &.active {
-        text {
-          color: #d94a2f;
-        }
-      }
-    }
-    image {
-      width: 65rpx;
-      height: 65rpx;
-    }
-
-    text {
-      font-size: 24rpx;
-      color: #111111;
-      margin-top: 8rpx;
-    }
-
-    &.active {
-      text {
-        color: #d94a2f;
-      }
-    }
+});
+function changeTab(newVal: number) {
+  switch(newVal) {
+    case 0:
+      switchTab('/pages/home', 0);
+      break;
+    case 1:
+      switchTab('/pages/discover', 1);
+      break;
+    case 2:
+      switchTab('/pages/inhert', 2);
+      break;
+    case 3:
+      switchTab('/pages/travel', 3);
+      break;
+    case 4:
+      switchTab('/pages/user/index', 4);
+      break;
   }
 }
-</style>
+
+function switchTab(path: string, index: number) {
+  if (props.current === index) 
+    return;
+  uni.switchTab({
+    url: path
+  });
+}
+</script>

+ 3 - 1
src/common/composeabe/TabControl.ts

@@ -1,7 +1,7 @@
 import { computed, ref, watch } from "vue";
 
 export interface TabControlItem {
-  name: string,
+  text: string,
   [key: string]: any,
 }
 
@@ -16,6 +16,8 @@ export function useTabControl(options: {
 
   watch(tabCurrentIndex, (v) => {
     options.onTabChange?.(v, tabCurrentId.value) 
+    if (tabsArray.value[v].jump)
+      tabsArray.value[v].jump()
   })
 
   const tabs = computed(() => {

+ 44 - 0
src/components/README.md

@@ -0,0 +1,44 @@
+# NaEasy UI 组件库
+
+NaEasy UI 是一款简单的 UniApp 移动端UI组件库。
+
+[文档](https://docs.imengyu.top/naeasy-ui-uniapp-docs/)
+
+## 版本
+
+当前版本:INDEV 0.0.1
+
+## 版权说明
+
+© 2024 imengyu. 保留所有权利。
+
+本组件库基于 MIT 许可证开源。您可以自由地使用、修改和分发本组件库,但必须保留原始的版权声明和许可证文本。
+
+### 许可证详情
+
+MIT License
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+### 使用注意事项
+
+1. 请确保在您的项目中适当标注本组件库的来源
+2. 对于商用项目,请确保遵守相关法律法规
+3. 如对组件库进行了修改或扩展,请在文档中明确说明
+4. 我们不对使用本组件库可能产生的任何损失或问题承担责任

+ 108 - 0
src/components/anim/SimpleTransition.vue

@@ -0,0 +1,108 @@
+<template>
+  <slot 
+    v-if="showState" 
+    name="show" 
+    :classNames="[
+      className,
+      animState ? `${name}-${animState}-active` : '',
+    ]" 
+  />
+</template>
+
+<script setup lang="ts">
+import { nextTick, onMounted, ref, watch } from 'vue';
+
+/**
+ * 简单过渡组件
+ */
+export interface TransitionProps {
+  /**
+   * 是否显示
+   * @default false
+   */
+  show: boolean;
+  /**
+   * 是否启用动画
+   * @default true
+   */
+  anim?: boolean;
+  /**
+   * 动画持续时间(毫秒)
+   * @default 500
+   */
+  duration?: number,
+  /**
+   * 动画名称
+   * @default 'v'
+   */
+  name?: string;
+}
+
+defineOptions({
+  options: {
+    virtualHost: true,
+    styleIsolation: "shared",
+  },
+});
+
+const props = withDefaults(defineProps<TransitionProps>(), {
+  anim: true,
+  name: 'v',
+  duration: 500,
+});
+
+const className = ref('');
+const showState = ref(false);
+const animState = ref<'enter'|'leave'|''>('');
+
+onMounted(() => {
+  if (props.show)
+    doAnim(true);
+});
+watch(() => props.show, (newState) => {
+  doAnim(newState);
+});
+
+let timer = 0;
+
+function clearTimer() {
+  if (timer) {
+    clearTimeout(timer);
+    timer = 0;
+  }
+}
+function doAnim(newState: boolean) {
+  if (!props.anim || props.duration <= 10) {
+    showState.value = newState;
+    return;
+  }
+  clearTimer();
+  if (newState) {
+    showState.value = true;
+    className.value = `${props.name}-enter-from`;
+    animState.value = 'enter';
+    setTimeout(() => {
+      nextTick(() => {
+        className.value = `${props.name}-enter-to`;
+        timer = setTimeout(() => {
+          animState.value = '';
+          timer = 0;
+        }, props.duration) as any;
+      })
+    }, 30);
+  } else {
+    showState.value = true;
+    className.value = `${props.name}-leave-from`;
+    animState.value = 'leave';
+    nextTick(() => {
+      className.value = `${props.name}-leave-to`;
+      timer = setTimeout(() => {
+        showState.value = false;
+        animState.value = '';
+        timer = 0;
+      }, props.duration) as any;
+    })
+  }
+
+}
+</script>

+ 125 - 0
src/components/basic/ActivityIndicator.vue

@@ -0,0 +1,125 @@
+<template>
+  <view 
+    class="nana-activity-indicator"
+    :style="style"
+  >
+    <!-- #ifndef APP-NVUE || MP -->
+    <svg class="chrome-spinner" viewBox="0 0 50 50">
+      <circle cx="25" cy="25" r="20" class="single-ring" :stroke-width="props.strokeWidth" />
+    </svg>
+    <!-- #endif -->
+    <!-- #ifdef APP-NVUE || MP -->
+    
+    <!-- #endif -->
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import { propGetThemeVar, useTheme, type ViewStyle } from '../theme/ThemeDefine';
+
+export interface ActivityIndicatorProps {
+  /**
+   * 加载中圆圈颜色
+   */
+  color?: string,
+  /**
+   * 加载中圆圈颜色
+   */
+  size?: string|number,
+  /**
+   * 加载中圆圈宽度
+   */
+  strokeWidth?: number,
+  /**
+   * 自定义样式
+   */
+  innerStyle?: ViewStyle,
+}
+
+const themeContext = useTheme();
+
+const props = withDefaults(defineProps<ActivityIndicatorProps>(), {
+  color: () => propGetThemeVar('ActivityIndicatorColor', 'primary'),
+  size: () => propGetThemeVar('ActivityIndicatorSize', 60),
+})
+
+const style = computed(() => {
+  return {
+    borderTopColor: themeContext.resolveThemeColor(props.color),
+    color: themeContext.resolveThemeColor(props.color),
+    width: themeContext.resolveThemeSize(props.size),
+    height: themeContext.resolveThemeSize(props.size),
+    ...props.innerStyle,
+  }
+});
+</script>
+
+<style lang="scss">
+/* #ifndef APP-NVUE || MP */
+
+/* 旋转动画 */
+@keyframes spin {
+  0% {
+    transform: rotate(0deg);
+  }
+  100% {
+    transform: rotate(360deg);
+  }
+}
+
+/* 线段长度变化动画 */
+@keyframes dash {
+  0% {
+    stroke-dasharray: 10 190; /* 最短状态 */
+    stroke-dashoffset: 0;
+  }
+  50% {
+    stroke-dasharray: 100 100; /* 最长状态 */
+    stroke-dashoffset: -40;
+  }
+  100% {
+    stroke-dasharray: 10 190; /* 回到最短 */
+    stroke-dashoffset: -200;
+  }
+}
+
+
+.nana-activity-indicator {
+  .chrome-spinner {
+    width: 100%;
+    height: 100%;
+    animation: spin 1.5s linear infinite;
+    
+    .single-ring {
+      fill: none;
+      stroke-width: 5;
+      stroke-linecap: round;
+      stroke: currentColor; /* 单色灰色 */
+      stroke-dasharray: 20 160; /* 控制线段长度 */
+      animation: dash 1.5s ease-in-out infinite;
+    }
+  }
+}
+/* #endif */
+
+/* #ifdef APP-NVUE || MP */
+@keyframes spin {
+  from {
+    transform: rotate(0deg);
+  }
+  to {
+    transform: rotate(360deg);
+  }
+}
+.nana-activity-indicator {
+  width: 50px;
+  height: 50px;
+  border: 5px solid transparent;
+  border-top: 5px solid #3498db;
+  border-radius: 50%;
+  animation: spin 1s linear infinite;
+  box-sizing: border-box;
+}
+/* #endif */
+</style>

+ 467 - 0
src/components/basic/Button.vue

@@ -0,0 +1,467 @@
+<template>
+  <Touchable
+    :innerStyle="{
+      ...currentStyle.style,
+      ...innerStyle,
+    }"
+    :innerClass="['nana-button', props.block ? 'nana-button-block' : 'nana-button-auto']"
+    center
+    direction="row"
+    v-bind="viewProps"
+    :pressedColor="finalPressedColor"
+    :touchable="touchable && !loading"
+    @state="(v) => state = v"
+    @click="emit('click', $event)"
+  >
+    <slot name="leftIcon">
+      <ActivityIndicator 
+        v-if="loading"
+        :size="selectStyleType(size, 'medium', FonstSizes)"
+        :color="themeContext.resolveThemeColor(loadingColor) || currentStyle.color"
+        :innerStyle="{
+          marginRight: iconMargin ? '10rpx': undefined,
+        }"
+      />
+      <Icon
+        v-else-if="icon"
+        :icon="icon"
+        :size="selectStyleType(size, 'medium', FonstSizes)"
+        :color="currentStyle.color"
+        :innerStyle="{
+          marginRight: iconMargin ? '10rpx': undefined,
+        }"
+        v-bind="iconProps"
+      />
+    </slot>
+    <slot>
+      <Text 
+        :color="textColorFinal" 
+        :fontSize="selectStyleType(size, 'medium', FonstSizes)" 
+        :fontWeight="type === 'text' ? 'bold' : undefined"
+        :innerStyle="textStyle"
+        :text="currentText"
+      />
+    </slot>
+    <slot name="rightIcon">
+      <Icon
+        v-if="rightIcon"
+        :icon="rightIcon"
+        :size="selectStyleType(size, 'medium', FonstSizes)"
+        :color="currentStyle.color"
+        :innerStyle="{
+          marginLeft: iconMargin ? '10rpx' : undefined,
+        }"
+        v-bind="rightIconProps"
+      />
+    </slot>
+  </Touchable>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+import { useTheme, type ViewStyle } from '../theme/ThemeDefine';
+import { configPadding, DynamicColor, DynamicSize, selectStyleType } from '../theme/ThemeTools';
+import type { IconProps } from './Icon.vue';
+import type { FlexProps } from '../layout/FlexView.vue';
+import Text from './Text.vue';
+import ActivityIndicator from './ActivityIndicator.vue';
+import Icon from './Icon.vue';
+import Touchable from '../feedback/Touchable.vue';
+
+export type ButtomType = 'default'|'primary'|'success'|'warning'|'danger'|'custom'|'text';
+export type ButtomSizeType = 'small'|'medium'|'large'|'larger'|'mini';
+
+export interface ButtonProp {
+  /**
+   * 按钮文字
+   */
+  text?: string,
+  /**
+   * 按钮支持 default、primary、success、warning、danger、custom 自定义 六种类型
+   * @default 'default'
+   */
+  type?: ButtomType,
+  /**
+   * 占满父级主轴
+   * @default false
+   */
+  block?: boolean,
+  /**
+   * * plain 将按钮设置为朴素按钮,朴素按钮的文字为按钮颜色,背景为白色。
+   * * light 将按钮设置为浅色按钮,浅色按钮的文字为按钮颜色,背景为主色调浅色。
+   * @default 'default'
+   */
+  scheme?: 'default'|'plain'|'light',
+  /**
+   * 通过 loading 属性设置按钮为加载状态,加载状态下默认会隐藏按钮文字,可以通过 loadingText 设置加载状态下的文字。
+   * @default false
+   */
+  loading?: boolean,
+  /**
+   * 加载状态下的文字。
+   * @default false
+   */
+  loadingText?: string,
+  /**
+   * 加载状态圆圈颜色
+   */
+  loadingColor?: string,
+  /**
+   * 按钮形状 通过 square 设置方形按钮,通过 round 设置圆形按钮。
+   * @default 'round'
+   */
+  shape?: 'square'|'round',
+  /**
+   * 左侧图标。支持 Icon 组件里的所有图标,也可以传入图标的图片 URL(http/https)。
+   */
+  icon?: string,
+  /**
+   * 当使用图标时,左侧图标的附加属性
+   */
+  iconProps?: IconProps;
+  /**
+   * 右侧图标。支持 Icon 组件里的所有图标,也可以传入图标的图片 URL(http/https)。
+   */
+  rightIcon?: string,
+  /**
+   * 当使用图标时,右侧图标的附加属性
+   */
+  rightIconProps?: IconProps;
+  /**
+   * 是否可以点击
+   * @default true
+   */
+  touchable?: boolean,
+  /**
+   * 当按扭为round圆形按扭时的圆角大小。
+   * @default 5
+   */
+  radius?: number,
+  /**
+   * 按钮尺寸. 支持 large、medium、small、mini 四种尺寸。
+   * @default 'medium'
+   */
+  size?: ButtomSizeType,
+  /**
+   * 通过 color 属性可以自定义按钮的背景颜色,仅在 type 为 `custom` 时有效
+   * @default grey
+   */
+  color?: string;
+  /**
+   * 按钮文字的颜色。
+   */
+  textColor?: string;
+  /**
+   * 按下时按钮文字的颜色。
+   */
+  pressedTextColor?: string;
+  /**
+   * 按钮文字的样式。
+   */
+  textStyle?: object;
+  /**
+   * 按下时的颜色,仅在 type 为 `custom` 时有效
+   * @default PressedColor(primary)
+   */
+  pressedColor?: string;
+  /**
+   * 禁用时的颜色,仅在 type 为 `custom` 时有效
+   * @default grey
+   */
+  disabledColor?: string;
+  /**
+   * 自定义样式
+   */
+  innerStyle?: object,
+  /**
+   * 按扭的文字,等同于 text 属性
+   */
+  children?: string;
+  /**
+   * 强制控制按钮的边距
+   * * 如果是数字,则设置所有方向边距
+   * * 两位数组 [vetical,horizontal]
+   * * 四位数组 [top,right,down,left]
+   */
+  padding?: number|number[],
+  /**
+   * 外层容器参数
+   */
+  viewProps?: FlexProps,
+
+  formType?: string;
+  openType?: string;
+  appParameter?: string;
+}
+
+defineOptions({
+  options: {
+    styleIsolation: "shared",
+    virtualHost: true,
+  }
+})
+
+const emit = defineEmits([ 
+  'click',
+])
+
+const themeContext = useTheme();
+
+const props = withDefaults(defineProps<ButtonProp>(), {
+  touchable: true,
+  loading: false,
+  color: 'primary',
+  pressedColor: 'pressed.primary',
+  disabledColor: 'grey',
+  type: 'default',
+  size: 'medium',
+  block: false,
+  radius: 16,
+  shape: "round",
+});
+
+const FonstSizes = computed(() => ({
+  mini: themeContext.resolveThemeSize('ButtonMiniFonstSize', 'fontSize.mini'),
+  small: themeContext.resolveThemeSize('ButtonSmallFonstSize', 'fontSize.small'),
+  medium: themeContext.resolveThemeSize('ButtonMediumFonstSize', 'fontSize.medium'),
+  large: themeContext.resolveThemeSize('ButtonLargeFonstSize', 'fontSize.large'),
+  larger: themeContext.resolveThemeSize('ButtonLargerFonstSize', 'fontSize.larger'),
+}));
+const themeVars = themeContext.getVars({
+  ButtonBorderWidth: 1.5,
+  ButtonDisableOpacity: 0.5,
+});
+const themeStyles = themeContext.useThemeStyles({
+  plainButtonDefault: {
+    borderStyle: 'solid',
+    borderWidth: DynamicSize('ButtonBorderWidth', 1.5),
+    borderColor: DynamicColor('ButtonPlainDefaultBorderColor', 'border'),
+    color: DynamicColor('ButtonPlainDefaultColor', 'text'),
+  },
+  plainButtonPrimary: {
+    borderStyle: 'solid',
+    borderWidth: DynamicSize('ButtonBorderWidth', 1.5),
+    borderColor: DynamicColor('ButtonPlainPrimaryBorderColor', 'primary'),
+    color: DynamicColor('ButtonPlainPrimaryColor', 'primary'),
+  },
+  plainButtonSuccess: {
+    borderStyle: 'solid',
+    borderWidth: DynamicSize('ButtonBorderWidth', 1.5),
+    borderColor: DynamicColor('ButtonPlainSuccessBorderColor', 'success'),
+    color: DynamicColor('ButtonPlainSuccessColor', 'success'),
+  },
+  plainButtonWarning: {
+    borderStyle: 'solid',
+    borderWidth: DynamicSize('ButtonBorderWidth', 1.5),
+    borderColor: DynamicColor('ButtonPlainWarningBorderColor', 'warning'),
+    color: DynamicColor('ButtonPlainWarningColor', 'warning'),
+  },
+  plainButtonDanger: {
+    borderStyle: 'solid',
+    borderWidth: DynamicSize('ButtonBorderWidth', 1.5),
+    borderColor: DynamicColor('ButtonPlainDangerBorderColor', 'danger'),
+    color: DynamicColor('ButtonPlainDangerColor', 'danger'),
+  },
+  lightButtonDefault: {
+    backgroundColor: DynamicColor('ButtonLightDefaultBackgroundColor', 'background.button'),
+    color: DynamicColor('ButtonLightDefaultColor', 'text'),
+  },
+  lightButtonPrimary: {
+    backgroundColor: DynamicColor('ButtonLightPrimaryBackgroundColor', 'background.primary'),
+    color: DynamicColor('ButtonLightPrimaryColor', 'text.primary'),
+  },
+  lightButtonSuccess: {
+    backgroundColor: DynamicColor('ButtonLightSuccessBackgroundColor', 'background.success'),
+    color: DynamicColor('ButtonLightSuccessColor', 'text.success'),
+  },
+  lightButtonWarning: {
+    backgroundColor: DynamicColor('ButtonLightWarningBackgroundColor', 'background.warning'),
+    color: DynamicColor('ButtonLightWarningColor', 'text.warning'),
+  },
+  lightButtonDanger: {
+    backgroundColor: DynamicColor('ButtonLightDangerBackgroundColor', 'background.danger'),
+    color: DynamicColor('ButtonLightDangerColor', 'text.danger'),
+  },
+  buttonSizeLarger: {
+    paddingVertical: DynamicSize('ButtonPaddingVerticalLarger', 25),
+    paddingHorizontal: DynamicSize('ButtonPaddingHorizontalLarger', 30),
+  },
+  buttonSizeLarge: {
+    paddingVertical: DynamicSize('ButtonPaddingVerticalLarge', 20),
+    paddingHorizontal: DynamicSize('ButtonPaddingHorizontalLarge', 25),
+  },
+  buttonSizeMedium: {
+    paddingVertical: DynamicSize('ButtonPaddingVerticalMedium', 15),
+    paddingHorizontal: DynamicSize('ButtonPaddingHorizontalMedium', 20),
+  },
+  buttonSizeSmall: {
+    paddingVertical: DynamicSize('ButtonPaddingVerticalSmall', 10),
+    paddingHorizontal: DynamicSize('ButtonPaddingHorizontalSmall', 15),
+  },
+  buttonSizeMini: {
+    paddingVertical: DynamicSize('ButtonPaddingVerticalMini', 5),
+    paddingHorizontal: DynamicSize('ButtonPaddingHorizontalMini', 6),
+  },
+  buttonDefault: {
+    backgroundColor: DynamicColor('ButtonDefaultBackgroundColor', 'button'),
+    color: DynamicColor('ButtonDefaultColor', 'black'),
+  },
+  buttonPrimary: {
+    backgroundColor: DynamicColor('ButtonPrimaryBackgroundColor', 'primary'),
+    color: DynamicColor('ButtonPrimaryColor', 'white'),
+  },
+  buttonSuccess: {
+    backgroundColor: DynamicColor('ButtonSuccessBackgroundColor', 'success'),
+    color: DynamicColor('ButtonSuccessColor', 'white'),
+  },
+  buttonWarning: {
+    backgroundColor: DynamicColor('ButtonWarningBackgroundColor', 'warning'),
+    color: DynamicColor('ButtonWarningColor', 'white'),
+  },
+  buttonDanger: {
+    backgroundColor: DynamicColor('ButtonDangerBackgroundColor', 'danger'),
+    color: DynamicColor('ButtonDangerColor', 'white'),
+  },
+});
+
+//按钮样式生成
+const currentStyle = computed(() => {
+  const colorStyle = selectStyleType<ViewStyle, ButtomType>(props.type, 'default', 
+    selectStyleType(props.scheme, 'default', {
+      default: {
+        default: themeStyles.buttonDefault.value,
+        primary: themeStyles.buttonPrimary.value,
+        success: themeStyles.buttonSuccess.value,
+        warning: themeStyles.buttonWarning.value,
+        danger: themeStyles.buttonDanger.value,
+        custom: {
+          backgroundColor: themeContext.resolveThemeColor(props.touchable ? props.color : props.disabledColor),
+          color: themeContext.resolveThemeColor(props.textColor),
+        },
+        text: {
+          color: themeContext.resolveThemeColor(props.textColor),
+        },
+      },
+      plain: {
+        default: themeStyles.plainButtonDefault.value,
+        primary: themeStyles.plainButtonPrimary.value,
+        success: themeStyles.plainButtonSuccess.value,
+        warning: themeStyles.plainButtonWarning.value,
+        danger: themeStyles.plainButtonDanger.value,
+        custom: {
+          borderStyle: 'solid',
+          borderWidth: themeContext.resolveThemeSize(themeVars.ButtonBorderWidth),
+          borderColor: themeContext.resolveThemeColor(props.color),
+          color: themeContext.resolveThemeColor(props.color),
+        },
+        text: {
+          color: themeContext.resolveThemeColor(props.color),
+        },
+      },
+      light: {
+        default: themeStyles.lightButtonDefault.value,
+        primary: themeStyles.lightButtonPrimary.value,
+        success: themeStyles.lightButtonSuccess.value,
+        warning: themeStyles.lightButtonWarning.value,
+        danger: themeStyles.lightButtonDanger.value,
+        custom: {
+          backgroundColor: themeContext.resolveThemeColor(props.touchable ? props.color : props.disabledColor),
+          color: themeContext.resolveThemeColor(props.textColor),
+        },
+        text: {
+          color: themeContext.resolveThemeColor(props.color),
+        },
+      },
+    })
+  );
+
+  const speicalStyle : ViewStyle = {
+    opacity: props.touchable ? 1 : themeVars.ButtonDisableOpacity,
+    borderRadius: props.shape === 'round' ? themeContext.resolveThemeSize(props.radius) : 0,
+  };
+
+  //自定义状态下的禁用颜色
+  if (props.disabledColor && !props.touchable && props.type === 'custom')
+    speicalStyle.backgroundColor = themeContext.resolveThemeColor(props.disabledColor);
+
+  const sizeStyle = selectStyleType<ViewStyle, ButtomSizeType>(props.size, 'medium', {
+    large: themeStyles.buttonSizeLarge.value,
+    larger: themeStyles.buttonSizeLarger.value,
+    medium: themeStyles.buttonSizeMedium.value,
+    small: themeStyles.buttonSizeSmall.value,
+    mini: themeStyles.buttonSizeMini.value,
+  });
+  //if (props.shape === 'round')
+  //  sizeStyle.paddingHorizontal = `calc(${sizeStyle.paddingHorizontal} + ${themeContext.resolveSize(props.radius / 4)})`;
+
+  //内边距样式的强制设置
+  configPadding(speicalStyle, themeContext.theme, props.padding);
+
+  return {
+    color: (colorStyle).color,
+    style: {
+      ...colorStyle,
+      ...sizeStyle,
+      ...speicalStyle,
+    },
+  };
+});
+
+const state = ref('');
+
+const currentText =  computed(() => (props.loading ? (props.loadingText || props.text) : props.text));
+const iconMargin = computed(() => Boolean(currentText.value));
+const textColorFinal = computed(() => (
+  state.value === 'active' ?
+    themeContext.resolveThemeColor(props.pressedTextColor) :
+    themeContext.resolveThemeColor(props.textColor)
+) || currentStyle.value.color);
+const finalPressedColor = computed(() => {
+  if (props.type === 'custom')
+    return themeContext.resolveThemeColor(props.pressedColor);
+  return (themeContext.resolveThemeColor((
+    props.scheme === 'plain' 
+    || props.scheme === 'light' 
+    || props.type === 'text'
+  ) ? 
+    'pressed.notice' : 
+    'pressed.' + props.type))
+});
+
+
+</script>
+
+<style>
+.nana-button {
+  width: auto;
+  user-select: none;
+  cursor: pointer;
+  appearance: none;
+}
+.nana-button-inner {
+  display: flex;
+  flex-direction: row;
+  justify-content: center;
+  align-items: center;
+  background-color: transparent;
+  border: none;
+  outline: none;
+  line-height: auto;
+  padding: 0;
+  margin: 0;
+  min-height: 0;
+}
+.nana-button-inner::after {
+  display: none;
+}
+.nana-button-auto {
+  flex-shrink: 0;
+  flex-grow: 0;
+  flex-basis: auto;
+}
+.nana-button-block {
+  align-self: stretch;
+  max-width: 100%;
+}
+</style>
+

+ 336 - 0
src/components/basic/Cell.vue

@@ -0,0 +1,336 @@
+<template>
+  <Touchable
+    direction="row"
+    :touchable="touchable || Boolean($attrs['onClick'])"
+    :pressedColor="pressedColor"
+    :innerStyle="{
+      ...viewStyle,
+      ...style,
+    }"
+    :flex="1" 
+    :align="center ? 'center' : 'flex-start'"
+    justify="space-between"
+    @click="handleClick"
+  >
+    <slot>
+      <FlexRow key="left" :flexShrink="1" center>
+        <slot name="left">
+          <FlexRow 
+            key="leftIcon"
+            :width="(iconPlaceholder || icon) ? iconWidth : 0" 
+            center
+          >
+            <slot name="leftIcon">
+              <Icon
+                v-if="icon"
+                key="leftIcon"
+                :icon="icon"
+                :size="iconSize"
+                v-bind="iconProps"
+                :innerStyle="{ 
+                  ...titleStyle, 
+                  ...iconStyle,
+                }"
+              />
+            </slot>
+          </FlexRow>
+          <FlexCol :innerStyle="leftViewStyle">
+            <slot name="title">
+              <Text v-if="title" :innerStyle="{ ...titleStyle, ...textStyle} ">{{ title }}</Text>
+            </slot>
+            <slot name="label">
+              <Text v-if="label" :innerStyle="{ ...labelStyle, ...textStyle} ">{{ label }}</Text>
+            </slot>
+          </FlexCol>
+        </slot>
+      </FlexRow>
+      <FlexRow key="right" :flexShrink="0" center>
+        <slot name="right">
+          <slot name="rightPrepend" />
+          <slot name="value">
+            <Text 
+              key="value"
+              :selectable="valueSelectable"
+              :innerStyle="{ ...valueStyle, ...textStyle }"
+            >
+              {{value ? ('' + value) : ''}}
+            </Text>
+          </slot>
+          <Icon
+            v-if="showArrow"
+            key="rightArrow"
+            icon="arrow-right"
+            :size="textStyle.fontSize"
+            :color="titleStyle.color"
+          />
+          <slot name="rightIcon">
+            <Icon
+              v-if="rightIcon"
+              key="rightIcon"
+              v-bind="rightIconProps"
+              :size="iconSize"
+              :icon="rightIcon"
+              :innerStyle="{ ...titleStyle, ...rightIconStyle }"
+            />
+          </slot>
+        </slot>
+      </FlexRow>
+    </slot>
+  </Touchable>
+</template>
+
+<script setup lang="ts">
+import { computed, provide } from 'vue';
+import { propGetThemeVar, useTheme, type ThemePaddingMargin } from '../theme/ThemeDefine';
+import { CellContextKey, type CellContext } from './CellContext';
+import { configPadding } from '../theme/ThemeTools';
+import type { IconProps } from './Icon.vue';
+import FlexRow from '../layout/FlexRow.vue';
+import FlexCol from '../layout/FlexCol.vue';
+import Text from './Text.vue';
+import Icon from './Icon.vue';
+import Touchable from '../feedback/Touchable.vue';
+
+export interface CellProp {
+  /**
+   * 左侧标题
+   */
+  title?: string,
+  /**
+   * 右侧内容
+   */
+  value?: string|number,
+  /**
+   * 设置右侧内容是否可以选择
+   * @default false
+   */
+  valueSelectable?: boolean,
+  /**
+   * 标题下方的描述信息
+   */
+  label?: string,
+  /**
+   * 左侧图标名称或图片链接(http/https),等同于 Icon 组件的 icon
+   */
+  icon?: string,
+  /**
+   * 当使用图标时,左图标的附加属性
+   */
+  iconProps?: IconProps;
+  /**
+   * 当左侧图标未设置时,是否在左侧追加一个占位区域,以和其他单元格对齐
+   * @default false
+   */
+  iconPlaceholder?: boolean,
+  /**
+   * 左侧图标区域的宽度
+   * @default 50
+   */
+  iconWidth?: number|string|'auto',
+  /**
+   * 左侧图标的大小
+   * @default 40
+   */
+  iconSize?: number|string,
+  /**
+   * 右侧图标的大小
+   * @default 40
+   */
+  rightIconSize?: number|string,
+  /**
+   * 右侧图标名称或图片链接(http/https),等同于 Icon 组件的 icon
+   */
+  rightIcon?: string,
+  /**
+   * 当使用图标时,右图标的附加属性
+   */
+  rightIconProps?: IconProps;
+  /**
+   * 是否可以点击
+   * @default false
+   */
+  touchable?: boolean,
+  /**
+   * 是否展示右侧箭头
+   * @default false
+   */
+  showArrow?: boolean,
+  /**
+   * 是否使内容垂直居中
+   * @default false
+   */
+  center?: boolean,
+  /**
+   * 大小
+   * @default medium
+   */
+  size?:'small'|'medium'|'large',
+  /**
+   * 背景颜色
+   * @default Color.white
+   */
+  backgroundColor?: string;
+  /**
+   * 是否显示顶部边框
+   * @default false
+   */
+  topBorder?: boolean;
+  /**
+   * 是否显示底部边框
+   * @default true
+   */
+  bottomBorder?: boolean;
+  /**
+   * 按下的背景颜色
+   * @default PressedColor(Color.white)
+   */
+  pressedColor?: string,
+  /**
+   * 圆角
+   */
+  radius?: string|number,
+  /**
+   * 自定义样式
+   */
+  innerStyle?: object,
+  /**
+   * 自定义左侧图标样式
+   */
+  iconStyle?: object,
+  /**
+   * 自定义右侧图标样式
+   */
+  rightIconStyle?: object,
+  /**
+   * 强制控制按钮的边距。如果是数字,则设置所有方向边距;两位数组 [vetical,horizontal];四位数组 [top,right,down,left]
+   */
+  padding?: number|number[]|ThemePaddingMargin,
+}
+
+const emit = defineEmits([ 'click' ]);
+
+const theme = useTheme();
+
+const props = withDefaults(defineProps<CellProp>(), {
+  backgroundColor: () => propGetThemeVar('CellBackground', 'white'),
+  size: () => propGetThemeVar('CellSize', 'medium'),
+  padding: () => propGetThemeVar<any>('CellPadding', undefined),
+  pressedColor: () => propGetThemeVar('CellPressedColor', 'pressed.white'),
+  bottomBorder: () => propGetThemeVar('CellBottomBorder', true),
+  topBorder: () => propGetThemeVar('CellTopBorder', false),
+  center: () => propGetThemeVar('CellCenter', true),
+  radius:  () => propGetThemeVar('CellRadius', 0),
+  iconWidth: () => propGetThemeVar('CellIconWidth', 50),
+  iconSize: () => propGetThemeVar('CellIconSize', 40),
+  rightIconSize: () => propGetThemeVar('CellIconSize', 40),
+  valueSelectable: false,
+})
+
+provide<CellContext>(CellContextKey, {
+  setOnClickListener: (listener) => {
+    customClickListener = listener;
+  },
+})
+
+const themeVars = computed(() => theme.resolveThemeSizes({
+  CellBorderWidth: 1,
+  CellFontSizeLarge: 36,
+  CellFontSizeMedium: 32,
+  CellFontSizeSmall: 28,
+  CellIconSize: 30,
+  CellIconWidth: 40,
+  CellHeightLarge: 100,
+  CellHeightMedium: 75,
+  CellHeightSmall: 50,
+  CellPaddingLarge: 15,
+  CellPaddingMedium: 10,
+  CellPaddingSmall: 7,
+}));
+const themeColorVars = computed(() => theme.resolveThemeColors({
+  CellBorderColor: 'border.cell',
+}));
+
+const leftViewStyle = computed(() => ({
+  marginLeft: theme.resolveThemeSize('CellLeftPaddingHorizontal', 15),
+  marginRight: theme.resolveThemeSize('CellLeftPaddingHorizontal', 15),
+}));
+const titleStyle = computed(() => ({
+  overflow: 'hidden',
+  textOverflow: 'ellipsis',
+  color: theme.resolveThemeColor('CellTitleColor', 'text.content'),
+}));
+const labelStyle = computed(() => ({
+  overflow: 'hidden',
+  textOverflow: 'ellipsis',
+  color: theme.resolveThemeColor('CellLabelColor', 'text.second'),
+}));
+const valueStyle = computed(() => ({
+  color: theme.resolveThemeColor('CellLabelColor', 'text.second'),
+  marginLeft: theme.resolveThemeSize('CellValuePaddingHorizontal', 20),
+  marginRight: theme.resolveThemeSize('CellValuePaddingHorizontal', 20),
+}));
+const viewStyle = computed(() => ({
+  position: 'relative',
+  flexDirection: 'row',
+  justifyContent: 'space-between',
+  alignItems: 'center',
+  overflow: 'hidden',
+  paddingLeft: theme.resolveThemeSize('CellPaddingHorizontal', 25),
+  paddingRight: theme.resolveThemeSize('CellPaddingHorizontal', 25),
+}));
+const style = computed(() => {
+  const styleObj : Record<string, any> = {
+    backgroundColor: theme.resolveThemeColor(props.backgroundColor),
+    borderRadius: theme.resolveThemeSize(props.radius),
+    ...props.innerStyle,
+  };
+
+  switch (props.size) {
+    case 'large':
+      styleObj.minHeight = themeVars.value.CellHeightLarge as number;
+      styleObj.paddingTop = themeVars.value.CellPaddingLarge as number;
+      styleObj.paddingBottom = themeVars.value.CellPaddingLarge as number;
+      break;
+    default:
+    case 'medium':
+      styleObj.minHeight = themeVars.value.CellHeightMedium as number;
+      styleObj.paddingTop = themeVars.value.CellPaddingMedium as number;
+      styleObj.paddingBottom = themeVars.value.CellPaddingMedium as number;
+      break;
+    case 'small':
+      styleObj.minHeight = themeVars.value.CellHeightSmall as number;
+      styleObj.paddingTop = themeVars.value.CellPaddingSmall as number;
+      styleObj.paddingBottom = themeVars.value.CellPaddingSmall as number;
+  }
+
+  //内边距样式的强制设置
+  configPadding(styleObj, theme.theme, props.padding);
+
+  //边框设置
+  if (props.topBorder)
+    styleObj.borderTop = `${themeVars.value.CellBorderWidth} solid ${themeColorVars.value.CellBorderColor}`;
+  if (props.bottomBorder)
+    styleObj.borderBottom = `${themeVars.value.CellBorderWidth} solid ${themeColorVars.value.CellBorderColor}`;
+
+  return styleObj
+});
+//文字样式
+const textStyle = computed(() => {
+  switch (props.size) {
+    case 'large':
+      return { fontSize: themeVars.value.CellFontSizeLarge };
+    default:
+      return { fontSize: themeVars.value.CellFontSizeMedium };
+    case 'small':
+      return { fontSize: themeVars.value.CellFontSizeSmall };
+  }
+});
+
+let customClickListener: (() => void)|undefined;
+
+function handleClick() {
+  customClickListener?.();
+  emit('click');
+}
+
+</script>

+ 20 - 0
src/components/basic/CellContext.ts

@@ -0,0 +1,20 @@
+import { inject } from "vue";
+
+export const CellContextKey = Symbol("CellContext");
+
+export interface CellContext {
+  /**
+   * 子组件设置单元格点击事件。注意:只能设置一次,后续设置会覆盖之前的设置。
+   * @param listener 点击事件
+   * @returns 
+   */
+  setOnClickListener: (listener: () => void) => void;
+}
+
+/**
+ * 获取单元格上下文
+ * @returns 单元格上下文
+ */
+export function useCellContext() {
+  return inject<CellContext>(CellContextKey, null as any);
+}

+ 90 - 0
src/components/basic/CellGroup.vue

@@ -0,0 +1,90 @@
+<template>
+  <FlexCol :style="{ width: '100%' }">
+    <text v-if="title" :style="(titleSpeicalStyle as any)">
+      {{ title }}
+    </text>
+    <view v-else-if="showTopMargin" :style="{ ...titleStyle, paddingTop: showTopMarginSize }" />
+    <view :style="{
+      ...(inset ? insetViewStyle as any : {}),
+      ...(round ? roundViewStyle as any : {}),
+    }">
+      <slot />
+    </view>
+    <view v-if="showBottomMargin" :style="(titleSpeicalStyle as any)" />
+  </FlexCol>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import { propGetThemeVar, useTheme } from '../theme/ThemeDefine';
+import FlexCol from '../layout/FlexCol.vue';
+
+export interface CellGroupProp {
+  /**
+   * 分组标题
+   */
+  title?: string,
+  /**
+   * 是否展示为圆角卡片风格
+   */
+  round?: boolean,
+  /**
+   * 是否增加内边距
+   */
+  inset?: boolean,
+  /**
+   * 是否显示底部边距。默认否
+   */
+  showBottomMargin?: boolean,
+  /**
+   * 是否显示没有标题时顶部边距。默认是
+   */
+  showTopMargin?: boolean,
+  /**
+   * 是否显示没有标题时顶部边距大小。默认是 10rpx
+   */
+  showTopMarginSize?: number,
+  /**
+   * 标题的样式
+   */
+  titleStyle?: object,
+  /**
+   * 标题背景是否变暗
+   */
+  titleDark?:  boolean,
+}
+
+const theme = useTheme();
+
+const props = withDefaults(defineProps<CellGroupProp>(), {
+  titleDark: () => propGetThemeVar('CellGroupTitleDark', false),
+  inset: () => propGetThemeVar('CellGroupInset', false),
+  showTopMargin: () => propGetThemeVar('CellGroupShowTopMargin', true),
+  showBottomMargin: () => propGetThemeVar('CellGroupShowBottomMargin', false),
+  showTopMarginSize: () => propGetThemeVar('CellGroupTopMarginSize', 10),
+});
+
+const CellGroupDarkTitleBackgroundColor = computed(() => theme.resolveThemeColor('CellGroupDarkTitleBackgroundColor', 'border.light'));
+const CellGroupInsetPaddingHorizontal = computed(() => theme.resolveThemeSize('CellGroupInsetPaddingHorizontal', 40));
+const CellGroupPaddingHorizontal = computed(() => theme.resolveThemeSize('CellGroupPaddingHorizontal', 20));
+
+const titleSpeicalStyle = computed(() => ({
+  color: theme.resolveThemeColor('CellGroupTitleColor', 'text.second'),
+  paddingTop: theme.resolveThemeSize('CellGroupTitlePaddingTop', 24),
+  paddingBottom: theme.resolveThemeSize('CellGroupTitlePaddingBottom', 12),
+  backgroundColor: props.titleDark ? CellGroupDarkTitleBackgroundColor.value : undefined,
+  paddingLeft: props.inset ? CellGroupInsetPaddingHorizontal.value : CellGroupPaddingHorizontal.value,
+  paddingRight: props.inset ? CellGroupInsetPaddingHorizontal.value : CellGroupPaddingHorizontal.value,
+}));
+const roundViewStyle = computed(() => ({
+  borderRadius: theme.resolveThemeSize('CellGroupInsetBorderRadius', 20),
+  backgroundColor: theme.resolveThemeColor('CellGroupInsetBackgroundColor', 'white'),
+  overflow: 'hidden',
+}));
+const insetViewStyle = computed(() => ({
+  position: 'relative',
+  margin: `${theme.resolveThemeSize('CellGroupInsetMarginVertical', 0)} ${theme.resolveThemeSize('CellGroupInsetMarginHorizontal', 30)}`,
+  flexDirection: 'column',
+}));
+
+</script>

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

@@ -0,0 +1,117 @@
+<template>
+  <text 
+    v-if="!iconData"
+    :style="{ color: '#f00', fontSize: '16px' }"
+    :class="innerClass"
+  >
+     {{ icon ? `Missing icon: ${icon}` : 'Empty' }} !
+  </text>
+  <text 
+    v-else-if="iconData.type == 'iconfont'"
+    :style="style"
+    :class="['iconfont', innerClass, iconData.value]"
+  />
+
+  <!-- #ifdef H5 || APP-PLUS -->
+  <view
+    v-else-if="iconData.type == 'svg'"
+    :style="{ 
+      ...style,
+      display: 'flex'
+    }"
+    :class="innerClass"
+    v-html="iconData.rawSvg"
+  />
+  <!-- #endif -->
+  <!-- #ifndef H5 --> 
+  <image
+    v-else-if="iconData.type == 'svg' && style.color && iconData.rawSvg"
+    :style="style"
+    :class="innerClass"
+    :src="IconUtils.getColoredSvg(iconData.rawSvg, style.color)"
+  />
+  <image
+    v-else-if="iconData.type == 'svg'"
+    :style="style"
+    :class="innerClass"
+    :src="iconData.value"
+  />
+  <!-- #endif -->
+
+  <image
+    v-else-if="iconData.type == 'image'"
+    :style="style"
+    :class="innerClass"
+    :src="iconData.value"
+  />
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import { IconUtils, type IconItem } from './IconUtils';
+import { useTheme } from '../theme/ThemeDefine';
+
+export interface IconProps {
+  /**
+   * 图标名称或图片 URL
+   */
+  icon?: string;
+  /**
+   * 图标大小
+   */
+  size?: number|string;
+  /**
+   * 图标颜色
+   */
+  color?: string;
+  /**
+   * 自定义样式
+   */
+  innerStyle?: object,
+  /**
+   * 自定义类名
+   */
+  innerClass?: string,
+}
+
+const theme = useTheme();
+const props = withDefaults(defineProps<IconProps>(), {
+  size: 45,
+});
+const iconData = computed(() => {
+  const data = props.icon ? IconUtils.getIconDataFromMap(props.icon) : undefined;
+  if (!data && props.icon && props.icon.startsWith('icon-')) {
+    return {
+      type: 'iconfont',
+      value: props.icon,
+      fontFamily: 'iconfont',
+    } as IconItem
+  } else if (!data) {
+    return {
+      type: 'image',
+      value: props.icon,
+    } as IconItem
+  }
+  return data;
+});
+const style = computed(() => {
+  const size = theme.resolveThemeSize(props.size);
+  return {
+    flexShrink: 0,
+    fontSize: size,
+    fontFamily: iconData.value.fontFamily || 'iconfont',
+    width: size,
+    height: size,
+    color: theme.resolveThemeColor(props.color, 'text.content'),
+    fill: theme.resolveThemeColor(props.color, 'text.content'),
+    ...props.innerStyle,
+  };
+});
+
+defineOptions({
+  options: {
+    virtualHost: true,
+  }
+})
+
+</script>

+ 95 - 0
src/components/basic/IconButton.vue

@@ -0,0 +1,95 @@
+<template>
+  <Touchable
+    :pressedColor="pressedBackgroundColor"
+    :innerStyle="style"
+    touchable
+    @click="(e) => emit('click', e)"
+    v-bind="$attrs"
+  >
+    <Icon v-if="icon" v-bind="props" />
+    <slot />
+  </Touchable>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import { propGetThemeVar, useTheme, type ViewStyle } from '../theme/ThemeDefine';
+import { selectStyleType } from '../theme/ThemeTools';
+import type { IconProps } from './Icon.vue';
+import Icon from './Icon.vue';
+import Touchable from '../feedback/Touchable.vue';
+
+export type IconButtonShapeType = 'round'|'square-full'|'custom';
+
+export interface IconButtonProps extends IconProps {
+  /**
+   * 按钮按下时的背景颜色
+   * @default PressedColor(Color.white)
+   */
+  pressedBackgroundColor?: string,
+  /**
+   * 按钮边距
+   */
+  padding?: number,
+  /**
+   * 按钮形状预设
+   * @default round
+   */
+  shape?: IconButtonShapeType;
+  /**
+   * 是否禁用
+   * @default false
+   */
+  disabled?: boolean|undefined;
+  /**
+   * 按钮样式
+   */
+  buttonStyle?: ViewStyle;
+  /**
+   * 按钮大小
+   */
+  buttonSize?: number|string;
+  /**
+   * 按钮背景颜色
+   */
+  backgroundColor?: string;
+}
+
+const emit = defineEmits(['click']);
+const theme = useTheme();
+const props = withDefaults(defineProps<IconButtonProps>(), {
+  shape: 'custom',
+  pressedBackgroundColor: () => propGetThemeVar('IconButtonPressedColor', 'pressed.white')
+});
+const style = computed(() => {
+  return {
+    flexDirection: 'row',
+    justifyContent: 'center',
+    alignItems: 'center',
+    padding: props.padding,
+    width: theme.resolveThemeSize(props.buttonSize),
+    height: theme.resolveThemeSize(props.buttonSize),
+    backgroundColor: theme.resolveThemeColor(props.backgroundColor),
+    ...selectStyleType(props.shape, 'round', {
+      "round": {
+        borderRadius: theme.resolveThemeSize('IconButtonRoundBorderRadius', 50),
+      },
+      "custom": {},
+      "square-full": {
+        height: '100%',
+        aspectRatio: 1,
+        borderRadius: 0,
+      },
+    }),
+    opacity: props.disabled ? theme.getVar('IconButtonDisabledOpacity', 0.4) : 1,
+    ...props.buttonStyle,
+  };
+});
+
+defineOptions({
+  options: {
+    inheritAttrs: false,
+    virtualHost: true,
+  }
+})
+</script>

+ 85 - 0
src/components/basic/IconUtils.ts

@@ -0,0 +1,85 @@
+import DefaultIcons from "../data/DefaultIcon.json";
+
+export type IconItem = {
+  type: 'iconfont'|'image'|'svg',
+  value: string,
+  rawSvg?: string,
+  fontFamily?: string,
+};
+type IconMap = Record<string, IconItem>;
+
+
+//图标集
+const iconMap = {} as IconMap;
+
+export const IconUtils = {
+  /**
+   * 设置 Icon 组件的图标名称映射。
+   * 如果已存在同名数据,则会覆盖之前的。
+   *
+   * key是图标的名字,value 可以是以下几种情况:
+   * * 是一个 iconfont:name 字符,则会渲染为字体形式的图标,通过 : 分割,前面是字体名称,后面是图标名称。
+   * * 是一个 `data:***` 或者 http/https URL,则会尝试使用 Image 渲染为图片
+   * * 是一个 `&lt;svg` 开头的字符串,会渲染为 svg
+   * * 否则,作为默认字体 iconfont 渲染为字体形式的图标。
+   */
+  configIconMap(map: Record<string, string>) {
+    for (const key in map) {
+      let result : IconItem;
+      const v = map[key];
+      if (v.startsWith('http') || v.startsWith('data:') || v.startsWith('/')) {
+        result = {
+          type: 'image',
+          value: v,
+        }
+      } else if (v.startsWith('<svg') || v.includes('<svg')) {
+        result = {
+          type: 'svg',
+          rawSvg: v,
+          value: toDataSvg(v),
+        }
+      } else if (v.includes(':')) {
+        const vv = v.split(':');
+        result = {
+          type: 'iconfont',
+          value: vv[1],
+          fontFamily: vv[0],
+        }
+      } else {
+        result = {
+          type: 'iconfont',
+          value: v,
+        }
+      } 
+      iconMap[key] = result;
+    }
+  },
+  getColoredSvg(svg: string, color: string) {
+    return toDataSvg(
+      svg.includes('fill=') ? svg : svg.replace(/<path /g, `<path fill="${color}" `)
+    );
+  },
+  /**
+   * 获取图标名称映射集
+   * @returns
+   */
+  getIconMap() {
+    return iconMap;
+  },
+  /**
+   * 从图标名称映射中获取指定名称的图标数据。
+   * @param key 图标名称
+   * @returns 返回图标数据,如果找不到,返回 undefined。
+   */
+  getIconDataFromMap(key: string) {
+    return iconMap[key];
+  },
+}
+
+function toDataSvg(str: string) {
+  if (!str.includes('xmlns'))
+    str = str.replace("<svg ", "<svg xmlns='http://www.w3.org/2000/svg' ");
+  return `data:image/svg+xml,${encodeURIComponent(str.replace(/\'/g, '"'))}`.replace(/\#/g, '%23');
+}
+
+IconUtils.configIconMap(DefaultIcons);

+ 188 - 0
src/components/basic/Image.vue

@@ -0,0 +1,188 @@
+<template>
+  <view 
+    class="nana-image-wrapper"
+    :style="style"
+    @click="handleClick"
+  >
+    <image 
+      :style="{
+        width: style.width,
+        height: style.height,
+      }"
+      :mode="($attrs.mode as any)"
+      :lazyLoad="$attrs.lazyLoad"
+      :fadeShow="$attrs.fadeShow"
+      :webp="$attrs.webp"
+      :show-menu-by-longpress="$attrs.showMenuByLongpress"
+      :draggable="$attrs.draggable"
+      :src="isErrorState ? failedImage : (src || defaultImage)"
+      @loadstart="isLoadState = true"
+      @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>
+    <view v-if="showLoading && isLoadState" class="inner-view loading">
+      <ActivityIndicator
+        :color="themeContext.resolveThemeColor(loadingColor)"
+        :size="themeContext.resolveThemeSize(loadingSize)"
+      />
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, ref, watch } from 'vue';
+import { propGetThemeVar, useTheme } from '../theme/ThemeDefine';
+import ActivityIndicator from './ActivityIndicator.vue';
+import Text from './Text.vue';
+
+export interface ImageProps {
+  /**
+   * 图片地址
+   */
+  src?: string,
+  /**
+   * 加载失败图片地址
+   */
+  failedImage?: string,
+  /**
+   * 为空时图片地址
+   */
+  defaultImage?: string,
+  /**
+   * 是否显示加载中提示,默认是
+   */
+  showLoading?: boolean,
+  /**
+   * 是否显示加载失败提示,默认是
+   */
+  showFailed?: boolean,
+  /**
+   * 是否显示灰色占位,默认是
+   */
+  showGrey?: boolean,
+  width?: string|number,
+  height?: string|number,
+  /**
+   * 是否可以点击预览图片
+   */
+  clickPreview?: boolean,
+  /**
+   * 初始加载中状态
+   */
+  loading?: boolean,
+  /**
+   * 加载中圆圈颜色
+   */
+  loadingColor?: string,
+  /**
+   * 加载中圆圈颜色
+   */
+  loadingSize?: string|number,
+  /**
+   * 指定图片是否可以点击,默认否
+   */
+  touchable?: boolean,
+  /**
+   * 图片是否有圆角
+   */
+  round?: boolean,
+  /**
+   * 当round为true的圆角大小,默认是50%
+   */
+  radius?: string|number,
+  /**
+   * 内部样式
+   */
+  innerStyle?: object;
+}
+
+defineOptions({
+  options: {
+    virtualHost: true
+  }
+})
+const props = withDefaults(defineProps<ImageProps>(), {
+  src: '',
+  failedImage: '',
+  defaultImage: '',
+  showLoading: true,
+  showFailed: true,
+  showGrey: () => propGetThemeVar('ImageShowGrey', false),
+  loading: false,
+  loadingColor: () => propGetThemeVar('ImageLoadingColor', 'border.default'),
+  loadingSize: () => propGetThemeVar('ImageLoadingSize', 50),
+  touchable: false,
+  round: () => propGetThemeVar('ImageRound', false),
+  radius: () => propGetThemeVar('ImageRadius', '50%'),
+})
+const emit = defineEmits([ 'click' ]);
+
+const isErrorState = ref(false);
+const isLoadState = ref(true);
+const themeContext = useTheme();
+
+const style = computed(() => {
+  const o : Record<string, any> = {
+    borderRadius: props.round ? themeContext.resolveThemeSize(props.radius) : '', 
+    backgroundColor: isErrorState.value || props.showGrey ? themeContext.resolveThemeColor('background.imageBox') : 'transparent',
+    overflow: 'hidden',
+    width: themeContext.resolveThemeSize(props.width),
+    height: themeContext.resolveThemeSize(props.height),
+    ...props.innerStyle,
+  }
+  return o;
+});
+
+function handleClick() {
+  if (props.clickPreview) {
+    uni.previewImage({
+      urls: [ props.src ],
+    })
+  }
+  if (props.touchable)
+    emit('click');
+}
+function loadSrcState() {
+  if (props.src) {
+    isErrorState.value = false;
+    isLoadState.value = true;
+  } else {
+    isErrorState.value = true;
+    isLoadState.value = false;
+  }
+}
+
+watch(() => props.src, (newVal, oldVal) => {
+  if (newVal) {
+    isErrorState.value = true;
+    isLoadState.value = false;
+  } else
+    isErrorState.value = false;
+})
+
+onMounted(() => {
+  loadSrcState();
+})
+</script>
+
+<style lang="scss">
+.nana-image-wrapper {
+  position: relative;
+  flex-shrink: 0;
+
+  .inner-view {
+    position: absolute;
+    left: 0;
+    right: 0;
+    top: 0;
+    bottom: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+}
+</style>

+ 11 - 0
src/components/basic/ImageButton.vue

@@ -0,0 +1,11 @@
+<template>
+  <view>
+
+  </view>
+</template>
+
+<script setup lang="ts">
+
+export type ImageButtonShapeType = 'round'|'square-full'|'custom';
+
+</script>

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

@@ -0,0 +1,261 @@
+<template>
+  <text :id="id" :class="innerClass" :style="style" @click="onClick">
+    <!-- #ifdef APP-NVUE -->
+    {{ text }}
+    <!-- #endif -->
+    <!-- #ifndef APP-NVUE -->
+    <slot>{{ text }}</slot>
+    <!-- #endif -->
+  </text>
+</template>
+
+<script lang="ts" setup>
+import { computed, getCurrentInstance } from 'vue';
+import { useTheme, type ThemePaddingMargin } from '../theme/ThemeDefine';
+import { RandomUtils } from '@imengyu/imengyu-utils';
+
+export interface TextProps {
+  /**
+   * 字体颜色。可以是颜色字符串或者在主题中配置的预设名称。
+   */
+  color?: string,
+  /**
+   * 文字阴影颜色。可以是颜色字符串或者在主题中配置的预设名称。
+   */
+  shadowColor?: string,
+  /**
+   * 文字对齐方式。
+   */
+  textAlign?: 'center'|'left'|'right'|'',
+  /**
+   * 字体预设。可以是主题中预设的一个名称。
+   */
+  fontConfig?: string,
+  /**
+   * 字体大小。可以是实际数值或者在主题中配置的预设名称。
+   */
+  fontSize?: string|number,
+  /**
+   * 字体名称。
+   */
+  fontFamily?: string,
+  /**
+   * 字体样式。
+   */
+  fontStyle?: string,
+  /** 
+   * 字体粗细。
+   */
+  fontWeight?: string|number,
+  /**
+   * 是否是粗体
+   */
+  bold?: boolean,
+  /**
+   * 是否是斜体
+   */
+  italic?: boolean,
+  /**
+   * 是否加下划线
+   */
+  underline?: boolean,
+  /**
+   * 是否加删除线
+   */
+  lineThrough?: boolean,
+  /**
+   * 是否有阴影。
+   */
+  shadow?: boolean,
+  /**
+   * 背景颜色。可以是颜色字符串或者在主题中配置的预设名称。
+   */
+  backgroundColor?: string, 
+  /**
+   * 是否可以选择
+   */
+  selectable?: boolean,
+  /**
+   * 行数限制
+   */
+  lines?: number,
+  margin?: number|string|number[]|ThemePaddingMargin,
+  padding?: number|string|number[]|ThemePaddingMargin,
+  innerStyle?: object,
+  innerClass?: object|string,
+  /**
+   * 最大宽度
+   */
+  maxWidth?: number|string,
+
+  /**
+   * 自动工具文字长短设置大小,在 autoSize 为 true 时有效。
+   *
+   * 这个是一个小功能,目的是为了在某些情况下(例如金额显示),容器宽度一定但是文字长短不定,
+   * 此时需要自动缩放大小,文字越长,字号越小。
+   *
+   * 计算公式是 (1 - (text.length - minLen) / (maxLen - minLen)) * (maxSize - minSize) + minSize
+   */
+  autoSize?: {
+    /**
+     * 小程序无法获取模板内容,需要额外提供字符串
+     */
+    text?: string,
+    /**
+     * 最长文字长度,用于自动大小公式计算
+     */
+    maxLen: number;
+    /**
+     * 最短文字长度,如果输入文字长度小于这个值,则不会进行自动缩放
+     */
+    minLen: number;
+    /**
+     * 最大文字字号,用于自动大小公式计算
+     */
+    maxSize: number;
+    /**
+     * 最小文字字号,用于自动大小公式计算
+     */
+    minSize: number;
+  },
+  text?: string|number,
+  /**
+   * 是否可以点击
+   */
+  touchable?: boolean,
+}
+
+/**
+ * 组件说明:文字封装,支持点击事件,颜色,阴影。
+ */
+const props = withDefaults(defineProps<TextProps>(), {
+  shadowColor: '#000',
+  bold: false,
+  italic: false,
+  underline: false,
+  lineThrough: false,
+  shadow: false,
+  touchable: false,
+});
+const emit = defineEmits([ 'click' ])
+
+const id = `text-${RandomUtils.genNonDuplicateID(12)}`;
+const instance = getCurrentInstance();
+const { resolveThemeColor, resolveThemeSize, getText } = useTheme();
+
+function getAutoSize() {
+  //自动缩放大小,文字越长,字号越小
+  const autoSizeOption = props.autoSize;
+  if (autoSizeOption) {
+    const text = '' + autoSizeOption.text;
+    if (text.length < autoSizeOption.minLen)
+      return props.fontSize;
+    return (1 - (text.length - autoSizeOption.minLen) / (autoSizeOption.maxLen - autoSizeOption.minLen))
+      * (autoSizeOption.maxSize - autoSizeOption.minSize) + autoSizeOption.minSize;
+  }
+}
+function onClick(e: any) {
+  if (props.touchable) {
+    emit("click", e)
+  }
+}
+
+const style = computed(() => {
+  const o : Record<string, any> = {
+  }
+  let color = props.color;
+  let backgroundColor = props.backgroundColor;
+  let fontSize = props.fontSize;
+  let fontWeight = props.fontWeight;
+  let fontFamily = props.fontFamily;
+  let fontStyle = props.fontStyle as any;
+  if (props.fontConfig) {
+    const style = getText(props.fontConfig, {});
+    color = props.color ?? style.color as string;
+    backgroundColor = props.backgroundColor ?? style.backgroundColor as string;
+    fontSize = props.fontSize ?? style.fontSize as string;
+    fontWeight = props.fontWeight ?? style.fontWeight as string;
+    fontFamily = props.fontFamily ?? style.fontFamily as string;
+    fontStyle = props.fontStyle ?? style.fontStyle as object;
+  }
+  if (color) o.color = resolveThemeColor(color, "text");
+  if (backgroundColor) o.background = resolveThemeColor(backgroundColor);
+  if (fontSize) o.fontSize = resolveThemeSize(fontSize);
+  if (fontWeight) o.fontWeight = fontWeight;
+  if (fontStyle) {
+    for(const k in fontStyle)
+      o.fontStyle = k;
+  }
+  if (fontFamily) o.fontFamily = fontFamily;
+  if (props.textAlign) {
+    o.textAlign = props.textAlign;
+  }
+  if (props.selectable) o.userSelect = props.selectable;
+  if (props.lines) {
+    o.display = '-webkit-box';
+		o['-webkit-line-clamp'] = props.lines;
+		o['-webkit-box-orient'] = 'vertical';
+		o.overflow = 'hidden';
+    o.textOverflow = 'ellipsis';
+  }
+  if (props.bold)
+    o.fontWeight = 'bold';
+  if (props.italic) 
+    o.fontStyle = 'italic';
+  if ((props.underline || props.lineThrough) && !o.textDecoration)
+    o.textDecoration = '';
+  if (props.underline)
+    o.textDecoration += ' underline';
+  if (props.lineThrough)
+    o.textDecoration += ' line-through';
+  if (props.maxWidth)
+    o.maxWidth = resolveThemeSize(props.maxWidth);
+
+  if (props.shadow && props.shadowColor) {
+    o.textShadow = '1px 1px 2px ' + resolveThemeColor(props.shadowColor, 'text');
+  }
+  if (props.autoSize) {
+    const s = getAutoSize();
+    if (s)
+      o.fontSize = s + 'rpx';
+  }
+
+  const rs = {
+    ...o,
+    ...props.innerStyle
+  };
+  return rs;
+})
+
+async function measureTextWidth() {
+  return await new Promise<number>((resolve) => {
+    uni.createSelectorQuery()
+      // #ifdef MP
+      .in(instance)
+      // #endif
+      .select(`#${id}`)
+      .boundingClientRect((data) => {
+        resolve((data as UniApp.NodeInfo)?.width ?? 0)
+      })
+      .exec();
+  })
+}
+
+defineExpose({
+  measureTextWidth,
+})
+defineOptions({
+  options: {
+    virtualHost: true,
+    styleIsolation: "shared",
+  },
+});
+
+</script>
+
+<style lang="scss">
+.nana-text-can-press {
+  cursor: pointer;
+  opacity: 0.8;
+}
+</style>

+ 54 - 0
src/components/composeabe/ChildItem.ts

@@ -0,0 +1,54 @@
+import { computed, onMounted, onUpdated, ref } from "vue";
+
+export function useChildLinkParent(options: {
+  getPositionExtra?: (index: number) => any,
+  onClean?: () => void,
+}) {
+
+  let currentIndex = 0;
+  let length = 0;
+
+
+  function getPosition() : Record<string, any> {
+    const index = currentIndex++;
+    length++;
+    return {
+      index,
+      ...options.getPositionExtra?.(index)
+    }
+  }
+  function resetCounter() {
+    currentIndex = 0
+  }
+
+  onMounted(() => {
+    resetCounter();
+    options.onClean?.();
+  });
+  onUpdated(() => {
+    resetCounter();
+    options.onClean?.();
+  });
+
+  return {
+    getLength: () => length,
+    getPosition,
+    resetCounter,
+  }
+}
+
+export function useChildLinkChild(getPosition: () => any) {
+
+  const position = computed(() => getPosition());
+
+  onMounted(() => {
+    position.value;
+  });
+  onUpdated(() => {
+    position.value;
+  });
+
+  return {
+    position,
+  }
+}

+ 29 - 0
src/components/composeabe/DataLoader.ts

@@ -0,0 +1,29 @@
+import { ref } from "vue"
+
+export function useDataLoader<T>(loader: () => Promise<T>, options: { immediate?: boolean }) {
+  const data = ref<T>()
+  const loading = ref(false)
+  const error = ref<Error>()
+
+  function load() {
+    loading.value = true
+    loader().then((res) => {
+      data.value = res
+      loading.value = false
+    }).catch((err) => {
+      error.value = err
+      loading.value = false
+    })
+  }
+
+  if (options.immediate) {
+    load()
+  }
+
+  return {
+    data,
+    loading,
+    error,
+    load,
+  }
+}

+ 19 - 0
src/components/composeabe/LoadingAction.ts

@@ -0,0 +1,19 @@
+import { ref } from "vue"
+
+/**
+ * 包装一个工作函数Promise用于控制按钮的loading状态
+ */
+export function useLoadingAction(promise: () => Promise<any>) {
+  const loading = ref(false);
+  return {
+    loading,
+    onClick: () => {
+      loading.value = true;
+      promise().then(() => {
+        loading.value = false;
+      }).catch(() => {
+        loading.value = false;
+      })
+    }
+  }
+}

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 45055 - 0
src/components/data/ChinaCityData.json


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 223 - 0
src/components/data/DefaultIcon.json


+ 46 - 0
src/components/demo/DemoBlock.vue

@@ -0,0 +1,46 @@
+<script setup lang="ts">
+import DemoTitle from './DemoTitle.vue';
+
+defineProps({	
+  title: String,
+  desc: String,
+  flat: Boolean,
+  smallTitle: Boolean,
+})
+</script>
+
+<template>
+  <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>
+    </view>
+    <slot />
+  </view>
+</template>
+
+<style>
+.nana-demo-block {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  margin: 0 40rpx 40rpx 40rpx;
+}
+.nana-demo-block.flat {
+  padding: 0;
+  margin: 0;
+  margin-bottom: 40rpx;
+  background-color: transparent; 
+}
+.nana-demo-block.flat .header {
+  margin: 0 40rpx 20rpx 40rpx;
+}
+.nana-demo-block .header {
+  margin: 0 0 20rpx 0;
+}
+.nana-demo-block .sub-title {
+  font-size: 28rpx;
+  color: #888;
+  margin-bottom: 20rpx;
+}
+</style>

+ 56 - 0
src/components/demo/DemoPage.vue

@@ -0,0 +1,56 @@
+<script setup lang="ts">
+import CommonRoot from '../dialog/CommonRoot.vue';
+
+const props = defineProps({	
+  title: String,
+  desc: String
+})
+
+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>
+      </view>
+      <slot />
+    </view>
+  </CommonRoot>
+</template>
+
+<style>
+.nana-demo-page {
+  display: flex;
+  flex-direction: column;
+  background-color: #efefef;
+  /* #ifdef H5 */
+  min-height: calc(100vh - 44px - env(safe-area-inset-top));
+  /* #endif */
+  /* #ifndef H5 */
+  min-height: calc(100vh - 44px);
+  /* #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;
+}
+</style>

+ 51 - 0
src/components/demo/DemoTitle.vue

@@ -0,0 +1,51 @@
+<template>
+  <view :class="['nana-demo-block-title',small?'small':'']">
+    <view v-if="!small" class="line">
+      <view class="line2"></view>
+    </view>
+    <text class="title">{{ title }}</text>
+  </view>
+</template>
+
+<script setup lang="ts">
+defineProps({	
+  title: String	,
+  small: Boolean,
+})
+</script>
+
+<style>
+.nana-demo-block-title {
+  position: relative;
+  padding-bottom: 15rpx;
+  margin-bottom: 20rpx;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+.nana-demo-block-title .title {
+  font-size: 36rpx;
+  color: #000;
+}
+.nana-demo-block-title.small .title {
+  font-size: 28rpx;
+  color: #000;
+}
+.nana-demo-block-title .line {
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  height: 1px;
+  border-radius: 5rpx;
+  background-color: #dddddd;
+  overflow: visible;
+}
+.nana-demo-block-title .line2 {
+  width: 60rpx;
+  height: 6rpx;
+  border-radius: 5rpx;
+  background-color: #0079db;
+  margin-top: -3rpx;
+}
+</style>

+ 153 - 0
src/components/dialog/ActionSheet.vue

@@ -0,0 +1,153 @@
+<template>
+  <Popup
+    v-bind="props"
+    :closeable="showCancel"
+    :closeIcon="false"
+    :position="center ? 'center' : 'bottom'"
+    :size="center ? themeContext.resolveThemeSize(centerWidth) : 'auto'"
+    round
+    @close="onCancelClick"
+  >
+    <scroll-view 
+      :scroll-y="true"
+      :style="{
+        ...themeStyles.topScroll.value,
+        width: center ? themeContext.resolveThemeSize(centerWidth) : undefined,
+      }"
+    >
+      <slot name="content" :close="onCancelClick">
+        <FlexCol>
+          <ActionSheetTitle :title="title" :description="description" />
+          <FlexCol position="relative">
+            <ActionSheetItem
+              v-for="(item, index) in props.actions"
+              :key="item.name"
+              :name="item.name"
+              :bold="item.bold || themeContext.getVar('ActionSheetItemBold', false)"
+              :color="themeContext.resolveThemeColor(item.color || props.textColor || themeContext.resolveThemeColor('ActionSheetItemColor', 'text.content'))"
+              :subname="item.subname"
+              :disabled="item.disabled"
+              @click="onItemClick(item, index)"
+            />
+          </FlexCol>
+          <FlexCol v-if="showCancel" position="relative" :innerStyle="themeStyles.viewCancel.value">
+            <ActionSheetItem
+              :name="props.cancelText || '取消'"
+              :bold="themeContext.getVar('ActionSheetCancelBold', false)"
+              :color="themeContext.resolveThemeColor(props.textColor || themeContext.resolveThemeColor('ActionSheetCancelColor', 'text.content'))"
+              :subname="''"
+              :disabled="false"
+              @click="onCancelClick"
+            />
+          </FlexCol>
+        </FlexCol>
+      </slot>
+    </scroll-view>
+  </Popup>
+</template>
+
+<script setup lang="ts">
+import { propGetThemeVar, useTheme } from '../theme/ThemeDefine';
+import { DynamicColor, DynamicSize, screenHeight } from '../theme/ThemeTools';
+import ActionSheetItem from './ActionSheetItem.vue';
+import ActionSheetTitle from './ActionSheetTitle.vue';
+import FlexCol from '../layout/FlexCol.vue';
+import Popup from './Popup.vue';
+import type { PopupProps } from './Popup.vue';
+
+export interface ActionSheetProps extends Omit<PopupProps, 'onClose'|'position'|'closeable'|'position'|'size'> {
+  /**
+   * 是否显示动作面板
+   * @default false
+   */
+  show: boolean;
+  /**
+   * 是否显示取消按扭
+   * @default false
+   */
+  showCancel?: boolean;
+  /**
+   * 取消条目的文字
+   */
+  cancelText?: string;
+  /**
+   * 顶部标题
+   */
+  title?: string;
+  /**
+   * 选项上方的描述信息
+   */
+  description?: string;
+  /**
+   * 面板选项列表
+   */
+  actions?: ActionSheetItem[];
+  /**
+   * 是否在点击条目后自动关闭
+   * @default false
+   */
+  autoClose?: boolean;
+  /**
+   * 是否在屏幕居中显示
+   * @default false
+   */
+  center?: boolean;
+  /**
+   * 居中显示时的宽度
+   */
+  centerWidth?: string|number;
+  /**
+   * 条目文字颜色
+   */
+  textColor?: string;
+}
+export interface ActionSheetItem {
+  /**
+   * 标题
+   */
+  name: string;
+  /**
+   * 二级标题
+   */
+  subname?: string;
+  /**
+   * 选项文字颜色
+   */
+  color?: string;
+  /**
+   * 是否加粗当前选项
+   */
+  bold?: boolean;
+  /**
+   * 是否禁用当前选项
+   */
+  disabled?: boolean;
+}
+
+const emit = defineEmits([ 'close', 'select' ]);
+const props = withDefaults(defineProps<ActionSheetProps>(), {
+  mask: true,
+  safeArea: true,
+  centerWidth: () => propGetThemeVar('ActionSheetCenterWidth', '600rpx'),
+});
+const themeContext = useTheme();
+const themeStyles = themeContext.useThemeStyles({
+  viewCancel: {
+    backgroundColor: DynamicColor('ActionSheetCancelBackgroundColor', 'light'),
+    paddingTop: DynamicSize('ActionSheetCancelPaddingTop', 20),
+  },
+  topScroll: {
+    maxHeight: DynamicSize('ActionSheetMaxScrollHeight', (screenHeight - 200) + 'px'),
+  },
+});
+
+function onItemClick(item: ActionSheetItem, index: number) {
+  emit('select', index, item.name);
+  if (props.autoClose === true)
+    onCancelClick();
+}
+function onCancelClick() {
+  emit('close');
+}
+
+</script>

+ 58 - 0
src/components/dialog/ActionSheetItem.vue

@@ -0,0 +1,58 @@
+<template>
+  <Touchable
+    center
+    direction="column"
+    :touchable="!disabled"
+    :innerStyle="themeStyles.item.value"
+    :pressedColor="themeContext.resolveThemeColor('ActionSheetItemPressedColor', 'pressed.white')"
+    @click="disabled === true ? undefined : emit('click')"
+  >
+    <text 
+      :style="{
+        ...themeStyles.itemTitle.value,
+        color: themeContext.resolveThemeColor(props.disabled === true ? themeContext.resolveThemeColor('ActionSheetItemDisabledTextColor', 'grey') : props.color),
+        fontWeight: bold ? 'bold' : 'normal',
+      }"
+    >
+      {{ name }}
+    </text>
+    <text v-if="subname" :style="themeStyles.itemSubTitle.value">{{subname}}</text>
+  </Touchable>
+</template>
+
+<script setup lang="ts">
+import Touchable from '../feedback/Touchable.vue';
+import { useTheme } from '../theme/ThemeDefine';
+import { DynamicColor, DynamicSize } from '../theme/ThemeTools';
+
+export interface ActionSheetItemProps {
+  name: string;
+  subname: string|undefined;
+  bold: boolean|undefined;
+  color: string|undefined;
+  disabled: boolean|undefined;
+}
+
+const themeContext = useTheme();
+const themeStyles = themeContext.useThemeStyles({
+  item: {
+    paddingTop: DynamicSize('ActionSheetItemPaddingVertical', 26),
+    paddingBottom: DynamicSize('ActionSheetItemPaddingVertical', 26),
+    backgroundColor: DynamicColor('ActionSheetItemBackgroundColor', 'white'),
+  },
+  itemTitle: {
+    fontSize: DynamicSize('ActionSheetItemTitleFontSize', 32),
+    color: DynamicColor('ActionSheetItemTitleColor', 'text.content'),
+  },
+  itemSubTitle: {
+    fontSize: DynamicSize('ActionSheetItemSubTitleFontSize', 26),
+    color: DynamicColor('ActionSheetItemSubTitleColor', 'text.second'),
+    marginTop: DynamicSize('ActionSheetItemSubTitleMarginTop', 4),
+  },
+});
+
+const emit = defineEmits([ 'click' ]);
+const props = withDefaults(defineProps<ActionSheetItemProps>(), {});
+
+
+</script>

+ 47 - 0
src/components/dialog/ActionSheetRoot.vue

@@ -0,0 +1,47 @@
+<template>
+  <ActionSheet 
+    v-bind="options"
+    :show="show"
+  />
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+import ActionSheet, { type ActionSheetProps } from './ActionSheet.vue';
+
+export interface ActionSheetOptions extends Omit<ActionSheetProps, 'show'> {
+  onSelect?: (index: number, name: string) => void;
+  onClose?: () => void;
+}
+export interface ActionSheetRoot {
+  show(options: ActionSheetOptions): Promise<void>;
+}
+
+const show = ref(false);
+const options = ref<ActionSheetOptions>();
+
+defineExpose<ActionSheetRoot>({
+  show(_options: ActionSheetOptions) {
+    show.value = true;
+    options.value = _options;
+
+    const onSelect = _options.onSelect;
+    const onClose = _options.onClose;
+
+    return new Promise<void>((resolve) => {
+      _options.onClose = () => {
+        show.value = false;
+        onClose?.();
+        resolve();
+      };
+      _options.onSelect = (i: number, n: string) => {
+        show.value = false;
+        onSelect?.(i, n);
+        resolve();
+      };
+      options.value = _options;
+      show.value = true;
+    });
+  },
+})
+</script>

+ 95 - 0
src/components/dialog/ActionSheetTitle.vue

@@ -0,0 +1,95 @@
+<template>
+  <FlexRow
+    v-if="title || description || cancelText || confirmText" 
+    :innerStyle="{
+      ...themeStyles.titleView.value,
+      ...border ? themeStyles.titleViewBorder.value : {},
+    }"
+    justify="space-between"
+  >
+    <Button v-if="cancelText" type="text" :textColor="cancelTextColor" @click="emit('cancel')">{{ cancelText }}</Button>
+    <view v-else />
+    <FlexCol v-if="title || description" :style="themeStyles.titleTextView.value" center>
+      <text v-if="title" :style="themeStyles.title.value">{{ title }}</text>
+      <text v-if="description" :style="themeStyles.description.value">{{ description }}</text>
+    </FlexCol>
+    <Button v-if="confirmText" type="text" :textColor="confirmTextColor" :touchable="!confirmDisabled" @click="emit('confirm')">{{ confirmText }}</Button>
+    <view v-else />
+  </FlexRow>
+</template>
+
+<script setup lang="ts">
+import Button from '../basic/Button.vue';
+import FlexCol from '../layout/FlexCol.vue';
+import FlexRow from '../layout/FlexRow.vue';
+import { useTheme } from '../theme/ThemeDefine';
+import { DynamicColor, DynamicSize, DynamicSize2 } from '../theme/ThemeTools';
+
+export interface ActionSheetTitleProps {
+  /**
+   * 标题
+   */
+  title?: string,
+  /**
+  * 说明
+  */
+  description?: string,
+  /**
+  * 取消按钮文字,如果不为空则会在左边添加一个取消按钮
+  */
+  cancelText?: string,
+  /**
+  * 确定按钮文字,如果不为空则会在右边添加一个确定按钮
+  */
+  confirmText?: string,
+  /**
+  * 取消按钮文字颜色
+  * @default text.content
+  */
+  cancelTextColor?: string,
+  /**
+  * 确定按钮文字颜色
+  * @default primary
+  */
+  confirmTextColor?: string,
+  /**
+  * 确定按钮是否禁用
+  * @default false
+  */
+  confirmDisabled?: boolean,
+  /**
+  * 是否显示底部边框
+  * @default true
+  */
+  border?: boolean;
+}
+
+const themeContext = useTheme();
+const themeStyles = themeContext.useThemeStyles({
+  titleView: {
+    padding: DynamicSize2('ActionSheetTitlePaddingHorizontal', 'ActionSheetTitlePaddingVertical', 16, 20),
+  },
+  titleViewBorder: {
+    borderBottomStyle: 'solid',
+    borderBottomColor: DynamicColor('ActionSheetTitleBorderBottomColor', 'border.cell'),
+    borderBottomWidth: DynamicSize('ActionSheetTitleBorderBottomWidth', 2),
+  },
+  titleTextView: {
+    paddingTop: DynamicSize('ActionSheetTitleTextPaddingVertical', 5),
+    paddingBottom: DynamicSize('ActionSheetTitleTextPaddingVertical', 10),
+  },
+  title: {
+    fontSize: DynamicSize('ActionSheetTitleTextFontSize', 32),
+    color: DynamicColor('ActionSheetTitleTextColor', 'text.content'),
+  },
+  description: {
+    fontSize: DynamicSize('ActionSheetTitleDescriptionFontSize', 26),
+    color: DynamicColor('ActionSheetTitleDescriptionColor', 'text.second'),
+  },
+});
+
+const emit = defineEmits([ 'cancel', 'confirm' ]);
+const props = withDefaults(defineProps<ActionSheetTitleProps>(), {});
+
+
+</script>

+ 61 - 0
src/components/dialog/CommonRoot.ts

@@ -0,0 +1,61 @@
+import type { App } from "vue";
+import type { ICommonRoot } from "./CommonRoot.vue";
+import type { DialogAlertOptions } from "./DialogRoot.vue";
+import type { ToastShowProps } from "../feedback/Toast.vue";
+import type { ActionSheetOptions } from "./ActionSheetRoot.vue";
+
+let currentRoot : ICommonRoot|null = null;
+
+export function setCurrentRoot(root : ICommonRoot) {
+  currentRoot = root;
+}
+export function NaDialogRoot() : ICommonRoot {
+  if (!currentRoot)
+    throw new Error("No dialog root found.");
+  return currentRoot;
+}
+
+export function alert(options: DialogAlertOptions) {
+  if (!currentRoot)
+    throw new Error("No dialog root found.");
+  return currentRoot.alert(options);
+}
+export function confirm(options: DialogAlertOptions) {
+  if (!currentRoot)
+    throw new Error("No dialog root found.");
+  return currentRoot.confirm(options);
+}
+export function toast(options: ToastShowProps) {
+  if (!currentRoot)
+    throw new Error("No dialog root found.");
+  return currentRoot.toast(options);
+}
+export function closeToast() {
+  if (!currentRoot)
+    throw new Error("No dialog root found.");
+  return currentRoot.closeToast();
+}
+export function actionSheet(options: ActionSheetOptions) {
+  if (!currentRoot)
+    throw new Error("No dialog root found.");
+  return currentRoot.actionSheet(options);
+}
+export function notify(options: ToastShowProps) {
+  if (!currentRoot)
+    throw new Error("No dialog root found.");
+  return currentRoot.notify(options);
+}
+
+export default {
+  install(app: App) {
+    const na : ICommonRoot = {
+      alert,
+      confirm,
+      toast,
+      closeToast,
+      actionSheet,
+      notify,
+    }
+    app.config.globalProperties.$na = na;
+  }
+}

+ 46 - 0
src/components/dialog/CommonRoot.vue

@@ -0,0 +1,46 @@
+<template>
+  <DialogRoot ref="naDialogRef" />
+  <ActionSheet ref="naActionSheetRef" />
+  <Toast ref="naToastRef" />
+  <Notify ref="naNotifyRef" />
+  <slot />
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref } from 'vue';
+import { 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';
+import DialogRoot, { type DialogAlertRoot } from './DialogRoot.vue';
+
+const naDialogRef = ref<DialogAlertRoot>();
+const naToastRef = ref<ToastInstance>();
+const naNotifyRef = ref<NotifyInstance>();
+const naActionSheetRef = ref<ActionSheetRoot>();
+
+export interface ICommonRoot {
+  alert: DialogAlertRoot['alert'];
+  confirm: DialogAlertRoot['confirm'];
+  toast: ToastInstance['show'];
+  closeToast: ToastInstance['close'];
+  actionSheet: ActionSheetRoot['show'];
+  notify: NotifyInstance['show'];
+}
+
+const commonRoot : ICommonRoot = {
+  alert: (a) => naDialogRef.value!.alert(a),
+  confirm: (a) => naDialogRef.value!.confirm(a),
+  toast: (a) => naToastRef.value!.show(a),
+  closeToast: () => naToastRef.value!.close(),
+  actionSheet: (a) => naActionSheetRef.value!.show(a),
+  notify: (a) => naNotifyRef.value!.show(a),
+};
+
+onMounted(() => {
+  setCurrentRoot(commonRoot);
+  (uni as any).$na = commonRoot;
+});
+
+defineExpose<ICommonRoot>(commonRoot);
+</script>

+ 158 - 0
src/components/dialog/Dialog.vue

@@ -0,0 +1,158 @@
+<template>
+  <Popup
+    v-bind="props"
+    round
+    position="center"
+    @close="onClose"
+  >
+    <DialogInner 
+      v-bind="$props"
+      :onConfirm="$props.onConfirm"
+      :onCancel="$props.onCancel"
+      :topSlots="{
+        default: Boolean($slots?.default),
+        bottomContent: Boolean($slots?.bottomContent),
+        title: Boolean($slots?.title),
+        content: Boolean($slots?.content),
+        icon: Boolean($slots?.icon),
+      }"
+      @close="onClose"
+    >
+      <template #default>
+        <slot />
+      </template>
+      <template #icon="{ icon }">
+        <slot name="icon" :icon="icon" />
+      </template>
+      <template #title="{ title }">
+        <slot name="title" :title="title" />
+      </template>
+      <template #content>
+        <slot name="content" />
+      </template>
+      <template #bottomContent="{ onConfirmClick, onCancelClick }">
+        <slot 
+          name="bottomContent"
+          :onConfirmClick="onConfirmClick"
+          :onCancelClick="onCancelClick"
+        />
+      </template>
+    </DialogInner>
+  </Popup>
+</template>
+
+<script setup lang="ts">
+import DialogInner from './DialogInner.vue';
+import Popup from './Popup.vue';
+import type { PopupProps } from './Popup.vue';
+
+export interface DialogProps extends Omit<PopupProps, 'onClose'|'position'|'renderContent'> {
+  /**
+   * 对话框的标题
+   */
+  title?: string;
+  /**
+   * 对话框的图标,显示在标题上方,同 Icon 组件的图标名字。
+   */
+  icon?: string;
+  /**
+   * 图标的颜色
+   * @default primary
+   */
+  iconColor?: string|undefined;
+  /**
+   * 图标大小
+   * @default 40
+   */
+  iconSize?: number;
+  /**
+   * 对话框的内容
+   */
+  content?: string;
+  /**
+   * 对话框内容超高后是否自动滚动
+   * @default true
+   */
+  contentScroll?: boolean;
+  /**
+   * 对话框内容自动滚动超高高度,
+   * @default 75%
+   */
+  contentScrollMaxHeight?: number|string,
+  /**
+   * 对话框内容框边距。
+   * 支持数字或者数组: 如果是数字,则设置所有方向边距;两位数组 [vetical,horizontal];四位数组 [top,right,down,left]
+   * @default [ 15, 20 ]
+   */
+  contentPadding?: number|number[],
+  /**
+   * 底部按扭是否垂直排版
+   * @default false
+   */
+  bottomVertical?: boolean;
+  /**
+   * 取消按扭的文字
+   * @default 取消
+   */
+  cancelText?: string|undefined;
+  /**
+   * 确定按扭的文字
+   * @default 确定
+   */
+  confirmText?: string|undefined;
+  /**
+   * 确定按扭文字的颜色
+   * @default primary
+   */
+  confirmColor?: string|undefined;
+  /**
+   * 取消按扭文字的颜色
+   * @default text.content
+   */
+  cancelColor?: string|undefined;
+  /**
+   * 自定义其他按扭,这些按扭将在 cancel 和 confirm 之间显示,建议设置 bottomVertical 使按扭垂直排列。
+   */
+  customButtons?: {
+    name: string,
+    text: string,
+    color?: string|undefined,
+    bold?: boolean,
+  }[];
+  /**
+   * 是否显示取消按扭
+   * @default false
+   */
+  showCancel?: boolean;
+  /**
+   * 是否显示确定按扭
+   * @default true
+   */
+  showConfirm?: boolean;
+  /**
+   * 对话框宽度
+   */
+  width?: number|string|undefined;
+  /**
+   * 当对话框点击取消时的回调
+   */
+  onCancel?: () => void|Promise<void>;
+  /**
+   * 当对话框点击确定的回调
+   */
+  onConfirm?: (buttonName?: string) => void|Promise<void>;
+}
+export type DialogConfirmProps = Omit<DialogProps, 'show'|'showCancel'|'onClose'>;
+
+const props = withDefaults(defineProps<DialogProps>(), {
+  mask: true,
+  showConfirm: true,
+  contentScroll: true,
+});
+const emit = defineEmits([ 'close', 'update:show' ]);
+
+function onClose() {
+  emit('close');
+  emit('update:show', false);
+}
+</script>

+ 105 - 0
src/components/dialog/DialogButton.vue

@@ -0,0 +1,105 @@
+<template>
+  <Touchable
+    :innerStyle="{
+      ...themeStyles.dialogButton.value,
+      ...vertical ? {} : themeStyles.dialogButtonHorz.value,
+    }"
+    :pressedColor="themeContext.resolveThemeColor(pressedColor)"
+    touchable
+    direction="row"
+    @click="loading ? undefined : $emit('click')"
+  >
+    <ActivityIndicator 
+      v-if="loading"
+      :color="themeContext.resolveThemeColor(buttonColor)" 
+      :size="themeContext.resolveThemeSize('DialogButtonTextFontSize', 30)"
+    />
+    <text 
+      v-else 
+      :style="{ 
+        ...themeStyles.buttonText.value, 
+        color: themeContext.resolveThemeColor(buttonColor)
+      }"
+    >
+      {{props.text}}
+    </text>
+  </Touchable>
+</template>
+
+<script setup lang="ts">
+import ActivityIndicator from '../basic/ActivityIndicator.vue';
+import Touchable from '../feedback/Touchable.vue';
+import { propGetThemeVar, useTheme } from '../theme/ThemeDefine';
+import { DynamicColor, DynamicSize } from '../theme/ThemeTools';
+
+/**
+ * 对话框底部按扭组件Props
+ */
+export interface DialogButtonProps {
+  /**
+   * 按钮文字
+   */
+  text?: string|undefined,
+  /**
+  * 按钮文字
+  * @default false
+  */
+  loading?: boolean,
+  /**
+  * 按钮文字
+  * @default false
+  */
+  vertical?: boolean|undefined,
+  /**
+  * 按钮文字
+  * @default undefined
+  */
+  buttonColor?: string|undefined,
+  /**
+  * 按钮文字
+  * @default undefined
+  */
+  pressedColor?: string|undefined,
+}
+
+const themeContext = useTheme();
+const themeStyles = themeContext.useThemeStyles({
+  dialogButton: {
+    justifyContent: 'center',
+    alignItems: 'center',
+    height: DynamicSize('DialogButtonHeight', 90),
+    borderTopStyle: 'solid',
+    borderTopWidth: DynamicSize('DialogButtonBorderTopWidth', 2),
+    borderTopColor: DynamicColor('DialogButtonBorderTopColor', 'border.cell'),
+    borderRightStyle: 'solid',
+    borderRightWidth: DynamicSize('DialogButtonBorderTopWidth', 2),
+    borderRightColor: DynamicColor('DialogButtonBorderTopColor', 'border.cell'),
+  },
+  dialogButtonHorz: {
+    flex: 1,
+    borderBottomStyle: 'solid',
+    borderBottomWidth: DynamicSize('DialogButtonBorderBottomWidth', 2),
+    borderBottomColor: DynamicColor('DialogButtonBorderBottomColor', 'border.cell'),
+  },
+  buttonText: {
+    fontSize: DynamicSize('DialogButtonTextFontSize', 30),
+    fontWeight: DynamicSize('DialogButtonTextFontWeight', 'bold'),
+  },
+});
+
+const emit = defineEmits([ 'click' ]);
+
+const props = withDefaults(defineProps<DialogButtonProps>(), {
+  text: '确定',
+  loading: false,
+  vertical: false,
+  buttonColor: undefined,
+  pressedColor: () => propGetThemeVar('DialogButtonPressedColor', 'pressed.white'),
+});
+
+defineOptions({
+  options: {
+    virtualHost: true,
+  }
+})
+</script>

+ 186 - 0
src/components/dialog/DialogInner.vue

@@ -0,0 +1,186 @@
+<template>
+  <!--TODO: 在uniapp插槽问题修复后,此处可修改为插槽默认值-->
+  <FlexCol :innerStyle="{ ...themeStyles.dialog.value, width: themeContext.resolveThemeSize(width) }">
+    <slot v-if="topSlots?.default" />
+    <FlexCol v-else :padding="contentPadding" align="center">
+      <!-- 图标 -->
+      <FlexCol v-if="icon" :innerStyle="themeStyles.icon.value">
+        <slot name="icon" :icon="icon" />
+        <Icon :icon="icon" :color="iconColor" :size="iconSize || 40" />
+      </FlexCol>
+      <!-- 标题 -->
+      <slot v-if="topSlots?.title" name="title" :title="title" />
+      <text v-else-if="title" :style="themeStyles.title.value">{{ title }}</text>
+
+      <!-- 内容 -->
+      <scroll-view 
+        v-if="contentScroll" 
+        scroll-y
+        scroll-x
+        :style="{
+          position: 'relative',
+          maxHeight: contentScrollMaxHeight
+        }"
+      >
+        <slot v-if="topSlots?.content" name="content" />
+        <text :style="themeStyles.contentText.value">{{ content }}</text>
+      </scroll-view>
+      <template v-else>
+        <slot v-if="topSlots?.content" name="content" />
+        <text v-else :style="themeStyles.contentText.value">{{ content }}</text>
+      </template>
+    </FlexCol>
+    <!-- 底部按钮 -->
+    <slot 
+      v-if="topSlots?.bottomContent"
+      name="bottomContent" 
+      :onConfirmClick="(name?: string) => onConfirmClick(name || 'confirm')" 
+      :onCancelClick="onCancelClick"
+    />
+    <FlexView v-else :direction="bottomVertical ? 'column' : 'row'" :innerStyle="themeStyles.bottomView.value">
+      <DialogButton
+        v-if="showCancel"
+        key="cancel"
+        :vertical="bottomVertical"
+        :text="cancelText"
+        :loading="buttomLoadingState.cancel"
+        :buttonColor="cancelColor"
+        @click="onCancelClick"
+      />
+      <DialogButton
+        v-for="(button, key) in customButtons"
+        :vertical="bottomVertical"
+        :key="key"
+        :text="button.text"
+        :loading="buttomLoadingState[button.name]"
+        :buttonColor="button.color || 'text.content'"
+        @click="onConfirmClick(button.name)"
+      />
+      <DialogButton
+        v-if="showConfirm"
+        key="confirm"
+        :vertical="bottomVertical"
+        :text="confirmText"
+        :loading="buttomLoadingState.confirm"
+        :buttonColor="confirmColor"
+        @click="onConfirmClick('confirm')"
+      />
+    </FlexView>
+  </FlexCol>
+</template>
+
+<script setup lang="ts">
+import FlexView from '../layout/FlexView.vue';
+import FlexCol from '../layout/FlexCol.vue';
+import { propGetThemeVar, useTheme } from '../theme/ThemeDefine';
+import { DynamicColor, DynamicSize, DynamicVar } from '../theme/ThemeTools';
+import type { DialogProps } from './Dialog.vue';
+import Icon from '../basic/Icon.vue';
+import { ref } from 'vue';
+import DialogButton from './DialogButton.vue';
+
+const themeContext = useTheme();
+const themeStyles = themeContext.useThemeStyles({
+  dialog: {
+    minWidth: DynamicSize('DialogMinWidth', 400),
+    maxWidth: DynamicSize('DialogMaxWidth', 700),
+  },
+  bottomView: {
+    position: 'relative',
+  },
+  icon: {
+    marginTop: DynamicSize('DialogIconMarginTop', 16),
+    marginBottom: DynamicSize('DialogIconMarginBottom', 12),
+    justifyContent: 'center',
+    alignItems: 'center',
+  },
+  title: {
+    fontSize: DynamicSize('DialogTitleFontSize', 36),
+    color: DynamicColor('DialogTitleColor', 'text.content'),
+    fontWeight: DynamicSize('DialogTitleFontWeight', 'bold'),
+    textAlign: 'center',
+    marginBottom: DynamicSize('DialogTitleMarginBottom', 20),
+  },
+  contentText: {
+    width: '100%',
+    display: 'block',
+    fontSize: DynamicSize('DialogContentTextFontSize', 28),
+    color: DynamicColor('DialogContentTextColor', 'text.second'),
+    textAlign: DynamicVar('DialogContentTextAlign', 'center'),
+  },
+});
+
+export interface DialogInnerProps extends Omit<DialogProps, 'show'> {
+  topSlots?: Record<string, boolean>,
+}
+
+const emit = defineEmits([ 'close' ]);
+
+const props = withDefaults(defineProps<DialogInnerProps>(), {
+  showConfirm: true,
+  cancelText: '取消',
+  cancelColor: () => propGetThemeVar('DialogCancelColor', 'text.content'),
+  confirmText: '确定',
+  confirmColor: () => propGetThemeVar('DialogConfirmColor', 'primary'),
+  iconSize: () => propGetThemeVar('DialogIconSize', 70),
+  iconColor: () => propGetThemeVar('DialogIconColor', 'primary'),
+  contentScroll: true,
+  contentScrollMaxHeight: () => propGetThemeVar('DialogContentScrollMaxHeight', '1000rpx'),
+  contentPadding: () => propGetThemeVar('DialogContentPadding', [ 30, 40 ]),
+});
+
+const buttomLoadingState = ref<Record<string, boolean>>({});
+
+function setButtonLoadingStateByName(name: string, state: boolean) {
+  buttomLoadingState.value[name] = state;
+}
+function checkAnyButtonLoading() {
+  for (const key in buttomLoadingState.value) {
+    if (buttomLoadingState.value[key] === true)
+      return true;
+  }
+  return false;
+}
+  
+function onPopupClose() {
+  emit('close');
+}
+function onCancelClick() {  
+  if (checkAnyButtonLoading())
+    return;
+  if (!props.onCancel) {
+    onPopupClose();
+    return;
+  }
+  const ret = props.onCancel();
+  if (typeof ret === 'object') {
+    setButtonLoadingStateByName('cancel', true);
+    ret.then(() => {
+      setButtonLoadingStateByName('cancel', false);
+      onPopupClose();
+    }).catch(() => {
+      setButtonLoadingStateByName('cancel', false);
+    });
+  } else onPopupClose();
+}
+function onConfirmClick(name: string) {
+  if (checkAnyButtonLoading())
+    return;
+  if (!props.onConfirm) {
+    onPopupClose();
+    return;
+  }
+  const ret = props.onConfirm(name);
+  if (typeof ret === 'object') {
+    setButtonLoadingStateByName(name, true);
+    ret.then(() => {
+      setButtonLoadingStateByName(name, false);
+      onPopupClose();
+    }).catch(() => {
+      setButtonLoadingStateByName(name, false);
+    });
+  } else onPopupClose();
+}
+
+
+</script>

+ 78 - 0
src/components/dialog/DialogRoot.vue

@@ -0,0 +1,78 @@
+<template>
+  <Dialog 
+    v-bind="options"
+    :show="show"
+    :showConfirm="showConfirm"
+    :showCancel="showCancel"
+  />
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+import Dialog, { type DialogProps } from './Dialog.vue';
+
+export interface DialogAlertOptions extends Omit<DialogProps, 'show'> {
+}
+export interface DialogAlertRoot {
+  confirm(options: DialogAlertOptions): Promise<boolean>;
+  alert(options: DialogAlertOptions): Promise<void>;
+}
+
+const show = ref(false);
+const showCancel = ref(false);
+const showConfirm = ref(true);
+const options = ref<DialogAlertOptions>();
+
+defineExpose<DialogAlertRoot>({
+  confirm(_options: DialogAlertOptions) {
+    showCancel.value = true;
+
+    const onConfirm = _options.onConfirm;
+    const onCancel = _options.onCancel;
+    const onClose = _options.onClose;
+
+    return new Promise<boolean>((resolve) => {
+      _options.onClose = () => {
+        show.value = false;
+        onClose?.();
+        resolve(false);
+      };
+      _options.onCancel = () => {
+        show.value = false;
+        onCancel?.();
+        resolve(false);
+      };
+      _options.onConfirm = () => {
+        show.value = false;
+        onConfirm?.();
+        resolve(true);
+      };
+      options.value = _options;
+      show.value = true;
+    });
+  },
+  alert(_options: DialogAlertOptions) {
+    showCancel.value = false;
+    show.value = true;
+    options.value = _options;
+
+    const onConfirm = _options.onConfirm;
+    const onClose = _options.onClose;
+
+    return new Promise<void>((resolve) => {
+      _options.onClose = () => {
+        show.value = false;
+        onClose?.();
+        resolve();
+      };
+      _options.onConfirm = () => {
+        show.value = false;
+        onConfirm?.();
+        resolve();
+      };
+      options.value = _options;
+      show.value = true;
+    });
+  }
+})
+</script>

+ 32 - 0
src/components/dialog/Overlay.vue

@@ -0,0 +1,32 @@
+<template>
+  <Popup 
+    v-bind="$props"
+    closeIcon=""
+    closeable
+    position="center"
+    :show="show"
+    @close="$emit('close')"
+    @update:show="$emit('update:show', $event)"
+  >
+    <slot />
+  </Popup>
+</template>
+
+<script setup lang="ts">
+import type { PopupProps } from './Popup.vue';
+import Popup from './Popup.vue';
+
+export interface OverlayProps extends Omit<PopupProps, 'closeIcon'|'position'> {
+}
+
+defineEmits(['close','update:show'])
+
+withDefaults(defineProps<OverlayProps>(), {
+  maskColor: 'background.mask',
+  mask: true,
+  backgroundColor: 'transparent',
+  duration: 230,
+  position: "center",
+  closeable: true,
+});
+</script>

+ 355 - 0
src/components/dialog/Popup.vue

@@ -0,0 +1,355 @@
+<template>
+  <view 
+    :class="[
+      'nana-popup',
+      position,
+      showAnimState ? 'show' : '',
+      mask ? 'stop' : '',
+      mask ? (show2 ? 'show2' : '') : 'no-mask',
+    ]"
+    :style="{
+      ...selectStyleType(position, 'bottom', {
+        center: {
+          justifyContent: 'center',
+          alignItems: 'center',
+        },
+        top: {
+          justifyContent: 'flex-start',
+          alignItems: 'center',
+        },
+        bottom: {
+          justifyContent: 'flex-end',
+          alignItems: 'center',
+        },
+        left: {
+          alignItems: 'flex-start',
+          justifyContent: 'center',
+        },
+        right: {
+          alignItems: 'flex-end',
+          justifyContent: 'center',
+        },
+      }),
+      top: inset[0] ? `${themeContext.resolveThemeSize(inset[0])}` : undefined,
+      right: inset[1] ? `${themeContext.resolveThemeSize(inset[1])}` : undefined,
+      bottom: inset[2] ? `${themeContext.resolveThemeSize(inset[2])}` : undefined,
+      left: inset[3] ? `${themeContext.resolveThemeSize(inset[3])}` : undefined,
+    }"
+  >
+    <view 
+      class="nana-popup-mask" 
+      :style="{
+        backgroundColor: mask ? themeContext.resolveThemeColor(maskColor) : '',
+        transitionDuration: `${duration}ms`,
+      }"
+      @mousedown.stop="handleClose"
+      @touchstart.stop="handleClose"
+      @click.stop="handleClose"
+    >  
+    </view>
+    <view 
+      v-if="show2"
+      :class="[ 'nana-popup-content', position] "
+      :style="{
+        ...selectStyleType(position, 'bottom', {
+          center: {
+            flexDirection: 'row',
+            borderRadius: radius,
+          },
+          top: {
+            borderBottomLeftRadius: radius,
+            borderBottomRightRadius: radius,
+            width: '100%',
+            minHeight: dialogSize,
+          },
+          bottom: {
+            borderTopLeftRadius: radius,
+            borderTopRightRadius: radius,
+            width: '100%',
+            minHeight: dialogSize,
+          },
+          left: {
+            borderTopRightRadius: radius,
+            borderBottomRightRadius: radius,
+            height: '100%',
+            minWidth: dialogSize,
+          },
+          right: {
+            borderTopLeftRadius: radius,
+            borderBottomLeftRadius: radius,
+            height: '100%',
+            minWidth: dialogSize,
+          },
+        }),
+        backgroundColor: themeContext.resolveThemeColor(backgroundColor),
+        margin: `${themeContext.resolveThemeSize(margin[0])} ${themeContext.resolveThemeSize(margin[1])} ${themeContext.resolveThemeSize(margin[2])} ${themeContext.resolveThemeSize(margin[3])}`,
+        ...innerStyle,
+      }"
+      @click.stop
+    >
+      <SafeAreaPadding
+        :top="safeArea && (position === 'top' || position === 'left' || position === 'right')"
+        :bottom="safeArea && (position === 'bottom' || position === 'left' || position === 'right')"
+      >
+        <PopupTitle 
+          v-if="position !== 'top'"
+          :closeable="closeable"
+          :closeIcon="closeIcon"
+          :closeIconSize="closeIconSize"
+          :closeIconPosition="closeIconPosition"
+          :top="true"
+          @close="doClose"
+        />
+        <slot />
+        <PopupTitle
+          v-if="position === 'top'"
+          :closeable="closeable"
+          :closeIcon="closeIcon"
+          :closeIconSize="closeIconSize"
+          :closeIconPosition="closeIconPosition"
+          @close="doClose"
+        />
+      </SafeAreaPadding>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue';
+import { useTheme, type ViewStyle } from '../theme/ThemeDefine';
+import { selectStyleType } from '../theme/ThemeTools';
+import { SimpleDelay } from '@imengyu/imengyu-utils';
+import PopupTitle from './PopupTitle.vue';
+import SafeAreaPadding from '../layout/space/SafeAreaPadding.vue';
+
+/**
+ * Popup 的显示位置
+ */
+export type PopupPosition = 'center'|'top'|'bottom'|'left'|'right';
+/**
+ * Popup 关闭按钮显示位置
+ */
+export type PopupCloseButtonPosition = 'left'|'right';
+
+/**
+ * Popup 组件属性
+ */
+export interface PopupProps {
+  /**
+   * 是否显示当前弹窗
+   */
+  show: boolean;
+  /**
+   * 弹出层圆角
+   */
+  round?: boolean;
+  /**
+   * 是否可以点击遮罩关闭当前弹出层,同时会显示一个关闭按扭,默认否
+   */
+  closeable?: boolean;
+  /**
+   * 关闭按扭,如果设置false则不显示
+   * @default 'close'
+   */
+  closeIcon?: string|false;
+  /**
+   * 关闭按扭大小
+   * @default 40
+   */
+  closeIconSize?: number;
+  /**
+   * 关闭按扭位置
+   */
+  closeIconPosition?: PopupCloseButtonPosition,
+  /**
+   * 指定当前弹出层弹出位置
+   */
+  position?: PopupPosition,
+  /**
+   * 遮罩的颜色
+   */
+  maskColor?: string,
+  /**
+   * 是否显示遮罩,默认是
+   * @default true
+   */
+  mask?: boolean,
+  /**
+   * 对话框偏移边距,默认为0,0,0,0
+   * @default [0,0,0,0]
+   */
+  margin?: number[],
+  /**
+   * 强制设置整体边距(包括遮罩层),默认为[undefined,undefined,undefined,undefined]
+   * @default [undefined,undefined,undefined,undefined]
+   */
+  inset?: (number|string|undefined)[],
+  /**
+   * 弹出层背景颜色,默认是 白色
+   * @default white
+   */
+  backgroundColor?: string;
+  /**
+   * 从侧边弹出时,是否自动设置安全区,默认是
+   * @default true
+   */
+  safeArea?: boolean,
+    /**
+   * 指定当前弹出层的特殊样式
+   */
+  innerStyle?: ViewStyle,
+  /**
+   * 指定弹出层动画时长,毫秒
+   * @default 230
+   */
+  duration?: number,
+  /**
+   * 指定弹出层从侧边弹出的高度,如果是横向弹出,则设置宽度,默认是30%, 设置 auto 让大小自动根据内容调整
+   * @default '30%'
+   */
+  size?: string|number;
+}
+
+const emit = defineEmits([ 'update:show', 'close', 'closeAnimFinished' ])
+const props = withDefaults(defineProps<PopupProps>(), {
+  closeIcon: 'close',
+  closeIconSize: 40,
+  closeIconPosition: 'right',
+  position: 'center',
+  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;
+  },
+  backgroundColor: 'white',
+  safeArea: true,
+  duration: 230,
+  size: '30%',
+});
+
+function handleClick(e: Event) {
+  e.stopPropagation();
+}
+function handleClose(e: Event) {
+  e.stopPropagation();
+  if (props.closeable)
+    doClose();
+}
+function doClose() {
+  emit('update:show', false);
+  emit('close');
+}
+
+const themeContext = useTheme();
+
+const show2 = ref(false);
+const showAnimState = ref(false);
+const radius = computed(() => props.round ? themeContext.resolveThemeSize('PopupRadius', 30) : 0);
+const dialogSize = computed(() => themeContext.resolveThemeSize(props.size));
+
+let lateStopTimer : SimpleDelay|undefined;
+
+watch(() => props.show, (v) => {
+  show2.value = true;
+  if (!v) {
+    showAnimState.value = false;
+    if (lateStopTimer)
+      lateStopTimer.stop();
+    lateStopTimer = new SimpleDelay(undefined, () => {
+      lateStopTimer = undefined;
+      show2.value = false;
+    }, props.duration)
+    lateStopTimer.start();
+  } else {
+    if (lateStopTimer)
+      lateStopTimer.stop();
+    lateStopTimer = new SimpleDelay(undefined, () => {
+      lateStopTimer = undefined;
+      showAnimState.value = true
+    }, 20)
+    lateStopTimer.start();
+  }
+});
+</script>
+
+<style lang="scss">
+.nana-popup {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0; 
+  bottom: 0;
+  z-index: 110;
+  display: flex;
+  flex-direction: column;
+  pointer-events: none;
+  overflow: hidden;
+
+  &.show2 {
+    pointer-events: auto;
+    .nana-popup-mask {
+      pointer-events: auto;
+    }
+  }
+  &.no-mask {
+    .nana-popup-mask {
+      pointer-events: none;
+    }
+  }
+  // &.stop {
+
+  // }
+
+  .nana-popup-mask {
+    position: absolute;
+    top: 0;
+    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;
+    pointer-events: auto;
+
+    &.center {
+      transform: translateY(-10px);
+    }
+    &.top {
+      transform: translateY(-100vh);
+    }
+    &.bottom {
+      transform: translateY(200vh);
+    }
+    &.left {
+      transform: translateX(-750rpx);
+    }
+    &.right {
+      transform: translateX(750rpx);
+    }
+  }
+
+  &.show {
+    .nana-popup-mask {
+      opacity: 1;
+    }
+    .nana-popup-content {
+      opacity: 1;
+      transform: translateX(0) translateY(0);
+    }
+  }
+}
+
+</style>

+ 88 - 0
src/components/dialog/PopupTitle.vue

@@ -0,0 +1,88 @@
+<script setup lang="ts">
+import IconButton from '../basic/IconButton.vue';
+import FlexRow from '../layout/FlexRow.vue';
+import { selectStyleType } from '../theme/ThemeTools';
+import { useTheme } from '../theme/ThemeDefine';
+import Text from '../basic/Text.vue';
+
+defineProps({
+  top: {
+    type: Boolean,
+    default: false,
+  },
+  relative: {
+    type: Boolean,
+    default: false,
+  },
+  title: {
+    type: String,
+    default: '',
+  },
+  closeable: {
+    type: Boolean,
+    default: false,
+  },
+  closeIcon: {
+    type: [String,Boolean],
+    default: 'close',
+  },
+  closeIconSize: {
+    type: Number,
+    default: 36,
+  },
+  closeIconPosition: {
+    type: String,
+    default: 'right',
+  },
+})
+
+const emit = defineEmits([ 'close' ])
+
+const theme = useTheme();
+
+defineOptions({
+  options: {
+    styleIsolation: "shared",
+    virtualHost: true,
+  }
+})
+</script>
+
+<template>
+  <FlexRow
+    pointerEvents="box-none"
+    innerClass="nana-popup-title"
+    :innerStyle="{
+      position: relative ? 'relative' : 'absolute',
+      display: (closeable === true && closeIcon !== false) ? 'flex' : 'none',
+      top: top ? 0 : undefined,
+      bottom: top ? undefined : 0,
+      alignItems: 'center',
+      justifyContent: 'space-between',
+      flexDirection:  selectStyleType(closeIconPosition, 'right', { left: 'row-reverse', right: 'row' }),
+    }"
+  >
+    <IconButton 
+      v-if="closeable === true && closeIcon"
+      :size="closeIconSize || theme.resolveThemeSize('PopupCloseIconSize', 25)"
+    />
+    <Text>{{ title }}</Text>
+    <IconButton 
+      v-if="closeable === true && closeIcon"
+      :icon="(closeIcon as string) || theme.resolveThemeSize('PopupCloseIconName', 'close')!"
+      :size="closeIconSize || theme.resolveThemeSize('PopupCloseIconSize', 25)"
+      :color="theme.resolveThemeSize('PopupCloseIconColor', 'text.content')"
+      @click="emit('close')"
+    />
+  </FlexRow>
+</template>
+
+<style>
+.nana-popup-title {
+  z-index: 110;
+  left: 0;
+  right: 0;
+  align-items: center;
+  padding: 20rpx 20rpx;
+}
+</style>

+ 134 - 0
src/components/display/Avatar.vue

@@ -0,0 +1,134 @@
+<template>
+  <view 
+    :class="[
+      'nana-avatar',
+      round ? 'round' : '',
+    ]"
+    :style="style"
+    @click="$emit('click')"
+  >
+    <image 
+      v-if="url || defaultAvatar" :src="url || defaultAvatar"
+      :style="{
+        width: themeContext.resolveThemeSize(size),
+        height: themeContext.resolveThemeSize(size),
+      }"
+    />
+    <text 
+      v-else 
+      :style="{ 
+        width: themeContext.resolveThemeSize(size),
+        height: themeContext.resolveThemeSize(size),
+        lineHeight: themeContext.resolveThemeSize(size),
+        color: textColor,
+        ...textStyle
+      }"
+    >
+      {{ text }}
+    </text>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import { propGetThemeVar, useTheme } from '../theme/ThemeDefine';
+
+const themeContext = useTheme();
+
+export interface AvatarProps {
+  /**
+   * 默认头像,在 url 为空或者加载失败时使用此头像
+   */
+  defaultAvatar?: string,
+  /**
+   * 随机背景颜色
+   */
+  randomColor?: boolean,
+  /**
+   * 头像的图标URL
+   */
+  url?: string,
+  /**
+   * 背景颜色
+   */
+  background?: string,
+  /**
+   * 当未提供 url 时,支持在头像上显示文字, 建议显示1-2个汉字
+   */
+  text?: string,
+  /**
+   * 文字的颜色
+   * @default '#fff'
+   */
+  textColor?: string,
+  /**
+   * 文字的样式
+   */
+  textStyle?: object,
+  /**
+   * 头像的样式
+   */
+  innerStyle?: object,
+  /**
+   * 头像的大小。
+   * @default 40
+   */
+  size?: number,
+  /**
+   * 头像圆角大小
+   * @default 0
+   */
+  radius?: number,
+  /**
+   * 头像是否是圆型,设置后 radius 无效
+   * @default false
+   */
+  round?: boolean,
+}
+
+const props = withDefaults(defineProps<AvatarProps>(), {
+  defaultAvatar: '',
+  background: () => propGetThemeVar('AvatarBackground', 'background.imageBox'),
+  url: '',
+  text: '',
+  textColor: () => propGetThemeVar('AvatarTextColor', 'text.content'),
+  size: () => propGetThemeVar('AvatarSize', 70),
+  radius: () => propGetThemeVar('AvatarRadius', 0),
+  round: () => propGetThemeVar('AvatarRound', true),
+});
+
+const randomBackgroundColors = [
+  'background.primary', 'background.danger', 'background.success', 'background.warning',
+]
+
+const style = computed(() => {
+  return {
+    backgroundColor: themeContext.resolveThemeColor(props.randomColor ? 
+      randomBackgroundColors[Math.floor(Math.random() * randomBackgroundColors.length)] :
+      props.background
+    ),
+    borderRadius: props.round ? 
+      themeContext.resolveThemeSize('AvatarRoundRadius', '50%') : 
+      themeContext.resolveThemeSize(props.radius),
+    width: themeContext.resolveThemeSize(props.size),
+    height: themeContext.resolveThemeSize(props.size),
+    ...props.innerStyle,
+  }
+});
+
+defineEmits([ 'click' ]);
+</script>
+
+<style lang="scss">
+.nana-avatar {
+  overflow: hidden;
+  flex-shrink: 0;
+
+  text {
+    display: block;
+    text-align: center;
+    user-select: none;
+    font-weight: bold;
+  }
+}
+</style>

+ 176 - 0
src/components/display/AvatarStack.vue

@@ -0,0 +1,176 @@
+<template>
+  <view class="nana-avatar-stack">
+    <template 
+      v-for="(img, i) of urls"
+      :key="i"
+    >
+      <Avatar v-if="i === 0" 
+        :innerStyle="{
+          ...imageStyle,
+          marginLeft: 0,
+          zIndex: 0,
+        }"
+        :url="urls[i]"
+        :size="size"
+        @click="handleClick(i)"
+      />
+      <Avatar v-else-if="i < maxCount" 
+        :innerStyle="{
+          ...imageStyle,
+          zIndex: i,
+        }"
+        :url="urls[i]"
+        :size="size"
+        @click="handleClick(i)"
+      />
+      <view v-else-if="showOverflowCount && i === maxCount" 
+        :style="{
+          ...imageStyle,
+          ...themeStyles.overflowCount.value,
+          zIndex: maxCount,
+        }"
+        @click="handleClick(i)"
+      >
+        <text :style="themeStyles.overflowCountText.value">+{{urls.length - i}}</text>
+      </view>
+    </template>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import { propGetThemeVar, useTheme, type ViewStyle } from '../theme/ThemeDefine';
+import Avatar from './Avatar.vue';
+import { DynamicColor, DynamicVar } from '../theme/ThemeTools';
+
+export interface AvatarStackProp {
+  /**
+   * 默认头像
+   */
+  defaultAvatar?: string,
+  /**
+   * 头像的图标URL
+   */
+  urls: string[],
+  /**
+   * 最大显示多少个头像,超过后显示数字
+   * @default 5
+   */
+  maxCount?: number,
+  /**
+   * 超过最大显示后是否显示数字
+   * @default true
+   */
+  showOverflowCount?: boolean,
+  /**
+   * 设置头像之间的距离
+   * @default size / 3
+   */
+  imageMargin?: number,
+  /**
+   * 头像的大小
+   * @default 70
+   */
+  size?: number,
+  /**
+   * 头像是否是圆形的
+   * @default true
+   */
+  round?: boolean,
+  /**
+   * 头像是圆角的大小,仅在 round=false 时有效
+   * @default 50%
+   */
+  radius?: number|string,
+  /**
+   * 是否为头像添加边框
+   * @default false
+   */
+  border?: boolean,
+  /**
+   * 头像边框宽度
+   * @default 1.5
+   */
+  borderWidth?: number,
+  /**
+   * 头像边框颜色
+   * @default Color.white
+   */
+  borderColor?: string,
+  /**
+   * 超出显示文字背景样式
+   */
+  overflowCountStyle?: ViewStyle,
+  /**
+   * 超出显示文字自定义样式
+   */
+  overflowCountTextStyle?: ViewStyle,
+  /**
+   * 是否可以点击放大预览
+   * @default false
+   */
+  preview?: boolean,
+}
+
+const emit = defineEmits([ 'click' ]);
+
+const themeContext = useTheme();
+
+const props = withDefaults(defineProps<AvatarStackProp>(), {
+  defaultAvatar: '',
+  urls: () => [],
+  maxCount: () => propGetThemeVar('AvatarStackMaxCount', 5),
+  showOverflowCount: () => propGetThemeVar('AvatarStackShowOverflowCount', true),
+  imageMargin: 0,
+  size: () => propGetThemeVar('AvatarStackSize', 70),
+  round: () => propGetThemeVar('AvatarStackRound', true),
+  radius: () => propGetThemeVar('AvatarStackRadius', '50%'),
+  border: () => propGetThemeVar('AvatarStackBorder', false),
+  borderWidth: () => propGetThemeVar('AvatarStackBorderWidth', 10),
+  borderColor: () => propGetThemeVar('AvatarStackBorderColor', 'white'),
+});
+
+const imageStyle = computed(() => {
+  const size = themeContext.resolveThemeSize(props.size, 0);
+  return {
+    marginLeft: props.imageMargin ? themeContext.resolveThemeSize(props.imageMargin) : `calc(-${size} / 3)`,
+    borderRadius: props.round ? '50%' : themeContext.resolveThemeSize(props.radius),
+    border: props.border ? `${themeContext.resolveThemeSize(props.borderWidth)} solid ${themeContext.resolveThemeColor(props.borderColor)}` : undefined,
+    width: size,
+    height: size,
+  }
+});
+
+const themeStyles = themeContext.useThemeStyles({
+  overflowCount: {
+    backgroundColor: DynamicColor('AvatarStackOverflowCountBackgroundColor', 'white'),
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    display: 'flex',
+  },
+  overflowCountText: {
+    fontSize: DynamicVar('AvatarStackOverflowCountTextFontSize', 12),
+    color: DynamicColor('AvatarStackOverflowCountTextColor', 'text.content'),
+  },
+})
+
+function handleClick(i: number) {
+  if (props.preview) {
+    uni.previewImage({
+      urls: props.urls,
+      current: i,
+    })
+  }
+  emit('click', i);
+}
+
+</script>
+
+<style>
+.nana-avatar-stack {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+</style>

+ 257 - 0
src/components/display/Badge.vue

@@ -0,0 +1,257 @@
+<template>
+  <view 
+    :class="['nana-badge', standalone ? 'standalone' : '']"
+    :style="containerStyle"
+  >
+    <slot />
+    <SimpleTransition name="badge" :show="showBadge" :duration="3000">
+      <template #show="{ classNames }">
+        <view :class="['nana-badge-inner',...classNames]" :style="currentStyle">
+          <VerticalScrollText v-if="anim" center :innerStyle="textStyle" :numberString="content2" />
+          <Text v-else :innerStyle="textStyle" :text="content2" />
+        </view>
+      </template>
+    </SimpleTransition>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import { propGetThemeVar, useTheme, type TextStyle, type ViewStyle } from '../theme/ThemeDefine';
+import { selectStyleType } from '../theme/ThemeTools';
+import VerticalScrollText from '../typography/VerticalScrollText.vue';
+import Text from '../basic/Text.vue';
+import SimpleTransition from '../anim/SimpleTransition.vue';
+
+type BadgePositionTypes = 'topRight'|'topLeft'|'bottomRight'|'bottomLeft';
+
+export interface BadgeProps {
+  /**
+   * 控制是否显示
+   * @default true
+   */
+  show?: boolean,
+  /**
+   * 独立显示
+   */
+  standalone?: boolean;
+  /**
+   * 徽标内容
+   */
+  content?: string|number,
+  /**
+   * 徽标数字最大值,如果徽标内容是数字,并且超过最大值,则显示 xx+。
+   */
+  maxCount?: number,
+  /**
+   * 徽标颜色
+   * @default danger
+   */
+  color?: string,
+  /**
+   * 是否增加描边
+   * @default false
+   */
+  border?: boolean;
+  /**
+   * 描边宽度
+   * @default 2
+   */
+  borderWidth?: number;
+  /**
+   * 描边颜色
+   * @default white
+   */
+  borderColor?: string;
+  /**
+   * 徽标在父级所处位置
+   * @default 'top-right'
+   */
+  position?: BadgePositionTypes;
+  /**
+   * 是否在切换时有动画效果
+   * @default false
+   */
+  anim?: boolean,
+  /**
+   * 徽标定位偏移
+   */
+  offset?: { x: number, y: number };
+  /**
+   * 徽标自定义样式
+   */
+  badgeStyle?: ViewStyle;
+  /**
+   * 徽标文字自定义样式
+   */
+  textStyle?: TextStyle;
+  /**
+   * 外层容器样式
+   */
+  containerStyle?: ViewStyle;
+  /**
+   * 徽标无文字情况下的徽标大小
+   * @default 10
+   */
+  badgeSize?: number;
+  /**
+   * 字号
+   * @default 12.5
+   */
+  fontSize?: number,
+  /**
+   * 圆角的大小
+   * @default 20
+   */
+  radius?: number;
+  /**
+   * 徽标有文字情况下的垂直内边距
+   * @default 2
+   */
+  paddingVertical?: number,
+  /**
+   * 徽标有文字情况下的水平内边距
+   * @default 4
+   */
+  paddingHorizontal?: number,
+  /**
+   * 如果 content===0 是否隐藏红点
+   * @default true
+   */
+  hiddenIfZero?: boolean;
+}
+
+const themeContext = useTheme();
+
+const props = withDefaults(defineProps<BadgeProps>(), {
+  color: 'danger',
+  show: true,
+  anim: () => propGetThemeVar('BadgeAnim', false),
+  content: '',
+  border: () => propGetThemeVar('BadgeBorder', false),
+  borderWidth: () => propGetThemeVar('BadgeBorderWidth', 4),
+  borderColor: () => propGetThemeVar('BadgeBorderColor', 'white'),
+  position: () => propGetThemeVar('BadgePosition', 'topRight'),
+  offset: () => propGetThemeVar('BadgeOffset', { x: 0, y: 0 }),
+  badgeSize: () => propGetThemeVar('BadgeSize', 18),
+  radius: () => propGetThemeVar('BadgeRadius', 20),
+  fontSize: () => propGetThemeVar('BadgeFontSize', 24),
+  paddingVertical: () => propGetThemeVar('BadgePaddingVertical', 4),
+  paddingHorizontal: () => propGetThemeVar('BadgePaddingHorizontal', 8),
+  hiddenIfZero: true,
+});
+
+//样式生成
+const currentStyle = computed(() => {
+  const size = themeContext.resolveThemeSize(props.badgeSize);
+  return {
+    backgroundColor: themeContext.resolveThemeColor(props.color),
+    borderRadius: themeContext.resolveThemeSize(props.radius),
+    border: props.border ? `${themeContext.resolveThemeSize(props.borderWidth)} solid ${themeContext.resolveThemeColor(props.borderColor)}` : undefined,
+    minWidth: props.content ? themeContext.resolveThemeSize(props.fontSize) : undefined,
+    padding: `${themeContext.resolveThemeSize(props.paddingVertical)} ${themeContext.resolveThemeSize(props.paddingHorizontal)}`,
+    ...props.badgeStyle,
+    ...(props.standalone ? {} : selectStyleType<TextStyle, BadgePositionTypes>(props.position, 'topRight', {
+      topRight: {
+        transform: `translate(50%, -50%)`,
+        top: themeContext.resolveSize(props.offset.y),
+        right: themeContext.resolveSize(props.offset.x),
+      },
+      topLeft: {
+        transform: `translate(-50%, -50%)`,
+        top: themeContext.resolveSize(props.offset.y),
+        left: themeContext.resolveSize(props.offset.x),
+      },
+      bottomRight: {
+        transform: `translate(50%, 50%)`,
+        bottom: themeContext.resolveSize(props.offset.y),
+        right: themeContext.resolveSize(props.offset.x),
+      },
+      bottomLeft: {
+        transform: `translate(-50%, 50%)`,
+        bottom: themeContext.resolveSize(props.offset.y),
+        left: themeContext.resolveSize(props.offset.x),
+      },
+    })),
+    ...(
+      props.content ? {} : {
+        width: size,
+        height: size,
+        borderRadius: '50%',
+        padding: undefined,
+      }
+    ),
+  }
+});
+
+const textStyle = computed(() => {
+  const fontSize = themeContext.resolveThemeSize(props.fontSize, 24);
+  return {
+    fontSize: fontSize,
+    color: themeContext.resolveThemeColor('BadgeTextColor', 'white'),
+    textAlign: 'center',
+    ...props.textStyle,
+  }
+})
+
+const content2 = computed(() => {
+  if (typeof props.content !== 'undefined') {
+    if (typeof props.content === 'number') {
+      return (props.maxCount && props.content > props.maxCount) ? `${props.maxCount}+` : props.content.toString();
+    } else
+      return props.content;
+  }
+  return '';
+});
+
+const showBadge = computed(() => 
+  props.show && (!props.hiddenIfZero || 
+  (props.hiddenIfZero && props.content !== 0 && props.content !== '0'))
+);
+
+</script>
+
+<style lang="scss">
+.nana-badge {
+  position: relative;
+  display: inline-flex;
+  width: auto;
+  height: auto;
+  overflow: visible;
+
+  .badge-enter-active,
+  .badge-leave-active {
+    opacity: 1;
+    transform: scale(1);
+    transition: all 0.3s ease-in-out;
+  }
+
+  .badge-enter-from,
+  .badge-leave-to {
+    transform: scale(0);
+    opacity: 0;
+  }
+
+  &.standalone {
+    position: relative;
+
+    .nana-badge-inner {
+      position: relative;
+      z-index: 100;
+    }
+  }
+}
+.nana-badge-inner {
+  position: absolute;
+  z-index: 100;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  text-align: center;
+  align-self: flex-start;
+  overflow: hidden;
+  flex-shrink: 0;
+}
+
+
+</style>

+ 116 - 0
src/components/display/Collapse.vue

@@ -0,0 +1,116 @@
+<template>
+  <FlexView direction="column">
+    <slot />
+  </FlexView>
+</template>
+
+<script setup lang="ts">
+import { provide, toRef, type Ref } from 'vue';
+import { useChildLinkParent } from '../composeabe/ChildItem';
+import FlexView from '../layout/FlexView.vue';
+import { ArrayUtils } from '@imengyu/imengyu-utils';
+
+export interface CollapseProps {
+  /**
+   * 当前展开面板的 name
+   */
+  modelValue: (number | string)[];
+  /**
+   * 是否开启手风琴模式
+   * @default false
+   */
+  accordion?: boolean;
+  /**
+   * 动画时长(ms),为0时禁用动画
+   * @default 300
+   */
+  animDuration?: number;
+}
+export interface CollapseInstance {
+  /**
+   * 展开指定面板
+   * @param name 面板的 name,为空时表示所有面板
+   */
+  open(name?: number | string): void;
+  /**
+   * 关闭指定面板
+   * @param name 面板的 name,为空时表示所有面板
+   */
+  close(name?: number | string): void;
+  /**
+   * 切换指定面板打开状态
+   * @param name 面板的 name,为空时表示所有面板
+   */
+  toggle(name?: number | string): void;
+}
+
+const emit = defineEmits([ 'update:modelValue' ]);
+const props = withDefaults(defineProps<CollapseProps>(), {
+  animDuration: 300,
+  accordion: false,
+});
+const childNames: (number | string)[] = [];
+
+const {
+  getPosition,
+} = useChildLinkParent({
+  onClean: () => {
+    //ArrayUtils.clear(childNames);
+  },
+});
+
+export interface CollapseContext {
+  activeName: Ref<(number|string)[]>,
+  animDuration: Ref<number>,
+  getPosition: (name?: string|number) => number,
+  itemClick: (i: number | string) => void;
+  
+}
+
+provide<CollapseContext>('CollapseContext', {
+  activeName: toRef(props, 'modelValue'),
+  animDuration: toRef(props, 'animDuration'),
+  getPosition: (name) => {
+    const position = getPosition().index;
+    childNames.push(name ?? position);
+    return position;
+  },
+  itemClick: (i) => {
+    if (props.modelValue.includes(i)) {
+      emit('update:modelValue', props.modelValue.filter((v) => v !== i));
+      return;
+    }
+    if (props.accordion) {
+      emit('update:modelValue', [i]);
+    } else {
+      emit('update:modelValue', [...props.modelValue, i]);
+    }
+  },
+});
+
+defineExpose<CollapseInstance>({
+  open: (name?: number | string) => {
+    if (name) {
+      emit('update:modelValue', props.accordion ? [name] : [...props.modelValue, name]);
+      return;
+    }
+    emit('update:modelValue', childNames);
+  },
+  close: (name?: number | string) => {
+    if (name) {
+      emit('update:modelValue', props.modelValue.filter((v) => v !== name));
+      return;
+    }
+    emit('update:modelValue', []);
+  },
+  toggle: (name?: number | string) => {
+    if (name) {
+      emit('update:modelValue', props.modelValue.includes(name) ? props.modelValue.filter((v) => v !== name) : [...props.modelValue, name]);
+      return;
+    }
+    emit('update:modelValue', childNames.filter((v) => !props.modelValue.includes(v)));
+  },
+});
+
+
+</script>

+ 92 - 0
src/components/display/CollapseBox.vue

@@ -0,0 +1,92 @@
+<template>
+  <view 
+    :id="id" 
+    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,
+    }"
+  >
+    <slot />
+  </view>
+</template>
+
+<script setup lang="ts">
+import { RandomUtils } from '@imengyu/imengyu-utils';
+import { computed, getCurrentInstance, nextTick, ref, watch } from 'vue';
+
+export interface CollapseBoxProps {
+  /**
+   * 开启状态
+   */
+  open: boolean;
+  /**
+   * 动画时长(ms),为0时禁用动画
+   * @default 300
+   */
+  animDuration?: number;
+  /**
+   * 名称,用于唯一标识
+   */
+  name?: string;
+}
+
+const id = computed(() => `nana-collapse-box-${props.name}-${RandomUtils.genNonDuplicateIDHEX(16)}`);
+const props = withDefaults(defineProps<CollapseBoxProps>(), {
+  animDuration: 300,
+});
+
+const realOpenState = ref(false);
+const targetHeight = ref(0);
+const instance = getCurrentInstance();
+let isAnimWorking = false;
+
+watch(() => props.open, (newVal) => {
+  if (props.animDuration <= 0) {
+    realOpenState.value = newVal;
+    return;
+  }
+  if (isAnimWorking) 
+    return;
+  if (newVal) {
+    realOpenState.value = true;
+    targetHeight.value = -1;
+    isAnimWorking = true;
+    nextTick(() => {
+      uni.createSelectorQuery()
+        // #ifdef MP
+        .in(instance)
+        // #endif
+        .select(`#${id.value}`)
+        .boundingClientRect(rect => {
+          const ref = rect instanceof Array ? rect[0] : rect;
+          const height = ref?.height || 500;
+          targetHeight.value = 0;
+          setTimeout(() => {
+            targetHeight.value = height;
+            setTimeout(() => {
+              isAnimWorking = false;
+            }, props.animDuration);
+          }, 10);
+        })
+        .exec()
+    })
+  } else {
+    isAnimWorking = true;
+    targetHeight.value = 0;
+    setTimeout(() => {
+      realOpenState.value = false;
+      isAnimWorking = false;
+    }, props.animDuration);
+  }
+});
+</script>
+
+<style lang="scss">
+.nana-collapse-box {
+  overflow: hidden;
+  will-change: height;
+}
+</style>
+

+ 118 - 0
src/components/display/CollapseItem.vue

@@ -0,0 +1,118 @@
+<template>
+  <FlexView direction="column" :data-position="position">
+    <slot 
+      name="cell" 
+      :state="state" 
+      :title="title"
+      :value="value"
+      :label="label"
+      :size="size"
+      :icon="icon"
+    >
+      <Cell 
+        :title="title"
+        :value="value"
+        :label="label"
+        :size="size"
+        :icon="icon"
+        :touchable="!props.disabled"
+        :innerStyle="{
+          opacity: props.disabled ? 0.7 : 1,
+        }"
+        :topBorder="props.border"
+        @click="context.itemClick(id)"
+      > 
+        <!-- TODO: Fix -->
+        <!-- #ifndef MP -->
+        <template v-if="$slots.icon" #leftIcon>
+          <slot name="icon" />
+        </template>
+        <template v-if="$slots.label" #label>
+          <slot name="label" />
+        </template>
+        <template v-if="$slots.value" #value>
+          <slot name="value" />
+        </template>
+        <template v-if="$slots.title" #title>
+          <slot name="title" />
+        </template>
+        <!-- #endif -->
+        <template #rightIcon>
+          <Icon 
+            icon="arrow-down" 
+            :class="['nana-collapse-item-icon', {'open': state}]"
+          />
+        </template>
+      </Cell>
+    </slot>
+    <CollapseBox :open="state" :anim-duration="context.animDuration.value" :name="name || position.value">
+      <slot />
+    </CollapseBox>
+  </FlexView>
+</template>
+
+<script setup lang="ts">
+import { computed, inject, provide, toRef, type Ref } from 'vue';
+import FlexView from '../layout/FlexView.vue';
+import type { CollapseContext } from './Collapse.vue';
+import Cell from '../basic/Cell.vue';
+import CollapseBox from './CollapseBox.vue';
+import Icon from '../basic/Icon.vue';
+import { useChildLinkChild } from '../composeabe/ChildItem';
+
+export interface CollapseProps {
+  /**
+   * 当前面板的唯一标识符,默认为索引值
+   */
+  name?: number | string;
+  /**
+   * 标题栏左侧图标名称或图片链接,等同于 Icon 组件的 icon 属性
+   */
+  icon?: string;
+  /**
+   * 标题栏大小,可选值为 large
+   * @default 'medium'
+   */
+  size?: 'medium' | 'large';
+  /**
+   * 标题栏左侧内容
+   */
+  title?: string;
+  /**
+   * 标题栏右侧内容
+   */
+  value?: string;
+  /**
+   * 标题栏描述信息
+   */
+  label?: string;
+  /**
+   * 是否显示内边框
+   */
+  border?: boolean;
+  /**
+   * 是否禁用面板
+   */
+  disabled?: boolean;
+}
+
+const emit = defineEmits([ 'update:modelValue' ]);
+const props = defineProps<CollapseProps>();
+
+
+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>

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

@@ -0,0 +1,160 @@
+<template>
+  <view 
+    :class="[
+      'nana-divider',
+      type,
+      orientation,
+    ]"
+    :style="outStyle"
+  >
+    <template v-if="text">
+      <view class="line left" :style="lineStyle">
+        <view class="bar" :style="barStyle" />
+      </view>
+      <text class="line center" :style="{
+        ...lineStyle,
+        ...textStyle
+      }">{{ text }}</text>
+      <view class="line right">
+        <view class="bar" :style="barStyle" />
+      </view>
+    </template>
+    <view v-else class="line" :style="lineStyle">
+      <view class="bar" :style="barStyle" />
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import { propGetThemeVar, useTheme } from '../theme/ThemeDefine';
+
+
+type DividerOrientationTypes = 'left' | 'right' | 'center';
+
+export interface DividerProps {
+  /**
+   * 是否虚线
+   * @default false
+   */
+  dashed?: boolean;
+  /**
+   * 是否虚线 (dotted)
+   * @default false
+   */
+  dotted?: boolean
+  /**
+   * 线的颜色,默认 gray
+   * @default gray
+   */
+  color?: string;
+  /**
+   * 线的颜色
+   * @default none
+   */
+  backgroundColor?: string;
+  /**
+   * 分割线标题的位置,默认 center
+   */
+  orientation?: DividerOrientationTypes;
+  /**
+   * 水平还是垂直类型
+   * @default 'horizontal'
+   */
+  type?: 'horizontal' | 'vertical';
+  /**
+   * 分割线上面的文字(仅水平状态有效)
+   */
+  text?: string,
+  /**
+   * 分割线上面的文字样式
+   */
+  textStyle?: object,
+  /**
+   * 分割线宽度
+   * @default 1
+   */
+  width?: number;
+  /**
+   * 容器大小(垂直的时候是宽度,水平的时候是高度)
+   * @default 36
+   */
+  size?: number;
+}
+
+const theme = useTheme();
+
+const props = withDefaults(defineProps<DividerProps>(), {
+  color: () => propGetThemeVar('DividerColor', 'grey'),
+  backgroundColor: () => propGetThemeVar('DividerBackgroundColor', undefined)!,
+  width: () => propGetThemeVar('DividerWidth', 2),
+  size: () => propGetThemeVar('DividerSize', 36),
+  type: 'horizontal',
+  orientation: 'center',
+});
+
+const outStyle = computed(() => {
+  return {
+    backgroundColor: theme.resolveThemeColor(props.backgroundColor),
+  }
+})
+const lineStyle = computed(() => {
+  return {
+    fontSize: '22rpx',
+    color: theme.resolveThemeColor(props.color),
+    width: theme.resolveThemeSize(props.type === 'horizontal' ? '100%' : props.size),
+    height: theme.resolveThemeSize(props.type === 'vertical' ? '100%' : props.size),
+  }
+})
+const barStyle = computed(() => {
+  const border = `${theme.resolveThemeSize(props.width)} ${props.dashed ? 'dashed' : 'solid'} ${theme.resolveThemeColor(props.color)}`
+  return {
+    borderLeft: props.type === 'vertical' ? border : undefined,
+    borderTop: props.type === 'vertical' ? undefined : border,
+    width: theme.resolveThemeSize(props.type === 'horizontal' ? '100%' : props.width),
+    height: theme.resolveThemeSize(props.type === 'vertical' ? '100%' : props.width),
+  }
+})
+</script>
+
+<style lang="scss">
+.nana-divider {
+  position: relative;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  align-self: stretch;
+
+  .line {
+    position: relative;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    flex: 1;
+
+    &.center {
+      flex-shrink: 0;
+      text-align: center;
+    }
+  }
+
+  &.left {
+    .line.left {
+      flex: 0.25;
+    }
+  }
+  &.right {
+    .line.right {
+      flex: 0.25;
+    }
+  }
+
+
+  &.vertical {
+    flex-direction: column;
+  }
+  &.horizontal {
+    flex-direction: row;
+  }
+}
+</style>

+ 62 - 0
src/components/display/Footer.vue

@@ -0,0 +1,62 @@
+<template>
+  <FlexCol center :padding="padding" :backgroundColor="props.backgroundColor">
+    <slot class="links">
+      <FlexRow v-if="props.links?.length" wrap align="center">
+        <template v-for="(item, i) in props.links" :key="i">
+          <A
+            :href="item.url"
+            :linkType="item.type"
+            :fontSize="props.textSize"
+            :color="props.linkColor"
+            v-bind="props.linkProps"  
+            @click="item.onClick"
+          >
+            {{ item.text }}
+          </A>
+          <Divider v-if="i < props.links.length - 1" type="vertical" />
+        </template>
+      </FlexRow>
+    </slot>
+    <slot class="text"> 
+      <Text 
+        v-if="props.text" 
+        fontConfig="footerText"
+        :fontSize="props.textSize"
+        :color="props.textColor"
+        v-bind="props.textProps"
+      >
+        {{ props.text }}
+      </Text>
+    </slot>
+  </FlexCol>
+</template>
+
+<script setup lang="ts">
+import Text, { type TextProps } from '../basic/Text.vue';
+import FlexCol from '../layout/FlexCol.vue';
+import FlexRow from '../layout/FlexRow.vue';
+import A, { type AProps } from '../typography/A.vue';
+import Divider from './Divider.vue';
+
+export interface FooterProps {
+  backgroundColor?: string,
+  padding?: number|number[],
+  text?: string,
+  textProps?: TextProps,
+  textSize?: number,
+  textColor?: string,
+  linkProps?: AProps,
+  linkColor?: string,
+  links?: {
+    text: string,
+    url: string,
+    type: 'uni-page'|'url'|'back'|'custom',
+    onClick?: (e: any) => void
+  }[]
+}
+
+const props = withDefaults(defineProps<FooterProps>(), {
+  padding: 20,
+});
+
+</script>

+ 140 - 0
src/components/display/NoticeBar.vue

@@ -0,0 +1,140 @@
+<template>
+  <Touchable
+    touchable 
+    direction="row"
+    :flexGrow="0" 
+    :flexShrink="0"
+    :innerStyle="{
+      ...themeStyles.view.value,
+      backgroundColor: themeContext.resolveThemeColor(backgroundColor),
+    }"
+    @click="emit('click')"
+  >
+    <slot name="leftIcon">
+      <Icon 
+        :innerStyle="themeStyles.icon.value"
+        :color="textColor"
+        :icon="icon"
+        v-bind="iconProps" 
+      />
+    </slot>
+
+    <view v-if="scroll" :style="themeStyles.contentView.value">
+      <HorizontalScrollText :innerStyle="textStyleFinal" :scrollDuration="scrollDuration" :text="content" />
+    </view>
+    <Text v-else :lines="wrap ? undefined : 1" :innerStyle="textStyleFinal" :text="content" />
+    
+    <slot name="rightIcon" />
+    <IconButton 
+      v-if="closeable"
+      icon="close"
+      :innerStyle="themeStyles.icon.value"
+      :color="textColor"
+      @click="emit('close')"
+    />
+  </Touchable>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import { propGetThemeVar, useTheme, type TextStyle, type ViewStyle } from '../theme/ThemeDefine';
+import { DynamicSize, DynamicSize2 } from '../theme/ThemeTools';
+import type { IconProps } from '../basic/Icon.vue';
+import Icon from '../basic/Icon.vue';
+import IconButton from '../basic/IconButton.vue';
+import Text from '../basic/Text.vue';
+import HorizontalScrollText from '../typography/HorizontalScrollText.vue';
+import Touchable from '../feedback/Touchable.vue';
+
+export interface NoticeBarProps {
+  /**
+   * 左边的图标, 可以传入图片
+   */
+  icon?: string|undefined;
+  /**
+   * 图标或者图片的自定义样式
+   */
+  iconStyle?: ViewStyle;
+  /**
+   * 图标额外属性
+   */
+  iconProps?: IconProps;
+  /**
+   * 内容
+   */
+  content?: string;
+  /**
+   * 文字的自定义样式
+   */
+  textStyle?: TextStyle;
+  /**
+   * 背景颜色
+   * @default light.warning
+   */
+  backgroundColor?: string;
+  /**
+   * 文字颜色
+   * @default text.warning
+   */
+  textColor?: string;
+  /**
+   * 是否滚动播放
+   * @default true
+   */
+  scroll?: boolean;
+  /**
+   * 滚动动画时长(毫秒)
+   * @default 100000
+   */
+  scrollDuration?: number;
+  /**
+   * 文字是否换行,仅在非滚动播放时生效
+   * @default false
+   */
+  wrap?: boolean;
+  /**
+   * 是否显示关闭按钮。用户点击后会触发 `close` 事件,请自行处理 NoticeBar 的显示与否。
+   * @default false
+   */
+  closeable?: boolean;
+}
+
+const themeContext = useTheme();
+
+const emit = defineEmits([ 'close', 'click' ]);
+const props = withDefaults(defineProps<NoticeBarProps>(), {
+  icon: 'notification',
+  backgroundColor: () => propGetThemeVar('NoticeBarBackgroundColor', 'background.warning'),
+  textColor: () => propGetThemeVar('NoticeBarTextColor', 'text.warning'),
+  scroll: true,
+  scrollDuration: () => propGetThemeVar('NoticeBarScrollDuration', 100000),
+  wrap: false,
+  closeable: false,
+});
+
+const  textStyleFinal =  computed(() => ({
+  width: 'auto',
+  height: 'auto',
+  flex: 1,
+  color: themeContext.resolveThemeColor(props.textColor),
+  ...props.textStyle,
+} as TextStyle));
+
+const themeStyles = themeContext.useThemeStyles({
+  view: {
+    display: 'flex',
+    position: 'relative',
+    padding: DynamicSize2('NoticeBarPaddingVertical', 'NoticeBarPaddingHorizontal', 12, 20),
+    justifyContent: 'space-between',
+    alignContent: 'center',
+  },
+  icon: {
+    marginRight: DynamicSize('NoticeBarIconMarginRight', 10),
+  },
+  contentView: {
+    flex: 1,
+    overflow: 'hidden',
+  },
+});
+
+</script>

+ 289 - 0
src/components/display/Progress.vue

@@ -0,0 +1,289 @@
+<template>
+  <view
+    class="nana-progress"
+    :style="{
+      ...selectStyleType<ViewStyle, ProgressTypes>(type, 'left-right', {
+        'left-right': {
+          height: height,
+          width: width,
+          flexDirection: 'row',
+          alignItems: 'center',
+          alignSelf: 'flex-start',
+        },
+        'right-left': {
+          height: height,
+          width: width,
+          flexDirection: 'row',
+          alignItems: 'center',
+          alignSelf: 'flex-start',
+        },
+        'top-bottom': {
+          width: height,
+          height: width,
+          flexDirection: 'column',
+          justifyContent: 'center',
+          alignSelf: 'flex-start',
+        },
+        'bottom-top': {
+          width: height,
+          height: width,
+          flexDirection: 'column',
+          justifyContent: 'center',
+          alignSelf: 'flex-start',
+        },
+      }),
+      ...props.innerStyle,
+    }"
+  >
+    <view
+      class="bar"
+      :style="{
+        ...selectStyleType(progressPos, 'flow', {
+          left: {
+            marginLeft: themeStyles.progressTextSingle.value.width,
+          },
+          right: {
+            marginRight: themeStyles.progressTextSingle.value.width,
+          },
+          flow: {},
+        }),
+        ...selectStyleType<ViewStyle, ProgressTypes>(type, 'left-right', {
+        'left-right': {
+          height: height,
+          width: width,
+        },
+        'right-left': {
+          height: height,
+          width: width,
+        },
+        'top-bottom': {
+          width: height,
+          height: width,
+        },
+        'bottom-top': {
+          width: height,
+          height: width,
+        },
+      }),
+        borderRadius: barRadius,
+        backgroundColor: barBackgroundColor,
+      }"
+    >
+      <view
+        class="inner"
+        :style="{
+          ...progressStyles,
+          width: isHorizontal ? `${precentValue}%` : '100%',
+          height: isHorizontal ? '100%' : `${precentValue}%`,
+          transition: animate ? `all ease ${animateDuration}ms` : undefined,
+        }"
+      />
+    </view>
+    <text
+      v-if="showProgressText"
+      class="text"
+      :style="{
+        transition: animate ? `all ease ${animateDuration}ms` : undefined,
+        ...selectStyleType(progressPos, 'flow', {
+          left: {
+            ...themeStyles.progressTextSingle.value,
+            left: 0,
+          },
+          right: {
+            ...themeStyles.progressTextSingle.value,
+            right: 0,
+          },
+          flow: {
+            backgroundColor: barColor,
+            ...themeStyles.progressTextPill.value
+          },
+        }),
+        ...(progressPos === 'flow' ? selectStyleType<ViewStyle, ProgressTypes>(props.type, 'left-right', {
+          'left-right': { 
+            left: `${precentValue}%`,
+            top: '50%',
+            transform: 'translateX(-50%) translateY(-50%)',
+          },
+          'right-left': {
+            right: `${precentValue}%`,
+            top: '50%',
+            transform: 'translateX(50%) translateY(-50%)',
+          },
+          'top-bottom': {
+            top: `${precentValue}%`,
+            left: '50%',
+            transform: 'translateX(-50%) translateY(50%) rotate(-90deg) ',
+          },
+          'bottom-top': {
+            bottom: `${precentValue}%`,  
+            left: '50%',
+            transform: 'translateX(-50%) translateY(50%) rotate(90deg)',
+          },
+        }) : {}),
+        ...progressTextStyle,
+      }"
+    >{{value}}%</text>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import { propGetThemeVar, useTheme, type TextStyle, type ViewStyle } from '../theme/ThemeDefine';
+import { DynamicColor, DynamicSize, DynamicSize2, DynamicVar, selectStyleType } from '../theme/ThemeTools';
+
+const themeContext = useTheme();
+
+type ProgressTypes = 'left-right'|'right-left'|'top-bottom'|'bottom-top';
+
+export interface ProgressProps {
+  /**
+   * 当前进度,0-100
+   */
+  value: number,
+  /**
+   * 背景的颜色
+   * @default gray
+   */
+  backgroundColor?: string,
+  /**
+   * 进度的颜色
+   * @default primary
+   */
+  progressColor?: string,
+  /**
+   * 进度条的方向
+   * * left-right 横向,从左到右
+   * * right-left 横向,从右到左
+   * * top-bottom 竖向,从上到下
+   * * bottom-top 竖向,从下到上
+   * @default 'left-right'
+   */
+  type?: ProgressTypes;
+  /**
+   * 进度条的高度,竖向模式时自动设置为高度
+   */
+  height?: number;
+  /**
+   * 进度条宽度,默认100%沾满父容器
+   * @default '100%'
+   */
+  width?: number|string;
+  /**
+   * 是否显示进度文字
+   */
+  showProgressText?: boolean;
+  /**
+   * 进度文字的位置。center 居中;right 居右;flow 跟随进度
+   * @default 'flow'
+   */
+  progressPos?: 'left'|'right'|'flow';
+  /**
+   * 进度文字的自定义样式
+   */
+  progressTextStyle?: TextStyle;
+  /**
+   * 背景样式
+   */
+  innerStyle?: ViewStyle,
+  /**
+   * 是否是圆角
+   * @default true
+   */
+  round?: boolean;
+  /**
+   * round=true 时的圆角大小
+   * @default 10
+   */
+  radius?: number;
+  /**
+   * 是否有动画效果
+   * @default false
+   */
+  animate?: boolean;
+  /**
+   * 动画效果时长,ms
+   * @default 300
+   */
+  animateDuration?: number;
+  /**
+   * 进度的样式
+   */
+  progressStyle?: ViewStyle,
+}
+
+
+const emit = defineEmits([ 'click' ]);
+const props = withDefaults(defineProps<ProgressProps>(), {
+  value: 0,
+  backgroundColor: () => propGetThemeVar('ProgressBackgroundColor', 'gray'),
+  progressColor: () => propGetThemeVar('ProgressProgressColor', 'primary'),
+  type: () => propGetThemeVar('ProgressType', 'left-right'),
+  height: () => propGetThemeVar('ProgressHeight', 15),
+  width: () => propGetThemeVar('ProgressWidth', '100%'),
+  showProgressText: () => propGetThemeVar('ProgressShowProgressText', false),
+  progressPos: () => propGetThemeVar('ProgressProgressPos', 'flow'),
+  round: () => propGetThemeVar('ProgressRound', true),
+  radius: () => propGetThemeVar('ProgressRadius', 20),
+  animate: () => propGetThemeVar('ProgressAnimate', false),
+  animateDuration: () => propGetThemeVar('ProgressAnimateDuration', 300),
+});
+
+
+const value = computed(() => Math.min(100, Math.max(props.value || 0, 0)));
+const barColor = computed(() => themeContext.resolveThemeColor(props.progressColor, themeContext.resolveThemeColor('ProgressProgressColor', 'primary')));
+const barBackgroundColor = computed(() => themeContext.resolveThemeColor(props.backgroundColor, themeContext.resolveThemeColor('ProgressBackgroundColor', 'grey')));
+const barRadius = computed(() => themeContext.resolveSize(props.round ? props.radius : props.height));
+const isHorizontal = computed(() => (typeof props.type === 'undefined' || props.type === 'left-right' || props.type === 'right-left'));
+const precentValue = computed(() => (value.value / 100) * 100);
+const height = computed(() => themeContext.resolveSize(props.height));
+const width = computed(() => themeContext.resolveSize(props.width));
+const progressStyles = computed(() => ({
+  borderRadius: barRadius.value,
+  backgroundColor: barColor.value,
+  ...selectStyleType<ViewStyle, ProgressTypes>(props.type, 'left-right', {
+    'left-right': { left: 0, height: height.value },
+    'right-left': { right: 0, height: height.value },
+    'top-bottom': { top: 0, width: height.value },
+    'bottom-top': { bottom: 0, width: height.value },
+  }),
+}));
+
+const themeStyles = themeContext.useThemeStyles({
+  progressTextPill: {
+    width: DynamicSize('ProgressTextWidth', 80),
+    textAlign: DynamicVar('ProgressTextAlign', 'center'),
+    padding: DynamicSize2('ProgressTextPaddingVertical', 'ProgressTextPaddingHorizontal', 2, 2),
+    borderRadius: DynamicSize('ProgressTextBorderRadius', 20),
+    fontSize: DynamicSize('ProgressTextFontSize', 24),
+    color: DynamicColor('ProgressTextColor', 'white'),
+  },
+  progressTextSingle: {
+    width: DynamicSize('ProgressTextWidth', 80),
+    borderRadius: DynamicSize('ProgressTextBorderRadius', 15),
+    textAlign: DynamicVar('ProgressTextAlign', 'center'),
+    fontSize: DynamicSize('ProgressTextFontSize', 24),
+    color: DynamicColor('ProgressTextSingleColor', 'text.content'),
+  },
+});
+
+</script>
+
+<style lang="scss">
+.nana-progress {
+  position: relative;
+  display: flex;
+
+  .bar {
+    position: relative;
+
+    .inner {
+      position: absolute;
+      z-index: 9;
+    }
+  }
+  .text {
+    position: absolute;
+    z-index: 10;
+  }
+}
+</style>

+ 93 - 0
src/components/display/Skeleton.vue

@@ -0,0 +1,93 @@
+<template>
+  <slot v-if="loading" name="placeholder"></slot>
+  <slot v-else />
+</template>
+
+<script setup lang="ts">
+import { computed, provide, toRef, type Ref } from 'vue';
+import { useTheme } from '../theme/ThemeDefine';
+
+export interface SkeletonProps {
+  /**
+   * 为 true 时,显示占位元素 placeholder slot。反之则显示内容组件 default slot
+   * @default false
+   */
+  loading?: boolean;
+  /**
+   * 是否展示动画效果
+   * @default false
+   */
+  active?: boolean;
+  /**
+   * 占位元素颜色
+   * @default skeleton
+   */
+  color?: string;
+}
+
+const props = withDefaults(defineProps<SkeletonProps>(), {
+  loading: false,
+  active: false,
+  color: 'skeleton',
+});
+const themeContext = useTheme();
+const color = computed(() => themeContext.resolveThemeColor(props.color));
+
+export interface SkeletonContext {
+  color: Ref<string>;
+  active: Ref<boolean>;
+}
+provide('SkeletonContext', {
+  color: color,
+  active: toRef(props, 'active'),
+});
+
+defineOptions({
+  options: {
+    virtualHost: true,
+    styleIsolation: "shared",
+  },
+});
+</script>
+
+<style lang="scss">
+.nana-skeleton {
+  position: relative;
+  width: 100%;
+  flex-shrink: 0;
+  overflow: hidden;
+
+  :deep(.anim-box) {
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    background: linear-gradient(90deg, rgba(0, 0, 0, 0.001) 25%, rgba(0, 0, 0, 0.015) 37%, rgba(0, 0, 0, 0.001) 63%);
+    width: 200%;
+    height: 100%;
+    animation: skeleton-anim 1s linear infinite;
+    display: none;
+    flex-shrink: 0;
+  }
+
+  &.anim {
+    :deep(.anim-box) {
+      display: initial;
+    }
+  }
+  &.avatar {
+    border-radius: 50%;
+  }
+}
+
+@keyframes skeleton-anim {
+  0% {
+    transform: translateX(-100%);
+  }
+  100% {
+    transform: translateX(100%);
+  }
+}
+
+
+</style>

+ 48 - 0
src/components/display/Status.vue

@@ -0,0 +1,48 @@
+<template>
+  <view class="nana-status">
+    <view class="dot" :style="{
+      backgroundColor: themeContext.resolveThemeColor(status),
+    }" />
+    <Text v-bind="textProps" :text="text" />
+  </view>
+</template>
+
+<script setup lang="ts">
+import { useTheme  } from '../theme/ThemeDefine';
+import type { TextProps } from '../basic/Text.vue';
+import Text from '../basic/Text.vue';
+
+export interface StatusProps {
+  status?: string,
+  text?: string,
+  textProps?: TextProps,
+}
+
+const themeContext = useTheme();
+
+const props = withDefaults(defineProps<StatusProps>(), {
+  status: 'primary',
+  textProps: () => ({
+    fontSize: 30,
+    color: 'text.content',
+  }),
+});
+</script>
+
+<style lang="scss">
+.nana-status {
+  position: relative;
+  display: inline-flex;
+  flex-direction: row;
+  align-items: center;
+  width: auto;
+  height: auto;
+
+  .dot {
+    margin-right: 10rpx;
+    width: 18rpx;
+    height: 18rpx;
+    border-radius: 50%;
+  }
+}
+</style>

+ 167 - 0
src/components/display/Step.vue

@@ -0,0 +1,167 @@
+<template>
+  <scroll-view
+    v-if="direction === 'horizontal'"
+    :scroll-x="true"
+    :show-scrollbar="false"
+    :scroll-with-animation="true"
+    :scroll-left="scrollLeft"
+    :style="themeStyles.scrollHorizontal.value"
+  >
+    <view :style="themeStyles.stepHorizontal.value">
+      <slot />
+    </view>
+  </scroll-view>
+  <view v-else :style="themeStyles.stepVertical.value">
+    <slot />
+  </view>
+</template>
+
+<script setup lang="ts">
+//TODO
+import { computed, provide, toRef, type Ref } from 'vue';
+import type { IconProps } from '../basic/Icon.vue';
+import { propGetThemeVar, useTheme, type TextStyle } from '../theme/ThemeDefine';
+import { useChildLinkParent } from '../composeabe/ChildItem';
+
+const themeContext = useTheme();
+
+export interface StepProps {
+  /**
+   * 步骤条的方向
+   * @default 'horizontal'
+   */
+  direction?: 'vertical'|'horizontal',
+  /**
+   * 激活时的颜色
+   * @default primary
+   */
+  activeColor?: string,
+  /**
+   * 未激活时的颜色
+   * @default Color.grey
+   */
+  inactiveColor?: string,
+  /**
+   * 文字颜色
+   * @default text.content
+
+   */
+  textColor?: string,
+  /**
+   * 当为水平模式时,条目的宽度
+   * @default 150rpx
+   */
+  lineItemWidth?: number,
+  /**
+   * 当为水平模式时,分隔线的边距偏移
+   * @default 10
+   */
+  lineOffset?: number,
+  /**
+   * 线段粗细
+   * @default 1
+   */
+  lineWidth?: number;
+  /**
+   * 当前激活的步骤
+   */
+  activeIndex: number,
+
+  /**
+   * 同 StepItemProps.activeIcon 此项用于所有子条目的设置
+   */
+  activeIcon?: string,
+  /**
+   * 同 StepItemProps.inactiveIcon 此项用于所有子条目的设置
+   */
+  inactiveIcon?: string,
+  /**
+   * 同 StepItemProps.finishIcon 此项用于所有子条目的设置
+   */
+  finishIcon?: string,
+  /**
+   * 同 StepItemProps.iconProps 此项用于所有子条目的设置
+   */
+  iconProps?: IconProps;
+  /**
+   * 同 StepItemProps.textStyle 此项用于所有子条目的设置
+   */
+  textStyle?: TextStyle,
+}
+
+const emit = defineEmits([ 'click' ]);
+const props = withDefaults(defineProps<StepProps>(), {
+  direction: 'horizontal',
+  lineItemWidth: () => propGetThemeVar('StepLineItemWidth', 150),
+  lineOffset: () => propGetThemeVar('StepLineOffset', 25),
+  lineWidth: () => propGetThemeVar('StepLineWidth', 2),
+  activeColor: () => propGetThemeVar('StepActiveColor', 'primary'),
+  inactiveColor: () => propGetThemeVar('StepInactiveColor', 'lightGrey'),
+  textColor: () => propGetThemeVar('StepTextColor', 'text.content'),
+});
+
+const scrollLeft = computed(() => {
+  if (props.direction === 'vertical')
+    return 0;
+  return uni.upx2px((props.activeIndex - 1) * props.lineItemWidth - props.lineItemWidth / 2);
+})
+
+const themeStyles = themeContext.useThemeStyles({
+  stepVertical: {
+    position: 'relative',
+    display: 'flex',
+    flexDirection: 'column',
+  },
+  stepHorizontal: {
+    display: 'flex',
+    position: 'relative',
+    flexDirection: 'row',
+    justifyContent: 'flex-start',
+    width: 'auto',
+  },
+  scrollHorizontal: {
+    position: 'relative',
+    flex: 0,
+    width: '100%',
+  },
+});
+
+const {
+  getPosition,
+  resetCounter,
+  getLength,
+} = useChildLinkParent({
+  getPositionExtra(index) {
+    return {}
+  },
+});
+
+export interface StepContext {
+  direction: Ref<StepProps['direction']>,
+  activeColor: Ref<StepProps['activeColor']>,
+  inactiveColor: Ref<StepProps['inactiveColor']>,
+  textColor: Ref<StepProps['textColor']>,
+  lineItemWidth: Ref<StepProps['lineItemWidth']>,
+  lineOffset: Ref<StepProps['lineOffset']>,
+  lineWidth: Ref<StepProps['lineWidth']>,
+  activeIndex: Ref<StepProps['activeIndex']>,
+  getLength: () => number,
+  getPosition: () => number,
+  resetCounter: () => void,
+}
+provide<StepContext>('StepContext', {
+  direction: toRef(props, 'direction'),
+  activeColor: toRef(props, 'activeColor'),
+  inactiveColor: toRef(props, 'inactiveColor'),
+  textColor: toRef(props, 'textColor'),
+  lineItemWidth: toRef(props, 'lineItemWidth'),
+  lineOffset: toRef(props, 'lineOffset'),
+  lineWidth: toRef(props, 'lineWidth'),
+  activeIndex: toRef(props, 'activeIndex'),
+  getLength,
+  getPosition: () => getPosition().index,
+  resetCounter,
+});
+
+
+</script>

+ 203 - 0
src/components/display/StepItem.vue

@@ -0,0 +1,203 @@
+<template>
+  <view 
+    :style="{
+      ...innerStyle, 
+      ...(context.direction.value === 'vertical' ? 
+        themeStyles.itemVertical.value : 
+        themeStyles.itemHorizontal.value) ,
+      flexBasis: context.direction.value === 'horizontal' ? 
+        themeContext.resolveSize(context.lineItemWidth.value) : 
+        undefined,
+      width: context.direction.value === 'horizontal' ? 
+        themeContext.resolveSize(context.lineItemWidth.value) : 
+        undefined,
+    }"
+  >
+    <view 
+      :style="{
+        ...themeStyles.iconContainer.value, 
+        width: themeContext.resolveSize(iconConSize), 
+        height: themeContext.resolveSize(iconSize)
+      }"
+    >
+      <StepItemInternalDotNumberIcon
+        v-if="useDefaultIcon && inactiveIcon === '__default_number'" 
+        :index="index + 1" 
+        :size="(iconProps.size as number)" 
+        :color="state === 'inactive' ? context.inactiveColor.value : context.activeColor.value" 
+      />
+      <StepItemInternalDotIcon
+        v-else-if="useDefaultIcon && inactiveIcon === '__default_dot'"
+        :size="(iconProps.size as number)" 
+        :color="state === 'inactive' ? context.inactiveColor.value : context.activeColor.value" />
+      <Icon
+        v-else
+        :color="state === 'active' || state === 'finish' ? context.activeColor.value : context.inactiveColor.value"
+        :icon="state === 'active' ? (activeIcon || inactiveIcon) : (state === 'finish' ? finishIcon : inactiveIcon)"
+        v-bind="iconProps"
+      />
+    </View>
+
+    <view :style="themeStyles.content.value">
+      <text 
+        :style="{
+          color: context.textColor.value,
+          ...themeStyles.text.value,
+          ...textStyle,
+        }"
+      >
+        {{ text }}
+      </text>
+      <slot name="extra">
+        <text v-if="extra" :style="{
+          color: context.textColor.value,
+          ...themeStyles.text.value,
+          ...textStyle,
+        }">
+          {{ extra }}
+        </text>
+      </slot>
+    </view>
+
+    <!-- 渲染垂直线段 -->
+    <view
+      v-if="context.direction.value === 'vertical' && !isLast"
+      :style="{
+        position: 'absolute',
+        left: themeContext.resolveSize(iconConSize / 2 - context.lineWidth.value! / 2),
+        top: themeContext.resolveSize(iconConSize - 2),
+        bottom: themeContext.resolveSize(-iconConSize / 2),
+        backgroundColor: themeContext.resolveThemeColor(state === 'finish' ? context.activeColor.value : context.inactiveColor.value),
+        width: themeContext.resolveSize(context.lineWidth.value!),
+      }"
+    />
+
+  </view>
+  <!-- 水平条目之间还需要渲染线段 -->
+  <view
+    v-if="context.direction.value === 'horizontal' && !isLast"
+    :style="{
+      position: 'absolute',
+      left: themeContext.resolveSize((index + 1) * context.lineItemWidth.value! - context.lineItemWidth.value! / 4),
+      top: themeContext.resolveSize(context.lineOffset.value!),
+      backgroundColor: themeContext.resolveThemeColor(context.activeIndex.value > index ? context.activeColor.value : context.inactiveColor.value),
+      height: themeContext.resolveSize(context.lineWidth.value!),
+      width: themeContext.resolveSize(context.lineItemWidth.value! / 2),
+    }"
+  />
+</template>
+
+<script setup lang="ts">
+import { computed, inject, onMounted, onUpdated, ref } from 'vue';
+import { propGetThemeVar, useTheme, type TextStyle, type ViewStyle } from '../theme/ThemeDefine';
+import { DynamicSize } from '../theme/ThemeTools';
+import type { StepContext } from './Step.vue';
+import type { IconProps } from '../basic/Icon.vue';
+import Icon from '../basic/Icon.vue';
+import StepItemInternalDotIcon from './step/StepItemInternalDotIcon.vue';
+import StepItemInternalDotNumberIcon from './step/StepItemInternalDotNumberIcon.vue';
+
+const themeContext = useTheme();
+
+export type StepItemState = 'inactive'|'active'|'finish';
+
+export interface StepItemProps {
+  /**
+   * 自定义激活状态图标。为空时尝试使用 inactiveIcon 的值。
+   */
+  activeIcon?: string,
+  /**
+   * 自定义未激活状态图标。有2个特殊值 `__default_number` 表示一个圆圈中间一个当前步骤的序号;`__default_dot` 表示一个圆圈。
+   * @default 横向默认是 '__default_number',竖向默认是 '__default_dot'
+   */
+  inactiveIcon?: string,
+  /**
+   * 自定义已完成步骤对应的底部图标,优先级高于 `inactiveIcon`
+   * @default 'success-filling'
+   */
+  finishIcon?: string,
+  /**
+   * 图标的附加属性
+   * @default { size: 48 }
+   */
+  iconProps?: IconProps;
+  /**
+   * 步骤的文字自定义样式
+   */
+  textStyle?: TextStyle,
+  /**
+   * 步骤的自定义样式
+   */
+  innerStyle?: ViewStyle,
+  /**
+   * 当前步骤的文字
+   */
+  text?: string;
+  /**
+   * 垂直模式下,允许你渲染附加内容
+   */
+  extra?: string;
+}
+
+const context = inject('StepContext') as StepContext;
+const emit = defineEmits([ 'click' ]);
+const props = withDefaults(defineProps<StepItemProps>(), {
+  iconProps: () => ({ size: propGetThemeVar('StepItemIconDefaultSize', 48) }),
+  activeIcon: () => propGetThemeVar('StepItemActiveIcon', ''),
+  finishIcon: () => propGetThemeVar('StepItemFinishIcon', 'success-filling'),
+  inactiveIcon: () => (inject('StepContext') as StepContext).direction.value === 'horizontal' ? 
+    '__default_number' : '__default_dot',
+});
+
+const index = computed(() => context.getPosition());
+const useDefaultIcon = computed(() => (state.value === 'inactive' || (state.value === 'active' && !props.activeIcon))); 
+const iconConSize = computed(() => props.iconProps.size as number + 0);
+const iconSize = computed(() => props.iconProps.size as number);
+const state = computed(() => context.activeIndex.value === index.value ? 'active' : (context.activeIndex.value > index.value ? 'finish' : 'inactive'));
+const isLast = ref(false);
+
+function updateWithCount() {
+  isLast.value = index.value >= context.getLength() - 1;
+}
+
+onUpdated(updateWithCount);
+onMounted(updateWithCount);
+
+const themeStyles = themeContext.useThemeStyles({
+  itemVertical: {
+    display: 'flex',
+    position: 'relative',
+    flexDirection: 'row',
+    justifyContent: 'flex-start',
+    alignItems: 'flex-start',
+    marginVertical: DynamicSize('StepItemMarginVertical', 10),
+  },
+  itemHorizontal: {
+    flex: '0 0 0%',
+    display: 'flex',
+    position: 'relative',
+    flexDirection: 'column',
+    justifyContent: 'center',
+    alignItems: 'center',
+  },
+  text: {
+    fontSize: DynamicSize('StepItemTextFontSize', 26),
+  },
+  content: {
+    fontSize: DynamicSize('StepItemContentFontSize', 26),
+    marginTop: DynamicSize('StepItemContentMarginTop', 6),
+    marginLeft: DynamicSize('StepItemContentMarginLeft', 6),
+    display: 'flex',
+    flexDirection: 'column',
+    justifyContent: 'center',
+    alignItems: 'flex-start',
+  },
+  iconContainer: {
+    display: 'flex',
+    flexDirection: 'row',
+    justifyContent: 'center',
+    alignItems: 'center',
+  },
+});
+
+</script>

+ 287 - 0
src/components/display/Tag.vue

@@ -0,0 +1,287 @@
+<template>
+  <Touchable 
+    center 
+    innerClass="nana-tag"
+    direction="row"
+    :innerStyle="{
+      ...style,
+      ...innerStyle,
+    }"
+    :touchable="touchable"
+    @click="emit('click')"
+  >
+    <slot name="prefix" />
+    <text :style="{
+      ...themeStyles.title.value,
+      color: themeContext.resolveThemeColor(textColor) || style.color,
+      fontSize: selectStyleType(size, 'medium', FonstSizes),
+    }">
+      {{text}}
+    </text>
+    <slot name="suffix" />
+    <FlexRow v-if="closeable" touchable @click="emit('close')">
+      <Icon
+        icon="close"
+        :size="themeContext.resolveThemeSize('TagCloseIconSize', 30)"
+        :color="(props.textColor || style.color as string)"
+      />
+    </FlexRow>
+  </Touchable>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import { propGetThemeVar, useTheme, type ViewStyle } from '../theme/ThemeDefine';
+import { DynamicColor, DynamicSize, DynamicSize2, selectStyleType } from '../theme/ThemeTools';
+import FlexRow from '../layout/FlexRow.vue';
+import Icon from '../basic/Icon.vue';
+import Touchable from '../feedback/Touchable.vue';
+
+export type TagTypes = 'default'|'primary'|'success'|'warning'|'danger';
+
+export type TagSizes = 'small'|'medium'|'large'|'larger'|'mini';
+
+export interface TagProps {
+  /**
+   * 文字
+   */
+  text?: string,
+  /**
+   * 支持 default、primary、success、warning、danger 五种内置颜色类型
+   * @default 'default'
+   */
+  type?: TagTypes,
+  /**
+   * 设置 plain 属性设置为空心样式。设置 light 属性设置为浅色样式。
+   * @default 'default'
+   */
+  scheme?: 'plain'|'light'|'default',
+  /**
+   * 形状 通过 square 设置方形,通过 round 设置圆形, mark设置标记样式(半圆角)。
+   * @default 'round'
+   */
+  shape?: 'square'|'round'|'mark',
+  /**
+   * 是否可以被关闭
+   * @default false
+   */
+  closeable?: boolean,
+  /**
+   * 尺寸. 支持 large、medium、small 三种尺寸。
+   * @default 'medium'
+   */
+  size?: TagSizes,
+  /**
+   * 自定义文字的颜色。
+   */
+  textColor?: string;
+  /**
+   * 圆角大小,仅在 shape=round 或者 shape=mark 时有效。
+   * @default 10
+   */
+  borderRadius?: number|string;
+  /**
+   * 自定义颜色。
+   */
+  color?: string;
+  /**
+   * 自定义样式
+   */
+  innerStyle?: ViewStyle,
+  /**
+   * 是否可点击
+   */
+  touchable?: boolean,
+}
+
+defineOptions({
+  options: {
+    virtualHost: true,
+    styleIsolation: "shared",
+  },
+});
+const emit = defineEmits([ 'close', 'click' ]);
+
+const themeContext = useTheme();
+
+const props = withDefaults(defineProps<TagProps>(), {
+  shape: () => propGetThemeVar('TagShape', 'round'),
+  type: () => propGetThemeVar('TagType', 'default'),
+  scheme: () => propGetThemeVar('TagScheme', 'default'),
+  size: () => propGetThemeVar('TagSize', 'medium'),
+  borderRadius: () => propGetThemeVar('TagBorderRadius', 20),
+  textColor: () => propGetThemeVar('TagTextColor'),
+});
+
+const FonstSizes = {
+  mini: themeContext.resolveThemeSize('TagSmallFonstSize', 22),
+  small: themeContext.resolveThemeSize('TagSmallFonstSize', 26),
+  medium: themeContext.resolveThemeSize('TagMediumFonstSize', 30),
+  large: themeContext.resolveThemeSize('TagLargeFonstSize', 36),
+  larger: themeContext.resolveThemeSize('TagLargeFonstSize', 48),
+};
+
+const themeStyles = themeContext.useThemeStyles({
+  title: {
+    fontSize: DynamicSize('TagFontSize', 24),
+  },
+  plainTagDefault: {
+    borderStyle: 'solid',
+    borderWidth: DynamicSize('TagBorderWidth', 2),
+    borderColor: DynamicColor('TagPlainDefaultBorderColor', 'text.content'),
+    color: DynamicColor('TagPlainDefaultColor', 'text.content'),
+  },
+  plainTagPrimary: {
+    borderStyle: 'solid',
+    borderWidth: DynamicSize('TagBorderWidth', 2),
+    borderColor: DynamicColor('TagPlainPrimaryBorderColor', 'primary'),
+    color: DynamicColor('TagPlainPrimaryColor', 'primary'),
+  },
+  plainTagSuccess: {
+    borderStyle: 'solid',
+    borderWidth: DynamicSize('TagBorderWidth', 2),
+    borderColor: DynamicColor('TagPlainSuccessBorderColor', 'success'),
+    color: DynamicColor('TagPlainSuccessColor', 'success'),
+  },
+  plainTagWarning: {
+    borderStyle: 'solid',
+    borderWidth: DynamicSize('TagBorderWidth', 2),
+    borderColor: DynamicColor('TagPlainWarningBorderColor', 'warning'),
+    color: DynamicColor('TagPlainWarningColor', 'warning'),
+  },
+  plainTagDanger: {
+    borderStyle: 'solid',
+    borderWidth: DynamicSize('TagBorderWidth', 2),
+    borderColor: DynamicColor('TagPlainDangerBorderColor', 'danger'),
+    color: DynamicColor('TagPlainDangerColor', 'danger'),
+  },
+  lightTagDefault: {
+    backgroundColor: DynamicColor('TagLightDefaultBackgroundColor', 'background.button'),
+    color: DynamicColor('TagLightDefaultColor', 'text.content'),
+  },
+  lightTagPrimary: {
+    backgroundColor: DynamicColor('TagLightPrimaryBackgroundColor', 'background.primary'),
+    color: DynamicColor('TagLightPrimaryColor', 'primary'),
+  },
+  lightTagSuccess: {    
+    color: DynamicColor('TagLightSuccessColor', 'success'),
+    backgroundColor: DynamicColor('TagLightSuccessBackgroundColor', 'background.success'),
+  },
+  lightTagWarning: {
+    backgroundColor: DynamicColor('TagLightWarningBackgroundColor', 'background.warning'),
+    color: DynamicColor('TagLightWarningColor', 'warning'),
+  },
+  lightTagDanger: {
+    backgroundColor: DynamicColor('TagLightDangerBackgroundColor', 'background.danger'),
+    color: DynamicColor('TagLightDangerColor', 'danger'),
+  },
+  tagDefault: {
+    backgroundColor: DynamicColor('TagDefaultBackgroundColor', 'button'),
+    color: DynamicColor('TagDefaultColor', 'black'),
+  },
+  tagPrimary: {
+    backgroundColor: DynamicColor('TagPrimaryBackgroundColor', 'primary'),
+    color: DynamicColor('TagPrimaryColor', 'white'),
+  },
+  tagSuccess: {
+    backgroundColor: DynamicColor('TagSuccessBackgroundColor', 'success'),
+    color: DynamicColor('TagSuccessColor', 'white'),
+  },
+  tagWarning: {
+    backgroundColor: DynamicColor('TagWarningBackgroundColor', 'warning'),
+    color: DynamicColor('TagWarningColor', 'white'),
+  },
+  tagDanger: {
+    backgroundColor: DynamicColor('TagDangerBackgroundColor', 'danger'),
+    color: DynamicColor('TagDangerColor', 'white'),
+  },
+  tagSizeLarger: {
+    padding: DynamicSize2('TagSizeLargePaddingVertical', 'TagSizeLargePaddingHorizontal', 20, 25),
+  },
+  tagSizeLarge: {
+    padding: DynamicSize2('TagSizeLargePaddingVertical', 'TagSizeLargePaddingHorizontal', 14, 20),
+  },
+  tagSizeMedium: {
+    padding: DynamicSize2('TagSizeMediumPaddingVertical', 'TagSizeMediumPaddingHorizontal', 9, 15),
+  },
+  tagSizeSmall: {
+    padding: DynamicSize2('TagSizeSmallPaddingVertical', 'TagSizeSmallPaddingHorizontal', 6, 10),
+  },
+  tagSizeMini: {
+    padding: DynamicSize2('TagSizeSmallPaddingVertical', 'TagSizeSmallPaddingHorizontal', 3, 5),
+  },
+});
+
+const style = computed(() => {
+  const borderRadius = themeContext.resolveThemeSize(props.borderRadius);
+  const color = themeContext.resolveThemeColor(props.color);
+  const style = {
+    ...selectStyleType(props.shape, 'round', {
+      round: { borderRadius: borderRadius },
+      square: {},
+      mark: {
+        borderTopRightRadius: borderRadius,
+        borderBottomRightRadius: borderRadius,
+      },
+    }),
+    ...selectStyleType<ViewStyle, TagTypes>(props.type, 'default', 
+      selectStyleType(props.scheme, 'default', {
+        default: {
+          default: themeStyles.tagDefault.value,
+          primary: themeStyles.tagPrimary.value,
+          success: themeStyles.tagSuccess.value,
+          warning: themeStyles.tagWarning.value,
+          danger: themeStyles.tagDanger.value,
+        },
+        plain: {
+          default: themeStyles.plainTagDefault.value,
+          primary: themeStyles.plainTagPrimary.value,
+          success: themeStyles.plainTagSuccess.value,
+          warning: themeStyles.plainTagWarning.value,
+          danger: themeStyles.plainTagDanger.value,
+        },
+        light: {
+          default: themeStyles.lightTagDefault.value,
+          primary: themeStyles.lightTagPrimary.value,
+          success: themeStyles.lightTagSuccess.value,
+          warning: themeStyles.lightTagWarning.value,
+          danger: themeStyles.lightTagDanger.value,
+        },
+      }),
+    ),
+    ...selectStyleType<ViewStyle, TagSizes>(props.size, 'medium', {
+      mini: themeStyles.tagSizeMini.value,
+      small: themeStyles.tagSizeSmall.value,
+      medium: themeStyles.tagSizeMedium.value,
+      large: themeStyles.tagSizeLarge.value,
+      larger: themeStyles.tagSizeLarger.value,
+    }),
+    
+  } as ViewStyle;
+  if (color !== undefined) {
+    if (props.scheme === 'plain') {
+      style.borderColor = color;
+      style.color = color;
+    } else if (props.scheme === 'light') {
+      style.color = color;
+    } else {
+      style.backgroundColor = color;
+    }
+  }
+  return style
+});
+</script>
+
+<style>
+.nana-tag {
+  display: flex;
+  flex: 0;
+  flex-shrink: 0;
+  flex-grow: 0;
+  flex-basis: auto;
+  min-width: 20rpx;
+  width: auto;
+  align-self: flex-start;
+}
+
+</style>

+ 75 - 0
src/components/display/TextEllipsis.vue

@@ -0,0 +1,75 @@
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+import type { TextProps } from '../basic/Text.vue';
+import Text from '../basic/Text.vue';
+import FlexCol from '../layout/FlexCol.vue';
+
+export interface TextEllipsisProps extends TextProps {
+  lines?: number;
+  expandable?: boolean;
+  startOpen?: boolean;
+  openText?: string;
+  closeText?: string;
+}
+
+
+const emit = defineEmits(['expand', 'collapse']);
+const props = withDefaults(defineProps<TextEllipsisProps>(), {
+  lines: 1,
+  startOpen: false,
+  expandable: false,
+  openText: '展开',
+  closeText: '收起',
+});
+
+const open = ref(props.startOpen);
+const currentLines = computed(() => {
+  if (open.value)
+    return undefined;
+  return props.lines;
+});
+
+function handleClick() {
+  open.value = !open.value;
+  emit(open.value ? 'expand' : 'collapse');
+}
+
+defineOptions({
+  options: {
+    virtualHost: true,
+    styleIsolation: "shared",
+  },
+})
+</script>
+
+<template>
+  <FlexCol>
+    <Text 
+      :ellipsis="true" 
+      :lines="currentLines"
+      v-bind="$attrs"
+    >
+      <slot>
+        {{ text }}
+      </slot>
+    </Text>
+    <slot v-if="expandable" name="button" :onClick="handleClick">
+      <Text 
+        innerClass="nana-text-ellipsis-expand"
+        touchable
+        :text="open ? closeText : openText"
+        color="primary" 
+        @click="handleClick"
+      />
+    </slot>
+  </FlexCol>
+</template>
+
+<style>
+.nana-text-ellipsis-expand {
+  /* #ifndef APP-NVUE */
+  display: inline-block;
+  /* #endif */
+  text-align: right;
+}
+</style>

+ 241 - 0
src/components/display/Watermark.vue

@@ -0,0 +1,241 @@
+<template>
+  <canvas 
+    :id="id"
+    :canvas-id="id"
+    :class="[
+      'nana-watermark',
+      {'nana-watermark-full-page': props.fullPage}
+    ]"
+    :style="style"
+    :width="`${measuredWidth}px`"
+    :height="`${measuredHeight}px`"
+  />
+</template>
+
+<script setup lang="ts">
+import { computed, getCurrentInstance, nextTick, onMounted, ref, watch } from 'vue';
+import { propGetThemeVar, useTheme } from '../theme/ThemeDefine';
+import { RandomUtils } from '@imengyu/imengyu-utils';
+
+export interface WatermarkProps {
+  /**
+   * 水印组件宽度(px)。默认占满父容器
+   */
+  width?: number,
+  /**
+   * 水印组件高度(px)。默认占满父容器
+   */
+  height?: number,
+  /**
+   * 水印文字字体
+   * @default '14px sans-serif'
+   */
+  contentFont?: string,
+  /**
+   * 水印文字宽度
+   * @default 100
+   */
+  contentWidth?: number,
+  /**
+   * 水印文字高度
+   * @default 100
+   */
+  contentHeight?: number,
+  /**
+   * 水印文字水平间距(px)
+   * @default 10
+   */
+  offsetX?: number,
+  /**
+   * 水印文字垂直间距(px)
+   * @default 10
+   */
+  offsetY?: number,
+  /**
+   * 水印组件z-index
+   * @default 999
+   */
+  zIndex?: number | string,
+  /**
+   * 水印内容
+   */
+  content?: string,
+  /**
+   * 水印字体颜色
+   * @default 'text.second'
+   */
+  contentColor?: string,
+  /**
+   * 水印图片
+   * @remark 小程序似乎不能使用本地图片,需要使用网络图片地址。
+   */
+  image?: string,
+  /**
+   * 水印旋转角度(deg)
+   * @default -22
+   */
+  rotate?: number,
+  /**
+   * 水印组件是否全屏
+   */
+  fullPage?: boolean,
+  /**
+   * 水印组件水平间距(px)
+   * @default 10
+   */
+  gapX?: number,
+  /**
+   * 水印组件垂直间距(px)
+   * @default 10
+   */
+  gapY?: number,
+  /**
+   * 水印透明度
+   * @default 0.8
+   */
+  opacity?: number,
+}
+
+const id = `watermarkCanvas${RandomUtils.genNonDuplicateID(10)}`;
+const instance = getCurrentInstance();
+const props = withDefaults(defineProps<WatermarkProps>(), {
+  zIndex: 999,
+  content: '',
+  contentFont: () => propGetThemeVar('WatermarkContentFont', '14px Arial'),
+  contentWidth: () => propGetThemeVar('WatermarkContentWidth', 100),
+  contentHeight: () => propGetThemeVar('WatermarkContentHeight', 60),
+  image: '',
+  rotate: () => propGetThemeVar('WatermarkRotate', -22),
+  fullPage: false,
+  offsetX: 0,
+  offsetY: 0,
+  gapX: () => propGetThemeVar('WatermarkGapX', 30),
+  gapY: () => propGetThemeVar('WatermarkGapY', 40),
+  contentColor: () => propGetThemeVar('WatermarkTextColor', 'text.second'),
+  opacity: () => propGetThemeVar('WatermarkOpacity', 0.4),
+});
+
+const measuredWidth = ref(0);
+const measuredHeight = ref(0);
+
+const theme = useTheme();
+const style = computed(() => ({
+  width: measuredWidth.value ? `${measuredWidth.value}px` : undefined,
+  height: measuredHeight.value ? `${measuredHeight.value}px` : undefined,
+  zIndex: props.zIndex,
+  opacity: props.opacity,
+}));
+
+const ctx = uni.createCanvasContext(id, instance);
+
+async function drawWatermark() {
+
+  // 设置 canvas 的尺寸为单个水印的尺寸
+  const w = props.contentWidth;
+  const h = props.contentHeight;
+  const ch = h + props.gapY;
+  const cw = w + props.gapX;
+
+  let vh = props.height!;
+  let vw = props.width!;
+
+  await nextTick();
+  await new Promise<void>((resolve) => {
+    if (!vh || !vw) {
+      uni.createSelectorQuery()
+        // #ifndef H5
+        .in(instance)
+        // #endif
+        .select(`#${id}`)
+        .boundingClientRect().exec((res) => {
+          if (res[0]) {
+            if (!vh)
+              vh = res[0].height;
+            if (!vw)
+              vw = res[0].width;
+          }
+          resolve();
+        });
+    }
+  });
+
+  measuredWidth.value = vw;
+  measuredHeight.value = vh;
+
+  // 清除 canvas
+  ctx.clearRect(0, 0, vw, vh);
+
+  if (props.image) {
+    // --- 绘制图片水印 ---
+    await new Promise<void>((resolve) => {
+      console.log(props.image);
+      
+      uni.getImageInfo({
+        src: props.image,
+        success(res) {
+          for (let i = props.offsetX; i <= vw; i += cw) {
+            for (let j = props.offsetY; j <= vh; j += ch) {
+              ctx.save();
+              ctx.translate(i + w / 2, j + h / 2);
+              ctx.rotate(props.rotate * Math.PI / 180);
+              ctx.drawImage(res.path, -w / 2, -h / 2, w, res.height / res.width * w);
+              ctx.restore();
+            }
+          }
+          ctx.draw();
+          resolve();
+        },
+        fail:(fail)=>{
+          console.error('绘制水印图片失败', fail);
+          resolve();
+        },
+
+      });
+    }); 
+  } else if (props.content) {
+    ctx.setTextAlign('center');
+    ctx.setTextBaseline('middle');
+    ctx.setFillStyle(theme.resolveThemeColor(props.contentColor)!);
+    ctx.font = props.contentFont;  
+
+    for (let i = props.offsetX; i < vw; i += cw) {
+      for (let j = props.offsetY; j < vh; j += ch) {
+        ctx.save();
+        ctx.translate(i + w / 2, j + h / 2);
+        ctx.rotate(props.rotate * Math.PI / 180);
+        ctx.fillText(props.content, 0, 0, w);
+        ctx.restore();
+      }
+    }
+    ctx.draw();
+  }
+}
+watch(() => [
+  props.content,
+  props.contentWidth,
+  props.contentHeight,
+  props.gapX,
+  props.gapY,
+  props.rotate,
+  props.contentColor,
+], () => {
+  drawWatermark();
+});
+onMounted(() => {
+  drawWatermark();
+});
+</script>
+
+<style>
+.nana-watermark {
+  position: absolute;
+  pointer-events: none;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+}
+.nana-watermark-full-page {
+  position: fixed;
+}
+</style>

+ 191 - 0
src/components/display/block/BackgroundBox.vue

@@ -0,0 +1,191 @@
+<template>
+  <!-- 组件:背景图显示盒子 -->
+  <FlexView
+    center
+    :flexShrink="0"
+    v-bind="$props"
+    :innerStyle="style" 
+  >
+    <slot />
+  </FlexView>
+</template>
+
+<script lang="ts">
+/**
+ * 组件说明: 背景盒子,用于显示背景图片、颜色、渐变等。
+ * 
+ * @example
+ * 显示背景图片:
+ * <BackgroundBox
+ *   width="fill"
+ *   :height="85"
+ *   backgroundImage="http://assets/images/footer/FooterBackground.png"
+ *   backgroundFillType="fillW"
+ *   backgroundSize="100%"
+ *   backgroundPosition="top"
+ * />
+ * 
+ * 显示渐变色:
+ * <BackgroundBox
+ *   width="fill"
+ *   :height="85"
+ *   color1="#111111"
+ *   color2="#222222"
+ *   gradientAngle="180"
+ * />
+ */
+export default {}
+</script>
+
+<script setup lang="ts">
+import FlexView 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({
+  /**
+   * 背景颜色(1)。
+   * 
+   * 格式:字符串格式或主题中定义的 background 颜色预设。
+   */
+  color1: {
+    type: String,
+    default: undefined
+  },
+  /**
+   * 背景颜色(2)。
+   * 
+   * 格式:字符串格式或主题中定义的 background 颜色预设。
+   */
+  color2: {
+    type: String,
+    default: undefined
+  },
+  /**
+   * 圆角。
+   */
+  radius: {
+    type: [String, Number],
+    default: undefined
+  },
+  /**
+   * 背景渐变角度。
+   * 只有 color1 和 color2 都定义时有效。
+   *
+   * 格式:角度(0-360)。
+   */
+  gradientAngle: {
+    type: Number,
+    default: undefined
+  },
+  /**
+   * 背景图片。
+   */
+  backgroundImage: {
+    type: String,
+    default: undefined
+  },
+  /**
+   * 背景填充方式。
+   *
+   * 格式:
+   * - fillW:横向填充,高度变化。
+   * - fillH:纵向填充,宽度变化。
+   * - none:不填充。
+   */
+  backgroundFillType: {
+    type: String as PropType<'none'|'fillH'|'fillW'>,
+    default: "fillW"
+  },
+  /**
+   * 背景填充大小。
+   */
+  backgroundSize: {
+    type: String,
+    default: "100%"
+  },
+  /**
+   * 背景填充位置。
+   */
+  backgroundPosition: {
+    type: String,
+    default: undefined
+  },
+  /**
+   * 背景图片九宫格裁剪大小。
+   *
+   * 格式:
+   * - 数组:[ top, right, bottom, left ]
+   */
+  backgroundCutBorder: {
+    type: Object as PropType<Array<number|string>>,
+    default: undefined
+  },
+  /**
+   * 背景图片九宫格渲染大小。
+   *
+   * 格式:
+   * - 数组:[ top, right, bottom, left ]
+   */
+  backgroundCutBorderSize: {
+    type: Object as PropType<Array<number|string>>,
+    default: () => ([ 'auto' ])
+  },
+});
+
+const theme = useTheme();
+
+const style = computed(() => {
+  const o : ViewStyle = {}
+  if (props.radius) {
+    o.borderRadius = theme.resolveThemeSize(props.radius);
+  }
+  if (props.color1 !== undefined && props.color2 !== undefined) {
+    o.background = `linear-gradient(${props.gradientAngle || 180}deg, ${theme.resolveThemeColor(props.color1)}, ${theme.resolveThemeColor(props.color2)})`;
+
+  } else if (props.backgroundImage) {
+    const b = props.backgroundCutBorder;
+    const s = props.backgroundCutBorderSize;
+    if (b) {
+      o.borderImageSource = solveUrl(props.backgroundImage);
+      o.borderImageSlice = `${b[0]} ${b[1]} ${b[2]} ${b[3]} fill`;
+      o.borderImageWidth = `${theme.resolveSize(s[0])} ${theme.resolveSize(s[1])} ${theme.resolveSize(s[2])} ${theme.resolveSize(s[3])}`;
+      o.borderImageRepeat = 'stretch';
+    } else {
+      o.backgroundImage = solveUrl(props.backgroundImage);
+      o.backgroundPosition = props.backgroundPosition;
+      o.backgroundRepeat = "no-repeat";
+      switch (props.backgroundFillType) {
+        case 'fillW':
+          o.backgroundSize = `${props.backgroundSize} auto`;
+          break;
+        case 'fillH':
+          o.backgroundSize = `auto ${props.backgroundSize}`;
+          break;
+        case 'none':
+          o.backgroundSize = `${props.backgroundSize}`;
+          break;
+      }
+    }
+  } else if (props.color1) {
+    o.backgroundColor = theme.resolveThemeColor(props.color1);
+  } else if (props.color2) {
+    o.backgroundColor = theme.resolveThemeColor(props.color2);
+  }
+  return o;
+})
+</script>
+
+<style lang="scss">
+</style>

+ 50 - 0
src/components/display/block/IconTextBlock.vue

@@ -0,0 +1,50 @@
+<script setup lang="ts">
+import Icon from '@/components/basic/Icon.vue';
+import Text from '@/components/basic/Text.vue';
+import FlexRow from '@/components/layout/FlexRow.vue';
+import type { IconProps } from '@/components/basic/Icon.vue';
+import type { TextProps } from '@/components/basic/Text.vue';
+import type { PropType } from 'vue';
+
+defineOptions({
+  options: {
+    virtualHost: true,
+    styleIsolation: "shared",
+  },
+});
+defineProps({
+  icon: {
+    type: String,
+    default: '',
+  },
+  iconProps: {
+    type: Object as PropType<IconProps>,
+    default: () => ({
+      color: 'text.content',
+      size: 30,
+    }),
+  },
+  text: {
+    type: [String,Number],
+    default: '',
+  },
+  textLines: {
+    type: Number,
+    default: 1,
+  },
+  textProps: {
+    type: Object as PropType<TextProps>,
+    default: () => ({
+      color: 'text.content',
+      fontSize: '30rpx',
+    }),
+  },
+})
+</script>
+
+<template>
+  <FlexRow gap="10" align="center">
+    <Icon v-bind="iconProps" :icon="icon"  />
+    <Text :lines="textLines" v-bind="textProps" :text="text" />
+  </FlexRow>
+</template>

+ 132 - 0
src/components/display/block/ImageBlock.vue

@@ -0,0 +1,132 @@
+<template>
+  <Touchable
+    touchable
+    position="relative"
+    overflow="hidden"
+    v-bind="$props"
+    :flexShrink="0"
+    :innerStyle="{ borderRadius: theme.resolveThemeSize(radius), overflow: 'hidden', }"
+    :width="width"
+    :height="height"
+    @click="$emit('click')"
+  >
+    <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]"
+    >
+      <slot name="desc">
+        <text class="nana-image-desc">{{ desc }}</text>
+      </slot>
+    </BackgroundBox>
+  </Touchable>
+</template>
+
+<script lang="ts">
+/**
+ * 组件说明:图片块。
+ * 
+ * 该组件可以用于显示图片,支持在图片下方显示描述。
+ *
+ * 该组件的默认高度为 200rpx。
+ */
+export default {}
+</script>
+
+<script setup lang="ts">
+import { useTheme } from '@/components/theme/ThemeDefine';
+import BackgroundBox from './BackgroundBox.vue';
+import VideoMark from '/static/images/VideoMark.png';
+import Touchable from '@/components/feedback/Touchable.vue';
+
+const theme = useTheme();
+
+defineOptions({
+  options: {
+    virtualHost: true,
+    styleIsolation: "shared",
+  },
+});
+defineProps({	
+  /**
+   * 宽度。
+   */
+  width: {
+    type: [ String, Number ],
+    default: 200
+  },
+  /**
+   * 高度。
+   */
+  height: {
+    type: [ String, Number ],
+    default: 100
+  },
+  /**
+   * 图片的路径。
+   */
+  src: {
+    type: String,
+    default: null
+  },
+  /**
+   * 图片的圆角。
+   */
+  radius: {
+    type: [ String, Number ],
+    default: undefined
+  },
+  /**
+   * 图片下方显示描述。
+   */
+  desc: {
+    type: String,
+    default: null
+  },
+  /**
+   * 是否显示播放视频标记。
+   */
+  videoMark: {
+    type: Boolean,
+    default: false
+  },
+})
+
+defineEmits([	
+  "click"	
+])
+</script>
+
+<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;
+}
+</style>

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

@@ -0,0 +1,94 @@
+<template>
+  <Touchable
+    touchable
+    backgroundColor="white"
+    overflow="hidden"
+    v-bind="$props"
+    :flexShrink="0"
+    :innerStyle="{ borderRadius: theme.resolveThemeSize(radius), overflow: 'hidden', }"
+    :width="width"
+    @click="$emit('click')"
+  >
+    <Image 
+      :src="src" 
+      width="100%"
+      :height="imageHeight"
+      mode="aspectFill"
+    />
+    <slot name="desc">
+      <Text class="nana-image-desc">{{ desc }}</Text>
+    </slot>
+  </Touchable>
+</template>
+
+<script lang="ts">
+/**
+ * 组件说明:图片块2。
+ * 
+ * 该组件可以用于显示图片,支持在图片下方显示描述。
+ *
+ * 该组件的默认高度为 200rpx。
+ */
+export default {}
+</script>
+
+<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';
+
+const theme = useTheme();
+
+defineProps({	
+  /**
+   * 宽度。
+   */
+  width: {
+    type: [ String, Number ],
+    default: 400
+  },
+  /**
+   * 高度。
+   */
+  imageHeight: {
+    type: [ String, Number ],
+    default: 250
+  },
+  /**
+   * 图片的路径。
+   */
+  src: {
+    type: String,
+    default: null
+  },
+  /**
+   * 图片的圆角。
+   */
+  radius: {
+    type: [ String, Number ],
+    default: undefined
+  },
+  /**
+   * 图片下方显示描述。
+   */
+  desc: {
+    type: String,
+    default: null
+  },
+})
+
+defineEmits([	
+  "click"	
+])
+</script>
+
+<style lang="scss">
+.nana-image-desc {
+  color: #fff;
+  font-size: 25rpx;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+</style>

+ 99 - 0
src/components/display/block/ImageBlock3.vue

@@ -0,0 +1,99 @@
+<template>
+  <Touchable
+    touchable
+    backgroundColor="white"
+    direction="row"
+    overflow="hidden"
+    v-bind="$props"
+    :flexShrink="0"
+    :innerStyle="{ borderRadius: theme.resolveThemeSize(radius), overflow: 'hidden', }"
+    @click="$emit('click')"
+  >
+    <Image 
+      :src="src" 
+      :width="imageWidth"
+      :height="imageHeight"
+      :radius="radius"
+      round
+      mode="aspectFill"
+    />
+    <FlexView direction="column">
+      <slot name="desc">
+        <Text class="nana-image-desc">{{ desc }}</Text>
+      </slot>
+    </FlexView>
+  </Touchable>
+</template>
+
+<script lang="ts">
+/**
+ * 组件说明:图片块3。
+ * 
+ * 该组件可以用于显示图片,左边图片,右边描述。
+ *
+ * 该组件的默认高度为 200rpx。
+ */
+export default {}
+</script>
+
+<script setup lang="ts">
+import { useTheme } from '@/components/theme/ThemeDefine';
+import FlexView from '../../layout/FlexView.vue';
+import Image from '../../basic/Image.vue';
+import Text from '../../basic/Text.vue';
+import Touchable from '@/components/feedback/Touchable.vue';
+
+const theme = useTheme();
+
+defineProps({	
+  /**
+   * 宽度
+   */
+  imageWidth: {
+    type: [ String, Number ],
+    default: 150
+  },
+  /**
+   * 高度。
+   */
+  imageHeight: {
+    type: [ String, Number ],
+    default: 150
+  },
+  /**
+   * 图片的路径。
+   */
+  src: {
+    type: String,
+    default: null
+  },
+  /**
+   * 图片的圆角。
+   */
+  radius: {
+    type: [ String, Number ],
+    default: undefined
+  },
+  /**
+   * 图片下方显示描述。
+   */
+  desc: {
+    type: String,
+    default: null
+  },
+})
+
+defineEmits([	
+  "click"	
+])
+</script>
+
+<style lang="scss">
+.nana-image-desc {
+  color: #fff;
+  font-size: 25rpx;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+</style>

+ 207 - 0
src/components/display/block/TextBlock.vue

@@ -0,0 +1,207 @@
+<script lang="ts">
+/**
+ * 组件说明: 文本块。
+ * 
+ * 可设置左右文字,左侧文字支持双行。
+ * 
+ * 此组件适用于需要展示多行文本信息并具备一定交互性和布局灵活性的场景,如列表项、信息展示框等。
+ */
+export default {}
+</script>
+
+<script setup lang="ts">
+import FlexCol from '../../layout/FlexCol.vue';
+import FlexRow from '../../layout/FlexRow.vue';
+import Text, { type TextProps } from '../../basic/Text.vue';
+import type { PropType } from 'vue';
+import Touchable from '@/components/feedback/Touchable.vue';
+
+defineEmits([ 'click' ])
+defineProps({	
+  /**
+   * 主文本内容
+   * @type {string}
+   * @default ''
+   */
+  text : {
+    type: String,
+    default: '',
+  },
+  /**
+   * 主文本的样式类名
+   * @type {string}
+   * @default 'contentLight'
+   */
+  textProps : {
+    type: Object as PropType<TextProps>,
+    default: () => ({
+      color: 'text.second',
+      fontSize: 26,
+    }),
+  },
+  /**
+   * 副文本内容
+   * @type {string}
+   * @default ''
+   */
+  text2 : {
+    type: String,
+    default: '',
+  },
+  /**
+   * 副文本的样式类名
+   * @type {string}
+   * @default 'contentSecond'
+   */
+  text2Props : {
+    type: Object as PropType<TextProps>,
+    default: () => ({
+      color: 'text.content',
+      fontSize: 30,
+    }),
+  },
+  /**
+   * 前缀文本内容
+   * @type {string}
+   * @default ''
+   */
+  prefix : {
+    type: String,
+    default: '',
+  },
+  /**
+   * 前缀文本样式类名
+   * @type {string}
+   * @default 'contentSecond'
+   */
+  prefixProps : {
+    type: Object as PropType<TextProps>,
+    default: () => ({
+      color: 'text.content',
+    }),
+  },
+  /**
+   * 后缀文本内容
+   * @type {string}
+   * @default ''
+   */
+  suffix : {
+    type: String,
+    default: '',
+  },
+  /**
+   * 后缀文本的样式类名
+   * @type {string}
+   * @default 'contentSecond'
+   */
+  suffixProps : {
+    type: Object as PropType<TextProps>,
+    default: () => ({
+      color: 'text.content',
+    }),
+  },
+  /**
+   * 传递给根容器的额外属性对象
+   * @type {Object}
+   * @default undefined
+   */
+  viewProps: {
+    type: Object,
+    default: undefined
+  },
+  /**
+   * 标识文本块是否可点击
+   * @type {boolean}
+   * @default false
+   */
+  touchable: {
+    type: Boolean,
+    default: false,
+  },
+  /**
+   * 标识文本是否换行显示
+   * @type {boolean}
+   * @default false
+   */
+  wrap: {
+    type: Boolean,
+    default: false,
+  },
+})
+
+</script>
+
+<template>
+  <Touchable
+    :touchable="touchable"
+    justify="space-between"
+    align="center"
+    direction="row"
+    v-bind="viewProps"
+    @click="$emit('click')"
+  >
+    <slot name="prefix">
+      <Text v-if="prefix" class="nana-text-prefix" v-bind="prefixProps">{{ prefix }}</Text>
+    </slot>
+    <slot>
+      <FlexCol v-if="text2" class="nana-text">
+        <Text 
+          :class="[
+            'nana-text',
+            wrap ? 'wrap' : '',
+          ]" 
+          v-bind="textProps"
+        >
+          {{ text }}
+        </Text>
+        <Text
+          :class="[
+            'nana-text',
+            wrap ? 'wrap' : '',
+          ]" 
+           v-bind="text2Props"
+        >
+          {{ text2 }}
+        </Text>
+      </FlexCol>
+      <Text 
+        v-else
+        :class="[
+          'nana-text',
+          wrap ? 'wrap' : '',
+        ]" 
+        :v-bind="textProps"
+      >
+        {{ text }}
+      </Text>
+    </slot>
+    <slot name="suffix">
+      <Text class="nana-text-suffix" v-bind="suffixProps">{{ suffix }}</Text>
+    </slot>
+  </Touchable>
+</template>
+
+<style lang="scss">
+.nana-text {
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  flex: 1 1 100%;
+  max-width: 100%;
+
+  &.wrap {
+    white-space: normal;
+  }
+}
+.nana-text-prefix {
+  flex-shrink: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  margin-right: 20rpx;
+}
+.nana-text-suffix {
+  flex-shrink: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+</style>

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

@@ -0,0 +1,162 @@
+<script lang="ts">
+/**
+ * 组件说明: 文本块。
+ * 
+ * 可设置左右文字,左侧文字支持双行。
+ * 
+ * 此组件适用于需要展示多行文本信息并具备一定交互性和布局灵活性的场景,如列表项、信息展示框等。
+ */
+export default {}
+</script>
+
+<script setup lang="ts">
+import Text, { type TextProps } from '../../basic/Text.vue';
+import type { PropType } from 'vue';
+import Touchable from '@/components/feedback/Touchable.vue';
+
+defineEmits([ 'click' ])
+defineProps({	
+  /**
+   * 主文本内容
+   * @type {string}
+   * @default ''
+   */
+  text : {
+    type: String,
+    default: '',
+  },
+  /**
+   * 主文本的样式类名
+   * @type {string}
+   * @default 'contentLight'
+   */
+  textProps : {
+    type: Object as PropType<TextProps>,
+    default: () => ({
+      color: 'text.content',
+    }),
+  },
+  /**
+   * 副文本内容
+   * @type {string}
+   * @default ''
+   */
+  text2 : {
+    type: String,
+    default: '',
+  },
+  text2Empty : {
+    type: String,
+    default: '暂无',
+  },
+  /**
+   * 副文本的样式类名
+   * @type {string}
+   * @default 'contentSecond'
+   */
+  text2Props : {
+    type: Object as PropType<TextProps>,
+    default: () => ({
+      color: 'text.second',
+    }),
+  },
+  /**
+   * 副文本的最大行数
+   */
+  text2Lines : {
+    type: Number,
+    default: undefined,
+  },
+  /**
+   * 传递给根容器的额外属性对象
+   * @type {Object}
+   * @default undefined
+   */
+  viewProps: {
+    type: Object,
+    default: undefined
+  },
+  /**
+   * 标识文本块是否可点击
+   * @type {boolean}
+   * @default false
+   */
+  touchable: {
+    type: Boolean,
+    default: false,
+  },
+  /**
+   * 标识文本是否换行显示
+   * @type {boolean}
+   * @default false
+   */
+  wrap: {
+    type: Boolean,
+    default: false,
+  },
+})
+
+</script>
+
+<template>
+  <Touchable
+    :touchable="touchable"
+    justify="space-between"
+    align="flex-start"
+    width="fill"
+    direction="row"
+    v-bind="viewProps"
+    @click="$emit('click')"
+  >
+    <Text
+      :class="[
+        'nana-text first',
+        wrap ? 'wrap' : '',
+      ]" 
+      v-bind="textProps"
+      :text="text"
+    />
+    <slot>
+      <Text
+        :class="[
+          'nana-text second',
+          wrap ? 'wrap' : '',
+        ]" 
+        v-bind="text2Props"
+        :lines="text2Lines"
+        :text="text2 || text2Empty"
+      />
+    </slot>
+  </Touchable>
+
+</template>
+
+<style lang="scss">
+.nana-text {
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  flex: 1 1 100%;
+  max-width: 100%;
+
+  &.wrap {
+    white-space: normal;
+  }
+  &.first {
+    flex-shrink: 1;
+    flex-grow: 0;
+    flex-basis: 30%;
+  }
+}
+.nana-text-prefix {
+  flex-shrink: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  margin-right: 20rpx;
+}
+.nana-text-suffix {
+  flex-shrink: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+</style>

+ 92 - 0
src/components/display/countdown/CountDown.vue

@@ -0,0 +1,92 @@
+<template>
+  <slot name="renderText" :time="countdown.current.value">
+    <Text v-bind="$attrs" :text="formatText()" />
+  </slot>
+</template>
+
+<script setup lang="ts">
+import Text, { type TextProps } from '@/components/basic/Text.vue';
+import { useCountDown } from './CountdownHook';
+import { onBeforeUnmount, onMounted, watch } from 'vue';
+import { FormatUtils } from '@imengyu/imengyu-utils';
+
+export interface CountDownProps extends TextProps {
+  /**
+   * 倒计时时长,单位毫秒
+   * @default 0
+   */
+  time?: number;
+  /**
+   * 时间格式
+   * @default 'HH:mm:ss'
+   */
+  format?: string;
+  /**
+   * 是否自动开始倒计时
+   * @default true
+   */
+  autoStart?: boolean;
+  /**
+   * 是否开启毫秒级渲染
+   * @default false
+   */
+  millisecond?: boolean;
+}
+
+export interface CountDownInstance {
+  start: () => void;
+  stop: () => void;
+  reset: (time?: number) => void;
+}
+
+const emit = defineEmits<{
+  /**
+   * 倒计时结束事件
+   */
+  (e: 'finish'): void;
+}>();
+
+const props = withDefaults(defineProps<CountDownProps>(), {
+  time: 0,
+  format: 'HH:mm:ss',
+  autoStart: true,
+  millisecond: false,
+});
+
+
+const countdown = useCountDown({
+  time: props.time || 0,
+  millisecond: props.millisecond || false,
+  onFinish: () => emit('finish'),
+});
+
+watch(() => props.time, () => {
+  countdown.reset(props.time);
+});
+
+onMounted(() => {
+  if (props.autoStart !== false)
+    countdown.start();
+});
+onBeforeUnmount(() => {
+  countdown.stop();
+});
+
+defineExpose<CountDownInstance>({
+  start() { countdown.start(); },
+  stop() { countdown.stop(); },
+  reset(time) { countdown.reset(time); },
+});
+
+function formatText() {
+  let str = props.format ? props.format : "HH:mm:ss";
+  str = str.replace(/DD/, FormatUtils.formatNumberWithZero(countdown.current.value.days, 2));
+  str = str.replace(/HH/, FormatUtils.formatNumberWithZero(countdown.current.value.hours, 2));
+  str = str.replace(/mm/, FormatUtils.formatNumberWithZero(countdown.current.value.minutes, 2));
+  str = str.replace(/ss/, FormatUtils.formatNumberWithZero(countdown.current.value.seconds, 2));
+  str = str.replace(/SSS/, FormatUtils.formatNumberWithZero(countdown.current.value.milliseconds, 3));
+  str = str.replace(/SS/, FormatUtils.formatNumberWithZero(Math.floor(countdown.current.value.milliseconds / 10), 2));
+  str = str.replace(/S/, Math.floor(countdown.current.value.milliseconds / 100).toString());
+  return str;
+}
+</script>

+ 115 - 0
src/components/display/countdown/CountDownButton.vue

@@ -0,0 +1,115 @@
+<template>
+  <slot 
+    name="button" 
+    :loading="loading" 
+    :text="text"
+    :count="count"
+    :touchable="count <= 0"
+    :onClick="onClick"
+  >
+    <Button 
+      v-bind="$attrs" 
+      :text="text" 
+      :loading="loading" 
+      :touchable="count <= 0"
+      @click="onClick"
+    />
+  </slot>
+</template>
+
+<script setup lang="ts">
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
+import { SimpleTimer } from '@imengyu/imengyu-utils';
+import type { TextProps } from '@/components/basic/Text.vue';
+import Button from '@/components/basic/Button.vue';
+
+export interface CountDownButtonProps extends TextProps {
+  /**
+   * 倒计时时长,单位秒
+   * @default 60
+   */
+  time?: number;
+  /**
+   * 保存倒计时状态的key,用于在页面刷新后恢复倒计时状态
+   */
+  saveState?: string;
+  /**
+   * 倒计时时显示的文本
+   * @default '%d 秒后重新获取'
+   */
+  countDownText?: string;
+  /**
+   * 在未倒计时时显示的文本
+   * @default '获取验证码'
+   */
+  defaultText?: string;
+
+  /**
+   * 发送验证码函数
+   */
+  send: () => Promise<void>;
+}
+
+const props = withDefaults(defineProps<CountDownButtonProps>(), {
+  time: 60,
+  countDownText: '%d 秒后重新获取',
+  defaultText: '获取验证码',
+});
+
+const loading = ref(false);
+const count = ref(0);
+
+const text = computed(() => {
+  if (count.value > 0)
+    return props.countDownText.replace('%d', count.value.toString());
+  return props.defaultText;
+});
+
+const timer = new SimpleTimer(undefined, () => {
+  if (count.value <= 0) {
+    timer.stop();
+    loading.value = false;
+    return;
+  }
+  count.value--;
+}, 1000);
+
+function saveState() {
+  if (props.saveState) {
+    uni.setStorageSync('CountDownButtonState' + props.saveState, new Date().getTime());
+  }
+}
+function loadState() {
+  if (props.saveState) {
+    const lastSendTime = uni.getStorageSync('CountDownButtonState' + props.saveState) || 0;
+    count.value = Math.floor(Math.max(0, props.time - (new Date().getTime() - lastSendTime) / 1000));
+    if (count.value > 0)
+      timer.start();
+  }
+}
+
+async function onClick() {
+  if (loading.value || count.value > 0)
+    return;
+
+  loading.value = true;
+  props.send()
+    .then(() => {
+      count.value = props.time || 0;
+      timer.start();
+      saveState();
+    })
+    .finally(() => {
+      loading.value = false;
+    })
+}
+
+
+onMounted(() => {
+  loadState();
+});
+onBeforeUnmount(() => {
+  timer.stop();
+});
+
+</script>

+ 149 - 0
src/components/display/countdown/CountTo.vue

@@ -0,0 +1,149 @@
+<template>
+  <VerticalScrollText 
+    v-if="type === 'scroller'" 
+    :numberString="finalString"
+    :animDuration="duration"
+    v-bind="props" 
+  />
+  <Text v-else v-bind="props" :text="finalString" />
+</template>
+
+<script setup lang="ts">
+import Text, { type TextProps } from '@/components/basic/Text.vue';
+import VerticalScrollText from '@/components/typography/VerticalScrollText.vue';
+import { FormatUtils } from '@imengyu/imengyu-utils';
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
+
+export interface CountToProps extends TextProps {
+  /**
+   * 开始值
+   * @default 0
+   */
+  startValue?: number;
+  /**
+   * 结束值
+   */
+  endValue: number;
+  /**
+   * 持续时间
+   * @default 3000
+   */
+  duration?: number;
+  /**
+   * 是否将数字转换为千分符
+   * @default false
+   */
+  thousand?: boolean;
+  /**
+   * 数字如果不足该位数,则在前面补0
+   */
+  numberCount?: number,
+  /**
+   * 保留小数位数
+   * @default 0
+   */
+  decimalCount?: number,
+  /**
+   * 效果类型。默认:text
+   * * text 普通文字切换效果
+   * * scroller 文字上下滚动效果
+   */
+  type?: 'text'|'scroller';
+}
+
+export interface CountToInstance {
+  restart: () => void;
+}
+
+const props = withDefaults(defineProps<CountToProps>(), {
+  startValue: 0,
+  endValue: 0,
+  duration: 3000,
+  thousand: false,
+  numberCount: 0,
+  decimalCount: 0,
+  type: 'text',
+});
+
+const value = ref(0);
+const interval = ref(0);
+const speed = computed(() => 
+  Math.max(1, Math.floor(Math.abs(props.startValue - props.endValue) / (props.duration / 50)))
+);
+
+function startAnim() {
+  if (interval.value > 0)
+    clearInterval(interval.value);
+  if (props.startValue < props.endValue) {
+    //+
+    interval.value = setInterval(() => {   
+      if (value.value >= props.endValue) {
+        clearInterval(interval.value);
+        interval.value = 0;
+        value.value = props.endValue;
+      }
+      else
+        value.value = value.value + speed.value;
+    }, 50) as unknown as number;
+  } else {
+    //-
+    interval.value = setInterval(() => {
+        if (value.value <= props.endValue) {
+          clearInterval(interval.value);
+          interval.value = 0;
+          value.value = props.endValue;
+        }
+        else
+          value.value = value.value - speed.value;
+    }, 50) as unknown as number;
+  }
+}
+
+const finalString = computed(() => {
+  let valueString = props.decimalCount > 0 ? value.value.toFixed(props.decimalCount) : value.value.toString();
+  if (valueString.length < props.numberCount)
+    valueString = FormatUtils.formatNumberWithZero(
+      Math.floor(value.value), 
+      props.numberCount
+    ) + (valueString.split('.')[1] ?? '');
+  
+    //转为文字
+  return (props.thousand ? FormatUtils.formatNumberWithComma(valueString) : valueString);
+})
+
+function loadAnim() {
+  if (props.type === 'text') {
+    value.value = props.startValue;
+    startAnim();
+  } else {
+    value.value = props.endValue;
+  }
+}
+
+watch(() => [ props.startValue, props.endValue, props.type ], () => {
+  loadAnim();
+});
+
+onMounted(() => {
+  loadAnim();
+})
+onBeforeUnmount(() => {
+  if (interval.value > 0) {
+    clearInterval(interval.value);
+    interval.value = 0;
+  }
+});
+
+defineExpose<CountToInstance>({
+   restart() {
+    if (props.type === 'text') {
+      value.value = props.startValue;
+      startAnim();
+    } else {
+      value.value = props.startValue;
+      setTimeout(() => value.value = props.endValue, 200);
+    }
+  },
+});
+
+</script>

+ 127 - 0
src/components/display/countdown/CountdownHook.ts

@@ -0,0 +1,127 @@
+import { TimeUtils } from "@imengyu/imengyu-utils";
+import { computed, ref } from "vue";
+
+interface CountDownComposeOptions {
+  /**
+   * 倒计时时长,单位毫秒
+   * @default 0
+   */
+  time: number,
+  /**
+   * 是否开启毫秒级渲染
+   * @default false
+   */
+  millisecond?: boolean,
+  /**
+   * 倒计时改变时触发的回调函数
+   */
+  onChange?: (current: CurrentTime) => void,
+  /**
+   * 倒计时结束时触发的回调函数
+   */
+  onFinish?: () => void,
+}
+export interface CurrentTime {
+  /**
+   * 剩余总时间(单位毫秒)
+   */
+  total: number,
+  /**
+   * 剩余天数
+   */
+  days: number,
+  /**
+   * 	剩余小时
+   */
+  hours: number,
+  /**
+   * 剩余分钟
+   */
+  minutes: number,
+  /**
+   * 剩余秒数
+   */
+  seconds: number,
+  /**
+   * 剩余毫秒
+   */
+  milliseconds: number,
+}
+
+/**
+ * 提供倒计时管理能力的 HOOK。
+ * @param options 参数
+ * @returns 返回一个对象,可以用于控制倒计时启停。
+ */
+export function useCountDown(options: CountDownComposeOptions) {
+  const now = ref(options.time);
+
+  const current = computed(() => {
+
+    const {
+      days, hours, minutes,
+      seconds, milliseconds
+    } = TimeUtils.splitMillSeconds(now.value);
+
+    return {
+      total: now.value,
+      days,
+      hours,
+      minutes,
+      seconds,
+      milliseconds,
+    };
+  });
+
+  const timer = ref(0);
+
+  const checkCallback = (v: number) => {
+    if (v <= 0) {
+      //小于0,结束
+      stop();
+      now.value = 0;
+      options.onFinish && options.onFinish();
+    }
+    options.onChange && options.onChange(current.value);
+  };
+
+  /**
+   * 开始倒计时
+   */
+  function start() {
+    if (timer.value <= 0) {
+      const subTime = options.millisecond ? 40 : 1000;
+      timer.value = setInterval(() => {
+        const nowValue = now.value - subTime;
+        checkCallback(nowValue);
+        now.value = nowValue;
+      }, subTime) as unknown as number;
+    }
+  }
+  /**
+   * 暂停倒计时
+   */
+  function stop() {
+    if (timer.value > 0) {
+      clearInterval(timer.value);
+      timer.value = 0;
+    }
+  }
+  /**
+   * 重置倒计时,支持传入新的倒计时时长
+   */
+  function reset(time?: number) {
+    now.value = (time || options.time);
+  }
+
+  return {
+    /**
+     * 当前剩余的时间
+     */
+    current,
+    now,
+    start,
+    stop,
+    reset,
+  };
+}

+ 63 - 0
src/components/display/loading/LoadingPage.vue

@@ -0,0 +1,63 @@
+<script setup lang="ts">
+import ActivityIndicator from '@/components/basic/ActivityIndicator.vue';
+import Text, { type TextProps } from '@/components/basic/Text.vue';
+import FlexView, { type FlexProps } from '@/components/layout/FlexView.vue';
+import { propGetThemeVar, useTheme, type ViewStyle } from '@/components/theme/ThemeDefine';
+
+export interface LoadingPageProps extends FlexProps {
+  /**
+   * 加载器下方文字
+   */
+  loadingText?: string,
+  /**
+   * 加载器下方文字样式
+   */
+  loadingTextProps?: TextProps,
+  /**
+   * 加载器颜色
+   * @default Color.primary
+   */
+  indicatorColor?: string,
+  /**
+   * 加载器样式
+   */
+  indicatorStyle?: ViewStyle,
+  /**
+   * 容器自定义样式
+   */
+  innerStyle?: ViewStyle,
+}
+
+const themeContext = useTheme();
+
+const props = withDefaults(defineProps<LoadingPageProps>(), {
+  loadingText: '加载中',
+  indicatorColor: () => propGetThemeVar('LoadingPageIndicatorColor', 'primary'),
+});
+</script>
+
+<template>
+  <FlexView 
+    position="absolute" 
+    direction="column"
+    :backgroundColor="themeContext.resolveThemeColor('LoadingPageBackgroundColor', 'mask.white')"
+    :left="0" 
+    :right="0" 
+    :top="0"
+    :bottom="0"
+    center
+    v-bind="$attrs"
+  >
+    <ActivityIndicator
+      :color="indicatorColor"
+      :innerStyle="indicatorStyle"
+      :size="50"
+    />
+    <Text
+      v-if="loadingText"
+      v-bind="loadingTextProps"
+      :text="loadingText"
+    />
+    <slot />
+  </FlexView>
+</template>

+ 80 - 0
src/components/display/loading/Loadmore.vue

@@ -0,0 +1,80 @@
+<script setup lang="ts">
+import ActivityIndicator from '@/components/basic/ActivityIndicator.vue';
+import Text from '@/components/basic/Text.vue';
+import FlexRow from '@/components/layout/FlexRow.vue';
+import type { FlexProps } from '@/components/layout/FlexView.vue';
+import Width from '@/components/layout/space/Width.vue';
+import { useTheme } from '@/components/theme/ThemeDefine';
+import { computed } from 'vue';
+
+export type LoadMoreStatus = ''|'loading'|'finished'|'error'|'nomore'|'loadmore';
+
+export interface LoadMoreProps extends FlexProps {
+  /**
+   * 加载更多状态
+   */
+  status?: LoadMoreStatus,
+  /**
+   * 加载中文字
+   * @default '正在努力加载中...'
+   */
+  loadingText?: string,
+  /**
+   * 加载完毕文字
+   * @default '加载完毕'
+   */
+  finishedText?: string,
+  /**
+   * 加载失败文字
+   * @default '加载失败'
+   */
+  errorText?: string,
+  /**
+   * 没有更多了文字
+   * @default '没有更多了'
+   */
+  nomoreText?: string,
+  /**
+   * 点击加载更多文字
+   * @default '点击加载更多'
+   */ 
+  loadmoreText?: string,
+}
+
+const props = withDefaults(defineProps<LoadMoreProps>(), {
+  loadingText: '正在努力加载中...',
+  finishedText: '加载完毕',
+  errorText: '加载失败',
+  nomoreText: '没有更多了',
+  loadmoreText: '点击加载更多',
+});
+
+const themeContext = useTheme();
+
+const text = computed(() => {
+  switch (props.status) {
+    case 'loading': return props.loadingText;
+    case 'finished': return props.finishedText;
+    case 'error': return props.errorText;
+    case 'nomore': return props.nomoreText;
+    case 'loadmore': return props.loadmoreText;
+  }
+  return '';
+})
+</script>
+
+<template>
+  <FlexRow 
+    :padding="[10,20]"
+    justify="center"
+    align="center" 
+    :backgroundColor="themeContext.resolveThemeColor('LoadMoreBackgroundColor', 'background.bar')" 
+    v-bind="$attrs"
+  >
+    <template v-if="props.status === 'loading'">
+      <ActivityIndicator  :size="30" />
+      <Width :size="20" />
+    </template>
+    <Text :text="text" fontConfig="subText" />
+  </FlexRow>
+</template>

src/uni_modules/uview-plus/components/u-parse/u-parse.vue → src/components/display/parse/Parse.vue


src/uni_modules/uview-plus/components/u-parse/node/node.vue → src/components/display/parse/node/node.vue


src/uni_modules/uview-plus/components/u-parse/parse.js → src/components/display/parse/parse.js


src/uni_modules/uview-plus/components/u-parse/parser.js → src/components/display/parse/parser.js


src/uni_modules/uview-plus/components/u-parse/props.js → src/components/display/parse/props.js


+ 70 - 0
src/components/display/skeleton/SkeletonAvatar.vue

@@ -0,0 +1,70 @@
+<template>
+  <BaseBox 
+    v-bind="props"
+    :type="'avatar'"
+    :inner-style="{
+      ...selectStyleType(props.shape, 'circle', {
+        circle: themeStyles.skeletonAvatarCircle.value,
+        square: themeStyles.skeletonAvatar.value,
+      }),
+      ...selectStyleType(props.size, 'medium', {
+        small: themeStyles.skeletonAvatarSm.value,
+        medium: themeStyles.skeletonAvatarMd.value,
+        large: themeStyles.skeletonAvatarLg.value,
+      }),
+      ...props.innerStyle,
+    }"
+  />
+</template>
+
+<script setup lang="ts">
+import { useTheme } from '@/components/theme/ThemeDefine';
+import { DynamicSize, selectStyleType } from '@/components/theme/ThemeTools';
+import BaseBox, { type SkeletonItemSize, type SleletonBaseBoxProps } from './SkeletonBaseBox.vue';
+
+const themeContext = useTheme();
+const themeStyles = themeContext.useThemeStyles({
+  skeletonAvatar: {
+    borderRadius: DynamicSize('SkeletonAvatarRadius', 10),
+  },
+  skeletonAvatarCircle: {
+  },
+  skeletonAvatarSm: {
+    width: DynamicSize('SkeletonAvatarSmWidth', 50),
+    height: DynamicSize('SkeletonAvatarSmHeight', 50),
+  },
+  skeletonAvatarMd: {
+    width: DynamicSize('SkeletonAvatarMdWidth', 80),
+    height: DynamicSize('SkeletonAvatarMdHeight', 80),
+  },
+  skeletonAvatarLg: {
+    width: DynamicSize('SkeletonAvatarLgWidth', 150),
+    height: DynamicSize('SkeletonAvatarLgHeight', 150),
+  },
+});
+
+export interface SkeletonAvatarProps extends SleletonBaseBoxProps {
+  /**
+   * 形状
+   * @default 'circle'
+   */
+  shape: 'circle' | 'square'; 
+  /**
+   * 大小预设
+   * @default 'medium'
+   */
+  size?: SkeletonItemSize,
+}
+
+const props = withDefaults(defineProps<SkeletonAvatarProps>(), {
+  shape: 'circle',
+  size: 'medium',
+});
+
+defineOptions({
+  options: {
+    virtualHost: true,
+    styleIsolation: "shared",
+  },
+});
+</script>

+ 47 - 0
src/components/display/skeleton/SkeletonBaseBox.vue

@@ -0,0 +1,47 @@
+<template>
+  <view 
+    :class="[ 
+      'nana-skeleton', 
+      type,
+      { 'anim': context.active.value }
+    ]"
+    :style="{
+      'background-color': context.color.value,
+      'width': themeContext.resolveSize(props.width),
+      'height': themeContext.resolveSize(props.height),
+      ...props.innerStyle,
+    }"
+  >
+    <view class="anim-box" />
+  </view>
+</template>
+
+<script setup lang="ts">
+import { inject } from 'vue';
+import type { SkeletonContext } from '../Skeleton.vue';
+import { useTheme, type ViewStyle } from '@/components/theme/ThemeDefine';
+
+const context = inject('SkeletonContext') as SkeletonContext;
+const themeContext = useTheme();
+
+export type SkeletonItemSize = 'small'|'medium'|'large';
+
+export interface SleletonBaseBoxProps {
+  width?: number;
+  height?: number;
+  innerStyle?: ViewStyle,
+  type?: string,
+
+}
+
+const props = withDefaults(defineProps<SleletonBaseBoxProps>(), {
+  type: 'base-box',
+});
+
+defineOptions({
+  options: {
+    virtualHost: true,
+    styleIsolation: "shared",
+  },
+});
+</script>

+ 61 - 0
src/components/display/skeleton/SkeletonButton.vue

@@ -0,0 +1,61 @@
+<template>
+  <BaseBox 
+    v-bind="props"
+    :inner-style="{
+      ...selectStyleType(props.size, 'medium', {
+        small: themeStyles.skeletonButtonSm.value,
+        medium: themeStyles.skeletonButtonMd.value,
+        large: themeStyles.skeletonButtonLg.value,
+      }),
+      ...themeStyles.skeletonButton.value,
+      ...props.innerStyle,
+    }"
+  />
+</template>
+
+<script setup lang="ts">
+import { useTheme } from '@/components/theme/ThemeDefine';
+import { DynamicSize, selectStyleType } from '@/components/theme/ThemeTools';
+import BaseBox, { type SkeletonItemSize, type SleletonBaseBoxProps } from './SkeletonBaseBox.vue';
+
+const themeContext = useTheme();
+const themeStyles = themeContext.useThemeStyles({
+  skeletonButton: {
+    borderRadius: DynamicSize('SkeletonButtonRadius', 10),
+  },
+  skeletonButtonSm: {
+    width: DynamicSize('SkeletonButtonWidth', 60),
+    height: DynamicSize('SkeletonButtonHeight', 42),
+  },
+  skeletonButtonMd: {
+    width: DynamicSize('SkeletonButtonWidth', 155),
+    height: DynamicSize('SkeletonButtonHeight', 82),
+  },
+  skeletonButtonLg: {
+    width: DynamicSize('SkeletonButtonWidth', 180),
+    height: DynamicSize('SkeletonButtonHeight', 100),
+  },
+});
+
+/**
+ * 标题占位组件
+ */
+export interface SkeletonButtonProps extends SleletonBaseBoxProps {
+  /**
+   * 大小预设
+   * @default 'medium'
+   */
+  size?: SkeletonItemSize,
+}
+
+const props = withDefaults(defineProps<SkeletonButtonProps>(), {
+  size: 'medium',
+});
+
+defineOptions({
+  options: {
+    virtualHost: true,
+    styleIsolation: "shared",
+  },
+});
+</script>

+ 38 - 0
src/components/display/skeleton/SkeletonImage.vue

@@ -0,0 +1,38 @@
+<template>
+  <BaseBox 
+    v-bind="props"
+    :inner-style="{
+      ...themeStyles.skeletonImage.value,
+      ...props.innerStyle,
+    }"
+  />
+</template>
+
+<script setup lang="ts">
+import { useTheme } from '@/components/theme/ThemeDefine';
+import { DynamicSize } from '@/components/theme/ThemeTools';
+import BaseBox, { type SleletonBaseBoxProps } from './SkeletonBaseBox.vue';
+
+const themeContext = useTheme();
+const themeStyles = themeContext.useThemeStyles({
+  skeletonImage: {
+    borderRadius: DynamicSize('SkeletonImageRadius', 10),
+  },
+});
+
+/**
+ * 图片占位组件
+ */
+export interface SkeletonImageProps extends SleletonBaseBoxProps {
+}
+
+const props = withDefaults(defineProps<SkeletonImageProps>(), {
+});
+
+defineOptions({
+  options: {
+    virtualHost: true,
+    styleIsolation: "shared",
+  },
+});
+</script>

+ 0 - 0
src/components/display/skeleton/SkeletonParagraph.vue


Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov