RiderWithdrawAudit.vue 14 KB
<template>
  <div>
    <a-card title="骑手提现审核" :bordered="false" class="list-table-card">
      <div class="soft-page-stack withdraw-audit-stack">
        <div class="soft-note-card">
          <strong>审核说明</strong>
          <p>骑手申请提现时会先冻结可用余额;审核拒绝后解冻,审核通过后进入待打款,标记已打款后扣减冻结金额并写入提现流水。</p>
        </div>

        <div class="list-toolbar">
          <div class="list-toolbar-left">
            <a-select
              v-if="isAdmin"
              v-model:value="filterCityId"
              placeholder="租户"
              allowClear
              class="list-filter"
              @change="handleFilterChange"
            >
              <a-select-option v-for="item in cityList" :key="item.id" :value="item.id">{{ item.name }}</a-select-option>
            </a-select>
            <a-select v-model:value="filterStatus" placeholder="提现状态" allowClear class="list-filter" @change="handleFilterChange">
              <a-select-option :value="0">待审核</a-select-option>
              <a-select-option :value="1">待打款</a-select-option>
              <a-select-option :value="2">已拒绝</a-select-option>
              <a-select-option :value="3">已打款</a-select-option>
              <a-select-option :value="4">打款失败</a-select-option>
            </a-select>
            <a-date-picker v-model:value="startDate" value-format="YYYY-MM-DD" placeholder="开始日期" class="list-filter" @change="handleFilterChange" />
            <a-date-picker v-model:value="endDate" value-format="YYYY-MM-DD" placeholder="结束日期" class="list-filter" @change="handleFilterChange" />
            <a-input-search v-model:value="keyword" placeholder="搜索提现单号/骑手姓名/手机号" class="list-search" @search="handleKeywordSearch" />
          </div>
        </div>

        <a-table
          class="withdraw-table"
          :dataSource="list"
          :columns="columns"
          :loading="loading"
          rowKey="id"
          :pagination="pagination"
          sticky
          :scroll="{ x: tableScrollX, y: 560 }"
          @change="handleTableChange"
        >
          <template #bodyCell="{ column, record }">
            <template v-if="column.key === 'cityName'">
              {{ record.cityName || getCityName(record.cityId) }}
            </template>
            <template v-else-if="column.key === 'amount' || column.key === 'riderBalance' || column.key === 'riderFrozenBalance'">
              ¥{{ formatMoney(record[column.key]) }}
            </template>
            <template v-else-if="column.key === 'status'">
              <a-tag :color="statusColor[record.status] || 'default'">{{ statusMap[record.status] || `状态${record.status}` }}</a-tag>
            </template>
            <template v-else-if="column.key === 'accountType'">
              {{ accountTypeMap[record.accountType] || '-' }}
            </template>
            <template v-else-if="column.key === 'accountNo'">
              {{ maskAccountNo(record.accountNo) }}
            </template>
            <template v-else-if="column.key === 'applyTime' || column.key === 'auditTime' || column.key === 'payTime'">
              {{ formatTime(record[column.key]) }}
            </template>
            <template v-else-if="column.key === 'action'">
              <a-space>
                <a @click="openDetail(record)">详情</a>
                <template v-if="record.status === 0">
                  <a @click="openAction('approve', record)" style="color: green">通过</a>
                  <a @click="openAction('reject', record)" style="color: red">拒绝</a>
                </template>
                <template v-else-if="record.status === 1">
                  <a @click="openAction('paid', record)">标记已打款</a>
                </template>
              </a-space>
            </template>
          </template>
        </a-table>
      </div>
    </a-card>

    <a-modal v-model:open="detailVisible" title="提现申请详情" :footer="null" width="860px">
      <a-descriptions v-if="detail" bordered :column="2" size="small">
        <a-descriptions-item label="提现单号">{{ detail.withdrawNo }}</a-descriptions-item>
        <a-descriptions-item label="状态">
          <a-tag :color="statusColor[detail.status] || 'default'">{{ statusMap[detail.status] || `状态${detail.status}` }}</a-tag>
        </a-descriptions-item>
        <a-descriptions-item label="骑手">{{ detail.riderName || '-' }} / {{ detail.mobile || '-' }}</a-descriptions-item>
        <a-descriptions-item label="租户">{{ detail.cityName || getCityName(detail.cityId) }}</a-descriptions-item>
        <a-descriptions-item label="可用余额">¥{{ formatMoney(detail.riderBalance) }}</a-descriptions-item>
        <a-descriptions-item label="冻结余额">¥{{ formatMoney(detail.riderFrozenBalance) }}</a-descriptions-item>
        <a-descriptions-item label="提现金额">¥{{ formatMoney(detail.amount) }}</a-descriptions-item>
        <a-descriptions-item label="申请时间">{{ formatTime(detail.applyTime) }}</a-descriptions-item>
        <a-descriptions-item label="账户类型">{{ accountTypeMap[detail.accountType] || '-' }}</a-descriptions-item>
        <a-descriptions-item label="收款人">{{ detail.accountName || '-' }}</a-descriptions-item>
        <a-descriptions-item label="开户行">{{ detail.bankName || '-' }}</a-descriptions-item>
        <a-descriptions-item label="开户支行">{{ detail.bankBranch || '-' }}</a-descriptions-item>
        <a-descriptions-item label="收款账号" :span="2">{{ maskAccountNo(detail.accountNo) }}</a-descriptions-item>
        <a-descriptions-item label="申请备注" :span="2">{{ detail.applyRemark || '-' }}</a-descriptions-item>
        <a-descriptions-item label="审核人">{{ detail.auditorName || '-' }}</a-descriptions-item>
        <a-descriptions-item label="审核时间">{{ formatTime(detail.auditTime) }}</a-descriptions-item>
        <a-descriptions-item label="审核/打款备注" :span="2">{{ detail.auditRemark || '-' }}</a-descriptions-item>
        <a-descriptions-item label="打款流水号">{{ detail.transferNo || '-' }}</a-descriptions-item>
        <a-descriptions-item label="打款时间">{{ formatTime(detail.payTime) }}</a-descriptions-item>
      </a-descriptions>
    </a-modal>

    <a-modal v-model:open="actionVisible" :title="actionTitle" @ok="handleAction" :confirmLoading="actionSaving">
      <div class="soft-page-stack">
        <div class="soft-note-card">
          <strong>{{ actionTitle }}</strong>
          <p>{{ actionTip }}</p>
        </div>
        <a-form layout="vertical">
          <a-form-item v-if="actionType === 'paid'" label="打款流水号" required>
            <a-input v-model:value="actionForm.transferNo" placeholder="请输入线下打款流水号" />
          </a-form-item>
          <a-form-item :label="actionType === 'reject' ? '拒绝原因' : '备注'" :required="actionType === 'reject'">
            <a-input v-model:value="actionForm.remark" :placeholder="actionType === 'reject' ? '请输入拒绝原因' : '请输入备注(选填)'" />
          </a-form-item>
        </a-form>
      </div>
    </a-modal>
  </div>
