Compare commits

...

3 Commits

Author SHA1 Message Date
99de03604d 驳回
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m1s
2026-06-26 18:14:37 +08:00
1baa6d87cf 驳回 2026-06-26 18:14:26 +08:00
f604791c69 倒计时没有覆盖复机停机的问题 2026-06-25 10:30:49 +08:00
17 changed files with 565 additions and 47 deletions

3
.gitignore vendored
View File

@@ -27,3 +27,6 @@ npminstall-debug.log
# ai # ai
.agents .agents
#build
one-pipe-system-dist.zip

View File

@@ -13,3 +13,9 @@ This context captures the business language used by the admin system for orders,
**Operational batch task**: An asynchronous admin task created from an uploaded batch import file and tracked through task status, counts, operator identity, and failure details. _Avoid_: Inline order operation, synchronous import **Operational batch task**: An asynchronous admin task created from an uploaded batch import file and tracked through task status, counts, operator identity, and failure details. _Avoid_: Inline order operation, synchronous import
**Operational remark**: A note captured when an admin creates a financial or operational record. Operational remarks are retained for later review and are not edited after creation. _Avoid_: Editable comment, processing note **Operational remark**: A note captured when an admin creates a financial or operational record. Operational remarks are retained for later review and are not edited after creation. _Avoid_: Editable comment, processing note
**Recharge order**: A C-side asset recharge order created when a user recharges an IoT card or device asset wallet. It is identified by `recharge_order_no`, is associated with `resource_type` and `resource_id`, and may be rejected while pending payment. _Avoid_: Agent recharge order
**Rejected recharge order**: A recharge order that an admin has rejected before payment because the recharge request should not proceed. Rejection is an irreversible terminal state and does not involve refunds. _Avoid_: Refunded recharge, closed recharge
**Agent recharge order**: A financial recharge record for an agent/shop wallet in the admin finance module. It uses a different API contract and status model from C-side recharge orders. _Avoid_: Recharge order

View File

@@ -0,0 +1,110 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, rm, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import * as esbuild from 'esbuild'
async function resolveSrcAlias(path) {
const resolvedPath = join(process.cwd(), 'src', path.slice(2))
try {
if ((await stat(resolvedPath)).isDirectory()) {
return join(resolvedPath, 'index.ts')
}
} catch {
// Let esbuild report the original resolution failure.
}
return resolvedPath
}
async function importBundledActions() {
const tempDir = await mkdtemp(join(tmpdir(), 'agent-recharge-actions-'))
const bundlePath = join(tempDir, 'agentRechargeActions.mjs')
await esbuild.build({
entryPoints: ['src/views/finance/agent-recharge/agentRechargeActions.ts'],
bundle: true,
outfile: bundlePath,
format: 'esm',
platform: 'node',
absWorkingDir: process.cwd(),
plugins: [
{
name: 'alias-src',
setup(build) {
build.onResolve({ filter: /^@\// }, async (args) => ({
path: await resolveSrcAlias(args.path)
}))
}
}
]
})
const module = await import(pathToFileURL(bundlePath).href)
return {
module,
cleanup: () => rm(tempDir, { recursive: true, force: true })
}
}
test('pending agent recharge exposes reject action for admins with rejection permission', async () => {
const { module, cleanup } = await importBundledActions()
const { buildAgentRechargeActions } = module
const row = {
id: 42,
status: 1,
payment_method: 'wechat',
payment_voucher_key: []
}
const handledRows = []
try {
const actions = buildAgentRechargeActions(row, {
hasAuth: (permission) => permission === 'agent_recharge:reject',
onViewPaymentVoucher: () => {},
onConfirmPayment: () => {},
onReject: (recharge) => handledRows.push(recharge)
})
assert.deepEqual(
actions.map((action) => action.label),
['拒绝']
)
actions[0].handler()
assert.deepEqual(handledRows, [row])
} finally {
await cleanup()
}
})
test('non-pending agent recharge does not expose reject action', async () => {
const { module, cleanup } = await importBundledActions()
const { buildAgentRechargeActions } = module
const row = {
id: 42,
status: 2,
payment_method: 'wechat',
payment_voucher_key: []
}
try {
const actions = buildAgentRechargeActions(row, {
hasAuth: (permission) => permission === 'agent_recharge:reject',
onViewPaymentVoucher: () => {},
onConfirmPayment: () => {},
onReject: () => {}
})
assert.deepEqual(
actions.map((action) => action.label),
[]
)
} finally {
await cleanup()
}
})

