This commit is contained in:
2026-09-19 17:02:11 +08:00
parent 4eb17ece4f
commit 514abf6eda
33 changed files with 525 additions and 223 deletions
+3 -9
View File
@@ -61,14 +61,12 @@ export interface AccountLedgerEntry {
createdAt: string
}
export type RechargePaymentMethod = 'WECHAT' | 'ALIPAY'
export type RechargeStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
export interface RechargeRequest {
requestId: string
requestNo: string
amount: string
paymentMethod: RechargePaymentMethod
voucherFileId: string
voucherFileName: string
status: RechargeStatus
@@ -81,8 +79,6 @@ export interface RechargeRequest {
}
export interface RechargeChannel {
paymentMethod: RechargePaymentMethod
displayName: string
enabled: boolean
qrCodeUrl: string | null
}
@@ -109,15 +105,13 @@ export async function getRechargeRequests(page: number, size: number) {
return normalizePageResult(result)
}
export function getRechargeChannels() {
return apiRequest<RechargeChannel[]>({ method: 'GET', url: '/client/finance/v1/recharge-channels' })
export function getRechargeChannel() {
return apiRequest<RechargeChannel>({ method: 'GET', url: '/client/finance/v1/recharge-channel' })
}
export function createRechargeRequest(amount: string, paymentMethod: RechargePaymentMethod, file: File,
idempotencyKey: string) {
export function createRechargeRequest(amount: string, file: File, idempotencyKey: string) {
const formData = new FormData()
formData.append('amount', amount)
formData.append('paymentMethod', paymentMethod)
formData.append('file', file, file.name)
return apiRequest<RechargeRequest>({
method: 'POST',
@@ -8,13 +8,13 @@ const routesSource = readFileSync(new URL('../routes.ts', import.meta.url), 'utf
const permissionsSource = readFileSync(new URL('../../tenant-management/contracts.ts', import.meta.url), 'utf8')
test('uses only authenticated client finance account endpoints without customer scope', () => {
for (const path of ['/client/finance/v1/account', '/client/finance/v1/account/ledger', '/client/finance/v1/recharge-requests', '/client/finance/v1/recharge-channels']) {
for (const path of ['/client/finance/v1/account', '/client/finance/v1/account/ledger', '/client/finance/v1/recharge-requests', '/client/finance/v1/recharge-channel']) {
assert.match(apiSource, new RegExp(path.replaceAll('/', '\\/')))
}
assert.match(apiSource, /headers: \{ 'Idempotency-Key': idempotencyKey \}/)
assert.match(apiSource, /formData\.append\('amount', amount\)/)
assert.match(apiSource, /formData\.append\('paymentMethod', paymentMethod\)/)
assert.match(apiSource, /formData\.append\('file', file, file\.name\)/)
assert.doesNotMatch(apiSource, /RechargePaymentMethod|paymentMethod|WECHAT|ALIPAY/)
assert.doesNotMatch(apiSource, /customerId|tenantId|mock|fallback/i)
})
@@ -23,12 +23,14 @@ test('activates the account route and maps read and recharge permissions indepen
assert.match(permissionsSource, /'merchant:finance:account:view': \['merchant:finance:account:view'\]/)
assert.match(permissionsSource, /'merchant:finance:recharge:create': \['merchant:finance:recharge:create'\]/)
assert.match(viewSource, /auth\.hasPermission\('merchant:finance:recharge:create'\)/)
assert.match(viewSource, /<el-button v-if="canRecharge"/)
assert.match(viewSource, /class="balance-title"[\s\S]*?<el-button v-if="canRecharge" size="small" type="primary"/)
assert.match(viewSource, /\.balance-summary \{[^}]*grid-template-columns: repeat\(6,/)
assert.doesNotMatch(viewSource, /class="account-actions"|class="balance-grid"/)
assert.doesNotMatch(viewSource, /class="account-heading"/)
})
test('renders server balances, ledger and recharge states without internal identifiers', () => {
for (const field of ['actualBalance', 'frozenBalance', 'availableBalance', 'creditLimit', 'creditUsed', 'availableCredit']) assert.match(viewSource, new RegExp(`account\\?\\.${field}`))
for (const field of ['creditUsedAfter', 'availableCreditAfter']) assert.match(viewSource, new RegExp(`row\\.${field}`))
assert.match(viewSource, /实际可用资金与可用授信合计/)
assert.match(viewSource, /CREDIT_LIMIT: '授信调整'/)
assert.match(viewSource, /row-key="businessNo"/)
@@ -39,9 +41,12 @@ test('renders server balances, ledger and recharge states without internal ident
assert.match(viewSource, /v-loading="pageLoading"/)
assert.match(viewSource, /function formatAccountAmount[\s\S]*value == null \? '—' : formatAmount\(value\)/)
assert.match(viewSource, /formatAccountAmount\(account\?\.actualBalance\)/)
assert.match(viewSource, /:default-current-page="ledgerPage"/)
assert.match(viewSource, /:default-current-page="rechargePage"/)
assert.doesNotMatch(viewSource, /<el-pagination (?:v-model:current-page|:current-page)=/)
assert.match(viewSource, /<DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize"/)
assert.match(viewSource, /<DataTable v-model:page="rechargePage" v-model:page-size="rechargePageSize"/)
assert.match(viewSource, /const ledgerColumns: DataTableColumn\[]/)
assert.match(viewSource, /const rechargeColumns: DataTableColumn\[]/)
assert.doesNotMatch(viewSource, /label: '已用授信'|label: '可用授信'/)
assert.doesNotMatch(viewSource, /<el-table|<el-pagination/)
assert.match(apiSource, /function normalizePageResult<[\s\S]*total: Number\(result\.total\)/)
assert.match(apiSource, /page: Number\(result\.page\)/)
assert.doesNotMatch(viewSource, /requestId|ledgerId|customerId|tenantId/)
@@ -50,12 +55,14 @@ test('renders server balances, ledger and recharge states without internal ident
test('matches the frozen recharge workflow and preserves an idempotency key for failed retries', () => {
assert.match(viewSource, /title="账户充值" width="760px"/)
for (const amount of ['50.00', '100.00', '200.00', '500.00']) assert.match(viewSource, new RegExp(amount.replace('.', '\\.')))
assert.match(viewSource, /\['WECHAT', 'ALIPAY'\]/)
assert.match(viewSource, /selectedChannel\.value\?\.enabled && selectedChannel\.value\.qrCodeUrl/)
assert.match(viewSource, /rechargeChannel\.value\?\.enabled && rechargeChannel\.value\.qrCodeUrl/)
assert.match(viewSource, /alt="平台收款二维码"/)
assert.match(viewSource, /请扫描平台收款二维码,付款后上传截图。/)
assert.match(viewSource, /accept="\.jpg,\.jpeg,\.png,image\/jpeg,image\/png"/)
assert.match(viewSource, /if \(!pendingIdempotencyKey\)[\s\S]*globalThis\.crypto\?\.randomUUID\?\.\(\)/)
assert.match(viewSource, /pendingIdempotencyKey = ''[\s\S]*rechargeVisible\.value = false/)
assert.match(viewSource, /:disabled="!canSubmit"/)
assert.match(viewSource, /await loadAccountData\(\)/)
assert.doesNotMatch(viewSource, /payment SDK|支付成功|模拟成功/)
assert.doesNotMatch(viewSource, /微信支付|支付宝|payment-methods|paymentMethod/)
})
@@ -3,18 +3,18 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { CircleDollarSign, RefreshCcw, Upload } from '@lucide/vue'
import { ElMessage, type UploadFile, type UploadFiles, type UploadInstance } from 'element-plus'
import { ApiError } from '@/api/http'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import { useAuthStore } from '@/modules/auth/public'
import {
createRechargeRequest,
getAccountLedger,
getFinanceAccount,
getRechargeChannels,
getRechargeChannel,
getRechargeRequests,
type AccountChangeType,
type AccountLedgerEntry,
type FinanceAccount,
type RechargeChannel,
type RechargePaymentMethod,
type RechargeRequest,
type RechargeStatus,
} from '../api/account'
@@ -23,15 +23,16 @@ const auth = useAuthStore()
const account = ref<FinanceAccount | null>(null)
const ledger = ref<AccountLedgerEntry[]>([])
const rechargeRequests = ref<RechargeRequest[]>([])
const channels = ref<RechargeChannel[]>([])
const rechargeChannel = ref<RechargeChannel | null>(null)
const pageLoading = ref(false)
const pageError = ref('')
const channelsError = ref('')
const ledgerPage = ref(1)
const ledgerTotal = ref(0)
const ledgerPageSize = ref(10)
const rechargePage = ref(1)
const rechargeTotal = ref(0)
const pageSize = 10
const rechargePageSize = ref(10)
const rechargeVisible = ref(false)
const submitting = ref(false)
const submitError = ref('')
@@ -39,11 +40,10 @@ const voucherFile = ref<File | null>(null)
const voucherUpload = ref<UploadInstance>()
let pendingIdempotencyKey = ''
const form = reactive({ amount: '100.00', paymentMethod: 'WECHAT' as RechargePaymentMethod })
const form = reactive({ amount: '100.00' })
const quickAmounts = ['50.00', '100.00', '200.00', '500.00']
const canRecharge = computed(() => auth.hasPermission('merchant:finance:recharge:create'))
const selectedChannel = computed(() => channels.value.find((item) => item.paymentMethod === form.paymentMethod))
const channelReady = computed(() => Boolean(selectedChannel.value?.enabled && selectedChannel.value.qrCodeUrl))
const channelReady = computed(() => Boolean(rechargeChannel.value?.enabled && rechargeChannel.value.qrCodeUrl))
const amountValid = computed(() => {
const value = Number(form.amount)
return Number.isFinite(value) && value > 0 && /^\d+(?:\.\d{1,2})?$/.test(form.amount.trim())
@@ -54,7 +54,27 @@ const changeLabels: Record<AccountChangeType, string> = {
RECHARGE: '充值', DEDUCTION: '扣款', FREEZE: '冻结', UNFREEZE: '解冻', CONSUMPTION: '消费', CREDIT_LIMIT: '授信调整',
}
const statusLabels: Record<RechargeStatus, string> = { PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' }
const methodLabels: Record<RechargePaymentMethod, string> = { WECHAT: '微信支付', ALIPAY: '支付宝' }
const ledgerColumns: DataTableColumn[] = [
{ label: '业务单号', prop: 'businessNo', minWidth: 180 },
{ label: '变动类型', slotName: 'changeType', width: 100 },
{ label: '变动金额', slotName: 'amount', width: 120, align: 'right' },
{ label: '实际余额', slotName: 'actualBalance', width: 120, align: 'right' },
{ label: '冻结金额', slotName: 'frozenBalance', width: 120, align: 'right' },
{ label: '可用余额', slotName: 'availableBalance', width: 120, align: 'right' },
{ label: '操作人', slotName: 'operatorName', width: 120 },
{ label: '备注', slotName: 'remark', minWidth: 180 },
{ label: '发生时间', slotName: 'createdAt', width: 160 },
]
const rechargeColumns: DataTableColumn[] = [
{ label: '充值申请单号', prop: 'requestNo', minWidth: 180 },
{ label: '金额', slotName: 'amount', width: 120, align: 'right' },
{ label: '付款截图', prop: 'voucherFileName', minWidth: 150 },
{ label: '状态', slotName: 'status', width: 100 },
{ label: '提交人', slotName: 'submittedByName', width: 120 },
{ label: '审核备注', slotName: 'reviewRemark', minWidth: 180 },
{ label: '提交时间', slotName: 'createdAt', width: 160 },
{ label: '审核时间', slotName: 'reviewedAt', width: 160 },
]
function messageOf(error: unknown, fallback: string) {
return error instanceof ApiError ? error.message : fallback
@@ -87,7 +107,7 @@ async function loadAccountData() {
pageLoading.value = true
pageError.value = ''
channelsError.value = ''
const requests = [getFinanceAccount(), getAccountLedger(ledgerPage.value, pageSize), getRechargeRequests(rechargePage.value, pageSize)] as const
const requests = [getFinanceAccount(), getAccountLedger(ledgerPage.value, ledgerPageSize.value), getRechargeRequests(rechargePage.value, rechargePageSize.value)] as const
try {
const [accountResult, ledgerResult, rechargeResult] = await Promise.all(requests)
account.value = accountResult
@@ -107,18 +127,17 @@ async function loadAccountData() {
}
if (canRecharge.value) {
try {
channels.value = await getRechargeChannels()
rechargeChannel.value = await getRechargeChannel()
} catch (error) {
channels.value = []
channelsError.value = messageOf(error, '充值渠道加载失败,请稍后重试')
rechargeChannel.value = null
channelsError.value = messageOf(error, '收款二维码加载失败,请稍后重试')
}
}
}
async function changeLedgerPage(nextPage: number) {
ledgerPage.value = nextPage
try {
const result = await getAccountLedger(nextPage, pageSize)
const result = await getAccountLedger(nextPage, ledgerPageSize.value)
ledger.value = result.records
ledgerTotal.value = result.total
} catch (error) {
@@ -127,9 +146,8 @@ async function changeLedgerPage(nextPage: number) {
}
async function changeRechargePage(nextPage: number) {
rechargePage.value = nextPage
try {
const result = await getRechargeRequests(nextPage, pageSize)
const result = await getRechargeRequests(nextPage, rechargePageSize.value)
rechargeRequests.value = result.records
rechargeTotal.value = result.total
} catch (error) {
@@ -175,7 +193,7 @@ async function submitRecharge() {
return
}
if (!channelReady.value) {
submitError.value = `${methodLabels[form.paymentMethod]}收款二维码尚未配置,暂不能提交充值申请`
submitError.value = '收款二维码尚未配置,暂不能提交充值申请'
return
}
if (!voucherFile.value) {
@@ -187,7 +205,7 @@ async function submitRecharge() {
}
submitting.value = true
try {
await createRechargeRequest(normalizedAmount(), form.paymentMethod, voucherFile.value, pendingIdempotencyKey)
await createRechargeRequest(normalizedAmount(), voucherFile.value, pendingIdempotencyKey)
pendingIdempotencyKey = ''
rechargeVisible.value = false
voucherFile.value = null
@@ -201,28 +219,26 @@ async function submitRecharge() {
}
}
watch([() => form.amount, () => form.paymentMethod, voucherFile], () => {
watch([() => form.amount, voucherFile], () => {
if (!submitting.value) pendingIdempotencyKey = ''
submitError.value = ''
})
watch([ledgerPage, ledgerPageSize], () => { void changeLedgerPage(ledgerPage.value) })
watch([rechargePage, rechargePageSize], () => { void changeRechargePage(rechargePage.value) })
onMounted(() => { void loadAccountData() })
</script>
<template>
<div v-loading="pageLoading" class="finance-account-page" :aria-busy="pageLoading">
<div class="account-heading">
<div><h2>资金账户</h2><p>查看本企业 CNY 资金、授信额度、账户流水和充值申请状态。</p></div>
<el-button v-if="canRecharge" type="primary" @click="openRecharge"><CircleDollarSign :size="17" />账户充值</el-button>
</div>
<div v-if="pageError" class="account-state account-state--error" role="alert">
<p>{{ pageError }}</p><el-button @click="loadAccountData"><RefreshCcw :size="15" />重新加载</el-button>
</div>
<template v-else>
<section class="balance-grid" aria-label="账户余额">
<article><span>实际余额</span><strong>¥ {{ formatAccountAmount(account?.actualBalance) }}</strong><small>当前账户实际资金</small></article>
<section class="balance-summary" aria-label="账户余额">
<article><div class="balance-title"><span>实际余额</span><el-button v-if="canRecharge" size="small" type="primary" @click="openRecharge"><CircleDollarSign :size="15" />账户充值</el-button></div><strong>¥ {{ formatAccountAmount(account?.actualBalance) }}</strong><small>当前账户实际资金</small></article>
<article><span>冻结金额</span><strong>¥ {{ formatAccountAmount(account?.frozenBalance) }}</strong><small>暂不可使用的资金</small></article>
<article class="balance-available"><span>总可用余额</span><strong>¥ {{ formatAccountAmount(account?.availableBalance) }}</strong><small>实际可用资金与可用授信合计</small></article>
<article><span>授信额度</span><strong>¥ {{ formatAccountAmount(account?.creditLimit) }}</strong><small>{{ account?.creditExpiresOn ? `${account.creditExpiresOn} ${account.creditActive ? '到期' : '已到期'}` : '未设置有效期' }}</small></article>
@@ -232,36 +248,28 @@ onMounted(() => { void loadAccountData() })
<section class="account-card">
<div class="section-heading"><div><h3>账户流水</h3><p>每次资金变动均生成唯一业务单号。</p></div></div>
<el-table :data="ledger" row-key="businessNo" empty-text="暂无资金流水">
<el-table-column prop="businessNo" label="业务单号" min-width="190" />
<el-table-column label="变动类型" width="110"><template #default="{ row }">{{ changeLabels[row.changeType as AccountChangeType] }}</template></el-table-column>
<el-table-column label="变动金额" width="145" align="right"><template #default="{ row }"><strong :class="amountClass(row.amount)">{{ Number(row.amount) >= 0 ? '+' : '' }}¥ {{ formatAmount(row.amount) }}</strong></template></el-table-column>
<el-table-column label="实际余额" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.actualBalanceAfter) }}</template></el-table-column>
<el-table-column label="冻结金额" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.frozenBalanceAfter) }}</template></el-table-column>
<el-table-column label="可用余额" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.availableBalanceAfter) }}</template></el-table-column>
<el-table-column label="已用授信" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.creditUsedAfter) }}</template></el-table-column>
<el-table-column label="可用授信" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.availableCreditAfter) }}</template></el-table-column>
<el-table-column prop="operatorName" label="操作人" min-width="130"><template #default="{ row }">{{ row.operatorName || '—' }}</template></el-table-column>
<el-table-column prop="remark" label="备注" min-width="180"><template #default="{ row }">{{ row.remark || '—' }}</template></el-table-column>
<el-table-column label="发生时间" width="170"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
</el-table>
<div v-if="ledgerTotal" class="pagination-row"><span>共 {{ ledgerTotal }} 条</span><el-pagination :default-current-page="ledgerPage" :page-size="pageSize" layout="prev, pager, next" :total="ledgerTotal" @current-change="changeLedgerPage" /></div>
<DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" :data="ledger" :columns="ledgerColumns" :total="ledgerTotal" :page-sizes="[10, 20, 50]" row-key="businessNo" empty-text="暂无资金流水">
<template #changeType="{ row }">{{ changeLabels[row.changeType as AccountChangeType] }}</template>
<template #amount="{ row }"><strong :class="amountClass(row.amount)">{{ Number(row.amount) >= 0 ? '+' : '' }}¥ {{ formatAmount(row.amount) }}</strong></template>
<template #actualBalance="{ row }">¥ {{ formatAmount(row.actualBalanceAfter) }}</template>
<template #frozenBalance="{ row }">¥ {{ formatAmount(row.frozenBalanceAfter) }}</template>
<template #availableBalance="{ row }">¥ {{ formatAmount(row.availableBalanceAfter) }}</template>
<template #operatorName="{ row }">{{ row.operatorName || '—' }}</template>
<template #remark="{ row }">{{ row.remark || '—' }}</template>
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</DataTable>
</section>
<section class="account-card">
<div class="section-heading"><div><h3>充值申请</h3><p>付款后上传截图,平台审核通过后资金入账。</p></div></div>
<el-table :data="rechargeRequests" row-key="requestNo" empty-text="暂无充值申请">
<el-table-column prop="requestNo" label="充值申请单号" min-width="190" />
<el-table-column label="金额" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.amount) }}</template></el-table-column>
<el-table-column label="付款方式" width="120"><template #default="{ row }">{{ methodLabels[row.paymentMethod as RechargePaymentMethod] }}</template></el-table-column>
<el-table-column prop="voucherFileName" label="付款截图" min-width="170" />
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.status as RechargeStatus)">{{ statusLabels[row.status as RechargeStatus] }}</el-tag></template></el-table-column>
<el-table-column prop="submittedByName" label="提交人" min-width="120"><template #default="{ row }">{{ row.submittedByName || '—' }}</template></el-table-column>
<el-table-column prop="reviewRemark" label="审核备注" min-width="180"><template #default="{ row }">{{ row.reviewRemark || '—' }}</template></el-table-column>
<el-table-column label="提交时间" width="170"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
<el-table-column label="审核时间" width="170"><template #default="{ row }">{{ formatDateTime(row.reviewedAt) }}</template></el-table-column>
</el-table>
<div v-if="rechargeTotal" class="pagination-row"><span>共 {{ rechargeTotal }} 条</span><el-pagination :default-current-page="rechargePage" :page-size="pageSize" layout="prev, pager, next" :total="rechargeTotal" @current-change="changeRechargePage" /></div>
<DataTable v-model:page="rechargePage" v-model:page-size="rechargePageSize" :data="rechargeRequests" :columns="rechargeColumns" :total="rechargeTotal" :page-sizes="[10, 20, 50]" row-key="requestNo" empty-text="暂无充值申请">
<template #amount="{ row }">¥ {{ formatAmount(row.amount) }}</template>
<template #status="{ row }"><el-tag :type="statusType(row.status as RechargeStatus)">{{ statusLabels[row.status as RechargeStatus] }}</el-tag></template>
<template #submittedByName="{ row }">{{ row.submittedByName || '—' }}</template>
<template #reviewRemark="{ row }">{{ row.reviewRemark || '—' }}</template>
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
<template #reviewedAt="{ row }">{{ formatDateTime(row.reviewedAt) }}</template>
</DataTable>
</section>
</template>
@@ -273,16 +281,12 @@ onMounted(() => { void loadAccountData() })
<button v-for="amount in quickAmounts" :key="amount" type="button" :class="{ active: form.amount === amount }" @click="chooseAmount(amount)">¥{{ Number(amount) }}</button>
</div>
<div class="payment-methods" aria-label="付款方式">
<button v-for="method in (['WECHAT', 'ALIPAY'] as RechargePaymentMethod[])" :key="method" type="button" :class="[`payment-${method.toLowerCase()}`, { active: form.paymentMethod === method }]" @click="form.paymentMethod = method"><span>{{ method === 'WECHAT' ? '微' : '支' }}</span>{{ methodLabels[method] }}</button>
</div>
<div v-if="channelsError" class="channel-warning" role="alert">{{ channelsError }}</div>
<div v-else-if="channelReady" class="qr-panel">
<img :src="selectedChannel?.qrCodeUrl || ''" :alt="`${methodLabels[form.paymentMethod]}收款二维码`">
<p>请使用{{ methodLabels[form.paymentMethod] }}扫码完成付款</p>
<img :src="rechargeChannel?.qrCodeUrl || ''" alt="平台收款二维码">
<div><strong>扫码付款</strong><p>请扫描平台收款二维码,付款后上传截图。</p></div>
</div>
<div v-else class="channel-warning" role="status">{{ methodLabels[form.paymentMethod] }}收款二维码尚未配置,暂不能提交充值申请。</div>
<div v-else class="channel-warning" role="status">收款二维码尚未配置,暂不能提交充值申请。</div>
<div class="voucher-upload">
<span class="field-label">付款截图</span>
@@ -300,18 +304,21 @@ onMounted(() => { void loadAccountData() })
<style scoped>
.finance-account-page { display: flex; min-height: 420px; flex-direction: column; gap: 16px; }
.account-heading, .section-heading, .pagination-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.account-heading h2, .section-heading h3 { margin: 0; color: var(--navy); }.account-heading h2 { font-size: 22px; }.section-heading h3 { font-size: 16px; }
.account-heading p, .section-heading p { margin: 5px 0 0; color: var(--muted); font-size: 13px; }.account-heading .el-button { display: inline-flex; align-items: center; gap: 6px; }
.balance-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
.balance-grid article { display: flex; min-height: 132px; flex-direction: column; padding: 22px 24px; border: 1px solid var(--border); border-radius: 10px; background: #fff; }
.balance-grid span { color: var(--muted); font-size: 13px; }.balance-grid strong { margin-top: 14px; color: var(--navy); font-size: 28px; line-height: 1; }.balance-grid small { margin-top: auto; color: #8a98a8; }.balance-grid .balance-available { border-color: #b9e1db; background: #f3faf8; }.balance-grid .balance-available strong { color: #058b78; }
.account-card { min-width: 0; padding: 18px; border: 1px solid var(--border); border-radius: 8px; background: #fff; }.section-heading { margin-bottom: 14px; }
.pagination-row { padding-top: 14px; color: var(--muted); font-size: 12px; }.amount-positive { color: #078a72; }.amount-negative { color: #b42318; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.section-heading h3 { margin: 0; color: var(--navy); font-size: 16px; }
.section-heading p { margin: 5px 0 0; color: var(--muted); font-size: 13px; }
.balance-summary { display: grid; overflow: hidden; grid-template-columns: repeat(6, minmax(0, 1fr)); border: 1px solid var(--client-border); border-radius: var(--client-radius); background: #fff; }
.balance-summary article { display: flex; min-width: 0; min-height: 104px; flex-direction: column; padding: 14px 16px; border-right: 1px solid var(--client-border); }
.balance-summary article:last-child { border-right: 0; }
.balance-summary span { color: var(--muted); font-size: 12px; }.balance-summary strong { margin-top: 10px; overflow: hidden; color: var(--navy); font-size: 22px; line-height: 1.1; text-overflow: ellipsis; white-space: nowrap; }.balance-summary small { margin-top: auto; overflow: hidden; color: #8a98a8; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.balance-title { display: flex; min-height: 28px; align-items: center; justify-content: space-between; gap: 8px; }.balance-title .el-button { flex: 0 0 auto; height: 28px; padding: 0 9px; }.balance-title .el-button :deep(span) { display: inline-flex; align-items: center; gap: 4px; color: inherit; }
.balance-summary .balance-available { background: #f3faf8; }.balance-summary .balance-available strong { color: #058b78; }
.account-card { min-width: 0; overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: #fff; }.section-heading { padding: 14px 16px; border-bottom: 1px solid var(--client-border); }
.amount-positive { color: #078a72; }.amount-negative { color: #b42318; }
.account-state { display: flex; min-height: 240px; align-items: center; justify-content: center; flex-direction: column; gap: 12px; border-radius: 8px; }.account-state--error { border: 1px solid #f5c2bd; background: #fff8f7; color: #b42318; }.account-state p { margin: 0; }
.recharge-form { display: flex; flex-direction: column; gap: 16px; }.field-label { color: #4e6075; font-size: 14px; font-weight: 650; }
.quick-amounts, .payment-methods { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }.quick-amounts button, .payment-methods button { min-height: 54px; border: 1px solid #dbe3ed; border-radius: 10px; background: #f8fafc; color: #506077; cursor: pointer; font: inherit; }.quick-amounts button.active, .payment-methods button.active { border: 2px solid #1671c9; background: #eef8f7; color: #1467b8; font-weight: 700; }
.payment-methods { grid-template-columns: repeat(2, 1fr); }.payment-methods button { display: flex; align-items: center; justify-content: center; gap: 10px; font-size: 17px; font-weight: 650; }.payment-methods span { display: grid; width: 26px; height: 26px; place-items: center; border-radius: 7px; background: #1685ef; color: #fff; font-size: 14px; }.payment-wechat span { background: #09ae42; }
.qr-panel { display: flex; align-items: center; flex-direction: column; gap: 14px; padding: 22px; border: 1px dashed #bed0e4; border-radius: 12px; background: #f8fafc; }.qr-panel img { width: 210px; height: 210px; border: 1px solid #dae3ed; border-radius: 10px; background: #fff; object-fit: contain; }.qr-panel p { margin: 0; color: #62758d; }
.quick-amounts { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }.quick-amounts button { min-height: 54px; border: 1px solid #dbe3ed; border-radius: 10px; background: #f8fafc; color: #506077; cursor: pointer; font: inherit; }.quick-amounts button.active { border: 2px solid #1671c9; background: #eef8f7; color: #1467b8; font-weight: 700; }
.qr-panel { display: grid; grid-template-columns: 210px minmax(0, 1fr); align-items: center; gap: 24px; padding: 20px; border: 1px solid #dbe3ed; border-radius: 12px; background: #f8fafc; }.qr-panel img { width: 210px; height: 210px; border: 1px solid #dae3ed; border-radius: 10px; background: #fff; object-fit: contain; }.qr-panel strong { color: var(--navy); font-size: 17px; }.qr-panel p { margin: 8px 0 0; color: #62758d; }
.channel-warning { padding: 24px; border: 1px solid #f0d39b; border-radius: 10px; background: #fff9ed; color: #8b5b12; text-align: center; }.voucher-upload { display: grid; gap: 10px; }.voucher-upload :deep(.el-button) { display: inline-flex; align-items: center; gap: 6px; }.submit-error { margin: 0; color: #b42318; font-size: 13px; }.dialog-actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }.dialog-actions .el-button { min-height: 46px; margin: 0; }
@media (max-width: 1200px) { .balance-summary { grid-template-columns: repeat(3, minmax(0, 1fr)); }.balance-summary article { border-bottom: 1px solid var(--client-border); }.balance-summary article:nth-child(3n) { border-right: 0; }.balance-summary article:nth-last-child(-n + 3) { border-bottom: 0; } }
</style>
@@ -119,8 +119,9 @@ test('requires both finance statement authorities for the page permission', () =
})
test('keeps actions first and exposes list, balance, detail, confirmation, receipt, empty and error states', () => {
const actionIndex = viewSource.indexOf('label="操作"')
const billNumberIndex = viewSource.indexOf('label="账单编号"')
const tableDefinition = viewSource.slice(viewSource.indexOf('const tableColumns'))
const actionIndex = tableDefinition.indexOf("label: '操作'")
const billNumberIndex = tableDefinition.indexOf("label: '账单编号'")
assert.ok(actionIndex >= 0 && actionIndex < billNumberIndex)
for (const expected of ['未收余额', '确认账单', '暂无账单', 'state-panel--error']) {
assert.match(viewSource, new RegExp(expected))
@@ -133,9 +134,9 @@ test('keeps actions first and exposes list, balance, detail, confirmation, recei
assert.match(detailViewSource, /InboundCodeAssociationDialog/)
assert.match(detailViewSource, /:inbound-code="inboundAssociationCode"/)
assert.match(viewSource, /auth\.hasPermission\('finance:bill:confirm'\)/)
assert.match(viewSource, /row\.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm/)
assert.match(viewSource, /size="small" type="success" plain/)
assert.match(viewSource, /confirmStatement\(row as FinanceBillSummary\)/)
assert.match(viewSource, /statement\.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm\.value/)
assert.match(viewSource, /label: '确认账单'/)
assert.match(viewSource, /run: \(\) => confirmStatement\(statement\)/)
assert.match(detailViewSource, /detail\.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm/)
assert.match(detailViewSource, /confirmStatement\(detail\)/)
assert.match(viewSource, /confirmMerchantFinanceStatement\(statementId, statement\.version, key\)/)
@@ -150,6 +151,14 @@ test('keeps actions first and exposes list, balance, detail, confirmation, recei
assert.match(detailViewSource, /bill-detail-error/)
})
test('uses the shared compact query, table and pagination layout', () => {
assert.match(viewSource, /<QueryToolbar[^>]*layout="grid"/)
assert.match(viewSource, /<DataTable[^>]*:row-actions="rowActions"/)
assert.match(viewSource, /v-model:page="page" v-model:page-size="pageSize"/)
assert.match(viewSource, /:page-sizes="\[20, 50, 100\]"/)
assert.doesNotMatch(viewSource, /class="filter-bar"|class="pagination-row"|class="table-empty"/)
})
test('keeps the existing customer finance view permission mapping', () => {
assert.match(permissionSource, /code: 'finance:bill:view'/)
assert.match(permissionSource, /merchant:finance:statement:list/)
+60 -46
View File
@@ -1,9 +1,12 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { CircleDollarSign, Eye, RefreshCcw, Search } from '@lucide/vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { RefreshCcw } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { ApiError } from '@/api/http'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import type { TableAction } from '@/components/common/tableActions'
import { useAuthStore } from '@/modules/auth/public'
import { confirmMerchantFinanceStatement, getMerchantFinanceBills } from '../api/finance'
import type { FinanceBillQuery, FinanceBillStatus, FinanceBillSummary, FinanceCustomerConfirmationStatus } from '../contracts'
@@ -28,7 +31,23 @@ const statusOptions: Array<{ label: string; value: FinanceBillStatus }> = [
{ label: '作废', value: 'VOIDED' },
]
const hasData = computed(() => bills.value.length > 0)
const queryModel = ref<QueryModel>({ keyword: '', status: '' })
const queryFields: QueryField[] = [
{ key: 'keyword', label: '账单编号', type: 'input', placeholder: '请输入账单编号' },
{ key: 'status', label: '账单状态', type: 'select', placeholder: '全部', options: statusOptions },
]
const tableColumns: DataTableColumn[] = [
{ label: '操作', slotName: 'actions', width: 190, fixed: 'left' },
{ label: '账单编号', prop: 'billNo', minWidth: 160 },
{ label: '出账日期', slotName: 'issueDate', width: 120 },
{ label: '到期日', slotName: 'dueDate', width: 120 },
{ label: '账单金额', slotName: 'totalAmount', width: 145, align: 'right' },
{ label: '已收', slotName: 'receivedAmount', width: 145, align: 'right' },
{ label: '未收余额', slotName: 'outstandingAmount', width: 145, align: 'right' },
{ label: '状态', slotName: 'status', width: 110 },
{ label: '客户确认', slotName: 'confirmationStatus', width: 110 },
]
const canConfirm = computed(() => auth.hasPermission('finance:bill:confirm'))
function formatAmount(value: string | null | undefined) {
@@ -87,6 +106,21 @@ async function loadBills(nextPage = page.value) {
function openDetail(row: FinanceBillSummary) { void router.push({ name: 'finance-bill-detail', params: { billNo: row.billNo } }) }
function rowActions(row: object): TableAction[] {
const statement = row as FinanceBillSummary
const actions: TableAction[] = [{ key: 'detail', label: '查看', run: () => openDetail(statement) }]
if (statement.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm.value) {
actions.push({
key: 'confirm',
label: '确认账单',
loading: confirmingBillId.value === statement.id,
disabled: Boolean(confirmingBillId.value),
run: () => confirmStatement(statement),
})
}
return actions
}
async function confirmStatement(statement: FinanceBillSummary) {
if (!canConfirm.value || statement.customerConfirmationStatus !== 'PENDING_CONFIRMATION' || confirmingBillId.value) return
try {
@@ -118,16 +152,19 @@ async function confirmStatement(statement: FinanceBillSummary) {
}
}
function resetFilters() {
filters.keyword = ''
filters.status = undefined
void loadBills(1)
function applyQuery(model: QueryModel) {
filters.keyword = String(model.keyword || '')
filters.status = (model.status || undefined) as FinanceBillStatus | undefined
if (page.value !== 1) page.value = 1
else void loadBills(1)
}
function handlePageChange(nextPage: number) {
void loadBills(nextPage)
function resetFilters(model: QueryModel) {
queryModel.value = model
applyQuery(model)
}
watch([page, pageSize], () => { void loadBills(page.value) })
onMounted(() => { void loadBills(1) })
</script>
@@ -139,38 +176,19 @@ onMounted(() => { void loadBills(1) })
<span class="amount-warning">未收 <strong>¥ {{ formatAmount(totals.outstanding) }}</strong></span>
</section>
<QueryToolbar v-model="queryModel" layout="grid" ariaLabel="客户账单筛选" :fields="queryFields" :search-on-reset="false" @search="applyQuery" @reset="resetFilters" />
<section class="content-card">
<form class="filter-bar" @submit.prevent="loadBills(1)">
<el-input v-model="filters.keyword" clearable placeholder="搜索账单编号" aria-label="搜索账单编号" class="keyword-input">
<template #prefix><Search :size="16" /></template>
</el-input>
<el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="账单状态" class="status-select">
<el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
<el-button type="primary" native-type="submit" :loading="loading">查询</el-button>
<el-button :disabled="loading" @click="resetFilters">重置</el-button>
</form>
<div v-if="errorMessage" class="state-panel state-panel--error" role="alert">
<p>{{ errorMessage }}</p>
<el-button @click="loadBills(1)"><RefreshCcw :size="15" />重试</el-button>
</div>
<el-table v-else v-loading="loading" :data="bills" row-key="billNo" class="finance-table" aria-label="客户账单列表">
<el-table-column label="操作" width="190" fixed="left">
<template #default="{ row }"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as FinanceBillSummary)"><Eye :size="15" />查看</el-button><el-button v-if="row.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm" size="small" type="success" plain :loading="confirmingBillId === row.id" :disabled="Boolean(confirmingBillId)" @click="confirmStatement(row as FinanceBillSummary)">确认账单</el-button></div></template>
</el-table-column>
<el-table-column prop="billNo" label="账单编号" min-width="160" />
<el-table-column prop="issueDate" label="出账日期" width="120"><template #default="{ row }">{{ formatDate(row.issueDate) }}</template></el-table-column>
<el-table-column prop="dueDate" label="到期日" width="120"><template #default="{ row }">{{ formatDate(row.dueDate) }}</template></el-table-column>
<el-table-column label="账单金额" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.totalAmount) }}</template></el-table-column>
<el-table-column label="已收" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.receivedAmount) }}</template></el-table-column>
<el-table-column label="未收余额" width="145" align="right"><template #default="{ row }"><strong :class="{ 'amount-warning': row.outstandingAmount !== '0' }">¥ {{ formatAmount(row.outstandingAmount) }}</strong></template></el-table-column>
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
<el-table-column label="客户确认" width="110"><template #default="{ row }"><el-tag :type="confirmationType(row.customerConfirmationStatus)">{{ confirmationLabel(row.customerConfirmationStatus) }}</el-tag></template></el-table-column>
<template #empty><div class="table-empty"><CircleDollarSign :size="32" /><strong>暂无账单</strong><span>平台财务出账后,这里会显示本企业账单。</span></div></template>
</el-table>
<div v-if="!errorMessage && hasData" class="pagination-row"><span>共 {{ total }} 条</span><el-pagination v-model:current-page="page" :page-size="pageSize" layout="prev, pager, next" :total="total" @current-change="handlePageChange" /></div>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="bills" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" :row-actions="rowActions" row-key="billNo" empty-text="暂无账单">
<template v-if="errorMessage" #error><div class="state-panel state-panel--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="loadBills(1)"><RefreshCcw :size="15" />重试</el-button></div></template>
<template #issueDate="{ row }">{{ formatDate(row.issueDate) }}</template>
<template #dueDate="{ row }">{{ formatDate(row.dueDate) }}</template>
<template #totalAmount="{ row }">¥ {{ formatAmount(row.totalAmount) }}</template>
<template #receivedAmount="{ row }">¥ {{ formatAmount(row.receivedAmount) }}</template>
<template #outstandingAmount="{ row }"><strong :class="{ 'amount-warning': row.outstandingAmount !== '0' }">¥ {{ formatAmount(row.outstandingAmount) }}</strong></template>
<template #status="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
<template #confirmationStatus="{ row }"><el-tag :type="confirmationType(row.customerConfirmationStatus)">{{ confirmationLabel(row.customerConfirmationStatus) }}</el-tag></template>
</DataTable>
</section>
</div>
@@ -179,12 +197,8 @@ onMounted(() => { void loadBills(1) })
<style scoped>
.finance-bills-page { display: flex; flex-direction: column; gap: 12px; }
.finance-summary { display: flex; flex-wrap: wrap; gap: 8px 26px; color: var(--muted); font-size: 13px; }.finance-summary strong { margin-left: 6px; color: var(--navy); font-size: 18px; }.amount-warning, .amount-warning strong { color: #b45309; }
.content-card { min-width: 0; padding: 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface, #fff); }
.filter-bar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }.keyword-input { width: 260px; }.status-select { width: 140px; }
.finance-table { width: 100%; }.finance-table :deep(.el-button) { display: inline-flex; align-items: center; gap: 4px; }.table-actions { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
.content-card { min-width: 0; overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: var(--surface, #fff); }
.state-panel { display: flex; min-height: 180px; align-items: center; justify-content: center; gap: 12px; flex-direction: column; color: var(--muted); text-align: center; }.state-panel p { margin: 0; }.state-panel--error { color: #b42318; background: #fff8f7; border: 1px solid #f5c2bd; border-radius: 6px; }
.table-empty { display: flex; align-items: center; flex-direction: column; gap: 8px; padding: 42px 12px; color: var(--muted); }.table-empty strong { color: var(--navy); }.table-empty span { font-size: 12px; }
.pagination-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 14px; color: var(--muted); font-size: 12px; }
.detail-content { min-height: 300px; }.detail-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }.detail-heading h2 { margin: 0 0 5px; color: var(--navy); font-size: 18px; }.detail-heading p { margin: 0; color: var(--muted); font-size: 13px; }.detail-statuses { display: flex; align-items: center; gap: 8px; }.confirmation-panel { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 18px; padding: 14px 16px; border: 1px solid #d7e9e4; border-radius: 8px; background: #f3f9f7; }.confirmation-panel strong { color: var(--navy); }.confirmation-panel p { margin: 4px 0 0; color: var(--muted); font-size: 12px; }.detail-section { margin-top: 22px; }.detail-section h3 { margin: 0 0 10px; color: var(--navy); font-size: 14px; }.sub-empty { padding: 22px; color: var(--muted); font-size: 12px; text-align: center; background: #f7fafb; }.readonly-notice { margin: 22px 0 0; padding: 11px 12px; color: #45615d; background: #edf6f4; font-size: 12px; }
@media (max-width: 720px) { .keyword-input, .status-select { width: 100%; }.filter-bar > .el-button { flex: 1; }.content-card { padding: 12px; }.pagination-row { align-items: flex-start; flex-direction: column; }.finance-summary { align-items: flex-start; flex-direction: column; gap: 6px; } }
@media (max-width: 720px) { .finance-summary { align-items: flex-start; flex-direction: column; gap: 6px; } }
</style>