diff --git a/auto-imports.d.ts b/auto-imports.d.ts index 776f1c9..6d61477 100644 --- a/auto-imports.d.ts +++ b/auto-imports.d.ts @@ -366,6 +366,9 @@ declare global { export type { HapticsOptions } from './src/composables/useVibrate' import('./src/composables/useVibrate') // @ts-ignore + export type { UploadFetchOptions } from './src/utils/aws/s3' + import('./src/utils/aws/s3') + // @ts-ignore export type { PageInstance, InputInstance, ModalInstance, FormInstance, ContentInstance } from './src/utils/ionic-helper' import('./src/utils/ionic-helper') } diff --git a/components.d.ts b/components.d.ts index d8e837d..28f4ecd 100644 --- a/components.d.ts +++ b/components.d.ts @@ -41,6 +41,7 @@ declare module 'vue' { IonModal: typeof import('@ionic/vue')['IonModal'] IonNote: typeof import('@ionic/vue')['IonNote'] IonPage: typeof import('@ionic/vue')['IonPage'] + IonProgressBar: typeof import('@ionic/vue')['IonProgressBar'] IonRadio: typeof import('@ionic/vue')['IonRadio'] IonRadioGroup: typeof import('@ionic/vue')['IonRadioGroup'] IonRange: typeof import('@ionic/vue')['IonRange'] @@ -99,6 +100,7 @@ declare global { const IonModal: typeof import('@ionic/vue')['IonModal'] const IonNote: typeof import('@ionic/vue')['IonNote'] const IonPage: typeof import('@ionic/vue')['IonPage'] + const IonProgressBar: typeof import('@ionic/vue')['IonProgressBar'] const IonRadio: typeof import('@ionic/vue')['IonRadio'] const IonRadioGroup: typeof import('@ionic/vue')['IonRadioGroup'] const IonRange: typeof import('@ionic/vue')['IonRange'] diff --git a/package.json b/package.json index 8d69b70..045dda2 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@elysiajs/eden": "^1.4.5", "@ionic/vue": "^8.7.11", "@ionic/vue-router": "^8.7.11", - "@riwa/api-types": "http://192.168.1.7:9527/api/riwa-eden-0.0.92.tgz", + "@riwa/api-types": "http://192.168.1.7:9527/api/riwa-eden-0.0.100.tgz", "@tailwindcss/vite": "^4.1.18", "@vee-validate/yup": "^4.15.1", "@vueuse/core": "^14.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02e7301..4e96aa4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,8 +57,8 @@ importers: specifier: ^8.7.11 version: 8.7.11(@stencil/core@4.39.0)(vue-router@4.6.3(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3)) '@riwa/api-types': - specifier: http://192.168.1.7:9527/api/riwa-eden-0.0.92.tgz - version: '@riwa/eden@http://192.168.1.7:9527/api/riwa-eden-0.0.92.tgz(@elysiajs/eden@1.4.5(elysia@1.4.18(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3)))' + specifier: http://192.168.1.7:9527/api/riwa-eden-0.0.100.tgz + version: '@riwa/eden@http://192.168.1.7:9527/api/riwa-eden-0.0.100.tgz(@elysiajs/eden@1.4.5(elysia@1.4.18(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3)))' '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2)) @@ -2772,9 +2772,9 @@ packages: '@remirror/core-constants@3.0.0': resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} - '@riwa/eden@http://192.168.1.7:9527/api/riwa-eden-0.0.92.tgz': - resolution: {tarball: http://192.168.1.7:9527/api/riwa-eden-0.0.92.tgz} - version: 0.0.92 + '@riwa/eden@http://192.168.1.7:9527/api/riwa-eden-0.0.100.tgz': + resolution: {tarball: http://192.168.1.7:9527/api/riwa-eden-0.0.100.tgz} + version: 0.0.100 peerDependencies: '@elysiajs/eden': ^1.4.5 @@ -12099,7 +12099,7 @@ snapshots: '@remirror/core-constants@3.0.0': {} - '@riwa/eden@http://192.168.1.7:9527/api/riwa-eden-0.0.92.tgz(@elysiajs/eden@1.4.5(elysia@1.4.18(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3)))': + '@riwa/eden@http://192.168.1.7:9527/api/riwa-eden-0.0.100.tgz(@elysiajs/eden@1.4.5(elysia@1.4.18(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3)))': dependencies: '@elysiajs/eden': 1.4.5(elysia@1.4.18(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3)) diff --git a/src/composables/useQRScanner.ts b/src/composables/useQRScanner.ts index 6ba3182..506001a 100644 --- a/src/composables/useQRScanner.ts +++ b/src/composables/useQRScanner.ts @@ -1,4 +1,4 @@ -import { CapacitorBarcodeScanner, CapacitorBarcodeScannerCameraDirection, CapacitorBarcodeScannerTypeHint } from "@capacitor/barcode-scanner"; +import { CapacitorBarcodeScanner, CapacitorBarcodeScannerCameraDirection, CapacitorBarcodeScannerScanOrientation, CapacitorBarcodeScannerTypeHint } from "@capacitor/barcode-scanner"; import { toastController } from "@ionic/vue"; export interface QRScanResult { @@ -24,6 +24,7 @@ export function useQRScanner() { hint: CapacitorBarcodeScannerTypeHint.QR_CODE, scanInstructions: options?.title || t("scanner.hint"), cameraDirection: CapacitorBarcodeScannerCameraDirection.BACK, + scanOrientation: CapacitorBarcodeScannerScanOrientation.PORTRAIT, }); vibrate(); diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 196e109..39cd496 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -385,6 +385,13 @@ "yesterday": "Yesterday", "items": "items" }, + "fileUpload": { + "uploadFile": "Upload File", + "files": "files", + "maxFilesError": "Maximum {max} files allowed", + "fileSizeError": "File {name} exceeds {max}MB limit", + "uploadError": "File {name} upload failed" + }, "auth": { "login": { "title": "Log in", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 7247a7e..1ff8c21 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -391,6 +391,13 @@ "yesterday": "昨天", "items": "项" }, + "fileUpload": { + "uploadFile": "上传文件", + "files": "个文件", + "maxFilesError": "最多只能上传 {max} 个文件", + "fileSizeError": "文件 {name} 超过 {max}MB 限制", + "uploadError": "文件 {name} 上传失败" + }, "auth": { "login": { "title": "登录", diff --git a/src/ui/file-upload/index.vue b/src/ui/file-upload/index.vue index 4c8752b..6375df8 100644 --- a/src/ui/file-upload/index.vue +++ b/src/ui/file-upload/index.vue @@ -1,10 +1,18 @@ @@ -239,6 +355,38 @@ function triggerFileInput() { margin-top: 2px; } +.upload-progress { + display: flex; + align-items: center; + gap: 8px; + margin-top: 4px; +} + +.upload-progress ion-progress-bar { + flex: 1; + height: 4px; +} + +.progress-text { + font-size: 11px; + color: var(--ion-color-primary); + font-weight: 500; + min-width: 35px; + text-align: right; +} + +.error-text { + font-size: 12px; + color: var(--ion-color-danger); + margin-top: 2px; +} + +.file-actions { + display: flex; + align-items: center; + flex-shrink: 0; +} + .file-count { font-size: 12px; color: var(--ion-color-medium); diff --git a/src/utils/aws/s3.ts b/src/utils/aws/s3.ts index 29822ae..3667032 100644 --- a/src/utils/aws/s3.ts +++ b/src/utils/aws/s3.ts @@ -1,19 +1,77 @@ +import type { TreatyBody } from "@/api/types"; import { client, safeClient } from "@/api"; -interface PresignedUrlResponse { - uploadUrl: string; - fileUrl: string; - key: string; - expiresIn: number; -} - interface UploadOptions { + fetchOptions: UploadFetchOptions; onProgress?: (progress: number) => void; signal?: AbortSignal; } -export function uploadToS3(file: File, options: UploadOptions = {}) { - const { onProgress, signal } = options || {}; +export type UploadFetchOptions = TreatyBody; + +export async function uploadToS3(file: File, options: UploadOptions) { + const { onProgress, signal, fetchOptions } = options; // 1. 获取预签名 URL + const { data, error } = await safeClient(client.api.file_storage.upload_url.post({ + ...fetchOptions, + })); + + if (error.value || !data.value) { + throw new Error("获取上传 URL 失败"); + } + const { fileId, uploadUrl, method, headers } = toRefs(data.value); + + // 2. 上传文件到 S3 + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + // 监听上传进度 + xhr.upload.addEventListener("progress", (e: ProgressEvent) => { + if (e.lengthComputable && onProgress) { + const progress = Math.round((e.loaded / e.total) * 100); + onProgress(progress); + } + }); + + // 上传成功 + xhr.addEventListener("load", () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(fileId.value); + } + else { + reject(new Error(`上传失败: ${xhr.status} ${xhr.statusText}`)); + } + }); + + // 网络错误 + xhr.addEventListener("error", () => { + reject(new Error("网络错误,上传失败")); + }); + + // 上传取消 + xhr.addEventListener("abort", () => { + reject(new Error("上传已取消")); + }); + + // 超时 + xhr.addEventListener("timeout", () => { + reject(new Error("上传超时")); + }); + + // 支持外部取消 + if (signal) { + signal.addEventListener("abort", () => { + xhr.abort(); + }); + } + + // 开始上传 + xhr.open(method.value, uploadUrl.value, true); + for (const [key, value] of Object.entries(headers.value)) { + xhr.setRequestHeader(key, value); + } + xhr.timeout = 300000; // 5分钟超时 + xhr.send(file); + }); } diff --git a/src/views/issue/issuing-apply/base.vue b/src/views/issue/issuing-apply/base.vue index d45b2b6..4512af3 100644 --- a/src/views/issue/issuing-apply/base.vue +++ b/src/views/issue/issuing-apply/base.vue @@ -128,11 +128,15 @@ function handleSubmit(values: GenericObject) {