fix: 代理

This commit is contained in:
luo
2026-09-12 11:27:50 +08:00
parent d3d257cdf8
commit 2308d82d0f
44 changed files with 5470 additions and 200 deletions

View File

@@ -193,12 +193,28 @@
prop: 'payment_channel',
formatter: (value) => value || '-'
},
{
label: '第三方支付流水号',
prop: 'payment_transaction_id',
formatter: (value) => value || '-',
fullWidth: true
},
...(isOfflineRecharge
? [
{
label: '交易流水号',
prop: 'external_transaction_no',
formatter: (value: string | null | undefined) => value || '-',
fullWidth: true
},
{
label: '收款方式',
formatter: (_: unknown, data: AgentRecharge) =>
data.offline_payment_method_name || data.offline_payment_method_code || '-'
}
]
: [
{
label: '第三方支付流水号',
prop: 'payment_transaction_id',
formatter: (value: string | null | undefined) => value || '-',
fullWidth: true
}
]),
{
label: '支付单号',
prop: 'payment_no',
@@ -226,6 +242,26 @@
() => '查看支付凭证'
)
: h('span', '-')
},
{
label: '其他凭证',
fullWidth: true,
render: (data: AgentRecharge) =>
hasVoucherKeys(data.other_voucher_key ?? undefined)
? h(
ElButton,
{
type: 'primary',
link: true,
onClick: () => {
paymentVoucherFileKeys.value = toVoucherKeyList(
data.other_voucher_key ?? undefined
)
}
},
() => '查看其他凭证'
)
: h('span', '-')
}
]
: []),

View File

