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

@@ -43,3 +43,7 @@ AUTH_TOKEN_SECRET = 564d14asdasd113e46542asd6das1a2a
# Webman Push浏览器实际连接的 WS 基址(不含 /app/。HTTPS 后台须用 wss://,与 Nginx 反代 3131 一致
# 示例PUSH_WEBSOCKET_CLIENT_URL = wss://zihua-api.yuliao666.top
PUSH_WEBSOCKET_CLIENT_URL =
# 充值支付渠道在代码注册表之外追加渠道JSON 数组,每项含 code / name / name_en / sort
# 示例DEPOSIT_CHANNELS_REGISTRY_JSON = [{"code":"bank_a","name":"银行转账A","name_en":"Bank A","sort":20}]
DEPOSIT_CHANNELS_REGISTRY_JSON =

View File

@@ -0,0 +1,421 @@
<?php
namespace app\admin\controller\config;
use app\common\controller\Backend;
use app\common\library\game\DepositChannel as DepositChannelLib;
use app\common\library\game\DepositTier as DepositTierLib;
use app\common\library\game\FinanceCashierConfig as FinanceCashierConfigLib;
use app\common\service\GameHotDataCoordinator;
use app\common\service\GameHotDataLock;
use InvalidArgumentException;
use support\think\Db;
use support\Response;
use Throwable;
use Webman\Http\Request as WebmanRequest;
/**
* 充值支付渠道game_config.deposit_channel
*/
class DepositChannel extends Backend
{
protected bool $modelValidate = false;
protected array $noNeedPermission = ['index', 'save'];
private function hasNodePermission(WebmanRequest $request, string $action): bool
{
if (!$this->auth) {
return false;
}
$controllerPath = get_controller_path($request);
if (!$controllerPath) {
return false;
}
$paths = [];
$paths[] = $controllerPath . '/' . $action;
$parts = explode('/', $controllerPath);
foreach ($parts as &$part) {
if (str_contains($part, '_')) {
$part = lcfirst(str_replace(' ', '', ucwords(str_replace('_', ' ', $part))));
}
}
$paths[] = implode('/', $parts) . '/' . $action;
foreach (array_values(array_unique($paths)) as $path) {
if ($this->auth->check($path)) {
return true;
}
}
return false;
}
protected function initController(WebmanRequest $request): ?Response
{
return null;
}
/**
* 列表baTablelist / total / remark+ registry + tier_options弹窗用
*/
public function index(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->hasNodePermission($request, 'index')) {
return $this->error(__('You have no permission'), [], 401);
}
if ($request->method() !== 'GET') {
return $this->error(__('Parameter error'));
}
$tierOptions = $this->buildTierOptions();
$registryOut = $this->buildRegistryOut();
$parsed = DepositChannelLib::parseStoredOverridesFromDb();
$items = DepositChannelLib::expandRowsForAdmin($parsed);
$quickSearch = $request->get('quickSearch', '');
if (is_string($quickSearch) && trim($quickSearch) !== '') {
$q = mb_strtolower(trim($quickSearch));
$items = array_values(array_filter($items, static function (array $it) use ($q, $registryOut): bool {
$code = isset($it['code']) && is_string($it['code']) ? mb_strtolower($it['code']) : '';
$name = '';
if (isset($it['code']) && is_string($it['code']) && isset($registryOut[$it['code']])) {
$meta = $registryOut[$it['code']];
$name = isset($meta['name']) && is_string($meta['name']) ? mb_strtolower($meta['name']) : '';
}
return $q === '' || str_contains($code, $q) || str_contains($name, $q);
}));
}
$orderRaw = $request->get('order', '');
if (is_string($orderRaw) && str_contains($orderRaw, ',')) {
$parts = explode(',', $orderRaw, 2);
$field = trim($parts[0]);
$dir = strtolower(trim($parts[1] ?? 'asc'));
if ($field === 'sort') {
usort($items, static function (array $a, array $b) use ($dir): int {
$sa = isset($a['sort']) && is_numeric($a['sort']) ? intval($a['sort']) : 0;
$sb = isset($b['sort']) && is_numeric($b['sort']) ? intval($b['sort']) : 0;
if ($sa !== $sb) {
return $dir === 'desc' ? ($sb <=> $sa) : ($sa <=> $sb);
}
$ca = isset($a['code']) && is_string($a['code']) ? $a['code'] : '';
$cb = isset($b['code']) && is_string($b['code']) ? $b['code'] : '';
return strcmp($ca, $cb);
});
}
}
$total = count($items);
$pageRaw = $request->get('page', '1');
$limitRaw = $request->get('limit', '20');
$page = is_numeric($pageRaw) ? max(1, intval($pageRaw)) : 1;
$limit = is_numeric($limitRaw) ? max(1, min(500, intval($limitRaw))) : 20;
$offset = ($page - 1) * $limit;
$pageRows = array_slice($items, $offset, $limit);
foreach ($pageRows as &$pr) {
if (!is_array($pr)) {
continue;
}
$code = isset($pr['code']) && is_string($pr['code']) ? $pr['code'] : '';
$meta = $code !== '' && isset($registryOut[$code]) ? $registryOut[$code] : null;
$pr['display_name'] = is_array($meta) && isset($meta['name']) && is_string($meta['name']) ? $meta['name'] : $code;
$pr['name_en'] = is_array($meta) && isset($meta['name_en']) && is_string($meta['name_en']) ? $meta['name_en'] : '';
}
unset($pr);
return $this->success('', [
'list' => $pageRows,
'total' => $total,
'remark' => '',
'registry' => $registryOut,
'tier_options' => $tierOptions,
'items' => $pageRows,
]);
}
/**
* 单条读取 / 单条更新(弹窗)
*/
public function edit(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->hasNodePermission($request, 'edit')) {
return $this->error(__('You have no permission'), [], 401);
}
if ($request->method() === 'GET') {
$code = $request->get('code', '');
if (!is_string($code) || trim($code) === '') {
$code = $request->get('id', '');
}
if (!is_string($code) || trim($code) === '') {
return $this->error(__('Parameter error'));
}
$code = strtolower(trim($code));
$parsed = DepositChannelLib::parseStoredOverridesFromDb();
$items = DepositChannelLib::expandRowsForAdmin($parsed);
$found = null;
foreach ($items as $it) {
if (!is_array($it)) {
continue;
}
$c = isset($it['code']) && is_string($it['code']) ? strtolower(trim($it['code'])) : '';
if ($c === $code) {
$found = $it;
break;
}
}
if ($found === null) {
return $this->error(__('Record not found'));
}
return $this->success('', ['row' => $found]);
}
if ($request->method() === 'POST') {
$payload = $request->post();
if (!is_array($payload)) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$code = isset($payload['code']) && is_string($payload['code']) ? strtolower(trim($payload['code'])) : '';
if ($code === '') {
return $this->error(__('Parameter error'));
}
$parsed = DepositChannelLib::parseStoredOverridesFromDb();
$items = DepositChannelLib::expandRowsForAdmin($parsed);
$foundIdx = -1;
$foundRow = null;
foreach ($items as $k => $it) {
if (!is_array($it)) {
continue;
}
$c = isset($it['code']) && is_string($it['code']) ? strtolower(trim($it['code'])) : '';
if ($c === $code) {
$foundIdx = $k;
$foundRow = $it;
break;
}
}
if ($foundIdx < 0 || $foundRow === null) {
return $this->error(__('Record not found'));
}
$items[$foundIdx] = $this->mergeDepositChannelEditPayload($foundRow, $payload, $code);
return $this->persistChannelList($items);
}
return $this->error(__('Parameter error'));
}
public function save(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->hasNodePermission($request, 'save')) {
return $this->error(__('You have no permission'), [], 401);
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$payload = $request->post();
if (!is_array($payload)) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$items = $payload['items'] ?? null;
if (!is_array($items)) {
return $this->error('items 必须为数组');
}
return $this->persistChannelList($items);
}
/**
* @param list<array<string, mixed>> $items
*/
private function persistChannelList(array $items): Response
{
try {
$registry = DepositChannelLib::codeRegistry();
$clean = DepositChannelLib::prepareOverridesForSave(array_values($items));
$expectedCodes = array_keys($registry);
sort($expectedCodes);
$got = array_column($clean, 'code');
sort($got);
if ($expectedCodes !== $got) {
return $this->error('请保存全部已注册渠道行(不可缺行)');
}
$json = DepositChannelLib::encodeForDb($clean);
} catch (InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$now = time();
$resourceKey = GameHotDataLock::safeResourceKeyForConfig(DepositChannelLib::CONFIG_KEY);
$lock = GameHotDataLock::tryAcquire(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey);
if (!$lock['acquired']) {
return $this->error('该配置正在被其他操作占用,请稍后再试');
}
try {
try {
$exists = Db::name('game_config')->where('config_key', DepositChannelLib::CONFIG_KEY)->find();
if ($exists) {
Db::name('game_config')->where('config_key', DepositChannelLib::CONFIG_KEY)->update([
'config_value' => $json,
'value_type' => 'json',
'update_time' => $now,
]);
} else {
Db::name('game_config')->insert([
'config_key' => DepositChannelLib::CONFIG_KEY,
'config_value' => $json,
'value_type' => 'json',
'remark' => '充值支付渠道 JSON与 finance_cashier.channels 同步)',
'create_time' => $now,
'update_time' => $now,
]);
}
$fcExists = Db::name('game_config')->where('config_key', FinanceCashierConfigLib::CONFIG_KEY)->find();
if ($fcExists) {
$fcPayload = FinanceCashierConfigLib::parseFromConfigValue($fcExists['config_value'] ?? null);
$fcPayload['channels'] = $clean;
$fcJson = FinanceCashierConfigLib::encodeForDb($fcPayload);
Db::name('game_config')->where('config_key', FinanceCashierConfigLib::CONFIG_KEY)->update([
'config_value' => $fcJson,
'value_type' => 'json',
'update_time' => $now,
]);
GameHotDataCoordinator::afterGameConfigKeyCommitted(FinanceCashierConfigLib::CONFIG_KEY);
}
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
GameHotDataCoordinator::afterGameConfigKeyCommitted(DepositChannelLib::CONFIG_KEY);
return $this->success(__('Saved successfully'));
} finally {
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey, $lock['token'], $lock['redis_lock']);
}
}
/**
* @return list<array{id: string, label: string}>
*/
private function buildTierOptions(): array
{
$tierRow = Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->find();
$allTiers = DepositTierLib::parseFromConfigValue($tierRow['config_value'] ?? null);
$tierOptions = [];
foreach ($allTiers as $t) {
if (!is_array($t)) {
continue;
}
$tid = isset($t['id']) && is_string($t['id']) ? $t['id'] : '';
if ($tid === '') {
continue;
}
$title = isset($t['title']) && is_string($t['title']) ? trim($t['title']) : '';
$tierOptions[] = [
'id' => $tid,
'label' => $title !== '' ? $title . ' (' . $tid . ')' : $tid,
];
}
return $tierOptions;
}
/**
* @return array<string, array{name: string, name_en: string, sort: int}>
*/
private function buildRegistryOut(): array
{
$registry = DepositChannelLib::codeRegistry();
$registryOut = [];
foreach ($registry as $code => $meta) {
if (!is_array($meta)) {
continue;
}
$registryOut[$code] = [
'name' => isset($meta['name']) && is_string($meta['name']) ? $meta['name'] : '',
'name_en' => isset($meta['name_en']) && is_string($meta['name_en']) ? $meta['name_en'] : '',
'sort' => isset($meta['sort']) && is_numeric($meta['sort']) ? intval($meta['sort']) : 10,
];
}
return $registryOut;
}
/**
* 弹窗整表提交与列表内开关均走此合并:未出现在 payload 中的字段沿用 $current。
*
* @param array<string, mixed> $current
* @param array<string, mixed> $payload
*
* @return array{code: string, sort: int, status: int, tier_ids: list<string>}
*/
private function mergeDepositChannelEditPayload(array $current, array $payload, string $code): array
{
$form = [];
if (array_key_exists('sort', $payload)) {
$form['sort'] = $payload['sort'];
} else {
$form['sort'] = $current['sort'] ?? 0;
}
if (array_key_exists('status', $payload)) {
$form['status'] = $payload['status'];
} else {
$form['status'] = $current['status'] ?? 0;
}
if (array_key_exists('tier_ids', $payload) && is_array($payload['tier_ids'])) {
$form['tier_ids'] = $payload['tier_ids'];
} else {
$form['tier_ids'] = isset($current['tier_ids']) && is_array($current['tier_ids']) ? $current['tier_ids'] : [];
}
return $this->normalizeChannelFormRow($form, $code);
}
/**
* @param array<string, mixed> $payload
*
* @return array{code: string, sort: int, status: int, tier_ids: list<string>}
*/
private function normalizeChannelFormRow(array $payload, string $code): array
{
$sort = isset($payload['sort']) && is_numeric($payload['sort']) ? intval($payload['sort']) : 0;
$status = 0;
$st = $payload['status'] ?? 1;
if ($st === true || $st === 1 || $st === '1') {
$status = 1;
}
$tierIds = [];
if (isset($payload['tier_ids']) && is_array($payload['tier_ids'])) {
foreach ($payload['tier_ids'] as $tid) {
if (is_string($tid)) {
$t = trim($tid);
if ($t !== '' && preg_match('/^[a-zA-Z0-9_\-]{1,32}$/', $t)) {
$tierIds[] = $t;
}
}
}
$tierIds = array_values(array_unique($tierIds));
}
return [
'code' => $code,
'sort' => $sort,
'status' => $status,
'tier_ids' => $tierIds,
];
}
}

