完善接口和后台页面

This commit is contained in:
2026-04-18 15:19:36 +08:00
parent a4878a9bbd
commit e3f26ba1f7
45 changed files with 3071 additions and 232 deletions

View File

@@ -0,0 +1,29 @@
export default {
title: 'Deposit Tiers',
desc: 'Configure the deposit tiers players can pick when creating a deposit order. In the third-party payment mode, only tier specs (name, amount, bonus, description) are maintained; receiving accounts are no longer stored here. Maintain both Chinese and English text for the title and description: the mobile API returns the language matching the request `lang` header, falling back to Chinese if English is blank. Changes take effect immediately.',
btn_add: 'Add Tier',
btn_save: 'Save',
btn_remove: 'Delete',
confirm_remove: 'Delete this deposit tier?',
tier_id: 'Tier ID',
auto_id: '(generated on save)',
sort: 'Sort',
status: 'Enabled',
title_col: 'Title (ZH)',
title_ph: 'e.g. 新手首充、VIP 高额充值',
title_en_col: 'Title (EN)',
title_en_ph: 'e.g. Starter Pack, VIP Recharge',
amount: 'Amount',
amount_ph: 'e.g. 100.00',
bonus_amount: 'Bonus',
bonus_ph: 'e.g. 20.00, use 0 if none',
desc_col: 'Description (ZH)',
desc_ph: 'Optional Chinese description, up to 255 chars',
desc_en_col: 'Description (EN)',
desc_en_ph: 'Optional English description, up to 255 chars',
currency: '',
operate: 'Action',
err_title: 'Row {no}: Chinese title is required',
err_amount: 'Row {no}: amount must be a number greater than 0',
err_bonus: 'Row {no}: bonus must be a number no less than 0',
}

View File

