Files
shanxia2.0/miniprogram/pages/bx/reimburse/list.vue
T

375 lines
11 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="bx-page">
<view class="topbar" :style="safeAreaStyle">
<view class="topbar-left">
<text class="topbar-back" @click="goBack">←</text>
<text class="topbar-title">报销申请</text>
</view>
<view class="topbar-right" />
</view>
<view class="fab-add" @click="goApply()">
<view class="fab-add__bar fab-add__bar--h" />
<view class="fab-add__bar fab-add__bar--v" />
</view>
<view class="search-wrap">
<view class="search-box">
<input
class="search-input"
v-model="keyword"
placeholder="搜索报销单"
confirm-type="search"
@confirm="onRefresh"
/>
<view v-if="keyword.trim()" class="search-box__clear" @click="clearKeyword">×</view>
</view>
</view>
<view class="search-wrap" style="padding-top: 0">
<scroll-view scroll-x class="search-filter-scroll" :show-scrollbar="false">
<view class="search-filter">
<view
v-for="tab in tabs"
:key="tab.value === '' ? '__all' : tab.value"
class="filter-tag"
:class="{ active: activeTab === tab.value }"
@click="onTabChange(tab.value)"
>
{{ tab.label }}
</view>
</view>
</scroll-view>
</view>
<scroll-view
class="page-padded list-scroll"
scroll-y
:style="{ height: listHeight }"
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="onPullRefresh"
@scrolltolower="onLoadMore"
>
<view v-if="!list.length && !loading" class="empty-state empty-state--fill">
<text>暂无报销记录</text>
</view>
<view
v-for="item in list"
:key="item.id"
class="expense-card"
@click="goDetail(item.id)"
>
<view class="ec-top">
<view class="ec-type">
<text class="reimburse-list-card__title">{{ cardTitle(item) }}</text>
</view>
<text class="status-tag" :class="workflowListStatusTagClass(item.status)">
{{ statusLabel(item.status) }}
</text>
</view>
<view class="ec-amount-label">报销金额</view>
<view class="ec-amount">{{ formatAmount(item.amount) }}</view>
<view class="reimburse-card-subs">
<view class="reimburse-card-sub-row">
<text class="k">冲抵金额</text>
<text class="v">¥ {{ moneyOrDash(item.offsetAmount ?? item.offset_amount) }}</text>
</view>
<view class="reimburse-card-sub-row">
<text class="k">实付金额</text>
<text class="v">
¥ {{ moneyOrDash(item.payAmount ?? item.actualAmount ?? item.actual_amount) }}
</text>
</view>
</view>
<view class="list-card-kv">
<view class="list-card-kv__row">
<text class="list-card-kv__k">申请单号</text>
<text class="list-card-kv__v">{{ item.applyCode || '—' }}</text>
</view>
<view class="list-card-kv__row">
<text class="list-card-kv__k">申请人</text>
<text class="list-card-kv__v">{{ item.applicantName || '-' }}</text>
</view>
<view
v-if="resolveExpenseDisplay(item, flatList) !== '-'"
class="list-card-kv__row"
>
<text class="list-card-kv__k">费用项目</text>
<text class="list-card-kv__v">{{ resolveExpenseDisplay(item, flatList) }}</text>
</view>
<view v-if="item.currentNode" class="list-card-kv__row">
<text class="list-card-kv__k">当前环节</text>
<text class="list-card-kv__v">{{ item.currentNode }}</text>
</view>
<view class="list-card-kv__row">
<text class="list-card-kv__k">发票 / 附件</text>
<text class="list-card-kv__v">
{{ item.invoiceCount ?? 0 }} 张 · {{ item.attachCount ?? 0 }} 个
</text>
</view>
<view v-if="item.remark" class="list-card-kv__row">
<text class="list-card-kv__k">备注</text>
<text class="list-card-kv__v">{{ item.remark }}</text>
</view>
</view>
<view class="ec-meta">
<text>{{ formatApplyDateTime(item) }}</text>
</view>
<view class="ec-bottom" @click.stop>
<view v-if="item.status === 'draft' || item.status === 'rejected'" class="op-actions">
<template v-if="item.status === 'draft'">
<button class="btn btn-sm btn-danger" @click="handleDelete(item)">删除</button>
<button class="btn btn-sm btn-primary" @click="goApply(item.id)">编辑</button>
<button class="btn btn-sm btn-primary" @click="handleSubmit(item)">提交</button>
</template>
<template v-else>
<button class="btn btn-sm btn-primary" @click="goDetail(item.id)">查看</button>
<button class="btn btn-sm btn-primary" @click="goApply(item.id)">编辑</button>
<button class="btn btn-sm btn-primary" @click="handleSubmit(item)">提交</button>
</template>
</view>
<view v-else class="op-actions">
<button class="btn btn-sm btn-primary" @click="goDetail(item.id)">查看</button>
<button
v-if="canWithdraw(item)"
class="btn btn-sm btn-ghost"
:disabled="withdrawingId === item.id"
@click="handleWithdraw(item)"
>
撤回
</button>
</view>
</view>
</view>
<view v-if="list.length" class="list-footer-tip">
{{ finished ? '没有更多了' : loading ? '加载中...' : '上拉加载更多' }}
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { PAGE_PATHS } from '@/config/api'
import { reimburseApi, expenseItemApi } from '@/api/bx'
import {
statusLabel,
workflowListStatusTagClass,
formatAmount,
formatApplyDateTime,
normalizeExpenseItem,
flattenExpenseTree,
resolveExpenseDisplay
} from '@/utils/bxFormat'
import { getSafeAreaStyle } from '@/utils/safeArea'
import { useUserStore } from '@/stores/user'
const safeAreaStyle = getSafeAreaStyle()
const userStore = useUserStore()
const withdrawingId = ref('')
function currentUserId() {
const u = userStore.userInfo || {}
return String(u.id || u.userId || '')
}
function canWithdraw(item) {
if (!item) return false
return item.status === 'pending' && String(item.applicantId || '') === currentUserId()
}
const tabs = [
{ label: '全部', value: '' },
{ label: '待提交', value: 'draft' },
{ label: '审批中', value: 'pending' },
{ label: '已通过', value: 'approved' },
{ label: '已驳回', value: 'rejected' },
{ label: '已支付', value: 'paid' }
]
const keyword = ref('')
const activeTab = ref('')
const list = ref([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const currentPage = ref(1)
const pageSize = 10
const listHeight = ref('60vh')
const flatList = ref([])
function computeListHeight() {
try {
const sys = uni.getSystemInfoSync()
const h = (sys.windowHeight || 600) - 210
listHeight.value = `${Math.max(h, 320)}px`
} catch (e) {
listHeight.value = '60vh'
}
}
function moneyOrDash(raw) {
if (raw == null || raw === '') return '—'
const n = typeof raw === 'string' ? parseFloat(raw) : Number(raw)
if (Number.isNaN(n) || n === 0) return '—'
return formatAmount(n)
}
function cardTitle(item) {
const t = (item.title ?? '').toString().trim()
if (t) return t
const ex = resolveExpenseDisplay(item, flatList.value)
return ex !== '-' ? ex : '-'
}
async function loadExpenseFlat() {
try {
const res = await expenseItemApi.tree()
const tree = (res.data || []).map(normalizeExpenseItem)
flatList.value = flattenExpenseTree(tree)
} catch (e) {
flatList.value = []
}
}
async function fetchPage() {
if (loading.value && !refreshing.value) return
loading.value = true
try {
const params = {
page: currentPage.value,
pageSize
}
if (activeTab.value) params.status = activeTab.value
if (keyword.value.trim()) params.keyword = keyword.value.trim()
const res = await reimburseApi.my(params)
const rows = res.data?.rows || res.data?.items || res.data?.list || []
const total = res.data?.total || 0
if (currentPage.value === 1) {
list.value = rows
} else {
list.value = list.value.concat(rows)
}
currentPage.value += 1
finished.value = rows.length < pageSize || list.value.length >= total
} catch (e) {
finished.value = true
} finally {
loading.value = false
refreshing.value = false
}
}
function onRefresh() {
currentPage.value = 1
finished.value = false
loading.value = false
list.value = []
fetchPage()
}
function onPullRefresh() {
refreshing.value = true
currentPage.value = 1
finished.value = false
fetchPage()
}
function onLoadMore() {
if (finished.value || loading.value) return
fetchPage()
}
function onTabChange(value) {
activeTab.value = value
onRefresh()
}
function clearKeyword() {
keyword.value = ''
onRefresh()
}
function goBack() {
uni.navigateBack({ fail: () => uni.reLaunch({ url: PAGE_PATHS.workbench }) })
}
function goApply(id) {
const q = id ? `?id=${id}` : ''
uni.navigateTo({ url: `${PAGE_PATHS.bxReimburseApplyForm}${q}` })
}
function goDetail(id) {
uni.navigateTo({ url: `${PAGE_PATHS.bxReimburseDetail}?id=${id}` })
}
async function handleSubmit(item) {
try {
await reimburseApi.submit(item.id)
uni.showToast({ title: '提交成功', icon: 'success' })
onRefresh()
} catch (e) {
// bxRequest already toasted
}
}
function handleDelete(item) {
uni.showModal({
title: '删除确认',
content: '确定要删除该报销单吗?删除后不可恢复。',
confirmText: '确定删除',
confirmColor: '#ef4444',
success: async (res) => {
if (!res.confirm) return
try {
await reimburseApi.delete(item.id)
uni.showToast({ title: '删除成功', icon: 'success' })
onRefresh()
} catch (e) {
// toasted
}
}
})
}
function handleWithdraw(item) {
if (!item?.id || !canWithdraw(item)) return
uni.showModal({
title: '确认撤回',
content: '确定撤回该报销单?撤回后将退回发票额度。',
confirmText: '确定撤回',
success: async (res) => {
if (!res.confirm) return
withdrawingId.value = String(item.id)
try {
await reimburseApi.withdraw(item.id)
uni.showToast({ title: '已撤回', icon: 'success' })
} catch (e) {
// toasted by bxRequest
} finally {
withdrawingId.value = ''
onRefresh()
}
}
})
}
onShow(() => {
computeListHeight()
loadExpenseFlat()
onRefresh()
})
</script>
<style lang="scss">
@import '@/styles/bx-theme.scss';
.list-scroll {
box-sizing: border-box;
}
</style>