Просмотр исходного кода

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>
zlwu 1 месяц назад
Родитель
Сommit
c70b17f8ca

+ 89 - 0
docs/sql/migrations/2026-09-27-mini-pl-vehicle-recharge-apply-menus.sql

@@ -0,0 +1,89 @@
+-- 小程序:运输管理 → ETC加油充值申请(角色「司机」「押运员」)
+-- 可重复执行(NOT EXISTS 防重)
+SET NAMES utf8mb4;
+
+-- 测试库补角色(生产已有则跳过)
+INSERT INTO `sys_role` (
+  `id`, `role_name`, `data_scope`, `status`, `tenant_no`, `order_num`, `remark`,
+  `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
+)
+SELECT
+  'MINI_ROLE_DRIVER', '司机', '1', '1', 'shanxia2.0', 60, '小程序司机端',
+  'system', '', NOW(), NULL, NOW(), 0
+FROM DUAL
+WHERE NOT EXISTS (
+  SELECT 1 FROM `sys_role` WHERE `role_name` = '司机' AND `del_flag` = 0
+);
+
+INSERT INTO `sys_role` (
+  `id`, `role_name`, `data_scope`, `status`, `tenant_no`, `order_num`, `remark`,
+  `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
+)
+SELECT
+  'MINI_ROLE_ESCORT', '押运员', '1', '1', 'shanxia2.0', 61, '小程序押运端',
+  'system', '', NOW(), NULL, NOW(), 0
+FROM DUAL
+WHERE NOT EXISTS (
+  SELECT 1 FROM `sys_role` WHERE `role_name` = '押运员' AND `del_flag` = 0
+);
+
+INSERT INTO `sys_menu` (
+  `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
+  `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
+  `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
+)
+SELECT
+  'MINI_PL_ROOT', '运输管理', '1', 'M', '/mini/pl', NULL, NULL,
+  1, '1', '1', 'shanxia2.0', 4, '小程序运输管理',
+  'system', '', NOW(), NULL, NOW(), 0
+FROM DUAL
+WHERE NOT EXISTS (
+  SELECT 1 FROM `sys_menu` WHERE `id` = 'MINI_PL_ROOT' AND `del_flag` = 0
+);
+
+INSERT INTO `sys_menu` (
+  `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
+  `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
+  `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
+)
+SELECT
+  'MINI_PL_VEHICLE_RECHARGE_APPLY', 'ETC加油充值申请', 'MINI_PL_ROOT', 'C',
+  'vehicle-recharge-apply', NULL,
+  'mini:pl:vehicleRechargeApply:list',
+  1, '1', '1', 'shanxia2.0', 2, '小程序司机/押运 ETC加油充值申请',
+  'system', '', NOW(), NULL, NOW(), 0
+FROM DUAL
+WHERE NOT EXISTS (
+  SELECT 1 FROM `sys_menu` WHERE `id` = 'MINI_PL_VEHICLE_RECHARGE_APPLY' AND `del_flag` = 0
+);
+
+INSERT INTO `sys_role_menu` (
+  `id`, `role_id`, `menu_id`, `tenant_no`, `order_num`, `remark`,
+  `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
+)
+SELECT
+  MD5(CONCAT(r.id, '|', m.menu_id, '|mini-pl-veh-recharge-apply|v1')),
+  r.id,
+  m.menu_id,
+  IFNULL(NULLIF(r.tenant_no, ''), 'shanxia2.0'),
+  0,
+  '小程序 ETC加油充值申请',
+  'system',
+  '',
+  NOW(),
+  NULL,
+  NOW(),
+  0
+FROM `sys_role` r
+JOIN (
+  SELECT '1' AS menu_id UNION ALL
+  SELECT 'MINI_PL_ROOT' UNION ALL
+  SELECT 'MINI_PL_VEHICLE_RECHARGE_APPLY'
+) m
+WHERE r.del_flag = 0
+  AND r.role_name IN ('司机', '押运员')
+  AND EXISTS (SELECT 1 FROM `sys_menu` sm WHERE sm.id = m.menu_id AND sm.del_flag = 0)
+  AND NOT EXISTS (
+    SELECT 1 FROM `sys_role_menu` x
+    WHERE x.role_id = r.id AND x.menu_id = m.menu_id AND x.del_flag = 0
+  );

+ 1 - 0
miniprogram/api/dbOptions.js

@@ -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
miniprogram/api/pl.js

@@ -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 - 0
miniprogram/config/api.js

@@ -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
miniprogram/config/workbench-menus.js

@@ -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 - 0
miniprogram/pages.json

@@ -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": "现场运输计划单" }

+ 686 - 0
miniprogram/pages/pl/vehicle-recharge-apply/form.vue

@@ -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>

+ 307 - 0
miniprogram/pages/pl/vehicle-recharge-apply/list.vue

@@ -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>