feat: 添加文件上传组件,更新表单字段,优化描述输入逻辑

This commit is contained in:
2025-12-21 19:22:17 +07:00
parent e29f687520
commit b065dca018
8 changed files with 367 additions and 28 deletions

8
components.d.ts vendored
View File

@@ -51,6 +51,7 @@ declare module 'vue' {
IonTabButton: typeof import('@ionic/vue')['IonTabButton'] IonTabButton: typeof import('@ionic/vue')['IonTabButton']
IonTabs: typeof import('@ionic/vue')['IonTabs'] IonTabs: typeof import('@ionic/vue')['IonTabs']
IonText: typeof import('@ionic/vue')['IonText'] IonText: typeof import('@ionic/vue')['IonText']
IonTextarea: typeof import('@ionic/vue')['IonTextarea']
IonTitle: typeof import('@ionic/vue')['IonTitle'] IonTitle: typeof import('@ionic/vue')['IonTitle']
IonToolbar: typeof import('@ionic/vue')['IonToolbar'] IonToolbar: typeof import('@ionic/vue')['IonToolbar']
LayoutDefault: typeof import('./src/components/layout/default.vue')['default'] LayoutDefault: typeof import('./src/components/layout/default.vue')['default']
@@ -62,12 +63,15 @@ declare module 'vue' {
UiCollapse: typeof import('./src/components/ui/collapse/index.vue')['default'] UiCollapse: typeof import('./src/components/ui/collapse/index.vue')['default']
UiDatetime: typeof import('./src/components/ui/datetime/index.vue')['default'] UiDatetime: typeof import('./src/components/ui/datetime/index.vue')['default']
UiDivider: typeof import('./src/components/ui/divider/index.vue')['default'] UiDivider: typeof import('./src/components/ui/divider/index.vue')['default']
UiFileUpload: typeof import('./src/components/ui/file-upload/index.vue')['default']
UiInput: typeof import('./src/components/ui/input/index.vue')['default'] UiInput: typeof import('./src/components/ui/input/index.vue')['default']
UiInputLabel: typeof import('./src/components/ui/input-label/index.vue')['default'] UiInputLabel: typeof import('./src/components/ui/input-label/index.vue')['default']
UiResult: typeof import('./src/components/ui/result/index.vue')['default'] UiResult: typeof import('./src/components/ui/result/index.vue')['default']
UiTabPane: typeof import('./src/components/ui/tab-pane/index.vue')['default'] UiTabPane: typeof import('./src/components/ui/tab-pane/index.vue')['default']
UiTabs: typeof import('./src/components/ui/tabs/index.vue')['default'] UiTabs: typeof import('./src/components/ui/tabs/index.vue')['default']
UiTag: typeof import('./src/components/ui/tag/index.vue')['default'] UiTag: typeof import('./src/components/ui/tag/index.vue')['default']
UiTextarea: typeof import('./src/components/ui/textarea/index.vue')['default']
UiTextareaLabel: typeof import('./src/components/ui/textarea-label/index.vue')['default']
} }
} }
@@ -112,6 +116,7 @@ declare global {
const IonTabButton: typeof import('@ionic/vue')['IonTabButton'] const IonTabButton: typeof import('@ionic/vue')['IonTabButton']
const IonTabs: typeof import('@ionic/vue')['IonTabs'] const IonTabs: typeof import('@ionic/vue')['IonTabs']
const IonText: typeof import('@ionic/vue')['IonText'] const IonText: typeof import('@ionic/vue')['IonText']
const IonTextarea: typeof import('@ionic/vue')['IonTextarea']
const IonTitle: typeof import('@ionic/vue')['IonTitle'] const IonTitle: typeof import('@ionic/vue')['IonTitle']
const IonToolbar: typeof import('@ionic/vue')['IonToolbar'] const IonToolbar: typeof import('@ionic/vue')['IonToolbar']
const LayoutDefault: typeof import('./src/components/layout/default.vue')['default'] const LayoutDefault: typeof import('./src/components/layout/default.vue')['default']
@@ -123,10 +128,13 @@ declare global {
const UiCollapse: typeof import('./src/components/ui/collapse/index.vue')['default'] const UiCollapse: typeof import('./src/components/ui/collapse/index.vue')['default']
const UiDatetime: typeof import('./src/components/ui/datetime/index.vue')['default'] const UiDatetime: typeof import('./src/components/ui/datetime/index.vue')['default']
const UiDivider: typeof import('./src/components/ui/divider/index.vue')['default'] const UiDivider: typeof import('./src/components/ui/divider/index.vue')['default']
const UiFileUpload: typeof import('./src/components/ui/file-upload/index.vue')['default']
const UiInput: typeof import('./src/components/ui/input/index.vue')['default'] const UiInput: typeof import('./src/components/ui/input/index.vue')['default']
const UiInputLabel: typeof import('./src/components/ui/input-label/index.vue')['default'] const UiInputLabel: typeof import('./src/components/ui/input-label/index.vue')['default']
const UiResult: typeof import('./src/components/ui/result/index.vue')['default'] const UiResult: typeof import('./src/components/ui/result/index.vue')['default']
const UiTabPane: typeof import('./src/components/ui/tab-pane/index.vue')['default'] const UiTabPane: typeof import('./src/components/ui/tab-pane/index.vue')['default']
const UiTabs: typeof import('./src/components/ui/tabs/index.vue')['default'] const UiTabs: typeof import('./src/components/ui/tabs/index.vue')['default']
const UiTag: typeof import('./src/components/ui/tag/index.vue')['default'] const UiTag: typeof import('./src/components/ui/tag/index.vue')['default']
const UiTextarea: typeof import('./src/components/ui/textarea/index.vue')['default']
const UiTextareaLabel: typeof import('./src/components/ui/textarea-label/index.vue')['default']
} }

