cabloy 5.1.95 → 5.1.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/.cabloy-version +1 -0
  2. package/.claude/skills/cabloy-backend-scaffold/references/follow-up-checklist.md +4 -1
  3. package/.claude/skills/cabloy-resource-field-update/SKILL.md +6 -3
  4. package/.github/workflows/vona-test-crud.yml +2 -0
  5. package/.github/workflows/vona-test-mysql.yml +2 -0
  6. package/.github/workflows/vona-test-pg.yml +2 -0
  7. package/.github/workflows/vona-test-sqlite3.yml +2 -0
  8. package/CHANGELOG.md +38 -0
  9. package/CLAUDE.md +1 -0
  10. package/cabloy-docs/ai/playbook-backend-module.md +6 -3
  11. package/cabloy-docs/fullstack/framework-performance.md +3 -3
  12. package/cabloy-docs/fullstack/quickstart.md +1 -1
  13. package/package.json +1 -1
  14. package/scripts/release.ts +27 -1
  15. package/vona/docker-compose-original/docker-compose-dockerfile-app +4 -0
  16. package/vona/docker-compose-original/docker-compose.original.yml +0 -1
  17. package/vona/env/.env +0 -1
  18. package/vona/env/.env.prod +0 -1
  19. package/vona/env/.env.prod.docker +0 -7
  20. package/vona/package.original.json +3 -3
  21. package/vona/packages-cli/cabloy-cli/package.json +2 -2
  22. package/vona/packages-cli/cli/package.json +1 -1
  23. package/vona/packages-cli/cli-set-api/package.json +2 -2
  24. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.build.ts +70 -2
  25. package/vona/packages-utils/babel-plugin-vona-bean-module/package.json +2 -2
  26. package/vona/packages-utils/module-glob/package.json +2 -2
  27. package/vona/packages-utils/module-info-pro/package.json +2 -2
  28. package/vona/packages-utils/utils/package.json +2 -2
  29. package/vona/packages-vona/vona/package.json +1 -1
  30. package/vona/packages-vona/vona-core/package.json +2 -2
  31. package/vona/packages-vona/vona-core/src/lib/addon/addon.ts +77 -0
  32. package/vona/packages-vona/vona-core/src/lib/addon/index.ts +3 -0
  33. package/vona/packages-vona/vona-core/src/lib/addon/sharp.ts +46 -0
  34. package/vona/packages-vona/vona-core/src/lib/addon/sqlite3.ts +23 -0
  35. package/vona/packages-vona/vona-core/src/lib/index.ts +1 -0
  36. package/vona/packages-vona/vona-core/src/lib/utils/index.ts +0 -1
  37. package/vona/packages-vona/vona-core/src/lib/utils/util.ts +5 -0
  38. package/vona/packages-vona/vona-mock/package.json +2 -2
  39. package/vona/pnpm-lock.yaml +441 -62
  40. package/vona/pnpm-workspace.yaml +1 -1
  41. package/vona/src/backend/cli.ts +2 -5
  42. package/vona/src/backend/config/config/config.ts +0 -4
  43. package/vona/src/suite/a-demo/modules/demo-basic/src/bean/ssrMenu.admin.ts +1 -1
  44. package/vona/src/suite/a-training/modules/training-record/src/.metadata/index.ts +26 -0
  45. package/vona/src/suite/a-training/modules/training-record/src/bean/imageScene.sceneImage.ts +11 -0
  46. package/vona/src/suite/a-training/modules/training-record/src/bean/meta.version.ts +1 -0
  47. package/vona/src/suite/a-training/modules/training-record/src/config/locale/en-us.ts +1 -0
  48. package/vona/src/suite/a-training/modules/training-record/src/config/locale/zh-cn.ts +1 -0
  49. package/vona/src/suite/a-training/modules/training-record/src/dto/recordSelectResItem.tsx +12 -0
  50. package/vona/src/suite/a-training/modules/training-record/src/dto/recordView.tsx +12 -0
  51. package/vona/src/suite/a-training/modules/training-record/src/entity/record.tsx +26 -1
  52. package/vona/src/suite/a-training/modules/training-record/src/index.ts +1 -0
  53. package/vona/src/suite/a-training/modules/training-student/src/.metadata/index.ts +27 -1
  54. package/vona/src/suite/a-training/modules/training-student/src/bean/imageScene.studentImage.ts +10 -0
  55. package/vona/src/suite/a-training/modules/training-student/src/bean/meta.version.ts +1 -0
  56. package/vona/src/suite/a-training/modules/training-student/src/config/locale/en-us.ts +1 -0
  57. package/vona/src/suite/a-training/modules/training-student/src/config/locale/zh-cn.ts +1 -0
  58. package/vona/src/suite/a-training/modules/training-student/src/dto/detailRecordBase.tsx +16 -2
  59. package/vona/src/suite/a-training/modules/training-student/src/dto/studentCreate.tsx +1 -1
  60. package/vona/src/suite/a-training/modules/training-student/src/dto/studentSelectResItem.tsx +12 -0
  61. package/vona/src/suite/a-training/modules/training-student/src/dto/studentUpdate.tsx +1 -1
  62. package/vona/src/suite/a-training/modules/training-student/src/dto/studentView.tsx +13 -1
  63. package/vona/src/suite/a-training/modules/training-student/src/entity/student.tsx +26 -1
  64. package/vona/src/suite/a-training/modules/training-student/src/index.ts +1 -0
  65. package/vona/src/suite/a-training/modules/training-student/src/model/student.ts +1 -0
  66. package/vona/src/suite-vendor/a-auth/modules/a-auth/package.json +1 -1
  67. package/vona/src/suite-vendor/a-auth/modules/a-auth/src/bean/bean.authProvider.ts +2 -5
  68. package/vona/src/suite-vendor/a-auth/package.json +1 -1
  69. package/vona/src/suite-vendor/a-image/modules/a-image/cli/imageProvider/boilerplate/{{sceneName}}.{{beanName}}.ts_ +23 -0
  70. package/vona/src/suite-vendor/a-image/modules/a-image/cli/imageScene/boilerplate/{{sceneName}}.{{beanName}}.ts_ +5 -0
  71. package/vona/src/suite-vendor/a-image/modules/a-image/package.json +68 -0
  72. package/vona/src/suite-vendor/a-image/modules/a-image/src/.metadata/index.ts +482 -0
  73. package/vona/src/suite-vendor/a-image/modules/a-image/src/.metadata/locales.ts +18 -0
  74. package/vona/src/suite-vendor/a-image/modules/a-image/src/.metadata/this.ts +2 -0
  75. package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/bean.image.ts +443 -0
  76. package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/bean.imageProvider.ts +55 -0
  77. package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/bean.imageUploadPolicy.ts +174 -0
  78. package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/meta.redlock.ts +11 -0
  79. package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/meta.version.ts +40 -0
  80. package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/serializerTransform.resolveView.ts +44 -0
  81. package/vona/src/suite-vendor/a-image/modules/a-image/src/bean/serializerTransform.resolveViews.ts +44 -0
  82. package/vona/src/suite-vendor/a-image/modules/a-image/src/config/config.ts +17 -0
  83. package/vona/src/suite-vendor/a-image/modules/a-image/src/config/locale/en-us.ts +1 -0
  84. package/vona/src/suite-vendor/a-image/modules/a-image/src/config/locale/zh-cn.ts +1 -0
  85. package/vona/src/suite-vendor/a-image/modules/a-image/src/controller/image.ts +165 -0
  86. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageDeliveryRequest.ts +20 -0
  87. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageDirectUploadRequest.ts +36 -0
  88. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageDirectUploadResponse.ts +38 -0
  89. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageTransformOptions.tsx +42 -0
  90. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageUploadResponse.ts +62 -0
  91. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageUploadTokenRequest.ts +24 -0
  92. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageUploadTokenResponse.ts +16 -0
  93. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageUploadUrlRequest.ts +33 -0
  94. package/vona/src/suite-vendor/a-image/modules/a-image/src/dto/imageView.ts +46 -0
  95. package/vona/src/suite-vendor/a-image/modules/a-image/src/entity/image.ts +58 -0
  96. package/vona/src/suite-vendor/a-image/modules/a-image/src/entity/imageProvider.ts +24 -0
  97. package/vona/src/suite-vendor/a-image/modules/a-image/src/index.ts +4 -0
  98. package/vona/src/suite-vendor/a-image/modules/a-image/src/lib/imageProvider.ts +9 -0
  99. package/vona/src/suite-vendor/a-image/modules/a-image/src/lib/imageScene.ts +7 -0
  100. package/vona/src/suite-vendor/a-image/modules/a-image/src/lib/imageVariant.ts +46 -0
  101. package/vona/src/suite-vendor/a-image/modules/a-image/src/lib/index.ts +3 -0
  102. package/vona/src/suite-vendor/a-image/modules/a-image/src/model/image.ts +10 -0
  103. package/vona/src/suite-vendor/a-image/modules/a-image/src/model/imageProvider.ts +10 -0
  104. package/vona/src/suite-vendor/a-image/modules/a-image/src/types/image.ts +208 -0
  105. package/vona/src/suite-vendor/a-image/modules/a-image/src/types/imageProvider.ts +110 -0
  106. package/vona/src/suite-vendor/a-image/modules/a-image/src/types/imageScene.ts +52 -0
  107. package/vona/src/suite-vendor/a-image/modules/a-image/src/types/index.ts +3 -0
  108. package/vona/src/suite-vendor/a-image/modules/a-image/tsconfig.build.json +11 -0
  109. package/vona/src/suite-vendor/a-image/modules/a-image/tsconfig.json +7 -0
  110. package/vona/src/suite-vendor/a-image/modules/image-cloudflare/package.json +49 -0
  111. package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/.metadata/index.ts +89 -0
  112. package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/.metadata/this.ts +2 -0
  113. package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/bean/imageProvider.cloudflare.ts +155 -0
  114. package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/index.ts +1 -0
  115. package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/lib/cloudflareImageUrl.ts +112 -0
  116. package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/service/imageCloudflare.ts +273 -0
  117. package/vona/src/suite-vendor/a-image/modules/image-cloudflare/tsconfig.build.json +11 -0
  118. package/vona/src/suite-vendor/a-image/modules/image-cloudflare/tsconfig.json +7 -0
  119. package/vona/src/suite-vendor/a-image/modules/image-native/package.json +63 -0
  120. package/vona/src/suite-vendor/a-image/modules/image-native/src/.metadata/index.ts +114 -0
  121. package/vona/src/suite-vendor/a-image/modules/image-native/src/.metadata/this.ts +2 -0
  122. package/vona/src/suite-vendor/a-image/modules/image-native/src/bean/imageProvider.native.ts +131 -0
  123. package/vona/src/suite-vendor/a-image/modules/image-native/src/config/config.ts +11 -0
  124. package/vona/src/suite-vendor/a-image/modules/image-native/src/index.ts +1 -0
  125. package/vona/src/suite-vendor/a-image/modules/image-native/src/monkey.ts +33 -0
  126. package/vona/src/suite-vendor/a-image/modules/image-native/src/service/imageNative.ts +291 -0
  127. package/vona/src/suite-vendor/a-image/modules/image-native/tsconfig.build.json +11 -0
  128. package/vona/src/suite-vendor/a-image/modules/image-native/tsconfig.json +7 -0
  129. package/vona/src/suite-vendor/a-image/package.json +14 -0
  130. package/vona/src/suite-vendor/a-image/tsconfig.base.json +4 -0
  131. package/vona/src/suite-vendor/a-image/tsconfig.json +16 -0
  132. package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +2 -2
  133. package/vona/src/suite-vendor/a-vona/modules/a-jwt/package.json +1 -1
  134. package/vona/src/suite-vendor/a-vona/modules/a-jwt/src/service/jwtClient.ts +5 -0
  135. package/vona/src/suite-vendor/a-vona/modules/a-jwt/src/types/jwt.ts +1 -0
  136. package/vona/src/suite-vendor/a-vona/modules/a-openapiutils/package.json +1 -1
  137. package/vona/src/suite-vendor/a-vona/modules/a-openapiutils/src/lib/schema/makeSchemaLikes.ts +6 -3
  138. package/vona/src/suite-vendor/a-vona/modules/a-openapiutils/src/lib/utils.ts +13 -7
  139. package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
  140. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/types/database.ts +0 -1
  141. package/vona/src/suite-vendor/a-vona/modules/a-ormdialect/package.json +9 -1
  142. package/vona/src/suite-vendor/a-vona/modules/a-upload/package.json +1 -1
  143. package/vona/src/suite-vendor/a-vona/modules/a-upload/src/bean/interceptor.upload.ts +39 -7
  144. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  145. package/zova/packages-cli/cli/package.json +3 -3
  146. package/zova/packages-cli/cli-set-front/package.json +9 -9
  147. package/zova/packages-cli/cli-set-front/src/lib/bean/cli.openapi.generate.ts +20 -10
  148. package/zova/packages-utils/babel-plugin-zova-bean-module/package.json +2 -2
  149. package/zova/packages-utils/babel-plugin-zova-bean-use/package.json +2 -2
  150. package/zova/packages-utils/babel-plugin-zova-behavior/package.json +2 -2
  151. package/zova/packages-utils/babel-plugin-zova-component/package.json +2 -2
  152. package/zova/packages-utils/zova-jsx/package.json +3 -3
  153. package/zova/packages-utils/zova-vite/package.json +7 -7
  154. package/zova/packages-zova/zova/package.json +3 -3
  155. package/zova/packages-zova/zova-core/package.json +3 -3
  156. package/zova/pnpm-lock.yaml +126 -74
  157. package/zova/src/suite/a-home/modules/home-api/src/api/captcha.ts +3 -1
  158. package/zova/src/suite/a-home/modules/home-api/src/api/home.ts +3 -1
  159. package/zova/src/suite/a-home/modules/home-api/src/api/homeBaseMenu.ts +3 -1
  160. package/zova/src/suite/a-home/modules/home-api/src/api/homeBasePermission.ts +3 -1
  161. package/zova/src/suite/a-home/modules/home-api/src/api/homeUserPassport.ts +22 -20
  162. package/zova/src/suite/a-home/modules/home-api/src/api/openapi/schemas.ts +163 -53
  163. package/zova/src/suite/a-home/modules/home-api/src/api/openapi/types.ts +2060 -675
  164. package/zova/src/suite/a-home/modules/home-api/src/api/testSsrToolOne.ts +3 -1
  165. package/zova/src/suite/a-home/modules/home-api/src/apiSchema/captcha.ts +3 -1
  166. package/zova/src/suite/a-home/modules/home-api/src/apiSchema/home.ts +3 -1
  167. package/zova/src/suite/a-home/modules/home-api/src/apiSchema/homeBaseMenu.ts +3 -1
  168. package/zova/src/suite/a-home/modules/home-api/src/apiSchema/homeBasePermission.ts +3 -1
  169. package/zova/src/suite/a-home/modules/home-api/src/apiSchema/homeUserPassport.ts +13 -11
  170. package/zova/src/suite/a-home/modules/home-api/src/apiSchema/testSsrToolOne.ts +3 -1
  171. package/zova/src/suite/a-training/modules/training-student/src/api/openapi/schemas.ts +54 -16
  172. package/zova/src/suite/a-training/modules/training-student/src/api/openapi/types.ts +468 -8
  173. package/zova/src/suite/a-training/modules/training-student/src/apiSchema/trainingStudent.ts +3 -1
  174. package/zova/src/suite/cabloy-basic/modules/basic-details/src/bean/tableCell.actionUpdate.tsx +1 -1
  175. package/zova/src/suite/cabloy-basic/modules/basic-details/src/component/actionCreate/controller.tsx +1 -1
  176. package/zova/src/suite/cabloy-basic/modules/basic-details/src/service/detail.tsx +53 -1
  177. package/zova/src/suite/cabloy-basic/modules/basic-image/cli/openapi.config.ts +9 -0
  178. package/zova/src/suite/cabloy-basic/modules/basic-image/package.json +60 -0
  179. package/zova/src/suite/cabloy-basic/modules/basic-image/src/.metadata/component/formFieldImage.ts +31 -0
  180. package/zova/src/suite/cabloy-basic/modules/basic-image/src/.metadata/index.ts +191 -0
  181. package/zova/src/suite/cabloy-basic/modules/basic-image/src/.metadata/locales.ts +7 -0
  182. package/zova/src/suite/cabloy-basic/modules/basic-image/src/.metadata/this.ts +2 -0
  183. package/zova/src/suite/cabloy-basic/modules/basic-image/src/api/image.ts +104 -0
  184. package/zova/src/suite/cabloy-basic/modules/basic-image/src/api/openapi/baseURL.ts +5 -0
  185. package/zova/src/suite/cabloy-basic/modules/basic-image/src/api/openapi/index.ts +3 -0
  186. package/zova/src/suite/cabloy-basic/modules/basic-image/src/api/openapi/schemas.ts +262 -0
  187. package/zova/src/suite/cabloy-basic/modules/basic-image/src/api/openapi/types.ts +5159 -0
  188. package/zova/src/suite/cabloy-basic/modules/basic-image/src/apiSchema/image.ts +35 -0
  189. package/zova/src/suite/cabloy-basic/modules/basic-image/src/bean/tableCell.image.tsx +200 -0
  190. package/zova/src/suite/cabloy-basic/modules/basic-image/src/component/formFieldImage/controller.tsx +781 -0
  191. package/zova/src/suite/cabloy-basic/modules/basic-image/src/config/locale/en-us.ts +18 -0
  192. package/zova/src/suite/cabloy-basic/modules/basic-image/src/config/locale/zh-cn.ts +18 -0
  193. package/zova/src/suite/cabloy-basic/modules/basic-image/src/index.ts +4 -0
  194. package/zova/src/suite/cabloy-basic/modules/basic-image/src/lib/index.ts +2 -0
  195. package/zova/src/suite/cabloy-basic/modules/basic-image/src/lib/preview.ts +105 -0
  196. package/zova/src/suite/cabloy-basic/modules/basic-image/src/lib/previewDialog.tsx +190 -0
  197. package/zova/src/suite/cabloy-basic/modules/basic-image/src/types/image.ts +5 -0
  198. package/zova/src/suite/cabloy-basic/modules/basic-image/src/types/index.ts +1 -0
  199. package/zova/src/suite/cabloy-basic/modules/basic-image/tsconfig.build.json +13 -0
  200. package/zova/src/suite/cabloy-basic/modules/basic-image/tsconfig.json +5 -0
  201. package/zova/src/suite/cabloy-basic/package.json +1 -0
  202. package/zova/src/suite-vendor/a-zova/modules/a-openapi/package.json +1 -1
  203. package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/image.ts +1 -0
  204. package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/index.ts +1 -0
  205. package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +5 -5
  206. package/zova/src/suite-vendor/a-zova/package.json +3 -3
  207. package/vona/packages-vona/vona-core/src/lib/utils/sqlite3.ts +0 -50
  208. /package/vona/patches/{zova-core@5.1.75.patch → zova-core@5.1.76.patch} +0 -0
