1.新增充值档位配置

2.新增充值/提现配置
This commit is contained in:
2026-04-21 18:31:43 +08:00
parent aad00e10f8
commit 0f28c0fd2a
29 changed files with 3647 additions and 278 deletions

View File

@@ -0,0 +1,16 @@
export default {
desc: 'Pay channels are registered in code and environment variables (display name, code). Here you only toggle availability, sort order, and which deposit tiers each channel accepts. Leave tiers empty to allow all enabled tiers. Changes apply immediately.',
btn_save: 'Save',
sort: 'Sort',
status: 'Enabled',
code: 'Channel code',
display_name: 'Display name (read-only)',
tier_ids: 'Allowed tier ids',
tier_ids_ph: 'Empty = all tiers',
'quick Search Fields': 'channel code / display name',
form_tip: 'Display names come from the code/env registry and cannot be edited here; adjust sort, status, and allowed tiers.',
status_on: 'Enabled',
status_off: 'Disabled',
tier_all: 'All tiers',
rule_sort: 'Sort value is required',
}

View File

@@ -1,29 +1,47 @@
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.',
desc: 'Configure mobile deposit tiers: titles (ZH/EN), pay currency & pay amount, credited platform coins (base + bonus), and stable tier id (tier_key) for orders and channel binding. Disabled tiers are hidden from players. Changes apply immediately.',
'quick Search Fields': 'tier id / title (ZH) / title (EN)',
form_tip: 'The table is read-only; use toolbar Add / Edit in the dialog. Changes apply immediately.',
tier_id_optional: 'Tier id (optional)',
tier_id_optional_ph: 'Leave empty to auto-generate',
status_on: 'Enabled',
status_off: 'Disabled',
total_platform: 'Total credit (base + bonus)',
rule_title: 'Title (ZH) is required',
rule_currency: 'Pay currency is required',
rule_pay_amount: 'Pay amount must be a number greater than 0',
rule_platform_base: 'Base platform coin must be a number greater than 0',
rule_bonus: 'Bonus must be a number no less than 0',
btn_add: 'Add Tier',
btn_save: 'Save',
btn_remove: 'Delete',
confirm_remove: 'Delete this deposit tier?',
tier_id: 'Tier ID',
tier_id: 'Tier key',
auto_id: '(generated on save)',
sort: 'Sort',
status: 'Enabled',
title_col: 'Title (ZH)',
title_ph: 'e.g. 新手首充、VIP 高额充值',
title_ph: 'e.g. First recharge, VIP top-up bundle',
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',
pay_currency: 'Pay currency',
pay_currency_ph: 'Select or type',
pay_amount: 'Pay amount (fiat)',
pay_amount_ph: 'e.g. 3.00',
platform_base: 'Base platform coin',
platform_base_ph: 'e.g. 210.00',
bonus_amount: 'Bonus platform coin',
bonus_ph: 'e.g. 20.00, use 0 if none',
platform_coin_suffix: 'coin',
desc_col: 'Description (ZH)',
desc_ph: 'Optional Chinese description, up to 255 chars',
desc_ph: 'Optional description for Chinese locale, up to 255 characters',
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_title: 'Row {no}: Title (ZH) is required',
err_currency: 'Row {no}: pay currency is required',
err_pay_amount: 'Row {no}: pay amount must be a number greater than 0',
err_platform_base: 'Row {no}: base platform coin must be a number greater than 0',
err_bonus: 'Row {no}: bonus must be a number no less than 0',
}

View File

@@ -0,0 +1,50 @@
export default {
desc: 'Mobile pay & receipt settings: platform coin labels, currencies and rates, deposit pay channels (on/off, sort, tier scope), withdraw banks, limits, copy, and required withdraw fields. Deposit tiers are configured separately.',
btn_save: 'Save',
btn_add_row: 'Add row',
sec_platform: 'Platform coin labels',
platform_label_zh: 'Label (Chinese)',
platform_label_en: 'Label (English)',
sec_currencies: 'Currencies (deposit/withdraw selectors)',
sec_deposit_channels: 'Deposit pay channels',
deposit_channels_hint: 'Display names come from the registry; here you only set enabled state, sort order, and applicable deposit tiers. Leave tiers empty to allow all tiers.',
currency_rates_hint: 'Deposit rate: platform coins credited per 1 fiat paid. Withdraw rate: platform coins needed per 1 fiat redeemed (e.g. 100 ⇒ 100 coins = 1 fiat unit).',
err_dup_code: 'Duplicate currency codes are not allowed.',
sec_banks: 'Withdraw bank codes',
sec_limits: 'Minimum withdraw (fiat amount; match your copy currency)',
min_ewallet: 'E-wallet minimum',
min_bank: 'Bank minimum',
sec_copy: 'Withdraw page copy',
rate_mode: 'Rate display',
rate_mode_fixed: 'Fixed rate',
rate_mode_live: 'Live rate (display)',
rate_hint_zh: 'Rate hint (ZH)',
rate_hint_en: 'Rate hint (EN)',
processing_zh: 'Processing note (ZH)',
processing_en: 'Processing note (EN)',
fee_note_zh: 'Fee note (ZH)',
fee_note_en: 'Fee note (EN)',
sec_fields: 'Withdraw form (required)',
field_cardholder: 'Cardholder name',
field_bank_account: 'Bank account',
field_email: 'Payee email',
field_mobile: 'Payee mobile',
col_code: 'Code',
col_label_zh: 'Name (ZH)',
col_label_en: 'Name (EN)',
col_sort: 'Sort',
col_deposit_rate: 'Deposit rate',
col_withdraw_rate: 'Withdraw rate',
col_bank_code: 'Bank code',
col_name_zh: 'Bank (ZH)',
col_name_en: 'Bank (EN)',
ph_ratio: 'e.g. 100',
ph_currency_code: 'e.g. MYR',
ph_bank_code: 'e.g. agrobank',
ch_code: 'Channel code',
ch_display_name: 'Display name',
ch_sort: 'Sort',
ch_status: 'Enabled',
ch_tier_ids: 'Allowed deposit tiers',
ch_tier_ids_ph: 'Empty = all tiers',
}

