zhangjf a74875eeda feat(移动端): 新增支出使用 COS 上传附件
1. API 增强 (src/api/index.ts):
   - 新增 uploadFile 函数:支持文件上传到腾讯云 COS
   - 新增 getFileList 函数:获取文件列表
   - 新增 deleteFile 函数:删除文件

2. 新增支出页面优化 (src/views/expense/Add.vue):
   - 修改附件上传逻辑:从 base64 改为 COS 上传
   - onAfterRead: 调用 uploadFile API 上传到 COS
   - 获取 COS 返回的文件路径并存储
   - 提交时将 COS 路径数组转为逗号分隔字符串
   - 图片预览直接使用 COS URL
   - 添加上传进度提示和成功/失败反馈

技术实现:
- 使用 FormData 进行 multipart/form-data 上传
- 业务类型标识为'expense'
- 附件以 COS 完整 URL 形式存储(逗号分隔)
- 支持多图片上传(最多 9 张)
- 每张图片独立上传到 COS,获得永久可访问链接
2026-03-01 22:23:59 +08:00

410 lines
9.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="page expense-add">
<div class="header-bar mac-card">
<div class="back-btn" @click="$router.back()">
<van-icon name="arrow-left" />
</div>
<span class="header-title">新增支出</span>
<div class="placeholder"></div>
</div>
<div class="form-card mac-card fade-in-up">
<div class="form-group">
<label>支出标题 <span class="required">*</span></label>
<input v-model="form.title" placeholder="输入标题" class="mac-input" />
</div>
<div class="form-group">
<label>支出类型 <span class="required">*</span></label>
<div class="mac-select" @click="showTypePicker = true">
<span :class="{ placeholder: !form.expenseTypeName }">
{{ form.expenseTypeName || '选择类型' }}
</span>
<van-icon name="arrow" />
</div>
</div>
<div class="form-group">
<label>支出金额 <span class="required">*</span></label>
<div class="amount-input">
<span class="currency">¥</span>
<input v-model="form.amount" type="number" placeholder="0.00" />
</div>
</div>
<div class="form-group">
<label>收款单位 <span class="required">*</span></label>
<input v-model="form.payeeName" placeholder="输入收款单位名称" class="mac-input" />
</div>
<div class="form-group">
<label>支出日期</label>
<div class="mac-select" @click="showDatePicker = true">
<span :class="{ placeholder: !form.expenseDate }">
{{ form.expenseDate || '选择日期' }}
</span>
<van-icon name="arrow" />
</div>
</div>
<div class="form-group">
<label>支出描述</label>
<textarea v-model="form.description" placeholder="输入描述" rows="3" class="mac-textarea"></textarea>
</div>
<div class="form-group">
<label>附件上传(图片)</label>
<van-uploader
v-model="fileList"
:accept="'image/*'"
:max-count="9"
:after-read="onAfterRead"
:before-delete="onBeforeDelete"
multiple
/>
</div>
</div>
<div class="submit-btn">
<button class="mac-btn" @click="handleSubmit" :disabled="loading">
{{ loading ? '提交中...' : '提交' }}
</button>
</div>
<!-- 类型选择器 -->
<van-popup v-model:show="showTypePicker" position="bottom" round>
<van-picker :columns="typeColumns" @confirm="onTypeConfirm" @cancel="showTypePicker = false" />
</van-popup>
<!-- 日期选择器 -->
<van-popup v-model:show="showDatePicker" position="bottom" round>
<van-date-picker @confirm="onDateConfirm" @cancel="showDatePicker = false" />
</van-popup>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showSuccessToast, showFailToast, ImagePreview } from 'vant'
import { createExpense, getExpenseTypeTree, uploadFile } from '@/api'
const router = useRouter()
const loading = ref(false)
const showTypePicker = ref(false)
const showDatePicker = ref(false)
const fileList = ref<any[]>([])
const uploadedAttachments = ref<string[]>([]) // 存储 COS 返回的文件路径
const form = reactive({
title: '',
expenseTypeId: null as number | null,
expenseTypeName: '',
amount: '',
payeeName: '',
expenseDate: '',
description: ''
})
const typeColumns = ref<any[]>([])
const onTypeConfirm = ({ selectedOptions }: any) => {
const option = selectedOptions[0]
form.expenseTypeId = option.value
form.expenseTypeName = option.text
showTypePicker.value = false
}
const onDateConfirm = ({ selectedValues }: any) => {
form.expenseDate = selectedValues.join('-')
showDatePicker.value = false
}
// 附件上传处理 - 使用 COS 上传
const onAfterRead = async (file: any) => {
if (!file.file) return
try {
// 显示上传提示
showToast('上传中...')
// 调用 uploadFile API 上传到 COS
const res: any = await uploadFile(file.file, 'expense', undefined, '支出附件')
// 从响应中获取文件路径
const filePath = res.data?.filePath || res.data?.url
if (filePath) {
uploadedAttachments.value.push(filePath)
console.log('文件上传成功:', filePath)
showSuccessToast('上传成功')
} else {
showFailToast('上传失败:未获取文件路径')
}
} catch (error: any) {
console.error('上传失败:', error)
showFailToast(error.message || '上传失败')
}
}
const onBeforeDelete = (file: any, detail: any) => {
// 从列表中移除
uploadedAttachments.value.splice(detail.index, 1)
return true
}
// 预览图片(通过后端下载 URL
const onPreviewImage = (index: number) => {
// 这里需要后端提供文件预览或下载接口
// 暂时使用后端返回的完整 URL
ImagePreview.show({
images: uploadedAttachments.value,
startPosition: index,
})
}
const handleSubmit = async () => {
if (!form.title) {
showFailToast('请输入支出标题')
return
}
if (!form.expenseTypeId) {
showFailToast('请选择支出类型')
return
}
if (!form.amount) {
showFailToast('请输入支出金额')
return
}
if (!form.payeeName) {
showFailToast('请输入收款单位')
return
}
loading.value = true
try {
// 转换日期格式为 LocalDateTime 格式
const expenseDateTime = form.expenseDate ? `${form.expenseDate}T12:00:00` : null
// 处理附件:将 COS 文件路径数组转为逗号分隔的字符串
const attachmentsStr = uploadedAttachments.value.length > 0
? uploadedAttachments.value.join(',')
: null
const requestData = {
title: form.title,
expenseType: form.expenseTypeId,
amount: parseFloat(form.amount),
expenseDate: expenseDateTime,
purpose: form.description,
payeeName: form.payeeName,
attachments: attachmentsStr
}
console.log('提交支出数据:', requestData)
await createExpense(requestData)
showSuccessToast('提交成功')
router.back()
} catch (e: any) {
console.error('提交失败:', e)
showFailToast(e.message || '提交失败')
} finally {
loading.value = false
}
}
onMounted(async () => {
// 加载支出类型
try {
const res: any = await getExpenseTypeTree()
const types = res.data || []
typeColumns.value = types.map((t: any) => ({
text: t.typeName,
value: t.id
}))
} catch (e) {
console.error('加载支出类型失败', e)
}
})
</script>
<style scoped>
.expense-add {
padding: 0 16px;
}
.header-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
margin: 12px -16px 16px;
border-radius: 0;
position: sticky;
top: 0;
z-index: 10;
background: rgba(245, 245, 247, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.back-btn {
width: 36px;
height: 36px;
border-radius: 10px;
background: rgba(0, 122, 255, 0.1);
display: flex;
align-items: center;
justify-content: center;
color: var(--mac-primary);
font-size: 18px;
cursor: pointer;
transition: all 0.2s ease;
}
.back-btn:active {
transform: scale(0.95);
background: rgba(0, 122, 255, 0.2);
}
.header-title {
font-size: 17px;
font-weight: 600;
color: var(--mac-text);
}
.placeholder {
width: 36px;
}
.form-card {
padding: 24px 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--mac-text-secondary);
margin-bottom: 8px;
white-space: nowrap;
}
.form-group label .required {
color: var(--mac-danger);
margin-left: 2px;
}
.mac-input {
width: 100%;
padding: 14px 16px;
background: rgba(0, 0, 0, 0.03);
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.08);
font-size: 15px;
color: var(--mac-text);
transition: all 0.2s ease;
outline: none;
}
.mac-input:focus {
border-color: var(--mac-primary);
background: #fff;
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.1);
}
.mac-select {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
background: rgba(0, 0, 0, 0.03);
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.08);
cursor: pointer;
transition: all 0.2s ease;
}
.mac-select:active {
background: rgba(0, 122, 255, 0.08);
}
.mac-select span {
font-size: 15px;
color: var(--mac-text);
}
.mac-select span.placeholder {
color: var(--mac-text-secondary);
}
.mac-select .van-icon {
color: var(--mac-text-secondary);
}
.amount-input {
display: flex;
align-items: center;
padding: 14px 16px;
background: rgba(0, 0, 0, 0.03);
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.08);
transition: all 0.2s ease;
}
.amount-input:focus-within {
border-color: var(--mac-primary);
background: #fff;
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.1);
}
.currency {
font-size: 18px;
font-weight: 600;
color: var(--mac-text);
margin-right: 8px;
}
.amount-input input {
flex: 1;
border: none;
background: transparent;
font-size: 24px;
font-weight: 600;
color: var(--mac-text);
outline: none;
}
.mac-textarea {
width: 100%;
padding: 14px 16px;
background: rgba(0, 0, 0, 0.03);
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.08);
font-size: 15px;
color: var(--mac-text);
resize: none;
outline: none;
font-family: inherit;
}
.mac-textarea:focus {
border-color: var(--mac-primary);
background: #fff;
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.1);
}
.submit-btn {
position: fixed;
bottom: 80px;
left: 16px;
right: 16px;
}
.submit-btn .mac-btn {
width: 100%;
}
</style>