</template>

<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import type { TablePaginationConfig } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import { riderWithdrawApi } from '@/api'
import { useRoleCityList } from '@/composables/useRoleCityList'

const PAGE_SIZE = 20
type ActionType = 'approve' | 'reject' | 'paid'

const loading = ref(false)
const actionSaving = ref(false)
const list = ref<any[]>([])
const detail = ref<any | null>(null)
const detailVisible = ref(false)
const actionVisible = ref(false)
const actionType = ref<ActionType>('approve')
const actionTargetId = ref<number>(0)
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(PAGE_SIZE)
const filterCityId = ref<number | undefined>()
const filterStatus = ref<number | undefined>()
const startDate = ref<string | undefined>()
const endDate = ref<string | undefined>()
const keyword = ref('')
const actionForm = reactive({ remark: '', transferNo: '' })
const { isAdmin, managedCityId, cityList, loadCities, getCityName } = useRoleCityList()

const tableScrollX = computed(() => (isAdmin.value ? 1780 : 1660))

const statusMap: Record<number, string> = {
  0: '待审核',
  1: '待打款',
  2: '已拒绝',
  3: '已打款',
  4: '打款失败',
}

const statusColor: Record<number, string> = {
  0: 'orange',
  1: 'blue',
  2: 'red',
  3: 'green',
  4: 'red',
}

const accountTypeMap: Record<number, string> = {
  1: '银行卡',
  2: '支付宝',
  3: '微信',
}

const columns = computed(() => {
  const base = [
    { title: '提现单号', dataIndex: 'withdrawNo', ellipsis: true, width: 160 },
    { title: '骑手', dataIndex: 'riderName', width: 100 },
    { title: '手机号', dataIndex: 'mobile', width: 120 },
    { title: '提现金额', key: 'amount', width: 100 },
    { title: '状态', key: 'status', width: 90 },
    { title: '账户类型', key: 'accountType', width: 90 },
    { title: '收款人', dataIndex: 'accountName', width: 100 },
    { title: '收款账号', key: 'accountNo', width: 140 },
    { title: '可用余额', key: 'riderBalance', width: 100 },
    { title: '冻结余额', key: 'riderFrozenBalance', width: 100 },
    { title: '申请时间', key: 'applyTime', width: 150 },
    { title: '审核人', dataIndex: 'auditorName', width: 100 },
    { title: '审核时间', key: 'auditTime', width: 150 },
    { title: '操作', key: 'action', fixed: 'right', width: 180 },
  ]
  return isAdmin.value ? [{ title: '租户', key: 'cityName', width: 120 }, ...base] : base
})