View File

@@ -0,0 +1,3 @@
export default {
'quick Search Fields': 'order no / pay channel / tier id / idempotency key',
}

View File

@@ -0,0 +1,16 @@
export default {
desc: '支付渠道在代码与环境变量中注册展示名、code此处仅配置开关、排序以及各渠道允许的充值档位唯一标识 tier id。留空「适用档位」表示不限制。修改后立即生效。',
btn_save: '保存',
sort: '排序',
status: '启用',
code: '渠道代码',
display_name: '展示名称(只读)',
tier_ids: '适用充值档位',
tier_ids_ph: '空=全部档位',
'quick Search Fields': '渠道代码 / 展示名称',
form_tip: '展示名由代码与环境注册表决定,此处不可改;可调整排序、开关与适用档位。',
status_on: '启用',
status_off: '停用',
tier_all: '全部档位',
rule_sort: '请填写排序值',
}

View File

@@ -1,11 +1,23 @@
export default {
title: '充值档位',
desc: '配置玩家创建充值订单时可选的充值档位。第三方支付模式下仅需维护档位规格:名称、充值金额、赠送金额、描述等;不再保存收款账户信息。充值名称/描述需分别维护中英文两套:移动端接口会根据请求头 `lang` 返回对应语言,英文缺省时回退到中文。修改后立即生效。',
desc: '配置移动端充值档位:充值名称(中英文)、支付货币与支付额度、到账平台币(基础+赠送、唯一档位标识id/tier_key用于下单与渠道绑定。关闭「启用」后玩家不可选该档。修改后立即生效。',
'quick Search Fields': '唯一标识 / 中文名 / 英文名',
form_tip: '列表为只读展示;请使用工具栏「添加 / 编辑」在弹窗中维护。保存后立即生效。',
tier_id_optional: '唯一标识(可选)',
tier_id_optional_ph: '留空则由系统自动生成',
status_on: '启用',
status_off: '停用',
total_platform: '到账合计(基础+赠送)',
rule_title: '请填写中文充值名称',
rule_currency: '请选择或填写支付货币',
rule_pay_amount: '支付货币额度须为大于 0 的数字',
rule_platform_base: '基础平台币须为大于 0 的数字',
rule_bonus: '赠送平台币须为不小于 0 的数字',
btn_add: '新增档位',
btn_save: '保存',
btn_remove: '删除',
confirm_remove: '确定删除该充值档位?',
tier_id: '档位 ID',
tier_id: '唯一标识',
auto_id: '(保存时生成)',
sort: '排序',
status: '启用',
@@ -13,17 +25,23 @@ export default {
title_ph: '例如新手首充、VIP 高额充值',
title_en_col: '充值名称(英文)',
title_en_ph: 'e.g. Starter Pack, VIP Recharge',
amount: '充值金额',
amount_ph: '例如100.00',
bonus_amount: '赠送金额',
pay_currency: '支付货币',
pay_currency_ph: '选择或输入',
pay_amount: '支付货币额度',
pay_amount_ph: '例如3.00',
platform_base: '到账平台币(基础)',
platform_base_ph: '例如210.00',
bonus_amount: '赠送平台币',
bonus_ph: '例如20.00,无赠送填 0',
platform_coin_suffix: '币',
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 的数字',
err_currency: '第 {no} 行:请选择支付货币',
err_pay_amount: '第 {no} 行:支付货币额度必须为于 0 的数字',
err_platform_base: '第 {no} 行:基础平台币到账必须为大于 0 的数字',
err_bonus: '第 {no} 行:赠送平台币必须为不小于 0 的数字',
}

View File

@@ -0,0 +1,51 @@
export default {
desc: '配置移动端支付与收款展示:平台币名称、货币与汇率、充值支付渠道(开关/排序/适用档位)、提现银行、最低限额、文案与提现表单字段。充值档位在「充值档位」中维护。',
btn_save: '保存',
btn_add_row: '新增一行',
sec_platform: '平台币展示名',
platform_label_zh: '名称(中文)',
platform_label_en: '名称(英文)',
sec_currencies: '货币列表(充值/提现货币下拉)',
sec_deposit_channels: '充值支付渠道',
deposit_channels_hint: '展示名由环境注册表决定,此处仅维护启用状态、排序与适用充值档位;不选档位表示全部档位可用。',
currency_rates_hint: '充值汇率:每支付 1 单位该货币到账的平台币;提现汇率:每兑换 1 单位该货币所需平台币(例 100 表示 100 平台币 = 1 单位)。',
err_dup_code: '货币代码不能重复,请检查后再保存。',
sec_banks: '提现支持银行代码',
sec_limits: '提现最低限额(法币金额,与文案中币种一致)',
min_ewallet: '电子钱包最低',
min_bank: '银行最低',
sec_copy: '提现页文案',
rate_mode: '汇率展示',
rate_mode_fixed: '固定汇率',
rate_mode_live: '实时汇率(展示用)',
rate_hint_zh: '汇率提示(中文)',
rate_hint_en: '汇率提示(英文)',
processing_zh: '到账说明(中文)',
processing_en: '到账说明(英文)',
fee_note_zh: '手续费说明(中文)',
fee_note_en: '手续费说明(英文)',
sec_fields: '提现表单字段(必填)',
field_cardholder: '持卡人姓名',
field_bank_account: '银行账号',
field_email: '收款邮箱',
field_mobile: '收款手机',
col_code: '代码',
col_label_zh: '中文名',
col_label_en: '英文名',
col_sort: '排序',
col_deposit_rate: '充值汇率',
col_withdraw_rate: '提现汇率',
col_bank_code: '银行代码',
col_name_zh: '银行名(中文)',
col_name_en: '银行名(英文)',
ph_ratio: '如 100',
ph_currency_code: '如 MYR',
ph_bank_code: '如 agrobank',
/** 本页「充值支付渠道」表格(不依赖 depositChannel 路由语言包) */
ch_code: '渠道代码',
ch_display_name: '展示名称',
ch_sort: '排序',
ch_status: '启用',
ch_tier_ids: '适用充值档位',
ch_tier_ids_ph: '不选表示全部档位',
}

View File

@@ -0,0 +1,3 @@
export default {
'quick Search Fields': '订单号/支付通道/档位ID/幂等键',
}

View File

@@ -0,0 +1,155 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" type="info" :closable="false" show-icon>
{{ t('config.depositChannel.desc') }}
</el-alert>
<TableHeader
:buttons="['refresh', 'edit', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('config.depositChannel.quick Search Fields') })"
></TableHeader>
<Table ref="tableRef"></Table>
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'config/depositChannel',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit'])
const baTable = new baTableClass(
new baTableApi('/admin/config.DepositChannel/'),
{
pk: 'code',
filter: {
page: 1,
limit: 20,
},
defaultOrder: { prop: 'sort', order: 'asc' },
extend: {
registry: {} as Record<string, { name: string; name_en: string; sort: number }>,
tier_options: [] as { id: string; label: string }[],
},
column: [
{ type: 'selection', align: 'center', operator: false },
{
label: t('config.depositChannel.sort'),
prop: 'sort',
align: 'center',
width: 88,
operator: 'RANGE',
sortable: 'custom',
},
{
label: t('config.depositChannel.status'),
prop: 'status',
align: 'center',
width: 100,
operator: 'eq',
sortable: false,
render: 'switch',
replaceValue: { 0: t('config.depositChannel.status_off'), 1: t('config.depositChannel.status_on') },
},
{
label: t('config.depositChannel.code'),
prop: 'code',
align: 'center',
minWidth: 120,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('config.depositChannel.display_name'),
prop: 'display_name',
align: 'center',
minWidth: 130,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{
label: t('config.depositChannel.tier_ids'),
prop: 'tier_ids',
align: 'center',
minWidth: 240,
operator: false,
render: 'tags',
formatter: (row: anyObj) => {
const ids = row.tier_ids
if (!Array.isArray(ids) || ids.length === 0) {
return [t('config.depositChannel.tier_all')]
}
const opts = (baTable.table.extend?.tier_options ?? []) as { id: string; label: string }[]
return ids.map((id: string) => {
const o = opts.find((x) => x.id === id)
return o ? o.label : id
})
},
},
{
label: t('Operate'),
align: 'center',
width: 90,
render: 'buttons',
buttons: optButtons,
operator: false,
fixed: 'right',
},
],
dblClickNotEditColumn: [undefined, 'status'],
},
{
defaultItems: {
code: '',
sort: 10,
status: 1,
tier_ids: [] as string[],
},
},
{},
{
getData({ res }) {
const d = res.data as
| {
registry?: Record<string, { name: string; name_en: string; sort: number }>
tier_options?: { id: string; label: string }[]
}
| undefined
if (d?.registry) {
baTable.table.extend.registry = d.registry
}
if (d?.tier_options) {
baTable.table.extend.tier_options = d.tier_options
}
},
}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,109 @@
<template>
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="baTable.form.operate === 'Edit'"
@close="baTable.toggleForm"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ t('Edit') }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form ba-Edit-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="formRules"
>
<el-alert class="ba-table-alert" type="info" :closable="false" show-icon>
{{ t('config.depositChannel.form_tip') }}
</el-alert>
<el-form-item :label="t('config.depositChannel.code')" prop="code">
<el-input :model-value="String(baTable.form.items!.code ?? '')" readonly />
</el-form-item>
<el-form-item :label="t('config.depositChannel.display_name')">
<el-input :model-value="registryDisplayName" readonly />
</el-form-item>
<el-form-item :label="t('config.depositChannel.sort')" prop="sort">
<el-input-number v-model="baTable.form.items!.sort" :min="0" :max="9999" :controls="true" class="w100" />
</el-form-item>
<el-form-item :label="t('config.depositChannel.status')" prop="status">
<el-switch v-model="baTable.form.items!.status" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item :label="t('config.depositChannel.tier_ids')" prop="tier_ids">
<el-select
v-model="baTable.form.items!.tier_ids"
multiple
collapse-tags
collapse-tags-tooltip
filterable
clearable
class="w100"
:placeholder="t('config.depositChannel.tier_ids_ph')"
>
<el-option v-for="opt in tierOptions" :key="opt.id" :label="opt.label" :value="opt.id" />
</el-select>
</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" type="primary" @click="baTable.onSubmit(formRef)">
{{ t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormInstance } from 'element-plus'
import { computed, inject, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useConfig } from '/@/stores/config'
type TierOpt = { id: string; label: string }
const config = useConfig()
const formRef = useTemplateRef<FormInstance>('formRef')
const baTable = inject('baTable') as baTable
const { t } = useI18n()
const tierOptions = computed(() => {
const raw = baTable.table.extend?.tier_options
return Array.isArray(raw) ? (raw as TierOpt[]) : []
})
const registryDisplayName = computed(() => {
const code = String(baTable.form.items?.code ?? '')
const reg = baTable.table.extend?.registry as Record<string, { name?: string }> | undefined
if (!code || !reg || !reg[code]) {
return code
}
const n = reg[code].name
return typeof n === 'string' && n !== '' ? n : code
})
const formRules = {}
</script>
<style scoped lang="scss">
.w100 {
width: 100%;
}
</style>

View File

@@ -1,278 +1,209 @@
<template>
<div class="default-main ba-table-box deposit-tier-page">
<el-alert type="info" :closable="false" show-icon>
<div class="default-main ba-table-box">
<el-alert class="ba-table-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>
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('config.depositTier.quick Search Fields') })"
></TableHeader>
<el-table
v-loading="loading"
border
stripe
:data="items"
row-key="_rowKey"
max-height="720"
class="deposit-tier-table"
header-align="center"
>
<el-table-column prop="sort" :label="t('config.depositTier.sort')" width="100" align="center" header-align="center">
<template #default="{ row }">
<div class="cell-center">
<el-input-number v-model="row.sort" :min="0" :max="9999" :controls="false" style="width: 100%; max-width: 160px" />
</div>
</template>
</el-table-column>
<Table ref="tableRef"></Table>
<el-table-column :label="t('config.depositTier.status')" width="100" align="center" header-align="center">
<template #default="{ row }">
<div class="cell-center">
<el-switch v-model="row.status" :active-value="1" :inactive-value="0" />
</div>
</template>
</el-table-column>
<el-table-column :label="t('config.depositTier.title_col')" min-width="180" align="center" header-align="center">
<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" align="center" header-align="center">
<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" align="center" header-align="center">
<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" align="center" header-align="center">
<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" align="center" header-align="center">
<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" align="center" header-align="center">
<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" align="center" header-align="center">
<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" header-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>
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import createAxios from '/@/utils/axios'
import { auth } from '/@/utils/common'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'config/depositTier',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
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(),
function formatAmount4(_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(4)
}
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 formatPayCell(row: anyObj, _column: any, cellValue: unknown) {
const amt = formatAmount4(row, _column, cellValue)
const cur = row.currency && String(row.currency).trim() !== '' ? String(row.currency).toUpperCase() : ''
if (amt === '-' || cur === '') {
return amt
}
return `${amt} ${cur}`
}
function onAdd() {
items.value.push(emptyTier())
function formatTotalPlatform(row: anyObj) {
const a = parseFloat(String(row.amount ?? '0').replace(',', '.'))
const b = parseFloat(String(row.bonus_amount ?? '0').replace(',', '.'))
const base = Number.isFinite(a) ? a : 0
const bonus = Number.isFinite(b) ? b : 0
return (base + bonus).toFixed(4)
}
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,
})),
const baTable = new baTableClass(
new baTableApi('/admin/config.DepositTier/'),
{
pk: 'id',
filter: {
page: 1,
limit: 20,
},
defaultOrder: { prop: 'sort', order: 'asc' },
column: [
{ type: 'selection', align: 'center', operator: false },
{
label: t('config.depositTier.sort'),
prop: 'sort',
align: 'center',
width: 88,
operator: 'RANGE',
sortable: 'custom',
},
showSuccessMessage: true,
})
await load()
} finally {
saving.value = false
{
label: t('config.depositTier.status'),
prop: 'status',
align: 'center',
width: 100,
operator: 'eq',
sortable: false,
render: 'switch',
replaceValue: { 0: t('config.depositTier.status_off'), 1: t('config.depositTier.status_on') },
},
{
label: t('config.depositTier.tier_id'),
prop: 'id',
align: 'center',
minWidth: 120,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{
label: t('config.depositTier.title_col'),
prop: 'title',
align: 'center',
minWidth: 140,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{
label: t('config.depositTier.title_en_col'),
prop: 'title_en',
align: 'center',
minWidth: 120,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{
label: t('config.depositTier.pay_currency'),
prop: 'currency',
align: 'center',
width: 100,
operator: 'eq',
},
{
label: t('config.depositTier.pay_amount'),
prop: 'pay_amount',
align: 'center',
minWidth: 130,
operator: 'RANGE',
formatter: formatPayCell,
},
{
label: t('config.depositTier.platform_base'),
prop: 'amount',
align: 'center',
minWidth: 120,
operator: 'RANGE',
formatter: formatAmount4,
},
{
label: t('config.depositTier.bonus_amount'),
prop: 'bonus_amount',
align: 'center',
minWidth: 110,
operator: 'RANGE',
formatter: formatAmount4,
},
{
label: t('config.depositTier.total_platform'),
prop: '__total_platform',
align: 'center',
minWidth: 120,
operator: false,
formatter: (row: anyObj) => formatTotalPlatform(row),
},
{
label: t('config.depositTier.desc_col'),
prop: 'desc',
align: 'center',
minWidth: 160,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{
label: t('Operate'),
align: 'center',
width: 100,
render: 'buttons',
buttons: optButtons,
operator: false,
fixed: 'right',
},
],
dblClickNotEditColumn: [undefined, 'status'],
},
{
defaultItems: {
id: '',
title: '',
title_en: '',
currency: 'MYR',
pay_amount: '',
amount: '',
bonus_amount: '0',
desc: '',
desc_en: '',
sort: 10,
status: 1,
},
}
}
)
provide('baTable', baTable)
onMounted(() => {
void load()
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
})
})
</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;
}
}
.deposit-tier-table {
:deep(.el-table__header th),
:deep(.el-table__body td) {
text-align: center;
}
}
.cell-center {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
}
</style>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,152 @@
<template>
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
>
<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>
</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"
>
<el-alert class="ba-table-alert" type="info" :closable="false" show-icon>
{{ t('config.depositTier.form_tip') }}
</el-alert>
<el-form-item v-if="baTable.form.operate === 'Edit'" :label="t('config.depositTier.tier_id')" prop="id">
<el-input :model-value="String(baTable.form.items!.id ?? '')" readonly />
</el-form-item>
<el-form-item v-if="baTable.form.operate === 'Add'" :label="t('config.depositTier.tier_id_optional')" prop="id">
<el-input v-model="baTable.form.items!.id" clearable :placeholder="t('config.depositTier.tier_id_optional_ph')" />
</el-form-item>
<el-form-item :label="t('config.depositTier.sort')" prop="sort">
<el-input-number v-model="baTable.form.items!.sort" :min="0" :max="9999" :controls="true" class="w100" />
</el-form-item>
<el-form-item :label="t('config.depositTier.status')" prop="status">
<el-switch v-model="baTable.form.items!.status" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item :label="t('config.depositTier.title_col')" prop="title">
<el-input v-model="baTable.form.items!.title" maxlength="64" :placeholder="t('config.depositTier.title_ph')" />
</el-form-item>
<el-form-item :label="t('config.depositTier.title_en_col')" prop="title_en">
<el-input v-model="baTable.form.items!.title_en" maxlength="64" :placeholder="t('config.depositTier.title_en_ph')" />
</el-form-item>
<el-form-item :label="t('config.depositTier.pay_currency')" prop="currency">
<el-select v-model="baTable.form.items!.currency" filterable allow-create default-first-option class="w100" :placeholder="t('config.depositTier.pay_currency_ph')">
<el-option v-for="c in payCurrencies" :key="c" :label="c" :value="c" />
</el-select>
</el-form-item>
<el-form-item :label="t('config.depositTier.pay_amount')" prop="pay_amount">
<el-input v-model="baTable.form.items!.pay_amount" :placeholder="t('config.depositTier.pay_amount_ph')" />
</el-form-item>
<el-form-item :label="t('config.depositTier.platform_base')" prop="amount">
<el-input v-model="baTable.form.items!.amount" :placeholder="t('config.depositTier.platform_base_ph')" />
</el-form-item>
<el-form-item :label="t('config.depositTier.bonus_amount')" prop="bonus_amount">
<el-input v-model="baTable.form.items!.bonus_amount" :placeholder="t('config.depositTier.bonus_ph')" />
</el-form-item>
<el-form-item :label="t('config.depositTier.desc_col')" prop="desc">
<el-input v-model="baTable.form.items!.desc" type="textarea" :rows="2" maxlength="255" :placeholder="t('config.depositTier.desc_ph')" />
</el-form-item>
<el-form-item :label="t('config.depositTier.desc_en_col')" prop="desc_en">
<el-input v-model="baTable.form.items!.desc_en" type="textarea" :rows="2" maxlength="255" :placeholder="t('config.depositTier.desc_en_ph')" />
</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">
{{ t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormInstance, FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const formRef = useTemplateRef<FormInstance>('formRef')
const baTable = inject('baTable') as baTable
const { t } = useI18n()
const payCurrencies = ['MYR', 'CNY', 'USD', 'USDT', 'VND', 'THB', 'SGD', 'IDR']
function positiveAmountRule(msg: string): FormItemRule {
return {
validator: (_rule, val, callback) => {
const s = val === null || val === undefined ? '' : String(val).trim().replace(',', '.')
if (s === '' || !/^-?\d+(\.\d+)?$/.test(s)) {
callback(new Error(msg))
return
}
const n = parseFloat(s)
if (!Number.isFinite(n) || n <= 0) {
callback(new Error(msg))
return
}
callback()
},
trigger: 'blur',
}
}
function nonNegAmountRule(msg: string): FormItemRule {
return {
validator: (_rule, val, callback) => {
const s = val === null || val === undefined || val === '' ? '0' : String(val).trim().replace(',', '.')
if (!/^-?\d+(\.\d+)?$/.test(s)) {
callback(new Error(msg))
return
}
const n = parseFloat(s)
if (!Number.isFinite(n) || n < 0) {
callback(new Error(msg))
return
}
callback()
},
trigger: 'blur',
}
}
const rules = reactive({
title: [{ required: true, message: t('config.depositTier.rule_title'), trigger: 'blur' }],
currency: [{ required: true, message: t('config.depositTier.rule_currency'), trigger: 'change' }],
pay_amount: [positiveAmountRule(t('config.depositTier.rule_pay_amount'))],
amount: [positiveAmountRule(t('config.depositTier.rule_platform_base'))],
bonus_amount: [nonNegAmountRule(t('config.depositTier.rule_bonus'))],
})
</script>
<style scoped lang="scss">
.w100 {
width: 100%;
}
</style>

View File

@@ -0,0 +1,518 @@
<template>
<div class="default-main finance-cashier-page">
<el-alert type="info" :closable="false" show-icon class="mb-3">
{{ t('config.financeCashierConfig.desc') }}
</el-alert>
<div class="toolbar">
<el-button type="primary" :loading="saving" :disabled="loading" @click="onSave">
{{ t('config.financeCashierConfig.btn_save') }}
</el-button>
</div>
<el-scrollbar max-height="calc(100vh - 220px)">
<el-form v-loading="loading" label-width="160px" class="finance-cashier-form">
<el-card shadow="never" class="section-card">
<template #header>{{ t('config.financeCashierConfig.sec_platform') }}</template>
<el-form-item :label="t('config.financeCashierConfig.platform_label_zh')">
<el-input v-model="form.platform_coin.label_zh" maxlength="32" class="w400" />
</el-form-item>
<el-form-item :label="t('config.financeCashierConfig.platform_label_en')">
<el-input v-model="form.platform_coin.label_en" maxlength="32" class="w400" />
</el-form-item>
</el-card>
<el-card shadow="never" class="section-card">
<template #header>
<span>{{ t('config.financeCashierConfig.sec_currencies') }}</span>
<el-button type="primary" link class="ml-2" @click="addCurrency">{{ t('config.financeCashierConfig.btn_add_row') }}</el-button>
</template>
<p class="hint">{{ t('config.financeCashierConfig.currency_rates_hint') }}</p>
<el-table :data="form.currencies" border stripe size="small">
<el-table-column :label="t('config.financeCashierConfig.col_code')" prop="code" width="110">
<template #default="{ row }">
<el-input
v-model="row.code"
maxlength="12"
:placeholder="t('config.financeCashierConfig.ph_currency_code')"
@blur="normalizeCurrencyCode(row)"
/>
</template>
</el-table-column>
<el-table-column :label="t('config.financeCashierConfig.col_label_zh')" min-width="130">
<template #default="{ row }">
<el-input v-model="row.label_zh" maxlength="64" />
</template>
</el-table-column>
<el-table-column :label="t('config.financeCashierConfig.col_label_en')" min-width="130">
<template #default="{ row }">
<el-input v-model="row.label_en" maxlength="64" />
</template>
</el-table-column>
<el-table-column :label="t('config.financeCashierConfig.col_deposit_rate')" min-width="120">
<template #default="{ row }">
<el-input v-model="row.deposit_coins_per_fiat" :placeholder="t('config.financeCashierConfig.ph_ratio')" />
</template>
</el-table-column>
<el-table-column :label="t('config.financeCashierConfig.col_withdraw_rate')" min-width="120">
<template #default="{ row }">
<el-input v-model="row.withdraw_coins_per_fiat" :placeholder="t('config.financeCashierConfig.ph_ratio')" />
</template>
</el-table-column>
<el-table-column :label="t('config.financeCashierConfig.col_sort')" width="108">
<template #default="{ row }">
<el-input-number v-model="row.sort" :min="0" :max="99999" :controls="true" class="w100p" @change="resortCurrenciesInPlace" />
</template>
</el-table-column>
<el-table-column :label="t('Operate')" width="90" align="center">
<template #default="{ $index }">
<el-button type="danger" link @click="removeRow(form.currencies, $index)">{{ t('Delete') }}</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-card shadow="never" class="section-card">
<template #header>{{ t('config.financeCashierConfig.sec_deposit_channels') }}</template>
<p class="hint">{{ t('config.financeCashierConfig.deposit_channels_hint') }}</p>
<el-table :data="form.channels" border stripe size="small">
<el-table-column :label="t('config.financeCashierConfig.ch_code')" prop="code" width="140">
<template #default="{ row }">
<el-input :model-value="String(row.code ?? '')" readonly />
</template>
</el-table-column>
<el-table-column :label="t('config.financeCashierConfig.ch_display_name')" min-width="140">
<template #default="{ row }">
<el-input :model-value="channelDisplayName(String(row.code ?? ''))" readonly />
</template>
</el-table-column>
<el-table-column :label="t('config.financeCashierConfig.ch_sort')" width="108">
<template #default="{ row }">
<el-input-number
v-model="row.sort"
:min="0"
:max="9999"
:controls="true"
class="w100p"
@change="resortChannelsInPlace"
/>
</template>
</el-table-column>
<el-table-column :label="t('config.financeCashierConfig.ch_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.financeCashierConfig.ch_tier_ids')" min-width="220">
<template #default="{ row }">
<el-select
v-model="row.tier_ids"
multiple
collapse-tags
collapse-tags-tooltip
filterable
clearable
class="w100p"
:placeholder="t('config.financeCashierConfig.ch_tier_ids_ph')"
>
<el-option v-for="opt in tierOptions" :key="opt.id" :label="opt.label" :value="opt.id" />
</el-select>
</template>
</el-table-column>
</el-table>
</el-card>
<el-card shadow="never" class="section-card">
<template #header>
<span>{{ t('config.financeCashierConfig.sec_banks') }}</span>
<el-button type="primary" link class="ml-2" @click="addBank">{{ t('config.financeCashierConfig.btn_add_row') }}</el-button>
</template>
<el-table :data="form.withdraw_banks" border stripe size="small">
<el-table-column :label="t('config.financeCashierConfig.col_bank_code')" width="140">
<template #default="{ row }">
<el-input v-model="row.code" maxlength="32" :placeholder="t('config.financeCashierConfig.ph_bank_code')" />
</template>
</el-table-column>
<el-table-column :label="t('config.financeCashierConfig.col_name_zh')" min-width="140">
<template #default="{ row }">
<el-input v-model="row.name_zh" maxlength="64" />
</template>
</el-table-column>
<el-table-column :label="t('config.financeCashierConfig.col_name_en')" min-width="140">
<template #default="{ row }">
<el-input v-model="row.name_en" maxlength="64" />
</template>
</el-table-column>
<el-table-column :label="t('config.financeCashierConfig.col_sort')" width="108">
<template #default="{ row }">
<el-input-number v-model="row.sort" :min="0" :max="99999" :controls="true" class="w100p" @change="resortBanksInPlace" />
</template>
</el-table-column>
<el-table-column :label="t('Operate')" width="90" align="center">
<template #default="{ $index }">
<el-button type="danger" link @click="removeRow(form.withdraw_banks, $index)">{{ t('Delete') }}</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-card shadow="never" class="section-card">
<template #header>{{ t('config.financeCashierConfig.sec_limits') }}</template>
<el-form-item :label="t('config.financeCashierConfig.min_ewallet')">
<el-input v-model="form.withdraw_limits.min_ewallet" class="w240" />
</el-form-item>
<el-form-item :label="t('config.financeCashierConfig.min_bank')">
<el-input v-model="form.withdraw_limits.min_bank" class="w240" />
</el-form-item>
</el-card>
<el-card shadow="never" class="section-card">
<template #header>{{ t('config.financeCashierConfig.sec_copy') }}</template>
<el-form-item :label="t('config.financeCashierConfig.rate_mode')">
<el-select v-model="form.withdraw_copy.rate_mode" class="w240">
<el-option :label="t('config.financeCashierConfig.rate_mode_fixed')" value="fixed" />
<el-option :label="t('config.financeCashierConfig.rate_mode_live')" value="live" />
</el-select>
</el-form-item>
<el-form-item :label="t('config.financeCashierConfig.rate_hint_zh')">
<el-input v-model="form.withdraw_copy.rate_hint_zh" type="textarea" :rows="2" maxlength="500" />
</el-form-item>
<el-form-item :label="t('config.financeCashierConfig.rate_hint_en')">
<el-input v-model="form.withdraw_copy.rate_hint_en" type="textarea" :rows="2" maxlength="500" />
</el-form-item>
<el-form-item :label="t('config.financeCashierConfig.processing_zh')">
<el-input v-model="form.withdraw_copy.processing_zh" maxlength="255" />
</el-form-item>
<el-form-item :label="t('config.financeCashierConfig.processing_en')">
<el-input v-model="form.withdraw_copy.processing_en" maxlength="255" />
</el-form-item>
<el-form-item :label="t('config.financeCashierConfig.fee_note_zh')">
<el-input v-model="form.withdraw_copy.fee_note_zh" type="textarea" :rows="2" maxlength="500" />
</el-form-item>
<el-form-item :label="t('config.financeCashierConfig.fee_note_en')">
<el-input v-model="form.withdraw_copy.fee_note_en" type="textarea" :rows="2" maxlength="500" />
</el-form-item>
</el-card>
<el-card shadow="never" class="section-card">
<template #header>{{ t('config.financeCashierConfig.sec_fields') }}</template>
<el-form-item :label="t('config.financeCashierConfig.field_cardholder')">
<el-switch v-model="form.withdraw_fields.require_cardholder" />
</el-form-item>
<el-form-item :label="t('config.financeCashierConfig.field_bank_account')">
<el-switch v-model="form.withdraw_fields.require_bank_account" />
</el-form-item>
<el-form-item :label="t('config.financeCashierConfig.field_email')">
<el-switch v-model="form.withdraw_fields.require_email" />
</el-form-item>
<el-form-item :label="t('config.financeCashierConfig.field_mobile')">
<el-switch v-model="form.withdraw_fields.require_mobile" />
</el-form-item>
</el-card>
</el-form>
</el-scrollbar>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import createAxios from '/@/utils/axios'
import { auth } from '/@/utils/common'
defineOptions({
name: 'config/financeCashierConfig',
})
const { t, locale } = useI18n()
type CurrencyRow = {
code: string
label_zh: string
label_en: string
sort: number
deposit_coins_per_fiat: string
withdraw_coins_per_fiat: string
}
type BankRow = { code: string; name_zh: string; name_en: string; sort: number }
type ChannelRow = { code: string; sort: number; status: number; tier_ids: string[] }
type TierOpt = { id: string; label: string }
type RegistryMeta = { name?: string; name_en?: string; sort?: number }
const loading = ref(false)
const saving = ref(false)
const registry = ref<Record<string, RegistryMeta>>({})
const tierOptions = ref<TierOpt[]>([])
const form = reactive({
platform_coin: { label_zh: '', label_en: '' },
currencies: [] as CurrencyRow[],
withdraw_banks: [] as BankRow[],
withdraw_limits: { min_ewallet: '0', min_bank: '0' },
withdraw_copy: {
rate_hint_zh: '',
rate_hint_en: '',
processing_zh: '',
processing_en: '',
fee_note_zh: '',
fee_note_en: '',
rate_mode: 'fixed',
},
withdraw_fields: {
require_cardholder: true,
require_bank_account: true,
require_email: true,
require_mobile: true,
},
channels: [] as ChannelRow[],
})
function rowSortValue(row: { sort?: unknown }): number {
const s = row.sort
if (typeof s === 'number' && Number.isFinite(s)) {
return Math.round(s)
}
if (typeof s === 'string' && s.trim() !== '') {
const n = Number(s)
if (!Number.isNaN(n) && Number.isFinite(n)) {
return Math.round(n)
}
}
return 0
}
function nextSort(rows: { sort?: unknown }[]): number {
let m = 0
for (const r of rows) {
const v = rowSortValue(r)
if (v > m) {
m = v
}
}
return m > 0 ? m + 10 : 10
}
function addCurrency() {
form.currencies.push({
code: '',
label_zh: '',
label_en: '',
sort: nextSort(form.currencies),
deposit_coins_per_fiat: '100',
withdraw_coins_per_fiat: '100',
})
}
function normalizeCurrencyCode(row: CurrencyRow) {
row.code = String(row.code || '').trim().toUpperCase()
}
function resortCurrenciesInPlace() {
form.currencies.sort((a, b) => {
const ds = rowSortValue(a) - rowSortValue(b)
if (ds !== 0) {
return ds
}
return String(a.code || '').localeCompare(String(b.code || ''))
})
}
function resortBanksInPlace() {
form.withdraw_banks.sort((a, b) => {
const ds = rowSortValue(a) - rowSortValue(b)
if (ds !== 0) {
return ds
}
return String(a.code || '').localeCompare(String(b.code || ''))
})
}
function resortChannelsInPlace() {
form.channels.sort((a, b) => {
const ds = rowSortValue(a) - rowSortValue(b)
if (ds !== 0) {
return ds
}
return String(a.code || '').localeCompare(String(b.code || ''))
})
}
function channelDisplayName(code: string): string {
const r = registry.value[code]
if (!r) {
return code
}
const loc = String(locale.value ?? '').toLowerCase().replaceAll('_', '-')
const preferEn = loc === 'en' || loc.startsWith('en-')
if (preferEn) {
const ne = r.name_en
if (typeof ne === 'string' && ne !== '') {
return ne
}
}
const n = r.name
if (typeof n === 'string' && n !== '') {
return n
}
const ne = r.name_en
if (typeof ne === 'string' && ne !== '') {
return ne
}
return code
}
function normalizeChannelRow(c: Record<string, unknown>): ChannelRow {
const tierIds: string[] = []
if (Array.isArray(c.tier_ids)) {
for (const x of c.tier_ids) {
if (typeof x === 'string') {
const t = x.trim()
if (t !== '') {
tierIds.push(t)
}
}
}
}
const st = c.status
const statusOn = st === 1 || st === true || st === '1'
return {
code: typeof c.code === 'string' ? c.code : '',
sort: rowSortValue({ sort: c.sort }),
status: statusOn ? 1 : 0,
tier_ids: tierIds,
}
}
function addBank() {
form.withdraw_banks.push({ code: '', name_zh: '', name_en: '', sort: nextSort(form.withdraw_banks) })
}
function removeRow<T>(arr: T[], index: number) {
arr.splice(index, 1)
}
async function load() {
loading.value = true
try {
const res = await createAxios({
url: '/admin/config.FinanceCashierConfig/index',
method: 'get',
})
if (res.code === 1 && res.data && res.data.form) {
const f = res.data.form as typeof form
const regRaw = res.data.registry
registry.value =
regRaw !== null && typeof regRaw === 'object' && !Array.isArray(regRaw)
? (regRaw as Record<string, RegistryMeta>)
: {}
const topts = res.data.tier_options
tierOptions.value = Array.isArray(topts) ? (topts as TierOpt[]) : []
Object.assign(form.platform_coin, f.platform_coin || {})
const curList = Array.isArray(f.currencies) ? f.currencies : []
const normalized: CurrencyRow[] = curList.map((c: Record<string, unknown>) => ({
code: typeof c.code === 'string' ? c.code : '',
label_zh: typeof c.label_zh === 'string' ? c.label_zh : '',
label_en: typeof c.label_en === 'string' ? c.label_en : '',
sort: rowSortValue({ sort: c.sort }),
deposit_coins_per_fiat:
typeof c.deposit_coins_per_fiat === 'string'
? c.deposit_coins_per_fiat
: typeof c.deposit_coins_per_fiat === 'number'
? String(c.deposit_coins_per_fiat)
: '100',
withdraw_coins_per_fiat:
typeof c.withdraw_coins_per_fiat === 'string'
? c.withdraw_coins_per_fiat
: typeof c.withdraw_coins_per_fiat === 'number'
? String(c.withdraw_coins_per_fiat)
: '100',
}))
form.currencies.splice(0, form.currencies.length, ...normalized)
resortCurrenciesInPlace()
const bankList = Array.isArray(f.withdraw_banks) ? f.withdraw_banks : []
const banksNorm: BankRow[] = bankList.map((b: Record<string, unknown>) => ({
code: typeof b.code === 'string' ? b.code : '',
name_zh: typeof b.name_zh === 'string' ? b.name_zh : '',
name_en: typeof b.name_en === 'string' ? b.name_en : '',
sort: rowSortValue({ sort: b.sort }),
}))
form.withdraw_banks.splice(0, form.withdraw_banks.length, ...banksNorm)
resortBanksInPlace()
const chList = Array.isArray(f.channels) ? f.channels : []
const channelsNorm: ChannelRow[] = chList.map((c) => normalizeChannelRow(c as Record<string, unknown>))
form.channels.splice(0, form.channels.length, ...channelsNorm)
resortChannelsInPlace()
Object.assign(form.withdraw_limits, f.withdraw_limits || {})
Object.assign(form.withdraw_copy, f.withdraw_copy || {})
Object.assign(form.withdraw_fields, f.withdraw_fields || {})
}
} finally {
loading.value = false
}
}
async function onSave() {
if (!auth('save')) {
return
}
for (const row of form.currencies) {
normalizeCurrencyCode(row)
}
const codes = form.currencies.map((c) => String(c.code || '').trim().toUpperCase()).filter((c) => c !== '')
if (new Set(codes).size !== codes.length) {
ElMessage.warning(t('config.financeCashierConfig.err_dup_code'))
return
}
resortCurrenciesInPlace()
resortBanksInPlace()
resortChannelsInPlace()
saving.value = true
try {
await createAxios({
url: '/admin/config.FinanceCashierConfig/save',
method: 'post',
data: JSON.parse(JSON.stringify(form)),
showSuccessMessage: true,
})
await load()
} finally {
saving.value = false
}
}
onMounted(() => {
void load()
})
</script>
<style scoped lang="scss">
.finance-cashier-page {
.toolbar {
margin-bottom: 12px;
}
.section-card {
margin-bottom: 16px;
}
.hint {
color: var(--el-text-color-secondary);
font-size: 13px;
margin: 0 0 10px;
}
.w400 {
max-width: 400px;
}
.w240 {
max-width: 240px;
}
.w100p {
width: 100%;
}
.mb-3 {
margin-bottom: 12px;
}
.ml-2 {
margin-left: 8px;
}
}
</style>

View File

@@ -0,0 +1,214 @@
<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', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('order.depositChannelOrder.quick Search Fields') })"
></TableHeader>
<Table ref="tableRef"></Table>
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from '../depositOrder/popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'order/depositChannelOrder',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
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.DepositChannelOrder/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('order.depositOrder.id'), prop: 'id', align: 'center', width: 80, operator: 'RANGE', sortable: 'custom' },
{
label: t('order.depositOrder.order_no'),
prop: 'order_no',
align: 'center',
minWidth: 170,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('order.depositOrder.user_username'),
prop: 'user.username',
align: 'center',
minWidth: 120,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
},
{
label: t('order.depositOrder.channel_name'),
prop: 'channel.name',
align: 'center',
minWidth: 110,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
},
{
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',
align: 'center',
width: 100,
operator: 'eq',
render: 'tag',
effect: 'dark',
custom: {
'0': 'info',
'1': 'success',
'2': 'danger',
'3': 'warning',
},
replaceValue: {
'0': t('order.depositOrder.status 0'),
'1': t('order.depositOrder.status 1'),
'2': t('order.depositOrder.status 2'),
'3': t('order.depositOrder.status 3'),
},
},
{
label: t('order.depositOrder.pay_channel'),
prop: 'pay_channel',
align: 'center',
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',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
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',
align: 'center',
minWidth: 150,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{
label: t('order.depositOrder.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.depositOrder.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 170,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
show: false,
},
{
label: t('Operate'),
align: 'center',
width: 90,
render: 'buttons',
buttons: optButtons,
operator: false,
fixed: 'right',
},
],
},
{
defaultItems: { status: 0, amount: '0.0000', bonus_amount: '0.0000' },
}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>