View File

@@ -44,6 +44,7 @@ class DepositTier extends Backend
return true;
}
}
return false;
}
@@ -53,7 +54,7 @@ class DepositTier extends Backend
}
/**
* 读取 game_config.deposit_tier 的档位列表
* 列表baTablelist / total / remark支持 quickSearch、分页
*/
public function index(WebmanRequest $request): Response
{
@@ -69,13 +70,219 @@ class DepositTier extends Backend
}
$row = Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->find();
$items = DepositTierLib::parseFromConfigValue($row['config_value'] ?? null);
$quickSearch = $request->get('quickSearch', '');
if (is_string($quickSearch) && trim($quickSearch) !== '') {
$q = mb_strtolower(trim($quickSearch));
$items = array_values(array_filter($items, static function (array $it) use ($q): bool {
$id = isset($it['id']) && is_string($it['id']) ? mb_strtolower($it['id']) : '';
$t = isset($it['title']) && is_string($it['title']) ? mb_strtolower($it['title']) : '';
$te = isset($it['title_en']) && is_string($it['title_en']) ? mb_strtolower($it['title_en']) : '';
return $q === '' || str_contains($id, $q) || str_contains($t, $q) || str_contains($te, $q);
}));
}
$orderRaw = $request->get('order', '');
if (is_string($orderRaw) && str_contains($orderRaw, ',')) {
$parts = explode(',', $orderRaw, 2);
$field = trim($parts[0]);
$dir = strtolower(trim($parts[1] ?? 'asc'));
if ($field === 'sort') {
usort($items, static function (array $a, array $b) use ($dir): int {
$sa = isset($a['sort']) && is_numeric($a['sort']) ? intval($a['sort']) : 0;
$sb = isset($b['sort']) && is_numeric($b['sort']) ? intval($b['sort']) : 0;
if ($sa !== $sb) {
return $dir === 'desc' ? ($sb <=> $sa) : ($sa <=> $sb);
}
$ida = isset($a['id']) && is_string($a['id']) ? $a['id'] : '';
$idb = isset($b['id']) && is_string($b['id']) ? $b['id'] : '';
return strcmp($ida, $idb);
});
}
}
$total = count($items);
$pageRaw = $request->get('page', '1');
$limitRaw = $request->get('limit', '20');
$page = is_numeric($pageRaw) ? max(1, intval($pageRaw)) : 1;
$limit = is_numeric($limitRaw) ? max(1, min(500, intval($limitRaw))) : 20;
$offset = ($page - 1) * $limit;
$pageRows = array_slice($items, $offset, $limit);
return $this->success('', [
'items' => $items,
'list' => $pageRows,
'total' => $total,
'remark' => '',
'items' => $pageRows,
]);
}
/**
* 保存 JSON 数组value_type=json
* 单条读取(弹窗编辑
*/
public function edit(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->hasNodePermission($request, 'edit')) {
return $this->error(__('You have no permission'), [], 401);
}
if ($request->method() === 'GET') {
$id = $request->get('id', '');
if (!is_string($id) || trim($id) === '') {
return $this->error(__('Parameter error'));
}
$row = Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->find();
$items = DepositTierLib::parseFromConfigValue($row['config_value'] ?? null);
$found = null;
foreach ($items as $it) {
if (!is_array($it)) {
continue;
}
$rid = $it['id'] ?? '';
if (is_string($rid) && $rid === $id) {
$found = $it;
break;
}
}
if ($found === null) {
return $this->error(__('Record not found'));
}
return $this->success('', ['row' => $found]);
}
if ($request->method() === 'POST') {
if (!$this->hasNodePermission($request, 'edit')) {
return $this->error(__('You have no permission'), [], 401);
}
$payload = $request->post();
if (!is_array($payload)) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$id = isset($payload['id']) && is_string($payload['id']) ? trim($payload['id']) : '';
if ($id === '') {
return $this->error(__('Parameter error'));
}
$cfgRow = Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->find();
$items = DepositTierLib::parseFromConfigValue($cfgRow['config_value'] ?? null);
$foundIdx = -1;
$foundRow = null;
foreach ($items as $k => $it) {
if (!is_array($it)) {
continue;
}
$rid = $it['id'] ?? '';
if (is_string($rid) && $rid === $id) {
$foundIdx = $k;
$foundRow = $it;
break;
}
}
if ($foundIdx < 0 || $foundRow === null) {
return $this->error(__('Record not found'));
}
$items[$foundIdx] = $this->mergeDepositTierEditPayload($foundRow, $payload, $id);
return $this->persistTierList($items);
}
return $this->error(__('Parameter error'));
}
/**
* 新增一条档位
*/
public function add(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->hasNodePermission($request, 'add')) {
return $this->error(__('You have no permission'), [], 401);
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$payload = $request->post();
if (!is_array($payload)) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$cfgRow = Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->find();
$items = DepositTierLib::parseFromConfigValue($cfgRow['config_value'] ?? null);
$newId = isset($payload['id']) && is_string($payload['id']) ? trim($payload['id']) : '';
if ($newId === '') {
$newId = DepositTierLib::generateId();
}
foreach ($items as $it) {
if (!is_array($it)) {
continue;
}
$rid = $it['id'] ?? '';
if (is_string($rid) && $rid === $newId) {
return $this->error('档位 ID 已存在');
}
}
$items[] = $this->normalizeFormRow($payload, $newId);
return $this->persistTierList($items);
}
/**
* 删除(支持批量 ids
*/
public function del(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->hasNodePermission($request, 'del')) {
return $this->error(__('You have no permission'), [], 401);
}
if ($request->method() !== 'DELETE') {
return $this->error(__('Parameter error'));
}
$ids = $request->get('ids', []);
if (!is_array($ids)) {
if (is_string($ids) && $ids !== '') {
$ids = [$ids];
} else {
$ids = [];
}
}
$idSet = [];
foreach ($ids as $id) {
if (is_string($id) && trim($id) !== '') {
$idSet[trim($id)] = true;
}
}
if ($idSet === []) {
return $this->error(__('Parameter error'));
}
$cfgRow = Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->find();
$items = DepositTierLib::parseFromConfigValue($cfgRow['config_value'] ?? null);
$filtered = [];
foreach ($items as $it) {
if (!is_array($it)) {
continue;
}
$rid = isset($it['id']) && is_string($it['id']) ? $it['id'] : '';
if ($rid !== '' && isset($idSet[$rid])) {
continue;
}
$filtered[] = $it;
}
return $this->persistTierList($filtered);
}
/**
* 整表保存 JSON 数组(兼容旧版批量提交)
*/
public function save(WebmanRequest $request): Response
{
@@ -97,6 +304,15 @@ class DepositTier extends Backend
if (!is_array($items)) {
return $this->error('items 必须为数组');
}
return $this->persistTierList($items);
}
/**
* @param list<array<string, mixed>> $items
*/
private function persistTierList(array $items): Response
{
try {
$clean = DepositTierLib::prepareItemsForSave(array_values($items));
$json = DepositTierLib::encodeForDb($clean);
@@ -132,7 +348,6 @@ class DepositTier extends Backend
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
GameHotDataCoordinator::afterGameConfigKeyCommitted(DepositTierLib::CONFIG_KEY);
return $this->success(__('Saved successfully'));
@@ -140,4 +355,67 @@ class DepositTier extends Backend
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey, $lock['token'], $lock['redis_lock']);
}
}
/**
* 弹窗整表提交与列表内开关:未出现在 payload 中的字段沿用 $current。
*
* @param array<string, mixed> $current
* @param array<string, mixed> $payload
*
* @return array<string, mixed>
*/
private function mergeDepositTierEditPayload(array $current, array $payload, string $id): array
{
$merged = [];
$merged['id'] = $id;
if (array_key_exists('sort', $payload)) {
$merged['sort'] = $payload['sort'];
} else {
$merged['sort'] = $current['sort'] ?? 0;
}
if (array_key_exists('status', $payload)) {
$merged['status'] = $payload['status'];
} else {
$merged['status'] = $current['status'] ?? 0;
}
$stringKeys = ['title', 'title_en', 'currency', 'pay_amount', 'amount', 'bonus_amount', 'desc', 'desc_en'];
foreach ($stringKeys as $key) {
if (array_key_exists($key, $payload)) {
$merged[$key] = $payload[$key];
} else {
$merged[$key] = $current[$key] ?? ($key === 'bonus_amount' ? '0' : '');
}
}
return $this->normalizeFormRow($merged, $id);
}
/**
* @param array<string, mixed> $payload
*
* @return array<string, mixed>
*/
private function normalizeFormRow(array $payload, string $id): array
{
$sort = isset($payload['sort']) && is_numeric($payload['sort']) ? intval($payload['sort']) : 0;
$status = 0;
$st = $payload['status'] ?? 1;
if ($st === true || $st === 1 || $st === '1') {
$status = 1;
}
return [
'id' => $id,
'title' => isset($payload['title']) && is_string($payload['title']) ? $payload['title'] : '',
'title_en' => isset($payload['title_en']) && is_string($payload['title_en']) ? $payload['title_en'] : '',
'currency' => isset($payload['currency']) && is_string($payload['currency']) ? $payload['currency'] : '',
'pay_amount' => $payload['pay_amount'] ?? '',
'amount' => $payload['amount'] ?? '',
'bonus_amount' => $payload['bonus_amount'] ?? '0',
'desc' => isset($payload['desc']) && is_string($payload['desc']) ? $payload['desc'] : '',
'desc_en' => isset($payload['desc_en']) && is_string($payload['desc_en']) ? $payload['desc_en'] : '',
'sort' => $sort,
'status' => $status,
];
}
}

