快乐的梦鱼 пре 1 недеља
родитељ
комит
131adecb0d
65 измењених фајлова са 2605 додато и 210 уклоњено
  1. 43 0
      .cursor/rules/dynamic-form.mdc
  2. 21 0
      .cursor/rules/naeasy-ui-prefer-components.mdc
  3. 29 0
      .cursor/rules/powershell-pwsh.mdc
  4. 14 0
      .cursor/rules/web-scraping-devtools-first.mdc
  5. 55 0
      src/api/auth/CollectUserApi.ts
  6. 1 1
      src/components/README.md
  7. 21 0
      src/components/basic/BackgroundImageButton.vue
  8. 23 26
      src/components/basic/Button.vue
  9. 1 1
      src/components/basic/Cell.vue
  10. 9 1
      src/components/basic/IconButton.vue
  11. 45 30
      src/components/basic/Image.vue
  12. 30 4
      src/components/basic/ImageButton.vue
  13. 782 0
      src/components/canvas/MiniRender.ts
  14. 54 0
      src/components/canvas/UniWeappRender.ts
  15. 51 0
      src/components/composeabe/LoadQuerys.ts
  16. 76 0
      src/components/composeabe/MemoryTimeOut.ts
  17. 20 0
      src/components/composeabe/StorageVar.ts
  18. 15 0
      src/components/composeabe/loader/LoaderCommon.ts
  19. 73 0
      src/components/composeabe/loader/SimpleDataLoader.ts
  20. 95 0
      src/components/composeabe/loader/SimplePageListLoader.ts
  21. 3 1
      src/components/dialog/PopupTitle.vue
  22. 4 3
      src/components/display/Avatar.vue
  23. 0 1
      src/components/display/Badge.vue
  24. 5 0
      src/components/display/NoticeBar.vue
  25. 2 2
      src/components/display/PreviewItem.vue
  26. 7 0
      src/components/display/Progress.vue
  27. 0 1
      src/components/display/Tag.vue
  28. 32 3
      src/components/display/block/BackgroundBox.vue
  29. 1 0
      src/components/display/block/ImageBlock.vue
  30. 8 14
      src/components/display/block/ImageBlock2.vue
  31. 1 1
      src/components/display/block/ImageBlock3.vue
  32. 2 0
      src/components/display/parse/Parse.vue
  33. 5 3
      src/components/display/title/SubTitle.vue
  34. 2 2
      src/components/feedback/Alert.vue
  35. 1 1
      src/components/feedback/DropdownMenuItem.vue
  36. 6 0
      src/components/form/Field.vue
  37. 5 2
      src/components/form/UploaderListAddItem.vue
  38. 33 7
      src/components/layout/BaseView.ts
  39. 20 13
      src/components/layout/FlexView.vue
  40. 41 0
      src/components/layout/masonry/MasonryGrid.vue
  41. 20 0
      src/components/layout/masonry/MasonryGridItem.vue
  42. 1 1
      src/components/layout/space/SafeAreaMargin.vue
  43. 3 1
      src/components/layout/space/SafeAreaPadding.vue
  44. 2 2
      src/components/layout/space/StatusBarSpace.vue
  45. 1 1
      src/components/layout/space/XBarSpace.vue
  46. 25 6
      src/components/list/FixedVirtualList.vue
  47. 5 10
      src/components/list/IndexList.vue
  48. 115 0
      src/components/loader/SimplePageContentLoader.vue
  49. 35 0
      src/components/loader/SimplePageListLoader.vue
  50. 17 4
      src/components/nav/NavBar.vue
  51. 1 1
      src/components/nav/TabBar.vue
  52. 29 6
      src/components/theme/Theme.ts
  53. 21 12
      src/components/theme/ThemeDefine.ts
  54. 12 48
      src/components/theme/ThemeTools.ts
  55. 71 0
      src/components/thirdPart/pinyinUtil.d.ts
  56. 364 0
      src/components/thirdPart/pinyinUtil.js
  57. 8 0
      src/components/thirdPart/pinyin_dict_firstletter.js
  58. 2 0
      src/components/typography/HorizontalScrollText.vue
  59. 2 0
      src/components/utils/DialogAction.ts
  60. 9 0
      src/components/utils/PingyinUtils.ts
  61. 21 0
      src/pages.json
  62. 6 0
      src/pages/collect/inheritor.vue
  63. 162 0
      src/pages/collect/login.vue
  64. 4 0
      src/pages/user/index.vue
  65. 33 1
      src/store/auth.ts

+ 43 - 0
.cursor/rules/dynamic-form.mdc

@@ -0,0 +1,43 @@
+---
+description: DynamicForm 动态表单定义、挂载与 dig/forms 数据工厂约定
+globs: "**/*.vue"
+alwaysApply: false
+---
+
+# 动态表单(DynamicForm)使用约定
+
+基于 `DynamicForm.vue`、`DynamicForm.ts`(`IDynamicFormOptions` / `IDynamicFormItem`)及 `pages/dig/forms/data/*.ts`(如 `overview.ts`)的实际用法。
+
+## 组件侧
+
+- 使用 `<DynamicForm :options="..." :model="..." :name="..." :globalParams="..." />`。`model` 与表单双向绑定;`globalParams` 可在条目回调的 `formGlobalParams` 中读取。
+- 通过 `ref` 拿到 `IDynamicFormRef`:`validate` / `submit`、`setValueByPath` / `getValueByPath`(路径为点分字符串或数组转点分)、`getFormRef`、`getFormItemControlRef`、`dispatchMessage` / `dispatchReload`(常量 `MESSAGE_RELOAD`)、`initDefaultValuesToModel`、`getVisibleFormNames`。
+- 顶层校验:在 `options.formRules`(async-validator `Rules`)或单项 `rules`(`RuleItem[]`)。可选 `formAdditionaProps`、`disabled`、`readonly`、`suppressRootError`、`suppressEmptyError`、`nestObjectMargin`、`emptyText`。
+
+## 选项结构 `IDynamicFormOptions`
+
+- **必填**:`formItems: IDynamicFormItem[]`。
+- 全局默认:可改 `defaultDynamicFormOptions` 或调用 `configDefaultDynamicFormOptions`(勿传入 `formItems`),与单次 `options` 合并。
+
+## 表单项 `IDynamicFormItem`
+
+- **必填**:`name`(字段路径名)。**常用**:`label`、`type`(内置控件类型字符串)、`additionalProps`(传给具体控件)、`formProps`(传给 `Field`)、`defaultValue`、`rules`、`children`(嵌套对象/数组项)。
+- **动态字段**:`label`、`additionalProps`、部分控件字段、`show`、`disabled` 等可写成 **`IDynamicFormItemCallback<T>`**:`{ callback: (model, rawModel, parentModel, params) => T }`。关联远端字段时优先用 **`rawModel`**;列表场景用 **`parentModel`**。`params` 含 `item`、`parent`、`form`(`IDynamicFormRef`)、`formGlobalParams`、`formRules`、`isFirst` / `isLast`。
+- **生命周期 / 联动**:`mounted` / `beforeUnmount` / `watch`;跨项赋值在拿到表单 ref 时用 **`form.setValueByPath('field', value)`**(参见 `overview` 中 `select-city` 的 `onSelectedTownship`)。
+- **布局容器**:`flat-group` 用于横向/分组排版(可配合 `childrenColProps`、`rowProps`);子项仍占用模型上的 `name`。嵌套对象用带 `children` 的条目(由渲染层按 `type` 区分对象/数组等)。
+- **数组子项**:可选 `newChildrenObject`、`deleteChildrenCallback` 控制增删;子层可用 `childrenColProps` / `colProps` / `nestObjectMargin`。
+- **默认值**:`defaultValue` 可为字面量或 **`() => value`**(惰性初始化);`initDefaultValuesToModel` 只对仍为 `undefined`/`null` 的路径写入。
+- **消息**:条目可实现 `message`,根上 `dispatchMessage` 分发;预置 `MESSAGE_RELOAD` 用于刷新类逻辑。
+
+## dig 业务表单数据(`GroupForm` / `SingleForm`)
+
+- 形态:`Record<number, SingleForm>`,每项为 **`[数据模型类, (formRef) => IDynamicFormOptions, { title, typeName, order?, ... }]`**(见 `forms.ts`)。
+- 工厂函数里若需表单实例(联动、路径赋值),签名应为 **`(form: Ref<IDynamicFormRef>) => ({ formItems: [...] })`**,与 `overview.ts` 中 `(form) => ({ formItems })` 一致。
+- 复用片段:可将公共 `formItems` 抽成函数返回片段,再用展开合并 **`...(xxx(f, opts)).formItems`**。
+- 选择类控件:`additionalProps.loadData` 异步拉选项并映射为 `{ value, text }`;按需 **`as PickerIdFieldProps` / `RadioIdFieldProps` / `CheckBoxListProps` / `FieldProps`** 等与控件 Props 对齐。
+- 条件显示:`show: { callback: (_, m) => ... }` 中用 **`m`(通常为当前层 rawModel)** 判断兄弟字段(见「其他服务业」等项)。
+
+## 建议避免
+
+- 勿猜测未注册的 `type`;新增类型需在动态表单渲染链路中已有对应组件映射。
+- 回调里访问兄弟节点不要用错层级:`model` 是当前项值,`rawModel` 是整表根数据(跨深层字段时尤其要用根路径或 `setValueByPath`)。

+ 21 - 0
.cursor/rules/naeasy-ui-prefer-components.mdc

@@ -0,0 +1,21 @@
+---
+description: 编写界面优先使用 NaEasy UI(src/components)组件
+globs: "**/*.vue"
+alwaysApply: false
+---
+
+# NaEasy UI 优先(`src/components`)
+
+依据 `src/components/README.md`:**NaEasy UI** 为本项目内置的 UniApp 移动端组件库;编写、改版界面时应**优先使用仓库内 `src/components` 下的组件与封装**,再考虑手写原生标签或其它 UI 包。
+
+## 实践要点
+
+- **导入路径**:本项目 TypeScript 配置为 `@/*` → `src/*`,请使用 **`@/components/...`** 引用(与 README 中「组件库」源码目录一致;勿与 `@/` 以外臆测别名混淆)。
+- **文档**:官方说明见 [NaEasy UI UniApp 文档](https://docs.imengyu.top/naeasy-ui-uniapp-docs/);复杂 props、行为以文档与现有页面用法为准。
+- **选型顺序**:同类需求若有 NaEasy 封装(含 `form`、`layout`、`dynamic` 等子目录)→ 先用封装;确无对应能力时再组合底层组件或扩展,并在改动处保持与现有风格一致(间距、字段、`Field`/`Form` 等)。
+- **许可与归属**:组件库为 MIT;商用与标注要求见 README「版权说明」「使用注意事项」。
+
+## 避免
+
+- 在未确认目录内是否已有组件的情况下,新增与其它页面不一致的第三方 UI 依赖。
+- 绕过已有表单体系重复造复杂表单(可结合项目内 `DynamicForm` 等既有模式)。

+ 29 - 0
.cursor/rules/powershell-pwsh.mdc

@@ -0,0 +1,29 @@
+---
+description: Windows 终端优先使用 pwsh(PowerShell 7+),避免旧版 powershell.exe
+alwaysApply: true
+---
+
+# PowerShell:优先 pwsh
+
+在 **Windows** 上需要跑 PowerShell 命令或脚本时,**优先使用 `pwsh`**(PowerShell 7+ 可执行文件),不要用旧版 **`powershell.exe`**(Windows PowerShell 5.1),除非用户明确要求兼容 5.1。
+
+## 执行方式
+
+- 直接开 PowerShell 会话或让用户在本机执行时:写 **`pwsh`**,不要写 **`powershell`**。
+- 单次内联执行:可用 `pwsh -NoProfile -Command "..."` 或 `pwsh -File script.ps1`。
+- 若环境未安装 `pwsh`,再回退到 `powershell`,并在回复里说明依赖 5.1 或建议安装 [PowerShell](https://github.com/PowerShell/PowerShell/releases)。
+
+## 语法提示
+
+- `pwsh` 支持 `&&`、`||` 等操作符;与 5.1 行为不一致时以 **7+** 为准编写命令。
+- 设置工作目录优先用:`Set-Location '路径'` 或 `cd '路径'`;不要用 CMD 的 `cd /d`。
+
+## 反例 / 正例
+
+```powershell
+# ❌ 默认指向旧版
+powershell -Command "Get-ChildItem"
+
+# ✅ 优先
+pwsh -NoProfile -Command "Get-ChildItem"
+```

+ 14 - 0
.cursor/rules/web-scraping-devtools-first.mdc

@@ -0,0 +1,14 @@
+---
+description: 网页抓取/页面检查优先用 Browser DevTools MCP
+alwaysApply: true
+---
+
+# 网页抓取优先使用 Browser DevTools MCP
+
+当任务需要**打开网页、抓取页面内容、定位元素、复现交互、查看网络请求**时,优先使用 Browser DevTools MCP,而不是直接用普通网页抓取/猜测选择器。
+
+- **结构优先**:导航到页面后,先用 ARIA 快照理解页面结构与可交互元素(必要时再用 AX tree)。
+- **交互前必快照**:任何点击/输入/选择/滚动等交互之前,先获取快照拿到真实 ref/selector,禁止凭截图或臆测 CSS 选择器。
+- **按需取内容**:需要正文/表格/接口参数时,再获取 HTML(可按 selector 范围化);只有在需要验证视觉效果时才截图。
+- **批量执行**:多步操作(填表+提交+等待+再抓取)尽量用批量执行能力合并成一次调用,减少往返与不确定性。
+

+ 55 - 0
src/api/auth/CollectUserApi.ts

@@ -0,0 +1,55 @@
+import { DataModel } from '@imengyu/js-request-transform';
+import { AppServerRequestModule } from '../RequestModules';
+import { LoginResult } from './UserApi';
+
+export class UserInfo extends DataModel<UserInfo> {
+  constructor() {
+    super(UserInfo, "用户信息");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+    }
+  }
+
+  id = 0;
+  userId = 0;
+  mobile = '';
+  nickname = '';
+  avatar = '';
+  username = '';
+  regionId = 0;
+  isReviewer = false;
+}
+
+export class CollectUserApi extends AppServerRequestModule<DataModel> {
+
+  constructor() {
+    super();
+  }
+  async login(data: {
+    mobile: string,
+    password: string,
+  }) {
+    return (await this.post('/ich/inheritor/login', '登录', data, undefined, LoginResult)).data as LoginResult;
+  }
+  async loginAdmin(data: {
+    account: string,
+    password: string,
+  }) {
+    return (await this.post('/user/adminLogin', '登录', {
+      account: data?.account,
+      password: data?.password,
+    }, undefined, LoginResult)).data as LoginResult;
+  }
+  async updatePassword(data: {
+    newpassword: string,
+    oldpassword: string,
+  }) {
+    return (await this.post('/content/main_body_user/changepwd', '更新密码', data))
+  }
+  async refresh() {
+    return (await this.post('/ich/inheritor/refresh', '刷新token', {}, undefined, LoginResult)).data as LoginResult;
+  }
+}
+
+export default new CollectUserApi();

+ 1 - 1
src/components/README.md

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

+ 21 - 0
src/components/basic/BackgroundImageButton.vue

@@ -0,0 +1,21 @@
+<template>
+  <Touchable :activeOpacity="activeOpacity" @click="emit('click')">
+    <BackgroundBox v-bind="props">
+      <slot />
+    </BackgroundBox>
+  </Touchable>
+</template>
+
+<script setup lang="ts">
+import BackgroundBox, { type BackgroundBoxProps } from '../display/block/BackgroundBox.vue';
+import Touchable from '../feedback/Touchable.vue';
+
+const props = withDefaults(defineProps<BackgroundBoxProps & {
+  activeOpacity?: number;
+}>(), {
+  activeOpacity: 0.7,
+});
+
+const emit = defineEmits(['click']);
+
+</script>

+ 23 - 26
src/components/basic/Button.vue

@@ -10,6 +10,7 @@
     v-bind="viewProps"
     :pressedColor="finalPressedColor"
     :touchable="touchable && !loading"
+    :gap="iconMargin"
     @state="(v) => state = v"
     @click="(e) => emit('click', e)"
   >
@@ -18,18 +19,12 @@
         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>
@@ -48,9 +43,6 @@
         :icon="rightIcon"
         :size="selectStyleType(size, 'medium', FonstSizes)"
         :color="currentStyle.color"
-        :innerStyle="{
-          marginLeft: iconMargin ? '10rpx' : undefined,
-        }"
         v-bind="rightIconProps"
       />
     </slot>
@@ -59,7 +51,7 @@
 
 <script setup lang="ts">
 import { computed, inject, ref } from 'vue';
