Files
yunzerwebsiteallinone/uniapp/pages/tools/expense/edit.vue
T

747 lines
17 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>
<view class="page">
<!-- 基本信息 -->
<view class="form-card">
<view class="field">
<text class="field-label">申请日期 <text class="required">*</text></text>
<picker mode="date" :value="form.applyDate" @change="onDateChange">
<view class="picker-value">
<text>{{ form.applyDate }}</text>
<FaIcon name="calendar-days" color="#909399" :size="16" />
</view>
</picker>
</view>
<view class="field">
<text class="field-label">报销说明</text>
<u-textarea
v-model="form.description"
placeholder="填写报销事由,便于审批人了解(选填)"
:maxlength="500"
count
height="100"
border="none"
/>
</view>
</view>
<!-- 费用明细 -->
<view class="items-card">
<view class="items-head">
<text class="items-title">费用明细</text>
<text class="items-total">合计 ¥{{ totalText }}</text>
</view>
<view v-if="!items.length" class="items-empty">
<text>尚未添加费用明细</text>
</view>
<view
v-for="(item, index) in items"
:key="index"
class="item-row"
@tap="openItemEditor(index)"
>
<view class="item-main">
<view class="item-line">
<text class="item-type">{{ item.expenseType || '未分类' }}</text>
<text class="item-amount">¥{{ moneyText(item.amount) }}</text>
</view>
<view class="item-sub">
<text>{{ item.expenseDate }}</text>
<text v-if="item.receipts?.length" class="item-receipt">发票 ×{{ item.receipts.length }}</text>
</view>
</view>
<view class="item-del" @tap.stop="onRemoveItem(index)">
<FaIcon name="trash-can" color="#f56c6c" :size="18" />
</view>
</view>
<view class="item-add" @tap="openItemEditor(-1)">
<FaIcon name="plus" color="#2563eb" :size="16" />
<text>添加费用明细</text>
</view>
</view>
<view class="footer">
<u-button
type="primary"
color="#2563eb"
shape="circle"
:loading="saving"
loading-text="保存中..."
text="保存"
@click="onSave"
/>
</view>
<!-- 明细编辑弹层 -->
<view v-if="itemEditorVisible" class="mask" @tap="closeItemEditor">
<view class="sheet" @tap.stop>
<view class="sheet-head">
<text class="sheet-cancel" @tap="closeItemEditor">取消</text>
<text class="sheet-title">{{ editingIndex >= 0 ? '编辑费用' : '添加费用' }}</text>
<text class="sheet-save" @tap="confirmItem">确定</text>
</view>
<scroll-view class="sheet-body" scroll-y>
<view class="field">
<text class="field-label">费用类型 <text class="required">*</text></text>
<picker :range="typeNames" :value="typeIndex" @change="onTypeChange">
<view class="picker-value">
<text>{{ typeNames[typeIndex] || '选择费用类型' }}</text>
<FaIcon name="layer-group" color="#909399" :size="16" />
</view>
</picker>
</view>
<view class="field">
<text class="field-label">费用日期 <text class="required">*</text></text>
<picker mode="date" :value="itemForm.expenseDate" @change="onItemDateChange">
<view class="picker-value">
<text>{{ itemForm.expenseDate }}</text>
<FaIcon name="calendar-days" color="#909399" :size="16" />
</view>
</picker>
</view>
<view class="field">
<text class="field-label">金额 <text class="required">*</text></text>
<u-input
v-model="itemForm.amount"
type="digit"
placeholder="0.00"
border="none"
/>
</view>
<view class="field">
<text class="field-label">数量</text>
<u-input
v-model="itemForm.quantity"
type="number"
placeholder="1"
border="none"
/>
</view>
<view class="field">
<text class="field-label">说明</text>
<u-input
v-model="itemForm.description"
placeholder="费用用途说明(选填)"
:maxlength="200"
border="none"
/>
</view>
<view class="field">
<view class="receipt-head">
<text class="field-label no-margin">发票附件</text>
<text v-if="itemForm.receipts.length" class="receipt-hint">长按可删除</text>
</view>
<view class="receipt-grid">
<template v-for="(img, index) in itemForm.receipts" :key="img.id || index">
<image
v-if="!isPdfReceipt(img)"
class="receipt-item"
:src="img.url"
mode="aspectFill"
@click="previewReceipt(index)"
@longpress="onRemoveReceipt(index)"
/>
<view v-else class="receipt-file" @longpress="onRemoveReceipt(index)">
<FaIcon name="file-pdf" color="#e74c3c" :size="26" />
<text class="receipt-file-name">{{ img.name || 'PDF 文件' }}</text>
</view>
</template>
<view class="receipt-add" @tap="onUploadReceipt">
<FaIcon name="camera" color="#94a3b8" :size="20" />
<text>{{ uploading ? '上传中...' : '上传' }}</text>
</view>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import {
getExpense,
createExpense,
updateExpense,
addExpenseItem,
deleteExpenseItem,
fetchExpenseTypes,
uploadExpenseReceipt,
resolveImageUrl,
isPdfReceipt,
moneyText,
EXPENSE_STATUS
} from '@/api/expense.js'
import { formatDate } from '@/utils/date.js'
const expenseId = ref('')
const isDraft = ref(true)
const saving = ref(false)
const uploading = ref(false)
const form = reactive({
applyDate: '',
description: ''
})
/** 本地明细列表:编辑已有明细时带 id,保存时全量重建 */
const items = ref([])
const typeOptions = ref([])
const typeNames = computed(() => typeOptions.value.map(t => t.name))
const itemEditorVisible = ref(false)
const editingIndex = ref(-1)
const typeIndex = ref(0)
const itemForm = reactive({
expenseType: '',
expenseDate: '',
amount: '',
quantity: '1',
description: '',
receipts: []
})
const totalText = computed(() =>
moneyText(items.value.reduce((sum, item) => sum + (Number(item.amount) || 0), 0))
)
onLoad(async (query) => {
form.applyDate = formatDate(new Date())
loadTypes()
if (query?.id) {
expenseId.value = query.id
uni.setNavigationBarTitle({ title: '编辑报销单' })
await loadDetail(query.id)
} else {
uni.setNavigationBarTitle({ title: '新建报销单' })
}
})
async function loadTypes() {
try {
typeOptions.value = await fetchExpenseTypes()
if (!typeOptions.value.length) typeOptions.value = [{ id: 0, name: '其他' }]
} catch (e) {
typeOptions.value = [{ id: 0, name: '其他' }]
}
}
async function loadDetail(id) {
try {
const data = await getExpense(id)
if (!data) {
uni.showToast({ title: '报销单不存在', icon: 'none' })
setTimeout(() => uni.navigateBack(), 800)
return
}
isDraft.value = Number(data.status) === EXPENSE_STATUS.DRAFT
if (!isDraft.value) {
uni.showToast({ title: '只有草稿可以编辑', icon: 'none' })
setTimeout(() => uni.navigateBack(), 800)
return
}
form.applyDate = data.apply_date || formatDate(new Date())
form.description = data.description || ''
items.value = (data.items || []).map(item => ({
id: item.id,
expenseType: item.expense_type,
expenseDate: item.expense_date,
amount: String(item.amount ?? ''),
quantity: String(item.quantity || 1),
description: item.description || '',
receipts: item.receipts || []
}))
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
}
function onDateChange(e) {
form.applyDate = e.detail.value
}
function openItemEditor(index) {
editingIndex.value = index
if (index >= 0) {
const item = items.value[index]
Object.assign(itemForm, {
expenseType: item.expenseType,
expenseDate: item.expenseDate,
amount: item.amount,
quantity: item.quantity,
unitPrice: item.unitPrice,
description: item.description,
receipts: [...(item.receipts || [])]
})
typeIndex.value = Math.max(
typeOptions.value.findIndex(t => t.name === item.expenseType),
0
)
} else {
Object.assign(itemForm, {
expenseType: typeOptions.value[0]?.name || '',
expenseDate: form.applyDate,
amount: '',
quantity: '1',
description: '',
receipts: []
})
typeIndex.value = 0
}
itemEditorVisible.value = true
}
function closeItemEditor() {
itemEditorVisible.value = false
}
function onTypeChange(e) {
typeIndex.value = Number(e.detail.value)
itemForm.expenseType = typeOptions.value[typeIndex.value]?.name || ''
}
function onItemDateChange(e) {
itemForm.expenseDate = e.detail.value
}
function confirmItem() {
const amount = Number(itemForm.amount)
if (!itemForm.expenseType) {
uni.showToast({ title: '请选择费用类型', icon: 'none' })
return
}
if (!itemForm.expenseDate) {
uni.showToast({ title: '请选择费用日期', icon: 'none' })
return
}
if (!amount || amount <= 0) {
uni.showToast({ title: '费用金额必须大于0', icon: 'none' })
return
}
if (editingIndex.value >= 0) {
const old = items.value[editingIndex.value]
Object.assign(old, {
expenseType: itemForm.expenseType,
expenseDate: itemForm.expenseDate,
amount: amount,
quantity: Math.max(Number(itemForm.quantity) || 1, 1),
description: itemForm.description.trim(),
receipts: [...itemForm.receipts]
})
} else {
items.value.push({
expenseType: itemForm.expenseType,
expenseDate: itemForm.expenseDate,
amount: amount,
quantity: Math.max(Number(itemForm.quantity) || 1, 1),
description: itemForm.description.trim(),
receipts: [...itemForm.receipts]
})
}
itemEditorVisible.value = false
}
function onRemoveItem(index) {
items.value.splice(index, 1)
}
function previewReceipt(index) {
const current = itemForm.receipts[index]
const urls = itemForm.receipts
.filter(img => !isPdfReceipt(img))
.map(img => img.url)
.filter(Boolean)
if (!urls.length) return
uni.previewImage({ urls, current: current && !isPdfReceipt(current) ? current.url : urls[0] })
}
function onRemoveReceipt(index) {
itemForm.receipts.splice(index, 1)
}
/** 上传发票:选择图片(拍照/相册)或从聊天文件选 PDF */
function onUploadReceipt() {
if (uploading.value) return
uni.showActionSheet({
itemList: ['拍摄或从相册选择', '从聊天文件选择'],
success: (res) => {
if (res.tapIndex === 0) {
chooseImageReceipt()
} else if (res.tapIndex === 1) {
chooseFileReceipt()
}
}
})
}
function chooseImageReceipt() {
uni.chooseImage({
count: 9,
success: async (res) => {
await uploadReceipts(res.tempFilePaths || [])
}
})
}
/** 小程序沙箱内选择本地 PDF 只能走微信会话文件(chooseMessageFile) */
function chooseFileReceipt() {
if (typeof uni.chooseMessageFile !== 'function') {
uni.showToast({ title: '当前环境不支持选择文件', icon: 'none' })
return
}
uni.chooseMessageFile({
count: 9,
type: 'file',
extension: ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'],
success: async (res) => {
const paths = (res.tempFiles || []).map(f => f.path).filter(Boolean)
if (!paths.length) return
await uploadReceipts(paths)
},
fail: () => {
// 用户取消选择,静默处理
}
})
}
async function uploadReceipts(paths) {
if (!paths.length || uploading.value) return
uploading.value = true
try {
for (const p of paths) {
const uploaded = await uploadExpenseReceipt(p)
itemForm.receipts.push({ ...uploaded, url: resolveImageUrl(uploaded.url) })
}
uni.showToast({ title: '上传成功', icon: 'success' })
} catch (e) {
// 上传封装已提示错误
} finally {
uploading.value = false
}
}
async function onSave() {
if (saving.value) return
if (!form.applyDate) {
uni.showToast({ title: '请选择申请日期', icon: 'none' })
return
}
saving.value = true
try {
let id = expenseId.value
const payload = { applyDate: form.applyDate, description: form.description }
if (id) {
await updateExpense(id, payload)
// 明细全量重建:先删旧再逐条新增(草稿单,明细量少)
const oldItems = await getExpense(id)
for (const item of oldItems?.items || []) {
await deleteExpenseItem(id, item.id)
}
} else {
id = await createExpense(payload)
}
for (const item of items.value) {
await addExpenseItem(id, item)
}
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
} catch (e) {
// request 封装已弹出服务端错误提示
} finally {
saving.value = false
}
}
</script>
<style lang="scss" scoped>
@import '@/src/styles/page-common.scss';
.page {
min-height: 100vh;
background: $color-bg-page;
padding: 24rpx $page-padding-x;
padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
}
.form-card,
.items-card {
@include card;
padding: 8rpx 0;
margin-bottom: 24rpx;
}
.field {
padding: 24rpx 28rpx;
& + & {
border-top: 1rpx solid $color-divider;
}
}
.field-label {
display: block;
font-size: 24rpx;
color: $color-text-muted;
margin-bottom: 12rpx;
&.no-margin {
margin-bottom: 0;
}
}
.required {
color: #f56c6c;
}
.picker-value {
display: flex;
align-items: center;
justify-content: space-between;
height: 72rpx;
padding: 0 20rpx;
background: $color-bg-page;
border-radius: $radius-md;
font-size: 28rpx;
color: $color-text;
}
.items-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 28rpx 8rpx;
}
.items-title {
font-size: 30rpx;
font-weight: 600;
color: $color-text;
}
.items-total {
font-size: 26rpx;
color: #f56c6c;
font-weight: 600;
}
.items-empty {
padding: 32rpx 28rpx;
font-size: 26rpx;
color: $color-text-muted;
text-align: center;
}
.item-row {
display: flex;
align-items: center;
padding: 24rpx 28rpx;
border-top: 1rpx solid $color-divider;
&:active {
background: $color-bg-page;
}
}
.item-main {
flex: 1;
min-width: 0;
}
.item-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.item-type {
font-size: 28rpx;
color: $color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-amount {
font-size: 28rpx;
font-weight: 600;
color: $color-text;
flex-shrink: 0;
}
.item-sub {
display: flex;
align-items: center;
gap: 20rpx;
margin-top: 8rpx;
font-size: 24rpx;
color: $color-text-muted;
}
.item-receipt {
color: #2563eb;
}
.item-del {
padding: 12rpx 0 12rpx 24rpx;
flex-shrink: 0;
}
.item-add {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
margin: 16rpx 28rpx 28rpx;
height: 80rpx;
border: 1rpx dashed $color-border;
border-radius: $radius-md;
font-size: 26rpx;
color: #2563eb;
&:active {
opacity: 0.7;
}
}
.footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 20rpx $page-padding-x;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
background: $color-card;
border-top: 1rpx solid $color-border;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.04);
}
.mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.45);
z-index: 100;
display: flex;
align-items: flex-end;
@include mask-responsive;
}
.sheet {
width: 100%;
background: $color-card;
border-radius: 28rpx 28rpx 0 0;
display: flex;
flex-direction: column;
max-height: 80vh;
padding-bottom: env(safe-area-inset-bottom);
@include sheet-responsive;
}
.sheet-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx $page-padding-x;
border-bottom: 1rpx solid $color-divider;
}
.sheet-title {
font-size: 30rpx;
font-weight: 600;
color: $color-text;
}
.sheet-cancel {
font-size: 28rpx;
color: $color-text-muted;
}
.sheet-save {
font-size: 28rpx;
font-weight: 600;
color: $color-primary;
}
.sheet-body {
flex: 1;
min-height: 0;
max-height: 60vh;
}
.receipt-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.receipt-hint {
font-size: 22rpx;
color: $color-text-muted;
}
.receipt-grid {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.receipt-item {
width: 140rpx;
height: 140rpx;
border-radius: $radius-md;
background: $color-bg-page;
}
.receipt-file {
width: 200rpx;
height: 140rpx;
border-radius: $radius-md;
background: $color-bg-page;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6rpx;
padding: 0 12rpx;
box-sizing: border-box;
}
.receipt-file-name {
max-width: 100%;
font-size: 22rpx;
color: $color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.receipt-add {
width: 140rpx;
height: 140rpx;
border: 1rpx dashed $color-border;
border-radius: $radius-md;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6rpx;
font-size: 22rpx;
color: $color-text-muted;
&:active {
opacity: 0.7;
}
}
</style>