View File

@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\config;
use app\common\controller\Backend;
use app\common\library\game\DepositChannel as DepositChannelLib;
use app\common\library\game\DepositTier as DepositTierLib;
use app\common\library\game\FinanceCashierConfig as FinanceCashierConfigLib;
use app\common\service\GameHotDataCoordinator;
use app\common\service\GameHotDataLock;
use InvalidArgumentException;
use support\think\Db;
use support\Response;
use Throwable;
use Webman\Http\Request as WebmanRequest;
/**
* 支付/收款配置game_config.finance_cashier含充值渠道
*/
class FinanceCashierConfig extends Backend
{
protected bool $modelValidate = false;
protected array $noNeedPermission = ['index', 'save'];
private function hasNodePermission(WebmanRequest $request, string $action): bool
{
if (!$this->auth) {
return false;
}
$controllerPath = get_controller_path($request);
if (!$controllerPath) {
return false;
}
$paths = [];
$paths[] = $controllerPath . '/' . $action;
$parts = explode('/', $controllerPath);
foreach ($parts as &$part) {
if (str_contains($part, '_')) {
$part = lcfirst(str_replace(' ', '', ucwords(str_replace('_', ' ', $part))));
}
}
$paths[] = implode('/', $parts) . '/' . $action;
foreach (array_values(array_unique($paths)) as $path) {
if ($this->auth->check($path)) {
return true;
}
}
return false;
}
protected function initController(WebmanRequest $request): ?Response
{
return null;
}
public function index(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->hasNodePermission($request, 'index')) {
return $this->error(__('You have no permission'), [], 401);
}
if ($request->method() !== 'GET') {
return $this->error(__('Parameter error'));
}
$row = Db::name('game_config')->where('config_key', FinanceCashierConfigLib::CONFIG_KEY)->find();
$form = FinanceCashierConfigLib::parseFromConfigValue($row['config_value'] ?? null);
$decoded = null;
if (is_array($row) && isset($row['config_value']) && is_string($row['config_value']) && trim($row['config_value']) !== '') {
$tmp = json_decode($row['config_value'], true);
if (is_array($tmp)) {
$decoded = $tmp;
}
}
$channelsKeyPresent = is_array($decoded) && array_key_exists('channels', $decoded);
if (!$channelsKeyPresent) {
$depRow = Db::name('game_config')->where('config_key', DepositChannelLib::CONFIG_KEY)->find();
$legacy = DepositChannelLib::parseOverridesFromConfigValue(is_array($depRow) ? ($depRow['config_value'] ?? null) : null);
$form['channels'] = DepositChannelLib::expandRowsForAdmin($legacy);
} else {
$form['channels'] = DepositChannelLib::expandRowsForAdmin($form['channels'] ?? []);
}
return $this->success('', [
'form' => $form,
'registry' => $this->buildRegistryOut(),
'tier_options' => $this->buildTierOptions(),
]);
}
public function save(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->hasNodePermission($request, 'save')) {
return $this->error(__('You have no permission'), [], 401);
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$payload = $request->post();
if (!is_array($payload)) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
try {
$json = FinanceCashierConfigLib::encodeForDb($payload);
} catch (InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$now = time();
$resourceKey = GameHotDataLock::safeResourceKeyForConfig(FinanceCashierConfigLib::CONFIG_KEY);
$lock = GameHotDataLock::tryAcquire(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey);
if (!$lock['acquired']) {
return $this->error('该配置正在被其他操作占用,请稍后再试');
}
try {
try {
$exists = Db::name('game_config')->where('config_key', FinanceCashierConfigLib::CONFIG_KEY)->find();
if ($exists) {
Db::name('game_config')->where('config_key', FinanceCashierConfigLib::CONFIG_KEY)->update([
'config_value' => $json,
'value_type' => 'json',
'update_time' => $now,
]);
} else {
Db::name('game_config')->insert([
'config_key' => FinanceCashierConfigLib::CONFIG_KEY,
'config_value' => $json,
'value_type' => 'json',
'remark' => '支付/收款配置(表单维护,含充值渠道)',
'create_time' => $now,
'update_time' => $now,
]);
}
$decodedSave = json_decode($json, true);
$chList = is_array($decodedSave) && isset($decodedSave['channels']) && is_array($decodedSave['channels'])
? $decodedSave['channels']
: [];
$mirrorJson = DepositChannelLib::encodeForDb($chList);
$depExists = Db::name('game_config')->where('config_key', DepositChannelLib::CONFIG_KEY)->find();
if ($depExists) {
Db::name('game_config')->where('config_key', DepositChannelLib::CONFIG_KEY)->update([
'config_value' => $mirrorJson,
'value_type' => 'json',
'update_time' => $now,
]);
} else {
Db::name('game_config')->insert([
'config_key' => DepositChannelLib::CONFIG_KEY,
'config_value' => $mirrorJson,
'value_type' => 'json',
'remark' => '充值渠道(与 finance_cashier.channels 同步)',
'create_time' => $now,
'update_time' => $now,
]);
}
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
GameHotDataCoordinator::afterGameConfigKeyCommitted(FinanceCashierConfigLib::CONFIG_KEY);
GameHotDataCoordinator::afterGameConfigKeyCommitted(DepositChannelLib::CONFIG_KEY);
return $this->success(__('Saved successfully'));
} finally {
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey, $lock['token'], $lock['redis_lock']);
}
}
/**
* @return list<array{id: string, label: string}>
*/
private function buildTierOptions(): array
{
$tierRow = Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->find();
$allTiers = DepositTierLib::parseFromConfigValue(is_array($tierRow) ? ($tierRow['config_value'] ?? null) : null);
$tierOptions = [];
foreach ($allTiers as $t) {
if (!is_array($t)) {
continue;
}
$tid = isset($t['id']) && is_string($t['id']) ? $t['id'] : '';
if ($tid === '') {
continue;
}
$title = isset($t['title']) && is_string($t['title']) ? trim($t['title']) : '';
$tierOptions[] = [
'id' => $tid,
'label' => $title !== '' ? $title . ' (' . $tid . ')' : $tid,
];
}
return $tierOptions;
}
/**
* @return array<string, array{name: string, name_en: string, sort: int}>
*/
private function buildRegistryOut(): array
{
$registry = DepositChannelLib::codeRegistry();
$registryOut = [];
foreach ($registry as $code => $meta) {
if (!is_array($meta)) {
continue;
}
$registryOut[$code] = [
'name' => isset($meta['name']) && is_string($meta['name']) ? $meta['name'] : '',
'name_en' => isset($meta['name_en']) && is_string($meta['name_en']) ? $meta['name_en'] : '',
'sort' => isset($meta['sort']) && is_numeric($meta['sort']) ? intval($meta['sort']) : 10,
];
}
return $registryOut;
}
}

View File

@@ -3,7 +3,9 @@
namespace app\admin\controller\config;
use app\common\controller\Backend;
use app\common\library\game\DepositChannel;
use app\common\library\game\DepositTier;
use app\common\library\game\FinanceCashierConfig;
use app\common\library\game\StreakWinReward;
use app\common\library\game\ZiHuaDictionary as ZiHuaDictionaryLib;
use support\Response;
@@ -42,6 +44,8 @@ class GameConfig extends Backend
return [
ZiHuaDictionaryLib::CONFIG_KEY,
DepositTier::CONFIG_KEY,
DepositChannel::CONFIG_KEY,
FinanceCashierConfig::CONFIG_KEY,
StreakWinReward::CONFIG_KEY,
];
}

View File

@@ -0,0 +1,29 @@
<?php
namespace app\admin\controller\order;
use app\common\library\game\DepositChannel;
/**
* 渠道充值订单仅列出已注册且启用的支付渠道pay_channel产生的充值单
*/
class DepositChannelOrder extends DepositOrder
{
/**
* @param list<array<mixed>> $where
*/
protected function appendDepositOrderIndexWhere(array &$where, string $mainShort): void
{
if ($mainShort === '') {
return;
}
$effective = DepositChannel::effectiveRowsFromDb();
$codes = DepositChannel::enabledPayChannelCodes($effective);
if ($codes === []) {
$where[] = [$mainShort . '.pay_channel', '=', '__no_pay_channel__'];
return;
}
$where[] = [$mainShort . '.pay_channel', 'in', $codes];
}
}

View File

