feat(mini): add ETC vehicle recharge apply for drivers and escorts

Add menu SQL, workbench entry, mineVehicles API client, and list/form pages styled like fuel register.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-09-27 18:38:35 +08:00
1 parent 48aef9f233
commit c70b17f8ca
8 files changed
+1149 -2

No files matched your search

+1
View File
@@ -58,6 +58,7 @@ export async function fetchVehicleOptions(q, opts = {}) {
const params = {
...(q?.trim() ? { q: q.trim() } : {}),
...(opts.vehicleType ? { vehicleType: opts.vehicleType } : {}),
...(opts.mineVehicles ? { mineVehicles: true } : {}),
page: 1,
pageSize: 100
}
+47 -1
View File
@@ -20,7 +20,13 @@ function unwrapList(data) {
function unwrapEntity(data) {
if (!data || typeof data !== 'object') return data
if (data.data && typeof data.data === 'object' && data.id == null && data.registerNo == null) {
if (
data.data &&
typeof data.data === 'object' &&
data.id == null &&
data.registerNo == null &&
data.applyNo == null
) {
return data.data
}
return data
@@ -81,6 +87,46 @@ export const sitePlanApi = {
db.post(`/transport/site-plan/${planId}/feedback`, payload)
}
export const vehicleRechargeApplyApi = {
list: async (params = {}) => {
const data = await db.get('/transport/vehicle-recharge-apply/', {
mineVehicles: true,
...params
})
return unwrapList(data)
},
get: async (id) =>
unwrapEntity(
await db.get(`/transport/vehicle-recharge-apply/${id}`, {
mineVehicles: true
})
),
create: async (payload) =>
unwrapEntity(
await db.post('/transport/vehicle-recharge-apply/', payload, {
params: { mineVehicles: true }
})
),
update: async (id, payload) =>
unwrapEntity(
await db.put(`/transport/vehicle-recharge-apply/${id}`, payload, {
params: { mineVehicles: true }
})
),
submit: (id) =>
db.post(
`/transport/vehicle-recharge-apply/${id}/submit`,
{},
{ params: { mineVehicles: true } }
),
cancel: (id) =>
db.post(
`/transport/vehicle-recharge-apply/${id}/cancel`,
{},
{ params: { mineVehicles: true } }
)
}
export const driverPlanApi = {
history: async (params = {}) => {
const data = unwrapData(await db.get('/transport/driver-plan/history', params))
+3
View File
@@ -85,6 +85,8 @@ export const PAGE_PATHS = {
bxWfReimburse: '/pages/bx/admin/wf-reimburse',
plFuelRegister: '/pages/pl/fuel-register/list',
plFuelRegisterForm: '/pages/pl/fuel-register/form',
plVehicleRechargeApply: '/pages/pl/vehicle-recharge-apply/list',
plVehicleRechargeApplyForm: '/pages/pl/vehicle-recharge-apply/form',
plTransportPlan: '/pages/pl/transport-plan/list',
plTransportPlanDetail: '/pages/pl/transport-plan/detail',
plDriverPlan: '/pages/pl/driver-plan/list',
@@ -108,6 +110,7 @@ export const WORKBENCH_ROUTE_MAP = {
'bx-wf-advance': PAGE_PATHS.bxWfAdvance,
'bx-wf-reimburse': PAGE_PATHS.bxWfReimburse,
'pl-fuel-register': PAGE_PATHS.plFuelRegister,
'pl-vehicle-recharge-apply': PAGE_PATHS.plVehicleRechargeApply,
'pl-transport-plan': PAGE_PATHS.plTransportPlan,
'pl-driver-plan': PAGE_PATHS.plDriverPlan,
'pl-plan-webview': PAGE_PATHS.mpWebview + '?path=' + encodeURIComponent('/m/pl/plan')
+8 -1
View File
@@ -56,7 +56,7 @@ export const WORKBENCH_MODULES = [
{
key: 'pl',
title: '运输管理',
desc: '加油登记 · 现场运输计划单 · 运输计划 · 司机运输计划单',
desc: '加油登记 · ETC充值申请 · 现场运输计划单 · 运输计划 · 司机运输计划单',
iconText: '运输',
visible: true,
menus: [
@@ -67,6 +67,13 @@ export const WORKBENCH_MODULES = [
icon: '⛽',
perms: 'mini:pl:fuelRegister:list'
},
{
name: 'ETC加油充值申请',
enabled: true,
code: 'pl-vehicle-recharge-apply',
icon: '💳',
perms: 'mini:pl:vehicleRechargeApply:list'
},
{
name: '现场运输计划单',
enabled: true,
+8
View File
@@ -203,6 +203,14 @@
"path": "fuel-register/form",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "加油登记" }
},
{
"path": "vehicle-recharge-apply/list",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "ETC加油充值申请" }
},
{
"path": "vehicle-recharge-apply/form",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "ETC加油充值申请" }
},
{
"path": "transport-plan/list",
"style": { "navigationStyle": "custom", "navigationBarTitleText": "现场运输计划单" }
@@ -0,0 +1,686 @@
<template>
<view class="bx-page bx-page--form">
<view class="topbar" :style="safeAreaStyle">
<view class="topbar-left">
<text class="topbar-back" @click="goBack">←</text>
<text class="topbar-title">{{ pageTitle }}</text>
</view>
<view class="topbar-right" />
</view>
<view class="page-padded">
<view class="form-card">
<view class="form-header">
<text class="form-header-title">申请信息</text>
<text class="form-badge">{{ formBadge }}</text>
</view>
<view v-if="form.applyNo" class="field">
<text class="field-label">申请单号</text>
<input class="form-input" :value="form.applyNo" disabled />
</view>
<view class="field">
<text class="field-label">申请人</text>
<input class="form-input" :value="applicantDisplay" disabled />
</view>
<view class="field">
<text class="field-label">车牌号<text class="req">*</text></text>
<view
class="picker-trigger"
:class="{ error: fieldErr.licensePlate }"
@click="openVehiclePicker"
>
<text class="value" :class="{ ph: !form.licensePlate }">
{{ form.licensePlate || '请选择本人车辆' }}
</text>
<text class="arrow">›</text>
</view>
</view>
<view class="field">
<text class="field-label">充值类型<text class="req">*</text></text>
<view class="fuel-seg">
<view
v-for="opt in typeOptions"
:key="opt.value"
class="fuel-seg__item"
:class="{
'fuel-seg__item--active': form.rechargeType === opt.value,
'fuel-seg__item--disabled': readonly
}"
@click="onPickType(opt.value)"
>
{{ opt.label }}
</view>
</view>
</view>
<view class="field">
<text class="field-label">申请日期<text class="req">*</text></text>
<picker
mode="date"
:value="form.applyDate"
:disabled="readonly"
@change="onDateChange"
>
<view class="picker-trigger" :class="{ error: fieldErr.applyDate }">
<text class="value" :class="{ ph: !form.applyDate }">
{{ form.applyDate || '请选择日期' }}
</text>
<text class="arrow">›</text>
</view>
</picker>
</view>
<view class="field">
<text class="field-label">期初公里数/KM</text>
<input
class="form-input"
type="digit"
:value="form.odometerKm"
:disabled="readonly"
placeholder="选填"
@input="onOdometerInput"
/>
</view>
<view class="field">
<text class="field-label">备注</text>
<textarea
class="form-textarea"
:value="form.remark"
:disabled="readonly"
placeholder="选填"
:maxlength="500"
@input="onRemarkInput"
/>
</view>
</view>
<view class="form-card">
<view class="form-header">
<text class="form-header-title">附件</text>
</view>
<button
v-if="!readonly"
class="btn btn-primary btn-sm"
:disabled="uploading"
@click="chooseAttachments"
>
{{ uploading ? '上传中…' : '上传图片' }}
</button>
<view v-if="imageUrls.length" class="att-grid">
<view v-for="(url, idx) in imageUrls" :key="url + idx" class="att-img" @click="previewAt(idx)">
<image :src="resolveUrl(url)" mode="aspectFill" />
<view v-if="!readonly" class="del" @click.stop="removeImage(idx)">×</view>
</view>
</view>
<view v-else class="detail-section-empty" style="margin-top: 8px">
{{ readonly ? '暂无附件' : '暂无附件(可选,支持相册/拍照)' }}
</view>
</view>
<view v-if="editId" class="form-card">
<view class="form-header">
<text class="form-header-title">审批进度</text>
</view>
<view v-if="!approvalNodes.length" class="detail-section-empty">暂无审批信息</view>
<view v-for="(node, idx) in approvalNodes" :key="node.id || idx" class="list-card-kv" style="margin-bottom: 10px">
<view class="list-card-kv__row">
<text class="list-card-kv__k">节点</text>
<text class="list-card-kv__v">{{ node.nodeName || '—' }}</text>
</view>
<view class="list-card-kv__row">
<text class="list-card-kv__k">状态</text>
<text class="list-card-kv__v">{{ approvalStatusLabel(node.approvalStatus) }}</text>
</view>
<view class="list-card-kv__row">
<text class="list-card-kv__k">审批人</text>
<text class="list-card-kv__v">{{ node.approverName || '—' }}</text>
</view>
<view v-if="node.approvalOpinion" class="list-card-kv__row">
<text class="list-card-kv__k">意见</text>
<text class="list-card-kv__v">{{ node.approvalOpinion }}</text>
</view>
<view v-if="node.approvalTime" class="list-card-kv__row">
<text class="list-card-kv__k">时间</text>
<text class="list-card-kv__v">{{ formatTime(node.approvalTime) }}</text>
</view>
</view>
</view>
<view v-if="editId" class="form-card">
<view class="form-header">
<text class="form-header-title">发放信息</text>
</view>
<view v-if="!record" class="detail-section-empty">暂无</view>
<view v-else class="list-card-kv">
<view class="list-card-kv__row">
<text class="list-card-kv__k">充值订单号</text>
<text class="list-card-kv__v">{{ record.rechargeOrderNo || '—' }}</text>
</view>
<view class="list-card-kv__row">
<text class="list-card-kv__k">充值日期</text>
<text class="list-card-kv__v">{{ record.rechargeDate || '—' }}</text>
</view>
<view class="list-card-kv__row">
<text class="list-card-kv__k">金额</text>
<text class="list-card-kv__v">
{{ record.amount != null ? `${Number(record.amount).toFixed(2)} 元` : '—' }}
</text>
</view>
<view class="list-card-kv__row">
<text class="list-card-kv__k">卡号</text>
<text class="list-card-kv__v">{{ record.cardNo || '—' }}</text>
</view>
<view class="list-card-kv__row">
<text class="list-card-kv__k">加油站/渠道</text>
<text class="list-card-kv__v">{{ record.gasStation || '—' }}</text>
</view>
<view class="list-card-kv__row">
<text class="list-card-kv__k">发放人</text>
<text class="list-card-kv__v">{{ record.createByName || '—' }}</text>
</view>
<view v-if="record.remark" class="list-card-kv__row">
<text class="list-card-kv__k">备注</text>
<text class="list-card-kv__v">{{ record.remark }}</text>
</view>
</view>
<view v-if="recordImageUrls.length" class="att-grid" style="margin-top: 8px">
<view
v-for="(url, idx) in recordImageUrls"
:key="'r' + url + idx"
class="att-img"
@click="previewRecordAt(idx)"
>
<image :src="resolveUrl(url)" mode="aspectFill" />
</view>
</view>
</view>
</view>
<view v-if="!readonly" class="bottom-action-bar">
<button
class="btn btn-ghost"
style="flex: 1"
:disabled="submitting || uploading"
@click="onSave"
>
{{ submitting ? '保存中…' : '保存' }}
</button>
<button
class="btn btn-primary"
style="flex: 1"
:disabled="submitting || uploading"
@click="onSaveAndSubmit"
>
保存并提交
</button>
</view>
<view v-else-if="canCancel" class="bottom-action-bar">
<button class="btn btn-danger btn-block" :disabled="submitting" @click="onCancel">作废</button>
</view>
<view v-if="showVehiclePicker" class="bx-sheet-mask" @click="closeVehiclePicker">
<view class="bx-sheet bx-sheet--tall" @click.stop>
<view class="bx-sheet__title">选择车牌</view>
<view class="bx-sheet__search">
<input
class="bx-sheet__search-input"
:value="vehicleKeyword"
placeholder="搜索车牌"
confirm-type="search"
@input="onVehicleKeywordInput"
@confirm="searchVehiclesNow"
/>
<view
v-if="vehicleKeyword.trim()"
class="bx-sheet__search-clear"
@click="clearVehicleKeyword"
>
×
</view>
</view>
<scroll-view scroll-y class="bx-sheet__list">
<view v-if="vehicleLoading" class="bx-sheet__empty">搜索中…</view>
<template v-else>
<view v-if="!vehicles.length" class="bx-sheet__empty">
{{ vehicleKeyword.trim() ? '无匹配车牌' : '暂无可用车辆' }}
</view>
<view
v-for="item in vehicles"
:key="vehicleRowKey(item)"
class="bx-sheet__item"
:class="{ 'is-selected': vehiclePlate(item) === form.licensePlate }"
@click="confirmVehiclePick(item)"
>
<view>
<text>{{ vehiclePlate(item) }}</text>
<view v-if="vehicleTypeTag(item)" class="bx-sheet__item-sub">
{{ vehicleTypeTag(item) }}
</view>
</view>
<text v-if="vehiclePlate(item) === form.licensePlate">✓</text>
</view>
</template>
</scroll-view>
<view class="bx-sheet__footer">
<button class="btn btn-ghost btn-block" @click="closeVehiclePicker">取消</button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { PAGE_PATHS } from '@/config/api'
import { resolveMinioUrl } from '@/config/env'
import { vehicleRechargeApplyApi, fetchVehicleOptions } from '@/api/pl'
import { chooseAndUploadImages, previewImages } from '@/utils/upload'
import { useUserStore } from '@/stores/user'
import { getSafeAreaStyle } from '@/utils/safeArea'
const safeAreaStyle = getSafeAreaStyle()
const userStore = useUserStore()
const MAX_ATTACHMENTS = 5
const typeOptions = [
{ label: 'ETC', value: 'etc' },
{ label: '加油', value: 'fuel' }
]
const editId = ref('')
const readonly = ref(false)
const submitting = ref(false)
const uploading = ref(false)
const imageUrls = ref([])
const detail = ref(null)
const vehicles = ref([])
const showVehiclePicker = ref(false)
const vehicleKeyword = ref('')
const vehicleLoading = ref(false)
let vehicleSearchTimer = null
let vehicleSearchSeq = 0
const form = reactive({
applyNo: '',
applicantId: '',
licensePlate: '',
rechargeType: 'etc',
applyDate: '',
odometerKm: '',
remark: ''
})
const fieldErr = reactive({
applyDate: false,
licensePlate: false
})
const isEdit = computed(() => !!editId.value)
const pageTitle = computed(() => {
if (readonly.value) return '充值申请详情'
return isEdit.value ? '编辑充值申请' : '新增充值申请'
})
const formBadge = computed(() => {
if (readonly.value) return '只读'
return isEdit.value ? '编辑' : '新建'
})
const applicantDisplay = computed(() => {
if (detail.value?.applicantName) return detail.value.applicantName
const info = userStore.userInfo || {}
return info.realName || info.nickName || info.username || '当前登录人'
})
const currentUserId = computed(() => {
const info = userStore.userInfo || {}
return String(info.id || info.userId || '')
})
const isOwn = computed(() => {
if (!editId.value) return true
return String(detail.value?.applicantId || '') === currentUserId.value
})
const canCancel = computed(
() =>
isOwn.value &&
editId.value &&
['draft', 'rejected'].includes(detail.value?.status)
)
const approvalNodes = computed(() => {
const d = detail.value
if (!d) return []
const flow = d.approvalFlow || d.approval_flow || []
const history = d.approvalHistory || d.approval_history || []
return [...flow, ...history]
})
const record = computed(() => detail.value?.record || null)
const recordImageUrls = computed(() => {
const urls = record.value?.attachmentUrls || record.value?.attachment_urls || ''
return String(urls)
.split(',')
.map((s) => s.trim())
.filter(Boolean)
})
function vehiclePlate(v) {
return String(v?.licensePlate || v?.license_plate || '').trim()
}
function vehicleTypeTag(v) {
const type = v?.vehicleType || v?.vehicle_type
if (type === 'self') return '自有'
if (type === 'carrier') return '三方'
return ''
}
function vehicleRowKey(v) {
return String(v?.id || vehiclePlate(v) || '')
}
function todayStr() {
const d = new Date()
const m = `${d.getMonth() + 1}`.padStart(2, '0')
const day = `${d.getDate()}`.padStart(2, '0')
return `${d.getFullYear()}-${m}-${day}`
}
function resolveUrl(url) {
return resolveMinioUrl(url)
}
function formatTime(v) {
if (!v) return '—'
return String(v).replace('T', ' ').slice(0, 19)
}
function approvalStatusLabel(v) {
const map = {
approved: '通过',
rejected: '驳回',
disbursed: '已发放',
pending: '待审批'
}
return map[v] || v || '—'
}
function goBack() {
uni.navigateBack({
fail: () => uni.redirectTo({ url: PAGE_PATHS.plVehicleRechargeApply })
})
}
function onPickType(value) {
if (readonly.value) return
form.rechargeType = value
}
function onDateChange(e) {
form.applyDate = e.detail.value
fieldErr.applyDate = false
}
function onOdometerInput(e) {
form.odometerKm = e.detail.value
}
function onRemarkInput(e) {
form.remark = e.detail.value
}
async function loadVehicles(keyword = '') {
const seq = ++vehicleSearchSeq
vehicleLoading.value = true
try {
const list = await fetchVehicleOptions(keyword, { mineVehicles: true })
if (seq !== vehicleSearchSeq) return
vehicles.value = Array.isArray(list) ? list : []
} catch (e) {
if (seq !== vehicleSearchSeq) return
vehicles.value = []
} finally {
if (seq === vehicleSearchSeq) vehicleLoading.value = false
}
}
function openVehiclePicker() {
if (readonly.value) return
showVehiclePicker.value = true
vehicleKeyword.value = ''
loadVehicles('')
}
function closeVehiclePicker() {
showVehiclePicker.value = false
if (vehicleSearchTimer) {
clearTimeout(vehicleSearchTimer)
vehicleSearchTimer = null
}
}
function onVehicleKeywordInput(e) {
vehicleKeyword.value = e.detail.value || ''
if (vehicleSearchTimer) clearTimeout(vehicleSearchTimer)
vehicleSearchTimer = setTimeout(() => {
loadVehicles(vehicleKeyword.value)
}, 300)
}
function searchVehiclesNow() {
if (vehicleSearchTimer) {
clearTimeout(vehicleSearchTimer)
vehicleSearchTimer = null
}
loadVehicles(vehicleKeyword.value)
}
function clearVehicleKeyword() {
vehicleKeyword.value = ''
if (vehicleSearchTimer) {
clearTimeout(vehicleSearchTimer)
vehicleSearchTimer = null
}
loadVehicles('')
}
function confirmVehiclePick(item) {
const plate = vehiclePlate(item)
if (!plate) return
form.licensePlate = plate
fieldErr.licensePlate = false
closeVehiclePicker()
}
async function chooseAttachments() {
uploading.value = true
try {
const urls = await chooseAndUploadImages({
folder: 'pl/vehicle-recharge/apply',
maxCount: MAX_ATTACHMENTS,
currentUrls: imageUrls.value
})
if (urls.length) imageUrls.value = urls
} finally {
uploading.value = false
}
}
function removeImage(idx) {
imageUrls.value = imageUrls.value.filter((_, i) => i !== idx)
}
function previewAt(idx) {
previewImages(
imageUrls.value.map((u) => resolveUrl(u)),
idx
)
}
function previewRecordAt(idx) {
previewImages(
recordImageUrls.value.map((u) => resolveUrl(u)),
idx
)
}
function validate() {
fieldErr.applyDate = !form.applyDate
fieldErr.licensePlate = !form.licensePlate
if (fieldErr.applyDate || fieldErr.licensePlate) {
uni.showToast({ title: '请完善必填项', icon: 'none' })
return false
}
if (!form.rechargeType) {
uni.showToast({ title: '请选择充值类型', icon: 'none' })
return false
}
return true
}
function buildPayload() {
const odo = form.odometerKm === '' || form.odometerKm == null ? null : Number(form.odometerKm)
return {
licensePlate: form.licensePlate,
rechargeType: form.rechargeType,
applyDate: form.applyDate,
odometerKm: Number.isNaN(odo) ? null : odo,
attachmentUrls: imageUrls.value.join(',') || null,
remark: form.remark || null,
applicantId: currentUserId.value || null
}
}
function fillFromDetail(data) {
detail.value = data
form.applyNo = data.applyNo || ''
form.applicantId = data.applicantId || ''
form.licensePlate = data.licensePlate || ''
form.rechargeType = data.rechargeType || 'etc'
form.applyDate = data.applyDate || ''
form.odometerKm =
data.odometerKm != null && data.odometerKm !== '' ? String(data.odometerKm) : ''
form.remark = data.remark || ''
imageUrls.value = String(data.attachmentUrls || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
const own = String(data.applicantId || '') === currentUserId.value
const editable = ['draft', 'rejected'].includes(data.status)
readonly.value = !(own && editable)
}
async function onSave() {
if (!validate() || submitting.value) return
submitting.value = true
try {
const payload = buildPayload()
let saved
if (editId.value) {
saved = await vehicleRechargeApplyApi.update(editId.value, payload)
} else {
saved = await vehicleRechargeApplyApi.create(payload)
editId.value = saved.id
}
fillFromDetail(saved)
uni.showToast({ title: '已保存', icon: 'success' })
} catch (e) {
// toasted
} finally {
submitting.value = false
}
}
async function onSaveAndSubmit() {
if (!validate() || submitting.value) return
submitting.value = true
try {
const payload = buildPayload()
let saved
if (editId.value) {
saved = await vehicleRechargeApplyApi.update(editId.value, payload)
} else {
saved = await vehicleRechargeApplyApi.create(payload)
editId.value = saved.id
}
await vehicleRechargeApplyApi.submit(editId.value)
const latest = await vehicleRechargeApplyApi.get(editId.value)
fillFromDetail(latest)
uni.showToast({ title: '已提交', icon: 'success' })
} catch (e) {
// toasted
} finally {
submitting.value = false
}
}
function onCancel() {
uni.showModal({
title: '作废确认',
content: '确定作废该充值申请吗?',
confirmText: '作废',
confirmColor: '#ef4444',
success: async (res) => {
if (!res.confirm) return
submitting.value = true
try {
await vehicleRechargeApplyApi.cancel(editId.value)
uni.showToast({ title: '已作废', icon: 'success' })
setTimeout(goBack, 500)
} catch (e) {
// toasted
} finally {
submitting.value = false
}
}
})
}
async function initNew() {
const info = userStore.userInfo || {}
form.applicantId = String(info.id || info.userId || '')
form.applyDate = todayStr()
form.rechargeType = 'etc'
readonly.value = false
await loadVehicles('')
if (vehicles.value.length === 1) {
form.licensePlate = vehiclePlate(vehicles.value[0])
} else if (!vehicles.value.length) {
uni.showToast({
title: '暂无可用车辆,请联系管理员绑定',
icon: 'none',
duration: 2500
})
}
}
async function loadDetail(id) {
const data = await vehicleRechargeApplyApi.get(id)
fillFromDetail(data)
}
onLoad(async (query) => {
const id = query?.id || ''
if (id) {
editId.value = id
try {
await loadDetail(id)
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
} else {
await initNew()
}
})
</script>
<style lang="scss">
@import '@/styles/bx-theme.scss';
</style>
@@ -0,0 +1,307 @@
<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">ETC加油充值申请</text>
</view>
<view class="topbar-right" />
</view>
<view class="fab-add" @click="goForm()">
<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="goForm(item.id)"
>
<view class="ec-top">
<view class="ec-type">
<text class="advance-list-card__title">{{ item.licensePlate || '—' }}</text>
</view>
<text class="status-tag" :class="statusTagClass(item.status)">
{{ statusLabel(item.status) }}
</text>
</view>
<view class="ec-amount-label">充值类型</view>
<view class="ec-amount">{{ typeLabel(item.rechargeType) }}</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.applyNo || '—' }}</text>
</view>
<view class="list-card-kv__row">
<text class="list-card-kv__k">申请日期</text>
<text class="list-card-kv__v">{{ item.applyDate || '—' }}</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 class="list-card-kv__row">
<text class="list-card-kv__k">期初公里数</text>
<text class="list-card-kv__v">{{ item.odometerKm != null ? item.odometerKm : '—' }}</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-bottom" @click.stop>
<view class="op-actions">
<template v-if="canWrite(item)">
<button class="btn btn-sm btn-ghost" @click="handleSubmit(item)">提交</button>
<button class="btn btn-sm btn-primary" @click="goForm(item.id)">编辑</button>
<button class="btn btn-sm btn-danger" @click="handleCancel(item)">作废</button>
</template>
<button v-else class="btn btn-sm btn-primary" @click="goForm(item.id)">查看</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 { vehicleRechargeApplyApi } from '@/api/pl'
import { useUserStore } from '@/stores/user'
import { getSafeAreaStyle } from '@/utils/safeArea'
const safeAreaStyle = getSafeAreaStyle()
const userStore = useUserStore()
const tabs = [
{ label: '全部', value: '' },
{ label: '草稿', value: 'draft' },
{ label: '审批中', value: 'in_approval' },
{ label: '已驳回', value: 'rejected' },
{ label: '已发放', value: 'disbursed' },
{ label: '已作废', value: 'cancelled' }
]
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')
function currentUserId() {
const info = userStore.userInfo || {}
return String(info.id || info.userId || '')
}
function canWrite(item) {
if (!item) return false
if (!['draft', 'rejected'].includes(item.status)) return false
return String(item.applicantId || '') === currentUserId()
}
function typeLabel(v) {
if (v === 'etc') return 'ETC'
if (v === 'fuel') return '加油'
return v || '—'
}
function statusLabel(v) {
const map = {
draft: '草稿',
in_approval: '审批中',
rejected: '已驳回',
disbursed: '已发放',
cancelled: '已作废',
pending: '待发放'
}
return map[v] || v || '—'
}
function statusTagClass(v) {
if (v === 'draft') return 's-draft'
if (v === 'in_approval' || v === 'pending') return 's-pending'
if (v === 'rejected') return 's-rejected'
if (v === 'disbursed') return 's-approved'
if (v === 'cancelled') return 's-withdrawn'
return 's-draft'
}
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'
}
}
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.q = keyword.value.trim()
const { items, total } = await vehicleRechargeApplyApi.list(params)
if (currentPage.value === 1) {
list.value = items
} else {
list.value = list.value.concat(items)
}
currentPage.value += 1
finished.value = items.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 goForm(id) {
const q = id ? `?id=${id}` : ''
uni.navigateTo({ url: `${PAGE_PATHS.plVehicleRechargeApplyForm}${q}` })
}
function handleSubmit(item) {
uni.showModal({
title: '提交确认',
content: '确定提交该充值申请进入审批吗?',
confirmText: '提交',
success: async (res) => {
if (!res.confirm) return
try {
await vehicleRechargeApplyApi.submit(item.id)
uni.showToast({ title: '已提交', icon: 'success' })
onRefresh()
} catch (e) {
// toasted
}
}
})
}
function handleCancel(item) {
uni.showModal({
title: '作废确认',
content: '确定作废该充值申请吗?',
confirmText: '作废',
confirmColor: '#ef4444',
success: async (res) => {
if (!res.confirm) return
try {
await vehicleRechargeApplyApi.cancel(item.id)
uni.showToast({ title: '已作废', icon: 'success' })
onRefresh()
} catch (e) {
// toasted
}
}
})
}
onShow(() => {
computeListHeight()
onRefresh()
})
</script>
<style lang="scss">
@import '@/styles/bx-theme.scss';
.list-scroll {
box-sizing: border-box;
}
</style>