@@ -0,0 +1,781 @@
1
+ import type { TableIdentity } from 'table-identity';
2
+ import type { IComponentOptions } from 'zova';
3
+ import type { IJsxRenderContextFormField } from 'zova-module-a-form';
4
+ import type { ControllerFormField, IFormFieldComponentOptions } from 'zova-module-a-form';
5
+ import type { IImageSceneRecord, IResourceFormFieldOptionsBase } from 'zova-module-a-openapi';
6
+
7
+ import { classes } from 'typestyle';
8
+ import { CircleStencil, Cropper, RectangleStencil } from 'vue-advanced-cropper';
9
+ import { BeanControllerBase, ClientOnly, Use } from 'zova';
10
+ import { Controller } from 'zova-module-a-bean';
11
+ import { ZFormField } from 'zova-module-a-form';
12
+ import {
13
+ buildImagePreviewTitle,
14
+ inferImageRelationName,
15
+ openImagePreviewDialog,
16
+ resolveImagePreviewUrl,
17
+ } from 'zova-module-basic-image';
18
+
19
+ import type { IImageTransformOptions } from '../../types/image.js';
20
+ import 'vue-advanced-cropper/dist/style.css';
21
+
22
+ declare module 'zova-module-a-openapi' {
23
+ export interface IResourceFormFieldRecord {
24
+ 'basic-image:formFieldImage'?: IResourceFormFieldImageOptions;
25
+ }
26
+ }
27
+
28
+ export interface IResourceFormFieldImageResizeOptions {
29
+ width?: number;
30
+ height?: number;
31
+ fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
32
+ background?: string;
33
+ quality?: number;
34
+ format?: IImageTransformOptions['format'];
35
+ }
36
+
37
+ export interface IResourceFormFieldImageOptions extends IResourceFormFieldOptionsBase {
38
+ imageScene?: keyof IImageSceneRecord | string;
39
+ relationName?: string;
40
+ multiple?: boolean;
41
+ maxCount?: number;
42
+ accept?: string | string[];
43
+ mimeTypes?: string[];
44
+ extensions?: string[];
45
+ maxSize?: number;
46
+ minSize?: number;
47
+ placeholder?: string;
48
+ enableCrop?: boolean;
49
+ cropAspectRatio?: number;
50
+ cropShape?: 'rect' | 'round';
51
+ resize?: IResourceFormFieldImageResizeOptions;
52
+ upload?: {
53
+ providerName?: string;
54
+ provider?: string;
55
+ clientName?: string;
56
+ maxSize?: number;
57
+ mimeTypes?: string[];
58
+ extensions?: string[];
59
+ accept?: string | string[];
60
+ };
61
+ }
62
+
63
+ export interface ControllerFormFieldImageProps extends IFormFieldComponentOptions {
64
+ options?: IResourceFormFieldImageOptions;
65
+ }
66
+
67
+ interface IImagePreviewItem {
68
+ id: TableIdentity;
69
+ url?: string;
70
+ filename?: string;
71
+ width?: number;
72
+ height?: number;
73
+ }
74
+
75
+ @Controller()
76
+ export class ControllerFormFieldImage extends BeanControllerBase {
77
+ static $propsDefault = {
78
+ options: {
79
+ accept: ['image/png', 'image/jpeg', 'image/webp'],
80
+ maxCount: 1,
81
+ enableCrop: true,
82
+ cropAspectRatio: 1,
83
+ cropShape: 'rect',
84
+ resize: {
85
+ width: 512,
86
+ height: 512,
87
+ fit: 'cover',
88
+ format: 'jpeg',
89
+ quality: 90,
90
+ },
91
+ },
92
+ };
93
+
94
+ static $componentOptions: IComponentOptions = { inheritAttrs: false, deepExtendDefault: true };
95
+
96
+ fileInputRef?: HTMLInputElement;
97
+ errorMessage?: string;
98
+ isUploading = false;
99
+ currentValue?: TableIdentity | TableIdentity[] | string;
100
+ currentOptions: IResourceFormFieldImageOptions = {};
101
+ $$formField?: ControllerFormField;
102
+ uploadedPreviewMap: Record<string, IImagePreviewItem> = {};
103
+
104
+ @Use({ injectionScope: 'host' })
105
+ $$renderContext: IJsxRenderContextFormField;
106
+
107
+ protected async __init__() {}
108
+
109
+ protected render() {
110
+ if (this.$props.readonly) {
111
+ return this._renderReadonlyPreset();
112
+ }
113
+ return (
114
+ <ZFormField
115
+ {...this.$props}
116
+ slotDefault={({ propsBucket, props }, $$formField) => {
117
+ this.$$formField = $$formField;
118
+ this.currentValue = propsBucket.value as any;
119
+ this.currentOptions = propsBucket.options ?? {};
120
+ const items = this._getPreviewItems(propsBucket.value);
121
+ const hasValidationError = !$$formField.field.state.meta.isValid;
122
+ const cardClass = classes(
123
+ 'rounded-box border border-base-300 bg-base-100 p-4',
124
+ (hasValidationError || !!this.errorMessage) && 'border-error',
125
+ props.class,
126
+ );
127
+ return (
128
+ <div class={cardClass}>
129
+ <input
130
+ ref={ref => {
131
+ this.fileInputRef = ref as HTMLInputElement;
132
+ }}
133
+ class="hidden"
134
+ type="file"
135
+ accept={this._getAcceptAttr(propsBucket.options)}
136
+ multiple={!!propsBucket.options?.multiple}
137
+ onChange={event => {
138
+ void this._handleFileChange(event, propsBucket.disableNotifyChanged);
139
+ }}
140
+ />
141
+ <div class="flex flex-wrap items-center gap-3">
142
+ <button
143
+ type="button"
144
+ class={classes('btn btn-primary', this.isUploading && 'btn-disabled')}
145
+ onClick={() => {
146
+ if (this.isUploading) return;
147
+ this.fileInputRef?.click();
148
+ }}
149
+ >
150
+ {this._getUploadButtonText(items.length)}
151
+ </button>
152
+ {this.isUploading && (
153
+ <span class="inline-flex items-center gap-2 text-sm text-base-content/70">
154
+ <span class="loading loading-spinner loading-sm text-primary"></span>
155
+ {this.scope.locale.Uploading()}
156
+ </span>
157
+ )}
158
+ {!items.length && !this.isUploading && (
159
+ <span class="text-sm text-base-content/60">
160
+ {propsBucket.options?.placeholder ?? this.scope.locale.NoImageSelected()}
161
+ </span>
162
+ )}
163
+ </div>
164
+ {!!this.errorMessage && <p class="mt-3 text-sm text-error">{this.errorMessage}</p>}
165
+ {!!items.length && (
166
+ <div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
167
+ {items.map((item, index) => {
168
+ return this._renderPreviewCard(
169
+ item,
170
+ index,
171
+ items,
172
+ false,
173
+ propsBucket.disableNotifyChanged,
174
+ );
175
+ })}
176
+ </div>
177
+ )}
178
+ </div>
179
+ );
180
+ }}
181
+ ></ZFormField>
182
+ );
183
+ }
184
+
185
+ private _renderReadonlyPreset() {
186
+ return (
187
+ <ZFormField
188
+ {...this.$props}
189
+ slotDefault={({ propsBucket, props }) => {
190
+ this.currentValue = propsBucket.value as any;
191
+ this.currentOptions = propsBucket.options ?? {};
192
+ const items = this._getPreviewItems(propsBucket.value);
193
+ return <div class={props.class}>{this._renderReadonlyItems(items)}</div>;
194
+ }}
195
+ ></ZFormField>
196
+ );
197
+ }
198
+
199
+ private _renderReadonlyItems(items: IImagePreviewItem[]) {
200
+ if (!items.length) {
201
+ return (
202
+ <span class="text-sm text-base-content/60">{this.scope.locale.NoImageSelected()}</span>
203
+ );
204
+ }
205
+ return (
206
+ <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
207
+ {items.map((item, index) => this._renderPreviewCard(item, index, items, true, false))}
208
+ </div>
209
+ );
210
+ }
211
+
212
+ private _renderPreviewCard(
213
+ item: IImagePreviewItem,
214
+ index: number,
215
+ items: IImagePreviewItem[],
216
+ readonly: boolean,
217
+ disableNotifyChanged?: boolean,
218
+ ) {
219
+ const previewUrl = item.url ? this._resolvePreviewUrl(item.url) : undefined;
220
+ return (
221
+ <div
222
+ key={`${item.id}-${index}`}
223
+ class="rounded-box border border-base-300 bg-base-100 shadow-sm"
224
+ >
225
+ <div class="aspect-square overflow-hidden rounded-t-box bg-base-200">
226
+ {previewUrl ? (
227
+ <img
228
+ class="h-full w-full object-cover"
229
+ src={previewUrl}
230
+ alt={item.filename ?? `image-${index + 1}`}
231
+ />
232
+ ) : (
233
+ <div class="flex h-full w-full items-center justify-center text-sm text-base-content/50">
234
+ #{String(item.id)}
235
+ </div>
236
+ )}
237
+ </div>
238
+ <div class="space-y-2 p-3">
239
+ <div class="min-h-10 text-sm text-base-content/70">
240
+ <div class="truncate font-medium text-base-content">
241
+ {item.filename ?? `#${String(item.id)}`}
242
+ </div>
243
+ {(item.width || item.height) && (
244
+ <div class="text-xs text-base-content/60">
245
+ {item.width ?? '-'} × {item.height ?? '-'}
246
+ </div>
247
+ )}
248
+ </div>
249
+ <div class="flex flex-wrap gap-2">
250
+ {previewUrl && (
251
+ <button
252
+ type="button"
253
+ class="btn btn-sm btn-outline"
254
+ onClick={() => {
255
+ this._openPreviewDialog(items, index);
256
+ }}
257
+ >
258
+ {this.scope.locale.PreviewImage()}
259
+ </button>
260
+ )}
261
+ {!readonly && (
262
+ <button
263
+ type="button"
264
+ class="btn btn-sm btn-outline btn-error"
265
+ onClick={() => {
266
+ this._removeItem(item.id, disableNotifyChanged);
267
+ }}
268
+ >
269
+ {this.scope.locale.RemoveImage()}
270
+ </button>
271
+ )}
272
+ </div>
273
+ </div>
274
+ </div>
275
+ );
276
+ }
277
+
278
+ private _getUploadButtonText(itemCount: number) {
279
+ const options = this.currentOptions ?? {};
280
+ if (options.multiple) {
281
+ return itemCount > 0 ? this.scope.locale.AddImage() : this.scope.locale.SelectImage();
282
+ }
283
+ return itemCount > 0 ? this.scope.locale.ReplaceImage() : this.scope.locale.SelectImage();
284
+ }
285
+
286
+ private async _handleFileChange(event: Event, disableNotifyChanged?: boolean) {
287
+ const input = event.target as HTMLInputElement;
288
+ const files = Array.from(input.files ?? []);
289
+ input.value = '';
290
+ if (files.length === 0) return;
291
+ this.errorMessage = undefined;
292
+ const options = this.currentOptions ?? {};
293
+ const multiple = !!options.multiple;
294
+ const currentIds = this._normalizeValueToImageIds(this.currentValue, multiple);
295
+ const filesToHandle = options.multiple ? files : files.slice(0, 1);
296
+ const maxCount = this._getMaxCount(options);
297
+ const nextCountCandidate = options.multiple
298
+ ? currentIds.length + filesToHandle.length
299
+ : filesToHandle.length;
300
+ if (nextCountCandidate > maxCount) {
301
+ this.errorMessage = this.scope.locale.TooManyImages(maxCount);
302
+ return;
303
+ }
304
+ const uploadTarget = this._resolveUploadTarget();
305
+ this.isUploading = true;
306
+ try {
307
+ const uploadedItems: IImagePreviewItem[] = [];
308
+ for (const file of filesToHandle) {
309
+ const validationMessage = this._validateFile(file, options);
310
+ if (validationMessage) {
311
+ this.errorMessage = validationMessage;
312
+ continue;
313
+ }
314
+ const preparedFile = await this._prepareFile(file, options);
315
+ if (!preparedFile) continue;
316
+ const tokenRes = await this.scope.api.image.createUploadToken({
317
+ ...uploadTarget,
318
+ size: preparedFile.size,
319
+ mimeType: preparedFile.type || file.type,
320
+ });
321
+ const uploaded = await this.scope.api.image.upload({
322
+ token: tokenRes.token,
323
+ image: preparedFile,
324
+ });
325
+ const item: IImagePreviewItem = {
326
+ id: uploaded.id,
327
+ url: uploaded.url,
328
+ filename: uploaded.filename,
329
+ width: uploaded.width,
330
+ height: uploaded.height,
331
+ };
332
+ this.uploadedPreviewMap[String(item.id)] = item;
333
+ uploadedItems.push(item);
334
+ }
335
+ if (uploadedItems.length === 0) return;
336
+ const nextIds = options.multiple
337
+ ? [...currentIds, ...uploadedItems.map(item => item.id)]
338
+ : [uploadedItems[uploadedItems.length - 1].id];
339
+ this._setFieldValue(nextIds, disableNotifyChanged, multiple);
340
+ this.errorMessage = undefined;
341
+ } catch (err: any) {
342
+ this.errorMessage = err?.message ?? this.scope.locale.ImageUploadFailed();
343
+ } finally {
344
+ this.isUploading = false;
345
+ }
346
+ }
347
+
348
+ private _resolveUploadTarget() {
349
+ const imageScene = this.currentOptions.imageScene;
350
+ if (!imageScene) {
351
+ throw new Error('should specify image upload scene');
352
+ }
353
+ return {
354
+ imageScene,
355
+ } as const;
356
+ }
357
+
358
+ private _removeItem(imageId: TableIdentity, disableNotifyChanged?: boolean) {
359
+ const multiple = !!this.currentOptions?.multiple;
360
+ const currentIds = this._normalizeValueToImageIds(this.currentValue, multiple);
361
+ const nextIds = currentIds.filter(item => String(item) !== String(imageId));
362
+ delete this.uploadedPreviewMap[String(imageId)];
363
+ this._setFieldValue(nextIds, disableNotifyChanged, multiple);
364
+ this.errorMessage = undefined;
365
+ }
366
+
367
+ private _setFieldValue(
368
+ imageIds: TableIdentity[],
369
+ disableNotifyChanged: boolean | undefined,
370
+ multiple: boolean,
371
+ ) {
372
+ const nextValue = multiple ? imageIds : (imageIds[0] ?? '');
373
+ this.currentValue = nextValue as any;
374
+ this._syncRelationField(imageIds);
375
+ this.$$formField?.setValue(nextValue, disableNotifyChanged);
376
+ this.$$formField?.handleBlur();
377
+ }
378
+
379
+ private _syncRelationField(imageIds: TableIdentity[]) {
380
+ const relationName = this._getRelationName();
381
+ if (!relationName) return;
382
+ const relationMap = this._getRelationPreviewMap();
383
+ const relationItems = imageIds.map(imageId => {
384
+ const key = String(imageId);
385
+ return this.uploadedPreviewMap[key] ?? relationMap[key] ?? { id: imageId };
386
+ });
387
+ const relationValue = this.currentOptions?.multiple
388
+ ? relationItems
389
+ : (relationItems[0] ?? undefined);
390
+ this.$$renderContext.$$form.setFieldValue(relationName as never, relationValue, true);
391
+ }
392
+
393
+ private _getPreviewItems(value: unknown) {
394
+ const multiple = !!this.currentOptions?.multiple;
395
+ const imageIds = this._normalizeValueToImageIds(value, multiple);
396
+ const relationMap = this._getRelationPreviewMap();
397
+ if (imageIds.length === 0) {
398
+ return Object.values(relationMap);
399
+ }
400
+ return imageIds.map(imageId => {
401
+ const key = String(imageId);
402
+ return this.uploadedPreviewMap[key] ?? relationMap[key] ?? { id: imageId };
403
+ });
404
+ }
405
+
406
+ private _getRelationPreviewMap() {
407
+ const relationName = this._getRelationName();
408
+ if (!relationName) return {} as Record<string, IImagePreviewItem>;
409
+ const relationValue = this.$$renderContext.$$form.getFieldValue(relationName as never);
410
+ const relationItems = Array.isArray(relationValue)
411
+ ? relationValue
412
+ : relationValue
413
+ ? [relationValue]
414
+ : [];
415
+ const map: Record<string, IImagePreviewItem> = {};
416
+ for (const relationItem of relationItems) {
417
+ if (!relationItem?.id) continue;
418
+ map[String(relationItem.id)] = {
419
+ id: relationItem.id,
420
+ url: relationItem.url,
421
+ filename: relationItem.filename,
422
+ width: relationItem.width,
423
+ height: relationItem.height,
424
+ };
425
+ }
426
+ return map;
427
+ }
428
+
429
+ private _getRelationName() {
430
+ return inferImageRelationName(this.$props.name, this.currentOptions.relationName);
431
+ }
432
+
433
+ private _normalizeValueToImageIds(value: unknown, multiple: boolean): TableIdentity[] {
434
+ if (Array.isArray(value)) {
435
+ return value
436
+ .filter(item => item !== undefined && item !== null && item !== '')
437
+ .map(item => item as TableIdentity);
438
+ }
439
+ if (!multiple && value !== undefined && value !== null && value !== '') {
440
+ return [value as TableIdentity];
441
+ }
442
+ return [];
443
+ }
444
+
445
+ private _getMaxCount(options: IResourceFormFieldImageOptions) {
446
+ if (!options.multiple) return 1;
447
+ return Math.max(options.maxCount ?? 1, 1);
448
+ }
449
+
450
+ private _getAcceptAttr(options?: IResourceFormFieldImageOptions) {
451
+ if (!options) return 'image/*';
452
+ if (typeof options.accept === 'string') return options.accept;
453
+ if (options.accept?.length) return options.accept.join(',');
454
+ const parts = [...(options.mimeTypes ?? []), ...(options.extensions ?? [])];
455
+ return parts.length > 0 ? parts.join(',') : 'image/*';
456
+ }
457
+
458
+ private _validateFile(file: File, options: IResourceFormFieldImageOptions) {
459
+ const acceptTokens = this._collectAcceptTokens(options);
460
+ if (acceptTokens.length > 0 && !this._matchesAccept(file, acceptTokens)) {
461
+ return this.scope.locale.InvalidImageType();
462
+ }
463
+ if (options.maxSize && file.size > options.maxSize) {
464
+ return this.scope.locale.ImageTooLarge(this._formatBytes(options.maxSize));
465
+ }
466
+ if (options.minSize && file.size < options.minSize) {
467
+ return this.scope.locale.ImageTooSmall(this._formatBytes(options.minSize));
468
+ }
469
+ return undefined;
470
+ }
471
+
472
+ private _collectAcceptTokens(options: IResourceFormFieldImageOptions) {
473
+ const accept = options.accept
474
+ ? Array.isArray(options.accept)
475
+ ? options.accept
476
+ : options.accept.split(',')
477
+ : [];
478
+ return [...accept, ...(options.mimeTypes ?? []), ...(options.extensions ?? [])]
479
+ .map(item => item.trim().toLowerCase())
480
+ .filter(item => !!item);
481
+ }
482
+
483
+ private _matchesAccept(file: File, acceptTokens: string[]) {
484
+ const mimeType = file.type.toLowerCase();
485
+ const extension = this._getFileExtension(file.name);
486
+ return acceptTokens.some(token => {
487
+ if (token === 'image/*') return mimeType.startsWith('image/');
488
+ if (token.startsWith('.')) return extension === token;
489
+ if (token.endsWith('/*')) {
490
+ const prefix = token.slice(0, token.length - 1);
491
+ return mimeType.startsWith(prefix);
492
+ }
493
+ return mimeType === token;
494
+ });
495
+ }
496
+
497
+ private _getFileExtension(filename: string) {
498
+ const index = filename.lastIndexOf('.');
499
+ if (index === -1) return '';
500
+ return filename.slice(index).toLowerCase();
501
+ }
502
+
503
+ private async _prepareFile(file: File, options: IResourceFormFieldImageOptions) {
504
+ if (!process.env.CLIENT) return file;
505
+ let canvas: HTMLCanvasElement | undefined;
506
+ if (options.enableCrop) {
507
+ canvas = await this._openCropDialog(file, options);
508
+ if (!canvas) return undefined;
509
+ }
510
+ const resizeOptions = options.resize;
511
+ if (!canvas && !resizeOptions) {
512
+ return file;
513
+ }
514
+ const sourceCanvas = canvas ?? (await this._createCanvasFromFile(file));
515
+ const targetCanvas = this._resizeCanvas(sourceCanvas, resizeOptions);
516
+ return await this._canvasToFile(targetCanvas, file, resizeOptions);
517
+ }
518
+
519
+ private async _openCropDialog(file: File, options: IResourceFormFieldImageOptions) {
520
+ const src = URL.createObjectURL(file);
521
+ try {
522
+ return await new Promise<HTMLCanvasElement | undefined>(resolve => {
523
+ let settled = false;
524
+ let cropperRef: { getResult?: () => { canvas?: HTMLCanvasElement } } | undefined;
525
+ const dialog = this.$appModal.dialog(
526
+ {
527
+ title: this.scope.locale.CropImage(),
528
+ slotDefault: () => {
529
+ return (
530
+ <ClientOnly>
531
+ <div class="space-y-4">
532
+ <div class="h-105 overflow-hidden rounded-box bg-base-200">
533
+ <Cropper
534
+ ref={ref => {
535
+ cropperRef = ref as any;
536
+ }}
537
+ class="h-full w-full"
538
+ src={src}
539
+ stencilComponent={
540
+ options.cropShape === 'round' ? CircleStencil : RectangleStencil
541
+ }
542
+ stencilProps={{
543
+ aspectRatio: options.cropAspectRatio,
544
+ }}
545
+ imageRestriction="stencil"
546
+ ></Cropper>
547
+ </div>
548
+ <p class="text-sm text-base-content/70">{this.scope.locale.AdjustImage()}</p>
549
+ </div>
550
+ </ClientOnly>
551
+ );
552
+ },
553
+ slotActions: modal => {
554
+ return (
555
+ <>
556
+ <button
557
+ type="button"
558
+ class="btn btn-ghost"
559
+ onClick={() => {
560
+ if (settled) return;
561
+ settled = true;
562
+ modal.close();
563
+ resolve(undefined);
564
+ }}
565
+ >
566
+ {this.scope.locale.CancelCrop()}
567
+ </button>
568
+ <button
569
+ type="button"
570
+ class="btn btn-primary"
571
+ onClick={() => {
572
+ if (settled) return;
573
+ const canvas = cropperRef?.getResult?.()?.canvas;
574
+ settled = true;
575
+ modal.close();
576
+ resolve(canvas);
577
+ }}
578
+ >
579
+ {this.scope.locale.ApplyCrop()}
580
+ </button>
581
+ </>
582
+ );
583
+ },
584
+ onClose: () => {
585
+ if (settled) return;
586
+ settled = true;
587
+ resolve(undefined);
588
+ },
589
+ },
590
+ {
591
+ maxWidth: 960,
592
+ showCloseButton: true,
593
+ closeOnBackdrop: false,
594
+ },
595
+ );
596
+ void dialog;
597
+ });
598
+ } finally {
599
+ URL.revokeObjectURL(src);
600
+ }
601
+ }
602
+
603
+ private async _createCanvasFromFile(file: File) {
604
+ const src = URL.createObjectURL(file);
605
+ try {
606
+ return await this._createCanvasFromUrl(src);
607
+ } finally {
608
+ URL.revokeObjectURL(src);
609
+ }
610
+ }
611
+
612
+ private async _createCanvasFromUrl(src: string) {
613
+ const image = await this._loadImage(src);
614
+ const canvas = document.createElement('canvas');
615
+ canvas.width = image.naturalWidth || image.width;
616
+ canvas.height = image.naturalHeight || image.height;
617
+ const context = canvas.getContext('2d');
618
+ if (!context) throw new Error(this.scope.locale.ImageUploadFailed());
619
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
620
+ return canvas;
621
+ }
622
+
623
+ private _resizeCanvas(
624
+ sourceCanvas: HTMLCanvasElement,
625
+ resizeOptions?: IResourceFormFieldImageResizeOptions,
626
+ ) {
627
+ if (!resizeOptions?.width && !resizeOptions?.height) {
628
+ return sourceCanvas;
629
+ }
630
+ const sourceWidth = sourceCanvas.width;
631
+ const sourceHeight = sourceCanvas.height;
632
+ const fallbackRatio = sourceWidth / Math.max(sourceHeight, 1);
633
+ const targetWidth =
634
+ resizeOptions.width ?? Math.round((resizeOptions.height ?? sourceHeight) * fallbackRatio);
635
+ const targetHeight =
636
+ resizeOptions.height ?? Math.round(targetWidth / Math.max(fallbackRatio, 0.0001));
637
+ const canvas = document.createElement('canvas');
638
+ canvas.width = targetWidth;
639
+ canvas.height = targetHeight;
640
+ const context = canvas.getContext('2d');
641
+ if (!context) return sourceCanvas;
642
+ if (resizeOptions.background) {
643
+ context.fillStyle = resizeOptions.background;
644
+ context.fillRect(0, 0, targetWidth, targetHeight);
645
+ }
646
+ const fit = resizeOptions.fit ?? 'cover';
647
+ if (fit === 'cover' || fit === 'crop') {
648
+ const scale = Math.max(targetWidth / sourceWidth, targetHeight / sourceHeight);
649
+ const width = sourceWidth * scale;
650
+ const height = sourceHeight * scale;
651
+ const left = (targetWidth - width) / 2;
652
+ const top = (targetHeight - height) / 2;
653
+ context.drawImage(sourceCanvas, left, top, width, height);
654
+ return canvas;
655
+ }
656
+ const scaleContain = Math.min(targetWidth / sourceWidth, targetHeight / sourceHeight);
657
+ const scale = fit === 'scale-down' ? Math.min(1, scaleContain) : scaleContain;
658
+ const width = sourceWidth * scale;
659
+ const height = sourceHeight * scale;
660
+ const left = (targetWidth - width) / 2;
661
+ const top = (targetHeight - height) / 2;
662
+ context.drawImage(sourceCanvas, left, top, width, height);
663
+ return canvas;
664
+ }
665
+
666
+ private async _canvasToFile(
667
+ canvas: HTMLCanvasElement,
668
+ file: File,
669
+ resizeOptions?: IResourceFormFieldImageResizeOptions,
670
+ ) {
671
+ const mimeType = this._resolveMimeType(file.type, resizeOptions?.format);
672
+ const quality = this._resolveQuality(resizeOptions?.quality);
673
+ const blob = await new Promise<Blob>((resolve, reject) => {
674
+ canvas.toBlob(
675
+ result => {
676
+ if (!result) {
677
+ reject(new Error(this.scope.locale.ImageUploadFailed()));
678
+ return;
679
+ }
680
+ resolve(result);
681
+ },
682
+ mimeType,
683
+ quality,
684
+ );
685
+ });
686
+ return new File([blob], this._replaceExtension(file.name, mimeType), {
687
+ type: mimeType,
688
+ lastModified: Date.now(),
689
+ });
690
+ }
691
+
692
+ private _resolveMimeType(
693
+ fileType: string | undefined,
694
+ format?: IImageTransformOptions['format'],
695
+ ) {
696
+ if (format === 'jpeg') return 'image/jpeg';
697
+ if (format === 'png') return 'image/png';
698
+ if (format === 'webp') return 'image/webp';
699
+ if (format === 'avif') return 'image/avif';
700
+ if (fileType && fileType.startsWith('image/')) return fileType;
701
+ return 'image/jpeg';
702
+ }
703
+
704
+ private _resolveQuality(quality?: number) {
705
+ if (quality === undefined) return 0.92;
706
+ if (quality > 1) return Math.min(quality / 100, 1);
707
+ return quality;
708
+ }
709
+
710
+ private _replaceExtension(filename: string, mimeType: string) {
711
+ const ext =
712
+ mimeType === 'image/png'
713
+ ? '.png'
714
+ : mimeType === 'image/webp'
715
+ ? '.webp'
716
+ : mimeType === 'image/avif'
717
+ ? '.avif'
718
+ : '.jpg';
719
+ const index = filename.lastIndexOf('.');
720
+ if (index === -1) return `${filename}${ext}`;
721
+ return `${filename.slice(0, index)}${ext}`;
722
+ }
723
+
724
+ private _loadImage(src: string) {
725
+ return new Promise<HTMLImageElement>((resolve, reject) => {
726
+ const image = new Image();
727
+ image.onload = () => resolve(image);
728
+ image.onerror = () => reject(new Error(this.scope.locale.ImageUploadFailed()));
729
+ image.src = src;
730
+ });
731
+ }
732
+
733
+ private _openPreviewDialog(items: IImagePreviewItem[], currentIndex: number) {
734
+ const previewItems = items
735
+ .map((item, index) => ({
736
+ index,
737
+ item,
738
+ }))
739
+ .filter(({ item }) => !!item.url)
740
+ .map(({ index, item }) => ({
741
+ index,
742
+ item: {
743
+ url: item.url!,
744
+ filename: item.filename,
745
+ },
746
+ }));
747
+ if (previewItems.length === 0) return;
748
+ const initialIndex = previewItems.findIndex(({ index }) => index === currentIndex);
749
+ openImagePreviewDialog({
750
+ appModal: this.$appModal,
751
+ title: this._getPreviewDialogTitle(previewItems.length),
752
+ items: previewItems.map(({ item }) => item),
753
+ initialIndex: initialIndex === -1 ? 0 : initialIndex,
754
+ baseURL: this.sys.config.api.baseURL,
755
+ });
756
+ }
757
+
758
+ private _getPreviewDialogTitle(count: number) {
759
+ return buildImagePreviewTitle(
760
+ this.$$renderContext.$celScope.property?.title ??
761
+ this.$$renderContext.$celScope.name ??
762
+ this.$props.name,
763
+ count,
764
+ () => this.scope.locale.PreviewImage(),
765
+ );
766
+ }
767
+
768
+ private _resolvePreviewUrl(url: string) {
769
+ return resolveImagePreviewUrl(url, this.sys.config.api.baseURL);
770
+ }
771
+
772
+ private _formatBytes(bytes: number) {
773
+ if (bytes >= 1024 * 1024) {
774
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
775
+ }
776
+ if (bytes >= 1024) {
777
+ return `${Math.round(bytes / 1024)} KB`;
778
+ }
779
+ return `${bytes} B`;
780
+ }
781
+ }