feat(移动端): 优化支出管理功能

1. 新增支出 (Add.vue):
   - 增加图片附件上传功能(限制为图片类型)
   - 支持最多上传 9 张图片
   - 实现图片预览和删除功能
   - 将图片转 base64 格式提交到后端 attachments 字段

2. 支出列表 (List.vue):
   - 重构卡片布局为 5 行展示:
     * 第一行:标题 + 支出时间(右侧对齐)
     * 第二行:支出类型(左)+ 支出金额(右,红色突出显示)
     * 第三行:收款单位
     * 第四行:支付描述(可选,有内容时显示)
     * 第五行:查看附件按钮(有附件时显示,蓝色可点击)
   - 添加 formatDateTime 函数格式化日期时间
   - 添加 getAttachmentCount 函数计算附件数量
   - 添加 previewAttachments 函数实现图片预览
   - 优化样式:分隔线、图标、标签等细节美化

技术实现:
- 使用 Vant 的 van-uploader 组件上传图片
- 使用 ImagePreview 组件预览图片
- 附件以 base64 逗号分隔字符串形式存储
- 响应式布局适配移动端
This commit is contained in:
zhangjf 2026-03-01 22:17:20 +08:00
parent 6923024650
commit da4488dccc
3 changed files with 199 additions and 53 deletions

View File

@ -24,5 +24,6 @@ declare module 'vue' {
VanPullRefresh: typeof import('vant/es')['PullRefresh'] VanPullRefresh: typeof import('vant/es')['PullRefresh']
VanSearch: typeof import('vant/es')['Search'] VanSearch: typeof import('vant/es')['Search']
VanTag: typeof import('vant/es')['Tag'] VanTag: typeof import('vant/es')['Tag']
VanUploader: typeof import('vant/es')['Uploader']
} }
} }

View File