@@ -6,9 +6,7 @@ export default {
user_id: 'User ID',
channel_id: 'Channel ID',
pick_numbers: 'Picks',
unit_amount: 'Unit amount',
pick_count: 'Pick count',
total_amount: 'Total',
total_amount: 'Total bet amount',
streak_at_bet: 'Streak at bet',
is_auto: 'Auto',
'is_auto 0': 'Manual',

View File

@@ -18,6 +18,6 @@ export default {
bet_id: 'Bet ID',
user_id: 'Player ID',
pick_numbers: 'Pick numbers',
unit_amount: 'Unit amount',
total_amount: 'Total bet amount',
streak_at_bet: 'Streak at bet',
}

View File

@@ -11,7 +11,8 @@ export default {
coin: 'Coin balance',
coin_placeholder: 'decimal(18,4)',
total_deposit_coin: 'Total deposit (coin)',
total_valid_bet_coin: 'Total valid bet (coin)',
total_withdraw_coin: 'Total withdraw (coin)',
bet_flow_coin: 'Bet flow (coin)',
risk_flags: 'Risk',
risk_none: 'None',
risk_no_login: 'No login',

View File

@@ -6,9 +6,7 @@
user_id: 'User ID',
channel_id: 'Channel ID',
pick_numbers: 'Picks',
unit_amount: 'Unit amount',
pick_count: 'Pick count',
total_amount: 'Total',
total_amount: 'Total bet amount',
streak_at_bet: 'Streak at bet',
is_auto: 'Auto',
'is_auto 0': 'Manual',

View File

@@ -1,10 +1,13 @@
export default {
'quick Search Fields': 'Order No./User ID/Pay channel',
'quick Search Fields': 'Order No./User ID/Pay channel/Tier/Idempotency key',
id: 'ID',
order_no: 'Order No.',
idempotency_key: 'Idempotency key',
user_id: 'User ID',
channel_id: 'Channel ID',
amount: 'Amount',
bonus_amount: 'Bonus',
total_credit: 'Total credit',
status: 'Status',
'status 0': 'Pending',
'status 1': 'Success',
@@ -12,9 +15,12 @@ export default {
'status 3': 'Canceled',
pay_channel: 'Pay channel',
pay_time: 'Pay time',
deposit_tier_id: 'Deposit tier',
remark: 'Remark',
create_time: 'Created',
update_time: 'Updated',
user_username: 'Username',
channel_name: 'Channel',
detail_title: 'Deposit Order Detail',
close_btn: 'Close',
}

View File

@@ -17,7 +17,20 @@ export default {
remark: 'Remark',
create_time: 'Created',
update_time: 'Updated',
user_username: 'Username',
user_username: 'User',
channel_name: 'Channel',
review_admin_username: 'Reviewer',
review_title: 'Withdraw review',
review_reject_title: 'Reject withdraw',
review_btn_approve: 'Approve',
review_btn_reject: 'Reject',
review_btn_back: 'Back',
review_btn_confirm_reject: 'Confirm reject',
review_reject_tip: 'Rejected withdrawals will refund the frozen amount back to the user wallet.',
review_reject_placeholder: 'Enter reject reason (visible to the user on mobile history)',
reject_reason_required: 'Please enter reject reason',
already_reviewed: 'This order has already been reviewed',
amount_invalid: 'Apply amount must be greater than 0',
fee_invalid: 'Fee cannot be negative',
fee_exceed_amount: 'Fee cannot exceed apply amount',
}

View File

@@ -11,7 +11,8 @@ export default {
coin: 'Coin balance',
coin_placeholder: 'Amounts are displayed with 2 decimals',
total_deposit_coin: 'Total deposit (coin)',
total_valid_bet_coin: 'Total valid bet (coin)',
total_withdraw_coin: 'Total withdraw (coin)',
bet_flow_coin: 'Bet flow (coin)',
risk_flags: 'Risk',
risk_none: 'None',
risk_no_login: 'No login',

View File

@@ -0,0 +1,29 @@
export default {
title: '充值档位',
desc: '配置玩家创建充值订单时可选的充值档位。第三方支付模式下仅需维护档位规格:名称、充值金额、赠送金额、描述等;不再保存收款账户信息。充值名称/描述需分别维护中英文两套:移动端接口会根据请求头 `lang` 返回对应语言,英文缺省时回退到中文。修改后立即生效。',
btn_add: '新增档位',
btn_save: '保存',
btn_remove: '删除',
confirm_remove: '确定删除该充值档位?',
tier_id: '档位 ID',
auto_id: '(保存时生成)',
sort: '排序',
status: '启用',
title_col: '充值名称(中文)',
title_ph: '例如新手首充、VIP 高额充值',
title_en_col: '充值名称(英文)',
title_en_ph: 'e.g. Starter Pack, VIP Recharge',
amount: '充值金额',
amount_ph: '例如100.00',
bonus_amount: '赠送金额',
bonus_ph: '例如20.00,无赠送填 0',
desc_col: '描述(中文)',
desc_ph: '可选,展示给中文玩家的档位说明,最长 255 字',
desc_en_col: '描述(英文)',
desc_en_ph: 'Optional English description for EN players, up to 255 chars',
currency: '币',
operate: '操作',
err_title: '第 {no} 行:中文充值名称不能为空',
err_amount: '第 {no} 行:充值金额必须为大于 0 的数字',
err_bonus: '第 {no} 行:赠送金额必须为不小于 0 的数字',
}

View File

@@ -6,9 +6,7 @@ export default {
user_id: '用户ID',
channel_id: '渠道ID',
pick_numbers: '选号',
unit_amount: '单号金额',
pick_count: '选号个数',
total_amount: '总金额',
total_amount: '压注总额',
streak_at_bet: '下注时连胜',
is_auto: '托管',
'is_auto 0': '手动',

View File

@@ -18,6 +18,6 @@ export default {
bet_id: '注单ID',
user_id: '玩家ID',
pick_numbers: '压注号码',
unit_amount: '单号金额',
total_amount: '压注总额',
streak_at_bet: '下注时连胜',
}

View File

@@ -11,7 +11,8 @@ export default {
coin: '游戏币余额',
coin_placeholder: 'decimal(18,4),禁止业务用浮点存库',
total_deposit_coin: '累计充值(币)',
total_valid_bet_coin: '累计有效投注(币)',
total_withdraw_coin: '累计提现(币)',
bet_flow_coin: '打码量/流水(币)',
risk_flags: '风控',
risk_none: '无限制',
risk_no_login: '禁止登录',

View File

@@ -6,9 +6,7 @@
user_id: '用户ID',
channel_id: '渠道ID',
pick_numbers: '选号',
unit_amount: '单号金额',
pick_count: '选号个数',
total_amount: '总金额',
total_amount: '压注总额',
streak_at_bet: '下注时连胜',
is_auto: '托管',
'is_auto 0': '手动',

View File

@@ -1,20 +1,26 @@
export default {
'quick Search Fields': '订单号/用户ID/支付通道',
'quick Search Fields': '订单号/用户ID/支付通道/档位ID/幂等键',
id: 'ID',
order_no: '订单号',
idempotency_key: '幂等键',
user_id: '用户ID',
user_username: '用户名',
channel_id: '渠道ID',
channel_name: '渠道',
amount: '金额',
bonus_amount: '赠送金额',
total_credit: '实际到账',
status: '状态',
'status 0': '待处理',
'status 0': '待支付',
'status 1': '成功',
'status 2': '失败',
'status 3': '已取消',
pay_channel: '支付通道',
pay_time: '支付时间',
deposit_tier_id: '充值档位',
remark: '备注',
create_time: '创建时间',
update_time: '更新时间',
detail_title: '充值订单详情',
close_btn: '关闭',
}

View File

@@ -17,7 +17,20 @@ export default {
remark: '备注',
create_time: '创建时间',
update_time: '更新时间',
user_username: '用户',
user_username: '用户',
channel_name: '渠道',
review_admin_username: '审核人',
review_title: '提现审核',
review_reject_title: '提现拒绝',
review_btn_approve: '通过',
review_btn_reject: '拒绝',
review_btn_back: '返回',
review_btn_confirm_reject: '确认拒绝',
review_reject_tip: '拒绝审核后,冻结的提现金额将原路退回用户钱包余额。',
review_reject_placeholder: '请输入拒绝原因,玩家可在提现记录中看到该说明',
reject_reason_required: '请输入拒绝原因',
already_reviewed: '该订单已审核,无需重复操作',
amount_invalid: '申请金额必须大于 0',
fee_invalid: '手续费不能为负',
fee_exceed_amount: '手续费不能大于申请金额',
}

View File

@@ -1,4 +1,4 @@
export default {
export default {
id: 'ID',
username: '用户名',
password: '密码',
@@ -11,7 +11,8 @@
coin: '余额',
coin_placeholder: '金额展示统一两位小数',
total_deposit_coin: '累计充值(币)',
total_valid_bet_coin: '累计有效投注(币)',
total_withdraw_coin: '累计提现(币)',
bet_flow_coin: '打码量/流水(币)',
risk_flags: '风控',
risk_none: '无限制',
risk_no_login: '禁止登录',

View File

@@ -0,0 +1,251 @@
<template>
<div class="default-main ba-table-box deposit-tier-page">
<el-alert type="info" :closable="false" show-icon>
{{ t('config.depositTier.desc') }}
</el-alert>
<div class="toolbar">
<el-button type="primary" :disabled="loading" @click="onAdd">
<Icon name="el-icon-Plus" />
<span class="ml-6">{{ t('config.depositTier.btn_add') }}</span>
</el-button>
<el-button type="success" :loading="saving" :disabled="loading" @click="onSave">
{{ t('config.depositTier.btn_save') }}
</el-button>
</div>
<el-table v-loading="loading" border stripe :data="items" row-key="_rowKey" max-height="720">
<el-table-column prop="sort" :label="t('config.depositTier.sort')" width="100" align="center">
<template #default="{ row }">
<el-input-number v-model="row.sort" :min="0" :max="9999" :controls="false" style="width: 100%" />
</template>
</el-table-column>
<el-table-column :label="t('config.depositTier.status')" width="100" align="center">
<template #default="{ row }">
<el-switch v-model="row.status" :active-value="1" :inactive-value="0" />
</template>
</el-table-column>
<el-table-column :label="t('config.depositTier.title_col')" min-width="180">
<template #default="{ row }">
<el-input v-model="row.title" maxlength="64" :placeholder="t('config.depositTier.title_ph')" />
</template>
</el-table-column>
<el-table-column :label="t('config.depositTier.title_en_col')" min-width="180">
<template #default="{ row }">
<el-input v-model="row.title_en" maxlength="64" :placeholder="t('config.depositTier.title_en_ph')" />
</template>
</el-table-column>
<el-table-column :label="t('config.depositTier.amount')" min-width="140">
<template #default="{ row }">
<el-input v-model="row.amount" :placeholder="t('config.depositTier.amount_ph')">
<template #suffix>
<span class="currency">{{ t('config.depositTier.currency') }}</span>
</template>
</el-input>
</template>
</el-table-column>
<el-table-column :label="t('config.depositTier.bonus_amount')" min-width="140">
<template #default="{ row }">
<el-input v-model="row.bonus_amount" :placeholder="t('config.depositTier.bonus_ph')">
<template #suffix>
<span class="currency">{{ t('config.depositTier.currency') }}</span>
</template>
</el-input>
</template>
</el-table-column>
<el-table-column :label="t('config.depositTier.desc_col')" min-width="220">
<template #default="{ row }">
<el-input v-model="row.desc" maxlength="255" :autosize="{ minRows: 1, maxRows: 3 }" type="textarea" :placeholder="t('config.depositTier.desc_ph')" />
</template>
</el-table-column>
<el-table-column :label="t('config.depositTier.desc_en_col')" min-width="220">
<template #default="{ row }">
<el-input v-model="row.desc_en" maxlength="255" :autosize="{ minRows: 1, maxRows: 3 }" type="textarea" :placeholder="t('config.depositTier.desc_en_ph')" />
</template>
</el-table-column>
<el-table-column :label="t('config.depositTier.tier_id')" width="140">
<template #default="{ row }">
<el-text class="tier-id" truncated>{{ row.id || t('config.depositTier.auto_id') }}</el-text>
</template>
</el-table-column>
<el-table-column :label="t('config.depositTier.operate')" width="90" align="center" fixed="right">
<template #default="{ $index }">
<el-button type="danger" link @click="onRemove($index)">
{{ t('config.depositTier.btn_remove') }}
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import createAxios from '/@/utils/axios'
import { auth } from '/@/utils/common'
defineOptions({
name: 'config/depositTier',
})
const { t } = useI18n()
type Tier = {
id: string
title: string
title_en: string
amount: string
bonus_amount: string
desc: string
desc_en: string
sort: number
status: number
_rowKey?: string
}
const loading = ref(false)
const saving = ref(false)
const items = ref<Tier[]>([])
function genRowKey(): string {
return 'r_' + Math.random().toString(36).slice(2, 10)
}
function emptyTier(): Tier {
return {
id: '',
title: '',
title_en: '',
amount: '',
bonus_amount: '0',
desc: '',
desc_en: '',
sort: 0,
status: 1,
_rowKey: genRowKey(),
}
}
async function load() {
loading.value = true
try {
const res = await createAxios({
url: '/admin/config.DepositTier/index',
method: 'get',
})
if (res.code === 1 && res.data) {
const list = (res.data.items || []) as Tier[]
items.value = (Array.isArray(list) ? list : []).map((it) => ({
...emptyTier(),
...it,
_rowKey: genRowKey(),
}))
}
} finally {
loading.value = false
}
}
function onAdd() {
items.value.push(emptyTier())
}
async function onRemove(idx: number) {
try {
await ElMessageBox.confirm(t('config.depositTier.confirm_remove'), t('Warning'), {
type: 'warning',
confirmButtonText: t('Delete'),
cancelButtonText: t('Cancel'),
})
} catch {
return
}
items.value.splice(idx, 1)
}
async function onSave() {
if (!auth('save')) {
return
}
for (let i = 0; i < items.value.length; i++) {
const row = items.value[i]
if (!row.title || !row.title.trim()) {
ElMessage.warning(t('config.depositTier.err_title', { no: i + 1 }))
return
}
const amount = Number(row.amount)
if (!row.amount || Number.isNaN(amount) || amount <= 0) {
ElMessage.warning(t('config.depositTier.err_amount', { no: i + 1 }))
return
}
const bonusRaw = row.bonus_amount === '' || row.bonus_amount === null || row.bonus_amount === undefined ? '0' : row.bonus_amount
const bonus = Number(bonusRaw)
if (Number.isNaN(bonus) || bonus < 0) {
ElMessage.warning(t('config.depositTier.err_bonus', { no: i + 1 }))
return
}
}
saving.value = true
try {
await createAxios({
url: '/admin/config.DepositTier/save',
method: 'post',
data: {
items: items.value.map((r) => ({
id: r.id,
title: r.title,
title_en: r.title_en || '',
amount: r.amount,
bonus_amount: r.bonus_amount === '' || r.bonus_amount === null || r.bonus_amount === undefined ? '0' : r.bonus_amount,
desc: r.desc || '',
desc_en: r.desc_en || '',
sort: r.sort,
status: r.status,
})),
},
showSuccessMessage: true,
})
await load()
} finally {
saving.value = false
}
}
onMounted(() => {
void load()
})
</script>
<style scoped lang="scss">
.deposit-tier-page {
.toolbar {
margin: 12px 0;
display: flex;
gap: 12px;
}
.currency {
color: var(--el-text-color-placeholder);
font-size: 12px;
}
.tier-id {
display: inline-block;
max-width: 120px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
}
</style>

View File

@@ -48,7 +48,7 @@
{{ formatPicks(scope.row.pick_numbers) }}
</template>
</el-table-column>
<el-table-column prop="unit_amount" :label="t('game.live.unit_amount')" width="120" />
<el-table-column prop="total_amount" :label="t('game.live.total_amount')" width="120" />
<el-table-column prop="streak_at_bet" :label="t('game.live.streak_at_bet')" width="90" />
</el-table>
</el-card>

View File

@@ -142,15 +142,6 @@ const baTable = new baTableClass(
operator: false,
formatter: formatPickNumbers,
},
{
label: t('order.betOrder.unit_amount'),
prop: 'unit_amount',
align: 'center',
minWidth: 110,
operator: 'RANGE',
formatter: formatAmount,
},
{ label: t('order.betOrder.pick_count'), prop: 'pick_count', align: 'center', width: 90, operator: 'RANGE' },
{
label: t('order.betOrder.total_amount'),
prop: 'total_amount',

View File

@@ -1,9 +1,9 @@
<template>
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:buttons="['refresh', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('order.depositOrder.quick Search Fields') })"
></TableHeader>
@@ -29,7 +29,19 @@ defineOptions({
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
const optButtons: OptButton[] = defaultOptButtons(['edit'])
function formatAmount(_row: anyObj, _column: any, cellValue: unknown) {
if (cellValue === null || cellValue === undefined || cellValue === '') {
return '-'
}
const s = String(cellValue).trim().replace(',', '.')
const n = parseFloat(s)
if (!Number.isFinite(n)) {
return String(cellValue)
}
return n.toFixed(2)
}
const baTable = new baTableClass(
new baTableApi('/admin/order.DepositOrder/'),
@@ -46,12 +58,11 @@ const baTable = new baTableClass(
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{ label: t('order.depositOrder.user_id'), prop: 'user_id', align: 'center', width: 90, operator: 'RANGE' },
{
label: t('order.depositOrder.user_username'),
prop: 'user.username',
align: 'center',
minWidth: 110,
minWidth: 120,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
@@ -65,7 +76,22 @@ const baTable = new baTableClass(
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
},
{ label: t('order.depositOrder.amount'), prop: 'amount', align: 'center', minWidth: 110, operator: 'RANGE' },
{
label: t('order.depositOrder.amount'),
prop: 'amount',
align: 'center',
minWidth: 110,
operator: 'RANGE',
formatter: formatAmount,
},
{
label: t('order.depositOrder.bonus_amount'),
prop: 'bonus_amount',
align: 'center',
minWidth: 110,
operator: 'RANGE',
formatter: formatAmount,
},
{
label: t('order.depositOrder.status'),
prop: 'status',
@@ -76,9 +102,9 @@ const baTable = new baTableClass(
effect: 'dark',
custom: {
'0': 'info',
'1': 'warning',
'2': 'success',
'3': 'danger',
'1': 'success',
'2': 'danger',
'3': 'warning',
},
replaceValue: {
'0': t('order.depositOrder.status 0'),
@@ -91,10 +117,19 @@ const baTable = new baTableClass(
label: t('order.depositOrder.pay_channel'),
prop: 'pay_channel',
align: 'center',
minWidth: 110,
minWidth: 130,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('order.depositOrder.deposit_tier_id'),
prop: 'deposit_tier_id',
align: 'center',
minWidth: 120,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
show: false,
},
{
label: t('order.depositOrder.pay_time'),
prop: 'pay_time',
@@ -106,6 +141,16 @@ const baTable = new baTableClass(
width: 170,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('order.depositOrder.idempotency_key'),
prop: 'idempotency_key',
align: 'center',
minWidth: 170,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
show: false,
},
{
label: t('order.depositOrder.remark'),
prop: 'remark',
@@ -136,6 +181,7 @@ const baTable = new baTableClass(
sortable: 'custom',
width: 170,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
show: false,
},
{
label: t('Operate'),
@@ -149,7 +195,7 @@ const baTable = new baTableClass(
],
},
{
defaultItems: { status: 0, amount: '0.0000' },
defaultItems: { status: 0, amount: '0.0000', bonus_amount: '0.0000' },
}
)

View File

@@ -1,48 +1,231 @@
<template>
<el-dialog class="ba-operate-dialog" :close-on-click-modal="false" :model-value="['Add', 'Edit'].includes(baTable.form.operate!)" @close="baTable.toggleForm">
<template>
<el-dialog
class="ba-operate-dialog deposit-detail-dialog"
:close-on-click-modal="false"
:model-value="isOpen"
width="640px"
@close="onDialogClose"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">{{ baTable.form.operate ? t(baTable.form.operate) : '' }}</div>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ t('order.depositOrder.detail_title') }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div class="ba-operate-form" :class="'ba-' + baTable.form.operate + '-form'" :style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'">
<el-form v-if="!baTable.form.loading" ref="formRef" @submit.prevent="" @keyup.enter="baTable.onSubmit(formRef)" :model="baTable.form.items" :label-position="config.layout.shrink ? 'top' : 'right'" :label-width="baTable.form.labelWidth + 'px'" :rules="rules">
<FormItem :label="t('order.depositOrder.order_no')" type="string" v-model="baTable.form.items!.order_no" prop="order_no" />
<FormItem :label="t('order.depositOrder.user_id')" type="number" v-model="baTable.form.items!.user_id" prop="user_id" :input-attr="{ min: 1, step: 1 }" />
<FormItem :label="t('order.depositOrder.channel_id')" type="number" v-model="baTable.form.items!.channel_id" prop="channel_id" :input-attr="{ min: 1, step: 1 }" />
<FormItem :label="t('order.depositOrder.amount')" type="number" v-model="baTable.form.items!.amount" prop="amount" :input-attr="{ step: 0.0001, precision: 4, min: 0 }" />
<FormItem :label="t('order.depositOrder.status')" type="radio" v-model="baTable.form.items!.status" prop="status" :input-attr="{ content: { '0': t('order.depositOrder.status 0'), '1': t('order.depositOrder.status 1'), '2': t('order.depositOrder.status 2'), '3': t('order.depositOrder.status 3') } }" />
<FormItem :label="t('order.depositOrder.pay_channel')" type="string" v-model="baTable.form.items!.pay_channel" prop="pay_channel" />
<FormItem :label="t('order.depositOrder.pay_time')" type="datetime" v-model="baTable.form.items!.pay_time" prop="pay_time" />
<FormItem :label="t('order.depositOrder.remark')" type="textarea" v-model="baTable.form.items!.remark" prop="remark" :input-attr="{ rows: 2 }" />
<el-scrollbar v-loading="loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form ba-edit-form"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + (baTable.form.labelWidth ?? 120) / 2 + 'px)'"
>
<el-form
v-if="!loading"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="(baTable.form.labelWidth ?? 120) + 'px'"
@submit.prevent=""
>
<el-form-item :label="t('order.depositOrder.order_no')">
<el-input :model-value="form.order_no" readonly />
</el-form-item>
<el-form-item :label="t('order.depositOrder.idempotency_key')">
<el-input :model-value="form.idempotency_key || '-'" readonly />
</el-form-item>
<el-form-item :label="t('order.depositOrder.user_username')">
<el-input :model-value="form.user_text" readonly />
</el-form-item>
<el-form-item :label="t('order.depositOrder.channel_name')">
<el-input :model-value="form.channel_text" readonly />
</el-form-item>
<el-form-item :label="t('order.depositOrder.status')">
<el-tag :type="statusTagType" effect="dark" size="small">{{ statusLabel }}</el-tag>
</el-form-item>
<el-form-item :label="t('order.depositOrder.amount')">
<el-input :model-value="amountText" readonly />
</el-form-item>
<el-form-item :label="t('order.depositOrder.bonus_amount')">
<el-input :model-value="bonusText" readonly />
</el-form-item>
<el-form-item :label="t('order.depositOrder.total_credit')">
<el-input :model-value="totalCreditText" readonly />
</el-form-item>
<el-form-item :label="t('order.depositOrder.pay_channel')">
<el-input :model-value="form.pay_channel || '-'" readonly />
</el-form-item>
<el-form-item :label="t('order.depositOrder.pay_time')">
<el-input :model-value="form.pay_time_text" readonly />
</el-form-item>
<el-form-item :label="t('order.depositOrder.deposit_tier_id')">
<el-input :model-value="form.deposit_tier_id || '-'" readonly />
</el-form-item>
<el-form-item :label="t('order.depositOrder.remark')">
<el-input v-model="form.remark" type="textarea" :rows="2" readonly />
</el-form-item>
<el-form-item :label="t('order.depositOrder.create_time')">
<el-input :model-value="form.create_time_text" readonly />
</el-form-item>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}</el-button>
<div class="detail-footer">
<el-button type="primary" v-blur @click="onDialogClose">{{ t('order.depositOrder.close_btn') }}</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef } from 'vue'
import { computed, inject, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
order_no: [{ required: true, message: t('Please input field', { field: t('order.depositOrder.order_no') }) }],
user_id: [{ required: true, message: t('Please input field', { field: t('order.depositOrder.user_id') }) }],
const loading = ref(false)
const form = reactive({
id: 0,
order_no: '',
idempotency_key: '',
user_text: '-',
channel_text: '-',
pay_channel: '',
pay_time_text: '-',
deposit_tier_id: '',
remark: '',
create_time_text: '-',
amount: 0,
bonus_amount: 0,
status: 0,
})
const isOpen = computed(() => ['Edit'].includes(baTable.form.operate ?? ''))
watch(
() => ({ visible: isOpen.value, loadingState: baTable.form.loading, items: baTable.form.items }),
({ visible, loadingState }) => {
if (!visible) return
loading.value = loadingState === true
if (loadingState) return
hydrate()
},
{ deep: true, immediate: true }
)
const hydrate = () => {
const row = baTable.form.items as Record<string, unknown> | undefined
if (!row || !row['id']) {
return
}
form.id = Number(row['id'] ?? 0)
form.order_no = String(row['order_no'] ?? '')
form.idempotency_key = String(row['idempotency_key'] ?? '')
form.pay_channel = String(row['pay_channel'] ?? '')
form.deposit_tier_id = String(row['deposit_tier_id'] ?? '')
form.remark = String(row['remark'] ?? '')
form.amount = parseNumber(row['amount'])
form.bonus_amount = parseNumber(row['bonus_amount'])
form.status = Number(row['status'] ?? 0)
form.create_time_text = formatTime(row['create_time'])
form.pay_time_text = formatTime(row['pay_time'])
form.user_text = resolveRelationText(row, 'user', row['user_id'])
form.channel_text = resolveRelationText(row, 'channel', row['channel_id'])
}
const statusLabel = computed(() => t('order.depositOrder.status ' + form.status))
const statusTagType = computed(() => {
switch (form.status) {
case 1:
return 'success'
case 2:
return 'danger'
case 3:
return 'warning'
default:
return 'info'
}
})
const amountText = computed(() => formatAmount(form.amount))
const bonusText = computed(() => formatAmount(form.bonus_amount))
const totalCreditText = computed(() => formatAmount(Number((form.amount + form.bonus_amount).toFixed(2))))
const onDialogClose = () => {
baTable.toggleForm()
}
function parseNumber(raw: unknown): number {
if (raw === null || raw === undefined || raw === '') return 0
const n = Number(raw)
if (!Number.isFinite(n)) return 0
return Number(n.toFixed(2))
}
function formatAmount(value: number): string {
if (!Number.isFinite(value)) return '0.00'
return value.toFixed(2)
}
function formatTime(raw: unknown): string {
if (raw === null || raw === undefined || raw === '' || raw === 0) return '-'
const sec = Number(raw)
if (!Number.isFinite(sec) || sec <= 0) return '-'
const d = new Date(sec * 1000)
const pad = (n: number) => (n < 10 ? '0' + n : String(n))
return (
d.getFullYear() +
'-' +
pad(d.getMonth() + 1) +
'-' +
pad(d.getDate()) +
' ' +
pad(d.getHours()) +
':' +
pad(d.getMinutes()) +
':' +
pad(d.getSeconds())
)
}
function resolveRelationText(row: Record<string, unknown>, relationKey: string, fallbackId: unknown): string {
const rel = row[relationKey]
if (rel && typeof rel === 'object') {
const r = rel as Record<string, unknown>
const name = r['username'] ?? r['name']
if (typeof name === 'string' && name !== '') {
const id = fallbackId === null || fallbackId === undefined || fallbackId === '' ? '' : ' (ID: ' + String(fallbackId) + ')'
return name + id
}
}
if (fallbackId === null || fallbackId === undefined || fallbackId === '') {
return '-'
}
return 'ID: ' + String(fallbackId)
}
</script>
<style scoped lang="scss"></style>
<style scoped lang="scss">
.deposit-detail-dialog {
:deep(.el-dialog__body) {
padding-top: 8px;
}
}
.detail-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
@media (max-width: 640px) {
:deep(.deposit-detail-dialog) {
width: calc(100vw - 24px) !important;
max-width: 100vw;
}
}
</style>

View File

@@ -3,7 +3,7 @@
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:buttons="['refresh', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('order.withdrawOrder.quick Search Fields') })"
></TableHeader>
@@ -29,7 +29,7 @@ defineOptions({
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
const optButtons: OptButton[] = defaultOptButtons(['edit'])
const baTable = new baTableClass(
new baTableApi('/admin/order.WithdrawOrder/'),
@@ -38,10 +38,33 @@ const baTable = new baTableClass(
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('order.withdrawOrder.id'), prop: 'id', align: 'center', width: 80, operator: 'RANGE', sortable: 'custom' },
{ label: t('order.withdrawOrder.order_no'), prop: 'order_no', align: 'center', minWidth: 170, operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{ label: t('order.withdrawOrder.user_id'), prop: 'user_id', align: 'center', width: 90, operator: 'RANGE' },
{ label: t('order.withdrawOrder.user_username'), prop: 'user.username', align: 'center', minWidth: 110, operator: 'LIKE', operatorPlaceholder: t('Fuzzy query'), render: 'tags' },
{ label: t('order.withdrawOrder.channel_name'), prop: 'channel.name', align: 'center', minWidth: 110, operator: 'LIKE', operatorPlaceholder: t('Fuzzy query'), render: 'tags' },
{
label: t('order.withdrawOrder.order_no'),
prop: 'order_no',
align: 'center',
minWidth: 170,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
// { label: t('order.withdrawOrder.user_id'), prop: 'user_id', align: 'center', width: 90, operator: 'RANGE' },
{
label: t('order.withdrawOrder.user_username'),
prop: 'user.username',
align: 'center',
minWidth: 120,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
},
{
label: t('order.withdrawOrder.channel_name'),
prop: 'channel.name',
align: 'center',
minWidth: 110,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
},
{ label: t('order.withdrawOrder.amount'), prop: 'amount', align: 'center', minWidth: 110, operator: 'RANGE' },
{ label: t('order.withdrawOrder.fee'), prop: 'fee', align: 'center', minWidth: 110, operator: 'RANGE' },
{ label: t('order.withdrawOrder.actual_amount'), prop: 'actual_amount', align: 'center', minWidth: 110, operator: 'RANGE' },
@@ -59,13 +82,64 @@ const baTable = new baTableClass(
'2': 'success',
'3': 'danger',
},
replaceValue: { '0': t('order.withdrawOrder.status 0'), '1': t('order.withdrawOrder.status 1'), '2': t('order.withdrawOrder.status 2'), '3': t('order.withdrawOrder.status 3') },
replaceValue: {
'0': t('order.withdrawOrder.status 0'),
'1': t('order.withdrawOrder.status 1'),
'2': t('order.withdrawOrder.status 2'),
'3': t('order.withdrawOrder.status 3'),
},
},
{
label: t('order.withdrawOrder.review_admin_username'),
prop: 'reviewAdmin.username',
align: 'center',
minWidth: 100,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
},
{
label: t('order.withdrawOrder.review_time'),
prop: 'review_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 170,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('order.withdrawOrder.remark'),
prop: 'remark',
align: 'center',
minWidth: 150,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{
label: t('order.withdrawOrder.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 170,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('order.withdrawOrder.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 170,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('order.withdrawOrder.review_admin_username'), prop: 'reviewAdmin.username', align: 'center', minWidth: 100, operator: 'LIKE', operatorPlaceholder: t('Fuzzy query'), render: 'tags' },
{ label: t('order.withdrawOrder.review_time'), prop: 'review_time', align: 'center', render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', width: 170, timeFormat: 'yyyy-mm-dd hh:MM:ss' },
{ label: t('order.withdrawOrder.remark'), prop: 'remark', align: 'center', minWidth: 150, operator: 'LIKE', operatorPlaceholder: t('Fuzzy query'), showOverflowTooltip: true },
{ label: t('order.withdrawOrder.create_time'), prop: 'create_time', align: 'center', render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', width: 170, timeFormat: 'yyyy-mm-dd hh:MM:ss' },
{ label: t('order.withdrawOrder.update_time'), prop: 'update_time', align: 'center', render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', width: 170, timeFormat: 'yyyy-mm-dd hh:MM:ss' },
{ label: t('Operate'), align: 'center', width: 90, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
],
},

View File

@@ -1,50 +1,434 @@
<template>
<el-dialog class="ba-operate-dialog" :close-on-click-modal="false" :model-value="['Add', 'Edit'].includes(baTable.form.operate!)" @close="baTable.toggleForm">
<el-dialog
class="ba-operate-dialog withdraw-review-dialog"
:close-on-click-modal="false"
:model-value="isOpen"
width="640px"
@close="onDialogClose"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">{{ baTable.form.operate ? t(baTable.form.operate) : '' }}</div>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ step === 'reject' ? t('order.withdrawOrder.review_reject_title') : t('order.withdrawOrder.review_title') }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div class="ba-operate-form" :class="'ba-' + baTable.form.operate + '-form'" :style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'">
<el-form v-if="!baTable.form.loading" ref="formRef" @submit.prevent="" @keyup.enter="baTable.onSubmit(formRef)" :model="baTable.form.items" :label-position="config.layout.shrink ? 'top' : 'right'" :label-width="baTable.form.labelWidth + 'px'" :rules="rules">
<FormItem :label="t('order.withdrawOrder.order_no')" type="string" v-model="baTable.form.items!.order_no" prop="order_no" />
<FormItem :label="t('order.withdrawOrder.user_id')" type="number" v-model="baTable.form.items!.user_id" prop="user_id" :input-attr="{ min: 1, step: 1 }" />
<FormItem :label="t('order.withdrawOrder.channel_id')" type="number" v-model="baTable.form.items!.channel_id" prop="channel_id" :input-attr="{ min: 1, step: 1 }" />
<FormItem :label="t('order.withdrawOrder.amount')" type="number" v-model="baTable.form.items!.amount" prop="amount" :input-attr="{ step: 0.0001, precision: 4, min: 0 }" />
<FormItem :label="t('order.withdrawOrder.fee')" type="number" v-model="baTable.form.items!.fee" prop="fee" :input-attr="{ step: 0.0001, precision: 4, min: 0 }" />
<FormItem :label="t('order.withdrawOrder.actual_amount')" type="number" v-model="baTable.form.items!.actual_amount" prop="actual_amount" :input-attr="{ step: 0.0001, precision: 4, min: 0 }" />
<FormItem :label="t('order.withdrawOrder.status')" type="radio" v-model="baTable.form.items!.status" prop="status" :input-attr="{ content: { '0': t('order.withdrawOrder.status 0'), '1': t('order.withdrawOrder.status 1'), '2': t('order.withdrawOrder.status 2'), '3': t('order.withdrawOrder.status 3') } }" />
<FormItem :label="t('order.withdrawOrder.review_admin_id')" type="number" v-model="baTable.form.items!.review_admin_id" prop="review_admin_id" :input-attr="{ min: 1, step: 1 }" />
<FormItem :label="t('order.withdrawOrder.review_time')" type="datetime" v-model="baTable.form.items!.review_time" prop="review_time" />
<FormItem :label="t('order.withdrawOrder.remark')" type="textarea" v-model="baTable.form.items!.remark" prop="remark" :input-attr="{ rows: 2 }" />
<el-scrollbar v-loading="loading" class="ba-table-form-scrollbar">
<div class="ba-operate-form ba-edit-form" :style="config.layout.shrink ? '' : 'width: calc(100% - ' + (baTable.form.labelWidth ?? 120) / 2 + 'px)'">
<!-- 第一页关联信息 + 申请金额 / 手续费 -->
<el-form
v-if="!loading && step === 'review'"
ref="reviewFormRef"
:model="form"
:rules="reviewRules"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="(baTable.form.labelWidth ?? 120) + 'px'"
@submit.prevent=""
>
<el-form-item :label="t('order.withdrawOrder.order_no')">
<el-input v-model="form.order_no" readonly />
</el-form-item>
<el-form-item :label="t('order.withdrawOrder.user_username')">
<el-input :model-value="form.user_text" readonly />
</el-form-item>
<el-form-item :label="t('order.withdrawOrder.channel_name')">
<el-input :model-value="form.channel_text" readonly />
</el-form-item>
<el-form-item :label="t('order.withdrawOrder.status')">
<el-tag :type="statusTagType" effect="dark" size="small">{{ statusLabel }}</el-tag>
</el-form-item>
<el-form-item :label="t('order.withdrawOrder.create_time')">
<el-input :model-value="form.create_time_text" readonly />
</el-form-item>
<el-form-item :label="t('order.withdrawOrder.amount')" prop="amount">
<el-input-number
v-model="form.amount"
:min="0"
:precision="2"
:step="1"
:controls="false"
:disabled="!isPending"
style="width: 100%"
/>
</el-form-item>
<el-form-item :label="t('order.withdrawOrder.fee')" prop="fee">
<el-input-number
v-model="form.fee"
:min="0"
:precision="2"
:step="0.1"
:controls="false"
:disabled="!isPending"
style="width: 100%"
/>
</el-form-item>
<el-form-item :label="t('order.withdrawOrder.actual_amount')">
<el-input :model-value="actualAmountText" readonly />
</el-form-item>
<el-form-item v-if="!isPending" :label="t('order.withdrawOrder.review_admin_username')">
<el-input :model-value="form.review_admin_text" readonly />
</el-form-item>
<el-form-item v-if="!isPending" :label="t('order.withdrawOrder.review_time')">
<el-input :model-value="form.review_time_text" readonly />
</el-form-item>
<el-form-item v-if="!isPending" :label="t('order.withdrawOrder.remark')">
<el-input v-model="form.remark" type="textarea" :rows="2" readonly />
</el-form-item>
</el-form>
<!-- 第二页拒绝 -> 必填备注 -->
<el-form
v-if="!loading && step === 'reject'"
ref="rejectFormRef"
:model="rejectForm"
:rules="rejectRules"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="(baTable.form.labelWidth ?? 120) + 'px'"
@submit.prevent=""
>
<el-alert
class="review-reject-hint"
type="warning"
show-icon
:closable="false"
:title="t('order.withdrawOrder.review_reject_tip')"
/>
<el-form-item :label="t('order.withdrawOrder.order_no')">
<el-input v-model="form.order_no" readonly />
</el-form-item>
<el-form-item :label="t('order.withdrawOrder.amount')">
<el-input :model-value="amountText" readonly />
</el-form-item>
<el-form-item :label="t('order.withdrawOrder.remark')" prop="remark">
<el-input
v-model="rejectForm.remark"
type="textarea"
:rows="4"
maxlength="255"
show-word-limit
:placeholder="t('order.withdrawOrder.review_reject_placeholder')"
/>
</el-form-item>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}</el-button>
<div class="review-footer">
<template v-if="step === 'review'">
<el-button @click="onDialogClose">{{ t('Cancel') }}</el-button>
<template v-if="isPending">
<el-button type="danger" :loading="submitting" @click="gotoReject">{{ t('order.withdrawOrder.review_btn_reject') }}</el-button>
<el-button type="primary" v-blur :loading="submitting" @click="submitApprove">{{ t('order.withdrawOrder.review_btn_approve') }}</el-button>
</template>
</template>
<template v-else>
<el-button @click="backToReview">{{ t('order.withdrawOrder.review_btn_back') }}</el-button>
<el-button type="danger" v-blur :loading="submitting" @click="submitReject">{{ t('order.withdrawOrder.review_btn_confirm_reject') }}</el-button>
</template>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef } from 'vue'
import type { FormInstance, FormItemRule } from 'element-plus'
import { ElMessage } from 'element-plus'
import { computed, inject, reactive, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import createAxios from '/@/utils/axios'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
order_no: [{ required: true, message: t('Please input field', { field: t('order.withdrawOrder.order_no') }) }],
user_id: [{ required: true, message: t('Please input field', { field: t('order.withdrawOrder.user_id') }) }],
const reviewFormRef = useTemplateRef<FormInstance>('reviewFormRef')
const rejectFormRef = useTemplateRef<FormInstance>('rejectFormRef')
type Step = 'review' | 'reject'
const step = ref<Step>('review')
const loading = ref(false)
const submitting = ref(false)
const form = reactive({
id: 0,
order_no: '',
user_text: '-',
channel_text: '-',
create_time_text: '',
review_admin_text: '-',
review_time_text: '-',
amount: 0,
fee: 0,
status: 0,
remark: '',
})
const rejectForm = reactive({
remark: '',
})
const isOpen = computed(() => ['Edit'].includes(baTable.form.operate ?? ''))
const isPending = computed(() => form.status === 0)
watch(
isOpen,
(visible) => {
if (!visible) {
return
}
step.value = 'review'
rejectForm.remark = ''
}
)
watch(
() => ({ visible: isOpen.value, loadingState: baTable.form.loading, items: baTable.form.items }),
({ visible, loadingState }) => {
if (!visible) return
loading.value = loadingState === true
if (loadingState) return
hydrate()
},
{ deep: true, immediate: true }
)
const hydrate = () => {
const row = baTable.form.items as Record<string, unknown> | undefined
if (!row || !row['id']) {
return
}
form.id = Number(row['id'] ?? 0)
form.order_no = String(row['order_no'] ?? '')
form.amount = parseNumber(row['amount'])
form.fee = parseNumber(row['fee'])
form.status = Number(row['status'] ?? 0)
form.remark = String(row['remark'] ?? '')
form.create_time_text = formatTime(row['create_time'])
form.review_time_text = formatTime(row['review_time'])
form.user_text = resolveRelationText(row, 'user', row['user_id'])
form.channel_text = resolveRelationText(row, 'channel', row['channel_id'])
form.review_admin_text = resolveRelationText(row, 'reviewAdmin', row['review_admin_id'])
}
const statusLabel = computed(() => t('order.withdrawOrder.status ' + form.status))
const statusTagType = computed(() => {
switch (form.status) {
case 1:
return 'success'
case 2:
return 'danger'
case 3:
return 'success'
default:
return 'warning'
}
})
const amountText = computed(() => formatAmount(form.amount))
const actualAmountText = computed(() => {
const actual = Number((form.amount - form.fee).toFixed(2))
return formatAmount(actual < 0 ? 0 : actual)
})
const reviewRules: Record<string, FormItemRule[]> = {
amount: [
{
required: true,
validator: (_r, value, cb) => {
if (value === null || value === undefined || Number(value) <= 0) {
cb(new Error(t('order.withdrawOrder.amount_invalid')))
return
}
cb()
},
},
],
fee: [
{
required: true,
validator: (_r, value, cb) => {
if (value === null || value === undefined || Number(value) < 0) {
cb(new Error(t('order.withdrawOrder.fee_invalid')))
return
}
if (Number(value) > Number(form.amount)) {
cb(new Error(t('order.withdrawOrder.fee_exceed_amount')))
return
}
cb()
},
},
],
}
const rejectRules: Record<string, FormItemRule[]> = {
remark: [
{
required: true,
validator: (_r, value, cb) => {
const text = typeof value === 'string' ? value.trim() : ''
if (text === '') {
cb(new Error(t('order.withdrawOrder.reject_reason_required')))
return
}
cb()
},
trigger: 'blur',
},
],
}
const onDialogClose = () => {
if (submitting.value) {
return
}
step.value = 'review'
baTable.toggleForm()
}
const gotoReject = async () => {
step.value = 'reject'
rejectForm.remark = ''
}
const backToReview = () => {
step.value = 'review'
}
const submitApprove = async () => {
if (!isPending.value) {
ElMessage.warning(t('order.withdrawOrder.already_reviewed'))
return
}
const formEl = reviewFormRef.value
if (!formEl) return
const valid = await formEl.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
await createAxios(
{
url: '/admin/order.WithdrawOrder/approve',
method: 'POST',
data: {
id: form.id,
amount: form.amount.toFixed(4),
fee: form.fee.toFixed(4),
},
},
{ showSuccessMessage: true }
)
baTable.onTableHeaderAction('refresh', {})
baTable.toggleForm()
} catch (_e) {
// errors already surfaced by axios interceptor
} finally {
submitting.value = false
}
}
const submitReject = async () => {
const formEl = rejectFormRef.value
if (!formEl) return
const valid = await formEl.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
await createAxios(
{
url: '/admin/order.WithdrawOrder/reject',
method: 'POST',
data: {
id: form.id,
remark: rejectForm.remark.trim(),
},
},
{ showSuccessMessage: true }
)
baTable.onTableHeaderAction('refresh', {})
baTable.toggleForm()
} catch (_e) {
// errors already surfaced by axios interceptor
} finally {
submitting.value = false
}
}
function parseNumber(raw: unknown): number {
if (raw === null || raw === undefined || raw === '') return 0
const n = Number(raw)
if (!Number.isFinite(n)) return 0
return Number(n.toFixed(2))
}
function formatAmount(value: number): string {
if (!Number.isFinite(value)) return '0.00'
return value.toFixed(2)
}
function formatTime(raw: unknown): string {
if (raw === null || raw === undefined || raw === '' || raw === 0) return '-'
const sec = Number(raw)
if (!Number.isFinite(sec) || sec <= 0) return '-'
const d = new Date(sec * 1000)
const pad = (n: number) => (n < 10 ? '0' + n : String(n))
return (
d.getFullYear() +
'-' +
pad(d.getMonth() + 1) +
'-' +
pad(d.getDate()) +
' ' +
pad(d.getHours()) +
':' +
pad(d.getMinutes()) +
':' +
pad(d.getSeconds())
)
}
function resolveRelationText(row: Record<string, unknown>, relationKey: string, fallbackId: unknown): string {
const rel = row[relationKey]
if (rel && typeof rel === 'object') {
const r = rel as Record<string, unknown>
const name = r['username'] ?? r['name']
if (typeof name === 'string' && name !== '') {
const id = fallbackId === null || fallbackId === undefined || fallbackId === '' ? '' : ' (ID: ' + String(fallbackId) + ')'
return name + id
}
}
if (fallbackId === null || fallbackId === undefined || fallbackId === '') {
return '-'
}
return 'ID: ' + String(fallbackId)
}
</script>
<style scoped lang="scss"></style>
<style scoped lang="scss">
.withdraw-review-dialog {
:deep(.el-dialog__body) {
padding-top: 8px;
}
.review-reject-hint {
margin-bottom: 14px;
}
}
.review-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
@media (max-width: 640px) {
:deep(.withdraw-review-dialog) {
width: calc(100vw - 24px) !important;
max-width: 100vw;
}
}
</style>

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
@@ -245,8 +245,17 @@ const baTable = new baTableClass(
formatter: formatCoin,
},
{
label: t('user.user.total_valid_bet_coin'),
prop: 'total_valid_bet_coin',
label: t('user.user.total_withdraw_coin'),
prop: 'total_withdraw_coin',
align: 'center',
minWidth: 110,
sortable: false,
operator: 'RANGE',
formatter: formatCoin,
},
{
label: t('user.user.bet_flow_coin'),
prop: 'bet_flow_coin',
align: 'center',
minWidth: 110,
sortable: false,
@@ -352,7 +361,8 @@ const baTable = new baTableClass(
status: '1',
coin: '0.00',
total_deposit_coin: '0.00',
total_valid_bet_coin: '0.00',
total_withdraw_coin: '0.00',
bet_flow_coin: '0.00',
risk_flags: 0,
current_streak: 0,
last_bet_period_no: '',