View 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>

View 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>

View File

@@ -146,8 +146,7 @@
"productValue": "تقييم المنتج", "productValue": "تقييم المنتج",
"enterProductValue": "الرجاء إدخال تقييم المنتج (بالدولار الأمريكي)", "enterProductValue": "الرجاء إدخال تقييم المنتج (بالدولار الأمريكي)",
"assetProof": "إثبات الأصول", "assetProof": "إثبات الأصول",
"enterAssetProof": "الرجاء إدخال إثبات الأصول", "enterAssetProof": "الرجاء إدخال إثبات الأصول", "uploadAssetProof": "تحميل مستندات إثبات الأصول", "totalSupplyLimit": "الحد الأقصى لإجمالي الإصدار",
"totalSupplyLimit": "الحد الأقصى لإجمالي الإصدار",
"enterTotalSupplyLimit": "الرجاء إدخال الحد الأقصى لإجمالي الإصدار", "enterTotalSupplyLimit": "الرجاء إدخال الحد الأقصى لإجمالي الإصدار",
"editionName": "اسم فترة الإصدار", "editionName": "اسم فترة الإصدار",
"enterEditionName": "الرجاء إدخال اسم فترة الإصدار", "enterEditionName": "الرجاء إدخال اسم فترة الإصدار",
@@ -163,6 +162,8 @@
"enterUnitPrice": "الرجاء إدخال سعر الوحدة", "enterUnitPrice": "الرجاء إدخال سعر الوحدة",
"dividendRate": "معدل الأرباح", "dividendRate": "معدل الأرباح",
"enterDividendRate": "معدل الأرباح (مثل 0.01 يمثل 1٪)", "enterDividendRate": "معدل الأرباح (مثل 0.01 يمثل 1٪)",
"description": "وصف المنتج",
"enterDescription": "الرجاء إدخال وصف المنتج",
"issuePeriodIndex": "فترة الإصدار {index}", "issuePeriodIndex": "فترة الإصدار {index}",
"realEstate": "العقارات", "realEstate": "العقارات",
"snickers": "الحلوى", "snickers": "الحلوى",
@@ -274,10 +275,12 @@
"chat": "الدردشة", "chat": "الدردشة",
"user": "حسابي" "user": "حسابي"
}, },
"auth": {
"common": { "common": {
"failedSendCode": "فشل إرسال رمز التحقق." "failedSendCode": "فشل إرسال رمز التحقق",
"uploadFile": "تحميل ملف",
"files": "ملفات"
}, },
"auth": {
"login": { "login": {
"title": "تسجيل الدخول", "title": "تسجيل الدخول",
"loginButton": "تسجيل الدخول", "loginButton": "تسجيل الدخول",

View File

@@ -147,6 +147,7 @@
"enterProductValue": "Please enter product valuation (USD)", "enterProductValue": "Please enter product valuation (USD)",
"assetProof": "Asset Proof", "assetProof": "Asset Proof",
"enterAssetProof": "Please enter asset proof", "enterAssetProof": "Please enter asset proof",
"uploadAssetProof": "Upload Asset Proof Documents",
"totalSupplyLimit": "Total Supply Limit", "totalSupplyLimit": "Total Supply Limit",
"enterTotalSupplyLimit": "Please enter total supply limit", "enterTotalSupplyLimit": "Please enter total supply limit",
"editionName": "Edition Name", "editionName": "Edition Name",
@@ -163,6 +164,8 @@
"enterUnitPrice": "Please enter unit price", "enterUnitPrice": "Please enter unit price",
"dividendRate": "Dividend Rate", "dividendRate": "Dividend Rate",
"enterDividendRate": "Dividend rate (e.g., 0.01 for 1%)", "enterDividendRate": "Dividend rate (e.g., 0.01 for 1%)",
"description": "Product Description",
"enterDescription": "Please enter product description",
"issuePeriodIndex": "Issue Period {index}", "issuePeriodIndex": "Issue Period {index}",
"realEstate": "Real Estate", "realEstate": "Real Estate",
"snickers": "Snickers", "snickers": "Snickers",
@@ -274,10 +277,12 @@
"chat": "Chat", "chat": "Chat",
"user": "Profile" "user": "Profile"
}, },
"auth": {
"common": { "common": {
"failedSendCode": "Failed to send verification code" "failedSendCode": "Failed to send verification code",
"uploadFile": "Upload File",
"files": "files"
}, },
"auth": {
"login": { "login": {
"title": "Log in", "title": "Log in",
"loginButton": "Log in", "loginButton": "Log in",

View File

@@ -147,6 +147,7 @@
"enterProductValue": "请输入产品估值(美元)", "enterProductValue": "请输入产品估值(美元)",
"assetProof": "资产证明", "assetProof": "资产证明",
"enterAssetProof": "请输入资产证明", "enterAssetProof": "请输入资产证明",
"uploadAssetProof": "上传资产证明文件",
"totalSupplyLimit": "总发行量上限", "totalSupplyLimit": "总发行量上限",
"enterTotalSupplyLimit": "请输入总发行量上限", "enterTotalSupplyLimit": "请输入总发行量上限",
"editionName": "发行期名称", "editionName": "发行期名称",
@@ -163,6 +164,8 @@
"enterUnitPrice": "请输入单价", "enterUnitPrice": "请输入单价",
"dividendRate": "分红率", "dividendRate": "分红率",
"enterDividendRate": "分红率(如0.01代表1%)", "enterDividendRate": "分红率(如0.01代表1%)",
"description": "产品描述",
"enterDescription": "请输入产品描述",
"issuePeriodIndex": "发行期 {index}", "issuePeriodIndex": "发行期 {index}",
"realEstate": "房地产", "realEstate": "房地产",
"snickers": "糖果", "snickers": "糖果",
@@ -274,10 +277,12 @@
"chat": "聊天", "chat": "聊天",
"user": "我的" "user": "我的"
}, },
"auth": {
"common": { "common": {
"failedSendCode": "发送验证码失败" "failedSendCode": "发送验证码失败",
"uploadFile": "上传文件",
"files": "个文件"
}, },
"auth": {
"login": { "login": {
"title": "登录", "title": "登录",
"loginButton": "登录", "loginButton": "登录",

View File

@@ -147,6 +147,7 @@
"enterProductValue": "請輸入產品估值(美元)", "enterProductValue": "請輸入產品估值(美元)",
"assetProof": "資產證明", "assetProof": "資產證明",
"enterAssetProof": "請輸入資產證明", "enterAssetProof": "請輸入資產證明",
"uploadAssetProof": "上傳資產證明文件",
"totalSupplyLimit": "總發行量上限", "totalSupplyLimit": "總發行量上限",
"enterTotalSupplyLimit": "請輸入總發行量上限", "enterTotalSupplyLimit": "請輸入總發行量上限",
"editionName": "發行期名稱", "editionName": "發行期名稱",
@@ -163,6 +164,8 @@
"enterUnitPrice": "請輸入單價", "enterUnitPrice": "請輸入單價",
"dividendRate": "分紅率", "dividendRate": "分紅率",
"enterDividendRate": "分紅率(如0.01代表1%)", "enterDividendRate": "分紅率(如0.01代表1%)",
"description": "產品描述",
"enterDescription": "請輸入產品描述",
"issuePeriodIndex": "發行期 {index}", "issuePeriodIndex": "發行期 {index}",
"realEstate": "房地產", "realEstate": "房地產",
"snickers": "糖果", "snickers": "糖果",
@@ -274,10 +277,12 @@
"chat": "聊天", "chat": "聊天",
"user": "我的" "user": "我的"
}, },
"auth": {
"common": { "common": {
"failedSendCode": "發送驗證碼失敗" "failedSendCode": "發送驗證碼失敗",
"uploadFile": "上傳文件",
"files": "個文件"
}, },
"auth": {
"login": { "login": {
"title": "登錄", "title": "登錄",
"loginButton": "登錄", "loginButton": "登錄",

View File

@@ -95,19 +95,6 @@ function handleSubmit(values: GenericObject) {
<ErrorMessage name="estimatedValue" /> <ErrorMessage name="estimatedValue" />
</div> </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> <div>
<Field name="totalSupplyLimit" type="text"> <Field name="totalSupplyLimit" type="text">
<template #default="{ field }"> <template #default="{ field }">
@@ -122,6 +109,35 @@ function handleSubmit(values: GenericObject) {
<ErrorMessage name="totalSupplyLimit" /> <ErrorMessage name="totalSupplyLimit" />
</div> </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"> <ion-button type="submit" expand="block" shape="round" color="success">
{{ t('asset.issue.apply.next') }} {{ t('asset.issue.apply.next') }}
</ion-button> </ion-button>
@@ -129,4 +145,12 @@ function handleSubmit(values: GenericObject) {
</Form> </Form>
</template> </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>