@ -51,6 +51,18 @@
<label>支出描述</label> <label>支出描述</label>
<textarea v-model="form.description" placeholder="输入描述" rows="3" class="mac-textarea"></textarea> <textarea v-model="form.description" placeholder="输入描述" rows="3" class="mac-textarea"></textarea>
</div> </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>
<div class="submit-btn"> <div class="submit-btn">
@ -74,13 +86,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { showToast, showSuccessToast, showFailToast } from 'vant' import { showToast, showSuccessToast, showFailToast, ImagePreview } from 'vant'
import { createExpense, getExpenseTypeTree } from '@/api' import { createExpense, getExpenseTypeTree } from '@/api'
const router = useRouter() const router = useRouter()
const loading = ref(false) const loading = ref(false)
const showTypePicker = ref(false) const showTypePicker = ref(false)
const showDatePicker = ref(false) const showDatePicker = ref(false)
const fileList = ref<any[]>([])
const uploadedAttachments = ref<string[]>([])
const form = reactive({ const form = reactive({
title: '', title: '',
@ -106,6 +120,39 @@ const onDateConfirm = ({ selectedValues }: any) => {
showDatePicker.value = false showDatePicker.value = false
} }
//
const onAfterRead = async (file: any) => {
// API
// 使 fund-file base64
if (file.file) {
const reader = new FileReader()
reader.onload = (e) => {
const base64 = e.target?.result as string
// base64 data:image/jpeg;base64,
const base64Data = base64.split(',')[1]
if (base64Data) {
uploadedAttachments.value.push(base64Data)
console.log('已添加附件:', base64Data.substring(0, 50) + '...')
}
}
reader.readAsDataURL(file.file)
}
}
const onBeforeDelete = (file: any, detail: any) => {
//
uploadedAttachments.value.splice(detail.index, 1)
return true
}
//
const onPreviewImage = (index: number) => {
ImagePreview.show({
images: uploadedAttachments.value.map(b64 => `data:image/jpeg;base64,${b64}`),
startPosition: index,
})
}
const handleSubmit = async () => { const handleSubmit = async () => {
if (!form.title) { if (!form.title) {
showFailToast('请输入支出标题') showFailToast('请输入支出标题')
@ -128,13 +175,20 @@ const handleSubmit = async () => {
try { try {
// LocalDateTime // LocalDateTime
const expenseDateTime = form.expenseDate ? `${form.expenseDate}T12:00:00` : null const expenseDateTime = form.expenseDate ? `${form.expenseDate}T12:00:00` : null
// base64
const attachmentsStr = uploadedAttachments.value.length > 0
? uploadedAttachments.value.join(',')
: null
const requestData = { const requestData = {
title: form.title, title: form.title,
expenseType: form.expenseTypeId, expenseType: form.expenseTypeId,
amount: parseFloat(form.amount), amount: parseFloat(form.amount),
expenseDate: expenseDateTime, expenseDate: expenseDateTime,
purpose: form.description, purpose: form.description,
payeeName: form.payeeName payeeName: form.payeeName,
attachments: attachmentsStr
} }
console.log('提交支出数据:', requestData) console.log('提交支出数据:', requestData)
await createExpense(requestData) await createExpense(requestData)

View File

@ -16,29 +16,42 @@
@load="onLoad" @load="onLoad"
> >
<div class="expense-card" v-for="item in list" :key="item.expenseId"> <div class="expense-card" v-for="item in list" :key="item.expenseId">
<div class="expense-header"> <!-- 第一行标题 + 支出时间 -->
<span class="expense-title">{{ item.title }}</span> <div class="card-row title-row">
<van-tag :type="getStatusType(item.status)">{{ getStatusText(item.status) }}</van-tag> <span class="card-title">{{ item.title }}</span>
<span class="card-time">{{ formatDateTime(item.expenseDate) }}</span>
</div> </div>
<div class="expense-info">
<div class="info-item"> <!-- 第二行支出类型 + 支出金额 -->
<van-icon name="apps-o" /> <div class="card-row info-row">
<div class="info-left">
<van-icon name="apps-o" class="row-icon" />
<span class="info-label">支出类型</span>
<span>{{ item.typeName || '-' }}</span> <span>{{ item.typeName || '-' }}</span>
</div> </div>
<div class="info-item"> <span class="card-amount">¥{{ item.amount?.toLocaleString() }}</span>
<van-icon name="clock-o" />
<span>{{ item.expenseDate }}</span>
</div> </div>
<!-- 第三行收款单位 -->
<div class="card-row payee-row">
<van-icon name="shop-o" class="row-icon" />
<span class="info-label">收款单位</span>
<span>{{ item.payeeName || '-' }}</span>
</div> </div>
<div class="expense-amount">
<div class="amount-item"> <!-- 第四行支付描述 -->
<span class="label">支出金额</span> <div class="card-row desc-row" v-if="item.purpose">
<span class="value expense-value">¥{{ item.amount?.toLocaleString() }}</span> <van-icon name="description-o" class="row-icon" />
</div> <span class="info-label">支付描述</span>
<div class="amount-item" v-if="item.paidAmount"> <span class="desc-text">{{ item.purpose }}</span>
<span class="label">已支付</span>
<span class="value">¥{{ item.paidAmount?.toLocaleString() }}</span>
</div> </div>
<!-- 第五行查看附件 -->
<div class="card-row attachment-row" v-if="item.attachments">
<van-icon name="photo-o" class="row-icon" />
<span class="attachment-btn" @click="previewAttachments(item)">
查看附件{{ getAttachmentCount(item.attachments) }}
</span>
</div> </div>
</div> </div>
</van-list> </van-list>
@ -54,6 +67,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { getExpenseList } from '@/api' import { getExpenseList } from '@/api'
import { ImagePreview } from 'vant'
const searchText = ref('') const searchText = ref('')
const loading = ref(false) const loading = ref(false)
@ -63,6 +77,43 @@ const list = ref<any[]>([])
const pageNum = ref(1) const pageNum = ref(1)
const pageSize = 10 const pageSize = 10
//
const formatDateTime = (dateTime: string) => {
if (!dateTime) return ''
try {
// LocalDateTime 2024-01-15T10:30:00
const date = new Date(dateTime)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
} catch (e) {
return dateTime
}
}
//
const getAttachmentCount = (attachments: string) => {
if (!attachments) return 0
return attachments.split(',').filter(s => s.trim()).length
}
//
const previewAttachments = (item: any) => {
if (!item.attachments) return
// base64 URL
const attachmentList = item.attachments.split(',')
const imageUrls = attachmentList.map((b64: string) => `data:image/jpeg;base64,${b64}`)
ImagePreview.show({
images: imageUrls,
startPosition: 0,
})
}
const getStatusType = (status: string): 'primary' | 'success' | 'warning' | 'danger' | 'default' => { const getStatusType = (status: string): 'primary' | 'success' | 'warning' | 'danger' | 'default' => {
const map: Record<string, 'primary' | 'success' | 'warning' | 'danger' | 'default'> = { const map: Record<string, 'primary' | 'success' | 'warning' | 'danger' | 'default'> = {
'pending': 'warning', 'pending': 'warning',
@ -152,59 +203,99 @@ onMounted(() => {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
} }
.expense-header { .card-row {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 12px; margin-bottom: 10px;
} }
.expense-title { .card-row:last-child {
margin-bottom: 0;
}
.title-row {
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #f0f0f0;
}
.card-title {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: #333; color: #333;
flex: 1;
margin-right: 12px;
} }
.expense-info { .card-time {
display: flex; font-size: 12px;
gap: 16px; color: #999;
margin-bottom: 12px; white-space: nowrap;
} }
.info-item { .info-row {
justify-content: space-between;
margin-bottom: 10px;
}
.info-left {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; flex: 1;
font-size: 13px; font-size: 13px;
color: #666; color: #666;
} }
.expense-amount { .row-icon {
display: flex; font-size: 14px;
gap: 24px; color: #999;
padding-top: 12px; margin-right: 4px;
}
.info-label {
color: #999;
margin-right: 4px;
}
.card-amount {
font-size: 18px;
font-weight: 600;
color: #FF3B30;
white-space: nowrap;
}
.payee-row,
.desc-row {
font-size: 13px;
color: #666;
margin-bottom: 8px;
}
.desc-text {
flex: 1;
line-height: 1.5;
}
.attachment-row {
padding-top: 10px;
margin-top: 10px;
border-top: 1px solid #f0f0f0; border-top: 1px solid #f0f0f0;
} }
.amount-item { .attachment-btn {
display: flex; color: #007AFF;
flex-direction: column; font-size: 13px;
cursor: pointer;
padding: 4px 8px;
background: rgba(0, 122, 255, 0.08);
border-radius: 4px;
transition: all 0.2s ease;
} }
.amount-item .label { .attachment-btn:active {
font-size: 12px; background: rgba(0, 122, 255, 0.15);
color: #999;
}
.amount-item .value {
font-size: 15px;
font-weight: 600;
color: #333;
margin-top: 4px;
}
.expense-value {
color: #FF3B30 !important;
} }
.add-btn { .add-btn {