View File

@@ -0,0 +1,49 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import * as esbuild from 'esbuild'
async function importBundledDisplay() {
const tempDir = await mkdtemp(join(tmpdir(), 'agent-recharge-display-'))
const bundlePath = join(tempDir, 'agentRechargeDisplay.mjs')
await esbuild.build({
entryPoints: ['src/views/finance/agent-recharge/agentRechargeDisplay.ts'],
bundle: true,
outfile: bundlePath,
format: 'esm',
platform: 'node',
absWorkingDir: process.cwd()
})
const module = await import(pathToFileURL(bundlePath).href)
return {
module,
cleanup: () => rm(tempDir, { recursive: true, force: true })
}
}
test('agent recharge rejection reason display uses backend rejection_reason when present', async () => {
const { module, cleanup } = await importBundledDisplay()
try {
assert.equal(module.formatRejectionReason('凭证不清晰'), '凭证不清晰')
} finally {
await cleanup()
}
})
test('agent recharge rejection reason display falls back to dash when absent', async () => {
const { module, cleanup } = await importBundledDisplay()
try {
assert.equal(module.formatRejectionReason(''), '-')
assert.equal(module.formatRejectionReason(null), '-')
assert.equal(module.formatRejectionReason(undefined), '-')
} finally {
await cleanup()
}
})

View File

@@ -0,0 +1,69 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import * as esbuild from 'esbuild'
test('agent recharge rejection posts the rejection reason to the order rejection endpoint', async () => {
const calls = []
const tempDir = await mkdtemp(join(tmpdir(), 'agent-recharge-service-'))
const bundlePath = join(tempDir, 'agentRechargeService.mjs')
const httpMockPath = join(tempDir, 'httpMock.mjs')
await writeFile(
httpMockPath,
`
export const calls = globalThis.__agentRechargeHttpCalls
export default {
post(options) {
calls.push(options)
return Promise.resolve({ code: 0, data: undefined })
}
}
`,
'utf8'
)
globalThis.__agentRechargeHttpCalls = calls
try {
await esbuild.build({
entryPoints: ['src/api/modules/agentRecharge.ts'],
bundle: true,
outfile: bundlePath,
format: 'esm',
platform: 'node',
absWorkingDir: process.cwd(),
plugins: [
{
name: 'mock-http-boundary',
setup(build) {
build.onResolve({ filter: /^@\/utils\/http$/ }, () => ({
path: httpMockPath
}))
}
}
]
})
const { AgentRechargeService } = await import(pathToFileURL(bundlePath).href)
await AgentRechargeService.rejectAgentRecharge(42, {
rejection_reason: '支付凭证不符合要求'
})
assert.deepEqual(calls, [
{
url: '/api/admin/agent-recharges/42/reject',
data: {
rejection_reason: '支付凭证不符合要求'
}
}
])
} finally {
delete globalThis.__agentRechargeHttpCalls
await rm(tempDir, { recursive: true, force: true })
}
})

View File

@@ -9,6 +9,7 @@ import type {
AgentRechargeListResponse, AgentRechargeListResponse,
CreateAgentRechargeRequest, CreateAgentRechargeRequest,
ConfirmOfflinePaymentRequest, ConfirmOfflinePaymentRequest,
RejectAgentRechargeRequest,
BaseResponse BaseResponse
} from '@/types/api' } from '@/types/api'
@@ -55,4 +56,16 @@ export class AgentRechargeService extends BaseService {
data data
) )
} }
/**
* 拒绝代理充值订单
* @param id 充值订单ID
* @param data 拒绝请求参数
*/
static rejectAgentRecharge(
id: number,
data: RejectAgentRechargeRequest
): Promise<BaseResponse<void>> {
return this.post<BaseResponse<void>>(`/api/admin/agent-recharges/${id}/reject`, data)
}
} }

View File

