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

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();
}
// 如果是编辑模式,加载地址数据
onMounted(() => {
onMounted(async () => {
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 = {
recipientName: "张三",
phoneNumber: "13800138000",
detailAddress: "北京市朝阳区建国路88号SOHO现代城A座1001室",
isDefault: true,
recipientName: data.value?.recipientName || "",
phoneNumber: data.value?.phoneNumber || "",
detailAddress: data.value?.detailAddress || "",
isDefault: data.value?.isDefault || false,
};
}
});
@@ -72,9 +71,11 @@ async function handleSubmit() {
isSubmitting.value = true;
try {
// TODO: 调用添加/编辑地址 API
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 {
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 { cameraOutline, cardOutline, checkmarkCircleOutline, imageOutline, personOutline } from "ionicons/icons";
import zod from "zod";
import { client } from "@/api";
const router = useRouter();
const formData = ref({
realName: "",
idCard: "",
idCardFrontImage: "",
idCardBackImage: "",
kycMethod: "id_card",
documentName: "",
documentNumber: "",
fileId1: "",
fileId2: "",
});
const isSubmitting = ref(false);
// 表单验证 Schema
const RealNameSchema = zod.object({
realName: zod
documentName: zod
.string()
.min(2, "请输入真实姓名")
.max(10, "姓名长度不能超过10个字符"),
idCard: zod
documentNumber: zod
.string()
.min(1, "请输入身份证号码")
.regex(
@@ -30,11 +32,11 @@ const RealNameSchema = zod.object({
"请输入正确的身份证号码",
),
idCardFrontImage: zod
fileId1: zod
.string()
.min(1, "请上传身份证正面照片"),
idCardBackImage: zod
fileId2: zod
.string()
.min(1, "请上传身份证反面照片"),
});
@@ -62,10 +64,10 @@ async function takePicture(type: "front" | "back") {
if (image.dataUrl) {
if (type === "front") {
formData.value.idCardFrontImage = image.dataUrl;
formData.value.fileId1 = image.dataUrl;
}
else {
formData.value.idCardBackImage = image.dataUrl;
formData.value.fileId2 = image.dataUrl;
}
}
}
@@ -86,13 +88,25 @@ async function selectFromGallery(type: "front" | "back") {
width: 1200,
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 (type === "front") {
formData.value.idCardFrontImage = image.dataUrl;
formData.value.fileId1 = fileId;
}
else {
formData.value.idCardBackImage = image.dataUrl;
formData.value.fileId2 = fileId;
}
}
}
@@ -185,7 +199,7 @@ async function handleSubmit() {
</div>
<ion-item lines="none" class="input-item">
<ion-input
v-model="formData.realName"
v-model="formData.documentName"
type="text"
placeholder="请输入真实姓名"
class="custom-input"
@@ -201,7 +215,7 @@ async function handleSubmit() {
</div>
<ion-item lines="none" class="input-item">
<ion-input
v-model="formData.idCard"
v-model="formData.documentNumber"
type="text"
placeholder="请输入身份证号码"
class="custom-input"
@@ -220,7 +234,7 @@ async function handleSubmit() {
class="upload-box"
@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]" />
<div class="text-sm text-[#999] mt-2">
点击拍照或选择照片
@@ -231,7 +245,7 @@ async function handleSubmit() {
</div>
<img
v-else
:src="formData.idCardFrontImage"
:src="formData.fileId1"
alt="身份证正面"
class="uploaded-image"
>
@@ -248,7 +262,7 @@ async function handleSubmit() {
class="upload-box"
@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]" />
<div class="text-sm text-[#999] mt-2">
点击拍照或选择照片
@@ -259,7 +273,7 @@ async function handleSubmit() {
</div>
<img
v-else
:src="formData.idCardBackImage"
:src="formData.fileId2"
alt="身份证反面"
class="uploaded-image"
>