feat: 添加 S3 文件上传功能,更新 '@capp/eden' 依赖至 0.0.11,优化实名认证表单逻辑

This commit is contained in:
2026-01-18 22:52:44 +07:00
parent 4cdbb5cac1
commit 40ad132ce7
7 changed files with 134 additions and 36 deletions

5
auto-imports.d.ts vendored
View File

@@ -129,6 +129,7 @@ declare global {
const unref: typeof import('vue').unref const unref: typeof import('vue').unref
const unrefElement: typeof import('@vueuse/core').unrefElement const unrefElement: typeof import('@vueuse/core').unrefElement
const until: typeof import('@vueuse/core').until const until: typeof import('@vueuse/core').until
const uploadToS3: typeof import('./src/utils/aws/s3').uploadToS3
const useActiveElement: typeof import('@vueuse/core').useActiveElement const useActiveElement: typeof import('@vueuse/core').useActiveElement
const useAnimate: typeof import('@vueuse/core').useAnimate const useAnimate: typeof import('@vueuse/core').useAnimate
const useArrayDifference: typeof import('@vueuse/core').useArrayDifference const useArrayDifference: typeof import('@vueuse/core').useArrayDifference
@@ -330,6 +331,9 @@ declare global {
export type { Language } from './src/composables/useLanguage' export type { Language } from './src/composables/useLanguage'
import('./src/composables/useLanguage') import('./src/composables/useLanguage')
// @ts-ignore // @ts-ignore
export type { UploadFetchOptions } from './src/utils/aws/s3'
import('./src/utils/aws/s3')
// @ts-ignore
export type { Wallet } from './src/store/wallet' export type { Wallet } from './src/store/wallet'
import('./src/store/wallet') import('./src/store/wallet')
} }
@@ -461,6 +465,7 @@ declare module 'vue' {
readonly unref: UnwrapRef<typeof import('vue')['unref']> readonly unref: UnwrapRef<typeof import('vue')['unref']>
readonly unrefElement: UnwrapRef<typeof import('@vueuse/core')['unrefElement']> readonly unrefElement: UnwrapRef<typeof import('@vueuse/core')['unrefElement']>
readonly until: UnwrapRef<typeof import('@vueuse/core')['until']> readonly until: UnwrapRef<typeof import('@vueuse/core')['until']>
readonly uploadToS3: UnwrapRef<typeof import('./src/utils/aws/s3')['uploadToS3']>
readonly useActiveElement: UnwrapRef<typeof import('@vueuse/core')['useActiveElement']> readonly useActiveElement: UnwrapRef<typeof import('@vueuse/core')['useActiveElement']>
readonly useAnimate: UnwrapRef<typeof import('@vueuse/core')['useAnimate']> readonly useAnimate: UnwrapRef<typeof import('@vueuse/core')['useAnimate']>
readonly useArrayDifference: UnwrapRef<typeof import('@vueuse/core')['useArrayDifference']> readonly useArrayDifference: UnwrapRef<typeof import('@vueuse/core')['useArrayDifference']>

14
pnpm-lock.yaml generated
View File

@@ -52,8 +52,8 @@ catalogs:
specifier: 8.0.0 specifier: 8.0.0
version: 8.0.0 version: 8.0.0
'@capp/eden': '@capp/eden':
specifier: http://192.168.1.2:9538/api/capp-eden-0.0.10.tgz specifier: http://192.168.1.2:9538/api/capp-eden-0.0.11.tgz
version: 0.0.10 version: 0.0.11
'@cloudflare/workers-types': '@cloudflare/workers-types':
specifier: ^4.20260113.0 specifier: ^4.20260113.0
version: 4.20260116.0 version: 4.20260116.0
@@ -298,7 +298,7 @@ importers:
version: 8.0.0(@capacitor/core@8.0.0) version: 8.0.0(@capacitor/core@8.0.0)
'@capp/eden': '@capp/eden':
specifier: 'catalog:' specifier: 'catalog:'
version: http://192.168.1.2:9538/api/capp-eden-0.0.10.tgz(@elysiajs/eden@1.4.6(elysia@1.4.22(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3))) version: http://192.168.1.2:9538/api/capp-eden-0.0.11.tgz(@elysiajs/eden@1.4.6(elysia@1.4.22(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3)))
'@elysiajs/eden': '@elysiajs/eden':
specifier: 'catalog:' specifier: 'catalog:'
version: 1.4.6(elysia@1.4.22(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3)) version: 1.4.6(elysia@1.4.22(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3))
@@ -1182,9 +1182,9 @@ packages:
'@capacitor/synapse@1.0.4': '@capacitor/synapse@1.0.4':
resolution: {integrity: sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==} resolution: {integrity: sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==}
'@capp/eden@http://192.168.1.2:9538/api/capp-eden-0.0.10.tgz': '@capp/eden@http://192.168.1.2:9538/api/capp-eden-0.0.11.tgz':
resolution: {tarball: http://192.168.1.2:9538/api/capp-eden-0.0.10.tgz} resolution: {tarball: http://192.168.1.2:9538/api/capp-eden-0.0.11.tgz}
version: 0.0.10 version: 0.0.11
peerDependencies: peerDependencies:
'@elysiajs/eden': ^1.4.6 '@elysiajs/eden': ^1.4.6
@@ -6903,7 +6903,7 @@ snapshots:
'@capacitor/synapse@1.0.4': {} '@capacitor/synapse@1.0.4': {}
'@capp/eden@http://192.168.1.2:9538/api/capp-eden-0.0.10.tgz(@elysiajs/eden@1.4.6(elysia@1.4.22(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3)))': '@capp/eden@http://192.168.1.2:9538/api/capp-eden-0.0.11.tgz(@elysiajs/eden@1.4.6(elysia@1.4.22(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3)))':
dependencies: dependencies:
'@elysiajs/eden': 1.4.6(elysia@1.4.22(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3)) '@elysiajs/eden': 1.4.6(elysia@1.4.22(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3))

View File

@@ -18,7 +18,7 @@ catalog:
'@capacitor/keyboard': 8.0.0 '@capacitor/keyboard': 8.0.0
'@capacitor/share': ^8.0.0 '@capacitor/share': ^8.0.0
'@capacitor/status-bar': 8.0.0 '@capacitor/status-bar': 8.0.0
'@capp/eden': http://192.168.1.2:9538/api/capp-eden-0.0.10.tgz '@capp/eden': http://192.168.1.2:9538/api/capp-eden-0.0.11.tgz
'@cloudflare/workers-types': ^4.20260113.0 '@cloudflare/workers-types': ^4.20260113.0
'@elysiajs/eden': ^1.4.6 '@elysiajs/eden': ^1.4.6
'@faker-js/faker': ^10.2.0 '@faker-js/faker': ^10.2.0

77
src/utils/aws/s3.ts Normal file
View File

@@ -0,0 +1,77 @@
import type { TreatyBody } from "@/api/types";
import { client, safeClient } from "@/api";
interface UploadOptions {
fetchOptions: UploadFetchOptions;
onProgress?: (progress: number) => void;
signal?: AbortSignal;
}
export type UploadFetchOptions = TreatyBody<typeof client.api.file_storage.upload_url.post>;
export async function uploadToS3(file: File, options: UploadOptions): Promise<string> {
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);
});
}