@@ -165,9 +165,8 @@ export class AssetService extends BaseService {
* @param identifier 资产标识符ICCID 或 VirtualNo * @param identifier 资产标识符ICCID 或 VirtualNo
*/ */
static stopAsset(identifier: string): Promise<BaseResponse<DeviceStopResponse | void>> { static stopAsset(identifier: string): Promise<BaseResponse<DeviceStopResponse | void>> {
return this.post<BaseResponse<DeviceStopResponse | void>>( return runRateLimitedAssetAction('stop', identifier, () =>
`/api/admin/assets/${identifier}/stop`, this.post<BaseResponse<DeviceStopResponse | void>>(`/api/admin/assets/${identifier}/stop`, {})
{}
) )
} }

View File

@@ -32,6 +32,7 @@ export interface AgentRecharge {
payment_config_id: number | null payment_config_id: number | null
payment_transaction_id: string payment_transaction_id: string
payment_voucher_key?: string[] | string // 凭证附件列表;历史数据可能为单字符串或逗号字符串 payment_voucher_key?: string[] | string // 凭证附件列表;历史数据可能为单字符串或逗号字符串
rejection_reason?: string | null // 拒绝原因
remark?: string // 运营备注 remark?: string // 运营备注
created_at: string created_at: string
paid_at: string | null paid_at: string | null
@@ -72,3 +73,8 @@ export interface CreateAgentRechargeRequest {
export interface ConfirmOfflinePaymentRequest { export interface ConfirmOfflinePaymentRequest {
operation_password: string operation_password: string
} }
// 拒绝代理充值订单请求
export interface RejectAgentRechargeRequest {
rejection_reason: string
}

View File

@@ -1,7 +1,7 @@
const FIVE_MINUTES_MS = 5 * 60 * 1000 const FIVE_MINUTES_MS = 5 * 60 * 1000
const STORAGE_KEY_PREFIX = 'asset-action-rate-limit' const STORAGE_KEY_PREFIX = 'asset-action-rate-limit'
export type AssetRateLimitedAction = 'refresh' | 'start' export type AssetRateLimitedAction = 'refresh' | 'start' | 'stop'
export class FrontendRateLimitError extends Error { export class FrontendRateLimitError extends Error {
readonly remainingMs: number readonly remainingMs: number

View File

@@ -184,7 +184,7 @@
<el-icon <el-icon
class="copy-icon" class="copy-icon"
@click="handleCopyICCID(scope.row.iccid)" @click="handleCopyICCID(scope.row.iccid)"
style=" color: var(--el-color-primary);cursor: pointer" style="color: var(--el-color-primary); cursor: pointer"
> >
<CopyDocument /> <CopyDocument />
</el-icon> </el-icon>
@@ -239,20 +239,22 @@
type="success" type="success"
size="small" size="small"
link link
:disabled="isBindingStartDisabled(scope.row)"
@click="handleEnableBindingCard(scope.row)" @click="handleEnableBindingCard(scope.row)"
v-permission="'iot_info:start'" v-permission="'iot_info:start'"
> >
启用此卡 {{ getBindingStartButtonText(scope.row) }}
</ElButton> </ElButton>
<ElButton <ElButton
v-if="scope.row.network_status === 1" v-if="scope.row.network_status === 1"
type="warning" type="warning"
size="small" size="small"
link link
:disabled="isBindingStopDisabled(scope.row)"
@click="handleDisableBindingCard(scope.row)" @click="handleDisableBindingCard(scope.row)"
v-permission="'iot_info:stop'" v-permission="'iot_info:stop'"
> >
停用此卡 {{ getBindingStopButtonText(scope.row) }}
</ElButton> </ElButton>
<ElButton <ElButton
type="primary" type="primary"
@@ -397,17 +399,19 @@
v-if="cardInfo?.network_status === 0" v-if="cardInfo?.network_status === 0"
v-permission="'iot_info:start'" v-permission="'iot_info:start'"
type="success" type="success"
:disabled="startDisabled"
@click="handleEnableCard" @click="handleEnableCard"
> >
启用此卡 {{ startRemainingText ? `启用此卡(${startRemainingText})` : '启用此卡' }}
</ElButton> </ElButton>
<ElButton <ElButton
v-if="cardInfo?.network_status === 1" v-if="cardInfo?.network_status === 1"
v-permission="'iot_info:stop'" v-permission="'iot_info:stop'"
type="warning" type="warning"
:disabled="stopDisabled"
@click="handleDisableCard" @click="handleDisableCard"
> >
停用此卡 {{ stopRemainingText ? `停用此卡(${stopRemainingText})` : '停用此卡' }}
</ElButton> </ElButton>
<!--<ElButton type="danger" v-permission="'iot_info:handler_stop'" @click="handleManualDeactivate"> 手动停用 </ElButton>--> <!--<ElButton type="danger" v-permission="'iot_info:handler_stop'" @click="handleManualDeactivate"> 手动停用 </ElButton>-->
<!-- 手动停用暂时不开放 --> <!-- 手动停用暂时不开放 -->
@@ -580,10 +584,22 @@
deviceRealtime: DeviceRealtimeInfo | null deviceRealtime: DeviceRealtimeInfo | null
pollingEnabled: boolean pollingEnabled: boolean
realtimeLoading?: boolean realtimeLoading?: boolean
startDisabled?: boolean
stopDisabled?: boolean
startRemainingText?: string
stopRemainingText?: string
getStartRemainingText?: (identifier: string) => string
getStopRemainingText?: (identifier: string) => string
isStartDisabled?: (identifier: string) => boolean
isStopDisabled?: (identifier: string) => boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
realtimeLoading: false realtimeLoading: false,
startDisabled: false,
stopDisabled: false,
startRemainingText: '',
stopRemainingText: ''
}) })
const { hasAuth } = useAuth() const { hasAuth } = useAuth()
@@ -717,17 +733,15 @@
// 卡操作 // 卡操作
const handleEnableCard = () => { const handleEnableCard = () => {
if (props.startDisabled) return
emit('enableCard') emit('enableCard')
} }
const handleDisableCard = () => { const handleDisableCard = () => {
if (props.stopDisabled) return
emit('disableCard') emit('disableCard')
} }
const handleManualDeactivate = () => {
emit('manualDeactivate')
}
// 设备操作 // 设备操作
const handleRebootDevice = () => { const handleRebootDevice = () => {
emit('rebootDevice') emit('rebootDevice')
@@ -758,7 +772,28 @@
} }
// 绑定卡操作 // 绑定卡操作
const getBindingStartRemainingText = (card: BindingCard) =>
props.getStartRemainingText?.(card.iccid) || ''
const getBindingStopRemainingText = (card: BindingCard) =>
props.getStopRemainingText?.(card.iccid) || ''
const isBindingStartDisabled = (card: BindingCard) => props.isStartDisabled?.(card.iccid) || false
const isBindingStopDisabled = (card: BindingCard) => props.isStopDisabled?.(card.iccid) || false
const getBindingStartButtonText = (card: BindingCard) => {
const remainingText = getBindingStartRemainingText(card)
return remainingText ? `启用此卡(${remainingText})` : '启用此卡'
}
const getBindingStopButtonText = (card: BindingCard) => {
const remainingText = getBindingStopRemainingText(card)
return remainingText ? `停用此卡(${remainingText})` : '停用此卡'
}
const handleEnableBindingCard = (card: BindingCard) => { const handleEnableBindingCard = (card: BindingCard) => {
if (isBindingStartDisabled(card)) return
ElMessageBox.confirm(`确定要启用此卡(${card.iccid})吗?`, '启用确认', { ElMessageBox.confirm(`确定要启用此卡(${card.iccid})吗?`, '启用确认', {
confirmButtonText: '确定', confirmButtonText: '确定',
cancelButtonText: '取消', cancelButtonText: '取消',
@@ -771,6 +806,7 @@
} }
const handleDisableBindingCard = (card: BindingCard) => { const handleDisableBindingCard = (card: BindingCard) => {
if (isBindingStopDisabled(card)) return
ElMessageBox.confirm(`确定要停用此卡(${card.iccid})吗?`, '停用确认', { ElMessageBox.confirm(`确定要停用此卡(${card.iccid})吗?`, '停用确认', {
confirmButtonText: '确定', confirmButtonText: '确定',
cancelButtonText: '取消', cancelButtonText: '取消',

View File

@@ -69,6 +69,10 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
} }
} catch (error: any) { } catch (error: any) {
if (error !== 'cancel') { if (error !== 'cancel') {
if (error instanceof FrontendRateLimitError) {
ElMessage.warning(`操作过于频繁,请${formatRemainingTime(error.remainingMs)}后再试`)
return
}
console.error('停用失败:', error) console.error('停用失败:', error)
} }
} finally { } finally {

View File

@@ -20,6 +20,14 @@
:device-realtime="deviceRealtime" :device-realtime="deviceRealtime"
v-model:polling-enabled="pollingEnabled" v-model:polling-enabled="pollingEnabled"
:realtime-loading="realtimeLoading" :realtime-loading="realtimeLoading"
:start-disabled="startDisabled"
:stop-disabled="stopDisabled"
:start-remaining-text="startRemainingText"
:stop-remaining-text="stopRemainingText"
:get-start-remaining-text="getStartRemainingText"
:get-stop-remaining-text="getStopRemainingText"
:is-start-disabled="isStartActionDisabled"
:is-stop-disabled="isStopActionDisabled"
@enable-card="handleEnableCard" @enable-card="handleEnableCard"
@disable-card="handleDisableCard" @disable-card="handleDisableCard"
@manual-deactivate="handleManualDeactivate" @manual-deactivate="handleManualDeactivate"
@@ -176,7 +184,8 @@
import { import {
formatRemainingTime, formatRemainingTime,
FrontendRateLimitError, FrontendRateLimitError,
getAssetActionRateLimitState getAssetActionRateLimitState,
type AssetRateLimitedAction
} from '@/utils/business/apiRateLimit' } from '@/utils/business/apiRateLimit'
// 引入子组件 // 引入子组件
@@ -248,6 +257,8 @@
// ========== 资产操作方法(使用 composable ========== // ========== 资产操作方法(使用 composable ==========
const { const {
enableCardLoading,
disableCardLoading,
enableCard: enableCardOp, enableCard: enableCardOp,
disableCard: disableCardOp, disableCard: disableCardOp,
manualDeactivateCard: manualDeactivateCardOp, manualDeactivateCard: manualDeactivateCardOp,
@@ -342,6 +353,59 @@
: '' : ''
) )
const startRateLimitState = computed(() => {
const identifier = cardInfo.value?.iccid || cardInfo.value?.identifier
if (!identifier) {
return {
limited: false,
remainingMs: 0,
retryAt: 0
}
}
return getAssetActionRateLimitState('start', identifier, rateLimitNow.value)
})
const stopRateLimitState = computed(() => {
const identifier = cardInfo.value?.iccid || cardInfo.value?.identifier
if (!identifier) {
return {
limited: false,
remainingMs: 0,
retryAt: 0
}
}
return getAssetActionRateLimitState('stop', identifier, rateLimitNow.value)
})
const startDisabled = computed(() => enableCardLoading.value || startRateLimitState.value.limited)
const stopDisabled = computed(() => disableCardLoading.value || stopRateLimitState.value.limited)
const startRemainingText = computed(() =>
startRateLimitState.value.limited
? formatRemainingTime(startRateLimitState.value.remainingMs)
: ''
)
const stopRemainingText = computed(() =>
stopRateLimitState.value.limited
? formatRemainingTime(stopRateLimitState.value.remainingMs)
: ''
)
const getActionRemainingText = (action: AssetRateLimitedAction, identifier?: string) => {
if (!identifier) return ''
const state = getAssetActionRateLimitState(action, identifier, rateLimitNow.value)
return state.limited ? formatRemainingTime(state.remainingMs) : ''
}
const isActionDisabled = (action: AssetRateLimitedAction, identifier?: string) => {
if (!identifier) return false
return getAssetActionRateLimitState(action, identifier, rateLimitNow.value).limited
}
const getStartRemainingText = (identifier: string) => getActionRemainingText('start', identifier)
const getStopRemainingText = (identifier: string) => getActionRemainingText('stop', identifier)
const isStartActionDisabled = (identifier: string) => isActionDisabled('start', identifier)
const isStopActionDisabled = (identifier: string) => isActionDisabled('stop', identifier)
const updateRateLimitNow = () => { const updateRateLimitNow = () => {
rateLimitNow.value = Date.now() rateLimitNow.value = Date.now()
} }
@@ -399,14 +463,24 @@
* IoT卡操作 - 启用卡 * IoT卡操作 - 启用卡
*/ */
const handleEnableCard = async () => { const handleEnableCard = async () => {
await enableCardOp() if (startDisabled.value) return
try {
await enableCardOp()
} finally {
updateRateLimitNow()
}
} }
/** /**
* IoT卡操作 - 停用卡 * IoT卡操作 - 停用卡
*/ */
const handleDisableCard = async () => { const handleDisableCard = async () => {
await disableCardOp() if (stopDisabled.value) return
try {
await disableCardOp()
} finally {
updateRateLimitNow()
}
} }
/** /**
@@ -550,6 +624,7 @@
*/ */
const handleEnableBindingCard = async (payload: { card: any }) => { const handleEnableBindingCard = async (payload: { card: any }) => {
const { card } = payload const { card } = payload
if (isStartActionDisabled(card.iccid)) return
try { try {
const { AssetService } = await import('@/api/modules') const { AssetService } = await import('@/api/modules')
await AssetService.startAsset(card.iccid) await AssetService.startAsset(card.iccid)
@@ -562,6 +637,8 @@
return return
} }
console.error('启用绑定卡失败:', error) console.error('启用绑定卡失败:', error)
} finally {
updateRateLimitNow()
} }
} }
@@ -570,6 +647,7 @@
*/ */
const handleDisableBindingCard = async (payload: { card: any }) => { const handleDisableBindingCard = async (payload: { card: any }) => {
const { card } = payload const { card } = payload
if (isStopActionDisabled(card.iccid)) return
try { try {
const { AssetService } = await import('@/api/modules') const { AssetService } = await import('@/api/modules')
await AssetService.stopAsset(card.iccid) await AssetService.stopAsset(card.iccid)
@@ -577,7 +655,13 @@
if (cardInfo.value) if (cardInfo.value)
await loadRealtimeStatus(cardInfo.value.identifier, cardInfo.value.asset_type) await loadRealtimeStatus(cardInfo.value.identifier, cardInfo.value.asset_type)
} catch (error: any) { } catch (error: any) {
if (error instanceof FrontendRateLimitError) {
ElMessage.warning(`操作过于频繁,请${formatRemainingTime(error.remainingMs)}后再试`)
return
}
console.error('停用绑定卡失败:', error) console.error('停用绑定卡失败:', error)
} finally {
updateRateLimitNow()
} }
} }

View File

@@ -2920,6 +2920,10 @@
await getTableData() await getTableData()
} }
} catch (error: any) { } catch (error: any) {
if (error instanceof FrontendRateLimitError) {
ElMessage.warning(`操作过于频繁,请${formatRemainingTime(error.remainingMs)}后再试`)
return
}
console.error('停用此卡失败:', error) console.error('停用此卡失败:', error)
} }
}) })