@@ -52,6 +52,7 @@ class DepositOrder extends Backend
$channelIds = $this->getScopedChannelIdsForFilter();
$where[] = [$mainShort . '.channel_id', 'in', $channelIds !== [] ? $channelIds : [0]];
}
$this->appendDepositOrderIndexWhere($where, $mainShort);
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
@@ -72,6 +73,15 @@ class DepositOrder extends Backend
]);
}
/**
* 子类可追加列表过滤条件(例如仅展示已注册充值渠道的订单)
*
* @param list<array<mixed>> $where
*/
protected function appendDepositOrderIndexWhere(array &$where, string $mainShort): void
{
}
/**
* GET 时返回关联信息,便于前端详情弹窗直接渲染 user.username / channel.name
* POST 一律拒绝,保证充值订单的金额/状态只能由结算服务变更。

View File

@@ -6,7 +6,9 @@ namespace app\api\controller;
use app\common\library\finance\DepositSettlement;
use app\common\library\finance\WithdrawFlow;
use app\common\library\game\DepositChannel as DepositChannelLib;
use app\common\library\game\DepositTier as DepositTierLib;
use app\common\library\game\FinanceCashierConfig as FinanceCashierConfigLib;
use app\common\model\DepositOrder;
use app\common\model\GameConfig;
use app\common\model\WithdrawOrder;
@@ -29,19 +31,30 @@ class Finance extends MobileBase
$lang = $this->currentLang();
$tiers = $this->loadEnabledTiers();
$effectiveChannels = $this->loadDepositChannelEffective();
$list = [];
foreach ($tiers as $tier) {
$amount = $this->amountString($tier['amount'] ?? '0');
$bonus = $this->amountString($tier['bonus_amount'] ?? '0');
$total = bcadd($amount, $bonus, 4);
$payAmount = $this->amountString($tier['pay_amount'] ?? '0');
$currency = isset($tier['currency']) && is_string($tier['currency']) ? strtoupper(trim($tier['currency'])) : 'CNY';
if ($currency === '') {
$currency = 'CNY';
}
$localized = DepositTierLib::localize($tier, $lang);
$tierId = isset($tier['id']) && is_string($tier['id']) ? $tier['id'] : '';
$list[] = [
'id' => $tier['id'],
'id' => $tierId,
'tier_key' => $tierId,
'title' => $localized['title'],
'currency' => $currency,
'pay_amount' => $payAmount,
'amount' => $amount,
'bonus_amount' => $bonus,
'total_amount' => $total,
'desc' => $localized['desc'],
'channels' => DepositChannelLib::channelsForTier($tierId, $effectiveChannels, $lang),
];
}
return $this->mobileSuccess([
@@ -69,7 +82,8 @@ class Finance extends MobileBase
* 并把入账动作放到网关回调里完成(回调中调用 DepositSettlement::settle
*
* 请求application/json 或 x-www-form-urlencoded
* - tier_id: 必填,档位 ID需在 game_config.deposit_tier 启用档位内
* - tier_id / tier_key: 必填,档位唯一标识(与 depositTierList 中 id、tier_key 一致
* - channel_code: 必填,支付渠道代码(与 depositTierList 各档位 channels[].code 一致)
* - idempotency_key: 必填,客户端幂等键,短时间内重复提交只生成一次订单
*
* 响应(统一结构,未来接入第三方也保持此形状):
@@ -83,8 +97,12 @@ class Finance extends MobileBase
}
$tierId = $this->stringParam($request->input('tier_id'));
if ($tierId === '') {
$tierId = $this->stringParam($request->input('tier_key'));
}
$channelCode = strtolower($this->stringParam($request->input('channel_code')));
$idempotencyKey = $this->stringParam($request->input('idempotency_key'));
if ($tierId === '' || $idempotencyKey === '') {
if ($tierId === '' || $channelCode === '' || $idempotencyKey === '') {
return $this->mobileError(1001, 'Missing parameters');
}
if (mb_strlen($idempotencyKey) > 64) {
@@ -96,6 +114,10 @@ class Finance extends MobileBase
if (!$tier) {
return $this->mobileError(2003, 'Deposit tier not available');
}
$effectiveChannels = $this->loadDepositChannelEffective();
if (!DepositChannelLib::assertChannelAllowsTier($channelCode, $tierId, $effectiveChannels)) {
return $this->mobileError(2004, 'Pay channel not available');
}
// 幂等命中:直接返回已有订单
try {
@@ -112,14 +134,21 @@ class Finance extends MobileBase
$user = $this->auth->getUser();
$orderNo = 'DP' . date('YmdHis') . substr(str_replace('.', '', uniqid('', true)), -6);
$curSnap = isset($tier['currency']) && is_string($tier['currency']) ? strtoupper(trim($tier['currency'])) : 'CNY';
if ($curSnap === '') {
$curSnap = 'CNY';
}
$tierSnapshot = [
'id' => $tier['id'],
'title' => is_string($tier['title'] ?? null) ? $tier['title'] : '',
'title_en' => is_string($tier['title_en'] ?? null) ? $tier['title_en'] : '',
'currency' => $curSnap,
'pay_amount' => $this->amountString($tier['pay_amount'] ?? '0'),
'amount' => $this->amountString($tier['amount'] ?? '0'),
'bonus_amount' => $this->amountString($tier['bonus_amount'] ?? '0'),
'desc' => is_string($tier['desc'] ?? null) ? $tier['desc'] : '',
'desc_en' => is_string($tier['desc_en'] ?? null) ? $tier['desc_en'] : '',
'channel_code' => $channelCode,
];
$now = time();
@@ -138,7 +167,7 @@ class Finance extends MobileBase
'amount' => $tierSnapshot['amount'],
'bonus_amount' => $tierSnapshot['bonus_amount'],
'status' => 0,
'pay_channel' => 'mock_gateway',
'pay_channel' => $channelCode,
'deposit_tier_id' => $tier['id'],
'proof_image' => '',
'pay_account_snapshot' => json_encode($tierSnapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
@@ -163,7 +192,9 @@ class Finance extends MobileBase
DepositSettlement::settle(
$orderId,
DepositSettlement::SOURCE_MOCK_GATEWAY,
'mock gateway auto settled'
'mock gateway auto settled',
null,
'channel_code=' . $channelCode
);
} catch (Throwable $e) {
return $this->mobileError(2000, $e->getMessage());
@@ -483,6 +514,214 @@ class Finance extends MobileBase
]);
}
/**
* 收银台配置:货币列表(含充值/提现汇率、支付渠道pay_channels、提现银行与文案供充值/提现页展示)
*/
public function cashierConfig(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$lang = $this->currentLang();
$isZh = str_starts_with($lang, 'zh');
$row = GameConfig::where('config_key', FinanceCashierConfigLib::CONFIG_KEY)->find();
$cfg = FinanceCashierConfigLib::parseFromConfigValue($row?->config_value ?? null);
$pc = $cfg['platform_coin'] ?? [];
$platformLabel = '';
if (is_array($pc)) {
$platformLabel = $isZh
? (is_string($pc['label_zh'] ?? null) ? $pc['label_zh'] : '')
: (is_string($pc['label_en'] ?? null) ? $pc['label_en'] : '');
}
$currencies = [];
if (isset($cfg['currencies']) && is_array($cfg['currencies'])) {
$list = $cfg['currencies'];
usort($list, function (array $a, array $b): int {
return $this->sortBySortKeyThenCode($a, $b);
});
foreach ($list as $c) {
if (!is_array($c)) {
continue;
}
$code = isset($c['code']) && is_string($c['code']) ? $c['code'] : '';
if ($code === '') {
continue;
}
$dep = isset($c['deposit_coins_per_fiat']) && is_string($c['deposit_coins_per_fiat']) ? $c['deposit_coins_per_fiat'] : '';
$wdr = isset($c['withdraw_coins_per_fiat']) && is_string($c['withdraw_coins_per_fiat']) ? $c['withdraw_coins_per_fiat'] : '';
$currencies[] = [
'code' => $code,
'label' => $isZh
? (is_string($c['label_zh'] ?? null) ? $c['label_zh'] : '')
: (is_string($c['label_en'] ?? null) ? $c['label_en'] : ''),
'deposit_coins_per_fiat' => $dep,
'withdraw_coins_per_fiat' => $wdr,
];
}
}
$rates = [];
foreach ($currencies as $c) {
if (!is_array($c)) {
continue;
}
$cur = isset($c['code']) && is_string($c['code']) ? $c['code'] : '';
$ratio = isset($c['withdraw_coins_per_fiat']) && is_string($c['withdraw_coins_per_fiat']) ? $c['withdraw_coins_per_fiat'] : '';
if ($cur === '' || $ratio === '') {
continue;
}
$rates[] = [
'currency' => $cur,
'diamonds_per_fiat_unit' => $ratio,
];
}
$banks = [];
if (isset($cfg['withdraw_banks']) && is_array($cfg['withdraw_banks'])) {
$list = $cfg['withdraw_banks'];
usort($list, function (array $a, array $b): int {
$cmp = $this->sortBySortKeyOnly($a, $b);
if ($cmp !== 0) {
return $cmp;
}
$ca = isset($a['code']) && is_string($a['code']) ? $a['code'] : '';
$cb = isset($b['code']) && is_string($b['code']) ? $b['code'] : '';
return strcmp($ca, $cb);
});
foreach ($list as $b) {
if (!is_array($b)) {
continue;
}
$code = isset($b['code']) && is_string($b['code']) ? $b['code'] : '';
if ($code === '') {
continue;
}
$banks[] = [
'code' => $code,
'name' => $isZh
? (is_string($b['name_zh'] ?? null) ? $b['name_zh'] : '')
: (is_string($b['name_en'] ?? null) ? $b['name_en'] : ''),
];
}
}
$wl = $cfg['withdraw_limits'] ?? [];
$minEw = is_array($wl) && isset($wl['min_ewallet']) && is_string($wl['min_ewallet']) ? $wl['min_ewallet'] : '0';
$minBk = is_array($wl) && isset($wl['min_bank']) && is_string($wl['min_bank']) ? $wl['min_bank'] : '0';
$wc = $cfg['withdraw_copy'] ?? [];
$rateMode = is_array($wc) && isset($wc['rate_mode']) && is_string($wc['rate_mode']) ? $wc['rate_mode'] : 'fixed';
$wf = $cfg['withdraw_fields'] ?? [];
$reqCard = is_array($wf) && !empty($wf['require_cardholder']);
$reqAcct = is_array($wf) && !empty($wf['require_bank_account']);
$reqMail = is_array($wf) && !empty($wf['require_email']);
$reqMob = is_array($wf) && !empty($wf['require_mobile']);
$payChannels = [];
$effectiveCh = DepositChannelLib::effectiveRowsFromDb();
$regCh = DepositChannelLib::codeRegistry();
foreach ($effectiveCh as $row) {
if (!is_array($row)) {
continue;
}
$code = isset($row['code']) && is_string($row['code']) ? $row['code'] : '';
if ($code === '' || !isset($regCh[$code]) || !is_array($regCh[$code])) {
continue;
}
$meta = $regCh[$code];
$nameZh = isset($meta['name']) && is_string($meta['name']) ? $meta['name'] : '';
$nameEn = isset($meta['name_en']) && is_string($meta['name_en']) ? $meta['name_en'] : '';
$sortNum = filter_var($row['sort'] ?? 0, FILTER_VALIDATE_INT);
$statusNum = filter_var($row['status'] ?? 0, FILTER_VALIDATE_INT);
$payChannels[] = [
'code' => $code,
'name' => $isZh ? $nameZh : ($nameEn !== '' ? $nameEn : $nameZh),
'sort' => $sortNum !== false ? $sortNum : 0,
'status' => $statusNum !== false ? $statusNum : 0,
'tier_ids' => isset($row['tier_ids']) && is_array($row['tier_ids']) ? $row['tier_ids'] : [],
];
}
usort($payChannels, function (array $a, array $b): int {
$sa = isset($a['sort']) ? filter_var($a['sort'], FILTER_VALIDATE_INT) : false;
$sb = isset($b['sort']) ? filter_var($b['sort'], FILTER_VALIDATE_INT) : false;
$ia = $sa === false ? 0 : $sa;
$ib = $sb === false ? 0 : $sb;
if ($ia !== $ib) {
return $ia <=> $ib;
}
$ca = isset($a['code']) && is_string($a['code']) ? $a['code'] : '';
$cb = isset($b['code']) && is_string($b['code']) ? $b['code'] : '';
return strcmp($ca, $cb);
});
return $this->mobileSuccess([
'platform_coin_label' => $platformLabel,
'currencies' => $currencies,
'rates' => $rates,
'pay_channels' => $payChannels,
'withdraw' => [
'banks' => $banks,
'min_ewallet' => $minEw,
'min_bank' => $minBk,
'rate_hint' => $isZh
? (is_array($wc) && is_string($wc['rate_hint_zh'] ?? null) ? $wc['rate_hint_zh'] : '')
: (is_array($wc) && is_string($wc['rate_hint_en'] ?? null) ? $wc['rate_hint_en'] : ''),
'processing_note' => $isZh
? (is_array($wc) && is_string($wc['processing_zh'] ?? null) ? $wc['processing_zh'] : '')
: (is_array($wc) && is_string($wc['processing_en'] ?? null) ? $wc['processing_en'] : ''),
'fee_note' => $isZh
? (is_array($wc) && is_string($wc['fee_note_zh'] ?? null) ? $wc['fee_note_zh'] : '')
: (is_array($wc) && is_string($wc['fee_note_en'] ?? null) ? $wc['fee_note_en'] : ''),
'rate_mode' => $rateMode,
'fields' => [
'require_cardholder' => $reqCard,
'require_bank_account' => $reqAcct,
'require_email' => $reqMail,
'require_mobile' => $reqMob,
],
],
]);
}
/**
* @param array<string, mixed> $a
* @param array<string, mixed> $b
*/
private function sortBySortKeyThenCode(array $a, array $b): int
{
$sa = isset($a['sort']) ? filter_var($a['sort'], FILTER_VALIDATE_INT) : false;
$sb = isset($b['sort']) ? filter_var($b['sort'], FILTER_VALIDATE_INT) : false;
$ia = $sa === false ? 0 : $sa;
$ib = $sb === false ? 0 : $sb;
if ($ia !== $ib) {
return $ia <=> $ib;
}
$ca = isset($a['code']) && is_string($a['code']) ? $a['code'] : '';
$cb = isset($b['code']) && is_string($b['code']) ? $b['code'] : '';
return strcmp($ca, $cb);
}
/**
* @param array<string, mixed> $a
* @param array<string, mixed> $b
*/
private function sortBySortKeyOnly(array $a, array $b): int
{
$sa = isset($a['sort']) ? filter_var($a['sort'], FILTER_VALIDATE_INT) : false;
$sb = isset($b['sort']) ? filter_var($b['sort'], FILTER_VALIDATE_INT) : false;
$ia = $sa === false ? 0 : $sa;
$ib = $sb === false ? 0 : $sb;
return $ia <=> $ib;
}
private function stringParam($raw): string
{
if ($raw === null) {
@@ -501,6 +740,14 @@ class Finance extends MobileBase
return DepositTierLib::publicList($all);
}
/**
* @return list<array{code: string, sort: int, status: int, tier_ids: list<string>}>
*/
private function loadDepositChannelEffective(): array
{
return DepositChannelLib::effectiveRowsFromDb();
}
private function mapDepositStatus($status): string
{
if ($this->intValue($status) === 1) {

View File

@@ -74,6 +74,10 @@ return [
'运营公告' => 'Operation notices',
'用户阅读记录' => 'User read log',
'充值档位' => 'Deposit tiers',
'充值提现收银配置' => 'Pay / receipt settings',
'支付/收款配置' => 'Pay / receipt settings',
'充值渠道' => 'Deposit channels',
'渠道充值订单' => 'Channel deposit orders',
'连胜奖励' => 'Win streak rewards',
'连胜降低档位' => 'Streak reduction tiers',
'钱包加减点' => 'Wallet adjust',

View File

@@ -0,0 +1,438 @@
<?php
declare(strict_types=1);
namespace app\common\library\game;
use InvalidArgumentException;
use support\think\Db;
/**
* 充值支付渠道:优先读取 game_config.finance_cashier.channels无此键时回退 game_config.deposit_channel迁移期镜像
*
* 每项code须在代码/环境注册表内、sort、status(0/1)、tier_ids空=全部启用档位)
*
* 渠道展示名以代码注册表为准;运营只配置开关、排序、可用档位。
*/
final class DepositChannel
{
public const CONFIG_KEY = 'deposit_channel';
/**
* @return array<string, array{name: string, name_en: string, sort: int}>
*/
public static function codeRegistry(): array
{
$base = [
'directpay' => ['name' => 'DirectPay', 'name_en' => 'DirectPay', 'sort' => 10],
];
$extra = self::registryFromEnv();
foreach ($extra as $code => $meta) {
if (!is_string($code) || trim($code) === '' || !is_array($meta)) {
continue;
}
$normCode = strtolower(trim($code));
if (!preg_match('/^[a-z0-9_\-]{1,24}$/', $normCode)) {
continue;
}
$name = isset($meta['name']) && is_string($meta['name']) ? trim($meta['name']) : '';
$nameEn = isset($meta['name_en']) && is_string($meta['name_en']) ? trim($meta['name_en']) : '';
$sort = isset($meta['sort']) && is_numeric($meta['sort']) ? intval($meta['sort']) : 50;
if ($name === '') {
continue;
}
$base[$normCode] = [
'name' => $name,
'name_en' => $nameEn !== '' ? $nameEn : $name,
'sort' => $sort,
];
}
return $base;
}
/**
* @return array<string, mixed>
*/
private static function registryFromEnv(): array
{
$raw = getenv('DEPOSIT_CHANNELS_REGISTRY_JSON');
if (!is_string($raw) || trim($raw) === '') {
return [];
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return [];
}
$out = [];
foreach ($decoded as $item) {
if (!is_array($item)) {
continue;
}
$code = isset($item['code']) && is_string($item['code']) ? strtolower(trim($item['code'])) : '';
if ($code === '') {
continue;
}
$out[$code] = $item;
}
return $out;
}
public static function parseOverridesFromConfigValue($raw): array
{
if (!is_string($raw) || trim($raw) === '') {
return [];
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return [];
}
$list = isset($decoded['channels']) && is_array($decoded['channels']) ? $decoded['channels'] : $decoded;
return self::normalizeOverrides($list);
}
/**
* 库中「运营覆盖」原始列表未与注册表合并finance_cashier 含 channels 键时用其值;否则读 deposit_channel
*
* @return list<array{code: string, sort: int, status: int, tier_ids: list<string>}>
*/
public static function parseStoredOverridesFromDb(): array
{
$fcRow = Db::name('game_config')->where('config_key', FinanceCashierConfig::CONFIG_KEY)->find();
$rawFc = is_array($fcRow) ? ($fcRow['config_value'] ?? null) : null;
$decoded = null;
if (is_string($rawFc) && trim($rawFc) !== '') {
$tmp = json_decode($rawFc, true);
if (is_array($tmp)) {
$decoded = $tmp;
}
}
$channelsKeyPresent = is_array($decoded) && array_key_exists('channels', $decoded);
if ($channelsKeyPresent) {
$list = isset($decoded['channels']) && is_array($decoded['channels']) ? $decoded['channels'] : [];
return self::normalizeOverrides($list);
}
$depRow = Db::name('game_config')->where('config_key', self::CONFIG_KEY)->find();
$depRaw = is_array($depRow) ? ($depRow['config_value'] ?? null) : null;
return self::parseOverridesFromConfigValue($depRaw);
}
/**
* 与注册表合并并排序后的有效渠道行(业务侧统一入口)
*
* @return list<array{code: string, sort: int, status: int, tier_ids: list<string>}>
*/
public static function effectiveRowsFromDb(): array
{
$stored = self::parseStoredOverridesFromDb();
return self::effectiveOverrides(self::expandRowsForAdmin($stored));
}
/**
* @param list<mixed> $items
*
* @return list<array{code: string, sort: int, status: int, tier_ids: list<string>}>
*/
public static function normalizeOverrides(array $items): array
{
$out = [];
foreach ($items as $row) {
if (!is_array($row)) {
continue;
}
$code = isset($row['code']) && is_string($row['code']) ? strtolower(trim($row['code'])) : '';
if ($code === '') {
continue;
}
$sort = isset($row['sort']) && is_numeric($row['sort']) ? intval($row['sort']) : 0;
$status = isset($row['status']) && is_numeric($row['status']) ? intval($row['status']) : 1;
$status = $status === 1 ? 1 : 0;
$tierIds = [];
if (isset($row['tier_ids']) && is_array($row['tier_ids'])) {
foreach ($row['tier_ids'] as $tid) {
if (is_string($tid)) {
$t = trim($tid);
if ($t !== '' && preg_match('/^[a-zA-Z0-9_\-]{1,32}$/', $t)) {
$tierIds[] = $t;
}
}
}
$tierIds = array_values(array_unique($tierIds));
}
$out[] = [
'code' => $code,
'sort' => $sort,
'status' => $status,
'tier_ids' => $tierIds,
];
}
return $out;
}
/**
* 合并注册表与运营覆盖;若库中无覆盖则对注册表内全部渠道启用默认行
*
* @param list<array{code: string, sort: int, status: int, tier_ids: list<string>}> $overrides
*
* @return list<array{code: string, sort: int, status: int, tier_ids: list<string>}>
*/
public static function effectiveOverrides(array $overrides): array
{
$registry = self::codeRegistry();
$byCode = [];
foreach ($overrides as $row) {
if (!isset($registry[$row['code']])) {
continue;
}
$byCode[$row['code']] = $row;
}
if ($byCode === []) {
foreach ($registry as $code => $meta) {
$sortMeta = $meta['sort'] ?? 10;
$sortVal = is_numeric($sortMeta) ? intval($sortMeta) : 10;
$byCode[$code] = [
'code' => $code,
'sort' => $sortVal,
'status' => 1,
'tier_ids' => [],
];
}
}
$list = array_values($byCode);
usort($list, static function (array $a, array $b): int {
if ($a['sort'] !== $b['sort']) {
return $a['sort'] <=> $b['sort'];
}
return strcmp($a['code'], $b['code']);
});
return $list;
}
/**
* @param array{code: string, sort: int, status: int, tier_ids: list<string>} $overrideRow
*/
public static function isTierAllowed(array $overrideRow, string $tierId): bool
{
$ids = $overrideRow['tier_ids'] ?? [];
if (!is_array($ids) || $ids === []) {
return true;
}
foreach ($ids as $id) {
if (is_string($id) && $id === $tierId) {
return true;
}
}
return false;
}
/**
* @param list<array<string, mixed>> $effectiveRows
*/
public static function findMergedByCode(array $effectiveRows, string $code): ?array
{
$norm = strtolower(trim($code));
foreach ($effectiveRows as $row) {
if (!is_array($row)) {
continue;
}
$c = isset($row['code']) && is_string($row['code']) ? strtolower(trim($row['code'])) : '';
if ($c === $norm) {
return $row;
}
}
return null;
}
/**
* @param list<array{code: string, sort: int, status: int, tier_ids: list<string>}> $overrideRows
*
* @return list<array{code: string, name: string, sort: int}>
*/
public static function channelsForTier(string $tierId, array $overrideRows, string $lang): array
{
$registry = self::codeRegistry();
$out = [];
foreach ($overrideRows as $row) {
if (($row['status'] ?? 0) !== 1) {
continue;
}
$code = $row['code'];
if (!isset($registry[$code])) {
continue;
}
if (!self::isTierAllowed($row, $tierId)) {
continue;
}
$meta = $registry[$code];
$name = self::pickLangName($meta, $lang);
$sortRaw = $row['sort'] ?? 0;
$sortVal = is_numeric($sortRaw) ? intval($sortRaw) : 0;
$out[] = [
'code' => $code,
'name' => $name,
'sort' => $sortVal,
];
}
usort($out, static function (array $a, array $b): int {
if ($a['sort'] !== $b['sort']) {
return $a['sort'] <=> $b['sort'];
}
return strcmp($a['code'], $b['code']);
});
return $out;
}
/**
* @param array<string, string> $meta
*/
private static function pickLangName(array $meta, string $lang): string
{
$name = isset($meta['name']) && is_string($meta['name']) ? $meta['name'] : '';
$nameEn = isset($meta['name_en']) && is_string($meta['name_en']) ? $meta['name_en'] : '';
$normalized = strtolower(str_replace('_', '-', trim($lang)));
$isEn = $normalized === 'en' || str_starts_with($normalized, 'en-');
if ($isEn) {
return $nameEn !== '' ? $nameEn : $name;
}
return $name !== '' ? $name : $nameEn;
}
/**
* @param list<array{code: string, sort: int, status: int, tier_ids: list<string>}> $effectiveRows
*/
public static function assertChannelAllowsTier(string $channelCode, string $tierId, array $effectiveRows): bool
{
$row = self::findMergedByCode($effectiveRows, $channelCode);
if ($row === null) {
return false;
}
if (($row['status'] ?? 0) !== 1) {
return false;
}
return self::isTierAllowed($row, $tierId);
}
/**
* @param list<array{code: string, sort: int, status: int, tier_ids: list<string>}> $effectiveRows
*
* @return list<string>
*/
public static function enabledPayChannelCodes(array $effectiveRows): array
{
$registry = self::codeRegistry();
$codes = [];
foreach ($effectiveRows as $row) {
if (($row['status'] ?? 0) !== 1) {
continue;
}
$c = isset($row['code']) && is_string($row['code']) ? $row['code'] : '';
if ($c !== '' && isset($registry[$c])) {
$codes[] = $c;
}
}
return array_values(array_unique($codes));
}
/**
* @param list<array<string, mixed>> $items
*
* @return list<array{code: string, sort: int, status: int, tier_ids: list<string>}>
*/
public static function prepareOverridesForSave(array $items): array
{
$registry = self::codeRegistry();
$seen = [];
$out = [];
foreach ($items as $idx => $row) {
if (!is_array($row)) {
throw new InvalidArgumentException('第 ' . ($idx + 1) . ' 行格式错误');
}
$code = isset($row['code']) && is_string($row['code']) ? strtolower(trim($row['code'])) : '';
if ($code === '' || !isset($registry[$code])) {
throw new InvalidArgumentException('第 ' . ($idx + 1) . ' 行渠道 code 未注册');
}
if (isset($seen[$code])) {
throw new InvalidArgumentException('渠道 code 重复:' . $code);
}
$seen[$code] = true;
$norm = self::normalizeOverrides([array_merge($row, ['code' => $code])]);
if ($norm === []) {
throw new InvalidArgumentException('第 ' . ($idx + 1) . ' 行渠道数据无效');
}
$out[] = $norm[0];
}
usort($out, static function (array $a, array $b): int {
if ($a['sort'] !== $b['sort']) {
return $a['sort'] <=> $b['sort'];
}
return strcmp($a['code'], $b['code']);
});
return $out;
}
/**
* @param list<array{code: string, sort: int, status: int, tier_ids: list<string>}> $items
*/
public static function encodeForDb(array $items): string
{
$wrapped = ['channels' => $items];
$encoded = json_encode($wrapped, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($encoded === false) {
throw new InvalidArgumentException('JSON 编码失败');
}
return $encoded;
}
/**
* 后台编辑用:为注册表内每个 code 补齐一行(合并库内覆盖)
*
* @param list<array{code: string, sort: int, status: int, tier_ids: list<string>}> $storedOverrides
*
* @return list<array{code: string, sort: int, status: int, tier_ids: list<string>}>
*/
public static function expandRowsForAdmin(array $storedOverrides): array
{
$registry = self::codeRegistry();
$effective = self::effectiveOverrides($storedOverrides);
$byCode = [];
foreach ($effective as $r) {
$byCode[$r['code']] = $r;
}
$items = [];
foreach ($registry as $code => $meta) {
$sortMeta = $meta['sort'] ?? 10;
$sortDefault = is_numeric($sortMeta) ? intval($sortMeta) : 10;
$items[] = $byCode[$code] ?? [
'code' => $code,
'sort' => $sortDefault,
'status' => 1,
'tier_ids' => [],
];
}
usort($items, static function (array $a, array $b): int {
if ($a['sort'] !== $b['sort']) {
return $a['sort'] <=> $b['sort'];
}
return strcmp($a['code'], $b['code']);
});
return $items;
}
}

View File

@@ -10,11 +10,13 @@ use InvalidArgumentException;
* 充值档位game_config.deposit_tier仅存 JSON 数组
*
* 每一项字段mock/第三方支付模式,已不再保存收款账户信息;支持中英文双语):
* - id : string档位稳定 ID(如 t_xxxxxxxx
* - id : string档位稳定唯一标识(如 t_xxxxxxxx,移动端下单 tier_id / tier_key
* - title : string档位中文名称必填前端中文环境展示
* - title_en : string档位英文名称可选前端英文环境展示为空时回退到 title
* - amount : string充值金额4 位小数
* - bonus_amount : string赠送金额4 位小数,可为 0
* - currency : string支付货币代码38 位大写字母,如 MYR、CNY
* - pay_amount : string玩家支付的法币/支付货币额度4 位小数)
* - amount : string到账基础平台币4 位小数)
* - bonus_amount : string赠送平台币4 位小数,可为 0
* - desc : string档位中文描述可空<=255
* - desc_en : string档位英文描述可空<=255为空时回退到 desc
* - sort : int排序权重小值在前
@@ -33,6 +35,8 @@ final class DepositTier
* id: string,
* title: string,
* title_en: string,
* currency: string,
* pay_amount: string,
* amount: string,
* bonus_amount: string,
* desc: string,
@@ -83,8 +87,18 @@ final class DepositTier
}
$titleEn = self::stringField($row, 'title_en');
$currency = self::normalizeCurrency($row['currency'] ?? '');
if ($currency === '') {
$currency = 'CNY';
}
$payAmount = self::normalizeAmount($row['pay_amount'] ?? '');
$amount = self::normalizeAmount($row['amount'] ?? '');
$bonus = self::normalizeAmount($row['bonus_amount'] ?? '0');
if (bccomp($payAmount, '0', 4) <= 0 && bccomp($amount, '0', 4) > 0) {
// 历史数据仅有 amount平台币用占位同步 pay_amount运营应在后台改为真实支付额度
$payAmount = $amount;
}
$desc = self::stringField($row, 'desc');
if ($desc === '') {
@@ -100,6 +114,8 @@ final class DepositTier
'id' => $id,
'title' => $title,
'title_en' => $titleEn,
'currency' => $currency,
'pay_amount' => $payAmount,
'amount' => $amount,
'bonus_amount' => $bonus,
'desc' => $desc,
@@ -165,9 +181,19 @@ final class DepositTier
throw new InvalidArgumentException('第 ' . $no . ' 行英文充值名称过长');
}
$currency = self::normalizeCurrency($row['currency'] ?? '');
if ($currency === '') {
throw new InvalidArgumentException('第 ' . $no . ' 行支付货币不能为空');
}
$payAmount = self::normalizeAmount($row['pay_amount'] ?? '');
if (bccomp($payAmount, '0', 4) <= 0) {
throw new InvalidArgumentException('第 ' . $no . ' 行支付货币额度必须大于 0');
}
$amount = self::normalizeAmount($row['amount'] ?? '');
if (bccomp($amount, '0', 4) <= 0) {
throw new InvalidArgumentException('第 ' . $no . ' 行充值金额必须大于 0');
throw new InvalidArgumentException('第 ' . $no . ' 行基础平台币到账必须大于 0');
}
$bonus = self::normalizeAmount($row['bonus_amount'] ?? '0');
@@ -193,6 +219,8 @@ final class DepositTier
'id' => $id,
'title' => $title,
'title_en' => $titleEn,
'currency' => $currency,
'pay_amount' => $payAmount,
'amount' => $amount,
'bonus_amount' => $bonus,
'desc' => $desc,
@@ -340,6 +368,26 @@ final class DepositTier
return is_string($v) ? trim($v) : '';
}
/**
* 支付货币代码38 位字母,统一大写
*
* @param mixed $raw
*/
private static function normalizeCurrency($raw): string
{
if (!is_string($raw)) {
return '';
}
$s = strtoupper(trim($raw));
if ($s === '') {
return '';
}
if (!preg_match('/^[A-Z]{3,8}$/', $s)) {
return '';
}
return $s;
}
private static function isEnglishLang(string $lang): bool
{
$normalized = strtolower(str_replace('_', '-', trim($lang)));

View File

@@ -0,0 +1,392 @@
<?php
declare(strict_types=1);
namespace app\common\library\game;
use InvalidArgumentException;
/**
* 移动端支付/收款展示game_config.finance_cashier含 channels 充值渠道)
*
* 货币列表每行含:
* - deposit_coins_per_fiat充值参考每支付 1 单位该货币到账的平台币数量
* - withdraw_coins_per_fiat提现换算每兑换 1 单位该货币所需平台币数量
*/
final class FinanceCashierConfig
{
public const CONFIG_KEY = 'finance_cashier';
/**
* @return array<string, mixed>
*/
public static function defaultPayload(): array
{
return [
'platform_coin' => [
'label_zh' => '钻石',
'label_en' => 'Diamonds',
],
'currencies' => [
[
'code' => 'MYR',
'label_zh' => '马来西亚林吉特',
'label_en' => 'Malaysian Ringgit',
'sort' => 10,
'deposit_coins_per_fiat' => '100',
'withdraw_coins_per_fiat' => '100',
],
[
'code' => 'VND',
'label_zh' => '越南盾',
'label_en' => 'Vietnamese Dong',
'sort' => 20,
'deposit_coins_per_fiat' => '10',
'withdraw_coins_per_fiat' => '10',
],
[
'code' => 'USDT',
'label_zh' => 'USDT',
'label_en' => 'USDT',
'sort' => 30,
'deposit_coins_per_fiat' => '1',
'withdraw_coins_per_fiat' => '1',
],
],
'withdraw_banks' => [
['code' => 'agrobank', 'name_zh' => 'Agrobank', 'name_en' => 'Agrobank', 'sort' => 10],
],
'withdraw_limits' => [
'min_ewallet' => '10',
'min_bank' => '10',
],
'withdraw_copy' => [
'rate_hint_zh' => '汇率为参考价格,实际以提现时为准。',
'rate_hint_en' => 'Exchange rates are for reference only; the actual rate at withdrawal time prevails.',
'processing_zh' => '9秒即可到账',
'processing_en' => 'Arrives in seconds',
'fee_note_zh' => '注意RM 10 — RM 99.99 之间的交易将收取最低 RM 1 的提现手续费。',
'fee_note_en' => 'A minimum RM 1 handling fee may apply for withdrawals between RM 10 and RM 99.99.',
'rate_mode' => 'fixed',
],
'withdraw_fields' => [
'require_cardholder' => true,
'require_bank_account' => true,
'require_email' => true,
'require_mobile' => true,
],
'channels' => [],
];
}
/**
* @return array<string, mixed>
*/
public static function parseFromConfigValue(mixed $raw): array
{
$out = self::defaultPayload();
if (!is_string($raw) || trim($raw) === '') {
return self::normalizePayload($out, []);
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return self::normalizePayload($out, []);
}
if (isset($decoded['platform_coin']) && is_array($decoded['platform_coin'])) {
$out['platform_coin'] = array_replace($out['platform_coin'], array_intersect_key($decoded['platform_coin'], $out['platform_coin']));
}
$legacyRates = [];
if (isset($decoded['rates']) && is_array($decoded['rates'])) {
$legacyRates = array_values($decoded['rates']);
}
foreach (['currencies', 'withdraw_banks', 'channels'] as $listKey) {
if (isset($decoded[$listKey]) && is_array($decoded[$listKey])) {
$out[$listKey] = array_values($decoded[$listKey]);
}
}
if (isset($decoded['withdraw_limits']) && is_array($decoded['withdraw_limits'])) {
$out['withdraw_limits'] = array_replace($out['withdraw_limits'], array_intersect_key($decoded['withdraw_limits'], $out['withdraw_limits']));
}
if (isset($decoded['withdraw_copy']) && is_array($decoded['withdraw_copy'])) {
$out['withdraw_copy'] = array_replace($out['withdraw_copy'], array_intersect_key($decoded['withdraw_copy'], $out['withdraw_copy']));
}
if (isset($decoded['withdraw_fields']) && is_array($decoded['withdraw_fields'])) {
$out['withdraw_fields'] = array_replace($out['withdraw_fields'], array_intersect_key($decoded['withdraw_fields'], $out['withdraw_fields']));
}
return self::normalizePayload($out, $legacyRates);
}
public static function encodeForDb(array $payload): string
{
$legacyRates = [];
if (isset($payload['rates']) && is_array($payload['rates'])) {
$legacyRates = array_values($payload['rates']);
}
$normalized = self::normalizePayload($payload, $legacyRates);
$normalized['channels'] = DepositChannel::prepareOverridesForSave(
DepositChannel::expandRowsForAdmin($normalized['channels'] ?? [])
);
self::validate($normalized);
return json_encode($normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
/**
* @param array<string, mixed> $p
* @param list<array<string, mixed>> $legacyRates
*
* @return array<string, mixed>
*/
private static function normalizePayload(array $p, array $legacyRates): array
{
$defaults = self::defaultPayload();
$out = array_replace($defaults, $p);
unset($out['rates'], $out['fx_pairs']);
$withdrawFromLegacyRates = [];
foreach ($legacyRates as $r) {
if (!is_array($r)) {
continue;
}
$cur = isset($r['currency']) && is_string($r['currency']) ? strtoupper(trim($r['currency'])) : '';
$ratio = self::ratioStringFromRow($r, 'diamonds_per_fiat_unit');
if ($cur !== '' && $ratio !== '') {
$withdrawFromLegacyRates[$cur] = $ratio;
}
}
if (isset($out['platform_coin']) && is_array($out['platform_coin'])) {
$out['platform_coin'] = array_replace($defaults['platform_coin'], $out['platform_coin']);
}
if (isset($out['currencies']) && is_array($out['currencies'])) {
foreach ($out['currencies'] as $i => $row) {
if (!is_array($row)) {
unset($out['currencies'][$i]);
continue;
}
$code = isset($row['code']) && is_string($row['code']) ? strtoupper(trim($row['code'])) : '';
$dep = self::ratioStringFromRow($row, 'deposit_coins_per_fiat');
$wdr = self::ratioStringFromRow($row, 'withdraw_coins_per_fiat');
if ($wdr === '') {
$wdr = self::ratioStringFromRow($row, 'diamonds_per_fiat_unit');
}
if ($wdr === '' && $code !== '' && isset($withdrawFromLegacyRates[$code])) {
$wdr = $withdrawFromLegacyRates[$code];
}
if ($dep === '' && $wdr !== '') {
$dep = $wdr;
}
if ($wdr === '' && $dep !== '') {
$wdr = $dep;
}
if ($code !== '' && $dep === '' && $wdr === '') {
$dep = '100';
$wdr = '100';
}
$out['currencies'][$i] = [
'code' => $code,
'label_zh' => isset($row['label_zh']) && is_string($row['label_zh']) ? trim($row['label_zh']) : '',
'label_en' => isset($row['label_en']) && is_string($row['label_en']) ? trim($row['label_en']) : '',
'sort' => self::normalizeSort($row['sort'] ?? 0),
'deposit_coins_per_fiat' => $dep,
'withdraw_coins_per_fiat' => $wdr,
];
}
$out['currencies'] = array_values(array_filter($out['currencies'], static fn ($r) => is_array($r) && $r['code'] !== ''));
}
usort($out['currencies'], static function (array $a, array $b): int {
$sa = $a['sort'] ?? 0;
$sb = $b['sort'] ?? 0;
if ($sa !== $sb) {
return $sa <=> $sb;
}
$ca = $a['code'] ?? '';
$cb = $b['code'] ?? '';
return strcmp($ca, $cb);
});
if (isset($out['withdraw_banks']) && is_array($out['withdraw_banks'])) {
foreach ($out['withdraw_banks'] as $i => $row) {
if (!is_array($row)) {
unset($out['withdraw_banks'][$i]);
continue;
}
$code = isset($row['code']) && is_string($row['code']) ? strtolower(trim($row['code'])) : '';
$out['withdraw_banks'][$i] = [
'code' => $code,
'name_zh' => isset($row['name_zh']) && is_string($row['name_zh']) ? trim($row['name_zh']) : '',
'name_en' => isset($row['name_en']) && is_string($row['name_en']) ? trim($row['name_en']) : '',
'sort' => self::normalizeSort($row['sort'] ?? 0),
];
}
$out['withdraw_banks'] = array_values(array_filter($out['withdraw_banks'], static fn ($r) => is_array($r) && $r['code'] !== ''));
usort($out['withdraw_banks'], static function (array $a, array $b): int {
$sa = $a['sort'] ?? 0;
$sb = $b['sort'] ?? 0;
if ($sa !== $sb) {
return $sa <=> $sb;
}
$ca = $a['code'] ?? '';
$cb = $b['code'] ?? '';
return strcmp($ca, $cb);
});
}
if (isset($out['withdraw_limits']) && is_array($out['withdraw_limits'])) {
$wl = array_replace($defaults['withdraw_limits'], $out['withdraw_limits']);
foreach (['min_ewallet', 'min_bank'] as $k) {
if (isset($wl[$k]) && is_numeric($wl[$k]) && !is_string($wl[$k])) {
$wl[$k] = strval($wl[$k]);
}
if (!isset($wl[$k]) || !is_string($wl[$k])) {
$wl[$k] = $defaults['withdraw_limits'][$k];
}
}
$out['withdraw_limits'] = $wl;
}
if (isset($out['withdraw_copy']) && is_array($out['withdraw_copy'])) {
$out['withdraw_copy'] = array_replace($defaults['withdraw_copy'], array_intersect_key($out['withdraw_copy'], $defaults['withdraw_copy']));
$mode = isset($out['withdraw_copy']['rate_mode']) && is_string($out['withdraw_copy']['rate_mode'])
? strtolower(trim($out['withdraw_copy']['rate_mode']))
: 'fixed';
if (!in_array($mode, ['fixed', 'live'], true)) {
$mode = 'fixed';
}
$out['withdraw_copy']['rate_mode'] = $mode;
}
if (isset($out['withdraw_fields']) && is_array($out['withdraw_fields'])) {
$wf = array_replace($defaults['withdraw_fields'], array_intersect_key($out['withdraw_fields'], $defaults['withdraw_fields']));
foreach (array_keys($defaults['withdraw_fields']) as $fk) {
$wf[$fk] = !empty($wf[$fk]);
}
$out['withdraw_fields'] = $wf;
}
if (isset($out['channels']) && is_array($out['channels'])) {
$out['channels'] = DepositChannel::normalizeOverrides(array_values($out['channels']));
} else {
$out['channels'] = [];
}
usort($out['channels'], static function (array $a, array $b): int {
$sa = $a['sort'] ?? 0;
$sb = $b['sort'] ?? 0;
if ($sa !== $sb) {
return $sa <=> $sb;
}
return strcmp($a['code'] ?? '', $b['code'] ?? '');
});
unset($out['fx_pairs']);
return $out;
}
/**
* @param array<string, mixed> $row
*/
private static function ratioStringFromRow(array $row, string $key): string
{
if (!isset($row[$key])) {
return '';
}
if (is_string($row[$key])) {
return trim($row[$key]);
}
if (is_numeric($row[$key])) {
return strval($row[$key]);
}
return '';
}
private static function normalizeSort(mixed $v): int
{
$opt = filter_var($v, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 99999]]);
if ($opt !== false) {
return $opt;
}
if (is_numeric($v)) {
$f = filter_var($v, FILTER_VALIDATE_FLOAT);
if ($f !== false) {
$rounded = round($f);
$opt2 = filter_var($rounded, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 99999]]);
if ($opt2 !== false) {
return $opt2;
}
}
}
return 0;
}
/**
* @param array<string, mixed> $p
*/
private static function validate(array $p): void
{
if (!isset($p['platform_coin']) || !is_array($p['platform_coin'])) {
throw new InvalidArgumentException('platform_coin 格式错误');
}
$lz = $p['platform_coin']['label_zh'] ?? '';
$le = $p['platform_coin']['label_en'] ?? '';
if (!is_string($lz) || trim($lz) === '' || !is_string($le) || trim($le) === '') {
throw new InvalidArgumentException('请填写平台币中英文名称');
}
if (!isset($p['currencies']) || !is_array($p['currencies']) || $p['currencies'] === []) {
throw new InvalidArgumentException('至少保留一条货币');
}
$seenCodes = [];
foreach ($p['currencies'] as $idx => $row) {
if (!is_array($row)) {
throw new InvalidArgumentException('货币列表第 ' . ($idx + 1) . ' 行格式错误');
}
$code = $row['code'] ?? '';
if (!is_string($code) || !preg_match('/^[A-Z0-9]{2,12}$/', $code)) {
throw new InvalidArgumentException('货币代码非法:' . (is_string($code) ? $code : ''));
}
if (isset($seenCodes[$code])) {
throw new InvalidArgumentException('货币代码不能重复:' . $code);
}
$seenCodes[$code] = true;
$dep = $row['deposit_coins_per_fiat'] ?? '';
$wdr = $row['withdraw_coins_per_fiat'] ?? '';
if (!is_string($dep) || $dep === '' || !is_numeric($dep) || bccomp($dep, '0', 8) <= 0) {
throw new InvalidArgumentException('第 ' . ($idx + 1) . ' 行:充值汇率须为大于 0 的数字');
}
if (!is_string($wdr) || $wdr === '' || !is_numeric($wdr) || bccomp($wdr, '0', 8) <= 0) {
throw new InvalidArgumentException('第 ' . ($idx + 1) . ' 行:提现汇率须为大于 0 的数字');
}
}
if (isset($p['withdraw_banks']) && is_array($p['withdraw_banks'])) {
$seen = [];
foreach ($p['withdraw_banks'] as $idx => $row) {
if (!is_array($row)) {
throw new InvalidArgumentException('银行第 ' . ($idx + 1) . ' 行格式错误');
}
$code = $row['code'] ?? '';
if (!is_string($code) || !preg_match('/^[a-z0-9][a-z0-9_\-]{0,31}$/', $code)) {
throw new InvalidArgumentException('银行代码非法');
}
if (isset($seen[$code])) {
throw new InvalidArgumentException('银行代码重复:' . $code);
}
$seen[$code] = true;
}
}
if (isset($p['withdraw_limits']) && is_array($p['withdraw_limits'])) {
foreach (['min_ewallet', 'min_bank'] as $k) {
$v = $p['withdraw_limits'][$k] ?? '0';
if (!is_string($v) || !is_numeric($v) || bccomp($v, '0', 4) < 0) {
throw new InvalidArgumentException('提现最低限额须为不小于 0 的数字');
}
}
}
$reg = DepositChannel::codeRegistry();
if ($reg !== [] && (!isset($p['channels']) || !is_array($p['channels']) || $p['channels'] === [])) {
throw new InvalidArgumentException('请配置充值渠道');
}
}
}

View File

@@ -139,6 +139,7 @@ Route::add(['GET', 'POST'], '/api/finance/depositTierList', [\app\api\controller
Route::post('/api/finance/depositCreate', [\app\api\controller\Finance::class, 'depositCreate']);
Route::add(['GET', 'POST'], '/api/finance/depositDetail', [\app\api\controller\Finance::class, 'depositDetail']);
Route::add(['GET', 'POST'], '/api/finance/depositList', [\app\api\controller\Finance::class, 'depositList']);
Route::add(['GET', 'POST'], '/api/finance/cashierConfig', [\app\api\controller\Finance::class, 'cashierConfig']);
Route::post('/api/finance/withdrawCreate', [\app\api\controller\Finance::class, 'withdrawCreate']);
Route::add(['GET', 'POST'], '/api/finance/withdrawDetail', [\app\api\controller\Finance::class, 'withdrawDetail']);
Route::add(['GET', 'POST'], '/api/finance/withdrawList', [\app\api\controller\Finance::class, 'withdrawList']);

View File

@@ -269,6 +269,16 @@
| 提现 | 有效投注 ≥ 总充值×配置倍数;最低提现与 0.5% 手续费;大额 / Jackpot 进审核 |
| 灾难恢复 | 模拟期卡在「计算中」,启动退本流程:期作废、本金退回账本 |
#### 8.2.1 后台「游戏实时对局」:运行开关与作废
| 项 | 说明 |
|----|------|
| 配置键 | `game_config.config_key = game_live_runtime_enabled``1` = 运行(默认),`0` = 维护/关闭 |
| 关闭时服务端行为 | **禁止下注**(移动端 **`betPlace`** 返回 `3001`**无进行中局时**定时 **`GamePeriodAutoTicker`** 不创建新期;派彩宽限期结束后的 **`createNextRecordAfterDraw`** 不插入下一期。**当局仍继续****`tickAutoDraw` / `drawResult` / 结算**照常执行,直至本期结束。后台 **`POST /admin/game.Live/runtime``enabled=1`** 时,若 **`hasActiveRecord()` 为假**,则 **`bootstrapPeriodWhenRuntimeEnabled`** 立即插入新一期 |
| 后台快照 `maintenance_ui` | `!game_live_runtime_enabled && !hasActiveRecord()` 时为 `true`:表示**当局已完全结束**(含派彩),此时后台页展示「维护中」并锁定操作区(仅顶部「游戏运行」开关可用);当局尚在 03 状态时为 `false`,倒计时与操作区保持可用直至收尾完成 |
| 作废本局(后台按钮) | 仅当本期为 **下注开放 / 已封盘**`game_record.status``0``1`):将所有 **待开奖** 注单(`bet_order.status = 1`)按 `total_amount` 退回余额并置 `bet_order.status = 3`,写钱包流水 `biz_type = bet_void`;本期 **`game_record.status = 5`**,写入 **`void_reason`**;并 **强制将 `game_live_runtime_enabled` 置为 `0`**。需管理员在后台再次打开「游戏运行」开关后,才恢复下注与后续期次创建 |
| 后台接口 | `POST /admin/game.Live/runtime`body`enabled` 0/1`POST /admin/game.Live/voidPeriod`body`record_id` 可选、`void_reason` 必填) |
### 8.3 代理与结算(逻辑就绪后)
1. 构造多代理树与多用户下注,跑一期结算脚本或单元测试纯算法。

View File

@@ -222,9 +222,10 @@
返回参数:
- `server_time`int含义服务端当前时间用于客户端校时
- `runtime_enabled`bool含义**游戏运行开关**`false` 时表示后台维护——**禁止下注**,且 idle 时不会自动创建新期、派彩结束后也不会自动创建下一期;**当前已开盘的局仍会开奖、派彩并结算**。移动端应禁用下注入口并提示「维护」类文案)
- `period`object
- `period_no`string含义当前全局期号
- `status`string`betting`/`locked`/`settling`/`finished`,含义:当前期状态
- `status`string`betting`/`locked`/`settling`/`finished`/`void`,含义:当前期状态;`void` 表示该期已作废
- `countdown`int含义当前期倒计时秒数
- `lock_at`int含义封盘时间戳
- `open_at`int含义预计开奖时间戳
@@ -266,9 +267,10 @@
- **POST** `/api/game/periodCurrent`
返回参数:
- `runtime_enabled`bool含义`lobbyInit.runtime_enabled`
- `period_id`int含义当前期主键 ID
- `period_no`string含义当前期号
- `status`string含义当前期状态
- `status`string含义当前期状态,含 `void` 已作废
- `countdown`int含义当前期剩余秒数
- `bet_close_in`int含义距离封盘剩余秒数
- `result_number`int/null未开奖为 null含义开奖号码
@@ -293,6 +295,7 @@
**可能错误码(补充)**(其余见文档头部错误码分段;扣款与缓存一致性强相关):
- `3001`:游戏已暂停(`runtime_enabled=false`,后台「游戏实时对局」关闭运行开关或作废本局后未重新开启;与非法流程类错误同段)
- `5000`:系统繁忙;或 **用户 Redis 互斥锁**未获取(与后台钱包/并发写同一用户串行,文案与后台一致:「该用户正在被其他管理员操作(钱包/并发保存),请稍后再试」);或 **`coin` 条件更新**未命中(并发下注/派彩/后台已改余额:「扣款失败:该用户余额已被其他请求修改(如下注、派彩或其他管理员已保存),请刷新后重试」)。
> 说明:一键重复上一注、自动托管开启/停止均由前端控制,客户端在相应时机调用 `/api/game/betPlace` 即可完成,不再提供独立接口。
@@ -359,6 +362,7 @@
- `bet`:下注扣款(提交下注时从玩家余额扣除的投注金额)
- `payout`:开奖派彩(中奖后系统将奖金入账到玩家余额)
- `adjust`:人工调整(后台管理员加/扣点,对应 `biz_type=admin_credit/admin_deduct`
- `bet_void`:期次作废退款(后台「游戏实时对局」作废本局时,退回待开奖注单本金)
返回参数:
- `list`array<object>

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>