第一次提交:Vue3后台前端最新版本

This commit is contained in:
XingQue
2026-06-14 02:43:42 +08:00
commit cf07faccc4
68 changed files with 29381 additions and 0 deletions

View File

@@ -0,0 +1,858 @@
<template>
<div class="withdraw-audit">
<!-- 顶部统计卡片 -->
<el-row :gutter="20" class="stats-cards">
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8">
<el-card class="stat-card" shadow="hover">
<div class="stat-item">
<div class="stat-label">总记录</div>
<div class="stat-value">{{ stats.total || 0 }}</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8">
<el-card class="stat-card" shadow="hover" @click="changeStatus(1)">
<div class="stat-item">
<div class="stat-label">待审核</div>
<div class="stat-value warning">{{ stats.awaiting || 0 }}</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8">
<el-card class="stat-card" shadow="hover" @click="changeStatus(2)">
<div class="stat-item">
<div class="stat-label">已处理</div>
<div class="stat-value success">{{ stats.processed || 0 }}</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 总金额卡片 -->
<el-row :gutter="20" style="margin-bottom: 20px;">
<el-col :span="24">
<el-card shadow="hover">
<div class="total-amount">
<span class="total-label">筛选总金额</span>
<span class="total-value">¥{{ stats.total_amount || '0.00' }}</span>
</div>
</el-card>
</el-col>
</el-row>
<!-- 筛选区域 -->
<el-card class="filter-card" shadow="hover">
<el-row :gutter="20">
<el-col :xs="24" :sm="6" :md="6" :lg="6" :xl="6">
<el-input
v-model="filters.searchUid"
placeholder="用户ID"
clearable
@keyup.enter="handleSearch"
/>
</el-col>
<el-col :xs="24" :sm="6" :md="6" :lg="6" :xl="6">
<el-date-picker
v-model="filters.date"
type="date"
placeholder="选择日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
clearable
@change="handleSearch"
style="width: 100%"
/>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="filter-actions">
<el-button type="primary" @click="handleSearch" :loading="loading">搜索</el-button>
<el-button @click="resetFilters">重置</el-button>
<el-button :icon="Refresh" @click="refreshList" circle />
</el-col>
</el-row>
<!-- 打款方式切换 -->
<el-row :gutter="20" class="filter-second-row">
<el-col :span="24">
<div class="mode-switch">
<span class="mode-switch-label">打款方式</span>
<el-radio-group v-model="filters.dakuanMode" @change="handleDakuanModeChange">
<el-radio-button :label="1">手动打款</el-radio-button>
<el-radio-button :label="2">自动打款</el-radio-button>
</el-radio-group>
</div>
</el-col>
</el-row>
<el-row :gutter="20" class="filter-second-row">
<el-col :span="24">
<div class="status-switch">
<el-button
:type="statusFilter === 1 ? 'primary' : 'default'"
@click="changeStatus(1)"
size="large"
>
待审核 <el-tag size="small" type="warning" v-if="stats.awaiting">{{ stats.awaiting }}</el-tag>
</el-button>
<el-button
:type="statusFilter === 2 ? 'success' : 'default'"
@click="changeStatus(2)"
size="large"
>
已处理 <el-tag size="small" type="success" v-if="stats.processed">{{ stats.processed }}</el-tag>
</el-button>
</div>
</el-col>
</el-row>
<el-row :gutter="20" class="filter-second-row">
<el-col :xs="24" :sm="12" :md="8" :lg="8" :xl="8">
<el-select v-model="filters.type" placeholder="提现类型" clearable @change="handleSearch">
<el-option label="打手佣金" :value="1" />
<el-option label="管事分红" :value="2" />
<el-option label="组长分红" :value="3" />
<el-option label="审核官分佣" :value="4" />
<el-option label="打手押金" :value="5" />
<el-option label="商家余额" :value="6" />
</el-select>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="8" :xl="8">
<el-select v-model="filters.payment" placeholder="收款方式" clearable @change="handleSearch">
<el-option label="全部" :value="0" />
<el-option label="微信收款" :value="1" />
<el-option label="支付宝收款" :value="2" />
</el-select>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="8" :xl="8">
<el-input-number
v-model="filters.minAmount"
placeholder="最小金额"
:min="0"
:precision="2"
controls-position="right"
@change="handleSearch"
style="width: 100%"
/>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="8" :xl="8">
<el-input-number
v-model="filters.maxAmount"
placeholder="最大金额"
:min="0"
:precision="2"
controls-position="right"
@change="handleSearch"
style="width: 100%"
/>
</el-col>
</el-row>
</el-card>
<!-- 自动打款批量操作栏 -->
<div v-if="isAutoMode && statusFilter === 1" class="batch-bar">
<span class="batch-tip">已选 {{ selectedRows.length }} </span>
<el-button
type="success"
:disabled="selectedRows.length === 0"
:loading="batchOperating"
@click="handleBatchApprove"
>
批量同意打款
</el-button>
<el-button
type="danger"
:disabled="selectedRows.length === 0"
:loading="batchOperating"
@click="showBatchRejectDialog"
>
批量驳回
</el-button>
</div>
<!-- 提现记录表格 -->
<el-card class="table-card" shadow="hover">
<el-table
ref="tableRef"
:data="list"
v-loading="loading"
border
stripe
style="width: 100%"
row-key="id"
@row-click="handleRowClick"
@selection-change="handleSelectionChange"
>
<el-table-column
v-if="isAutoMode && statusFilter === 1"
type="selection"
width="48"
fixed="left"
:selectable="isRowSelectable"
/>
<el-table-column prop="yonghuid" label="用户ID" width="120" />
<el-table-column label="用户信息" min-width="200">
<template #default="{ row }">
<div style="display: flex; align-items: center;">
<el-avatar :size="40" :src="getFullImageUrl(row.avatar)" />
<div style="margin-left: 12px;">
<div>{{ row.nicheng || '--' }}</div>
<div style="font-size: 12px; color: #909399;">{{ row.phone }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="jine" label="金额" width="120">
<template #default="{ row }">¥{{ row.jine }}</template>
</el-table-column>
<el-table-column label="提现类型" width="110">
<template #default="{ row }">{{ getTypeText(row.leixing) }}</template>
</el-table-column>
<el-table-column v-if="isAutoMode" prop="shenhe_danhao" label="审核单号" min-width="160" show-overflow-tooltip />
<el-table-column label="打款方式" width="100">
<template #default="{ row }">
<el-tag :type="row.dakuan_mode === 2 ? 'success' : 'info'" size="small">
{{ getDakuanModeText(row.dakuan_mode) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="收款方式" width="100">
<template #default="{ row }">{{ getPaymentText(row.fangshi) }}</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="getStatusType(row.zhuangtai)">
{{ getStatusText(row.zhuangtai) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="create_time" label="申请时间" width="160" :formatter="formatTime" />
<el-table-column label="操作" :width="isAutoMode && statusFilter === 1 ? 200 : 80" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click.stop="goToDetail(row)">详情</el-button>
<template v-if="isAutoMode && statusFilter === 1 && row.zhuangtai === 1">
<el-button type="success" link @click.stop="handleInlineApprove(row)">同意</el-button>
<el-button type="danger" link @click.stop="showInlineRejectDialog(row)">驳回</el-button>
</template>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 30, 50]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</el-card>
<!-- 列表页驳回弹窗自动打款单条/批量 -->
<el-dialog
v-model="rejectDialogVisible"
:title="rejectMode === 'batch' ? `批量驳回(${selectedRows.length} 条)` : '驳回提现'"
width="420px"
>
<el-form>
<el-form-item label="驳回理由" required>
<el-input v-model="rejectReason" type="textarea" :rows="4" placeholder="请输入驳回理由" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rejectDialogVisible = false">取消</el-button>
<el-button type="danger" @click="handleRejectSubmit" :loading="rowOperating || batchOperating">
确认驳回
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import request from '@/utils/request'
const router = useRouter()
const STORAGE_KEY = 'withdraw_audit_filters'
const loading = ref(false)
const batchOperating = ref(false)
const rowOperating = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const tableRef = ref(null)
const selectedRows = ref([])
const stats = ref({
total: 0,
awaiting: 0,
processed: 0,
total_amount: '0.00'
})
const filters = reactive({
searchUid: '',
date: '',
type: '',
payment: 0,
minAmount: null,
maxAmount: null,
dakuanMode: 1
})
const statusFilter = ref(1)
const rejectDialogVisible = ref(false)
const rejectReason = ref('')
const rejectTargetRow = ref(null)
const rejectMode = ref('single')
const isAutoMode = computed(() => filters.dakuanMode === 2)
const getFullImageUrl = (url) => {
if (!url) return ''
const ossUrl = window.$ossURL || ''
return url.startsWith('http') ? url : ossUrl + url
}
const formatTime = (row, column, cellValue) => {
if (!cellValue) return '--'
const date = new Date(cellValue)
return `${date.getFullYear()}-${(date.getMonth()+1).toString().padStart(2,'0')}-${date.getDate().toString().padStart(2,'0')} ${date.getHours().toString().padStart(2,'0')}:${date.getMinutes().toString().padStart(2,'0')}`
}
const getPaymentText = (val) => {
const map = { 1: '微信', 2: '支付宝' }
return map[val] || '未知'
}
const getTypeText = (val) => {
const map = {
1: '打手佣金',
2: '管事分红',
3: '组长分红',
4: '审核官分佣',
5: '打手押金',
6: '商家余额'
}
return map[val] || '未知'
}
const getDakuanModeText = (val) => {
if (val === 2) return '自动'
return '手动'
}
const statusMap = {
1: '审核中',
2: '提现成功',
3: '提现失败',
4: '审核成功',
5: '审核失败',
6: '待收款'
}
const getStatusText = (code) => statusMap[code] || '未知'
const getStatusType = (code) => {
if (code === 1 || code === 6) return 'warning'
if (code === 2 || code === 4) return 'success'
if (code === 3 || code === 5) return 'danger'
return ''
}
const isRowSelectable = (row) => row.zhuangtai === 1
/**
* 与后端 kefu_txsh_action 对齐(自动打款必传四项):
* tixian_id / yonghuid / leixing / shenhe_danhao
*/
const buildActionItem = (row) => ({
tixian_id: Number(row.id),
yonghuid: String(row.yonghuid || '').trim(),
leixing: Number(row.leixing),
shenhe_danhao: String(row.shenhe_danhao || '').trim()
})
const isAutoRow = (row) => Number(row?.dakuan_mode) === 2 || isAutoMode.value
const validateActionRows = (rows) => {
for (const row of rows) {
if (!row?.id) {
return '记录缺少 tixian_id提现记录ID'
}
if (!row.yonghuid) {
return `记录缺少 yonghuid用户ID记录ID=${row.id}`
}
if (row.leixing == null || row.leixing === '') {
return `记录缺少 leixing提现类型用户 ${row.yonghuid}`
}
if (isAutoRow(row) && !row.shenhe_danhao) {
return `记录缺少 shenhe_danhao审核单号用户 ${row.yonghuid},请确认列表接口已返回该字段`
}
}
return ''
}
const submitWithdrawAction = async (payload) => {
const res = await request.post('/yonghu/kefu_txsh_action', {
phone: localStorage.getItem('username'),
...payload
})
if (parseApiCode(res) === 0) {
ElMessage.success(res?.msg || '处理成功')
rejectDialogVisible.value = false
rejectTargetRow.value = null
selectedRows.value = []
fetchData()
return true
}
ElMessage.error(getApiErrorMsg(res, '操作失败'))
return false
}
const saveFiltersToStorage = () => {
const toStore = {
searchUid: filters.searchUid,
date: filters.date,
type: filters.type,
payment: filters.payment,
minAmount: filters.minAmount,
maxAmount: filters.maxAmount,
statusFilter: statusFilter.value,
dakuanMode: filters.dakuanMode
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore))
}
const loadFiltersFromStorage = () => {
const stored = localStorage.getItem(STORAGE_KEY)
if (!stored) return
try {
const parsed = JSON.parse(stored)
if (parsed.searchUid !== undefined) filters.searchUid = parsed.searchUid
if (parsed.date !== undefined) filters.date = parsed.date
if (parsed.type !== undefined && [1, 2, 3, 4, 5, 6].includes(parsed.type)) filters.type = parsed.type
if (parsed.payment !== undefined && [0, 1, 2].includes(parsed.payment)) filters.payment = parsed.payment
if (parsed.minAmount !== undefined) filters.minAmount = parsed.minAmount
if (parsed.maxAmount !== undefined) filters.maxAmount = parsed.maxAmount
if (parsed.statusFilter !== undefined && [1, 2].includes(parsed.statusFilter)) statusFilter.value = parsed.statusFilter
if (parsed.dakuanMode !== undefined && [1, 2].includes(parsed.dakuanMode)) filters.dakuanMode = parsed.dakuanMode
} catch (e) {
console.error('恢复筛选条件失败', e)
}
}
const buildListParams = () => {
const params = {
phone: localStorage.getItem('username'),
page: page.value,
page_size: pageSize.value,
status: statusFilter.value,
dakuan_mode: filters.dakuanMode,
type: filters.type || undefined,
payment: filters.payment === 0 ? undefined : filters.payment,
search_uid: filters.searchUid || undefined,
date: filters.date || undefined
}
if (filters.minAmount !== null && filters.minAmount !== '') {
params.min_amount = filters.minAmount
}
if (filters.maxAmount !== null && filters.maxAmount !== '') {
params.max_amount = filters.maxAmount
}
Object.keys(params).forEach(key => params[key] === undefined && delete params[key])
return params
}
const parseApiCode = (res) => Number(res?.code)
const getApiErrorMsg = (res, fallback = '获取失败') => {
return res?.msg || res?.message || fallback
}
const extractRequestError = (error, fallback = '获取失败') => {
return error?.response?.data?.msg || error?.response?.data?.message || error?.message || fallback
}
const fetchData = async () => {
const phone = localStorage.getItem('username')
if (!phone) {
ElMessage.error('未登录或缺少客服手机号')
return
}
loading.value = true
selectedRows.value = []
try {
const res = await request.post('/yonghu/kefu_txsh_list', buildListParams())
const code = parseApiCode(res)
if (code === 0) {
const data = res?.data || {}
list.value = Array.isArray(data.list) ? data.list : []
total.value = Number(data.total) || 0
stats.value = {
total: 0,
awaiting: 0,
processed: 0,
total_amount: '0.00',
...(data.stats || {})
}
return
}
if (code === 403) {
ElMessage.error(getApiErrorMsg(res, '您无权查看提现列表'))
return
}
ElMessage.error(getApiErrorMsg(res))
} catch (error) {
console.error('获取提现列表失败', error, error?.response?.data)
ElMessage.error(extractRequestError(error))
} finally {
loading.value = false
}
}
const handleDakuanModeChange = () => {
page.value = 1
selectedRows.value = []
fetchData()
saveFiltersToStorage()
}
const changeStatus = (status) => {
if (statusFilter.value === status) return
statusFilter.value = status
page.value = 1
selectedRows.value = []
fetchData()
saveFiltersToStorage()
}
const handleSearch = () => {
page.value = 1
fetchData()
saveFiltersToStorage()
}
const resetFilters = () => {
filters.searchUid = ''
filters.date = ''
filters.type = ''
filters.payment = 0
filters.minAmount = null
filters.maxAmount = null
filters.dakuanMode = 1
statusFilter.value = 1
page.value = 1
localStorage.removeItem(STORAGE_KEY)
fetchData()
}
const refreshList = () => {
fetchData()
}
const handleSizeChange = (val) => {
pageSize.value = val
page.value = 1
fetchData()
}
const handlePageChange = (val) => {
page.value = val
fetchData()
}
const handleRowClick = (row) => {
goToDetail(row)
}
const goToDetail = (row) => {
const ctx = {
dakuan_mode: row.dakuan_mode ?? filters.dakuanMode,
shenhe_danhao: row.shenhe_danhao || '',
yonghuid: row.yonghuid || '',
leixing: row.leixing
}
sessionStorage.setItem(`withdraw_detail_ctx_${row.id}`, JSON.stringify(ctx))
router.push({
path: `/withdraw/detail/${row.id}`,
query: {
dakuan_mode: String(ctx.dakuan_mode),
shenhe_danhao: ctx.shenhe_danhao
}
})
}
const handleSelectionChange = (rows) => {
selectedRows.value = rows
}
const handleInlineApprove = async (row) => {
const validateMsg = validateActionRows([row])
if (validateMsg) {
ElMessage.error(validateMsg)
return
}
try {
await ElMessageBox.confirm(
`确认同意用户 ${row.nicheng || row.yonghuid} 的提现申请(¥${row.jine})吗?`,
'确认打款',
{ confirmButtonText: '确认同意', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
rowOperating.value = true
try {
await submitWithdrawAction({
action: 1,
...buildActionItem(row)
})
} catch (error) {
ElMessage.error('操作失败')
} finally {
rowOperating.value = false
}
}
const showInlineRejectDialog = (row) => {
rejectMode.value = 'single'
rejectTargetRow.value = row
rejectReason.value = ''
rejectDialogVisible.value = true
}
const showBatchRejectDialog = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先选择待审核记录')
return
}
const validateMsg = validateActionRows(selectedRows.value)
if (validateMsg) {
ElMessage.error(validateMsg)
return
}
rejectMode.value = 'batch'
rejectTargetRow.value = null
rejectReason.value = ''
rejectDialogVisible.value = true
}
const handleRejectSubmit = async () => {
if (!rejectReason.value.trim()) {
ElMessage.warning('请输入驳回理由')
return
}
const reason = rejectReason.value.trim()
const rows = rejectMode.value === 'batch' ? selectedRows.value : [rejectTargetRow.value]
const validateMsg = validateActionRows(rows.filter(Boolean))
if (validateMsg) {
ElMessage.error(validateMsg)
return
}
const loadingRef = rejectMode.value === 'batch' ? batchOperating : rowOperating
loadingRef.value = true
try {
if (rejectMode.value === 'batch') {
await submitWithdrawAction({
action: 2,
reason,
batch_list: selectedRows.value.map(buildActionItem)
})
} else {
await submitWithdrawAction({
action: 2,
reason,
...buildActionItem(rejectTargetRow.value)
})
}
} catch (error) {
ElMessage.error('操作失败')
} finally {
loadingRef.value = false
}
}
const handleBatchApprove = async () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先选择待审核记录')
return
}
const validateMsg = validateActionRows(selectedRows.value)
if (validateMsg) {
ElMessage.error(validateMsg)
return
}
try {
await ElMessageBox.confirm(
`确认批量同意 ${selectedRows.value.length} 条提现申请吗?`,
'批量同意打款',
{ confirmButtonText: '确认同意', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
batchOperating.value = true
try {
await submitWithdrawAction({
action: 1,
batch_list: selectedRows.value.map(buildActionItem)
})
} catch (error) {
ElMessage.error('操作失败')
} finally {
batchOperating.value = false
}
}
onMounted(() => {
loadFiltersFromStorage()
page.value = 1
fetchData()
})
</script>
<style scoped>
.withdraw-audit {
padding: 20px;
background-color: #f5f9fc;
min-height: 100%;
}
.stats-cards {
margin-bottom: 20px;
}
.stat-card {
border-radius: 12px;
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0,0,0,0.05);
}
.stat-item {
text-align: center;
padding: 10px 0;
}
.stat-label {
font-size: 14px;
color: #6b7a88;
margin-bottom: 8px;
}
.stat-value {
font-size: 32px;
font-weight: 600;
color: #1a2634;
}
.stat-value.warning {
color: #e6a23c;
}
.stat-value.success {
color: #67c23a;
}
.total-amount {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 8px 0;
}
.total-label {
font-size: 16px;
font-weight: 500;
color: #1a2634;
}
.total-value {
font-size: 28px;
font-weight: bold;
color: #f56c6c;
}
.filter-card {
margin-bottom: 20px;
border-radius: 12px;
}
.filter-second-row {
margin-top: 16px;
}
.filter-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.mode-switch {
display: flex;
align-items: center;
gap: 16px;
}
.mode-switch-label {
font-size: 14px;
font-weight: 500;
color: #1a2634;
flex-shrink: 0;
}
.status-switch {
display: flex;
gap: 20px;
margin: 8px 0;
}
.status-switch .el-button {
flex: 1;
}
.batch-bar {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 12px;
padding: 12px 16px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.02);
}
.batch-tip {
font-size: 14px;
color: #6b7a88;
}
.table-card {
border-radius: 12px;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
@media screen and (max-width: 768px) {
.withdraw-audit {
padding: 12px;
}
.filter-actions {
margin-top: 12px;
justify-content: flex-start;
}
.mode-switch {
flex-direction: column;
align-items: flex-start;
}
.status-switch {
flex-direction: column;
gap: 10px;
}
.pagination {
justify-content: center;
}
}
</style>