View File

@@ -0,0 +1,56 @@
import { AgentRechargeStatus, type AgentRecharge } from '@/types/api'
import { hasVoucherKeys } from '@/utils/business'
export interface AgentRechargeAction {
label: string
handler: () => void
type: 'primary' | 'danger'
}
interface BuildAgentRechargeActionsOptions {
hasAuth: (permission: string) => boolean
onViewPaymentVoucher: (row: AgentRecharge) => void
onConfirmPayment: (row: AgentRecharge) => void
onReject: (row: AgentRecharge) => void
}
export const buildAgentRechargeActions = (
row: AgentRecharge,
options: BuildAgentRechargeActionsOptions
): AgentRechargeAction[] => {
const actions: AgentRechargeAction[] = []
if (
row.payment_method === 'offline' &&
hasVoucherKeys(row.payment_voucher_key) &&
options.hasAuth('agent_recharge:view_payment_voucher')
) {
actions.push({
label: '查看支付凭证',
handler: () => options.onViewPaymentVoucher(row),
type: 'primary'
})
}
if (
row.status === AgentRechargeStatus.PENDING &&
row.payment_method === 'offline' &&
options.hasAuth('agent_recharge:confirm_payment')
) {
actions.push({
label: '确认支付',
handler: () => options.onConfirmPayment(row),
type: 'primary'
})
}
if (row.status === AgentRechargeStatus.PENDING && options.hasAuth('agent_recharge:reject')) {
actions.push({
label: '拒绝',
handler: () => options.onReject(row),
type: 'danger'
})
}
return actions
}

