feat: 添加文件上传组件,更新表单字段,优化描述输入逻辑
This commit is contained in:
248
src/components/ui/file-upload/index.vue
Normal file
248
src/components/ui/file-upload/index.vue
Normal file
@@ -0,0 +1,248 @@
|
||||
<script lang='ts' setup>
|
||||
import { documentAttachOutline, trashOutline } from "ionicons/icons";
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
size: number;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: string[];
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
maxSize?: number; // MB
|
||||
maxFiles?: number;
|
||||
}>(), {
|
||||
modelValue: () => [],
|
||||
accept: "*/*",
|
||||
multiple: true,
|
||||
maxSize: 10,
|
||||
maxFiles: 5,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: string[]): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const fileInput = ref<HTMLInputElement>();
|
||||
const files = ref<FileItem[]>([]);
|
||||
|
||||
// 初始化已有文件
|
||||
watchEffect(() => {
|
||||
if (props.modelValue && props.modelValue.length > 0) {
|
||||
files.value = props.modelValue.map(url => ({
|
||||
name: url.split("/").pop() || "file",
|
||||
size: 0,
|
||||
url,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const selectedFiles = Array.from(input.files || []);
|
||||
|
||||
if (!selectedFiles.length)
|
||||
return;
|
||||
|
||||
// 检查文件数量限制
|
||||
if (files.value.length + selectedFiles.length > props.maxFiles) {
|
||||
// TODO: 显示错误提示
|
||||
console.warn(`最多只能上传 ${props.maxFiles} 个文件`);
|
||||
return;
|
||||
}
|
||||
|
||||
selectedFiles.forEach((file) => {
|
||||
// 检查文件大小
|
||||
if (file.size > props.maxSize * 1024 * 1024) {
|
||||
console.warn(`文件 ${file.name} 超过 ${props.maxSize}MB 限制`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建预览 URL
|
||||
const url = URL.createObjectURL(file);
|
||||
files.value.push({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
url,
|
||||
});
|
||||
});
|
||||
|
||||
// 更新值
|
||||
updateValue();
|
||||
|
||||
// 重置 input
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function removeFile(index: number) {
|
||||
const file = files.value[index];
|
||||
if (file.url && file.url.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(file.url);
|
||||
}
|
||||
files.value.splice(index, 1);
|
||||
updateValue();
|
||||
}
|
||||
|
||||
function updateValue() {
|
||||
const urls = files.value.map(f => f.url || "").filter(Boolean);
|
||||
emit("update:modelValue", urls);
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0)
|
||||
return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function triggerFileInput() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-upload">
|
||||
<div v-if="label" class="file-upload-label">
|
||||
{{ label }}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
:accept="accept"
|
||||
:multiple="multiple"
|
||||
class="file-input-hidden"
|
||||
@change="handleFileSelect"
|
||||
>
|
||||
|
||||
<!-- 上传按钮 -->
|
||||
<ion-button
|
||||
v-if="files.length < maxFiles"
|
||||
expand="block"
|
||||
fill="outline"
|
||||
class="upload-button"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
<ion-icon slot="start" :icon="documentAttachOutline" />
|
||||
{{ placeholder || t('common.uploadFile') }}
|
||||
</ion-button>
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<div v-if="files.length > 0" class="file-list">
|
||||
<div v-for="(file, index) in files" :key="index" class="file-item">
|
||||
<div class="file-info">
|
||||
<ion-icon :icon="documentAttachOutline" class="file-icon" />
|
||||
<div class="file-details">
|
||||
<div class="file-name">
|
||||
{{ file.name }}
|
||||
</div>
|
||||
<div v-if="file.size" class="file-size">
|
||||
{{ formatFileSize(file.size) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ion-button fill="clear" size="small" color="danger" @click="removeFile(index)">
|
||||
<ion-icon slot="icon-only" :icon="trashOutline" />
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="files.length > 0" class="file-count">
|
||||
{{ files.length }} / {{ maxFiles }} {{ t('common.files') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-upload {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-upload-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: var(--ion-text-color);
|
||||
}
|
||||
|
||||
.file-input-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-button {
|
||||
--border-style: dashed;
|
||||
--border-width: 2px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
background: var(--ion-color-light);
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
background: var(--ion-color-light-shade);
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 24px;
|
||||
color: var(--ion-color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--ion-text-color);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 12px;
|
||||
color: var(--ion-color-medium);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.file-count {
|
||||
font-size: 12px;
|
||||
color: var(--ion-color-medium);
|
||||
text-align: right;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
41
src/components/ui/textarea-label/index.vue
Normal file
41
src/components/ui/textarea-label/index.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang='ts' setup>
|
||||
import type { ComponentInstance } from "vue";
|
||||
import { IonTextarea } from "@ionic/vue";
|
||||
|
||||
defineProps<{
|
||||
label?: string;
|
||||
}>();
|
||||
|
||||
const vm = getCurrentInstance()!;
|
||||
|
||||
function changeRef(exposed: any) {
|
||||
vm.exposed = exposed;
|
||||
}
|
||||
|
||||
defineExpose({} as ComponentInstance<typeof UiInput>);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="label" class="label">
|
||||
{{ label }}
|
||||
</div>
|
||||
<component :is="h(IonTextarea, { ...$attrs, ref: changeRef })" class="ui-textarea" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 14px;
|
||||
color: var(--ion-text-color-secondary);
|
||||
}
|
||||
.ui-textarea {
|
||||
--padding-start: 12px;
|
||||
--padding-end: 12px;
|
||||
--background: var(--ui-input-background, #fff);
|
||||
--color: var(--ui-input-color, #000);
|
||||
--border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -146,8 +146,7 @@
|
||||
"productValue": "تقييم المنتج",
|
||||
"enterProductValue": "الرجاء إدخال تقييم المنتج (بالدولار الأمريكي)",
|
||||
"assetProof": "إثبات الأصول",
|
||||
"enterAssetProof": "الرجاء إدخال إثبات الأصول",
|
||||
"totalSupplyLimit": "الحد الأقصى لإجمالي الإصدار",
|
||||
"enterAssetProof": "الرجاء إدخال إثبات الأصول", "uploadAssetProof": "تحميل مستندات إثبات الأصول", "totalSupplyLimit": "الحد الأقصى لإجمالي الإصدار",
|
||||
"enterTotalSupplyLimit": "الرجاء إدخال الحد الأقصى لإجمالي الإصدار",
|
||||
"editionName": "اسم فترة الإصدار",
|
||||
"enterEditionName": "الرجاء إدخال اسم فترة الإصدار",
|
||||
@@ -163,6 +162,8 @@
|
||||
"enterUnitPrice": "الرجاء إدخال سعر الوحدة",
|
||||
"dividendRate": "معدل الأرباح",
|
||||
"enterDividendRate": "معدل الأرباح (مثل 0.01 يمثل 1٪)",
|
||||
"description": "وصف المنتج",
|
||||
"enterDescription": "الرجاء إدخال وصف المنتج",
|
||||
"issuePeriodIndex": "فترة الإصدار {index}",
|
||||
"realEstate": "العقارات",
|
||||
"snickers": "الحلوى",
|
||||
@@ -274,10 +275,12 @@
|
||||
"chat": "الدردشة",
|
||||
"user": "حسابي"
|
||||
},
|
||||
"common": {
|
||||
"failedSendCode": "فشل إرسال رمز التحقق",
|
||||
"uploadFile": "تحميل ملف",
|
||||
"files": "ملفات"
|
||||
},
|
||||
"auth": {
|
||||
"common": {
|
||||
"failedSendCode": "فشل إرسال رمز التحقق."
|
||||
},
|
||||
"login": {
|
||||
"title": "تسجيل الدخول",
|
||||
"loginButton": "تسجيل الدخول",
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
"enterProductValue": "Please enter product valuation (USD)",
|
||||
"assetProof": "Asset Proof",
|
||||
"enterAssetProof": "Please enter asset proof",
|
||||
"uploadAssetProof": "Upload Asset Proof Documents",
|
||||
"totalSupplyLimit": "Total Supply Limit",
|
||||
"enterTotalSupplyLimit": "Please enter total supply limit",
|
||||
"editionName": "Edition Name",
|
||||
@@ -163,6 +164,8 @@
|
||||
"enterUnitPrice": "Please enter unit price",
|
||||
"dividendRate": "Dividend Rate",
|
||||
"enterDividendRate": "Dividend rate (e.g., 0.01 for 1%)",
|
||||
"description": "Product Description",
|
||||
"enterDescription": "Please enter product description",
|
||||
"issuePeriodIndex": "Issue Period {index}",
|
||||
"realEstate": "Real Estate",
|
||||
"snickers": "Snickers",
|
||||
@@ -274,10 +277,12 @@
|
||||
"chat": "Chat",
|
||||
"user": "Profile"
|
||||
},
|
||||
"common": {
|
||||
"failedSendCode": "Failed to send verification code",
|
||||
"uploadFile": "Upload File",
|
||||
"files": "files"
|
||||
},
|
||||
"auth": {
|
||||
"common": {
|
||||
"failedSendCode": "Failed to send verification code"
|
||||
},
|
||||
"login": {
|
||||
"title": "Log in",
|
||||
"loginButton": "Log in",
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
"enterProductValue": "请输入产品估值(美元)",
|
||||
"assetProof": "资产证明",
|
||||
"enterAssetProof": "请输入资产证明",
|
||||
"uploadAssetProof": "上传资产证明文件",
|
||||
"totalSupplyLimit": "总发行量上限",
|
||||
"enterTotalSupplyLimit": "请输入总发行量上限",
|
||||
"editionName": "发行期名称",
|
||||
@@ -163,6 +164,8 @@
|
||||
"enterUnitPrice": "请输入单价",
|
||||
"dividendRate": "分红率",
|
||||
"enterDividendRate": "分红率(如0.01代表1%)",
|
||||
"description": "产品描述",
|
||||
"enterDescription": "请输入产品描述",
|
||||
"issuePeriodIndex": "发行期 {index}",
|
||||
"realEstate": "房地产",
|
||||
"snickers": "糖果",
|
||||
@@ -274,10 +277,12 @@
|
||||
"chat": "聊天",
|
||||
"user": "我的"
|
||||
},
|
||||
"common": {
|
||||
"failedSendCode": "发送验证码失败",
|
||||
"uploadFile": "上传文件",
|
||||
"files": "个文件"
|
||||
},
|
||||
"auth": {
|
||||
"common": {
|
||||
"failedSendCode": "发送验证码失败"
|
||||
},
|
||||
"login": {
|
||||
"title": "登录",
|
||||
"loginButton": "登录",
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
"enterProductValue": "請輸入產品估值(美元)",
|
||||
"assetProof": "資產證明",
|
||||
"enterAssetProof": "請輸入資產證明",
|
||||
"uploadAssetProof": "上傳資產證明文件",
|
||||
"totalSupplyLimit": "總發行量上限",
|
||||
"enterTotalSupplyLimit": "請輸入總發行量上限",
|
||||
"editionName": "發行期名稱",
|
||||
@@ -163,6 +164,8 @@
|
||||
"enterUnitPrice": "請輸入單價",
|
||||
"dividendRate": "分紅率",
|
||||
"enterDividendRate": "分紅率(如0.01代表1%)",
|
||||
"description": "產品描述",
|
||||
"enterDescription": "請輸入產品描述",
|
||||
"issuePeriodIndex": "發行期 {index}",
|
||||
"realEstate": "房地產",
|
||||
"snickers": "糖果",
|
||||
@@ -274,10 +277,12 @@
|
||||
"chat": "聊天",
|
||||
"user": "我的"
|
||||
},
|
||||
"common": {
|
||||
"failedSendCode": "發送驗證碼失敗",
|
||||
"uploadFile": "上傳文件",
|
||||
"files": "個文件"
|
||||
},
|
||||
"auth": {
|
||||
"common": {
|
||||
"failedSendCode": "發送驗證碼失敗"
|
||||
},
|
||||
"login": {
|
||||
"title": "登錄",
|
||||
"loginButton": "登錄",
|
||||
|
||||
@@ -95,19 +95,6 @@ function handleSubmit(values: GenericObject) {
|
||||
<ErrorMessage name="estimatedValue" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Field name="proofDocuments" type="text">
|
||||
<template #default="{ field }">
|
||||
<ui-input-label
|
||||
v-bind="field"
|
||||
:label="t('asset.issue.apply.assetProof')"
|
||||
:placeholder="t('asset.issue.apply.enterAssetProof')"
|
||||
/>
|
||||
</template>
|
||||
</Field>
|
||||
<ErrorMessage name="proofDocuments" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Field name="totalSupplyLimit" type="text">
|
||||
<template #default="{ field }">
|
||||
@@ -122,6 +109,35 @@ function handleSubmit(values: GenericObject) {
|
||||
<ErrorMessage name="totalSupplyLimit" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Field name="description" type="text">
|
||||
<template #default="{ field }">
|
||||
<ui-textarea-label
|
||||
v-bind="field"
|
||||
:label="t('asset.issue.apply.description')"
|
||||
:placeholder="t('asset.issue.apply.enterDescription')"
|
||||
:maxlength="500"
|
||||
:auto-grow="true"
|
||||
/>
|
||||
</template>
|
||||
</Field>
|
||||
<ErrorMessage name="description" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Field v-slot="{ field }" name="proofDocuments">
|
||||
<ui-file-upload
|
||||
v-bind="field"
|
||||
:label="t('asset.issue.apply.assetProof')"
|
||||
:placeholder="t('asset.issue.apply.uploadAssetProof')"
|
||||
:max-files="5"
|
||||
:max-size="10"
|
||||
accept="application/pdf,image/*,.doc,.docx"
|
||||
/>
|
||||
</Field>
|
||||
<ErrorMessage name="proofDocuments" />
|
||||
</div>
|
||||
|
||||
<ion-button type="submit" expand="block" shape="round" color="success">
|
||||
{{ t('asset.issue.apply.next') }}
|
||||
</ion-button>
|
||||
@@ -129,4 +145,12 @@ function handleSubmit(values: GenericObject) {
|
||||
</Form>
|
||||
</template>
|
||||
|
||||
<style lang='css' scoped></style>
|
||||
<style lang='css' scoped>
|
||||
ion-textarea {
|
||||
--padding-start: 12px;
|
||||
--padding-end: 12px;
|
||||
--background: var(--ui-input-background, #fff);
|
||||
--color: var(--ui-input-color, #000);
|
||||
--border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user