@@ -121,10 +121,66 @@
ref="uploadRef"
v-model="createForm.payment_voucher_key"
voucher-name="支付凭证"
accept="image/*"
content-type="image/jpeg"
@uploading-change="voucherUploading = $event"
@change="createFormRef?.validateField('payment_voucher_key')"
/>
</ElFormItem>
<ElFormItem
v-if="createMode === 'offline'"
label="收款方式"
prop="offline_payment_method_id"
>
<ElSelect
v-model="createForm.offline_payment_method_id"
placeholder="请选择收款方式"
style="width: 100%"
filterable
:loading="paymentMethodOptionsLoading"
>
<ElOption
v-for="item in offlinePaymentMethodOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="createForm.payment_method === 'offline'"
label="交易流水号"
prop="external_transaction_no"
>
<div class="external-transaction-row">
<ElInput
v-model="createForm.external_transaction_no"
placeholder="请输入交易流水号,或点击右侧识别凭证预填"
:disabled="ocrLoading"
/>
<ElButton
:loading="ocrLoading"
:disabled="!offlineVoucherKeys.length"
@click="handleRecognizeVoucher"
>
识别凭证
</ElButton>
</div>
<div class="external-transaction-tip">
识别结果仅供参考,请对照凭证核对后再提交。
</div>
</ElFormItem>
<ElFormItem v-if="createMode === 'offline'" label="其他凭证">
<VoucherUpload
ref="otherUploadRef"
v-model="createForm.other_voucher_key"
voucher-name="其他凭证"
:max-count="5"
accept="image/*"
content-type="image/jpeg"
@uploading-change="otherVoucherUploading = $event"
/>
</ElFormItem>
<ElFormItem v-if="createMode === 'offline'" label="运营备注" prop="remark">
<ElInput
v-model="createForm.remark"
@@ -297,7 +353,12 @@
import { h } from 'vue'
import { useRouter } from 'vue-router'
import QrcodeVue from 'qrcode.vue'
import { AgentRechargeService, CommissionService, ShopService } from '@/api/modules'
import {
AgentRechargeService,
CommissionService,
EmployeeCollectionService,
ShopService
} from '@/api/modules'
import {
ElMessage,
ElMessageBox,
@@ -319,6 +380,7 @@
AgentRechargePaymentStatusResponse,
CreateAgentRechargeRequest,
ConfirmOfflinePaymentRequest,
EmployeeCollectionPaymentMethod,
RejectAgentRechargeRequest
} from '@/types/api'
import type { SearchFormItem } from '@/types'
@@ -343,6 +405,7 @@
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
import { buildAgentRechargeActions } from './agentRechargeActions'
import { formatRejectionReason } from './agentRechargeDisplay'
import { normalizeCollectionList } from '@/views/finance/employee-collection/employeeCollectionDisplay'
import {
amountYuanToFen,
createOnlineRechargeRequestId,
@@ -406,6 +469,10 @@
max_amount: ONLINE_MAX_RECHARGE_AMOUNT_FEN
})
const paymentVoucherFileKeys = ref<string[]>([])
const offlinePaymentMethodOptions = ref<EmployeeCollectionPaymentMethod[]>([])
const paymentMethodOptionsLoading = ref(false)
const otherVoucherUploading = ref(false)
const ocrLoading = ref(false)
// 搜索表单初始值
const initialSearchState: AgentRechargeQueryParams = {
@@ -542,6 +609,8 @@
{ label: '业务处理状态', prop: 'processing_status_name' },
{ label: '支付方式', prop: 'payment_method' },
{ label: '支付通道', prop: 'payment_channel' },
{ label: '交易流水号', prop: 'external_transaction_no' },
{ label: '收款方式', prop: 'offline_payment_method_name' },
{ label: '运营备注', prop: 'remark' },
{ label: '驳回原因', prop: 'rejection_reason' },
{ label: '创建时间', prop: 'created_at' },
@@ -554,6 +623,7 @@
const confirmPayFormRef = ref<FormInstance>()
const rejectFormRef = ref<FormInstance>()
const uploadRef = ref<InstanceType<typeof VoucherUpload>>()
const otherUploadRef = ref<InstanceType<typeof VoucherUpload>>()
const OFFLINE_MIN_RECHARGE_AMOUNT = 0.01
const OFFLINE_MAX_RECHARGE_AMOUNT = 1_000_000
@@ -579,6 +649,8 @@
() =>
createLoading.value ||
voucherUploading.value ||
otherVoucherUploading.value ||
ocrLoading.value ||
paymentMethodsLoading.value ||
(createMode.value === 'online' && onlinePaymentMethods.value.length === 0)
)
@@ -631,6 +703,12 @@
}
if (createMode.value === 'offline') {
rules.payment_voucher_key = [{ required: true, message: '请上传支付凭证', trigger: 'change' }]
rules.offline_payment_method_id = [
{ required: true, message: '请选择收款方式', trigger: 'change' }
]
rules.external_transaction_no = [
{ required: true, message: '请输入交易流水号', trigger: 'blur' }
]
}
if (createMode.value === 'online') delete rules.shop_id
return rules
@@ -651,12 +729,18 @@
payment_method: AgentRechargePaymentMethod | ''
shop_id: number | null
payment_voucher_key: string[]
offline_payment_method_id: number | null
external_transaction_no: string
other_voucher_key: string[]
remark: string
}>({
amount: OFFLINE_MIN_RECHARGE_AMOUNT,
payment_method: '',
shop_id: null,
payment_voucher_key: [],
offline_payment_method_id: null,
external_transaction_no: '',
other_voucher_key: [],
remark: ''
})
@@ -803,6 +887,21 @@
width: 120,
formatter: (row: AgentRecharge) => row.payment_channel || '-'
},
{
prop: 'external_transaction_no',
label: '交易流水号',
minWidth: 180,
showOverflowTooltip: true,
formatter: (row: AgentRecharge) => row.external_transaction_no || '-'
},
{
prop: 'offline_payment_method_name',
label: '收款方式',
width: 140,
showOverflowTooltip: true,
formatter: (row: AgentRecharge) =>
row.payment_method === 'offline' ? row.offline_payment_method_name || '-' : '-'
},
{
prop: 'remark',
label: '运营备注',
@@ -1000,8 +1099,9 @@
if (createMode.value === 'online') {
await loadPaymentMethods()
} else {
// 重新加载店铺列表,确保获取最新数据
// 重新加载店铺列表与收款方式字典,确保获取最新数据
await loadShops()
await loadPaymentMethodOptions()
}
createDialogVisible.value = true
@@ -1012,10 +1112,16 @@
createForm.payment_method = ''
createForm.shop_id = null
createForm.payment_voucher_key = []
createForm.offline_payment_method_id = null
createForm.external_transaction_no = ''
createForm.other_voucher_key = []
createForm.remark = ''
onlineRequestId.value = null
voucherUploading.value = false
otherVoucherUploading.value = false
ocrLoading.value = false
uploadRef.value?.clearFiles(false)
otherUploadRef.value?.clearFiles(false)
}
// 对话框关闭后的清理
@@ -1024,12 +1130,12 @@
resetCreateForm()
}
// 加载代理在线充值可用支付方式
// 加载代理在线充值可用支付方式(超管调用该接口返回 403,仅通过系统配置查看允许范围)
const loadPaymentMethods = async () => {
paymentMethodsLoading.value = true
onlinePaymentMethods.value = []
try {
const res = await AgentRechargeService.getPaymentMethods()
const res = await AgentRechargeService.getSelfRechargePaymentMethods()
if (res.code === 0) {
onlinePaymentMethods.value = Array.isArray(res.data?.methods) ? res.data.methods : []
paymentMethodsBounds.min_amount = ONLINE_MIN_RECHARGE_AMOUNT_FEN
@@ -1037,15 +1143,75 @@
Number(res.data?.max_amount) || ONLINE_MAX_RECHARGE_AMOUNT_FEN
createForm.amount = minimumAmountYuan.value
} else {
ElMessage.warning(res.msg || '当前暂无可用在线支付方式')
ElMessage.warning(res.msg || '当前暂无可用的在线支付方式')
}
} catch (error) {
console.error('加载在线支付方式失败:', error)
ElMessage.warning('当前暂无可用的在线支付方式')
} finally {
paymentMethodsLoading.value = false
}
}
// 加载线下收款方式字典启用项
const loadPaymentMethodOptions = async () => {
paymentMethodOptionsLoading.value = true
offlinePaymentMethodOptions.value = []
try {
const res = await EmployeeCollectionService.getPaymentMethods({
page: 1,
page_size: 100,
enabled: true
})
if (res.code === 0) {
offlinePaymentMethodOptions.value =
normalizeCollectionList<EmployeeCollectionPaymentMethod>(res.data)
}
} catch (error) {
console.error('加载线下收款方式失败:', error)
} finally {
paymentMethodOptionsLoading.value = false
}
}
// 已上传的支付凭证对象键(OCR 识别取第一个)
const offlineVoucherKeys = computed(() => toVoucherKeyList(createForm.payment_voucher_key))
// 识别付款凭证中的交易流水号:只做预填,失败不阻断人工填写
const handleRecognizeVoucher = async () => {
if (ocrLoading.value) return
const voucherKeys = offlineVoucherKeys.value
if (!voucherKeys.length) {
ElMessage.warning('请先上传支付凭证')
return
}
ocrLoading.value = true
try {
const res = await AgentRechargeService.recognizePaymentVoucher({
payment_voucher_key: voucherKeys[0]
})
if (res.code !== 0) {
ElMessage.warning(res.msg || '识别失败,请手动填写交易流水号')
return
}
const transactionNo = res.data?.external_transaction_no
if (!transactionNo) {
ElMessage.warning('未识别到交易流水号,请手动填写')
return
}
createForm.external_transaction_no = transactionNo
createFormRef.value?.validateField('external_transaction_no')
ElMessage.success('已识别交易流水号,请对照凭证核对')
} catch (error) {
console.error('识别付款凭证失败:', error)
ElMessage.warning('识别失败,请手动填写交易流水号')
} finally {
ocrLoading.value = false
}
}
// 创建充值订单
const handleCreateRecharge = async () => {
if (createLoading.value) return
@@ -1056,6 +1222,10 @@
ElMessage.warning('支付凭证上传中,请稍候')
return
}
if (otherVoucherUploading.value) {
ElMessage.warning('其他凭证上传中,请稍候')
return
}
createLoading.value = true
try {
@@ -1071,6 +1241,10 @@
ElMessage.warning('请选择目标店铺')
return
}
if (!createForm.offline_payment_method_id) {
ElMessage.warning('请选择收款方式')
return
}
const voucherKeys = toVoucherKeyList(createForm.payment_voucher_key)
if (!hasVoucherKeys(voucherKeys)) {
@@ -1078,11 +1252,15 @@
return
}
const otherVoucherKeys = toVoucherKeyList(createForm.other_voucher_key)
const data: CreateAgentRechargeRequest = {
amount: amountYuanToFen(createForm.amount),
payment_method: 'offline',
shop_id: createForm.shop_id,
offline_payment_method_id: createForm.offline_payment_method_id,
external_transaction_no: createForm.external_transaction_no.trim(),
payment_voucher_key: voucherKeys,
other_voucher_key: otherVoucherKeys.length ? otherVoucherKeys : undefined,
remark: createForm.remark || undefined
}
@@ -1430,6 +1608,18 @@
color: var(--el-color-danger);
}
.external-transaction-row {
display: flex;
gap: 8px;
width: 100%;
}
.external-transaction-tip {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.online-recharge-qr-dialog {
display: flex;
flex-direction: column;

View File

@@ -0,0 +1,574 @@
<template>
<ElDialog
:model-value="modelValue"
:title="dialogTitle"
width="760px"
destroy-on-close
@update:model-value="emit('update:modelValue', $event)"
@closed="handleClosed"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="140px">
<ElFormItem label="收款方式" prop="payment_method_id">
<ElSelect
v-model="form.payment_method_id"
placeholder="请选择收款方式"
style="width: 100%"
:loading="paymentMethodsLoading"
>
<ElOption
v-for="item in enabledPaymentMethods"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</ElSelect>
<div v-if="!paymentMethodsLoading && !enabledPaymentMethodCount" class="form-tip">
暂无可用的收款方式,请联系管理员
</div>
</ElFormItem>
<ElFormItem label="核销账单" required>
<div class="bill-selector">
<div v-if="billsLoading" class="bill-selector__empty">加载中...</div>
<ElEmpty
v-else-if="!candidateBills.length"
description="暂无可核销账单"
:image-size="60"
/>
<div v-else class="bill-selector__list">
<div v-for="bill in candidateBills" :key="bill.id" class="bill-row">
<ElCheckbox
:model-value="selectedBillIds.includes(bill.id)"
@change="toggleBill(bill)"
/>
<div class="bill-row__main">
<div class="bill-row__title">账单 #{{ bill.id }}</div>
<div class="bill-row__meta">
单号:{{ bill.source_no || '-' }} · 未核销
{{ formatCollectionCurrency(bill.remaining_amount) }}
</div>
</div>
<ElInputNumber
v-if="selectedBillIds.includes(bill.id)"
v-model="amountMap[bill.id]"
:min="0"
:max="fenToYuan(bill.remaining_amount)"
:precision="2"
:step="1"
size="small"
style="width: 160px"
/>
</div>
</div>
</div>
<div class="bill-selector__total">
已选 {{ selectedBillIds.length }} 张账单,核销合计
{{ formatCollectionCurrency(totalAmountFen) }}
</div>
</ElFormItem>
<ElFormItem label="付款金额" prop="paid_amount">
<ElInputNumber
v-model="form.paid_amount"
:min="0"
:precision="2"
:step="1"
style="width: 220px"
/>
<span class="amount-tip">本次线下收款金额(元),不得小于核销合计</span>
</ElFormItem>
<ElFormItem label="付款方名称" prop="payer_name">
<ElInput
v-model="form.payer_name"
maxlength="100"
show-word-limit
placeholder="请输入付款方名称"
/>
</ElFormItem>
<ElFormItem label="付款时间" prop="paid_at">
<ElDatePicker
v-model="form.paid_at"
type="datetime"
placeholder="请选择付款时间"
value-format="YYYY-MM-DDTHH:mm:ssZ"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem label="外部交易流水号" prop="external_transaction_no">
<ElInput
v-model="form.external_transaction_no"
maxlength="128"
show-word-limit
placeholder="请输入经人工核对的外部交易流水号"
/>
</ElFormItem>
<ElFormItem label="付款凭证" prop="payment_voucher_keys">
<VoucherUpload
ref="uploadRef"
v-model="form.payment_voucher_keys"
voucher-name="付款凭证"
:max-count="5"
@uploading-change="voucherUploading = $event"
@change="formRef?.validateField('payment_voucher_keys')"
/>
</ElFormItem>
<ElFormItem label="备注" prop="remark">
<ElInput
v-model="form.remark"
type="textarea"
:rows="2"
maxlength="500"
show-word-limit
placeholder="选填"
/>
</ElFormItem>
<ElFormItem v-if="isActing" label="代办原因" prop="acting_reason">
<ElInput
v-model="form.acting_reason"
type="textarea"
:rows="2"
maxlength="500"
show-word-limit
placeholder="超级管理员代办必须填写原因"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="emit('update:modelValue', false)">取消</ElButton>
<ElButton
type="primary"
:loading="submitting || voucherUploading"
:disabled="voucherUploading || !enabledPaymentMethodCount"
@click="handleSubmit"
>
{{ voucherUploading ? '凭证上传中...' : '提交' }}
</ElButton>
</div>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { EmployeeCollectionService } from '@/api/modules'
import type {
EmployeeCollectionAllocation,
EmployeeCollectionAllocationRequest,
EmployeeCollectionApplication,
EmployeeCollectionApplicationRequest,
EmployeeCollectionBill,
EmployeeCollectionPaymentMethod
} from '@/types/api'
import { fenToYuan, yuanToFen } from '@/utils/business/format'
import { getErrorMessage, toVoucherKeyList } from '@/utils/business'
import { useUserStore } from '@/store/modules/user'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
import {
canCreateApplication,
formatCollectionCurrency,
normalizeCollectionList
} from '../../employeeCollectionDisplay'
interface Props {
modelValue: boolean
application?: EmployeeCollectionApplication | null
presetBill?: EmployeeCollectionBill | null
}
interface BillOption {
id: number
source_no?: string | null
remaining_amount: number
}
const props = withDefaults(defineProps<Props>(), {
application: null,
presetBill: null
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
success: []
}>()
const userStore = useUserStore()
const formRef = ref<FormInstance>()
const uploadRef = ref<InstanceType<typeof VoucherUpload>>()
const paymentMethodsLoading = ref(false)
const billsLoading = ref(false)
const submitting = ref(false)
const voucherUploading = ref(false)
const paymentMethods = ref<EmployeeCollectionPaymentMethod[]>([])
const candidateBills = ref<BillOption[]>([])
const selectedBillIds = ref<number[]>([])
const amountMap = reactive<Record<number, number>>({})
const form = reactive({
payment_method_id: undefined as number | undefined,
paid_amount: 0,
payer_name: '',
paid_at: '',
external_transaction_no: '',
payment_voucher_keys: [] as string[],
remark: '',
acting_reason: ''
})
const isActing = computed(() => userStore.isSuperAdmin)
const isResubmit = computed(() => !!props.application)
const dialogTitle = computed(() => (isResubmit.value ? '修改并重新提交核销申请' : '创建核销申请'))
const enabledPaymentMethods = computed(() => paymentMethods.value.filter((item) => item.enabled))
const enabledPaymentMethodCount = computed(() => enabledPaymentMethods.value.length)
const paidAmountFen = computed(() => yuanToFen(form.paid_amount) || 0)
const totalAmountFen = computed(() =>
selectedBillIds.value.reduce((total, billId) => total + (yuanToFen(amountMap[billId]) || 0), 0)
)
const rules = computed<FormRules>(() => {
const base: FormRules = {
payment_method_id: [{ required: true, message: '请选择收款方式', trigger: 'change' }],
paid_amount: [
{ required: true, message: '请输入付款金额', trigger: 'blur' },
{
validator: (_rule, _value, callback) => {
if (paidAmountFen.value <= 0) {
callback(new Error('付款金额必须大于 0'))
return
}
if (paidAmountFen.value < totalAmountFen.value) {
callback(new Error('付款金额不能小于核销合计'))
return
}
callback()
},
trigger: 'change'
}
],
payer_name: [{ required: true, message: '请输入付款方名称', trigger: 'blur' }],
paid_at: [{ required: true, message: '请选择付款时间', trigger: 'change' }],
external_transaction_no: [
{ required: true, message: '请输入外部交易流水号', trigger: 'blur' }
],
payment_voucher_keys: [
{
required: true,
validator: (_rule, value, callback) => {
if (Array.isArray(value) && value.length) callback()
else callback(new Error('请上传付款凭证(1-5 个)'))
},
trigger: 'change'
}
]
}
if (isActing.value) {
base.acting_reason = [{ required: true, message: '请填写代办原因', trigger: 'blur' }]
}
return base
})
watch(totalAmountFen, (total) => {
if (paidAmountFen.value < total) {
form.paid_amount = fenToYuan(total)
}
})
const loadPaymentMethods = async () => {
paymentMethodsLoading.value = true
try {
const res = await EmployeeCollectionService.getPaymentMethods({ page: 1, page_size: 100 })
if (res.code === 0) {
paymentMethods.value = normalizeCollectionList<EmployeeCollectionPaymentMethod>(res.data)
}
} catch (error) {
ElMessage.error(getErrorMessage(error, '获取收款方式失败'))
} finally {
paymentMethodsLoading.value = false
}
}
const toBillOption = (bill: {
id: number
source_no?: string | null
remaining_amount?: number | null
}): BillOption => ({
id: bill.id,
source_no: bill.source_no,
remaining_amount: bill.remaining_amount ?? 0
})
const loadCandidateBills = async () => {
billsLoading.value = true
try {
const res = await EmployeeCollectionService.getBills({ page: 1, page_size: 100 })
const list: BillOption[] =
res.code === 0
? normalizeCollectionList<EmployeeCollectionBill>(res.data)
.filter((bill) => canCreateApplication(bill))
.map((bill) => toBillOption(bill))
: []
if (props.presetBill && !list.some((bill) => bill.id === props.presetBill?.id)) {
list.unshift(toBillOption(props.presetBill))
}
candidateBills.value = list
} catch (error) {
ElMessage.error(getErrorMessage(error, '获取可核销账单失败'))
} finally {
billsLoading.value = false
}
}
const normalizePaidAtForPicker = (value?: string | null): string => {
if (!value) return ''
const match = value
.trim()
.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?\s*(Z|[+-]\d{2}:?\d{2})?$/)
if (!match) return value
const base = `${match[1]}T${match[2]}`
const zone = match[3]
if (!zone) return base
if (zone === 'Z') return `${base}+00:00`
return `${base}${zone.includes(':') ? zone : `${zone.slice(0, 3)}:${zone.slice(3)}`}`
}
const fetchApplicationAllocations = async (
applicationId: number
): Promise<EmployeeCollectionAllocation[]> => {
const res = await EmployeeCollectionService.getApplicationById(applicationId)
if (res.code === 0 && res.data) {
return res.data.allocations || []
}
return []
}
const toggleBill = (bill: BillOption) => {
const index = selectedBillIds.value.indexOf(bill.id)
if (index >= 0) {
selectedBillIds.value.splice(index, 1)
delete amountMap[bill.id]
} else {
selectedBillIds.value.push(bill.id)
amountMap[bill.id] = fenToYuan(bill.remaining_amount)
}
}
const resetState = () => {
form.payment_method_id = undefined
form.paid_amount = 0
form.payer_name = ''
form.paid_at = ''
form.external_transaction_no = ''
form.payment_voucher_keys = []
form.remark = ''
form.acting_reason = ''
selectedBillIds.value = []
Object.keys(amountMap).forEach((key) => delete amountMap[Number(key)])
uploadRef.value?.clearFiles()
}
const initialize = async (): Promise<void> => {
resetState()
await Promise.all([loadPaymentMethods(), loadCandidateBills()])
if (props.application) {
const application = props.application
form.payment_method_id = application.payment_method_id ?? undefined
form.paid_amount = fenToYuan(application.paid_amount)
form.payer_name = application.payer_name || ''
form.paid_at = normalizePaidAtForPicker(application.paid_at)
form.external_transaction_no = application.external_transaction_no || ''
form.payment_voucher_keys = toVoucherKeyList(application.payment_voucher_keys ?? undefined)
form.remark = application.remark || ''
form.acting_reason = application.acting_reason || ''
const allocations = await fetchApplicationAllocations(application.id)
allocations.forEach((allocation) => {
if (!candidateBills.value.some((item) => item.id === allocation.bill_id)) {
candidateBills.value.push({
id: allocation.bill_id,
source_no: allocation.bill_source_no,
remaining_amount: Math.max(
allocation.amount,
(allocation.bill_receivable_amount ?? 0) -
(allocation.bill_received_amount ?? 0) -
(allocation.bill_reserved_amount ?? 0),
0
)
})
}
if (!selectedBillIds.value.includes(allocation.bill_id)) {
selectedBillIds.value.push(allocation.bill_id)
}
amountMap[allocation.bill_id] = fenToYuan(allocation.amount)
})
if (paidAmountFen.value < totalAmountFen.value) {
form.paid_amount = fenToYuan(totalAmountFen.value)
}
} else if (props.presetBill) {
const bill = candidateBills.value.find((item) => item.id === props.presetBill?.id)
selectedBillIds.value = [props.presetBill.id]
amountMap[props.presetBill.id] = fenToYuan(
bill?.remaining_amount ?? props.presetBill.remaining_amount ?? 0
)
}
}
watch(
() => props.modelValue,
(visible) => {
if (visible) void initialize()
}
)
const handleClosed = () => {
resetState()
formRef.value?.clearValidate()
}
const buildAllocations = (): EmployeeCollectionAllocationRequest[] | null => {
const payload: EmployeeCollectionAllocationRequest[] = []
for (const billId of selectedBillIds.value) {
const amount = yuanToFen(amountMap[billId]) || 0
const option = candidateBills.value.find((bill) => bill.id === billId)
if (amount <= 0) {
ElMessage.warning('请填写每张账单的核销金额')
return null
}
if (option && amount > option.remaining_amount) {
ElMessage.warning('核销金额不能超过账单未核销金额')
return null
}
payload.push({ bill_id: billId, amount })
}
return payload
}
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate()
if (!selectedBillIds.value.length) {
ElMessage.warning('请至少选择一张待核销账单')
return
}
const allocations = buildAllocations()
if (!allocations) return
const payload: EmployeeCollectionApplicationRequest = {
payment_method_id: form.payment_method_id as number,
paid_amount: paidAmountFen.value,
paid_at: form.paid_at,
payer_name: form.payer_name.trim(),
external_transaction_no: form.external_transaction_no.trim(),
payment_voucher_keys: toVoucherKeyList(form.payment_voucher_keys),
remark: form.remark.trim() || undefined,
allocations,
acting_reason: isActing.value ? form.acting_reason.trim() : undefined
}
submitting.value = true
try {
const res = props.application
? await EmployeeCollectionService.updateApplication(props.application.id, payload)
: await EmployeeCollectionService.createApplication(payload)
if (res.code !== 0) return
ElMessage.success(isResubmit.value ? '重新提交成功' : '核销申请已提交')
emit('update:modelValue', false)
emit('success')
} catch (error) {
ElMessage.error(getErrorMessage(error, '提交核销申请失败'))
} finally {
submitting.value = false
}
}
</script>
<style scoped lang="scss">
.form-tip {
margin-top: 4px;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
}
.amount-tip {
margin-left: 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.bill-selector {
width: 100%;
&__empty {
padding: 16px 0;
color: var(--el-text-color-secondary);
text-align: center;
}
&__list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 280px;
padding-right: 4px;
overflow-y: auto;
}
&__total {
margin-top: 10px;
font-size: 13px;
color: var(--el-text-color-regular);
}
}
.bill-row {
display: flex;
gap: 12px;
align-items: center;
padding: 10px 12px;
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
&__main {
flex: 1;
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
&__meta {
margin-top: 2px;
overflow: hidden;
font-size: 12px;
color: var(--el-text-color-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
}
.dialog-footer {
display: flex;
gap: 12px;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,308 @@
<template>
<div class="employee-collection-application-detail-page">
<ElCard shadow="never">
<div class="detail-header">
<ElButton @click="handleBack">
<template #icon>
<ElIcon><ArrowLeft /></ElIcon>
</template>
返回
</ElButton>
<h2 class="detail-title">核销申请详情</h2>
</div>
<DetailPage v-if="application" :sections="detailSections" :data="application" />
<div v-if="loading" class="loading-container">
<ElIcon class="is-loading"><Loading /></ElIcon>
<span>加载中...</span>
</div>
</ElCard>
<ElCard v-if="allocations.length" shadow="never" class="block-card">
<template #header>
<div class="block-title">分摊账单</div>
</template>
<ElTable :data="allocations" border>
<ElTableColumn label="账单编号" width="130">
<template #default="{ row }">
<span class="link-text" @click="handleViewBill(row.bill_id)">#{{ row.bill_id }}</span>
</template>
</ElTableColumn>
<ElTableColumn prop="bill_source_no" label="来源单号" min-width="190" show-overflow-tooltip>
<template #default="{ row }">{{ row.bill_source_no || '-' }}</template>
</ElTableColumn>
<ElTableColumn label="本次核销金额" width="140">
<template #default="{ row }">{{ formatCollectionCurrency(row.amount) }}</template>
</ElTableColumn>
<ElTableColumn label="账单状态" width="120">
<template #default="{ row }">
{{ row.bill_status_name || getBillStatusLabel(row.bill_status) }}
</template>
</ElTableColumn>
<ElTableColumn label="分摊状态" width="130">
<template #default="{ row }">{{ row.status_name || '-' }}</template>
</ElTableColumn>
<ElTableColumn prop="created_at" label="创建时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</ElTableColumn>
<ElTableColumn prop="released_at" label="释放时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.released_at) }}</template>
</ElTableColumn>
</ElTable>
</ElCard>
<ElCard shadow="never" class="block-card">
<template #header>
<div class="block-title">付款凭证</div>
</template>
<ElButton v-if="voucherKeys.length" @click="voucherVisible = true">查看付款凭证</ElButton>
<span v-else class="empty-text">暂无付款凭证</span>
</ElCard>
<ElCard v-if="attempts.length" shadow="never" class="block-card">
<template #header>
<div class="block-title">提交与审批记录</div>
</template>
<ElTimeline>
<ElTimelineItem
v-for="(item, index) in attempts"
:key="index"
:timestamp="item.created_at ? formatDateTime(item.created_at) : ''"
placement="top"
>
<div class="timeline-title">
第 {{ item.attempt_no || index + 1 }} 次提交 · {{ item.approval_status_name || '-' }}
</div>
<div class="timeline-operator">
付款方:{{ item.payer_name || '-' }} · 金额:{{
formatCollectionCurrency(item.paid_amount)
}}
</div>
<div class="timeline-operator">
流水号:{{ item.external_transaction_no || '-' }} · 收款方式:{{
item.payment_method_name || '-'
}}
</div>
<div v-if="item.acting_reason" class="timeline-comment"
>代办原因:{{ item.acting_reason }}</div
>
<div v-if="item.approval_opinion" class="timeline-comment">
审批意见:{{ item.approval_opinion }}
</div>
<div v-if="item.remark" class="timeline-comment">备注:{{ item.remark }}</div>
</ElTimelineItem>
</ElTimeline>
</ElCard>
<PaymentVoucherDialog :file-keys="voucherKeys" @close="voucherVisible = false" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ElButton,
ElCard,
ElIcon,
ElMessage,
ElTable,
ElTableColumn,
ElTimeline,
ElTimelineItem
} from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import DetailPage from '@/components/common/DetailPage.vue'
import type { DetailSection } from '@/components/common/DetailPage.vue'
import { EmployeeCollectionService } from '@/api/modules'
import type {
EmployeeCollectionAllocation,
EmployeeCollectionApplication,
EmployeeCollectionApplicationAttempt,
EmployeeCollectionApplicationDetailData
} from '@/types/api'
import { RoutesAlias } from '@/router/routesAlias'
import { getErrorMessage, toVoucherKeyList } from '@/utils/business'
import { formatDateTime } from '@/utils/business/format'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import {
formatCollectionCurrency,
getApplicationStatusLabel,
getBillStatusLabel
} from '../employeeCollectionDisplay'
defineOptions({ name: 'EmployeeCollectionApplicationDetail' })
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const detail = ref<EmployeeCollectionApplicationDetailData | null>(null)
const voucherVisible = ref(false)
const application = computed<EmployeeCollectionApplication | null>(
() => detail.value?.application || null
)
const allocations = computed<EmployeeCollectionAllocation[]>(
() => detail.value?.allocations || []
)
const attempts = computed<EmployeeCollectionApplicationAttempt[]>(
() => detail.value?.attempts || []
)
const voucherKeys = computed(() =>
toVoucherKeyList(application.value?.payment_voucher_keys ?? undefined)
)
const detailSections = computed((): DetailSection[] => [
{
title: '申请信息',
fields: [
{ label: '申请编号', prop: 'id', formatter: (value) => (value ? `#${value}` : '-') },
{ label: '收款方式', prop: 'payment_method_name', formatter: (value) => value || '-' },
{
label: '付款金额',
formatter: (_, data) => formatCollectionCurrency(data.paid_amount)
},
{ label: '付款方名称', prop: 'payer_name', formatter: (value) => value || '-' },
{
label: '付款时间',
prop: 'paid_at',
formatter: (value) => formatDateTime(value)
},
{
label: '外部交易流水号',
prop: 'external_transaction_no',
formatter: (value) => value || '-'
},
{
label: '状态',
prop: 'status',
formatter: (value, data) => data.status_name || getApplicationStatusLabel(value)
},
{
label: '审批实例ID',
prop: 'latest_approval_instance_id',
formatter: (value) => (value ? String(value) : '-')
},
{ label: '提交时间', prop: 'created_at', formatter: (value) => formatDateTime(value) },
{ label: '更新时间', prop: 'updated_at', formatter: (value) => formatDateTime(value) },
{ label: '审批终态时间', prop: 'decided_at', formatter: (value) => formatDateTime(value) },
{
label: '备注',
prop: 'remark',
formatter: (value) => value || '-',
fullWidth: true
},
{
label: '代办原因',
prop: 'acting_reason',
formatter: (value) => value || '-',
fullWidth: true
},
{
label: '异常终态说明',
prop: 'terminal_reason',
formatter: (value) => value || '-',
fullWidth: true
}
]
}
])
const loadDetail = async () => {
const id = Number(route.params.id)
if (!id) return
loading.value = true
try {
const res = await EmployeeCollectionService.getApplicationById(id)
if (res.code === 0) {
detail.value = res.data
}
} catch (error) {
ElMessage.error(getErrorMessage(error, '获取核销申请详情失败'))
} finally {
loading.value = false
}
}
const handleViewBill = (billId: number) => {
if (!billId) return
router.push({ path: `${RoutesAlias.EmployeeCollectionBillDetail}/${billId}` })
}
const handleBack = () => {
router.push({ path: RoutesAlias.EmployeeCollectionApplications })
}
onMounted(() => void loadDetail())
</script>
<style scoped lang="scss">
.employee-collection-application-detail-page {
height: 100%;
}
.detail-header {
display: flex;
gap: 16px;
align-items: center;
margin-bottom: 16px;
.detail-title {
flex: 1;
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.block-card {
margin-top: 16px;
}
.block-title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.link-text {
color: var(--el-color-primary);
text-decoration: underline;
cursor: pointer;
}
.empty-text {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.timeline-title {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.timeline-operator {
margin-top: 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.timeline-comment {
margin-top: 4px;
font-size: 13px;
color: var(--el-text-color-regular);
}
.loading-container {
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
padding: 32px 0;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -0,0 +1,300 @@
<template>
<ArtTableFullScreen>
<div id="table-full-screen" class="employee-collection-applications-page">
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
@reset="handleReset"
@search="handleSearch"
/>
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:column-list="columnOptions"
v-model:columns="columnChecks"
@refresh="getTableData"
>
<template #left>
<ElButton
v-permission="AUGUST_PERMISSIONS.employeeCollection.applicationCreate"
type="primary"
@click="openCreateApplication"
>
创建核销申请
</ElButton>
</template>
</ArtTableHeader>
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="applicationList"
:current-page="pagination.page"
:page-size="pagination.page_size"
:total="pagination.total"
:margin-top="10"
:actions="getActions"
:actions-width="200"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
</div>
<ApplicationFormDialog
v-model="applicationDialogVisible"
:application="currentApplication"
@success="handleApplicationSuccess"
/>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElButton, ElMessage, ElTag } from 'element-plus'
import { EmployeeCollectionService } from '@/api/modules'
import type {
EmployeeCollectionApplication,
EmployeeCollectionApplicationQueryParams,
EmployeeCollectionPaymentMethod
} from '@/types/api'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { AUGUST_PERMISSIONS } from '@/config/constants/augustIteration'
import { RoutesAlias } from '@/router/routesAlias'
import { getErrorMessage } from '@/utils/business'
import { formatDateTime } from '@/utils/business/format'
import ApplicationFormDialog from './components/ApplicationFormDialog.vue'
import {
APPLICATION_STATUS_OPTIONS,
canResubmitApplication,
formatCollectionCurrency,
getApplicationStatusLabel,
getApplicationStatusTagType,
normalizeCollectionList,
normalizeCollectionPage
} from '../employeeCollectionDisplay'
defineOptions({ name: 'EmployeeCollectionApplications' })
const router = useRouter()
const { hasAuth } = useAuth()
const loading = ref(false)
const tableRef = ref()
const applicationDialogVisible = ref(false)
const applicationList = ref<EmployeeCollectionApplication[]>([])
const currentApplication = ref<EmployeeCollectionApplication | null>(null)
const paymentMethods = ref<EmployeeCollectionPaymentMethod[]>([])
const initialSearchState = {
status: undefined as number | undefined,
payment_method_id: undefined as number | undefined,
dateRange: [] as string[],
created_from: '',
created_to: ''
}
const searchForm = reactive({ ...initialSearchState })
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const searchFormItems: SearchFormItem[] = [
{
label: '状态',
prop: 'status',
type: 'select',
placeholder: '请选择状态',
options: APPLICATION_STATUS_OPTIONS,
config: { clearable: true }
},
{
label: '收款方式',
prop: 'payment_method_id',
type: 'select',
placeholder: '请选择收款方式',
options: () => paymentMethods.value.map((item) => ({ label: item.name, value: item.id })),
config: { clearable: true, filterable: true }
},
{
label: '创建时间',
prop: 'dateRange',
type: 'daterange',
config: {
type: 'daterange',
rangeSeparator: '至',
startPlaceholder: '开始日期',
endPlaceholder: '结束日期',
valueFormat: 'YYYY-MM-DD'
}
}
]
const columnOptions = [
{ label: '申请编号', prop: 'id' },
{ label: '收款方式', prop: 'payment_method_name' },
{ label: '付款金额', prop: 'paid_amount' },
{ label: '状态', prop: 'status' },
{ label: '提交时间', prop: 'created_at' }
]
const handleViewDetail = (row: EmployeeCollectionApplication) => {
router.push({ path: `${RoutesAlias.EmployeeCollectionApplicationDetail}/${row.id}` })
}
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'id',
label: '申请编号',
minWidth: 140,
formatter: (row: EmployeeCollectionApplication) =>
h(
'span',
{
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
onClick: () => handleViewDetail(row)
},
`#${row.id}`
)
},
{
prop: 'payment_method_name',
label: '收款方式',
width: 140,
formatter: (row: EmployeeCollectionApplication) => row.payment_method_name || '-'
},
{
prop: 'paid_amount',
label: '付款金额',
width: 130,
formatter: (row: EmployeeCollectionApplication) => formatCollectionCurrency(row.paid_amount)
},
{
prop: 'status',
label: '状态',
width: 130,
formatter: (row: EmployeeCollectionApplication) =>
h(
ElTag,
{ type: getApplicationStatusTagType(row.status), effect: 'plain' },
() => row.status_name || getApplicationStatusLabel(row.status)
)
},
{
prop: 'created_at',
label: '提交时间',
width: 170,
formatter: (row: EmployeeCollectionApplication) => formatDateTime(row.created_at)
}
])
const buildQueryParams = (): EmployeeCollectionApplicationQueryParams => ({
status: searchForm.status,
payment_method_id: searchForm.payment_method_id,
created_from: searchForm.created_from || undefined,
created_to: searchForm.created_to || undefined
})
const loadPaymentMethods = async () => {
try {
const res = await EmployeeCollectionService.getPaymentMethods({ page: 1, page_size: 100 })
if (res.code === 0) {
paymentMethods.value = normalizeCollectionList<EmployeeCollectionPaymentMethod>(res.data)
}
} catch (error) {
console.error('Load payment methods failed:', error)
}
}
const getTableData = async () => {
loading.value = true
try {
const res = await EmployeeCollectionService.getApplications({
page: pagination.page,
page_size: pagination.page_size,
...buildQueryParams()
})
if (res.code === 0) {
const { list, total } = normalizeCollectionPage<EmployeeCollectionApplication>(res.data)
applicationList.value = list
pagination.total = total
}
} catch (error) {
ElMessage.error(getErrorMessage(error, '获取核销申请列表失败'))
} finally {
loading.value = false
}
}
const handleSearch = () => {
if (Array.isArray(searchForm.dateRange) && searchForm.dateRange.length === 2) {
searchForm.created_from = searchForm.dateRange[0]
searchForm.created_to = searchForm.dateRange[1]
} else {
searchForm.created_from = ''
searchForm.created_to = ''
}
pagination.page = 1
getTableData()
}
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })
pagination.page = 1
getTableData()
}
const handleSizeChange = (size: number) => {
pagination.page_size = size
getTableData()
}
const handleCurrentChange = (page: number) => {
pagination.page = page
getTableData()
}
const openCreateApplication = () => {
currentApplication.value = null
applicationDialogVisible.value = true
}
const handleResubmit = (row: EmployeeCollectionApplication) => {
currentApplication.value = row
applicationDialogVisible.value = true
}
const handleApplicationSuccess = () => {
getTableData()
}
const getActions = (row: EmployeeCollectionApplication) => {
const actions: any[] = []
if (hasAuth(AUGUST_PERMISSIONS.employeeCollection.applicationDetail)) {
actions.push({ label: '详情', handler: () => handleViewDetail(row), type: 'primary' })
}
if (
hasAuth(AUGUST_PERMISSIONS.employeeCollection.applicationUpdate) &&
canResubmitApplication(row)
) {
actions.push({ label: '修改并重新提交', handler: () => handleResubmit(row), type: 'primary' })
}
return actions
}
onMounted(() => {
void getTableData()
void loadPaymentMethods()
})
</script>
<style scoped lang="scss">
.employee-collection-applications-page {
height: 100%;
}
</style>

View File

@@ -0,0 +1,368 @@
<template>
<div class="employee-collection-bill-detail-page">
<ElCard shadow="never">
<div class="detail-header">
<ElButton @click="handleBack">
<template #icon>
<ElIcon><ArrowLeft /></ElIcon>
</template>
返回
</ElButton>
<h2 class="detail-title">员工代收款账单详情</h2>
</div>
<DetailPage v-if="bill" :sections="detailSections" :data="bill" />
<div v-if="loading" class="loading-container">
<ElIcon class="is-loading"><Loading /></ElIcon>
<span>加载中...</span>
</div>
</ElCard>
<ElCard v-if="allocations.length" shadow="never" class="block-card">
<template #header>
<div class="block-title">核销分摊</div>
</template>
<ElTable :data="allocations" border>
<ElTableColumn label="核销申请" width="140">
<template #default="{ row }">
{{ row.application_id ? `#${row.application_id}` : '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="核销金额" width="140">
<template #default="{ row }">{{ formatCollectionCurrency(row.amount) }}</template>
</ElTableColumn>
<ElTableColumn label="分摊状态" width="130">
<template #default="{ row }">{{ row.status_name || '-' }}</template>
</ElTableColumn>
<ElTableColumn label="申请状态" width="130">
<template #default="{ row }">
{{ row.application_status_name || getApplicationStatusLabel(row.application_status) }}
</template>
</ElTableColumn>
<ElTableColumn label="所属尝试" width="110">
<template #default="{ row }">{{ row.attempt_id ? `#${row.attempt_id}` : '-' }}</template>
</ElTableColumn>
<ElTableColumn prop="created_at" label="创建时间" min-width="170">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</ElTableColumn>
<ElTableColumn prop="released_at" label="释放时间" min-width="170">
<template #default="{ row }">{{ formatDateTime(row.released_at) }}</template>
</ElTableColumn>
</ElTable>
</ElCard>
<ElCard v-if="applications.length" shadow="never" class="block-card">
<template #header>
<div class="block-title">关联核销申请</div>
</template>
<ElTable :data="applications" border>
<ElTableColumn type="expand" width="48">
<template #default="{ row }">
<div v-if="row.attempts?.length" class="attempt-list">
<ElTimeline>
<ElTimelineItem
v-for="(item, index) in row.attempts"
:key="index"
:timestamp="item.created_at ? formatDateTime(item.created_at) : ''"
placement="top"
>
<div class="timeline-title">
第 {{ item.attempt_no || index + 1 }} 次提交 ·
{{ item.approval_status_name || '-' }}
</div>
<div class="timeline-operator">
付款方:{{ item.payer_name || '-' }} · 金额:{{
formatCollectionCurrency(item.paid_amount)
}}
</div>
<div class="timeline-operator">
流水号:{{ item.external_transaction_no || '-' }} · 收款方式:{{
item.payment_method_name || '-'
}}
</div>
<div v-if="item.approval_opinion" class="timeline-comment">
审批意见:{{ item.approval_opinion }}
</div>
</ElTimelineItem>
</ElTimeline>
</div>
<span v-else class="empty-text">暂无审批记录</span>
</template>
</ElTableColumn>
<ElTableColumn label="申请编号" width="140">
<template #default="{ row }">
<span class="link-text" @click="handleViewApplication(row)">#{{ row.id }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="收款方式" width="140">
<template #default="{ row }">{{ row.payment_method_name || '-' }}</template>
</ElTableColumn>
<ElTableColumn label="付款金额" width="140">
<template #default="{ row }">{{ formatCollectionCurrency(row.paid_amount) }}</template>
</ElTableColumn>
<ElTableColumn label="状态" width="120">
<template #default="{ row }">
{{ row.status_name || getApplicationStatusLabel(row.status) }}
</template>
</ElTableColumn>
<ElTableColumn prop="created_at" label="提交时间" min-width="170">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</ElTableColumn>
</ElTable>
</ElCard>
<ElCard v-if="refunds.length" shadow="never" class="block-card">
<template #header>
<div class="block-title">退款冲销</div>
</template>
<ElTable :data="refunds" border>
<ElTableColumn label="退款申请ID" width="130">
<template #default="{ row }">{{ row.refund_id ? `#${row.refund_id}` : '-' }}</template>
</ElTableColumn>
<ElTableColumn label="来源订单ID" width="130">
<template #default="{ row }">
{{ row.source_order_id ? `#${row.source_order_id}` : '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="退款金额" width="130">
<template #default="{ row }">{{ formatCollectionCurrency(row.refund_amount) }}</template>
</ElTableColumn>
<ElTableColumn label="冲减应收" width="130">
<template #default="{ row }">{{ formatCollectionCurrency(row.reduced_amount) }}</template>
</ElTableColumn>
<ElTableColumn label="冲销前应收" width="140">
<template #default="{ row }">
{{ formatCollectionCurrency(row.bill_receivable_amount) }}
</template>
</ElTableColumn>
<ElTableColumn label="处理结果" width="150">
<template #default="{ row }">{{ row.outcome_name || row.outcome || '-' }}</template>
</ElTableColumn>
<ElTableColumn prop="created_at" label="创建时间" min-width="170">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</ElTableColumn>
</ElTable>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ElButton,
ElCard,
ElIcon,
ElMessage,
ElTable,
ElTableColumn,
ElTimeline,
ElTimelineItem
} from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import DetailPage from '@/components/common/DetailPage.vue'
import type { DetailSection } from '@/components/common/DetailPage.vue'
import { EmployeeCollectionService } from '@/api/modules'
import type {
EmployeeCollectionBill,
EmployeeCollectionBillAllocation,
EmployeeCollectionBillApplication,
EmployeeCollectionBillDetailData,
EmployeeCollectionBillRefund
} from '@/types/api'
import { RoutesAlias } from '@/router/routesAlias'
import { getErrorMessage } from '@/utils/business'
import { formatDateTime } from '@/utils/business/format'
import {
formatCollectionCurrency,
getApplicationStatusLabel,
getBillSourceLabel,
getBillStatusLabel
} from '../employeeCollectionDisplay'
defineOptions({ name: 'EmployeeCollectionBillDetail' })
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const detail = ref<EmployeeCollectionBillDetailData | null>(null)
const bill = computed<EmployeeCollectionBill | null>(() => detail.value?.bill || null)
const allocations = computed<EmployeeCollectionBillAllocation[]>(
() => detail.value?.allocations || []
)
const applications = computed<EmployeeCollectionBillApplication[]>(
() => detail.value?.applications || []
)
const refunds = computed<EmployeeCollectionBillRefund[]>(() => detail.value?.refunds || [])
const detailSections = computed((): DetailSection[] => [
{
title: '账单信息',
fields: [
{ label: '账单编号', prop: 'id', formatter: (value) => (value ? `#${value}` : '-') },
{
label: '来源',
prop: 'source_type_name',
formatter: (value, data) => value || getBillSourceLabel(data.source_type)
},
{ label: '关联单号', prop: 'source_no', formatter: (value) => value || '-' },
{
label: '负责员工',
prop: 'debtor_snapshot.account_name',
formatter: (value) => value || '-'
},
{
label: '客户',
prop: 'customer_snapshot.buyer_nickname',
formatter: (value) => value || '-'
},
{
label: '店铺',
prop: 'customer_snapshot.shop_id',
formatter: (value) => (value ? `店铺 #${value}` : '-')
},
{
label: '资产标识',
prop: 'customer_snapshot.asset_identifier',
formatter: (value) => value || '-'
},
{
label: '账单状态',
prop: 'status_name',
formatter: (value, data) => value || getBillStatusLabel(data.status)
},
{
label: '应收金额',
formatter: (_, data) => formatCollectionCurrency(data.receivable_amount)
},
{
label: '已核销金额',
formatter: (_, data) => formatCollectionCurrency(data.received_amount)
},
{
label: '审批中预占金额',
formatter: (_, data) => formatCollectionCurrency(data.reserved_amount)
},
{
label: '未核销金额',
formatter: (_, data) => formatCollectionCurrency(data.remaining_amount)
},
{
label: '审批中申请',
formatter: (_, data) => (data.approval_pending ? '存在审批中的申请' : '无')
},
{ label: '创建时间', prop: 'created_at', formatter: (value) => formatDateTime(value) },
{ label: '更新时间', prop: 'updated_at', formatter: (value) => formatDateTime(value) },
{ label: '关闭时间', prop: 'closed_at', formatter: (value) => formatDateTime(value) },
{
label: '关闭原因',
prop: 'closed_reason',
formatter: (value) => value || '-',
fullWidth: true
}
]
}
])
const loadDetail = async () => {
const id = Number(route.params.id)
if (!id) return
loading.value = true
try {
const res = await EmployeeCollectionService.getBillById(id)
if (res.code === 0) {
detail.value = res.data
}
} catch (error) {
ElMessage.error(getErrorMessage(error, '获取账单详情失败'))
} finally {
loading.value = false
}
}
const handleViewApplication = (row: EmployeeCollectionBillApplication) => {
router.push({ path: `${RoutesAlias.EmployeeCollectionApplicationDetail}/${row.id}` })
}
const handleBack = () => {
router.push({ path: RoutesAlias.EmployeeCollectionBills })
}
onMounted(() => void loadDetail())
</script>
<style scoped lang="scss">
.employee-collection-bill-detail-page {
height: 100%;
}
.detail-header {
display: flex;
gap: 16px;
align-items: center;
margin-bottom: 16px;
.detail-title {
flex: 1;
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.block-card {
margin-top: 16px;
}
.block-title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.link-text {
color: var(--el-color-primary);
text-decoration: underline;
cursor: pointer;
}
.attempt-list {
padding: 4px 12px;
}
.empty-text {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.timeline-title {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.timeline-operator {
margin-top: 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.timeline-comment {
margin-top: 4px;
font-size: 13px;
color: var(--el-text-color-regular);
}
.loading-container {
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
padding: 32px 0;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -0,0 +1,539 @@
<template>
<ArtTableFullScreen>
<div id="table-full-screen" class="employee-collection-bills-page">
<div class="stat-row">
<ElCard shadow="never" class="stat-card">
<div class="stat-card__label">应收金额</div>
<div class="stat-card__value">
{{ formatCollectionCurrency(statistics.receivable_total) }}
</div>
</ElCard>
<ElCard shadow="never" class="stat-card">
<div class="stat-card__label">已核销金额</div>
<div class="stat-card__value stat-card__value--success">
{{ formatCollectionCurrency(statistics.received_total) }}
</div>
</ElCard>
<ElCard shadow="never" class="stat-card">
<div class="stat-card__label">未核销金额</div>
<div class="stat-card__value stat-card__value--warning">
{{ formatCollectionCurrency(statistics.unsettled_total) }}
</div>
</ElCard>
<ElCard shadow="never" class="stat-card">
<div class="stat-card__label">待处理账单</div>
<div class="stat-card__value">{{ statistics.pending_bill_count }}</div>
</ElCard>
</div>
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
@reset="handleReset"
@search="handleSearch"
/>
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:column-list="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton
v-permission="AUGUST_PERMISSIONS.employeeCollection.applicationCreate"
type="primary"
:disabled="!hasSelectableBill"
@click="openCreateApplication()"
>
创建核销申请
</ElButton>
</template>
</ArtTableHeader>
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="billList"
:current-page="pagination.page"
:page-size="pagination.page_size"
:total="pagination.total"
:margin-top="10"
:actions="getActions"
:actions-width="200"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
</div>
<ElDialog v-model="closeDialogVisible" title="关闭账单" width="480px" destroy-on-close>
<ElForm ref="closeFormRef" :model="closeForm" :rules="closeRules" label-width="88px">
<ElFormItem label="账单编号">
<span>{{ currentBill ? `#${currentBill.id}` : '-' }}</span>
</ElFormItem>
<ElFormItem label="关闭原因" prop="reason">
<ElInput
v-model="closeForm.reason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请填写关闭原因"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="closeDialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="closeSubmitting" @click="handleCloseBill">
确定关闭
</ElButton>
</div>
</template>
</ElDialog>
<ApplicationFormDialog
v-model="applicationDialogVisible"
:preset-bill="presetBill"
@success="handleApplicationSuccess"
/>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElButton, ElMessage, ElTag } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { EmployeeCollectionService, ShopService } from '@/api/modules'
import type {
EmployeeCollectionBill,
EmployeeCollectionBillQueryParams,
EmployeeCollectionBillStatistics
} from '@/types/api'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { AUGUST_PERMISSIONS } from '@/config/constants/augustIteration'
import { RoutesAlias } from '@/router/routesAlias'
import { getErrorMessage } from '@/utils/business'
import { formatDateTime } from '@/utils/business/format'
import ApplicationFormDialog from '../applications/components/ApplicationFormDialog.vue'
import {
BILL_SOURCE_OPTIONS,
BILL_STATUS_OPTIONS,
canCloseBill,
canCreateApplication,
formatCollectionCurrency,
getBillCustomerLabel,
getBillDebtorName,
getBillSourceLabel,
getBillStatusLabel,
getBillStatusTagType,
normalizeCollectionPage
} from '../employeeCollectionDisplay'
defineOptions({ name: 'EmployeeCollectionBills' })
const router = useRouter()
const { hasAuth } = useAuth()
const loading = ref(false)
const closeSubmitting = ref(false)
const tableRef = ref()
const closeFormRef = ref<FormInstance>()
const closeDialogVisible = ref(false)
const applicationDialogVisible = ref(false)
const currentBill = ref<EmployeeCollectionBill | null>(null)
const presetBill = ref<EmployeeCollectionBill | null>(null)
const billList = ref<EmployeeCollectionBill[]>([])
const shopOptions = ref<Array<{ id: number; shop_name: string }>>([])
const statistics = ref<EmployeeCollectionBillStatistics>({
receivable_total: 0,
received_total: 0,
unsettled_total: 0,
pending_bill_count: 0
})
const initialSearchState = {
source_type: undefined as string | undefined,
source_no: '',
status: undefined as number | undefined,
customer_id: undefined as number | undefined,
dateRange: [] as string[],
created_from: '',
created_to: ''
}
const searchForm = reactive({ ...initialSearchState })
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const closeForm = reactive({ reason: '' })
const closeRules: FormRules = {
reason: [{ required: true, message: '请填写关闭原因', trigger: 'blur' }]
}
const shopNameMap = computed<Record<number, string>>(() =>
shopOptions.value.reduce<Record<number, string>>((map, shop) => {
map[shop.id] = shop.shop_name
return map
}, {})
)
const searchFormItems: SearchFormItem[] = [
{
label: '来源',
prop: 'source_type',
type: 'select',
placeholder: '请选择来源',
options: BILL_SOURCE_OPTIONS,
config: { clearable: true }
},
{
label: '来源单号',
prop: 'source_no',
type: 'input',
placeholder: '请输入来源单号',
config: { clearable: true }
},
{
label: '核销状态',
prop: 'status',
type: 'select',
placeholder: '请选择核销状态',
options: BILL_STATUS_OPTIONS,
config: { clearable: true }
},
{
label: '店铺',
prop: 'customer_id',
type: 'select',
placeholder: '请选择店铺',
options: () => shopOptions.value.map((shop) => ({ label: shop.shop_name, value: shop.id })),
config: {
clearable: true,
filterable: true,
remote: true,
remoteMethod: (query: string) => searchShops(query)
}
},
{
label: '起止时间',
prop: 'dateRange',
type: 'daterange',
config: {
type: 'daterange',
rangeSeparator: '至',
startPlaceholder: '开始日期',
endPlaceholder: '结束日期',
valueFormat: 'YYYY-MM-DD'
}
}
]
const columnOptions = [
{ label: '账单编号', prop: 'id' },
{ label: '来源', prop: 'source_type' },
{ label: '关联单号', prop: 'source_no' },
{ label: '负责员工', prop: 'debtor_snapshot' },
{ label: '客户/店铺', prop: 'customer_snapshot' },
{ label: '应收金额', prop: 'receivable_amount' },
{ label: '已核销金额', prop: 'received_amount' },
{ label: '未核销金额', prop: 'remaining_amount' },
{ label: '状态', prop: 'status' },
{ label: '创建时间', prop: 'created_at' }
]
const hasSelectableBill = computed(() =>
billList.value.some((bill) => canCreateApplication(bill))
)
const buildQueryParams = (): EmployeeCollectionBillQueryParams => ({
source_type: searchForm.source_type,
source_no: searchForm.source_no.trim() || undefined,
status: searchForm.status,
customer_id: searchForm.customer_id,
created_from: searchForm.created_from || undefined,
created_to: searchForm.created_to || undefined
})
const searchShops = async (query: string) => {
try {
const params: any = { page: 1, page_size: 20 }
if (query) params.shop_name = query
const res = await ShopService.getShops(params)
if (res.code === 0) {
shopOptions.value = res.data.items || []
}
} catch (error) {
console.error('Search shops failed:', error)
}
}
const handleViewDetail = (row: EmployeeCollectionBill) => {
router.push({ path: `${RoutesAlias.EmployeeCollectionBillDetail}/${row.id}` })
}
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'id',
label: '账单编号',
minWidth: 120,
formatter: (row: EmployeeCollectionBill) =>
h(
'span',
{
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
onClick: () => handleViewDetail(row)
},
`#${row.id}`
)
},
{
prop: 'source_type',
label: '来源',
width: 160,
formatter: (row: EmployeeCollectionBill) =>
row.source_type_name || getBillSourceLabel(row.source_type)
},
{ prop: 'source_no', label: '关联单号', width: 210, showOverflowTooltip: true },
{
prop: 'debtor_snapshot',
label: '负责员工',
width: 120,
formatter: (row: EmployeeCollectionBill) => getBillDebtorName(row)
},
{
prop: 'customer_snapshot',
label: '客户/店铺',
width: 160,
showOverflowTooltip: true,
formatter: (row: EmployeeCollectionBill) => getBillCustomerLabel(row, shopNameMap.value)
},
{
prop: 'receivable_amount',
label: '应收金额',
width: 120,
formatter: (row: EmployeeCollectionBill) => formatCollectionCurrency(row.receivable_amount)
},
{
prop: 'received_amount',
label: '已核销金额',
width: 130,
formatter: (row: EmployeeCollectionBill) => formatCollectionCurrency(row.received_amount)
},
{
prop: 'remaining_amount',
label: '未核销金额',
width: 130,
formatter: (row: EmployeeCollectionBill) => formatCollectionCurrency(row.remaining_amount)
},
{
prop: 'status',
label: '状态',
width: 110,
formatter: (row: EmployeeCollectionBill) =>
h(
ElTag,
{ type: getBillStatusTagType(row.status), effect: 'plain' },
() => row.status_name || getBillStatusLabel(row.status)
)
},
{
prop: 'created_at',
label: '创建时间',
width: 170,
formatter: (row: EmployeeCollectionBill) => formatDateTime(row.created_at)
}
])
const getTableData = async () => {
loading.value = true
try {
const res = await EmployeeCollectionService.getBills({
page: pagination.page,
page_size: pagination.page_size,
...buildQueryParams()
})
if (res.code === 0) {
const { list, total } = normalizeCollectionPage<EmployeeCollectionBill>(res.data)
billList.value = list
pagination.total = total
}
} catch (error) {
ElMessage.error(getErrorMessage(error, '获取账单列表失败'))
} finally {
loading.value = false
}
}
const getStatistics = async () => {
try {
const res = await EmployeeCollectionService.getBillStatistics(buildQueryParams())
if (res.code === 0 && res.data) {
statistics.value = res.data
}
} catch (error) {
ElMessage.error(getErrorMessage(error, '获取账单统计失败'))
}
}
const reload = () => {
void getTableData()
void getStatistics()
}
const handleSearch = () => {
if (Array.isArray(searchForm.dateRange) && searchForm.dateRange.length === 2) {
searchForm.created_from = searchForm.dateRange[0]
searchForm.created_to = searchForm.dateRange[1]
} else {
searchForm.created_from = ''
searchForm.created_to = ''
}
pagination.page = 1
reload()
}
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })
pagination.page = 1
reload()
}
const handleRefresh = () => reload()
const handleSizeChange = (size: number) => {
pagination.page_size = size
getTableData()
}
const handleCurrentChange = (page: number) => {
pagination.page = page
getTableData()
}
const openCreateApplication = (row?: EmployeeCollectionBill) => {
presetBill.value = row || null
applicationDialogVisible.value = true
}
const handleApplicationSuccess = () => {
reload()
}
const openCloseDialog = (row: EmployeeCollectionBill) => {
if (!canCloseBill(row)) {
ElMessage.warning('账单已关闭,无需重复操作')
return
}
if (row.approval_pending) {
ElMessage.warning('存在审批中的核销申请,账单暂不可关闭')
return
}
currentBill.value = row
closeForm.reason = ''
closeDialogVisible.value = true
}
const handleCloseBill = async () => {
if (!closeFormRef.value || !currentBill.value) return
await closeFormRef.value.validate()
closeSubmitting.value = true
try {
const res = await EmployeeCollectionService.closeBill(currentBill.value.id, {
reason: closeForm.reason.trim()
})
if (res.code !== 0) return
ElMessage.success('账单已关闭')
closeDialogVisible.value = false
reload()
} catch (error) {
ElMessage.error(getErrorMessage(error, '关闭账单失败'))
} finally {
closeSubmitting.value = false
}
}
const getActions = (row: EmployeeCollectionBill) => {
const actions: any[] = []
if (
hasAuth(AUGUST_PERMISSIONS.employeeCollection.applicationCreate) &&
canCreateApplication(row)
) {
actions.push({
label: '创建核销申请',
handler: () => openCreateApplication(row),
type: 'primary'
})
}
if (hasAuth(AUGUST_PERMISSIONS.employeeCollection.billDetail)) {
actions.push({ label: '详情', handler: () => handleViewDetail(row), type: 'primary' })
}
if (hasAuth(AUGUST_PERMISSIONS.employeeCollection.billClose) && canCloseBill(row)) {
actions.push({ label: '关闭账单', handler: () => openCloseDialog(row), type: 'danger' })
}
return actions
}
onMounted(() => {
reload()
void searchShops('')
})
</script>
<style scoped lang="scss">
.employee-collection-bills-page {
height: 100%;
}
.stat-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-bottom: 12px;
}
.stat-card {
:deep(.el-card__body) {
padding: 16px 20px;
}
&__label {
font-size: 13px;
color: var(--el-text-color-secondary);
}
&__value {
margin-top: 8px;
font-size: 22px;
font-weight: 600;
color: var(--el-text-color-primary);
&--success {
color: var(--el-color-success);
}
&--warning {
color: var(--el-color-warning);
}
}
}
.dialog-footer {
display: flex;
gap: 12px;
justify-content: flex-end;
}
@media (width <= 768px) {
.stat-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>

View File

@@ -0,0 +1,215 @@
/**
* 员工代收款展示辅助函数
*/
import type {
EmployeeCollectionApplication,
EmployeeCollectionApplicationStatus,
EmployeeCollectionBill,
EmployeeCollectionBillStatus
} from '@/types/api'
type TagType = 'success' | 'warning' | 'danger' | 'info'
/** 账单状态:0 待核销 / 1 部分核销 / 2 已核销 / 3 已关闭 */
export const BILL_STATUS = {
PENDING: 0,
PARTIAL: 1,
VERIFIED: 2,
CLOSED: 3
} as const
/** 申请状态:0 审批中 / 1 已通过 / 2 已驳回 / 3 已撤销或已关闭 */
export const APPLICATION_STATUS = {
PENDING: 0,
APPROVED: 1,
REJECTED: 2,
REVOKED: 3
} as const
/**
* 兼容后端列表返回结构:
* - 直接返回数组
* - { list: [...] } / { items: [...] } / { records: [...] }
*/
export const normalizeCollectionPage = <T>(data: unknown): { list: T[]; total: number } => {
if (Array.isArray(data)) {
return { list: data as T[], total: data.length }
}
if (data && typeof data === 'object') {
const record = data as Record<string, unknown>
for (const key of ['items', 'list', 'records']) {
const value = record[key]
if (Array.isArray(value)) {
const total = Number(record.total)
return { list: value as T[], total: Number.isNaN(total) ? value.length : total }
}
}
}
return { list: [], total: 0 }
}
export const normalizeCollectionList = <T>(data: unknown): T[] =>
normalizeCollectionPage<T>(data).list
/**
* 金额格式化(分 -> 元)
*/
export const formatCollectionCurrency = (amount?: number | null): string => {
if (amount === undefined || amount === null || Number.isNaN(amount)) return '-'
return `¥${(amount / 100).toFixed(2)}`
}
/**
* 账单状态选项
*/
export const BILL_STATUS_OPTIONS = [
{ label: '待核销', value: BILL_STATUS.PENDING },
{ label: '部分核销', value: BILL_STATUS.PARTIAL },
{ label: '已核销', value: BILL_STATUS.VERIFIED },
{ label: '已关闭', value: BILL_STATUS.CLOSED }
]
/**
* 核销申请状态选项
*/
export const APPLICATION_STATUS_OPTIONS = [
{ label: '审批中', value: APPLICATION_STATUS.PENDING },
{ label: '已通过', value: APPLICATION_STATUS.APPROVED },
{ label: '已驳回', value: APPLICATION_STATUS.REJECTED },
{ label: '已撤销或已关闭', value: APPLICATION_STATUS.REVOKED }
]
/**
* 账单来源选项
*/
export const BILL_SOURCE_OPTIONS = [
{ label: '后台线下套餐订单', value: 'order' },
{ label: '代理线下充值', value: 'recharge' }
]
const BILL_STATUS_LABEL_MAP: Record<number, string> = BILL_STATUS_OPTIONS.reduce(
(map, item) => ({ ...map, [item.value]: item.label }),
{}
)
const APPLICATION_STATUS_LABEL_MAP: Record<number, string> = APPLICATION_STATUS_OPTIONS.reduce(
(map, item) => ({ ...map, [item.value]: item.label }),
{}
)
const BILL_SOURCE_LABEL_MAP: Record<string, string> = BILL_SOURCE_OPTIONS.reduce(
(map, item) => ({ ...map, [item.value]: item.label }),
{}
)
/**
* 账单状态文案(优先使用后端 status_name)
*/
export const getBillStatusLabel = (status?: EmployeeCollectionBillStatus | null): string => {
if (status === undefined || status === null) return '-'
return BILL_STATUS_LABEL_MAP[status] ?? String(status)
}
/**
* 核销申请状态文案
*/
export const getApplicationStatusLabel = (
status?: EmployeeCollectionApplicationStatus | null
): string => {
if (status === undefined || status === null) return '-'
return APPLICATION_STATUS_LABEL_MAP[status] ?? String(status)
}
/**
* 账单来源文案(优先使用后端 source_type_name)
*/
export const getBillSourceLabel = (source?: string | null): string => {
if (!source) return '-'
return BILL_SOURCE_LABEL_MAP[source] || source
}
/**
* 账单状态标签类型
*/
export const getBillStatusTagType = (status?: EmployeeCollectionBillStatus | null): TagType => {
switch (status) {
case BILL_STATUS.PENDING:
return 'warning'
case BILL_STATUS.PARTIAL:
return 'info'
case BILL_STATUS.VERIFIED:
return 'success'
case BILL_STATUS.CLOSED:
return 'info'
default:
return 'info'
}
}
/**
* 核销申请状态标签类型
*/
export const getApplicationStatusTagType = (
status?: EmployeeCollectionApplicationStatus | null
): TagType => {
switch (status) {
case APPLICATION_STATUS.PENDING:
return 'warning'
case APPLICATION_STATUS.APPROVED:
return 'success'
case APPLICATION_STATUS.REJECTED:
return 'danger'
case APPLICATION_STATUS.REVOKED:
return 'info'
default:
return 'info'
}
}
/**
* 账单责任人(员工)名称
*/
export const getBillDebtorName = (bill?: EmployeeCollectionBill | null): string =>
bill?.debtor_snapshot?.account_name || '-'
/**
* 账单关联店铺 ID
*/
export const getBillShopId = (bill?: EmployeeCollectionBill | null): number | undefined =>
bill?.customer_snapshot?.shop_id ?? bill?.customer_snapshot?.seller_shop_id ?? undefined
/**
* 账单客户展示:优先买家昵称,其次店铺名称,最后店铺 ID
*/
export const getBillCustomerLabel = (
bill?: EmployeeCollectionBill | null,
shopNameMap?: Record<number, string>
): string => {
const nickname = bill?.customer_snapshot?.buyer_nickname
if (nickname) return nickname
const shopId = getBillShopId(bill)
if (!shopId) return '-'
return shopNameMap?.[shopId] || `店铺 #${shopId}`
}
/**
* 账单是否可关闭:仅待核销或部分核销可关闭(存在审批中预占时由调用方拦截或后端拒绝)
*/
export const canCloseBill = (bill: EmployeeCollectionBill): boolean =>
bill.status === BILL_STATUS.PENDING || bill.status === BILL_STATUS.PARTIAL
/**
* 账单是否可发起核销申请
*/
export const canCreateApplication = (bill: EmployeeCollectionBill): boolean => {
if (bill.status === BILL_STATUS.CLOSED) return false
return (bill.remaining_amount ?? 0) > 0
}
/**
* 申请是否可修改并重新提交
*/
export const canResubmitApplication = (application: EmployeeCollectionApplication): boolean => {
return application.status === APPLICATION_STATUS.REJECTED
}

View File

@@ -0,0 +1,348 @@
<template>
<ArtTableFullScreen>
<div id="table-full-screen" class="employee-collection-payment-methods-page">
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
@reset="handleReset"
@search="handleSearch"
/>
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:column-list="columnOptions"
v-model:columns="columnChecks"
@refresh="getTableData"
>
<template #left>
<ElButton
v-permission="AUGUST_PERMISSIONS.employeeCollection.paymentMethodCreate"
type="primary"
@click="openCreate"
>
新增收款方式
</ElButton>
</template>
</ArtTableHeader>
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="filteredPaymentMethods"
:pagination="false"
:margin-top="10"
:actions="getActions"
:actions-width="140"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
</div>
<ElDialog
v-model="dialogVisible"
:title="editing ? '编辑收款方式' : '新增收款方式'"
width="520px"
destroy-on-close
@closed="handleDialogClosed"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="56px">
<ElFormItem label="名称" prop="name">
<ElInput v-model="form.name" maxlength="100" placeholder="请输入收款方式名称" />
</ElFormItem>
<ElFormItem label="编码" prop="code">
<ElInput v-model="form.code" maxlength="64" placeholder="请输入唯一编码,如 cash" />
<div v-if="editing" class="form-tip">编码在未被核销申请引用时可修改</div>
</ElFormItem>
<ElFormItem label="排序" prop="sort">
<ElInputNumber v-model="form.sort" :min="0" :max="9999" style="width: 100%" />
</ElFormItem>
<ElFormItem label="状态">
<ElSwitch v-model="form.enabled" active-text="启用" inactive-text="停用" />
</ElFormItem>
<ElFormItem label="备注" prop="remark">
<ElInput
v-model="form.remark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="选填"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="saving" @click="handleSubmit">确定</ElButton>
</div>
</template>
</ElDialog>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, ElTag } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { EmployeeCollectionService } from '@/api/modules'
import type {
EmployeeCollectionPaymentMethod,
EmployeeCollectionPaymentMethodQueryParams
} from '@/types/api'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { STATUS_SELECT_OPTIONS } from '@/config/constants/status'
import { AUGUST_PERMISSIONS } from '@/config/constants/augustIteration'
import { getErrorMessage } from '@/utils/business'
import { formatDateTime } from '@/utils/business/format'
import { normalizeCollectionList } from '../employeeCollectionDisplay'
defineOptions({ name: 'EmployeeCollectionPaymentMethods' })
const { hasAuth } = useAuth()
const loading = ref(false)
const saving = ref(false)
const tableRef = ref()
const formRef = ref<FormInstance>()
const dialogVisible = ref(false)
const editing = ref(false)
const currentId = ref<number | null>(null)
const paymentMethods = ref<EmployeeCollectionPaymentMethod[]>([])
const initialSearchState = {
keyword: '',
enabled: undefined as number | undefined
}
const searchForm = reactive({ ...initialSearchState })
const searchFormItems: SearchFormItem[] = [
{
label: '关键字',
prop: 'keyword',
type: 'input',
placeholder: '请输入名称或编码',
config: { clearable: true }
},
{
label: '状态',
prop: 'enabled',
type: 'select',
placeholder: '请选择状态',
options: STATUS_SELECT_OPTIONS,
config: { clearable: true }
}
]
const columnOptions = [
{ label: '名称', prop: 'name' },
{ label: '编码', prop: 'code' },
{ label: '排序', prop: 'sort' },
{ label: '状态', prop: 'enabled' },
{ label: '备注', prop: 'remark' },
{ label: '创建时间', prop: 'created_at' }
]
const { columnChecks, columns } = useCheckedColumns(() => [
{ prop: 'name', label: '名称', minWidth: 140 },
{ prop: 'code', label: '编码', minWidth: 140 },
{ prop: 'sort', label: '排序', width: 90 },
{
prop: 'enabled',
label: '状态',
width: 100,
formatter: (row: EmployeeCollectionPaymentMethod) =>
h(ElTag, { type: row.enabled ? 'success' : 'info', effect: 'plain' }, () =>
row.enabled ? '启用' : '停用'
)
},
{ prop: 'remark', label: '备注', minWidth: 160, showOverflowTooltip: true },
{
prop: 'created_at',
label: '创建时间',
width: 170,
formatter: (row: EmployeeCollectionPaymentMethod) => formatDateTime(row.created_at)
}
])
const filteredPaymentMethods = computed(() => {
const keyword = searchForm.keyword?.trim().toLowerCase()
return paymentMethods.value.filter((item) => {
const matchKeyword =
!keyword ||
(item.name || '').toLowerCase().includes(keyword) ||
(item.code || '').toLowerCase().includes(keyword)
const matchEnabled =
searchForm.enabled === undefined ||
searchForm.enabled === null ||
(searchForm.enabled === 1 ? item.enabled : !item.enabled)
return matchKeyword && matchEnabled
})
})
const form = reactive({
code: '',
name: '',
sort: 0,
enabled: true,
remark: ''
})
const rules: FormRules = {
name: [{ required: true, message: '请输入收款方式名称', trigger: 'blur' }],
code: [{ required: true, message: '请输入收款方式编码', trigger: 'blur' }]
}
const getTableData = async () => {
loading.value = true
try {
const params: EmployeeCollectionPaymentMethodQueryParams = { page: 1, page_size: 100 }
if (searchForm.enabled !== undefined && searchForm.enabled !== null) {
params.enabled = searchForm.enabled === 1
}
if (searchForm.keyword?.trim()) {
params.keyword = searchForm.keyword.trim()
}
const res = await EmployeeCollectionService.getPaymentMethods(params)
if (res.code === 0) {
paymentMethods.value = normalizeCollectionList<EmployeeCollectionPaymentMethod>(res.data)
}
} catch (error) {
ElMessage.error(getErrorMessage(error, '获取收款方式失败'))
} finally {
loading.value = false
}
}
const handleSearch = () => getTableData()
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })
void getTableData()
}
const resetForm = () => {
form.code = ''
form.name = ''
form.sort = 0
form.enabled = true
form.remark = ''
}
const openCreate = () => {
editing.value = false
currentId.value = null
resetForm()
dialogVisible.value = true
}
const openEdit = (row: EmployeeCollectionPaymentMethod) => {
editing.value = true
currentId.value = row.id
form.code = row.code
form.name = row.name
form.sort = row.sort ?? 0
form.enabled = !!row.enabled
form.remark = row.remark || ''
dialogVisible.value = true
}
const handleDialogClosed = () => {
resetForm()
formRef.value?.clearValidate()
}
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate()
saving.value = true
try {
if (editing.value && currentId.value !== null) {
const res = await EmployeeCollectionService.updatePaymentMethod(currentId.value, {
code: form.code.trim(),
name: form.name.trim(),
sort: form.sort,
enabled: form.enabled,
remark: form.remark.trim()
})
if (res.code !== 0) return
} else {
const res = await EmployeeCollectionService.createPaymentMethod({
code: form.code.trim(),
name: form.name.trim(),
sort: form.sort,
enabled: form.enabled,
remark: form.remark.trim()
})
if (res.code !== 0) return
}
ElMessage.success(editing.value ? '修改成功' : '新增成功')
dialogVisible.value = false
await getTableData()
} catch (error) {
ElMessage.error(getErrorMessage(error, '保存失败'))
} finally {
saving.value = false
}
}
const handleDelete = (row: EmployeeCollectionPaymentMethod) => {
ElMessageBox.confirm(`确定删除收款方式「${row.name}」吗?`, '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
try {
const res = await EmployeeCollectionService.deletePaymentMethod(row.id)
if (res && res.code !== 0) return
ElMessage.success('删除成功')
await getTableData()
} catch (error) {
ElMessage.error(
getErrorMessage(error, '删除失败;该收款方式可能已被业务引用,请改为停用')
)
}
})
.catch(() => {
// 用户取消
})
}
const getActions = (row: EmployeeCollectionPaymentMethod) => {
const actions: any[] = []
if (hasAuth(AUGUST_PERMISSIONS.employeeCollection.paymentMethodEdit)) {
actions.push({ label: '编辑', handler: () => openEdit(row), type: 'primary' })
}
if (hasAuth(AUGUST_PERMISSIONS.employeeCollection.paymentMethodDelete)) {
actions.push({ label: '删除', handler: () => handleDelete(row), type: 'danger' })
}
return actions
}
onMounted(() => void getTableData())
</script>
<style scoped lang="scss">
.employee-collection-payment-methods-page {
height: 100%;
}
.form-tip {
margin-top: 4px;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
}
.dialog-footer {
display: flex;
gap: 12px;
justify-content: flex-end;
}
</style>

