@kine-design/crud 0.0.1-beta.2 → 0.0.1-beta.21

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 (118) hide show
  1. package/.vlaude/last-session-id +1 -0
  2. package/components/crudPage/KCrudPage.tsx +178 -0
  3. package/components/crudPage/crudPage.css +64 -0
  4. package/components/crudPage/index.ts +10 -0
  5. package/components/editableTable/KEditableTable.tsx +281 -0
  6. package/components/editableTable/editableTable.css +268 -0
  7. package/components/editableTable/index.ts +10 -0
  8. package/components/formPage/KApprovalDialog.tsx +142 -0
  9. package/components/formPage/KFormCard.tsx +65 -0
  10. package/components/formPage/KFormPage.tsx +128 -0
  11. package/components/formPage/KMasterDetailPage.tsx +205 -0
  12. package/components/formPage/KStickyActionBar.tsx +33 -0
  13. package/components/formPage/formPage.css +629 -0
  14. package/components/formPage/index.ts +14 -0
  15. package/components/layout/KContent.tsx +20 -0
  16. package/components/layout/KHeader.tsx +37 -0
  17. package/components/layout/KLayout.tsx +82 -0
  18. package/components/layout/KSider.tsx +80 -0
  19. package/components/layout/index.ts +18 -0
  20. package/components/layout/layout.css +262 -0
  21. package/components/login/KLoginPage.tsx +129 -0
  22. package/components/login/index.ts +10 -0
  23. package/components/login/login.css +118 -0
  24. package/components/navMenu/KNavMenu.tsx +175 -0
  25. package/components/navMenu/index.ts +2 -0
  26. package/components/navMenu/navMenu.css +197 -0
  27. package/components/pageHeader/KPageHeader.tsx +85 -0
  28. package/components/pageHeader/index.ts +9 -0
  29. package/components/pageHeader/pageHeader.css +93 -0
  30. package/components/searchTable/KSearchTable.tsx +138 -0
  31. package/components/searchTable/index.ts +10 -0
  32. package/components/searchTable/searchTable.css +121 -0
  33. package/components/upload/KFileList.tsx +95 -0
  34. package/components/upload/KImageUpload.tsx +286 -0
  35. package/components/upload/KUpload.tsx +206 -0
  36. package/components/upload/index.ts +13 -0
  37. package/components/upload/types.ts +26 -0
  38. package/components/upload/upload.css +345 -0
  39. package/composables/auth/authGuard.ts +128 -0
  40. package/composables/auth/index.ts +23 -0
  41. package/composables/auth/types.ts +109 -0
  42. package/composables/auth/useAuth.ts +278 -0
  43. package/composables/auth/vCan.ts +95 -0
  44. package/composables/defineRepository.ts +224 -0
  45. package/composables/error/createErrorHandler.ts +46 -0
  46. package/composables/error/defaultFeedbackHandler.ts +76 -0
  47. package/composables/error/dispatchError.ts +70 -0
  48. package/composables/error/index.ts +32 -0
  49. package/composables/error/types.ts +57 -0
  50. package/composables/error/useErrorHandler.ts +41 -0
  51. package/composables/form/index.ts +18 -0
  52. package/composables/form/renderFormField.tsx +119 -0
  53. package/composables/form/types.ts +129 -0
  54. package/composables/form/useFormPage.ts +183 -0
  55. package/composables/index.ts +62 -0
  56. package/composables/page/index.ts +11 -0
  57. package/composables/page/types.ts +62 -0
  58. package/composables/page/useCrudPage.ts +88 -0
  59. package/composables/request/composables.ts +206 -0
  60. package/composables/request/controlGate.ts +143 -0
  61. package/composables/request/createRequest.ts +173 -0
  62. package/composables/request/index.ts +71 -0
  63. package/composables/request/orchestrator.ts +145 -0
  64. package/composables/request/requestBuilder.ts +418 -0
  65. package/composables/request/transport/fetchTransport.ts +79 -0
  66. package/composables/request/transport/xhrTransport.ts +100 -0
  67. package/composables/request/types.ts +226 -0
  68. package/composables/request/upload.ts +146 -0
  69. package/composables/router/createRouterGuard.ts +134 -0
  70. package/composables/router/defineCrudRoutes.ts +116 -0
  71. package/composables/router/index.ts +22 -0
  72. package/composables/router/types.ts +128 -0
  73. package/composables/router/useMenuFromRoutes.ts +109 -0
  74. package/composables/router/useTabStore.ts +183 -0
  75. package/composables/search/index.ts +11 -0
  76. package/composables/search/useAutoCompleteSearch.ts +161 -0
  77. package/composables/setupCrud.ts +43 -0
  78. package/composables/storage/createStorageAdapter.ts +72 -0
  79. package/composables/storage/index.ts +13 -0
  80. package/composables/storage/types.ts +30 -0
  81. package/composables/storage/useStorage.ts +108 -0
  82. package/composables/store/defineUserStore.ts +122 -0
  83. package/composables/store/index.ts +11 -0
  84. package/composables/types.ts +118 -0
  85. package/dist/components/crudPage/KCrudPage.d.ts +14 -0
  86. package/dist/components/crudPage/index.d.ts +9 -0
  87. package/dist/components/editableTable/KEditableTable.d.ts +146 -0
  88. package/dist/components/editableTable/index.d.ts +10 -0
  89. package/dist/components/formPage/KApprovalDialog.d.ts +99 -0
  90. package/dist/components/formPage/KFormCard.d.ts +49 -0
  91. package/dist/components/formPage/KFormPage.d.ts +14 -0
  92. package/dist/components/formPage/KMasterDetailPage.d.ts +14 -0
  93. package/dist/components/formPage/KStickyActionBar.d.ts +16 -0
  94. package/dist/components/formPage/index.d.ts +14 -0
  95. package/dist/components/layout/KLayout.d.ts +7 -4
  96. package/dist/composables/auth/useAuth.d.ts +5 -5
  97. package/dist/composables/error/types.d.ts +2 -1
  98. package/dist/composables/form/index.d.ts +12 -0
  99. package/dist/composables/form/renderFormField.d.ts +11 -0
  100. package/dist/composables/form/types.d.ts +104 -0
  101. package/dist/composables/form/useFormPage.d.ts +38 -0
  102. package/dist/composables/index.d.ts +2 -0
  103. package/dist/composables/page/index.d.ts +10 -0
  104. package/dist/composables/page/types.d.ts +61 -0
  105. package/dist/composables/page/useCrudPage.d.ts +14 -0
  106. package/dist/composables/request/createRequest.d.ts +2 -0
  107. package/dist/composables/request/requestBuilder.d.ts +2 -0
  108. package/dist/composables/search/index.d.ts +10 -0
  109. package/dist/composables/search/useAutoCompleteSearch.d.ts +50 -0
  110. package/dist/crud.css +2499 -663
  111. package/dist/crud.js +11512 -2910
  112. package/dist/index.d.ts +11 -0
  113. package/dist/setup.d.ts +2 -2
  114. package/index.ts +144 -0
  115. package/package.json +20 -19
  116. package/setup.ts +288 -0
  117. package/tsconfig.json +12 -0
  118. package/vite.config.build.ts +52 -0
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @description KSearchTable 搜索表格组合组件样式 — Phosphor 主题
3
+ * @author 阿怪
4
+ * @date 2026/2/26
5
+ * @version v0.0.2
6
+ */
7
+
8
+ /* ===== 根容器:flex 纵向,占满父元素高度 ===== */
9
+ .k-search-table {
10
+ display: flex;
11
+ flex-direction: column;
12
+ height: 100%;
13
+ width: 100%;
14
+ box-sizing: border-box;
15
+ background-color: transparent;
16
+ gap: var(--kine-spacing-6);
17
+ }
18
+
19
+ /* 加载态透明度 */
20
+ .k-search-table--loading {
21
+ opacity: 0.6;
22
+ pointer-events: none;
23
+ }
24
+
25
+ /* ===== 搜索区:固定高度,不参与 flex 伸缩 ===== */
26
+ .k-search-table-search {
27
+ display: flex;
28
+ flex-direction: row;
29
+ align-items: flex-end;
30
+ flex-wrap: wrap;
31
+ gap: var(--kine-spacing-6);
32
+ flex-shrink: 0;
33
+ padding: var(--kine-spacing-8);
34
+ background-color: var(--kine-color-bg-primary);
35
+ border-radius: var(--kine-radius-md);
36
+ border: 1px solid var(--kine-color-border-default);
37
+ box-sizing: border-box;
38
+ }
39
+
40
+ /* 搜索表单插槽容器 */
41
+ .k-search-table-search-form {
42
+ display: flex;
43
+ flex-direction: row;
44
+ flex-wrap: wrap;
45
+ gap: var(--kine-spacing-6);
46
+ flex: 1;
47
+ min-width: 0;
48
+ }
49
+
50
+ /* 搜索 / 重置按钮区 */
51
+ .k-search-table-search-actions {
52
+ display: flex;
53
+ flex-direction: row;
54
+ align-items: center;
55
+ gap: var(--kine-spacing-4);
56
+ flex-shrink: 0;
57
+ }
58
+
59
+ /* ===== 工具栏:搜索区与表格之间 ===== */
60
+ .k-search-table-toolbar {
61
+ display: flex;
62
+ flex-direction: row;
63
+ align-items: center;
64
+ gap: var(--kine-spacing-4);
65
+ flex-shrink: 0;
66
+ min-height: 36px;
67
+ }
68
+
69
+ /* ===== 表格区:flex: 1 撑满剩余空间,overflow hidden 防溢出 ===== */
70
+ .k-search-table-body {
71
+ flex: 1;
72
+ min-height: 0;
73
+ overflow: hidden;
74
+ background-color: var(--kine-color-bg-primary);
75
+ border-radius: var(--kine-radius-md);
76
+ border: 1px solid var(--kine-color-border-default);
77
+ box-sizing: border-box;
78
+ }
79
+
80
+ /* KTable 内部需要能撑满,设置高度 100% */
81
+ .k-search-table-body .k-table {
82
+ height: 100%;
83
+ }
84
+
85
+ /* ===== 分页区:固定底部,不参与 flex 伸缩 ===== */
86
+ .k-search-table-pagination {
87
+ display: flex;
88
+ flex-direction: row;
89
+ justify-content: flex-end;
90
+ align-items: center;
91
+ flex-shrink: 0;
92
+ padding: var(--kine-spacing-4) 0;
93
+ }
94
+
95
+ /* ================================================================
96
+ 移动端(<768px)
97
+ ================================================================ */
98
+
99
+ @media (max-width: 767px) {
100
+ /* 搜索区从横排改为纵排 */
101
+ .k-search-table-search {
102
+ flex-direction: column;
103
+ align-items: stretch;
104
+ padding: var(--kine-spacing-6);
105
+ gap: var(--kine-spacing-4);
106
+ }
107
+
108
+ .k-search-table-search-form {
109
+ flex-direction: column;
110
+ }
111
+
112
+ /* 搜索按钮区靠右 */
113
+ .k-search-table-search-actions {
114
+ justify-content: flex-end;
115
+ }
116
+
117
+ /* 表格区允许横向滚动 */
118
+ .k-search-table-body {
119
+ overflow-x: auto;
120
+ }
121
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @description KFileList 文件列表展示组件
3
+ * @author 阿怪
4
+ * @date 2026/2/26
5
+ * @version v0.0.1
6
+ *
7
+ * 江湖的业务千篇一律,复杂的代码好几百行。
8
+ */
9
+ import { defineComponent, PropType } from 'vue';
10
+ import type { UploadFile } from './types';
11
+ import KButton from 'kine-ui/components/button/KButton.tsx';
12
+ import './upload.css';
13
+
14
+ const STATUS_TEXT: Record<UploadFile['status'], string> = {
15
+ pending: '等待中',
16
+ uploading: '上传中',
17
+ success: '已完成',
18
+ error: '上传失败',
19
+ };
20
+
21
+ function formatSize(bytes: number): string {
22
+ if (bytes < 1024) return `${bytes} B`;
23
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
24
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
25
+ }
26
+
27
+ export default defineComponent({
28
+ name: 'KFileList',
29
+ props: {
30
+ files: {
31
+ type: Array as PropType<UploadFile[]>,
32
+ default: () => [],
33
+ },
34
+ removable: {
35
+ type: Boolean,
36
+ default: true,
37
+ },
38
+ },
39
+ emits: ['remove'],
40
+ setup(props, ctx) {
41
+ return () => {
42
+ if (props.files.length === 0) return null;
43
+
44
+ return (
45
+ <ul class="k-file-list">
46
+ {props.files.map(file => {
47
+ const statusClass = `k-file-list-item--${file.status}`;
48
+
49
+ return (
50
+ <li key={file.uid} class={['k-file-list-item', statusClass]}>
51
+ {/* 文件图标 */}
52
+ <span class="k-file-list-icon" aria-hidden="true">
53
+ {file.status === 'success' ? '✓' : file.status === 'error' ? '✕' : '○'}
54
+ </span>
55
+
56
+ {/* 文件信息 */}
57
+ <div class="k-file-list-info">
58
+ <span class="k-file-list-name" title={file.name}>
59
+ {file.url
60
+ ? <a class="k-file-list-link" href={file.url} target="_blank" rel="noopener noreferrer">{file.name}</a>
61
+ : file.name
62
+ }
63
+ </span>
64
+ <span class="k-file-list-meta">
65
+ {formatSize(file.size)} · {STATUS_TEXT[file.status]}
66
+ </span>
67
+ </div>
68
+
69
+ {/* 删除按钮 */}
70
+ {props.removable && (
71
+ <KButton
72
+ class="k-file-list-remove"
73
+ plain
74
+ text="×"
75
+ onClick={() => ctx.emit('remove', file)}
76
+ />
77
+ )}
78
+
79
+ {/* 进度条(仅上传中显示,grid 第二行跨满) */}
80
+ {file.status === 'uploading' && (
81
+ <div class="k-file-list-progress-track">
82
+ <div
83
+ class="k-file-list-progress-fill"
84
+ style={{ width: `${file.percent}%` }}
85
+ />
86
+ </div>
87
+ )}
88
+ </li>
89
+ );
90
+ })}
91
+ </ul>
92
+ );
93
+ };
94
+ },
95
+ });
@@ -0,0 +1,286 @@
1
+ /**
2
+ * @description KImageUpload 图片上传组件,支持预览与多图网格
3
+ * @author 阿怪
4
+ * @date 2026/2/26
5
+ * @version v0.0.1
6
+ *
7
+ * 江湖的业务千篇一律,复杂的代码好几百行。
8
+ */
9
+ import { defineComponent, ref, computed, onBeforeUnmount, PropType } from 'vue';
10
+ import type { UploadFile, UploadRequest } from './types';
11
+ import KButton from 'kine-ui/components/button/KButton.tsx';
12
+ import './upload.css';
13
+
14
+ let uidCounter = 0;
15
+ function genUid(): string {
16
+ return `img-upload-${Date.now()}-${++uidCounter}`;
17
+ }
18
+
19
+ export default defineComponent({
20
+ name: 'KImageUpload',
21
+ props: {
22
+ request: {
23
+ type: Function as PropType<UploadRequest>,
24
+ required: true,
25
+ },
26
+ accept: {
27
+ type: String,
28
+ default: 'image/*',
29
+ },
30
+ multiple: {
31
+ type: Boolean,
32
+ default: true,
33
+ },
34
+ maxCount: {
35
+ type: Number,
36
+ default: Infinity,
37
+ },
38
+ maxSize: {
39
+ type: Number,
40
+ default: Infinity,
41
+ },
42
+ disabled: {
43
+ type: Boolean,
44
+ default: false,
45
+ },
46
+ fileList: {
47
+ type: Array as PropType<UploadFile[]>,
48
+ default: () => [],
49
+ },
50
+ drag: {
51
+ type: Boolean,
52
+ default: true,
53
+ },
54
+ previewSize: {
55
+ type: String,
56
+ default: '100px',
57
+ },
58
+ },
59
+ emits: ['update:fileList', 'change', 'error'],
60
+ setup(props, ctx) {
61
+ const inputRef = ref<HTMLInputElement | null>(null);
62
+ const isDragOver = ref(false);
63
+ // 本地 objectURL 缓存,组件销毁时统一释放
64
+ const objectUrlMap = ref<Map<string, string>>(new Map());
65
+
66
+ const getPreviewUrl = (file: UploadFile): string => {
67
+ if (file.thumbUrl) return file.thumbUrl;
68
+ if (file.url) return file.url;
69
+ if (file.raw) {
70
+ const existing = objectUrlMap.value.get(file.uid);
71
+ if (existing) return existing;
72
+ const url = URL.createObjectURL(file.raw);
73
+ objectUrlMap.value.set(file.uid, url);
74
+ return url;
75
+ }
76
+ return '';
77
+ };
78
+
79
+ onBeforeUnmount(() => {
80
+ objectUrlMap.value.forEach(url => URL.revokeObjectURL(url));
81
+ objectUrlMap.value.clear();
82
+ });
83
+
84
+ const showAddCard = computed(
85
+ () => !props.disabled && props.fileList.length < props.maxCount,
86
+ );
87
+
88
+ const openFileDialog = () => {
89
+ if (props.disabled) return;
90
+ inputRef.value?.click();
91
+ };
92
+
93
+ const processFiles = async (rawFiles: FileList | File[]) => {
94
+ const fileArray = Array.from(rawFiles);
95
+ const currentCount = props.fileList.length;
96
+ const remaining = props.maxCount - currentCount;
97
+ if (remaining <= 0) return;
98
+
99
+ const filesToProcess = fileArray.slice(0, remaining);
100
+ const newList = [...props.fileList];
101
+
102
+ for (const raw of filesToProcess) {
103
+ if (props.maxSize !== Infinity && raw.size > props.maxSize) {
104
+ ctx.emit('error', { file: raw, message: `文件 "${raw.name}" 超过大小限制` });
105
+ continue;
106
+ }
107
+
108
+ const uploadFile: UploadFile = {
109
+ uid: genUid(),
110
+ name: raw.name,
111
+ size: raw.size,
112
+ type: raw.type,
113
+ status: 'uploading',
114
+ percent: 0,
115
+ raw,
116
+ };
117
+
118
+ newList.push(uploadFile);
119
+ ctx.emit('update:fileList', [...newList]);
120
+ ctx.emit('change', uploadFile);
121
+
122
+ try {
123
+ const result = await props.request(raw, (percent) => {
124
+ const current = newList.find(f => f.uid === uploadFile.uid);
125
+ if (current) {
126
+ const i = newList.indexOf(current);
127
+ newList[i] = { ...current, percent };
128
+ ctx.emit('update:fileList', [...newList]);
129
+ }
130
+ });
131
+
132
+ const current = newList.find(f => f.uid === uploadFile.uid);
133
+ if (current) {
134
+ // 上传成功后释放 objectURL,改用远程 url
135
+ const localUrl = objectUrlMap.value.get(uploadFile.uid);
136
+ if (localUrl) {
137
+ URL.revokeObjectURL(localUrl);
138
+ objectUrlMap.value.delete(uploadFile.uid);
139
+ }
140
+ const i = newList.indexOf(current);
141
+ newList[i] = {
142
+ ...current,
143
+ status: 'success',
144
+ percent: 100,
145
+ url: result.url,
146
+ };
147
+ ctx.emit('update:fileList', [...newList]);
148
+ ctx.emit('change', newList[i]);
149
+ }
150
+ } catch (e) {
151
+ const current = newList.find(f => f.uid === uploadFile.uid);
152
+ if (current) {
153
+ const i = newList.indexOf(current);
154
+ newList[i] = { ...current, status: 'error' };
155
+ ctx.emit('update:fileList', [...newList]);
156
+ ctx.emit('error', { file: raw, message: String(e) });
157
+ }
158
+ }
159
+ }
160
+ };
161
+
162
+ const onInputChange = (e: Event) => {
163
+ const target = e.target as HTMLInputElement;
164
+ if (target.files) {
165
+ processFiles(target.files);
166
+ target.value = '';
167
+ }
168
+ };
169
+
170
+ const onDragOver = (e: DragEvent) => {
171
+ if (props.disabled || !props.drag) return;
172
+ e.preventDefault();
173
+ isDragOver.value = true;
174
+ };
175
+
176
+ const onDragLeave = () => {
177
+ isDragOver.value = false;
178
+ };
179
+
180
+ const onDrop = (e: DragEvent) => {
181
+ if (props.disabled || !props.drag) return;
182
+ e.preventDefault();
183
+ isDragOver.value = false;
184
+ if (e.dataTransfer?.files) {
185
+ processFiles(e.dataTransfer.files);
186
+ }
187
+ };
188
+
189
+ const removeFile = (uid: string) => {
190
+ const localUrl = objectUrlMap.value.get(uid);
191
+ if (localUrl) {
192
+ URL.revokeObjectURL(localUrl);
193
+ objectUrlMap.value.delete(uid);
194
+ }
195
+ const next = props.fileList.filter(f => f.uid !== uid);
196
+ ctx.emit('update:fileList', next);
197
+ };
198
+
199
+ return () => {
200
+ const cardStyle = {
201
+ width: props.previewSize,
202
+ height: props.previewSize,
203
+ };
204
+
205
+ const previewCards = props.fileList.map(file => {
206
+ const previewUrl = getPreviewUrl(file);
207
+ const statusClass = `k-image-upload-card--${file.status}`;
208
+
209
+ return (
210
+ <div
211
+ key={file.uid}
212
+ class={['k-image-upload-card', statusClass]}
213
+ style={cardStyle}
214
+ >
215
+ {previewUrl && (
216
+ <img
217
+ class="k-image-upload-preview"
218
+ src={previewUrl}
219
+ alt={file.name}
220
+ />
221
+ )}
222
+ {file.status === 'uploading' && (
223
+ <div class="k-image-upload-overlay">
224
+ <div
225
+ class="k-image-upload-progress"
226
+ style={{ height: `${file.percent}%` }}
227
+ />
228
+ <span class="k-image-upload-percent">{file.percent}%</span>
229
+ </div>
230
+ )}
231
+ {file.status === 'error' && (
232
+ <div class="k-image-upload-overlay k-image-upload-overlay--error">
233
+ <span>上传失败</span>
234
+ </div>
235
+ )}
236
+ {!props.disabled && (
237
+ <KButton
238
+ class="k-image-upload-remove"
239
+ plain
240
+ text="×"
241
+ onClick={(e: MouseEvent) => {
242
+ e.stopPropagation();
243
+ removeFile(file.uid);
244
+ }}
245
+ />
246
+ )}
247
+ </div>
248
+ );
249
+ });
250
+
251
+ const addCard = showAddCard.value && (
252
+ <div
253
+ class={[
254
+ 'k-image-upload-card',
255
+ 'k-image-upload-card--add',
256
+ isDragOver.value ? 'k-upload-drag-over' : '',
257
+ ].filter(Boolean)}
258
+ style={cardStyle}
259
+ onClick={openFileDialog}
260
+ onDragover={onDragOver}
261
+ onDragleave={onDragLeave}
262
+ onDrop={onDrop}
263
+ >
264
+ <span class="k-image-upload-add-icon">+</span>
265
+ </div>
266
+ );
267
+
268
+ return (
269
+ <div class={['k-image-upload', props.disabled ? 'k-upload-disabled' : ''].filter(Boolean)}>
270
+ <input
271
+ ref={inputRef}
272
+ type="file"
273
+ class="k-upload-input"
274
+ accept={props.accept}
275
+ multiple={props.multiple}
276
+ onChange={onInputChange}
277
+ />
278
+ <div class="k-image-upload-grid">
279
+ {previewCards}
280
+ {addCard}
281
+ </div>
282
+ </div>
283
+ );
284
+ };
285
+ },
286
+ });
@@ -0,0 +1,206 @@
1
+ /**
2
+ * @description KUpload 基础上传组件,支持点击与拖拽
3
+ * @author 阿怪
4
+ * @date 2026/2/26
5
+ * @version v0.0.1
6
+ *
7
+ * 江湖的业务千篇一律,复杂的代码好几百行。
8
+ */
9
+ import { defineComponent, ref, PropType } from 'vue';
10
+ import type { UploadFile, UploadRequest } from './types';
11
+ import './upload.css';
12
+
13
+ let uidCounter = 0;
14
+ function genUid(): string {
15
+ return `upload-${Date.now()}-${++uidCounter}`;
16
+ }
17
+
18
+ function formatSize(bytes: number): string {
19
+ if (bytes < 1024) return `${bytes} B`;
20
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
21
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
22
+ }
23
+
24
+ export default defineComponent({
25
+ name: 'KUpload',
26
+ props: {
27
+ request: {
28
+ type: Function as PropType<UploadRequest>,
29
+ required: true,
30
+ },
31
+ accept: {
32
+ type: String,
33
+ default: '',
34
+ },
35
+ multiple: {
36
+ type: Boolean,
37
+ default: false,
38
+ },
39
+ maxCount: {
40
+ type: Number,
41
+ default: Infinity,
42
+ },
43
+ maxSize: {
44
+ type: Number,
45
+ default: Infinity,
46
+ },
47
+ disabled: {
48
+ type: Boolean,
49
+ default: false,
50
+ },
51
+ fileList: {
52
+ type: Array as PropType<UploadFile[]>,
53
+ default: () => [],
54
+ },
55
+ drag: {
56
+ type: Boolean,
57
+ default: true,
58
+ },
59
+ },
60
+ emits: ['update:fileList', 'change', 'error'],
61
+ setup(props, ctx) {
62
+ const inputRef = ref<HTMLInputElement | null>(null);
63
+ const isDragOver = ref(false);
64
+
65
+ const openFileDialog = () => {
66
+ if (props.disabled) return;
67
+ inputRef.value?.click();
68
+ };
69
+
70
+ const validateFile = (file: File): string | null => {
71
+ if (props.maxSize !== Infinity && file.size > props.maxSize) {
72
+ return `文件 "${file.name}" 超过大小限制(最大 ${formatSize(props.maxSize)})`;
73
+ }
74
+ return null;
75
+ };
76
+
77
+ const processFiles = async (rawFiles: FileList | File[]) => {
78
+ const fileArray = Array.from(rawFiles);
79
+ const currentCount = props.fileList.length;
80
+ const remaining = props.maxCount - currentCount;
81
+
82
+ if (remaining <= 0) return;
83
+
84
+ const filesToProcess = fileArray.slice(0, remaining);
85
+ const newList = [...props.fileList];
86
+
87
+ for (const raw of filesToProcess) {
88
+ const err = validateFile(raw);
89
+ if (err) {
90
+ ctx.emit('error', { file: raw, message: err });
91
+ continue;
92
+ }
93
+
94
+ const uploadFile: UploadFile = {
95
+ uid: genUid(),
96
+ name: raw.name,
97
+ size: raw.size,
98
+ type: raw.type,
99
+ status: 'pending',
100
+ percent: 0,
101
+ raw,
102
+ };
103
+
104
+ newList.push(uploadFile);
105
+ ctx.emit('update:fileList', [...newList]);
106
+ ctx.emit('change', uploadFile);
107
+
108
+ // 启动上传
109
+ const idx = newList.indexOf(uploadFile);
110
+ newList[idx] = { ...uploadFile, status: 'uploading' };
111
+ ctx.emit('update:fileList', [...newList]);
112
+
113
+ try {
114
+ const result = await props.request(raw, (percent) => {
115
+ const current = newList.find(f => f.uid === uploadFile.uid);
116
+ if (current) {
117
+ const i = newList.indexOf(current);
118
+ newList[i] = { ...current, percent };
119
+ ctx.emit('update:fileList', [...newList]);
120
+ }
121
+ });
122
+
123
+ const current = newList.find(f => f.uid === uploadFile.uid);
124
+ if (current) {
125
+ const i = newList.indexOf(current);
126
+ newList[i] = { ...current, status: 'success', percent: 100, url: result.url };
127
+ ctx.emit('update:fileList', [...newList]);
128
+ ctx.emit('change', newList[i]);
129
+ }
130
+ } catch (e) {
131
+ const current = newList.find(f => f.uid === uploadFile.uid);
132
+ if (current) {
133
+ const i = newList.indexOf(current);
134
+ newList[i] = { ...current, status: 'error' };
135
+ ctx.emit('update:fileList', [...newList]);
136
+ ctx.emit('error', { file: raw, message: String(e) });
137
+ }
138
+ }
139
+ }
140
+ };
141
+
142
+ const onInputChange = (e: Event) => {
143
+ const target = e.target as HTMLInputElement;
144
+ if (target.files) {
145
+ processFiles(target.files);
146
+ // 清空 input,允许重复选同一文件
147
+ target.value = '';
148
+ }
149
+ };
150
+
151
+ const onDragOver = (e: DragEvent) => {
152
+ if (props.disabled || !props.drag) return;
153
+ e.preventDefault();
154
+ isDragOver.value = true;
155
+ };
156
+
157
+ const onDragLeave = () => {
158
+ isDragOver.value = false;
159
+ };
160
+
161
+ const onDrop = (e: DragEvent) => {
162
+ if (props.disabled || !props.drag) return;
163
+ e.preventDefault();
164
+ isDragOver.value = false;
165
+ if (e.dataTransfer?.files) {
166
+ processFiles(e.dataTransfer.files);
167
+ }
168
+ };
169
+
170
+ return () => {
171
+ const classes = [
172
+ 'k-upload',
173
+ props.drag ? 'k-upload-drag' : '',
174
+ props.disabled ? 'k-upload-disabled' : '',
175
+ isDragOver.value ? 'k-upload-drag-over' : '',
176
+ ].filter(Boolean);
177
+
178
+ return (
179
+ <div
180
+ class={classes}
181
+ onClick={openFileDialog}
182
+ onDragover={onDragOver}
183
+ onDragleave={onDragLeave}
184
+ onDrop={onDrop}
185
+ >
186
+ <input
187
+ ref={inputRef}
188
+ type="file"
189
+ class="k-upload-input"
190
+ accept={props.accept}
191
+ multiple={props.multiple}
192
+ onChange={onInputChange}
193
+ />
194
+ {ctx.slots.default?.() ?? (
195
+ <div class="k-upload-placeholder">
196
+ <span class="k-upload-icon">↑</span>
197
+ <span class="k-upload-text">
198
+ {props.drag ? '点击或拖拽文件到此处上传' : '点击上传文件'}
199
+ </span>
200
+ </div>
201
+ )}
202
+ </div>
203
+ );
204
+ };
205
+ },
206
+ });