feat(移动端): 优化支出管理功能
1. 新增支出 (Add.vue):
- 增加图片附件上传功能(限制为图片类型)
- 支持最多上传 9 张图片
- 实现图片预览和删除功能
- 将图片转 base64 格式提交到后端 attachments 字段
2. 支出列表 (List.vue):
- 重构卡片布局为 5 行展示:
* 第一行:标题 + 支出时间(右侧对齐)
* 第二行:支出类型(左)+ 支出金额(右,红色突出显示)
* 第三行:收款单位
* 第四行:支付描述(可选,有内容时显示)
* 第五行:查看附件按钮(有附件时显示,蓝色可点击)
- 添加 formatDateTime 函数格式化日期时间
- 添加 getAttachmentCount 函数计算附件数量
- 添加 previewAttachments 函数实现图片预览
- 优化样式:分隔线、图标、标签等细节美化
技术实现:
- 使用 Vant 的 van-uploader 组件上传图片
- 使用 ImagePreview 组件预览图片
- 附件以 base64 逗号分隔字符串形式存储
- 响应式布局适配移动端
This commit is contained in:
parent
6923024650
commit
da4488dccc
1
fund-mobile/components.d.ts
vendored
1
fund-mobile/components.d.ts
vendored
@ -24,5 +24,6 @@ declare module 'vue' {
|
||||
VanPullRefresh: typeof import('vant/es')['PullRefresh']
|
||||
VanSearch: typeof import('vant/es')['Search']
|
||||
VanTag: typeof import('vant/es')['Tag']
|
||||
VanUploader: typeof import('vant/es')['Uploader']
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,6 +51,18 @@
|
||||
<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">
|
||||
@ -74,13 +86,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast, showSuccessToast, showFailToast } from 'vant'
|
||||
import { showToast, showSuccessToast, showFailToast, ImagePreview } from 'vant'
|
||||
import { createExpense, getExpenseTypeTree } 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[]>([])
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
@ -106,6 +120,39 @@ const onDateConfirm = ({ selectedValues }: any) => {
|
||||
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 () => {
|
||||
if (!form.title) {
|
||||
showFailToast('请输入支出标题')
|
||||
@ -126,15 +173,22 @@ const handleSubmit = async () => {
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
// 转换日期格式为LocalDateTime格式
|
||||
// 转换日期格式为 LocalDateTime 格式
|
||||
const expenseDateTime = form.expenseDate ? `${form.expenseDate}T12:00:00` : null
|
||||
|
||||
// 处理附件:将 base64 数组转为逗号分隔的字符串
|
||||
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
|
||||
payeeName: form.payeeName,
|
||||
attachments: attachmentsStr
|
||||
}
|
||||
console.log('提交支出数据:', requestData)
|
||||
await createExpense(requestData)
|
||||
|
||||
@ -16,29 +16,42 @@
|
||||
@load="onLoad"
|
||||
>
|
||||
<div class="expense-card" v-for="item in list" :key="item.expenseId">
|
||||
<div class="expense-header">
|
||||
<span class="expense-title">{{ item.title }}</span>
|
||||
<van-tag :type="getStatusType(item.status)">{{ getStatusText(item.status) }}</van-tag>
|
||||
<!-- 第一行:标题 + 支出时间 -->
|
||||
<div class="card-row title-row">
|
||||
<span class="card-title">{{ item.title }}</span>
|
||||
<span class="card-time">{{ formatDateTime(item.expenseDate) }}</span>
|
||||
</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>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<van-icon name="clock-o" />
|
||||
<span>{{ item.expenseDate }}</span>
|
||||
</div>
|
||||
<span class="card-amount">¥{{ item.amount?.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div class="expense-amount">
|
||||
<div class="amount-item">
|
||||
<span class="label">支出金额</span>
|
||||
<span class="value expense-value">¥{{ item.amount?.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div class="amount-item" v-if="item.paidAmount">
|
||||
<span class="label">已支付</span>
|
||||
<span class="value">¥{{ item.paidAmount?.toLocaleString() }}</span>
|
||||
</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 class="card-row desc-row" v-if="item.purpose">
|
||||
<van-icon name="description-o" class="row-icon" />
|
||||
<span class="info-label">支付描述:</span>
|
||||
<span class="desc-text">{{ item.purpose }}</span>
|
||||
</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>
|
||||
</van-list>
|
||||
@ -54,6 +67,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getExpenseList } from '@/api'
|
||||
import { ImagePreview } from 'vant'
|
||||
|
||||
const searchText = ref('')
|
||||
const loading = ref(false)
|
||||
@ -63,6 +77,43 @@ const list = ref<any[]>([])
|
||||
const pageNum = ref(1)
|
||||
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 map: Record<string, 'primary' | 'success' | 'warning' | 'danger' | 'default'> = {
|
||||
'pending': 'warning',
|
||||
@ -152,59 +203,99 @@ onMounted(() => {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.expense-header {
|
||||
.card-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
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-weight: 600;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.expense-info {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
.card-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
.info-row {
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.info-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.expense-amount {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
padding-top: 12px;
|
||||
.row-icon {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
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;
|
||||
}
|
||||
|
||||
.amount-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.attachment-btn {
|
||||
color: #007AFF;
|
||||
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 {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.amount-item .value {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.expense-value {
|
||||
color: #FF3B30 !important;
|
||||
.attachment-btn:active {
|
||||
background: rgba(0, 122, 255, 0.15);
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user