View File

@@ -197,7 +197,11 @@
提示: 使用钱包支付时,订单将直接完成
</template>
<template v-else-if="createForm.payment_method === 'offline'">
提示: 线下支付订单需要手动确认支付
{{
generatesCollectionBill
? '提示: 线下支付订单将生成员工代收款账单,付款凭证可在核销申请中提交'
: '提示: 线下支付订单需要手动确认支付'
}}
</template>
</div>
</ElFormItem>
@@ -213,6 +217,12 @@
@uploading-change="voucherUploading = $event"
@change="createFormRef?.validateField('payment_voucher_key')"
/>
<div
v-if="generatesCollectionBill"
style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary)"
>
付款凭证为选填,也可在员工代收款核销申请中提交
</div>
</ElFormItem>
</ElForm>
<template #footer>
@@ -570,8 +580,8 @@
]
}
// 线下支付时,支付凭证为必填
if (createForm.payment_method === 'offline') {
// 线下支付时,支付凭证为必填;生成员工代收款账单时可在核销申请中提交
if (createForm.payment_method === 'offline' && !generatesCollectionBill.value) {
baseRules.payment_voucher_key = [
{
required: true,
@@ -641,6 +651,34 @@
return `¥${(pkg.suggested_retail_price / 100).toFixed(2)}`
}
// 平台账号(超级管理员/平台用户)操作
const isPlatformAccount = computed(() => {
const userType = Number(userStore.info.user_type)
return userType === 1 || userType === 2
})
// 选中套餐的实际收款金额(分)
const selectedPackageAmountFen = computed(() => {
const selectedPackage = packageOptions.value.find((pkg) => pkg.id === createForm.package_id)
if (!selectedPackage) return 0
return (
selectedPackage.effective_retail_price ??
selectedPackage.suggested_retail_price ??
selectedPackage.retail_price ??
0
)
})
// 由平台账号操作、实际收款金额大于 0 且非赠送的线下订单会生成员工代收款账单,
// 该场景付款凭证由核销申请环节提供,创建订单时可为空
const generatesCollectionBill = computed(
() =>
createForm.payment_method === 'offline' &&
isPlatformAccount.value &&
!selectedPackageIsGift.value &&
selectedPackageAmountFen.value > 0
)
// IoT卡选择变化时,根据series_id加载套餐列表
const handleIotCardChange = (cardId: number | null) => {
if (!cardId) {
@@ -1276,9 +1314,10 @@
return
}
// 线下支付时,支付凭证为必填
// 线下支付时,支付凭证为必填;生成员工代收款账单时可在核销申请中提交
if (
createForm.payment_method === 'offline' &&
!generatesCollectionBill.value &&
!hasVoucherKeys(createForm.payment_voucher_key)
) {
ElMessage.error('线下支付必须上传支付凭证')

View File

@@ -31,7 +31,7 @@
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="180"
:actionsWidth="160"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
@@ -48,13 +48,6 @@
destroy-on-close
@closed="clearSensitiveForm"
>
<ElAlert
title="支付凭证仅用于本次写入,页面不会回显、缓存或记录凭证内容。"
type="warning"
:closable="false"
show-icon
/>
<ElForm ref="formRef" :model="form" :rules="formRules" label-width="120px">
<ElFormItem label="商户名称" prop="name">
<ElInput v-model="form.name" maxlength="100" placeholder="请输入商户名称" />
@@ -117,12 +110,12 @@
</ElFormItem>
<template v-if="showCredentialEditor">
<ElDivider content-position="left">写入支付凭证</ElDivider>
<ElAlert
title="凭证值以密码方式输入,提交后立即清空。"
type="info"
:closable="false"
show-icon
/>
<div class="credential-tip">
必填字段:{{ credentialFieldSpec.required.join('、') || '-' }}
<template v-if="credentialFieldSpec.optional.length">
;可选字段:{{ credentialFieldSpec.optional.join('、') }}
</template>
</div>
<div class="credential-list">
<div
v-for="(entry, index) in form.credentials"
@@ -170,37 +163,12 @@
</ElButton>
</template>
</ElDrawer>
<ElDrawer v-model="detailDrawerVisible" title="支付商户详情" size="560px">
<ElDescriptions v-if="detail" :column="1" border>
<ElDescriptionsItem label="商户名称">{{ detail.name }}</ElDescriptionsItem>
<ElDescriptionsItem label="支付方式">{{
getPaymentMethodLabel(detail.payment_method)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="服务商类型">{{
getPaymentProviderLabel(detail.provider_type)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="商户标识">{{ detail.merchant_identity }}</ElDescriptionsItem>
<ElDescriptionsItem label="启停状态">{{
detail.enabled ? '启用' : '停用'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="支付凭证">{{
Number(detail.credential_version) > 0 ? '已配置' : '未配置'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="凭证版本">{{
detail.credential_version || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="备注">{{ detail.remark || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间">{{
formatDateTime(detail.updated_at)
}}</ElDescriptionsItem>
</ElDescriptions>
</ElDrawer>
</div>
</template>
<script setup lang="ts">
import { computed, h, onMounted, onUnmounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Delete, Plus } from '@element-plus/icons-vue'
import { ElButton, ElMessage, ElMessageBox, ElTag } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
@@ -211,8 +179,10 @@
import {
PAYMENT_METHOD_OPTIONS,
PAYMENT_PROVIDER_OPTIONS,
buildPaymentCredentials,
getPaymentMethodLabel,
getPaymentProviderLabel,
getPaymentCredentialFieldSpec,
type PaymentCredentialEntry,
type PaymentCredentials,
type PaymentMerchant,
@@ -221,6 +191,7 @@
type PaymentMerchantProviderType,
type PaymentMerchantQueryParams
} from '@/types/api/paymentMerchantPools'
import { RoutesAlias } from '@/router/routesAlias'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'PaymentMerchantManagement' })
@@ -228,8 +199,20 @@
type FilterVo = string | number | undefined | null | unknown[]
const { isPlatformAccount } = usePermission()
const router = useRouter()
const canManage = computed(() => isPlatformAccount.value)
const handleNameClick = (row: PaymentMerchant) => {
if (!canManage.value) {
ElMessage.warning('您没有查看支付商户详情的权限')
return
}
router.push({
path: `${RoutesAlias.PaymentMerchantPoolsDetail}/${row.id}`
})
}
// 列表查询
const searchForm = reactive<Record<string, FilterVo>>({
page: 1,
@@ -273,7 +256,24 @@
]
const columnOptions = [
{ label: '商户名称', prop: 'name', minWidth: 180 },
{
label: '商户名称',
prop: 'name',
minWidth: 180,
showOverflowTooltip: true,
formatter: (row: PaymentMerchant) =>
h(
'span',
{
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
onClick: (event: MouseEvent) => {
event.stopPropagation()
handleNameClick(row)
}
},
row.name
)
},
{
label: '支付方式',
prop: 'payment_method',
@@ -388,6 +388,7 @@
const formRef = ref<FormInstance>()
const showCredentialEditor = ref(false)
const credentialError = ref('')
const credentialTemplateKeys = ref<string[]>([])
const initialFormState = () => ({
id: 0,
@@ -409,6 +410,9 @@
const credentialConfigured = computed(() => Number(form.credential_version) > 0)
// 当前服务商组合下的凭证必填/可选字段枚举
const credentialFieldSpec = computed(() => getPaymentCredentialFieldSpec(form.provider_type))
const formRules = reactive<FormRules>({
name: [
{ required: true, message: '请输入商户名称', trigger: 'blur' },
@@ -429,6 +433,7 @@
form.credentials = []
showCredentialEditor.value = false
credentialError.value = ''
credentialTemplateKeys.value = []
}
const clearSensitiveForm = () => {
@@ -439,25 +444,13 @@
}
const buildCredentialPayload = (): PaymentCredentials | null => {
const credentials: Record<string, string> = {}
for (const entry of form.credentials) {
const key = entry.key.trim()
if (!key) {
credentialError.value = '请填写凭证字段名'
return null
}
if (!entry.value) {
credentialError.value = `请填写凭证字段 ${key} 的值`
return null
}
if (Object.prototype.hasOwnProperty.call(credentials, key)) {
credentialError.value = `凭证字段 ${key} 重复`
return null
}
credentials[key] = entry.value
}
if (form.credentials.length === 0) {
credentialError.value = '请至少添加一个凭证字段'
const { credentials, error } = buildPaymentCredentials(
form.provider_type,
form.merchant_identity,
form.credentials
)
if (!credentials) {
credentialError.value = error
return null
}
credentialError.value = ''
@@ -467,6 +460,7 @@
const showCreateDrawer = () => {
if (!canManage.value) return
Object.assign(form, initialFormState())
credentialTemplateKeys.value = []
formDrawerVisible.value = true
formMode.value = 'create'
showCredentialEditor.value = true
@@ -476,8 +470,16 @@
const startCredentialReplacement = () => {
if (!canManage.value) return
form.credentials = []
if (credentialTemplateKeys.value.length > 0) {
form.credentials = credentialTemplateKeys.value.map((key) => ({
localId: generateLocalId(),
key,
value: ''
}))
} else {
addCredential()
}
showCredentialEditor.value = true
addCredential()
}
const addCredential = () => {
@@ -531,6 +533,7 @@
ElMessage.success('支付凭证更新成功')
}
formDrawerVisible.value = false
clearSensitiveForm()
await loadMerchants()
} catch (error) {
console.error('提交支付商户失败:', error)
@@ -549,6 +552,7 @@
})
ElMessage.success('支付商户已更新')
formDrawerVisible.value = false
clearSensitiveForm()
await loadMerchants()
} catch (error) {
console.error('更新支付商户失败:', error)
@@ -557,23 +561,9 @@
}
}
// 详情 / 启停 / 删除
const detailDrawerVisible = ref(false)
const detail = ref<PaymentMerchant | null>(null)
// 启停 / 删除
const tableRef = ref()
const showDetail = async (id: number) => {
try {
const res = await PaymentMerchantPoolsService.getPaymentMerchantById(id)
if (res.code === 0) {
detail.value = res.data
detailDrawerVisible.value = true
}
} catch (error) {
console.error('加载支付商户详情失败:', error)
}
}
const openEditDrawer = async (id: number) => {
if (!canManage.value) return
try {
@@ -648,11 +638,6 @@
}
const getActions = (row: PaymentMerchant) => [
{
label: '详情',
type: 'primary' as const,
handler: () => showDetail(row.id)
},
{
label: '编辑',
type: 'primary' as const,
@@ -682,7 +667,6 @@
onUnmounted(() => {
resetSensitiveForm()
merchants.value = []
detail.value = null
})
</script>
@@ -708,6 +692,14 @@
color: var(--el-text-color-secondary);
}
.credential-tip {
margin-bottom: 8px;
font-size: 12px;
line-height: 20px;
color: var(--el-text-color-secondary);
word-break: break-all;
}
.field-error {
margin-top: 8px;
font-size: 12px;

View File

@@ -202,48 +202,12 @@
</ElButton>
</template>
</ElDrawer>
<ElDrawer v-model="detailDrawerVisible" title="商户池详情" size="640px">
<ElDescriptions v-if="detail" :column="1" border>
<ElDescriptionsItem label="商户池名称">{{ detail.name }}</ElDescriptionsItem>
<ElDescriptionsItem label="支付方式">{{
getPaymentMethodLabel(detail.payment_method)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="成员数量">{{ detail.member_ids.length }}</ElDescriptionsItem>
<ElDescriptionsItem label="轮询策略">{{
getPaymentPoolStrategyLabel(detail.strategy)
}}</ElDescriptionsItem>
<ElDescriptionsItem
v-if="detail.strategy === 'amount' || detail.strategy === 'count'"
label="统计周期"
>
{{ getPaymentStatisticCycleLabel(detail.statistic_cycle) }}
</ElDescriptionsItem>
<ElDescriptionsItem v-if="detail.strategy === 'amount'" label="金额阈值">
{{ (Number(detail.threshold_amount) / 100).toFixed(2) }} 元
</ElDescriptionsItem>
<ElDescriptionsItem v-if="detail.strategy === 'count'" label="笔数阈值">
{{ detail.threshold_count }}
</ElDescriptionsItem>
<ElDescriptionsItem v-if="detail.strategy === 'time'" label="时间周期">
{{ detail.time_period_value }}
{{ getPaymentTimePeriodUnitLabel(detail.time_period_unit) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="启停状态">
{{ detail.enabled ? '启用' : '停用' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="路由世代">v{{ detail.routing_epoch }}</ElDescriptionsItem>
<ElDescriptionsItem label="备注">{{ detail.remark || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间">{{
formatDateTime(detail.updated_at)
}}</ElDescriptionsItem>
</ElDescriptions>
</ElDrawer>
</div>
</template>
<script setup lang="ts">
import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { computed, h, onMounted, onUnmounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Delete, Plus, Rank } from '@element-plus/icons-vue'
import { VueDraggable } from 'vue-draggable-plus'
import { ElButton, ElMessage, ElMessageBox, ElTag } from 'element-plus'
@@ -269,14 +233,25 @@
type PaymentPoolStrategy
} from '@/types/api/paymentMerchantPools'
import { formatDateTime } from '@/utils/business/format'
import { RoutesAlias } from '@/router/routesAlias'
defineOptions({ name: 'PaymentMerchantPoolManagement' })
type FilterVo = string | number | undefined | null | unknown[]
const { isPlatformAccount } = usePermission()
const router = useRouter()
const canManage = computed(() => isPlatformAccount.value)
const handleNameClick = (row: PaymentMerchantPool) => {
if (!canManage.value) {
ElMessage.warning('您没有查看商户池详情的权限')
return
}
router.push({ path: `${RoutesAlias.PaymentMerchantPoolDetail}/${row.id}` })
}
const searchForm = reactive<Record<string, FilterVo>>({
page: 1,
page_size: 10,
@@ -301,7 +276,24 @@
]
const columnOptions = [
{ label: '商户池名称', prop: 'name', minWidth: 180 },
{
label: '商户池名称',
prop: 'name',
minWidth: 180,
showOverflowTooltip: true,
formatter: (row: PaymentMerchantPool) =>
h(
'span',
{
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
onClick: (event: MouseEvent) => {
event.stopPropagation()
handleNameClick(row)
}
},
row.name
)
},
{
label: '支付方式',
prop: 'payment_method',
@@ -445,7 +437,7 @@
)
const getMemberName = (id: number) =>
availableMerchants.value.find((m) => m.id === id)?.name || `#${id}`
availableMerchants.value.find((m) => m.id === id)?.name || '未知商户'
// 表单
type FormMode = 'create' | 'edit'
@@ -475,16 +467,13 @@
const form = reactive(initialFormState())
const orderedMemberIds = ref<number[]>([])
watch(
() => form.member_ids,
(val) => {
orderedMemberIds.value = [...val]
},
{ immediate: true, deep: true }
)
watch(orderedMemberIds, (val) => {
form.member_ids = [...val]
// 成员顺序以 form.member_ids 为单一数据源:VueDraggable 的 v-model 直接写回该数组,
// 避免双向 watch 互相赋值导致的递归更新。
const orderedMemberIds = computed<number[]>({
get: () => form.member_ids,
set: (val) => {
form.member_ids = [...val]
}
})
const formRules = reactive<FormRules>({
@@ -611,7 +600,6 @@
const handlePaymentMethodChange = (value: PaymentMerchantMethod) => {
form.payment_method = value
form.member_ids = []
orderedMemberIds.value = []
availableMerchants.value = []
memberError.value = ''
loadAvailableMerchants(value)
@@ -688,7 +676,6 @@
const showCreateDrawer = async () => {
if (!canManage.value) return
Object.assign(form, initialFormState())
orderedMemberIds.value = []
formDrawerVisible.value = true
formMode.value = 'create'
await loadAvailableMerchants(form.payment_method)
@@ -712,14 +699,12 @@
time_period_started_at: pool.time_period_started_at || '',
remark: pool.remark || ''
})
orderedMemberIds.value = [...pool.member_ids]
formDrawerVisible.value = true
formMode.value = 'edit'
}
const resetForm = () => {
Object.assign(form, initialFormState())
orderedMemberIds.value = []
memberError.value = ''
pendingMemberId.value = undefined
formRef.value?.clearValidate()
@@ -753,23 +738,8 @@
}
}
// 详情
const detailDrawerVisible = ref(false)
const detail = ref<PaymentMerchantPool | null>(null)
const tableRef = ref()
const showDetail = async (id: number) => {
try {
const res = await PaymentMerchantPoolsService.getPaymentMerchantPoolById(id)
if (res.code === 0) {
detail.value = res.data
detailDrawerVisible.value = true
}
} catch (error) {
console.error('加载商户池详情失败:', error)
}
}
const toggleEnabled = async (pool: PaymentMerchantPool) => {
if (!canManage.value) return
const target = !pool.enabled
@@ -795,11 +765,6 @@
}
const getActions = (row: PaymentMerchantPool) => [
{
label: '详情',
type: 'primary' as const,
handler: () => showDetail(row.id)
},
{
label: '编辑',
type: 'primary' as const,
@@ -822,9 +787,7 @@
onUnmounted(() => {
pools.value = []
detail.value = null
availableMerchants.value = []
orderedMemberIds.value = []
})
</script>

View File

@@ -0,0 +1,297 @@
<template>
<div class="payment-merchant-detail-page">
<ElCard shadow="never">
<div class="detail-header">
<ElButton @click="handleBack">
<template #icon>
<ElIcon><ArrowLeft /></ElIcon>
</template>
返回
</ElButton>
<h2 class="detail-title">{{ pageTitle }}</h2>
</div>
<DetailPage v-if="detailData" :sections="detailSections" :data="detailData" />
<div v-if="loading" class="loading-container">
<ElIcon class="is-loading"><Loading /></ElIcon>
<span>加载中...</span>
</div>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElButton, ElCard, ElIcon } from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import DetailPage from '@/components/common/DetailPage.vue'
import type { DetailField, DetailSection } from '@/components/common/DetailPage.vue'
import { PaymentMerchantPoolsService } from '@/api/modules'
import type { PaymentMerchantDetail } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import { getPaymentMethodLabel, getPaymentProviderLabel } from '@/types/api/paymentMerchantPools'
defineOptions({ name: 'PaymentMerchantPoolsDetail' })
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const detailData = ref<PaymentMerchantDetail | null>(null)
const merchantId = computed(() => Number(route.params.id))
const pageTitle = computed(() => `支付商户详情 #${merchantId.value}`)
const formatText = (value: string | number | boolean | undefined | null): string => {
if (value === undefined || value === null || value === '') return '-'
return String(value)
}
const formatBoolean = (value: boolean): string => (value ? '是' : '否')
const formatCredentialStatus = (value: string | number | boolean | undefined | null): string => {
if (value === '已配置') return '已配置'
if (value === true || value === 1) return '已配置'
return '未配置'
}
const baseSection: DetailSection = {
title: '基本信息',
fields: [
{ label: '商户ID', prop: 'id' },
{ label: '商户名称', prop: 'name' },
{
label: '支付方式',
formatter: (_, data) => getPaymentMethodLabel(data.payment_method)
},
{
label: '服务商类型',
formatter: (_, data) => getPaymentProviderLabel(data.provider_type)
},
{ label: '商户标识', prop: 'merchant_identity' },
{
label: '启停状态',
formatter: (_, data) => (data.enabled ? '启用' : '停用')
},
{
label: '凭证状态',
formatter: (_, data) =>
Number(data.credential_version) > 0 ? '已配置' : '未配置'
},
{ label: '凭证版本', prop: 'credential_version' },
{
label: '备注',
prop: 'remark',
formatter: (value) => formatText(value),
fullWidth: true
},
{
label: '创建时间',
prop: 'created_at',
formatter: (value) => formatDateTime(value)
},
{
label: '更新时间',
prop: 'updated_at',
formatter: (value) => formatDateTime(value)
}
]
}
const alipayFields: DetailField[] = [
{
label: '支付宝AppID',
prop: 'credentials.ali_app_id',
formatter: (value) => formatText(value)
},
{
label: '支付过期分钟数',
prop: 'credentials.ali_pay_expire_minutes',
formatter: (value) => formatText(value)
},
{
label: '生产环境',
prop: 'credentials.ali_production',
formatter: (value) => formatBoolean(value)
},
{
label: '异步通知地址',
prop: 'credentials.ali_notify_url',
formatter: (value) => formatText(value),
fullWidth: true
},
{
label: '同步跳转地址',
prop: 'credentials.ali_return_url',
formatter: (value) => formatText(value),
fullWidth: true
},
{
label: '应用私钥',
prop: 'credentials.ali_private_key',
formatter: (value) => formatCredentialStatus(value)
},
{
label: '支付宝公钥',
prop: 'credentials.ali_public_key',
formatter: (value) => formatCredentialStatus(value)
}
]
const wechatPayFields: DetailField[] = [
{
label: '微信商户号',
prop: 'credentials.wx_mch_id',
formatter: (value) => formatText(value)
},
{
label: '证书序列号',
prop: 'credentials.wx_serial_no',
formatter: (value) => formatText(value)
},
{
label: '支付回调地址',
prop: 'credentials.wx_notify_url',
formatter: (value) => formatText(value),
fullWidth: true
},
{
label: 'APIv2密钥',
prop: 'credentials.wx_api_v2_key',
formatter: (value) => formatCredentialStatus(value)
},
{
label: 'APIv3密钥',
prop: 'credentials.wx_api_v3_key',
formatter: (value) => formatCredentialStatus(value)
},
{
label: '支付证书',
prop: 'credentials.wx_cert_content',
formatter: (value) => formatCredentialStatus(value)
},
{
label: '支付密钥',
prop: 'credentials.wx_key_content',
formatter: (value) => formatCredentialStatus(value)
}
]
const fuiouFields: DetailField[] = [
{
label: '富友API地址',
prop: 'credentials.fy_api_url',
formatter: (value) => formatText(value),
fullWidth: true
},
{
label: '富友机构号',
prop: 'credentials.fy_ins_cd',
formatter: (value) => formatText(value)
},
{
label: '富友商户号',
prop: 'credentials.fy_mchnt_cd',
formatter: (value) => formatText(value)
},
{
label: '富友终端号',
prop: 'credentials.fy_term_id',
formatter: (value) => formatText(value)
},
{
label: '支付回调地址',
prop: 'credentials.fy_notify_url',
formatter: (value) => formatText(value)
},
{
label: '富友私钥',
prop: 'credentials.fy_private_key',
formatter: (value) => formatCredentialStatus(value)
},
{
label: '富友公钥',
prop: 'credentials.fy_public_key',
formatter: (value) => formatCredentialStatus(value)
}
]
const credentialSections = computed<DetailSection[]>(() => {
if (!detailData.value) return []
const providerType = detailData.value.provider_type
if (providerType === 'alipay') {
return [{ title: '支付宝配置', fields: alipayFields }]
}
if (providerType === 'wechat' || providerType === 'wechat_v2') {
return [{ title: '微信支付配置', fields: wechatPayFields }]
}
if (providerType === 'fuiou') {
return [{ title: '富友支付配置', fields: fuiouFields }]
}
return []
})
const detailSections = computed<DetailSection[]>(() => [baseSection, ...credentialSections.value])
const handleBack = () => {
router.back()
}
const fetchDetail = async () => {
loading.value = true
try {
const res = await PaymentMerchantPoolsService.getPaymentMerchantDetailById(merchantId.value)
if (res.code === 0) {
detailData.value = res.data
}
} catch (error) {
console.error('加载支付商户详情失败:', error)
} finally {
loading.value = false
}
}
onMounted(() => {
fetchDetail()
})
</script>
<style scoped lang="scss">
.payment-merchant-detail-page {
padding: 20px;
}
.detail-header {
display: flex;
gap: 16px;
align-items: center;
padding-bottom: 16px;
.detail-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.loading-container {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--el-text-color-secondary);
.el-icon {
font-size: 32px;
}
}
</style>

View File

@@ -0,0 +1,243 @@
<template>
<div class="payment-merchant-pool-detail-page">
<ElCard shadow="never">
<div class="detail-header">
<ElButton @click="handleBack">
<template #icon>
<ElIcon><ArrowLeft /></ElIcon>
</template>
返回
</ElButton>
<h2 class="detail-title">商户池详情</h2>
</div>
<DetailPage v-if="detailData" :sections="detailSections" :data="detailData" />
<div v-if="loading" class="loading-container">
<ElIcon class="is-loading"><Loading /></ElIcon>
<span>加载中...</span>
</div>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElButton, ElCard, ElIcon } from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import DetailPage from '@/components/common/DetailPage.vue'
import type { DetailField, DetailSection } from '@/components/common/DetailPage.vue'
import { PaymentMerchantPoolsService } from '@/api/modules'
import type {
PaymentMerchantMethod,
PaymentMerchantPool,
PaymentMerchantPoolMemberOption
} from '@/types/api'
import {
getPaymentMethodLabel,
getPaymentPoolStrategyLabel,
getPaymentStatisticCycleLabel,
getPaymentTimePeriodUnitLabel
} from '@/types/api/paymentMerchantPools'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'PaymentMerchantPoolDetail' })
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const detailData = ref<PaymentMerchantPool | null>(null)
const memberMerchants = ref<PaymentMerchantPoolMemberOption[]>([])
const poolId = computed(() => Number(route.params.id))
const loadMemberMerchants = async (paymentMethod: PaymentMerchantMethod) => {
try {
const res = await PaymentMerchantPoolsService.getPaymentMerchants({
page: 1,
page_size: 100,
payment_method: paymentMethod
})
if (res.code === 0) {
memberMerchants.value = (res.data?.items || []).map((item) => ({
id: item.id,
name: item.name,
payment_method: item.payment_method,
enabled: item.enabled
}))
}
} catch (error) {
console.error('加载商户池成员商户失败:', error)
}
}
// 成员只展示商户名称,不展示 member_ids 原始 ID
const memberNamesText = computed(() => {
const memberIds = detailData.value?.member_ids || []
if (memberIds.length === 0) return '-'
return memberIds
.map(
(memberId) => memberMerchants.value.find((item) => item.id === memberId)?.name || '未知商户'
)
.join('、')
})
const formatText = (value: string | number | undefined | null): string => {
if (value === undefined || value === null || value === '') return '-'
return String(value)
}
const baseSection: DetailSection = {
title: '基本信息',
fields: [
{ label: '商户池名称', prop: 'name' },
{
label: '支付方式',
formatter: (_, data) => getPaymentMethodLabel(data.payment_method)
},
{
label: '启停状态',
formatter: (_, data) => (data.enabled ? '启用' : '停用')
},
{
label: '成员数量',
formatter: (_, data) => `${(data.member_ids || []).length} 个`
},
{
label: '成员商户',
formatter: () => memberNamesText.value,
fullWidth: true
},
{
label: '备注',
prop: 'remark',
formatter: (value) => formatText(value),
fullWidth: true
}
]
}
const rotationFields = computed<DetailField[]>(() => {
const data = detailData.value
if (!data) return []
const fields: DetailField[] = [
{
label: '轮询策略',
formatter: (_, row) => getPaymentPoolStrategyLabel(row.strategy)
}
]
if (data.strategy === 'amount') {
fields.push({
label: '统计周期',
formatter: (_, row) => getPaymentStatisticCycleLabel(row.statistic_cycle)
})
fields.push({
label: '金额阈值',
formatter: (_, row) => `${(Number(row.threshold_amount) / 100).toFixed(2)} 元`
})
} else if (data.strategy === 'count') {
fields.push({
label: '统计周期',
formatter: (_, row) => getPaymentStatisticCycleLabel(row.statistic_cycle)
})
fields.push({ label: '笔数阈值', formatter: (_, row) => `${row.threshold_count} 笔` })
} else if (data.strategy === 'time') {
fields.push({
label: '时间周期',
formatter: (_, row) =>
`${row.time_period_value} ${getPaymentTimePeriodUnitLabel(row.time_period_unit)}`
})
fields.push({
label: '时间起点',
formatter: (_, row) => formatDateTime(row.time_period_started_at)
})
}
return fields
})
const runtimeFields = computed<DetailField[]>(() => {
const data = detailData.value
if (!data) return []
const fields: DetailField[] = [
{ label: '路由世代', formatter: (_, row) => `v${row.routing_epoch}` }
]
if (data.created_at) {
fields.push({ label: '创建时间', formatter: (_, row) => formatDateTime(row.created_at) })
}
if (data.updated_at) {
fields.push({ label: '更新时间', formatter: (_, row) => formatDateTime(row.updated_at) })
}
return fields
})
const detailSections = computed<DetailSection[]>(() => [
baseSection,
{ title: '轮询配置', fields: rotationFields.value },
{ title: '运行状态', fields: runtimeFields.value }
])
const handleBack = () => {
router.back()
}
const fetchDetail = async () => {
loading.value = true
try {
const res = await PaymentMerchantPoolsService.getPaymentMerchantPoolById(poolId.value)
if (res.code === 0) {
detailData.value = res.data
await loadMemberMerchants(res.data.payment_method)
}
} catch (error) {
console.error('加载商户池详情失败:', error)
} finally {
loading.value = false
}
}
onMounted(() => {
fetchDetail()
})
</script>
<style scoped lang="scss">
.payment-merchant-pool-detail-page {
padding: 20px;
}
.detail-header {
display: flex;
gap: 16px;
align-items: center;
padding-bottom: 16px;
.detail-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.loading-container {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--el-text-color-secondary);
.el-icon {
font-size: 32px;
}
}
</style>

View File

@@ -68,7 +68,7 @@
v-for="item in currentConfig?.enum_values"
:key="item"
:value="item"
:label="item"
:label="formatEnumLabel(item)"
/>
</ElSelect>
<ElInput
@@ -105,7 +105,7 @@
</template>
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue'
import { computed, h, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElTag } from 'element-plus'
import { SystemConfigService } from '@/api/modules'
@@ -131,7 +131,21 @@
{ label: 'C 端支付方式配置', value: 'c2b.payment' }
]
const searchForm = reactive<{ module?: SystemConfigModule }>({ module: undefined })
const configEnumLabels: Record<string, string> = {
wechat_only: '仅微信支付',
alipay_only: '仅支付宝支付',
both: '同时支持微信与支付宝'
}
const formatEnumLabel = (value: string) => configEnumLabels[value] || value
const isSystemConfigModule = (value: unknown): value is SystemConfigModule =>
moduleOptions.some((item) => item.value === value)
const queryModule = String(route.query.module || '')
const searchForm = reactive<{ module?: SystemConfigModule }>({
module: isSystemConfigModule(queryModule) ? queryModule : undefined
})
const searchFormItems: SearchFormItem[] = [
{
label: '配置模块',
@@ -252,6 +266,9 @@
const methods = parsePaymentMethods(config.value)
return methods.length ? methods.map((method) => paymentMethodLabels[method]).join('、') : '-'
}
if (config.enum_values?.length) {
return formatEnumLabel(config.value) || '-'
}
return config.value || '-'
}
@@ -392,10 +409,16 @@
loadConfigs()
}
// 菜单跳转可能只变更 query(同一组件实例复用),需要跟随 module 变化重新筛选
watch(
() => route.query.module,
(value) => {
const module = String(value || '')
searchForm.module = isSystemConfigModule(module) ? module : undefined
pagination.page = 1
loadConfigs()
}
)
onMounted(loadConfigs)
</script>
<style scoped lang="scss">
.system-configs-page {
}
</style>

View File

@@ -64,6 +64,7 @@
>
<ElOption label="退款审批" value="refund_approval" />
<ElOption label="线下代充值审批" value="offline_recharge_approval" />
<ElOption label="员工代收款审批" value="employee_collection_approval" />
</ElSelect>
</ElFormItem>
</ElCol>