1
src/utils/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from "./aws/s3";

View File

@@ -48,16 +48,15 @@ async function showToast(message: string, color: "success" | "danger" | "warning
await toast.present(); await toast.present();
} }
// 如果是编辑模式,加载地址数据 onMounted(async () => {
onMounted(() => {
if (isEditMode.value) { if (isEditMode.value) {
// TODO: 根据 route.query.id 加载地址数据 const id = route.query.id as string;
// 模拟数据 const { data } = await safeClient(client.api.shipping_address({ id }).get());
formData.value = { formData.value = {
recipientName: "张三", recipientName: data.value?.recipientName || "",
phoneNumber: "13800138000", phoneNumber: data.value?.phoneNumber || "",
detailAddress: "北京市朝阳区建国路88号SOHO现代城A座1001室", detailAddress: data.value?.detailAddress || "",
isDefault: true, isDefault: data.value?.isDefault || false,
}; };
} }
}); });
@@ -72,9 +71,11 @@ async function handleSubmit() {
isSubmitting.value = true; isSubmitting.value = true;
try { try {
// TODO: 调用添加/编辑地址 API
if (isEditMode.value) { if (isEditMode.value) {
// const { data } = await safeClient(); const id = route.query.id as string;
await safeClient(client.api.shipping_address({ id }).patch({
...formData.value,
}));
} }
else { else {
await safeClient(client.api.shipping_address.post({ await safeClient(client.api.shipping_address.post({

View File

@@ -3,26 +3,28 @@ import { Camera, CameraResultType, CameraSource } from "@capacitor/camera";
import { actionSheetController, toastController } from "@ionic/vue"; import { actionSheetController, toastController } from "@ionic/vue";
import { cameraOutline, cardOutline, checkmarkCircleOutline, imageOutline, personOutline } from "ionicons/icons"; import { cameraOutline, cardOutline, checkmarkCircleOutline, imageOutline, personOutline } from "ionicons/icons";
import zod from "zod"; import zod from "zod";
import { client } from "@/api";
const router = useRouter(); const router = useRouter();
const formData = ref({ const formData = ref({
realName: "", kycMethod: "id_card",
idCard: "", documentName: "",
idCardFrontImage: "", documentNumber: "",
idCardBackImage: "", fileId1: "",
fileId2: "",
}); });
const isSubmitting = ref(false); const isSubmitting = ref(false);
// 表单验证 Schema // 表单验证 Schema
const RealNameSchema = zod.object({ const RealNameSchema = zod.object({
realName: zod documentName: zod
.string() .string()
.min(2, "请输入真实姓名") .min(2, "请输入真实姓名")
.max(10, "姓名长度不能超过10个字符"), .max(10, "姓名长度不能超过10个字符"),
idCard: zod documentNumber: zod
.string() .string()
.min(1, "请输入身份证号码") .min(1, "请输入身份证号码")
.regex( .regex(
@@ -30,11 +32,11 @@ const RealNameSchema = zod.object({
"请输入正确的身份证号码", "请输入正确的身份证号码",
), ),
idCardFrontImage: zod fileId1: zod
.string() .string()
.min(1, "请上传身份证正面照片"), .min(1, "请上传身份证正面照片"),
idCardBackImage: zod fileId2: zod
.string() .string()
.min(1, "请上传身份证反面照片"), .min(1, "请上传身份证反面照片"),
}); });
@@ -62,10 +64,10 @@ async function takePicture(type: "front" | "back") {
if (image.dataUrl) { if (image.dataUrl) {
if (type === "front") { if (type === "front") {
formData.value.idCardFrontImage = image.dataUrl; formData.value.fileId1 = image.dataUrl;
} }
else { else {
formData.value.idCardBackImage = image.dataUrl; formData.value.fileId2 = image.dataUrl;
} }
} }
} }
@@ -86,13 +88,25 @@ async function selectFromGallery(type: "front" | "back") {
width: 1200, width: 1200,
height: 800, height: 800,
}); });
const file = new File(
[image.dataUrl ? await (await fetch(image.dataUrl)).blob() : ""],
"upload.jpeg",
{ type: "image/jpeg" },
);
const fileId = await uploadToS3(file, {
fetchOptions: {
fileName: `idCard_${Date.now()}.jpeg`,
fileSize: image.dataUrl?.length || 0,
businessType: "kyc_document",
},
});
if (image.dataUrl) { if (image.dataUrl) {
if (type === "front") { if (type === "front") {
formData.value.idCardFrontImage = image.dataUrl; formData.value.fileId1 = fileId;
} }
else { else {
formData.value.idCardBackImage = image.dataUrl; formData.value.fileId2 = fileId;
} }
} }
} }
@@ -185,7 +199,7 @@ async function handleSubmit() {
</div> </div>
<ion-item lines="none" class="input-item"> <ion-item lines="none" class="input-item">
<ion-input <ion-input
v-model="formData.realName" v-model="formData.documentName"
type="text" type="text"
placeholder="请输入真实姓名" placeholder="请输入真实姓名"
class="custom-input" class="custom-input"
@@ -201,7 +215,7 @@ async function handleSubmit() {
</div> </div>
<ion-item lines="none" class="input-item"> <ion-item lines="none" class="input-item">
<ion-input <ion-input
v-model="formData.idCard" v-model="formData.documentNumber"
type="text" type="text"
placeholder="请输入身份证号码" placeholder="请输入身份证号码"
class="custom-input" class="custom-input"
@@ -220,7 +234,7 @@ async function handleSubmit() {
class="upload-box" class="upload-box"
@click="showImageSourceOptions('front')" @click="showImageSourceOptions('front')"
> >
<div v-if="!formData.idCardFrontImage" class="upload-placeholder"> <div v-if="!formData.fileId1" class="upload-placeholder">
<ion-icon :icon="cameraOutline" class="text-4xl text-[#999]" /> <ion-icon :icon="cameraOutline" class="text-4xl text-[#999]" />
<div class="text-sm text-[#999] mt-2"> <div class="text-sm text-[#999] mt-2">
点击拍照或选择照片 点击拍照或选择照片
@@ -231,7 +245,7 @@ async function handleSubmit() {
</div> </div>
<img <img
v-else v-else
:src="formData.idCardFrontImage" :src="formData.fileId1"
alt="身份证正面" alt="身份证正面"
class="uploaded-image" class="uploaded-image"
> >
@@ -248,7 +262,7 @@ async function handleSubmit() {
class="upload-box" class="upload-box"
@click="showImageSourceOptions('back')" @click="showImageSourceOptions('back')"
> >
<div v-if="!formData.idCardBackImage" class="upload-placeholder"> <div v-if="!formData.fileId2" class="upload-placeholder">
<ion-icon :icon="cameraOutline" class="text-4xl text-[#999]" /> <ion-icon :icon="cameraOutline" class="text-4xl text-[#999]" />
<div class="text-sm text-[#999] mt-2"> <div class="text-sm text-[#999] mt-2">
点击拍照或选择照片 点击拍照或选择照片
@@ -259,7 +273,7 @@ async function handleSubmit() {
</div> </div>
<img <img
v-else v-else
:src="formData.idCardBackImage" :src="formData.fileId2"
alt="身份证反面" alt="身份证反面"
class="uploaded-image" class="uploaded-image"
> >