-import { useTheme, type ViewStyle } from '../theme/ThemeDefine';
+import { propGetThemeVar, 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';
@@ -122,6 +114,11 @@ export interface ButtonProp {
    */
   iconProps?: IconProps;
   /**
+   * 图标与文字之间的间距
+   * @default 10
+   */
+  iconMargin?: number|string;
+  /**
    * 右侧图标。支持 Icon 组件里的所有图标,也可以传入图标的图片 URL(http/https)。
    */
   rightIcon?: string,
@@ -138,7 +135,7 @@ export interface ButtonProp {
    * 当按扭为round圆形按扭时的圆角大小。
    * @default 5
    */
-  radius?: number,
+  radius?: number|string,
   /**
    * 按钮尺寸. 支持 large、medium、small、mini 四种尺寸。
    * @default 'medium'
@@ -212,22 +209,23 @@ 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",
+  color: () => propGetThemeVar('ButtonColor', 'primary'),
+  pressedColor: () => propGetThemeVar('ButtonPressedColor', 'pressed.primary'),
+  disabledColor: () => propGetThemeVar('ButtonDisabledColor', 'grey'),
+  type: () => propGetThemeVar('ButtonType', 'default'),
+  size: () => propGetThemeVar('ButtonSize', 'medium'),
+  block: () => propGetThemeVar('ButtonBlock', false),
+  radius: () => propGetThemeVar('ButtonRadius', 16),
+  iconMargin: () => propGetThemeVar('ButtonIconMargin', 10),
+  shape: () => propGetThemeVar('ButtonShape', "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'),
+  mini: themeContext.resolveThemeSize('ButtonMiniFonstSize', 'fontSize.xs'),
+  small: themeContext.resolveThemeSize('ButtonSmallFonstSize', 'fontSize.sm'),
+  medium: themeContext.resolveThemeSize('ButtonMediumFonstSize', 'fontSize.md'),
+  large: themeContext.resolveThemeSize('ButtonLargeFonstSize', 'fontSize.lg'),
+  larger: themeContext.resolveThemeSize('ButtonLargerFonstSize', 'fontSize.xl'),
 }));
 const themeVars = themeContext.getVars({
   ButtonBorderWidth: 1.5,
@@ -408,7 +406,7 @@ const currentStyle = computed(() => {
   //  sizeStyle.paddingHorizontal = `calc(${sizeStyle.paddingHorizontal} + ${themeContext.resolveSize(props.radius / 4)})`;
 
   //内边距样式的强制设置
-  configPadding(speicalStyle, themeContext.theme.value, props.padding);
+  configPadding(speicalStyle, themeContext, props.padding);
 
   return {
     color: (colorStyle).color,
@@ -423,7 +421,6 @@ const currentStyle = computed(() => {
 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) :

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

@@ -305,7 +305,7 @@ const style = computed(() => {
   }
 
   //内边距样式的强制设置
-  configPadding(styleObj, theme.theme.value, props.padding);
+  configPadding(styleObj, theme, props.padding);
 
   //边框设置
   if (props.topBorder)

+ 9 - 1
src/components/basic/IconButton.vue

@@ -3,11 +3,14 @@
     :pressedColor="pressedBackgroundColor"
     :innerStyle="style"
     :touchable="touchable"
+    gap="gap.md"
     @click="(e) => emit('click', e)"
     v-bind="$attrs"
   >
     <Icon v-if="icon" v-bind="props" />
-    <slot />
+    <slot>
+      <Text :text="text" />
+    </slot>
   </Touchable>
 </template>
 
@@ -18,6 +21,7 @@ import { selectStyleType } from '../theme/ThemeTools';
 import type { IconProps } from './Icon.vue';
 import Icon from './Icon.vue';
 import Touchable from '../feedback/Touchable.vue';
+import Text from './Text.vue';
 
 /**
  * 图标按钮形状预设
@@ -30,6 +34,10 @@ export type IconButtonShapeType = 'round'|'round-full'|'square-full'|'custom';
 
 export interface IconButtonProps extends IconProps {
   /**
+   * 按钮文字,在自定义slot后无效。
+   */
+  text?: string;
+  /**
    * 按钮按下时的背景颜色
    * @default PressedColor(Color.white)
    */

+ 45 - 30
src/components/basic/Image.vue

@@ -6,36 +6,40 @@
     :class="innerClass"
     @click="handleClick"
   >
-    <view v-if="showBackgroundEffect" :style="(backgroundEffectStyle as any)">
-    </view>
-    <view v-if="showBackgroundEffect" :style="(backgroundEffectStyle2 as any)">
-    </view>
-    <image 
-      :style="{
-        width: precentOrFull(style.width),
-        height: precentOrFull(style.height),
-      }"
-      :mode="(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 && !failedImage" class="inner-view error">
-      <Icon icon="warning" color="text.second" :size="32" noError />
-      <Text v-if="realWidth > 50" color="text.second" :text="src ? '加载失败' : '暂无图片'" :fontSize="22" />
-    </view>
-    <view v-if="showLoading && isLoadState" class="inner-view loading">
-      <ActivityIndicator
-        :color="themeContext.resolveThemeColor(loadingColor)"
-        :size="themeContext.resolveThemeSize(loadingSize)"
+    <template v-if="mode !== 'borderImage'">
+      <view v-if="showBackgroundEffect" :style="(backgroundEffectStyle as any)">
+      </view>
+      <view v-if="showBackgroundEffect" :style="(backgroundEffectStyle2 as any)">
+      </view>
+      <image 
+        :style="{
+          width: precentOrFull(style.width),
+          height: precentOrFull(style.height),
+          borderRadius: themeContext.resolveThemeSize(props.radius),
+        }"
+        :mode="(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>
+      <view v-if="showFailed && isErrorState && !failedImage" class="inner-view error">
+        <Icon icon="warning" color="text.second" :size="32" noError />
+        <Text v-if="realWidth > 50" color="text.second" :text="src ? '加载失败' : '暂无图片'" :fontSize="22" />
+      </view>
+      <view v-if="showLoading && isLoadState" class="inner-view loading">
+        <ActivityIndicator
+          :color="themeContext.resolveThemeColor(loadingColor)"
+          :size="themeContext.resolveThemeSize(loadingSize)"
+        />
+      </view>
+    </template>
+    <slot v-if="!src && !defaultImage" name="empty"></slot>
     <slot />
   </view>
 </template>
@@ -109,11 +113,16 @@ export interface ImageProps {
    * @default 0
    */
   radius?: string|number,
+
+  borderImageSlice?: number,
+  borderImageRepeat?: 'stretch' | 'repeat' | 'round',
+  borderImageWidth?: number,
+
   /**
    * 内部样式
    */
   innerStyle?: object;
-  mode?: 'aspectFill' | 'aspectFit' | 'widthFix' | 'heightFix' | 'top' | 'bottom' | 'left' | 'right' | 'center',
+  mode?: 'aspectFill' | 'aspectFit' | 'widthFix' | 'heightFix' | 'top' | 'bottom' | 'left' | 'right' | 'center' | 'borderImage',
   innerClass?: string,
 }
 
@@ -157,6 +166,12 @@ const style = computed(() => {
     height: themeContext.resolveThemeSize(props.height),
     ...props.innerStyle,
   }
+  if (props.mode === 'borderImage') {
+    o.borderImageSource = `url('${props.src || props.defaultImage}')`;
+    o.borderImageSlice = props.borderImageSlice;
+    o.borderImageRepeat = props.borderImageRepeat;
+    o.borderImageWidth = props.borderImageWidth;
+  }
   return o;
 });
 const backgroundEffectStyle = computed(() => ({

+ 30 - 4
src/components/basic/ImageButton.vue

@@ -1,11 +1,37 @@
 <template>
-  <view>
-
-  </view>
+  <Touchable 
+    :activeOpacity="activeOpacity" 
+    :touchable="touchable"
+    position="relative" 
+    center
+    @click="emit('click')"
+  >
+    <Image v-bind="props" />
+    <slot>
+      <FlexCol center position="absolute" inset="0">
+        <Text v-if="text" :text="text" v-bind="textProps" />
+      </FlexCol>
+    </slot>
+  </Touchable>
 </template>
 
 <script setup lang="ts">
+import Touchable from '../feedback/Touchable.vue';
+import FlexCol from '../layout/FlexCol.vue';
+import type { ImageProps } from './Image.vue';
+import Image from './Image.vue';
+import Text, { type TextProps } from './Text.vue';
+
+const props = withDefaults(defineProps<ImageProps & {
+  activeOpacity?: number;
+  touchable?: boolean;
+  text?: string;
+  textProps?: TextProps;
+}>(), {
+  activeOpacity: 0.7,
+  touchable: true,
+});
 
-export type ImageButtonShapeType = 'round'|'square-full'|'custom';
+const emit = defineEmits(['click']);
 
 </script>

+ 782 - 0
src/components/canvas/MiniRender.ts

@@ -0,0 +1,782 @@
+export namespace MiniRender {
+  
+  export interface RendeCanvasInterface {
+    initCanvas(width: number, height: number): Promise<void>;
+    createImage(src: string): Promise<any>;
+    clearCanvas(): void;
+    requestAnimationFrame(draw: (ts: number) => void): number;
+    cancelAnimationFrame(id: number): void;
+    getCtx(): CanvasRenderingContext2D;
+  }
+
+  export type RenderObjectId = string;
+
+  export interface Disposable {
+    dispose(): void;
+  }
+
+  export interface RenderContext {
+    readonly scene: Scene;
+    readonly canvas: RendeCanvasInterface;
+    readonly ctx: CanvasRenderingContext2D;
+  }
+
+  export interface IRenderable {
+    render(rc: RenderContext): void;
+  }
+
+  export interface IUpdatable {
+    update(dtMs: number): void;
+  }
+
+  export interface TransformLike {
+    x: number;
+    y: number;
+    width: number;
+    height: number;
+    rotation: number; // radians
+    alpha: number; // 0..1
+    scaleX: number;
+    scaleY: number;
+    anchorX: number; // 0..1 (relative to width)
+    anchorY: number; // 0..1 (relative to height)
+  }
+
+  export type RenderObjectEventName = "added" | "removed" | "click" | "touchstart" | "touchmove" | "touchend" | "touchcancel";
+  export type RenderObjectEventHandler = (obj: RenderObject, data?: any) => void;
+
+  export class RenderObject implements TransformLike, IRenderable, IUpdatable {
+    public readonly id: RenderObjectId;
+    public name?: string;
+
+    public x = 0;
+    public y = 0;
+    public width = 0;
+    public height = 0;
+    public rotation = 0;
+    public alpha = 1;
+    public scaleX = 1;
+    public scaleY = 1;
+    public anchorX = 0;
+    public anchorY = 0;
+
+    public visible = true;
+    public zIndex = 0;
+    public interactive = false;
+
+    public parent: Container | null = null;
+
+    private listeners = new Map<RenderObjectEventName, Set<RenderObjectEventHandler>>();
+
+    constructor(id?: RenderObjectId) {
+      this.id = id ?? RenderObject.newId();
+    }
+
+    protected static newId(): RenderObjectId {
+      return `ro_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
+    }
+
+    public on(event: RenderObjectEventName, handler: RenderObjectEventHandler): () => void {
+      const set = this.listeners.get(event) ?? new Set<RenderObjectEventHandler>();
+      set.add(handler);
+      this.listeners.set(event, set);
+      return () => this.off(event, handler);
+    }
+
+    public off(event: RenderObjectEventName, handler: RenderObjectEventHandler): void {
+      this.listeners.get(event)?.delete(handler);
+    }
+
+    public emit(event: RenderObjectEventName, data?: any): void {
+      const set = this.listeners.get(event);
+      if (!set) return;
+      for (const h of set) h(this, data);
+    }
+
+    public hasListener(event: RenderObjectEventName): boolean {
+      const set = this.listeners.get(event);
+      return !!set && set.size > 0;
+    }
+
+    protected withTransform(rc: RenderContext, draw: () => void): void {
+      const { ctx } = rc;
+      ctx.save();
+
+      const ax = this.anchorX * this.width;
+      const ay = this.anchorY * this.height;
+
+      ctx.translate(this.x + ax, this.y + ay);
+      if (this.rotation) ctx.rotate(this.rotation);
+      if (this.scaleX !== 1 || this.scaleY !== 1) ctx.scale(this.scaleX, this.scaleY);
+
+      const prevAlpha = ctx.globalAlpha;
+      ctx.globalAlpha = (prevAlpha ?? 1) * this.alpha;
+
+      ctx.translate(-ax, -ay);
+      draw();
+      ctx.restore();
+    }
+
+    protected draw(_rc: RenderContext): void {}
+
+    public render(rc: RenderContext): void {
+      if (!this.visible || this.alpha <= 0) return;
+      this.withTransform(rc, () => this.draw(rc));
+    }
+
+    public update(_dtMs: number): void {}
+
+    public parentToLocal(px: number, py: number): { x: number; y: number } | null {
+      const ax = this.anchorX * this.width;
+      const ay = this.anchorY * this.height;
+
+      let lx = px - (this.x + ax);
+      let ly = py - (this.y + ay);
+
+      if (this.rotation) {
+        const cos = Math.cos(-this.rotation);
+        const sin = Math.sin(-this.rotation);
+        const rx = lx * cos - ly * sin;
+        const ry = lx * sin + ly * cos;
+        lx = rx;
+        ly = ry;
+      }
+
+      if (this.scaleX === 0 || this.scaleY === 0) return null;
+      lx /= this.scaleX;
+      ly /= this.scaleY;
+
+      lx += ax;
+      ly += ay;
+
+      return { x: lx, y: ly };
+    }
+
+    public hitTest(px: number, py: number): RenderObject | null {
+      if (!this.visible || this.alpha <= 0) return null;
+      if (!this.interactive) return null;
+
+      const local = this.parentToLocal(px, py);
+      if (!local) return null;
+
+      if (local.x >= 0 && local.x <= this.width && local.y >= 0 && local.y <= this.height) {
+        return this;
+      }
+      return null;
+    }
+  }
+
+  export class Container extends RenderObject {
+    private _children: RenderObject[] = [];
+
+    public get children(): readonly RenderObject[] {
+      return this._children;
+    }
+
+    public add(child: RenderObject): this {
+      if (child.parent) child.parent.remove(child);
+      child.parent = this;
+      this._children.push(child);
+      this.sortChildren();
+      child.emit("added");
+      return this;
+    }
+
+    public remove(child: RenderObject): this {
+      const idx = this._children.indexOf(child);
+      if (idx < 0) return this;
+      this._children.splice(idx, 1);
+      child.parent = null;
+      child.emit("removed");
+      return this;
+    }
+
+    public removeAll(): this {
+      for (const c of this._children) {
+        c.parent = null;
+        c.emit("removed");
+      }
+      this._children = [];
+      return this;
+    }
+
+    public sortChildren(): void {
+      this._children.sort((a, b) => a.zIndex - b.zIndex);
+    }
+
+    public override update(dtMs: number): void {
+      for (const c of this._children) {
+        (c as unknown as IUpdatable).update?.(dtMs);
+      }
+    }
+
+    protected override draw(rc: RenderContext): void {
+      for (const c of this._children) c.render(rc);
+    }
+
+    public override hitTest(px: number, py: number): RenderObject | null {
+      if (!this.visible || this.alpha <= 0) return null;
+
+      const local = this.parentToLocal(px, py);
+      if (!local) return null;
+
+      for (let i = this._children.length - 1; i >= 0; i--) {
+        const hit = this._children[i].hitTest(local.x, local.y);
+        if (hit) return hit;
+      }
+
+      if (this.interactive && this.hasListener("click")) {
+        if (local.x >= 0 && local.x <= this.width && local.y >= 0 && local.y <= this.height) {
+          return this;
+        }
+      }
+
+      return null;
+    }
+  }
+
+  export class Rect extends RenderObject {
+    public fillStyle: string | null = "#000000";
+    public strokeStyle: string | null = null;
+    public lineWidth = 1;
+    public radius = 0;
+
+    protected override draw(rc: RenderContext): void {
+      const { ctx } = rc;
+      const w = this.width;
+      const h = this.height;
+      if (w <= 0 || h <= 0) return;
+
+      if (this.radius > 0) {
+        const r = Math.max(0, Math.min(this.radius, Math.min(w, h) / 2));
+        ctx.beginPath();
+        ctx.moveTo(r, 0);
+        ctx.lineTo(w - r, 0);
+        ctx.arcTo(w, 0, w, r, r);
+        ctx.lineTo(w, h - r);
+        ctx.arcTo(w, h, w - r, h, r);
+        ctx.lineTo(r, h);
+        ctx.arcTo(0, h, 0, h - r, r);
+        ctx.lineTo(0, r);
+        ctx.arcTo(0, 0, r, 0, r);
+        ctx.closePath();
+
+        if (this.fillStyle) {
+          ctx.fillStyle = this.fillStyle;
+          ctx.fill();
+        }
+        if (this.strokeStyle) {
+          ctx.strokeStyle = this.strokeStyle;
+          ctx.lineWidth = this.lineWidth;
+          ctx.stroke();
+        }
+        return;
+      }
+
+      if (this.fillStyle) {
+        ctx.fillStyle = this.fillStyle;
+        ctx.fillRect(0, 0, w, h);
+      }
+      if (this.strokeStyle) {
+        ctx.strokeStyle = this.strokeStyle;
+        ctx.lineWidth = this.lineWidth;
+        ctx.strokeRect(0, 0, w, h);
+      }
+    }
+  }
+
+  export class Sprite extends RenderObject {
+    public src: string = "";
+    public image: any | null = null;
+
+    protected override draw(rc: RenderContext): void {
+      const { ctx, scene } = rc;
+      if (!this.src) return;
+      const w = this.width;
+      const h = this.height;
+      if (w <= 0 || h <= 0) return;
+
+      const img = this.image ?? scene.assets.getImageSync(this.src);
+      if (!img) {
+        scene.assets
+          .getImage(this.src)
+          .then((loaded) => {
+            this.image = loaded;
+          })
+          .catch(() => {});
+        return;
+      }
+      ctx.drawImage(img, 0, 0, w, h);
+    }
+  }
+
+  export type TextAlign = CanvasTextAlign;
+  export type TextBaseline = CanvasTextBaseline;
+
+  export interface TextStyle {
+    fontSize?: number;
+    fontFamily?: string;
+    fontWeight?: string | number;
+    fontStyle?: string;
+    color?: string;
+    strokeColor?: string | null;
+    strokeWidth?: number;
+    align?: TextAlign;
+    baseline?: TextBaseline;
+    lineHeight?: number;
+    /**
+     * - "nowrap": 单行(遇到 \n 也会分行)
+     * - "wrap": 自动按 maxWidth/width 换行
+     */
+    wrap?: "nowrap" | "wrap";
+    /** 指定自动换行宽度;若不设置则使用 `width` */
+    maxWidth?: number;
+  }
+
+  export class Text extends RenderObject {
+    public text = "";
+    public style: Required<TextStyle> = {
+      fontSize: 16,
+      fontFamily: "sans-serif",
+      fontWeight: "normal",
+      fontStyle: "normal",
+      color: "#000000",
+      strokeColor: null,
+      strokeWidth: 1,
+      align: "left",
+      baseline: "top",
+      lineHeight: 20,
+      wrap: "nowrap",
+      maxWidth: 0,
+    };
+
+    constructor(text?: string, style?: TextStyle) {
+      super();
+      if (typeof text === "string") this.text = text;
+      if (style) this.setStyle(style);
+    }
+
+    public setStyle(style: TextStyle): void {
+      this.style = { ...this.style, ...style };
+      if (!style.lineHeight && style.fontSize) {
+        // 常见经验值
+        this.style.lineHeight = Math.round(style.fontSize * 1.25);
+      }
+    }
+
+    private applyTextStyle(ctx: CanvasRenderingContext2D): void {
+      const s = this.style;
+      ctx.font = `${s.fontStyle} ${s.fontWeight} ${s.fontSize}px ${s.fontFamily}`.trim();
+      ctx.fillStyle = s.color;
+      ctx.textAlign = s.align;
+      ctx.textBaseline = s.baseline;
+      if (s.strokeColor) {
+        ctx.strokeStyle = s.strokeColor;
+        ctx.lineWidth = s.strokeWidth;
+      }
+    }
+
+    private splitLines(ctx: CanvasRenderingContext2D): string[] {
+      const raw = (this.text ?? "").toString();
+      const baseLines = raw.split(/\r?\n/);
+
+      if (this.style.wrap !== "wrap") return baseLines;
+
+      const max =
+        (this.style.maxWidth > 0 ? this.style.maxWidth : 0) ||
+        (this.width > 0 ? this.width : 0);
+      if (max <= 0) return baseLines;
+
+      const out: string[] = [];
+      for (const line of baseLines) {
+        if (!line) {
+          out.push("");
+          continue;
+        }
+
+        let current = "";
+        for (const ch of [...line]) {
+          const test = current + ch;
+          const w = ctx.measureText(test).width;
+          if (w > max && current) {
+            out.push(current);
+            current = ch;
+          } else {
+            current = test;
+          }
+        }
+        out.push(current);
+      }
+      return out;
+    }
+
+    private computeAutoSize(ctx: CanvasRenderingContext2D, lines: string[]): void {
+      // 未指定 width 时,使用最长行宽;指定了 maxWidth 时以 maxWidth 为上限
+      if (this.width <= 0) {
+        let maxLine = 0;
+        for (const l of lines) maxLine = Math.max(maxLine, ctx.measureText(l).width);
+        const limit = this.style.maxWidth > 0 ? this.style.maxWidth : 0;
+        this.width = limit > 0 ? Math.min(limit, maxLine) : maxLine;
+      }
+      if (this.height <= 0) {
+        this.height = lines.length * this.style.lineHeight;
+      }
+    }
+
+    protected override draw(rc: RenderContext): void {
+      const { ctx } = rc;
+      if (!this.text) return;
+
+      this.applyTextStyle(ctx);
+
+      const lines = this.splitLines(ctx);
+      this.computeAutoSize(ctx, lines);
+
+      const s = this.style;
+
+      // 对齐偏移:以对象局部坐标系 (0,0) 为绘制起点
+      const xBase =
+        s.align === "center" ? this.width / 2 : s.align === "right" || s.align === "end" ? this.width : 0;
+
+      let y = 0;
+      for (const line of lines) {
+        if (s.strokeColor) ctx.strokeText(line, xBase, y, s.maxWidth > 0 ? s.maxWidth : undefined);
+        ctx.fillText(line, xBase, y, s.maxWidth > 0 ? s.maxWidth : undefined);
+        y += s.lineHeight;
+      }
+    }
+  }
+
+  export type AnimateSpriteFrame = [
+    x: number,
+    y: number,
+    width: number,
+    height: number,
+    originX?: number,
+    originY?: number,
+    imageIndex?: number,
+  ];
+
+  export interface AnimateSpriteAnimation {
+    /** 指向 `frames` 的索引数组 */
+    frames: number[];
+    /** 播放速度倍率(1 = 使用 framerate) */
+    speed?: number;
+    /** 是否循环覆盖全局 playOnce */
+    loop?: boolean;
+  }
+
+  export interface AnimateSpriteConfig {
+    framerate: number;
+    images: string[];
+    frames: AnimateSpriteFrame[];
+    animations: Record<string, AnimateSpriteAnimation>;
+    playOnce?: boolean;
+    currentAnimation: string;
+  }
+
+  export class AnimateSprite extends RenderObject {
+    public framerate = 7;
+    public images: string[] = [];
+    public frames: AnimateSpriteFrame[] = [];
+    public animations: Record<string, AnimateSpriteAnimation> = {};
+    public playOnce = false;
+    public currentAnimation = "";
+
+    public playing = true;
+
+    private _frameCursor = 0;
+    private _accMs = 0;
+    private _resolvedImages: Array<any | null> = [];
+
+    constructor(config?: Partial<AnimateSpriteConfig>) {
+      super();
+      if (config) Object.assign(this, config);
+      this._resolvedImages = new Array(this.images.length).fill(null);
+      if (this.currentAnimation && !this.animations[this.currentAnimation]) {
+        // 若未配置 animations,则允许把 currentAnimation 当成 “默认帧序列” 的别名
+        this.animations[this.currentAnimation] = { frames: [0] };
+      }
+    }
+
+    public play(name?: string, opts?: { reset?: boolean; once?: boolean }): void {
+      if (name) this.currentAnimation = name;
+      if (opts?.reset ?? true) {
+        this._frameCursor = 0;
+        this._accMs = 0;
+      }
+      if (typeof opts?.once === "boolean") this.playOnce = opts.once;
+      this.playing = true;
+    }
+
+    public stop(): void {
+      this.playing = false;
+    }
+
+    public gotoAndStop(frameCursor: number): void {
+      this._frameCursor = Math.max(0, frameCursor | 0);
+      this._accMs = 0;
+      this.playing = false;
+    }
+
+    public get currentFrameIndex(): number {
+      const seq = this.getAnimationSequence();
+      if (seq.length <= 0) return 0;
+      const cursor = Math.max(0, Math.min(this._frameCursor, seq.length - 1));
+      return seq[cursor] ?? 0;
+    }
+
+    private getAnimationSequence(): number[] {
+      const anim = this.animations[this.currentAnimation];
+      if (anim?.frames?.length) return anim.frames;
+      // fallback:没有动画就按全部 frames 依次播放
+      return this.frames.map((_, i) => i);
+    }
+
+    private getEffectiveFrameDurationMs(): number {
+      const fps = Math.max(0.0001, this.framerate);
+      const base = 1000 / fps;
+      const anim = this.animations[this.currentAnimation];
+      const speed = anim?.speed ?? 1;
+      return base / Math.max(0.0001, speed);
+    }
+
+    public override update(dtMs: number): void {
+      if (!this.playing) return;
+
+      const seq = this.getAnimationSequence();
+      if (seq.length <= 1) return;
+
+      this._accMs += dtMs;
+      const frameDur = this.getEffectiveFrameDurationMs();
+      if (this._accMs < frameDur) return;
+
+      const steps = Math.floor(this._accMs / frameDur);
+      this._accMs = this._accMs % frameDur;
+
+      this._frameCursor += steps;
+
+      const anim = this.animations[this.currentAnimation];
+      const loop = anim?.loop ?? !this.playOnce;
+      if (loop) {
+        this._frameCursor = this._frameCursor % seq.length;
+      } else {
+        if (this._frameCursor >= seq.length - 1) {
+          this._frameCursor = seq.length - 1;
+          this.playing = false;
+        }
+      }
+    }
+
+    protected override draw(rc: RenderContext): void {
+      const { ctx, scene } = rc;
+      if (!this.frames.length) return;
+
+      const fIdx = this.currentFrameIndex;
+      const frame = this.frames[fIdx];
+      if (!frame) return;
+
+      const [sx, sy, sw, sh, originX = 0, originY = 0, imageIndex = 0] = frame;
+      if (sw <= 0 || sh <= 0) return;
+
+      const src = this.images[imageIndex] ?? this.images[0];
+      if (!src) return;
+
+      // 默认使用帧尺寸作为显示尺寸
+      if (this.width <= 0) this.width = sw;
+      if (this.height <= 0) this.height = sh;
+
+      let img = this._resolvedImages[imageIndex] ?? null;
+      img = img ?? scene.assets.getImageSync(src);
+      if (!img) {
+        scene.assets
+          .getImage(src)
+          .then((loaded) => {
+            this._resolvedImages[imageIndex] = loaded;
+          })
+          .catch(() => {});
+        return;
+      }
+
+      // drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh)
+      ctx.drawImage(img, sx, sy, sw, sh, -originX, -originY, this.width, this.height);
+    }
+  }
+
+  export class AssetManager {
+    private imageCache = new Map<string, Promise<any>>();
+    private imageResolved = new Map<string, any>();
+
+    constructor(private canvas: RendeCanvasInterface) {}
+
+    public getImage(src: string): Promise<any> {
+      if (this.imageResolved.has(src)) return Promise.resolve(this.imageResolved.get(src));
+      const inflight = this.imageCache.get(src);
+      if (inflight) return inflight;
+
+      const p = this.canvas.createImage(src).then((img) => {
+        this.imageResolved.set(src, img);
+        return img;
+      });
+      this.imageCache.set(src, p);
+      return p;
+    }
+
+    public getImageSync(src: string): any | null {
+      return this.imageResolved.get(src) ?? null;
+    }
+
+    public clear(): void {
+      this.imageCache.clear();
+      this.imageResolved.clear();
+    }
+  }
+
+  export type CanvasAdapterFactory = (...args: any[]) => RendeCanvasInterface;
+
+  export class CanvasAdapterRegistry {
+    private static registry = new Map<string, CanvasAdapterFactory>();
+
+    public static register(name: string, factory: CanvasAdapterFactory): void {
+      this.registry.set(name, factory);
+    }
+
+    public static get(name: string): CanvasAdapterFactory | undefined {
+      return this.registry.get(name);
+    }
+
+    public static create(name: string, ...args: any[]): RendeCanvasInterface {
+      const f = this.registry.get(name);
+      if (!f) throw new Error(`CanvasAdapter 未注册: ${name}`);
+      return f(...args);
+    }
+
+    public static has(name: string): boolean {
+      return this.registry.has(name);
+    }
+
+    public static names(): string[] {
+      return [...this.registry.keys()];
+    }
+  }
+
+  export class Scene implements Disposable {
+    public readonly root: Container = new Container("root");
+    public readonly assets: AssetManager;
+
+    private rafId: number | null = null;
+    private lastTs: number | null = null;
+    private currentDragObject: RenderObject | null = null;
+
+    public readonly canvas: RendeCanvasInterface;
+    public readonly width: number;
+    public readonly height: number;
+    public readonly backgroundColor: string;
+
+
+    public readonly events = {
+      handleTouchStart: (e: any) => {
+        const pos = this.getEventPosition(e);
+        const hit = this.root.hitTest(pos.x, pos.y);
+        if (hit) {
+          hit.emit("touchstart", e);
+          this.currentDragObject = hit;
+        }
+      },
+      handleTouchMove: (e: any) => {
+        if (this.currentDragObject) {
+          this.currentDragObject.emit("touchmove", e);
+        }
+      },
+      handleTouchEnd: (e: any) => {
+        if (this.currentDragObject) {
+          this.currentDragObject.emit("touchend", e);
+          this.currentDragObject.emit("click", e);
+          this.currentDragObject = null;
+        }
+      },
+      handleTouchCancel: (e: any) => {
+        if (this.currentDragObject) 
+          this.currentDragObject.emit("touchcancel", e);
+        this.currentDragObject = null;
+      },
+    };
+
+    constructor(
+      public options: {
+        canvas: RendeCanvasInterface,
+        width: number,
+        height: number,
+        backgroundColor: string,
+      },
+      public onInit: () => void,
+      public updateFn: (dtMs: number) => void,
+    ) {
+      this.canvas = options.canvas;
+      this.width = options.width;
+      this.height = options.height;
+      this.backgroundColor = options.backgroundColor;
+      this.assets = new AssetManager(this.canvas);
+    }
+
+    public async init() {
+      console.log(`[Canvas] init canvas ${this.width}x${this.height}`);
+      
+      await this.canvas.initCanvas(this.width, this.height);
+      this.onInit();
+    }
+
+    public clear(): void {
+      this.canvas.clearCanvas();
+      this.canvas.getCtx().fillStyle = this.backgroundColor;
+      this.canvas.getCtx().fillRect(0, 0, this.width, this.height);
+    }
+
+    public renderOnce(): void {
+      this.clear();
+      const rc: RenderContext = {
+        scene: this,
+        canvas: this.canvas,
+        ctx: this.canvas.getCtx(),
+      };
+      this.root.render(rc);
+    }
+
+    public tick(ts: number): void {
+      const dt = this.lastTs === null ? 16 : Math.max(0, ts - this.lastTs);
+      this.lastTs = ts;
+      this.updateFn(dt);
+      this.root.update(dt);
+      this.renderOnce();
+    }
+
+    public start(): void {
+      if (this.rafId !== null) return;
+      const loop = (ts: number) => {
+        this.tick(ts);
+        this.rafId = this.canvas.requestAnimationFrame(loop);
+      };
+      this.rafId = this.canvas.requestAnimationFrame(loop);
+    }
+
+    public stop(): void {
+      if (this.rafId === null) return;
+      this.canvas.cancelAnimationFrame(this.rafId);
+      this.rafId = null;
+      this.lastTs = null;
+    }
+
+    public dispose(): void {
+      this.stop();
+      this.assets.clear();
+      this.root.removeAll();
+    }
+
+    private getEventPosition(e: any): { x: number; y: number } {
+      if (e?.detail?.x !== undefined) return { x: e.detail.x, y: e.detail.y };
+      if (e?.offsetX !== undefined) return { x: e.offsetX, y: e.offsetY };
+      if (e?.touches?.[0]) return { x: e.touches[0].x, y: e.touches[0].y };
+      return { x: 0, y: 0 };
+    }
+  }
+}

+ 54 - 0
src/components/canvas/UniWeappRender.ts

@@ -0,0 +1,54 @@
+import { requireNotNull } from "@imengyu/imengyu-utils";
+import { MiniRender } from "./MiniRender";
+
+export class UniWeappRender implements MiniRender.RendeCanvasInterface {
+
+  constructor(private id: string, private componentInstance: any) {
+  }
+
+  private ctx: CanvasRenderingContext2D | null = null;
+  private width: number = 0;
+  private height: number = 0;
+  private canvas: any;
+
+  async initCanvas(width: number, height: number): Promise<void> {
+    this.width = width;
+    this.height = height;
+    await new Promise<void>((resolve) => {
+      uni.createSelectorQuery()
+        .in(this.componentInstance)
+        .select('#' + this.id) // 在 WXML 中填入的 id
+        .fields({ node: true }, (res) => {})
+        .exec((res) => {
+          const canvas = (res as any)[0].node
+          canvas.width = width
+          canvas.height = height
+          this.canvas = canvas
+          this.ctx = canvas.getContext('2d')
+          resolve()
+        });
+    })
+  }
+  createImage(src: string) {
+    return new Promise((resolve, reject) => {
+      const image = this.canvas.createImage()
+      image.onload = () => { resolve(image) }
+      image.onerror = () => { reject(new Error('Failed to load image')) }
+      image.src = src
+    })
+  }
+  clearCanvas(): void {
+    requireNotNull(this.ctx).clearRect(0, 0, this.width, this.height)
+  }
+  requestAnimationFrame(draw: (ts: number) => void): number {
+    return this.canvas.requestAnimationFrame(draw)
+  }
+  cancelAnimationFrame(id: number): void {
+    this.canvas.cancelAnimationFrame(id)
+  }
+  getCtx(): CanvasRenderingContext2D {
+    return requireNotNull(this.ctx)
+  }
+
+
+}

+ 51 - 0
src/components/composeabe/LoadQuerys.ts

@@ -0,0 +1,51 @@
+import { onLoad } from "@dcloudio/uni-app";
+import { nextTick, ref, type Ref } from "vue";
+
+/**
+ * 用于在页面加载时获取并处理页面参数的组合式函数。
+ * @param defaults - 页面参数的默认值对象,其类型为泛型 T,T 需继承自 Record<string, any>。
+ * @param afterLoad - 可选的回调函数,在页面加载完成且参数处理完毕后执行,接收处理后的参数对象作为参数。
+ */
+export function useLoadQuerys<T extends Record<string, any>>(
+  defaults: T, 
+  afterLoad?: (querys: T) => void
+) {
+  const querys = ref<T>(defaults) as Ref<T>; 
+  const rawQuerys = ref({}) as Ref<Record<string, any>>; 
+
+  onLoad((_querys) => {
+    rawQuerys.value = _querys || {};
+    if (_querys) {
+      for (const key in defaults) {
+        const isDefined = _querys[key] !== undefined && _querys[key] !== 'undefined';
+        if (typeof defaults[key] === 'number')
+          (querys.value as Record<string, any>)[key] = isDefined ? Number(_querys[key]) : defaults[key]; 
+        else if (typeof defaults[key] === 'boolean')
+          (querys.value as Record<string, any>)[key] = isDefined ? Boolean(_querys[key]) && _querys[key] != 'false' : defaults[key]; 
+        else if (typeof defaults[key] === 'object')
+          (querys.value as Record<string, any>)[key] = isDefined ? JSON.parse(decodeURIComponent(_querys[key])) : defaults[key]; 
+        else
+          querys.value[key] = isDefined ? decodeURIComponent(decodeURIComponent(_querys[key])) as any : defaults[key];
+      }
+      console.log('querys', querys.value);
+    }
+    nextTick(() => {
+      afterLoad?.(querys.value);
+    });
+  });
+
+  return {
+    querys,
+    rawQuerys,
+  }
+}
+
+export function stringDotNumbersToNumbers(a: number|number[]|string|undefined): number|number[]|undefined {
+  if (typeof a === 'string') {
+    if (a.includes(','))
+      return a.split(',').map(stringDotNumbersToNumbers) as number[]; 
+    else
+      return Number(a.replace(/\./g, '')); 
+  }
+  return a;
+}

+ 76 - 0
src/components/composeabe/MemoryTimeOut.ts

@@ -0,0 +1,76 @@
+/**
+ * MemoryTimeOut 工具类
+ * 用于记录操作时间,判断是否超时,支持重置功能
+ * 适用于 uni-app 环境
+ */
+
+export class MemoryTimeOut {
+  /** 唯一键名 */
+  private key: string;
+  /** 超时时间(毫秒) */
+  private timeout: number;
+
+  /**
+   * 构造函数
+   * @param uniqueKey 唯一键名,用于区分不同的超时记录
+   * @param timeout 超时时间(毫秒),默认3600000毫秒(1小时)
+   */
+  constructor(uniqueKey: string, timeout: number = 3600000) {
+    this.key = `MemoryTimeOut_${uniqueKey}`;
+    this.timeout = timeout; // 默认1小时
+  }
+
+  /**
+   * 记录当前时间
+   */
+  public recordTime(): void {
+    const timestamp = Date.now();
+    uni.setStorageSync(this.key, timestamp);
+  }
+
+  /**
+   * 判断是否超时
+   * @returns boolean 是否超时
+   */
+  public isTimeout(): boolean {
+    const storedTime = uni.getStorageSync(this.key);
+    if (!storedTime) {
+      return true; // 没有记录时间,视为超时
+    }
+    return Date.now() - storedTime > this.timeout;
+  }
+
+  /**
+   * 重置超时记录(清除存储的时间)
+   */
+  public reset(): void {
+    uni.removeStorageSync(this.key);
+  }
+
+  /**
+   * 获取剩余时间(毫秒)
+   * @returns number 剩余时间
+   */
+  public getRemainingTime(): number {
+    const storedTime = uni.getStorageSync(this.key);
+    if (!storedTime) {
+      return 0;
+    }
+    const remaining = this.timeout - (Date.now() - storedTime);
+    return Math.max(0, remaining);
+  }
+
+  /**
+   * 获取已过时间(毫秒)
+   * @returns number 已过时间
+   */
+  public getElapsedTime(): number {
+    const storedTime = uni.getStorageSync(this.key);
+    if (!storedTime) {
+      return 0;
+    }
+    return Date.now() - storedTime;
+  }
+}
+
+export default MemoryTimeOut;

+ 20 - 0
src/components/composeabe/StorageVar.ts

@@ -0,0 +1,20 @@
+import { onMounted, ref, watch } from "vue";
+
+export function useStorageVar<T>(key: string, defaultValue: T) {
+  const value = ref<T>(defaultValue);
+
+  watch(value, (newValue) => {
+    uni.setStorageSync(key, JSON.stringify(newValue));
+  })
+
+  onMounted(() => {
+    const storageValue = uni.getStorageSync(key);
+    if (storageValue) {
+      value.value = JSON.parse(storageValue);
+    }
+  })
+
+  return {
+    value,
+  }
+}

+ 15 - 0
src/components/composeabe/loader/LoaderCommon.ts

@@ -0,0 +1,15 @@
+import type { Ref } from "vue";
+
+export type LoaderLoadType = 'loading' | 'finished' | 'nomore' | 'error' | 'empty';
+
+/**
+ * 说明:
+ * * 通用加载器接口。
+ * @param P 加载参数类型
+ */
+export interface ILoaderCommon<P> {
+  error: Ref<string>;
+  status: Ref<LoaderLoadType>;
+  load: (refresh?: boolean, params?: P) => Promise<void>;
+  reload: () => Promise<void>;
+}

+ 73 - 0
src/components/composeabe/loader/SimpleDataLoader.ts

@@ -0,0 +1,73 @@
+import { onMounted, ref, type Ref } from "vue";
+import type { ILoaderCommon, LoaderLoadType } from "./LoaderCommon";
+
+export interface ISimpleDataLoader<T, P> extends ILoaderCommon<P> {
+  content: Ref<T|null>;
+  getLastParams: () => P | undefined;
+}
+
+/**
+ * 说明:
+ * * 简单数据加载器组合式代码。
+ * @param loader 数据加载函数
+ * @param loadWhenMounted 是否在挂载时自动加载数据
+ * @param emptyIfArrayEmpty 如果数组为空是否显示空状态
+ * @param showGlobalLoading 是否显示全局加载提示
+ */
+export function useSimpleDataLoader<T, P = any>(
+  loader: (params?: P) => Promise<T>,
+  loadWhenMounted = true,
+  emptyIfArrayEmpty = true,
+  showGlobalLoading = false,
+)  : ISimpleDataLoader<T, P>
+ {
+
+  const content = ref<T|null>(null) as Ref<T|null>;
+  const status = ref<LoaderLoadType>('loading');
+  const error = ref('');
+
+  let lastParams: P | undefined;
+
+  async function load(refresh?: boolean, params?: P) {
+    if (params)
+      lastParams = params;
+    status.value = 'loading';
+    if (showGlobalLoading)
+      uni.showLoading({ title: '加载中...' });
+
+    try {
+      const res = (await loader(params ?? lastParams)) as T;
+      content.value = res;
+      if (Array.isArray(res) && emptyIfArrayEmpty && (res as any[]).length === 0)
+        status.value = 'nomore';
+      else
+        status.value = 'finished';
+      error.value = '';
+    } catch(e) {
+      error.value = '' + e;
+      status.value = 'error';
+      console.log(e);
+      
+    } finally {
+      if (showGlobalLoading)
+        uni.hideLoading();
+    }
+  }
+
+  onMounted(() => {
+    if (loadWhenMounted) {
+      setTimeout(() => {
+        load(false);
+      }, (0.5 + Math.random()) * 500);
+    }
+  })
+
+  return {
+    content,
+    status,
+    error,
+    load,
+    reload: () => load(true),
+    getLastParams: () => lastParams,
+  }
+}

+ 95 - 0
src/components/composeabe/loader/SimplePageListLoader.ts

@@ -0,0 +1,95 @@
+import { onPullDownRefresh, onReachBottom } from "@dcloudio/uni-app";
+import { onMounted, ref, type Ref } from "vue";
+import type { ILoaderCommon, LoaderLoadType } from "./LoaderCommon";
+
+export interface ISimplePageListLoader<T, P> extends ILoaderCommon<P> {
+  list: Ref<T[]>;
+  page: Ref<number>;
+  total: Ref<number>;
+}
+
+/**
+ * 说明:
+ * * 简单页面分页列表加载器组合式代码。
+ * @param pageSize 每页数量
+ * @param loader 数据加载函数
+ * @param showGlobalLoading 是否显示全局加载提示
+ */
+export function useSimplePageListLoader<T, P = any>(
+  pageSize: number, 
+  loader: (page: number, pageSize: number, params?: P) => Promise<{ list: T[], total: number }>,
+  loadWhenMounted = true,
+  showGlobalLoading = false,
+)  : ISimplePageListLoader<T, P>
+{
+  
+  const status = ref<LoaderLoadType>('loading');
+  const error = ref('');
+  const page = ref(0);
+  const total = ref(0);
+  const list = ref<T[]>([]) as Ref<T[]>;
+
+  let lastParams: P | undefined;
+  let loading = false;
+
+  async function load(refresh: boolean = false, params?: P) {
+    if (loading) 
+      return;
+    if (params)
+      lastParams = params;
+    if (refresh) {
+      page.value = 0;
+      list.value = []; 
+    }
+    page.value++;
+    status.value = 'loading';
+    loading = true;
+    if (showGlobalLoading)
+      uni.showLoading({ title: '加载中...' });
+
+    try {
+      const res = (await loader(page.value, pageSize, lastParams));
+      list.value = list.value.concat(res.list as T[]);
+      total.value = res.total;
+      status.value = res.list.length > 0 ? 'finished' : (list.value.length > 0 ? 'nomore' : 'empty');
+      error.value = '';
+      loading = false;
+    } catch(e) {
+      console.error(e);
+      error.value = '' + e;
+      status.value = 'error';
+      loading = false;
+    } finally {
+      if (showGlobalLoading)
+        uni.hideLoading();
+    }
+  }
+
+  onPullDownRefresh(() => {
+    load(true, lastParams).then(() => {
+      uni.stopPullDownRefresh();
+    }).catch(() => {
+      uni.stopPullDownRefresh();
+    });
+  });
+  onReachBottom(() => {
+    if (status.value == 'nomore')
+      return;
+    load(false, lastParams);
+  });
+
+  onMounted(() => {
+    if (loadWhenMounted)
+      load(false, lastParams);
+  })
+
+  return {
+    list,
+    total,
+    page,
+    status,
+    error,
+    load,
+    reload: () => load(true),
+  }
+}

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

@@ -50,7 +50,7 @@ defineOptions({
 
 <template>
   <FlexRow
-    pointerEvents="box-none"
+    pointerEvents="none"
     innerClass="nana-popup-title"
     :innerStyle="{
       position: relative ? 'relative' : 'absolute',
@@ -65,6 +65,7 @@ defineOptions({
     <IconButton 
       v-if="closeable === true && closeIcon"
       :size="closeIconSize || theme.resolveThemeSize('PopupCloseIconSize', 25)"
+      :innerStyle="{ pointerEvents: 'auto' }"
     />
     <Text :text="title" />
     <IconButton 
@@ -72,6 +73,7 @@ defineOptions({
       :icon="(closeIcon as string) || theme.resolveThemeSize('PopupCloseIconName', 'close')!"
       :size="closeIconSize || theme.resolveThemeSize('PopupCloseIconSize', 25)"
       :color="theme.resolveThemeSize('PopupCloseIconColor', 'text.content')"
+      :innerStyle="{ pointerEvents: 'auto' }"
       @click="emit('close')"
     />
   </FlexRow>

+ 4 - 3
src/components/display/Avatar.vue

@@ -15,6 +15,7 @@
       }"
     />
     <text 
+      class="text"
       v-else 
       :style="{ 
         width: themeContext.resolveThemeSize(size),
@@ -73,12 +74,12 @@ export interface AvatarProps {
    * 头像的大小。
    * @default 40
    */
-  size?: number,
+  size?: number|string,
   /**
    * 头像圆角大小
    * @default 0
    */
-  radius?: number,
+  radius?: number|string,
   /**
    * 头像是否是圆型,设置后 radius 无效
    * @default false
@@ -124,7 +125,7 @@ defineEmits([ 'click' ]);
   overflow: hidden;
   flex-shrink: 0;
 
-  text {
+  .text {
     display: block;
     text-align: center;
     user-select: none;

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

@@ -248,7 +248,6 @@ const showBadge = computed(() =>
   justify-content: center;
   align-items: center;
   text-align: center;
-  align-self: flex-start;
   overflow: hidden;
   flex-shrink: 0;
 }

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

@@ -6,6 +6,7 @@
     :flexShrink="0"
     :innerStyle="{
       ...themeStyles.view.value,
+      ...innerStyle,
       backgroundColor: themeContext.resolveThemeColor(backgroundColor),
     }"
     @click="emit('click')"
@@ -60,6 +61,10 @@ export interface NoticeBarProps {
    */
   iconProps?: IconProps;
   /**
+   * 自定义样式
+   */
+  innerStyle?: ViewStyle;
+  /**
    * 内容
    */
   content?: string;

+ 2 - 2
src/components/display/PreviewItem.vue

@@ -23,7 +23,7 @@
           :key="k" 
           :width="100" 
           :height="100"
-          :radius="12"
+          radius="radius.sm"
           round
           v-bind="(valueProps as ImageProps)" 
           :src="item"
@@ -34,7 +34,7 @@
         v-else
         :width="100"
         :height="100"
-        :radius="12"
+        radius="radius.sm"
         round
         v-bind="(valueProps as ImageProps)"
         :src="value"

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

@@ -67,6 +67,7 @@
       }),
         borderRadius: barRadius,
         backgroundColor: barBackgroundColor,
+        ...props.backgroundStyle,
       }"
     >
       <view
@@ -209,6 +210,11 @@ export interface ProgressProps {
    * 进度的样式
    */
   progressStyle?: ViewStyle,
+
+  /**
+   * 背景的样式
+   */
+  backgroundStyle?: ViewStyle,
 }
 
 
@@ -240,6 +246,7 @@ const width = computed(() => themeContext.resolveSize(props.width));
 const progressStyles = computed(() => ({
   borderRadius: barRadius.value,
   backgroundColor: barColor.value,
+  ...props.progressStyle,
   ...selectStyleType<ViewStyle, ProgressTypes>(props.type, 'left-right', {
     'left-right': { left: 0, height: height.value },
     'right-left': { right: 0, height: height.value },

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

@@ -281,7 +281,6 @@ const style = computed(() => {
   flex-basis: auto;
   min-width: 20rpx;
   width: auto;
-  align-self: flex-start;
 }
 
 </style>

+ 32 - 3
src/components/display/block/BackgroundBox.vue

@@ -2,7 +2,6 @@
   <!-- 组件:背景图显示盒子 -->
   <FlexView
     v-bind="$props"
-    center
     :flexShrink="0"
     :innerStyle="style" 
   >
@@ -57,6 +56,18 @@ export interface BackgroundBoxProps extends FlexProps {
    */
   color2?: string;
   /**
+   * 背景颜色(3)。
+   * 
+   * 格式:字符串格式或主题中定义的颜色预设。
+   */
+  color3?: string;
+  /**
+   * 背景颜色(2)位置。
+   * 
+   * 格式:50% 表示颜色(2)位置为50%,即颜色(2)在背景中间。
+   */
+  color2Position?: string;
+  /**
    * 圆角。
    */
   radius?: string | number;
@@ -136,8 +147,26 @@ const style = computed(() => {
   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)})`;
+  if (props.color1 !== undefined && (props.color2 !== undefined || props.color3 !== undefined)) {
+    // 支持 color2Position, color3
+    if (props.color3 !== undefined) {
+      // 当有 color3 时,支持三色渐变
+      const colorStops = [
+        `${theme.resolveThemeColor(props.color1)} 0%`,
+        props.color2Position !== undefined
+          ? `${theme.resolveThemeColor(props.color2)} ${props.color2Position}`
+          : `${theme.resolveThemeColor(props.color2)} 50%`,
+        `${theme.resolveThemeColor(props.color3)} 100%`
+      ];
+      o.background = `linear-gradient(${props.gradientAngle || 180}deg, ${colorStops.join(', ')})`;
+    } else {
+      // 仅 color1/color2 时,支持 color2Position
+      if (props.color2Position !== undefined) {
+        o.background = `linear-gradient(${props.gradientAngle || 180}deg, ${theme.resolveThemeColor(props.color1)} 0%, ${theme.resolveThemeColor(props.color2)} ${props.color2Position})`;
+      } else {
+        o.background = `linear-gradient(${props.gradientAngle || 180}deg, ${theme.resolveThemeColor(props.color1)}, ${theme.resolveThemeColor(props.color2)})`;
+      }
+    }
 
   } else if (props.backgroundImage) {
     const b = props.backgroundCutBorder;

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

@@ -36,6 +36,7 @@
         <Text class="nana-image-desc" color="text.second" v-bind="descProps" :text="desc" />
       </BackgroundBox>
     </slot>
+    <slot name="footer" />
   </Touchable>
 </template>
 

+ 8 - 14
src/components/display/block/ImageBlock2.vue

@@ -9,10 +9,10 @@
   >
     <Image 
       :src="src" 
+      :width="imageWidth || '100%'"
       :height="imageHeight"
       :radius="imageRadius"
-      width="100%"
-      mode="aspectFill"
+      :mode="imageHeight ? 'aspectFill' : 'widthFix'"
     />
     <slot name="desc">
       <FlexCol :padding="15">
@@ -26,6 +26,7 @@
             <slot name="extra" />
           </template>
         </IconTextBlock>
+        <slot name="footer" />
       </FlexCol>
     </slot>
   </Touchable>
@@ -60,6 +61,10 @@ export interface ImageBlock2Props extends Partial<FlexProps> {
    */
   imageHeight?: string | number;
   /**
+   * 图片宽度。
+   */
+  imageWidth?: string | number;
+  /**
    * 图片的路径。
    */
   src?: string;
@@ -84,7 +89,6 @@ export interface ImageBlock2Props extends Partial<FlexProps> {
 const theme = useTheme();
 const props = withDefaults(defineProps<ImageBlock2Props>(), {
   width: 400,
-  imageHeight: 250,
   imageRadius: 0,
   direction: 'column',
   backgroundColor: "white",
@@ -94,14 +98,4 @@ const props = withDefaults(defineProps<ImageBlock2Props>(), {
 defineEmits([	
   "click"	
 ])
-</script>
-
-<style lang="scss">
-.nana-image-desc {
-  color: #fff;
-  font-size: 25rpx;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-}
-</style>
+</script>

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

@@ -29,7 +29,7 @@
           </template>
         </IconTextBlock>
       </slot>
-
+      <slot name="footer" />
     </FlexView>
   </Touchable>
 </template>

+ 2 - 0
src/components/display/parse/Parse.vue

@@ -128,6 +128,8 @@ const parseHtml = (html: string): ParseNode[] => {
       nodes.push(childNode);
     }
   }
+  console.log(doc);
+  
   return nodes;
 };
 

+ 5 - 3
src/components/display/title/SubTitle.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { useTheme, type ThemePaddingMargin, type ThemePaddingMarginProp } from '@/components/theme/ThemeDefine';
+import { useTheme, type ThemePaddingOrMarginType } from '@/components/theme/ThemeDefine';
 import Icon from '@/components/basic/Icon.vue';
 import Text, { type TextProps } from '@/components/basic/Text.vue';
 import Touchable from '@/components/feedback/Touchable.vue';
@@ -13,7 +13,7 @@ const props = withDefaults(defineProps<{
   showMore?: boolean,
   badgeColor?: string,
   backgroundColor?: string,
-  padding?: ThemePaddingMarginProp,
+  padding?: ThemePaddingOrMarginType,
   titleProps?: TextProps,
   badgeStyle?: object,
 }>(), {
@@ -50,7 +50,9 @@ const finalBadgeStyle = theme.useThemeStyle({
         }"></view>
       </slot>
       <slot name="titlePrefix" />
-      <Text fontConfig="h4" :text="title" v-bind="titleProps" />
+      <slot name="title">
+        <Text fontConfig="h4" :text="title" v-bind="titleProps" />
+      </slot>
       <slot name="titleSuffix" />
     </FlexRow>
     <FlexRow align="center">

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

@@ -54,7 +54,7 @@
         <Text 
           v-if="message"
           :text="message"
-          :fontSize="themeContext.resolveThemeSize('AlertTitleFontSize', 'fontSize.medium')"
+          :fontSize="themeContext.resolveThemeSize('AlertTitleFontSize', 'fontSize.md')"
           :fontWeight="themeContext.getVar('AlertTitleFontWeight', 'bold')"
           :color="color"
           :innerStyle="themeStyles.message.value"
@@ -65,7 +65,7 @@
         <Text 
           v-if="description"
           :text="description"
-          :fontSize="themeContext.resolveThemeSize('AlertMessageFontSize', 'fontSize.small')"
+          :fontSize="themeContext.resolveThemeSize('AlertMessageFontSize', 'fontSize.sm')"
           :color="color"
           :lines="lines"
           :innerStyle="{

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

@@ -245,7 +245,7 @@ async function updateDialogMargin() {
       })
       .exec();
   });
-  const systemInfo = uni.getSystemInfoSync();
+  const systemInfo = uni.getWindowInfo();
   const pages = getCurrentPages();
   let v = 0
   if (topContext.direction.value === 'up') {

+ 6 - 0
src/components/form/Field.vue

@@ -100,6 +100,7 @@
             :placeholder="placeholder"
             :placeholder-style="`color: ${themeContext.resolveThemeColor(error ? errorTextColor : placeholderTextColor)}`"
             confirm-type="done"
+            :rows="rows"
             :maxlength="maxLength"
             :disabled="disabled || readonly"
             @input="onInput"
@@ -289,6 +290,11 @@ export interface FieldProps {
    */
   multiline?: boolean;
   /**
+   * 多行文字的行数
+   * @default undefined
+   */
+  rows?: number|string;
+  /**
    * 多行文字下是否自动调整高度
    * @default false
    */

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

@@ -1,7 +1,7 @@
 <template>
   <IconButton
     v-if="!isListStyle"
-    icon="add"
+    :icon="themeContext.getVar('UploaderAddIcon', 'add')"
     :size="themeContext.getVar('UploaderAddIconSize', 60)"
     :pressedBackgroundColor="themeContext.getVar('UploaderListAddItemPressedBackgroundColor', 'pressed.button')"
     :buttonStyle="{
@@ -19,7 +19,7 @@
 import Button from '../basic/Button.vue';
 import IconButton from '../basic/IconButton.vue';
 import { useTheme, type ViewStyle } from '../theme/ThemeDefine';
-import { DynamicColor, DynamicSize } from '../theme/ThemeTools';
+import { DynamicColor, DynamicSize, DynamicVar } from '../theme/ThemeTools';
 
 export interface UploaderListAddItemProps {
   style?: ViewStyle;
@@ -35,6 +35,9 @@ const themeStyles = themeContext.useThemeStyles({
   itemAddButton: {
     overflow: 'hidden',
     backgroundColor: DynamicColor('UploaderListAddItemBackgroundColor', 'button'),
+    backgroundImage: DynamicVar('UploaderListAddItemBackgroundImage', 'none'),
+    backgroundSize: '100% 100%',
+    backgroundRepeat: 'no-repeat',
     borderRadius: DynamicSize('UploaderListAddItemBorderRadius', 20),
     margin: DynamicSize('UploaderListAddItemMargin', 4),
   },

+ 33 - 7
src/components/layout/BaseView.ts

@@ -1,5 +1,5 @@
 import { computed } from "vue";
-import { useTheme } from "../theme/ThemeDefine";
+import { useTheme, type ThemePaddingMargin } from "../theme/ThemeDefine";
 import type { FlexProps } from "./FlexView.vue";
 import { configMargin, configPadding } from "../theme/ThemeTools";
 import { ObjectUtils } from "@imengyu/imengyu-utils";
@@ -33,12 +33,13 @@ export function useBaseViewStyleBuilder(props: FlexProps) {
       borderStyle: props.borderStyle,
       boxShadow: props.shadow ? themeContext.getVar('shadow.' + props.shadow, undefined) : undefined,
       zIndex: props.zIndex,
+      pointerEvents: props.pointerEvents,
     }
 
     //内边距样式
-    configPadding(obj, themeContext.theme.value, props.padding as any);
+    configPadding(obj, themeContext, props.padding);
     //外边距样式
-    configMargin(obj, themeContext.theme.value, props.margin as any);
+    configMargin(obj, themeContext, props.margin);
 
     if (props.innerStyle)
       ObjectUtils.cloneValuesToObject(props.innerStyle, obj);
@@ -48,24 +49,44 @@ export function useBaseViewStyleBuilder(props: FlexProps) {
         obj.paddingTop = obj.paddingVertical;
       if (obj.paddingBottom === undefined)
         obj.paddingBottom = obj.paddingVertical;
-      obj.paddingVertical = undefined;
+      delete obj.paddingVertical;
     }
     if (obj.paddingHorizontal) {
       if (obj.paddingLeft === undefined)
         obj.paddingLeft = obj.paddingHorizontal;
       if (obj.paddingRight === undefined)
         obj.paddingRight = obj.paddingHorizontal;
-      obj.paddingHorizontal = undefined;
+      delete obj.paddingHorizontal;
     }
     if (obj.marginVertical) {
       obj.marginTop = obj.marginVertical;
       obj.marginBottom = obj.marginVertical;
-      obj.marginVertical = undefined;
+      delete obj.marginVertical;
     }
     if (obj.marginHorizontal) {
       obj.marginLeft = obj.marginHorizontal;
       obj.marginRight = obj.marginHorizontal;
-      obj.marginHorizontal = undefined;
+      delete obj.marginHorizontal;
+    }
+
+    if (props.inset) {
+      if (Array.isArray(props.inset)) {
+        obj.top = themeContext.resolveThemeSize(props.inset[0]);
+        obj.right = themeContext.resolveThemeSize(props.inset[1]);
+        obj.bottom = themeContext.resolveThemeSize(props.inset[2]);
+        obj.left = themeContext.resolveThemeSize(props.inset[3]);
+      } else if (typeof props.inset === 'object') {
+        obj.left = themeContext.resolveThemeSize(props.inset.l);
+        obj.right = themeContext.resolveThemeSize(props.inset.r);
+        obj.top = themeContext.resolveThemeSize(props.inset.t);
+        obj.bottom = themeContext.resolveThemeSize(props.inset.b);
+      } else {
+        const v = themeContext.resolveThemeSize(props.inset);
+        obj.left = v;
+        obj.right = v;
+        obj.top = v;
+        obj.bottom = v;
+      }
     }
 
     //绝对距样式
@@ -80,6 +101,11 @@ export function useBaseViewStyleBuilder(props: FlexProps) {
     if (typeof props.flex !== 'undefined')
       obj.flex = props.flex;
 
+    for (const key in obj) {
+      if (obj[key] === undefined)
+        delete obj[key];
+    }
+
     return obj
   });
 

+ 20 - 13
src/components/layout/FlexView.vue

@@ -20,15 +20,16 @@
  * 组件说明:Flex组件,用于一些布局中快速写容器,是一系列盒子的基础组件。
  */
 import { computed, getCurrentInstance } from 'vue';
-import { type ThemePaddingMargin } from '../theme/ThemeDefine';
 import { RandomUtils } from '@imengyu/imengyu-utils';
 import { useBaseViewStyleBuilder } from './BaseView';
+import type { ThemePaddingOrMarginType, ThemeSizeType } from '../theme/ThemeDefine';
 
 export type FlexDirection = "row"|"column"|'row-reverse'|'column-reverse';
 export type FlexJustifyType =  'flex-start' | 'flex-end' | 'center' |'space-between' |'space-around' |'space-evenly';
 export type FlexAlignType = "stretch"|'center'|'start'|'end'|'flex-start' | 'flex-end' | 'center';
 export type StateType = 'default' | 'active' | 'pressed';
 
+
 export interface FlexProps {
   innerId?: string,
   /**
@@ -74,7 +75,7 @@ export interface FlexProps {
   /**
    * flexBasis 参数
    */
-  flexBasis?: number|string,
+  flexBasis?: ThemeSizeType,
   /**
    * flexGrow 参数
    */
@@ -86,26 +87,31 @@ export interface FlexProps {
   /**
    * 内边距参数(支持数字或数组)
    */
-  padding?: number|Array<number>|ThemePaddingMargin,
+  padding?: ThemePaddingOrMarginType,
   /**
    * 外边距参数(支持数字或数组)
    */
-  margin?: number|Array<number>|ThemePaddingMargin,
+  margin?: ThemePaddingOrMarginType,
   /**
    * 位置参数
    */
-  top?: number|string,
-  right?: number|string,
-  bottom?: number|string,
-  left?: number|string,
+  top?: ThemeSizeType,
+  right?: ThemeSizeType,
+  bottom?: ThemeSizeType,
+  left?: ThemeSizeType,
+  /**
+   * 设置元素与其父元素之间的距离(支持数字或数组,等同于 top, right, bottom, left,
+   * 但优先级比它们低),
+   */
+  inset?: ThemePaddingOrMarginType,
   /**
    * 圆角
    */
-  radius?: number|string,
+  radius?: ThemeSizeType,
   /**
    * 间距
    */
-  gap?: number|number[]|string,
+  gap?: ThemeSizeType|ThemeSizeType[],
   /**
    * 背景颜色
    */
@@ -125,7 +131,7 @@ export interface FlexProps {
   /**
    * 边框宽度
    */
-  borderWidth?: number|string,
+  borderWidth?: ThemeSizeType,
   /**
    * 边框样式
    */
@@ -133,12 +139,13 @@ export interface FlexProps {
   /**
    * 宽度
    */
-  width?: number|string,
+  width?: ThemeSizeType,
   /**
    * 高度
    */
-  height?: number|string,
+  height?: ThemeSizeType,
   overflow?: 'visible'|'hidden'|'scroll'|'auto'
+  pointerEvents?: 'auto'|'box-none'|'box-only'|'none',
   /**
    * 层级
    */

+ 41 - 0
src/components/layout/masonry/MasonryGrid.vue

@@ -0,0 +1,41 @@
+<template>
+  <!-- <FlexView v-if="supportsGridMasonry" v-bind="props" :innerStyle="{
+    display: 'grid',
+    gridTemplateColumns: `repeat(${columnCount}, 1fr)`, /* 定义3列 */
+    gridTemplateRows: 'masonry', /* 行方向采用瀑布流布局 */
+    rowGap: resolveThemeSize(rowSpacing), /* 间距 */
+    columnGap: resolveThemeSize(columnSpacing), /* 间距 */
+    ...(innerStyle || {}),
+  }">
+    <slot />
+  </FlexView> -->
+  <FlexView v-bind="props" :innerStyle="{
+    display: 'block',
+    columnCount: columnCount, 
+    columnGap: resolveThemeSize(columnSpacing),
+    ...(innerStyle || {}),
+  }">
+    <slot />
+  </FlexView>
+</template>
+
+<script setup lang="ts">
+import { useTheme } from '@/components/theme/ThemeDefine';
+import FlexView, { type FlexProps } from '../FlexView.vue';
+
+export interface MasonryGridProps extends Omit<FlexProps, 'justify' | 'align' | 'direction'> {
+  columnCount?: number;
+  columnSpacing?: number;
+  rowSpacing?: number;
+}
+
+// const supportsGridMasonry = CSS.supports('grid-template-rows', 'masonry');
+
+const props = withDefaults(defineProps<MasonryGridProps>(), {
+  columnCount: 2,
+  columnSpacing: 10,
+  rowSpacing: 10,
+});
+
+const { resolveThemeSize } = useTheme();
+</script>

+ 20 - 0
src/components/layout/masonry/MasonryGridItem.vue

@@ -0,0 +1,20 @@
+<template>
+  <FlexView v-bind="props" :innerStyle="{
+    breakInside: 'avoid',
+    pageBreakInside: 'avoid', /* For older browsers */
+    ...(innerStyle || {}),
+  }">
+    <slot />
+  </FlexView>
+</template>
+
+<script setup lang="ts">
+import FlexView, { type FlexProps } from '../FlexView.vue';
+
+export interface MasonryGridItemProps extends FlexProps {
+}
+
+const props = withDefaults(defineProps<MasonryGridItemProps>(), {
+});
+
+</script>

+ 1 - 1
src/components/layout/space/SafeAreaMargin.vue

@@ -10,7 +10,7 @@
 </template>
 
 <script setup lang="ts">
-const systemInfo = uni.getSystemInfoSync();
+const systemInfo = uni.getWindowInfo();
 const safeAreaInsets = systemInfo?.safeAreaInsets || {
   top: 0,
   bottom: 0,

+ 3 - 1
src/components/layout/space/SafeAreaPadding.vue

@@ -1,5 +1,7 @@
 <template>
   <view :style="{
+    position: 'relative',
+    boxSizing: 'border-box',
     width: '100%',
     height: '100%',
     paddingTop: props.top ? `${safeAreaInsets.top}px` : undefined,
@@ -12,7 +14,7 @@
 </template>
 
 <script setup lang="ts">
-const systemInfo = uni.getSystemInfoSync();
+const systemInfo = uni.getWindowInfo();
 const safeAreaInsets = systemInfo.safeAreaInsets || {
   top: 0,
   bottom: 0,

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

@@ -21,7 +21,7 @@
 <script setup lang="ts">
 import { useTheme } from '@/components/theme/ThemeDefine';
 
-const systemInfo = uni.getSystemInfoSync();
+const systemInfo = uni.getWindowInfo();
 const height = systemInfo.statusBarHeight || 0;
 
 const themeContext = useTheme();
@@ -54,7 +54,7 @@ export interface StatusBarSpaceProps {
 }
 
 const props = withDefaults(defineProps<StatusBarSpaceProps>(), {
-  backgroundColor: 'white',
+  backgroundColor: 'transparent',
   extendsBackgroundColorHeight: 0,
   includeStatus: true,
   includeHeader: false,

+ 1 - 1
src/components/layout/space/XBarSpace.vue

@@ -3,7 +3,7 @@
 </template>
 
 <script setup lang="ts">
-const systemInfo = uni.getSystemInfoSync();
+const systemInfo = uni.getWindowInfo();
 const safeAreaBottom = systemInfo.safeAreaInsets?.bottom || 0;// 底部安全区距离
 
 defineOptions({

+ 25 - 6
src/components/list/FixedVirtualList.vue

@@ -8,12 +8,11 @@
       class="virtual-list-scroll"
       :scroll-y="direction === 'vertical'"
       :scroll-x="direction === 'horizontal'"
-      :scroll-left="$attrs.scrollLeft"
-      :scroll-anchoring="$attrs.scrollAnchoring"
-      :scroll-top="$attrs.scrollTop"
-      :scroll-into-view="$attrs.scrollIntoView"
-      :scroll-with-animation="$attrs.scrollWithAnimation"
-      :show-scrollbar="$attrs.showScrollbar"
+      :scroll-left="scrollLeft"
+      :scroll-top="scrollTop"
+      :scroll-into-view="scrollIntoView"
+      :scroll-with-animation="scrollWithAnimation"
+      :show-scrollbar="showScrollbar"
       @scroll="handleScroll"
     >
       <slot name="prefix" />
@@ -82,6 +81,26 @@ const props = defineProps({
     default: 'vertical',
     validator: (val: string) => ['vertical', 'horizontal'].includes(val)
   },
+  scrollLeft: {
+    type: Number,
+    default: 0
+  },
+  scrollTop: {
+    type: Number,
+    default: 0
+  },
+  scrollIntoView: {
+    type: String,
+    default: ''
+  },
+  scrollWithAnimation: {
+    type: Boolean,
+    default: false
+  },
+  showScrollbar: {
+    type: Boolean,
+    default: false
+  },
   /**
    * 缓冲区大小(可视区域外额外渲染的条目数量)
    * @default 5

+ 5 - 10
src/components/list/IndexList.vue

@@ -242,16 +242,11 @@ const activeIndex = ref(-1);
 const scrollCurrentValue = ref(0);
 
 function handleScroll(e: any) {
-  if (scrollCurrentValue.value != e.detail.scrollTop) {
-    scrollCurrentValue.value = e.detail.scrollTop;
-    if (!flushLock) {
-      const index = Math.floor(scrollCurrentValue.value / props.itemHeight);
-      for (let i = index; i >= 0; i--) {
-        if (groupedData.value.list[i].isHeader) {
-          activeIndex.value = groupedData.value.list[i].headerIndex || -1;
-          break;
-        }
-      }
+  const index = Math.floor(e.detail.scrollTop / props.itemHeight);
+  for (let i = index; i >= 0; i--) {
+    if (groupedData.value.list[i].isHeader) {
+      activeIndex.value = groupedData.value.list[i].headerIndex ?? -1;
+      break;
     }
   }
 }

+ 115 - 0
src/components/loader/SimplePageContentLoader.vue

@@ -0,0 +1,115 @@
+<template>
+  <view
+    v-if="loader?.status.value == 'loading'"
+    class="loader-view center"
+  >
+    <LoadingPage loadingText="加载中" textSize="18" />
+  </view>
+  <view
+    v-else-if="loader?.status.value == 'error'"
+    class="loader-view"
+  >
+    <Empty
+      image="error"
+      :description="loader.error.value"
+    >
+      <Height :height="20" />
+      <Button type="primary" text="刷新" @click="handleRetry" />
+    </Empty>
+  </view> 
+  <view
+    v-if="showEmpty || loader?.status.value == 'nomore'"
+    class="loader-view"
+  >
+    <Empty
+      image="search"
+      :description="emptyView?.text ?? '暂无数据'"
+    >
+      <Height :height="20" />
+      <Button 
+        v-if="emptyView?.button"  
+        type="primary"
+        :text="emptyView?.buttonText ?? '刷新'"
+            @click="() => emptyView?.buttonClick ? emptyView?.buttonClick() : handleRetry()"
+      />
+    </Empty>
+  </view>
+  <image 
+    v-if="lazy && !loaded"
+    :lazy-load="true"
+    @load="handleLoad"
+    @error="handleLoad"
+    src="https://mn.wenlvti.net/app_static/empty.jpg"
+    style="width:0px;height:0px"
+  />
+
+  <slot />
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref, type PropType } from 'vue';
+import Empty from '@/components/feedback/Empty.vue';
+import Button from '@/components/basic/Button.vue';
+import LoadingPage from '@/components/display/loading/LoadingPage.vue';
+import Height from '@/components/layout/space/Height.vue';
+import type { ILoaderCommon } from '../composeabe/loader/LoaderCommon';
+
+const props = defineProps({	
+  loader: {
+    type: Object as PropType<ILoaderCommon<any>>,
+    default: null,
+  },
+  lazy: {
+    type: Boolean,
+    default: false, 
+  },
+  autoLoad: {
+    type: Boolean,
+    default: false, 
+  },
+  showEmpty: {
+    type: Boolean,
+    default: false, 
+  },
+  emptyView: {
+    type: Object as PropType<{
+      text: string,
+      buttonText?: string,
+      button?: boolean,
+      buttonClick?: () => void,
+    }>,
+    default: null,
+  },
+})
+
+const loaded = ref(false);
+
+onMounted(() => {
+  loaded.value = false;
+  if (props.autoLoad)
+    handleLoad(); 
+});
+
+function handleRetry() {
+  props.loader.reload();
+}
+function handleLoad() {
+  if (loaded.value) 
+    return;
+  loaded.value = true;
+  props.loader.reload();
+}
+</script>
+
+<style lang="scss">
+.loader-view {
+  position: relative;
+  min-height: 200rpx;
+
+  &.center {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+  }
+}
+</style>

+ 35 - 0
src/components/loader/SimplePageListLoader.vue

@@ -0,0 +1,35 @@
+<template>
+  <slot />
+  <Loadmore
+    v-if="loader.status.value == 'loading' 
+      || (loader.status.value == 'nomore' && !$slots.empty)" 
+    :status="loader.status.value" 
+  />
+  <slot v-else-if="loader.status.value == 'empty'" name="empty">
+    <Empty description="暂无数据" />
+  </slot>
+  <Loadmore 
+    v-else-if="loader.status.value == 'error'"
+    status="loadmore" 
+    :loadmoreText="loader.error.value" 
+    @loadmore="handleRetry" 
+  />
+</template>
+
+<script setup lang="ts">
+import type { PropType } from 'vue';
+import type { ISimplePageListLoader } from '../composeabe/loader/SimplePageListLoader';
+import Loadmore from '@/components/display/loading/Loadmore.vue';
+import Empty from '../feedback/Empty.vue';
+
+const props = defineProps({	
+  loader: {
+    type: Object as PropType<ISimplePageListLoader<any, any>>,
+    default: null,
+  },
+})
+
+function handleRetry() {
+  props.loader.reload();
+}
+</script>

+ 17 - 4
src/components/nav/NavBar.vue

@@ -12,6 +12,7 @@
       v-if="align !== 'left'"
       class="nana-nav-button-wrapper" :style="{
         marginRight: rightPillSpace ? `${menuButtonInfo.width}px` : undefined,
+        width: theme.resolveThemeSize(leftWidth),
       }"
     >
       <slot v-if="showLeftButton && leftButton" name="left">
@@ -91,8 +92,15 @@ export interface NavBarProps {
   height?: number|string;
   /**
    * 左侧按钮
+   * 
+   * 特殊按钮:
+   * * back:返回按钮
+   * * menu:菜单按钮
+   * * search:搜索按钮
+   * * setting:设置按钮
+   * @default ''
    */
-  leftButton?: NavBarButtonTypes,
+  leftButton?: NavBarButtonTypes|string,
   /**
    * 标题文字,支持自定义元素
    */
@@ -105,7 +113,7 @@ export interface NavBarProps {
   /**
    * 右侧按钮
    */
-  rightButton?: NavBarButtonTypes,
+  rightButton?: NavBarButtonTypes|string,
   /**
    * 是否显示右侧按钮
    * @default true
@@ -125,6 +133,8 @@ export interface NavBarProps {
    * @default true
    */
   showLeftButton?: boolean;
+
+  leftWidth?: number|string;
   /**
    * 自定义背景颜色
    */
@@ -161,13 +171,14 @@ export interface NavBarProps {
   innerClass?: any;
 }
 
-function getButton(type: NavBarButtonTypes) {
+function getButton(type: NavBarButtonTypes|string) {
   let button = '';
   switch (type) {
     case 'back': button = 'arrow-left-bold'; break;
     case 'menu': button = 'elipsis'; break;
     case 'search': button = 'search'; break;
     case 'setting': button = 'setting'; break;
+    default: button = type; break;
   }
   return button;
 }
@@ -203,7 +214,7 @@ const titleTextStyle = theme.useThemeStyle({
   paddingRight: DynamicSize('NavBarTitlePaddingHorizontal', 15),
 });
 
-function handleButtonNavBack(button: NavBarButtonTypes, callback: () => void) {
+function handleButtonNavBack(button: NavBarButtonTypes|string, callback: () => void) {
   if (button === 'back') {
     if (isTopLevelPage()) {
       uni.reLaunch({
@@ -236,6 +247,7 @@ function handleButtonNavBack(button: NavBarButtonTypes, callback: () => void) {
 }
 .nana-nav-button-wrapper {
   position: relative;
+  display: flex;
   flex-direction: row;
   justify-content: flex-start;
   height: 100%;
@@ -243,6 +255,7 @@ function handleButtonNavBack(button: NavBarButtonTypes, callback: () => void) {
 }
 .nana-nav-button-wrapper-end {
   position: relative;
+  display: flex;
   flex-direction: row;
   justify-content: flex-end;
   height: 100%;

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

@@ -107,7 +107,7 @@ provide('TabBarContext', {
   resetCounter,
 })
 
-const systemInfo = uni.getSystemInfoSync();
+const systemInfo = uni.getWindowInfo();
 const safeAreaBottom = systemInfo.safeAreaInsets?.bottom || 0;// 底部安全区距离
 
 onMounted(() => {

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

@@ -3,18 +3,34 @@ import type { ThemeConfig } from "./ThemeDefine";
 /** 默认主题配置 */
 export const DefaultTheme : ThemeConfig = {
   varOverrides: {
-    spaceSize: {
+    space: {
       xs: '10rpx',
       sm: '15rpx',
       md: '20rpx',
       lg: '30rpx',
+      xl: '40rpx',
+      '2xl': '50rpx',
+      '3xl': '60rpx',
+      '4xl': '70rpx',
+      '5xl': '80rpx',
     },
     fontSize: {
-      mini: 22,
-      small: 26,
-      medium: 30,
-      large: 38,
-      larger: 46,
+      xs: 22,
+      sm: 26,
+      md: 30,
+      lg: 38,
+      xl: 46,
+    },
+    gap: {
+      xs: '2rpx',
+      sm: '5rpx',
+      md: '10rpx',
+      lg: '20rpx',
+      xl: '30rpx',
+      '2xl': '40rpx',
+      '3xl': '50rpx',
+      '4xl': '60rpx',
+      '5xl': '70rpx',
     },
     shadow: {
       default: '0 0 10px rgba(0, 0, 0, 0.1)',
@@ -26,6 +42,13 @@ export const DefaultTheme : ThemeConfig = {
       default: '1px solid #dddddd',
       none: 'none',
     },
+    radius: {
+      xs: '5rpx',
+      sm: '10rpx',
+      md: '20rpx',
+      lg: '30rpx',
+      xl: '50rpx',
+    },
   },
   colorConfigs: {
     default: {

+ 21 - 12
src/components/theme/ThemeDefine.ts

@@ -18,6 +18,9 @@ export function configDefaultSizeUnit(unit: string) {
 export type ViewStyle = Record<string, any>
 export type TextStyle = Record<string, any>
 
+export type ThemeSizeType = number|string;
+export type ThemePaddingOrMarginType = number|Array<number>|ThemePaddingMargin|string|string[];
+
 export interface ThemeConfig {
   varOverrides: Record<string, any>,
   colorConfigs: Record<string, Record<string, string>>,
@@ -91,7 +94,7 @@ export function useTheme() {
   const topTheme = inject<Ref<ThemeConfig>>(ThemeKey);
   const theme = computed(() => topTheme?.value ?? DefaultTheme);
 
-  function resolveThemeSize(inValue?: string|number, defaultValue?: string|number) : string|undefined {    
+  function resolveThemeSize(inValue?: ThemeSizeType, defaultValue?: ThemeSizeType) : string|undefined {    
     const preResolve = resolveSize(inValue);
     if (preResolve !== undefined)
       return preResolve;    
@@ -111,7 +114,7 @@ export function useTheme() {
     return resolveSize(inValue);
   }
   function resolveThemeColor(inValue?: string, defaultValue?: string) : string|undefined {
-    if (inValue === 'transparent')
+    if (isSpecialColor(inValue))
       return inValue;
     if (inValue === undefined)
       inValue = defaultValue;
@@ -136,6 +139,10 @@ export function useTheme() {
     return result;
   }
 
+  function isSpecialColor(key?: string) {
+    return key === 'transparent' || key === 'currentColor';
+  }
+
   function getColor(key: string, defaultValue?: string) {
     if (key === undefined)
       return defaultValue;
@@ -145,6 +152,8 @@ export function useTheme() {
       key = keyResolve;
     if (key.includes('.'))
       [type, key] = key.split('.');
+    if (isSpecialColor(keyResolve))
+      return keyResolve;
     let group = theme.value.colorConfigs[type || 'default'];
     if (!group) 
       group = theme.value.colorConfigs['default'];
@@ -243,12 +252,14 @@ export function useTheme() {
   }
 }
 
-function isNumbrSize(inValue: string|number|undefined) {
+export type ThemeContext = ReturnType<typeof useTheme>;
+
+function isNumbrSize(inValue: ThemeSizeType|undefined) {
   if (inValue == undefined)
     return false;
   return typeof inValue === 'number' || !isNaN(Number(inValue));
 }
-function isRealSize(inValue: string|number|undefined) {
+function isRealSize(inValue: ThemeSizeType|undefined) {
   if (inValue == undefined)
     return false;
   return typeof inValue === 'number' || 
@@ -264,7 +275,7 @@ function isRealSize(inValue: string|number|undefined) {
  * @param themeType 
  * @returns 
  */
-export function resolveSize(inValue: string|number|undefined) : string|undefined {
+export function resolveSize(inValue: ThemeSizeType|undefined) : string|undefined {
   if (inValue == undefined)
     return undefined;
   if (isNumbrSize(inValue))
@@ -282,13 +293,11 @@ export function resolveSize(inValue: string|number|undefined) : string|undefined
   return undefined;
 }
 
-export type ThemePaddingMarginProp = number | number[] | ThemePaddingMargin
-
 export interface ThemePaddingMargin {
-  l?: number,
-  r?: number,
-  t?: number,
-  b?: number,
+  l?: number|string,
+  r?: number|string,
+  t?: number|string,
+  b?: number|string,
 }
 
 /**
@@ -314,7 +323,7 @@ export function configTheme(
   let defaultDarkTheme = ObjectUtils.clone(DefaultDarkTheme);
   
   const [theme, darkTheme] = cb?.(defaultTheme, defaultDarkTheme) ?? [defaultTheme, defaultDarkTheme];
-  const currentSystemDark = ref(autoMatchSystemDark !== false && uni.getSystemInfoSync().theme === 'dark');
+  const currentSystemDark = ref(autoMatchSystemDark !== false && uni.getAppBaseInfo().theme === 'dark');
   const currentTheme = shallowRef(currentSystemDark.value ? darkTheme : theme);
 
   provide(ThemeKey, currentTheme);

+ 12 - 48
src/components/theme/ThemeTools.ts

@@ -1,4 +1,4 @@
-import { resolveSize, type ThemeConfig, type ThemePaddingMargin } from "./ThemeDefine";
+import { resolveSize, type ThemeConfig, type ThemeContext, type ThemePaddingOrMarginType } from "./ThemeDefine";
 
 export type StringSize = number | string;
 
@@ -14,7 +14,7 @@ const info = {
 }
 // #endif
 // #ifndef H5
-const info = uni.getSystemInfoSync() ;
+const info = uni.getWindowInfo() ;
 // #endif
 
 export const screenWidth = info.screenWidth;
@@ -24,63 +24,27 @@ export function rpx2px(rpx: number) {
   return rpx / 750 * screenWidth;
 } 
 
-export function configThemePaddingMarginOneSide(input: string|number|number[]|ThemePaddingMargin|undefined, controlType: 'vertical'|'horizontal', controlValue: number) : ThemePaddingMargin {
-  
-  const result = { l: 0, r: 0, t: 0, b: 0 };
-  if (!input) {
-    if (controlType == 'horizontal')
-      result.l = result.r = controlValue;
-    else
-      result.t = result.b = controlValue;
-    return result;
-  }
-
-  if (typeof input === 'number') {
-    result.l += input;
-    result.r += input;
-    result.t += input;
-    result.b += input;
-  } else if (input instanceof Array) {
-    if (input.length === 2) {
-      result.t += input[0];
-      result.b += input[0];
-      result.l += input[1];
-      result.r += input[1];
-    } else if (input.length === 4) {
-      result.t += input[0];
-      result.r += input[1];
-      result.b += input[2];
-      result.l += input[3];
-    }
-  } else if (typeof input === 'object') {
-    result.t += input.t || 0;
-    result.r += input.r || 0;
-    result.b += input.b || 0;
-    result.l += (input.l || 0);
-  }
-  return result;
-}
-export function configMargin(style: Record<string, any>, theme: ThemeConfig, input: string|number|number[]|ThemePaddingMargin|undefined) {
+export function configMargin(style: Record<string, any>, theme: ThemeContext, input: ThemePaddingOrMarginType|undefined) {
   configMarginOrStyle('margin', style, theme, input);
 }
-export function configPadding(style: Record<string, any>, theme: ThemeConfig, input: string|number|number[]|ThemePaddingMargin|undefined) {
+export function configPadding(style: Record<string, any>, theme: ThemeContext, input: ThemePaddingOrMarginType|undefined) {
   configMarginOrStyle('padding', style, theme, input);
 }
-function configMarginOrStyle(name: string, style: Record<string, any>, theme: ThemeConfig, input: string|number|number[]|ThemePaddingMargin|undefined) {
+function configMarginOrStyle(name: string, style: Record<string, any>, theme: ThemeContext, input: ThemePaddingOrMarginType|undefined) {
   if (input === undefined)
     return;
-  if (typeof input === 'number') {
-    style[name] = resolveSize(input);
-  } else if (input instanceof Array) {
+  if (input instanceof Array) {
     style[name] = undefined;
     if (input.length === 2) {
-      style[name] = `${resolveSize(input[0] ?? 0)} ${resolveSize(input[1] ?? 0)}`;
+      style[name] = `${theme.resolveThemeSize(input[0] ?? 0)} ${theme.resolveThemeSize(input[1] ?? 0)}`;
     } else if (input.length === 4) {
-      style[name] = `${resolveSize(input[0] ?? 0)} ${resolveSize(input[1] ?? 0)} ${resolveSize(input[2] ?? 0)} ${resolveSize(input[3] ?? 0)}`;
+      style[name] = `${theme.resolveThemeSize(input[0] ?? 0)} ${theme.resolveThemeSize(input[1] ?? 0)} ${theme.resolveThemeSize(input[2] ?? 0)} ${theme.resolveThemeSize(input[3] ?? 0)}`;
     }
   } else if (typeof input === 'object') {
-    style[name] = `${resolveSize(input.t ?? 0)} ${resolveSize(input.r ?? 0)} ${resolveSize(input.b ?? 0)} ${resolveSize(input.l ?? 0)}`;
-  }
+    style[name] = `${theme.resolveThemeSize(input.t ?? 0)} ${theme.resolveThemeSize(input.r ?? 0)} ${theme.resolveThemeSize(input.b ?? 0)} ${theme.resolveThemeSize(input.l ?? 0)}`;
+  } else {
+    style[name] = theme.resolveThemeSize(input);
+  } 
 }
 
 export interface DynamicVarType {

+ 71 - 0
src/components/thirdPart/pinyinUtil.d.ts

@@ -0,0 +1,71 @@
+export type PinyinGetPinyinResult<Polyphone extends boolean | undefined> =
+  Polyphone extends true ? string[] : string;
+
+export type PinyinFirstLetterResult<Polyphone extends boolean | undefined> =
+  Polyphone extends true ? string[] : string;
+
+export interface PinyinUtilFirstLetterDict {
+  /** 长度约 20902 的首字母字符串,索引为 (unicode - 19968) */
+  all: string;
+  /** key 为汉字 unicode,value 为首字母候选(例如 "ZC") */
+  polyphone: Record<number, string>;
+}
+
+export interface PinyinUtilDict {
+  /** 汉字 -> 带声调拼音(多音字用空格分隔,例如 "da tai") */
+  withtone?: Record<string, string>;
+  /** 汉字 -> 不带声调拼音(不支持多音字) */
+  notone?: Record<string, string>;
+  /** 拼音(无声调) -> 汉字集合字符串 */
+  py2hz?: Record<string, string>;
+  /** 首字母字典 */
+  firstletter?: PinyinUtilFirstLetterDict;
+  [key: string]: unknown;
+}
+
+export interface PinyinUtil {
+  /** 解析各种字典文件(需在本文件之前导入字典脚本) */
+  parseDict(): void;
+
+  /**
+   * 根据汉字获取拼音;遇到非汉字会原样保留。
+   * - `withtone` 默认 `true`
+   * - `polyphone` 默认 `false`
+   *
+   * 当 `polyphone=true` 时,可能返回所有组合的数组。
+   */
+  getPinyin<Polyphone extends boolean | undefined = false>(
+    chinese: string,
+    splitter?: string,
+    withtone?: boolean,
+    polyphone?: Polyphone
+  ): PinyinGetPinyinResult<Polyphone>;
+
+  /**
+   * 获取汉字的拼音首字母;遇到非汉字会原样保留。
+   *
+   * 当 `polyphone=true` 时,返回所有可能组合的数组。
+   */
+  getFirstLetter<Polyphone extends boolean | undefined = false>(
+    str: string,
+    polyphone?: Polyphone
+  ): PinyinFirstLetterResult<Polyphone>;
+
+  /** 拼音转汉字(只支持单个拼音音节),返回匹配汉字集合字符串 */
+  getHanzi(pinyin: string): string;
+
+  /** 获取某个汉字的同音字(当前实现未完全可靠) */
+  getSameVoiceWord(hz: string, sameTone?: boolean): string;
+
+  /** 去除拼音中的声调,例如 "xiǎo míng" -> "xiao ming" */
+  removeTone(pinyin: string): string;
+
+  /** 数字声调转标点声调,例如 "xiao3" -> "xiǎo" */
+  getTone(pinyinWithoutTone: string): string;
+
+  /** 内部字典(由 `parseDict()` 填充) */
+  dict: PinyinUtilDict;
+}
+
+declare const pinyinUtil: PinyinUtil;
+export default pinyinUtil;

+ 364 - 0
src/components/thirdPart/pinyinUtil.js

@@ -0,0 +1,364 @@
+
+/**
+ * 汉字与拼音互转工具,根据导入的字典文件的不同支持不同
+ * 对于多音字目前只是将所有可能的组合输出,准确识别多音字需要完善的词库,而词库文件往往比字库还要大,所以不太适合web环境。
+ * @start 2016-09-26
+ * @last 2016-09-29
+ */
+
+var toneMap = 
+{
+  "ā": "a1",
+  "á": "a2",
+  "ǎ": "a3",
+  "à": "a4",
+  "ō": "o1",
+  "ó": "o2",
+  "ǒ": "o3",
+  "ò": "o4",
+  "ē": "e1",
+  "é": "e2",
+  "ě": "e3",
+  "è": "e4",
+  "ī": "i1",
+  "í": "i2",
+  "ǐ": "i3",
+  "ì": "i4",
+  "ū": "u1",
+  "ú": "u2",
+  "ǔ": "u3",
+  "ù": "u4",
+  "ü": "v0",
+  "ǖ": "v1",
+  "ǘ": "v2",
+  "ǚ": "v3",
+  "ǜ": "v4",
+  "ń": "n2",
+  "ň": "n3",
+  "": "m2"
+};
+
+var dict = {}; // 存储所有字典数据
+var pinyinUtil =
+{
+  /**
+   * 解析各种字典文件,所需的字典文件必须在本JS之前导入
+   */
+  parseDict: function()
+  {
+    // 如果导入了 pinyin_dict_firstletter.js
+    if(globalThis.pinyin_dict_firstletter)
+    {
+      dict.firstletter = pinyin_dict_firstletter;
+    }
+    // 如果导入了 pinyin_dict_notone.js
+    if(globalThis.pinyin_dict_notone)
+    {
+      dict.notone = {};
+      dict.py2hz = pinyin_dict_notone; // 拼音转汉字
+      for(var i in pinyin_dict_notone)
+      {
+        var temp = pinyin_dict_notone[i];
+        for(var j=0, len=temp.length; j<len; j++)
+        {
+          if(!dict.notone[temp[j]]) dict.notone[temp[j]] = i; // 不考虑多音字
+        }
+      }
+    }
+    // 如果导入了 pinyin_dict_withtone.js
+    if(globalThis.pinyin_dict_withtone)
+    {
+      dict.withtone = {}; // 汉字与拼音映射,多音字用空格分开,类似这种结构:{'大': 'da tai'}
+      var temp = pinyin_dict_withtone.split(',');
+      for(var i=0, len = temp.length; i<len; i++)
+      {
+        // 这段代码耗时28毫秒左右,对性能影响不大,所以一次性处理完毕
+        dict.withtone[String.fromCharCode(i + 19968)] = temp[i]; // 这里先不进行split(' '),因为一次性循环2万次split比较消耗性能
+      }
+
+      // 拼音 -> 汉字
+      if(globalThis.pinyin_dict_notone)
+      {
+        // 对于拼音转汉字,我们优先使用pinyin_dict_notone字典文件
+        // 因为这个字典文件不包含生僻字,且已按照汉字使用频率排序
+        dict.py2hz = pinyin_dict_notone; // 拼音转汉字
+      }
+      else
+      {
+        // 将字典文件解析成拼音->汉字的结构
+        // 与先分割后逐个去掉声调相比,先一次性全部去掉声调然后再分割速度至少快了3倍,前者大约需要120毫秒,后者大约只需要30毫秒(Chrome下)
+        var notone = pinyinUtil.removeTone(pinyin_dict_withtone).split(',');
+        var py2hz = {}, py, hz;
+        for(var i=0, len = notone.length; i<len; i++)
+        {
+          hz = String.fromCharCode(i + 19968); // 汉字
+          py = notone[i].split(' '); // 去掉了声调的拼音数组
+          for(var j=0; j<py.length; j++)
+          {
+            py2hz[py[j]] = (py2hz[py[j]] || '') + hz;
+          }
+        }
+        dict.py2hz = py2hz;
+      }
+    }
+  },
+  /**
+   * 根据汉字获取拼音,如果不是汉字直接返回原字符
+   * @param chinese 要转换的汉字
+   * @param splitter 分隔字符,默认用空格分隔
+   * @param withtone 返回结果是否包含声调,默认是
+   * @param polyphone 是否支持多音字,默认否
+   */
+  getPinyin: function(chinese, splitter, withtone, polyphone)
+  {
+    if(!chinese || /^ +$/g.test(chinese)) return '';
+    splitter = splitter == undefined ? ' ' : splitter;
+    withtone = withtone == undefined ? true : withtone;
+    polyphone = polyphone == undefined ? false : polyphone;
+    var result = [];
+    if(dict.withtone) // 优先使用带声调的字典文件
+    {
+      var noChinese = '';
+      for (var i=0, len = chinese.length; i < len; i++)
+      {
+        var pinyin = dict.withtone[chinese[i]];
+        if(pinyin)
+        {
+          // 如果不需要多音字,默认返回第一个拼音,后面的直接忽略
+          // 所以这对数据字典有一定要求,常见字的拼音必须放在最前面
+          if(!polyphone) pinyin = pinyin.replace(/ .*$/g, '');
+          if(!withtone) pinyin = this.removeTone(pinyin); // 如果不需要声调
+          //空格,把noChinese作为一个词插入
+          noChinese && ( result.push( noChinese), noChinese = '' );
+          result.push( pinyin ); 
+        }
+        else if ( !chinese[i] || /^ +$/g.test(chinese[i]) ){
+          //空格,把noChinese作为一个词插入
+          noChinese && ( result.push( noChinese), noChinese = '' );
+        }
+        else{
+          noChinese += chinese[i];
+        }
+      }
+      if ( noChinese ){
+        result.push( noChinese);
+        noChinese = '';
+      }
+    }
+    else if(dict.notone) // 使用没有声调的字典文件
+    {
+      if(withtone) console.warn('pinyin_dict_notone 字典文件不支持声调!');
+      if(polyphone) console.warn('pinyin_dict_notone 字典文件不支持多音字!');
+      var noChinese = '';
+      for (var i=0, len = chinese.length; i < len; i++)
+      {
+        var temp = chinese.charAt(i),
+          pinyin = dict.notone[temp];
+        if ( pinyin ){ //插入拼音
+          //空格,把noChinese作为一个词插入
+          noChinese && ( result.push( noChinese), noChinese = '' );
+          result.push( pinyin );
+        }
+        else if ( !temp || /^ +$/g.test(temp) ){
+          //空格,插入之前的非中文字符
+          noChinese && ( result.push( noChinese), noChinese = '' );
+        }
+        else {
+          //非空格,关联到noChinese中
+          noChinese += temp;
+        }
+      }
+
+      if ( noChinese ){
+        result.push( noChinese );
+        noChinese = '';
+      }
+    }
+    else
+    {
+      throw '抱歉,未找到合适的拼音字典文件!';
+    }
+    if(!polyphone) return result.join(splitter);
+    else
+    {
+      if(globalThis.pinyin_dict_polyphone) return parsePolyphone(chinese, result, splitter, withtone);
+      else return handlePolyphone(result, ' ', splitter);
+    }
+  },
+  /**
+   * 获取汉字的拼音首字母
+   * @param str 汉字字符串,如果遇到非汉字则原样返回
+   * @param polyphone 是否支持多音字,默认false,如果为true,会返回所有可能的组合数组
+   */
+  getFirstLetter: function(str, polyphone)
+  {
+    polyphone = polyphone == undefined ? false : polyphone;
+    if(!str || /^ +$/g.test(str)) return '';
+    if(dict.firstletter) // 使用首字母字典文件
+    {
+      var result = [];
+      for(var i=0; i<str.length; i++)
+      {
+        var unicode = str.charCodeAt(i);
+        var ch = str.charAt(i);
+        if(unicode >= 19968 && unicode <= 40869)
+        {
+          ch = dict.firstletter.all.charAt(unicode-19968);
+          if(polyphone) ch = dict.firstletter.polyphone[unicode] || ch;
+        }
+        result.push(ch);
+      }
+      if(!polyphone) return result.join(''); // 如果不用管多音字,直接将数组拼接成字符串
+      else return handlePolyphone(result, '', ''); // 处理多音字,此时的result类似于:['D', 'ZC', 'F']
+    }
+    else
+    {
+      var py = this.getPinyin(str, ' ', false, polyphone);
+      py = py instanceof Array ? py : [py];
+      var result = [];
+      for(var i=0; i<py.length; i++)
+      {
+        result.push(py[i].replace(/(^| )(\w)\w*/g, function(m,$1,$2){return $2.toUpperCase();}));
+      }
+      if(!polyphone) return result[0];
+      else return simpleUnique(result);
+    }
+  },
+  /**
+   * 拼音转汉字,只支持单个汉字,返回所有匹配的汉字组合
+   * @param pinyin 单个汉字的拼音,可以包含声调
+   */
+  getHanzi: function(pinyin)
+  {
+    if(!dict.py2hz)
+    {
+      throw '抱歉,未找到合适的拼音字典文件!';
+    }
+    return dict.py2hz[this.removeTone(pinyin)] || '';
+  },
+  /**
+   * 获取某个汉字的同音字,本方法暂时有问题,待完善
+   * @param hz 单个汉字
+   * @param sameTone 是否获取同音同声调的汉字,必须传进来的拼音带声调才支持,默认false
+   */
+  getSameVoiceWord: function(hz, sameTone)
+  {
+    sameTone = sameTone || false
+    return this.getHanzi(this.getPinyin(hz, ' ', false))
+  },
+  /**
+   * 去除拼音中的声调,比如将 xiǎo míng tóng xué 转换成 xiao ming tong xue
+   * @param pinyin 需要转换的拼音
+   */
+  removeTone: function(pinyin)
+  {
+    return pinyin.replace(/[āáǎàōóǒòēéěèīíǐìūúǔùüǖǘǚǜńň]/g, function(m){ return toneMap[m][0]; });
+  },
+  /**
+   * 将数组拼音转换成真正的带标点的拼音
+   * @param pinyinWithoutTone 类似 xu2e这样的带数字的拼音
+   */
+  getTone: function(pinyinWithoutTone)
+  {
+    var newToneMap = {};
+    for(var i in toneMap) newToneMap[toneMap[i]] = i;
+    return (pinyinWithoutTone || '').replace(/[a-z]\d/g, function(m) {
+      return newToneMap[m] || m;
+    });
+  }
+};
+
+
+/**
+ * 处理多音字,将类似['D', 'ZC', 'F']转换成['DZF', 'DCF']
+ * 或者将 ['chang zhang', 'cheng'] 转换成 ['chang cheng', 'zhang cheng']
+ */
+function handlePolyphone(array, splitter, joinChar)
+{
+  splitter = splitter || '';
+  var result = [''], temp = [];
+  for(var i=0; i<array.length; i++)
+  {
+    temp = [];
+    var t = array[i].split(splitter);
+    for(var j=0; j<t.length; j++)
+    {
+      for(var k=0; k<result.length; k++)
+        temp.push(result[k] + (result[k]?joinChar:'') + t[j]);
+    }
+    result = temp;
+  }
+  return simpleUnique(result);
+}
+
+/**
+ * 根据词库找出多音字正确的读音
+ * 这里只是非常简单的实现,效率和效果都有一些问题
+ * 推荐使用第三方分词工具先对句子进行分词,然后再匹配多音字
+ * @param chinese 需要转换的汉字
+ * @param result 初步匹配出来的包含多个发音的拼音结果
+ * @param splitter 返回结果拼接字符
+ */
+function parsePolyphone(chinese, result, splitter, withtone)
+{
+  var poly = globalThis.pinyin_dict_polyphone;
+  var max = 7; // 最多只考虑7个汉字的多音字词,虽然词库里面有10个字的,但是数量非常少,为了整体效率暂时忽略之
+  var temp = poly[chinese];
+  if(temp) // 如果直接找到了结果
+  {
+    temp = temp.split(' ');
+    for(var i=0; i<temp.length; i++)
+    {
+      result[i] = temp[i] || result[i];
+      if(!withtone) result[i] = pinyinUtil.removeTone(result[i]);
+    }
+    return result.join(splitter);
+  }
+  for(var i=0; i<chinese.length; i++)
+  {
+    temp = '';
+    for(var j=0; j<max && (i+j)<chinese.length; j++)
+    {
+      if(!/^[\u2E80-\u9FFF]+$/.test(chinese[i+j])) break; // 如果碰到非汉字直接停止本次查找
+      temp += chinese[i+j];
+      var res = poly[temp];
+      if(res) // 如果找到了多音字词语
+      {
+        res = res.split(' ');
+        for(var k=0; k<=j; k++)
+        {
+          if(res[k]) result[i+k] = withtone ? res[k] : pinyinUtil.removeTone(res[k]);
+        }
+        break;
+      }
+    }
+  }
+  // 最后这一步是为了防止出现词库里面也没有包含的多音字词语
+  for(var i=0; i<result.length; i++)
+  {
+    result[i] = result[i].replace(/ .*$/g, '');
+  }
+  return result.join(splitter);
+}
+
+// 简单数组去重
+function simpleUnique(array)
+{
+  var result = [];
+  var hash = {};
+  for(var i=0; i<array.length; i++)
+  {
+    var key = (typeof array[i]) + array[i];
+    if(!hash[key])
+    {
+      result.push(array[i]);
+      hash[key] = true;
+    }
+  }
+  return result;
+}
+
+pinyinUtil.parseDict();
+pinyinUtil.dict = dict;
+
+export default pinyinUtil;

Разлика између датотеке није приказан због своје велике величине
+ 8 - 0
src/components/thirdPart/pinyin_dict_firstletter.js


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

@@ -82,6 +82,8 @@ async function lodScrollInfo() {
       .exec();
   })
 
+  if (!realTextRef.value) return;
+
   let textWidth = await realTextRef.value.measureTextWidth();
   //console.log('textWidth', textWidth, 'conWidth', conWidth);
   

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

@@ -25,11 +25,13 @@ function toast(content: string) {
 function alert(option: {
   title?: string,
   content?: string,
+  confirmText?: string,
 }) {
   uni.showModal({
     title: option.title,
     content: option.content,
     showCancel: false,
+    confirmText: option.confirmText,
   })
 };
 /**

+ 9 - 0
src/components/utils/PingyinUtils.ts

@@ -0,0 +1,9 @@
+import '../thirdPart/pinyin_dict_firstletter'
+import pinyinUtil from '../thirdPart/pinyinUtil';
+
+export default {
+  getFirstLetter(char: string) : string {
+    const res = pinyinUtil.getFirstLetter(char);
+    return res;
+  }
+}

+ 21 - 0
src/pages.json

@@ -222,6 +222,27 @@
     },
     // #endif
   ],
+  "subPackages": [
+    {
+      "root": "pages/collect",
+      "pages": [
+        {
+          "path": "login",
+          "style": {
+            "navigationBarTitleText": "传承人登录",
+            "enablePullDownRefresh": false
+          }
+        },
+        {
+          "path": "inheritor",
+          "style": {
+            "navigationBarTitleText": "非遗数字化资源",
+            "enablePullDownRefresh": false
+          }
+        }
+      ]
+    }
+  ],
   "globalStyle": {
     "navigationBarTextStyle": "black",
     "navigationBarTitleText": "uni-app",

+ 6 - 0
src/pages/collect/inheritor.vue

@@ -0,0 +1,6 @@
+<template>
+  <text>TODO:inheritor</text>
+</template>
+
+<script setup lang="ts">
+</script>

+ 162 - 0
src/pages/collect/login.vue

@@ -0,0 +1,162 @@
+<template>
+  <FlexCol gap="gap.xl" padding="space.lg">
+    <FlexCol center>
+      <Text fontConfig="h2">传承人登录</Text>
+      <Text fontConfig="subText">欢迎使用非遗数字化资源信息校对系统</Text>
+    </FlexCol>
+    <FlexCol radius="radius.md" backgroundColor="white" overflow="hidden">
+      <DynamicForm
+        ref="formRef"
+        :model="formModel"
+        :options="formDefine"
+      />
+    </FlexCol>
+    <FlexCol gap="gap.md">
+      <Button type="primary" block :loading="loading" @click="handleSubmit">
+        登录
+      </Button>
+      <Button block @click="back()">
+        返回
+      </Button>
+    </FlexCol>
+    <XBarSpace />
+  </FlexCol>
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref } from 'vue';
+import { useAuthStore } from '@/store/auth';
+import { showError } from '@/common/composeabe/ErrorDisplay';
+import { toast } from '@/components/utils/DialogAction';
+import Button from '@/components/basic/Button.vue';
+import XBarSpace from '@/components/layout/space/XBarSpace.vue';
+import DynamicForm from '@/components/dynamic/DynamicForm.vue';
+import type { IDynamicFormOptions, IDynamicFormRef } from '@/components/dynamic';
+import type { FormProps } from '@/components/form/Form.vue';
+import type { RadioValueProps } from '@/components/dynamic/wrappers/RadioValue';
+import { back, redirectTo } from '@/components/utils/PageAction';
+import FlexCol from '@/components/layout/FlexCol.vue';
+import FlexRow from '@/components/layout/FlexRow.vue';
+import Text from '@/components/basic/Text.vue';
+
+const authStore = useAuthStore();
+const formRef = ref<IDynamicFormRef>();
+const loading = ref(false);
+
+const formModel = ref({
+  mobile: '',
+  account: '',
+  password: '',
+  type: 0,
+});
+
+const formDefine: IDynamicFormOptions = {
+  formAdditionaProps: {
+    labelFlex: 4,
+    inputFlex: 8,
+  } as FormProps,
+  formRules: {
+    mobile: [{ required: true, message: '请输入账号' }],
+    account: [{ required: true, message: '请输入账号' }],
+    password: [{ required: true, message: '请输入密码' }],
+  },
+  formItems: [
+    {
+      label: '账号',
+      name: 'mobile',
+      type: 'text',
+      defaultValue: '',
+      show: { callback: (_, m) => (m as { type: number }).type === 0 },
+      additionalProps: {
+        placeholder: '请输入账号',
+      },
+    },
+    {
+      label: '账号',
+      name: 'account',
+      type: 'text',
+      defaultValue: '',
+      show: { callback: (_, m) => (m as { type: number }).type === 1 },
+      additionalProps: {
+        placeholder: '请输入账号',
+      },
+    },
+    {
+      label: '密码',
+      name: 'password',
+      type: 'text',
+      defaultValue: '',
+      formProps: {
+        type: 'password',
+      },
+      additionalProps: {
+        placeholder: '请输入密码',
+      },
+    },
+    /* {
+      label: '登录类型',
+      name: 'type',
+      type: 'radio-value',
+      defaultValue: 0,
+      additionalProps: {
+        options: [
+          { text: '传承人', value: 0 },
+          { text: '管理员', value: 1 },
+        ],
+      } as RadioValueProps,
+    }, */
+  ],
+};
+
+async function handleSubmit() {
+  if (!formRef.value)
+    return;
+  try {
+    await formRef.value.validate();
+  } catch {
+    uni.showToast({
+      title: '有必填项未填写,请检查',
+      icon: 'none',
+    });
+    return;
+  }
+
+  const loginType = Number(formModel.value.type);
+  const account =
+    loginType === 1
+      ? formModel.value.account.trim()
+      : formModel.value.mobile.trim();
+  if (!account) {
+    uni.showToast({
+      title: '请输入账号',
+      icon: 'none',
+    });
+    return;
+  }
+
+  loading.value = true;
+  try {
+    await authStore.loginCollect(account, formModel.value.password, loginType);
+    toast('您已成功登录');
+    await new Promise((r) => setTimeout(r, 200));
+    if (authStore.loginType === 0) {
+      uni.redirectTo({ url: '/pages/collect/inheritor' });
+    } else {
+      uni.switchTab({ url: '/pages/user/index' });
+    }
+  } catch (e) {
+    showError(e);
+  } finally {
+    loading.value = false;
+  }
+}
+
+onMounted(() => {
+  if (authStore.isLogged && authStore.isCollect) {
+    redirectTo('/pages/collect/inheritor');
+  }
+});
+</script>
+
+<style lang="scss">
+</style>

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

@@ -67,6 +67,7 @@
           </Cell>
           <Cell icon="https://mncdn.wenlvti.net/uploads/20250313/66d4665b1da5075e60148312469b2630.png" title="我的投稿" showArrow touchable @click="goContributeList" />
           <Cell icon="https://mncdn.wenlvti.net/uploads/20250313/042236758da5aaed21c1010e5b9440ce.png" title="我的收藏" showArrow touchable @click="goCollectList" />
+          <Cell icon="https://mncdn.wenlvti.net/uploads/20250313/acd97ca7b3f7736942495c7aec1dd65b.png" title="传承人" showArrow touchable @click="goInheritor" />
           <button open-type="contact" class="remove-button-style">
             <Cell icon="https://mncdn.wenlvti.net/uploads/20250313/d2e9010323d098aa51e268fc32f14d3d.png" title="在线客服" showArrow touchable />
           </button>
@@ -135,6 +136,9 @@ function goCollectList() {
 function goContribute() {
   requireLogin(() => navTo('/pages/user/contribute/submit'), '登录后才能投稿哦!');
 }
+function goInheritor() {
+  navTo('/pages/collect/login');
+}
 function goUserProfile() {
   if (authStore.isLogged)
     navTo('/pages/user/profile/index');

+ 33 - 1
src/store/auth.ts

@@ -1,14 +1,20 @@
+import CollectUserApi from "@/api/auth/CollectUserApi";
 import UserApi, { LoginResult, UserInfo } from "@/api/auth/UserApi";
 import { defineStore } from "pinia"
 
 const STORAGE_KEY = 'authInfo';
 
+export const LOGIN_TYPE_USER = -1;
+export const LOGIN_TYPE_COLLECT = 0;
+export const LOGIN_TYPE_COLLECT_ADMIN = 1;
+
 export const useAuthStore = defineStore('auth', {
   state: () => ({
     token: '',
     expireAt: 0,
     userId: 0,
     userInfo: null as null|UserInfo,
+    loginType: 0,
   }),
   actions: {
     async loadLoginState() {
@@ -52,10 +58,27 @@ export const useAuthStore = defineStore('auth', {
       })
       this.loginResultHandle(loginResult);
     },
-    async loginResultHandle(loginResult: LoginResult) {
+    async loginCollect(mobile: string, password: string, loginType: number) {
+      let loginResult;
+      if (loginType == LOGIN_TYPE_COLLECT) {
+        loginResult = await CollectUserApi.login({
+          mobile,
+          password,
+        })
+      } else if (loginType == LOGIN_TYPE_COLLECT_ADMIN) {
+        loginResult = await CollectUserApi.loginAdmin({
+          account: mobile,
+          password,
+        })
+      } else
+        throw 'login type error';
+      this.loginResultHandle(loginResult, loginType);
+    },
+    async loginResultHandle(loginResult: LoginResult, loginType: number = -1) {
       this.token = loginResult.auth.token;
       this.userId = loginResult.mainBodyUserInfo.id;
       this.userInfo = loginResult.mainBodyUserInfo;
+      this.loginType = loginType;
       this.expireAt = loginResult.auth.expiresIn + Date.now();
       this.saveLoginState();
     },
@@ -92,5 +115,14 @@ export const useAuthStore = defineStore('auth', {
     isLogged(state) {
       return state.token != '' && state.userId != 0
     },
+    isCollect(state) {
+      return state.loginType === LOGIN_TYPE_COLLECT;
+    },
+    isCollectAdmin(state) {
+      return state.loginType === LOGIN_TYPE_COLLECT_ADMIN;
+    },
+    isCollectReviewer(state) {
+      return state.userInfo?.isReviewer ?? false;
+    },  
   },
 })