const pagination = computed<TablePaginationConfig>(() => ({
  current: currentPage.value,
  pageSize: pageSize.value,
  total: total.value,
  showSizeChanger: false,
  showTotal: (count) => `共 ${count} 条`,
}))

const actionTitle = computed(() => {
  if (actionType.value === 'reject') return '拒绝提现申请'
  if (actionType.value === 'paid') return '标记已打款'
  return '审核通过提现申请'
})

const actionTip = computed(() => {
  if (actionType.value === 'reject') return '拒绝后会解冻本次提现金额并退回骑手可用余额。'
  if (actionType.value === 'paid') return '请确认线下已完成打款,提交后会扣减冻结余额并写入提现流水。'
  return '通过后申请进入待打款状态,冻结余额暂不扣减,后续需手动标记已打款。'
})

function buildParams() {
  return {
    cityId: isAdmin.value ? filterCityId.value : managedCityId.value,
    status: filterStatus.value,
    keyword: keyword.value || undefined,
    startDate: startDate.value,
    endDate: endDate.value,
    page: currentPage.value,
  }
}

async function loadList() {
  loading.value = true
  try {
    const res: any = await riderWithdrawApi.list(buildParams())
    const data = res?.data ?? {}
    list.value = Array.isArray(data.list) ? data.list : []
    total.value = Number(data.total ?? 0)
    currentPage.value = Number(data.page ?? currentPage.value)
    pageSize.value = Number(data.pageSize ?? PAGE_SIZE)
  } finally {
    loading.value = false
  }
}

async function openDetail(record: any) {
  const res: any = await riderWithdrawApi.detail(record.id)
  detail.value = res?.data ?? record
  detailVisible.value = true
}

function openAction(type: ActionType, record: any) {
  actionType.value = type
  actionTargetId.value = record.id
  actionForm.remark = ''
  actionForm.transferNo = ''
  actionVisible.value = true
}

async function handleAction() {
  if (actionType.value === 'reject' && !actionForm.remark.trim()) {
    message.error('请输入拒绝原因')
    return
  }
  if (actionType.value === 'paid' && !actionForm.transferNo.trim()) {
    message.error('请输入打款流水号')
    return
  }
  actionSaving.value = true
  try {
    if (actionType.value === 'approve') {
      await riderWithdrawApi.approve(actionTargetId.value, { remark: actionForm.remark })
    } else if (actionType.value === 'reject') {
      await riderWithdrawApi.reject(actionTargetId.value, { remark: actionForm.remark })
    } else {
      await riderWithdrawApi.markPaid(actionTargetId.value, {
        transferNo: actionForm.transferNo,
        remark: actionForm.remark,
      })
    }
    message.success('操作成功')
    actionVisible.value = false
    await loadList()
  } finally {
    actionSaving.value = false
  }
}

function handleFilterChange() {
  currentPage.value = 1
  loadList()
}

function handleKeywordSearch() {
  currentPage.value = 1
  loadList()
}

function handleTableChange(page: TablePaginationConfig) {
  const nextPage = page.current ?? 1
  if (nextPage === currentPage.value) return
  currentPage.value = nextPage
  loadList()
}

function formatMoney(value: number | string | null | undefined) {
  return Number(value ?? 0).toFixed(2)
}

function formatTime(value: number | null | undefined) {
  if (!value) return '-'
  return new Date(Number(value) * 1000).toLocaleString('zh-CN', { hour12: false })
}

function maskAccountNo(value?: string) {
  const text = String(value || '')
  if (!text) return '-'
  if (text.length <= 4) return text
  return `${text.slice(0, 4)}****${text.slice(-4)}`
}

onMounted(async () => {
  await loadCities()
  if (!isAdmin.value) {
    filterCityId.value = managedCityId.value
  }
  await loadList()
})
</script>

<style scoped>
.withdraw-audit-stack,
.withdraw-table {
  min-width: 0;
}

.withdraw-table {
  --withdraw-fixed-bg: #fff;
  --withdraw-fixed-hover-bg: #e6e2fc;
}

.withdraw-table :deep(.ant-table-cell-fix-right) {
  background: var(--withdraw-fixed-bg) !important;
}

.withdraw-table :deep(.ant-table-tbody > tr:hover > td.ant-table-cell-fix-right) {
  background: var(--withdraw-fixed-hover-bg) !important;
}

:global(.dark) .withdraw-table {
  --withdraw-fixed-bg: rgb(40, 36, 60);
  --withdraw-fixed-hover-bg: #302c45;
}

.withdraw-table :deep(.ant-table-cell-fix-right-first) {
  box-shadow: -8px 0 12px -10px rgba(47, 41, 70, 0.45);
}

.withdraw-table :deep(.ant-space) {
  white-space: nowrap;
}
</style>