feat(finance): add customer recharge account
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
## 项目定位
|
||||
|
||||
- 本项目是入驻企业及其成员使用的客户工作台,所有业务数据都处于企业租户上下文中。
|
||||
- 当前范围包括注册、业务总览、企业资料、企业成员、企业角色与权限、头程订单、客户财务账单查询与客户确认,以及海外仓贴标、转运、废弃和退货换标自助服务。
|
||||
- 当前范围包括注册、业务总览、企业资料、企业成员、企业角色与权限、头程订单、客户资金账户、客户财务账单查询与客户确认,以及海外仓贴标、转运、废弃和退货换标自助服务。
|
||||
- 用户可见的“平台—客户”层级语义读取 `../../docs/平台客户层级与权限契约.md`;涉及海外 API、海外 Token 或跨区域事件时读取 `../../docs/跨区域认证与服务通信契约.md`。本文件中的 `tenant` 仅表示服务端隔离上下文,不扩展用户可见产品层级。
|
||||
- 不在本项目实现平台内部运营管理、品牌官网、仓库 PDA 作业或仅属于原生移动端的能力。
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
| 装柜完成后的退税报关资料 | `src/modules/first-leg/views/FirstLegOrdersView.vue` 的“报关资料”、`components/CustomsDocumentsDialog.vue`、`api/customs.ts` | 只显示登录客户在同柜的退税入仓码;报关开始前可上传、替换、调整同一资料覆盖的一个或多个入仓码及删除误传,全部写入携带幂等键和版本;开始后只读冻结资料,整柜完成后才能下载平台回传的一对一完成文件。Backend `firstleg/customs/container/`、`customs-v1.yaml`,二进制归 `file/` |
|
||||
| 统一订单展示 | `src/modules/order-management/` | `src/modules/first-leg/` 与对应后端订单契约 |
|
||||
| 客户财务账单、入仓码联查、收款、未收余额与客户确认 | `src/modules/finance/`;确认与明细入口 `views/FinanceBillsView.vue`、`api/finance.ts`,入仓码通过 `src/modules/first-leg/public.ts` 复用 `InboundCodeAssociationDialog` | Backend `finance/`;账单行只显示服务端 `inboundCode` 并按登录客户查询业务关系,缺失显示“—”,不得回退内部 ID。收款方式由服务端枚举映射中文。出账或已出账调整后为 `PENDING_CONFIRMATION`,客户确认提交登录成员、`expectedVersion` 和幂等键 |
|
||||
| 客户资金账户与充值申请 | `src/modules/finance/views/FinanceAccountView.vue`、`api/account.ts` | 显示当前企业 CNY 实际、冻结、可用余额和不可变流水;充值只展示服务端启用的微信/支付宝静态收款码,客户上传 JPG/PNG 付款截图后进入待审核。客户范围只取登录态,无收款码时禁止提交,不模拟支付成功 |
|
||||
| 海外采购、美国本土卡与美本土散板 | `src/modules/cross-border/routes.ts`、`components/BusinessOrdersView.vue`、`api/businessOrders.ts` | 三个路由均为 `active`,客户范围只取 Token;Backend `lastleg-service/` 的 `procurement/`、`trucking/`、`looseboard/` 客户接口 |
|
||||
| 海外仓入库订单 | `src/modules/order-management/routes.ts`、`views/InboundOrdersView.vue`、`api/inboundPlans.ts` | `orders/inbound` 为只读列表/详情,权限 `merchant:warehouse:inbound:list`;不展示或提交内部 ID |
|
||||
| 海外仓贴标、仓储转运、货物废弃、退货换标 | `src/modules/overseas-warehouse/routes.ts`、`views/WarehouseServiceView.vue`、`api/warehouseServices.ts` | 四个路由均为 `active`;Backend `lastleg-service/` 的 `relabel/`、`transfer/`、`discard/`,请求统一经 `src/api/overseasHttp.ts` 与 `gateway-overseas/` |
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { apiRequest } from '@/api/http'
|
||||
|
||||
export interface PageResult<T> {
|
||||
records: T[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface FinanceAccount {
|
||||
currencyCode: 'CNY'
|
||||
actualBalance: string
|
||||
frozenBalance: string
|
||||
availableBalance: string
|
||||
version: number
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type AccountChangeType = 'RECHARGE' | 'DEDUCTION' | 'FREEZE' | 'UNFREEZE' | 'CONSUMPTION'
|
||||
|
||||
export interface AccountLedgerEntry {
|
||||
ledgerId: string
|
||||
businessNo: string
|
||||
changeType: AccountChangeType
|
||||
amount: string
|
||||
actualBalanceAfter: string
|
||||
frozenBalanceAfter: string
|
||||
availableBalanceAfter: string
|
||||
sourceType: 'CLIENT_RECHARGE' | 'MANUAL_RECHARGE' | 'MANUAL_DEDUCTION'
|
||||
sourceNo: string
|
||||
operatorName: string | null
|
||||
remark: string | null
|
||||
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
|
||||
version: number
|
||||
submittedByName: string | null
|
||||
reviewedByName: string | null
|
||||
reviewRemark: string | null
|
||||
createdAt: string
|
||||
reviewedAt: string | null
|
||||
}
|
||||
|
||||
export interface RechargeChannel {
|
||||
paymentMethod: RechargePaymentMethod
|
||||
displayName: string
|
||||
enabled: boolean
|
||||
qrCodeUrl: string | null
|
||||
}
|
||||
|
||||
export function getFinanceAccount() {
|
||||
return apiRequest<FinanceAccount>({ method: 'GET', url: '/client/finance/v1/account' })
|
||||
}
|
||||
|
||||
export function getAccountLedger(page: number, size: number) {
|
||||
return apiRequest<PageResult<AccountLedgerEntry>>({
|
||||
method: 'GET',
|
||||
url: '/client/finance/v1/account/ledger',
|
||||
params: { page, size },
|
||||
})
|
||||
}
|
||||
|
||||
export function getRechargeRequests(page: number, size: number) {
|
||||
return apiRequest<PageResult<RechargeRequest>>({
|
||||
method: 'GET',
|
||||
url: '/client/finance/v1/recharge-requests',
|
||||
params: { page, size },
|
||||
})
|
||||
}
|
||||
|
||||
export function getRechargeChannels() {
|
||||
return apiRequest<RechargeChannel[]>({ method: 'GET', url: '/client/finance/v1/recharge-channels' })
|
||||
}
|
||||
|
||||
export function createRechargeRequest(amount: string, paymentMethod: RechargePaymentMethod, 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',
|
||||
url: '/client/finance/v1/recharge-requests',
|
||||
headers: { 'Idempotency-Key': idempotencyKey },
|
||||
data: formData,
|
||||
})
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
import { ReceiptText } from '@lucide/vue'
|
||||
import { CircleDollarSign, ReceiptText } from '@lucide/vue'
|
||||
import type { ClientModuleManifest } from '../types.ts'
|
||||
|
||||
export const financeModule = {
|
||||
key: 'finance',
|
||||
navigationGroup: { title: '财务结算', icon: ReceiptText },
|
||||
routes: [
|
||||
{
|
||||
path: 'finance/account',
|
||||
name: 'finance-account',
|
||||
component: () => import('./views/FinanceAccountView.vue'),
|
||||
release: 'active',
|
||||
meta: { title: '资金账户', description: '查看本企业资金余额、流水与充值申请', permission: 'merchant:finance:account:view' },
|
||||
navigation: { key: 'finance-account', title: '资金账户', icon: CircleDollarSign },
|
||||
},
|
||||
{
|
||||
path: 'finance/bills',
|
||||
name: 'finance-bills',
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const apiSource = readFileSync(new URL('../api/account.ts', import.meta.url), 'utf8')
|
||||
const viewSource = readFileSync(new URL('./FinanceAccountView.vue', import.meta.url), 'utf8')
|
||||
const routesSource = readFileSync(new URL('../routes.ts', import.meta.url), 'utf8')
|
||||
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']) {
|
||||
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, /customerId|tenantId|mock|fallback/i)
|
||||
})
|
||||
|
||||
test('activates the account route and maps read and recharge permissions independently', () => {
|
||||
assert.match(routesSource, /path: 'finance\/account'[\s\S]*FinanceAccountView\.vue[\s\S]*release: 'active'[\s\S]*permission: 'merchant:finance:account:view'/)
|
||||
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"/)
|
||||
})
|
||||
|
||||
test('renders server balances, ledger and recharge states without internal identifiers', () => {
|
||||
for (const field of ['actualBalance', 'frozenBalance', 'availableBalance']) assert.match(viewSource, new RegExp(`account\\?\\.${field}`))
|
||||
assert.match(viewSource, /row-key="businessNo"/)
|
||||
assert.match(viewSource, /row-key="requestNo"/)
|
||||
assert.match(viewSource, /empty-text="暂无资金流水"/)
|
||||
assert.match(viewSource, /empty-text="暂无充值申请"/)
|
||||
assert.match(viewSource, /account-state--error/)
|
||||
assert.match(viewSource, /v-loading="pageLoading"/)
|
||||
assert.doesNotMatch(viewSource, /requestId|ledgerId|customerId|tenantId/)
|
||||
})
|
||||
|
||||
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, /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|支付成功|模拟成功/)
|
||||
})
|
||||
@@ -0,0 +1,308 @@
|
||||
<script setup lang="ts">
|
||||
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 { useAuthStore } from '@/modules/auth/public'
|
||||
import {
|
||||
createRechargeRequest,
|
||||
getAccountLedger,
|
||||
getFinanceAccount,
|
||||
getRechargeChannels,
|
||||
getRechargeRequests,
|
||||
type AccountChangeType,
|
||||
type AccountLedgerEntry,
|
||||
type FinanceAccount,
|
||||
type RechargeChannel,
|
||||
type RechargePaymentMethod,
|
||||
type RechargeRequest,
|
||||
type RechargeStatus,
|
||||
} from '../api/account'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const account = ref<FinanceAccount | null>(null)
|
||||
const ledger = ref<AccountLedgerEntry[]>([])
|
||||
const rechargeRequests = ref<RechargeRequest[]>([])
|
||||
const channels = ref<RechargeChannel[]>([])
|
||||
const pageLoading = ref(false)
|
||||
const pageError = ref('')
|
||||
const channelsError = ref('')
|
||||
const ledgerPage = ref(1)
|
||||
const ledgerTotal = ref(0)
|
||||
const rechargePage = ref(1)
|
||||
const rechargeTotal = ref(0)
|
||||
const pageSize = 10
|
||||
const rechargeVisible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const submitError = ref('')
|
||||
const voucherFile = ref<File | null>(null)
|
||||
const voucherUpload = ref<UploadInstance>()
|
||||
let pendingIdempotencyKey = ''
|
||||
|
||||
const form = reactive({ amount: '100.00', paymentMethod: 'WECHAT' as RechargePaymentMethod })
|
||||
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 amountValid = computed(() => {
|
||||
const value = Number(form.amount)
|
||||
return Number.isFinite(value) && value > 0 && /^\d+(?:\.\d{1,2})?$/.test(form.amount.trim())
|
||||
})
|
||||
const canSubmit = computed(() => amountValid.value && channelReady.value && Boolean(voucherFile.value) && !submitting.value)
|
||||
|
||||
const changeLabels: Record<AccountChangeType, string> = {
|
||||
RECHARGE: '充值', DEDUCTION: '扣款', FREEZE: '冻结', UNFREEZE: '解冻', CONSUMPTION: '消费',
|
||||
}
|
||||
const statusLabels: Record<RechargeStatus, string> = { PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' }
|
||||
const methodLabels: Record<RechargePaymentMethod, string> = { WECHAT: '微信支付', ALIPAY: '支付宝' }
|
||||
|
||||
function messageOf(error: unknown, fallback: string) {
|
||||
return error instanceof ApiError ? error.message : fallback
|
||||
}
|
||||
|
||||
function formatAmount(value: string | null | undefined) {
|
||||
const number = Number(value ?? '0')
|
||||
return Number.isFinite(number)
|
||||
? number.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
: '0.00'
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
return value ? value.replace('T', ' ').replace(/Z$/, '').slice(0, 16) : '—'
|
||||
}
|
||||
|
||||
function statusType(status: RechargeStatus) {
|
||||
return ({ PENDING: 'warning', APPROVED: 'success', REJECTED: 'danger' })[status] as 'warning' | 'success' | 'danger'
|
||||
}
|
||||
|
||||
function amountClass(amount: string) {
|
||||
return Number(amount) >= 0 ? 'amount-positive' : 'amount-negative'
|
||||
}
|
||||
|
||||
async function loadAccountData() {
|
||||
pageLoading.value = true
|
||||
pageError.value = ''
|
||||
channelsError.value = ''
|
||||
const requests = [getFinanceAccount(), getAccountLedger(ledgerPage.value, pageSize), getRechargeRequests(rechargePage.value, pageSize)] as const
|
||||
try {
|
||||
const [accountResult, ledgerResult, rechargeResult] = await Promise.all(requests)
|
||||
account.value = accountResult
|
||||
ledger.value = ledgerResult.records
|
||||
ledgerTotal.value = ledgerResult.total
|
||||
rechargeRequests.value = rechargeResult.records
|
||||
rechargeTotal.value = rechargeResult.total
|
||||
} catch (error) {
|
||||
account.value = null
|
||||
ledger.value = []
|
||||
rechargeRequests.value = []
|
||||
ledgerTotal.value = 0
|
||||
rechargeTotal.value = 0
|
||||
pageError.value = messageOf(error, '资金账户加载失败,请稍后重试')
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
if (canRecharge.value) {
|
||||
try {
|
||||
channels.value = await getRechargeChannels()
|
||||
} catch (error) {
|
||||
channels.value = []
|
||||
channelsError.value = messageOf(error, '充值渠道加载失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function changeLedgerPage(nextPage: number) {
|
||||
ledgerPage.value = nextPage
|
||||
try {
|
||||
const result = await getAccountLedger(nextPage, pageSize)
|
||||
ledger.value = result.records
|
||||
ledgerTotal.value = result.total
|
||||
} catch (error) {
|
||||
ElMessage.error(messageOf(error, '资金流水加载失败,请重试'))
|
||||
}
|
||||
}
|
||||
|
||||
async function changeRechargePage(nextPage: number) {
|
||||
rechargePage.value = nextPage
|
||||
try {
|
||||
const result = await getRechargeRequests(nextPage, pageSize)
|
||||
rechargeRequests.value = result.records
|
||||
rechargeTotal.value = result.total
|
||||
} catch (error) {
|
||||
ElMessage.error(messageOf(error, '充值申请加载失败,请重试'))
|
||||
}
|
||||
}
|
||||
|
||||
function openRecharge() {
|
||||
submitError.value = ''
|
||||
rechargeVisible.value = true
|
||||
}
|
||||
|
||||
function chooseAmount(amount: string) {
|
||||
form.amount = amount
|
||||
}
|
||||
|
||||
function handleVoucherChange(uploadFile: UploadFile, _uploadFiles: UploadFiles) {
|
||||
const file = uploadFile.raw
|
||||
if (!file) return
|
||||
const extensionAllowed = /\.(?:jpe?g|png)$/i.test(file.name)
|
||||
const mimeAllowed = ['image/jpeg', 'image/png'].includes(file.type)
|
||||
if (!extensionAllowed || !mimeAllowed) {
|
||||
voucherFile.value = null
|
||||
ElMessage.error('付款截图仅支持 jpg、jpeg 或 png 图片')
|
||||
return
|
||||
}
|
||||
voucherFile.value = file
|
||||
}
|
||||
|
||||
function handleVoucherRemove() {
|
||||
voucherFile.value = null
|
||||
}
|
||||
|
||||
function normalizedAmount() {
|
||||
return Number(form.amount).toFixed(2)
|
||||
}
|
||||
|
||||
async function submitRecharge() {
|
||||
if (submitting.value) return
|
||||
submitError.value = ''
|
||||
if (!amountValid.value) {
|
||||
submitError.value = '请输入大于 0 且最多两位小数的充值金额'
|
||||
return
|
||||
}
|
||||
if (!channelReady.value) {
|
||||
submitError.value = `${methodLabels[form.paymentMethod]}收款二维码尚未配置,暂不能提交充值申请`
|
||||
return
|
||||
}
|
||||
if (!voucherFile.value) {
|
||||
submitError.value = '请上传付款截图'
|
||||
return
|
||||
}
|
||||
if (!pendingIdempotencyKey) {
|
||||
pendingIdempotencyKey = globalThis.crypto?.randomUUID?.() ?? `recharge-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await createRechargeRequest(normalizedAmount(), form.paymentMethod, voucherFile.value, pendingIdempotencyKey)
|
||||
pendingIdempotencyKey = ''
|
||||
rechargeVisible.value = false
|
||||
voucherFile.value = null
|
||||
voucherUpload.value?.clearFiles()
|
||||
ElMessage.success('充值申请已提交,等待平台审核')
|
||||
await loadAccountData()
|
||||
} catch (error) {
|
||||
submitError.value = messageOf(error, '充值申请提交失败,请使用当前内容重试')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch([() => form.amount, () => form.paymentMethod, voucherFile], () => {
|
||||
if (!submitting.value) pendingIdempotencyKey = ''
|
||||
submitError.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>¥ {{ formatAmount(account?.actualBalance) }}</strong><small>当前账户实际资金</small></article>
|
||||
<article><span>冻结金额</span><strong>¥ {{ formatAmount(account?.frozenBalance) }}</strong><small>暂不可使用的资金</small></article>
|
||||
<article class="balance-available"><span>可用余额</span><strong>¥ {{ formatAmount(account?.availableBalance) }}</strong><small>可用于后续业务支付</small></article>
|
||||
</section>
|
||||
|
||||
<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 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 :current-page="ledgerPage" :page-size="pageSize" layout="prev, pager, next" :total="ledgerTotal" @current-change="changeLedgerPage" /></div>
|
||||
</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 :current-page="rechargePage" :page-size="pageSize" layout="prev, pager, next" :total="rechargeTotal" @current-change="changeRechargePage" /></div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<el-dialog v-model="rechargeVisible" title="账户充值" width="760px" :close-on-click-modal="false" :close-on-press-escape="!submitting" :show-close="!submitting" class="recharge-dialog">
|
||||
<div class="recharge-form">
|
||||
<label class="field-label" for="recharge-amount">充值金额</label>
|
||||
<el-input id="recharge-amount" v-model="form.amount" size="large" inputmode="decimal" placeholder="请输入充值金额" aria-label="充值金额"><template #prefix>¥</template></el-input>
|
||||
<div class="quick-amounts" aria-label="快捷充值金额">
|
||||
<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>
|
||||
</div>
|
||||
<div v-else class="channel-warning" role="status">{{ methodLabels[form.paymentMethod] }}收款二维码尚未配置,暂不能提交充值申请。</div>
|
||||
|
||||
<div class="voucher-upload">
|
||||
<span class="field-label">付款截图</span>
|
||||
<el-upload ref="voucherUpload" :auto-upload="false" :limit="1" accept=".jpg,.jpeg,.png,image/jpeg,image/png" :on-change="handleVoucherChange" :on-remove="handleVoucherRemove">
|
||||
<el-button><Upload :size="16" />选择付款截图</el-button>
|
||||
<template #tip><div class="el-upload__tip">仅支持 jpg、jpeg、png 图片;提交后进入平台审核。</div></template>
|
||||
</el-upload>
|
||||
</div>
|
||||
<p v-if="submitError" class="submit-error" role="alert">{{ submitError }}</p>
|
||||
</div>
|
||||
<template #footer><div class="dialog-actions"><el-button :disabled="submitting" @click="rechargeVisible = false">取消</el-button><el-button type="primary" :loading="submitting" :disabled="!canSubmit" @click="submitRecharge">确认充值</el-button></div></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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; }
|
||||
.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; }
|
||||
.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; }
|
||||
</style>
|
||||
@@ -38,6 +38,8 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [
|
||||
key: 'finance',
|
||||
label: '财务账单',
|
||||
items: [
|
||||
{ code: 'merchant:finance:account:view', label: '查看资金账户', description: '查看本企业实际、冻结和可用余额及资金流水' },
|
||||
{ code: 'merchant:finance:recharge:create', label: '提交充值申请', description: '上传付款截图并提交本企业充值申请' },
|
||||
{ code: 'finance:bill:view', label: '查看客户账单', description: '查看本企业账单、收款记录和未收余额' },
|
||||
{ code: 'finance:bill:confirm', label: '确认客户账单', description: '确认本企业已出账且待客户确认的对账单' },
|
||||
],
|
||||
@@ -93,6 +95,8 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [
|
||||
]
|
||||
|
||||
export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly<Record<string, readonly string[]>> = {
|
||||
'merchant:finance:account:view': ['merchant:finance:account:view'],
|
||||
'merchant:finance:recharge:create': ['merchant:finance:recharge:create'],
|
||||
'order:first-leg:list': ['merchant:order:list', 'merchant:order:view'],
|
||||
'order:first-leg:create': ['merchant:order:create'],
|
||||
'order:first-leg:quick-create': ['merchant:order:quick-create', 'merchant:order:create'],
|
||||
|
||||
@@ -67,7 +67,7 @@ test('keeps only implemented navigation groups visible', () => {
|
||||
},
|
||||
{
|
||||
title: '财务结算',
|
||||
children: ['客户账单'],
|
||||
children: ['资金账户', '客户账单'],
|
||||
},
|
||||
{
|
||||
title: '企业管理',
|
||||
@@ -108,6 +108,7 @@ test('flattens visible leaves with unique paths', () => {
|
||||
'/overseas-warehouse/return-relabel',
|
||||
'/orders/inbound',
|
||||
'/orders/delivery',
|
||||
'/finance/account',
|
||||
'/finance/bills',
|
||||
'/company/profile',
|
||||
'/organization/members',
|
||||
|
||||
Reference in New Issue
Block a user