View File

@@ -0,0 +1,3 @@
export const formatRejectionReason = (reason?: string | null): string => {
return reason || '-'
}

View File

@@ -41,6 +41,7 @@
import { formatDateTime } from '@/utils/business/format' import { formatDateTime } from '@/utils/business/format'
import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business' import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue' import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import { formatRejectionReason } from './agentRechargeDisplay'
defineOptions({ name: 'AgentRechargeDetail' }) defineOptions({ name: 'AgentRechargeDetail' })
@@ -111,6 +112,12 @@
h(ElTag, { type: getStatusType(data.status) }, () => h(ElTag, { type: getStatusType(data.status) }, () =>
getStatusText(data.status, data.status_name) getStatusText(data.status, data.status_name)
) )
},
{
label: '驳回原因',
prop: 'rejection_reason',
formatter: (value) => formatRejectionReason(value),
fullWidth: true
} }
] ]
}, },

View File

@@ -167,6 +167,38 @@
</template> </template>
</ElDialog> </ElDialog>
<!-- 拒绝充值订单对话框 -->
<ElDialog
v-model="rejectDialogVisible"
title="拒绝充值订单"
width="500px"
@closed="handleRejectDialogClosed"
>
<ElForm ref="rejectFormRef" :model="rejectForm" :rules="rejectRules" label-width="100px">
<ElFormItem label="充值单号">
<span>{{ currentRecharge?.recharge_no }}</span>
</ElFormItem>
<ElFormItem label="拒绝原因" prop="rejection_reason">
<ElInput
v-model="rejectForm.rejection_reason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请输入拒绝原因"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="rejectDialogVisible = false">取消</ElButton>
<ElButton type="danger" @click="handleRejectRecharge" :loading="rejectLoading">
确认拒绝
</ElButton>
</div>
</template>
</ElDialog>
<!-- 支付凭证预览 --> <!-- 支付凭证预览 -->
<PaymentVoucherDialog <PaymentVoucherDialog
:file-keys="paymentVoucherFileKeys" :file-keys="paymentVoucherFileKeys"
@@ -198,7 +230,8 @@
AgentRechargeStatus, AgentRechargeStatus,
AgentRechargePaymentMethod, AgentRechargePaymentMethod,
CreateAgentRechargeRequest, CreateAgentRechargeRequest,
ConfirmOfflinePaymentRequest ConfirmOfflinePaymentRequest,
RejectAgentRechargeRequest
} from '@/types/api' } from '@/types/api'
import type { SearchFormItem } from '@/types' import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns' import { useCheckedColumns } from '@/composables/useCheckedColumns'
@@ -207,6 +240,8 @@
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue' import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import VoucherUpload from '@/components/business/VoucherUpload.vue' import VoucherUpload from '@/components/business/VoucherUpload.vue'
import { buildAgentRechargeActions } from './agentRechargeActions'
import { formatRejectionReason } from './agentRechargeDisplay'
defineOptions({ name: 'AgentRechargeList' }) defineOptions({ name: 'AgentRechargeList' })
@@ -217,9 +252,11 @@
const createLoading = ref(false) const createLoading = ref(false)
const voucherUploading = ref(false) const voucherUploading = ref(false)
const confirmPayLoading = ref(false) const confirmPayLoading = ref(false)
const rejectLoading = ref(false)
const tableRef = ref() const tableRef = ref()
const createDialogVisible = ref(false) const createDialogVisible = ref(false)
const confirmPayDialogVisible = ref(false) const confirmPayDialogVisible = ref(false)
const rejectDialogVisible = ref(false)
const currentRecharge = ref<AgentRecharge | null>(null) const currentRecharge = ref<AgentRecharge | null>(null)
const paymentVoucherFileKeys = ref<string[]>([]) const paymentVoucherFileKeys = ref<string[]>([])
@@ -330,6 +367,7 @@
{ label: '状态', prop: 'status' }, { label: '状态', prop: 'status' },
{ label: '支付方式', prop: 'payment_method' }, { label: '支付方式', prop: 'payment_method' },
{ label: '支付通道', prop: 'payment_channel' }, { label: '支付通道', prop: 'payment_channel' },
{ label: '驳回原因', prop: 'rejection_reason' },
{ label: '创建时间', prop: 'created_at' }, { label: '创建时间', prop: 'created_at' },
{ label: '支付时间', prop: 'paid_at' }, { label: '支付时间', prop: 'paid_at' },
{ label: '完成时间', prop: 'completed_at' } { label: '完成时间', prop: 'completed_at' }
@@ -337,6 +375,7 @@
const createFormRef = ref<FormInstance>() const createFormRef = ref<FormInstance>()
const confirmPayFormRef = ref<FormInstance>() const confirmPayFormRef = ref<FormInstance>()
const rejectFormRef = ref<FormInstance>()
const uploadRef = ref<InstanceType<typeof VoucherUpload>>() const uploadRef = ref<InstanceType<typeof VoucherUpload>>()
const MIN_RECHARGE_AMOUNT = 0.01 const MIN_RECHARGE_AMOUNT = 0.01
@@ -374,6 +413,10 @@
] ]
}) })
const rejectRules = reactive<FormRules>({
rejection_reason: [{ required: true, message: '请输入拒绝原因', trigger: 'blur' }]
})
const createForm = reactive<{ const createForm = reactive<{
amount: number amount: number
payment_method: string payment_method: string
@@ -392,6 +435,10 @@
operation_password: '' operation_password: ''
}) })
const rejectForm = reactive<RejectAgentRechargeRequest>({
rejection_reason: ''
})
const rechargeList = ref<AgentRecharge[]>([]) const rechargeList = ref<AgentRecharge[]>([])
// 格式化货币 - 将分转换为元 // 格式化货币 - 将分转换为元
@@ -496,6 +543,13 @@
showOverflowTooltip: true, showOverflowTooltip: true,
formatter: (row: AgentRecharge) => row.remark || '-' formatter: (row: AgentRecharge) => row.remark || '-'
}, },
{
prop: 'rejection_reason',
label: '驳回原因',
minWidth: 180,
showOverflowTooltip: true,
formatter: (row: AgentRecharge) => formatRejectionReason(row.rejection_reason)
},
{ {
prop: 'created_at', prop: 'created_at',
label: '创建时间', label: '创建时间',
@@ -735,6 +789,44 @@
}) })
} }
// 显示拒绝对话框
const handleShowReject = (row: AgentRecharge) => {
currentRecharge.value = row
rejectDialogVisible.value = true
}
// 拒绝对话框关闭后的清理
const handleRejectDialogClosed = () => {
rejectFormRef.value?.resetFields()
rejectForm.rejection_reason = ''
currentRecharge.value = null
}
// 拒绝代理充值订单
const handleRejectRecharge = async () => {
const formRef = rejectFormRef.value
const recharge = currentRecharge.value
if (!formRef || !recharge) return
await formRef.validate(async (valid) => {
if (valid) {
rejectLoading.value = true
try {
await AgentRechargeService.rejectAgentRecharge(recharge.id, {
rejection_reason: rejectForm.rejection_reason
})
ElMessage.success('拒绝成功')
rejectDialogVisible.value = false
await getTableData()
} catch (error) {
console.error(error)
} finally {
rejectLoading.value = false
}
}
})
}
// 处理名称点击 // 处理名称点击
const handleNameClick = (row: AgentRecharge) => { const handleNameClick = (row: AgentRecharge) => {
if (hasAuth('agent_recharge:detail_page')) { if (hasAuth('agent_recharge:detail_page')) {
@@ -754,35 +846,12 @@
// 获取操作按钮 // 获取操作按钮
const getActions = (row: AgentRecharge) => { const getActions = (row: AgentRecharge) => {
const actions: any[] = [] return buildAgentRechargeActions(row, {
hasAuth,
// 线下支付且有凭证可以查看 onViewPaymentVoucher: handleViewPaymentVoucher,
if ( onConfirmPayment: handleShowConfirmPay,
row.payment_method === 'offline' && onReject: handleShowReject
hasVoucherKeys(row.payment_voucher_key) && })
hasAuth('agent_recharge:view_payment_voucher')
) {
actions.push({
label: '查看支付凭证',
handler: () => handleViewPaymentVoucher(row),
type: 'primary'
})
}
// 待支付且线下转账的订单可以确认支付
if (
row.status === 1 &&
row.payment_method === 'offline' &&
hasAuth('agent_recharge:confirm_payment')
) {
actions.push({
label: '确认支付',
handler: () => handleShowConfirmPay(row),
type: 'primary'
})
}
return actions
} }
// 查看支付凭证 // 查看支付凭证