35 Commits

Author SHA1 Message Date
15fdd3ba57 创建模型验证器,验证数据正确性 2026-04-16 14:52:13 +08:00
7aaa66d2dc [游戏管理]用户管理-修复500报错 2026-04-16 14:32:56 +08:00
a9a66063b4 [运营]用户阅读记录 2026-04-16 14:32:40 +08:00
6c491bac81 [运营]公告 2026-04-16 14:32:35 +08:00
54f460a242 [渠道管理]-优化样式,新增手动结算 2026-04-16 13:39:08 +08:00
5bf948e309 优化样式 2026-04-16 11:17:01 +08:00
fb16321b7e 执行文档 2026-04-15 18:06:15 +08:00
7e695d32cf [代理与结算]代理结算周期 2026-04-15 18:06:04 +08:00
080ead200b [代理与结算]代理佣金记录 2026-04-15 18:05:56 +08:00
0b0c0ee04a [游戏管理]游戏配置 2026-04-15 17:46:34 +08:00
518642ddb0 [游戏管理]36字花字典-来自游戏配置扩展 2026-04-15 17:46:30 +08:00
569f9e7749 [游戏管理]玩家钱包流水 2026-04-15 17:46:26 +08:00
f88afbcadb [订单管理]提现订单 2026-04-15 17:46:22 +08:00
92545067d2 [游戏管理]压注订单 2026-04-15 17:46:18 +08:00
6bca594769 [订单管理]充值订单 2026-04-15 17:46:14 +08:00
56df105af6 [游戏管理]用户管理-优化表单样式 2026-04-15 17:46:04 +08:00
c213b2d9e9 [渠道管理] 2026-04-15 17:46:00 +08:00
6573f59531 [游戏管理]游戏对局-创建游戏对局service 2026-04-15 14:21:59 +08:00
e2097450eb [游戏管理]游戏配置 2026-04-15 14:21:27 +08:00
b6e5f09479 [游戏管理]36字花字典-来自游戏配置扩展 2026-04-15 14:21:21 +08:00
c9f0154174 [游戏管理]用户管理-优化表单样式 2026-04-15 13:50:39 +08:00
a0bcc73503 [游戏管理]压注订单 2026-04-15 13:44:55 +08:00
14b9920667 [游戏管理]游戏对局 2026-04-15 13:44:50 +08:00
ba80e7c392 [游戏管理]玩家钱包流水 2026-04-15 11:50:14 +08:00
9d06c7a226 [游戏管理]用户管理-优化 2026-04-15 11:27:14 +08:00
c01e6430db [会员管理]-移除 2026-04-15 10:22:33 +08:00
979751f719 [权限管理]角色组管理 2026-04-15 10:19:45 +08:00
8cc28096c4 [渠道管理] 2026-04-15 10:19:41 +08:00
7b002c9410 [游戏管理]用户管理 2026-04-15 10:19:30 +08:00
66c14eacb3 数据库原版文件 2026-04-15 10:03:54 +08:00
36c344731e 环境配置文件 2026-04-15 10:03:28 +08:00
6af87681b7 说明文档 2026-04-15 10:03:21 +08:00
a410098b17 配置文件修改 2026-04-14 13:57:02 +08:00
8a1287d8ed 1.修复矿建鉴权报错
2.优化登录跳转接口
3.优化登录跳转接口
4.修复CURD生成代码模块表不加前缀访问返回404问题
5.系统级报错***优化报错Fatal error: Type of app\common\library\token\TokenExpirationException::$message
2026-04-13 16:48:37 +08:00
2d14527da8 MySQL数据 2026-04-01 17:12:50 +08:00
224 changed files with 12233 additions and 12634 deletions

View File

@@ -7,12 +7,17 @@ APP_DEFAULT_TIMEZONE = Asia/Shanghai
# 语言
LANG_DEFAULT_LANG = zh-cn
# Database
# 数据库config/thinkorm.php/database.php
DATABASE_DRIVER = mysql
DATABASE_TYPE = mysql
DATABASE_HOSTNAME = 127.0.0.1
DATABASE_DATABASE = webman-buildadmin-dafuweng
DATABASE_USERNAME = webman-buildadmin-dafuweng
DATABASE_PASSWORD = 6dzMaX32Xdsc4DjS
DATABASE_DATABASE = buildadmin-webman-game-number
DATABASE_USERNAME = buildadmin-webman-game-number
DATABASE_PASSWORD = 123456
DATABASE_HOSTPORT = 3306
DATABASE_CHARSET = utf8mb4
DATABASE_PREFIX =
DATABASE_PREFIX =
# 缓存config/cache.php
CACHE_DRIVER = file

View File

@@ -0,0 +1,839 @@
<?php
namespace app\admin\controller;
use Throwable;
use app\common\controller\Backend;
use support\think\Db;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 渠道管理
*/
class Channel extends Backend
{
/**
* 预览接口与手动结算共用「手动结算」按钮权限(避免额外菜单节点)
*/
protected array $noNeedPermission = ['manualSettlePreview'];
/**
* Channel模型对象
* @var object|null
* @phpstan-var \app\common\model\Channel|null
*/
protected ?object $model = null;
protected array|string $preExcludeFields = ['id', 'user_count', 'profit_amount', 'create_time', 'update_time'];
protected array $withJoinTable = ['adminGroup', 'admin'];
protected string|array $quickSearchField = ['id', 'code', 'name'];
protected bool $modelSceneValidate = true;
private array $currentChannelIds = [];
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\Channel();
$this->currentChannelIds = $this->getCurrentChannelIds();
return null;
}
/**
* 渠道-管理员树(父级=渠道,子级=管理员,仅可选择子级)
*/
public function adminTree(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$query = Db::name('channel')
->field(['id', 'name', 'admin_group_id'])
->order('id', 'asc');
if (!$this->auth->isSuperAdmin()) {
$query = $query->where('id', 'in', $this->currentChannelIds ?: [0]);
}
$channels = $query->select()->toArray();
$groupChildrenCache = [];
$getGroupChildren = function ($groupId) use (&$getGroupChildren, &$groupChildrenCache) {
if ($groupId === null || $groupId === '') return [];
if (array_key_exists($groupId, $groupChildrenCache)) return $groupChildrenCache[$groupId];
$children = Db::name('admin_group')
->where('pid', $groupId)
->where('status', 1)
->column('id');
$all = [];
foreach ($children as $cid) {
$all[] = $cid;
foreach ($getGroupChildren($cid) as $cc) {
$all[] = $cc;
}
}
$groupChildrenCache[$groupId] = $all;
return $all;
};
$tree = [];
foreach ($channels as $ch) {
$groupId = $ch['admin_group_id'] ?? null;
$groupIds = [];
if ($groupId !== null && $groupId !== '') {
$groupIds[] = $groupId;
foreach ($getGroupChildren($groupId) as $gid) {
$groupIds[] = $gid;
}
}
$adminIds = [];
if ($groupIds) {
$adminIds = Db::name('admin_group_access')
->where('group_id', 'in', array_unique($groupIds))
->column('uid');
}
$adminIds = array_values(array_unique($adminIds));
$admins = [];
if ($adminIds) {
$admins = Db::name('admin')
->field(['id', 'username'])
->where('id', 'in', $adminIds)
->order('id', 'asc')
->select()
->toArray();
}
$children = [];
foreach ($admins as $a) {
$children[] = [
'value' => (string) $a['id'],
'label' => $a['username'],
'channel_id' => $ch['id'],
'is_leaf' => true,
];
}
$tree[] = [
'value' => 'channel_' . $ch['id'],
'label' => $ch['name'],
'disabled' => true,
'children' => $children,
];
}
return $this->success('', [
'list' => $tree,
]);
}
/**
* 添加重写管理员只选顶级组admin_group_id 后端自动写入)
* @throws Throwable
*/
protected function _add(): Response
{
if ($this->request && $this->request->method() === 'POST') {
$data = $this->request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->applyInputFilter($data);
$data = $this->excludeFields($data);
$data = $this->normalizeAgentModeFields($data);
$bizErr = $this->validateAndNormalizeBusinessFields($data);
if ($bizErr !== null) {
return $this->error($bizErr);
}
unset($data['invite_code']);
$adminId = $data['admin_id'] ?? null;
if ($adminId === null || $adminId === '') {
return $this->error(__('Parameter %s can not be empty', ['admin_id']));
}
if (array_key_exists('admin_group_id', $data)) {
unset($data['admin_group_id']);
}
$topGroupId = Db::name('admin_group_access')
->alias('aga')
->join('admin_group ag', 'aga.group_id = ag.id')
->where('aga.uid', $adminId)
->where('ag.pid', 0)
->value('ag.id');
if ($topGroupId === null || $topGroupId === '') {
return $this->error(__('Record not found'));
}
$data['admin_group_id'] = $topGroupId;
if (!$this->auth->isSuperAdmin()) {
$data['admin_id'] = $this->auth->id;
}
if ($this->dataLimit && $this->dataLimitFieldAutoFill) {
$data[$this->dataLimitField] = $this->auth->id;
}
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) {
$validate->scene('add');
}
$validate->check($data);
}
}
$result = $this->model->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($result !== false) {
return $this->success(__('Added successfully'));
}
return $this->error(__('No rows were added'));
}
return $this->error(__('Parameter error'));
}
/**
* 编辑重写管理员只选顶级组admin_group_id 后端自动写入)
* @throws Throwable
*/
protected function _edit(): Response
{
$pk = $this->model->getPk();
$id = $this->request ? ($this->request->post($pk) ?? $this->request->get($pk)) : null;
$row = $this->model->find($id);
if (!$row) {
return $this->error(__('Record not found'));
}
if (!$this->auth->isSuperAdmin() && !in_array($row['id'], $this->currentChannelIds, true)) {
return $this->error(__('You have no permission'));
}
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds && !in_array($row[$this->dataLimitField], $dataLimitAdminIds)) {
return $this->error(__('You have no permission'));
}
if ($this->request && $this->request->method() === 'POST') {
$data = $this->request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->applyInputFilter($data);
$data = $this->excludeFields($data);
$data = $this->normalizeAgentModeFields($data);
$bizErr = $this->validateAndNormalizeBusinessFields($data);
if ($bizErr !== null) {
return $this->error($bizErr);
}
unset($data['invite_code']);
if (array_key_exists('admin_group_id', $data)) {
unset($data['admin_group_id']);
}
$nextAdminId = array_key_exists('admin_id', $data) ? $data['admin_id'] : ($row['admin_id'] ?? null);
if ($nextAdminId !== null && $nextAdminId !== '') {
$topGroupId = Db::name('admin_group_access')
->alias('aga')
->join('admin_group ag', 'aga.group_id = ag.id')
->where('aga.uid', $nextAdminId)
->where('ag.pid', 0)
->value('ag.id');
if ($topGroupId === null || $topGroupId === '') {
return $this->error(__('Record not found'));
}
$data['admin_group_id'] = $topGroupId;
}
if (!$this->auth->isSuperAdmin()) {
$data['admin_id'] = $this->auth->id;
}
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) {
$validate->scene('edit');
}
$data[$pk] = $row[$pk];
$validate->check($data);
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($result !== false) {
return $this->success(__('Update successful'));
}
return $this->error(__('No rows updated'));
}
return $this->success('', [
'row' => $row
]);
}
/**
* 查看
* @throws Throwable
*/
protected function _index(): Response
{
if ($this->request && $this->request->get('select')) {
return $this->select($this->request);
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
if (!$this->auth->isSuperAdmin()) {
$where[] = [$alias['channel'] . '.id', 'in', $this->currentChannelIds ?: [0]];
}
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->visible(['adminGroup' => ['name'], 'admin' => ['username']])
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* 手动结算预览:区间=上次结算周期结束~当前时间;金额来自已结算注单汇总(服务端计算)
*/
public function manualSettlePreview(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->auth->check('channel/manualSettle')) {
return $this->error(__('You have no permission'));
}
$id = (int) ($request->get('id', 0));
if ($id <= 0) {
return $this->error(__('Parameter error'));
}
$row = $this->model->find($id);
if (!$row) {
return $this->error(__('Record not found'));
}
if (!$this->auth->isSuperAdmin() && !in_array((int) $row['id'], $this->currentChannelIds, true)) {
return $this->error(__('You have no permission'));
}
$payload = $this->buildManualSettlePayload($row->toArray());
if (is_string($payload)) {
return $this->error($payload);
}
return $this->success('', $payload);
}
/**
* 手动结算(渠道维度):仅接收备注;周期与金额全部由服务端按注单汇总计算,并写入结算周期与佣金记录
*/
public function manualSettle(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
$id = (int) ($request->post('id', $request->get('id', 0)));
if ($id <= 0) {
return $this->error(__('Parameter error'));
}
$row = $this->model->find($id);
if (!$row) {
return $this->error(__('Record not found'));
}
if (!$this->auth->isSuperAdmin() && !in_array((int) $row['id'], $this->currentChannelIds, true)) {
return $this->error(__('You have no permission'));
}
$remark = (string) $request->post('remark', '');
$payload = $this->buildManualSettlePayload($row->toArray());
if (is_string($payload)) {
return $this->error($payload);
}
$settlementNo = $payload['settlement_no'];
if (Db::name('agent_settlement_period')->where('settlement_no', $settlementNo)->value('id')) {
return $this->error('结算单号已存在,请稍后重试');
}
$adminId = $row['admin_id'] ?? null;
if ($adminId === null || $adminId === '' || (int) $adminId <= 0) {
return $this->error('渠道未绑定代理管理员,无法生成佣金记录');
}
$now = time();
Db::startTrans();
try {
$periodId = (int) Db::name('agent_settlement_period')->insertGetId([
'settlement_no' => $settlementNo,
'period_start_at' => $payload['period_start_ts'],
'period_end_at' => $payload['period_end_ts'],
'total_bet_amount' => $payload['total_bet_amount'],
'total_payout_amount' => $payload['total_payout_amount'],
'platform_profit_amount' => $payload['platform_profit_amount'],
'status' => 2,
'remark' => trim($remark) !== '' ? $remark : ('手动结算-渠道#' . $row['id'] . '-' . $row['name']),
'create_time' => $now,
'update_time' => $now,
]);
Db::name('agent_commission_record')->insert([
'settlement_period_id' => $periodId,
'channel_id' => (int) $row['id'],
'admin_id' => (int) $adminId,
'commission_rate' => $payload['commission_rate'],
'calc_base_amount' => $payload['calc_base_amount'],
'commission_amount' => $payload['commission_amount'],
'status' => 0,
'settled_at' => null,
'remark' => trim($remark) !== '' ? $remark : ('手动结算佣金-CH' . $row['id']),
'create_time' => $now,
'update_time' => $now,
]);
Db::name('channel')->where('id', $row['id'])->update([
'update_time' => $now,
]);
Db::commit();
} catch (Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
return $this->success('手动结算已完成,已生成结算周期与佣金记录');
}
/**
* @return array|string 成功返回预览数据数组,失败返回错误文案
*/
private function buildManualSettlePayload(array $row): array|string
{
$channelId = (int) ($row['id'] ?? 0);
if ($channelId <= 0) {
return '渠道数据异常';
}
$endTs = time();
$lastEnd = $this->getLastSettlementEndForChannel($channelId);
$channelCreateTs = (int) ($row['create_time'] ?? 0);
if ($lastEnd === null) {
$periodStartTs = $channelCreateTs > 0 ? $channelCreateTs : 0;
} else {
$periodStartTs = (int) $lastEnd;
}
if ($periodStartTs >= $endTs) {
return '结算区间无效(开始时间不早于当前)';
}
$stats = $this->aggregateBetOrderForChannel($channelId, $periodStartTs, $lastEnd !== null, $endTs);
$totalBet = $stats['total_bet'];
$totalPayout = $stats['total_payout'];
$profit = bcsub($totalBet, $totalPayout, 4);
$mode = (string) ($row['agent_mode'] ?? 'turnover');
$commission = $this->computeCommissionAmounts($row, $totalBet, $profit, $mode);
if (is_string($commission)) {
return $commission;
}
$settlementNo = $this->generateAgentSettlementNo('M', $channelId, $endTs);
return [
'settlement_no' => $settlementNo,
'period_start_ts' => $periodStartTs,
'period_end_ts' => $endTs,
'period_start_at' => date('Y-m-d H:i:s', $periodStartTs),
'period_end_at' => date('Y-m-d H:i:s', $endTs),
'total_bet_amount' => $totalBet,
'total_payout_amount' => $totalPayout,
'platform_profit_amount' => $profit,
'commission_rate' => $commission['commission_rate'],
'calc_base_amount' => $commission['calc_base_amount'],
'commission_amount' => $commission['commission_amount'],
'agent_mode' => $mode,
];
}
/**
* 生成代理结算周期单号:仅大写字母与数字、无分隔符;首字符 M=手动结算A=自动结算(定时任务等复用)
*/
private function generateAgentSettlementNo(string $sourceFlag, int $channelId, int $endTs): string
{
$flag = strtoupper(trim($sourceFlag));
if ($flag !== 'M' && $flag !== 'A') {
$flag = 'M';
}
$channelPart = str_pad((string) max(0, $channelId), 6, '0', STR_PAD_LEFT);
$timePart = str_pad((string) max(0, $endTs), 10, '0', STR_PAD_LEFT);
$base = $flag . $channelPart . $timePart;
for ($i = 0; $i < 8; $i++) {
$randPart = strtoupper(substr(bin2hex(random_bytes(4)), 0, 8));
$no = $base . $randPart;
if (!Db::name('agent_settlement_period')->where('settlement_no', $no)->value('id')) {
return $no;
}
}
return $base . strtoupper(substr(bin2hex(random_bytes(8)), 0, 16));
}
private function getLastSettlementEndForChannel(int $channelId): ?int
{
$row = Db::name('agent_commission_record')
->alias('acr')
->join('agent_settlement_period asp', 'acr.settlement_period_id = asp.id')
->where('acr.channel_id', $channelId)
->field('MAX(asp.period_end_at) AS m')
->find();
if (!$row) {
return null;
}
$m = $row['m'] ?? null;
if ($m === null || $m === '') {
return null;
}
return (int) $m;
}
/**
* @return array{total_bet:string,total_payout:string}
*/
private function aggregateBetOrderForChannel(int $channelId, int $periodStartTs, bool $hasPriorSettlement, int $endTs): array
{
$query = Db::name('bet_order')
->where('channel_id', $channelId)
->where('status', 2)
->where('create_time', '<=', $endTs);
if ($hasPriorSettlement) {
$query->where('create_time', '>', $periodStartTs);
} else {
$query->where('create_time', '>=', $periodStartTs);
}
$row = $query->field('SUM(total_amount) AS tb, SUM(win_amount) AS tw, SUM(jackpot_extra_amount) AS tj')->find();
$tb = $row && $row['tb'] !== null && $row['tb'] !== '' ? (string) $row['tb'] : '0.0000';
$tw = $row && $row['tw'] !== null && $row['tw'] !== '' ? (string) $row['tw'] : '0.0000';
$tj = $row && $row['tj'] !== null && $row['tj'] !== '' ? (string) $row['tj'] : '0.0000';
$totalPayout = bcadd($tw, $tj, 4);
return [
'total_bet' => number_format((float) $tb, 4, '.', ''),
'total_payout' => number_format((float) $totalPayout, 4, '.', ''),
];
}
/**
* @return array{commission_rate:string,calc_base_amount:string,commission_amount:string}|string
*/
private function computeCommissionAmounts(array $row, string $totalBet, string $platformProfit, string $mode): array|string
{
if ($mode === 'turnover') {
$ratePercent = $row['turnover_share_rate'] ?? null;
if ($ratePercent === null || $ratePercent === '') {
return '普通返水代理未配置返水分红比例';
}
$rateDec = bcdiv((string) $ratePercent, '100', 6);
$amount = bcmul($totalBet, $rateDec, 4);
return [
'commission_rate' => $rateDec,
'calc_base_amount' => $totalBet,
'commission_amount' => $amount,
];
}
if ($mode === 'affiliate') {
$fee = $row['affiliate_fee_rate'] ?? null;
$rulesRaw = $row['affiliate_ladder_rules'] ?? null;
if ($fee === null || $fee === '') {
return '联营代理未配置成本扣除比例';
}
$rules = $this->normalizeLadderRulesForSettlement($rulesRaw);
if ($rules === []) {
return '联营阶梯规则无效或为空';
}
if (bccomp($platformProfit, '0', 4) <= 0) {
return [
'commission_rate' => '0.000000',
'calc_base_amount' => '0.0000',
'commission_amount' => '0.0000',
];
}
$afterFee = bcmul($platformProfit, bcsub('1', (string) $fee, 8), 4);
if (bccomp($afterFee, '0', 4) <= 0) {
return [
'commission_rate' => '0.000000',
'calc_base_amount' => '0.0000',
'commission_amount' => '0.0000',
];
}
$playerLoss = $platformProfit;
$share = $this->pickAffiliateShareRateFromLadder($rules, $playerLoss);
$rateDec = number_format($share, 6, '.', '');
$amount = bcmul($afterFee, $rateDec, 4);
return [
'commission_rate' => $rateDec,
'calc_base_amount' => $afterFee,
'commission_amount' => $amount,
];
}
return '未知的代理模式';
}
/**
* @return array<int, array{minLoss: string, shareRate: string}>
*/
private function normalizeLadderRulesForSettlement(mixed $rulesRaw): array
{
if ($rulesRaw === null || $rulesRaw === '') {
return [];
}
if (is_string($rulesRaw)) {
$decoded = json_decode($rulesRaw, true);
$rulesRaw = is_array($decoded) ? $decoded : [];
}
if (!is_array($rulesRaw)) {
return [];
}
$out = [];
foreach ($rulesRaw as $rule) {
if (!is_array($rule)) {
continue;
}
$minLoss = $rule['minLoss'] ?? ($rule['min_loss'] ?? null);
$shareRate = $rule['shareRate'] ?? ($rule['share_rate'] ?? null);
if ($minLoss === null || $shareRate === null || !is_numeric((string) $minLoss) || !is_numeric((string) $shareRate)) {
continue;
}
$out[] = [
'minLoss' => number_format((float) $minLoss, 4, '.', ''),
'shareRate' => number_format((float) $shareRate, 6, '.', ''),
];
}
usort($out, function ($a, $b) {
return bccomp($a['minLoss'], $b['minLoss'], 4);
});
return $out;
}
/**
* @param array<int, array{minLoss: string, shareRate: string}> $rules
*/
private function pickAffiliateShareRateFromLadder(array $rules, string $playerLoss): float
{
$chosen = (float) $rules[0]['shareRate'];
foreach ($rules as $rule) {
if (bccomp($playerLoss, $rule['minLoss'], 4) >= 0) {
$chosen = (float) $rule['shareRate'];
}
}
return $chosen;
}
private function getCurrentChannelIds(): array
{
if ($this->auth->isSuperAdmin()) {
return Db::name('channel')->column('id');
}
$admin = Db::name('admin')
->field(['id', 'channel_id'])
->where('id', $this->auth->id)
->find();
$ids = [];
if ($admin && !empty($admin['channel_id'])) {
$ids[] = $admin['channel_id'];
}
$byAdmin = Db::name('channel')->where('admin_id', $this->auth->id)->column('id');
return array_values(array_unique(array_merge($ids, $byAdmin)));
}
private function normalizeAgentModeFields(array $data): array
{
$mode = $data['agent_mode'] ?? null;
if (empty($data['settle_cycle'])) {
$data['settle_cycle'] = 'weekly';
}
if (empty($data['settle_weekday'])) {
$data['settle_weekday'] = 1;
}
if (empty($data['settle_time'])) {
$data['settle_time'] = '02:00:00';
}
if ($mode === 'turnover') {
$data['affiliate_share_rate'] = null;
$data['affiliate_fee_rate'] = null;
$data['carryover_balance'] = 0;
$data['affiliate_contract_no'] = null;
$data['affiliate_contract_name'] = null;
$data['affiliate_ladder_rules'] = null;
$data['affiliate_effective_start_at'] = null;
$data['affiliate_effective_end_at'] = null;
return $data;
}
if ($mode === 'affiliate') {
$data['turnover_share_rate'] = null;
}
return $data;
}
private function validateAndNormalizeBusinessFields(array &$data): ?string
{
$cycle = isset($data['settle_cycle']) ? trim((string) $data['settle_cycle']) : 'weekly';
if (!in_array($cycle, ['daily', 'weekly', 'monthly'], true)) {
return '结算周期不合法';
}
$data['settle_cycle'] = $cycle;
$settleTime = isset($data['settle_time']) ? trim((string) $data['settle_time']) : '02:00:00';
if (!preg_match('/^\d{2}:\d{2}:\d{2}$/', $settleTime)) {
return '结算时间格式不正确HH:mm:ss';
}
$data['settle_time'] = $settleTime;
if ($cycle === 'weekly') {
$weekday = isset($data['settle_weekday']) ? (int) $data['settle_weekday'] : 1;
if ($weekday < 1 || $weekday > 7) {
return '周结必须选择周一到周日';
}
$data['settle_weekday'] = $weekday;
} else {
$data['settle_weekday'] = 1;
}
if ($cycle === 'monthly') {
$monthday = isset($data['settle_monthday']) ? (int) $data['settle_monthday'] : 1;
if ($monthday < 1 || $monthday > 31) {
return '月结日期必须在1到31之间';
}
$data['settle_monthday'] = $monthday;
} else {
$data['settle_monthday'] = 1;
}
$mode = isset($data['agent_mode']) ? (string) $data['agent_mode'] : '';
if ($mode === 'turnover') {
if (isset($data['turnover_share_rate']) && $data['turnover_share_rate'] !== '' && $data['turnover_share_rate'] !== null) {
$num = (float) $data['turnover_share_rate'];
if ($num < 0 || $num > 100) {
return '返水分红比例必须在0到100之间';
}
}
return null;
}
if ($mode === 'affiliate') {
foreach (['affiliate_share_rate' => '联营占成比例', 'affiliate_fee_rate' => '联营成本扣除比例'] as $field => $label) {
if (!isset($data[$field]) || $data[$field] === '' || $data[$field] === null) {
return $label . '不能为空';
}
$num = (float) $data[$field];
if ($num < 0 || $num > 1) {
return $label . '必须在0到1之间';
}
}
$ladderErr = $this->validateLadderRulesField($data);
if ($ladderErr !== null) {
return $ladderErr;
}
}
return null;
}
private function validateLadderRulesField(array &$data): ?string
{
$rulesRaw = $data['affiliate_ladder_rules'] ?? null;
if ($rulesRaw === null || $rulesRaw === '') {
return '联营阶梯规则不能为空';
}
if (is_string($rulesRaw)) {
$decoded = json_decode($rulesRaw, true);
if (!is_array($decoded)) {
return '联营阶梯规则必须是有效JSON数组';
}
$rulesRaw = $decoded;
}
if (!is_array($rulesRaw) || $rulesRaw === []) {
return '联营阶梯规则至少需要一条';
}
$normalized = [];
$prevMinLoss = null;
foreach ($rulesRaw as $idx => $rule) {
if (!is_array($rule)) {
return '联营阶梯规则第' . ($idx + 1) . '行格式错误';
}
$minLoss = $rule['minLoss'] ?? ($rule['min_loss'] ?? null);
$shareRate = $rule['shareRate'] ?? ($rule['share_rate'] ?? null);
if ($minLoss === null || $minLoss === '' || !is_numeric((string) $minLoss)) {
return '联营阶梯规则第' . ($idx + 1) . '行起始客损格式错误';
}
if ($shareRate === null || $shareRate === '' || !is_numeric((string) $shareRate)) {
return '联营阶梯规则第' . ($idx + 1) . '行占成比例格式错误';
}
$minLossNum = (float) $minLoss;
$shareRateNum = (float) $shareRate;
if ($minLossNum < 0) {
return '联营阶梯规则第' . ($idx + 1) . '行起始客损不能为负';
}
if ($shareRateNum < 0 || $shareRateNum > 1) {
return '联营阶梯规则第' . ($idx + 1) . '行占成比例必须在0到1之间';
}
if ($prevMinLoss !== null && $minLossNum <= $prevMinLoss) {
return '联营阶梯规则需按起始客损递增';
}
$prevMinLoss = $minLossNum;
$normalized[] = [
'minLoss' => number_format($minLossNum, 4, '.', ''),
'shareRate' => number_format($shareRateNum, 6, '.', ''),
];
}
$data['affiliate_ladder_rules'] = $normalized;
return null;
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace app\admin\controller\agent;
use app\common\controller\Backend;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 代理佣金记录
*/
class CommissionRecord extends Backend
{
protected ?object $model = null;
protected string|array $preExcludeFields = ['id', 'create_time', 'update_time'];
protected string|array $quickSearchField = ['id', 'remark'];
protected string|array $defaultSortField = ['id' => 'desc'];
protected string|array $orderGuarantee = ['id' => 'desc'];
protected array $withJoinTable = ['settlementPeriod', 'channel', 'admin'];
protected bool $modelValidate = true;
protected bool $modelSceneValidate = true;
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\AgentCommissionRecord();
return null;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace app\admin\controller\agent;
use app\common\controller\Backend;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 代理结算周期
*/
class SettlementPeriod extends Backend
{
protected ?object $model = null;
protected string|array $preExcludeFields = ['id', 'create_time', 'update_time'];
protected string|array $quickSearchField = ['id', 'settlement_no', 'remark'];
protected string|array $defaultSortField = ['id' => 'desc'];
protected string|array $orderGuarantee = ['id' => 'desc'];
protected bool $modelValidate = true;
protected bool $modelSceneValidate = true;
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\AgentSettlementPeriod();
return null;
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace app\admin\controller\auth;
use ba\Random;
use Throwable;
use support\think\Db;
use support\validation\Validator;
@@ -21,10 +22,7 @@ class Admin extends Backend
protected array|string $quickSearchField = ['username', 'nickname'];
/**
* 开启数据范围;具体范围见重写的 getDataLimitAdminIds角色组树仅本人 + 下级组内管理员)
*/
protected bool|string|int $dataLimit = true;
protected string|int|bool $dataLimit = 'parent';
protected string $dataLimitField = 'id';
@@ -34,17 +32,6 @@ class Admin extends Backend
return null;
}
/**
* 非超管:仅可管理「本人 + 树形下级组内」的管理员账号;与角色组管理页的可见范围一致(列表不含仅同级的其他管理员)
*/
protected function getDataLimitAdminIds(): array
{
if (!$this->dataLimit || !$this->auth || $this->auth->isSuperAdmin()) {
return [];
}
return $this->auth->getSelfAndSubordinateAdminIds();
}
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
@@ -75,9 +62,18 @@ class Admin extends Backend
$res = $query
->order($order)
->paginate($limit);
$items = $res->items();
$topGroupUids = $this->getTopGroupUserMap(array_column($items, 'id'));
foreach ($items as &$item) {
$id = $item['id'] ?? null;
if ($id === 1 || isset($topGroupUids[$id])) {
$item['commission_rate'] = null;
}
}
unset($item);
return $this->success('', [
'list' => $res->items(),
'list' => $items,
'total' => $res->total(),
'remark' => get_route_remark(),
]);
@@ -163,6 +159,10 @@ class Admin extends Backend
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->normalizeSingleGroup($data);
if (!$this->hasSingleGroup($data['group_arr'] ?? null)) {
return $this->error('请选择且仅选择一个角色组');
}
if ($this->modelValidate) {
try {
@@ -186,6 +186,25 @@ class Admin extends Backend
$passwd = $data['password'] ?? '';
$data = $this->excludeFields($data);
$creatorChannelId = $this->getCreatorChannelId();
if (!$this->auth->isSuperAdmin()) {
if ($creatorChannelId === null || $creatorChannelId === '') {
return $this->error(__('You have no permission'));
}
$data['channel_id'] = $creatorChannelId;
$data['parent_admin_id'] = $this->auth->id;
}
$data['invite_code'] = $this->generateUniqueInviteCode();
$requireCommissionRate = $this->requireCommissionRate($data['group_arr'] ?? []);
if ($requireCommissionRate) {
if (!$this->isValidCommissionRate($data['commission_rate'] ?? null)) {
return $this->error(__('Please enter a valid commission rate for non-top role group'));
}
$commissionRes = $this->validateAdminCommissionByGroups($data['group_arr'] ?? [], floatval((string)$data['commission_rate']));
if ($commissionRes !== null) return $commissionRes;
} else {
$data['commission_rate'] = null;
}
$result = false;
if (!empty($data['group_arr'])) {
$authRes = $this->checkGroupAuth($data['group_arr']);
@@ -244,6 +263,37 @@ class Admin extends Backend
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->normalizeSingleGroup($data);
if (!$this->hasSingleGroup($data['group_arr'] ?? null)) {
return $this->error('请选择且仅选择一个角色组');
}
// 未提交分红比例时,若角色组未变更则沿用数据库原值(避免表单单项 number 校验把空串判错)
$postedGroups = array_map('intval', $data['group_arr'] ?? []);
$rowGroups = array_map('intval', $row->group_arr ?? []);
sort($postedGroups);
sort($rowGroups);
$sameGroups = $postedGroups === $rowGroups;
$postedCommission = $data['commission_rate'] ?? null;
if (($postedCommission === null || $postedCommission === '') && $sameGroups && $this->isValidCommissionRate($row['commission_rate'] ?? null)) {
$data['commission_rate'] = $row['commission_rate'];
}
// 当前管理员编辑自身时,不允许修改角色组和分红比
if ((int)$this->auth->id === (int)$id) {
$postedGroups = $data['group_arr'] ?? [];
if (!is_array($postedGroups)) {
$postedGroups = [];
}
$originGroups = $row->group_arr ?? [];
sort($postedGroups);
sort($originGroups);
$postedRate = $data['commission_rate'] ?? null;
$originRate = $row['commission_rate'] ?? null;
if ($postedGroups !== $originGroups || (string)$postedRate !== (string)$originRate) {
return $this->error(__('You cannot modify your own management group!'));
}
}
if ($this->modelValidate) {
try {
@@ -290,15 +340,29 @@ class Admin extends Backend
if ($authRes !== null) return $authRes;
}
Db::name('admin_group_access')
->where('uid', $id)
->delete();
$data = $this->excludeFields($data);
unset($data['invite_code']);
$creatorChannelId = $this->getCreatorChannelId();
if (!$this->auth->isSuperAdmin() && $creatorChannelId !== null && $creatorChannelId !== '') {
$data['channel_id'] = $creatorChannelId;
}
$requireCommissionRate = $this->requireCommissionRate($data['group_arr'] ?? []);
if ($requireCommissionRate) {
if (!$this->isValidCommissionRate($data['commission_rate'] ?? null)) {
return $this->error(__('Please enter a valid commission rate for non-top role group'));
}
$commissionRes = $this->validateAdminCommissionByGroups($data['group_arr'] ?? [], floatval((string)$data['commission_rate']), intval((string)$id));
if ($commissionRes !== null) return $commissionRes;
} else {
$data['commission_rate'] = null;
}
$result = false;
$this->model->startTrans();
try {
$result = $row->save($data);
Db::name('admin_group_access')
->where('uid', $id)
->delete();
if ($groupAccess) {
Db::name('admin_group_access')->insertAll($groupAccess);
}
@@ -371,15 +435,140 @@ class Admin extends Backend
if ($this->auth->isSuperAdmin()) {
return null;
}
$allowedGroupIds = array_values(array_unique(array_merge(
Db::name('admin_group_access')->where('uid', $this->auth->id)->column('group_id'),
$this->auth->getAdminChildGroups()
)));
$authGroups = $this->getManageableGroupIds();
foreach ($groups as $group) {
if (!in_array($group, $allowedGroupIds, false)) {
if (!in_array($group, $authGroups)) {
return $this->error(__('You have no permission to add an administrator to this group!'));
}
}
return null;
}
private function getManageableGroupIds(): array
{
if ($this->auth->isSuperAdmin()) {
return Db::name('admin_group')->where('status', 1)->column('id');
}
$own = Db::name('admin_group_access')->where('uid', $this->auth->id)->column('group_id');
$children = $this->auth->getAdminChildGroups();
return array_values(array_unique(array_merge($own, $children)));
}
private function getCreatorChannelId(): mixed
{
$currentAdmin = Db::name('admin')
->field(['id', 'channel_id'])
->where('id', $this->auth->id)
->find();
if ($currentAdmin && !empty($currentAdmin['channel_id'])) {
return $currentAdmin['channel_id'];
}
$channelId = Db::name('channel')
->where('admin_id', $this->auth->id)
->value('id');
return $channelId ?: null;
}
private function generateUniqueInviteCode(): string
{
$tries = 0;
do {
$tries++;
$code = strtoupper(substr(Random::uuid(), 0, 8));
$exists = Db::name('admin')->where('invite_code', $code)->find();
} while ($exists && $tries < 20);
return $code;
}
private function requireCommissionRate(array $groupIds): bool
{
if (!$groupIds) {
return false;
}
$count = Db::name('admin_group')
->where('id', 'in', $groupIds)
->where('pid', '<>', 0)
->count();
return $count > 0;
}
private function isValidCommissionRate(mixed $value): bool
{
if ($value === null || $value === '') {
return false;
}
$rate = trim((string)$value);
if (!preg_match('/^(100(\.00?)?|[0-9]{1,2}(\.[0-9]{1,2})?)$/', $rate)) {
return false;
}
return true;
}
private function validateAdminCommissionByGroups(array $groupIds, float $currentRate, ?int $excludeAdminId = null): ?Response
{
if (!$groupIds) {
return null;
}
$groups = Db::name('admin_group')
->where('id', 'in', $groupIds)
->where('pid', '<>', 0)
->column('name', 'id');
foreach ($groups as $groupId => $groupName) {
$query = Db::name('admin_group_access')->alias('aga')
->join('admin a', 'aga.uid = a.id')
->where('aga.group_id', intval((string)$groupId));
if ($excludeAdminId !== null) {
$query = $query->where('a.id', '<>', $excludeAdminId);
}
$sum = (float)$query->sum('a.commission_rate');
$remaining = 100 - $sum;
if ($currentRate > $remaining + 0.000001) {
$exceed = $currentRate - $remaining;
return $this->error(sprintf('角色组[%s]分红比例总和不能超过100%%,当前剩余 %.2f%%,本次超出 %.2f%%', $groupName, max(0, $remaining), $exceed));
}
}
return null;
}
private function getTopGroupUserMap(array $userIds): array
{
if (!$userIds) {
return [];
}
$uids = Db::name('admin_group_access')->alias('aga')
->join('admin_group ag', 'aga.group_id = ag.id')
->where('aga.uid', 'in', $userIds)
->where('ag.pid', 0)
->column('aga.uid');
$map = [];
foreach ($uids as $uid) {
$map[$uid] = true;
}
return $map;
}
private function normalizeSingleGroup(array $data): array
{
if (!array_key_exists('group_arr', $data)) {
return $data;
}
$group = $data['group_arr'];
if (is_array($group)) {
$data['group_arr'] = array_values(array_filter($group, static function ($item) {
return $item !== null && $item !== '';
}));
return $data;
}
if ($group === null || $group === '') {
$data['group_arr'] = [];
return $data;
}
$data['group_arr'] = [$group];
return $data;
}
private function hasSingleGroup(mixed $groups): bool
{
return is_array($groups) && count($groups) === 1;
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace app\admin\controller\auth;
use Throwable;
use app\common\controller\Backend;
use app\admin\model\AdminLog as AdminLogModel;
use support\Response;
@@ -35,10 +36,7 @@ class AdminLog extends Backend
list($where, $alias, $limit, $order) = $this->queryBuilder();
if (!$this->auth->isSuperAdmin()) {
$scopeIds = $this->auth->getSelfAndSubordinateAdminIds();
if ($scopeIds !== []) {
$where[] = ['admin_id', 'in', $scopeIds];
}
$where[] = ['admin_id', '=', $this->auth->id];
}
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)

View File

@@ -17,6 +17,8 @@ use Webman\Http\Request;
class Group extends Backend
{
protected string $authMethod = 'allAuthAndOthers';
protected ?object $model = null;
protected string|array $preExcludeFields = ['create_time', 'update_time'];
@@ -32,6 +34,7 @@ class Group extends Backend
protected bool $assembleTree = true;
protected array $adminGroups = [];
protected array $manageableGroupIds = [];
protected function initController(Request $request): ?Response
{
@@ -46,6 +49,7 @@ class Group extends Backend
$this->assembleTree = $isTree && !$this->initValue;
$this->adminGroups = Db::name('admin_group_access')->where('uid', $this->auth->id)->column('group_id');
$this->manageableGroupIds = $this->getManageableGroupIds();
return null;
}
@@ -77,12 +81,26 @@ class Group extends Backend
}
$data = $this->excludeFields($data);
$pid = $data['pid'] ?? 0;
$pidInt = intval((string)$pid);
if (!$this->auth->isSuperAdmin() && $pidInt !== 0 && !in_array($pidInt, $this->manageableGroupIds, true)) {
return $this->error(__('You have no permission'));
}
$shouldHandleCommissionRate = true;
if ($shouldHandleCommissionRate) {
if (!$this->isValidCommissionRate($data['commission_rate'] ?? null)) {
return $this->error(__('Please enter the correct field', ['commission_rate']));
}
if ($pidInt !== 0) {
$commissionRes = $this->validateSiblingCommissionRate($pidInt, floatval((string)$data['commission_rate']));
if ($commissionRes !== null) return $commissionRes;
}
} else {
$data['commission_rate'] = 0;
}
$rulesRes = $this->handleRules($data);
if ($rulesRes instanceof Response) return $rulesRes;
$pidRes = $this->validateGroupParentId($data['pid'] ?? null);
if ($pidRes instanceof Response) return $pidRes;
$result = false;
$this->model->startTrans();
try {
@@ -142,14 +160,26 @@ class Group extends Backend
}
$data = $this->excludeFields($data);
$pid = $data['pid'] ?? $row['pid'] ?? 0;
$pidInt = intval((string)$pid);
if (!$this->auth->isSuperAdmin() && $pidInt !== 0 && !in_array($pidInt, $this->manageableGroupIds, true)) {
return $this->error(__('You have no permission'));
}
$shouldHandleCommissionRate = true;
if ($shouldHandleCommissionRate) {
if (!$this->isValidCommissionRate($data['commission_rate'] ?? null)) {
return $this->error(__('Please enter the correct field', ['commission_rate']));
}
if ($pidInt !== 0) {
$commissionRes = $this->validateSiblingCommissionRate($pidInt, floatval((string)$data['commission_rate']), intval((string)$row['id']));
if ($commissionRes !== null) return $commissionRes;
}
} else {
$data['commission_rate'] = 0;
}
$rulesRes = $this->handleRules($data);
if ($rulesRes instanceof Response) return $rulesRes;
if (array_key_exists('pid', $data)) {
$pidRes = $this->validateGroupParentId($data['pid'] ?? null);
if ($pidRes instanceof Response) return $pidRes;
}
$result = false;
$this->model->startTrans();
try {
@@ -300,6 +330,8 @@ class Group extends Backend
$pk = $this->model->getPk();
$initKey = $request->get('initKey') ?? $pk;
$absoluteAuth = $request->get('absoluteAuth') ?? false;
if ($this->keyword) {
$keyword = explode(' ', $this->keyword);
foreach ($keyword as $item) {
@@ -312,14 +344,12 @@ class Group extends Backend
}
if (!$this->auth->isSuperAdmin()) {
$descendantIds = $this->auth->getAdminChildGroups();
// 本人所在组 + 树形下级;不含同级、不含其它分支(与 getAllAuthGroups 的「权限多寡」脱钩)
$visibleIds = array_values(array_unique(array_merge($this->adminGroups, $descendantIds)));
if ($visibleIds === []) {
$where[] = ['id', '=', -1];
} else {
$where[] = ['id', 'in', $visibleIds];
// 仅本人所在角色组 + 其下级子组getManageableGroupIds不包含上级/同级。无父节点在结果集时assembleChild 将该节点作为树根展示,符合「只看我这条线」
$authGroups = $this->manageableGroupIds;
if ($absoluteAuth) {
$authGroups = array_values(array_diff($authGroups, $this->adminGroups));
}
$where[] = ['id', 'in', $authGroups ?: [0]];
}
$data = $this->model->where($where)->select()->toArray();
@@ -344,44 +374,48 @@ class Group extends Backend
private function checkAuth($groupId): ?Response
{
if ($this->auth->isSuperAdmin()) {
return null;
}
$descendantIds = $this->auth->getAdminChildGroups();
if (!in_array($groupId, $descendantIds, false)) {
return $this->error(__('You can only operate subordinate role groups in the tree hierarchy~'));
$authGroups = $this->manageableGroupIds;
if (!$this->auth->isSuperAdmin() && !in_array(intval((string)$groupId), $authGroups, true)) {
return $this->error(__($this->authMethod == 'allAuth' ? 'You need to have all permissions of this group to operate this group~' : 'You need to have all the permissions of the group and have additional permissions before you can operate the group~'));
}
return null;
}
/**
* 新增/编辑时校验父级:非超管只能挂在本人所在组或其树形下级之下,不可建顶级(pid=0)
*/
private function validateGroupParentId(mixed $pid): ?Response
private function getManageableGroupIds(): array
{
if ($this->auth->isSuperAdmin()) {
return null;
return AdminGroup::where('status', 1)->column('id');
}
if ($pid === null || $pid === '' || $pid === false) {
return $this->error(__('Non super administrators cannot create top-level role groups'));
$own = array_map('intval', $this->adminGroups);
$children = array_map('intval', $this->auth->getAdminChildGroups());
return array_values(array_unique(array_merge($own, $children)));
}
private function isValidCommissionRate(mixed $value): bool
{
if ($value === null || $value === '') {
return false;
}
if ($pid === 0 || $pid === '0') {
return $this->error(__('Non super administrators cannot create top-level role groups'));
$rate = trim((string)$value);
if (!preg_match('/^(100(\.00?)?|[0-9]{1,2}(\.[0-9]{1,2})?)$/', $rate)) {
return false;
}
if (!is_numeric($pid)) {
return $this->error(__('The parent group is not within your manageable scope'));
return true;
}
private function validateSiblingCommissionRate(int $pid, float $currentRate, ?int $excludeId = null): ?Response
{
$query = Db::name('admin_group')->where('pid', $pid);
if ($excludeId !== null) {
$query = $query->where('id', '<>', $excludeId);
}
$allowed = array_values(array_unique(array_merge($this->adminGroups, $this->auth->getAdminChildGroups())));
$ok = false;
foreach ($allowed as $aid) {
if ($aid == $pid) {
$ok = true;
break;
}
}
if (!$ok) {
return $this->error(__('The parent group is not within your manageable scope'));
$sum = (float)$query->sum('commission_rate');
$remaining = 100 - $sum;
if ($currentRate > $remaining + 0.000001) {
$exceed = $currentRate - $remaining;
return $this->error(sprintf('同一父级角色组分红比例总和不能超过100%%,当前父级剩余 %.2f%%,本次超出 %.2f%%', max(0, $remaining), $exceed));
}
return null;
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace app\admin\controller\config;
use app\common\controller\Backend;
use app\common\library\game\ZiHuaDictionary as ZiHuaDictionaryLib;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 游戏配置game_config
*/
class GameConfig extends Backend
{
protected ?object $model = null;
protected string|array $preExcludeFields = ['id', 'create_time', 'update_time'];
protected string|array $quickSearchField = ['id', 'config_key', 'remark'];
protected string|array $defaultSortField = ['id' => 'asc'];
protected string|array $orderGuarantee = ['id' => 'asc'];
protected bool $modelValidate = true;
protected bool $modelSceneValidate = true;
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\GameConfig();
return null;
}
/**
* 列表:排除独立表单维护的 36 字花字典
*/
protected function _index(): Response
{
if ($this->request && $this->request->get('select')) {
return $this->select($this->request);
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$table = strtolower($this->model->getTable());
$mainShort = $alias[$table] ?? '';
if ($mainShort !== '') {
$where[] = [$mainShort . '.config_key', '<>', ZiHuaDictionaryLib::CONFIG_KEY];
}
$res = $this->model
->field($this->indexField)
->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace app\admin\controller\config;
use app\common\controller\Backend;
use app\common\library\game\ZiHuaDictionary as ZiHuaDictionaryLib;
use InvalidArgumentException;
use support\think\Db;
use support\Response;
use Throwable;
use Webman\Http\Request as WebmanRequest;
/**
* 36 字花字典独立编辑(仅 game_config.zi_hua_36_dictionary
*/
class ZiHuaDictionary 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;
}
/**
* GET读取 36 条POST 不支持
*/
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', ZiHuaDictionaryLib::CONFIG_KEY)->find();
$items = ZiHuaDictionaryLib::parseFromConfigValue($row['config_value'] ?? null);
return $this->success('', [
'items' => $items,
'categories' => ZiHuaDictionaryLib::CATEGORIES,
]);
}
/**
* 保存 JSON 数组(仅 value_type=json
*/
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 必须为数组');
}
try {
$clean = ZiHuaDictionaryLib::prepareItemsForSave($items);
$json = ZiHuaDictionaryLib::encodeForDb($clean);
} catch (InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$now = time();
try {
$exists = Db::name('game_config')->where('config_key', ZiHuaDictionaryLib::CONFIG_KEY)->find();
if ($exists) {
Db::name('game_config')->where('config_key', ZiHuaDictionaryLib::CONFIG_KEY)->update([
'config_value' => $json,
'value_type' => 'json',
'update_time' => $now,
]);
} else {
Db::name('game_config')->insert([
'config_key' => ZiHuaDictionaryLib::CONFIG_KEY,
'config_value' => $json,
'value_type' => 'json',
'remark' => '36字花字典 JSON 数组(独立表单维护)',
'create_time' => $now,
'update_time' => $now,
]);
}
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
return $this->success(__('Saved successfully'));
}
}

View File

@@ -1,661 +0,0 @@
<?php
namespace app\admin\controller\game;
use Throwable;
use app\common\controller\Backend;
use app\common\library\GameRewardConfigTemplate;
use support\think\Db;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 渠道管理
*/
class Channel extends Backend
{
/**
* GameChannel模型对象
* @var object|null
* @phpstan-var \app\common\model\GameChannel|null
*/
protected ?object $model = null;
protected array|string $preExcludeFields = ['id', 'user_count', 'profit_amount', 'create_time', 'update_time'];
protected array $withJoinTable = ['adminGroup', 'admin'];
protected string|array $quickSearchField = ['id', 'code', 'name'];
/**
* 非超级管理员仅能操作 game_channel.admin_id 为当前账号的渠道;超管不限制
* @see \app\common\controller\Backend::getDataLimitAdminIds()
*/
protected bool|string|int $dataLimit = true;
/**
* adminTree 为辅助接口,默认权限节点名 game/channel/admintree 往往未在后台录入;
* 与列表权限 game/channel/index 对齐,避免子管理员已勾「渠道管理」仍 401。
*/
protected array $noNeedPermission = ['adminTree', 'deleteRelatedCounts'];
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\GameChannel();
return null;
}
/**
* 列表;附带 delete_related_counts=1 时返回删除前关联数据统计(走与 index 相同的路由入口,避免单独 URL 在部分环境下 404
*
* @throws Throwable
*/
public function index(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
$delPreview = $request->get('delete_related_counts');
if ($delPreview === '1' || $delPreview === 1 || $delPreview === true) {
return $this->deleteRelatedCountsResponse($request);
}
return $this->_index();
}
/**
* 渠道-管理员树(父级=渠道,子级=管理员,仅可选择子级)
*/
public function adminTree(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if (!$this->auth->check('game/channel/index')) {
return $this->error(__('You have no permission'));
}
$channelQuery = Db::name('game_channel')
->field(['id', 'name', 'admin_group_id'])
->order('id', 'asc');
if (!$this->auth->isSuperAdmin()) {
$channelQuery->where('admin_id', $this->auth->id);
}
$channels = $channelQuery->select()->toArray();
$groupChildrenCache = [];
$getGroupChildren = function ($groupId) use (&$getGroupChildren, &$groupChildrenCache) {
if ($groupId === null || $groupId === '') return [];
if (array_key_exists($groupId, $groupChildrenCache)) return $groupChildrenCache[$groupId];
$children = Db::name('admin_group')
->where('pid', $groupId)
->where('status', 1)
->column('id');
$all = [];
foreach ($children as $cid) {
$all[] = $cid;
foreach ($getGroupChildren($cid) as $cc) {
$all[] = $cc;
}
}
$groupChildrenCache[$groupId] = $all;
return $all;
};
$tree = [];
foreach ($channels as $ch) {
$groupId = $ch['admin_group_id'] ?? null;
$groupIds = [];
if ($groupId !== null && $groupId !== '') {
$groupIds[] = $groupId;
foreach ($getGroupChildren($groupId) as $gid) {
$groupIds[] = $gid;
}
}
$adminIds = [];
if ($groupIds) {
$adminIds = Db::name('admin_group_access')
->where('group_id', 'in', array_unique($groupIds))
->column('uid');
}
$adminIds = array_values(array_unique($adminIds));
$admins = [];
if ($adminIds) {
$admins = Db::name('admin')
->field(['id', 'username'])
->where('id', 'in', $adminIds)
->order('id', 'asc')
->select()
->toArray();
}
$children = [];
foreach ($admins as $a) {
$children[] = [
'value' => (string) $a['id'],
'label' => $a['username'],
'channel_id' => $ch['id'],
'is_leaf' => true,
];
}
$tree[] = [
'value' => 'channel_' . $ch['id'],
'label' => $ch['name'],
'disabled' => true,
'children' => $children,
];
}
return $this->success('', [
'list' => $tree,
]);
}
/**
* 添加重写管理员只选顶级组admin_group_id 后端自动写入)
* @throws Throwable
*/
protected function _add(): Response
{
if ($this->request && $this->request->method() === 'POST') {
$data = $this->request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->applyInputFilter($data);
$data = $this->excludeFields($data);
if (!$this->auth->isSuperAdmin()) {
$data['admin_id'] = $this->auth->id;
}
$adminId = $data['admin_id'] ?? null;
if ($adminId === null || $adminId === '') {
return $this->error(__('Parameter %s can not be empty', ['admin_id']));
}
// 不允许前端填写,统一后端根据管理员所属“顶级角色组(pid=0)”自动回填
if (array_key_exists('admin_group_id', $data)) {
unset($data['admin_group_id']);
}
$topGroupId = Db::name('admin_group_access')
->alias('aga')
->join('admin_group ag', 'aga.group_id = ag.id')
->where('aga.uid', $adminId)
->where('ag.pid', 0)
->value('ag.id');
if ($topGroupId === null || $topGroupId === '') {
return $this->error(__('Record not found'));
}
$data['admin_group_id'] = $topGroupId;
if ($this->dataLimit && $this->dataLimitFieldAutoFill) {
$data[$this->dataLimitField] = $this->auth->id;
}
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) {
$validate->scene('add');
}
$validate->check($data);
}
}
$result = $this->model->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($result !== false) {
$newChannelId = $this->resolveNewChannelIdAfterInsert($data);
if (!$this->isPositiveChannelId($newChannelId)) {
$code = $data['code'] ?? null;
if (is_string($code) && trim($code) !== '') {
$newChannelId = Db::name('game_channel')->where('code', trim($code))->order('id', 'desc')->value('id');
}
}
if ($this->isPositiveChannelId($newChannelId)) {
try {
$this->copyGameConfigFromChannelZero($newChannelId);
$this->copyRewardConfigFromTemplate($newChannelId);
} catch (Throwable $e) {
return $this->error(__('Game channel copy default config failed') . ': ' . $e->getMessage());
}
}
return $this->success(__('Added successfully'));
}
return $this->error(__('No rows were added'));
}
return $this->error(__('Parameter error'));
}
/**
* 编辑重写管理员只选顶级组admin_group_id 后端自动写入)
* @throws Throwable
*/
protected function _edit(): Response
{
$pk = $this->model->getPk();
$id = $this->request ? ($this->request->post($pk) ?? $this->request->get($pk)) : null;
$row = $this->model->find($id);
if (!$row) {
return $this->error(__('Record not found'));
}
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds && !in_array($row[$this->dataLimitField], $dataLimitAdminIds)) {
return $this->error(__('You have no permission'));
}
if ($this->request && $this->request->method() === 'POST') {
$data = $this->request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->applyInputFilter($data);
$data = $this->excludeFields($data);
// 不允许前端填写,统一后端根据管理员所属“顶级角色组(pid=0)”自动回填
if (array_key_exists('admin_group_id', $data)) {
unset($data['admin_group_id']);
}
if (!$this->auth->isSuperAdmin()) {
unset($data['admin_id']);
}
$nextAdminId = array_key_exists('admin_id', $data) ? $data['admin_id'] : ($row['admin_id'] ?? null);
if ($nextAdminId !== null && $nextAdminId !== '') {
$topGroupId = Db::name('admin_group_access')
->alias('aga')
->join('admin_group ag', 'aga.group_id = ag.id')
->where('aga.uid', $nextAdminId)
->where('ag.pid', 0)
->value('ag.id');
if ($topGroupId === null || $topGroupId === '') {
return $this->error(__('Record not found'));
}
$data['admin_group_id'] = $topGroupId;
}
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) {
$validate->scene('edit');
}
$data[$pk] = $row[$pk];
$validate->check($data);
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($result !== false) {
return $this->success(__('Update successful'));
}
return $this->error(__('No rows updated'));
}
return $this->success('', [
'row' => $row
]);
}
/**
* 查看
* @throws Throwable
*/
protected function _index(): Response
{
// 如果是 select 则转发到 select 方法,若未重写该方法,其实还是继续执行 index
if ($this->request && $this->request->get('select')) {
return $this->select($this->request);
}
/**
* 1. withJoin 不可使用 alias 方法设置表别名,别名将自动使用关联模型名称(小写下划线命名规则)
* 2. 以下的别名设置了主表别名,同时便于拼接查询参数等
* 3. paginate 数据集可使用链式操作 each(function($item, $key) {}) 遍历处理
*/
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->visible(['adminGroup' => ['name'], 'admin' => ['username']])
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
$list = $this->buildChannelListWithRealtimeUserCounts($res->items());
return $this->success('', [
'list' => $list,
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* 列表 user_count 按 game_user.game_channel_id 实时 COUNT与库字段无关用户增删改时会回写 game_channel.user_count
*
* @param iterable<int|string, mixed> $items
* @return list<array<string, mixed>>
*/
private function buildChannelListWithRealtimeUserCounts(iterable $items): array
{
$rows = [];
foreach ($items as $item) {
$rows[] = is_array($item) ? $item : $item->toArray();
}
if ($rows === []) {
return [];
}
$ids = [];
foreach ($rows as $r) {
if (isset($r['id'])) {
$ids[] = $r['id'];
}
}
if ($ids === []) {
return $rows;
}
$agg = Db::name('game_user')
->where('game_channel_id', 'in', $ids)
->field('game_channel_id, count(*) as cnt')
->group('game_channel_id')
->select()
->toArray();
$countMap = [];
foreach ($agg as $a) {
$countMap[$a['game_channel_id']] = (int) $a['cnt'];
}
foreach ($rows as &$r) {
$cid = $r['id'] ?? null;
$r['user_count'] = ($cid !== null && $cid !== '') ? ($countMap[$cid] ?? 0) : 0;
}
unset($r);
return $rows;
}
/**
* 删除前统计:与当前选中渠道相关的游戏配置条数、游戏用户条数(须具备 game/channel/del
*
* @throws Throwable
*/
public function deleteRelatedCounts(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
return $this->deleteRelatedCountsResponse($request);
}
/**
* @throws Throwable
*/
private function deleteRelatedCountsResponse(WebmanRequest $request): Response
{
if (!$this->auth->check('game/channel/del')) {
return $this->error(__('You have no permission'));
}
$channelIds = $this->getAuthorizedChannelIdsForIncomingIds($request);
if ($channelIds === []) {
return $this->success('', [
'game_config_count' => 0,
'game_user_count' => 0,
]);
}
// 实时统计game_config.channel_id、game_user.game_channel_id与渠道 id 一致)
$configCount = Db::name('game_config')->where('channel_id', 'in', $channelIds)->count();
$userCount = Db::name('game_user')->where('game_channel_id', 'in', $channelIds)->count();
return $this->success('', [
'game_config_count' => $configCount,
'game_user_count' => $userCount,
]);
}
/**
* 删除渠道:若存在关联的游戏配置或用户,须带 confirm_cascade=1同时级联删除关联数据
*
* @throws Throwable
*/
protected function _del(): Response
{
$where = [];
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds) {
$where[] = [$this->dataLimitField, 'in', $dataLimitAdminIds];
}
$ids = $this->request ? ($this->request->post('ids') ?? $this->request->get('ids') ?? []) : [];
if (!is_array($ids)) {
$ids = $ids !== null && $ids !== '' ? [$ids] : [];
}
if ($ids === []) {
return $this->error(__('Parameter error'));
}
$pk = $this->model->getPk();
$where[] = [$pk, 'in', $ids];
$data = $this->model->where($where)->select();
if (count($data) === 0) {
return $this->error(__('No rows were deleted'));
}
$channelIds = [];
foreach ($data as $v) {
$channelIds[] = $v[$pk];
}
// 删除确认用实时条数game_config.channel_id、game_user.game_channel_id
$configCount = Db::name('game_config')->where('channel_id', 'in', $channelIds)->count();
$userCount = Db::name('game_user')->where('game_channel_id', 'in', $channelIds)->count();
$confirmCascade = $this->request->get('confirm_cascade');
if ($confirmCascade === null || $confirmCascade === '') {
$confirmCascade = $this->request->post('confirm_cascade');
}
$confirmed = $confirmCascade === 1 || $confirmCascade === '1' || $confirmCascade === true;
if (($configCount > 0 || $userCount > 0) && !$confirmed) {
return $this->error(__('Game channel delete need confirm related'), [
'need_confirm' => true,
'game_config_count' => $configCount,
'game_user_count' => $userCount,
]);
}
$count = 0;
$this->model->startTrans();
try {
Db::name('game_config')->where('channel_id', 'in', $channelIds)->delete();
Db::name('game_user')->where('game_channel_id', 'in', $channelIds)->delete();
foreach ($data as $v) {
$count += $v->delete();
}
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($count) {
return $this->success(__('Deleted successfully'));
}
return $this->error(__('No rows were deleted'));
}
/**
* @param list<string|int> $ids
* @return list<int|string>
*/
private function getAuthorizedChannelIdsForIncomingIds(WebmanRequest $request): array
{
$where = [];
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds) {
$where[] = [$this->dataLimitField, 'in', $dataLimitAdminIds];
}
$ids = $request->post('ids') ?? $request->get('ids') ?? [];
if (!is_array($ids)) {
$ids = $ids !== null && $ids !== '' ? [$ids] : [];
}
if ($ids === []) {
return [];
}
$pk = $this->model->getPk();
$where[] = [$pk, 'in', $ids];
return $this->model->where($where)->column($pk);
}
/**
* ThinkORM 在连接池/部分驱动下insert 后 getKey() 可能未及时带上自增 id这里多路兜底
*
* @param array<string, mixed> $postedChannelData 已过滤后的入库数据(含 code 等)
*/
private function resolveNewChannelIdAfterInsert(array $postedChannelData): int|string|null
{
$pk = $this->model->getPk();
$id = $this->model->getKey();
if ($this->isPositiveChannelId($id)) {
return $id;
}
$rowData = $this->model->getData();
if (is_array($rowData)) {
if (isset($rowData[$pk]) && $this->isPositiveChannelId($rowData[$pk])) {
return $rowData[$pk];
}
if (isset($rowData['id']) && $this->isPositiveChannelId($rowData['id'])) {
return $rowData['id'];
}
}
$lastInsId = $this->model->db()->getLastInsID();
if ($this->isPositiveChannelId($lastInsId)) {
return $lastInsId;
}
$lastInsId2 = Db::name('game_channel')->getLastInsID();
if ($this->isPositiveChannelId($lastInsId2)) {
return $lastInsId2;
}
$code = $postedChannelData['code'] ?? null;
if (is_string($code) && trim($code) !== '') {
$found = Db::name('game_channel')->where('code', trim($code))->order('id', 'desc')->value('id');
if ($this->isPositiveChannelId($found)) {
return $found;
}
}
return null;
}
private function isPositiveChannelId(mixed $id): bool
{
if ($id === null || $id === '') {
return false;
}
if (is_numeric($id)) {
return $id > 0;
}
return false;
}
/**
* 新建渠道后game_reward_config 优先从 game_channel_id=0 的默认模板复制;若无则使用 resource JSON 模板
*
* @param int|string $newChannelId 新建 game_channel.id
*/
private function copyRewardConfigFromTemplate(int|string $newChannelId): void
{
$exists = Db::name('game_reward_config')->where('game_channel_id', $newChannelId)->count();
if ($exists > 0) {
return;
}
$now = time();
$tpl = Db::name('game_reward_config')->whereIn('game_channel_id', [0, '0'])->order('id', 'asc')->find();
if ($tpl) {
Db::name('game_reward_config')->insert([
'game_channel_id' => $newChannelId,
'tier_reward_form' => $tpl['tier_reward_form'],
'bigwin_form' => $tpl['bigwin_form'],
'create_time' => $now,
'update_time' => $now,
]);
return;
}
$cols = GameRewardConfigTemplate::getDefaultJsonColumns();
Db::name('game_reward_config')->insert([
'game_channel_id' => $newChannelId,
'tier_reward_form' => $cols['tier_reward_form'],
'bigwin_form' => $cols['bigwin_form'],
'create_time' => $now,
'update_time' => $now,
]);
}
private function copyGameConfigFromChannelZero(int|string $newChannelId): void
{
$exists = Db::name('game_config')->where('channel_id', $newChannelId)->count();
if ($exists > 0) {
return;
}
// 全局模板channel_id 为 0 或字符串 '0'(与业务约定一致)
$rows = Db::name('game_config')
->whereIn('channel_id', [0, '0'])
->select()
->toArray();
if ($rows === []) {
return;
}
$now = time();
foreach ($rows as $row) {
foreach (['ID', 'id', 'Id'] as $pkField) {
unset($row[$pkField]);
}
$row['channel_id'] = $newChannelId;
$row['create_time'] = $now;
$row['update_time'] = $now;
Db::name('game_config')->strict(false)->insert($row);
}
}
/**
* 若需重写查看、编辑、删除等方法,请复制 @see \app\admin\library\traits\Backend 中对应的方法至此进行重写
*/
}

View File

@@ -2,52 +2,29 @@
namespace app\admin\controller\game;
use Throwable;
use app\common\controller\Backend;
use support\think\Db;
use app\common\library\game\ZiHuaDictionary as ZiHuaDictionaryLib;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 游戏配置
* 游戏配置game_config
*/
class Config extends Backend
{
/**
* GameConfig模型对象
* @var object|null
* @phpstan-var \app\common\model\GameConfig|null
*/
protected ?object $model = null;
/**
* 数据范围:非超管仅本人 + 下级角色组内管理员game_config 无 admin_id通过 channel_id 关联 game_channel.admin_id 限定
*/
protected bool|string|int $dataLimit = 'parent';
protected string|array $preExcludeFields = ['id', 'create_time', 'update_time'];
/**
* 列表/删除等条件字段为 channel_id见 {@see getDataLimitAdminIds()} 实际返回渠道 ID
*/
protected string $dataLimitField = 'channel_id';
protected string|array $quickSearchField = ['id', 'config_key', 'remark'];
/**
* 表无 admin_id勿自动写入
*/
protected bool $dataLimitFieldAutoFill = false;
protected string|array $defaultSortField = ['id' => 'asc'];
protected string|array $defaultSortField = 'channel_id,desc';
protected string|array $orderGuarantee = ['id' => 'asc'];
protected array $withJoinTable = ['channel'];
protected bool $modelValidate = true;
protected array|string $preExcludeFields = ['create_time', 'update_time'];
protected string|array $quickSearchField = ['ID'];
/** default_tier_weight / default_kill_score_weight每项≤100 且各项之和必须=100 */
private const WEIGHT_SUM_100_NAMES = ['default_tier_weight', 'default_kill_score_weight'];
/** default_bigwin_weight仅校验每项整数、0100005/30 固定 10000不参与「和≤100」 */
private const BIGWIN_WEIGHT_NAME = 'default_bigwin_weight';
protected bool $modelSceneValidate = true;
protected function initController(WebmanRequest $request): ?Response
{
@@ -56,375 +33,25 @@ class Config extends Backend
}
/**
* 将「可访问管理员 ID」转为「其负责的渠道 ID」供 queryBuilder 使用 channel_id IN (...)
*
* @return list<int|string>
*/
protected function getDataLimitAdminIds(): array
{
if (!$this->dataLimit || !$this->auth || $this->auth->isSuperAdmin()) {
return [];
}
$adminIds = parent::getDataLimitAdminIds();
if ($adminIds === []) {
return [];
}
$channelIds = Db::name('game_channel')->where('admin_id', 'in', $adminIds)->column('id');
if ($channelIds === []) {
return [-1];
}
return array_values(array_unique($channelIds));
}
/**
* @throws Throwable
*/
protected function _add(): Response
{
if ($this->request && $this->request->method() === 'POST') {
$data = $this->request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->applyInputFilter($data);
$data = $this->excludeFields($data);
$err = $this->validateGameWeightPayload($data, null);
if ($err !== null) {
return $this->error($err);
}
if (!$this->auth->isSuperAdmin()) {
$allowedChannelIds = $this->getDataLimitAdminIds();
$cid = $data['channel_id'] ?? null;
if ($cid === null || $cid === '') {
return $this->error(__('Parameter %s can not be empty', ['channel_id']));
}
if ($allowedChannelIds !== [] && !in_array($cid, $allowedChannelIds)) {
return $this->error(__('You have no permission'));
}
}
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) {
$validate->scene('add');
}
$validate->check($data);
}
}
$result = $this->model->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($result !== false) {
return $this->success(__('Added successfully'));
}
return $this->error(__('No rows were added'));
}
return $this->error(__('Parameter error'));
}
/**
* @throws Throwable
*/
protected function _edit(): Response
{
$pk = $this->model->getPk();
$id = $this->request ? ($this->request->post($pk) ?? $this->request->get($pk)) : null;
$row = $this->model->find($id);
if (!$row) {
return $this->error(__('Record not found'));
}
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds && !in_array($row[$this->dataLimitField], $dataLimitAdminIds)) {
return $this->error(__('You have no permission'));
}
if ($this->request && $this->request->method() === 'POST') {
$data = $this->request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->applyInputFilter($data);
$data = $this->excludeFields($data);
if (!$this->auth->isSuperAdmin()) {
$data['channel_id'] = $row['channel_id'];
$data['group'] = $row['group'];
$data['name'] = $row['name'];
$data['title'] = $row['title'];
} elseif (!isset($data['name']) || $data['name'] === '' || $data['name'] === null) {
// JSON/表单未带 name 时回退库值避免走「每项≤10000」
$data['name'] = $row['name'] ?? '';
}
// 超管编辑时若未传 group用库值参与 game_weight 校验(与 name 回退一致)
if ($this->auth->isSuperAdmin() && trim((string) ($data['group'] ?? '')) === '') {
$data['group'] = (string) ($row['group'] ?? '');
}
$err = $this->validateGameWeightPayload($data, $row['value'] ?? null);
if ($err !== null) {
return $this->error($err);
}
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) {
$validate->scene('edit');
}
$data[$pk] = $row[$pk];
$validate->check($data);
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($result !== false) {
return $this->success(__('Update successful'));
}
return $this->error(__('No rows updated'));
}
return $this->success('', [
'row' => $row
]);
}
/**
* 与前端 gameWeightFixed.normalizeGameWeightConfigName 一致trim、去括号后说明、小写
*/
private function normalizeGameWeightConfigName(string $raw): string
{
$s = trim($raw);
if (preg_match('/^([^(]+)/u', $s, $m)) {
$s = trim($m[1]);
}
return strtolower($s);
}
/**
* 解析 game_weight JSON 里每一项的权重:支持 int/float、数字字符串与前端 JSON.stringify 一致常为字符串)
*/
private function parseGameWeightScalarToFloat(mixed $v): ?float
{
if (is_int($v) || is_float($v)) {
return (float) $v;
}
if (is_bool($v) || is_array($v) || $v === null) {
return null;
}
$s = trim((string) $v);
if ($s === '') {
return null;
}
$f = filter_var($s, FILTER_VALIDATE_FLOAT);
if ($f !== false) {
return $f;
}
$i = filter_var($s, FILTER_VALIDATE_INT);
if ($i !== false) {
return (float) $i;
}
return null;
}
/**
* game_weighttier/kill 每项≤100 且和必须=100bigwin 每项 010000编辑时键不可改
*
* @param array<string, mixed> $data
*/
private function validateGameWeightPayload(array &$data, ?string $originalValue): ?string
{
$group = strtolower(trim((string) ($data['group'] ?? '')));
if ($group !== 'game_weight') {
return null;
}
$rawName = (string) ($data['name'] ?? '');
$rawName = preg_replace('/[\x{200B}-\x{200D}\x{FEFF}]/u', '', $rawName);
$name = $this->normalizeGameWeightConfigName($rawName);
$rawValue = $data['value'] ?? null;
$valueWasArray = false;
if (is_array($rawValue)) {
$decoded = $rawValue;
$valueWasArray = true;
} elseif (is_string($rawValue)) {
$s = trim($rawValue);
if (str_starts_with($s, "\xEF\xBB\xBF")) {
$s = substr($s, 3);
}
if ($s === '') {
return __('Parameter error');
}
$decoded = json_decode($s, true);
// 双重 JSON 字符串(外层已解析为字符串时)
if (is_string($decoded)) {
$decoded = json_decode(trim($decoded), true);
}
} else {
return __('Parameter error');
}
if (!is_array($decoded)) {
return __('Parameter error');
}
// 骰子键 530 结构一律按大奖 010000 校验(避免库中 name 与 JSON 不一致时误走 tier/kill 单项上限)
$diceBigwin = $this->gameWeightDecodedIsBigwinDiceKeys($decoded);
// 归一化后的 name + 原始串包含 default_bigwin_weight兼容说明后缀、异常空格避免误走单项上限
$rawLower = strtolower($rawName);
$useBigwinRules = $diceBigwin
|| ($name === self::BIGWIN_WEIGHT_NAME)
|| str_contains($rawLower, 'default_bigwin_weight');
$keys = [];
$numbers = [];
foreach ($decoded as $item) {
if (!is_array($item)) {
return __('Parameter error');
}
foreach ($item as $k => $v) {
$keys[] = (string) $k;
$num = $this->parseGameWeightScalarToFloat($v);
if ($num === null) {
return __('Game config weight value must be numeric');
}
if ($useBigwinRules) {
if ($num < 0 || $num > 10000) {
return __('Game config bigwin weight each 0 10000');
}
$ks = strval($k);
if (($ks === '5' || $ks === '30') && abs($num - 10000.0) > 0.000001) {
return __('Game config bigwin weight locked 5 30');
}
} else {
if ($num > 100) {
return __('Game config weight each value must not exceed 100');
}
}
$numbers[] = $num;
}
}
if (count($numbers) === 0) {
return __('Parameter %s can not be empty', ['value']);
}
if ($originalValue !== null && $originalValue !== '') {
$oldKeys = $this->extractGameWeightKeys($originalValue);
if ($oldKeys !== $keys) {
return __('Game config weight keys cannot be modified');
}
}
if (!$useBigwinRules && in_array($name, self::WEIGHT_SUM_100_NAMES, true)) {
$sum = array_sum($numbers);
if (abs($sum - 100.0) > 0.000001) {
return __('Game config weight sum must equal 100');
}
}
if ($valueWasArray) {
$data['value'] = json_encode($decoded, JSON_UNESCAPED_UNICODE);
}
return null;
}
/**
* 是否为 default_bigwin_weight 的固定骰子键集合(与前端 BIGWIN_WEIGHT_KEYS 一致)
*
* @param array<mixed> $decoded
*/
private function gameWeightDecodedIsBigwinDiceKeys(array $decoded): bool
{
$keys = [];
foreach ($decoded as $item) {
if (!is_array($item)) {
return false;
}
foreach (array_keys($item) as $k) {
$keys[] = trim((string) $k);
}
}
if (count($keys) !== 6) {
return false;
}
$ints = [];
foreach ($keys as $k) {
if ($k === '' || !ctype_digit($k)) {
return false;
}
$ints[] = (int) $k;
}
sort($ints, SORT_NUMERIC);
return $ints === [5, 10, 15, 20, 25, 30];
}
/**
* @return list<string>
*/
private function extractGameWeightKeys(string $value): array
{
$decoded = json_decode($value, true);
if (!is_array($decoded)) {
return [];
}
$keys = [];
foreach ($decoded as $item) {
if (!is_array($item)) {
continue;
}
foreach ($item as $k => $_) {
$keys[] = (string) $k;
}
}
return $keys;
}
/**
* 查看
* @throws Throwable
* 列表:排除独立表单维护的 36 字花字典
*/
protected function _index(): Response
{
// 如果是 select 则转发到 select 方法,若未重写该方法,其实还是继续执行 index
if ($this->request && $this->request->get('select')) {
return $this->select($this->request);
}
/**
* 1. withJoin 不可使用 alias 方法设置表别名,别名将自动使用关联模型名称(小写下划线命名规则)
* 2. 以下的别名设置了主表别名,同时便于拼接查询参数等
* 3. paginate 数据集可使用链式操作 each(function($item, $key) {}) 遍历处理
*/
list($where, $alias, $limit, $order) = $this->queryBuilder();
$table = strtolower($this->model->getTable());
$mainShort = $alias[$table] ?? '';
if ($mainShort !== '') {
$where[] = [$mainShort . '.config_key', '<>', ZiHuaDictionaryLib::CONFIG_KEY];
}
$res = $this->model
->field($this->indexField)
->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->visible(['channel' => ['name']])
->alias($alias)
->where($where)
->order($order)
@@ -436,8 +63,4 @@ class Config extends Backend
'remark' => get_route_remark(),
]);
}
/**
* 若需重写查看、编辑、删除等方法,请复制 @see \app\admin\library\traits\Backend 中对应方法至此进行重写
*/
}

View File

@@ -0,0 +1,114 @@
<?php
namespace app\admin\controller\game;
use app\common\controller\Backend;
use app\common\service\GamePeriodService;
use support\Response;
use Throwable;
use Webman\Http\Request as WebmanRequest;
/**
* 游戏对局(期号)
*/
class Period extends Backend
{
protected ?object $model = null;
protected string|array $preExcludeFields = ['id', 'create_time', 'update_time'];
protected string|array $quickSearchField = ['id', 'period_no'];
protected string|array $defaultSortField = ['id' => 'desc'];
protected string|array $orderGuarantee = ['id' => 'desc'];
protected bool $modelValidate = true;
protected bool $modelSceneValidate = true;
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\GamePeriod();
return null;
}
/**
* 读取 / 保存:自动创建下一期、手动创建下一期 开关(存 game_config
*/
public function periodSettings(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
$method = $request->method();
if ($method === 'GET') {
return $this->success('', GamePeriodService::getPeriodSettings());
}
if ($method === 'POST') {
$data = $request->post();
if (!is_array($data)) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
try {
GamePeriodService::savePeriodSettings($data);
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
return $this->success(__('Saved successfully'));
}
return $this->error(__('Parameter error'));
}
/**
* 手动创建下一期(受开关与「存在进行中期号」约束)
*/
public function createNextManual(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$result = GamePeriodService::createNextPeriodForManual();
if ($result['ok']) {
return $this->success($result['msg'], ['period_no' => $result['period_no'] ?? '']);
}
return $this->error($result['msg']);
}
protected function _add(): Response
{
if ($this->request && $this->request->method() === 'POST') {
$data = $this->request->post();
if (!is_array($data)) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->applyInputFilter($data);
$data = $this->excludeFields($data);
if (!isset($data['period_start_at']) || $data['period_start_at'] === '' || $data['period_start_at'] === null) {
$data['period_start_at'] = time();
}
if ($this->dataLimit && $this->dataLimitFieldAutoFill) {
$data[$this->dataLimitField] = $this->auth->id;
}
$result = false;
$this->model->startTrans();
try {
$result = $this->model->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($result !== false) {
return $this->success(__('Added successfully'));
}
return $this->error(__('No rows were added'));
}
return $this->error(__('Parameter error'));
}
}

View File

@@ -1,277 +0,0 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\game;
use Throwable;
use app\common\controller\Backend;
use app\common\library\GameRewardConfigTemplate;
use app\common\library\GameRewardTierBoardGenerator;
use app\common\library\GameRewardWeightSeeder;
use app\common\model\GameRewardConfig;
use app\common\validate\GameRewardConfig as GameRewardConfigValidate;
use support\think\Db;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 游戏奖励配置(渠道表单页,路由 /admin/game/rewardConfig
* 约定game_channel_id = 0 为超管维护的「全渠道默认模板」,新建渠道时优先从此行复制到新渠道
*/
class RewardConfig extends Backend
{
/** 默认模板渠道主键(非真实渠道,仅存库一行) */
private const DEFAULT_TEMPLATE_CHANNEL_ID = 0;
public function index(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
[$channelId, $err] = $this->resolveTargetChannelId($request, false);
if ($err !== null) {
return $err;
}
$row = GameRewardConfig::where('game_channel_id', $channelId)->find();
if ($row) {
return $this->success('', [
'row' => $row->toArray(),
]);
}
$defaults = GameRewardConfigTemplate::getDefaultJsonColumns();
return $this->success('', [
'row' => [
'id' => null,
'game_channel_id' => $channelId,
'tier_reward_form' => $defaults['tier_reward_form'],
'bigwin_form' => $defaults['bigwin_form'],
],
]);
}
public function save(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$data = $request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
[$channelId, $err] = $this->resolveTargetChannelId($request, true);
if ($err !== null) {
return $err;
}
if (!$this->channelExists($channelId)) {
return $this->error(__('Record not found'));
}
$tier = $data['tier_reward_form'] ?? null;
$big = $data['bigwin_form'] ?? null;
if (!is_string($tier) || !is_string($big)) {
return $this->error(__('Parameter error'));
}
try {
$validate = new GameRewardConfigValidate();
$validate->scene('channel_form')->check([
'tier_reward_form' => $tier,
'bigwin_form' => $big,
]);
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
$existing = GameRewardConfig::where('game_channel_id', $channelId)->find();
try {
if ($existing) {
$existing->save([
'tier_reward_form' => $tier,
'bigwin_form' => $big,
]);
} else {
$m = new GameRewardConfig();
$m->save([
'game_channel_id' => $channelId,
'tier_reward_form' => $tier,
'bigwin_form' => $big,
]);
}
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
return $this->success(__('Update successful'));
}
/**
* 按条数与结算标准生成 26 格档位奖励并保存(保留当前 bigwin_form
*/
public function generateTierBoard(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$data = $request->post();
if (!$data || !is_array($data)) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
[$channelId, $err] = $this->resolveTargetChannelId($request, true);
if ($err !== null) {
return $err;
}
if (!$this->channelExists($channelId)) {
return $this->error(__('Record not found'));
}
try {
$out = GameRewardTierBoardGenerator::generate($data);
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
$tier = $out['tier_reward_form'];
$existing = GameRewardConfig::where('game_channel_id', $channelId)->find();
if ($existing && is_string($existing->bigwin_form) && trim($existing->bigwin_form) !== '') {
$big = $existing->bigwin_form;
} else {
$defaults = GameRewardConfigTemplate::getDefaultJsonColumns();
$big = $defaults['bigwin_form'];
}
try {
$validate = new GameRewardConfigValidate();
$validate->scene('channel_form')->check([
'tier_reward_form' => $tier,
'bigwin_form' => $big,
]);
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
try {
if ($existing) {
$existing->save([
'tier_reward_form' => $tier,
'bigwin_form' => $big,
]);
} else {
$m = new GameRewardConfig();
$m->save([
'game_channel_id' => $channelId,
'tier_reward_form' => $tier,
'bigwin_form' => $big,
]);
}
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
return $this->success(__('Update successful'));
}
/**
* 根据当前档位奖励 JSON 生成 game_reward_weight先清空该渠道再写入 52 条)
*/
public function generateRewardWeight(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
[$channelId, $err] = $this->resolveTargetChannelId($request, true);
if ($err !== null) {
return $err;
}
if (!$this->channelExists($channelId)) {
return $this->error(__('Record not found'));
}
$row = GameRewardConfig::where('game_channel_id', $channelId)->find();
if (!$row || !is_string($row->tier_reward_form) || trim($row->tier_reward_form) === '') {
return $this->error('请先保存档位奖励配置');
}
try {
GameRewardWeightSeeder::syncFromTierRewardForm($channelId, $row->tier_reward_form);
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
return $this->success(__('Update successful'));
}
/**
* @return array{0: int, 1: Response|null}
*/
private function resolveTargetChannelId(WebmanRequest $request, bool $isPost): array
{
if ($this->auth->isSuperAdmin()) {
$raw = $isPost ? ($request->post('game_channel_id') ?? $request->get('game_channel_id')) : $request->get('game_channel_id');
if ($raw === null || $raw === '') {
return [self::DEFAULT_TEMPLATE_CHANNEL_ID, null];
}
if (!is_numeric($raw)) {
return [self::DEFAULT_TEMPLATE_CHANNEL_ID, $this->error(__('Parameter error'))];
}
$cid = intval(strval($raw));
if ($cid < 0) {
return [self::DEFAULT_TEMPLATE_CHANNEL_ID, $this->error(__('Parameter error'))];
}
if ($cid === self::DEFAULT_TEMPLATE_CHANNEL_ID) {
return [self::DEFAULT_TEMPLATE_CHANNEL_ID, null];
}
return [$cid, null];
}
$ids = Db::name('game_channel')->where('admin_id', $this->auth->id)->order('id', 'asc')->column('id');
if ($ids === []) {
return [self::DEFAULT_TEMPLATE_CHANNEL_ID, $this->error(__('Record not found'))];
}
return [intval(strval($ids[0])), null];
}
private function channelExists(int $channelId): bool
{
if ($channelId === self::DEFAULT_TEMPLATE_CHANNEL_ID) {
return $this->auth->isSuperAdmin();
}
if ($this->auth->isSuperAdmin()) {
return Db::name('game_channel')->where('id', $channelId)->count() > 0;
}
return Db::name('game_channel')->where('id', $channelId)->where('admin_id', $this->auth->id)->count() > 0;
}
}

View File

@@ -1,70 +0,0 @@
<?php
namespace app\admin\controller\game;
use Throwable;
use app\common\controller\Backend;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 游戏奖励权重配置
*/
class RewardWeight extends Backend
{
/**
* GameRewardWeight模型对象
* @var object|null
* @phpstan-var \app\common\model\GameRewardWeight|null
*/
protected ?object $model = null;
protected array|string $preExcludeFields = ['id', 'create_time', 'update_time'];
protected array $withJoinTable = ['gameChannel'];
protected string|array $quickSearchField = ['id'];
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\GameRewardWeight();
return null;
}
/**
* 查看
* @throws Throwable
*/
protected function _index(): Response
{
// 如果是 select 则转发到 select 方法,若未重写该方法,其实还是继续执行 index
if ($this->request && $this->request->get('select')) {
return $this->select($this->request);
}
/**
* 1. withJoin 不可使用 alias 方法设置表别名,别名将自动使用关联模型名称(小写下划线命名规则)
* 2. 以下的别名设置了主表别名,同时便于拼接查询参数等
* 3. paginate 数据集可使用链式操作 each(function($item, $key) {}) 遍历处理
*/
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->visible(['gameChannel' => ['name']])
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* 若需重写查看、编辑、删除等方法,请复制 @see \app\admin\library\traits\Backend 中对应的方法至此进行重写
*/
}

View File

@@ -1,660 +0,0 @@
<?php
namespace app\admin\controller\game;
use Throwable;
use app\common\controller\Backend;
use app\common\service\GameChannelUserCount;
use support\think\Db;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 用户管理
*/
class User extends Backend
{
/**
* GameUser模型对象
* @var object|null
* @phpstan-var \app\common\model\GameUser|null
*/
protected ?object $model = null;
/**
* 数据范围:非超管仅本人 + 下级角色组内管理员(与 auth.Admin 一致,见 Backend::getDataLimitAdminIds parent
*/
protected bool|string|int $dataLimit = 'parent';
/**
* admin_id 由表单选择归属管理员,勿在保存时强制改为当前登录账号
*/
protected bool $dataLimitFieldAutoFill = false;
protected array|string $preExcludeFields = ['id', 'uuid', 'create_time', 'update_time'];
protected array $withJoinTable = ['gameChannel', 'admin'];
protected string|array $quickSearchField = ['id', 'username', 'phone'];
/**
* 与渠道 Config 类似:从游戏配置拉取默认权重;辅助接口,需具备用户管理列表权限
*/
protected array $noNeedPermission = ['defaultWeightPresets', 'defaultWeightByChannel'];
/** game_weight 分组下的配置名 */
private const GC_GROUP_WEIGHT = 'game_weight';
private const GC_NAME_TIER = 'default_tier_weight';
private const GC_NAME_BIGWIN_PRIMARY = 'default_bigwin_weight';
/** 档位权重固定键T1~T5 */
private const TIER_WEIGHT_KEYS = ['T1', 'T2', 'T3', 'T4', 'T5'];
/** 中大奖权重固定键5~30 */
private const BIGWIN_WEIGHT_KEYS = ['5', '10', '15', '20', '25', '30'];
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\GameUser();
return null;
}
/**
* 抽奖券 ticket_count空串、[]、[""]、无有效 {ante,count} 时写入 NULL避免库里出现无意义 JSON
*
* @param array<string, mixed> $data
*/
private function normalizeEmptyTicketCount(array &$data): void
{
if (!array_key_exists('ticket_count', $data)) {
return;
}
$v = $data['ticket_count'];
if ($v === null) {
$data['ticket_count'] = null;
return;
}
if ($v === '') {
$data['ticket_count'] = null;
return;
}
if (!is_string($v)) {
return;
}
$s = trim($v);
if ($s === '' || $s === '[]' || strtolower($s) === 'null') {
$data['ticket_count'] = null;
return;
}
$decoded = json_decode($s, true);
if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) {
return;
}
if (!is_array($decoded)) {
return;
}
if ($decoded === []) {
$data['ticket_count'] = null;
return;
}
$valid = false;
foreach ($decoded as $item) {
if (!is_array($item)) {
continue;
}
if (!array_key_exists('ante', $item) || !array_key_exists('count', $item)) {
continue;
}
$ante = $item['ante'];
$count = $item['count'];
if ($ante === '' || $ante === null || $count === '' || $count === null) {
continue;
}
if (!is_numeric($ante) || !is_numeric($count)) {
continue;
}
$valid = true;
break;
}
if (!$valid) {
$data['ticket_count'] = null;
}
}
/**
* 添加重写password 使用 Admin 同款加密uuid 由 username+channel_id 生成)
* @throws Throwable
*/
protected function _add(): Response
{
if ($this->request && $this->request->method() === 'POST') {
$data = $this->request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->applyInputFilter($data);
$data = $this->excludeFields($data);
$this->normalizeEmptyTicketCount($data);
$password = $data['password'] ?? null;
if (!is_string($password) || trim($password) === '') {
return $this->error(__('Parameter %s can not be empty', ['password']));
}
$data['password'] = hash_password($password);
$username = $data['username'] ?? '';
$channelId = $data['channel_id'] ?? ($data['game_channel_id'] ?? null);
if (!is_string($username) || trim($username) === '' || $channelId === null || $channelId === '') {
return $this->error(__('Parameter %s can not be empty', ['username/channel_id']));
}
$data['uuid'] = md5(trim($username) . '|' . $channelId);
if ($this->gameUserUsernameExistsInChannel($username, $channelId)) {
return $this->error(__('Game user username exists in channel'));
}
if (!$this->auth->isSuperAdmin()) {
$allowed = $this->getDataLimitAdminIds();
$adminIdNew = $data['admin_id'] ?? null;
if ($adminIdNew === null || $adminIdNew === '') {
return $this->error(__('Parameter %s can not be empty', ['admin_id']));
}
if ($allowed !== [] && !in_array($adminIdNew, $allowed)) {
return $this->error(__('You have no permission'));
}
}
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) {
$validate->scene('add');
}
$validate->check($data);
}
}
$result = $this->model->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($result !== false) {
$cid = $data['game_channel_id'] ?? $data['channel_id'] ?? null;
if (($cid === null || $cid === '') && $this->model) {
$rowData = $this->model->getData();
$cid = $rowData['game_channel_id'] ?? $rowData['channel_id'] ?? null;
}
if ($cid !== null && $cid !== '') {
GameChannelUserCount::syncFromGameUser($cid);
}
return $this->success(__('Added successfully'));
}
return $this->error(__('No rows were added'));
}
return $this->error(__('Parameter error'));
}
/**
* 编辑重写password 使用 Admin 同款加密uuid 由 username+channel_id 生成)
* @throws Throwable
*/
protected function _edit(): Response
{
$pk = $this->model->getPk();
$id = $this->request ? ($this->request->post($pk) ?? $this->request->get($pk)) : null;
$row = $this->model->find($id);
if (!$row) {
return $this->error(__('Record not found'));
}
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds && !in_array($row[$this->dataLimitField], $dataLimitAdminIds)) {
return $this->error(__('You have no permission'));
}
$oldChannelId = $row['game_channel_id'] ?? $row['channel_id'] ?? null;
if ($this->request && $this->request->method() === 'POST') {
$data = $this->request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->applyInputFilter($data);
$data = $this->excludeFields($data);
$this->normalizeEmptyTicketCount($data);
if (array_key_exists('password', $data)) {
$password = $data['password'];
if (!is_string($password) || trim($password) === '') {
unset($data['password']);
} else {
$data['password'] = hash_password($password);
}
}
$nextUsername = array_key_exists('username', $data) ? $data['username'] : $row['username'];
$nextChannelId = null;
if (array_key_exists('channel_id', $data)) {
$nextChannelId = $data['channel_id'];
} elseif (array_key_exists('game_channel_id', $data)) {
$nextChannelId = $data['game_channel_id'];
} else {
$nextChannelId = $row['channel_id'] ?? $row['game_channel_id'] ?? null;
}
if (is_string($nextUsername) && trim($nextUsername) !== '' && $nextChannelId !== null && $nextChannelId !== '') {
$data['uuid'] = md5(trim($nextUsername) . '|' . $nextChannelId);
if ($this->gameUserUsernameExistsInChannel($nextUsername, $nextChannelId, $row[$pk])) {
return $this->error(__('Game user username exists in channel'));
}
}
if (!$this->auth->isSuperAdmin()) {
$allowed = $this->getDataLimitAdminIds();
$adminIdAfter = array_key_exists('admin_id', $data) ? $data['admin_id'] : ($row['admin_id'] ?? null);
if ($allowed !== [] && $adminIdAfter !== null && $adminIdAfter !== '' && !in_array($adminIdAfter, $allowed)) {
return $this->error(__('You have no permission'));
}
}
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) {
$validate->scene('edit');
}
$data[$pk] = $row[$pk];
$validate->check($data);
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($result !== false) {
$merged = array_merge($row->toArray(), $data);
$newChannelId = $merged['game_channel_id'] ?? $merged['channel_id'] ?? null;
if ($newChannelId !== null && $newChannelId !== '') {
GameChannelUserCount::syncFromGameUser($newChannelId);
}
if ($oldChannelId !== null && $oldChannelId !== '' && (string) $oldChannelId !== (string) $newChannelId) {
GameChannelUserCount::syncFromGameUser($oldChannelId);
}
return $this->success(__('Update successful'));
}
return $this->error(__('No rows updated'));
}
// GET: 返回编辑数据时,剔除敏感字段
unset($row['password'], $row['salt'], $row['token'], $row['refresh_token']);
return $this->success('', [
'row' => $row
]);
}
/**
* 删除后按 game_user 重算相关渠道的 user_count
*
* @throws Throwable
*/
protected function _del(): Response
{
$where = [];
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds) {
$where[] = [$this->dataLimitField, 'in', $dataLimitAdminIds];
}
$ids = $this->request ? ($this->request->post('ids') ?? $this->request->get('ids') ?? []) : [];
$ids = is_array($ids) ? $ids : [];
$where[] = [$this->model->getPk(), 'in', $ids];
$data = $this->model->where($where)->select();
$channelIdsToSync = [];
foreach ($data as $v) {
$cid = $v['game_channel_id'] ?? $v['channel_id'] ?? null;
if ($cid !== null && $cid !== '') {
$channelIdsToSync[] = $cid;
}
}
$count = 0;
$this->model->startTrans();
try {
foreach ($data as $v) {
$count += $v->delete();
}
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($count) {
GameChannelUserCount::syncChannels($channelIdsToSync);
return $this->success(__('Deleted successfully'));
}
return $this->error(__('No rows were deleted'));
}
/**
* 查看
* @throws Throwable
*/
protected function _index(): Response
{
// 如果是 select 则转发到 select 方法,若未重写该方法,其实还是继续执行 index
if ($this->request && $this->request->get('select')) {
return $this->select($this->request);
}
/**
* 1. withJoin 不可使用 alias 方法设置表别名,别名将自动使用关联模型名称(小写下划线命名规则)
* 2. 以下的别名设置了主表别名,同时便于拼接查询参数等
* 3. paginate 数据集可使用链式操作 each(function($item, $key) {}) 遍历处理
*/
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->visible(['gameChannel' => ['name'], 'admin' => ['username']])
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* 新建用户时:超管可选各渠道 default_tier_weight / default_bigwin_weight大奖仅 default_bigwin_weightdefault_kill_score_weight 为 T1T5 击杀分档位,不作大奖回退)
*
* @throws Throwable
*/
public function defaultWeightPresets(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->auth->check('game/user/index')) {
return $this->error(__('You have no permission'));
}
$allowed = $this->getAllowedChannelIdsForGameConfig();
$channelQuery = Db::name('game_channel')->field(['id', 'name'])->order('id', 'asc');
if ($allowed !== null) {
$channelQuery->where('id', 'in', $allowed);
}
$channels = $channelQuery->select()->toArray();
$tierOut = [];
$bigwinOut = [];
/** 超管:首条为 game_config.channel_id=0 的全局默认权重 */
if ($this->auth->isSuperAdmin()) {
$gp = $this->fetchDefaultWeightsForChannelId(0);
$gname = __('Global default');
$tierOut[] = [
'channel_id' => 0,
'channel_name' => $gname,
'value' => $gp['tier_weight'],
];
$bigwinOut[] = [
'channel_id' => 0,
'channel_name' => $gname,
'value' => $gp['bigwin_weight'],
];
}
if ($channels === []) {
return $this->success('', [
'tier' => $tierOut,
'bigwin' => $bigwinOut,
]);
}
$channelIds = array_column($channels, 'id');
$nameList = [self::GC_NAME_TIER, self::GC_NAME_BIGWIN_PRIMARY];
$rows = Db::name('game_config')
->where('group', self::GC_GROUP_WEIGHT)
->where('name', 'in', $nameList)
->where('channel_id', 'in', $channelIds)
->field(['channel_id', 'name', 'value'])
->select()
->toArray();
$map = [];
foreach ($rows as $row) {
$cid = $row['channel_id'];
if (!isset($map[$cid])) {
$map[$cid] = [];
}
$map[$cid][$row['name']] = $row['value'];
}
foreach ($channels as $ch) {
$cid = $ch['id'];
$cname = $ch['name'] ?? '';
$names = $map[$cid] ?? [];
$tierVal = $names[self::GC_NAME_TIER] ?? null;
$tierOut[] = [
'channel_id' => $cid,
'channel_name' => $cname,
'value' => ($tierVal !== null && $tierVal !== '') ? trim((string) $tierVal) : '[]',
];
$bigPrimary = $names[self::GC_NAME_BIGWIN_PRIMARY] ?? null;
$bigVal = ($bigPrimary !== null && $bigPrimary !== '') ? trim((string) $bigPrimary) : null;
$bigwinOut[] = [
'channel_id' => $cid,
'channel_name' => $cname,
'value' => $bigVal !== null ? $bigVal : '[]',
];
}
return $this->success('', [
'tier' => $tierOut,
'bigwin' => $bigwinOut,
]);
}
/**
* 按渠道取默认档位/大奖权重(非超管仅可访问权限内渠道)
*
* @throws Throwable
*/
public function defaultWeightByChannel(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->auth->check('game/user/index')) {
return $this->error(__('You have no permission'));
}
$channelId = $request->get('channel_id', $request->post('channel_id'));
if ($channelId === null || $channelId === '') {
return $this->error(__('Parameter error'));
}
$cid = (int) $channelId;
if ($cid === 0) {
if (!$this->auth->isSuperAdmin()) {
return $this->error(__('You have no permission'));
}
$pair = $this->fetchDefaultWeightsForChannelId(0);
return $this->success('', [
'tier_weight' => $pair['tier_weight'],
'bigwin_weight' => $pair['bigwin_weight'],
]);
}
if ($cid < 1 || !$this->canAccessChannelGameConfig($cid)) {
return $this->error(__('You have no permission'));
}
$pair = $this->fetchDefaultWeightsForChannelId($cid);
return $this->success('', [
'tier_weight' => $pair['tier_weight'],
'bigwin_weight' => $pair['bigwin_weight'],
]);
}
/**
* @return list<int|string>|null null 表示超管不限制渠道
*/
private function getAllowedChannelIdsForGameConfig(): ?array
{
if ($this->auth->isSuperAdmin()) {
return null;
}
$adminIds = parent::getDataLimitAdminIds();
if ($adminIds === []) {
return [-1];
}
$channelIds = Db::name('game_channel')->where('admin_id', 'in', $adminIds)->column('id');
if ($channelIds === []) {
return [-1];
}
return array_values(array_unique($channelIds));
}
private function canAccessChannelGameConfig(int $channelId): bool
{
$exists = Db::name('game_channel')->where('id', $channelId)->count();
if ($exists < 1) {
return false;
}
$allowed = $this->getAllowedChannelIdsForGameConfig();
if ($allowed === null) {
return true;
}
return in_array($channelId, $allowed, false);
}
/**
* @return array{tier_weight: string, bigwin_weight: string}
*/
private function fetchDefaultWeightsForChannelId(int $channelId): array
{
$rows = Db::name('game_config')
->where('channel_id', $channelId)
->where('group', self::GC_GROUP_WEIGHT)
->where('name', 'in', [self::GC_NAME_TIER, self::GC_NAME_BIGWIN_PRIMARY])
->column('value', 'name');
$tier = $rows[self::GC_NAME_TIER] ?? null;
$tierStr = ($tier !== null && $tier !== '') ? trim((string) $tier) : '[]';
$bigPrimary = $rows[self::GC_NAME_BIGWIN_PRIMARY] ?? null;
$bigStr = '[]';
if ($bigPrimary !== null && $bigPrimary !== '') {
$bigStr = trim((string) $bigPrimary);
}
// 适配导入:强制返回固定键的 JSON缺失键补空字符串
$tierStr = $this->normalizeWeightJsonToFixedKeys($tierStr, self::TIER_WEIGHT_KEYS);
$bigStr = $this->normalizeWeightJsonToFixedKeys($bigStr, self::BIGWIN_WEIGHT_KEYS);
return [
'tier_weight' => $tierStr,
'bigwin_weight' => $bigStr,
];
}
/**
* 将 JSON 权重(数组形式:[{key:value}, ...])归一化到固定键顺序。
* 缺失键补空字符串;解析失败则返回固定键且值为空。
*/
private function normalizeWeightJsonToFixedKeys(string $raw, array $fixedKeys): string
{
$raw = trim($raw);
if ($raw === '' || $raw === '[]') {
$empty = [];
foreach ($fixedKeys as $k) {
$empty[] = [$k => ''];
}
return json_encode($empty, JSON_UNESCAPED_UNICODE);
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
$empty = [];
foreach ($fixedKeys as $k) {
$empty[] = [$k => ''];
}
return json_encode($empty, JSON_UNESCAPED_UNICODE);
}
$map = [];
foreach ($decoded as $item) {
if (!is_array($item)) {
continue;
}
foreach ($item as $k => $v) {
if (!is_string($k) && !is_int($k)) {
continue;
}
$map[strval($k)] = $v === null ? '' : strval($v);
}
}
$pairs = [];
foreach ($fixedKeys as $k) {
$pairs[] = [$k => $map[$k] ?? ''];
}
return json_encode($pairs, JSON_UNESCAPED_UNICODE);
}
/**
* 当前渠道game_channel_id下是否已存在该用户名编辑时排除当前记录主键
*/
private function gameUserUsernameExistsInChannel(string $username, int|string $channelId, string|int|null $excludePk = null): bool
{
$name = trim($username);
$cid = (int) $channelId;
$query = Db::name('game_user')
->where('game_channel_id', $cid)
->where('username', $name);
if ($excludePk !== null && $excludePk !== '') {
$query->where('id', '<>', $excludePk);
}
return $query->count() > 0;
}
/**
* 若需重写查看、编辑、删除等方法,请复制 @see \app\admin\library\traits\Backend 中对应的方法至此进行重写
*/
}

View File

@@ -0,0 +1,116 @@
<?php
namespace app\admin\controller\game;
use app\common\controller\Backend;
use app\common\library\game\ZiHuaDictionary as ZiHuaDictionaryLib;
use InvalidArgumentException;
use support\think\Db;
use support\Response;
use Throwable;
use Webman\Http\Request as WebmanRequest;
/**
* 36 字花字典独立编辑(仅 game_config.zi_hua_36_dictionary
*/
class ZiHuaDictionary 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;
}
return $this->auth->check($controllerPath . '/' . $action);
}
protected function initController(WebmanRequest $request): ?Response
{
return null;
}
/**
* GET读取 36 条POST 不支持
*/
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', ZiHuaDictionaryLib::CONFIG_KEY)->find();
$items = ZiHuaDictionaryLib::parseFromConfigValue($row['config_value'] ?? null);
return $this->success('', [
'items' => $items,
'categories' => ZiHuaDictionaryLib::CATEGORIES,
]);
}
/**
* 保存 JSON 数组(仅 value_type=json
*/
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 必须为数组');
}
try {
$clean = ZiHuaDictionaryLib::prepareItemsForSave($items);
$json = ZiHuaDictionaryLib::encodeForDb($clean);
} catch (InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$now = time();
try {
$exists = Db::name('game_config')->where('config_key', ZiHuaDictionaryLib::CONFIG_KEY)->find();
if ($exists) {
Db::name('game_config')->where('config_key', ZiHuaDictionaryLib::CONFIG_KEY)->update([
'config_value' => $json,
'value_type' => 'json',
'update_time' => $now,
]);
} else {
Db::name('game_config')->insert([
'config_key' => ZiHuaDictionaryLib::CONFIG_KEY,
'config_value' => $json,
'value_type' => 'json',
'remark' => '36字花字典 JSON 数组(独立表单维护)',
'create_time' => $now,
'update_time' => $now,
]);
}
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
return $this->success(__('Saved successfully'));
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace app\admin\controller\operation;
use app\common\controller\Backend;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 运营公告
*/
class OperationNotice extends Backend
{
protected ?object $model = null;
protected string|array $preExcludeFields = ['id', 'create_time', 'update_time'];
protected string|array $quickSearchField = ['id', 'title'];
protected string|array $defaultSortField = ['id' => 'desc'];
protected string|array $orderGuarantee = ['id' => 'desc'];
protected bool $modelValidate = true;
protected bool $modelSceneValidate = true;
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\OperationNotice();
return null;
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace app\admin\controller\operation;
use app\common\controller\Backend;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 用户公告阅读记录
*/
class UserNoticeRead extends Backend
{
protected ?object $model = null;
protected string|array $preExcludeFields = ['id', 'create_time'];
protected string|array $quickSearchField = ['id', 'user_id', 'notice_id'];
protected string|array $defaultSortField = ['id' => 'desc'];
protected string|array $orderGuarantee = ['id' => 'desc'];
protected array $withJoinTable = ['user', 'operationNotice'];
protected bool $modelValidate = true;
protected bool $modelSceneValidate = true;
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\UserNoticeRead();
return null;
}
}

View File

@@ -0,0 +1,128 @@
<?php
namespace app\admin\controller\order;
use app\common\controller\Backend;
use support\think\Db;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 压注订单(业务下单写入,后台以查询为主)
*/
class BetOrder extends Backend
{
protected ?object $model = null;
protected bool $modelValidate = false;
protected string|array $quickSearchField = ['id', 'period_no', 'idempotency_key'];
protected string|array $defaultSortField = ['id' => 'desc'];
protected string|array $orderGuarantee = ['id' => 'desc'];
protected array $withJoinTable = ['user', 'channel', 'gamePeriod'];
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\BetOrder();
return null;
}
public function add(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
return $this->error('注单由游戏接口生成,禁止后台手工新增');
}
public function edit(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
return $this->error('注单不可编辑');
}
public function del(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
return $this->error('注单不可删除');
}
public function sortable(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
return $this->error('不支持排序');
}
/**
* 渠道管理员仅看本渠道注单
*/
protected function _index(): Response
{
if ($this->request && $this->request->get('select')) {
return $this->select($this->request);
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$table = strtolower($this->model->getTable());
$mainShort = $alias[$table] ?? '';
if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) {
$channelIds = $this->getScopedChannelIdsForFilter();
$where[] = [$mainShort . '.channel_id', 'in', $channelIds !== [] ? $channelIds : [0]];
}
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->visible([
'user' => ['username', 'phone'],
'channel' => ['name'],
'gamePeriod' => ['period_no', 'status'],
])
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* @return int[]
*/
private function getScopedChannelIdsForFilter(): array
{
if (!$this->auth) {
return [0];
}
if ($this->auth->isSuperAdmin()) {
return [];
}
$admin = Db::name('admin')
->field(['id', 'channel_id'])
->where('id', $this->auth->id)
->find();
$ids = [];
if ($admin && !empty($admin['channel_id'])) {
$ids[] = $admin['channel_id'];
}
$byAdmin = Db::name('channel')->where('admin_id', $this->auth->id)->column('id');
return array_values(array_unique(array_merge($ids, $byAdmin)));
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace app\admin\controller\order;
use app\common\controller\Backend;
use support\think\Db;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 充值订单
*/
class DepositOrder extends Backend
{
protected ?object $model = null;
protected bool $modelValidate = true;
protected bool $modelSceneValidate = true;
protected string|array $quickSearchField = ['id', 'order_no', 'pay_channel', 'remark'];
protected string|array $defaultSortField = ['id' => 'desc'];
protected string|array $orderGuarantee = ['id' => 'desc'];
protected array $withJoinTable = ['user', 'channel'];
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\DepositOrder();
return null;
}
protected function _index(): Response
{
if ($this->request && $this->request->get('select')) {
return $this->select($this->request);
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$table = strtolower($this->model->getTable());
$mainShort = $alias[$table] ?? '';
if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) {
$channelIds = $this->getScopedChannelIdsForFilter();
$where[] = [$mainShort . '.channel_id', 'in', $channelIds !== [] ? $channelIds : [0]];
}
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->visible([
'user' => ['username', 'phone'],
'channel' => ['name'],
])
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* @return int[]
*/
private function getScopedChannelIdsForFilter(): array
{
if (!$this->auth) {
return [0];
}
if ($this->auth->isSuperAdmin()) {
return [];
}
$admin = Db::name('admin')->field(['id', 'channel_id'])->where('id', $this->auth->id)->find();
$ids = [];
if ($admin && !empty($admin['channel_id'])) {
$ids[] = $admin['channel_id'];
}
$byAdmin = Db::name('channel')->where('admin_id', $this->auth->id)->column('id');
return array_values(array_unique(array_merge($ids, $byAdmin)));
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace app\admin\controller\order;
use app\common\controller\Backend;
use support\think\Db;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 提现订单
*/
class WithdrawOrder extends Backend
{
protected ?object $model = null;
protected bool $modelValidate = true;
protected bool $modelSceneValidate = true;
protected string|array $quickSearchField = ['id', 'order_no', 'remark'];
protected string|array $defaultSortField = ['id' => 'desc'];
protected string|array $orderGuarantee = ['id' => 'desc'];
protected array $withJoinTable = ['user', 'channel', 'reviewAdmin'];
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\WithdrawOrder();
return null;
}
protected function _index(): Response
{
if ($this->request && $this->request->get('select')) {
return $this->select($this->request);
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$table = strtolower($this->model->getTable());
$mainShort = $alias[$table] ?? '';
if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) {
$channelIds = $this->getScopedChannelIdsForFilter();
$where[] = [$mainShort . '.channel_id', 'in', $channelIds !== [] ? $channelIds : [0]];
}
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->visible([
'user' => ['username', 'phone'],
'channel' => ['name'],
'reviewAdmin' => ['username'],
])
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* @return int[]
*/
private function getScopedChannelIdsForFilter(): array
{
if (!$this->auth) {
return [0];
}
if ($this->auth->isSuperAdmin()) {
return [];
}
$admin = Db::name('admin')->field(['id', 'channel_id'])->where('id', $this->auth->id)->find();
$ids = [];
if ($admin && !empty($admin['channel_id'])) {
$ids[] = $admin['channel_id'];
}
$byAdmin = Db::name('channel')->where('admin_id', $this->auth->id)->column('id');
return array_values(array_unique(array_merge($ids, $byAdmin)));
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace app\admin\controller\record;
use app\common\controller\Backend;
use support\think\Db;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 玩家钱包流水(只读列表,数据由业务入账写入本表)
*/
class UserWalletRecord extends Backend
{
protected ?object $model = null;
protected bool $modelValidate = false;
protected string|array $quickSearchField = ['id', 'biz_type', 'ref_type', 'remark', 'idempotency_key'];
protected string|array $defaultSortField = ['id' => 'desc'];
protected string|array $orderGuarantee = ['id' => 'desc'];
protected array $withJoinTable = ['user', 'channel', 'operatorAdmin'];
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\UserWalletRecord();
return null;
}
public function add(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
return $this->error('钱包流水仅允许业务入账,禁止后台手工新增');
}
public function edit(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
return $this->error('钱包流水不可编辑');
}
public function del(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
return $this->error('钱包流水不可删除');
}
public function sortable(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
return $this->error('不支持排序');
}
/**
* 列表渠道管理员仅看本渠道流水channel_id 快照)
*/
protected function _index(): Response
{
if ($this->request && $this->request->get('select')) {
return $this->select($this->request);
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$table = strtolower($this->model->getTable());
$mainShort = $alias[$table] ?? '';
if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) {
$channelIds = $this->getScopedChannelIdsForFilter();
$where[] = [$mainShort . '.channel_id', 'in', $channelIds !== [] ? $channelIds : [0]];
}
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->visible([
'user' => ['username', 'phone'],
'channel' => ['name'],
'operatorAdmin' => ['username'],
])
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* 非超管:与渠道管理一致,仅本账号相关渠道
*
* @return int[]
*/
private function getScopedChannelIdsForFilter(): array
{
if (!$this->auth) {
return [0];
}
if ($this->auth->isSuperAdmin()) {
return [];
}
$admin = Db::name('admin')
->field(['id', 'channel_id'])
->where('id', $this->auth->id)
->find();
$ids = [];
if ($admin && !empty($admin['channel_id'])) {
$ids[] = $admin['channel_id'];
}
$byAdmin = Db::name('channel')->where('admin_id', $this->auth->id)->column('id');
return array_values(array_unique(array_merge($ids, $byAdmin)));
}
}

View File

@@ -1,150 +0,0 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\user;
use Throwable;
use app\admin\model\UserRule;
use app\admin\model\UserGroup;
use app\common\controller\Backend;
use Webman\Http\Request;
use support\Response;
class Group extends Backend
{
protected ?object $model = null;
protected array|string $preExcludeFields = ['update_time', 'create_time'];
protected array|string $quickSearchField = 'name';
protected function initController(Request $request): ?Response
{
$this->model = new UserGroup();
return null;
}
public function select(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
list($where, $alias, $limit, $order) = $this->queryBuilder();
$data = $this->model
->alias($alias)
->where($where)
->order($order)
->limit(9999)
->select()
->toArray();
return $this->success('', [
'options' => $data,
]);
}
public function add(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$data = $request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
$data = $this->handleRules($data);
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validateClass = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validateClass)) {
$validate = new $validateClass();
$validate->scene('add')->check($data);
}
}
$result = $this->model->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
return $result !== false ? $this->success(__('Added successfully')) : $this->error(__('No rows were added'));
}
public function edit(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$pk = $this->model->getPk();
$id = $request->post($pk) ?? $request->get($pk);
$row = $this->model->find($id);
if (!$row) {
return $this->error(__('Record not found'));
}
if ($request->method() === 'POST') {
$data = $request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
$data = $this->handleRules($data);
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validateClass = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validateClass)) {
$validate = new $validateClass();
$validate->scene('edit')->check(array_merge($data, [$pk => $row[$pk]]));
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
return $result !== false ? $this->success(__('Update successful')) : $this->error(__('No rows updated'));
}
$pidArr = UserRule::field('pid')->distinct(true)->where('id', 'in', $row->rules)->select()->toArray();
$rules = $row->rules ? explode(',', $row->rules) : [];
foreach ($pidArr as $item) {
$ruKey = array_search($item['pid'], $rules);
if ($ruKey !== false) {
unset($rules[$ruKey]);
}
}
$rowData = $row->toArray();
$rowData['rules'] = array_values($rules);
return $this->success('', ['row' => $rowData]);
}
private function handleRules(array $data): array
{
if (isset($data['rules']) && is_array($data['rules']) && $data['rules']) {
$rules = UserRule::select();
$super = true;
foreach ($rules as $rule) {
if (!in_array($rule['id'], $data['rules'])) {
$super = false;
break;
}
}
$data['rules'] = $super ? '*' : implode(',', $data['rules']);
} else {
unset($data['rules']);
}
return $data;
}
}

View File

@@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\user;
use app\admin\model\User;
use app\admin\model\UserMoneyLog;
use app\common\controller\Backend;
use Webman\Http\Request;
use support\Response;
class MoneyLog extends Backend
{
protected ?object $model = null;
protected array $withJoinTable = ['user'];
protected array|string $preExcludeFields = ['create_time'];
protected array|string $quickSearchField = ['user.username', 'user.nickname'];
protected function initController(Request $request): ?Response
{
$this->model = new UserMoneyLog();
return null;
}
public function add(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->method() === 'POST') {
return $this->_add();
}
$userId = $request->get('userId', $request->post('userId', 0));
$user = User::where('id', $userId)->find();
if (!$user) {
return $this->error(__("The user can't find it"));
}
return $this->success('', ['user' => $user]);
}
}

View File

@@ -1,98 +0,0 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\user;
use ba\Tree;
use app\common\controller\Backend;
use app\admin\model\UserRule;
use Webman\Http\Request;
use support\Response;
class Rule extends Backend
{
protected ?object $model = null;
protected Tree $tree;
protected array $initValue = [];
protected bool $assembleTree = true;
protected string $keyword = '';
protected array|string $preExcludeFields = ['create_time', 'update_time'];
protected array|string $defaultSortField = ['weigh' => 'desc'];
protected array|string $quickSearchField = 'title';
protected function initController(Request $request): ?Response
{
$this->model = new UserRule();
$this->tree = Tree::instance();
$isTree = filter_var($request->get('isTree', $request->post('isTree', true)), FILTER_VALIDATE_BOOLEAN);
$initValue = $request->get('initValue') ?? $request->post('initValue') ?? [];
$this->initValue = is_array($initValue) ? array_filter($initValue) : [];
$this->keyword = $request->get('quickSearch', $request->post('quickSearch', ''));
$this->assembleTree = $isTree && !$this->initValue;
return null;
}
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->get('select') || $request->post('select')) {
return $this->select($request);
}
list($where, , , ) = $this->queryBuilder();
$list = $this->model->where($where)->order($this->defaultSortField)->select();
if ($this->assembleTree) {
$list = $this->tree->assembleChild($list->toArray());
} else {
$list = $list->toArray();
}
return $this->success('', [
'list' => $list,
'remark' => get_route_remark(),
]);
}
public function add(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
return $this->_add();
}
public function edit(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
return $this->_edit();
}
public function del(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
return $this->_del();
}
public function select(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
list($where, , , ) = $this->queryBuilder();
$list = $this->model->where($where)->order($this->defaultSortField)->select();
if ($this->assembleTree) {
$list = $this->tree->assembleChild($list->toArray());
} else {
$list = $list->toArray();
}
return $this->success('', ['list' => $list]);
}
}

View File

@@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\user;
use app\admin\model\User;
use app\admin\model\UserScoreLog;
use app\common\controller\Backend;
use Webman\Http\Request;
use support\Response;
class ScoreLog extends Backend
{
protected ?object $model = null;
protected array $withJoinTable = ['user'];
protected array|string $preExcludeFields = ['create_time'];
protected array|string $quickSearchField = ['user.username', 'user.nickname'];
protected function initController(Request $request): ?Response
{
$this->model = new UserScoreLog();
return null;
}
public function add(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->method() === 'POST') {
return $this->_add();
}
$userId = $request->get('userId', $request->post('userId', 0));
$user = User::where('id', $userId)->find();
if (!$user) {
return $this->error(__("The user can't find it"));
}
return $this->success('', ['user' => $user]);
}
}

View File

@@ -1,101 +1,107 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\user;
use Throwable;
use app\common\controller\Backend;
use app\admin\model\User as UserModel;
use Webman\Http\Request;
use support\think\Db;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 用户管理
*/
class User extends Backend
{
/**
* User模型对象
* @var object|null
* @phpstan-var \app\common\model\User|null
*/
protected ?object $model = null;
protected array $withJoinTable = ['userGroup'];
protected array|string $preExcludeFields = ['last_login_time', 'login_failure', 'password', 'salt'];
protected array|string $quickSearchField = ['username', 'nickname', 'id'];
protected array|string $preExcludeFields = ['id', 'uuid', 'create_time', 'update_time'];
protected function initController(Request $request): ?Response
protected array $withJoinTable = ['channel', 'admin'];
protected string|array $quickSearchField = ['id', 'username', 'phone'];
protected bool $modelSceneValidate = true;
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new UserModel();
$this->model = new \app\common\model\User();
return null;
}
public function index(Request $request): Response
/**
* 添加重写password 使用 Admin 同款加密uuid 由 username+channel_id 生成)
* @throws Throwable
*/
protected function _add(): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($this->request && $this->request->method() === 'POST') {
$data = $this->request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
if ($request->get('select') || $request->post('select')) {
return $this->select($request);
}
$data = $this->applyInputFilter($data);
$data = $this->excludeFields($data);
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withoutField('password,salt')
->withJoin($this->withJoinTable, $this->withJoinType)
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
$password = $data['password'] ?? null;
if (!is_string($password) || trim($password) === '') {
return $this->error(__('Parameter %s can not be empty', ['password']));
}
$data['password'] = hash_password($password);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
$username = $data['username'] ?? '';
$channelId = $data['channel_id'] ?? null;
if (!is_string($username) || trim($username) === '' || $channelId === null || $channelId === '') {
return $this->error(__('Parameter %s can not be empty', ['username/channel_id']));
}
$data['uuid'] = md5(trim($username) . '|' . $channelId);
public function add(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($this->dataLimit && $this->dataLimitFieldAutoFill) {
$data[$this->dataLimitField] = $this->auth->id;
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$data = $request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$passwd = $data['password'] ?? '';
$data = $this->excludeFields($data);
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validateClass = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validateClass)) {
$validate = new $validateClass();
if ($this->modelSceneValidate) $validate->scene('add');
$validate->check($data);
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) {
$validate->scene('add');
}
$validate->check($data);
}
}
$result = $this->model->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
$result = $this->model->save($data);
$this->model->commit();
if ($passwd) {
$this->model->resetPassword($this->model->id, $passwd);
if ($result !== false) {
return $this->success(__('Added successfully'));
}
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
return $this->error(__('No rows were added'));
}
return $result !== false ? $this->success(__('Added successfully')) : $this->error(__('No rows were added'));
return $this->error(__('Parameter error'));
}
public function edit(Request $request): Response
/**
* 编辑重写password 使用 Admin 同款加密uuid 由 username+channel_id 生成)
* @throws Throwable
*/
protected function _edit(): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$pk = $this->model->getPk();
$id = $request->post($pk) ?? $request->get($pk);
$id = $this->request ? ($this->request->post($pk) ?? $this->request->get($pk)) : null;
$row = $this->model->find($id);
if (!$row) {
return $this->error(__('Record not found'));
@@ -106,23 +112,48 @@ class User extends Backend
return $this->error(__('You have no permission'));
}
if ($request->method() === 'POST') {
$data = $request->post();
if ($this->request && $this->request->method() === 'POST') {
$data = $this->request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
if (!empty($data['password'])) {
$this->model->resetPassword($row->id, $data['password']);
}
$data = $this->applyInputFilter($data);
$data = $this->excludeFields($data);
if (array_key_exists('password', $data)) {
$password = $data['password'];
if (!is_string($password) || trim($password) === '') {
unset($data['password']);
} else {
$data['password'] = hash_password($password);
}
}
$nextUsername = array_key_exists('username', $data) ? $data['username'] : $row['username'];
$nextChannelId = null;
if (array_key_exists('channel_id', $data)) {
$nextChannelId = $data['channel_id'];
} else {
$nextChannelId = $row['channel_id'] ?? null;
}
if (is_string($nextUsername) && trim($nextUsername) !== '' && $nextChannelId !== null && $nextChannelId !== '') {
$data['uuid'] = md5(trim($nextUsername) . '|' . $nextChannelId);
}
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validateClass = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validateClass)) {
$validate = new $validateClass();
$validate->scene('edit')->check(array_merge($data, [$pk => $row[$pk]]));
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) {
$validate->scene('edit');
}
$data[$pk] = $row[$pk];
$validate->check($data);
}
}
$result = $row->save($data);
@@ -131,31 +162,182 @@ class User extends Backend
$this->model->rollback();
return $this->error($e->getMessage());
}
return $result !== false ? $this->success(__('Update successful')) : $this->error(__('No rows updated'));
if ($result !== false) {
return $this->success(__('Update successful'));
}
return $this->error(__('No rows updated'));
}
unset($row['password'], $row['salt']);
$row['password'] = '';
return $this->success('', ['row' => $row]);
// GET: 返回编辑数据时,剔除敏感字段
unset($row['password'], $row['salt'], $row['token'], $row['refresh_token']);
return $this->success('', [
'row' => $row
]);
}
public function select(Request $request): Response
/**
* 查看
* @throws Throwable
*/
protected function _index(): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
// 如果是 select 则转发到 select 方法,若未重写该方法,其实还是继续执行 index
if ($this->request && $this->request->get('select')) {
return $this->select($this->request);
}
/**
* 1. withJoin 不可使用 alias 方法设置表别名,别名将自动使用关联模型名称(小写下划线命名规则)
* 2. 以下的别名设置了主表别名,同时便于拼接查询参数等
* 3. paginate 数据集可使用链式操作 each(function($item, $key) {}) 遍历处理
*/
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withoutField('password,salt')
->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->visible(['channel' => ['name'], 'admin' => ['username']])
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
}
/**
* 角色组 → 管理员树(仅当前账号可管理的角色组及其下管理员;用于游戏用户归属)
* 同一管理员若属于多个组,只挂在 id 最小的所属组下,避免树中重复 value
*/
public function adminScopeTree(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
$groupIds = $this->getManageableAdminGroupIds();
if ($groupIds === []) {
return $this->success('', ['list' => []]);
}
$groups = Db::name('admin_group')
->where('id', 'in', $groupIds)
->where('status', 1)
->field(['id', 'pid', 'name'])
->order('id', 'asc')
->select()
->toArray();
$accessRows = Db::name('admin_group_access')->alias('aga')
->join('admin a', 'aga.uid = a.id')
->where('aga.group_id', 'in', $groupIds)
->field(['a.id', 'a.username', 'a.channel_id', 'a.invite_code', 'aga.group_id'])
->select()
->toArray();
$adminPrimary = [];
foreach ($accessRows as $row) {
$uid = intval((string)$row['id']);
$gid = intval((string)$row['group_id']);
if (!isset($adminPrimary[$uid]) || $gid < $adminPrimary[$uid]['gid']) {
$adminPrimary[$uid] = [
'gid' => $gid,
'user' => $row,
];
}
}
$adminsByGroup = [];
foreach ($adminPrimary as $item) {
$row = $item['user'];
$gid = $item['gid'];
$invite = $row['invite_code'] ?? '';
$invite = is_string($invite) ? $invite : '';
$channelId = $row['channel_id'] ?? null;
$adminsByGroup[$gid][] = [
'value' => (string)$row['id'],
'label' => (string)$row['username'],
'is_leaf' => true,
'channel_id' => $channelId === null || $channelId === '' ? null : intval((string)$channelId),
'invite_code' => $invite,
];
}
$groupMap = [];
foreach ($groups as $g) {
$groupMap[intval((string)$g['id'])] = $g;
}
$childGroupIdsByPid = [];
foreach ($groups as $g) {
$id = intval((string)$g['id']);
$pid = intval((string)($g['pid'] ?? 0));
$childGroupIdsByPid[$pid][] = $id;
}
$buildNode = null;
$buildNode = function (int $groupId) use (&$buildNode, $groupMap, $childGroupIdsByPid, $adminsByGroup): array {
if (!isset($groupMap[$groupId])) {
return [];
}
$g = $groupMap[$groupId];
$children = [];
foreach ($childGroupIdsByPid[$groupId] ?? [] as $childId) {
$children[] = $buildNode($childId);
}
foreach ($adminsByGroup[$groupId] ?? [] as $leaf) {
$children[] = $leaf;
}
return [
'value' => 'group_' . $groupId,
'label' => (string)$g['name'],
'disabled' => true,
'children' => $children,
];
};
$groupIdSet = array_fill_keys(array_keys($groupMap), true);
$roots = [];
foreach ($groups as $g) {
$id = intval((string)$g['id']);
$pid = intval((string)($g['pid'] ?? 0));
if ($pid === 0 || !isset($groupIdSet[$pid])) {
$roots[] = $id;
}
}
$roots = array_values(array_unique($roots));
sort($roots);
$tree = [];
foreach ($roots as $rid) {
$tree[] = $buildNode($rid);
}
return $this->success('', [
'list' => $tree,
]);
}
/**
* @return int[]
*/
private function getManageableAdminGroupIds(): array
{
if ($this->auth->isSuperAdmin()) {
return Db::name('admin_group')->where('status', 1)->column('id');
}
$own = Db::name('admin_group_access')->where('uid', $this->auth->id)->column('group_id');
$own = array_map('intval', $own);
$children = array_map('intval', $this->auth->getAdminChildGroups());
return array_values(array_unique(array_merge($own, $children)));
}
/**
* 若需重写查看、编辑、删除等方法,请复制 @see \app\admin\library\traits\Backend 中对应的方法至此进行重写
*/
}

View File

@@ -34,7 +34,6 @@ return [
'The uploaded file is too large (%sMiB), Maximum file size:%sMiB' => 'The uploaded file is too large (%sMiB), maximum file size:%sMiB',
'No files have been uploaded or the file size exceeds the upload limit of the server' => 'No files have been uploaded or the file size exceeds the server upload limit.',
'Unknown' => 'Unknown',
'Global default' => 'Global default (channel_id=0)',
'Super administrator' => 'Super administrator',
'No permission' => 'No permission',
'%first% etc. %count% items' => '%first% etc. %count% items',
@@ -95,17 +94,6 @@ return [
'No rows were restore' => 'No rows were restored',
'%d records and files have been deleted' => '%d records and files have been deleted',
'Please input correct username' => 'Please enter the correct username',
'Please enter a valid commission rate for non-top role group' => 'Non-top role groups require a commission rate between 0 and 100 (%)',
'Group Name Arr' => 'Group Name Arr',
'Game config weight keys cannot be modified' => 'Weight config keys cannot be modified',
'Game config weight value must be numeric' => 'Weight values must be numeric',
'Game config weight each value must not exceed 100' => 'Weight must not exceed 10000',
'Game config weight each value must not exceed 10000' => 'Weight must not exceed 10000',
'Game config weight sum must equal 100' => 'The sum of weights for default_tier_weight / default_kill_score_weight must equal 100',
'Game config weight sum must not exceed 100' => 'The sum of weights for default_tier_weight / default_kill_score_weight must not exceed 100',
'Game config bigwin weight must be integer' => 'Each default big win weight must be an integer',
'Game config bigwin weight each 0 10000' => 'Each default big win weight must be between 0 and 10000',
'Game config bigwin weight locked 5 30' => 'Dice totals 5 and 30 are guaranteed leopard (豹子); weight is fixed at 10000 and cannot be changed',
'Game user username exists in channel' => 'This username already exists in the selected channel',
'Game channel delete need confirm related' => 'Please confirm deletion of related game configuration and user data first',
'Game channel copy default config failed' => 'Channel was created, but copying default game configuration failed',
];

View File

@@ -9,8 +9,5 @@ return [
'You need to have all the permissions of the group and have additional permissions before you can operate the group~' => 'You need to have all the permissions of the group and have additional permissions before you can operate the group~',
'Role group has all your rights, please contact the upper administrator to add or do not need to add!' => 'Role group has all your rights, please contact the upper administrator to add or do not need to add!',
'The group permission node exceeds the range that can be allocated' => 'The group permission node exceeds the range that can be allocated, please refresh and try again~',
'You can only operate subordinate role groups in the tree hierarchy~' => 'You can only manage role groups that are strictly below yours in the role-group tree (not peers or other branches).',
'Non super administrators cannot create top-level role groups' => 'Non super administrators cannot create top-level role groups',
'The parent group is not within your manageable scope' => 'The selected parent group is outside your manageable scope',
'Remark lang' => 'Role groups follow the tree (parent/child): you may only manage groups under your own membership branch; peers, other branches and ancestors cannot be managed here. When assigning rules you can still only select permission nodes you own.',
'Remark lang' => 'For system security, the hierarchical relationship of role groups is for reference only. The actual hierarchy is determined by the number of permission nodes: same permissions = peer, containing and having additional permissions = superior. Peers cannot manage peers; superiors can assign their permission nodes to subordinates. For special cases where an admin needs to become a superior, create a virtual permission node.',
];

View File

@@ -37,7 +37,6 @@ return [
'Topic format error' => '上传存储子目录格式错误!',
'Driver %s not supported' => '不支持的驱动:%s',
'Unknown' => '未知',
'Global default' => '全局默认channel_id=0',
// 权限类语言包-s
'Super administrator' => '超级管理员',
'No permission' => '无权限',
@@ -114,17 +113,6 @@ return [
'No rows were restore' => '未恢复任何行',
'%d records and files have been deleted' => '已删除%d条记录和文件',
'Please input correct username' => '请输入正确的用户名',
'Please enter a valid commission rate for non-top role group' => '非顶级角色组须填写 0100 的分红比例(%)',
'Group Name Arr' => '分组名称数组',
'Game config weight keys cannot be modified' => '权重配置的键不可修改',
'Game config weight value must be numeric' => '权重值必须为数字',
'Game config weight each value must not exceed 100' => '权重不能超过10000',
'Game config weight each value must not exceed 10000' => '权重不能超过10000',
'Game config weight sum must equal 100' => 'default_tier_weight / default_kill_score_weight 的权重之和必须等于100',
'Game config weight sum must not exceed 100' => 'default_tier_weight / default_kill_score_weight 的权重之和不能超过100',
'Game config bigwin weight must be integer' => '默认大奖权重每项必须为整数',
'Game config bigwin weight each 0 10000' => '默认大奖权重每项须在 010000 之间',
'Game config bigwin weight locked 5 30' => '点数 5 与 30 为豹子号必中,权重固定为 10000不可修改',
'Game user username exists in channel' => '该渠道下已存在相同用户名的用户',
'Game channel delete need confirm related' => '请先确认是否同时删除关联的游戏配置与用户数据',
'Game channel copy default config failed' => '渠道已创建,但复制默认游戏配置失败',
];

View File

@@ -9,8 +9,5 @@ return [
'You need to have all the permissions of the group and have additional permissions before you can operate the group~' => '您需要拥有该分组的所有权限且还有额外权限时,才可以操作该分组~',
'Role group has all your rights, please contact the upper administrator to add or do not need to add!' => '角色组拥有您的全部权限,请联系上级管理员添加或无需添加!',
'The group permission node exceeds the range that can be allocated' => '分组权限节点超出可分配范围,请刷新重试~',
'You can only operate subordinate role groups in the tree hierarchy~' => '仅可管理在角色组树中属于您下级的角色组(不含同级、不含其他分支)~',
'Non super administrators cannot create top-level role groups' => '非超级管理员不能创建顶级角色组',
'The parent group is not within your manageable scope' => '所选父级角色组不在您可管理的范围内',
'Remark lang' => '角色组以「树形父子关系」为准:仅可管理本人所在组之下的下级组;同级、其他分支及上级组不可在此管理。分配权限时仍只能勾选您自身拥有的权限节点。',
'Remark lang' => '为保障系统安全,角色组本身的上下级关系仅供参考,系统的实际上下级划分是根据`权限多寡`来确定的,两位管理员的权限节点:相同被认为是`同级`、包含且有额外权限才被认为是`上级`,同级不可管理同级,上级可为下级分配自己拥有的权限节点;若有特殊情况管理员需转`上级`,可建立一个虚拟权限节点',
];

View File

@@ -297,26 +297,6 @@ class Auth extends \ba\Auth
return array_unique($children);
}
/**
* 本人 + 树形下级角色组内的管理员 ID与管理员管理列表数据范围一致
*/
public function getSelfAndSubordinateAdminIds(): array
{
if ($this->isSuperAdmin()) {
return [];
}
$descendantGroupIds = $this->getAdminChildGroups();
$adminIds = [];
if ($descendantGroupIds !== []) {
$adminIds = Db::name('admin_group_access')
->where('group_id', 'in', $descendantGroupIds)
->column('uid');
}
$adminIds[] = $this->id;
return array_values(array_unique($adminIds));
}
public function getGroupChildGroups(int $groupId, array &$children): void
{
$childrenTemp = AdminGroup::where('pid', $groupId)

View File

@@ -802,8 +802,8 @@ class Helper
$indexVueData['defaultItems'] = self::getJsonFromArray($indexVueData['defaultItems'] ?? []);
$indexVueData['tableColumn'] = self::buildTableColumn($indexVueData['tableColumn'] ?? []);
$indexVueData['dblClickNotEditColumn'] = self::buildSimpleArray($indexVueData['dblClickNotEditColumn'] ?? ['undefined']);
$controllerFile['path'][] = $controllerFile['originalLastName'];
$indexVueData['controllerUrl'] = '\'/admin/' . ($controllerFile['path'] ? implode('.', $controllerFile['path']) : '') . '/\'';
$urlSegments = array_merge($controllerFile['path'], [$controllerFile['originalLastName']]);
$indexVueData['controllerUrl'] = '\'/admin/' . ($urlSegments ? implode('.', array_map('strtolower', $urlSegments)) : '') . '/\'';
$indexVueData['componentName'] = ($webViewsDir['path'] ? implode('/', $webViewsDir['path']) . '/' : '') . $webViewsDir['originalLastName'];
$indexVueContent = self::assembleStub('html/index', $indexVueData);
self::writeFile(root_path() . $webViewsDir['views'] . '/' . 'index.vue', $indexVueContent);

View File

@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
namespace app\admin\model;
use app\common\model\traits\TimestampInteger;
use support\think\Model;
use think\model\relation\BelongsTo;
class User extends Model
{
use TimestampInteger;
protected string $table = 'user';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
public function getAvatarAttr($value): string
{
return full_url($value ?? '', false, config('buildadmin.default_avatar'));
}
public function setAvatarAttr($value): string
{
return $value === full_url('', false, config('buildadmin.default_avatar')) ? '' : $value;
}
public function getMoneyAttr($value): string
{
return bcdiv((string) $value, '100', 2);
}
public function setMoneyAttr($value): string
{
return bcmul((string) $value, '100', 2);
}
public function userGroup(): BelongsTo
{
return $this->belongsTo(UserGroup::class, 'group_id');
}
public function resetPassword(int|string $uid, string $newPassword): int
{
return $this->where(['id' => $uid])->update(['password' => hash_password($newPassword), 'salt' => '']);
}
}

View File

@@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace app\admin\model;
use app\common\model\traits\TimestampInteger;
use support\think\Model;
class UserGroup extends Model
{
use TimestampInteger;
protected string $table = 'user_group';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
}

View File

@@ -1,72 +0,0 @@
<?php
namespace app\admin\model;
use app\common\model\traits\TimestampInteger;
use support\think\Model;
use think\model\relation\BelongsTo;
class UserMoneyLog extends Model
{
use TimestampInteger;
protected string $table = 'user_money_log';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
protected bool $updateTime = false;
public static function onBeforeInsert($model): void
{
$user = User::where('id', $model->user_id)->lock(true)->find();
if (!$user) {
throw new \Exception(__("The user can't find it"));
}
if (!$model->memo) {
throw new \Exception(__("Change note cannot be blank"));
}
$model->before = $user->money;
$user->money += $model->money;
$user->save();
$model->after = $user->money;
}
public static function onBeforeDelete(): bool
{
return false;
}
public function getMoneyAttr($value): string
{
return bcdiv((string) $value, '100', 2);
}
public function setMoneyAttr($value): string
{
return bcmul((string) $value, '100', 2);
}
public function getBeforeAttr($value): string
{
return bcdiv((string) $value, '100', 2);
}
public function setBeforeAttr($value): string
{
return bcmul((string) $value, '100', 2);
}
public function getAfterAttr($value): string
{
return bcdiv((string) $value, '100', 2);
}
public function setAfterAttr($value): string
{
return bcmul((string) $value, '100', 2);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -1,42 +0,0 @@
<?php
namespace app\admin\model;
use app\common\model\traits\TimestampInteger;
use support\think\Model;
use think\model\relation\BelongsTo;
class UserScoreLog extends Model
{
use TimestampInteger;
protected string $table = 'user_score_log';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
protected bool $updateTime = false;
public static function onBeforeInsert($model): void
{
$user = User::where('id', $model->user_id)->lock(true)->find();
if (!$user) {
throw new \Exception(__("The user can't find it"));
}
if (!$model->memo) {
throw new \Exception(__("Change note cannot be blank"));
}
$model->before = $user->score;
$user->score += $model->score;
$user->save();
$model->after = $user->score;
}
public static function onBeforeDelete(): bool
{
return false;
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace app\admin\validate;
use think\Validate;
class UserMoneyLog extends Validate
{
protected $failException = true;
protected $rule = [
'user_id' => 'require',
'money' => 'require',
'memo' => 'require',
];
protected $message = [];
protected $field = [];
protected $scene = [
'add' => ['user_id', 'money', 'memo'],
'edit' => ['user_id', 'money', 'memo'],
];
public function __construct()
{
$this->field = [
'user_id' => __('user_id'),
'money' => __('money'),
'memo' => __('memo'),
];
parent::__construct();
}
}

View File

@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace app\admin\validate;
use think\Validate;
class UserScoreLog extends Validate
{
protected $failException = true;
protected $rule = [
'user_id' => 'require',
'score' => 'require',
'memo' => 'require',
];
protected $message = [];
protected $field = [];
protected $scene = [
'add' => ['user_id', 'score', 'memo'],
'edit' => ['user_id', 'score', 'memo'],
];
public function __construct()
{
$this->field = [
'user_id' => __('user_id'),
'score' => __('score'),
'memo' => __('memo'),
];
parent::__construct();
}
}

View File

@@ -11,7 +11,6 @@ use ba\Terminal;
use ba\Filesystem;
use app\common\controller\Api;
use app\admin\model\Admin as AdminModel;
use app\admin\model\User as UserModel;
use app\process\Monitor;
use support\Response;
use Webman\Http\Request;
@@ -627,10 +626,6 @@ class Install extends Api
$adminModel->resetPassword($defaultAdmin->id, $param['adminpassword']);
}
// 默认用户密码修改
$user = new UserModel();
$user->resetPassword(1, Random::build());
// 修改站点名称
if (class_exists(\app\admin\model\Config::class)) {
\app\admin\model\Config::where('name', 'site_name')->update([
@@ -668,7 +663,7 @@ class Install extends Api
/**
* 获取安装完成后的访问地址(根据请求来源区分 API 与前端开发模式)
* - 通过 API 访问8787index.html#/admin、index.html#/
* - 通过 API 访问8787index#/admin、index#/(无 index 与 # 之间的斜杠)
* - 通过前端开发服务访问1818/#/admin、/#/
*/
public function accessUrls(Request $request): Response
@@ -680,7 +675,8 @@ class Install extends Api
$port = substr($host, strrpos($host, ':') + 1);
}
$scheme = $request->header('x-forwarded-proto', 'http');
$base = rtrim($scheme . '://' . $host, '/');
$basePath = $request instanceof \support\Request ? $request->publicBasePath() : '';
$base = rtrim($scheme . '://' . $host, '/') . $basePath;
if ($port === '1818') {
$adminUrl = $base . '/#/admin';

View File

@@ -6,6 +6,7 @@ use ba\Captcha;
use ba\ClickCaptcha;
use app\common\controller\Frontend;
use app\common\facade\Token;
use support\think\Db;
use support\validation\Validator;
use support\validation\ValidationException;
use Webman\Http\Request;
@@ -46,6 +47,7 @@ class User extends Frontend
'captchaId' => $params['captchaId'] ?? '',
'captchaInfo' => $params['captchaInfo'] ?? '',
'registerType' => $params['registerType'] ?? '',
'invite_code' => $params['invite_code'] ?? '',
]);
if (!in_array($params['tab'], ['login', 'register'])) {
@@ -72,7 +74,20 @@ class User extends Frontend
if (!$captchaObj->check($params['captcha'], $params[$params['registerType']] . 'user_register')) {
return $this->error(__('Please enter the correct verification code'));
}
$res = $this->auth->register($params['username'], $params['password'], $params['mobile'], $params['email']);
$extend = [];
if (!empty($params['invite_code'])) {
$inviterAdmin = Db::name('admin')
->field(['id', 'channel_id'])
->where('invite_code', $params['invite_code'])
->find();
if (!$inviterAdmin) {
return $this->error(__('Parameter error'));
}
$extend['register_invite_code'] = $params['invite_code'];
$extend['inviter_admin_id'] = $inviterAdmin['id'];
$extend['channel_id'] = $inviterAdmin['channel_id'] ?? null;
}
$res = $this->auth->register($params['username'], $params['password'], $params['mobile'], $params['email'], 1, $extend);
}
if ($res === true) {
@@ -117,6 +132,7 @@ class User extends Frontend
'email' => 'required_if:registerType,email|email|unique:user,email',
'mobile' => 'required_if:registerType,mobile|regex:/^1[3-9]\d{9}$/|unique:user,mobile',
'captcha' => 'required|string',
'invite_code' => 'nullable|string|max:64',
],
[
'username.regex' => __('Please input correct username'),
@@ -129,6 +145,7 @@ class User extends Frontend
'password' => __('Password'),
'captcha' => __('captcha'),
'registerType' => __('Register type'),
'invite_code' => __('Invite code'),
]
];
}

View File

@@ -142,14 +142,26 @@ class Backend extends Api
if ($needLogin) {
if (!$this->auth->isLogin()) {
if ($request->method() === 'GET' && !$this->expectsApiJsonResponse($request)) {
$location = $this->adminSpaLoginUrl($request);
return redirect($location);
}
// 必须使用 HTTP 200 返回 JSON若用 HTTP 303axios 会跟随重定向,拿不到 JSON前端无法跳转登录
return $this->error(__('Please login first'), [
'type' => Auth::NEED_LOGIN,
], 0, ['statusCode' => Auth::LOGIN_RESPONSE_CODE]);
], 0);
}
if ($needPermission) {
$controllerPath = $this->getControllerPath($request);
$routePath = $controllerPath . '/' . $action;
if (!$this->auth->check($routePath)) {
$routePaths = $this->buildPermissionRoutePaths($controllerPath, $action);
$pass = false;
foreach ($routePaths as $routePath) {
if ($this->auth->check($routePath)) {
$pass = true;
break;
}
}
if (!$pass) {
return $this->error(__('You have no permission'), [], 401);
}
}
@@ -167,6 +179,37 @@ class Backend extends Api
return null;
}
/**
* 是否应按 API 返回 JSON前端 axios 会带 server: true纯浏览器地址栏访问多为 HTML Accept
*/
protected function expectsApiJsonResponse(WebmanRequest $request): bool
{
$server = $request->header('server', '');
if ($server === 'true' || $server === '1') {
return true;
}
if (strtolower($request->header('x-requested-with', '')) === 'xmlhttprequest') {
return true;
}
$accept = strtolower($request->header('accept', ''));
if (str_contains($accept, 'application/json')) {
return true;
}
// 浏览器地址栏/点击链接触发的主文档请求,优先 302 到前端登录(避免误判为 API
if (strtolower((string) $request->header('sec-fetch-mode', '')) === 'navigate') {
return false;
}
return false;
}
/**
* 后台 Vue 为 hash 路由时的登录页(相对路径,与 web/src/router 一致)
*/
protected function adminSpaLoginUrl(WebmanRequest $request): string
{
return '/#/admin/login';
}
/**
* 子类可覆盖,用于初始化 model 等(替代原 initialize
* @return Response|null 需直接返回时返回 Response否则 null
@@ -431,4 +474,26 @@ class Backend extends Api
{
return get_controller_path($request);
}
/**
* 构造权限节点候选:兼容 snake_case 与 camelCase 节点名
*
* @return string[]
*/
protected function buildPermissionRoutePaths(string $controllerPath, string $action): array
{
$paths = [];
$paths[] = $controllerPath . '/' . $action;
$parts = explode('/', $controllerPath);
foreach ($parts as &$part) {
if (str_contains($part, '_')) {
$part = lcfirst(str_replace(' ', '', ucwords(str_replace('_', ' ', $part))));
}
}
$camelControllerPath = implode('/', $parts);
$paths[] = $camelControllerPath . '/' . $action;
return array_values(array_unique($paths));
}
}

View File

@@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
namespace app\common\library;
/**
* 游戏奖励配置:默认模板(与 resource/game_reward_config_template.json 一致)
*/
class GameRewardConfigTemplate
{
private static ?array $cached = null;
public static function templatePath(): string
{
return dirname(__DIR__, 3) . DIRECTORY_SEPARATOR . 'resource' . DIRECTORY_SEPARATOR . 'game_reward_config_template.json';
}
/**
* @return array{tier_reward_form: string, bigwin_form: string}
*/
public static function getDefaultJsonColumns(): array
{
if (self::$cached !== null) {
return self::$cached;
}
$path = self::templatePath();
if (!is_file($path)) {
throw new \RuntimeException('game_reward_config_template.json missing: ' . $path);
}
$raw = file_get_contents($path);
if ($raw === false || trim($raw) === '') {
throw new \RuntimeException('game_reward_config_template.json read failed');
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
throw new \RuntimeException('game_reward_config_template.json invalid JSON');
}
$tier = $decoded['tier_reward_form'] ?? null;
$big = $decoded['bigwin_form'] ?? null;
if (!is_array($tier) || !is_array($big)) {
throw new \RuntimeException('game_reward_config_template.json missing tier_reward_form or bigwin_form');
}
$tierJson = json_encode($tier, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$bigJson = json_encode($big, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($tierJson) || !is_string($bigJson)) {
throw new \RuntimeException('game_reward_config_template encode failed');
}
self::$cached = [
'tier_reward_form' => $tierJson,
'bigwin_form' => $bigJson,
];
return self::$cached;
}
}

View File

@@ -1,273 +0,0 @@
<?php
declare(strict_types=1);
namespace app\common\library;
/**
* 按「顺/逆时针摇点落格」条数约束生成 26 格档位盘面(写入 tier_reward_form JSON
*/
final class GameRewardTierBoardGenerator
{
private const LEOPARD = [5, 10, 15, 20, 25, 30];
private static function landingCw(int $d): int
{
$start = $d - 5;
return ($start + $d) % 26;
}
/** 图二:逆时针 end = start D若小于 0 则 26 + start D */
private static function landingCcw(int $d): int
{
$start = $d - 5;
$x = $start - $d;
return $x >= 0 ? $x : 26 + $start - $d;
}
/**
* 豹子点数:该次摇取顺、逆落点档位不能为 T4、T5
*
* @param list<string> $tier
*/
private static function leopardOk(array $tier): bool
{
foreach (self::LEOPARD as $d) {
foreach ([self::landingCw($d), self::landingCcw($d)] as $idx) {
$t = $tier[$idx];
if ($t === 'T4' || $t === 'T5') {
return false;
}
}
}
return true;
}
/**
* @param array<string, mixed> $params
* @return array{tier_reward_form: string}
*/
public static function generate(array $params): array
{
$t1Cw = self::intParam($params, 't1_fixed_cw');
$t1Ccw = self::intParam($params, 't1_fixed_ccw');
$t2MinCw = self::intParam($params, 't2_min_cw');
$t2MinCcw = self::intParam($params, 't2_min_ccw');
$t4Cw = self::intParam($params, 't4_fixed_cw');
$t4Ccw = self::intParam($params, 't4_fixed_ccw');
$t5Cw = self::intParam($params, 't5_fixed_cw');
$t5Ccw = self::intParam($params, 't5_fixed_ccw');
$amt1 = self::numParam($params, 'amt_t1');
$amt2 = self::numParam($params, 'amt_t2');
$amt3 = self::numParam($params, 'amt_t3');
$amt4 = self::numParam($params, 'amt_t4');
$bestTier = null;
$bestScore = INF;
for ($attempt = 0; $attempt < 32; $attempt++) {
$tier = self::randomInitialTier($attempt);
$temp = 5.0;
for ($step = 0; $step < 8000; $step++) {
$score = self::score($tier, $t1Cw, $t1Ccw, $t2MinCw, $t2MinCcw, $t4Cw, $t4Ccw, $t5Cw, $t5Ccw);
if ($score < $bestScore) {
$bestScore = $score;
$bestTier = $tier;
}
$i = mt_rand(0, 25);
$j = mt_rand(0, 25);
if ($i === $j) {
continue;
}
$oldI = $tier[$i];
$oldJ = $tier[$j];
$tier[$i] = $oldJ;
$tier[$j] = $oldI;
if (!self::leopardOk($tier)) {
$tier[$i] = $oldI;
$tier[$j] = $oldJ;
continue;
}
$newScore = self::score($tier, $t1Cw, $t1Ccw, $t2MinCw, $t2MinCcw, $t4Cw, $t4Ccw, $t5Cw, $t5Ccw);
$delta = $newScore - $score;
$u = mt_rand() / max(1, mt_getrandmax());
if ($delta < 0 || ($temp > 0.02 && exp(-$delta / $temp) > $u)) {
// keep
} else {
$tier[$i] = $oldI;
$tier[$j] = $oldJ;
}
$temp *= 0.999;
}
}
if ($bestTier === null || $bestScore > 45) {
throw new \RuntimeException('无法在豹子与条数约束下收敛盘面,请调整条数后重试');
}
$json = self::buildJson($bestTier, $amt1, $amt2, $amt3, $amt4);
return ['tier_reward_form' => $json];
}
/** @return list<string> */
private static function randomInitialTier(int $seedBias): array
{
mt_srand((int) (microtime(true) * 1000000) + $seedBias * 10007);
$tier = [];
for ($p = 0; $p < 26; $p++) {
$tier[$p] = ['T1', 'T2', 'T3'][mt_rand(0, 2)];
}
if (!self::leopardOk($tier)) {
for ($p = 0; $p < 26; $p++) {
$tier[$p] = 'T3';
}
}
return $tier;
}
/**
* @param list<string> $tier
*/
private static function score(
array $tier,
int $t1Cw,
int $t1Ccw,
int $t2MinCw,
int $t2MinCcw,
int $t4Cw,
int $t4Ccw,
int $t5Cw,
int $t5Ccw
): float {
if (!self::leopardOk($tier)) {
return 1e9;
}
$h = self::histogram($tier);
$s = 0.0;
$s += ($h['cw']['T1'] - $t1Cw) ** 2;
$s += ($h['ccw']['T1'] - $t1Ccw) ** 2;
$s += max(0, $t2MinCw - $h['cw']['T2']) ** 2 * 8;
$s += max(0, $t2MinCcw - $h['ccw']['T2']) ** 2 * 8;
$s += ($h['cw']['T4'] - $t4Cw) ** 2;
$s += ($h['ccw']['T4'] - $t4Ccw) ** 2;
$s += ($h['cw']['T5'] - $t5Cw) ** 2;
$s += ($h['ccw']['T5'] - $t5Ccw) ** 2;
return $s;
}
/**
* @param list<string> $tier
* @return array{cw: array<string, int>, ccw: array<string, int>}
*/
private static function histogram(array $tier): array
{
$cw = ['T1' => 0, 'T2' => 0, 'T3' => 0, 'T4' => 0, 'T5' => 0];
$ccw = ['T1' => 0, 'T2' => 0, 'T3' => 0, 'T4' => 0, 'T5' => 0];
for ($d = 5; $d <= 30; $d++) {
$cw[$tier[self::landingCw($d)]]++;
$ccw[$tier[self::landingCcw($d)]]++;
}
return ['cw' => $cw, 'ccw' => $ccw];
}
/**
* @param list<string> $tier
*/
private static function remarkForTier(string $t, float $amt2): string
{
if ($t === 'T1') {
return '大奖';
}
if ($t === 'T2') {
return $amt2 < 100 ? '完美回本' : '小赚';
}
if ($t === 'T3') {
return '抽水';
}
if ($t === 'T4') {
return '惩罚';
}
if ($t === 'T5') {
return '再来一次';
}
return '';
}
private static function buildJson(array $tier, float $amt1, float $amt2, float $amt3, float $amt4): string
{
$rows = [];
for ($i = 0; $i < 26; $i++) {
$t = $tier[$i];
if ($t === 'T5') {
$ui = '再来一次';
$uiEn = 'Once again';
$ev = '0';
} else {
$a = match ($t) {
'T1' => $amt1,
'T2' => $amt2,
'T3' => $amt3,
'T4' => $amt4,
default => $amt3,
};
$ui = self::fmtMoney($a);
$uiEn = self::fmtMoney($a);
$ev = self::fmtMoney($a);
}
$rows[] = [
'grid_number' => strval(5 + $i),
'ui_text' => $ui,
'ui_text_en' => $uiEn,
'real_ev' => $ev,
'tier' => $t,
'remark' => self::remarkForTier($t, $amt2),
];
}
return json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}
private static function fmtMoney(float $v): string
{
if (abs($v - round($v)) < 0.000001) {
return strval((int) round($v));
}
return rtrim(rtrim(sprintf('%.4f', $v), '0'), '.');
}
private static function intParam(array $params, string $key): int
{
$v = $params[$key] ?? 0;
if (is_string($v) && trim($v) === '') {
return 0;
}
if (!is_numeric($v)) {
return 0;
}
return intval(strval($v));
}
private static function numParam(array $params, string $key): float
{
$v = $params[$key] ?? 0;
if (is_string($v) && trim($v) === '') {
return 0.0;
}
if (!is_numeric($v)) {
return 0.0;
}
return floatval(strval($v));
}
}

View File

@@ -1,172 +0,0 @@
<?php
declare(strict_types=1);
namespace app\common\library;
use support\think\Db;
use Throwable;
/**
* 根据档位奖励 JSON 生成 game_reward_weight 对照(先删后插)
*/
final class GameRewardWeightSeeder
{
private const LEOPARD = [5, 10, 15, 20, 25, 30];
/**
* @throws Throwable
*/
public static function syncFromTierRewardForm(int $gameChannelId, string $tierRewardFormJson): void
{
$decoded = json_decode($tierRewardFormJson, true);
if (!is_array($decoded) || count($decoded) !== 26) {
throw new \RuntimeException('档位奖励表单必须为 26 条且为合法 JSON');
}
$byGrid = [];
foreach ($decoded as $idx => $row) {
if (!is_array($row)) {
throw new \RuntimeException('档位奖励表单第' . strval($idx + 1) . '条格式错误');
}
$g = $row['grid_number'] ?? null;
if (!is_numeric($g)) {
throw new \RuntimeException('档位奖励表单第' . strval($idx + 1) . '条点数无效');
}
$gi = intval(strval($g));
if ($gi < 5 || $gi > 30) {
throw new \RuntimeException('档位奖励表单点数须在 530');
}
$byGrid[$gi] = $row;
}
$cells = [];
for ($i = 0; $i < 26; $i++) {
$g = 5 + $i;
if (!isset($byGrid[$g])) {
throw new \RuntimeException('档位奖励表单缺少点数 ' . strval($g));
}
$cells[$i] = self::normalizeCell($byGrid[$g], $i + 1);
}
self::assertLeopardOk($cells);
$batch = [];
for ($d = 5; $d <= 30; $d++) {
$start = $d - 5;
$endCw = ($start + $d) % 26;
$x = $start - $d;
$endCcw = $x >= 0 ? $x : 26 + $start - $d;
$batch[] = self::buildInsertRow($gameChannelId, 0, $d, $start, $endCw, $cells[$endCw]);
$batch[] = self::buildInsertRow($gameChannelId, 1, $d, $start, $endCcw, $cells[$endCcw]);
}
$now = time();
foreach ($batch as $k => $_) {
$batch[$k]['create_time'] = $now;
$batch[$k]['update_time'] = $now;
}
Db::startTrans();
try {
Db::name('game_reward_weight')->where('game_channel_id', $gameChannelId)->delete();
Db::name('game_reward_weight')->insertAll($batch);
Db::commit();
} catch (Throwable $e) {
Db::rollback();
$msg = $e->getMessage();
if (str_contains($msg, 'game_reward_weight') || str_contains($msg, "doesn't exist")) {
throw new \RuntimeException('写入失败:请确认已创建数据表 game_reward_weight 并已执行迁移。' . $msg);
}
throw $e;
}
}
/**
* @param list<array{ui_text: string, real_ev: float, tier: string, remark: string}> $cells
*/
private static function assertLeopardOk(array $cells): void
{
foreach (self::LEOPARD as $d) {
$start = $d - 5;
$endCw = ($start + $d) % 26;
$x = $start - $d;
$endCcw = $x >= 0 ? $x : 26 + $start - $d;
foreach ([$endCw, $endCcw] as $idx) {
$t = $cells[$idx]['tier'];
if ($t === 'T4' || $t === 'T5') {
throw new \RuntimeException(
'豹子点数 ' . strval($d) . ' 的落点不能为 T4/T5请先在档位表中调整后再生成权重对照'
);
}
}
}
}
/**
* @param array<string, mixed> $row
* @return array{ui_text: string, real_ev: float, tier: string, remark: string}
*/
private static function normalizeCell(array $row, int $rowNo): array
{
$ui = $row['ui_text'] ?? null;
$ev = $row['real_ev'] ?? null;
$tier = $row['tier'] ?? null;
if (!is_string($ui) || trim($ui) === '') {
throw new \RuntimeException('档位奖励表单第' . strval($rowNo) . '条显示文本不能为空');
}
if ($ev === null || $ev === '' || !is_numeric($ev)) {
throw new \RuntimeException('档位奖励表单第' . strval($rowNo) . '条实际中奖无效');
}
if (!is_string($tier) || !in_array($tier, ['T1', 'T2', 'T3', 'T4', 'T5'], true)) {
throw new \RuntimeException('档位奖励表单第' . strval($rowNo) . '条档位无效');
}
$remark = $row['remark'] ?? '';
$remarkStr = is_string($remark) ? $remark : '';
return [
'ui_text' => $ui,
'real_ev' => floatval(strval($ev)),
'tier' => $tier,
'remark' => $remarkStr,
];
}
/**
* @param array{ui_text: string, real_ev: float, tier: string, remark: string} $cell
* @return array<string, int|float|string>
*/
private static function buildInsertRow(
int $gameChannelId,
int $direction,
int $gridNumber,
int $startIndex,
int $endIndex,
array $cell
): array {
return [
'game_channel_id' => $gameChannelId,
'direction' => $direction,
'grid_number' => $gridNumber,
'start_index' => $startIndex,
'end_index' => $endIndex,
'ui_text' => $cell['ui_text'],
'real_ev' => $cell['real_ev'],
'tier' => $cell['tier'],
'type' => self::tierToType($cell['tier']),
'remark' => $cell['remark'],
'weight' => 1,
];
}
private static function tierToType(string $tier): int
{
return match ($tier) {
'T1' => 3,
'T2' => 2,
'T3' => -1,
'T4' => -2,
'T5' => 1,
};
}
}

View File

@@ -0,0 +1,226 @@
<?php
declare(strict_types=1);
namespace app\common\library\game;
use InvalidArgumentException;
/**
* 36 字花字典game_config.zi_hua_36_dictionary仅存 JSON 数组
* category 使用英文zodiac|beast|fowl|vermin|divine
*/
final class ZiHuaDictionary
{
public const CONFIG_KEY = 'zi_hua_36_dictionary';
/** 与表单下拉一致,顺序即展示顺序 */
public const CATEGORIES = ['zodiac', 'beast', 'fowl', 'vermin', 'divine'];
private const LEGACY_CATEGORY_MAP = [
'生肖' => 'zodiac',
'猛兽' => 'beast',
'飞禽' => 'fowl',
'虫蛇' => 'vermin',
'神兽' => 'divine',
'zodiac' => 'zodiac',
'beast' => 'beast',
'fowl' => 'fowl',
'vermin' => 'vermin',
'divine' => 'divine',
];
/**
* 默认 36 条(与 PRD 一致category 为英文)
*
* @return list<array{no: int, name: string, category: string}>
*/
public static function defaultItems(): array
{
return [
['no' => 1, 'name' => '鼠', 'category' => 'zodiac'],
['no' => 2, 'name' => '牛', 'category' => 'zodiac'],
['no' => 3, 'name' => '虎', 'category' => 'zodiac'],
['no' => 4, 'name' => '兔', 'category' => 'zodiac'],
['no' => 5, 'name' => '龙', 'category' => 'zodiac'],
['no' => 6, 'name' => '蛇', 'category' => 'zodiac'],
['no' => 7, 'name' => '马', 'category' => 'zodiac'],
['no' => 8, 'name' => '羊', 'category' => 'zodiac'],
['no' => 9, 'name' => '猴', 'category' => 'zodiac'],
['no' => 10, 'name' => '鸡', 'category' => 'zodiac'],
['no' => 11, 'name' => '狗', 'category' => 'zodiac'],
['no' => 12, 'name' => '猪', 'category' => 'zodiac'],
['no' => 13, 'name' => '狮', 'category' => 'beast'],
['no' => 14, 'name' => '象', 'category' => 'beast'],
['no' => 15, 'name' => '鹿', 'category' => 'beast'],
['no' => 16, 'name' => '熊', 'category' => 'beast'],
['no' => 17, 'name' => '狼', 'category' => 'beast'],
['no' => 18, 'name' => '豹', 'category' => 'beast'],
['no' => 19, 'name' => '鹰', 'category' => 'fowl'],
['no' => 20, 'name' => '鹤', 'category' => 'fowl'],
['no' => 21, 'name' => '孔雀', 'category' => 'fowl'],
['no' => 22, 'name' => '鸳鸯', 'category' => 'fowl'],
['no' => 23, 'name' => '鸵鸟', 'category' => 'fowl'],
['no' => 24, 'name' => '天鹅', 'category' => 'fowl'],
['no' => 25, 'name' => '蟾蜍', 'category' => 'vermin'],
['no' => 26, 'name' => '蜘蛛', 'category' => 'vermin'],
['no' => 27, 'name' => '蝙蝠', 'category' => 'vermin'],
['no' => 28, 'name' => '蜈蚣', 'category' => 'vermin'],
['no' => 29, 'name' => '蝎', 'category' => 'vermin'],
['no' => 30, 'name' => '蛇蜥', 'category' => 'vermin'],
['no' => 31, 'name' => '麒麟', 'category' => 'divine'],
['no' => 32, 'name' => '凤凰', 'category' => 'divine'],
['no' => 33, 'name' => '青龙', 'category' => 'divine'],
['no' => 34, 'name' => '白虎', 'category' => 'divine'],
['no' => 35, 'name' => '朱雀', 'category' => 'divine'],
['no' => 36, 'name' => '玄武', 'category' => 'divine'],
];
}
/**
* @return list<array{no: int, name: string, category: string}>
*/
public static function parseFromConfigValue(?string $configValue): array
{
if ($configValue === null || trim($configValue) === '') {
return self::defaultItems();
}
$decoded = json_decode($configValue, true);
if (!is_array($decoded)) {
return self::defaultItems();
}
if (isset($decoded['items']) && is_array($decoded['items'])) {
return self::normalizeItemsList($decoded['items']);
}
if ($decoded !== [] && array_is_list($decoded)) {
return self::normalizeItemsList($decoded);
}
return self::defaultItems();
}
/**
* @param list<mixed> $items
* @return list<array{no: int, name: string, category: string}>
*/
public static function normalizeItemsList(array $items): array
{
$defaults = self::defaultItems();
$defaultByNo = [];
foreach ($defaults as $row) {
$defaultByNo[$row['no']] = $row;
}
$out = [];
foreach ($items as $row) {
if (!is_array($row)) {
continue;
}
$no = $row['no'] ?? null;
if (!is_numeric($no)) {
continue;
}
$n = intval($no, 10);
if ($n < 1 || $n > 36) {
continue;
}
$name = isset($row['name']) && is_string($row['name']) ? trim($row['name']) : '';
if ($name === '') {
$name = $defaultByNo[$n]['name'] ?? '';
}
$catRaw = $row['category'] ?? 'zodiac';
$catStr = is_string($catRaw) ? trim($catRaw) : '';
$category = self::LEGACY_CATEGORY_MAP[$catStr] ?? (in_array($catStr, self::CATEGORIES, true) ? $catStr : 'zodiac');
$out[] = ['no' => $n, 'name' => $name, 'category' => $category];
}
if (count($out) !== 36) {
return self::defaultItems();
}
usort($out, static fn (array $a, array $b): int => $a['no'] <=> $b['no']);
$seen = [];
foreach ($out as $row) {
if (isset($seen[$row['no']])) {
return self::defaultItems();
}
$seen[$row['no']] = true;
}
for ($i = 1; $i <= 36; $i++) {
if (!isset($seen[$i])) {
return self::defaultItems();
}
}
return $out;
}
/**
* 校验 POST 数据并输出入库用的 36 条no 升序)
*
* @param list<array{no?: mixed, name?: mixed, category?: mixed}> $items
*
* @return list<array{no: int, name: string, category: string}>
*
* @throws InvalidArgumentException
*/
public static function prepareItemsForSave(array $items): array
{
if (count($items) !== 36) {
throw new InvalidArgumentException('必须恰好 36 条');
}
$nos = [];
$out = [];
foreach ($items as $idx => $row) {
if (!is_array($row)) {
throw new InvalidArgumentException('第 ' . ($idx + 1) . ' 行格式错误');
}
$no = $row['no'] ?? null;
if (!is_numeric($no)) {
throw new InvalidArgumentException('编号必须为数字');
}
$n = intval($no, 10);
if ($n < 1 || $n > 36) {
throw new InvalidArgumentException('编号必须在 136');
}
if (isset($nos[$n])) {
throw new InvalidArgumentException('编号重复:' . $n);
}
$nos[$n] = true;
$name = $row['name'] ?? '';
if (!is_string($name) || trim($name) === '') {
throw new InvalidArgumentException('编号 ' . $n . ' 名称不能为空');
}
$cat = $row['category'] ?? '';
$catTrim = is_string($cat) ? trim($cat) : '';
if (!in_array($catTrim, self::CATEGORIES, true)) {
throw new InvalidArgumentException('编号 ' . $n . ' 类型无效');
}
$out[] = ['no' => $n, 'name' => trim($name), 'category' => $catTrim];
}
for ($i = 1; $i <= 36; $i++) {
if (!isset($nos[$i])) {
throw new InvalidArgumentException('缺少编号:' . $i);
}
}
usort($out, static fn (array $a, array $b): int => $a['no'] <=> $b['no']);
return $out;
}
/**
* @param list<array{no: int, name: string, category: string}> $items
*/
public static function encodeForDb(array $items): string
{
usort($items, static fn (array $a, array $b): int => $a['no'] <=> $b['no']);
$encoded = json_encode($items, JSON_UNESCAPED_UNICODE);
if ($encoded === false) {
throw new InvalidArgumentException('JSON 编码失败');
}
return $encoded;
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace app\common\model;
use support\think\Model;
class AgentCommissionRecord extends Model
{
protected $name = 'agent_commission_record';
protected $autoWriteTimestamp = true;
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'settled_at' => 'integer',
'commission_rate' => 'string',
'calc_base_amount' => 'string',
'commission_amount' => 'string',
'status' => 'integer',
];
public function channel(): \think\model\relation\BelongsTo
{
return $this->belongsTo(Channel::class, 'channel_id', 'id');
}
public function admin(): \think\model\relation\BelongsTo
{
return $this->belongsTo(\app\admin\model\Admin::class, 'admin_id', 'id');
}
public function settlementPeriod(): \think\model\relation\BelongsTo
{
return $this->belongsTo(AgentSettlementPeriod::class, 'settlement_period_id', 'id');
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace app\common\model;
use support\think\Model;
class AgentSettlementPeriod extends Model
{
protected $name = 'agent_settlement_period';
protected $autoWriteTimestamp = true;
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'period_start_at' => 'integer',
'period_end_at' => 'integer',
'total_bet_amount' => 'string',
'total_payout_amount' => 'string',
'platform_profit_amount' => 'string',
'status' => 'integer',
];
}

View File

@@ -0,0 +1,42 @@
<?php
namespace app\common\model;
use support\think\Model;
class BetOrder extends Model
{
protected $name = 'bet_order';
protected $autoWriteTimestamp = true;
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'pick_numbers' => 'json',
'unit_amount' => 'string',
'total_amount' => 'string',
'win_amount' => 'string',
'jackpot_extra_amount' => 'string',
'status' => 'integer',
'pick_count' => 'integer',
'streak_at_bet' => 'integer',
'is_auto' => 'integer',
];
public function user(): \think\model\relation\BelongsTo
{
return $this->belongsTo(User::class, 'user_id', 'id');
}
public function channel(): \think\model\relation\BelongsTo
{
return $this->belongsTo(Channel::class, 'channel_id', 'id');
}
public function gamePeriod(): \think\model\relation\BelongsTo
{
return $this->belongsTo(GamePeriod::class, 'period_id', 'id');
}
}

View File

@@ -5,12 +5,12 @@ namespace app\common\model;
use support\think\Model;
/**
* GameChannel
* Channel
*/
class GameChannel extends Model
class Channel extends Model
{
// 表名
protected $name = 'game_channel';
protected $name = 'channel';
// 自动写入时间戳字段
protected $autoWriteTimestamp = true;
@@ -19,9 +19,12 @@ class GameChannel extends Model
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'affiliate_effective_start_at' => 'integer',
'affiliate_effective_end_at' => 'integer',
'settle_weekday' => 'integer',
'settle_monthday' => 'integer',
];
public function getprofitAmountAttr($value): ?float
{
return is_null($value) ? null : (float)$value;
@@ -36,4 +39,4 @@ class GameChannel extends Model
{
return $this->belongsTo(\app\admin\model\Admin::class, 'admin_id', 'id');
}
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace app\common\model;
use support\think\Model;
class DepositOrder extends Model
{
protected $name = 'deposit_order';
protected $autoWriteTimestamp = true;
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'pay_time' => 'integer',
'amount' => 'string',
'status' => 'integer',
];
public function user(): \think\model\relation\BelongsTo
{
return $this->belongsTo(User::class, 'user_id', 'id');
}
public function channel(): \think\model\relation\BelongsTo
{
return $this->belongsTo(Channel::class, 'channel_id', 'id');
}
}

View File

@@ -5,28 +5,16 @@ namespace app\common\model;
use support\think\Model;
/**
* GameConfig
* 游戏/平台动态参数KV
*/
class GameConfig extends Model
{
// 表主键
protected $pk = 'ID';
// 表名
protected $name = 'game_config';
// 自动写入时间戳字段
protected $autoWriteTimestamp = true;
// 字段类型转换
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
];
public function channel(): \think\model\relation\BelongsTo
{
return $this->belongsTo(\app\common\model\GameChannel::class, 'channel_id', 'id');
}
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace app\common\model;
use support\think\Model;
class GamePeriod extends Model
{
protected $name = 'game_period';
protected $autoWriteTimestamp = true;
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'period_start_at' => 'integer',
'status' => 'integer',
'draw_mode' => 'integer',
'preset_number' => 'integer',
'result_number' => 'integer',
];
/**
* 后台表单 datetime 可能提交字符串,入库前转为 Unix 时间戳
* ThinkPHP 修改器签名为 (mixed $value, array $data)
*/
public function setPeriodStartAtAttr($value, $data = [])
{
if ($value === null || $value === '') {
return 0;
}
if (is_int($value)) {
return $value;
}
if (is_string($value)) {
$t = strtotime($value);
if ($t !== false) {
return $t;
}
}
return 0;
}
}

View File

@@ -1,29 +0,0 @@
<?php
namespace app\common\model;
use support\think\Model;
/**
* GameRewardConfig
*/
class GameRewardConfig extends Model
{
// 表名
protected $name = 'game_reward_config';
// 自动写入时间戳字段
protected $autoWriteTimestamp = true;
// 字段类型转换(避免时间戳被写成 'now' 字符串)
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
];
public function gameChannel(): \think\model\relation\BelongsTo
{
return $this->belongsTo(\app\common\model\GameChannel::class, 'game_channel_id', 'id');
}
}

View File

@@ -1,29 +0,0 @@
<?php
namespace app\common\model;
use support\think\Model;
/**
* GameRewardWeight
*/
class GameRewardWeight extends Model
{
// 表名
protected $name = 'game_reward_weight';
// 自动写入时间戳字段
protected $autoWriteTimestamp = true;
// 字段类型转换
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
];
public function gameChannel(): \think\model\relation\BelongsTo
{
return $this->belongsTo(\app\common\model\GameChannel::class, 'game_channel_id', 'id');
}
}

View File

@@ -1,39 +0,0 @@
<?php
namespace app\common\model;
use support\think\Model;
/**
* GameUser
*/
class GameUser extends Model
{
// 表名
protected $name = 'game_user';
// 自动写入时间戳字段
protected $autoWriteTimestamp = true;
// 字段类型转换
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
];
public function getcoinAttr($value): ?float
{
return is_null($value) ? null : (float)$value;
}
public function gameChannel(): \think\model\relation\BelongsTo
{
return $this->belongsTo(\app\common\model\GameChannel::class, 'game_channel_id', 'id');
}
public function admin(): \think\model\relation\BelongsTo
{
return $this->belongsTo(\app\admin\model\Admin::class, 'admin_id', 'id');
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace app\common\model;
use support\think\Model;
class OperationNotice extends Model
{
protected $name = 'operation_notice';
protected $autoWriteTimestamp = true;
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'publish_at' => 'integer',
'notice_type' => 'integer',
'status' => 'integer',
];
public function setPublishAtAttr($value)
{
if ($value === null || $value === '') {
return 0;
}
if (is_int($value)) {
return $value;
}
if (is_string($value)) {
if (ctype_digit($value)) {
return (int) $value;
}
$ts = strtotime($value);
if ($ts !== false) {
return $ts;
}
}
return 0;
}
}

View File

@@ -2,42 +2,34 @@
namespace app\common\model;
use app\common\model\traits\TimestampInteger;
use support\think\Model;
/**
* 会员公共模型
* 用户主表
*/
class User extends Model
{
use TimestampInteger;
protected $name = 'user';
protected string $table = 'user';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
protected $autoWriteTimestamp = true;
public function getAvatarAttr($value): string
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'coin' => 'string',
'total_deposit_coin' => 'string',
'total_valid_bet_coin' => 'string',
'risk_flags' => 'integer',
'current_streak' => 'integer',
];
public function channel(): \think\model\relation\BelongsTo
{
return full_url($value, false, config('buildadmin.default_avatar'));
return $this->belongsTo(Channel::class, 'channel_id', 'id');
}
public function setAvatarAttr($value): string
public function admin(): \think\model\relation\BelongsTo
{
return $value == full_url('', false, config('buildadmin.default_avatar')) ? '' : $value;
}
public function resetPassword($uid, $newPassword)
{
return $this->where(['id' => $uid])->update(['password' => hash_password($newPassword), 'salt' => '']);
}
public function getMoneyAttr($value): string
{
return bcdiv((string)$value, '100', 2);
}
public function setMoneyAttr($value): string
{
return bcmul((string)$value, '100', 2);
return $this->belongsTo(\app\admin\model\Admin::class, 'admin_id', 'id');
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace app\common\model;
use support\think\Model;
class UserNoticeRead extends Model
{
protected $name = 'user_notice_read';
protected $autoWriteTimestamp = true;
protected $type = [
'create_time' => 'integer',
'read_at' => 'integer',
'confirmed' => 'integer',
];
public function user(): \think\model\relation\BelongsTo
{
return $this->belongsTo(User::class, 'user_id', 'id');
}
public function operationNotice(): \think\model\relation\BelongsTo
{
return $this->belongsTo(OperationNotice::class, 'notice_id', 'id');
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace app\common\model;
use support\think\Model;
/**
* 用户钱包流水(原 game_user_wallet_record
*/
class UserWalletRecord extends Model
{
protected $name = 'user_wallet_record';
protected $autoWriteTimestamp = false;
protected $createTime = 'create_time';
protected $updateTime = false;
protected $type = [
'create_time' => 'integer',
'amount' => 'string',
'balance_before' => 'string',
'balance_after' => 'string',
];
public function user(): \think\model\relation\BelongsTo
{
return $this->belongsTo(User::class, 'user_id', 'id');
}
public function channel(): \think\model\relation\BelongsTo
{
return $this->belongsTo(Channel::class, 'channel_id', 'id');
}
public function operatorAdmin(): \think\model\relation\BelongsTo
{
return $this->belongsTo(\app\admin\model\Admin::class, 'operator_admin_id', 'id');
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace app\common\model;
use support\think\Model;
class WithdrawOrder extends Model
{
protected $name = 'withdraw_order';
protected $autoWriteTimestamp = true;
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'review_time' => 'integer',
'amount' => 'string',
'fee' => 'string',
'actual_amount' => 'string',
'status' => 'integer',
];
public function user(): \think\model\relation\BelongsTo
{
return $this->belongsTo(User::class, 'user_id', 'id');
}
public function channel(): \think\model\relation\BelongsTo
{
return $this->belongsTo(Channel::class, 'channel_id', 'id');
}
public function reviewAdmin(): \think\model\relation\BelongsTo
{
return $this->belongsTo(\app\admin\model\Admin::class, 'review_admin_id', 'id');
}
}

View File

@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use support\think\Db;
/**
* 按 game_user 表统计各渠道用户数,回写 game_channel.user_count
*/
class GameChannelUserCount
{
/**
* 统计 game_user.game_channel_id = 该渠道 id 的行数,更新 game_channel.user_count
*/
public static function syncFromGameUser(int|string|null $channelId): void
{
if ($channelId === null || $channelId === '') {
return;
}
if (is_numeric($channelId) && (float) $channelId < 1) {
return;
}
$count = Db::name('game_user')->where('game_channel_id', $channelId)->count();
Db::name('game_channel')->where('id', $channelId)->update(['user_count' => $count]);
}
/**
* @param list<int|string|null> $channelIds
*/
public static function syncChannels(array $channelIds): void
{
$seen = [];
foreach ($channelIds as $cid) {
if ($cid === null || $cid === '') {
continue;
}
$k = (string) $cid;
if (isset($seen[$k])) {
continue;
}
$seen[$k] = true;
self::syncFromGameUser($cid);
}
}
}

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use support\think\Db;
use Throwable;
/**
* 全局期号:创建与 game_config 开关(自动新建下一期)
*/
final class GamePeriodService
{
public const KEY_AUTO_CREATE = 'period_auto_create_enabled';
public const KEY_MANUAL_CREATE = 'period_manual_create_enabled';
/** 进行中状态:未结束则不可再开新期 */
private const ACTIVE_STATUSES = [0, 1, 2, 3];
public static function getConfigBool(string $key): bool
{
$row = Db::name('game_config')->where('config_key', $key)->find();
if (!$row) {
return false;
}
$v = $row['config_value'] ?? '';
return $v === '1' || $v === 1;
}
/**
* @return array{period_auto_create_enabled: int, period_manual_create_enabled: int}
*/
public static function getPeriodSettings(): array
{
return [
'period_auto_create_enabled' => self::getConfigBool(self::KEY_AUTO_CREATE) ? 1 : 0,
'period_manual_create_enabled' => self::getConfigBool(self::KEY_MANUAL_CREATE) ? 1 : 0,
];
}
/**
* @param array{period_auto_create_enabled?: int|string, period_manual_create_enabled?: int|string} $data
*/
public static function savePeriodSettings(array $data): void
{
$now = time();
$auto = self::truthyConfigInput($data['period_auto_create_enabled'] ?? null) ? '1' : '0';
$manual = self::truthyConfigInput($data['period_manual_create_enabled'] ?? null) ? '1' : '0';
self::upsertConfig(self::KEY_AUTO_CREATE, $auto, 'int', '是否允许定时任务自动创建下一期(全局仅一局)', $now);
self::upsertConfig(self::KEY_MANUAL_CREATE, $manual, 'int', '是否允许后台手动创建下一期', $now);
}
public static function hasActivePeriod(): bool
{
$count = Db::name('game_period')->whereIn('status', self::ACTIVE_STATUSES)->count();
return $count > 0;
}
/**
* 自动开奖任务调用:开启自动创建且无进行中期号时插入新期
*/
public static function tickAutoCreate(): void
{
if (!self::getConfigBool(self::KEY_AUTO_CREATE)) {
return;
}
if (self::hasActivePeriod()) {
return;
}
try {
self::createNextPeriodRow();
} catch (Throwable) {
// 并发下可能重复,忽略
}
}
/**
* @return array{ok: bool, msg: string, period_no?: string}
*/
public static function createNextPeriodForManual(): array
{
if (!self::getConfigBool(self::KEY_MANUAL_CREATE)) {
return ['ok' => false, 'msg' => '未开启「手动创建下一期」开关'];
}
if (self::hasActivePeriod()) {
return ['ok' => false, 'msg' => '存在未结束期号,无法新建'];
}
try {
$periodNo = self::createNextPeriodRow();
return ['ok' => true, 'msg' => '已创建新期', 'period_no' => $periodNo];
} catch (Throwable $e) {
return ['ok' => false, 'msg' => $e->getMessage()];
}
}
/**
* @throws Throwable
*/
private static function createNextPeriodRow(): string
{
$periodNo = self::generatePeriodNo();
$now = time();
Db::name('game_period')->insert([
'period_no' => $periodNo,
'period_start_at' => $now,
'status' => 0,
'draw_mode' => 0,
'void_reason' => '',
'create_time' => $now,
'update_time' => $now,
]);
return $periodNo;
}
private static function generatePeriodNo(): string
{
return date('Ymd-His') . '-' . substr(bin2hex(random_bytes(4)), 0, 8);
}
private static function truthyConfigInput(mixed $v): bool
{
return $v === 1 || $v === '1' || $v === true;
}
private static function upsertConfig(string $key, string $value, string $valueType, string $remark, int $now): void
{
$exists = Db::name('game_config')->where('config_key', $key)->find();
if ($exists) {
Db::name('game_config')->where('config_key', $key)->update([
'config_value' => $value,
'value_type' => $valueType,
'remark' => $remark,
'update_time' => $now,
]);
return;
}
Db::name('game_config')->insert([
'config_key' => $key,
'config_value' => $value,
'value_type' => $valueType,
'remark' => $remark,
'create_time' => $now,
'update_time' => $now,
]);
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace app\common\validate;
use think\Validate;
class AgentCommissionRecord extends Validate
{
protected $failException = true;
protected $rule = [
'settlement_period_id' => 'require|integer|gt:0',
'channel_id' => 'require|integer|gt:0',
'admin_id' => 'require|integer|gt:0',
'commission_rate' => 'require|float|egt:0',
'calc_base_amount' => 'require|float',
'commission_amount' => 'require|float',
'status' => 'in:0,1,2',
'remark' => 'max:255',
];
protected $scene = [
'add' => ['settlement_period_id', 'channel_id', 'admin_id', 'commission_rate', 'calc_base_amount', 'commission_amount', 'status', 'remark'],
'edit' => ['commission_rate', 'calc_base_amount', 'commission_amount', 'status', 'remark'],
];
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace app\common\validate;
use think\Validate;
class AgentSettlementPeriod extends Validate
{
protected $failException = true;
protected $rule = [
'settlement_no' => 'require|max:64|unique:agent_settlement_period',
'period_start_at' => 'require|integer|egt:0',
'period_end_at' => 'require|integer|egt:0',
'status' => 'in:0,1,2',
'remark' => 'max:255',
];
protected $scene = [
'add' => ['settlement_no', 'period_start_at', 'period_end_at', 'status', 'remark'],
'edit' => ['period_start_at', 'period_end_at', 'status', 'remark'],
];
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace app\common\validate;
use think\Validate;
class Channel extends Validate
{
protected $failException = true;
protected $rule = [
'code' => 'require|max:255|unique:channel',
'name' => 'require|max:255',
'agent_mode' => 'require|in:turnover,affiliate',
'status' => 'in:0,1',
'admin_id' => 'require|integer|gt:0',
'remark' => 'max:255',
];
protected $scene = [
'add' => ['code', 'name', 'agent_mode', 'status', 'admin_id', 'remark'],
'edit' => ['name', 'agent_mode', 'status', 'admin_id', 'remark'],
];
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace app\common\validate;
use think\Validate;
class DepositOrder extends Validate
{
protected $failException = true;
protected $rule = [
'order_no' => 'require|max:64|unique:deposit_order',
'user_id' => 'require|integer|gt:0',
'status' => 'require|in:0,1,2,3',
];
protected $scene = [
'add' => ['order_no', 'user_id', 'status'],
'edit' => ['status'],
];
}

View File

@@ -1,31 +0,0 @@
<?php
namespace app\common\validate;
use think\Validate;
class GameChannel extends Validate
{
protected $failException = true;
/**
* 验证规则
*/
protected $rule = [
];
/**
* 提示消息
*/
protected $message = [
];
/**
* 验证场景
*/
protected $scene = [
'add' => [],
'edit' => [],
];
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace app\common\validate;
use think\Validate;
@@ -8,24 +10,14 @@ class GameConfig extends Validate
{
protected $failException = true;
/**
* 验证规则
*/
protected $rule = [
'config_key' => 'require|max:64|unique:game_config',
'value_type' => 'require|in:string,int,decimal,json',
'remark' => 'max:255',
];
/**
* 提示消息
*/
protected $message = [
];
/**
* 验证场景
*/
protected $scene = [
'add' => [],
'edit' => [],
'add' => ['config_key', 'value_type', 'remark'],
'edit' => ['value_type', 'remark'],
];
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace app\common\validate;
use think\Validate;
class GamePeriod extends Validate
{
protected $failException = true;
protected $rule = [
'period_no' => 'require|max:64|unique:game_period',
'period_start_at' => 'integer',
'status' => 'require|in:0,1,2,3,4,5',
'draw_mode' => 'in:0,1',
'preset_number' => 'between:1,36',
'result_number' => 'between:1,36',
];
protected $scene = [
'add' => ['period_no', 'period_start_at', 'status', 'draw_mode', 'preset_number', 'result_number'],
'edit' => ['period_start_at', 'status', 'draw_mode', 'preset_number', 'result_number'],
];
}

View File

@@ -1,145 +0,0 @@
<?php
namespace app\common\validate;
use think\Validate;
class GameRewardConfig extends Validate
{
protected $failException = true;
/**
* 验证规则
*/
protected $rule = [
'game_channel_id' => 'require|integer|gt:0',
'tier_reward_form' => 'require|checkTierRewardForm',
'bigwin_form' => 'require|checkBigwinForm',
];
/**
* 提示消息
*/
protected $message = [
'game_channel_id.require' => '请选择渠道',
'game_channel_id.integer' => '渠道参数格式错误',
'game_channel_id.gt' => '渠道参数格式错误',
'tier_reward_form.require' => '档位奖励表单不能为空',
'bigwin_form.require' => '超级大奖表单不能为空',
];
/**
* 验证场景
*/
protected $scene = [
'add' => ['game_channel_id', 'tier_reward_form', 'bigwin_form'],
'edit' => ['game_channel_id', 'tier_reward_form', 'bigwin_form'],
/** 渠道表单页:渠道由后端固定,仅校验两份 JSON */
'channel_form' => ['tier_reward_form', 'bigwin_form'],
];
private function parseJsonArray(mixed $value, string $label): array|string
{
if (!is_string($value) || trim($value) === '') {
return $label . '不能为空';
}
try {
$decoded = json_decode($value, true, 512, JSON_THROW_ON_ERROR);
} catch (\Throwable $e) {
return $label . '必须为合法 JSON';
}
if (!is_array($decoded)) {
return $label . '格式错误';
}
return $decoded;
}
protected function checkTierRewardForm($value, $rule, array $data = []): bool|string
{
$decoded = $this->parseJsonArray($value, '档位奖励表单');
if (!is_array($decoded)) {
return $decoded;
}
$expectedGrid = range(5, 30);
if (count($decoded) !== 26) {
return '档位奖励表单必须固定 26 条';
}
$allowTiers = ['T1', 'T2', 'T3', 'T4', 'T5'];
foreach ($decoded as $idx => $row) {
$rowNo = $idx + 1;
if (!is_array($row)) {
return '档位奖励表单第' . $rowNo . '条格式错误';
}
$gridNumber = $row['grid_number'] ?? null;
$uiText = $row['ui_text'] ?? null;
$realEv = $row['real_ev'] ?? null;
$tier = $row['tier'] ?? null;
if (!is_numeric($gridNumber)) {
return '档位奖励表单第' . $rowNo . '条点数必须为数字';
}
$gridInt = intval(strval($gridNumber));
if ($gridInt !== $expectedGrid[$idx]) {
return '档位奖励表单点数必须固定为 5-30 且不可修改';
}
if (!is_string($uiText) || trim($uiText) === '') {
return '档位奖励表单第' . $rowNo . '条显示文本不能为空';
}
if ($realEv === null || $realEv === '' || !is_numeric($realEv)) {
return '档位奖励表单第' . $rowNo . '条实际中奖必须为数字';
}
if (!is_string($tier) || !in_array($tier, $allowTiers, true)) {
return '档位奖励表单第' . $rowNo . '条档位只能是 T1-T5';
}
}
return true;
}
protected function checkBigwinForm($value, $rule, array $data = []): bool|string
{
$decoded = $this->parseJsonArray($value, '超级大奖表单');
if (!is_array($decoded)) {
return $decoded;
}
$expectedGrid = [5, 10, 15, 20, 25, 30];
if (count($decoded) !== 6) {
return '超级大奖表单必须固定 6 条';
}
foreach ($decoded as $idx => $row) {
$rowNo = $idx + 1;
if (!is_array($row)) {
return '超级大奖表单第' . $rowNo . '条格式错误';
}
$gridNumber = $row['grid_number'] ?? null;
$uiText = $row['ui_text'] ?? null;
$realEv = $row['real_ev'] ?? null;
$tier = $row['tier'] ?? null;
if (!is_numeric($gridNumber)) {
return '超级大奖表单第' . $rowNo . '条点数必须为数字';
}
$gridInt = intval(strval($gridNumber));
if ($gridInt !== $expectedGrid[$idx]) {
return '超级大奖表单点数必须固定为 5、10、15、20、25、30 且不可修改';
}
if (!is_string($uiText) || trim($uiText) === '') {
return '超级大奖表单第' . $rowNo . '条显示文本不能为空';
}
if ($realEv === null || $realEv === '' || !is_numeric($realEv)) {
return '超级大奖表单第' . $rowNo . '条实际中奖必须为数字';
}
if (!is_string($tier) || $tier !== 'BIGWIN') {
return '超级大奖表单第' . $rowNo . '条档位必须为 BIGWIN';
}
}
return true;
}
}

View File

@@ -1,31 +0,0 @@
<?php
namespace app\common\validate;
use think\Validate;
class GameRewardWeight extends Validate
{
protected $failException = true;
/**
* 验证规则
*/
protected $rule = [
];
/**
* 提示消息
*/
protected $message = [
];
/**
* 验证场景
*/
protected $scene = [
'add' => [],
'edit' => [],
];
}

View File

@@ -1,31 +0,0 @@
<?php
namespace app\common\validate;
use think\Validate;
class GameUser extends Validate
{
protected $failException = true;
/**
* 验证规则
*/
protected $rule = [
];
/**
* 提示消息
*/
protected $message = [
];
/**
* 验证场景
*/
protected $scene = [
'add' => [],
'edit' => [],
];
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace app\common\validate;
use think\Validate;
class OperationNotice extends Validate
{
protected $failException = true;
protected $rule = [
'title' => 'require|max:255',
'notice_type' => 'require|in:0,1',
'status' => 'require|in:0,1',
'publish_at' => 'require|checkPublishAt',
];
protected $scene = [
'add' => ['title', 'notice_type', 'status', 'publish_at'],
'edit' => ['title', 'notice_type', 'status', 'publish_at'],
];
protected function checkPublishAt($value): bool
{
if (is_int($value)) {
return $value >= 0;
}
if (is_string($value)) {
if ($value === '') {
return false;
}
if (ctype_digit($value)) {
return true;
}
return strtotime($value) !== false;
}
return false;
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace app\common\validate;
use think\Validate;
class User extends Validate
{
protected $failException = true;
protected $rule = [
'username' => 'require|max:255',
'password' => 'require|min:6|max:255',
'phone' => 'max:32',
'email' => 'email',
'channel_id' => 'require|integer|gt:0',
'status' => 'in:0,1',
];
protected $scene = [
'add' => ['username', 'password', 'phone', 'email', 'channel_id', 'status'],
'edit' => ['username', 'password', 'phone', 'email', 'channel_id', 'status'],
];
public function sceneEdit(): static
{
return $this->only(['username', 'password', 'phone', 'email', 'channel_id', 'status'])
->remove('password', 'require');
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace app\common\validate;
use think\Validate;
class UserNoticeRead extends Validate
{
protected $failException = true;
protected $rule = [
'user_id' => 'require|integer|gt:0',
'notice_id' => 'require|integer|gt:0',
'confirmed' => 'in:0,1',
'read_at' => 'integer',
];
protected $scene = [
'add' => ['user_id', 'notice_id', 'confirmed', 'read_at'],
'edit' => ['user_id', 'notice_id', 'confirmed', 'read_at'],
];
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace app\common\validate;
use think\Validate;
class WithdrawOrder extends Validate
{
protected $failException = true;
protected $rule = [
'order_no' => 'require|max:64|unique:withdraw_order',
'user_id' => 'require|integer|gt:0',
'amount' => 'float|egt:0',
'status' => 'require|in:0,1,2,3',
'remark' => 'max:255',
];
protected $scene = [
'add' => ['order_no', 'user_id', 'amount', 'status', 'remark'],
'edit' => ['amount', 'status', 'remark'],
];
}

View File

@@ -170,7 +170,7 @@ if (!function_exists('get_controller_path')) {
* 从 Request 或路由获取控制器路径(等价于 ThinkPHP controllerPath
* 优先从 $request->controllerWebman 路由匹配时设置)解析,否则从 path 解析
* @param \Webman\Http\Request|null $request
* @return string|null 如 auth/admin、user/user
* @return string|null 如 auth/admin
*/
function get_controller_path($request = null): ?string
{
@@ -188,8 +188,7 @@ if (!function_exists('get_controller_path')) {
$parts = explode('\\', $relative);
$path = [];
foreach ($parts as $p) {
// 与 BuildAdmin admin_rule.name 一致:多段类名用 camelCase如 auth/adminLog不用 admin_log
$path[] = lcfirst($p);
$path[] = strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $p));
}
return implode('/', $path);
}
@@ -216,10 +215,10 @@ if (!function_exists('get_controller_path')) {
$dotPos = strpos($seg, '.');
$mod = substr($seg, 0, $dotPos);
$ctrl = substr($seg, $dotPos + 1);
$normalized[] = lcfirst($mod);
$normalized[] = lcfirst($ctrl);
$normalized[] = strtolower($mod);
$normalized[] = strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $ctrl));
} else {
$normalized[] = lcfirst($seg);
$normalized[] = strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $seg));
}
}
return implode('/', $normalized);

View File

@@ -0,0 +1,19 @@
<?php
namespace app\process;
use app\common\service\GamePeriodService;
use Workerman\Timer;
/**
* 定时检查:开启自动创建且无进行中期号时插入新期(全局仅一局)
*/
class GamePeriodAutoTicker
{
public function onWorkerStart(): void
{
Timer::add(15, static function (): void {
GamePeriodService::tickAutoCreate();
});
}
}

View File

@@ -39,8 +39,9 @@
"robmorgan/phinx": "^0.15",
"nelexa/zip": "^4.0.0",
"voku/anti-xss": "^4.1",
"topthink/think-validate": "^3.0"
},
"topthink/think-validate": "^3.0",
"ext-bcmath": "*"
},
"suggest": {
"ext-event": "For better performance. "
},

View File

@@ -44,7 +44,7 @@ return [
// 默认驱动方式
'default' => 'mysql',
// 加密key
'key' => 'u4w3NzEr5QTv2ygjYOoMVZ6snKAePxJp',
'key' => 'L1iYVS0PChKA9pjcFdmOGb4zfDIHo5xw',
// 加密方式
'algo' => 'ripemd160',
// 驱动

View File

@@ -35,6 +35,13 @@ return [
'publicPath' => public_path()
]
],
// 游戏期号:自动创建下一期(依赖 game_config.period_auto_create_enabled
'gamePeriodAutoTicker' => [
'handler' => app\process\GamePeriodAutoTicker::class,
'count' => 1,
'reloadable' => false,
],
// File update detection and automatic reload
'monitor' => [
'handler' => app\process\Monitor::class,

View File

@@ -169,32 +169,8 @@ Route::post('/admin/auth/rule/edit', [\app\admin\controller\auth\Rule::class, 'e
Route::post('/admin/auth/rule/del', [\app\admin\controller\auth\Rule::class, 'del']);
Route::get('/admin/auth/rule/select', [\app\admin\controller\auth\Rule::class, 'select']);
// admin/auth/adminLog(兼容 ThinkPHP 风格 /admin/auth.AdminLog/index
// admin/auth/adminLog
Route::get('/admin/auth/adminLog/index', [\app\admin\controller\auth\AdminLog::class, 'index']);
Route::get('/admin/auth.AdminLog/index', [\app\admin\controller\auth\AdminLog::class, 'index']);
// admin/user/user
Route::get('/admin/user/user/index', [\app\admin\controller\user\User::class, 'index']);
Route::post('/admin/user/user/add', [\app\admin\controller\user\User::class, 'add']);
Route::post('/admin/user/user/edit', [\app\admin\controller\user\User::class, 'edit']);
Route::get('/admin/user/user/select', [\app\admin\controller\user\User::class, 'select']);
// admin/user/group
Route::post('/admin/user/group/add', [\app\admin\controller\user\Group::class, 'add']);
Route::post('/admin/user/group/edit', [\app\admin\controller\user\Group::class, 'edit']);
// admin/user/rule
Route::get('/admin/user/rule/index', [\app\admin\controller\user\Rule::class, 'index']);
Route::post('/admin/user/rule/add', [\app\admin\controller\user\Rule::class, 'add']);
Route::post('/admin/user/rule/edit', [\app\admin\controller\user\Rule::class, 'edit']);
Route::post('/admin/user/rule/del', [\app\admin\controller\user\Rule::class, 'del']);
Route::get('/admin/user/rule/select', [\app\admin\controller\user\Rule::class, 'select']);
// admin/user/scoreLog
Route::post('/admin/user/scoreLog/add', [\app\admin\controller\user\ScoreLog::class, 'add']);
// admin/user/moneyLog
Route::post('/admin/user/moneyLog/add', [\app\admin\controller\user\MoneyLog::class, 'add']);
// admin/routine/config
Route::get('/admin/routine/config/index', [\app\admin\controller\routine\Config::class, 'index']);
@@ -246,21 +222,39 @@ Route::get('/admin/security/dataRecycleLog/index', [\app\admin\controller\securi
Route::post('/admin/security/dataRecycleLog/restore', [\app\admin\controller\security\DataRecycleLog::class, 'restore']);
Route::get('/admin/security/dataRecycleLog/info', [\app\admin\controller\security\DataRecycleLog::class, 'info']);
// ==================== 显式路由(优先级高,避免动态路由误匹配 ====================
Route::add(['GET', 'POST', 'HEAD'], '/admin/game.User/defaultWeightPresets', [\app\admin\controller\game\User::class, 'defaultWeightPresets']);
Route::add(['GET', 'POST', 'HEAD'], '/admin/game.User/defaultWeightByChannel', [\app\admin\controller\game\User::class, 'defaultWeightByChannel']);
Route::add(['GET', 'POST', 'HEAD'], '/admin/game.user/defaultWeightPresets', [\app\admin\controller\game\User::class, 'defaultWeightPresets']);
Route::add(['GET', 'POST', 'HEAD'], '/admin/game.user/defaultWeightByChannel', [\app\admin\controller\game\User::class, 'defaultWeightByChannel']);
// 游戏渠道:删除前统计关联数据(显式路由,避免动态路由 404
Route::add(['GET', 'POST', 'HEAD'], '/admin/game.Channel/deleteRelatedCounts', [\app\admin\controller\game\Channel::class, 'deleteRelatedCounts']);
Route::add(['GET', 'POST', 'HEAD'], '/admin/game.channel/deleteRelatedCounts', [\app\admin\controller\game\Channel::class, 'deleteRelatedCounts']);
// ==================== CRUD 生成的根级控制器(/admin/item/index 或 /admin/Item/index无子目录、无点号 ====================
// 显式路由在上,此处作为兜底;与 /admin/module.controller/action 互补
Route::add(
['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD'],
'/admin/{controller:[a-zA-Z][a-zA-Z0-9]*}/{action}',
function (\Webman\Http\Request $request, string $controller, string $action) {
$class = '\\app\\admin\\controller\\' . ucfirst(strtolower($controller));
if (!class_exists($class)) {
return new Response(404, ['Content-Type' => 'application/json'], json_encode(['code' => 404, 'msg' => '404 Not Found', 'data' => []], JSON_UNESCAPED_UNICODE));
}
if (!method_exists($class, $action)) {
return new Response(404, ['Content-Type' => 'application/json'], json_encode(['code' => 404, 'msg' => '404 Not Found', 'data' => []], JSON_UNESCAPED_UNICODE));
}
$request->controller = $class;
try {
$instance = new $class();
return $instance->$action($request);
} catch (\Throwable $e) {
return new Response(500, ['Content-Type' => 'application/json'], json_encode([
'code' => 0,
'msg' => $e->getMessage(),
'time' => time(),
'data' => null,
], JSON_UNESCAPED_UNICODE));
}
}
);
// ==================== 兼容 ThinkPHP 风格 URLmodule.Controller/action ====================
// 前端使用 /admin/user.Rule/index 格式,需转换为控制器调用
Route::add(
['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD'],
'/admin/{controllerPart:[a-zA-Z]+\\.[a-zA-Z0-9]+}/{action:[^/]+}',
'/admin/{controllerPart:[a-zA-Z]+\\.[a-zA-Z0-9]+}/{action}',
function (\Webman\Http\Request $request, string $controllerPart, string $action) {
$pos = strpos($controllerPart, '.');
if ($pos === false) {
@@ -268,21 +262,8 @@ Route::add(
}
$module = substr($controllerPart, 0, $pos);
$controller = substr($controllerPart, $pos + 1);
// game.user / game.User 等:小写控制器名需解析为 User.phpPSR-4 类名 StudlyCase
$class = null;
$candidates = array_unique([
$controller,
ucfirst($controller),
ucfirst(strtolower($controller)),
]);
foreach ($candidates as $base) {
$tryClass = '\\app\\admin\\controller\\' . strtolower($module) . '\\' . $base;
if (class_exists($tryClass)) {
$class = $tryClass;
break;
}
}
if ($class === null) {
$class = '\\app\\admin\\controller\\' . strtolower($module) . '\\' . $controller;
if (!class_exists($class)) {
return new Response(404, ['Content-Type' => 'application/json'], json_encode(['code' => 404, 'msg' => '404 Not Found', 'data' => []], JSON_UNESCAPED_UNICODE));
}
if (!method_exists($class, $action)) {
@@ -304,26 +285,6 @@ Route::add(
}
);
// 兜底closure 显式调用,避免路由绑定在某些情况下仍落到 404
Route::add(['GET', 'POST', 'HEAD'], '/admin/game.User/defaultWeightByChannel/', function (\Webman\Http\Request $request) {
return (new \app\admin\controller\game\User())->defaultWeightByChannel($request);
});
Route::add(['GET', 'POST', 'HEAD'], '/admin/game.user/defaultWeightByChannel/', function (\Webman\Http\Request $request) {
return (new \app\admin\controller\game\User())->defaultWeightByChannel($request);
});
Route::add(['GET', 'POST', 'HEAD'], '/admin/game.User/defaultWeightPresets/', function (\Webman\Http\Request $request) {
return (new \app\admin\controller\game\User())->defaultWeightPresets($request);
});
Route::add(['GET', 'POST', 'HEAD'], '/admin/game.user/defaultWeightPresets/', function (\Webman\Http\Request $request) {
return (new \app\admin\controller\game\User())->defaultWeightPresets($request);
});
Route::add(['GET', 'POST', 'HEAD'], '/admin/game.Channel/deleteRelatedCounts/', function (\Webman\Http\Request $request) {
return (new \app\admin\controller\game\Channel())->deleteRelatedCounts($request);
});
Route::add(['GET', 'POST', 'HEAD'], '/admin/game.channel/deleteRelatedCounts/', function (\Webman\Http\Request $request) {
return (new \app\admin\controller\game\Channel())->deleteRelatedCounts($request);
});
// ==================== CORS 预检OPTIONS ====================
// 放在最后注册;显式加上前端会请求的路径,再加固通配
Route::add('OPTIONS', '/api/index/index', [\app\common\middleware\AllowCrossDomain::class, 'optionsResponse']);

View File

@@ -42,11 +42,11 @@ return [
// 服务器地址
'hostname' => $env('database.hostname', '127.0.0.1'),
// 数据库名(与 database.php / .env 一致)
'database' => $env('database.database', 'webman-buildadmin-dafuweng'),
'database' => $env('database.database', 'buildadmin-webman'),
// 用户名(与 .env DATABASE_USERNAME 一致,默认勿用 root 以免与本机 MySQL 不符)
'username' => $env('database.username', 'webman-buildadmin-dafuweng'),
'username' => $env('database.username', 'buildadmin-webman'),
// 密码(与 .env DATABASE_PASSWORD 一致)
'password' => $env('database.password', '6dzMaX32Xdsc4DjS'),
'password' => $env('database.password', '123456'),
// 端口
'hostport' => $env('database.hostport', '3306'),
// 数据库连接参数MYSQL_ATTR_USE_BUFFERED_QUERY 避免 "Cannot execute queries while other unbuffered queries are active"

View File

@@ -0,0 +1,411 @@
-- =====================================================================
-- 「36字花」数据库 DDL V1
-- 字符集utf8mb4 | 引擎InnoDB | 金额DECIMAL(18,4)(游戏币)
-- 执行前请全库备份。若库中已存在同名列/表,请按需删减对应语句后执行。
-- =====================================================================
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ---------------------------------------------------------------------
-- 一、新渠道表channel替代 game_channel
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `channel`;
CREATE TABLE `channel` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
`top_admin_id` int(10) unsigned DEFAULT NULL COMMENT '顶级代理管理员IDadmin.id',
`code` varchar(255) NOT NULL DEFAULT '' COMMENT '渠道标识',
`invite_code` varchar(64) DEFAULT NULL COMMENT '渠道主邀请码',
`name` varchar(255) NOT NULL DEFAULT '' COMMENT '渠道名',
`agent_mode` varchar(32) NOT NULL DEFAULT 'turnover' COMMENT '代理模式turnover=普通刷水代理 affiliate=联营代理',
`user_count` int(10) DEFAULT NULL COMMENT '用户数',
`profit_amount` decimal(18,4) DEFAULT NULL COMMENT '渠道当期利润快照(游戏币)',
`total_profit_amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '渠道累计总利润(游戏币)',
`commission_pool_amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '可分红资金池(游戏币)',
`turnover_share_rate` decimal(9,6) DEFAULT NULL COMMENT '普通刷水代理分红比例(按流水分桶后再乘该比例)',
`affiliate_share_rate` decimal(9,6) DEFAULT NULL COMMENT '联营代理占成比例',
`affiliate_fee_rate` decimal(9,6) DEFAULT NULL COMMENT '联营代理成本扣除比例',
`carryover_balance` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '联营负结转余额(可负)',
`status` tinyint(1) unsigned NOT NULL DEFAULT 1 COMMENT '状态:0=禁用,1=启用',
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
`admin_id` int(10) unsigned DEFAULT NULL COMMENT '创建管理员',
`admin_group_id` int(10) unsigned DEFAULT NULL COMMENT '渠道角色组IDadmin_group.id',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
`update_time` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code` (`code`),
UNIQUE KEY `uk_invite_code` (`invite_code`),
KEY `idx_agent_mode` (`agent_mode`),
KEY `idx_top_admin_id` (`top_admin_id`),
KEY `idx_admin_group_id` (`admin_group_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='渠道表替代game_channel';
-- 可选数据迁移(仅当历史存在 game_channel 且字段兼容时执行)
-- INSERT INTO `channel` (`id`,`code`,`name`,`user_count`,`profit_amount`,`status`,`remark`,`admin_id`,`admin_group_id`,`create_time`,`update_time`)
-- SELECT `id`,`code`,`name`,`user_count`,CAST(`profit_amount` AS DECIMAL(18,4)),`status`,`remark`,`admin_id`,`admin_group_id`,`create_time`,`update_time`
-- FROM `game_channel`;
-- ---------------------------------------------------------------------
-- 二、既有表适配game_userC 端用户)
-- ---------------------------------------------------------------------
ALTER TABLE `game_user`
MODIFY COLUMN `coin` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '游戏币余额',
ADD COLUMN `email` varchar(255) NOT NULL DEFAULT '' COMMENT '邮箱,可与手机号二选一注册' AFTER `phone`,
ADD COLUMN `channel_id` int(10) unsigned DEFAULT NULL COMMENT '所属渠道IDchannel.id' AFTER `game_channel_id`,
ADD COLUMN `register_invite_code` varchar(64) NOT NULL DEFAULT '' COMMENT '注册时使用的邀请码快照' AFTER `channel_id`,
ADD COLUMN `total_deposit_coin` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '累计充值入账游戏币(提现流水校验分子/分母用)' AFTER `register_invite_code`,
ADD COLUMN `total_valid_bet_coin` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '累计有效投注游戏币' AFTER `total_deposit_coin`,
ADD COLUMN `risk_flags` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '风控位标记:可按位表示禁止登录/下注/提现等' AFTER `total_valid_bet_coin`,
ADD COLUMN `current_streak` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '当前连胜场次DB兜底权威状态可放Redis' AFTER `risk_flags`,
ADD COLUMN `last_bet_period_no` varchar(64) NOT NULL DEFAULT '' COMMENT '上一笔有效下注所在期号,用于连胜连续期校验' AFTER `current_streak`;
ALTER TABLE `game_user`
ADD KEY `idx_channel_id` (`channel_id`),
ADD KEY `idx_register_invite_code` (`register_invite_code`),
ADD KEY `idx_last_bet_period_no` (`last_bet_period_no`);
-- ---------------------------------------------------------------------
-- 三、既有表适配admin子代理管理
-- ---------------------------------------------------------------------
ALTER TABLE `admin`
ADD COLUMN `parent_admin_id` int(11) unsigned DEFAULT NULL COMMENT '上级代理管理员ID顶级为空' AFTER `id`,
ADD COLUMN `channel_id` int(10) unsigned DEFAULT NULL COMMENT '所属渠道IDchannel.id' AFTER `parent_admin_id`,
ADD COLUMN `invite_code` varchar(64) DEFAULT NULL COMMENT '子代理邀请码(用于发展玩家)' AFTER `mobile`,
ADD COLUMN `agent_role` varchar(32) NOT NULL DEFAULT 'sub_agent' COMMENT 'agent_admin|sub_agent|staff均无开奖权限' AFTER `status`;
ALTER TABLE `admin`
ADD UNIQUE KEY `uk_invite_code` (`invite_code`),
ADD KEY `idx_parent_admin_id` (`parent_admin_id`),
ADD KEY `idx_channel_id` (`channel_id`);
-- ---------------------------------------------------------------------
-- 四、既有表适配admin_group角色组按渠道隔离
-- ---------------------------------------------------------------------
ALTER TABLE `admin_group`
ADD COLUMN `channel_id` int(10) unsigned DEFAULT NULL COMMENT '所属渠道IDchannel.idNULL表示系统级角色组' AFTER `pid`,
ADD COLUMN `commission_rate` decimal(6,2) NOT NULL DEFAULT 0.00 COMMENT '角色组分红比例(百分比)' AFTER `channel_id`;
ALTER TABLE `admin_group`
ADD KEY `idx_channel_id` (`channel_id`);
-- ---------------------------------------------------------------------
-- 五、系统参数KV禁止业务写死
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `game_config`;
CREATE TABLE `game_config` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
`config_key` varchar(64) NOT NULL DEFAULT '' COMMENT '参数键,全局唯一',
`config_value` text COMMENT '参数值可为JSON',
`value_type` varchar(16) NOT NULL DEFAULT 'string' COMMENT 'string|int|decimal|json',
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '说明',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
`update_time` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_config_key` (`config_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='游戏/平台动态参数';
-- ---------------------------------------------------------------------
-- 四、期号与状态机
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `game_period`;
CREATE TABLE `game_period` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`period_no` varchar(64) NOT NULL DEFAULT '' COMMENT '业务期号,唯一,如 20260414-123045',
`period_start_at` bigint(16) unsigned NOT NULL DEFAULT 0 COMMENT '本期开始时间戳',
`status` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '0=下注开放 1=已封盘 2=算票中 3=派彩中 4=已结束 5=已作废',
`draw_mode` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '0=自动AI 1=手动预设',
`preset_number` tinyint(3) unsigned DEFAULT NULL COMMENT '手动模式预设开奖号码 1-36',
`result_number` tinyint(3) unsigned DEFAULT NULL COMMENT '实际开奖号码 1-36',
`void_reason` varchar(255) NOT NULL DEFAULT '' COMMENT '作废原因',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
`update_time` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_period_no` (`period_no`),
KEY `idx_status_start` (`status`,`period_start_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='游戏期号与状态';
-- ---------------------------------------------------------------------
-- 五、注单
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `game_bet_order`;
CREATE TABLE `game_bet_order` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`period_id` bigint(20) unsigned NOT NULL DEFAULT 0 COMMENT '关联 game_period.id',
`period_no` varchar(64) NOT NULL DEFAULT '' COMMENT '冗余期号,便于对账与查询',
`user_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'game_user.id',
`channel_id` int(10) unsigned DEFAULT NULL COMMENT '下单时归属渠道',
`pick_numbers` json DEFAULT NULL COMMENT '选中号码 JSON 数组,如 [1,8,16]',
`unit_amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '单号金额(各号一致)',
`pick_count` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '选中号码个数',
`total_amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '本单总金额',
`streak_at_bet` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '下注时连胜次数(用于派彩倍率)',
`is_auto` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '0=手动 1=托管',
`win_amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '常规派彩金额(含连赢倍率后)',
`jackpot_extra_amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT 'Jackpot 额外派彩',
`status` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '1=已扣款待开奖 2=已结算 3=已作废已退款',
`idempotency_key` varchar(64) DEFAULT NULL COMMENT '幂等键防重复下单未使用则为NULL允许多条NULL',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
`update_time` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_idempotency` (`idempotency_key`),
KEY `idx_period_user` (`period_id`,`user_id`),
KEY `idx_user_ctime` (`user_id`,`create_time`),
KEY `idx_period_no` (`period_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='游戏注单';
-- ---------------------------------------------------------------------
-- 六、自动托管
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `game_bet_auto`;
CREATE TABLE `game_bet_auto` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'game_user.id',
`remaining_rounds` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '剩余托管局数',
`pick_numbers` json DEFAULT NULL COMMENT '选号 JSON',
`unit_amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '单号金额',
`status` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '0=停止 1=运行中',
`next_trigger_before` bigint(16) unsigned NOT NULL DEFAULT 0 COMMENT '下次允许自动下注前的时间戳上界配合0-10秒抖动',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
`update_time` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
KEY `idx_user_status` (`user_id`,`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='自动托管任务';
-- ---------------------------------------------------------------------
-- 七、用户钱包流水账
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `user_wallet_ledger`;
CREATE TABLE `user_wallet_ledger` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'game_user.id',
`biz_type` varchar(32) NOT NULL DEFAULT '' COMMENT '业务类型deposit|bet|payout|withdraw_freeze|withdraw|fee|manual|void_refund 等',
`direction` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '1=入金 2=出金',
`amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '变动额,恒正',
`balance_after` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '变动后余额',
`ref_type` varchar(32) NOT NULL DEFAULT '' COMMENT '关联类型period|bet_order|deposit_order|withdraw_order',
`ref_id` bigint(20) unsigned DEFAULT NULL COMMENT '关联主键',
`remark` varchar(500) NOT NULL DEFAULT '' COMMENT '备注',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_user_ctime` (`user_id`,`create_time`),
KEY `idx_ref` (`ref_type`,`ref_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='用户游戏币账本流水';
-- ---------------------------------------------------------------------
-- 八、充值订单
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `deposit_order`;
CREATE TABLE `deposit_order` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`order_no` varchar(64) NOT NULL DEFAULT '' COMMENT '内部订单号',
`user_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'game_user.id',
`fiat_currency` varchar(16) NOT NULL DEFAULT '' COMMENT '法币币种代码',
`fiat_amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '法币金额',
`fx_rate` decimal(18,8) NOT NULL DEFAULT 0.00000000 COMMENT '成交汇率',
`coin_amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '入账游戏币',
`gateway` varchar(32) NOT NULL DEFAULT '' COMMENT '支付网关标识',
`gateway_trade_no` varchar(128) NOT NULL DEFAULT '' COMMENT '第三方流水号',
`status` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '0=待支付 1=成功 2=失败 3=关闭',
`paid_at` bigint(16) unsigned DEFAULT NULL COMMENT '支付成功时间',
`notify_payload` mediumtext COMMENT '回调原文存档',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
`update_time` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_user_ctime` (`user_id`,`create_time`),
KEY `idx_gateway_trade` (`gateway`,`gateway_trade_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='充值订单';
-- ---------------------------------------------------------------------
-- 九、提现订单
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `withdraw_order`;
CREATE TABLE `withdraw_order` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`order_no` varchar(64) NOT NULL DEFAULT '' COMMENT '内部订单号',
`user_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'game_user.id',
`apply_amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '申请提现游戏币',
`fee_amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '手续费游戏币',
`actual_amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '应出账游戏币(扣费后)',
`fiat_currency` varchar(16) NOT NULL DEFAULT '' COMMENT '出款法币币种',
`need_audit` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '0=免审 1=人工审核',
`audit_status` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '0=待审 1=通过 2=驳回',
`from_jackpot` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '0=否 1=Jackpot相关拦截',
`reject_reason` varchar(500) NOT NULL DEFAULT '' COMMENT '驳回原因',
`paid_at` bigint(16) unsigned DEFAULT NULL COMMENT '出款完成时间',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
`update_time` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_user_ctime` (`user_id`,`create_time`),
KEY `idx_audit` (`need_audit`,`audit_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='提现订单';
-- ---------------------------------------------------------------------
-- 十、代理钱包(每渠道/代理线一条)
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `game_agent_wallet`;
CREATE TABLE `game_agent_wallet` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`channel_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'channel.id一对一',
`balance` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '可用佣金余额',
`frozen` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '冻结',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
`update_time` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_channel` (`channel_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='代理佣金钱包';
-- ---------------------------------------------------------------------
-- 十一、代理钱包流水
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `game_agent_wallet_ledger`;
CREATE TABLE `game_agent_wallet_ledger` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`channel_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'channel.id',
`biz_type` varchar(32) NOT NULL DEFAULT '' COMMENT 'commission_payout|withdraw|manual 等',
`direction` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '1=入金 2=出金',
`amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '变动额,恒正',
`balance_after` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '变动后余额',
`ref_type` varchar(32) NOT NULL DEFAULT '' COMMENT '关联类型',
`ref_id` bigint(20) unsigned DEFAULT NULL COMMENT '关联主键',
`remark` varchar(500) NOT NULL DEFAULT '' COMMENT '备注',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_channel_ctime` (`channel_id`,`create_time`),
KEY `idx_ref` (`ref_type`,`ref_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='代理钱包流水';
-- ---------------------------------------------------------------------
-- 十二、代理结算周期(大盘快照)
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `agent_settlement_period`;
CREATE TABLE `agent_settlement_period` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`period_type` varchar(16) NOT NULL DEFAULT '' COMMENT 'daily|weekly|monthly',
`period_key` varchar(32) NOT NULL DEFAULT '' COMMENT '周期唯一键,如 2026-W16',
`platform_ggr` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '平台当期总客损/毛利(可负)',
`platform_turnover` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '全网有效投注流水',
`status` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '0=未结算 1=已计算 2=已发放',
`start_at` bigint(16) unsigned NOT NULL DEFAULT 0 COMMENT '周期开始时间戳',
`end_at` bigint(16) unsigned NOT NULL DEFAULT 0 COMMENT '周期结束时间戳',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
`update_time` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_period` (`period_type`,`period_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='代理结算周期';
-- ---------------------------------------------------------------------
-- 十三、代理佣金明细(级差按行记录)
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `agent_commission_record`;
CREATE TABLE `agent_commission_record` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`settlement_period_id` bigint(20) unsigned NOT NULL DEFAULT 0 COMMENT 'agent_settlement_period.id',
`channel_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '获得佣金的渠道',
`commission_mode` varchar(16) NOT NULL DEFAULT 'turnover' COMMENT 'turnover=流水占比 affiliate=联营',
`line_turnover` decimal(18,4) DEFAULT NULL COMMENT '该线当期流水turnover 模式用)',
`line_turnover_ratio` decimal(18,8) DEFAULT NULL COMMENT '占全网比例',
`pool_amount` decimal(18,4) DEFAULT NULL COMMENT '该线分桶基数',
`rate_snapshot` decimal(9,6) DEFAULT NULL COMMENT '当期有效拨比/级差快照',
`commission_amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '本笔应发佣金',
`status` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '0=待入账 1=已入账',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_period_channel` (`settlement_period_id`,`channel_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='代理佣金明细';
-- ---------------------------------------------------------------------
-- 十四、联营契约
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `affiliate_contract`;
CREATE TABLE `affiliate_contract` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`channel_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '联营代理 channel.id',
`title` varchar(128) NOT NULL DEFAULT '' COMMENT '契约标题',
`share_rate` decimal(9,6) NOT NULL DEFAULT 0.000000 COMMENT '客损占成比例',
`admin_fee_rate` decimal(9,6) NOT NULL DEFAULT 0.000000 COMMENT '平台双降费等成本比例快照',
`tier_json` json DEFAULT NULL COMMENT '阶梯条件 JSON可选',
`effective_from` bigint(16) unsigned NOT NULL DEFAULT 0 COMMENT '生效时间',
`effective_to` bigint(16) unsigned DEFAULT NULL COMMENT '失效时间,空为长期',
`status` tinyint(3) unsigned NOT NULL DEFAULT 1 COMMENT '0=停用 1=有效',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
`update_time` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
KEY `idx_channel_time` (`channel_id`,`effective_from`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='联营契约';
-- ---------------------------------------------------------------------
-- 十五、联营负结转(按代理线 + 周期)
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `affiliate_carryover`;
CREATE TABLE `affiliate_carryover` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`settlement_period_id` bigint(20) unsigned NOT NULL DEFAULT 0 COMMENT 'agent_settlement_period.id',
`channel_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '联营代理线',
`opening_carryover` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '期初结转(常为负)',
`period_net_ggr` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '当期辖区净客损',
`period_net_after_fee` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '扣费后净利润基数',
`commission_amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '本期应发联营佣金',
`closing_carryover` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '期末结转至下期',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_period_channel` (`settlement_period_id`,`channel_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='联营负结转';
-- ---------------------------------------------------------------------
-- 十六、运营公告
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `operation_notice`;
CREATE TABLE `operation_notice` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`title` varchar(255) NOT NULL DEFAULT '' COMMENT '标题',
`content` mediumtext COMMENT '正文,可富文本',
`notice_type` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '0=静默信箱 1=强弹窗',
`status` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '0=草稿 1=发布',
`publish_at` bigint(16) unsigned DEFAULT NULL COMMENT '发布时间',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
`update_time` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
KEY `idx_type_status` (`notice_type`,`status`,`publish_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='运营公告';
-- ---------------------------------------------------------------------
-- 十七、用户公告已读/确认
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `user_notice_read`;
CREATE TABLE `user_notice_read` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'game_user.id',
`notice_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'operation_notice.id',
`read_at` bigint(16) unsigned DEFAULT NULL COMMENT '阅读时间',
`confirmed` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '强弹窗是否已勾选确认 0/1',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_notice` (`user_id`,`notice_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='用户公告阅读与确认';
-- ---------------------------------------------------------------------
-- 十八、站内信(与公告分离的个性化通知)
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `user_site_message`;
CREATE TABLE `user_site_message` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'game_user.id',
`title` varchar(255) NOT NULL DEFAULT '' COMMENT '标题',
`body` mediumtext COMMENT '内容',
`category` varchar(32) NOT NULL DEFAULT 'system' COMMENT '分类',
`read_at` bigint(16) unsigned DEFAULT NULL COMMENT '已读时间,空未读',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_user_read` (`user_id`,`read_at`,`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='用户站内信';
SET FOREIGN_KEY_CHECKS = 1;
-- =====================================================================
-- 可选game_config 初始键(按需导入,值由运营最终确定)
-- INSERT INTO game_config (config_key, config_value, value_type, remark, create_time, update_time) VALUES
-- ('period_seconds','30','int','单局总时长秒', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
-- ('bet_seconds','20','int','下注开放秒数', UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
-- =====================================================================

View File

@@ -17,8 +17,6 @@ class InstallData extends AbstractMigration
$this->menuRule();
$this->securityDataRecycle();
$this->securitySensitiveData();
$this->user();
$this->userGroup();
$this->userRule();
}
@@ -231,8 +229,17 @@ class InstallData extends AbstractMigration
public function menuRule(): void
{
if (!$this->hasTable('menu_rule')) return;
$table = $this->table('menu_rule');
// Install 迁移在已存在 admin_rule旧版表名时会跳过创建 menu_rule此处需与之一致
$ruleTable = null;
if ($this->hasTable('menu_rule')) {
$ruleTable = 'menu_rule';
} elseif ($this->hasTable('admin_rule')) {
$ruleTable = 'admin_rule';
}
if ($ruleTable === null) {
return;
}
$table = $this->table($ruleTable);
$rows = [
[
'id' => '1',
@@ -447,245 +454,6 @@ class InstallData extends AbstractMigration
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '21',
'type' => 'menu_dir',
'title' => '会员管理',
'name' => 'user',
'path' => 'user',
'icon' => 'fa fa-drivers-license',
'weigh' => '95',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '22',
'pid' => '21',
'type' => 'menu',
'title' => '会员管理',
'name' => 'user/user',
'path' => 'user/user',
'icon' => 'fa fa-user',
'menu_type' => 'tab',
'component' => '/src/views/backend/user/user/index.vue',
'keepalive' => '1',
'weigh' => '94',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '23',
'pid' => '22',
'type' => 'button',
'title' => '查看',
'name' => 'user/user/index',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '24',
'pid' => '22',
'type' => 'button',
'title' => '添加',
'name' => 'user/user/add',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '25',
'pid' => '22',
'type' => 'button',
'title' => '编辑',
'name' => 'user/user/edit',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '26',
'pid' => '22',
'type' => 'button',
'title' => '删除',
'name' => 'user/user/del',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '27',
'pid' => '21',
'type' => 'menu',
'title' => '会员分组管理',
'name' => 'user/group',
'path' => 'user/group',
'icon' => 'fa fa-group',
'menu_type' => 'tab',
'component' => '/src/views/backend/user/group/index.vue',
'keepalive' => '1',
'weigh' => '93',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '28',
'pid' => '27',
'type' => 'button',
'title' => '查看',
'name' => 'user/group/index',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '29',
'pid' => '27',
'type' => 'button',
'title' => '添加',
'name' => 'user/group/add',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '30',
'pid' => '27',
'type' => 'button',
'title' => '编辑',
'name' => 'user/group/edit',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '31',
'pid' => '27',
'type' => 'button',
'title' => '删除',
'name' => 'user/group/del',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '32',
'pid' => '21',
'type' => 'menu',
'title' => '会员规则管理',
'name' => 'user/rule',
'path' => 'user/rule',
'icon' => 'fa fa-th-list',
'menu_type' => 'tab',
'component' => '/src/views/backend/user/rule/index.vue',
'keepalive' => '1',
'weigh' => '92',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '33',
'pid' => '32',
'type' => 'button',
'title' => '查看',
'name' => 'user/rule/index',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '34',
'pid' => '32',
'type' => 'button',
'title' => '添加',
'name' => 'user/rule/add',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '35',
'pid' => '32',
'type' => 'button',
'title' => '编辑',
'name' => 'user/rule/edit',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '36',
'pid' => '32',
'type' => 'button',
'title' => '删除',
'name' => 'user/rule/del',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '37',
'pid' => '32',
'type' => 'button',
'title' => '快速排序',
'name' => 'user/rule/sortable',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '38',
'pid' => '21',
'type' => 'menu',
'title' => '会员余额管理',
'name' => 'user/moneyLog',
'path' => 'user/moneyLog',
'icon' => 'el-icon-Money',
'menu_type' => 'tab',
'component' => '/src/views/backend/user/moneyLog/index.vue',
'keepalive' => '1',
'weigh' => '91',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '39',
'pid' => '38',
'type' => 'button',
'title' => '查看',
'name' => 'user/moneyLog/index',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '40',
'pid' => '38',
'type' => 'button',
'title' => '添加',
'name' => 'user/moneyLog/add',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '41',
'pid' => '21',
'type' => 'menu',
'title' => '会员积分管理',
'name' => 'user/scoreLog',
'path' => 'user/scoreLog',
'icon' => 'el-icon-Discount',
'menu_type' => 'tab',
'component' => '/src/views/backend/user/scoreLog/index.vue',
'keepalive' => '1',
'weigh' => '90',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '42',
'pid' => '41',
'type' => 'button',
'title' => '查看',
'name' => 'user/scoreLog/index',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '43',
'pid' => '41',
'type' => 'button',
'title' => '添加',
'name' => 'user/scoreLog/add',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '44',
'type' => 'menu_dir',
@@ -1155,7 +923,7 @@ class InstallData extends AbstractMigration
'createtime' => $this->nowTime,
],
];
$exist = Db::name('menu_rule')->where('id', 1)->value('id');
$exist = Db::name($ruleTable)->where('id', 1)->value('id');
if (!$exist) {
$table->insert($rows)->saveData();
}
@@ -1205,16 +973,6 @@ class InstallData extends AbstractMigration
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => 5,
'name' => '会员',
'controller' => 'user/User.php',
'controller_as' => 'user/user',
'data_table' => 'user',
'primary_key' => 'id',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => 6,
'name' => '数据回收规则',
@@ -1248,18 +1006,6 @@ class InstallData extends AbstractMigration
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => 2,
'name' => '会员数据',
'controller' => 'user/User.php',
'controller_as' => 'user/user',
'data_table' => 'user',
'primary_key' => 'id',
'data_fields' => '{"username":"用户名","mobile":"手机号","password":"密码","status":"状态","email":"邮箱地址"}',
'status' => '1',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => 3,
'name' => '管理员权限',
@@ -1279,49 +1025,6 @@ class InstallData extends AbstractMigration
}
}
public function user(): void
{
$table = $this->table('user');
$rows = [
[
'id' => 1,
'group_id' => 1,
'username' => 'user',
'nickname' => 'User',
'email' => '18888888888@qq.com',
'mobile' => '18888888888',
'gender' => '2',
'birthday' => date('Y-m-d'),
'status' => 'enable',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
]
];
$exist = Db::name('user')->where('id', 1)->value('id');
if (!$exist) {
$table->insert($rows)->saveData();
}
}
public function userGroup(): void
{
$table = $this->table('user_group');
$rows = [
[
'id' => 1,
'name' => '默认分组',
'rules' => '*',
'status' => '1',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
]
];
$exist = Db::name('user_group')->where('id', 1)->value('id');
if (!$exist) {
$table->insert($rows)->saveData();
}
}
public function userRule(): void
{
$table = $this->table('user_rule');

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,429 @@
# 「36字花」数据库与实施计划
**文档版本**V1.10
**依据**《36字花 PRD》《业务流程说明书》《后端系统规格书》及现有表 `user`
**目标**:明确分阶段落地步骤、需新建表、两表适配方向与可执行验证清单。
**DDL 脚本(可执行)**[database/dfw_36zihua_schema_v1.sql](../database/dfw_36zihua_schema_v1.sql)
内含:新建 `channel` 表,及 `user` / `admin` / `admin_group``ALTER` 语句,以及新建表完整 `CREATE TABLE`(字段级 `COMMENT` 即字段清单)。
---
## 一、现状与表定位
| 表名 | 当前角色 | 目标角色 |
|------|----------|----------|
| `channel` | 新渠道主表 | **顶级代理渠道分红配置表**:由超管创建,维护分红方式、分红金额、总利润等渠道参数 |
| `user` | 用户coin、channel_id 等) | **C 端玩家主表**:余额与精度、归属代理、流水与风控字段 |
| `admin` | 后台管理员 | **子代理承载表**:顶级代理下级代理放在 `admin`,通过 `invite_code` 管理客户,统一无开奖控制权 |
| `admin_group` | 角色组 | **按渠道隔离**:角色组归属 `channel`,渠道仅管理本渠道角色组 |
### 1.1 全局对局与渠道职责(重要)
与「多渠道」并存的一条硬规则:**对局与开奖全局唯一**,渠道**不**切分牌桌、**不**各自开奖。
| 维度 | 口径 |
|------|------|
| **期号 / 对局** | **全平台共用一套** `game_period`(及全网唯一的当前 `period_no`、同一套状态机)。不存在「每个渠道一局」或「每渠道独立期号表」。 |
| **开奖结果** | **全渠道玩家共享同一期、同一 `result_number`**。所有玩家(无论来自哪个 `channel_id`)压的是**同一场**;避免用户感知「不同入口有不同局、可被渠道操纵」。 |
| **渠道的用途** | `channel``user.channel_id`、注单上的 **`channel_id` 快照**(若有)仅用于 **代理归属、分润、风控与后台数据范围****不**用于生成多套并行对局或独立开奖。 |
| **Redis / 接口** | 当前期倒计时、封盘、算票、开奖号码等 **热状态全局一份**;不得在业务上按渠道维护多套「当前期」或多套开奖结果。 |
> **与 DDL 的对应**`game_period` **不设** `channel_id``bet_order.channel_id` 为下单时用户归属快照,不改变「全场一局」语义。详见 §11.5。
---
## 二、分阶段执行计划与「先核心、后细节」顺序
**原则**:先打通 **账号 / 渠道 / 余额与流水 / 最小可演示闭环**,再叠 **玩法细则、自动开奖调度、公告与站内信等展示类能力**。避免一上来就做全量 DDL 与全部后台菜单,导致联调面过大。
### 2.1 总览(与下文 P0P4 对应)
| 阶段 | 目标 | 主要内容 |
|------|------|----------|
| **P0** | 数据地基 | 新建 `channel` 并适配 `user``admin``admin_group`;建立账本类最小表集(金额精度统一) |
| **P1** | 游戏核心 | 期号、注单、开奖、系统配置Redis 状态机 + MQ 异步落库 |
| **P2** | 资金与风控 | 充提订单、流水账、提现审核、Jackpot / 大额拦截 |
| **P3** | 代理与结算 | 流水占比分桶、级差、联营占成与负结转(表结构 + 跑批骨架) |
| **P4** | 运营与审计 | 公告(含 Pop-out、站内信、后台 RBAC 与操作审计 |
### 2.2 建议先做的:第一批建表(核心路径)
按依赖顺序执行 DDL或 Phinx**优先保证下列表/变更已落地**,其余表可分期补建:
| 顺序 | 类型 | 对象 | 说明 |
|------|------|------|------|
| 1 | 新建 | **`channel`** | 渠道与顶级代理参数;替代历史 `game_channel` 方向 |
| 2 | 适配 | **`user``admin``admin_group`** | `coin` 精度、`channel_id`、邀请码与风控等(见 §四~§六) |
| 3 | 新建 | **`game_config`** | 全局可调参数(局时长、下注窗口等),避免写死 |
| 4 | 新建 | **`user_wallet_record`** | 玩家钱包流水,与余额变更同事务、可审计 |
| 5 | 新建 | **`game_period`**、**`bet_order`** | 最小「一期号 + 注单」闭环;先有表再写状态机与接口 |
**第一批可暂缓的表(归入后续迭代)**`game_bet_auto`(自动托管)、`operation_notice` / `user_notice_read` / `user_site_message`(纯展示与触达)、`game_agent_wallet` 及结算域各表(代理分佣与联营跑批)。
### 2.3 建议先做的:第一批后台菜单(与核心能力对齐)
在 BuildAdmin **`admin_rule`** 中,**优先保证**与下列能力相关的菜单与按钮权限可配置(具体 `component` 路径以仓库为准):
| 优先级 | 菜单能力 | 目的 |
|--------|----------|------|
| 高 | **渠道**、**游戏用户**、**角色组 / 管理员**(含渠道与邀请码相关字段) | 搭好「谁管哪条线、玩家归谁」 |
| 高 | **用户管理 / 用户钱包流水** | 覆盖玩家主数据与账务对账 |
| 高 | **游戏对局 / 压注订单** | 核心玩法与对账入口 |
| 中 | **游戏配置 / 36字花字典** | 运营参数与字典维护 |
| 中 | **充值订单 / 提现订单** | 充提流程管理与审核 |
| 低 | **公告 / 站内信**、**代理结算** 等 | 属 **P3P4**,核心跑通后再接 |
### 2.4 明确放到「细节阶段」的能力(勿阻塞第一期)
下列项依赖 **P1 状态机、定时任务、前端 C 端页面** 较完整后再做,避免与「账号 + 余额 + 最小下注」抢工期:
- **玩法细则**选号上限、连胜倍率、Jackpot 条件等在 `game_config` 与代码中逐步细化即可。
- **自动开奖 / 调度**依赖期号服务、Redis 与可选MQ表结构可先建**调度与算票逻辑后迭代**。
- **游戏公告、强弹窗、站内信展示**:表可后建;**后台编辑 + C 端展示** 属于运营体验增强,放在核心闭环之后。
### 2.5 最小可验收里程碑(建议)
1. **P0 完成**:能创建渠道与子代理管理员,玩家归属正确,`coin``user_wallet_record` 手工或脚本入账可对上。
2. **P1 骨架完成**:能创建期号、写入注单、产生钱包流水(即使开奖仍为**手动或脚本模拟**)。
3. 再推进 **自动流程、充提、公告展示****P3 结算**
### 2.6 本次重构迁移顺序(已执行口径)
1. 新建订单表:`deposit_order``withdraw_order`(带 `order_no` 唯一索引、`user_id/status/create_time` 常用索引)。
2. 硬重命名:`game_user -> user``game_bet_order -> bet_order`
3. 重建菜单与权限目录:`user``order``game``config``record`
4. 新增菜单节点:
- `user/user`
- `order/betOrder``order/depositOrder``order/withdrawOrder`
- `game/period`
- `config/gameConfig``config/ziHuaDictionary`
- `record/userWalletRecord`
5. 清理收口:删除迁移过程中的冗余权限别名规则,仅保留最终模块节点(`user/order/game/config/record`)。
---
## 三、`channel` 设计(替代 `game_channel`
### 3.1 建议保留字段
`id``code`(可作渠道码)、`invite_code``name``status``remark``admin_id``admin_group_id``create_time``update_time`
### 3.2 建议新增或调整(按你确认口径)
| 方向 | 说明 |
|------|------|
| 顶级代理归属 | `top_admin_id`:关联到顶级代理管理员 `admin.id` |
| 代理模式参数 | `agent_mode``turnover` 普通刷水 / `affiliate` 联营)、`turnover_share_rate``affiliate_share_rate``affiliate_fee_rate``carryover_balance` |
| 渠道经营指标 | `profit_amount`(精度升级)、`total_profit_amount``commission_pool_amount` |
| 邀请码参数 | 渠道不生成邀请码,邀请码由管理员自动生成并用于注册归属 |
| 代理钱包 | 余额可字段扩展或独立 `game_agent_wallet`(推荐独立表,便于对账) |
### 3.3 注意
`channel` 不承担子代理树结构;子代理层级与邀请码归属下沉到 `admin`。除超管外,渠道管理仅可查看当前管理员归属渠道。
---
## 四、`user` 适配(用户表)
### 4.1 建议保留字段
`id``username` / `phone``password``uuid``head_image``channel_id`(归属渠道)、`status``create_time``update_time`
### 4.2 建议新增或调整
| 方向 | 说明 |
|------|------|
| 金额精度 | `coin` 调整为 **`decimal(18,4)`**(与规格书一致,禁止用浮点存币) |
| 注册与归属 | `invite_code` 或注册时解析 URL 与 `channel.code` 绑定 |
| 提现门槛 | `total_deposit_coin``total_valid_bet_coin` 或等价的「待完成流水」字段(全项目统一一种口径) |
| 风控 | 禁止登录 / 禁止下注 / 禁止提现等,可用位标记或独立布尔字段 |
| 连胜持久化 | 可选 `current_streak``last_bet_period_id`;高频仍以 **Redis** 为准DB 作兜底同步 |
| 邮箱 | PRD 支持手机或邮箱注册时,可增加可空字段 `email`(是否唯一索引按产品定) |
---
## 五、`admin` 适配(子代理承载)
| 方向 | 说明 |
|------|------|
| 层级关系 | `parent_admin_id` 表示上级代理 |
| 渠道归属 | `channel_id` 表示该子代理属于哪个顶级渠道 |
| 邀请管理 | `invite_code` 自动生成且全局唯一,用于发展玩家并做归属绑定 |
| 角色标识 | `agent_role`(如 `agent_admin` / `sub_agent` / `staff` |
| 分红设置 | `commission_rate`百分比0-100最多两位小数管理员角色组改为单选同一角色组下管理员分红比例总和不超过100% |
| 开奖权限 | 不在数据层开放给渠道/子代理;开奖权限仅由超管 RBAC 控制 |
### 5.1 角色组与管理员分红计算口径
- 角色组分红:`当前角色分红 = 渠道设置获取分红 × (1 - 上级角色分红比例) × 当前角色分红比例`
- 管理员分红:`当前管理员分红 = 当前角色分红 × 当前管理员分红比例`
- 校验约束:
- 同一父级下角色组分红比例总和 `<= 100%`
- 同一角色组下管理员分红比例总和 `<= 100%`
---
## 六、`admin_group` 适配(角色组仅归属渠道)
| 方向 | 说明 |
|------|------|
| 渠道归属 | 增加 `channel_id` 字段,角色组只属于一个渠道 |
| 管理边界 | 渠道管理员只能增删改查本渠道 `admin_group` |
| 系统角色组 | `channel_id` 可为空表示系统级(仅超管可见) |
---
## 七、需新建的核心表
表名可按项目命名规范微调,须小写加下划线。
### 7.1 游戏引擎
**对局范围**:与 §1.1 一致——**全平台一套期号**,不按渠道分桌;详见 `game_period``channel_id` 的 DDL。
| 表名 | 用途 |
|------|------|
| `game_period`(或 `game_issue` | **全局**期号、状态机阶段、开奖号码、自动/手动、作废标记(**非**每渠道一局) |
| `bet_order` | 注单:关联全局 `period_id`;可选 `channel_id` 为用户归属快照(分润/查询),**非**独立牌桌 |
| `game_bet_auto`(可选) | 自动托管:剩余局数、选号快照、启停状态(仍挂在全局期号下) |
### 7.2 系统配置
| 表名 | 用途 |
|------|------|
| `game_config`(或统一 `system_config_kv` | 局时长、下注时长、选号上限、赔率步长、Jackpot 参数、RTP 放水、提现倍数与手续费等(须 DB 可配,禁止写死) |
### 7.3 资金与账本(强烈建议)
| 表名 | 用途 |
|------|------|
| **`user_wallet_record`**(原草案名 `user_wallet_ledger`,以 DDL 为准) | **玩家游戏币钱包流水**:充值、提现(含冻结/解冻)、平台划入划出、管理员加扣币、下注、派彩、手续费、作废退款、调账等;金额 **`decimal(18,4)`****只增不改**;与 `user.coin` 变更须同事务 + 条件更新 |
| `deposit_order` | 第三方充值、法币金额、汇率、游戏币到账、回调状态 |
| `withdraw_order` | 提现申请、手续费、审核状态、Jackpot / 大额标记 |
### 7.4 代理与结算
| 表名 | 用途 |
|------|------|
| `agent_settlement_period` | 结算周期、大盘盈亏快照 |
| `agent_commission_record` | 流水占比分桶结果、级差拆分、发放状态 |
| `affiliate_contract`(可选) | 联营契约、占成比例、阶梯条件 |
| `affiliate_carryover`(或与佣金记录合并设计) | 联营负结转余额与流水 |
### 7.5 运营
| 表名 | 用途 |
|------|------|
| `operation_notice` | 公告类型(静默 / Pop-out、内容 |
| `user_notice_read` | 用户已读 / 强弹窗已确认 |
### 7.6 审计(按需)
若现有 `admin_log` 不足以覆盖财务与风控操作,可增补结构化审计表。
### 7.7 冷热分离(中后期)
历史注单与流水检索迁移至 **ElasticSearch 或 ClickHouse**,主库保留近期热数据与归档策略。
---
## 八、验证步骤
### 8.1 数据库与模型
1. DDL 执行后检查:金额字段均为 **`decimal(18,4)`**`channel``user``admin``admin_group`、期号等关联字段有合理索引。
2. 创建测试渠道(顶级)→ 创建子代理 `admin`(设置 `parent_admin_id``channel_id``invite_code`)→ 创建用户并挂载 `channel_id`,链路与后台筛选正常。
3. 扣款更新采用「**条件更新**」语义(如 `WHERE balance >= 扣款额`),压测或单测验证**不出现负余额**。
### 8.1.1 菜单与权限回归(本次新增)
1. 使用超管登录,确认五大目录 `user/order/game/config/record` 可见。
2. 使用普通角色登录,逐项验证按钮权限(`index/add/edit/del/save`)是否与授权一致。
3. 抽查重点页面:
- `user/user`
- `order/depositOrder``order/withdrawOrder``order/betOrder`
- `game/period`
- `config/gameConfig``config/ziHuaDictionary`
- `record/userWalletRecord`
4. 验证 `config/ziHuaDictionary` 的读取与保存权限均命中,且仅授权角色可操作。
### 8.2 业务规则(对照 PRD / 业务流程)
| 场景 | 验证要点 |
|------|----------|
| 注册与邀请 | 玩家使用子代理邀请码注册时,先命中 `admin.invite_code`,再落到对应 `channel_id` |
| 子代理权限 | 渠道与子代理账号均无开奖权限;仅超管角色可访问开奖控制接口 |
| 30 秒节奏 | 020s 可下注20s 后拒绝;约 22s 后可查开奖结果 |
| 连胜与跳局 | 连续期号未下注则连胜清零;连赢时本轮总下注不超过上轮总下注 |
| Jackpot | 连胜达阈值且单局下注不低于配置最小额才触发额外大奖 |
| 提现 | 有效投注 ≥ 总充值×配置倍数;最低提现与 0.5% 手续费;大额 / Jackpot 进审核 |
| 灾难恢复 | 模拟期卡在「计算中」,启动退本流程:期作废、本金退回账本 |
### 8.3 代理与结算(逻辑就绪后)
1. 构造多代理树与多用户下注,跑一期结算脚本或单元测试纯算法。
2. **大盘亏损**:本期代理分佣为 0。
3. **大盘盈利**:流水占比分桶 + 级差,各级金额之和与线总包一致(可对照文档数值验算)。
4. **联营**:客损为负产生负结转;下期盈利先抵扣再分佣。
### 8.4 非功能
- Redis当前期注单池、倒计时、近 30 期开奖缓存。
- MQ派彩异步消费**幂等**(如 `period_id` + 用户 + 业务单号)。
- API下注、提现等核心接口**限流**生效。
---
## 九、风险与依赖
1. **子代理数据源**:子代理统一在 `admin`,避免在 `channel` 重复建树造成双主数据源。
2. **现有 `profit_amount`decimal(5,2)**:与游戏币 **18,4** 精度不一致,演进时改为 `decimal(18,4)` 或迁移至结算域,避免对账误差。
3. 文档要求 **AI 算票在 Redis 内完成**,主库避免结算期同步锁表;账本与注单以异步一致性与幂等为准。
4. **全局对局一致性**:任何需求(多租户展示、渠道后台)均不得引入「按渠道独立期号/独立开奖」;若出现产品歧义,以 **§1.1** 为准,避免公平性质疑与客诉。
---
## 十、相关文档索引
| 文档 | 说明 |
|------|------|
| `docs/36字花prd.md` | 玩法、代理双轨、风控与性能要求 |
| `docs/业务流程.md` | C 端、代理、联营、总控、灾难恢复流程 |
| `docs/后端.md` | 状态机、API、后台模块、安全与精度 |
| `docs/CRUD生成逻辑说明.md` | BuildAdmin CRUD 路径与表名规范 |
| `database/dfw_36zihua_schema_v1.sql` | 具体 DDL字段、索引、注释 |
### 10.1 新建表一览(字段以 SQL 为准)
| 序号 | 表名 | 说明 |
|------|------|------|
| 1 | `game_config` | 动态参数 KV |
| 2 | `game_period` | 期号与状态机 |
| 3 | `bet_order` | 注单 |
| 4 | `game_bet_auto` | 自动托管 |
| 5 | `user_wallet_record` | 玩家游戏币钱包流水(替代旧名 `user_wallet_ledger` |
| 6 | `deposit_order` | 充值订单 |
| 7 | `withdraw_order` | 提现订单 |
| 8 | `game_agent_wallet` | 代理钱包 |
| 9 | `game_agent_wallet_ledger` | 代理钱包流水 |
| 10 | `agent_settlement_period` | 结算周期与大盘快照 |
| 11 | `agent_commission_record` | 代理佣金明细 |
| 12 | `affiliate_contract` | 联营契约 |
| 13 | `affiliate_carryover` | 联营负结转 |
| 14 | `operation_notice` | 运营公告 |
| 15 | `user_notice_read` | 公告已读/确认 |
| 16 | `user_site_message` | 站内信 |
**既有表变更**:新建 `channel` 替代 `game_channel``admin` 增加子代理字段(`parent_admin_id``channel_id``invite_code``agent_role`)用于代理邀请链路管理;`admin_group` 增加 `channel_id` 实现角色组按渠道隔离;`game_user` 已硬重命名为 `user``game_bet_order` 已硬重命名为 `bet_order``user_wallet_record``game_config` 保持现名。
---
**变更记录**
| 版本 | 日期 | 说明 |
|------|------|------|
| V1.0 | 2026-04-14 | 初稿:表适配、新建表清单、验证步骤 |
| V1.1 | 2026-04-14 | 增加可执行 DDL 脚本路径与新建表一览 |
| V1.2 | 2026-04-14 | 修正口径:`game_channel` 仅顶级分红参数,子代理迁移至 `admin` |
| V1.3 | 2026-04-14 | 收敛权限:仅超管可开奖,渠道/子代理仅拉用户 |
| V1.4 | 2026-04-14 | 新建 `channel` 替代 `game_channel``admin_group` 按渠道隔离 |
| V1.5 | 2026-04-15 | 落地 `game_user_wallet_record` 玩家钱包流水表;文档增补字段说明附录;与 DDL 统一命名 |
| V1.6 | 2026-04-15 | 增加「先核心后细节」实施顺序:先建表/菜单清单、可暂缓表与细节阶段能力;修正章节编号(验证步骤改为第八章) |
| V1.7 | 2026-04-15 | 明确「多渠道、单场对局」:全平台共用 `game_period` 与同一开奖;渠道仅归属/分润;附录 `game_period`/`bet_order` 注释对齐 |
| V1.8 | 2026-04-15 | §7.1 游戏引擎表与 §1.1 对齐:显式说明全局期号、`bet_order.channel_id` 仅为归属快照 |
| V1.9 | 2026-04-15 | 落地模块化重构:`game_user->user``game_bet_order->bet_order`;新增 `deposit_order`/`withdraw_order`;后台菜单重组为 `user/order/game/config/record` 五大目录 |
| V1.10 | 2026-04-15 | 完成表名与权限规则收口:`user_wallet_record``game_config` 保持不变;移除冗余 snake alias 菜单规则,文档口径与线上结构一致 |
---
## 十一、附录:主要表字段说明(与 `database/dfw_36zihua_schema_v1.sql` 同步)
以下为各表**字段含义与使用要点**,便于评审与联调;**以仓库内 DDL 为最终口径**。
### 11.1 `channel`(渠道 / 顶级代理分红参数)
| 字段 | 作用 |
|------|------|
| `id` | 主键 |
| `code` | 渠道标识,业务唯一 |
| `invite_code` | 渠道侧邀请码(与 PRD「邀请码由管理员生成」并存时以产品定义为准 |
| `name` | 渠道名称 |
| `agent_mode` | `turnover` 普通刷水 / `affiliate` 联营,决定分红与契约字段使用方式 |
| `profit_amount` / `total_profit_amount` / `commission_pool_amount` | 经营与分红池快照类金额,**decimal(18,4)** |
| `turnover_share_rate` / `affiliate_share_rate` / `affiliate_fee_rate` | 分红与联营费率类参数 |
| `carryover_balance` | 联营负结转余额(可负) |
| `top_admin_id` | 顶级代理管理员,关联 `admin.id` |
| `admin_id` / `admin_group_id` | 创建人、渠道绑定的角色组 |
| `status` / `remark` / `create_time` / `update_time` | 状态、备注、时间戳 |
### 11.2 `user`C 端玩家)
| 字段 | 作用 |
|------|------|
| `coin` | 当前游戏币余额,**decimal(18,4)**,更新须条件更新防负余额 |
| `channel_id` | 归属渠道;历史 `game_channel_id` 若仍存在,迁移期注意双写/对照 |
| `register_invite_code` | 注册时邀请码快照,用于审计与归属 |
| `total_deposit_coin` / `total_valid_bet_coin` | 累计充值入账、累计有效投注;提现流水倍数校验用 |
| `risk_flags` | 风控位(如禁止登录/下注/提现,按位定义) |
| `current_streak` / `last_bet_period_no` | 连胜与期号兜底;高频仍以 Redis 为准 |
| `admin_id` | 归属子代理管理员 |
| 其余 | 账号、头像、状态、时间戳等见 DDL |
### 11.3 `admin` / `admin_group`(子代理与角色组)
| 表/字段 | 作用 |
|---------|------|
| `admin.parent_admin_id` | 子代理上下级 |
| `admin.channel_id` | 所属渠道 |
| `admin.invite_code` | 子代理邀请码,注册归属 |
| `admin.agent_role` | 角色类型(均无开奖权) |
| `admin_group.channel_id` | 角色组归属渠道;`NULL` 可为系统级(仅超管) |
| `admin_group.commission_rate` | 角色组分红比例(百分比) |
### 11.4 `game_config`(动态参数)
| 字段 | 作用 |
|------|------|
| `config_key` | 全局唯一键 |
| `config_value` / `value_type` | 参数值及类型(含 JSON |
| `remark` | 说明,禁止业务写死应读此表 |
### 11.5 `game_period` / `bet_order` / `game_bet_auto`
| 表 | 要点 |
|----|------|
| `game_period` | **全平台唯一**期号:`period_no` 全局唯一;`status` 状态机;开奖结果与作废原因。**无 `channel_id` 字段**:对局不按渠道拆分。 |
| `bet_order` | 注单关联**全局** `period_id``channel_id`(若有)为**用户/归属快照**,便于分润与查询,**不表示**独立牌桌或独立开奖。`idempotency_key` 幂等;金额 **18,4**`win_amount` / `jackpot_extra_amount` |
| `game_bet_auto` | 托管剩余局数、选号快照、抖动时间等;仍挂在**全局**期号下 |
### 11.6 `user_wallet_record`(玩家钱包流水)
| 字段 | 作用 |
|------|------|
| `user_id` | 玩家 `user.id` |
| `channel_id` | **账务发生时**渠道快照,便于按渠道查询与对账;入账逻辑应写入 |
| `biz_type` | 业务类型:`deposit``withdraw``platform_in``platform_out``admin_credit``admin_deduct``bet``payout``fee``void_refund``adjust` 等(字符串枚举,与代码常量一致) |
| `direction` | `1` 入金 / `2` 出金;与 `amount` 恒正配合使用 |
| `amount` | 变动额,**恒正** |
| `balance_before` / `balance_after` | 变动前后余额,对账与审计 |
| `ref_type` / `ref_id` | 关联订单或业务主键(如 `deposit_order``bet_order` |
| `idempotency_key` | 幂等键,防重复记账(唯一索引,允许多 `NULL` |
| `operator_admin_id` | 人工加扣币时的操作者 `admin.id` |
| `remark` | 说明 |
| `create_time` | 流水时间;**本表不设 `update_time`,禁止 UPDATE** |
### 11.7 `deposit_order` / `withdraw_order`
| 表 | 要点 |
|----|------|
| `deposit_order` | 法币、`fx_rate``coin_amount`、网关单号、回调存档 |
| `withdraw_order` | 申请额、手续费、审核与 Jackpot 标记、驳回原因 |
### 11.8 代理钱包与结算域(`game_agent_wallet`、`game_agent_wallet_ledger`、`agent_settlement_period`、`agent_commission_record`、`affiliate_*`
见 DDL 注释:代理余额与流水、结算周期大盘快照、佣金行、联营契约与负结转;金额均为 **decimal(18,4)**,与玩家账本区分职责。
### 11.9 运营与消息(`operation_notice`、`user_notice_read`、`user_site_message`
公告类型(含强弹)、已读确认;站内信与公告分离存储,详见各表 `COMMENT`

164
docs/36字花prd.md Normal file
View File

@@ -0,0 +1,164 @@
# 《"36字花" 数字竞猜游戏功能需求与详细流程规格说明书 (PRD)》
**文档版本**V 1.0 (最终定稿版)
**项目定位**高频30秒/期)、高爆率、多国本地化数字竞猜平台
## 一、 系统全局架构与底层规范
### 1.1 多币种与游戏币体系(统一结算层)
为支持多国市场无缝拓展,系统底层不直接使用法币(如 RM、IDR进行业务流转
* **充值兑换**:用户使用本地法币充值,资金到账后,系统根据实时/固定汇率自动转化为平台统一的**游戏币Coins/Credits**。
* **前端展示**:所有下注、余额、开奖金额均显示为统一的“游戏币图标 + 数值”(如 💎 100
* **提现结汇**:提现时,系统将游戏币按提现国的当前汇率反向换算为本地法币打款。
* **技术优势**代理分佣、AI算票引擎完全剥离汇率波动仅对游戏币进行纯数学计算极大降低系统复杂度。
### 1.2 分布式站点架构
系统拆分为三个独立的前端与一个核心后端服务:
1. **用户前端 (User Portal)**H5/Web响应式面向玩家游玩。
2. **代理后台 (Agent Portal)**:面向总代理/子代理的数据与分佣端。
3. **总控后台 (Admin Portal)**:面向平台运营方的全局管控端。
### 1.3 域名与防封安全架构
* 采用 **方案A反向代理 + CDN 隐藏源站) + 方案D域名轮换池 Domain Pool** 的组合方案。确保源站IP完全隐藏入口域名被封时可秒级切换备用域名。
---
## 二、 核心玩法与游戏机制
### 2.1 36字花字典对照表
系统包含36个字号分为生肖、猛兽、飞禽、虫蛇、神兽五大类。前端需展示“编号+名称+专属插画”。
| 编号 | 名称 | 编号 | 名称 | 编号 | 名称 |
| :---: | :---: | :---: | :---: | :---: | :---: |
| 01 | 鼠 | 02 | 牛 | 03 | 虎 |
| 04 | 兔 | 05 | 龙 | 06 | 蛇 |
| 07 | 马 | 08 | 羊 | 09 | 猴 |
| 10 | 鸡 | 11 | 狗 | 12 | 猪 |
| 13 | 狮 | 14 | 象 | 15 | 鹿 |
| 16 | 熊 | 17 | 狼 | 18 | 豹 |
| 19 | 鹰 | 20 | 鹤 | 21 | 孔雀 |
| 22 | 鸳鸯 | 23 | 鸵鸟 | 24 | 天鹅 |
| 25 | 蟾蜍 | 26 | 蜘蛛 | 27 | 蝙蝠 |
| 28 | 蜈蚣 | 29 | 蝎 | 30 | 蛇蜥 |
| 31 | 麒麟 | 32 | 凤凰 | 33 | 青龙 |
| 34 | 白虎 | 35 | 朱雀 | 36 | 玄武 |
其中 01-12 为十二生肖13-18 为猛兽类19-24 为飞禽类25-30 为虫蛇类31-36 为神兽类。
### 2.2 游戏节奏30秒循环制
每局精确耗时30秒分为两个绝对阶段
* **【常态阶段 / 下注期】 (0-20秒)**
* 倒计时 20 秒启动,接收玩家下注。
* **UI表现**:前端界面的 36 个格子上的高亮光圈,呈现**持续不断、不规则、快速的随机跳动**,营造强烈的赌场氛围。
* **【封盘开奖期】 (20-30秒)**
* **第 20.0 秒**:准时锁盘。在此之后到达服务器的任何延迟请求(如因网络卡顿),直接**拒单退回(提示:已封盘,下注失败)**。
* **第 20-22 秒AI算票**AI 引擎瞬间完成全网盈亏计算并得出结果。
* **第 22-25 秒(开奖动画与异步派彩)**:后端在第 22.0 秒得出结果后,立刻将派彩结算指令打入 MQ 异步执行。同时前端高亮光圈加速跳动配合紧张心跳音效。在3秒内不规则快速跳动后“啪”地一声**定格**在最终中奖号码上。
* **第 25-30 秒(结算展示)**:前端通过轮询/推送接收到余额更新通知,开始播放中奖者全屏/半屏的**金币喷洒动画(伴随爆金音效)**,弹出醒目提示【恭喜获得 💎 XXX】。
### 2.3 下注交互与限制
* **便捷筹码**:提供 💎1, 5, 10, 25, 50, 100 的快捷筹码按钮。
* **下注限制**:默认每场最多选 **5个数字**(后台可配)。单局内所选的每个数字,下注金额必须一致。
* **单注上限**后台须配置“单号最高下注额Max Bet Limit如 💎500以对冲超级连赢带来的数学风险。
* **快捷操作**
* **重复上一注 (Re-bet)**:一键复制上一局的选号和金额并提交。
* **自动托管 (Auto-Spin)**:允许设定连续下注 N 局。采用**服务端执行、逐局扣款**机制。开启后,服务器将在每局的 **00:00 - 00:10 秒之间(引入随机抖动,防止并发风暴)**平滑地为所有托管玩家自动扣款下注,直至局数完成或余额不足。
---
## 三、 连续中奖 (Streak Bonus) & 大奖 (Jackpot) 规则
### 3.1 赔率累加机制
* 基础赔率为 **1:33**
* 玩家连续中奖时赔率累加第1场 1:33连中2场 1:66连中3场 1:99... 依此类推。
* 只要某场未中奖(中断),连续记录清零,赔率重置为 1:33。
* **【跳局处理规则】**:出于提高活跃度和平台流水考虑,**连胜必须基于系统的“连续期号”判断**。若玩家在连胜期间,主动跳过了某一局(未下注),则连胜状态**直接强制清零**。
### 3.2 连赢期间的“换注限制”
为了防止玩家利用连赢的高赔率恶意“洗盘梭哈”:
* **下注金额锁定**:若第 N 轮中奖,则第 N+1 轮的**总下注金额上限**严格锁定为第 N 轮的实际总下注额。上轮总共下了💎100本轮最多只能下💎100若本轮只下了💎10并中奖下一轮上限将降为💎10
* **选号策略自由**:在总金额不超限的前提下,**允许玩家改变选字的数量**。例如上一轮花💎100买了5个字本轮可以花💎100全押在1个字上。
### 3.3 Jackpot 百万大奖触发
* 当玩家连续中奖达到 **10 场** 时触发。
* **触发门槛与限红**为防止利用极小注额套取大奖系统设定【Jackpot资格最小下注额】如单注连续 ≥ 💎50。若连胜中有任何一局未达大奖基准入场额连胜仅享受常规多倍赔率**不触发额外 Jackpot 大奖**。
* **派发逻辑(额外叠加)**达标玩家正常获得第10场应得的常规连赢派彩系统将在此基础上**额外发放 Jackpot 大奖(如 💎100万**。
* 触发后连续中奖场次重置为0。
---
## 四、 AI算票风控与开奖系统
### 4.1 自动开奖引擎(利润最大化 + 随机放水)
* **第一优先级**AI在封盘后遍历36个数字的全局赔付包含当前所有人的连胜倍率计算优先开出让平台**总赔付最少(利润最大)**的数字。
* **RTP 全局风控放水池**
* 系统累积平台抽水利润的固定比例进入“放水池”。
* 当开出某个高赔付(如触发别人连胜或大奖)造成的亏损 **小于** 当前放水池余额时AI 有一定概率后台可配如15%)主动开出该数字,制造真实大奖作为营销案例。
### 4.2 手动开奖机制
* 管理员在后台一键切换至“手动模式”。
* 为了避免 10 秒开奖期的操作延误,管理员必须在**下注倒计时期间前20秒内**提前选定本局必开数字。20秒锁盘后系统直接开出预设号码。
### 4.3 灾难恢复 (Disaster Recovery)
* 若系统在某局运行中途如第25秒遭遇断电或宕机崩溃。
* 服务器重启后的首要动作是自动执行 **“作废退本 (Void & Refund)”** 脚本:该期残局作废,退回所有玩家本局投注的本金,杜绝暗箱操作嫌疑。
---
## 五、 代理体系:普通刷水代理与联营合营代理双轨制 (Agent & Affiliate System)
为了适应不同渠道的推广需求,系统代理账号不开放前台自助注册(必须由后台人工创建或通过专属代理邀请码裂变),并支持**两条平行的代理分佣路线**
### 5.1 普通刷水代理(以流水分桶为导向)
这是一种不承担玩家赢钱风险的基础代理模式。
1. **大盘盈亏判断**:周期结算时,优先计算全平台总盈亏。若全平台整体亏损或平局,则该周期内所有此类代理不分佣。
2. **计算流水占比**:若全平台盈利,则按某代理整条线下注流水占全平台总流水的百分比,分配资金池。(*应分红 = 平台总盈利 × 该线下注流水占比%*)。
3. **无负结转优势**:即便该代理名下的玩家赢了钱,只要全平台大盘是盈利的,其依然可按流水占比分佣。
### 5.2 多级拨比机制
在上述“分桶利润分配”算法中系统支持无限线代理树的极差分账。例如GM 给出的盘口总分红给到顶部总代的是 80%,当这个总代拿手上的分红比例拨给下级时,若他给 70%,则这位下级能拿单线利润基数的 70%,总代由于极差卡段,自己这条线只能拿到 10%80%-70%)的差额利润。这个级差裂变能一直往下分直到没有 % 为止。
### 5.3 联营代理模式 (Affiliate Agent / 客损占成分成)
这是专为有强大客源且愿意与平台**“共担风险、共享高利润”**的高级代理设计的模式(即行业标准的**联营 / 占成代理**)。
* **角色定位**:联营代理脱离于普通流水池,直接对其名下所有会员的总客损(净输赢)负责。
* **净亏损分润**:结算时,系统统计该联营代理辖区内的【玩家总输赢】,扣除平台抽水与通道费后得出【净客损】。联营代理按配置的**占成比例**(如 50%)直接分走这笔客损。
* **负结转 (Negative Carryover)**:这是联营核心机制。如果该联营代理名下的玩家在当期大赢,导致结算时客损为负(代理亏钱),该亏损数字将“结转”至该代理的下期账单中。在后续周期里,必须先用玩家的输额填平该负结转账单后,代理才能恢复分红提现。
---
## 六、 用户与财务风控流程
### 6.1 用户交互与注册
* 只需手机号/邮箱+密码注册,**无需 KYC 实名认证**。
* **路子图 (走势图)**:前端界面底部提供类似百家乐大路/珠盘路的走势图。以数字区分:单数使用**红色图标**Odd双数使用**蓝色图标**Even仅保留最近30期。
### 6.2 平台公告与运营触达流程
为保证由于赔率调整、节日活动或临时维护等重要信息能 100% 触达玩家,前端具备两级触达机制:
* **静默收件箱 (Silent Mailbox)**:常规活动或系统通知直接派发至用户的【公告信箱/站内信】UI 仅显示红点提示,不中断玩家游戏体验。
* **强阻断强制公告 (Pop-out Notification)**:具有最高优先级。运营在后台下发此级公告后,无论玩家在任何界面、或者新用户首次登录,系统立即在屏幕中央弹出该公告弹窗。玩家**必须手动勾选“已阅读并同意”**后才可关闭弹窗并继续游戏下注。
### 6.3 财务风控与提现
* **提现门槛**:用户必须完成历史总充值额 **1倍流水** 的有效下注方可提现,防止恶意洗钱。
* **提现限制**:最低提现 💎100系统自动扣除 0.5% 手续费。
* **人工审核强阻断**:任何触发 **100万 Jackpot** 或达到系统设定的超大额提现标准的资金,不会直接进入可用余额,而是进入“冻结/审核中”状态。必须由总后台管理员核查 IP 与下注流水,点击【通过】后方可打款。
### 6.4 历史数据展示限制
* 前端的下注记录、开奖记录仅展示**最近 1 个月**的数据。
* 历史更久远的数据前端做隐藏处理,减少数据库连表查询压力。
---
## 七、 技术性能要求规范
由于“30秒/期”属超高频高并发场景,系统架构必须满足以下规范:
1. **冷热数据分离架构**
* 必须引入 **Redis** 内存数据库用于存放最新30期开奖记录、当前进行中的下注队列、以及玩家当前的连赢Streak状态。确保 AI 引擎 2 秒内完成算票。
* 引入 **ElasticSearch 或 ClickHouse** 存储海量历史注单与资金流水,与交易主库隔离开来。
2. **异步派彩机制**
* 开奖后的批量资金入账、连胜进度更新、以及站内信发送,必须采用 **消息队列 (Kafka / RabbitMQ)** 异步削峰处理。严禁在 10 秒结算期内使用数据库同步强锁表,防止并发雪崩。
3. **API 频控 (Rate Limiting)**
* 用户端及代理端所有核心 API如下注、提现申请必须配置严格的频率限制防止恶意脚本撞库或刷单。

168
docs/业务流程.md Normal file
View File

@@ -0,0 +1,168 @@
# 🚀《"36字花" 全链路业务流程说明书 (Business Flow Document)》
**文档版本**V 1.0 (最终定稿版)
**适用对象**后端开发工程师、前端开发工程师、QA 测试工程师、系统运营人员
---
## 模块一C端用户生命周期与游戏流程 (User Flow)
### 1.1 新用户注册与登入流程 (Onboarding Flow)
1. **获取链接**用户通过点击代理的专属推广链接URL携带代理专属 Invite Code进入注册页。
2. **极简注册**:用户输入 手机号/邮箱 + 密码 进行注册(**系统不要求上传身份证进行 KYC 实名认证**)。
3. **绑定归属**:系统自动将该新用户挂载到对应代理的下线树状图中。
4. **强阻断公告触达**
* 用户首次登录成功,进入主界面前。
* 系统弹出【强制公告信箱】(展示平台规则、近期活动)。
* 必须勾选 `[ √ ] 我已阅读并同意`,否则进入游戏的按钮为灰色不可点击状态。
* 勾选并点击后,正式进入游戏主大厅。
### 1.2 资金充值转换流程 (Deposit & Exchange Flow)
1. **发起充值**:用户点击顶部 `[+] 充值`,选择本地法币(如 RM和金额例如充值 RM 100
2. **网关支付**:跳转至第三方 Payment Gateway 完成支付。
3. **回调与转换**
* 第三方网关向系统发送成功回调。
* 系统底层执行**汇率转换**(如 RM 1 = 💎 1将法币转化为平台统一的【游戏币】。
4. **资金入账**:用户界面余额更新为 💎 100并产生一条充值流水记录。系统记录该用户的“需完成流水额度”即 💎 1001倍提现门槛
### 1.3 🌟 核心游戏交互流程单局30秒标准生命周期
这是系统最高频运转的流程每30秒循环一次
* **阶段 A下注期 (00:00 - 00:20)**
1. 用户在 36宫格 挑选心仪的数字(上限 5 个)。
2. 用户在下方选择筹码面额(如 💎 10。**系统强校验**:所有已选数字将被统一设置为 💎 10。
3. **确认防呆**:用户点击右下角发光的 `[ ✅ 确定下注 ]` 按钮。
4. **资金扣除**:系统校验余额是否充足,若充足则实时扣除余额,并在选中的格子上打上“锁定”印章。此时注单正式生效写入数据库。
* **阶段 B封盘与算票期 (00:20 - 00:22)**
1. 第 20.0 秒准时封盘。界面变灰,提示 `[停止下注]`
2. 系统拒收任何晚于此时间的下注请求。
3. **AI 算票执行**AI 瞬间遍历 36 个数字的全局赔付表,结合 RTP 放水池概率,选定本期开奖数字(详见 3.1 节)。
* **阶段 C开奖与结算期 (00:22 - 00:30)**
1. **异步派彩 (00:22)**:后端得出结果立刻扔进消息队列更新数据、派发奖金、计算连胜次数(主动跳局未下注的玩家也会被清零连胜)。
2. **前端动画 (00:22 - 00:25)**:前端同步收到倒计时状态与定格数字,播放 3 秒紧张的快速跳动转盘动画。
3. **结算展示 (00:25 - 00:30)**:弹出定格的开奖结果。前端轮询接收最新的资金状态更新,播放金币爆破与余额暴增动画。
### 1.4 特殊托管流程:自动下注 (Auto-Spin Flow)
1. 用户在前端设定:托管 50 局,每局选 【08羊16熊】每注 💎 10。点击启动。
2. 前端界面进入暗色“锁定态”。
3. **服务端接管**:用户的请求下发给后端。此后每一局的前段(**引入 00:00 - 00:10 秒的平滑随机分布,防服务器高并发崩溃**),服务器自动在后台帮用户扣款并生成注单。
4. **中断条件**:当达到 50 局、或用户中途点击 `[ 🛑 停止 ]`、或用户余额不足以支付单局总注时,托管脚本自动终止。
### 1.5 资金提现与风控流程 (Withdrawal Flow)
1. **发起提现**:用户点击提现,输入金额(最低限制 💎 100
2. **前置系统校验**
* 余额是否充足?
* **流水校验**:该用户的历史总有效投注额,是否达到了历史总充值额的 1 倍?(未达标则驳回并提示)。
3. **扣除手续费**:系统自动扣除 0.5% 的手续费(例如提现 100实际申请出金金额为 99.5)。
4. **大额/安全拦截**
* 若提现金额超过后台设定的免审阈值,或该资金来源于 Jackpot 百万大奖。系统自动将提现单挂起,状态为 `[待人工审核]`
5. **财务打款**后台管理员审核通过后调用网关接口按实时汇率将游戏币转换回当地法币RM打入用户银行卡。
6. **成功入账**:流转完成,前端发送提现成功站内信。
---
## 模块二:代理端业务与分佣流程 (Agent Flow)
由于采用防刷机制,系统**不支持** C端用户自助升级为代理。
### 2.1 代理开户与层级搭建流程
1. **创设总代**:平台最高管理员在 Admin 后台,创建一个【总代 Master Agent】账号并设定 GM 抽成比例(如平台截留 20%,总代享有 80% 的分红权)。
2. **总代拓客**:总代登录 Agent Portal生成自己的推广链接发给散户或者生成【子代专属邀请码】发展下级代理。
3. **分配比例**:总代在给子代开户时,将自己手中 80% 的分红权,划拨一部分(如 70%)给子代,自己赚取 10% 的级差。
### 2.2 🌟 核心分佣结算流程(流水占比分桶模式)
系统设定为每周一凌晨(或每日)自动执行结算脚本:
1. **大盘盈亏判断**:系统计算在此周期内的平台实际盈利(总下注扣除总派彩)。
* *分支 A*:假如是亏损,**终结流程**,所有代理本期没有分佣。
* *分支 B*:假如实际盈利为 RM 2,000系统内记为 💎 2,000进入下一步。
2. **计算单线流水占比 (分桶)**
* 统计所有单线的流水占全网总下注金额的百分比。例如总下注金额为 RM 10,000。
* 【Line B】本期下注 RM 2,000计算得占比为 **20%**
3. **拨出单线应得红利基数**
* Line B 的路线应分红基数 = 2,000 (实际盈利) × 20% (该线占比) = 💎 400。
4. **结合 GM 盘口计算实际到手金额**
* 假设我们 GM 开发者给出的盘口分红比例是 **80%**
* Line B 整线实际到手金额 = 400 × 80% = 💎 320。
5. **层层级差分配 (无限下延)**
* 当 Line B 总代拿手上的分红拨给旗下的子代(譬如给 70%)。
* 该子代拿到的佣金 = 400 (基数) × 70% = 💎 280。
* Line B 总代自己这条线只能吃到级差倒扣 (80% - 70% = 10%)400 × 10% = 💎 40。
* *(注280 + 40 = 320刚好核对总线实际到手金额多级代理一直按此“抽水级差”相减最后分到无差价为止。)*
6. **发放佣金**:计算完毕后,佣金分别直接发放到各级代理的 Agent 钱包,代理可随时提现。
---
## 模块三:联营代理佣金结算大流 (Affiliate Commission Flow)
系统支持高级的“联营代理(占成代理)”独立结算。联营代理不按流水比例切蛋糕,而是直接就其辖区玩家的**客损净利**与平台进行按比例分成结算。
### 3.1 🌟 客损占成与负结转计算逻辑
系统每月(或单周)触发联营账单结算脚本:
1. **计算总客损 (GGR)**
* 统计【联营代理 A】整条线下所有散户和子代的总输赢额。
* 例如:玩家总输款 150,000玩家总赢款 50,000。该代理辖区 GGR (总客损) = 100,000。
2. **扣除平台双降费 (Admin Fees)**
* 平台需扣除运营杂费包括充提通道手续费、API算票系统服务费等假设固定扣除 GGR 的 15%)。
* 真实净利润 = 100,000 - 15,000 = 💎 85,000。
3. **正盈利分成拨付**
* 根据总后台签署的“联营契约”,代理 A 满足阶梯条件,享有 **55% 的客损占成**
* 联营代理 A 本期佣金 = 85,000 × 55% = 💎 46,750。系统将其下发至代理钱包卡。
4. **负结转机制 (Negative Carryover)**
* *异常分支*:若周期内玩家鸿运当头,该代理辖区 GGR = **负 50,000**(代理大亏)。
* 系统不会直接要求代理补款,而是将这 -50,000 计入代理的**负结转账单 (Carryover Balance)**。
* **下期抵扣**:下个结算周期,若该区重新盈利 80,000必须先刨去上期的 -50,000 亏空窟窿,只能用剩下的 30,000 作为基数来进行分成。以此保证平台与联营代理的绝对风险共担。
---
## 模块四:总控后台运营与管理流程 (Admin Flow)
### 4.1 🌟 开奖管控流程(自动 vs 手动)
管理员在后台【开奖控制台】进行全局调控:
* **【自动开奖模式】 (默认运转流)**
1. 系统在封盘后第20秒AI 引擎接管。
2. AI 遍历 36 个格子的平台赔付金额PnL
3. **风控池判定**:检查 RTP 放水池余额。如果开出最高连赢赔付大奖的亏损 放水池余额AI 以 15%(参数可调)的概率主动把开奖结果设定为该大奖号码;否则,强制开出平台赔付最少的安全号码。
* **【手动开奖模式】 (特殊干预流)**
1. 管理员开启手动模式。
2. **强制前置选择**:管理员必须在**下注倒计时的 20 秒内**,在后台面板点击选中一个必开的动物并保存。
3. 20秒一到系统直接抓取管理员预设的号码作为本期开奖结果。
4. *异常兜底与强制重置*:若管理员开启了手动模式,但 20 秒内忘了点选号码。系统为防宕机,该期自动由 AI 引擎接管兜底出号。**(重要)同时系统会自动将全局模式强制切回并锁定至【自动模式】**,直至管理员再次手动开启,防范持续缺位引发的事故。
### 4.2 大额提现与异常账号审计流程
1. **触发警报**:某玩家触发了 10连胜获得 100万 Jackpot或单笔提现超过 💎 50,000。
2. **资金冻结**:该笔提现进入后台 `[Risk Audit / 风控审核]` 列表。
3. **人工溯源**:风控专员点击该玩家详情,查看:
* **IP 与设备指纹**:是否与其他多账号重合(防对刷)。
* **投注路径**:是否每局固定在最后 0.1 秒压秒下注(防接口脚本外挂)。
4. **处理决策**
* 若无异常:点击 `[通过 Pass]`,财务打款。
* 若实锤作弊:点击 `[驳回并冻结 Reject & Freeze]`,账号封禁,资金不予出款。
### 4.3 平台公告与运营触达流程
1. 运营在后台新建一条公告内容(如“本周末狂欢,基础赔率提升至 1:35”
2. **选择触达方式**
* *静默发布*:仅收纳进用户的 `[公告信箱]`
* *强弹窗发布*:设定为 `[Pop-out]`
3. **用户端生效**:前端检测到有新的 Pop-out 级别的公告未读,立即在用户当前界面弹出。用户必须勾选同意后才可继续游戏,保证 100% 触达率。
---
## 模块五:极端异常与灾难恢复流程 (Disaster Recovery Flow)
### 5.1 开奖期服务器宕机补偿流 (Void & Refund)
这是对平台信誉极其重要的保护流程:
1. **灾难发生**:某局倒计时走到 00:22玩家已全额扣款系统正在算票机房突然断电或遭遇极端 DDoS 攻击,服务器彻底宕机。
2. **工程师重启**30 分钟后,服务器恢复上线。
3. **自检与重置机制**
* 后端主程序启动时的**第一件事**,是去数据库自检上一局的状态。
* 发现状态为 `[计算中/未结算]`,触发**熔断补偿脚本**。
4. **执行退本与安抚**
* 系统将该期直接标记为 **`[已作废 VOID]`**。
* 将该局所有已被扣除下注资金的用户本金,**100% 原路退回**其可用余额。
* 系统通过站内信群发:*“尊敬的用户,因极端的网络波动,期号 20260413-1240 发生数据异常,该期已作废,您的下注本金已全额退回,给您带来的不便敬请谅解。”*
5. **恢复循环**:处理完残局后,系统开启全新的 30 秒倒计时,游戏继续运转。
---

145
docs/后端.md Normal file
View File

@@ -0,0 +1,145 @@
# 💻《"36字花" 后端系统开发与后台模块详细规格书》
**适用对象**:后端开发工程师、数据库架构师、系统管理员
## 一、 系统架构与技术栈规范 (Architecture & Tech Stack)
1. **核心开发语言/框架**:由于极高的并发读写,推荐使用并发性能优越的语言。
2. **数据冷热分离架构**
* **热数据 (Redis)**:存放**当前局的倒计时状态**、**当前局全网注单池**、**玩家连胜状态 (Streak)**、**30期开奖历史**。AI算票必须全程在 Redis 内存中计算,确保 2 秒内出结果。
* **业务主库 (MySQL / PostgreSQL)**:存放用户资产、核心账本、代理树状图、系统配置。注单状态确认后异步落库。
* **冷数据查询 (ElasticSearch / ClickHouse)**:超海量注单流水(每月上亿条)的存储与分页查询,减轻主库压力。
3. **异步消息队列 (MQ)**:引入 RabbitMQ 或 Kafka。开奖结果一出立刻将派彩结算、连胜状态更新、站内信发放等操作扔进 MQ 异步执行,**绝对禁止在结算期同步锁库写数据**。
4. **事务安全性**:下注扣款动作必须是**强原子性操作 (Atomic)**,结合 Redis 分布式锁杜绝并发下注导致的余额超扣Negative Balance
---
## 二、 核心驱动引擎30秒状态机与AI算票 (Core Engine)
后端必须启动一个绝对精准的守护进程Daemon来维持 30 秒一局的生命周期。
### 2.1 状态机切换逻辑 (State Machine)
* `STATUS_OPEN (0-20s)`:接收 `Bet_API` 请求。若有自动托管任务,在 0-10s 内通过 Jitter 平滑随机执行注单,防并发风暴。
* `STATUS_LOCKED (20.0s)`:严格切断请求接入,开始计算。
* `STATUS_CALCULATING (20-22s)`:调用 AI 算票引擎。
* `STATUS_PAYOUT (22-25s)`:第 22.0 秒立刻将派彩和站内信任务扔入 MQ 异步执行。同时下发结果给前端开始播放倒计时定格动画。
* `STATUS_FINISHED (25-30s)`:本期收尾,创建下一期期号,循环。
* **灾难兜底器**:系统启动时自检,若发现有卡在 `CALCULATING` 的历史期号,立即执行 `VOID_AND_REFUND`(全额退本)脚本。
### 2.2 AI 算票引擎算法 (后台执行逻辑)
1. **汇集注单**:抓取本期 Redis 池中所有有效注单。
2. **提取连赢系数**关联下注玩家当前的连赢次数0次=33倍1次=66倍2次=99倍...)。
3. **遍历计算 (PnL Loop)**
* `For i = 1 to 36:`
* 计算如果开出 `i`平台需赔付的总金额含触发Jackpot的额外100万
4. **RTP 风控池判定**
* 拉取当前“全局放水池”余额。
* 如果有某个结果导致平台亏损,但亏损额 < 放水池余额AI通过 `Math.random()` 以配置概率如15%)选择该结果。
5. **最终决断**:若未触发放水,则 `Return Min(PnL_Array)`(返回让平台赔付最少的数字)。若有多个最小值,随机取其一。
---
## 三、 🌟 核心重点:总控后台 (Admin Portal) 功能模块清单
为了让运营团队能够全方位掌控平台,后端需要为前端/后台管理系统提供以下极为详尽的 API 和模块支持:
### 3.1 📊 数据看板台 (Dashboard)
运营人员登录后台第一眼看到的数据宏观监控:
* **实时在线**:当前 WebSocket 在线连接数。
* **宏观财务 (今日/本周/本月)**:全网总充值额、总提现额、全网总下注额 (Turnover)、**平台实际客损盈亏 (GGR / PnL)**。
* **风控预警屏**滚动显示大额中奖提示单笔赢取超过X万、异常频繁提现提示。
### 3.2 🎲 游戏与开奖控制台 (Game & Draw Control)
* **模式切换开关**`[自动 AI 模式]``[手动控制模式]` 实时切换。
* **实时注单监控**:在下注的 0-20 秒内,实时展示当前 36 个格子的注金分布热力图,并实时预估每个格子的 PnL。
* **手动开奖干预框**:当开启手动模式时,提供一个允许在前 20 秒内指定开奖号码的输入框。
* **历史开奖纠错与作废**:允许超管针对历史某期执行“撤回作废 (Void)”指令,系统自动扣除已派彩金额(慎用功能)。
### 3.3 👥 用户 CRM 与风控管理 (User & Risk Management)
* **玩家列表与画像**包含玩家ID、归属代理、注册时间、当前余额、总充提差、历史总输赢。
* **玩家详情穿透 (Deep Trace)**
* 查看该玩家详细下注流水、充提流水。
* 查看登录设备指纹、登录 IP 历史(支持同一 IP 多账号关联查询)。
* **风控操作箱**
* **冻结/解冻账户**(禁止登录 / 禁止下注 / 禁止提现)。
* **人工加减币 (Manual Adjustment)**:用于处理客诉补偿或线下充值,必须记录操作理由和操作人。
* 清零连胜进度(异常账号干预)。
### 3.4 💰 财务与出入金审核中枢 (Finance Center)
* **充值明细表**:所有充值订单状态(成功/失败/掉单补单)。
* **提现审核队列 (Withdrawal Audit)**
* 列出待审核单据。**后端必须在此处提供“有效流水达标检测”标签**该玩家充值1000当前已打流水1500流水完成率 150%,亮绿灯)。
* 支持 `[一键通过并打款]``[驳回请求并退回余额]` 操作。
* **Jackpot 提现专项通道**:针对大额中奖资金的专属审核队列,必须由超管权限放行。
* **提现手续费收益报表**:单独统计系统抽取的 0.5% 手续费总收入。
### 3.5 🏢 代理中枢与佣金结算系统 (Agent System)
* **创建总代账号**:由于前端不支持散户变代理,只能由管理员在此处点击 `[新增总代 Master Agent]`,生成专属邀请链接。
* **代理树状图 (Tree View)**:可视化查看整个多级代理层级及人数。
* **佣金结算看板 (Commission Settlement)**
* 后端按周/月跑批处理脚本Cron Job
* 展示全平台周期总盈利(大盘盈亏)。
* 列出各代理线的流水占比%、应得分红、GM盘口扣除利润、实际下放分红。
* 提供 `[一键发佣]` 按钮,将佣金批量转入各代理的 Agent 钱包。
### 3.6 🤝 联营契约与合营代理管控大厅 (Affiliate Management)
设计为与普通流水分佣系统并行的**客损占成代理模块**方案:
* **联营契约(合营契约)线上配置**平台超管可与高级代理在线签署“联营契约”。支持动态阶梯的占成比例如当月新增活跃≥10人且总客损>1万美元时该代客损占成提高至45%)。
* **独立负结转账单明细 (Agent Statement)**
* 后端按月或周生成带有“负盈利结转”规则的专业级合营账单。
* 账表包含项目:辖区总输赢、当期大促红利扣除、当期通道成本扣除、**历史负结转累计抵扣**、当期应结佣金。
* **佣金与负盈利全自动跑批**:结算周期末由 Cron Job 自动判断,若是正佣金则划入代理钱包;若是负佣金(玩家赢),则记入该代理名下的 Carryover Balance累计负结转账本供下期抵扣。
* **独立的财务出金通道**:联营代理大额提取高额占成分红时,有专门供高级财务总监审批的安全通道,与普通玩家或刷水代理区分开。
### 3.7 ⚙️ 系统超级参数引擎 (System Configuration)
这是系统最核心的动态配置中心,**后端必须将以下参数做成 DB 可配置,严禁在代码里写死**
* **时间参数**单局时长默认30、下注时长默认20
* **下注参数**最高选字数默认5可调为6或7、最低下注额默认1、**单注最高限额**如500风控核心、前端快捷筹码面额配置。
* **赔率参数**基础赔率默认33、连续中奖递增步长默认33
* **大奖参数**Jackpot 触发连胜阈值默认10、Jackpot 奖励固定金额默认1,000,000、**Jackpot资格最小下注额默认50**。
* **风控参数**提现流水倍数要求默认1.0倍、提现手续费比例默认0.005、AI RTP 放水抽水比例。
* **代理参数**:全局 GM 盘口截留比例默认20%)、**联营合营基础成本双降费比例默认15%**。
### 3.8 📢 运营中心 (Operations)
* **公告信箱管理**:富文本编辑器。支持设置公告类型为 `[普通静默]``[强阻断 Pop-out]`,其中 Pop-out 要求客户端弹出并强制确认。
* **前端走马灯/跑马灯**:管理首页顶部滚动的文字通知。
### 3.9 🔒 权限与操作日志 (Admin RBAC & Logs)
* **角色控制 (Role-Based Access Control)**:设置超级管理员、财务审核员、普通客服。
* **管理员操作审计 (Audit Trail)**记录所有后台操作的日志谁、在什么时间、把赔率从33改成了35或给谁手动加了钱防范内部作恶。
---
## 四、 C端前端核心 API 接口要求 (User API Requirements)
除了基础的登录注册,后端需要重点保证以下几个接口的性能与安全:
1. **`POST /api/user/register` (用户注册)**
* 接受手机/邮箱、密码、以及 `invite_code`(选传)。如果带有 `invite_code`,自动挂载到对应代理名下;否则为直客。
2. **`GET /api/game/roadmap_history` (历史路子图拉取)**
* 前端初始化首屏时调用,返回最近 30 期的开奖记录数组(含期号、中奖数字、赔率),用于渲染屏幕底部大路/红蓝球走势图。
3. **`GET /api/game/current_status` (轮询/长连接)**
* 前端每秒请求或通过 WebSocket 接收。返回当前期号、倒计时剩余秒数、当前开奖状态。
4. **`POST /api/game/place_bet` (核心下注接口)**
* **入参**:期号、选中的动物编号数组 `[01, 02, 15]`、单注金额 `10`
* **后端强校验逻辑**
1. 验证时间:是否超过第 20 秒?是则返回 403 封盘报错。
2. 验证数量:数组长度是否 ≤ 系统配置的【最高选字数】?
3. 验证连赢上限:该玩家若处于连赢态,本次下注总额(如 30是否 ≤ 上局总下注额?
4. 验证余额:余额是否 ≥ 30
* 校验全过,开启 DB 事务,扣钱,写注单,提交。
5. **`POST /api/game/auto_spin` (启动/停止自动托管)**
* 后端接收托管指令后,写入守护进程列队。由服务器在后台**引入 Jitter(0-10s随机制打散)** 帮玩家调用 `place_bet`
6. **`GET /api/user/pnl_history` (历史记录过滤)**
* 后端仅返回最近 1 个月的数据(通过 SQL 时间戳硬性过滤),防止扫库查询。
---
## 五、 后端安全与反黑客建议 (Security & Anti-Fraud)
1. **接口限流 (Rate Limiting)**`place_bet` 接口必须限制单个用户的请求频率(如 1秒内最多2次防止外挂脚本通过并发漏洞击穿余额。
2. **汇率与金额精度**:底层数据库所有金钱字段(游戏币)**必须使用 `DECIMAL(18,4)` 或将其放大 10000 倍用整型 (`INT64`) 存储**。绝不允许使用浮点数Float/Double防止精度丢失导致对账不平。
3. **负资产阻断**:扣款 SQL 必须带上条件判断:`UPDATE user SET balance = balance - 100 WHERE id = 1 AND balance >= 100;` 确保高并发下余额不会被扣成负数。

View File

@@ -13,11 +13,7 @@ if (!defined('BASE_PATH')) {
require $baseDir . '/vendor/autoload.php';
if (class_exists('Dotenv\Dotenv') && is_file($baseDir . '/.env')) {
if (method_exists('Dotenv\Dotenv', 'createUnsafeImmutable')) {
Dotenv\Dotenv::createUnsafeImmutable($baseDir)->load();
} else {
Dotenv\Dotenv::createMutable($baseDir)->load();
}
Dotenv\Dotenv::createMutable($baseDir)->load();
}
if (!function_exists('env')) {

View File

@@ -10,11 +10,8 @@ $baseDir = __DIR__;
require $baseDir . '/vendor/autoload.php';
if (class_exists('Dotenv\Dotenv') && is_file($baseDir . '/.env')) {
if (method_exists('Dotenv\Dotenv', 'createUnsafeImmutable')) {
Dotenv\Dotenv::createUnsafeImmutable($baseDir)->load();
} else {
Dotenv\Dotenv::createMutable($baseDir)->load();
}
// 必须用 MutableWebman Worker 已加载过时Immutable 不会覆盖 $_ENV会导致 Phinx 与 Db::name() 前缀/库名不一致
Dotenv\Dotenv::createMutable($baseDir)->load();
}
if (!function_exists('env')) {

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
install-end

File diff suppressed because one or more lines are too long

View File

@@ -1,147 +1,163 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/install/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BuildAdmin-安装</title>
<script>
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/install/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BuildAdmin-安装</title>
<script>
(function(){
var urls = { adminUrl: '', frontUrl: '' };
fetch('/api/install/accessUrls').then(function(r){return r.json();}).then(function(res){
if (res && res.data) { urls.adminUrl = res.data.adminUrl || ''; urls.frontUrl = res.data.frontUrl || ''; }
}).catch(function(){});
function ensureQuickPanel() {
if (!urls.adminUrl && !urls.frontUrl) return;
if (document.getElementById('__ba_install_quick_urls__')) return;
var wrap = document.createElement('div');
wrap.id = '__ba_install_quick_urls__';
wrap.style.position = 'fixed';
wrap.style.right = '16px';
wrap.style.bottom = '16px';
wrap.style.zIndex = '99999';
wrap.style.maxWidth = '560px';
wrap.style.fontFamily = 'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"';
wrap.innerHTML =
'<div style="background:rgba(255,255,255,.96);border:1px solid rgba(0,0,0,.08);box-shadow:0 8px 24px rgba(0,0,0,.12);border-radius:12px;overflow:hidden">' +
'<div style="padding:10px 12px;border-bottom:1px solid rgba(0,0,0,.06);display:flex;gap:8px;align-items:center;justify-content:space-between">' +
'<div style="font-weight:600;color:#111">安装完成快捷入口</div>' +
'<button type="button" aria-label="close" style="border:0;background:transparent;cursor:pointer;font-size:16px;line-height:16px;color:#666;padding:4px 6px">×</button>' +
'</div>' +
'<div style="padding:12px;display:flex;flex-direction:column;gap:10px">' +
'<div>' +
var urls = { adminUrl: '', frontUrl: '' };
fetch('/api/install/accessUrls').then(function(r){return r.json();}).then(function(res){
if (res && res.data) { urls.adminUrl = res.data.adminUrl || ''; urls.frontUrl = res.data.frontUrl || ''; }
}).catch(function(){});
function ensureQuickPanel() {
if (!urls.adminUrl && !urls.frontUrl) return;
if (document.getElementById('__ba_install_quick_urls__')) return;
var wrap = document.createElement('div');
wrap.id = '__ba_install_quick_urls__';
wrap.style.position = 'fixed';
wrap.style.right = '16px';
wrap.style.bottom = '16px';
wrap.style.zIndex = '99999';
wrap.style.maxWidth = '560px';
wrap.style.fontFamily = 'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"';
wrap.innerHTML =
'<div style="background:rgba(255,255,255,.96);border:1px solid rgba(0,0,0,.08);box-shadow:0 8px 24px rgba(0,0,0,.12);border-radius:12px;overflow:hidden">' +
'<div style="padding:10px 12px;border-bottom:1px solid rgba(0,0,0,.06);display:flex;gap:8px;align-items:center;justify-content:space-between">' +
'<div style="font-weight:600;color:#111">安装完成快捷入口</div>' +
'<button type="button" aria-label="close" style="border:0;background:transparent;cursor:pointer;font-size:16px;line-height:16px;color:#666;padding:4px 6px">×</button>' +
'</div>' +
'<div style="padding:12px;display:flex;flex-direction:column;gap:10px">' +
'<div>' +
'<div style="font-size:12px;color:#666;margin-bottom:6px">后台地址</div>' +
'<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">' +
'<a data-k="admin" target="_blank" rel="noreferrer" style="color:#1677ff;text-decoration:none;word-break:break-all"></a>' +
'<button data-copy="admin" type="button" style="border:1px solid rgba(0,0,0,.12);background:#fff;border-radius:8px;padding:6px 10px;cursor:pointer">复制</button>' +
'<a data-k="admin" target="_blank" rel="noreferrer" style="color:#1677ff;text-decoration:none;word-break:break-all"></a>' +
'<button data-copy="admin" type="button" style="border:1px solid rgba(0,0,0,.12);background:#fff;border-radius:8px;padding:6px 10px;cursor:pointer">复制</button>' +
'</div>' +
'</div>' +
'<div>' +
'</div>' +
'<div>' +
'<div style="font-size:12px;color:#666;margin-bottom:6px">前台地址</div>' +
'<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">' +
'<a data-k="front" target="_blank" rel="noreferrer" style="color:#1677ff;text-decoration:none;word-break:break-all"></a>' +
'<button data-copy="front" type="button" style="border:1px solid rgba(0,0,0,.12);background:#fff;border-radius:8px;padding:6px 10px;cursor:pointer">复制</button>' +
'<a data-k="front" target="_blank" rel="noreferrer" style="color:#1677ff;text-decoration:none;word-break:break-all"></a>' +
'<button data-copy="front" type="button" style="border:1px solid rgba(0,0,0,.12);background:#fff;border-radius:8px;padding:6px 10px;cursor:pointer">复制</button>' +
'</div>' +
'</div>' +
'<div data-msg style="font-size:12px;color:#52c41a;min-height:16px"></div>' +
'</div>' +
'</div>';
document.body.appendChild(wrap);
'</div>' +
'<div data-msg style="font-size:12px;color:#52c41a;min-height:16px"></div>' +
'</div>' +
'</div>';
document.body.appendChild(wrap);
var closeBtn = wrap.querySelector('button[aria-label="close"]');
if (closeBtn) closeBtn.addEventListener('click', function(){ wrap.remove(); });
var closeBtn = wrap.querySelector('button[aria-label="close"]');
if (closeBtn) closeBtn.addEventListener('click', function(){ wrap.remove(); });
function setLink(which, val) {
var a = wrap.querySelector('a[data-k="' + which + '"]');
if (!a) return;
a.textContent = val || '';
a.href = val || 'javascript:void(0)';
}
setLink('admin', urls.adminUrl);
setLink('front', urls.frontUrl);
function showMsg(text, ok) {
var el = wrap.querySelector('[data-msg]');
if (!el) return;
el.style.color = ok ? '#52c41a' : '#ff4d4f';
el.textContent = text;
window.clearTimeout(el.__t);
el.__t = window.setTimeout(function(){ el.textContent = ''; }, 1800);
}
function copyText(text) {
if (!text) return Promise.reject(new Error('empty'));
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
}
return new Promise(function(resolve, reject){
try {
var ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', 'readonly');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
var ok = document.execCommand('copy');
document.body.removeChild(ta);
ok ? resolve() : reject(new Error('copy failed'));
} catch (e) {
reject(e);
function setLink(which, val) {
var a = wrap.querySelector('a[data-k="' + which + '"]');
if (!a) return;
a.textContent = val || '';
a.href = val || 'javascript:void(0)';
}
});
setLink('admin', urls.adminUrl);
setLink('front', urls.frontUrl);
function showMsg(text, ok) {
var el = wrap.querySelector('[data-msg]');
if (!el) return;
el.style.color = ok ? '#52c41a' : '#ff4d4f';
el.textContent = text;
window.clearTimeout(el.__t);
el.__t = window.setTimeout(function(){ el.textContent = ''; }, 1800);
}
function copyText(text) {
if (!text) return Promise.reject(new Error('empty'));
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
}
return new Promise(function(resolve, reject){
try {
var ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', 'readonly');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
var ok = document.execCommand('copy');
document.body.removeChild(ta);
ok ? resolve() : reject(new Error('copy failed'));
} catch (e) {
reject(e);
}
});
}
wrap.addEventListener('click', function(e){
var t = e.target;
if (!t || !t.getAttribute) return;
var which = t.getAttribute('data-copy');
if (!which) return;
var text = which === 'admin' ? urls.adminUrl : urls.frontUrl;
copyText(text).then(function(){
showMsg('已复制:' + text, true);
}).catch(function(){
showMsg('复制失败,请手动复制', false);
});
});
}
wrap.addEventListener('click', function(e){
var t = e.target;
if (!t || !t.getAttribute) return;
var which = t.getAttribute('data-copy');
if (!which) return;
var text = which === 'admin' ? urls.adminUrl : urls.frontUrl;
copyText(text).then(function(){
showMsg('已复制:' + text, true);
}).catch(function(){
showMsg('复制失败,请手动复制', false);
});
});
}
function applyUrls() {
if (!urls.adminUrl && !urls.frontUrl) return;
document.querySelectorAll('input[type="text"], input:not([type])').forEach(function(inp){
var v = (inp.value || '').trim();
if (v && (v.indexOf('#/admin') >= 0 || v.indexOf('index.html') >= 0) && v.indexOf('#/') >= 0) {
inp.value = urls.adminUrl;
inp.dispatchEvent(new Event('input', { bubbles: true }));
}
});
document.querySelectorAll('a[href*="#/admin"]').forEach(function(a){ if (urls.adminUrl) a.href = urls.adminUrl; });
document.querySelectorAll('a[href*="#/"]').forEach(function(a){
if (urls.frontUrl && a.href.indexOf('#/admin') < 0) a.href = urls.frontUrl;
});
ensureQuickPanel();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function(){ setInterval(applyUrls, 800); });
else setInterval(applyUrls, 800);
function applyUrls() {
if (!urls.adminUrl && !urls.frontUrl) return;
document.querySelectorAll('input[type="text"], input:not([type])').forEach(function(inp){
var v = (inp.value || '').trim();
if (v && (v.indexOf('#/admin') >= 0 || v.indexOf('index.html') >= 0 || v.indexOf('/index#') >= 0) && v.indexOf('#/') >= 0) {
inp.value = urls.adminUrl;
inp.dispatchEvent(new Event('input', { bubbles: true }));
}
});
document.querySelectorAll('a[href*="#/admin"]').forEach(function(a){ if (urls.adminUrl) a.href = urls.adminUrl; });
document.querySelectorAll('a[href*="#/"]').forEach(function(a){
if (urls.frontUrl && a.href.indexOf('#/admin') < 0) a.href = urls.frontUrl;
});
// index.html/#/ 会被当成路径 /index.html/ 导致 webman 404统一为 index.html#/
document.querySelectorAll('a[href*="index.html/#"]').forEach(function(a){
a.href = a.href.replace(/index\.html\/#\//g, 'index.html#/');
});
// 打包的 index.js 完成页用 protocol+host 拼 adminUrl会漏掉 /index.php 等前缀;用接口结果覆盖展示与点击
if (urls.adminUrl) {
document.querySelectorAll('.admin-url').forEach(function(el){
el.textContent = urls.adminUrl;
el.style.cursor = 'pointer';
el.onclick = function(ev){
ev.preventDefault();
ev.stopPropagation();
window.open(urls.adminUrl, '_blank', 'noreferrer');
};
});
}
ensureQuickPanel();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function(){ setInterval(applyUrls, 800); });
else setInterval(applyUrls, 800);
})();
(function(){
function closeMigrateModal() {
if (!document.body) return;
var txt = document.body.innerText || document.body.textContent || '';
if (txt.indexOf('数据表迁移失败') < 0 && txt.indexOf('数据表自动迁移失败') < 0) return;
var btns = document.body.querySelectorAll('button, [role="button"], .el-button');
for (var i = 0; i < btns.length; i++) {
var b = btns[i];
if (b.textContent && b.textContent.indexOf('继续安装') >= 0) { b.click(); return; }
function closeMigrateModal() {
if (!document.body) return;
var txt = document.body.innerText || document.body.textContent || '';
if (txt.indexOf('数据表迁移失败') < 0 && txt.indexOf('数据表自动迁移失败') < 0) return;
var btns = document.body.querySelectorAll('button, [role="button"], .el-button');
for (var i = 0; i < btns.length; i++) {
var b = btns[i];
if (b.textContent && b.textContent.indexOf('继续安装') >= 0) { b.click(); return; }
}
}
}
var obs = new MutationObserver(closeMigrateModal);
function start() { if (document.body) { obs.observe(document.body, { childList: true, subtree: true }); closeMigrateModal(); } }
if (document.body) start(); else document.addEventListener('DOMContentLoaded', start);
setInterval(closeMigrateModal, 150);
var obs = new MutationObserver(closeMigrateModal);
function start() { if (document.body) { obs.observe(document.body, { childList: true, subtree: true }); closeMigrateModal(); } }
if (document.body) start(); else document.addEventListener('DOMContentLoaded', start);
setInterval(closeMigrateModal, 150);
})();
</script>
<script type="module" crossorigin src="/install/assets/index.js"></script>
<link rel="stylesheet" crossorigin href="/install/assets/index.css">
</head>
<body>
<div id="app"></div>
</body>
</script>
<script type="module" crossorigin src="/install/assets/index.js"></script>
<link rel="stylesheet" crossorigin href="/install/assets/index.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -1,38 +0,0 @@
{
"tier_reward_form": [
{"grid_number": 5, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 6, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 7, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 8, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 9, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 10, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 11, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 12, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 13, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 14, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 15, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 16, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 17, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 18, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 19, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 20, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 21, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 22, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 23, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 24, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 25, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 26, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 27, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 28, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 29, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 30, "ui_text": "", "real_ev": "", "tier": "T1"}
],
"bigwin_form": [
{"grid_number": 5, "ui_text": "", "real_ev": "", "tier": "BIGWIN"},
{"grid_number": 10, "ui_text": "", "real_ev": "", "tier": "BIGWIN"},
{"grid_number": 15, "ui_text": "", "real_ev": "", "tier": "BIGWIN"},
{"grid_number": 20, "ui_text": "", "real_ev": "", "tier": "BIGWIN"},
{"grid_number": 25, "ui_text": "", "real_ev": "", "tier": "BIGWIN"},
{"grid_number": 30, "ui_text": "", "real_ev": "", "tier": "BIGWIN"}
]
}

View File

@@ -35,6 +35,18 @@ class Request extends \Webman\Http\Request
return $path;
}
/**
* 入口为 /index.php/... 时返回 /index.php用于拼接后台/前台完整 URL与路由 path() 剥离规则对应)
*/
public function publicBasePath(): string
{
$path = parent::path();
if (str_starts_with($path, '/index.php')) {
return '/index.php';
}
return '';
}
/**
* 获取请求参数(兼容 ThinkPHP param合并 get/postpost 优先)
* @param string|null $name 参数名null 返回全部

View File

@@ -1,8 +0,0 @@
import createAxios from '/@/utils/axios'
export function getUserRules() {
return createAxios({
url: '/admin/user.Rule/index',
method: 'get',
})
}

View File

@@ -1,13 +0,0 @@
import createAxios from '/@/utils/axios'
export const url = '/admin/user.MoneyLog/'
export function add(userId: string) {
return createAxios({
url: url + 'add',
method: 'get',
params: {
userId: userId,
},
})
}

View File

@@ -1,13 +0,0 @@
import createAxios from '/@/utils/axios'
export const url = '/admin/user.ScoreLog/'
export function add(userId: string) {
return createAxios({
url: url + 'add',
method: 'get',
params: {
userId: userId,
},
})
}

View File

@@ -2,6 +2,7 @@
import { isArray, isString } from 'lodash-es'
import type { PropType, VNode } from 'vue'
import { computed, createVNode, defineComponent, reactive, resolveComponent } from 'vue'
import { dayjs } from 'element-plus'
import { getArea } from '/@/api/common'
import type { InputAttr, InputData, ModelValueTypes } from '/@/components/baInput'
import { inputTypes } from '/@/components/baInput'
@@ -40,6 +41,29 @@ export default defineComponent({
},
emits: ['update:modelValue'],
setup(props, { emit, slots }) {
const normalizeDateTimeValue = (value: unknown, format: string) => {
if (value === null || value === undefined || value === '') {
return value
}
if (typeof value === 'number' && Number.isFinite(value)) {
const ms = value > 9999999999 ? value : value * 1000
const d = dayjs(ms)
return d.isValid() ? d.format(format) : value
}
if (typeof value === 'string') {
const trimmed = value.trim()
if (/^\d{10,13}$/.test(trimmed)) {
const num = Number(trimmed)
if (Number.isFinite(num)) {
const ms = trimmed.length === 13 ? num : num * 1000
const d = dayjs(ms)
return d.isValid() ? d.format(format) : value
}
}
}
return value
}
// 合并 props.attr 和 props.data
const attrs = computed(() => {
return { ...props.attr, ...props.data }
@@ -186,6 +210,7 @@ export default defineComponent({
valueFormat = 'YYYY'
break
}
const valueComputed = computed(() => normalizeDateTimeValue(props.modelValue, valueFormat))
return () =>
createVNode(
resolveComponent('el-date-picker'),
@@ -194,7 +219,7 @@ export default defineComponent({
type: props.type,
'value-format': valueFormat,
...attrs.value,
modelValue: props.modelValue,
modelValue: valueComputed.value,
'onUpdate:modelValue': onValueUpdate,
},
slots
@@ -300,7 +325,7 @@ export default defineComponent({
'year',
() => {
return () => {
const valueComputed = computed(() => (!props.modelValue ? null : '' + props.modelValue))
const valueComputed = computed(() => normalizeDateTimeValue(props.modelValue, 'YYYY'))
return createVNode(
resolveComponent('el-date-picker'),
{

View File

@@ -8,9 +8,5 @@ import { adminBaseRoutePath } from '/@/router/static/adminBase'
export default {
'/': ['./frontend/${lang}/index.ts'],
[adminBaseRoutePath + '/moduleStore']: ['./backend/${lang}/module.ts'],
[adminBaseRoutePath + '/user/rule']: ['./backend/${lang}/auth/rule.ts'],
[adminBaseRoutePath + '/user/scoreLog']: ['./backend/${lang}/user/moneyLog.ts'],
[adminBaseRoutePath + '/crud/crud']: ['./backend/${lang}/crud/log.ts', './backend/${lang}/crud/state.ts'],
// /admin/game/rewardConfig 会加载 rewardConfig.ts页面标题等仍在 rewardConfigForm.tsgame.rewardConfigForm
[adminBaseRoutePath + '/game/rewardConfig']: ['./backend/${lang}/game/rewardConfigForm.ts'],
}

View File

@@ -0,0 +1,22 @@
export default {
'quick Search Fields': 'ID/Settlement period ID/Remark',
id: 'ID',
settlement_period_id: 'Settlement period',
settlement_period_no: 'Settlement no.',
channel_id: 'Channel',
channel_name: 'Channel',
admin_id: 'Agent admin',
admin_username: 'Agent username',
commission_rate: 'Commission rate',
calc_base_amount: 'Calculation base amount',
commission_amount: 'Commission amount',
status: 'Status',
'status 0': 'Pending',
'status 1': 'Paid',
'status 2': 'Reverted',
settled_at: 'Settled at',
remark: 'Remark',
create_time: 'Created',
update_time: 'Updated',
}

View File

@@ -0,0 +1,19 @@
export default {
'quick Search Fields': 'ID/Settlement No./Remark',
id: 'ID',
settlement_no: 'Settlement No.',
period_start_at: 'Period start',
period_end_at: 'Period end',
total_bet_amount: 'Total bet amount',
total_payout_amount: 'Total payout amount',
platform_profit_amount: 'Platform profit',
status: 'Status',
'status 0': 'Pending',
'status 1': 'Processing',
'status 2': 'Completed',
'status 3': 'Closed',
remark: 'Remark',
create_time: 'Created',
update_time: 'Updated',
}

View File

@@ -5,6 +5,13 @@ export default {
avatar: 'Avatar',
email: 'Email',
mobile: 'Mobile Number',
invite_code: 'Invite code',
commission_rate: 'Commission rate(%)',
commission_rate_desc_title: 'Admin commission notes',
commission_rate_desc_1: 'Admin commission means this admin allocation ratio inside assigned group.',
commission_rate_desc_2: 'Current admin commission = current group commission × current admin commission rate.',
commission_rate_desc_3: 'Within same group, total admin commission rate cannot exceed 100%; exceed and remaining are returned on validation.',
'Please select exactly one group': 'Please select exactly one group',
'Last login': 'Last login',
Password: 'Password',
'Please leave blank if not modified': 'Please leave blank if you do not modify.',

View File

@@ -1,9 +1,14 @@
export default {
GroupName: 'Group Name',
'Group name': 'Group Name',
commission_rate: 'Commission rate (%)',
commission_rate_desc_title: 'Group commission notes',
commission_rate_desc_1: 'The total group commission rate under the same parent cannot exceed 100%.',
commission_rate_desc_2: 'Current group commission = channel commission × (1 - parent group commission rate) × current group commission rate.',
commission_rate_desc_3: 'If exceeded, the system returns both exceeded value and remaining quota under current parent.',
jurisdiction: 'Permissions',
'Parent group': 'Superior group',
'The parent group cannot be the group itself': 'The parent group cannot be the group itself',
'Manage subordinate role groups here':
'You can only manage role groups under your branch in the tree; peers, other branches and ancestors are out of scope. You can still only assign permission nodes you own.',
'In managing a subordinate role group (excluding a peer role group), you have all the rights of a subordinate role group and additional rights',
}

View File

@@ -0,0 +1,78 @@
export default {
id: 'id',
code: 'code',
invite_code: 'invite_code',
name: 'name',
agent_mode: 'agent_mode',
'agent_mode turnover': 'turnover',
'agent_mode affiliate': 'affiliate',
agent_mode_title_turnover: 'Turnover Agent Mode',
agent_mode_desc_turnover_1: 'Settlement: commissions apply only when platform-wide PnL is positive.',
agent_mode_desc_turnover_2: 'Formula: bucket base by turnover × turnover share rate.',
agent_mode_desc_turnover_3: 'Fields: fill turnover share rate only; affiliate fields are cleared automatically.',
agent_mode_title_affiliate: 'Affiliate Agent Mode',
agent_mode_desc_affiliate_1: 'Settlement: based on net player loss within this affiliate line.',
agent_mode_desc_affiliate_2: 'Formula: net loss after costs × affiliate share rate.',
agent_mode_desc_affiliate_3: 'Fields: supports fee deduction rate and carryover balance; turnover rate is cleared automatically.',
turnover_share_rate: 'turnover_share_rate',
affiliate_share_rate: 'affiliate_share_rate',
affiliate_fee_rate: 'affiliate_fee_rate',
affiliate_contract_no: 'affiliate_contract_no',
affiliate_contract_name: 'affiliate_contract_name',
settle_cycle: 'settlement_cycle',
settle_cycle_placeholder: 'Select settlement cycle (daily/weekly day/monthly date)',
'settle_cycle daily': 'daily',
'settle_cycle weekly': 'weekly',
'settle_cycle monthly': 'monthly',
settle_weekday: 'settlement_weekday',
settle_time: 'settlement_time',
'weekday 1': 'Mon',
'weekday 2': 'Tue',
'weekday 3': 'Wed',
'weekday 4': 'Thu',
'weekday 5': 'Fri',
'weekday 6': 'Sat',
'weekday 7': 'Sun',
affiliate_effective_start_at: 'affiliate_effective_start_at',
affiliate_effective_end_at: 'affiliate_effective_end_at',
affiliate_ladder_rules: 'affiliate_ladder_rules',
affiliate_ladder_rules_placeholder: 'Input JSON, e.g. [{\"minLoss\":\"0.0000\",\"shareRate\":\"0.200000\"}]',
ladder_min_loss: 'min loss',
ladder_share_rate: 'share rate',
ladder_rule_required: 'At least one ladder rule is required',
ladder_min_loss_invalid: 'Ladder min loss must be a number >= 0',
ladder_share_rate_invalid: 'Ladder share rate must be between 0 and 1',
ladder_min_loss_order_invalid: 'Ladder rules must be sorted by min loss ascending',
settle_day_daily: 'Every day',
day_suffix: 'd',
carryover_balance: 'carryover_balance',
user_count: 'user_count',
profit_amount: 'profit_amount',
total_profit_amount: 'total_profit_amount',
commission_pool_amount: 'commission_pool_amount',
status: 'status',
'status 0': 'status 0',
'status 1': 'status 1',
remark: 'remark',
admin_group_id: 'admin_group_id',
admingroup__name: 'name',
admin_id: 'admin_id',
admin_tree_tip: 'Admins are grouped by channel. Pick a leaf under a channel name. One channel maps to one admin.',
manual_settle: 'Manual settle',
manual_settle_confirm: 'Confirm trigger manual settlement for this channel?',
manual_settle_settlement_no: 'Settlement No.',
manual_settle_period_start: 'Period start',
manual_settle_period_end: 'Period end (now)',
manual_settle_total_bet: 'Total bet (settled bets)',
manual_settle_total_payout: 'Total payout',
manual_settle_platform_profit: 'Platform PnL',
manual_settle_commission_rate: 'Commission rate (decimal)',
manual_settle_calc_base: 'Settlement base',
manual_settle_commission_amount: 'Commission amount',
manual_settle_remark: 'Remark',
admin_id_placeholder: 'Select a channel admin account',
admin__username: 'username',
create_time: 'create_time',
update_time: 'update_time',
'quick Search Fields': 'id,code,name',
}

View File

@@ -0,0 +1,3 @@
import gameConfig from '../game/config'
export default gameConfig

View File

@@ -0,0 +1,3 @@
import dict from '../game/ziHuaDictionary'
export default dict

View File

@@ -0,0 +1,29 @@
export default {
'quick Search Fields': 'ID / Period / Idempotency',
id: 'ID',
period_id: 'Period ID',
period_no: 'Period No.',
user_id: 'User ID',
channel_id: 'Channel ID',
pick_numbers: 'Picks',
unit_amount: 'Unit amount',
pick_count: 'Pick count',
total_amount: 'Total',
streak_at_bet: 'Streak at bet',
is_auto: 'Auto',
'is_auto 0': 'Manual',
'is_auto 1': 'Auto bet',
win_amount: 'Payout',
jackpot_extra_amount: 'Jackpot extra',
status: 'Status',
'status 1': 'Pending draw',
'status 2': 'Settled',
'status 3': 'Refunded',
idempotency_key: 'Idempotency key',
create_time: 'Created',
update_time: 'Updated',
gamePeriod_period_no: 'Period (relation)',
gamePeriod_status: 'Period status',
user_username: 'Username',
channel_name: 'Channel',
}

View File

@@ -1,21 +0,0 @@
export default {
delete_confirm_title: 'Delete channel',
delete_confirm_related:
'This will also delete {countConfig} game config row(s) and {countUser} game user row(s) under this channel. This cannot be undone. Continue?',
id: 'id',
code: 'code',
name: 'name',
user_count: 'user_count',
profit_amount: 'profit_amount',
status: 'status',
'status 0': 'status 0',
'status 1': 'status 1',
remark: 'remark',
admin_group_id: 'admin_group_id',
admingroup__name: 'name',
admin_id: 'admin_id',
admin__username: 'username',
create_time: 'create_time',
update_time: 'update_time',
'quick Search Fields': 'id,code,name',
}

View File

@@ -1,33 +1,14 @@
export default {
ID: 'ID',
channel_id: 'channel_id',
channel__name: 'name',
group: 'group',
name: 'name',
title: 'title',
value: 'value',
'weight key': 'Key',
'weight value': 'Value',
'weight sum must 100': 'The sum of weights for default_tier_weight / default_kill_score_weight must equal 100',
'weight sum max 100': 'The sum of weights for default_tier_weight / default_kill_score_weight must not exceed 100',
'weight each max 100': 'Each weight value must not exceed 100',
'weight each max 10000': 'Weight must not exceed 10000',
'bigwin weight must integer': 'Each big win weight must be an integer',
'name opt game_rule': 'game_rule',
'name opt game_rule_en': 'game_rule_en',
'name opt default_tier_weight': 'default_tier_weight',
'name opt default_kill_score_weight': 'default_kill_score_weight',
'name opt default_bigwin_weight': 'default_bigwin_weight',
default_bigwin_weight_help:
'Big win weight range is 010000: 0 means never, 10000 means 100%. Totals 5 and 30 are guaranteed leopard (豹子); fixed at 10000 and cannot be changed.',
'bigwin weight each 0 10000': 'Each weight (except fixed keys) must be between 0 and 10000',
'bigwin weight locked 5 30': 'Weights for totals 5 and 30 are fixed at 10000',
'weight value numeric': 'Weight values must be valid numbers',
sort: 'sort',
instantiation: 'instantiation',
'instantiation 0': '---',
'instantiation 1': 'YES',
create_time: 'create_time',
update_time: 'update_time',
'quick Search Fields': 'ID',
'quick Search Fields': 'ID / Key / Remark',
id: 'ID',
config_key: 'Config key',
config_value: 'Value',
value_type: 'Value type',
'value_type string': 'String',
'value_type int': 'Integer',
'value_type decimal': 'Decimal',
'value_type json': 'JSON',
remark: 'Remark',
create_time: 'Created',
update_time: 'Updated',
}

View File

@@ -0,0 +1,28 @@
export default {
'quick Search Fields': 'Period No. / ID',
id: 'ID',
period_no: 'Period No.',
period_start_at: 'Start time',
status: 'Status',
'status 0': 'Betting open',
'status 1': 'Closed',
'status 2': 'Settling',
'status 3': 'Paying',
'status 4': 'Ended',
'status 5': 'Void',
draw_mode: 'Draw mode',
'draw_mode 0': 'Auto AI',
'draw_mode 1': 'Manual preset',
preset_number: 'Preset number',
result_number: 'Result number',
void_reason: 'Void reason',
create_time: 'Created',
update_time: 'Updated',
section_auto: 'Auto draw & new period',
auto_create_label: 'Allow auto-create next period',
auto_create_tip: 'When enabled, a background ticker inserts a new period if none is in progress',
manual_create_label: 'Allow manual create next period',
manual_create_tip: 'When enabled, the button below can create the next period',
btn_create_next: 'Create next period (manual)',
saving: 'Saving…',
}

View File

@@ -1,25 +0,0 @@
export default {
id: 'id',
game_channel_id: 'game_channel_id',
gamechannel__name: 'name',
tier_reward_form: 'tier_reward_form',
bigwin_form: 'bigwin_form',
create_time: 'create_time',
update_time: 'update_time',
grid_number: 'grid_number',
ui_text: 'ui_text',
ui_text_en: 'ui_text (EN)',
remark: 'remark',
real_ev: 'real_ev',
tier: 'tier',
tier_t1: 'T1',
tier_t2: 'T2',
tier_t3: 'T3',
tier_t4: 'T4',
tier_t5: 'T5',
tier_bigwin: 'BIGWIN',
tier_reward_form_help:
'Fixed 26 rows (5-30), no add/delete. Editable: ui_text, ui_text_en, real_ev, tier, remark.',
bigwin_form_help: 'Fixed 6 rows (5,10,15,20,25,30), no add/delete. Editable: ui_text, real_ev.',
'quick Search Fields': 'id',
}

View File

@@ -1,46 +0,0 @@
export default {
title: 'Game reward config',
intro:
'Super admins can maintain the global default template (game_channel_id=0). New channels copy this reward config first; if no template row exists yet, the built-in JSON template is used. Channel admins can only edit their own channel.',
super_scope_label: 'Scope',
super_scope_template: 'Global default template',
super_scope_channel: 'Specific channel',
super_scope_hint: 'Pick a channel below, then click Refresh to load.',
btn_add: 'Save',
btn_reset: 'Reset',
btn_gen_tier: 'Generate tier board',
btn_gen_weight: 'Generate reward weight table',
gen_tier_title: 'Generate reward index by rules',
gen_tier_rule:
'[Same logic as reward comparison]\n' +
'• 26 cells id 025; grid_number 530 unique.\n' +
'• Roll D: start_index = id where grid_number=D; CW end=(start+D)%26; CCW end=startD, if <0 then +26.\n' +
'• Comparison rows use D as dice points; tier/settlement/copy from landing cell.\n\n' +
'[Leopard rolls]\n' +
'For D in 5,10,15,20,25,30, CW/CCW landing tier cannot be T4/T5.\n\n' +
'[Settlement vs tier]\n' +
'<0→T4; 0100→T3; 100200→T2; >200→T1; T5 amount 0. Below you set per-tier amounts; T1T4 zh/en display text = amount string; T5 fixed.\n\n' +
'[Inputs]\n' +
'Counts: T1/T4/T5 fixed per direction; T2 minimum per direction.',
gen_tier_footer_hint: 'T2 is a lower bound; if generation fails, relax counts. You can still edit the table after.',
gen_tier_cancel: 'Cancel',
gen_tier_submit: 'Generate and save',
gen_t1_label: 'T1 grand prize',
gen_t1_fixed: 'Fixed count (CW/CCW)',
gen_t2_label: 'T2 small profit / break-even',
gen_t2_min: 'Minimum count',
gen_t3_label: 'T3 commission',
gen_t3_amt_only: 'Settlement amount',
gen_t4_label: 'T4 penalty',
gen_t4_fixed: 'Fixed count (CW/CCW)',
gen_t5_label: 'T5 try again',
gen_t5_fixed: 'Fixed count (CW/CCW)',
gen_settlement: 'Settlement amount',
gen_dir_cw: 'Clockwise',
gen_dir_ccw: 'Counter-clockwise',
gen_weight_confirm_title: 'Create reward comparison',
gen_weight_confirm_body:
'Rules: start_index = id of the cell whose grid_number equals roll D; CW end_index=(start_index+D)%26; CCW end_index = start_indexD if ≥0 else 26+start_indexD. Existing rows for this channel in game_reward_weight will be deleted, then 52 rows created (D=5..30 × two directions). Tier, settlement, display text and remark come from the landing cell in the tier table. Continue?',
gen_weight_confirm_ok: 'Confirm',
gen_weight_need_channel: 'Select a channel and refresh before generating weights.',
}

View File

@@ -1,26 +0,0 @@
export default {
id: 'id',
game_channel_id: 'game_channel_id',
gamechannel__name: 'name',
direction: 'direction',
'direction 0': 'direction 0',
'direction 1': 'direction 1',
grid_number: 'grid_number',
start_index: 'start_index',
end_index: 'end_index',
ui_text: 'ui_text',
real_ev: 'real_ev',
tier: 'tier',
type: 'type',
'type -2': 'type -2',
'type -1': 'type -1',
'type 0': 'type 0',
'type 1': 'type 1',
'type 2': 'type 2',
'type 3': 'type 3',
remark: 'remark',
weight: 'weight',
create_time: 'create_time',
update_time: 'update_time',
'quick Search Fields': 'id',
}

View File

@@ -1,35 +1,45 @@
export default {
id: 'id',
username: 'username',
password: 'password',
uuid: 'uuid',
phone: 'phone',
remark: 'remark',
coin: 'coin',
tier_weight: 'tier weight',
bigwin_weight: 'big win weight',
tier_weight_preset: 'tier weight preset (game config)',
bigwin_weight_preset: 'big win weight preset (game config)',
'weight value': 'weight value',
'weight value numeric': 'Weight values must be valid numbers',
'weight each max 100': 'Each weight value must not exceed 100',
tier_weight_help: 'Sum of T1~T5 must not exceed 100',
tier_weight_sum_max_100: 'Sum of tier weights (T1~T5) must not exceed 100',
bigwin_weight_help: 'Only requires each item ≤ 10000; points 5 and 30 are guaranteed big wins, fixed to 10000',
bigwin_weight_each_max_10000: 'Each big win weight must not exceed 10000',
ticket_count: 'tickets',
ticket_ante: 'bets',
ticket_count_times: 'times',
'ticket row incomplete': 'Each row must have both ante and count',
'ticket row numeric': 'ante and count must be valid numbers',
status: 'status',
'status 0': 'status 0',
'status 1': 'status 1',
game_channel_id: 'game_channel_id',
gamechannel__name: 'name',
admin_id: 'admin_id',
admin__username: 'username',
create_time: 'create_time',
update_time: 'update_time',
'quick Search Fields': 'id,username,phone',
id: 'ID',
username: 'Username',
password: 'Password',
uuid: 'UUID',
phone: 'Phone',
email: 'Email',
email_placeholder: 'Optional (register with phone or email)',
head_image: 'Avatar',
remark: 'Remark',
coin: 'Coin balance',
coin_placeholder: 'decimal(18,4)',
total_deposit_coin: 'Total deposit (coin)',
total_valid_bet_coin: 'Total valid bet (coin)',
risk_flags: 'Risk',
risk_none: 'None',
risk_no_login: 'No login',
risk_no_bet: 'No bet',
risk_no_withdraw: 'No withdraw',
current_streak: 'Win streak',
last_bet_period_no: 'Last bet period',
last_bet_period_no_placeholder: 'DB fallback for streak',
register_invite_code: 'Invite code (snapshot)',
register_invite_code_placeholder: 'Invite code at registration',
status: 'Status',
'status 0': 'Disabled',
'status 1': 'Enabled',
section_admin_attribution: 'Administrator',
admin_affiliation: 'Assigned admin',
admin_affiliation_placeholder: 'Role group tree — only admins in your scope',
register_invite_code_auto_placeholder: 'Filled from selected admin invite code',
channel_id: 'Channel',
channel__name: 'Channel',
admin_id: 'Admin',
admin__username: 'Admin',
create_time: 'Created',
update_time: 'Updated',
'quick Search Fields': 'ID, username, phone, email, invite code',
section_basic: 'Account',
section_register: 'Registration',
section_finance: 'Balance & turnover',
section_risk: 'Risk control',
section_streak: 'Streak (fallback)',
section_other: 'Other',
}

View File

@@ -0,0 +1,33 @@
export default {
id: 'Record ID',
user_id: 'User ID',
'user__username': 'Username',
'channel__name': 'Channel',
biz_type: 'Biz type',
direction: 'Direction',
amount: 'Amount',
balance_before: 'Balance before',
balance_after: 'Balance after',
ref_type: 'Ref type',
ref_id: 'Ref ID',
idempotency_key: 'Idempotency key',
'operator_admin__username': 'Operator',
remark: 'Remark',
create_time: 'Created at',
'quick Search Fields': 'ID, biz type, ref type, remark, idempotency key',
'direction in': 'Credit',
'direction out': 'Debit',
'biz deposit': 'Deposit',
'biz withdraw': 'Withdraw',
'biz withdraw_freeze': 'Withdraw freeze',
'biz withdraw_unfreeze': 'Withdraw unfreeze',
'biz platform_in': 'Platform credit',
'biz platform_out': 'Platform debit',
'biz admin_credit': 'Admin credit',
'biz admin_deduct': 'Admin debit',
'biz bet': 'Bet',
'biz payout': 'Payout',
'biz fee': 'Fee',
'biz void_refund': 'Void refund',
'biz adjust': 'Adjustment',
}

View File

@@ -0,0 +1,16 @@
export default {
title: '36 Zi Hua dictionary',
desc: 'Maintain the display names and grouping of the 36 entries. Changes affect player selection display, related filters/statistics, and admin mapping references. Please review before saving.',
no: 'No.',
name: 'Name',
category: 'Category',
btn_save: 'Save',
category_label: {
zodiac: 'Zodiac',
beast: 'Beast',
fowl: 'Fowl',
vermin: 'Vermin',
divine: 'Divine',
},
}

View File

@@ -0,0 +1,15 @@
export default {
'quick Search Fields': 'ID/Title',
id: 'ID',
title: 'Title',
content: 'Content',
notice_type: 'Notice type',
'notice_type 0': 'Inbox (silent)',
'notice_type 1': 'Pop-up',
status: 'Status',
'status 0': 'Draft',
'status 1': 'Published',
publish_at: 'Publish time',
create_time: 'Created',
update_time: 'Updated',
}

View File

@@ -0,0 +1,13 @@
export default {
'quick Search Fields': 'ID/User ID/Notice ID',
id: 'ID',
user_id: 'User',
notice_id: 'Notice',
notice_title: 'Notice title',
username: 'Username',
read_at: 'Read at',
confirmed: 'Confirmed',
'confirmed 0': 'No',
'confirmed 1': 'Yes',
create_time: 'Created',
}

View File

@@ -0,0 +1,35 @@
export default {
'quick Search Fields': 'ID / Period / Idempotency',
id: 'ID',
period_id: 'Period ID',
period_no: 'Period No.',
user_id: 'User ID',
channel_id: 'Channel ID',
pick_numbers: 'Picks',
unit_amount: 'Unit amount',
pick_count: 'Pick count',
total_amount: 'Total',
streak_at_bet: 'Streak at bet',
is_auto: 'Auto',
'is_auto 0': 'Manual',
'is_auto 1': 'Auto bet',
win_amount: 'Payout',
jackpot_extra_amount: 'Jackpot extra',
status: 'Status',
'status 1': 'Pending draw',
'status 2': 'Settled',
'status 3': 'Refunded',
idempotency_key: 'Idempotency key',
create_time: 'Created',
update_time: 'Updated',
gamePeriod_period_no: 'Period (relation)',
gamePeriod_status: 'Period status',
'gamePeriod_status 0': 'Open for betting',
'gamePeriod_status 1': 'Closed',
'gamePeriod_status 2': 'Settling tickets',
'gamePeriod_status 3': 'Paying out',
'gamePeriod_status 4': 'Finished',
'gamePeriod_status 5': 'Voided',
user_username: 'Username',
channel_name: 'Channel',
}

View File

@@ -0,0 +1,20 @@
export default {
'quick Search Fields': 'Order No./User ID/Pay channel',
id: 'ID',
order_no: 'Order No.',
user_id: 'User ID',
channel_id: 'Channel ID',
amount: 'Amount',
status: 'Status',
'status 0': 'Pending',
'status 1': 'Success',
'status 2': 'Failed',
'status 3': 'Canceled',
pay_channel: 'Pay channel',
pay_time: 'Pay time',
remark: 'Remark',
create_time: 'Created',
update_time: 'Updated',
user_username: 'Username',
channel_name: 'Channel',
}

View File

@@ -0,0 +1,23 @@
export default {
'quick Search Fields': 'Order No./User ID',
id: 'ID',
order_no: 'Order No.',
user_id: 'User ID',
channel_id: 'Channel ID',
amount: 'Apply amount',
fee: 'Fee',
actual_amount: 'Actual amount',
status: 'Status',
'status 0': 'Pending review',
'status 1': 'Approved',
'status 2': 'Rejected',
'status 3': 'Paid',
review_admin_id: 'Reviewer',
review_time: 'Review time',
remark: 'Remark',
create_time: 'Created',
update_time: 'Updated',
user_username: 'Username',
channel_name: 'Channel',
review_admin_username: 'Reviewer',
}

View File

@@ -0,0 +1,33 @@
export default {
id: 'Record ID',
user_id: 'User ID',
'user__username': 'Username',
'channel__name': 'Channel',
biz_type: 'Biz type',
direction: 'Direction',
amount: 'Amount',
balance_before: 'Balance before',
balance_after: 'Balance after',
ref_type: 'Ref type',
ref_id: 'Ref ID',
idempotency_key: 'Idempotency key',
'operator_admin__username': 'Operator',
remark: 'Remark',
create_time: 'Created at',
'quick Search Fields': 'ID, biz type, ref type, remark, idempotency key',
'direction in': 'Credit',
'direction out': 'Debit',
'biz deposit': 'Deposit',
'biz withdraw': 'Withdraw',
'biz withdraw_freeze': 'Withdraw freeze',
'biz withdraw_unfreeze': 'Withdraw unfreeze',
'biz platform_in': 'Platform credit',
'biz platform_out': 'Platform debit',
'biz admin_credit': 'Admin credit',
'biz admin_deduct': 'Admin debit',
'biz bet': 'Bet',
'biz payout': 'Payout',
'biz fee': 'Fee',
'biz void_refund': 'Void refund',
'biz adjust': 'Adjustment',
}

View File

@@ -1,5 +0,0 @@
export default {
GroupName: 'Group name',
'Group name': 'Group name',
jurisdiction: 'Permissions',
}

View File

@@ -1,16 +0,0 @@
export default {
'User name': 'Username',
'User nickname': 'User nickname',
balance: 'Balance',
'User ID': 'User ID',
'Change balance': 'Change balance',
'Before change': 'Before the change',
'After change': 'After the change',
remarks: 'Remark',
'Current balance': 'Current balance',
'Change amount': 'Change amount',
'Please enter the balance change amount': 'Please enter the balance change amount.',
'Balance after change': 'Balance after change',
'Please enter change remarks / description': 'Please enter change remarks/description',
User: 'User',
}

View File

@@ -1,26 +0,0 @@
export default {
'Normal routing': 'Normal routing',
'Member center menu contents': 'Member center menu directory ',
'Member center menu items': 'Member Center menu items',
'Top bar menu items': 'Top bar menu items',
'Page button': 'Page button',
'Top bar user dropdown': 'Top bar user dropdown',
'Type route tips': 'Automatically register as a front-end route',
'Type menu_dir tips': 'Automatically register routes and serve as menu directory of member center This item cannot jump',
'Type menu tips': 'Automatically register routes and serve as menu items in member centers',
'Type nav tips': 'Routes are automatically registered as menu items in the top bar of the site',
'Type button tips': 'Automatic registration as a permission node, can be quickly verified by v-auth',
'Type nav_user_menu tips': 'Automatically register routes and serve as a dropdown menu for top bar members',
'English name': 'English name',
'Web side routing path': 'Web side routing path',
no_login_valid: 'no login valid',
'no_login_valid 0': 'no',
'no_login_valid 1': 'yes',
'no_login_valid tips': 'Tourists do not have membership groups Use this option to set whether the current rules are valid for tourists (visible)',
'For example, if you add account/overview as a route only':
'Please start with /src for web side component paths, such as: /src/views/frontend/index.vue',
'Web side component path, please start with /src, such as: /src/views/frontend/index':
"For example, if you add 'account/overview' as a route only, then you can additionally add 'account/overview', 'account/overview/:a' and 'account/overview/:b/:C' as menus only.",
'Component path tips':
'This item is mandatory within a WEB project; otherwise, it cannot be accessed. However, when it is used as a menu within a Nuxt project, there is no need to fill in this item',
}

View File

@@ -1,8 +0,0 @@
export default {
integral: 'Integral',
'Change points': 'Change points',
'Current points': 'Current points',
'Please enter the change amount of points': 'Please enter the change amount of points',
'Points after change': 'Points after change',
'Please enter change remarks / description': 'Please enter change remarks/description',
}

View File

@@ -1,22 +1,45 @@
export default {
'User name': 'Username',
nickname: 'Nickname',
group: 'Group',
avatar: 'Avatar',
Gender: 'Gender',
male: 'Male',
female: 'Female',
mobile: 'Mobile Number',
'Last login IP': 'Last login IP',
'Last login': 'Last login',
email: 'Email',
birthday: 'Birthday',
balance: 'Balance',
'Adjustment balance': 'Adjust balance',
integral: 'Integral',
'Adjust integral': 'Adjust integral',
id: 'ID',
username: 'Username',
password: 'Password',
'Please leave blank if not modified': 'Please leave blank if you do not modify',
'Personal signature': 'Personal signature',
'Login account': 'Login account name',
uuid: 'UUID',
phone: 'Phone',
email: 'Email',
email_placeholder: 'Optional (register with phone or email)',
head_image: 'Avatar',
remark: 'Remark',
coin: 'Coin balance',
coin_placeholder: 'decimal(18,4)',
total_deposit_coin: 'Total deposit (coin)',
total_valid_bet_coin: 'Total valid bet (coin)',
risk_flags: 'Risk',
risk_none: 'None',
risk_no_login: 'No login',
risk_no_bet: 'No bet',
risk_no_withdraw: 'No withdraw',
current_streak: 'Win streak',
last_bet_period_no: 'Last bet period',
last_bet_period_no_placeholder: 'DB fallback for streak',
register_invite_code: 'Invite code (snapshot)',
register_invite_code_placeholder: 'Invite code at registration',
status: 'Status',
'status 0': 'Disabled',
'status 1': 'Enabled',
section_admin_attribution: 'Administrator',
admin_affiliation: 'Assigned admin',
admin_affiliation_placeholder: 'Role group tree — only admins in your scope',
register_invite_code_auto_placeholder: 'Filled from selected admin invite code',
channel_id: 'Channel',
channel__name: 'Channel',
admin_id: 'Admin',
admin__username: 'Admin',
create_time: 'Created',
update_time: 'Updated',
'quick Search Fields': 'ID, username, phone, email, invite code',
section_basic: 'Account',
section_register: 'Registration',
section_finance: 'Balance & turnover',
section_risk: 'Risk control',
section_streak: 'Streak (fallback)',
section_other: 'Other',
}

View File

@@ -0,0 +1,22 @@
export default {
'quick Search Fields': 'ID/结算周期ID/备注',
id: 'ID',
settlement_period_id: '结算周期',
settlement_period_no: '结算周期号',
channel_id: '渠道',
channel_name: '渠道名称',
admin_id: '代理管理员',
admin_username: '代理账号',
commission_rate: '佣金比例',
calc_base_amount: '结算基数',
commission_amount: '佣金金额',
status: '状态',
'status 0': '待发放',
'status 1': '已发放',
'status 2': '已回退',
settled_at: '发放时间',
remark: '备注',
create_time: '创建时间',
update_time: '更新时间',
}

View File

@@ -0,0 +1,19 @@
export default {
'quick Search Fields': 'ID/结算周期号/备注',
id: 'ID',
settlement_no: '结算周期号',
period_start_at: '周期开始',
period_end_at: '周期结束',
total_bet_amount: '总投注额',
total_payout_amount: '总派彩额',
platform_profit_amount: '平台盈亏',
status: '状态',
'status 0': '待结算',
'status 1': '结算中',
'status 2': '已完成',
'status 3': '已关闭',
remark: '备注',
create_time: '创建时间',
update_time: '更新时间',
}

View File

@@ -5,6 +5,13 @@ export default {
avatar: '头像',
email: '电子邮箱',
mobile: '手机号',
invite_code: '邀请码',
commission_rate: '分红比(%)',
commission_rate_desc_title: '管理员分红说明',
commission_rate_desc_1: '管理员分红用于该管理员在所属角色组内的分配比例。',
commission_rate_desc_2: '当前管理员分红=当前角色分红×当前管理员分红比例。',
commission_rate_desc_3: '同一角色组内管理员分红比例总和不能超过100%;超额会提示超出值与剩余额度。',
'Please select exactly one group': '请选择且仅选择一个角色组',
'Last login': '最后登录',
Password: '密码',
'Please leave blank if not modified': '不修改请留空',

View File

@@ -1,9 +1,13 @@
export default {
GroupName: '组名',
'Group name': '组别名称',
commission_rate: '分红比例(%)',
commission_rate_desc_title: '角色组分红说明',
commission_rate_desc_1: '同一父级下角色组分红比例总和不能超过100%。',
commission_rate_desc_2: '当前角色分红=渠道设置获取分红×(1-上级角色分红比例)×当前角色分红比例。',
commission_rate_desc_3: '提交超额时,系统会提示超出值与当前父级剩余额度。',
jurisdiction: '权限',
'Parent group': '上级分组',
'The parent group cannot be the group itself': '上级分组不能是分组本身',
'Manage subordinate role groups here':
'在此仅可管理「角色组树」中您所在组之下的下级组;同级、其他分支与上级组不在管理范围内。分配权限时仍只能勾选您自身拥有的节点。',
'Manage subordinate role groups here': '在此管理下级角色组(您拥有下级角色组的所有权限并且拥有额外的权限,不含同级)',
}

View File

@@ -0,0 +1,78 @@
export default {
id: 'ID',
code: '渠道标识',
invite_code: '渠道邀请码',
name: '渠道名',
agent_mode: '代理模式',
'agent_mode turnover': '普通返水代理',
'agent_mode affiliate': '联营代理',
agent_mode_title_turnover: '普通返水代理(按流水分桶)',
agent_mode_desc_turnover_1: '结算口径仅在平台大盘盈利时参与分红大盘亏损或持平时当期分红为0。',
agent_mode_desc_turnover_2: '计算方式:渠道分桶基数 × 返水分红比例turnover_share_rate。',
agent_mode_desc_turnover_3: '字段说明:仅需填写返水分红比例;切换到该模式会自动清空联营相关字段。',
agent_mode_title_affiliate: '联营代理(客损占成)',
agent_mode_desc_affiliate_1: '结算口径:按该渠道辖区净客损结算,支持联营占成分账。',
agent_mode_desc_affiliate_2: '计算方式:净客损扣除成本后 × 联营占成比例affiliate_share_rate。',
agent_mode_desc_affiliate_3: '字段说明:可配置联营成本扣除比例与负结转余额;切换到该模式会自动清空返水比例。',
turnover_share_rate: '返水分红比例',
affiliate_share_rate: '联营占成比例',
affiliate_fee_rate: '联营成本扣除比例',
affiliate_contract_no: '联营契约编号',
affiliate_contract_name: '联营契约名称',
settle_cycle: '结算周期',
settle_cycle_placeholder: '请选择结算周期(可细化到周几/几号)',
'settle_cycle daily': '日结',
'settle_cycle weekly': '周结',
'settle_cycle monthly': '月结',
settle_weekday: '结算周几',
settle_time: '结算时间',
'weekday 1': '周一',
'weekday 2': '周二',
'weekday 3': '周三',
'weekday 4': '周四',
'weekday 5': '周五',
'weekday 6': '周六',
'weekday 7': '周日',
affiliate_effective_start_at: '联营生效开始',
affiliate_effective_end_at: '联营生效结束',
affiliate_ladder_rules: '联营阶梯规则',
affiliate_ladder_rules_placeholder: '请输入 JSON例如 [{\"minLoss\":\"0.0000\",\"shareRate\":\"0.200000\"}]',
ladder_min_loss: '起始客损',
ladder_share_rate: '占成比例',
ladder_rule_required: '联营阶梯规则至少需要一条',
ladder_min_loss_invalid: '联营阶梯规则起始客损必须为大于等于0的数字',
ladder_share_rate_invalid: '联营阶梯规则占成比例必须在0到1之间',
ladder_min_loss_order_invalid: '联营阶梯规则需按起始客损递增',
settle_day_daily: '每天',
day_suffix: '号',
carryover_balance: '联营负结转余额',
user_count: '用户数',
profit_amount: '当期利润',
total_profit_amount: '累计总利润',
commission_pool_amount: '可分红资金池',
status: '状态',
'status 0': '禁用',
'status 1': '启用',
remark: '备注',
admin_group_id: '管理角色组',
admingroup__name: '组名',
admin_id: '管理员',
admin_tree_tip: '按渠道分组展示可关联的管理员账号,请在「渠道名称」下选择具体管理员(叶子节点)。渠道负责人仅对应一名管理员。',
manual_settle: '手动结算',
manual_settle_confirm: '确认触发当前渠道手动结算?',
manual_settle_settlement_no: '结算单号',
manual_settle_period_start: '周期开始',
manual_settle_period_end: '周期结束(当前时间)',
manual_settle_total_bet: '总投注额(已结算注单)',
manual_settle_total_payout: '总派彩额',
manual_settle_platform_profit: '平台盈亏',
manual_settle_commission_rate: '佣金比例(小数)',
manual_settle_calc_base: '结算基数',
manual_settle_commission_amount: '佣金金额',
manual_settle_remark: '备注',
admin_id_placeholder: '请选择渠道下的管理员账号',
admin__username: '用户名',
create_time: '创建时间',
update_time: '修改时间',
'quick Search Fields': 'ID、渠道标识、渠道名',
}

View File

@@ -0,0 +1,3 @@
import gameConfig from '../game/config'
export default gameConfig

View File

@@ -0,0 +1,3 @@
import dict from '../game/ziHuaDictionary'
export default dict

View File

@@ -0,0 +1,29 @@
export default {
'quick Search Fields': 'ID/期号/幂等键',
id: 'ID',
period_id: '对局ID',
period_no: '期号',
user_id: '用户ID',
channel_id: '渠道ID',
pick_numbers: '选号',
unit_amount: '单号金额',
pick_count: '选号个数',
total_amount: '总金额',
streak_at_bet: '下注时连胜',
is_auto: '托管',
'is_auto 0': '手动',
'is_auto 1': '托管',
win_amount: '派彩',
jackpot_extra_amount: 'Jackpot',
status: '状态',
'status 1': '待开奖',
'status 2': '已结算',
'status 3': '已退款',
idempotency_key: '幂等键',
create_time: '创建时间',
update_time: '更新时间',
gamePeriod_period_no: '对局期号',
gamePeriod_status: '期状态',
user_username: '用户名',
channel_name: '渠道',
}

View File

@@ -1,21 +0,0 @@
export default {
delete_confirm_title: '删除渠道',
delete_confirm_related:
'将同时删除该渠道下 {countConfig} 条游戏配置、{countUser} 条游戏用户数据,此操作不可恢复。确定删除所选渠道吗?',
id: 'ID',
code: '渠道标识',
name: '渠道名',
user_count: '用户数',
profit_amount: '利润',
status: '状态',
'status 0': '禁用',
'status 1': '启用',
remark: '备注',
admin_group_id: '管理角色组',
admingroup__name: '组名',
admin_id: '管理员',
admin__username: '用户名',
create_time: '创建时间',
update_time: '修改时间',
'quick Search Fields': 'ID、渠道标识、渠道名',
}

View File

@@ -1,33 +1,14 @@
export default {
ID: 'ID',
channel_id: '渠道id',
channel__name: '渠道名',
group: '分组',
name: '配置标识',
title: '配置名称',
value: '',
'weight key': '',
'weight value': '数值',
'weight sum must 100': 'default_tier_weight / default_kill_score_weight 的权重之和必须等于 100',
'weight sum max 100': 'default_tier_weight / default_kill_score_weight 的权重之和不能超过 100',
'weight each max 100': '每项权重不能超过 100',
'weight each max 10000': '权重不能超过10000',
'bigwin weight must integer': '大奖权重每项必须为整数',
'name opt game_rule': 'game_rule游戏规则',
'name opt game_rule_en': 'game_rule_en游戏规则英文',
'name opt default_tier_weight': 'default_tier_weight默认档位权重',
'name opt default_kill_score_weight': 'default_kill_score_weight默认击杀分权重',
'name opt default_bigwin_weight': 'default_bigwin_weight默认大奖权重',
default_bigwin_weight_help:
'大奖权重区间为 0100000 表示不可能出现10000 表示 100% 出现。点数 5 与 30 为豹子号必中组合,权重固定为 10000不可修改。',
'bigwin weight each 0 10000': '除固定项外,每项大奖权重须在 010000 之间',
'bigwin weight locked 5 30': '点数 5 与 30 权重固定为 10000不可修改',
'weight value numeric': '权重值必须为有效数字',
sort: '排序',
instantiation: '实例化',
'instantiation 0': '---',
'instantiation 1': '需要',
'quick Search Fields': 'ID/参数键/说明',
id: 'ID',
config_key: '参数键',
config_value: '参数值',
value_type: '值类型',
'value_type string': '字符串',
'value_type int': '整数',
'value_type decimal': '小数',
'value_type json': 'JSON',
remark: '说明',
create_time: '创建时间',
update_time: '更新时间',
'quick Search Fields': 'ID',
}

View File

@@ -0,0 +1,28 @@
export default {
'quick Search Fields': '期号/ID',
id: 'ID',
period_no: '期号',
period_start_at: '开始时间',
status: '状态',
'status 0': '下注开放',
'status 1': '已封盘',
'status 2': '算票中',
'status 3': '派彩中',
'status 4': '已结束',
'status 5': '已作废',
draw_mode: '开奖方式',
'draw_mode 0': '自动AI',
'draw_mode 1': '手动预设',
preset_number: '预设号码',
result_number: '开奖号码',
void_reason: '作废原因',
create_time: '创建时间',
update_time: '更新时间',
section_auto: '自动开奖与新建期',
auto_create_label: '允许自动创建下一期',
auto_create_tip: '开启后由后台定时任务在无进行中期号时自动插入新期',
manual_create_label: '允许手动创建下一期',
manual_create_tip: '开启后可在本页使用「手动创建下一期」按钮',
btn_create_next: '手动创建下一期',
saving: '保存中…',
}

View File

@@ -1,25 +0,0 @@
export default {
id: 'ID',
game_channel_id: '渠道',
gamechannel__name: '渠道名',
tier_reward_form: '档位奖励表单',
bigwin_form: '超级大奖表单',
create_time: '创建时间',
update_time: '更新时间',
grid_number: '色子点数',
ui_text: '显示文本',
ui_text_en: '显示文本(en)',
remark: '备注',
real_ev: '实际中奖',
tier: '档位',
tier_t1: 'T1',
tier_t2: 'T2',
tier_t3: 'T3',
tier_t4: 'T4',
tier_t5: 'T5',
tier_bigwin: 'BIGWIN',
tier_reward_form_help:
'固定 26 条(点数 5-30不可新增或删除可修改显示文本、英文显示、实际中奖、档位与备注生成器会预填英文与备注',
bigwin_form_help: '固定 6 条(点数 5、10、15、20、25、30不可新增或删除仅可修改显示文本、实际中奖',
'quick Search Fields': 'ID',
}

View File

@@ -1,48 +0,0 @@
export default {
title: '游戏奖励配置',
intro:
'超级管理员可维护「全渠道默认模板」(对应库中 game_channel_id=0。新建渠道时会优先复制该模板的档位奖励与超级大奖配置若尚未保存过模板则使用项目内置 JSON 模板。渠道管理员仅能编辑自己负责渠道的配置。',
super_scope_label: '维护范围',
super_scope_template: '全渠道默认模板',
super_scope_channel: '指定渠道',
super_scope_hint: '选择「指定渠道」后请在下拉框中选择具体渠道并点击刷新加载。',
btn_add: '新增',
btn_reset: '重置',
btn_gen_tier: '生成游戏奖励配置',
btn_gen_weight: '生成游戏奖励权重配置',
gen_tier_title: '按规则生成奖励索引',
gen_tier_rule:
'【生成逻辑(与创建奖励对照一致)】\n' +
'• 盘面 26 格按 id 升序为位置 025每条配置的 grid_number 为 530 且不重复。\n' +
'• 摇取点数 D530起点为「grid_number=D」所在格位的 idstart_index顺时针落点 = (起点 + D) mod 26逆时针落点 = 起点 D若小于 0 则 +26。\n' +
'• 对照表每条记录的「色子点数」列为 D档位、真实结算、显示文案取自落点格位对应 id 的配置。\n\n' +
'【豹子摇取点数】\n' +
'摇取点数为 5、10、15、20、25、30 时,其顺/逆时针落点档位不能为 T4、T5。\n\n' +
'【结算金额与档位】\n' +
'结算金额 < 0 → T40 < 结算金额 < 100 → T3100 < 结算金额 < 200 → T2200 < 结算金额 → T1T5 结算金额=0。\n' +
'下方填写各档位统一结算金额标准T1T4 的中/英文显示文本将等于该金额字符串T5 固定「再来一次」/「Once again」。\n\n' +
'【本弹窗输入】\n' +
'条数T1/T4/T5 为顺时针与逆时针各自的固定条数T2 为顺时针与逆时针各自「不少于」的条数。生成后仍可在主表中微调。',
gen_tier_footer_hint:
'T1/T4/T5 为精确条数T2 为下限;生成失败时请放宽条数或稍后再试。生成后可在上方表格中继续修改。',
gen_tier_cancel: '取消',
gen_tier_submit: '生成并保存',
gen_t1_label: 'T1 大奖',
gen_t1_fixed: '固定条数(顺/逆)',
gen_t2_label: 'T2 小赚/回本',
gen_t2_min: '最少条数',
gen_t3_label: 'T3 抽水',
gen_t3_amt_only: '结算金额',
gen_t4_label: 'T4 惩罚',
gen_t4_fixed: '固定条数(顺/逆)',
gen_t5_label: 'T5 再来一次',
gen_t5_fixed: '固定条数(顺/逆)',
gen_settlement: '结算金额',
gen_dir_cw: '顺时针',
gen_dir_ccw: '逆时针',
gen_weight_confirm_title: '创建奖励对照',
gen_weight_confirm_body:
'按规则创建奖励对照:起始索引 start_index 为奖励配置中 grid_number 与摇取点数 D 相同的那一格的 id顺时针 end_index=(start_index+摇取点数)%26逆时针 end_index=start_index摇取点数若≥0 则取该值,否则 26+start_index摇取点数。将先清空该渠道 game_reward_weight 表中现有数据,再为 530 共 26 个点数、顺/逆时针各生成一条(共 52 条)。档位、真实结算、显示文案、备注取自落点格位在档位表中的配置。是否继续?',
gen_weight_confirm_ok: '确定创建',
gen_weight_need_channel: '请先选择具体渠道并刷新后再生成权重对照。',
}

View File

@@ -1,26 +0,0 @@
export default {
id: 'ID',
game_channel_id: '渠道',
gamechannel__name: '渠道名',
direction: '方向',
'direction 0': '顺时针',
'direction 1': '逆时针',
grid_number: '点数',
start_index: '起始索引',
end_index: '结束索引',
ui_text: '显示文本',
real_ev: '实际中奖金额',
tier: '档位',
type: '奖励类型',
'type -2': '唯一惩罚',
'type -1': '抽水',
'type 0': '回本',
'type 1': '再来一次',
'type 2': '小赚',
'type 3': '大奖格',
remark: '备注',
weight: '权重',
create_time: '创建时间',
update_time: '修改时间',
'quick Search Fields': 'ID',
}

View File

@@ -4,32 +4,42 @@ export default {
password: '密码',
uuid: '用户唯一标识',
phone: '手机号',
email: '邮箱',
email_placeholder: '可选,与手机号二选一注册时填写',
head_image: '头像',
remark: '备注',
coin: '平台币',
tier_weight: '档位权重',
bigwin_weight: '中大奖权重',
tier_weight_preset: '档位权重模板(游戏配置)',
bigwin_weight_preset: '大奖权重模板(游戏配置)',
'weight value': '权重数值',
'weight value numeric': '权重值必须为有效数字',
'weight each max 100': '每项权重 100',
tier_weight_help: 'T1~T5 权重之和不能超过 100',
tier_weight_sum_max_100: '档位权重T1~T5之和不能超过 100',
bigwin_weight_help: '仅限制每项权重不超过 10000点数 5 和 30 为必中大奖组合,权重固定为 10000',
bigwin_weight_each_max_10000: '每项中大奖权重不能超过 10000',
ticket_count: '抽奖券',
ticket_ante: '注数',
ticket_count_times: '次数',
'ticket row incomplete': '每行需同时填写 ante 与 count',
'ticket row numeric': 'ante、count 须为有效数字',
coin: '游戏币余额',
coin_placeholder: 'decimal(18,4),禁止业务用浮点存库',
total_deposit_coin: '累计充值(币)',
total_valid_bet_coin: '累计有效投注(币)',
risk_flags: '风控',
risk_none: '无限制',
risk_no_login: '禁止登录',
risk_no_bet: '禁止下注',
risk_no_withdraw: '禁止提现',
current_streak: '当前连胜',
last_bet_period_no: '最近下注期号',
last_bet_period_no_placeholder: '连胜兜底同步用,可与 Redis 对照',
register_invite_code: '注册邀请码快照',
register_invite_code_placeholder: '注册时绑定渠道/代理邀请码',
status: '状态',
'status 0': '禁用',
'status 1': '启用',
game_channel_id: '所属渠道',
gamechannel__name: '渠道名',
admin_id: '所属管理员',
admin__username: '用户名',
section_admin_attribution: '管理员归属',
admin_affiliation: '归属管理员',
admin_affiliation_placeholder: '按角色组展开,仅展示您可管理范围内的管理员',
register_invite_code_auto_placeholder: '随所选管理员邀请码自动带出',
channel_id: '所属渠道',
channel__name: '渠道名',
admin_id: '归属管理员',
admin__username: '管理员',
create_time: '创建时间',
update_time: '修改时间',
'quick Search Fields': 'ID、用户名、手机号',
'quick Search Fields': 'ID、用户名、手机号、邮箱、邀请码',
section_basic: '账号信息',
section_register: '注册与邀请',
section_finance: '资金与流水',
section_risk: '风控',
section_streak: '连胜(兜底)',
section_other: '其他',
}

View File

@@ -0,0 +1,33 @@
export default {
id: '流水ID',
user_id: '用户ID',
'user__username': '用户名',
'channel__name': '渠道',
biz_type: '业务类型',
direction: '方向',
amount: '变动额',
balance_before: '变动前余额',
balance_after: '变动后余额',
ref_type: '关联类型',
ref_id: '关联ID',
idempotency_key: '幂等键',
'operator_admin__username': '操作管理员',
remark: '备注',
create_time: '创建时间',
'quick Search Fields': 'ID、业务类型、关联类型、备注、幂等键',
'direction in': '入金',
'direction out': '出金',
'biz deposit': '充值入账',
'biz withdraw': '提现出账',
'biz withdraw_freeze': '提现冻结',
'biz withdraw_unfreeze': '提现解冻',
'biz platform_in': '平台划入',
'biz platform_out': '平台划出',
'biz admin_credit': '管理员加币',
'biz admin_deduct': '管理员扣币',
'biz bet': '下注扣款',
'biz payout': '派彩',
'biz fee': '手续费',
'biz void_refund': '作废退款',
'biz adjust': '其他调账',
}

View File

@@ -0,0 +1,16 @@
export default {
title: '36字花字典',
desc: '用于维护 36 字花的展示名称与归类。修改后会影响前台选号展示、相关筛选统计及后台对照信息,请确认无误后再保存。',
no: '编号',
name: '名称',
category: '类型',
btn_save: '保存',
category_label: {
zodiac: '生肖',
beast: '猛兽',
fowl: '飞禽',
vermin: '虫蛇',
divine: '神兽',
},
}

View File

@@ -0,0 +1,15 @@
export default {
'quick Search Fields': 'ID/标题',
id: 'ID',
title: '标题',
content: '正文',
notice_type: '公告类型',
'notice_type 0': '静默信箱',
'notice_type 1': '强弹窗',
status: '状态',
'status 0': '草稿',
'status 1': '发布',
publish_at: '发布时间',
create_time: '创建时间',
update_time: '更新时间',
}

View File

@@ -0,0 +1,13 @@
export default {
'quick Search Fields': 'ID/用户ID/公告ID',
id: 'ID',
user_id: '用户',
notice_id: '公告',
notice_title: '公告标题',
username: '用户名',
read_at: '阅读时间',
confirmed: '已确认',
'confirmed 0': '未确认',
'confirmed 1': '已确认',
create_time: '创建时间',
}

View File

@@ -0,0 +1,35 @@
export default {
'quick Search Fields': 'ID/期号/幂等键',
id: 'ID',
period_id: '对局ID',
period_no: '期号',
user_id: '用户ID',
channel_id: '渠道ID',
pick_numbers: '选号',
unit_amount: '单号金额',
pick_count: '选号个数',
total_amount: '总金额',
streak_at_bet: '下注时连胜',
is_auto: '托管',
'is_auto 0': '手动',
'is_auto 1': '托管',
win_amount: '派彩',
jackpot_extra_amount: 'Jackpot',
status: '状态',
'status 1': '待开奖',
'status 2': '已结算',
'status 3': '已退款',
idempotency_key: '幂等键',
create_time: '创建时间',
update_time: '更新时间',
gamePeriod_period_no: '对局期号',
gamePeriod_status: '期状态',
'gamePeriod_status 0': '下注开放',
'gamePeriod_status 1': '已封盘',
'gamePeriod_status 2': '算票中',
'gamePeriod_status 3': '派彩中',
'gamePeriod_status 4': '已结束',
'gamePeriod_status 5': '已作废',
user_username: '用户名',
channel_name: '渠道',
}

View File

@@ -0,0 +1,20 @@
export default {
'quick Search Fields': '订单号/用户ID/支付通道',
id: 'ID',
order_no: '订单号',
user_id: '用户ID',
user_username: '用户名',
channel_id: '渠道ID',
channel_name: '渠道',
amount: '金额',
status: '状态',
'status 0': '待处理',
'status 1': '成功',
'status 2': '失败',
'status 3': '已取消',
pay_channel: '支付通道',
pay_time: '支付时间',
remark: '备注',
create_time: '创建时间',
update_time: '更新时间',
}

View File

@@ -0,0 +1,23 @@
export default {
'quick Search Fields': '订单号/用户ID',
id: 'ID',
order_no: '订单号',
user_id: '用户ID',
channel_id: '渠道ID',
amount: '申请金额',
fee: '手续费',
actual_amount: '实际到账',
status: '状态',
'status 0': '待审核',
'status 1': '已通过',
'status 2': '已拒绝',
'status 3': '已打款',
review_admin_id: '审核管理员',
review_time: '审核时间',
remark: '备注',
create_time: '创建时间',
update_time: '更新时间',
user_username: '用户名',
channel_name: '渠道',
review_admin_username: '审核人',
}

View File

@@ -0,0 +1,33 @@
export default {
id: '流水ID',
user_id: '用户ID',
'user__username': '用户名',
'channel__name': '渠道',
biz_type: '业务类型',
direction: '方向',
amount: '变动额',
balance_before: '变动前余额',
balance_after: '变动后余额',
ref_type: '关联类型',
ref_id: '关联ID',
idempotency_key: '幂等键',
'operator_admin__username': '操作管理员',
remark: '备注',
create_time: '创建时间',
'quick Search Fields': 'ID、业务类型、关联类型、备注、幂等键',
'direction in': '入金',
'direction out': '出金',
'biz deposit': '充值入账',
'biz withdraw': '提现出账',
'biz withdraw_freeze': '提现冻结',
'biz withdraw_unfreeze': '提现解冻',
'biz platform_in': '平台划入',
'biz platform_out': '平台划出',
'biz admin_credit': '管理员加币',
'biz admin_deduct': '管理员扣币',
'biz bet': '下注扣款',
'biz payout': '派彩',
'biz fee': '手续费',
'biz void_refund': '作废退款',
'biz adjust': '其他调账',
}

View File

@@ -1,5 +0,0 @@
export default {
GroupName: '组名',
'Group name': '组别名称',
jurisdiction: '权限',
}

View File

@@ -1,16 +0,0 @@
export default {
'User name': '用户名',
'User nickname': '用户昵称',
balance: '余额',
'User ID': '用户ID',
'Change balance': '变更余额',
'Before change': '变更前',
'After change': '变更后',
remarks: '备注',
'Current balance': '当前余额',
'Change amount': '变动数额',
'Please enter the balance change amount': '请输入余额变更数额',
'Balance after change': '变更后余额',
'Please enter change remarks / description': '请输入变更备注/说明',
User: '用户',
}

View File

@@ -1,24 +0,0 @@
export default {
'Normal routing': '普通路由',
'Member center menu contents': '会员中心菜单目录',
'Member center menu items': '会员中心菜单项',
'Top bar menu items': '顶栏菜单项',
'Page button': '页面按钮',
'Top bar user dropdown': '顶栏会员菜单下拉项',
'Type route tips': '自动注册为前端路由',
'Type menu_dir tips': '自动注册路由,并作为会员中心的菜单目录,此项本身不可跳转',
'Type menu tips': '自动注册路由,并作为会员中心的菜单项目',
'Type nav tips': '自动注册路由,并作为站点顶栏的菜单项目',
'Type button tips': '自动注册为权限节点,可通过 v-auth 快速验权',
'Type nav_user_menu tips': '自动注册路由,并作为顶栏会员菜单下拉项',
'English name': '英文名称',
'Web side routing path': 'WEB 端路由路径vue-router 的 path',
no_login_valid: '未登录有效',
'no_login_valid 0': '游客无效',
'no_login_valid 1': '游客有效',
'no_login_valid tips': '游客没有会员分组,通过本选项设置当前规则是否对游客有效(可见)',
'For example, if you add account/overview as a route only': 'WEB 端组件路径,请以 /src 开头,如:/src/views/frontend/index.vue',
'Web side component path, please start with /src, such as: /src/views/frontend/index':
'比如将 `account/overview` 只添加为路由,那么可以另外将 `account/overview`、`account/overview/:a`、`account/overview/:b/:c` 只添加为菜单',
'Component path tips': '组件路径在 WEB 工程内是必填的,否则无法访问,但作为 Nuxt 工程内的菜单时,无需填写此项,请根据菜单使用场景填写',
}

View File

@@ -1,8 +0,0 @@
export default {
integral: '积分',
'Change points': '变更积分',
'Current points': '当前积分',
'Please enter the change amount of points': '请输入积分变更数额',
'Points after change': '变更后积分',
'Please enter change remarks / description': '请输入变更备注/说明',
}

View File

@@ -1,22 +1,45 @@
export default {
'User name': '用户名',
nickname: '昵称',
group: '分组',
avatar: '头像',
Gender: '性别',
male: '男',
female: '女',
mobile: '手机号',
'Last login IP': '最后登录IP',
'Last login': '最后登录',
email: '电子邮箱',
birthday: '生日',
balance: '余额',
'Adjustment balance': '调整余额',
integral: '积分',
'Adjust integral': '调整积分',
id: 'ID',
username: '用户名',
password: '密码',
'Please leave blank if not modified': '不修改请留空',
'Personal signature': '个性签名',
'Login account': '登录账户名',
uuid: '用户唯一标识',
phone: '手机号',
email: '邮箱',
email_placeholder: '可选,与手机号二选一注册时填写',
head_image: '头像',
remark: '备注',
coin: '游戏币余额',
coin_placeholder: 'decimal(18,4),禁止业务用浮点存库',
total_deposit_coin: '累计充值(币)',
total_valid_bet_coin: '累计有效投注(币)',
risk_flags: '风控',
risk_none: '无限制',
risk_no_login: '禁止登录',
risk_no_bet: '禁止下注',
risk_no_withdraw: '禁止提现',
current_streak: '当前连胜',
last_bet_period_no: '最近下注期号',
last_bet_period_no_placeholder: '连胜兜底同步用,可与 Redis 对照',
register_invite_code: '注册邀请码快照',
register_invite_code_placeholder: '注册时绑定渠道/代理邀请码',
status: '状态',
'status 0': '禁用',
'status 1': '启用',
section_admin_attribution: '管理员归属',
admin_affiliation: '归属管理员',
admin_affiliation_placeholder: '按角色组展开,仅展示您可管理范围内的管理员',
register_invite_code_auto_placeholder: '随所选管理员邀请码自动带出',
channel_id: '所属渠道',
channel__name: '渠道名',
admin_id: '归属管理员',
admin__username: '管理员',
create_time: '创建时间',
update_time: '修改时间',
'quick Search Fields': 'ID、用户名、手机号、邮箱、邀请码',
section_basic: '账号信息',
section_register: '注册与邀请',
section_finance: '资金与流水',
section_risk: '风控',
section_streak: '连胜(兜底)',
section_other: '其他',
}

View File

@@ -37,6 +37,12 @@ router.beforeEach((to, from, next) => {
// 按需动态加载页面的语言包-start
let loadPath: string[] = []
const toCamelPath = (path: string): string => {
return path
.split('/')
.map((seg) => seg.replace(/_([a-z])/g, (_m, c: string) => c.toUpperCase()))
.join('/')
}
const config = useConfig()
if (to.path in langAutoLoadMap) {
loadPath.push(...langAutoLoadMap[to.path as keyof typeof langAutoLoadMap])
@@ -47,7 +53,10 @@ router.beforeEach((to, from, next) => {
// 去除 path 中的 /admin
const adminPath = to.path.slice(to.path.indexOf(adminBaseRoutePath) + adminBaseRoutePath.length)
if (adminPath) loadPath.push(prefix + adminPath + '.ts')
if (adminPath) {
loadPath.push(prefix + adminPath + '.ts')
loadPath.push(prefix + toCamelPath(adminPath) + '.ts')
}
} else {
prefix = './frontend/' + config.lang.defaultLang
loadPath.push(prefix + to.path + '.ts')
@@ -55,7 +64,9 @@ router.beforeEach((to, from, next) => {
// 根据路由 name 加载的语言包
if (to.name) {
loadPath.push(prefix + '/' + to.name.toString() + '.ts')
const routeName = to.name.toString()
loadPath.push(prefix + '/' + routeName + '.ts')
loadPath.push(prefix + '/' + toCamelPath(routeName) + '.ts')
}
if (!window.loadLangHandle.publicMessageLoaded) window.loadLangHandle.publicMessageLoaded = []

View File

@@ -19,8 +19,6 @@ const staticRoutes: Array<RouteRecordRaw> = [
meta: {
title: pageTitle('home'),
},
redirect: adminBaseRoutePath,
children: [],
},
{
// 管理员登录页 - 不放在 adminBaseRoute.children 因为登录页不需要使用后台的布局

View File

@@ -100,6 +100,16 @@ export const useTerminal = defineStore(
}
function addTask(command: string, blockOnFailure = true, extend = '', callback: Function = () => {}) {
const duplicatePending = state.taskList.some(
(item) =>
item.command === command &&
(item.status === taskStatus.Waiting ||
item.status === taskStatus.Connecting ||
item.status === taskStatus.Executing)
)
if (duplicatePending) {
return
}
if (!state.show) toggleDot(true)
state.taskList = state.taskList.concat({
uuid: uuid(),

View File

@@ -1,6 +1,7 @@
import type { AxiosRequestConfig, Method } from 'axios'
import axios from 'axios'
import { ElLoading, ElNotification, type LoadingOptions } from 'element-plus'
import { nextTick } from 'vue'
import { refreshToken } from '/@/api/common'
import { i18n } from '/@/lang/index'
import router from '/@/router/index'
@@ -20,6 +21,12 @@ const loadingInstance: LoadingInstance = {
count: 0,
}
/** 请求是否后台 /admin/ 接口(不依赖当前路由,避免 loading 等场景误判为前台) */
function isAdminBackendRequest(config: AxiosRequestConfig): boolean {
const u = `${config.baseURL ?? ''}${config.url ?? ''}`
return /\/admin\//i.test(u)
}
/**
* 根据运行环境获取基础请求URL
*/
@@ -112,6 +119,22 @@ function createAxios<Data = any, T = ApiPromise<Data>>(axiosConfig: AxiosRequest
if (response.config.responseType == 'json') {
if (response.data && response.data.code !== 1) {
const needLogin = response.data.data && typeof response.data.data === 'object' && response.data.data.type === 'need login'
if (needLogin) {
const isAdminAppFlag = isAdminApp() || isAdminBackendRequest(response.config)
if (isAdminAppFlag) {
adminInfo.removeToken()
} else {
userInfo.removeToken()
}
const loginRouteName = isAdminAppFlag ? 'adminLogin' : 'userLogin'
if (router.currentRoute.value.name !== loginRouteName) {
nextTick(() => {
void router.replace({ name: loginRouteName })
})
}
return Promise.reject(response.data)
}
if (response.data.code == 409) {
if (!window.tokenRefreshing) {
window.tokenRefreshing = true

View File

@@ -1,117 +0,0 @@
/** 档位权重固定键(与 GameConfig / GameUser JSON 一致) */
export const TIER_WEIGHT_KEYS = ['T1', 'T2', 'T3', 'T4', 'T5'] as const
/** 中大奖权重固定键 */
export const BIGWIN_WEIGHT_KEYS = ['5', '10', '15', '20', '25', '30'] as const
/** 默认大奖权重:点数 5、30 为豹子号必中,权重固定 10000 */
export function isBigwinDiceLockedKey(key: string): boolean {
return key === '5' || key === '30'
}
export type WeightRow = { key: string; val: string }
export function weightArrayToMap(arr: unknown): Record<string, string> {
const map: Record<string, string> = {}
if (!Array.isArray(arr)) {
return map
}
for (const item of arr) {
if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
for (const [k, v] of Object.entries(item)) {
map[k] = v === null || v === undefined ? '' : String(v)
}
}
}
return map
}
/** 解析 game_weight JSON 为键值映射(键为 T1 / 5 等) */
export function parseWeightJsonToMap(raw: unknown): Record<string, string> {
if (raw === null || raw === undefined || raw === '') {
return {}
}
if (typeof raw === 'string') {
const s = raw.trim()
if (!s || s === '[]') {
return {}
}
try {
const parsed = JSON.parse(s)
return weightArrayToMap(parsed)
} catch {
return {}
}
}
if (Array.isArray(raw)) {
return weightArrayToMap(raw)
}
return {}
}
export function fixedRowsFromKeys(keys: readonly string[], map: Record<string, string>): WeightRow[] {
const rows: WeightRow[] = []
for (const k of keys) {
rows.push({ key: k, val: map[k] ?? '' })
}
return rows
}
export function rowsToMap(rows: WeightRow[]): Record<string, string> {
const m: Record<string, string> = {}
for (const r of rows) {
m[r.key] = r.val
}
return m
}
export function jsonStringFromFixedKeys(keys: readonly string[], map: Record<string, string>): string {
const pairs: Record<string, string>[] = []
for (const k of keys) {
const one: Record<string, string> = {}
one[k] = map[k] ?? ''
pairs.push(one)
}
return JSON.stringify(pairs)
}
/**
* 统一配置标识trim、去掉「 (说明)」「(说明)」「(说明)」等后缀、小写,避免下拉/接口返回不一致导致走错位校验如误报每项≤10000
*/
export function normalizeGameWeightConfigName(name: string | undefined): string {
if (name === undefined || name === null) {
return ''
}
let s = String(name).trim()
// 截到第一个半角/全角左括号之前(兼容 "key (说明)"、"key说明"、"key(说明)"、无前导空格)
const m = s.match(/^([^(]+)/u)
if (m) {
s = m[1].trim()
}
return s.toLowerCase()
}
/** 当前行是否为大奖骰子键 530与 BIGWIN_WEIGHT_KEYS 一致) */
export function weightRowsMatchBigwinDiceKeys(rows: { key: string }[]): boolean {
const keys = rows
.map((r) => r.key.trim())
.filter((k) => k !== '')
.sort((a, b) => Number(a) - Number(b))
if (keys.length !== BIGWIN_WEIGHT_KEYS.length) {
return false
}
return BIGWIN_WEIGHT_KEYS.every((k, i) => keys[i] === k)
}
/** GameConfig 中按 name 判断是否使用固定键编辑 */
export function getFixedKeysForGameConfigName(name: string | undefined): readonly string[] | null {
const n = normalizeGameWeightConfigName(name)
/** 击杀分权重与档位权重同为 T1T5库中 JSON 为 [{"T1":"0"},...],非骰子点 530 */
if (n === 'default_tier_weight' || n === 'default_kill_score_weight') {
return TIER_WEIGHT_KEYS
}
if (n === 'default_bigwin_weight') {
return BIGWIN_WEIGHT_KEYS
}
return null
}

View File

@@ -0,0 +1,157 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('agent.commissionRecord.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: 'agent/commissionRecord',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
const baTable = new baTableClass(
new baTableApi('/admin/agent.CommissionRecord/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('agent.commissionRecord.id'), prop: 'id', align: 'center', width: 80, operator: 'RANGE', sortable: 'custom' },
{
label: t('agent.commissionRecord.settlement_period_id'),
prop: 'settlement_period_id',
align: 'center',
width: 130,
operator: 'RANGE',
show: false,
},
{
label: t('agent.commissionRecord.settlement_period_no'),
prop: 'settlementPeriod.settlement_no',
align: 'center',
minWidth: 170,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
},
{ label: t('agent.commissionRecord.channel_id'), prop: 'channel_id', align: 'center', width: 100, operator: 'RANGE', show: false },
{
label: t('agent.commissionRecord.channel_name'),
prop: 'channel.name',
align: 'center',
minWidth: 120,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
},
{ label: t('agent.commissionRecord.admin_id'), prop: 'admin_id', align: 'center', width: 100, operator: 'RANGE', show: false },
{
label: t('agent.commissionRecord.admin_username'),
prop: 'admin.username',
align: 'center',
minWidth: 120,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
},
{ label: t('agent.commissionRecord.commission_rate'), prop: 'commission_rate', align: 'center', minWidth: 110, operator: 'RANGE' },
{ label: t('agent.commissionRecord.calc_base_amount'), prop: 'calc_base_amount', align: 'center', minWidth: 120, operator: 'RANGE' },
{ label: t('agent.commissionRecord.commission_amount'), prop: 'commission_amount', align: 'center', minWidth: 120, operator: 'RANGE' },
{
label: t('agent.commissionRecord.status'),
prop: 'status',
align: 'center',
width: 100,
effect: 'dark',
custom: { 0: 'info', 1: 'warning', 2: 'success' },
operator: 'eq',
render: 'tag',
replaceValue: {
'0': t('agent.commissionRecord.status 0'),
'1': t('agent.commissionRecord.status 1'),
'2': t('agent.commissionRecord.status 2'),
},
},
{
label: t('agent.commissionRecord.settled_at'),
prop: 'settled_at',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
width: 170,
sortable: 'custom',
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('agent.commissionRecord.remark'),
prop: 'remark',
align: 'center',
minWidth: 160,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{
label: t('agent.commissionRecord.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
width: 170,
sortable: 'custom',
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('agent.commissionRecord.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
width: 170,
sortable: 'custom',
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', minWidth: 80, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
],
},
{
defaultItems: { status: 0, commission_rate: '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>

View File

@@ -0,0 +1,86 @@
<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">
<FormItem
:label="t('agent.commissionRecord.settlement_period_id')"
type="remoteSelect"
v-model="baTable.form.items!.settlement_period_id"
prop="settlement_period_id"
:key="'sp-' + (baTable.form.items!.id ?? 'new')"
:input-attr="{
pk: 'id',
field: 'settlement_no',
remoteUrl: '/admin/agent.SettlementPeriod/index',
placeholder: t('Click select'),
}"
/>
<FormItem
:label="t('agent.commissionRecord.channel_id')"
type="remoteSelect"
v-model="baTable.form.items!.channel_id"
prop="channel_id"
:key="'ch-' + (baTable.form.items!.id ?? 'new')"
:input-attr="{
pk: 'id',
field: 'name',
remoteUrl: '/admin/channel/index',
placeholder: t('Click select'),
}"
/>
<FormItem
:label="t('agent.commissionRecord.admin_id')"
type="remoteSelect"
v-model="baTable.form.items!.admin_id"
prop="admin_id"
:key="'adm-' + (baTable.form.items!.id ?? 'new')"
:input-attr="{
pk: 'id',
field: 'username',
remoteUrl: '/admin/auth/admin/index',
placeholder: t('Click select'),
}"
/>
<FormItem :label="t('agent.commissionRecord.commission_rate')" type="number" v-model="baTable.form.items!.commission_rate" prop="commission_rate" :input-attr="{ min: 0, precision: 4, step: 0.0001 }" />
<FormItem :label="t('agent.commissionRecord.calc_base_amount')" type="number" v-model="baTable.form.items!.calc_base_amount" prop="calc_base_amount" :input-attr="{ min: 0, precision: 4, step: 0.0001 }" />
<FormItem :label="t('agent.commissionRecord.commission_amount')" type="number" v-model="baTable.form.items!.commission_amount" prop="commission_amount" :input-attr="{ precision: 4, step: 0.0001 }" />
<FormItem :label="t('agent.commissionRecord.status')" type="radio" v-model="baTable.form.items!.status" prop="status" :input-attr="{ content: { '0': t('agent.commissionRecord.status 0'), '1': t('agent.commissionRecord.status 1'), '2': t('agent.commissionRecord.status 2') } }" />
<FormItem :label="t('agent.commissionRecord.settled_at')" type="datetime" v-model="baTable.form.items!.settled_at" prop="settled_at" />
<FormItem :label="t('agent.commissionRecord.remark')" type="textarea" v-model="baTable.form.items!.remark" prop="remark" :input-attr="{ rows: 2 }" />
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
settlement_period_id: [{ required: true, message: t('Please input field', { field: t('agent.commissionRecord.settlement_period_id') }) }],
channel_id: [{ required: true, message: t('Please input field', { field: t('agent.commissionRecord.channel_id') }) }],
admin_id: [{ required: true, message: t('Please input field', { field: t('agent.commissionRecord.admin_id') }) }],
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,152 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('agent.settlementPeriod.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: 'agent/settlementPeriod',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
const baTable = new baTableClass(
new baTableApi('/admin/agent.SettlementPeriod/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('agent.settlementPeriod.id'), prop: 'id', align: 'center', width: 80, operator: 'RANGE', sortable: 'custom' },
{
label: t('agent.settlementPeriod.settlement_no'),
prop: 'settlement_no',
align: 'center',
minWidth: 160,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('agent.settlementPeriod.period_start_at'),
prop: 'period_start_at',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
width: 170,
sortable: 'custom',
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('agent.settlementPeriod.period_end_at'),
prop: 'period_end_at',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
width: 170,
sortable: 'custom',
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('agent.settlementPeriod.total_bet_amount'), prop: 'total_bet_amount', align: 'center', operator: 'RANGE', minWidth: 120 },
{
label: t('agent.settlementPeriod.total_payout_amount'),
prop: 'total_payout_amount',
align: 'center',
operator: 'RANGE',
minWidth: 120,
},
{
label: t('agent.settlementPeriod.platform_profit_amount'),
prop: 'platform_profit_amount',
align: 'center',
operator: 'RANGE',
minWidth: 120,
},
{
label: t('agent.settlementPeriod.status'),
prop: 'status',
align: 'center',
width: 100,
effect: 'dark',
custom: { 0: 'info', 1: 'warning', 2: 'success', 3: 'danger' },
operator: 'eq',
render: 'tag',
replaceValue: {
'0': t('agent.settlementPeriod.status 0'),
'1': t('agent.settlementPeriod.status 1'),
'2': t('agent.settlementPeriod.status 2'),
'3': t('agent.settlementPeriod.status 3'),
},
},
{
label: t('agent.settlementPeriod.remark'),
prop: 'remark',
align: 'center',
minWidth: 160,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{
label: t('agent.settlementPeriod.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
width: 170,
sortable: 'custom',
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('agent.settlementPeriod.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
width: 170,
sortable: 'custom',
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', minWidth: 80, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
],
},
{
defaultItems: { status: 0 },
}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,48 @@
<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">
<FormItem :label="t('agent.settlementPeriod.settlement_no')" type="string" v-model="baTable.form.items!.settlement_no" prop="settlement_no" />
<FormItem :label="t('agent.settlementPeriod.period_start_at')" type="datetime" v-model="baTable.form.items!.period_start_at" prop="period_start_at" />
<FormItem :label="t('agent.settlementPeriod.period_end_at')" type="datetime" v-model="baTable.form.items!.period_end_at" prop="period_end_at" />
<FormItem :label="t('agent.settlementPeriod.total_bet_amount')" type="number" v-model="baTable.form.items!.total_bet_amount" prop="total_bet_amount" :input-attr="{ min: 0, precision: 4, step: 0.0001 }" />
<FormItem :label="t('agent.settlementPeriod.total_payout_amount')" type="number" v-model="baTable.form.items!.total_payout_amount" prop="total_payout_amount" :input-attr="{ min: 0, precision: 4, step: 0.0001 }" />
<FormItem :label="t('agent.settlementPeriod.platform_profit_amount')" type="number" v-model="baTable.form.items!.platform_profit_amount" prop="platform_profit_amount" :input-attr="{ precision: 4, step: 0.0001 }" />
<FormItem :label="t('agent.settlementPeriod.status')" type="radio" v-model="baTable.form.items!.status" prop="status" :input-attr="{ content: { '0': t('agent.settlementPeriod.status 0'), '1': t('agent.settlementPeriod.status 1'), '2': t('agent.settlementPeriod.status 2'), '3': t('agent.settlementPeriod.status 3') } }" />
<FormItem :label="t('agent.settlementPeriod.remark')" type="textarea" v-model="baTable.form.items!.remark" prop="remark" :input-attr="{ rows: 2 }" />
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
settlement_no: [{ required: true, message: t('Please input field', { field: t('agent.settlementPeriod.settlement_no') }) }],
})
</script>
<style scoped lang="scss"></style>

View File

@@ -40,6 +40,13 @@ optButtons[1].display = (row) => {
return row.id != adminInfo.id
}
const formatRatePercent = (_row: any, _column: any, cellValue: number | string | null) => {
if (cellValue === null || cellValue === undefined || cellValue === '') return '--'
const num = Number(cellValue)
if (Number.isNaN(num)) return '--'
return `${num.toFixed(2)}%`
}
const baTable = new baTableClass(
new baTableApi('/admin/auth.Admin/'),
{
@@ -48,7 +55,23 @@ const baTable = new baTableClass(
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ label: t('auth.admin.username'), prop: 'username', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{ label: t('auth.admin.nickname'), prop: 'nickname', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{ label: t('auth.admin.group'), prop: 'group_name_arr', align: 'center', operator: false, render: 'tags' },
{
label: t('auth.admin.group'),
prop: 'group_name_arr',
align: 'center',
minWidth: 150,
operator: false,
render: 'tags',
},
{ label: t('auth.admin.invite_code'), prop: 'invite_code', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('auth.admin.commission_rate'),
prop: 'commission_rate',
align: 'center',
minWidth: 90,
operator: 'RANGE',
formatter: formatRatePercent,
},
{ label: t('auth.admin.avatar'), prop: 'avatar', align: 'center', render: 'image', operator: false },
{ label: t('auth.admin.email'), prop: 'email', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{ label: t('auth.admin.mobile'), prop: 'mobile', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
@@ -67,16 +90,18 @@ const baTable = new baTableClass(
prop: 'status',
align: 'center',
render: 'tag',
effect: 'dark',
custom: { disable: 'danger', enable: 'success' },
replaceValue: { disable: t('Disable'), enable: t('Enable') },
},
{
label: t('Operate'),
align: 'center',
width: '100',
minWidth: '80',
render: 'buttons',
buttons: optButtons,
operator: false,
fixed: 'right',
},
],
dblClickNotEditColumn: [undefined, 'status'],

View File

@@ -43,18 +43,43 @@
/>
<FormItem
:label="t('auth.admin.group')"
v-model="baTable.form.items!.group_arr"
v-model="singleGroupValue"
prop="group_arr"
type="remoteSelect"
:key="'group-' + baTable.form.items!.id"
:input-attr="{
multiple: true,
multiple: false,
disabled: adminInfo.id == baTable.form.items!.id,
params: { isTree: true, absoluteAuth: adminInfo.id == baTable.form.items!.id ? 0 : 1 },
field: 'name',
remoteUrl: '/admin/auth.Group/index',
placeholder: t('Click select'),
}"
/>
<FormItem
:label="t('auth.admin.commission_rate')"
v-model="baTable.form.items!.commission_rate"
type="number"
prop="commission_rate"
:input-attr="{ step: 0.01, precision: 2, min: 0, max: 100, disabled: shouldDisableCommissionRate() }"
:placeholder="t('Please input field', { field: t('auth.admin.commission_rate') })"
/>
<el-alert class="commission-rate-alert" :title="t('auth.admin.commission_rate_desc_title')" type="info" :closable="false" show-icon>
<template #default>
<ul class="commission-rate-desc-list">
<li>{{ t('auth.admin.commission_rate_desc_1') }}</li>
<li>{{ t('auth.admin.commission_rate_desc_2') }}</li>
<li>{{ t('auth.admin.commission_rate_desc_3') }}</li>
</ul>
</template>
</el-alert>
<FormItem
v-if="baTable.form.operate == 'Edit'"
:label="t('auth.admin.invite_code')"
v-model="baTable.form.items!.invite_code"
type="string"
:input-attr="{ readonly: true, disabled: true }"
/>
<FormItem :label="t('auth.admin.avatar')" type="image" v-model="baTable.form.items!.avatar" />
<FormItem
:label="t('auth.admin.email')"
@@ -115,7 +140,7 @@
</template>
<script setup lang="ts">
import { reactive, inject, watch, useTemplateRef } from 'vue'
import { computed, reactive, inject, watch, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import { regularPassword, buildValidatorData } from '/@/utils/validate'
@@ -131,10 +156,69 @@ const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
/** 解析管理员分红比例:与后端 isValidCommissionRate 一致地拒绝非法字符,避免 Number('30,00') 等为 NaN 导致误报 */
function parseAdminCommissionRateInput(raw: unknown): { kind: 'empty' } | { kind: 'invalid' } | { kind: 'ok'; value: number } {
if (raw === null || raw === undefined || raw === '') {
return { kind: 'empty' }
}
if (typeof raw === 'number') {
if (!Number.isFinite(raw)) {
return { kind: 'invalid' }
}
return { kind: 'ok', value: raw }
}
const s = String(raw).trim()
if (s === '') {
return { kind: 'empty' }
}
const normalized = s.replace(',', '.')
const n = parseFloat(normalized)
if (!Number.isFinite(n)) {
return { kind: 'invalid' }
}
return { kind: 'ok', value: n }
}
const shouldDisableCommissionRate = () => {
return adminInfo.id == baTable.form.items!.id
}
const singleGroupValue = computed({
get: () => {
const group = baTable.form.items?.group_arr
if (Array.isArray(group)) {
return group.length > 0 ? group[0] : ''
}
return group ?? ''
},
set: (value) => {
if (!baTable.form.items) {
return
}
baTable.form.items.group_arr = value === '' || value === null || value === undefined ? [] : [value]
},
})
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
username: [buildValidatorData({ name: 'required', title: t('auth.admin.username') }), buildValidatorData({ name: 'account' })],
nickname: [buildValidatorData({ name: 'required', title: t('auth.admin.nickname') })],
group_arr: [buildValidatorData({ name: 'required', message: t('Please select field', { field: t('auth.admin.group') }) })],
group_arr: [
{
required: true,
validator: (_rule: any, val: unknown, callback: Function) => {
if (Array.isArray(val)) {
if (val.length !== 1) {
return callback(new Error(t('auth.admin.Please select exactly one group')))
}
return callback()
}
if (val === null || val === undefined || val === '') {
return callback(new Error(t('Please select field', { field: t('auth.admin.group') })))
}
return callback()
},
trigger: 'change',
},
],
email: [buildValidatorData({ name: 'email', message: t('Please enter the correct field', { field: t('auth.admin.email') }) })],
mobile: [buildValidatorData({ name: 'mobile', message: t('Please enter the correct field', { field: t('auth.admin.mobile') }) })],
password: [
@@ -157,6 +241,26 @@ const rules: Partial<Record<string, FormItemRule[]>> = reactive({
trigger: 'blur',
},
],
commission_rate: [
{
validator: (_rule: unknown, val: unknown, callback: (e?: Error) => void) => {
const parsed = parseAdminCommissionRateInput(val)
if (parsed.kind === 'empty') {
return callback()
}
if (parsed.kind === 'invalid') {
return callback(new Error(t('Please enter the correct field', { field: t('auth.admin.commission_rate') })))
}
const n = parsed.value
const rounded = Math.round(n * 100) / 100
if (rounded < -0.000001 || rounded > 100.000001) {
return callback(new Error(t('Please enter the correct field', { field: t('auth.admin.commission_rate') })))
}
return callback()
},
trigger: ['blur', 'change'],
},
],
})
watch(
@@ -181,6 +285,14 @@ watch(
width: 110px;
height: 110px;
}
.commission-rate-alert {
margin-bottom: 12px;
}
.commission-rate-desc-list {
margin: 6px 0 0;
padding-left: 18px;
line-height: 1.6;
}
.avatar-uploader:hover {
border-color: var(--el-color-primary);
}

View File

@@ -90,7 +90,17 @@ const baTable = new baTableClass(new baTableApi('/admin/auth.AdminLog/'), {
showOverflowTooltip: true,
render: 'url',
},
{ label: t('auth.adminLog.ip'), prop: 'ip', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query'), render: 'tag' },
{
label: t('auth.adminLog.ip'),
prop: 'ip',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
render: 'tag',
customRenderAttr: {
tag: () => ({ type: 'info' }),
},
},
{
label: t('auth.adminLog.useragent'),
prop: 'useragent',
@@ -111,10 +121,11 @@ const baTable = new baTableClass(new baTableApi('/admin/auth.AdminLog/'), {
{
label: t('Operate'),
align: 'center',
width: '100',
minWidth: '80',
render: 'buttons',
buttons: optButtons,
operator: false,
fixed: 'right',
},
],
dblClickNotEditColumn: [undefined],

View File

@@ -54,19 +54,33 @@ const baTable: baTableClass = new baTableClass(
dblClickNotEditColumn: [undefined],
column: [
{ type: 'selection', align: 'center' },
{ label: t('auth.group.Group name'), prop: 'name', align: 'left', width: '200' },
{
label: t('auth.group.Group name'),
prop: 'name',
align: 'left',
minWidth: '180',
},
{ label: t('auth.group.commission_rate'), prop: 'commission_rate', align: 'center', formatter: formatRatePercent },
{ label: t('auth.group.jurisdiction'), prop: 'rules', align: 'center' },
{
label: t('State'),
prop: 'status',
align: 'center',
render: 'tag',
effect: 'dark',
custom: { 0: 'danger', 1: 'success' },
replaceValue: { 0: t('Disable'), 1: t('Enable') },
},
{ label: t('Update time'), prop: 'update_time', align: 'center', width: '160', render: 'datetime' },
{ label: t('Create time'), prop: 'create_time', align: 'center', width: '160', render: 'datetime' },
{ label: t('Operate'), align: 'center', width: '130', render: 'buttons', buttons: defaultOptButtons(['edit', 'delete']) },
{
label: t('Operate'),
align: 'center',
width: '80',
render: 'buttons',
buttons: defaultOptButtons(['edit', 'delete']),
fixed: 'right',
},
],
},
{
@@ -165,6 +179,13 @@ const menuRuleTreeUpdate = () => {
provide('baTable', baTable)
function formatRatePercent(row: anyObj, _column: any, cellValue: number | string | null) {
if (cellValue === null || cellValue === undefined || cellValue === '') {
return '0%'
}
return `${cellValue}%`
}
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()

View File

@@ -49,6 +49,23 @@
:placeholder="t('Please input field', { field: t('auth.group.Group name') })"
></el-input>
</el-form-item>
<FormItem
:label="t('auth.group.commission_rate')"
v-model="baTable.form.items!.commission_rate"
type="number"
prop="commission_rate"
:input-attr="{ step: 0.01, precision: 2, min: 0, max: 100, disabled: shouldDisableCommissionRate() }"
:placeholder="t('Please input field', { field: t('auth.group.commission_rate') })"
/>
<el-alert class="commission-rate-alert" :title="t('auth.group.commission_rate_desc_title')" type="info" :closable="false" show-icon>
<template #default>
<ul class="commission-rate-desc-list">
<li>{{ t('auth.group.commission_rate_desc_1') }}</li>
<li>{{ t('auth.group.commission_rate_desc_2') }}</li>
<li>{{ t('auth.group.commission_rate_desc_3') }}</li>
</ul>
</template>
</el-alert>
<el-form-item prop="auth" :label="t('auth.group.jurisdiction')">
<el-tree
ref="treeRef"
@@ -94,16 +111,40 @@ import type { ElTree, FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import type Node from 'element-plus/es/components/tree/src/model/node'
import { useConfig } from '/@/stores/config'
import { useAdminInfo } from '/@/stores/adminInfo'
const config = useConfig()
const adminInfo = useAdminInfo()
const formRef = useTemplateRef('formRef')
const treeRef = useTemplateRef('treeRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const shouldDisableCommissionRate = () => {
return false
}
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
name: [buildValidatorData({ name: 'required', title: t('auth.group.Group name') })],
commission_rate: [
{
required: true,
validator: (_rule: any, val: number | string, callback: Function) => {
if (shouldDisableCommissionRate()) {
return callback()
}
const strVal = String(val ?? '').trim()
if (!strVal) {
return callback(new Error(t('Please input field', { field: t('auth.group.commission_rate') })))
}
if (!/^(100(\.00?)?|[0-9]{1,2}(\.[0-9]{1,2})?)$/.test(strVal)) {
return callback(new Error(t('auth.admin.Commission rate must be between 0 and 100 with up to 2 decimals')))
}
return callback()
},
trigger: 'blur',
},
],
auth: [
{
required: true,
@@ -153,6 +194,16 @@ defineExpose({
</script>
<style scoped lang="scss">
.commission-rate-alert {
margin-bottom: 12px;
}
.commission-rate-desc-list {
margin: 6px 0 0;
padding-left: 18px;
line-height: 1.6;
}
:deep(.penultimate-node) {
.el-tree-node__children {
padding-left: 60px;

View File

@@ -68,9 +68,10 @@ const baTable = new baTableClass(
{
label: t('Operate'),
align: 'center',
width: '130',
width: '120',
render: 'buttons',
buttons: defaultOptButtons(),
fixed: 'right',
},
],
dragSortLimitField: 'pid',

View File

@@ -0,0 +1,467 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('channel.quick Search Fields') })"
></TableHeader>
<Table ref="tableRef"></Table>
<PopupForm />
<el-dialog class="ba-operate-dialog" :close-on-click-modal="false" :model-value="manualSettle.visible" @close="closeManualSettleDialog">
<template #header>
<div class="title">{{ t('channel.manual_settle') }}</div>
</template>
<div v-loading="manualSettle.previewLoading" class="manual-settle-dialog-body">
<el-form :model="manualSettle.form" label-width="140px">
<el-form-item :label="t('channel.manual_settle_settlement_no')">
<el-input v-model="manualSettle.form.settlement_no" readonly />
</el-form-item>
<el-form-item :label="t('channel.manual_settle_period_start')">
<el-input v-model="manualSettle.form.period_start_at" readonly />
</el-form-item>
<el-form-item :label="t('channel.manual_settle_period_end')">
<el-input v-model="manualSettle.form.period_end_at" readonly />
</el-form-item>
<el-form-item :label="t('channel.manual_settle_total_bet')">
<el-input v-model="manualSettle.form.total_bet_amount" readonly />
</el-form-item>
<el-form-item :label="t('channel.manual_settle_total_payout')">
<el-input v-model="manualSettle.form.total_payout_amount" readonly />
</el-form-item>
<el-form-item :label="t('channel.manual_settle_platform_profit')">
<el-input v-model="manualSettle.form.platform_profit_amount" readonly />
</el-form-item>
<el-form-item :label="t('channel.manual_settle_commission_rate')">
<el-input v-model="manualSettle.form.commission_rate" readonly />
</el-form-item>
<el-form-item :label="t('channel.manual_settle_calc_base')">
<el-input v-model="manualSettle.form.calc_base_amount" readonly />
</el-form-item>
<el-form-item :label="t('channel.manual_settle_commission_amount')">
<el-input v-model="manualSettle.form.commission_amount" readonly />
</el-form-item>
<el-form-item :label="t('channel.manual_settle_remark')">
<el-input v-model="manualSettle.form.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button @click="closeManualSettleDialog">{{ t('Cancel') }}</el-button>
<el-button type="primary" :disabled="manualSettle.previewLoading" :loading="manualSettle.loading" @click="submitManualSettle">
{{ t('Save') }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { auth } from '/@/utils/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'
import createAxios from '/@/utils/axios'
defineOptions({
name: 'channel',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
let optButtons: OptButton[] = [
{
render: 'tipButton',
name: 'manualSettle',
title: 'channel.manual_settle',
text: '',
type: 'warning',
icon: 'el-icon-Clock',
class: 'table-row-manual-settle',
disabledTip: false,
display: () => auth('manualSettle'),
click: (row: TableRow) => {
void openManualSettleDialog(row)
},
},
]
optButtons = optButtons.concat(defaultOptButtons(['edit', 'delete']))
const formatRatePercent = (_row: any, _column: any, cellValue: number | string | null) => {
if (cellValue === null || cellValue === undefined || cellValue === '') return '-'
const num = Number(cellValue)
if (Number.isNaN(num)) return '-'
return `${num.toFixed(2)}%`
}
const formatAmountInt = (_row: any, _column: any, cellValue: number | string | null) => {
if (cellValue === null || cellValue === undefined || cellValue === '') return '-'
const num = Number(cellValue)
if (Number.isNaN(num)) return '-'
return `${num}`
}
const formatSettleDay = (row: anyObj) => {
if (row.settle_cycle === 'weekly') {
return t(`channel.weekday ${row.settle_weekday ?? 1}`)
}
if (row.settle_cycle === 'monthly') {
return `${row.settle_monthday ?? 1}${t('channel.day_suffix')}`
}
return t('channel.settle_day_daily')
}
const manualSettle = reactive({
visible: false,
loading: false,
previewLoading: false,
channelId: 0,
form: {
settlement_no: '',
period_start_at: '',
period_end_at: '',
total_bet_amount: '',
total_payout_amount: '',
platform_profit_amount: '',
commission_rate: '',
calc_base_amount: '',
commission_amount: '',
remark: '',
},
})
const resetManualSettleForm = () => {
manualSettle.form.settlement_no = ''
manualSettle.form.period_start_at = ''
manualSettle.form.period_end_at = ''
manualSettle.form.total_bet_amount = ''
manualSettle.form.total_payout_amount = ''
manualSettle.form.platform_profit_amount = ''
manualSettle.form.commission_rate = ''
manualSettle.form.calc_base_amount = ''
manualSettle.form.commission_amount = ''
manualSettle.form.remark = ''
}
const closeManualSettleDialog = () => {
manualSettle.visible = false
resetManualSettleForm()
}
const openManualSettleDialog = async (row: TableRow) => {
manualSettle.channelId = row.id
resetManualSettleForm()
manualSettle.visible = true
manualSettle.previewLoading = true
try {
const res = await createAxios(
{
url: '/admin/channel/manualSettlePreview',
method: 'get',
params: { id: row.id },
},
{ showErrorMessage: true }
)
if (res.code !== 1 || !res.data) {
manualSettle.visible = false
return
}
const d = res.data as anyObj
manualSettle.form.settlement_no = d.settlement_no ?? ''
manualSettle.form.period_start_at = d.period_start_at ?? ''
manualSettle.form.period_end_at = d.period_end_at ?? ''
manualSettle.form.total_bet_amount = d.total_bet_amount ?? ''
manualSettle.form.total_payout_amount = d.total_payout_amount ?? ''
manualSettle.form.platform_profit_amount = d.platform_profit_amount ?? ''
manualSettle.form.commission_rate = d.commission_rate ?? ''
manualSettle.form.calc_base_amount = d.calc_base_amount ?? ''
manualSettle.form.commission_amount = d.commission_amount ?? ''
manualSettle.form.remark = `${t('channel.manual_settle')}-CH${row.id}`
} catch {
manualSettle.visible = false
} finally {
manualSettle.previewLoading = false
}
}
const submitManualSettle = async () => {
if (!manualSettle.channelId) return
manualSettle.loading = true
try {
await createAxios(
{
url: '/admin/channel/manualSettle',
method: 'post',
data: {
id: manualSettle.channelId,
remark: manualSettle.form.remark,
},
},
{ showSuccessMessage: true }
)
closeManualSettleDialog()
baTable.onTableHeaderAction('refresh', { event: 'manual-settle' })
} finally {
manualSettle.loading = false
}
}
const baTable = new baTableClass(
new baTableApi('/admin/channel/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('channel.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{
label: t('channel.code'),
prop: 'code',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('channel.name'),
prop: 'name',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('channel.agent_mode'),
prop: 'agent_mode',
align: 'center',
minWidth: 120,
operator: 'eq',
sortable: false,
render: 'tag',
custom: {
turnover: 'primary',
affiliate: 'success',
},
replaceValue: {
turnover: t('channel.agent_mode turnover'),
affiliate: t('channel.agent_mode affiliate'),
},
},
{
label: t('channel.turnover_share_rate'),
prop: 'turnover_share_rate',
align: 'center',
minWidth: 110,
sortable: false,
operator: 'RANGE',
formatter: formatRatePercent,
},
{
label: t('channel.affiliate_share_rate'),
prop: 'affiliate_share_rate',
align: 'center',
minWidth: 110,
sortable: false,
operator: 'RANGE',
formatter: formatRatePercent,
},
{
label: t('channel.affiliate_fee_rate'),
prop: 'affiliate_fee_rate',
align: 'center',
minWidth: 140,
sortable: false,
operator: 'RANGE',
formatter: formatRatePercent,
},
{
label: t('channel.carryover_balance'),
prop: 'carryover_balance',
align: 'center',
minWidth: 130,
sortable: false,
operator: 'RANGE',
formatter: formatAmountInt,
},
{
label: t('channel.affiliate_contract_no'),
prop: 'affiliate_contract_no',
align: 'center',
minWidth: 140,
sortable: false,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{
label: t('channel.settle_cycle'),
prop: 'settle_cycle',
align: 'center',
width: 110,
operator: 'eq',
sortable: false,
render: 'tag',
custom: { daily: 'info', weekly: 'primary', monthly: 'success' },
replaceValue: {
daily: t('channel.settle_cycle daily'),
weekly: t('channel.settle_cycle weekly'),
monthly: t('channel.settle_cycle monthly'),
},
},
{
label: t('channel.settle_weekday'),
prop: 'settle_day',
align: 'center',
width: 110,
operator: false,
sortable: false,
formatter: (row: anyObj) => formatSettleDay(row),
},
{
label: t('channel.settle_time'),
prop: 'settle_time',
align: 'center',
width: 100,
operator: 'LIKE',
sortable: false,
},
{
label: t('channel.affiliate_effective_start_at'),
prop: 'affiliate_effective_start_at',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('channel.affiliate_effective_end_at'),
prop: 'affiliate_effective_end_at',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('channel.user_count'),
prop: 'user_count',
align: 'center',
sortable: false,
operator: 'RANGE',
},
{
label: t('channel.profit_amount'),
prop: 'profit_amount',
align: 'center',
minWidth: 110,
sortable: false,
operator: 'RANGE',
formatter: formatAmountInt,
},
{
label: t('channel.total_profit_amount'),
prop: 'total_profit_amount',
align: 'center',
minWidth: 110,
sortable: false,
operator: 'RANGE',
formatter: formatAmountInt,
},
{
label: t('channel.commission_pool_amount'),
prop: 'commission_pool_amount',
align: 'center',
minWidth: 110,
sortable: false,
operator: 'RANGE',
formatter: formatAmountInt,
},
{
label: t('channel.status'),
prop: 'status',
align: 'center',
operator: 'eq',
sortable: false,
render: 'switch',
replaceValue: { '0': t('channel.status 0'), '1': t('channel.status 1') },
},
{
label: t('channel.admingroup__name'),
prop: 'adminGroup.name',
align: 'center',
minWidth: 110,
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
operator: 'LIKE',
comSearchRender: 'string',
},
{
label: t('channel.admin__username'),
prop: 'admin.username',
align: 'center',
minWidth: 90,
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
operator: 'LIKE',
comSearchRender: 'string',
},
{
label: t('channel.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('channel.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', width: 120, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
],
dblClickNotEditColumn: [undefined, 'status'],
},
{
defaultItems: {
status: '1',
agent_mode: 'turnover',
settle_cycle: 'weekly',
settle_weekday: 1,
settle_monthday: 1,
settle_time: '02:00:00',
},
}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,578 @@
<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"
>
<FormItem
:label="t('channel.code')"
type="string"
v-model="baTable.form.items!.code"
prop="code"
:placeholder="t('Please input field', { field: t('channel.code') })"
/>
<FormItem
:label="t('channel.name')"
type="string"
v-model="baTable.form.items!.name"
prop="name"
:placeholder="t('Please input field', { field: t('channel.name') })"
/>
<FormItem
:label="t('channel.agent_mode')"
type="radio"
v-model="baTable.form.items!.agent_mode"
prop="agent_mode"
:input-attr="{ content: { turnover: t('channel.agent_mode turnover'), affiliate: t('channel.agent_mode affiliate') } }"
/>
<el-alert
:title="currentAgentMode === 'turnover' ? t('channel.agent_mode_title_turnover') : t('channel.agent_mode_title_affiliate')"
type="info"
show-icon
:closable="false"
class="agent-mode-alert"
>
<template #default>
<ul class="agent-mode-desc-list">
<li v-for="item in currentAgentModeDescList" :key="item">{{ item }}</li>
</ul>
</template>
</el-alert>
<FormItem
v-if="currentAgentMode === 'turnover'"
:label="t('channel.turnover_share_rate')"
type="number"
v-model="baTable.form.items!.turnover_share_rate"
prop="turnover_share_rate"
:input-attr="{ step: 0.01, precision: 2, min: 0, max: 100 }"
:placeholder="`${t('Please input field', { field: t('channel.turnover_share_rate') })} (例如 30.5)`"
/>
<FormItem
v-if="currentAgentMode === 'affiliate'"
:label="t('channel.affiliate_contract_no')"
type="string"
v-model="baTable.form.items!.affiliate_contract_no"
prop="affiliate_contract_no"
:placeholder="t('Please input field', { field: t('channel.affiliate_contract_no') })"
/>
<FormItem
v-if="currentAgentMode === 'affiliate'"
:label="t('channel.affiliate_contract_name')"
type="string"
v-model="baTable.form.items!.affiliate_contract_name"
prop="affiliate_contract_name"
:placeholder="t('Please input field', { field: t('channel.affiliate_contract_name') })"
/>
<FormItem
v-if="currentAgentMode === 'affiliate'"
:label="t('channel.affiliate_share_rate')"
type="number"
v-model="baTable.form.items!.affiliate_share_rate"
prop="affiliate_share_rate"
:input-attr="{ step: 0.000001, precision: 6, min: 0, max: 1 }"
:placeholder="`${t('Please input field', { field: t('channel.affiliate_share_rate') })} (例如 0.240000)`"
/>
<FormItem
v-if="currentAgentMode === 'affiliate'"
:label="t('channel.affiliate_fee_rate')"
type="number"
v-model="baTable.form.items!.affiliate_fee_rate"
prop="affiliate_fee_rate"
:input-attr="{ step: 0.000001, precision: 6, min: 0, max: 1 }"
:placeholder="`${t('Please input field', { field: t('channel.affiliate_fee_rate') })} (例如 0.070000)`"
/>
<el-form-item :label="t('channel.settle_cycle')" prop="settle_plan">
<el-tree-select
v-model="settlePlanValue"
class="w100"
clearable
filterable
:data="settlePlanTree"
:props="settlePlanTreeProps"
:render-after-expand="false"
:placeholder="t('channel.settle_cycle_placeholder')"
@change="onSettlePlanChange"
/>
</el-form-item>
<FormItem :label="t('channel.settle_time')" type="time" v-model="baTable.form.items!.settle_time" prop="settle_time" />
<FormItem
v-if="currentAgentMode === 'affiliate'"
:label="t('channel.affiliate_effective_start_at')"
type="datetime"
v-model="baTable.form.items!.affiliate_effective_start_at"
prop="affiliate_effective_start_at"
/>
<FormItem
v-if="currentAgentMode === 'affiliate'"
:label="t('channel.affiliate_effective_end_at')"
type="datetime"
v-model="baTable.form.items!.affiliate_effective_end_at"
prop="affiliate_effective_end_at"
/>
<el-form-item v-if="currentAgentMode === 'affiliate'" :label="t('channel.affiliate_ladder_rules')" prop="affiliate_ladder_rules">
<div class="ladder-rule-box">
<div class="ladder-rule-row ladder-rule-head">
<span>{{ t('channel.ladder_min_loss') }}</span>
<span>{{ t('channel.ladder_share_rate') }}</span>
<span>{{ t('Operate') }}</span>
</div>
<div v-for="(item, idx) in ladderRuleList" :key="idx" class="ladder-rule-row">
<el-input-number v-model="item.minLoss" class="w100" :precision="4" :step="0.0001" :min="0" />
<el-input-number v-model="item.shareRate" class="w100" :precision="6" :step="0.000001" :min="0" :max="1" />
<el-button type="danger" link @click="removeLadderRule(idx)">{{ t('Delete') }}</el-button>
</div>
<el-button type="primary" link @click="addLadderRule">{{ t('Add') }}</el-button>
</div>
</el-form-item>
<FormItem
v-if="currentAgentMode === 'affiliate'"
:label="t('channel.carryover_balance')"
type="number"
v-model="baTable.form.items!.carryover_balance"
prop="carryover_balance"
:input-attr="{ step: 0.0001, precision: 4 }"
:placeholder="t('Please input field', { field: t('channel.carryover_balance') })"
/>
<FormItem
:label="t('channel.status')"
type="switch"
v-model="baTable.form.items!.status"
prop="status"
:input-attr="{ content: { '0': t('channel.status 0'), '1': t('channel.status 1') } }"
/>
<FormItem
:label="t('channel.remark')"
type="textarea"
v-model="baTable.form.items!.remark"
prop="remark"
:input-attr="{ rows: 3 }"
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
:placeholder="t('Please input field', { field: t('channel.remark') })"
/>
<el-alert type="info" :closable="false" show-icon class="channel-admin-tree-tip">
{{ t('channel.admin_tree_tip') }}
</el-alert>
<el-form-item :label="t('channel.admin_id')" prop="admin_id">
<el-tree-select
v-model="baTable.form.items!.admin_id"
class="w100"
clearable
filterable
:data="channelAdminTree"
:props="channelAdminTreeProps"
:render-after-expand="false"
:placeholder="t('channel.admin_id_placeholder')"
/>
</el-form-item>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { ElMessage } from 'element-plus'
import { computed, inject, nextTick, onMounted, reactive, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
import createAxios from '/@/utils/axios'
import { buildValidatorData } from '/@/utils/validate'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
type ChannelAdminTreeNode = {
value: string
label: string
disabled?: boolean
children?: ChannelAdminTreeNode[]
channel_id?: number
is_leaf?: boolean
}
const channelAdminTree = ref<ChannelAdminTreeNode[]>([])
const channelAdminTreeProps = {
value: 'value',
label: 'label',
children: 'children',
disabled: 'disabled',
}
type LadderRuleRow = { minLoss: number; shareRate: number }
const ladderRuleList = ref<LadderRuleRow[]>([])
const settlePlanTree = computed(() => [
{ value: 'daily', label: t('channel.settle_cycle daily'), children: [] },
{
value: 'weekly',
label: t('channel.settle_cycle weekly'),
disabled: true,
children: [1, 2, 3, 4, 5, 6, 7].map((n) => ({ value: `weekly:${n}`, label: t(`channel.weekday ${n}`), is_leaf: true })),
},
{
value: 'monthly',
label: t('channel.settle_cycle monthly'),
disabled: true,
children: Array.from({ length: 31 }).map((_, i) => {
const day = i + 1
return { value: `monthly:${day}`, label: `${day}${t('channel.day_suffix')}`, is_leaf: true }
}),
},
])
const settlePlanTreeProps = { value: 'value', label: 'label', children: 'children', disabled: 'disabled' }
const settlePlanValue = ref<string | null>(null)
const syncSettlePlanToItems = () => {
const items = baTable.form.items
if (!items) return
;(items as any).settle_plan = settlePlanValue.value ?? ''
}
const syncSettlePlanFromItems = () => {
const items = baTable.form.items
if (!items) return
const cycle = items.settle_cycle || 'weekly'
if (cycle === 'daily') {
settlePlanValue.value = 'daily'
syncSettlePlanToItems()
return
}
if (cycle === 'monthly') {
settlePlanValue.value = `monthly:${items.settle_monthday || 1}`
syncSettlePlanToItems()
return
}
settlePlanValue.value = `weekly:${items.settle_weekday || 1}`
syncSettlePlanToItems()
}
watch(settlePlanValue, (val) => {
if (!baTable.form.items) return
if (!val) {
syncSettlePlanToItems()
return
}
if (val === 'daily') {
baTable.form.items.settle_cycle = 'daily'
baTable.form.items.settle_weekday = 1
baTable.form.items.settle_monthday = 1
return
}
if (val.startsWith('weekly:')) {
const day = parseInt(val.split(':')[1] || '1', 10)
baTable.form.items.settle_cycle = 'weekly'
baTable.form.items.settle_weekday = Number.isNaN(day) ? 1 : day
baTable.form.items.settle_monthday = 1
return
}
if (val.startsWith('monthly:')) {
const day = parseInt(val.split(':')[1] || '1', 10)
baTable.form.items.settle_cycle = 'monthly'
baTable.form.items.settle_monthday = Number.isNaN(day) ? 1 : day
baTable.form.items.settle_weekday = 1
}
syncSettlePlanToItems()
})
const onSettlePlanChange = () => {
nextTick(() => {
formRef.value?.validateField('settle_plan')
})
}
const loadChannelAdminTree = async () => {
const res = await createAxios({
url: '/admin/channel/adminTree',
method: 'get',
})
channelAdminTree.value = (res.data?.list ?? []) as ChannelAdminTreeNode[]
}
const currentAgentMode = computed(() => baTable.form.items?.agent_mode ?? 'turnover')
const currentAgentModeDescList = computed(() => {
if (currentAgentMode.value === 'turnover') {
return [t('channel.agent_mode_desc_turnover_1'), t('channel.agent_mode_desc_turnover_2'), t('channel.agent_mode_desc_turnover_3')]
}
return [t('channel.agent_mode_desc_affiliate_1'), t('channel.agent_mode_desc_affiliate_2'), t('channel.agent_mode_desc_affiliate_3')]
})
const normalizeLadderRulesToText = (val: unknown): string | null => {
if (val === null || val === undefined || val === '') {
return null
}
if (typeof val === 'string') {
return val
}
try {
return JSON.stringify(val, null, 2)
} catch {
return String(val)
}
}
const parseLadderRulesText = (val: unknown): unknown => {
if (val === null || val === undefined) {
return null
}
if (typeof val !== 'string') {
return val
}
const text = val.trim()
if (!text) {
return null
}
try {
return JSON.parse(text)
} catch {
return text
}
}
const syncLadderRuleRowsFromItems = () => {
const items = baTable.form.items
if (!items) return
const raw = parseLadderRulesText(items.affiliate_ladder_rules)
if (!Array.isArray(raw)) {
ladderRuleList.value = []
return
}
ladderRuleList.value = raw
.map((r: any) => ({
minLoss: Number(r.minLoss ?? r.min_loss ?? 0),
shareRate: Number(r.shareRate ?? r.share_rate ?? 0),
}))
.filter((r: LadderRuleRow) => Number.isFinite(r.minLoss) && Number.isFinite(r.shareRate))
}
const addLadderRule = () => {
ladderRuleList.value.push({ minLoss: 0, shareRate: 0 })
}
const removeLadderRule = (idx: number) => {
ladderRuleList.value.splice(idx, 1)
}
onMounted(() => {
loadChannelAdminTree()
syncSettlePlanFromItems()
})
watch(
() => baTable.form.operate,
(op) => {
if (op === 'Add' || op === 'Edit') {
loadChannelAdminTree()
}
}
)
watch(
() => [baTable.form.operate, baTable.form.items?.id] as const,
() => {
const items = baTable.form.items
if (!items || baTable.form.operate !== 'Edit') {
return
}
if (items.admin_id !== undefined && items.admin_id !== null && items.admin_id !== '') {
items.admin_id = String(items.admin_id) as any
}
items.affiliate_ladder_rules = normalizeLadderRulesToText(items.affiliate_ladder_rules) as any
syncSettlePlanFromItems()
syncLadderRuleRowsFromItems()
}
)
watch(
() => baTable.form.items?.agent_mode,
(mode) => {
if (!baTable.form.items) {
return
}
if (mode === 'turnover') {
baTable.form.items.affiliate_share_rate = null
baTable.form.items.affiliate_fee_rate = null
baTable.form.items.carryover_balance = 0
baTable.form.items.affiliate_contract_no = null
baTable.form.items.affiliate_contract_name = null
baTable.form.items.affiliate_ladder_rules = null
baTable.form.items.affiliate_effective_start_at = null
baTable.form.items.affiliate_effective_end_at = null
ladderRuleList.value = []
return
}
if (mode === 'affiliate') {
baTable.form.items.turnover_share_rate = null
if (!baTable.form.items.settle_cycle) {
baTable.form.items.settle_cycle = 'weekly'
}
syncLadderRuleRowsFromItems()
}
syncSettlePlanFromItems()
}
)
const turnoverRateValidator = (label: string) => {
return (_rule: any, value: string | number | null, callback: (error?: Error) => void) => {
if (currentAgentMode.value !== 'turnover') {
callback()
return
}
if (value === null || value === undefined || value === '') {
callback()
return
}
const str = String(value).trim()
if (!/^\d+(\.\d{1,6})?$/.test(str)) {
callback(new Error(`${label}仅支持最多六位小数`))
return
}
const num = Number(str)
if (Number.isNaN(num) || num < 0 || num > 100) {
callback(new Error(`${label}必须在0到100之间`))
return
}
callback()
}
}
const settlePlanValidator = (_rule: unknown, _value: unknown, callback: (error?: Error) => void) => {
const v = settlePlanValue.value
if (v !== null && v !== undefined && v !== '') {
callback()
return
}
callback(new Error(t('Please input field', { field: t('channel.settle_cycle') })))
}
const affiliateRateValidator = (label: string) => {
return (_rule: any, value: string | number | null, callback: (error?: Error) => void) => {
if (currentAgentMode.value !== 'affiliate') {
callback()
return
}
if (value === null || value === undefined || value === '') {
callback()
return
}
const str = String(value).trim()
if (!/^\d+(\.\d{1,6})?$/.test(str)) {
callback(new Error(`${label}仅支持最多六位小数`))
return
}
const num = Number(str)
if (Number.isNaN(num) || num < 0 || num > 1) {
callback(new Error(`${label}必须在0到1之间`))
return
}
callback()
}
}
baTable.before.onSubmit = ({ items }) => {
if (!items) {
return
}
if (Object.prototype.hasOwnProperty.call(items, 'settle_plan')) {
delete (items as any).settle_plan
}
if (items.agent_mode === 'affiliate') {
if (ladderRuleList.value.length === 0) {
ElMessage.error(t('channel.ladder_rule_required'))
return false
}
const sorted = [...ladderRuleList.value].sort((a, b) => a.minLoss - b.minLoss)
for (let i = 0; i < sorted.length; i++) {
const row = sorted[i]
if (!Number.isFinite(row.minLoss) || row.minLoss < 0) {
ElMessage.error(t('channel.ladder_min_loss_invalid'))
return false
}
if (!Number.isFinite(row.shareRate) || row.shareRate < 0 || row.shareRate > 1) {
ElMessage.error(t('channel.ladder_share_rate_invalid'))
return false
}
if (i > 0 && row.minLoss <= sorted[i - 1].minLoss) {
ElMessage.error(t('channel.ladder_min_loss_order_invalid'))
return false
}
}
items.affiliate_ladder_rules = sorted.map((r) => ({
minLoss: Number(r.minLoss).toFixed(4),
shareRate: Number(r.shareRate).toFixed(6),
}))
} else {
items.affiliate_ladder_rules = null
}
items.settle_cycle = items.settle_cycle || 'weekly'
items.settle_weekday = items.settle_weekday || 1
items.settle_monthday = items.settle_monthday || 1
items.settle_time = items.settle_time || '02:00:00'
}
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
code: [buildValidatorData({ name: 'required', title: t('channel.code') })],
name: [buildValidatorData({ name: 'required', title: t('channel.name') })],
agent_mode: [buildValidatorData({ name: 'required', title: t('channel.agent_mode') })],
turnover_share_rate: [{ validator: turnoverRateValidator(t('channel.turnover_share_rate')), trigger: 'blur' }],
affiliate_share_rate: [{ validator: affiliateRateValidator(t('channel.affiliate_share_rate')), trigger: 'blur' }],
affiliate_fee_rate: [{ validator: affiliateRateValidator(t('channel.affiliate_fee_rate')), trigger: 'blur' }],
settle_plan: [{ validator: settlePlanValidator, trigger: ['change', 'blur'] }],
settle_time: [buildValidatorData({ name: 'required', title: t('channel.settle_time') })],
affiliate_effective_start_at: [buildValidatorData({ name: 'required', title: t('channel.affiliate_effective_start_at') })],
carryover_balance: [buildValidatorData({ name: 'number', title: t('channel.carryover_balance') })],
admin_id: [buildValidatorData({ name: 'required', title: t('channel.admin_id') })],
})
</script>
<style scoped lang="scss">
.agent-mode-alert {
margin-bottom: 12px;
}
.agent-mode-desc-list {
margin: 6px 0 0;
padding-left: 18px;
line-height: 1.6;
}
.channel-admin-tree-tip {
margin-bottom: 12px;
}
</style>

View File

@@ -0,0 +1,133 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('config.gameConfig.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/gameConfig',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
const baTable = new baTableClass(
new baTableApi('/admin/config.GameConfig/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('config.gameConfig.id'), prop: 'id', align: 'center', width: 80, operator: 'RANGE', sortable: 'custom' },
{
label: t('config.gameConfig.config_key'),
prop: 'config_key',
align: 'center',
minWidth: 200,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
showOverflowTooltip: true,
},
{
label: t('config.gameConfig.config_value'),
prop: 'config_value',
align: 'center',
minWidth: 160,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
showOverflowTooltip: true,
},
{
label: t('config.gameConfig.value_type'),
prop: 'value_type',
align: 'center',
width: 110,
operator: 'eq',
render: 'tag',
custom: {
string: 'primary',
int: 'success',
decimal: 'warning',
json: 'info',
},
replaceValue: {
string: t('config.gameConfig.value_type string'),
int: t('config.gameConfig.value_type int'),
decimal: t('config.gameConfig.value_type decimal'),
json: t('config.gameConfig.value_type json'),
},
},
{
label: t('config.gameConfig.remark'),
prop: 'remark',
align: 'center',
minWidth: 200,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
showOverflowTooltip: true,
},
{
label: t('config.gameConfig.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('config.gameConfig.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 170,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', width: 80, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: { value_type: 'string', remark: '' },
}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,107 @@
<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"
>
<FormItem
:label="t('config.gameConfig.config_key')"
type="string"
v-model="baTable.form.items!.config_key"
prop="config_key"
:placeholder="t('Please input field', { field: t('config.gameConfig.config_key') })"
:input-attr="{ disabled: baTable.form.operate === 'Edit' }"
/>
<FormItem
:label="t('config.gameConfig.config_value')"
type="textarea"
v-model="baTable.form.items!.config_value"
prop="config_value"
:input-attr="{ rows: 5 }"
:placeholder="t('Please input field', { field: t('config.gameConfig.config_value') })"
/>
<FormItem
:label="t('config.gameConfig.value_type')"
type="radio"
v-model="baTable.form.items!.value_type"
prop="value_type"
:input-attr="{
content: {
string: t('config.gameConfig.value_type string'),
int: t('config.gameConfig.value_type int'),
decimal: t('config.gameConfig.value_type decimal'),
json: t('config.gameConfig.value_type json'),
},
}"
/>
<FormItem
:label="t('config.gameConfig.remark')"
type="textarea"
v-model="baTable.form.items!.remark"
prop="remark"
:input-attr="{ rows: 2 }"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
config_key: [buildRequired('config.gameConfig.config_key')],
value_type: [buildRequired('config.gameConfig.value_type')],
})
function buildRequired(langKey: string): FormItemRule {
return {
required: true,
message: t('Please input field', { field: t(langKey) }),
}
}
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,104 @@
<template>
<div class="default-main ba-table-box zi-hua-dict-page">
<el-alert type="info" :closable="false" show-icon>
{{ t('config.ziHuaDictionary.desc') }}
</el-alert>
<div class="toolbar">
<el-button type="primary" :loading="saving" :disabled="loading" @click="onSave">
{{ t('config.ziHuaDictionary.btn_save') }}
</el-button>
</div>
<el-table v-loading="loading" border stripe :data="items" row-key="no" max-height="640">
<el-table-column prop="no" :label="t('config.ziHuaDictionary.no')" width="72" align="center" />
<el-table-column :label="t('config.ziHuaDictionary.name')" min-width="120">
<template #default="{ row }">
<el-input v-model="row.name" maxlength="32" show-word-limit />
</template>
</el-table-column>
<el-table-column :label="t('config.ziHuaDictionary.category')" min-width="160">
<template #default="{ row }">
<el-select v-model="row.category" style="width: 100%">
<el-option v-for="c in categories" :key="c" :label="categoryLabel(c)" :value="c" />
</el-select>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import createAxios from '/@/utils/axios'
import { auth } from '/@/utils/common'
defineOptions({
name: 'config/ziHuaDictionary',
})
const { t } = useI18n()
type Item = { no: number; name: string; category: string }
const loading = ref(false)
const saving = ref(false)
const items = ref<Item[]>([])
const categories = ref<string[]>(['zodiac', 'beast', 'fowl', 'vermin', 'divine'])
function categoryLabel(cat: string): string {
return t('config.ziHuaDictionary.category_label.' + cat)
}
async function load() {
loading.value = true
try {
const res = await createAxios({
url: '/admin/config.ZiHuaDictionary/index',
method: 'get',
})
if (res.code === 1 && res.data) {
const list = res.data.items as Item[]
items.value = Array.isArray(list) ? list : []
if (Array.isArray(res.data.categories) && res.data.categories.length) {
categories.value = res.data.categories as string[]
}
}
} finally {
loading.value = false
}
}
async function onSave() {
if (!auth('save')) {
return
}
saving.value = true
try {
await createAxios({
url: '/admin/config.ZiHuaDictionary/save',
method: 'post',
data: { items: items.value },
showSuccessMessage: true,
})
await load()
} finally {
saving.value = false
}
}
onMounted(() => {
void load()
})
</script>
<style scoped lang="scss">
.zi-hua-dict-page {
.toolbar {
margin: 12px 0;
}
}
</style>

View File

@@ -385,13 +385,14 @@ const baTable = new baTableClass(
align: 'center',
render: 'tag',
sortable: false,
effect: 'dark',
replaceValue: {
delete: t('crud.log.status delete'),
success: t('crud.log.status success'),
error: t('crud.log.status error'),
start: t('crud.log.status start'),
},
custom: { delete: 'danger', success: 'success', error: 'warning', start: '' },
custom: { delete: 'danger', success: 'success', error: 'warning', start: 'info' },
},
{
label: t('crud.log.create_time'),

View File

@@ -1,185 +0,0 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<!-- 自定义按钮请使用插槽甚至公共搜索也可以使用具名插槽渲染参见文档 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('game.channel.quick Search Fields') })"
></TableHeader>
<!-- 表格 -->
<!-- 表格列有多种自定义渲染方式比如自定义组件具名插槽等参见文档 -->
<!-- 要使用 el-table 组件原有的属性直接加在 Table 标签上即可 -->
<Table ref="tableRef"></Table>
<!-- 表单 -->
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessageBox } from 'element-plus'
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'
import createAxios from '/@/utils/axios'
import { adminBaseRoutePath } from '/@/router/static/adminBase'
defineOptions({
name: 'game/channel',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
/**
* baTable 内包含了表格的所有数据且数据具备响应性,然后通过 provide 注入给了后代组件
*/
const channelApiBase = `${adminBaseRoutePath}/game.Channel/`
const baTable = new baTableClass(
new baTableApi(channelApiBase),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('game.channel.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{
label: t('game.channel.code'),
prop: 'code',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('game.channel.name'),
prop: 'name',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{ label: t('game.channel.user_count'), prop: 'user_count', align: 'center', sortable: false, operator: 'RANGE' },
{ label: t('game.channel.profit_amount'), prop: 'profit_amount', align: 'center', sortable: false, operator: 'RANGE' },
{
label: t('game.channel.status'),
prop: 'status',
align: 'center',
operator: 'eq',
sortable: false,
render: 'switch',
replaceValue: { '0': t('game.channel.status 0'), '1': t('game.channel.status 1') },
},
{
label: t('game.channel.admingroup__name'),
prop: 'adminGroup.name',
align: 'center',
minWidth: 110,
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
operator: 'LIKE',
comSearchRender: 'string',
},
{
label: t('game.channel.admin__username'),
prop: 'admin.username',
align: 'center',
minWidth: 90,
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
operator: 'LIKE',
comSearchRender: 'string',
},
{
label: t('game.channel.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('game.channel.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', width: 80, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
],
dblClickNotEditColumn: [undefined, 'status'],
},
{
defaultItems: { status: '1' },
}
)
provide('baTable', baTable)
baTable.postDel = (ids: string[]) => {
if (baTable.runBefore('postDel', { ids }) === false) return
void (async () => {
try {
const stats = await createAxios(
{
url: channelApiBase + 'index',
method: 'get',
params: { delete_related_counts: '1', ids },
},
{ showSuccessMessage: false }
)
const counts = stats.data as { game_config_count?: number; game_user_count?: number }
const countConfig = Number(counts?.game_config_count ?? 0)
const countUser = Number(counts?.game_user_count ?? 0)
await ElMessageBox.confirm(
t('game.channel.delete_confirm_related', { countConfig, countUser }),
t('game.channel.delete_confirm_title'),
{
type: 'warning',
confirmButtonText: t('Confirm'),
cancelButtonText: t('Cancel'),
}
)
const delRes = await createAxios(
{
url: channelApiBase + 'del',
method: 'DELETE',
params: { ids, confirm_cascade: 1 },
},
{ showSuccessMessage: true }
)
baTable.onTableHeaderAction('refresh', { event: 'delete', ids })
baTable.runAfter('postDel', { res: delRes })
} catch {
// 用户取消或接口失败axios 已提示)
}
})()
}
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -1,93 +0,0 @@
<template>
<div v-if="isGameWeight && weightTagLabels.length" class="game-config-value-tags">
<el-tag
v-for="(label, idx) in weightTagLabels"
:key="idx"
class="m-4"
effect="light"
type="primary"
size="default"
>
{{ label }}
</el-tag>
</div>
<span v-else class="game-config-value-plain">{{ plainText }}</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
renderRow: TableRow
renderField: TableColumn
renderValue: unknown
renderColumn: import('element-plus').TableColumnCtx<TableRow>
renderIndex: number
}>()
const isGameWeight = computed(() => props.renderRow?.group === 'game_weight')
/**
* value 形如 [{"T1":"5"},{"T2":"20"},...] 或同结构的 JSON 字符串
*/
function parseWeightTagLabels(raw: unknown): string[] {
if (raw === null || raw === undefined || raw === '') {
return []
}
let arr: unknown[] = []
if (typeof raw === 'string') {
const s = raw.trim()
if (!s) return []
try {
const parsed = JSON.parse(s)
arr = Array.isArray(parsed) ? parsed : []
} catch {
return []
}
} else if (Array.isArray(raw)) {
arr = raw
} else {
return []
}
const labels: string[] = []
for (const item of arr) {
if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
for (const [k, v] of Object.entries(item as Record<string, unknown>)) {
labels.push(`${k}:${String(v)}`)
}
}
}
return labels
}
const weightTagLabels = computed(() => parseWeightTagLabels(props.renderValue))
const plainText = computed(() => {
const v = props.renderValue
if (v === null || v === undefined) return ''
if (typeof v === 'object') {
try {
return JSON.stringify(v)
} catch {
return String(v)
}
}
return String(v)
})
</script>
<style scoped lang="scss">
.game-config-value-tags {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 4px 0;
}
.m-4 {
margin: 4px;
}
.game-config-value-plain {
word-break: break-all;
}
</style>

View File

@@ -2,19 +2,13 @@
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<!-- 自定义按钮请使用插槽甚至公共搜索也可以使用具名插槽渲染参见文档 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('game.config.quick Search Fields') })"
></TableHeader>
<!-- 表格 -->
<!-- 表格列有多种自定义渲染方式比如自定义组件具名插槽等参见文档 -->
<!-- 要使用 el-table 组件原有的属性直接加在 Table 标签上即可 -->
<Table ref="tableRef"></Table>
<!-- 表单 -->
<PopupForm />
</div>
</template>
@@ -23,7 +17,6 @@
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import GameConfigValueCell from './GameConfigValueCell.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
@@ -38,85 +31,60 @@ const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
/**
* baTable 内包含了表格的所有数据且数据具备响应性,然后通过 provide 注入给了后代组件
*/
const baTable = new baTableClass(
new baTableApi('/admin/game.Config/'),
{
pk: 'ID',
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('game.config.ID'), prop: 'ID', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{ label: t('game.config.id'), prop: 'id', align: 'center', width: 80, operator: 'RANGE', sortable: 'custom' },
{
label: t('game.config.channel_id'),
prop: 'channel_id',
label: t('game.config.config_key'),
prop: 'config_key',
align: 'center',
show: false,
width: 88,
operator: 'RANGE',
sortable: 'custom',
},
{
label: t('game.config.channel__name'),
prop: 'channel.name',
align: 'center',
minWidth: 100,
sortable: 'true',
minWidth: 200,
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
operator: 'LIKE',
comSearchRender: 'string',
showOverflowTooltip: true,
},
{
label: t('game.config.group'),
prop: 'group',
label: t('game.config.config_value'),
prop: 'config_value',
align: 'center',
minWidth: 160,
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
showOverflowTooltip: true,
},
{
label: t('game.config.name'),
prop: 'name',
label: t('game.config.value_type'),
prop: 'value_type',
align: 'center',
minWidth: 180,
operatorPlaceholder: t('Fuzzy query'),
width: 110,
operator: 'eq',
render: 'tag',
sortable: false,
operator: 'LIKE',
custom: {
string: 'primary',
int: 'success',
decimal: 'warning',
json: 'info',
},
replaceValue: {
string: t('game.config.value_type string'),
int: t('game.config.value_type int'),
decimal: t('game.config.value_type decimal'),
json: t('game.config.value_type json'),
},
},
{
label: t('game.config.title'),
prop: 'title',
label: t('game.config.remark'),
prop: 'remark',
align: 'center',
minWidth: 85,
minWidth: 200,
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
showOverflowTooltip: true,
},
{
label: t('game.config.value'),
prop: 'value',
align: 'center',
minWidth: 220,
sortable: false,
operator: 'LIKE',
comSearchRender: 'string',
render: 'customRender',
customRender: GameConfigValueCell,
},
{
label: t('game.config.instantiation'),
prop: 'instantiation',
align: 'center',
custom: { 0: 'error', 1: 'primary' },
operator: 'RANGE',
sortable: false,
render: 'tag',
replaceValue: { '0': t('game.config.instantiation 0'), '1': t('game.config.instantiation 1') },
},
{ label: t('game.config.sort'), prop: 'sort', align: 'center', sortable: false, operator: 'RANGE' },
{
label: t('game.config.create_time'),
prop: 'create_time',
@@ -125,7 +93,7 @@ const baTable = new baTableClass(
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
width: 170,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
@@ -136,16 +104,15 @@ const baTable = new baTableClass(
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
width: 170,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false },
{ label: t('Operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
],
dblClickNotEditColumn: [undefined, 'instantiation'],
defaultOrder: { prop: 'channel_id', order: 'desc' },
dblClickNotEditColumn: [undefined],
},
{
defaultItems: { sort: 100 },
defaultItems: { value_type: 'string', remark: '' },
}
)

View File

@@ -1,7 +1,4 @@
<template>
<!-- 对话框表单 -->
<!-- 建议使用 Prettier 格式化代码 -->
<!-- el-form 内可以混用 el-form-itemFormItemba-input 等输入组件 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
@@ -30,91 +27,41 @@
:rules="rules"
>
<FormItem
:label="t('game.config.channel_id')"
type="remoteSelect"
v-model="baTable.form.items!.channel_id"
prop="channel_id"
:input-attr="{ ...channelRemoteAttr, disabled: metaFieldsDisabled }"
:placeholder="t('Please select field', { field: t('game.config.channel_id') })"
/>
<FormItem
:label="t('game.config.group')"
type="select"
v-model="baTable.form.items!.group"
prop="group"
:input-attr="{ content: groupSelectContentFiltered, disabled: metaFieldsDisabled }"
:placeholder="t('Please select field', { field: t('game.config.group') })"
/>
<FormItem
:label="t('game.config.name')"
type="select"
v-model="baTable.form.items!.name"
prop="name"
:input-attr="{ content: nameSelectContent, disabled: metaFieldsDisabled }"
:placeholder="t('Please select field', { field: t('game.config.name') })"
/>
<FormItem
:label="t('game.config.title')"
:label="t('game.config.config_key')"
type="string"
v-model="baTable.form.items!.title"
prop="title"
:input-attr="{ disabled: metaFieldsDisabled }"
:placeholder="t('Please input field', { field: t('game.config.title') })"
v-model="baTable.form.items!.config_key"
prop="config_key"
:placeholder="t('Please input field', { field: t('game.config.config_key') })"
:input-attr="{ disabled: baTable.form.operate === 'Edit' }"
/>
<!-- game_weight数组形式编辑存库仍为 JSON 字符串 -->
<el-form-item v-if="isGameWeight" :label="t('game.config.value')" prop="value">
<div class="weight-value-editor">
<div v-for="(row, idx) in weightRows" :key="idx" class="weight-value-row">
<el-input
v-model="row.key"
class="weight-key"
:readonly="weightKeyReadonly"
:clearable="!weightKeyReadonly"
:placeholder="t('Please input field', { field: t('game.config.weight key') })"
@input="onWeightRowChange"
/>
<span class="weight-sep">:</span>
<el-input
v-model="row.val"
class="weight-val"
:placeholder="t('Please input field', { field: t('game.config.weight value') })"
clearable
:disabled="isDefaultBigwinWeight && isBigwinDiceLockedKey(row.key)"
@input="onWeightRowChange"
/>
<el-button v-if="canEditWeightStructure" type="danger" link @click="removeWeightRow(idx)">
{{ t('Delete') }}
</el-button>
</div>
<el-button v-if="canEditWeightStructure" type="primary" link @click="addWeightRow">{{ t('Add') }}</el-button>
<div v-if="isDefaultBigwinWeight" class="form-help">{{ t('game.config.default_bigwin_weight_help') }}</div>
</div>
</el-form-item>
<FormItem
v-else
:label="t('game.config.value')"
:label="t('game.config.config_value')"
type="textarea"
v-model="baTable.form.items!.value"
prop="value"
:input-attr="{ rows: 3 }"
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
:placeholder="t('Please input field', { field: t('game.config.value') })"
v-model="baTable.form.items!.config_value"
prop="config_value"
:input-attr="{ rows: 5 }"
:placeholder="t('Please input field', { field: t('game.config.config_value') })"
/>
<FormItem
:label="t('game.config.sort')"
type="number"
v-model="baTable.form.items!.sort"
prop="sort"
:input-attr="{ step: 1 }"
:placeholder="t('Please input field', { field: t('game.config.sort') })"
:label="t('game.config.value_type')"
type="radio"
v-model="baTable.form.items!.value_type"
prop="value_type"
:input-attr="{
content: {
string: t('game.config.value_type string'),
int: t('game.config.value_type int'),
decimal: t('game.config.value_type decimal'),
json: t('game.config.value_type json'),
},
}"
/>
<FormItem
:label="t('game.config.instantiation')"
type="switch"
v-model="baTable.form.items!.instantiation"
prop="instantiation"
:input-attr="{ content: { '0': t('game.config.instantiation 0'), '1': t('game.config.instantiation 1') } }"
:label="t('game.config.remark')"
type="textarea"
v-model="baTable.form.items!.remark"
prop="remark"
:input-attr="{ rows: 2 }"
/>
</el-form>
</div>
@@ -132,395 +79,28 @@
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { computed, inject, reactive, ref, useTemplateRef, watch } from 'vue'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import { useAdminInfo } from '/@/stores/adminInfo'
import type baTableClass from '/@/utils/baTable'
import { buildValidatorData } from '/@/utils/validate'
import {
fixedRowsFromKeys,
getFixedKeysForGameConfigName,
isBigwinDiceLockedKey,
jsonStringFromFixedKeys,
normalizeGameWeightConfigName,
parseWeightJsonToMap,
rowsToMap,
weightRowsMatchBigwinDiceKeys,
type WeightRow,
} from '/@/utils/gameWeightFixed'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const adminInfo = useAdminInfo()
const { t } = useI18n()
const isSuperAdmin = computed(() => adminInfo.super === true)
/** 编辑且非超级管理员:渠道、分组、配置标识、配置名称不可改 */
const metaFieldsDisabled = computed(() => !isSuperAdmin.value && baTable.form.operate === 'Edit')
const channelRemoteAttr = {
pk: 'game_channel.id',
field: 'name',
remoteUrl: '/admin/game.Channel/index',
}
const groupSelectBase = {
game_config: 'game_config',
game_weight: 'game_weight',
}
/** 非超级管理员新增时不可选 game_weight需先由超级管理员建好键结构 */
const groupSelectContentFiltered = computed(() => {
if (!isSuperAdmin.value && baTable.form.operate === 'Add') {
return { game_config: groupSelectBase.game_config }
}
return groupSelectBase
})
/** default_tier_weight / default_bigwin_weight及 default_kill_score_weight键固定仅值可改不可增删行 */
const isFixedGameWeightConfig = computed(() => getFixedKeysForGameConfigName(baTable.form.items?.name) !== null)
/** game_weight编辑或非超管时键只读仅超管新增非固定项时可增删行、改键 */
const weightKeyReadonly = computed(() => {
if (!isGameWeight.value) return false
if (isFixedGameWeightConfig.value) return true
if (baTable.form.operate === 'Edit') return true
return !isSuperAdmin.value
})
const canEditWeightStructure = computed(
() => isGameWeight.value && baTable.form.operate === 'Add' && isSuperAdmin.value && !isFixedGameWeightConfig.value
)
/** 默认大奖权重:仅校验每项整数与 0100005/30 固定 10000不参与 tier/kill 的「和≤100」 */
const isDefaultBigwinWeight = computed(() => normalizeGameWeightConfigName(baTable.form.items?.name) === 'default_bigwin_weight')
const weightRows = ref<WeightRow[]>([{ key: '', val: '' }])
/** default_tier_weight / default_kill_score_weight每项≤100且权重之和必须=100 */
const WEIGHT_SUM100_NAMES = ['default_tier_weight', 'default_kill_score_weight']
/** 配置标识:按分组限定可选项;编辑时若库中旧值不在列表中则临时追加一条 */
const nameSelectContent = computed((): Record<string, string> => {
const g = baTable.form.items?.group
let base: Record<string, string> = {}
if (g === 'game_config') {
base = {
game_rule: t('game.config.name opt game_rule'),
game_rule_en: t('game.config.name opt game_rule_en'),
}
} else if (g === 'game_weight') {
base = {
default_tier_weight: t('game.config.name opt default_tier_weight'),
default_kill_score_weight: t('game.config.name opt default_kill_score_weight'),
default_bigwin_weight: t('game.config.name opt default_bigwin_weight'),
}
}
const n = baTable.form.items?.name
if (typeof n === 'string' && n.trim() !== '' && base[n] === undefined) {
const norm = normalizeGameWeightConfigName(n)
if (norm !== '' && base[norm] !== undefined) {
return { ...base, [n]: base[norm] }
}
return { ...base, [n]: n }
}
return base
})
const isGameWeight = computed(() => baTable.form.items?.group === 'game_weight')
function parseValueToWeightRows(raw: unknown): WeightRow[] {
if (raw === null || raw === undefined || raw === '') {
return [{ key: '', val: '' }]
}
if (typeof raw === 'string') {
const s = raw.trim()
if (!s) return [{ key: '', val: '' }]
try {
const parsed = JSON.parse(s)
return arrayToWeightRows(parsed)
} catch {
return [{ key: '', val: '' }]
}
}
if (Array.isArray(raw)) {
return arrayToWeightRows(raw)
}
return [{ key: '', val: '' }]
}
function arrayToWeightRows(arr: unknown): WeightRow[] {
if (!Array.isArray(arr)) {
return [{ key: '', val: '' }]
}
const out: WeightRow[] = []
for (const item of arr) {
if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
for (const [k, v] of Object.entries(item)) {
out.push({ key: k, val: v === null || v === undefined ? '' : String(v) })
}
}
}
return out.length ? out : [{ key: '', val: '' }]
}
function weightRowsToJsonString(rows: WeightRow[]): string {
const pairs: Record<string, string>[] = []
for (const r of rows) {
const k = r.key.trim()
if (k === '') continue
const one: Record<string, string> = {}
one[k] = r.val
pairs.push(one)
}
return JSON.stringify(pairs)
}
function syncWeightRowsToFormValue() {
const items = baTable.form.items
if (!items) return
const fixedKeys = getFixedKeysForGameConfigName(items.name)
if (fixedKeys) {
const map = rowsToMap(weightRows.value)
items.value = jsonStringFromFixedKeys(fixedKeys, map)
return
}
items.value = weightRowsToJsonString(weightRows.value)
}
function enforceDefaultBigwinLockedValues() {
if (normalizeGameWeightConfigName(baTable.form.items?.name) !== 'default_bigwin_weight') {
return
}
for (const r of weightRows.value) {
if (isBigwinDiceLockedKey(r.key)) {
r.val = '10000'
}
}
}
function onWeightRowChange() {
if (isGameWeight.value) {
enforceDefaultBigwinLockedValues()
syncWeightRowsToFormValue()
}
}
function addWeightRow() {
if (!canEditWeightStructure.value) return
weightRows.value.push({ key: '', val: '' })
syncWeightRowsToFormValue()
}
function removeWeightRow(idx: number) {
if (!canEditWeightStructure.value) return
if (weightRows.value.length <= 1) {
weightRows.value = [{ key: '', val: '' }]
} else {
weightRows.value.splice(idx, 1)
}
syncWeightRowsToFormValue()
}
function hydrateWeightRowsFromForm() {
if (!isGameWeight.value) return
const fixedKeys = getFixedKeysForGameConfigName(baTable.form.items?.name)
if (fixedKeys) {
const map = parseWeightJsonToMap(baTable.form.items?.value)
weightRows.value = fixedRowsFromKeys(fixedKeys, map)
enforceDefaultBigwinLockedValues()
syncWeightRowsToFormValue()
return
}
weightRows.value = parseValueToWeightRows(baTable.form.items?.value)
}
watch(isGameWeight, (gw) => {
if (gw) {
hydrateWeightRowsFromForm()
}
})
watch(
() => baTable.form.loading,
(loading) => {
if (loading === false && baTable.form.items?.group === 'game_weight') {
hydrateWeightRowsFromForm()
}
}
)
watch(
() => baTable.form.items?.group,
() => {
if (baTable.form.items?.group === 'game_weight') {
hydrateWeightRowsFromForm()
}
const items = baTable.form.items
if (!items || !isSuperAdmin.value) {
return
}
const c = nameSelectContent.value
const keys = Object.keys(c)
if (keys.length === 0) {
return
}
if (typeof items.name !== 'string' || items.name === '' || c[items.name] === undefined) {
items.name = keys[0]
}
}
)
watch(
() => baTable.form.items?.name,
() => {
if (baTable.form.items?.group === 'game_weight') {
hydrateWeightRowsFromForm()
}
}
)
function validateGameWeightRules(): string | undefined {
if (baTable.form.items?.group !== 'game_weight') {
return undefined
}
const configName = normalizeGameWeightConfigName(baTable.form.items?.name)
const fixedKeys = getFixedKeysForGameConfigName(configName)
const nums: number[] = []
if (fixedKeys) {
const map = rowsToMap(weightRows.value)
if (configName === 'default_bigwin_weight') {
for (const k of fixedKeys) {
const vs = (map[k] ?? '').trim()
if (vs === '') {
return t('Please input field', { field: t('game.config.weight value') })
}
const n = Number(vs)
if (!Number.isFinite(n)) {
return t('game.config.weight value numeric')
}
if (isBigwinDiceLockedKey(k)) {
if (n !== 10000) {
return t('game.config.bigwin weight locked 5 30')
}
} else if (n < 0 || n > 10000) {
return t('game.config.bigwin weight each 0 10000')
}
nums.push(n)
}
} else {
for (const k of fixedKeys) {
const vs = (map[k] ?? '').trim()
if (vs === '') {
return t('Please input field', { field: t('game.config.weight value') })
}
const n = Number(vs)
if (!Number.isFinite(n)) {
return t('game.config.weight value numeric')
}
if (n > 100) {
return t('game.config.weight each max 100')
}
nums.push(n)
}
}
} else {
// 非固定键名但行结构已是 530 骰子时,按大奖 010000 校验(避免 name 格式异常时误走每项≤10000
const treatAsBigwin = configName === 'default_bigwin_weight' || weightRowsMatchBigwinDiceKeys(weightRows.value)
for (const r of weightRows.value) {
const k = r.key.trim()
if (k === '') continue
const vs = r.val.trim()
if (vs === '') {
return t('Please input field', { field: t('game.config.weight value') })
}
const n = Number(vs)
if (!Number.isFinite(n)) {
return t('game.config.weight value numeric')
}
if (treatAsBigwin) {
if (isBigwinDiceLockedKey(k)) {
if (n !== 10000) {
return t('game.config.bigwin weight locked 5 30')
}
} else if (n < 0 || n > 10000) {
return t('game.config.bigwin weight each 0 10000')
}
} else if (n > 10000) {
return t('game.config.weight each max 10000')
}
nums.push(n)
}
if (nums.length === 0) {
return t('Please input field', { field: t('game.config.value') })
}
}
if (WEIGHT_SUM100_NAMES.includes(configName)) {
let sum = 0
for (const x of nums) {
sum += x
}
if (Math.abs(sum - 100) > 0.000001) {
return t('game.config.weight sum must 100')
}
}
return undefined
}
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
group: [buildValidatorData({ name: 'required', title: t('game.config.group') })],
name: [buildValidatorData({ name: 'required', title: t('game.config.name') })],
title: [buildValidatorData({ name: 'required', title: t('game.config.title') })],
sort: [buildValidatorData({ name: 'number', title: t('game.config.sort') })],
instantiation: [buildValidatorData({ name: 'number', title: t('game.config.instantiation') })],
value: [
{
validator: (_rule, _val, callback) => {
const err = validateGameWeightRules()
if (err) {
callback(new Error(err))
return
}
callback()
},
trigger: ['blur', 'change'],
},
],
create_time: [buildValidatorData({ name: 'date', title: t('game.config.create_time') })],
update_time: [buildValidatorData({ name: 'date', title: t('game.config.update_time') })],
config_key: [buildRequired('game.config.config_key')],
value_type: [buildRequired('game.config.value_type')],
})
function buildRequired(langKey: string): FormItemRule {
return {
required: true,
message: t('Please input field', { field: t(langKey) }),
}
}
</script>
<style scoped lang="scss">
.weight-value-editor {
width: 100%;
}
.weight-value-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.weight-key {
max-width: 140px;
}
.weight-val {
flex: 1;
min-width: 80px;
}
.weight-sep {
flex-shrink: 0;
color: var(--el-text-color-secondary);
}
.form-help {
margin-top: 8px;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
}
</style>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,303 @@
<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 />
<el-card v-if="canSettings" class="period-settings-card" shadow="never">
<template #header>
<span>{{ t('game.period.section_auto') }}</span>
</template>
<div v-loading="settingsLoading" class="period-settings-body">
<div class="period-setting-row">
<span class="period-setting-label">{{ t('game.period.auto_create_label') }}</span>
<el-switch v-model="autoCreate" :disabled="settingsSaving" @change="onSwitchChange" />
<span class="period-setting-tip">{{ t('game.period.auto_create_tip') }}</span>
</div>
<div class="period-setting-row">
<span class="period-setting-label">{{ t('game.period.manual_create_label') }}</span>
<el-switch v-model="manualCreate" :disabled="settingsSaving" @change="onSwitchChange" />
<span class="period-setting-tip">{{ t('game.period.manual_create_tip') }}</span>
</div>
<div v-if="canManual" class="period-setting-actions">
<el-button type="primary" :loading="createLoading" @click="onCreateNextManual">
{{ t('game.period.btn_create_next') }}
</el-button>
</div>
</div>
</el-card>
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('game.period.quick Search Fields') })"
></TableHeader>
<Table ref="tableRef"></Table>
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, provide, ref, 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 createAxios from '/@/utils/axios'
import baTableClass from '/@/utils/baTable'
import { auth } from '/@/utils/common'
defineOptions({
name: 'game/period',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
const settingsLoading = ref(false)
const settingsSaving = ref(false)
const createLoading = ref(false)
const autoCreate = ref(false)
const manualCreate = ref(false)
/** 避免初次拉取开关值时触发保存 */
const settingsReady = ref(false)
const canSettings = computed(() => auth('periodSettings'))
const canManual = computed(() => auth('createNextManual'))
const baTable = new baTableClass(
new baTableApi('/admin/game.Period/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('game.period.id'), prop: 'id', align: 'center', width: 100, operator: 'RANGE', sortable: 'custom' },
{
label: t('game.period.period_no'),
prop: 'period_no',
align: 'center',
minWidth: 180,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
},
{
label: t('game.period.period_start_at'),
prop: 'period_start_at',
align: 'center',
width: 170,
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('game.period.status'),
prop: 'status',
align: 'center',
width: 110,
operator: 'eq',
render: 'tag',
effect: 'dark',
custom: {
'0': 'success',
'1': 'warning',
'2': 'info',
'3': 'primary',
'4': 'warning',
'5': 'danger',
},
replaceValue: {
'0': t('game.period.status 0'),
'1': t('game.period.status 1'),
'2': t('game.period.status 2'),
'3': t('game.period.status 3'),
'4': t('game.period.status 4'),
'5': t('game.period.status 5'),
},
},
{
label: t('game.period.draw_mode'),
prop: 'draw_mode',
align: 'center',
width: 110,
operator: 'eq',
render: 'tag',
custom: {
'0': 'info',
'1': 'warning',
},
replaceValue: {
'0': t('game.period.draw_mode 0'),
'1': t('game.period.draw_mode 1'),
},
},
{
label: t('game.period.preset_number'),
prop: 'preset_number',
align: 'center',
width: 100,
operator: 'RANGE',
},
{
label: t('game.period.result_number'),
prop: 'result_number',
align: 'center',
width: 100,
operator: 'RANGE',
},
{
label: t('game.period.void_reason'),
prop: 'void_reason',
align: 'center',
minWidth: 140,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
showOverflowTooltip: true,
},
{
label: t('game.period.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('game.period.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 170,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: {
status: 0,
draw_mode: 0,
void_reason: '',
},
}
)
provide('baTable', baTable)
async function loadPeriodSettings() {
if (!canSettings.value) {
return
}
settingsLoading.value = true
try {
const res = await createAxios({
url: '/admin/game.Period/periodSettings',
method: 'get',
showCodeMessage: false,
})
if (res.code === 1 && res.data) {
autoCreate.value = res.data.period_auto_create_enabled === 1
manualCreate.value = res.data.period_manual_create_enabled === 1
settingsReady.value = true
}
} catch {
// 无权限或接口异常时不打断列表
} finally {
settingsLoading.value = false
}
}
async function onSaveSettings() {
if (!canSettings.value) {
return
}
settingsSaving.value = true
try {
await createAxios({
url: '/admin/game.Period/periodSettings',
method: 'post',
data: {
period_auto_create_enabled: autoCreate.value ? 1 : 0,
period_manual_create_enabled: manualCreate.value ? 1 : 0,
},
showSuccessMessage: true,
})
} finally {
settingsSaving.value = false
}
}
function onSwitchChange() {
if (!settingsReady.value) {
return
}
void onSaveSettings()
}
async function onCreateNextManual() {
if (!canManual.value) {
return
}
createLoading.value = true
try {
await createAxios({
url: '/admin/game.Period/createNextManual',
method: 'post',
showSuccessMessage: true,
})
await baTable.getData()
} finally {
createLoading.value = false
}
}
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
void loadPeriodSettings()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss">
.period-settings-card {
margin-bottom: 12px;
}
.period-settings-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.period-setting-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.period-setting-label {
min-width: 160px;
font-weight: 500;
}
.period-setting-tip {
color: var(--el-text-color-secondary);
font-size: 13px;
flex: 1;
min-width: 200px;
}
.period-setting-actions {
margin-top: 4px;
}
</style>

View File

@@ -1,7 +1,4 @@
<template>
<!-- 对话框表单 -->
<!-- 建议使用 Prettier 格式化代码 -->
<!-- el-form 内可以混用 el-form-itemFormItemba-input 等输入组件 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
@@ -30,44 +27,67 @@
:rules="rules"
>
<FormItem
:label="t('game.channel.code')"
:label="t('game.period.period_no')"
type="string"
v-model="baTable.form.items!.code"
prop="code"
:placeholder="t('Please input field', { field: t('game.channel.code') })"
v-model="baTable.form.items!.period_no"
prop="period_no"
:placeholder="t('Please input field', { field: t('game.period.period_no') })"
/>
<FormItem
:label="t('game.channel.name')"
type="string"
v-model="baTable.form.items!.name"
prop="name"
:placeholder="t('Please input field', { field: t('game.channel.name') })"
:label="t('game.period.period_start_at')"
type="datetime"
v-model="baTable.form.items!.period_start_at"
prop="period_start_at"
:placeholder="t('Please select field', { field: t('game.period.period_start_at') })"
/>
<FormItem
:label="t('game.channel.status')"
type="switch"
:label="t('game.period.status')"
type="radio"
v-model="baTable.form.items!.status"
prop="status"
:input-attr="{ content: { '0': t('game.channel.status 0'), '1': t('game.channel.status 1') } }"
:input-attr="{
content: {
'0': t('game.period.status 0'),
'1': t('game.period.status 1'),
'2': t('game.period.status 2'),
'3': t('game.period.status 3'),
'4': t('game.period.status 4'),
'5': t('game.period.status 5'),
},
}"
/>
<FormItem
:label="t('game.channel.remark')"
:label="t('game.period.draw_mode')"
type="radio"
v-model="baTable.form.items!.draw_mode"
prop="draw_mode"
:input-attr="{
content: {
'0': t('game.period.draw_mode 0'),
'1': t('game.period.draw_mode 1'),
},
}"
/>
<FormItem
:label="t('game.period.preset_number')"
type="number"
v-model="baTable.form.items!.preset_number"
prop="preset_number"
:input-attr="{ step: 1, min: 1, max: 36 }"
/>
<FormItem
:label="t('game.period.result_number')"
type="number"
v-model="baTable.form.items!.result_number"
prop="result_number"
:input-attr="{ step: 1, min: 1, max: 36 }"
/>
<FormItem
:label="t('game.period.void_reason')"
type="textarea"
v-model="baTable.form.items!.remark"
prop="remark"
v-model="baTable.form.items!.void_reason"
prop="void_reason"
:input-attr="{ rows: 3 }"
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
:placeholder="t('Please input field', { field: t('game.channel.remark') })"
/>
<FormItem
v-if="isSuperAdmin"
:label="t('game.channel.admin_id')"
type="remoteSelect"
v-model="baTable.form.items!.admin_id"
prop="admin_id"
:input-attr="{ pk: 'admin.id', field: 'username', remoteUrl: '/admin/auth.Admin/index', params: { top_group: '1' } }"
:placeholder="t('Please select field', { field: t('game.channel.admin_id') })"
/>
</el-form>
</div>
@@ -85,34 +105,27 @@
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { computed, inject, useTemplateRef } from 'vue'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import { useAdminInfo } from '/@/stores/adminInfo'
import type baTableClass from '/@/utils/baTable'
import { buildValidatorData } from '/@/utils/validate'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const adminInfo = useAdminInfo()
const { t } = useI18n()
const isSuperAdmin = computed(() => adminInfo.super === true)
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
period_no: [buildRequired()],
})
const rules = computed<Partial<Record<string, FormItemRule[]>>>(() => ({
code: [buildValidatorData({ name: 'required', title: t('game.channel.code') })],
name: [buildValidatorData({ name: 'required', title: t('game.channel.name') })],
user_count: [buildValidatorData({ name: 'integer', title: t('game.channel.user_count') })],
profit_amount: [buildValidatorData({ name: 'float', title: t('game.channel.profit_amount') })],
admin_id: isSuperAdmin.value
? [buildValidatorData({ name: 'required', title: t('game.channel.admin_id') })]
: [],
create_time: [buildValidatorData({ name: 'date', title: t('game.channel.create_time') })],
update_time: [buildValidatorData({ name: 'date', title: t('game.channel.update_time') })],
}))
function buildRequired(): FormItemRule {
return {
required: true,
message: t('Please input field', { field: t('game.period.period_no') }),
}
}
</script>
<style scoped lang="scss"></style>

View File

@@ -1,892 +0,0 @@
<template>
<div class="default-main ba-table-box">
<el-card shadow="never" class="reward-form-card" v-loading="pageLoading">
<template #header>
<span class="card-title">{{ t('game.rewardConfigForm.title') }}</span>
</template>
<el-alert
v-if="showTemplateIntro"
class="intro-alert"
type="info"
:closable="false"
show-icon
>
<template #title>{{ t('game.rewardConfigForm.intro') }}</template>
</el-alert>
<el-form
ref="formRef"
class="reward-form-body"
@submit.prevent=""
:model="formModel"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="formLabelWidth + 'px'"
:rules="rules"
>
<template v-if="isSuperAdmin">
<el-form-item :label="t('game.rewardConfigForm.super_scope_label')">
<el-radio-group v-model="superEditScope" @change="onSuperScopeChange">
<el-radio-button value="template">{{ t('game.rewardConfigForm.super_scope_template') }}</el-radio-button>
<el-radio-button value="channel">{{ t('game.rewardConfigForm.super_scope_channel') }}</el-radio-button>
</el-radio-group>
</el-form-item>
<el-alert
v-if="superEditScope === 'channel'"
class="scope-hint"
type="warning"
:closable="false"
:title="t('game.rewardConfigForm.super_scope_hint')"
/>
<div v-if="superEditScope === 'channel'" class="channel-bar">
<div class="channel-picker">
<FormItem
:label="t('game.rewardConfig.game_channel_id')"
type="remoteSelect"
v-model="formModel.game_channel_id"
prop="game_channel_id"
:input-attr="channelRemoteAttr"
:placeholder="t('Please select field', { field: t('game.rewardConfig.game_channel_id') })"
/>
</div>
<el-button type="primary" @click="loadData" :disabled="!formModel.game_channel_id">{{ t('Refresh') }}</el-button>
</div>
</template>
<template v-if="showInner">
<el-form-item :label="t('game.rewardConfig.tier_reward_form')" prop="tier_reward_form">
<div class="block-editor tier-editor">
<div class="line line-head">
<span>{{ t('game.rewardConfig.grid_number') }}</span>
<span>{{ t('game.rewardConfig.ui_text') }}</span>
<span>{{ t('game.rewardConfig.ui_text_en') }}</span>
<span>{{ t('game.rewardConfig.real_ev') }}</span>
<span>{{ t('game.rewardConfig.tier') }}</span>
<span>{{ t('game.rewardConfig.remark') }}</span>
</div>
<div v-for="(row, idx) in tierRows" :key="'tier-' + idx" class="line">
<el-input v-model="row.grid_number" disabled />
<el-input
v-model="row.ui_text"
:placeholder="t('Please input field', { field: t('game.rewardConfig.ui_text') })"
@input="syncPayload"
/>
<el-input
v-model="row.ui_text_en"
:placeholder="t('Please input field', { field: t('game.rewardConfig.ui_text_en') })"
@input="syncPayload"
/>
<el-input
v-model="row.real_ev"
:placeholder="t('Please input field', { field: t('game.rewardConfig.real_ev') })"
@input="syncPayload"
/>
<el-select v-model="row.tier" style="width: 100px" @change="syncPayload">
<el-option :label="t('game.rewardConfig.tier_t1')" value="T1" />
<el-option :label="t('game.rewardConfig.tier_t2')" value="T2" />
<el-option :label="t('game.rewardConfig.tier_t3')" value="T3" />
<el-option :label="t('game.rewardConfig.tier_t4')" value="T4" />
<el-option :label="t('game.rewardConfig.tier_t5')" value="T5" />
</el-select>
<el-input
v-model="row.remark"
:placeholder="t('Please input field', { field: t('game.rewardConfig.remark') })"
@input="syncPayload"
/>
</div>
<div class="form-help">{{ t('game.rewardConfig.tier_reward_form_help') }}</div>
</div>
</el-form-item>
<el-form-item :label="t('game.rewardConfig.bigwin_form')" prop="bigwin_form">
<div class="block-editor bigwin-editor">
<div class="line line-head">
<span>{{ t('game.rewardConfig.grid_number') }}</span>
<span>{{ t('game.rewardConfig.ui_text') }}</span>
<span>{{ t('game.rewardConfig.real_ev') }}</span>
<span>{{ t('game.rewardConfig.tier') }}</span>
</div>
<div v-for="(row, idx) in bigwinRows" :key="'bigwin-' + idx" class="line">
<el-input v-model="row.grid_number" disabled />
<el-input
v-model="row.ui_text"
:placeholder="t('Please input field', { field: t('game.rewardConfig.ui_text') })"
@input="syncPayload"
/>
<el-input
v-model="row.real_ev"
:placeholder="t('Please input field', { field: t('game.rewardConfig.real_ev') })"
@input="syncPayload"
/>
<el-input v-model="row.tier" disabled style="width: 120px" />
</div>
<div class="form-help">{{ t('game.rewardConfig.bigwin_form_help') }}</div>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitLoading" @click="onSubmit">{{ t('game.rewardConfigForm.btn_add') }}</el-button>
<el-button @click="onReset">{{ t('game.rewardConfigForm.btn_reset') }}</el-button>
<el-button v-auth="'generateTierBoard'" @click="openGenTierDialog">{{ t('game.rewardConfigForm.btn_gen_tier') }}</el-button>
<el-button v-auth="'generateRewardWeight'" :loading="genWeightSubmitting" @click="onGenWeightClick">{{
t('game.rewardConfigForm.btn_gen_weight')
}}</el-button>
</el-form-item>
</template>
</el-form>
</el-card>
<el-dialog
v-model="genTierDialogVisible"
class="gen-tier-dialog"
:title="t('game.rewardConfigForm.gen_tier_title')"
:width="genTierDialogWidth"
:fullscreen="genTierDialogFullscreen"
:align-center="!genTierDialogFullscreen"
append-to-body
:close-on-click-modal="false"
destroy-on-close
>
<el-scrollbar :max-height="genTierScrollMaxHeight" class="gen-tier-scroll">
<div class="gen-rule">{{ t('game.rewardConfigForm.gen_tier_rule') }}</div>
<el-form
:model="genTierForm"
class="gen-tier-form"
:label-position="genTierFormLabelPosition"
:label-width="genTierFormLabelWidth"
>
<div class="gen-tier-block">
<div class="gen-tier-block-title">{{ t('game.rewardConfigForm.gen_t1_label') }}</div>
<el-form-item :label="t('game.rewardConfigForm.gen_t1_fixed') + '' + t('game.rewardConfigForm.gen_dir_cw') + ''">
<el-input-number v-model="genTierForm.t1_fixed_cw" :min="0" :max="26" :step="1" controls-position="right" />
</el-form-item>
<el-form-item :label="t('game.rewardConfigForm.gen_t1_fixed') + '' + t('game.rewardConfigForm.gen_dir_ccw') + ''">
<el-input-number v-model="genTierForm.t1_fixed_ccw" :min="0" :max="26" :step="1" controls-position="right" />
</el-form-item>
<el-form-item :label="t('game.rewardConfigForm.gen_settlement')">
<el-input-number v-model="genTierForm.amt_t1" :min="0" :step="1" controls-position="right" />
</el-form-item>
</div>
<div class="gen-tier-block">
<div class="gen-tier-block-title">{{ t('game.rewardConfigForm.gen_t2_label') }}</div>
<el-form-item :label="t('game.rewardConfigForm.gen_t2_min') + '' + t('game.rewardConfigForm.gen_dir_cw') + ''">
<el-input-number v-model="genTierForm.t2_min_cw" :min="0" :max="26" :step="1" controls-position="right" />
</el-form-item>
<el-form-item :label="t('game.rewardConfigForm.gen_t2_min') + '' + t('game.rewardConfigForm.gen_dir_ccw') + ''">
<el-input-number v-model="genTierForm.t2_min_ccw" :min="0" :max="26" :step="1" controls-position="right" />
</el-form-item>
<el-form-item :label="t('game.rewardConfigForm.gen_settlement')">
<el-input-number v-model="genTierForm.amt_t2" :min="0" :precision="2" :step="0.1" controls-position="right" />
</el-form-item>
</div>
<div class="gen-tier-block">
<div class="gen-tier-block-title">{{ t('game.rewardConfigForm.gen_t3_label') }}</div>
<el-form-item :label="t('game.rewardConfigForm.gen_t3_amt_only')">
<el-input-number v-model="genTierForm.amt_t3" :min="0" :precision="2" :step="0.1" controls-position="right" />
</el-form-item>
</div>
<div class="gen-tier-block">
<div class="gen-tier-block-title">{{ t('game.rewardConfigForm.gen_t4_label') }}</div>
<el-form-item :label="t('game.rewardConfigForm.gen_t4_fixed') + '' + t('game.rewardConfigForm.gen_dir_cw') + ''">
<el-input-number v-model="genTierForm.t4_fixed_cw" :min="0" :max="26" :step="1" controls-position="right" />
</el-form-item>
<el-form-item :label="t('game.rewardConfigForm.gen_t4_fixed') + '' + t('game.rewardConfigForm.gen_dir_ccw') + ''">
<el-input-number v-model="genTierForm.t4_fixed_ccw" :min="0" :max="26" :step="1" controls-position="right" />
</el-form-item>
<el-form-item :label="t('game.rewardConfigForm.gen_settlement')">
<el-input-number v-model="genTierForm.amt_t4" :precision="2" :step="0.1" controls-position="right" />
</el-form-item>
</div>
<div class="gen-tier-block">
<div class="gen-tier-block-title">{{ t('game.rewardConfigForm.gen_t5_label') }}</div>
<el-form-item :label="t('game.rewardConfigForm.gen_t5_fixed') + '' + t('game.rewardConfigForm.gen_dir_cw') + ''">
<el-input-number v-model="genTierForm.t5_fixed_cw" :min="0" :max="26" :step="1" controls-position="right" />
</el-form-item>
<el-form-item :label="t('game.rewardConfigForm.gen_t5_fixed') + '' + t('game.rewardConfigForm.gen_dir_ccw') + ''">
<el-input-number v-model="genTierForm.t5_fixed_ccw" :min="0" :max="26" :step="1" controls-position="right" />
</el-form-item>
<el-form-item :label="t('game.rewardConfigForm.gen_settlement')">
<el-input-number :model-value="0" disabled />
</el-form-item>
</div>
</el-form>
<div class="gen-tier-footer-hint">{{ t('game.rewardConfigForm.gen_tier_footer_hint') }}</div>
</el-scrollbar>
<template #footer>
<div class="gen-tier-dialog-footer">
<el-button @click="genTierDialogVisible = false">{{ t('game.rewardConfigForm.gen_tier_cancel') }}</el-button>
<el-button v-auth="'generateTierBoard'" type="primary" :loading="genTierSubmitting" @click="onGenTierSubmit">{{
t('game.rewardConfigForm.gen_tier_submit')
}}</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import type { FormInstance, FormItemRule } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useWindowSize } from '@vueuse/core'
import { computed, onMounted, reactive, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import { useAdminInfo } from '/@/stores/adminInfo'
import createAxios from '/@/utils/axios'
defineOptions({
name: 'game/rewardConfig',
})
type RewardRow = {
grid_number: string
ui_text: string
ui_text_en: string
real_ev: string
tier: string
remark: string
}
const { t } = useI18n()
const config = useConfig()
const adminInfo = useAdminInfo()
const { width: windowWidth } = useWindowSize()
/** 生成弹窗:窄屏全屏/宽百分比,宽屏固定最大宽度 */
const genTierDialogFullscreen = computed(() => windowWidth.value <= 520)
const genTierDialogWidth = computed(() => {
if (windowWidth.value <= 520) {
return '100%'
}
if (windowWidth.value <= 768) {
return '92%'
}
return '720px'
})
const genTierFormLabelPosition = computed(() => (windowWidth.value < 640 ? 'top' : 'right'))
const genTierFormLabelWidth = computed(() => (windowWidth.value < 640 ? 'auto' : '150px'))
const genTierScrollMaxHeight = computed(() => (genTierDialogFullscreen.value ? 'calc(100vh - 140px)' : 'min(70vh, 640px)'))
const formRef = useTemplateRef<FormInstance>('formRef')
const TIER_GRIDS = Array.from({ length: 26 }, (_v, i) => String(i + 5))
const BIGWIN_GRIDS = ['5', '10', '15', '20', '25', '30']
const isSuperAdmin = computed(() => adminInfo.super === true)
const formLabelWidth = computed(() => (config.layout.shrink ? 100 : 140))
const channelRemoteAttr = { pk: 'game_channel.id', field: 'name', remoteUrl: '/admin/game.Channel/index' }
const pageLoading = ref(false)
const submitLoading = ref(false)
const genTierDialogVisible = ref(false)
const genTierSubmitting = ref(false)
const genWeightSubmitting = ref(false)
const channelChangeSilent = ref(false)
const lastAutoLoadChannelId = ref<number | null>(null)
const genTierForm = reactive({
t1_fixed_cw: 3,
t1_fixed_ccw: 3,
t2_min_cw: 5,
t2_min_ccw: 5,
t4_fixed_cw: 1,
t4_fixed_ccw: 1,
t5_fixed_cw: 1,
t5_fixed_ccw: 1,
amt_t1: 3,
amt_t2: 1.5,
amt_t3: 0.5,
amt_t4: -0.4,
})
/** 超管template = game_channel_id 0 默认模板channel = 编辑指定渠道 */
const superEditScope = ref<'template' | 'channel'>('template')
/** 仅超管维护「全渠道默认模板」game_channel_id=0、未绑定具体渠道时展示顶部说明渠道管理员或超管选「指定渠道」时不展示 */
const showTemplateIntro = computed(() => isSuperAdmin.value && superEditScope.value === 'template')
const formModel = reactive({
id: null as number | string | null,
game_channel_id: 0 as number | string,
tier_reward_form: '',
bigwin_form: '',
})
const tierRows = ref<RewardRow[]>(
TIER_GRIDS.map((g) => ({ grid_number: g, ui_text: '', ui_text_en: '', real_ev: '', tier: 'T1', remark: '' }))
)
const bigwinRows = ref<RewardRow[]>(
BIGWIN_GRIDS.map((g) => ({ grid_number: g, ui_text: '', ui_text_en: '', real_ev: '', tier: 'BIGWIN', remark: '' }))
)
const showInner = computed(() => {
if (!isSuperAdmin.value) {
return true
}
if (superEditScope.value === 'template') {
return true
}
return !!formModel.game_channel_id
})
function parseRows(raw: unknown): RewardRow[] {
if (typeof raw !== 'string' || raw.trim() === '') return []
try {
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
const out: RewardRow[] = []
for (const item of parsed) {
if (item === null || typeof item !== 'object' || Array.isArray(item)) continue
const obj = item as Record<string, unknown>
out.push({
grid_number: String(obj.grid_number ?? ''),
ui_text: String(obj.ui_text ?? ''),
ui_text_en: String(obj.ui_text_en ?? ''),
real_ev: String(obj.real_ev ?? ''),
tier: String(obj.tier ?? ''),
remark: String(obj.remark ?? ''),
})
}
return out
} catch {
return []
}
}
function toTierRows(raw: unknown): RewardRow[] {
const parsed = parseRows(raw)
const map = new Map<string, RewardRow>()
for (const r of parsed) map.set(r.grid_number, r)
return TIER_GRIDS.map((g) => {
const row = map.get(g)
return {
grid_number: g,
ui_text: row?.ui_text ?? '',
ui_text_en: row?.ui_text_en ?? '',
real_ev: row?.real_ev ?? '',
tier: row?.tier && row.tier !== 'BIGWIN' ? row.tier : 'T1',
remark: row?.remark ?? '',
}
})
}
function toBigwinRows(raw: unknown): RewardRow[] {
const parsed = parseRows(raw)
const map = new Map<string, RewardRow>()
for (const r of parsed) map.set(r.grid_number, r)
return BIGWIN_GRIDS.map((g) => {
const row = map.get(g)
return {
grid_number: g,
ui_text: row?.ui_text ?? '',
ui_text_en: row?.ui_text_en ?? '',
real_ev: row?.real_ev ?? '',
tier: 'BIGWIN',
remark: row?.remark ?? '',
}
})
}
function syncPayload() {
formModel.tier_reward_form = JSON.stringify(tierRows.value)
formModel.bigwin_form = JSON.stringify(bigwinRows.value)
}
function applyRowToForm(row: Record<string, unknown>) {
channelChangeSilent.value = true
formModel.id = (row.id as number | string | null | undefined) ?? null
formModel.game_channel_id = (row.game_channel_id as number | string) ?? 0
tierRows.value = toTierRows(row.tier_reward_form)
bigwinRows.value = toBigwinRows(row.bigwin_form)
syncPayload()
channelChangeSilent.value = false
}
function validateForms(): string | undefined {
if (tierRows.value.length !== 26 || bigwinRows.value.length !== 6) {
return t('Parameter error')
}
for (let i = 0; i < tierRows.value.length; i++) {
const row = tierRows.value[i]
if (row.grid_number !== TIER_GRIDS[i]) return t('Parameter error')
if (row.ui_text.trim() === '' || row.real_ev.trim() === '') return t('Please input field', { field: t('game.rewardConfig.ui_text') })
if (!Number.isFinite(Number(row.real_ev))) return t('game.rewardConfig.real_ev')
if (!['T1', 'T2', 'T3', 'T4', 'T5'].includes(row.tier)) return t('Parameter error')
}
for (let i = 0; i < bigwinRows.value.length; i++) {
const row = bigwinRows.value[i]
if (row.grid_number !== BIGWIN_GRIDS[i]) return t('Parameter error')
if (row.tier !== 'BIGWIN') return t('Parameter error')
if (row.ui_text.trim() === '' || row.real_ev.trim() === '') return t('Please input field', { field: t('game.rewardConfig.ui_text') })
if (!Number.isFinite(Number(row.real_ev))) return t('game.rewardConfig.real_ev')
}
return undefined
}
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
game_channel_id: [
{
validator: (_rule, _val, callback) => {
if (!isSuperAdmin.value || superEditScope.value !== 'channel') {
callback()
return
}
if (!_val && _val !== 0) {
callback(new Error(t('Please select field', { field: t('game.rewardConfig.game_channel_id') })))
return
}
if (_val === 0 || _val === '0') {
callback(new Error(t('Please select field', { field: t('game.rewardConfig.game_channel_id') })))
return
}
callback()
},
trigger: ['change', 'blur'],
},
],
tier_reward_form: [
{
validator: (_rule, _val, callback) => {
const err = validateForms()
if (err) return callback(new Error(err))
callback()
},
trigger: ['blur', 'change'],
},
],
bigwin_form: [
{
validator: (_rule, _val, callback) => {
const err = validateForms()
if (err) return callback(new Error(err))
callback()
},
trigger: ['blur', 'change'],
},
],
})
function resolveRequestChannelId(): number | null {
if (!isSuperAdmin.value) {
return null
}
if (superEditScope.value === 'template') {
return 0
}
const v = formModel.game_channel_id
if (!v && v !== 0) return null
const n = Number(v)
if (!Number.isFinite(n) || n <= 0) return null
return n
}
async function loadData() {
if (isSuperAdmin.value && superEditScope.value === 'channel' && !formModel.game_channel_id) {
return
}
pageLoading.value = true
try {
const params: Record<string, number> = {}
const cid = resolveRequestChannelId()
if (cid !== null) {
params.game_channel_id = cid
}
const res = await createAxios<{ row?: Record<string, unknown> }>(
{
url: '/admin/game.RewardConfig/index',
method: 'get',
params,
},
{ showErrorMessage: true, loading: false }
)
const row = res.data?.row
if (row && typeof row === 'object' && !Array.isArray(row)) {
applyRowToForm(row as Record<string, unknown>)
}
} catch (error: unknown) {
const err = error as { code?: string; message?: string }
if (err.code === 'ERR_CANCELED' || String(err.message ?? '').toLowerCase().includes('canceled')) {
return
}
} finally {
pageLoading.value = false
}
}
function onSuperScopeChange(val: string | number | boolean | undefined) {
if (val === 'template') {
formModel.game_channel_id = 0
loadData()
} else {
formModel.game_channel_id = ''
lastAutoLoadChannelId.value = null
formRef.value?.clearValidate(['game_channel_id'])
}
}
async function onSubmit() {
const form = formRef.value
if (!form) return
syncPayload()
await form.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
const body: Record<string, string | number> = {
tier_reward_form: formModel.tier_reward_form,
bigwin_form: formModel.bigwin_form,
}
const cid = resolveRequestChannelId()
if (cid !== null) {
body.game_channel_id = cid
}
await createAxios(
{
url: '/admin/game.RewardConfig/save',
method: 'post',
data: body,
},
{ showSuccessMessage: true, loading: false }
)
await loadData()
} finally {
submitLoading.value = false
}
})
}
async function onReset() {
formRef.value?.clearValidate()
await loadData()
}
function openGenTierDialog() {
genTierDialogVisible.value = true
}
async function onGenTierSubmit() {
genTierSubmitting.value = true
try {
const body: Record<string, string | number> = {
t1_fixed_cw: genTierForm.t1_fixed_cw,
t1_fixed_ccw: genTierForm.t1_fixed_ccw,
t2_min_cw: genTierForm.t2_min_cw,
t2_min_ccw: genTierForm.t2_min_ccw,
t4_fixed_cw: genTierForm.t4_fixed_cw,
t4_fixed_ccw: genTierForm.t4_fixed_ccw,
t5_fixed_cw: genTierForm.t5_fixed_cw,
t5_fixed_ccw: genTierForm.t5_fixed_ccw,
amt_t1: genTierForm.amt_t1,
amt_t2: genTierForm.amt_t2,
amt_t3: genTierForm.amt_t3,
amt_t4: genTierForm.amt_t4,
}
const cid = resolveRequestChannelId()
if (cid !== null) {
body.game_channel_id = cid
}
await createAxios(
{
url: '/admin/game.RewardConfig/generateTierBoard',
method: 'post',
data: body,
},
{ showSuccessMessage: true, loading: false }
)
genTierDialogVisible.value = false
await loadData()
} finally {
genTierSubmitting.value = false
}
}
async function onGenWeightClick() {
if (isSuperAdmin.value && superEditScope.value === 'channel' && !formModel.game_channel_id) {
ElMessage.warning(t('game.rewardConfigForm.gen_weight_need_channel'))
return
}
try {
await ElMessageBox.confirm(
t('game.rewardConfigForm.gen_weight_confirm_body'),
t('game.rewardConfigForm.gen_weight_confirm_title'),
{
type: 'warning',
confirmButtonText: t('game.rewardConfigForm.gen_weight_confirm_ok'),
cancelButtonText: t('Cancel'),
}
)
} catch {
return
}
genWeightSubmitting.value = true
try {
const body: Record<string, number> = {}
const cid = resolveRequestChannelId()
if (cid !== null) {
body.game_channel_id = cid
}
await createAxios(
{
url: '/admin/game.RewardConfig/generateRewardWeight',
method: 'post',
data: body,
},
{ showSuccessMessage: true, loading: false }
)
} finally {
genWeightSubmitting.value = false
}
}
onMounted(() => {
if (isSuperAdmin.value) {
superEditScope.value = 'template'
formModel.game_channel_id = 0
loadData()
} else {
loadData()
}
})
watch(
() => formModel.game_channel_id,
(val, oldVal) => {
if (!isSuperAdmin.value || superEditScope.value !== 'channel') {
return
}
if (channelChangeSilent.value) {
return
}
const nextCid = Number(val)
const prevCid = Number(oldVal)
if (Number.isFinite(nextCid) && Number.isFinite(prevCid) && nextCid === prevCid) {
return
}
if (!Number.isFinite(nextCid) || nextCid <= 0) {
return
}
if (lastAutoLoadChannelId.value === nextCid) {
return
}
lastAutoLoadChannelId.value = nextCid
void loadData()
}
)
</script>
<style scoped lang="scss">
.reward-form-card {
max-width: 1280px;
}
.card-title {
font-weight: 600;
}
.intro-alert {
margin-bottom: 16px;
}
.scope-hint {
margin-bottom: 12px;
}
.channel-bar {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.channel-picker {
flex: 1 1 360px;
min-width: 320px;
max-width: 520px;
}
.channel-picker :deep(.el-form-item) {
margin-bottom: 0;
}
.channel-picker :deep(.el-select) {
width: 100%;
}
.reward-form-body {
margin-top: 8px;
}
.block-editor {
width: 100%;
}
.tier-editor .line {
display: grid;
grid-template-columns: 64px minmax(88px, 1fr) minmax(88px, 1fr) minmax(88px, 1fr) minmax(96px, 110px) minmax(124px, 1fr);
column-gap: 10px;
row-gap: 8px;
margin-bottom: 8px;
}
.bigwin-editor .line {
display: grid;
grid-template-columns: 110px 1fr 1fr 120px;
gap: 8px;
margin-bottom: 8px;
}
.line-head {
margin-bottom: 6px;
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 20px;
font-weight: 600;
}
.form-help {
margin-top: 6px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
@media (max-width: 768px) {
.reward-form-card :deep(.el-card__body) {
padding: 12px 10px;
}
.reward-form-body :deep(.el-form-item) {
margin-bottom: 12px;
}
.channel-picker {
min-width: 100%;
max-width: 100%;
}
.channel-bar {
gap: 8px;
margin-bottom: 10px;
}
.channel-bar > .el-button {
width: 100%;
}
/* 移动端主配置表:保持列宽,允许左右滚动,避免被压扁 */
.tier-editor,
.bigwin-editor {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 4px;
}
.tier-editor .line {
min-width: 820px;
grid-template-columns: 48px 80px 80px 80px 100px 130px;
column-gap: 10px;
row-gap: 8px;
}
.bigwin-editor .line {
min-width: 560px;
grid-template-columns: 48px 80px 80px 100px;
}
.line-head {
font-size: 11px;
line-height: 16px;
}
.reward-form-body :deep(.el-form-item:last-child .el-form-item__content) {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.reward-form-body :deep(.el-form-item:last-child .el-button) {
flex: 1 1 calc(50% - 8px);
min-width: 128px;
margin-left: 0;
}
}
</style>
<!-- 弹窗 append-to-body 后不在当前组件 DOM scoped 样式无法作用单独写全局选择器 -->
<style lang="scss">
.gen-tier-dialog.el-dialog {
box-sizing: border-box;
max-width: 100vw;
}
.gen-tier-dialog .el-dialog__header {
padding: 12px 14px;
margin-right: 0;
}
.gen-tier-dialog .el-dialog__body {
padding: 8px 12px 12px;
box-sizing: border-box;
overflow-x: hidden;
}
.gen-tier-dialog .el-dialog__footer {
padding: 10px 12px 14px;
}
.gen-tier-dialog .gen-tier-scroll {
padding-right: 4px;
}
.gen-tier-dialog .gen-rule {
white-space: pre-line;
overflow-wrap: anywhere;
word-break: break-word;
max-width: 100%;
box-sizing: border-box;
background: var(--el-fill-color-light);
padding: 12px 12px;
border-radius: 8px;
margin-bottom: 12px;
font-size: 13px;
line-height: 1.55;
color: var(--el-text-color-regular);
}
.gen-tier-dialog .gen-tier-block {
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.gen-tier-dialog .gen-tier-block:last-of-type {
border-bottom: none;
}
.gen-tier-dialog .gen-tier-block-title {
font-weight: 600;
margin-bottom: 8px;
color: var(--el-text-color-primary);
font-size: 14px;
}
.gen-tier-dialog .gen-tier-form .el-form-item {
margin-bottom: 14px;
}
.gen-tier-dialog .gen-tier-form.el-form--label-top .el-form-item__label {
line-height: 1.4;
margin-bottom: 6px;
height: auto;
white-space: normal;
word-break: break-word;
}
.gen-tier-dialog .gen-tier-form .el-input-number {
width: 100%;
max-width: 320px;
}
.gen-tier-dialog .gen-tier-footer-hint {
margin-top: 4px;
padding: 0 2px 8px;
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-secondary);
overflow-wrap: anywhere;
word-break: break-word;
}
.gen-tier-dialog .gen-tier-dialog-footer {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
width: 100%;
}
@media (max-width: 639px) {
.gen-tier-dialog .gen-tier-form .el-input-number {
max-width: none;
}
}
</style>

View File

@@ -1,149 +0,0 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<!-- 自定义按钮请使用插槽甚至公共搜索也可以使用具名插槽渲染参见文档 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('game.rewardWeight.quick Search Fields') })"
></TableHeader>
<!-- 表格 -->
<!-- 表格列有多种自定义渲染方式比如自定义组件具名插槽等参见文档 -->
<!-- 要使用 el-table 组件原有的属性直接加在 Table 标签上即可 -->
<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: 'game/rewardWeight',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
/**
* baTable 内包含了表格的所有数据且数据具备响应性,然后通过 provide 注入给了后代组件
*/
const baTable = new baTableClass(
new baTableApi('/admin/game.RewardWeight/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('game.rewardWeight.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{
label: t('game.rewardWeight.gamechannel__name'),
prop: 'gameChannel.name',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
operator: 'LIKE',
comSearchRender: 'string',
},
{
label: t('game.rewardWeight.direction'),
prop: 'direction',
align: 'center',
operator: 'eq',
sortable: false,
render: 'tag',
replaceValue: { '0': t('game.rewardWeight.direction 0'), '1': t('game.rewardWeight.direction 1') },
},
{ label: t('game.rewardWeight.grid_number'), prop: 'grid_number', align: 'center', sortable: false, operator: 'RANGE' },
{ label: t('game.rewardWeight.start_index'), prop: 'start_index', align: 'center', sortable: false, operator: 'RANGE' },
{ label: t('game.rewardWeight.end_index'), prop: 'end_index', align: 'center', sortable: false, operator: 'RANGE' },
{
label: t('game.rewardWeight.ui_text'),
prop: 'ui_text',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{ label: t('game.rewardWeight.real_ev'), prop: 'real_ev', align: 'center', sortable: false, operator: 'RANGE' },
{
label: t('game.rewardWeight.tier'),
prop: 'tier',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('game.rewardWeight.type'),
prop: 'type',
align: 'center',
operator: 'eq',
sortable: false,
render: 'tag',
replaceValue: {
'-2': t('game.rewardWeight.type -2'),
'-1': t('game.rewardWeight.type -1'),
'0': t('game.rewardWeight.type 0'),
'1': t('game.rewardWeight.type 1'),
'2': t('game.rewardWeight.type 2'),
'3': t('game.rewardWeight.type 3'),
},
},
{ label: t('game.rewardWeight.remark'), prop: 'remark', align: 'center', operatorPlaceholder: t('Fuzzy query'), operator: 'LIKE' },
{ label: t('game.rewardWeight.weight'), prop: 'weight', align: 'center', sortable: false, operator: 'RANGE' },
{
label: t('game.rewardWeight.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('game.rewardWeight.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false },
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: { direction: '0', type: '0' },
}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -1,87 +0,0 @@
<template>
<!-- 对话框表单 -->
<!-- 建议使用 Prettier 格式化代码 -->
<!-- el-form 内可以混用 el-form-itemFormItemba-input 等输入组件 -->
<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"
>
<FormItem :label="t('game.rewardWeight.game_channel_id')" type="remoteSelect" v-model="baTable.form.items!.game_channel_id" prop="game_channel_id" :input-attr="{ pk: 'game_channel.id', field: 'name', remoteUrl: '/admin/game.Channel/index' }" :placeholder="t('Please select field', { field: t('game.rewardWeight.game_channel_id') })" />
<FormItem :label="t('game.rewardWeight.direction')" type="radio" v-model="baTable.form.items!.direction" prop="direction" :input-attr="{ content: { '0': t('game.rewardWeight.direction 0'), '1': t('game.rewardWeight.direction 1') } }" :placeholder="t('Please select field', { field: t('game.rewardWeight.direction') })" />
<FormItem :label="t('game.rewardWeight.grid_number')" type="number" v-model="baTable.form.items!.grid_number" prop="grid_number" :input-attr="{ step: 1 }" :placeholder="t('Please input field', { field: t('game.rewardWeight.grid_number') })" />
<FormItem :label="t('game.rewardWeight.start_index')" type="number" v-model="baTable.form.items!.start_index" prop="start_index" :input-attr="{ step: 1 }" :placeholder="t('Please input field', { field: t('game.rewardWeight.start_index') })" />
<FormItem :label="t('game.rewardWeight.end_index')" type="number" v-model="baTable.form.items!.end_index" prop="end_index" :input-attr="{ step: 1 }" :placeholder="t('Please input field', { field: t('game.rewardWeight.end_index') })" />
<FormItem :label="t('game.rewardWeight.ui_text')" type="string" v-model="baTable.form.items!.ui_text" prop="ui_text" :placeholder="t('Please input field', { field: t('game.rewardWeight.ui_text') })" />
<FormItem :label="t('game.rewardWeight.real_ev')" type="number" v-model="baTable.form.items!.real_ev" prop="real_ev" :input-attr="{ step: 1 }" :placeholder="t('Please input field', { field: t('game.rewardWeight.real_ev') })" />
<FormItem :label="t('game.rewardWeight.tier')" type="string" v-model="baTable.form.items!.tier" prop="tier" :placeholder="t('Please input field', { field: t('game.rewardWeight.tier') })" />
<FormItem :label="t('game.rewardWeight.type')" type="select" v-model="baTable.form.items!.type" prop="type" :input-attr="{ content: { '-2': t('game.rewardWeight.type -2'), '-1': t('game.rewardWeight.type -1'), '0': t('game.rewardWeight.type 0'), '1': t('game.rewardWeight.type 1'), '2': t('game.rewardWeight.type 2'), '3': t('game.rewardWeight.type 3') } }" :placeholder="t('Please select field', { field: t('game.rewardWeight.type') })" />
<FormItem :label="t('game.rewardWeight.remark')" type="textarea" v-model="baTable.form.items!.remark" prop="remark" :input-attr="{ rows: 3 }" @keyup.enter.stop="" @keyup.ctrl.enter="baTable.onSubmit(formRef)" :placeholder="t('Please input field', { field: t('game.rewardWeight.remark') })" />
<FormItem :label="t('game.rewardWeight.weight')" type="number" v-model="baTable.form.items!.weight" prop="weight" :input-attr="{ step: 1 }" :placeholder="t('Please input field', { field: t('game.rewardWeight.weight') })" />
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
import { buildValidatorData } from '/@/utils/validate'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
direction: [buildValidatorData({ name: 'required', title: t('game.rewardWeight.direction') })],
grid_number: [buildValidatorData({ name: 'integer', title: t('game.rewardWeight.grid_number') }), buildValidatorData({ name: 'required', title: t('game.rewardWeight.grid_number') })],
start_index: [buildValidatorData({ name: 'integer', title: t('game.rewardWeight.start_index') }), buildValidatorData({ name: 'required', title: t('game.rewardWeight.start_index') })],
end_index: [buildValidatorData({ name: 'integer', title: t('game.rewardWeight.end_index') }), buildValidatorData({ name: 'required', title: t('game.rewardWeight.end_index') })],
ui_text: [buildValidatorData({ name: 'required', title: t('game.rewardWeight.ui_text') })],
real_ev: [buildValidatorData({ name: 'number', title: t('game.rewardWeight.real_ev') }), buildValidatorData({ name: 'required', title: t('game.rewardWeight.real_ev') })],
tier: [buildValidatorData({ name: 'required', title: t('game.rewardWeight.tier') })],
type: [buildValidatorData({ name: 'required', title: t('game.rewardWeight.type') })],
weight: [buildValidatorData({ name: 'integer', title: t('game.rewardWeight.weight') })],
create_time: [buildValidatorData({ name: 'date', title: t('game.rewardWeight.create_time') })],
update_time: [buildValidatorData({ name: 'date', title: t('game.rewardWeight.update_time') })],
})
</script>
<style scoped lang="scss"></style>

View File

@@ -1,83 +0,0 @@
<template>
<div v-if="tagLabels.length" class="game-user-ticket-tags">
<el-tag v-for="(label, idx) in tagLabels" :key="idx" class="m-4" effect="light" type="success" size="default">
{{ label }}
</el-tag>
</div>
<span v-else class="game-user-json-plain">{{ plainText }}</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
renderRow: TableRow
renderField: TableColumn
renderValue: unknown
renderColumn: import('element-plus').TableColumnCtx<TableRow>
renderIndex: number
}>()
/** [{"ante":1,"count":1},...] */
function parseTicketTagLabels(raw: unknown): string[] {
if (raw === null || raw === undefined || raw === '') {
return []
}
let arr: unknown[] = []
if (typeof raw === 'string') {
const s = raw.trim()
if (!s) return []
try {
const parsed = JSON.parse(s)
arr = Array.isArray(parsed) ? parsed : []
} catch {
return []
}
} else if (Array.isArray(raw)) {
arr = raw
} else {
return []
}
const labels: string[] = []
for (const item of arr) {
if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
const o = item as Record<string, unknown>
const ante = o.ante
const count = o.count
labels.push(`ante:${String(ante)} count:${String(count)}`)
}
}
return labels
}
const tagLabels = computed(() => parseTicketTagLabels(props.renderValue))
const plainText = computed(() => {
const v = props.renderValue
if (v === null || v === undefined) return ''
if (typeof v === 'object') {
try {
return JSON.stringify(v)
} catch {
return String(v)
}
}
return String(v)
})
</script>
<style scoped lang="scss">
.game-user-ticket-tags {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 4px 0;
}
.m-4 {
margin: 4px;
}
.game-user-json-plain {
word-break: break-all;
}
</style>

View File

@@ -1,82 +0,0 @@
<template>
<div v-if="tagLabels.length" class="game-user-json-weight-tags">
<el-tag v-for="(label, idx) in tagLabels" :key="idx" class="m-4" effect="light" type="primary" size="default">
{{ label }}
</el-tag>
</div>
<span v-else class="game-user-json-plain">{{ plainText }}</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
renderRow: TableRow
renderField: TableColumn
renderValue: unknown
renderColumn: import('element-plus').TableColumnCtx<TableRow>
renderIndex: number
}>()
/** [{"T1":"5"},{"T2":"20"},...] */
function parseWeightTagLabels(raw: unknown): string[] {
if (raw === null || raw === undefined || raw === '') {
return []
}
let arr: unknown[] = []
if (typeof raw === 'string') {
const s = raw.trim()
if (!s) return []
try {
const parsed = JSON.parse(s)
arr = Array.isArray(parsed) ? parsed : []
} catch {
return []
}
} else if (Array.isArray(raw)) {
arr = raw
} else {
return []
}
const labels: string[] = []
for (const item of arr) {
if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
for (const [k, v] of Object.entries(item as Record<string, unknown>)) {
labels.push(`${k}:${String(v)}`)
}
}
}
return labels
}
const tagLabels = computed(() => parseWeightTagLabels(props.renderValue))
const plainText = computed(() => {
const v = props.renderValue
if (v === null || v === undefined) return ''
if (typeof v === 'object') {
try {
return JSON.stringify(v)
} catch {
return String(v)
}
}
return String(v)
})
</script>
<style scoped lang="scss">
.game-user-json-weight-tags {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 4px 0;
}
.m-4 {
margin: 4px;
}
.game-user-json-plain {
word-break: break-all;
}
</style>

View File

@@ -1,195 +0,0 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<!-- 自定义按钮请使用插槽甚至公共搜索也可以使用具名插槽渲染参见文档 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('game.user.quick Search Fields') })"
></TableHeader>
<!-- 表格 -->
<!-- 表格列有多种自定义渲染方式比如自定义组件具名插槽等参见文档 -->
<!-- 要使用 el-table 组件原有的属性直接加在 Table 标签上即可 -->
<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 GameUserTicketJsonCell from './GameUserTicketJsonCell.vue'
import GameUserWeightJsonCell from './GameUserWeightJsonCell.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'
import { BIGWIN_WEIGHT_KEYS, TIER_WEIGHT_KEYS, jsonStringFromFixedKeys } from '/@/utils/gameWeightFixed'
defineOptions({
name: 'game/user',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
/**
* baTable 内包含了表格的所有数据且数据具备响应性,然后通过 provide 注入给了后代组件
*/
const baTable = new baTableClass(
new baTableApi('/admin/game.User/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('game.user.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{
label: t('game.user.username'),
prop: 'username',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('game.user.uuid'),
prop: 'uuid',
align: 'center',
showOverflowTooltip: true,
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{ label: t('game.user.phone'), prop: 'phone', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
{ label: t('game.user.coin'), prop: 'coin', align: 'center', sortable: false, operator: 'RANGE' },
{
label: t('game.user.tier_weight'),
prop: 'tier_weight',
align: 'center',
minWidth: 200,
sortable: false,
operator: false,
render: 'customRender',
customRender: GameUserWeightJsonCell,
},
{
label: t('game.user.bigwin_weight'),
prop: 'bigwin_weight',
align: 'center',
minWidth: 200,
sortable: false,
operator: false,
render: 'customRender',
customRender: GameUserWeightJsonCell,
},
{
label: t('game.user.ticket_count'),
prop: 'ticket_count',
align: 'center',
minWidth: 220,
sortable: false,
operator: false,
render: 'customRender',
customRender: GameUserTicketJsonCell,
},
{
label: t('game.user.status'),
prop: 'status',
align: 'center',
operator: 'eq',
sortable: false,
render: 'switch',
replaceValue: { '0': t('game.user.status 0'), '1': t('game.user.status 1') },
},
{
label: t('game.user.gamechannel__name'),
prop: 'gameChannel.name',
align: 'center',
minWidth: 100,
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
operator: 'LIKE',
comSearchRender: 'string',
},
{
label: t('game.user.admin__username'),
prop: 'admin.username',
align: 'center',
minWidth: 90,
effect: 'plain',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
operator: 'LIKE',
comSearchRender: 'string',
//修改tag颜色
customRenderAttr: {
tag: () => ({
color: '#e8f3ff',
style: { color: '#1677ff', borderColor: '#91caff' },
}),
},
},
{
label: t('game.user.remark'),
prop: 'remark',
align: 'center',
minWidth: 100,
showOverflowTooltip: true,
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('game.user.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('game.user.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', width: 80, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
],
dblClickNotEditColumn: [undefined, 'status'],
},
{
defaultItems: {
status: '1',
tier_weight: jsonStringFromFixedKeys(TIER_WEIGHT_KEYS, {}),
bigwin_weight: jsonStringFromFixedKeys(BIGWIN_WEIGHT_KEYS, {}),
ticket_count: null,
},
}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -1,654 +0,0 @@
<template>
<!-- 对话框表单 -->
<!-- 建议使用 Prettier 格式化代码 -->
<!-- el-form 内可以混用 el-form-itemFormItemba-input 等输入组件 -->
<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"
>
<FormItem
:label="t('game.user.username')"
type="string"
v-model="baTable.form.items!.username"
prop="username"
:placeholder="t('Please input field', { field: t('game.user.username') })"
/>
<FormItem
:label="t('game.user.password')"
type="password"
v-model="baTable.form.items!.password"
prop="password"
:placeholder="t('Please input field', { field: t('game.user.password') })"
/>
<FormItem
:label="t('game.user.phone')"
type="string"
v-model="baTable.form.items!.phone"
prop="phone"
:placeholder="t('Please input field', { field: t('game.user.phone') })"
/>
<FormItem
:label="t('game.user.remark')"
type="textarea"
v-model="baTable.form.items!.remark"
prop="remark"
:input-attr="{ rows: 3 }"
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
:placeholder="t('Please input field', { field: t('game.user.remark') })"
/>
<FormItem
:label="t('game.user.coin')"
type="number"
v-model="baTable.form.items!.coin"
prop="coin"
:input-attr="{ step: 1 }"
:placeholder="t('Please input field', { field: t('game.user.coin') })"
/>
<!-- 档位权重键固定 T1T5仅可改值 -->
<el-form-item :label="t('game.user.tier_weight')" prop="tier_weight">
<div class="weight-value-editor">
<div v-for="(row, idx) in tierWeightRows" :key="'tw-' + idx" class="weight-value-row">
<el-input v-model="row.key" class="weight-key" readonly tabindex="-1" />
<span class="weight-sep">:</span>
<el-input
v-model="row.val"
class="weight-val"
:placeholder="t('Please input field', { field: t('game.user.weight value') })"
clearable
@input="onTierWeightRowChange"
/>
</div>
<div class="form-help">{{ t('game.user.tier_weight_help') }}</div>
</div>
</el-form-item>
<!-- 中大奖权重键固定 530仅可改值 -->
<el-form-item :label="t('game.user.bigwin_weight')" prop="bigwin_weight">
<div class="weight-value-editor">
<div v-for="(row, idx) in bigwinWeightRows" :key="'bw-' + idx" class="weight-value-row">
<el-input v-model="row.key" class="weight-key" readonly tabindex="-1" />
<span class="weight-sep">:</span>
<el-input
v-model="row.val"
class="weight-val"
:placeholder="t('Please input field', { field: t('game.user.weight value') })"
clearable
:disabled="isBigwinValueLocked(row.key)"
@input="onBigwinWeightRowChange"
/>
</div>
<div class="form-help">{{ t('game.user.bigwin_weight_help') }}</div>
</div>
</el-form-item>
<!-- 抽奖券最多一条 JSON 元素 [{"ante":1,"count":1}]不可增行可删除清空 -->
<el-form-item :label="t('game.user.ticket_count')" prop="ticket_count">
<div class="ticket-value-editor">
<div v-for="(row, idx) in ticketRows" :key="'tk-' + idx" class="ticket-value-row">
<span class="ticket-label">{{ t('game.user.ticket_ante') }}</span>
<el-input
v-model="row.ante"
class="ticket-field"
clearable
:placeholder="t('Please input field', { field: t('game.user.ticket_ante') })"
@input="onTicketRowChange"
/>
<span class="ticket-label">{{ t('game.user.ticket_count_times') }}</span>
<el-input
v-model="row.count"
class="ticket-field"
clearable
:placeholder="t('Please input field', { field: t('game.user.ticket_count_times') })"
@input="onTicketRowChange"
/>
<el-button type="danger" link @click="removeTicketRow">{{ t('Delete') }}</el-button>
</div>
</div>
</el-form-item>
<FormItem
:label="t('game.user.status')"
type="switch"
v-model="baTable.form.items!.status"
prop="status"
:input-attr="{ content: { '0': t('game.user.status 0'), '1': t('game.user.status 1') } }"
/>
<el-form-item :label="t('game.user.game_channel_id')" prop="admin_id">
<el-tree-select
v-model="adminIdForTree"
class="w100"
clearable
filterable
:data="channelAdminTree"
:props="treeProps"
:render-after-expand="false"
:placeholder="t('Please select field', { field: t('game.user.admin_id') })"
@change="onAdminTreeChange"
/>
</el-form-item>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { computed, inject, onMounted, reactive, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useAdminInfo } from '/@/stores/adminInfo'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
import { buildValidatorData, regularPassword } from '/@/utils/validate'
import createAxios from '/@/utils/axios'
import {
BIGWIN_WEIGHT_KEYS,
TIER_WEIGHT_KEYS,
fixedRowsFromKeys,
jsonStringFromFixedKeys,
parseWeightJsonToMap,
rowsToMap,
type WeightRow,
} from '/@/utils/gameWeightFixed'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const adminInfo = useAdminInfo()
const { t } = useI18n()
const isSuperAdmin = computed(() => adminInfo.super === true)
type TreeNode = {
value: string
label: string
disabled?: boolean
children?: TreeNode[]
channel_id?: number
is_leaf?: boolean
}
const channelAdminTree = ref<TreeNode[]>([])
const adminIdToChannelId = ref<Record<string, number>>({})
const treeProps = {
value: 'value',
label: 'label',
children: 'children',
disabled: 'disabled',
}
/** 与 adminTree 叶子 value字符串一致避免 number 与 string 不一致导致 el-tree-select 只显示原始 ID */
const adminIdForTree = computed<string | undefined>({
get() {
const v = baTable.form.items?.admin_id
if (v === undefined || v === null || v === '') return undefined
return String(v)
},
set(v: string | number | undefined | null) {
if (!baTable.form.items) return
if (v === undefined || v === null || v === '') {
baTable.form.items.admin_id = undefined
return
}
const n = Number(v)
if (Number.isFinite(n)) {
baTable.form.items.admin_id = n
}
},
})
const loadChannelAdminTree = async () => {
const res = await createAxios({
url: '/admin/game.Channel/adminTree',
method: 'get',
})
const list = (res.data?.list ?? []) as TreeNode[]
channelAdminTree.value = list
const map: Record<string, number> = {}
const walk = (nodes: TreeNode[]) => {
for (const n of nodes) {
if (n.children && n.children.length) {
walk(n.children)
} else if (n.is_leaf && n.channel_id !== undefined) {
map[n.value] = n.channel_id
}
}
}
walk(list)
adminIdToChannelId.value = map
}
const onAdminTreeChange = (val: string | number | null) => {
if (val === null || val === undefined || val === '') {
return
}
const key = typeof val === 'number' ? String(val) : String(val)
const channelId = adminIdToChannelId.value[key]
if (channelId !== undefined) {
baTable.form.items!.game_channel_id = channelId
}
}
onMounted(() => {
loadChannelAdminTree()
})
type TicketRow = { ante: string; count: string }
const tierWeightRows = ref<WeightRow[]>(fixedRowsFromKeys(TIER_WEIGHT_KEYS, {}))
const bigwinWeightRows = ref<WeightRow[]>(fixedRowsFromKeys(BIGWIN_WEIGHT_KEYS, {}))
const ticketRows = ref<TicketRow[]>([{ ante: '', count: '1' }])
function isBigwinValueLocked(key: string): boolean {
return key === '5' || key === '30'
}
function enforceBigwinFixedValues() {
for (const r of bigwinWeightRows.value) {
if (isBigwinValueLocked(r.key)) {
r.val = '10000'
}
}
}
function syncTierWeightToForm() {
const items = baTable.form.items
if (!items) return
items.tier_weight = jsonStringFromFixedKeys(TIER_WEIGHT_KEYS, rowsToMap(tierWeightRows.value))
}
function syncBigwinWeightToForm() {
const items = baTable.form.items
if (!items) return
items.bigwin_weight = jsonStringFromFixedKeys(BIGWIN_WEIGHT_KEYS, rowsToMap(bigwinWeightRows.value))
}
function onTierWeightRowChange() {
syncTierWeightToForm()
}
function onBigwinWeightRowChange() {
enforceBigwinFixedValues()
syncBigwinWeightToForm()
}
function parseTicketRows(raw: unknown): TicketRow[] {
if (raw === null || raw === undefined || raw === '') {
return [{ ante: '', count: '1' }]
}
if (typeof raw === 'string') {
const s = raw.trim()
if (!s) return [{ ante: '', count: '1' }]
try {
const parsed = JSON.parse(s)
return arrayToTicketRows(parsed)
} catch {
return [{ ante: '', count: '1' }]
}
}
if (Array.isArray(raw)) {
return arrayToTicketRows(raw)
}
return [{ ante: '', count: '1' }]
}
function arrayToTicketRows(arr: unknown): TicketRow[] {
if (!Array.isArray(arr)) {
return [{ ante: '', count: '1' }]
}
const out: TicketRow[] = []
for (const item of arr) {
if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
const ante = Reflect.get(item, 'ante')
const count = Reflect.get(item, 'count')
out.push({
ante: ante === null || ante === undefined ? '' : String(ante),
count: count === null || count === undefined || String(count) === '' ? '1' : String(count),
})
break
}
}
return out.length ? out : [{ ante: '', count: '1' }]
}
function ticketRowsToJsonString(rows: TicketRow[]): string {
const body: { ante: number; count: number }[] = []
for (const r of rows) {
const a = r.ante.trim()
const c = r.count.trim()
if (a === '' && c === '') continue
if (a === '' || c === '') continue
const na = Number(a)
const nc = Number(c)
body.push({ ante: na, count: nc })
}
if (body.length === 0) {
return ''
}
return JSON.stringify(body)
}
function syncTicketToForm() {
const items = baTable.form.items
if (!items) return
const a = ticketRows.value[0]?.ante?.trim() ?? ''
const c = ticketRows.value[0]?.count?.trim() ?? ''
if (a === '' || c === '') {
items.ticket_count = null
return
}
const json = ticketRowsToJsonString(ticketRows.value)
items.ticket_count = json === '' ? null : json
}
function applyTierBigwinFromJson(tierJson: string, bigwinJson: string) {
const items = baTable.form.items
if (!items) return
const tm = parseWeightJsonToMap(tierJson)
const bm = parseWeightJsonToMap(bigwinJson)
items.tier_weight = jsonStringFromFixedKeys(TIER_WEIGHT_KEYS, tm)
items.bigwin_weight = jsonStringFromFixedKeys(BIGWIN_WEIGHT_KEYS, bm)
tierWeightRows.value = fixedRowsFromKeys(TIER_WEIGHT_KEYS, tm)
bigwinWeightRows.value = fixedRowsFromKeys(BIGWIN_WEIGHT_KEYS, bm)
enforceBigwinFixedValues()
syncBigwinWeightToForm()
}
async function loadAndApplyDefaultsForChannel(channelId: number) {
try {
const res = await createAxios(
{
url: '/admin/game.User/defaultWeightByChannel',
method: 'get',
params: { channel_id: channelId },
},
{
showErrorMessage: false,
showCodeMessage: false,
}
)
applyTierBigwinFromJson(res.data.tier_weight ?? '[]', res.data.bigwin_weight ?? '[]')
} catch {
// 路由或权限异常时不阻断打开表单,保持可手工编辑
}
}
function onTicketRowChange() {
syncTicketToForm()
}
/** 最多一条,删除/不填则 ticket_count 为空 */
function removeTicketRow() {
ticketRows.value = [{ ante: '', count: '1' }]
syncTicketToForm()
}
function hydrateJsonFieldsFromForm() {
const tm = parseWeightJsonToMap(baTable.form.items?.tier_weight)
const bm = parseWeightJsonToMap(baTable.form.items?.bigwin_weight)
tierWeightRows.value = fixedRowsFromKeys(TIER_WEIGHT_KEYS, tm)
bigwinWeightRows.value = fixedRowsFromKeys(BIGWIN_WEIGHT_KEYS, bm)
syncTierWeightToForm()
enforceBigwinFixedValues()
syncBigwinWeightToForm()
ticketRows.value = normalizeTicketRowsToOne(parseTicketRows(baTable.form.items?.ticket_count))
syncTicketToForm()
}
function normalizeTicketRowsToOne(rows: TicketRow[]): TicketRow[] {
if (rows.length === 0) {
return [{ ante: '', count: '1' }]
}
const first = rows[0]
return [
{
ante: first?.ante ?? '',
count: first?.count && String(first.count).trim() !== '' ? String(first.count) : '1',
},
]
}
watch(
() => baTable.form.loading,
(loading) => {
if (loading === false) {
hydrateJsonFieldsFromForm()
}
}
)
watch(
() => [baTable.form.operate, baTable.form.loading] as const,
async ([op, loading]) => {
if (op !== 'Add' || loading !== false) return
if (!isSuperAdmin.value) return
await loadAndApplyDefaultsForChannel(0)
}
)
watch(
() => baTable.form.items?.game_channel_id,
async (ch) => {
if (baTable.form.operate !== 'Add') return
if (ch === undefined || ch === null || ch === '') return
const cid = Number(ch)
if (!Number.isFinite(cid)) return
if (isSuperAdmin.value) {
return
}
await loadAndApplyDefaultsForChannel(cid)
}
)
watch(
() => baTable.form.items?.admin_id,
(val) => {
if (val === undefined || val === null || val === '') return
onAdminTreeChange(typeof val === 'number' ? val : Number(val))
}
)
function validateTierWeightRows(): string | undefined {
let sum = 0
for (const r of tierWeightRows.value) {
const vs = r.val.trim()
if (vs === '') {
return t('Please input field', { field: t('game.user.weight value') })
}
const n = Number(vs)
if (!Number.isFinite(n)) {
return t('game.user.weight value numeric')
}
if (n > 100) {
return t('game.user.weight each max 100')
}
sum += n
}
if (sum > 100 + 0.000001) {
return t('game.user.tier_weight_sum_max_100')
}
return undefined
}
function validateBigwinWeightRows(): string | undefined {
for (const r of bigwinWeightRows.value) {
const vs = r.val.trim()
if (vs === '') {
return t('Please input field', { field: t('game.user.weight value') })
}
const n = Number(vs)
if (!Number.isFinite(n)) {
return t('game.user.weight value numeric')
}
if (n > 10000) {
return t('game.user.bigwin_weight_each_max_10000')
}
}
return undefined
}
function validateTicketRowsField(): string | undefined {
for (const r of ticketRows.value) {
const a = r.ante.trim()
const c = r.count.trim()
if (a === '' && c === '') continue
if (a === '' || c === '') {
return t('game.user.ticket row incomplete')
}
const na = Number(a)
const nc = Number(c)
if (!Number.isFinite(na) || !Number.isFinite(nc)) {
return t('game.user.ticket row numeric')
}
}
return undefined
}
const validatorGameUserPassword = (rule: any, val: string, callback: (error?: Error) => void) => {
const operate = baTable.form.operate
const v = typeof val === 'string' ? val.trim() : ''
// 新增:必填
if (operate === 'Add') {
if (!v) return callback(new Error(t('Please input field', { field: t('game.user.password') })))
if (!regularPassword(v)) return callback(new Error(t('validate.Please enter the correct password')))
return callback()
}
// 编辑:可空;非空则校验格式
if (!v) return callback()
if (!regularPassword(v)) return callback(new Error(t('validate.Please enter the correct password')))
return callback()
}
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
username: [buildValidatorData({ name: 'required', title: t('game.user.username') })],
password: [{ validator: validatorGameUserPassword, trigger: 'blur' }],
phone: [buildValidatorData({ name: 'required', title: t('game.user.phone') })],
coin: [buildValidatorData({ name: 'number', title: t('game.user.coin') })],
tier_weight: [
{
validator: (_rule, _val, callback) => {
const err = validateTierWeightRows()
if (err) {
callback(new Error(err))
return
}
callback()
},
trigger: ['blur', 'change'],
},
],
bigwin_weight: [
{
validator: (_rule, _val, callback) => {
const err = validateBigwinWeightRows()
if (err) {
callback(new Error(err))
return
}
callback()
},
trigger: ['blur', 'change'],
},
],
ticket_count: [
{
validator: (_rule, _val, callback) => {
const err = validateTicketRowsField()
if (err) {
callback(new Error(err))
return
}
callback()
},
trigger: ['blur', 'change'],
},
],
admin_id: [buildValidatorData({ name: 'required', title: t('game.user.admin_id') })],
create_time: [buildValidatorData({ name: 'date', title: t('game.user.create_time') })],
update_time: [buildValidatorData({ name: 'date', title: t('game.user.update_time') })],
})
</script>
<style scoped lang="scss">
.weight-value-editor {
width: 100%;
}
.weight-value-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.weight-key {
max-width: 140px;
}
.weight-val {
flex: 1;
min-width: 80px;
}
.weight-sep {
flex-shrink: 0;
color: var(--el-text-color-secondary);
}
.ticket-value-editor {
width: 100%;
}
.ticket-value-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.ticket-field {
flex: 1;
min-width: 100px;
max-width: 160px;
}
.ticket-label {
flex-shrink: 0;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.form-help {
margin-top: 4px;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -0,0 +1,102 @@
<template>
<div class="default-main ba-table-box zi-hua-dict-page">
<el-alert type="info" :closable="false" show-icon>
{{ t('game.ziHuaDictionary.desc') }}
</el-alert>
<div class="toolbar">
<el-button type="primary" :loading="saving" :disabled="loading" @click="onSave">
{{ t('game.ziHuaDictionary.btn_save') }}
</el-button>
</div>
<el-table v-loading="loading" border stripe :data="items" row-key="no" max-height="640">
<el-table-column prop="no" :label="t('game.ziHuaDictionary.no')" width="72" align="center" />
<el-table-column :label="t('game.ziHuaDictionary.name')" min-width="120">
<template #default="{ row }">
<el-input v-model="row.name" maxlength="32" show-word-limit />
</template>
</el-table-column>
<el-table-column :label="t('game.ziHuaDictionary.category')" min-width="160">
<template #default="{ row }">
<el-select v-model="row.category" style="width: 100%">
<el-option v-for="c in categories" :key="c" :label="categoryLabel(c)" :value="c" />
</el-select>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import createAxios from '/@/utils/axios'
import { auth } from '/@/utils/common'
defineOptions({
name: 'game/ziHuaDictionary',
})
const { t } = useI18n()
type Item = { no: number; name: string; category: string }
const loading = ref(false)
const saving = ref(false)
const items = ref<Item[]>([])
const categories = ref<string[]>(['zodiac', 'beast', 'fowl', 'vermin', 'divine'])
function categoryLabel(cat: string): string {
return t('game.ziHuaDictionary.category_label.' + cat)
}
async function load() {
loading.value = true
try {
const res = await createAxios({
url: '/admin/game.ZiHuaDictionary/index',
method: 'get',
})
if (res.code === 1 && res.data) {
const list = res.data.items as Item[]
items.value = Array.isArray(list) ? list : []
if (Array.isArray(res.data.categories) && res.data.categories.length) {
categories.value = res.data.categories as string[]
}
}
} finally {
loading.value = false
}
}
async function onSave() {
if (!auth('save')) {
return
}
saving.value = true
try {
await createAxios({
url: '/admin/game.ZiHuaDictionary/save',
method: 'post',
data: { items: items.value },
showSuccessMessage: true,
})
await load()
} finally {
saving.value = false
}
}
onMounted(() => {
void load()
})
</script>
<style scoped lang="scss">
.zi-hua-dict-page {
.toolbar {
margin: 12px 0;
}
}
</style>

View File

@@ -0,0 +1,130 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('operation.operationNotice.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: 'operation/operationNotice',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
const baTable = new baTableClass(
new baTableApi('/admin/operation.OperationNotice/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('operation.operationNotice.id'), prop: 'id', align: 'center', width: 80, operator: 'RANGE', sortable: 'custom' },
{
label: t('operation.operationNotice.title'),
prop: 'title',
align: 'center',
minWidth: 200,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{
label: t('operation.operationNotice.notice_type'),
prop: 'notice_type',
align: 'center',
width: 120,
operator: 'eq',
render: 'tag',
effect: 'dark',
custom: { 0: 'info', 1: 'warning' },
replaceValue: {
'0': t('operation.operationNotice.notice_type 0'),
'1': t('operation.operationNotice.notice_type 1'),
},
},
{
label: t('operation.operationNotice.status'),
prop: 'status',
align: 'center',
width: 100,
operator: 'eq',
render: 'tag',
effect: 'dark',
custom: { 0: 'info', 1: 'success' },
replaceValue: {
'0': t('operation.operationNotice.status 0'),
'1': t('operation.operationNotice.status 1'),
},
},
{
label: t('operation.operationNotice.publish_at'),
prop: 'publish_at',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
width: 170,
sortable: 'custom',
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('operation.operationNotice.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
width: 170,
sortable: 'custom',
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('operation.operationNotice.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
width: 170,
sortable: 'custom',
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', minWidth: 80, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
],
},
{
defaultItems: { status: 0, notice_type: 0 },
}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,56 @@
<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">
<FormItem :label="t('operation.operationNotice.title')" type="string" v-model="baTable.form.items!.title" prop="title" />
<FormItem :label="t('operation.operationNotice.content')" type="textarea" v-model="baTable.form.items!.content" prop="content" :input-attr="{ rows: 8 }" />
<FormItem
:label="t('operation.operationNotice.notice_type')"
type="radio"
v-model="baTable.form.items!.notice_type"
prop="notice_type"
:input-attr="{ content: { '0': t('operation.operationNotice.notice_type 0'), '1': t('operation.operationNotice.notice_type 1') } }"
/>
<FormItem
:label="t('operation.operationNotice.status')"
type="radio"
v-model="baTable.form.items!.status"
prop="status"
:input-attr="{ content: { '0': t('operation.operationNotice.status 0'), '1': t('operation.operationNotice.status 1') } }"
/>
<FormItem :label="t('operation.operationNotice.publish_at')" type="datetime" v-model="baTable.form.items!.publish_at" prop="publish_at" />
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
title: [{ required: true, message: t('Please input field', { field: t('operation.operationNotice.title') }) }],
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,116 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('operation.userNoticeRead.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: 'operation/userNoticeRead',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
const baTable = new baTableClass(
new baTableApi('/admin/operation.UserNoticeRead/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('operation.userNoticeRead.id'), prop: 'id', align: 'center', width: 100, operator: 'RANGE', sortable: 'custom' },
{ label: t('operation.userNoticeRead.user_id'), prop: 'user_id', align: 'center', width: 100, operator: 'RANGE', show: false },
{
label: t('operation.userNoticeRead.username'),
prop: 'user.username',
align: 'center',
minWidth: 120,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
},
{ label: t('operation.userNoticeRead.notice_id'), prop: 'notice_id', align: 'center', width: 100, operator: 'RANGE', show: false },
{
label: t('operation.userNoticeRead.notice_title'),
prop: 'operationNotice.title',
align: 'center',
minWidth: 180,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
},
{
label: t('operation.userNoticeRead.read_at'),
prop: 'read_at',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
width: 170,
sortable: 'custom',
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('operation.userNoticeRead.confirmed'),
prop: 'confirmed',
align: 'center',
width: 100,
operator: 'eq',
render: 'tag',
effect: 'dark',
custom: { 0: 'info', 1: 'success' },
replaceValue: {
'0': t('operation.userNoticeRead.confirmed 0'),
'1': t('operation.userNoticeRead.confirmed 1'),
},
},
{
label: t('operation.userNoticeRead.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
width: 170,
sortable: 'custom',
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', minWidth: 80, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
],
},
{
defaultItems: { confirmed: 0 },
}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,74 @@
<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">
<FormItem
:label="t('operation.userNoticeRead.user_id')"
type="remoteSelect"
v-model="baTable.form.items!.user_id"
prop="user_id"
:key="'uid-' + (baTable.form.items!.id ?? 'new')"
:input-attr="{
pk: 'id',
field: 'username',
remoteUrl: '/admin/user.User/index',
placeholder: t('Click select'),
}"
/>
<FormItem
:label="t('operation.userNoticeRead.notice_id')"
type="remoteSelect"
v-model="baTable.form.items!.notice_id"
prop="notice_id"
:key="'nid-' + (baTable.form.items!.id ?? 'new')"
:input-attr="{
pk: 'id',
field: 'title',
remoteUrl: '/admin/operation.OperationNotice/index',
placeholder: t('Click select'),
}"
/>
<FormItem :label="t('operation.userNoticeRead.read_at')" type="datetime" v-model="baTable.form.items!.read_at" prop="read_at" />
<FormItem
:label="t('operation.userNoticeRead.confirmed')"
type="radio"
v-model="baTable.form.items!.confirmed"
prop="confirmed"
:input-attr="{ content: { '0': t('operation.userNoticeRead.confirmed 0'), '1': t('operation.userNoticeRead.confirmed 1') } }"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
user_id: [{ required: true, message: t('Please input field', { field: t('operation.userNoticeRead.user_id') }) }],
notice_id: [{ required: true, message: t('Please input field', { field: t('operation.userNoticeRead.notice_id') }) }],
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,256 @@
<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.betOrder.quick Search Fields') })"
></TableHeader>
<Table ref="tableRef"></Table>
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { baTableApi } from '/@/api/common'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'order/betOrder',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
function formatPickNumbers(_row: anyObj, _column: any, cellValue: unknown) {
if (cellValue === null || cellValue === undefined) {
return '-'
}
if (typeof cellValue === 'string') {
return cellValue
}
try {
return JSON.stringify(cellValue)
} catch {
return String(cellValue)
}
}
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(4)
}
const baTable = new baTableClass(
new baTableApi('/admin/order.BetOrder/'),
{
pk: 'id',
column: [
{ label: t('order.betOrder.id'), prop: 'id', align: 'center', width: 100, operator: 'RANGE', sortable: 'custom' },
{ label: t('order.betOrder.period_id'), prop: 'period_id', align: 'center', width: 100, operator: 'RANGE' },
{
label: t('order.betOrder.period_no'),
prop: 'period_no',
align: 'center',
minWidth: 160,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
},
{
label: t('order.betOrder.gamePeriod_period_no'),
prop: 'gamePeriod.period_no',
align: 'center',
minWidth: 160,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
render: 'tags',
},
{
label: t('order.betOrder.gamePeriod_status'),
prop: 'gamePeriod.status',
align: 'center',
width: 100,
operator: 'eq',
render: 'tag',
effect: 'dark',
custom: {
'0': 'success',
'1': 'warning',
'2': 'info',
'3': 'primary',
'4': 'warning',
'5': 'danger',
},
replaceValue: {
'0': t('order.betOrder.gamePeriod_status 0'),
'1': t('order.betOrder.gamePeriod_status 1'),
'2': t('order.betOrder.gamePeriod_status 2'),
'3': t('order.betOrder.gamePeriod_status 3'),
'4': t('order.betOrder.gamePeriod_status 4'),
'5': t('order.betOrder.gamePeriod_status 5'),
},
},
{
label: t('order.betOrder.user_id'),
prop: 'user_id',
align: 'center',
show: false,
width: 90,
operator: 'RANGE',
},
{
label: t('order.betOrder.user_username'),
prop: 'user.username',
align: 'center',
minWidth: 120,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
render: 'tags',
},
{
label: t('order.betOrder.channel_name'),
prop: 'channel.name',
align: 'center',
minWidth: 100,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
render: 'tags',
},
{
label: t('order.betOrder.pick_numbers'),
prop: 'pick_numbers',
align: 'center',
minWidth: 120,
operator: false,
formatter: formatPickNumbers,
},
{
label: t('order.betOrder.unit_amount'),
prop: 'unit_amount',
align: 'center',
minWidth: 110,
operator: 'RANGE',
formatter: formatAmount,
},
{ label: t('order.betOrder.pick_count'), prop: 'pick_count', align: 'center', width: 90, operator: 'RANGE' },
{
label: t('order.betOrder.total_amount'),
prop: 'total_amount',
align: 'center',
minWidth: 110,
operator: 'RANGE',
formatter: formatAmount,
},
{ label: t('order.betOrder.streak_at_bet'), prop: 'streak_at_bet', align: 'center', width: 110, operator: 'RANGE' },
{
label: t('order.betOrder.is_auto'),
prop: 'is_auto',
align: 'center',
width: 90,
operator: 'eq',
render: 'tag',
custom: {
'0': 'info',
'1': 'primary',
},
replaceValue: {
'0': t('order.betOrder.is_auto 0'),
'1': t('order.betOrder.is_auto 1'),
},
},
{
label: t('order.betOrder.win_amount'),
prop: 'win_amount',
align: 'center',
minWidth: 110,
operator: 'RANGE',
formatter: formatAmount,
},
{
label: t('order.betOrder.jackpot_extra_amount'),
prop: 'jackpot_extra_amount',
align: 'center',
minWidth: 120,
operator: 'RANGE',
formatter: formatAmount,
},
{
label: t('order.betOrder.status'),
prop: 'status',
align: 'center',
width: 100,
operator: 'eq',
render: 'tag',
effect: 'dark',
custom: {
'1': 'warning',
'2': 'success',
'3': 'danger',
},
replaceValue: {
'1': t('order.betOrder.status 1'),
'2': t('order.betOrder.status 2'),
'3': t('order.betOrder.status 3'),
},
},
{
label: t('order.betOrder.idempotency_key'),
prop: 'idempotency_key',
align: 'center',
minWidth: 140,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
showOverflowTooltip: true,
},
{
label: t('order.betOrder.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.betOrder.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 170,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
],
dblClickNotEditColumn: [undefined],
},
{}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,168 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('order.depositOrder.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: 'order/depositOrder',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
const baTable = new baTableClass(
new baTableApi('/admin/order.DepositOrder/'),
{
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_id'), prop: 'user_id', align: 'center', width: 90, operator: 'RANGE' },
{
label: t('order.depositOrder.user_username'),
prop: 'user.username',
align: 'center',
minWidth: 110,
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' },
{
label: t('order.depositOrder.status'),
prop: 'status',
align: 'center',
width: 100,
operator: 'eq',
render: 'tag',
effect: 'dark',
custom: {
'0': 'info',
'1': 'warning',
'2': 'success',
'3': 'danger',
},
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: 110,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
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.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',
},
{
label: t('Operate'),
align: 'center',
width: 90,
render: 'buttons',
buttons: optButtons,
operator: false,
fixed: 'right',
},
],
},
{
defaultItems: { status: 0, 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>

View File

@@ -0,0 +1,48 @@
<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">
<FormItem :label="t('order.depositOrder.order_no')" type="string" v-model="baTable.form.items!.order_no" prop="order_no" />
<FormItem :label="t('order.depositOrder.user_id')" type="number" v-model="baTable.form.items!.user_id" prop="user_id" :input-attr="{ min: 1, step: 1 }" />
<FormItem :label="t('order.depositOrder.channel_id')" type="number" v-model="baTable.form.items!.channel_id" prop="channel_id" :input-attr="{ min: 1, step: 1 }" />
<FormItem :label="t('order.depositOrder.amount')" type="number" v-model="baTable.form.items!.amount" prop="amount" :input-attr="{ step: 0.0001, precision: 4, min: 0 }" />
<FormItem :label="t('order.depositOrder.status')" type="radio" v-model="baTable.form.items!.status" prop="status" :input-attr="{ content: { '0': t('order.depositOrder.status 0'), '1': t('order.depositOrder.status 1'), '2': t('order.depositOrder.status 2'), '3': t('order.depositOrder.status 3') } }" />
<FormItem :label="t('order.depositOrder.pay_channel')" type="string" v-model="baTable.form.items!.pay_channel" prop="pay_channel" />
<FormItem :label="t('order.depositOrder.pay_time')" type="datetime" v-model="baTable.form.items!.pay_time" prop="pay_time" />
<FormItem :label="t('order.depositOrder.remark')" type="textarea" v-model="baTable.form.items!.remark" prop="remark" :input-attr="{ rows: 2 }" />
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
order_no: [{ required: true, message: t('Please input field', { field: t('order.depositOrder.order_no') }) }],
user_id: [{ required: true, message: t('Please input field', { field: t('order.depositOrder.user_id') }) }],
})
</script>
<style scoped lang="scss"></style>

View File

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

View File

@@ -0,0 +1,50 @@
<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">
<FormItem :label="t('order.withdrawOrder.order_no')" type="string" v-model="baTable.form.items!.order_no" prop="order_no" />
<FormItem :label="t('order.withdrawOrder.user_id')" type="number" v-model="baTable.form.items!.user_id" prop="user_id" :input-attr="{ min: 1, step: 1 }" />
<FormItem :label="t('order.withdrawOrder.channel_id')" type="number" v-model="baTable.form.items!.channel_id" prop="channel_id" :input-attr="{ min: 1, step: 1 }" />
<FormItem :label="t('order.withdrawOrder.amount')" type="number" v-model="baTable.form.items!.amount" prop="amount" :input-attr="{ step: 0.0001, precision: 4, min: 0 }" />
<FormItem :label="t('order.withdrawOrder.fee')" type="number" v-model="baTable.form.items!.fee" prop="fee" :input-attr="{ step: 0.0001, precision: 4, min: 0 }" />
<FormItem :label="t('order.withdrawOrder.actual_amount')" type="number" v-model="baTable.form.items!.actual_amount" prop="actual_amount" :input-attr="{ step: 0.0001, precision: 4, min: 0 }" />
<FormItem :label="t('order.withdrawOrder.status')" type="radio" v-model="baTable.form.items!.status" prop="status" :input-attr="{ content: { '0': t('order.withdrawOrder.status 0'), '1': t('order.withdrawOrder.status 1'), '2': t('order.withdrawOrder.status 2'), '3': t('order.withdrawOrder.status 3') } }" />
<FormItem :label="t('order.withdrawOrder.review_admin_id')" type="number" v-model="baTable.form.items!.review_admin_id" prop="review_admin_id" :input-attr="{ min: 1, step: 1 }" />
<FormItem :label="t('order.withdrawOrder.review_time')" type="datetime" v-model="baTable.form.items!.review_time" prop="review_time" />
<FormItem :label="t('order.withdrawOrder.remark')" type="textarea" v-model="baTable.form.items!.remark" prop="remark" :input-attr="{ rows: 2 }" />
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
order_no: [{ required: true, message: t('Please input field', { field: t('order.withdrawOrder.order_no') }) }],
user_id: [{ required: true, message: t('Please input field', { field: t('order.withdrawOrder.user_id') }) }],
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,223 @@
<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('record.userWalletRecord.quick Search Fields') })"
></TableHeader>
<Table ref="tableRef"></Table>
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { baTableApi } from '/@/api/common'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'record/userWalletRecord',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
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(4)
}
const bizReplace = {
deposit: t('record.userWalletRecord.biz deposit'),
withdraw: t('record.userWalletRecord.biz withdraw'),
withdraw_freeze: t('record.userWalletRecord.biz withdraw_freeze'),
withdraw_unfreeze: t('record.userWalletRecord.biz withdraw_unfreeze'),
platform_in: t('record.userWalletRecord.biz platform_in'),
platform_out: t('record.userWalletRecord.biz platform_out'),
admin_credit: t('record.userWalletRecord.biz admin_credit'),
admin_deduct: t('record.userWalletRecord.biz admin_deduct'),
bet: t('record.userWalletRecord.biz bet'),
payout: t('record.userWalletRecord.biz payout'),
fee: t('record.userWalletRecord.biz fee'),
void_refund: t('record.userWalletRecord.biz void_refund'),
adjust: t('record.userWalletRecord.biz adjust'),
}
const bizTypeTagCustom = {
deposit: 'success',
withdraw: 'warning',
withdraw_freeze: 'info',
withdraw_unfreeze: 'info',
platform_in: 'success',
platform_out: 'danger',
admin_credit: 'success',
admin_deduct: 'danger',
bet: 'primary',
payout: 'success',
fee: 'warning',
void_refund: 'info',
adjust: 'warning',
}
const dirReplace = {
'1': t('record.userWalletRecord.direction in'),
'2': t('record.userWalletRecord.direction out'),
}
const baTable = new baTableClass(
new baTableApi('/admin/record.UserWalletRecord/'),
{
pk: 'id',
column: [
{ label: t('record.userWalletRecord.id'), prop: 'id', align: 'center', width: 100, operator: 'RANGE', sortable: 'custom' },
{
label: t('record.userWalletRecord.user_id'),
prop: 'user_id',
align: 'center',
width: 90,
show: false,
operator: 'RANGE',
sortable: false,
},
{
label: t('record.userWalletRecord.user__username'),
prop: 'user.username',
align: 'center',
minWidth: 110,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
render: 'tags',
comSearchRender: 'string',
},
{
label: t('record.userWalletRecord.channel__name'),
prop: 'channel.name',
align: 'center',
minWidth: 100,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
render: 'tags',
comSearchRender: 'string',
},
{
label: t('record.userWalletRecord.biz_type'),
prop: 'biz_type',
align: 'center',
minWidth: 120,
operator: 'eq',
render: 'tag',
custom: bizTypeTagCustom,
replaceValue: bizReplace,
},
{
label: t('record.userWalletRecord.direction'),
prop: 'direction',
align: 'center',
width: 90,
operator: 'eq',
render: 'tag',
custom: {
'1': 'success',
'2': 'danger',
},
replaceValue: dirReplace,
},
{
label: t('record.userWalletRecord.amount'),
prop: 'amount',
align: 'center',
minWidth: 110,
operator: 'RANGE',
formatter: formatAmount,
},
{
label: t('record.userWalletRecord.balance_before'),
prop: 'balance_before',
align: 'center',
minWidth: 110,
operator: 'RANGE',
formatter: formatAmount,
},
{
label: t('record.userWalletRecord.balance_after'),
prop: 'balance_after',
align: 'center',
minWidth: 110,
operator: 'RANGE',
formatter: formatAmount,
},
{
label: t('record.userWalletRecord.ref_type'),
prop: 'ref_type',
align: 'center',
minWidth: 100,
showOverflowTooltip: true,
operator: 'LIKE',
},
{ label: t('record.userWalletRecord.ref_id'), prop: 'ref_id', align: 'center', width: 100, operator: 'RANGE' },
{
label: t('record.userWalletRecord.idempotency_key'),
prop: 'idempotency_key',
align: 'center',
minWidth: 120,
showOverflowTooltip: true,
operator: 'LIKE',
},
{
label: t('record.userWalletRecord.operator_admin__username'),
prop: 'operatorAdmin.username',
align: 'center',
minWidth: 100,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
render: 'tags',
comSearchRender: 'string',
},
{
label: t('record.userWalletRecord.remark'),
prop: 'remark',
align: 'center',
minWidth: 140,
showOverflowTooltip: true,
operator: 'LIKE',
},
{
label: t('record.userWalletRecord.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
],
},
{}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -154,10 +154,11 @@ const baTable = new baTableClass(new baTableApi('/admin/routine.Attachment/'), {
{
label: t('Operate'),
align: 'center',
width: '100',
width: '80',
render: 'buttons',
buttons: optBtn,
operator: false,
fixed: 'right',
},
],
defaultOrder: { prop: 'last_upload_time', order: 'desc' },

View File

@@ -76,6 +76,7 @@ const baTable = new baTableClass(
prop: 'status',
align: 'center',
render: 'tag',
effect: 'dark',
custom: { 0: 'danger', 1: 'success' },
replaceValue: { 0: t('Disable'), 1: t('security.dataRecycle.Deleting monitoring') },
},
@@ -84,10 +85,11 @@ const baTable = new baTableClass(
{
label: t('Operate'),
align: 'center',
width: '130',
width: '80',
render: 'buttons',
buttons: defaultOptButtons(['edit', 'delete']),
operator: false,
fixed: 'right',
},
],
dblClickNotEditColumn: [undefined, 'status'],

View File

@@ -167,6 +167,7 @@ const baTable = new baTableClass(new baTableApi(url), {
render: 'buttons',
buttons: optButtons,
operator: false,
fixed: 'right',
},
],
dblClickNotEditColumn: [undefined],

View File

@@ -85,6 +85,7 @@ const baTable = new sensitiveDataClass(
prop: 'status',
align: 'center',
render: 'tag',
effect: 'dark',
custom: { 0: 'danger', 1: 'success' },
replaceValue: { 0: t('Disable'), 1: t('security.sensitiveData.Modifying monitoring') },
},
@@ -93,10 +94,11 @@ const baTable = new sensitiveDataClass(
{
label: t('Operate'),
align: 'center',
width: '130',
width: '80',
render: 'buttons',
buttons: defaultOptButtons(['edit', 'delete']),
operator: false,
fixed: 'right',
},
],
dblClickNotEditColumn: [undefined],

View File

@@ -180,6 +180,7 @@ const baTable = new baTableClass(new baTableApi(url), {
render: 'buttons',
buttons: optButtons,
operator: false,
fixed: 'right',
},
],
dblClickNotEditColumn: [undefined],

View File

@@ -1,155 +0,0 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('user.group.GroupName') })"
/>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table ref="tableRef" />
<!-- 表单 -->
<PopupForm ref="formRef" />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { getUserRules } from '/@/api/backend/user/group'
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'
import { uuid } from '/@/utils/random'
defineOptions({
name: 'user/group',
})
const { t } = useI18n()
const formRef = useTemplateRef('formRef')
const tableRef = useTemplateRef('tableRef')
const baTable = new baTableClass(
new baTableApi('/admin/user.Group/'),
{
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ label: t('user.group.Group name'), prop: 'name', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('State'),
prop: 'status',
align: 'center',
render: 'tag',
custom: { 0: 'danger', 1: 'success' },
replaceValue: { 0: t('Disable'), 1: t('Enable') },
},
{ label: t('Update time'), prop: 'update_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
{ label: t('Create time'), prop: 'create_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
{
label: t('Operate'),
align: 'center',
width: '130',
render: 'buttons',
buttons: defaultOptButtons(['edit', 'delete']),
operator: false,
},
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: {
status: 1,
},
}
)
// 利用提交前钩子重写提交操作
baTable.before.onSubmit = ({ formEl, operate, items }) => {
let submitCallback = () => {
baTable.form.submitLoading = true
baTable.api
.postData(operate, {
...items,
rules: formRef.value?.getCheckeds(),
})
.then((res) => {
baTable.onTableHeaderAction('refresh', {})
baTable.form.submitLoading = false
baTable.form.operateIds?.shift()
if (baTable.form.operateIds!.length > 0) {
baTable.toggleForm('Edit', baTable.form.operateIds)
} else {
baTable.toggleForm()
}
baTable.runAfter('onSubmit', { res })
})
.catch(() => {
baTable.form.submitLoading = false
})
}
if (formEl) {
baTable.form.ref = formEl
formEl.validate((valid) => {
if (valid) {
submitCallback()
}
})
} else {
submitCallback()
}
return false
}
// 打开表单后
baTable.after.toggleForm = ({ operate }) => {
if (operate == 'Add') {
menuRuleTreeUpdate()
}
}
// 获取到编辑数据后
baTable.after.getEditData = () => {
menuRuleTreeUpdate()
}
const menuRuleTreeUpdate = () => {
getUserRules().then((res) => {
baTable.form.extend!.menuRules = res.data.list
if (baTable.form.items!.rules && baTable.form.items!.rules.length) {
if (baTable.form.items!.rules.includes('*')) {
let arr: number[] = []
for (const key in baTable.form.extend!.menuRules) {
arr.push(baTable.form.extend!.menuRules[key].id)
}
baTable.form.extend!.defaultCheckedKeys = arr
} else {
baTable.form.extend!.defaultCheckedKeys = baTable.form.items!.rules
}
} else {
baTable.form.extend!.defaultCheckedKeys = []
}
baTable.form.extend!.treeKey = uuid()
})
}
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()
})
</script>
<style scoped lang="scss"></style>

View File

@@ -1,145 +0,0 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
top="10vh"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
:destroy-on-close="true"
>
<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
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-form-item prop="name" :label="t('user.group.Group name')">
<el-input
v-model="baTable.form.items!.name"
type="string"
:placeholder="t('Please input field', { field: t('user.group.Group name') })"
></el-input>
</el-form-item>
<el-form-item prop="auth" :label="t('user.group.jurisdiction')">
<el-tree
ref="treeRef"
:key="baTable.form.extend!.treeKey"
:default-checked-keys="baTable.form.extend!.defaultCheckedKeys"
:default-expand-all="true"
show-checkbox
node-key="id"
:props="{ children: 'children', label: 'title', class: treeNodeClass }"
:data="baTable.form.extend!.menuRules"
class="w100"
/>
</el-form-item>
<FormItem
:label="t('State')"
v-model="baTable.form.items!.status"
type="radio"
:input-attr="{
border: true,
content: { 0: t('Disable'), 1: t('Enable') },
}"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, inject, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import type { ElTree, FormItemRule } from 'element-plus'
import FormItem from '/@/components/formItem/index.vue'
import type Node from 'element-plus/es/components/tree/src/model/node'
import { buildValidatorData } from '/@/utils/validate'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const treeRef = useTemplateRef('treeRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
name: [buildValidatorData({ name: 'required', title: t('user.group.Group name') })],
auth: [
{
required: true,
validator: (rule: any, val: string, callback: Function) => {
let ids = getCheckeds()
if (ids.length <= 0) {
return callback(new Error(t('Please select field', { field: t('user.group.jurisdiction') })))
}
return callback()
},
},
],
})
const getCheckeds = () => {
return treeRef.value!.getCheckedKeys().concat(treeRef.value!.getHalfCheckedKeys())
}
const treeNodeClass = (data: anyObj, node: Node) => {
if (node.isLeaf) return ''
let addClass = true
for (const key in node.childNodes) {
if (!node.childNodes[key].isLeaf) {
addClass = false
}
}
return addClass ? 'penultimate-node' : ''
}
defineExpose({
getCheckeds,
})
</script>
<style scoped lang="scss">
:deep(.penultimate-node) {
.el-tree-node__children {
padding-left: 60px;
white-space: pre-wrap;
line-height: 12px;
.el-tree-node {
display: inline-block;
}
.el-tree-node__content {
padding-left: 5px !important;
padding-right: 5px;
.el-tree-node__expand-icon {
display: none;
}
}
}
}
</style>

View File

@@ -1,147 +0,0 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="
t('Quick search placeholder', { fields: t('user.moneyLog.User name') + '/' + t('user.moneyLog.User nickname') })
"
>
<el-button v-if="!isEmpty(state.userInfo)" v-blur class="table-header-operate">
<span class="table-header-operate-text">
{{ state.userInfo.username + '(ID:' + state.userInfo.id + ') ' + t('user.moneyLog.balance') + ':' + state.userInfo.money }}
</span>
</el-button>
</TableHeader>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table />
<!-- 表单 -->
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { debounce, isEmpty, parseInt } from 'lodash-es'
import { provide, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import PopupForm from './popupForm.vue'
import { add, url } from '/@/api/backend/user/moneyLog'
import { baTableApi } from '/@/api/common'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'user/moneyLog',
})
const { t } = useI18n()
const route = useRoute()
const defalutUser = (route.query.user_id ?? '') as string
const state = reactive({
userInfo: {} as anyObj,
})
const baTable = new baTableClass(
new baTableApi(url),
{
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ label: t('user.moneyLog.User ID'), prop: 'user_id', align: 'center', width: 70 },
{ label: t('user.moneyLog.User name'), prop: 'user.username', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('user.moneyLog.User nickname'),
prop: 'user.nickname',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{ label: t('user.moneyLog.Change balance'), prop: 'money', align: 'center', operator: 'RANGE', sortable: 'custom' },
{ label: t('user.moneyLog.Before change'), prop: 'before', align: 'center', operator: 'RANGE', sortable: 'custom' },
{ label: t('user.moneyLog.After change'), prop: 'after', align: 'center', operator: 'RANGE', sortable: 'custom' },
{
label: t('user.moneyLog.remarks'),
prop: 'memo',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{ label: t('Create time'), prop: 'create_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
],
dblClickNotEditColumn: ['all'],
},
{
defaultItems: {
user_id: defalutUser,
memo: '',
},
}
)
// 表单提交后
baTable.after.onSubmit = () => {
getUserInfo(baTable.comSearch.form.user_id)
}
baTable.after.onTableHeaderAction = ({ event }) => {
// 刷新后
if (event == 'refresh') {
getUserInfo(baTable.comSearch.form.user_id)
}
}
baTable.before.onTableAction = ({ event }) => {
// 公共搜索
if (event === 'com-search') {
baTable.table.filter!.search = baTable.getComSearchData()
for (const key in baTable.table.filter!.search) {
if (['money', 'before', 'after'].includes(baTable.table.filter!.search[key].field)) {
const val = (baTable.table.filter!.search[key].val as string).split(',')
const newVal: (string | number)[] = []
for (const k in val) {
newVal.push(isNaN(parseFloat(val[k])) ? '' : parseFloat(val[k]) * 100)
}
baTable.table.filter!.search[key].val = newVal.join(',')
}
}
baTable.onTableHeaderAction('refresh', { event: 'com-search', data: baTable.table.filter!.search })
return false
}
}
baTable.mount()
baTable.getData()
provide('baTable', baTable)
const getUserInfo = debounce((userId: string) => {
if (userId && parseInt(userId) > 0) {
add(userId).then((res) => {
state.userInfo = res.data.user
})
} else {
state.userInfo = {}
}
}, 300)
getUserInfo(baTable.comSearch.form.user_id)
watch(
() => baTable.comSearch.form.user_id,
(newVal) => {
baTable.form.defaultItems!.user_id = newVal
getUserInfo(newVal)
}
)
</script>
<style scoped lang="scss"></style>

View File

@@ -1,160 +0,0 @@
<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 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
ref="formRef"
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
v-if="!baTable.form.loading"
>
<FormItem
type="remoteSelect"
prop="user_id"
:label="t('user.moneyLog.User ID')"
v-model="baTable.form.items!.user_id"
:placeholder="t('Click select')"
:input-attr="{
pk: 'user.id',
field: 'nickname_text',
remoteUrl: '/admin/user.User/index',
onChange: getAdd,
}"
/>
<el-form-item :label="t('user.moneyLog.User name')">
<el-input v-model="state.userInfo.username" disabled></el-input>
</el-form-item>
<el-form-item :label="t('user.moneyLog.User nickname')">
<el-input v-model="state.userInfo.nickname" disabled></el-input>
</el-form-item>
<el-form-item :label="t('user.moneyLog.Current balance')">
<el-input v-model="state.userInfo.money" disabled type="number"></el-input>
</el-form-item>
<el-form-item prop="money" :label="t('user.moneyLog.Change amount')">
<el-input
@input="changeMoney"
v-model="baTable.form.items!.money"
type="number"
:placeholder="t('user.moneyLog.Please enter the balance change amount')"
></el-input>
</el-form-item>
<el-form-item :label="t('user.moneyLog.Balance after change')">
<el-input v-model="state.after" type="number" disabled></el-input>
</el-form-item>
<el-form-item prop="memo" :label="t('user.moneyLog.remarks')">
<el-input
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
v-model="baTable.form.items!.memo"
type="textarea"
:placeholder="t('user.moneyLog.Please enter change remarks / description')"
></el-input>
</el-form-item>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds!.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, inject, watch, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import { add } from '/@/api/backend/user/moneyLog'
import FormItem from '/@/components/formItem/index.vue'
import type { FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const { t } = useI18n()
const baTable = inject('baTable') as baTableClass
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
user_id: [buildValidatorData({ name: 'required', message: t('Please select field', { field: t('user.moneyLog.User') }) })],
money: [
buildValidatorData({ name: 'required', title: t('user.moneyLog.Change amount') }),
{
validator: (rule: any, val: string, callback: Function) => {
if (!val || parseFloat(val) == 0) {
return callback(new Error(t('Please enter the correct field', { field: t('user.moneyLog.Change amount') })))
}
return callback()
},
trigger: 'blur',
},
],
memo: [buildValidatorData({ name: 'required', title: t('user.moneyLog.remarks') })],
})
const formRef = useTemplateRef('formRef')
const state: {
userInfo: anyObj
after: number
} = reactive({
userInfo: {},
after: 0,
})
const getAdd = () => {
if (!baTable.form.items!.user_id || parseInt(baTable.form.items!.user_id) <= 0) {
return
}
add(baTable.form.items!.user_id).then((res) => {
state.userInfo = res.data.user
state.after = res.data.user.money
})
}
const changeMoney = (value: string) => {
if (!state.userInfo || typeof state.userInfo == 'undefined') {
state.after = 0
return
}
let newValue = value == '' ? 0 : parseFloat(value)
state.after = parseFloat((parseFloat(state.userInfo.money) + newValue).toFixed(2))
}
// 打开表单时刷新用户数据
watch(
() => baTable.form.operate,
(newValue) => {
if (newValue) {
getAdd()
}
}
)
</script>
<style scoped lang="scss">
.preview-img {
width: 60px;
height: 60px;
}
</style>

View File

@@ -1,108 +0,0 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'unfold', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('auth.rule.Rule title') })"
/>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table ref="tableRef" :pagination="false" />
<!-- 表单 -->
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import baTableClass from '/@/utils/baTable'
import PopupForm from './popupForm.vue'
import Table from '/@/components/table/index.vue'
import TableHeader from '/@/components/table/header/index.vue'
import { defaultOptButtons } from '/@/components/table'
import { baTableApi } from '/@/api/common'
import { useI18n } from 'vue-i18n'
defineOptions({
name: 'user/rule',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const baTable = new baTableClass(
new baTableApi('/admin/user.Rule/'),
{
expandAll: false,
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('auth.rule.title'), prop: 'title', align: 'left', width: '200' },
{ label: t('auth.rule.Icon'), prop: 'icon', align: 'center', width: '60', render: 'icon', default: 'fa fa-circle-o' },
{ label: t('auth.rule.name'), prop: 'name', align: 'center', showOverflowTooltip: true },
{
label: t('auth.rule.type'),
prop: 'type',
align: 'center',
render: 'tag',
custom: { menu: 'danger', menu_dir: 'success', route: 'info' },
replaceValue: {
menu: t('user.rule.Member center menu items'),
menu_dir: t('user.rule.Member center menu contents'),
route: t('user.rule.Normal routing'),
nav: t('user.rule.Top bar menu items'),
button: t('user.rule.Page button'),
nav_user_menu: t('user.rule.Top bar user dropdown'),
},
},
{ label: t('State'), prop: 'status', align: 'center', width: '80', render: 'switch' },
{ label: t('Update time'), prop: 'update_time', align: 'center', width: '160', render: 'datetime' },
{ label: t('Create time'), prop: 'create_time', align: 'center', width: '160', render: 'datetime' },
{ label: t('Operate'), align: 'center', width: '130', render: 'buttons', buttons: defaultOptButtons() },
],
dblClickNotEditColumn: [undefined, 'status'],
},
{
defaultItems: {
type: 'route',
menu_type: 'tab',
extend: 'none',
no_login_valid: '0',
keepalive: 0,
status: 1,
icon: 'fa fa-circle-o',
},
}
)
// 表单提交前
baTable.before.onSubmit = () => {
if (baTable.form.items!.type == 'route') {
baTable.form.items!.menu_type = 'tab'
} else if (['menu', 'menu_dir', 'nav_user_menu'].includes(baTable.form.items!.type)) {
baTable.form.items!.no_login_valid = '0'
}
}
// 取得编辑行的数据后
baTable.after.getEditData = () => {
if (baTable.form.items && !baTable.form.items.icon) {
baTable.form.items.icon = 'fa fa-circle-o'
}
}
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -1,237 +0,0 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
top="5vh"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
:destroy-on-close="true"
>
<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
ref="formRef"
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
v-if="!baTable.form.loading"
>
<FormItem
type="remoteSelect"
prop="pid"
:label="t('auth.rule.Superior menu rule')"
v-model="baTable.form.items!.pid"
:placeholder="t('Click select')"
:input-attr="{
params: { isTree: true },
field: 'title',
remoteUrl: baTable.api.actionUrl.get('index'),
emptyValues: ['', null, undefined, 0],
valueOnClear: 0,
}"
/>
<el-form-item :label="t('auth.rule.Rule type')">
<el-radio-group v-model="baTable.form.items!.type">
<el-radio class="ba-el-radio" value="route" :border="true">{{ t('user.rule.Normal routing') }}</el-radio>
<el-radio class="ba-el-radio" value="menu_dir" :border="true">{{ t('user.rule.Member center menu contents') }}</el-radio>
<el-radio class="ba-el-radio" value="menu" :border="true">{{ t('user.rule.Member center menu items') }}</el-radio>
<el-radio class="ba-el-radio" value="nav" :border="true">{{ t('user.rule.Top bar menu items') }}</el-radio>
<el-radio class="ba-el-radio" value="button" :border="true">{{ t('user.rule.Page button') }}</el-radio>
<el-radio class="ba-el-radio" value="nav_user_menu" :border="true">{{ t('user.rule.Top bar user dropdown') }}</el-radio>
</el-radio-group>
<div class="block-help">{{ t('user.rule.Type ' + baTable.form.items!.type + ' tips') }}</div>
</el-form-item>
<el-form-item prop="title" :label="t('auth.rule.Rule title')">
<el-input
v-model="baTable.form.items!.title"
type="string"
:placeholder="t('Please input field', { field: t('auth.rule.Rule title') })"
></el-input>
</el-form-item>
<el-form-item prop="name" :label="t('auth.rule.Rule name')">
<el-input v-model="baTable.form.items!.name" type="string" :placeholder="t('user.rule.English name')"></el-input>
<div class="block-help">
{{ t('auth.rule.It will be registered as the web side routing name and used as the server side API authentication') }}
</div>
</el-form-item>
<el-form-item v-if="baTable.form.items!.type != 'button'" prop="path" :label="t('auth.rule.Routing path')">
<el-input v-model="baTable.form.items!.path" type="string" :placeholder="t('user.rule.Web side routing path')"></el-input>
</el-form-item>
<!-- 规则图标 -->
<FormItem
v-if="baTable.form.items!.type != 'button'"
type="icon"
:label="t('auth.rule.Rule Icon')"
v-model="baTable.form.items!.icon"
:input-attr="{ showIconName: true }"
/>
<!-- 菜单类型tablinkiframe -->
<FormItem
v-if="!['menu_dir', 'button', 'route'].includes(baTable.form.items!.type)"
:label="t('auth.rule.Menu type')"
v-model="baTable.form.items!.menu_type"
type="radio"
:input-attr="{
border: true,
content: { tab: t('auth.rule.Menu type tab'), link: t('auth.rule.Menu type link (offsite)'), iframe: 'Iframe' },
}"
/>
<!-- URL -->
<el-form-item
prop="url"
v-if="!['menu_dir', 'button', 'route'].includes(baTable.form.items!.type) && baTable.form.items!.menu_type != 'tab'"
:label="t('auth.rule.Link address')"
>
<el-input
v-model="baTable.form.items!.url"
type="string"
:placeholder="t('auth.rule.Please enter the URL address of the link or iframe')"
></el-input>
</el-form-item>
<!-- 组件路径 -->
<el-form-item
v-if="
baTable.form.items!.type == 'route' ||
(!['menu_dir', 'button'].includes(baTable.form.items!.type) && baTable.form.items!.menu_type == 'tab')
"
:label="t('auth.rule.Component path')"
>
<el-input
v-model="baTable.form.items!.component"
type="string"
:placeholder="t('user.rule.For example, if you add account/overview as a route only')"
></el-input>
<div class="block-help component-path-tips">
{{ t('user.rule.Component path tips') }}
</div>
</el-form-item>
<!-- 扩展属性 -->
<el-form-item
v-if="!['menu_dir', 'button'].includes(baTable.form.items!.type) && baTable.form.items!.menu_type == 'tab'"
:label="t('auth.rule.Extended properties')"
>
<el-select
class="w100"
v-model="baTable.form.items!.extend"
:placeholder="t('Please select field', { field: t('auth.rule.Extended properties') })"
>
<el-option :label="t('auth.rule.none')" value="none"></el-option>
<el-option :label="t('auth.rule.Add as route only')" value="add_rules_only"></el-option>
<el-option :label="t('auth.rule.Add as menu only')" value="add_menu_only"></el-option>
</el-select>
<div class="block-help">
{{ t('user.rule.Web side component path, please start with /src, such as: /src/views/frontend/index') }}
</div>
</el-form-item>
<FormItem
v-if="!['menu_dir', 'menu', 'nav_user_menu'].includes(baTable.form.items!.type)"
:label="t('user.rule.no_login_valid')"
v-model="baTable.form.items!.no_login_valid"
type="radio"
:input-attr="{
border: true,
content: { '0': t('user.rule.no_login_valid 0'), '1': t('user.rule.no_login_valid 1') },
}"
:block-help="t('user.rule.no_login_valid tips')"
/>
<el-form-item :label="t('auth.rule.Rule comments')">
<el-input
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
v-model="baTable.form.items!.remark"
type="textarea"
:autosize="{ minRows: 2, maxRows: 5 }"
:placeholder="t('Please input field', { field: t('auth.rule.Rule comments') })"
></el-input>
</el-form-item>
<el-form-item :label="t('auth.rule.Rule weight')">
<el-input
v-model="baTable.form.items!.weigh"
type="number"
:placeholder="t('auth.rule.Please enter the weight of menu rule (sort by)')"
></el-input>
</el-form-item>
<FormItem
:label="t('State')"
v-model="baTable.form.items!.status"
type="radio"
:input-attr="{
border: true,
content: { 0: t('Disable'), 1: t('Enable') },
}"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, inject, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import FormItem from '/@/components/formItem/index.vue'
import type { FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
title: [buildValidatorData({ name: 'required', title: t('auth.rule.Rule title') })],
pid: [
{
validator: (rule: any, val: string, callback: Function) => {
if (!val) {
return callback()
}
if (parseInt(val) == parseInt(baTable.form.items!.id)) {
return callback(new Error(t('auth.rule.The superior menu rule cannot be the rule itself')))
}
return callback()
},
trigger: 'blur',
},
],
name: [buildValidatorData({ name: 'required', title: t('auth.rule.Rule name') })],
path: [buildValidatorData({ name: 'required', title: t('auth.rule.Routing path') })],
url: [
buildValidatorData({ name: 'required', message: t('auth.rule.Link address') }),
buildValidatorData({ name: 'url', message: t('auth.rule.Please enter the correct URL') }),
],
})
</script>
<style scoped lang="scss">
.ba-el-radio {
margin-bottom: 10px;
}
.component-path-tips {
color: var(--el-color-warning);
}
</style>

View File

@@ -1,126 +0,0 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="
t('Quick search placeholder', { fields: t('user.moneyLog.User name') + '/' + t('user.moneyLog.User nickname') })
"
>
<el-button v-if="!isEmpty(state.userInfo)" v-blur class="table-header-operate">
<span class="table-header-operate-text">
{{ state.userInfo.username + '(ID:' + state.userInfo.id + ') ' + t('user.scoreLog.integral') + ':' + state.userInfo.score }}
</span>
</el-button>
</TableHeader>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table />
<!-- 表单 -->
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { debounce, isEmpty, parseInt } from 'lodash-es'
import { provide, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import PopupForm from './popupForm.vue'
import { add, url } from '/@/api/backend/user/scoreLog'
import { baTableApi } from '/@/api/common'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'user/scoreLog',
})
const { t } = useI18n()
const route = useRoute()
const defalutUser = (route.query.user_id ?? '') as string
const state = reactive({
userInfo: {} as anyObj,
})
const baTable = new baTableClass(
new baTableApi(url),
{
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ label: t('user.moneyLog.User ID'), prop: 'user_id', align: 'center', width: 70 },
{ label: t('user.moneyLog.User name'), prop: 'user.username', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('user.moneyLog.User nickname'),
prop: 'user.nickname',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{ label: t('user.scoreLog.Change points'), prop: 'score', align: 'center', operator: 'RANGE', sortable: 'custom' },
{ label: t('user.moneyLog.Before change'), prop: 'before', align: 'center', operator: 'RANGE', sortable: 'custom' },
{ label: t('user.moneyLog.After change'), prop: 'after', align: 'center', operator: 'RANGE', sortable: 'custom' },
{
label: t('user.moneyLog.remarks'),
prop: 'memo',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{ label: t('Create time'), prop: 'create_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
],
dblClickNotEditColumn: ['all'],
},
{
defaultItems: {
user_id: defalutUser,
memo: '',
},
}
)
// 表单提交后
baTable.after.onSubmit = () => {
getUserInfo(baTable.comSearch.form.user_id)
}
baTable.after.onTableHeaderAction = ({ event }) => {
// 刷新后
if (event == 'refresh') {
getUserInfo(baTable.comSearch.form.user_id)
}
}
baTable.mount()
baTable.getData()
provide('baTable', baTable)
const getUserInfo = debounce((userId: string) => {
if (userId && parseInt(userId) > 0) {
add(userId).then((res) => {
state.userInfo = res.data.user
})
} else {
state.userInfo = {}
}
}, 300)
getUserInfo(baTable.comSearch.form.user_id)
watch(
() => baTable.comSearch.form.user_id,
(newVal) => {
baTable.form.defaultItems!.user_id = newVal
getUserInfo(newVal)
}
)
</script>
<style scoped lang="scss"></style>

View File

@@ -1,160 +0,0 @@
<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 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
ref="formRef"
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
v-if="!baTable.form.loading"
>
<FormItem
type="remoteSelect"
prop="user_id"
:label="t('user.moneyLog.User ID')"
v-model="baTable.form.items!.user_id"
:placeholder="t('Click select')"
:input-attr="{
pk: 'user.id',
field: 'nickname_text',
remoteUrl: '/admin/user.User/index',
onChange: getAdd,
}"
/>
<el-form-item :label="t('user.moneyLog.User name')">
<el-input v-model="state.userInfo.username" disabled></el-input>
</el-form-item>
<el-form-item :label="t('user.moneyLog.User nickname')">
<el-input v-model="state.userInfo.nickname" disabled></el-input>
</el-form-item>
<el-form-item :label="t('user.scoreLog.Current points')">
<el-input v-model="state.userInfo.score" disabled type="number"></el-input>
</el-form-item>
<el-form-item prop="score" :label="t('user.moneyLog.Change amount')">
<el-input
@input="changeScore"
v-model="baTable.form.items!.score"
type="number"
:placeholder="t('user.scoreLog.Please enter the change amount of points')"
></el-input>
</el-form-item>
<el-form-item :label="t('user.scoreLog.Points after change')">
<el-input v-model="state.after" type="number" disabled></el-input>
</el-form-item>
<el-form-item prop="memo" :label="t('user.moneyLog.remarks')">
<el-input
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
v-model="baTable.form.items!.memo"
type="textarea"
:placeholder="t('user.scoreLog.Please enter change remarks / description')"
></el-input>
</el-form-item>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds!.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, inject, watch, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import { add } from '/@/api/backend/user/scoreLog'
import FormItem from '/@/components/formItem/index.vue'
import type { FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const { t } = useI18n()
const baTable = inject('baTable') as baTableClass
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
user_id: [buildValidatorData({ name: 'required', message: t('Please select field', { field: t('user.moneyLog.User') }) })],
score: [
buildValidatorData({ name: 'required', title: t('user.moneyLog.Change amount') }),
{
validator: (rule: any, val: string, callback: Function) => {
if (!val || parseInt(val) == 0) {
return callback(new Error(t('Please enter the correct field', { field: t('user.moneyLog.Change amount') })))
}
return callback()
},
trigger: 'blur',
},
],
memo: [buildValidatorData({ name: 'required', title: t('user.moneyLog.remarks') })],
})
const formRef = useTemplateRef('formRef')
const state: {
userInfo: anyObj
after: number
} = reactive({
userInfo: {},
after: 0,
})
const getAdd = () => {
if (!baTable.form.items!.user_id || parseInt(baTable.form.items!.user_id) <= 0) {
return
}
add(baTable.form.items!.user_id).then((res) => {
state.userInfo = res.data.user
state.after = res.data.user.score
})
}
const changeScore = (value: string) => {
if (!state.userInfo || typeof state.userInfo == 'undefined') {
state.after = 0
return
}
let newValue = value == '' ? 0 : parseFloat(value)
state.after = parseFloat(state.userInfo.score) + newValue
}
// 打开表单时刷新用户数据
watch(
() => baTable.form.operate,
(newValue) => {
if (newValue) {
getAdd()
}
}
)
</script>
<style scoped lang="scss">
.preview-img {
width: 60px;
height: 60px;
}
</style>

View File

@@ -1,114 +1,251 @@
<template>
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('user.user.User name') + '/' + t('user.user.nickname') })"
/>
:quick-search-placeholder="t('Quick search placeholder', { fields: t('user.user.quick Search Fields') })"
></TableHeader>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table />
<Table ref="tableRef"></Table>
<!-- 表单 -->
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { provide } from 'vue'
import baTableClass from '/@/utils/baTable'
import PopupForm from './popupForm.vue'
import Table from '/@/components/table/index.vue'
import TableHeader from '/@/components/table/header/index.vue'
import { defaultOptButtons } from '/@/components/table'
import { baTableApi } from '/@/api/common'
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: 'user/user',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
function formatCoin(_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)
}
/** 杩斿洖澶氭爣绛炬枃妗堟暟缁勶紝渚?render: tags 浣跨敤 */
function formatRiskFlags(row: anyObj, _column: any, cellValue: unknown) {
const raw = cellValue !== undefined && cellValue !== null && cellValue !== '' ? cellValue : row.risk_flags
const f = typeof raw === 'number' ? raw : parseInt(String(raw ?? '0'), 10) || 0
const parts: string[] = []
if (f & 1) parts.push(t('user.user.risk_no_login'))
if (f & 2) parts.push(t('user.user.risk_no_bet'))
if (f & 4) parts.push(t('user.user.risk_no_withdraw'))
if (!parts.length) parts.push(t('user.user.risk_none'))
return parts
}
const baTable = new baTableClass(
new baTableApi('/admin/user.User/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ label: t('user.user.User name'), prop: 'username', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{ label: t('user.user.nickname'), prop: 'nickname', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{ label: t('user.user.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{
label: t('user.user.group'),
prop: 'userGroup.name',
label: t('user.user.username'),
prop: 'username',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
render: 'tag',
},
{ label: t('user.user.avatar'), prop: 'avatar', align: 'center', render: 'image', operator: false },
{
label: t('user.user.Gender'),
prop: 'gender',
align: 'center',
render: 'tag',
custom: { '0': 'info', '1': '', '2': 'success' },
replaceValue: { '0': t('Unknown'), '1': t('user.user.male'), '2': t('user.user.female') },
},
{ label: t('user.user.mobile'), prop: 'mobile', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('user.user.Last login IP'),
prop: 'last_login_ip',
align: 'center',
sortable: false,
operator: 'LIKE',
},
{ label: t('user.user.phone'), prop: 'phone', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
{
label: t('user.user.email'),
prop: 'email',
align: 'center',
minWidth: 120,
showOverflowTooltip: true,
operatorPlaceholder: t('Fuzzy query'),
render: 'tag',
operator: 'LIKE',
},
{
label: t('user.user.Last login'),
prop: 'last_login_time',
label: t('user.user.head_image'),
prop: 'head_image',
align: 'center',
render: 'datetime',
sortable: 'custom',
width: 72,
operator: false,
render: 'image',
},
{
label: t('user.user.uuid'),
prop: 'uuid',
align: 'center',
showOverflowTooltip: true,
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('user.user.register_invite_code'),
prop: 'register_invite_code',
align: 'center',
minWidth: 100,
showOverflowTooltip: true,
operator: 'LIKE',
},
{ label: t('user.user.coin'), prop: 'coin', align: 'center', sortable: false, operator: 'RANGE', formatter: formatCoin },
{
label: t('user.user.total_deposit_coin'),
prop: 'total_deposit_coin',
align: 'center',
minWidth: 110,
sortable: false,
operator: 'RANGE',
width: 160,
formatter: formatCoin,
},
{ label: t('Create time'), prop: 'create_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
{
label: t('State'),
label: t('user.user.total_valid_bet_coin'),
prop: 'total_valid_bet_coin',
align: 'center',
minWidth: 110,
sortable: false,
operator: 'RANGE',
formatter: formatCoin,
},
{
label: t('user.user.risk_flags'),
prop: 'risk_flags',
align: 'center',
render: 'tags',
minWidth: 200,
operator: false,
formatter: formatRiskFlags,
custom: {
[t('user.user.risk_none')]: 'primary',
[t('user.user.risk_no_login')]: 'danger',
[t('user.user.risk_no_bet')]: 'danger',
[t('user.user.risk_no_withdraw')]: 'danger',
},
},
{ label: t('user.user.current_streak'), prop: 'current_streak', align: 'center', width: 90, operator: 'RANGE' },
{
label: t('user.user.last_bet_period_no'),
prop: 'last_bet_period_no',
align: 'center',
minWidth: 120,
showOverflowTooltip: true,
operator: 'LIKE',
},
{
label: t('user.user.status'),
prop: 'status',
align: 'center',
render: 'tag',
custom: { disable: 'danger', enable: 'success' },
replaceValue: { disable: t('Disable'), enable: t('Enable') },
operator: 'eq',
sortable: false,
render: 'switch',
replaceValue: { '0': t('user.user.status 0'), '1': t('user.user.status 1') },
},
{
label: t('Operate'),
label: t('user.user.channel__name'),
prop: 'channel.name',
align: 'center',
width: '100',
render: 'buttons',
buttons: defaultOptButtons(['edit', 'delete']),
operator: false,
minWidth: 100,
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
operator: 'LIKE',
comSearchRender: 'string',
},
{
label: t('user.user.admin__username'),
prop: 'admin.username',
align: 'center',
minWidth: 90,
effect: 'plain',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
operator: 'LIKE',
comSearchRender: 'string',
customRenderAttr: {
tag: () => ({
color: '#e8f3ff',
style: { color: '#1677ff', borderColor: '#91caff' },
}),
},
},
{
label: t('user.user.remark'),
prop: 'remark',
align: 'center',
minWidth: 100,
showOverflowTooltip: true,
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('user.user.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('user.user.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', width: 80, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
],
dblClickNotEditColumn: [undefined],
dblClickNotEditColumn: [undefined, 'status'],
},
{
defaultItems: {
gender: 0,
money: '0',
score: '0',
status: 'enable',
status: '1',
coin: '0.0000',
total_deposit_coin: '0.0000',
total_valid_bet_coin: '0.0000',
risk_flags: 0,
current_streak: 0,
last_bet_period_no: '',
register_invite_code: '',
email: '',
head_image: '',
},
}
)
baTable.mount()
baTable.getData()
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -1,9 +1,7 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:destroy-on-close="true"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
>
@@ -19,123 +17,182 @@
: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"
v-if="!baTable.form.loading"
>
<el-form-item prop="username" :label="t('user.user.User name')">
<el-input
v-model="baTable.form.items!.username"
type="string"
:placeholder="t('Please input field', { field: t('user.user.User name') + '(' + t('user.user.Login account') + ')' })"
></el-input>
</el-form-item>
<el-form-item prop="nickname" :label="t('user.user.nickname')">
<el-input
v-model="baTable.form.items!.nickname"
type="string"
:placeholder="t('Please input field', { field: t('user.user.nickname') })"
></el-input>
</el-form-item>
<!-- <el-alert class="game-user-form-tip" type="info" :closable="false" show-icon>-->
<!-- {{ t('user.user.form_tip') }}-->
<!-- </el-alert>-->
<el-divider content-position="left">{{ t('user.user.section_basic') }}</el-divider>
<FormItem
type="remoteSelect"
:label="t('user.user.group')"
v-model="baTable.form.items!.group_id"
prop="group_id"
:placeholder="t('user.user.group')"
:input-attr="{
params: { isTree: true, search: [{ field: 'status', val: '1', operator: 'eq' }] },
field: 'name',
remoteUrl: '/admin/user.Group/index',
}"
:label="t('user.user.username')"
type="string"
v-model="baTable.form.items!.username"
prop="username"
:placeholder="t('Please input field', { field: t('user.user.username') })"
/>
<FormItem :label="t('user.user.avatar')" type="image" v-model="baTable.form.items!.avatar" />
<el-form-item prop="email" :label="t('user.user.email')">
<el-input
v-model="baTable.form.items!.email"
type="string"
:placeholder="t('Please input field', { field: t('user.user.email') })"
></el-input>
</el-form-item>
<el-form-item prop="mobile" :label="t('user.user.mobile')">
<el-input
v-model="baTable.form.items!.mobile"
type="string"
:placeholder="t('Please input field', { field: t('user.user.mobile') })"
></el-input>
</el-form-item>
<FormItem
:label="t('user.user.Gender')"
v-model="baTable.form.items!.gender"
type="radio"
:input-attr="{
border: true,
content: { 0: t('Unknown'), 1: t('user.user.male'), 2: t('user.user.female') },
}"
:label="t('user.user.password')"
type="password"
v-model="baTable.form.items!.password"
prop="password"
:placeholder="t('Please input field', { field: t('user.user.password') })"
/>
<el-form-item :label="t('user.user.birthday')">
<el-date-picker
<FormItem
:label="t('user.user.phone')"
type="string"
v-model="baTable.form.items!.phone"
prop="phone"
:placeholder="t('Please input field', { field: t('user.user.phone') })"
/>
<FormItem
:label="t('user.user.email')"
type="string"
v-model="baTable.form.items!.email"
prop="email"
:placeholder="t('user.user.email_placeholder')"
/>
<FormItem
:label="t('user.user.head_image')"
type="image"
v-model="baTable.form.items!.head_image"
prop="head_image"
/>
<el-divider content-position="left">{{ t('user.user.section_admin_attribution') }}</el-divider>
<el-form-item :label="t('user.user.admin_affiliation')" prop="admin_id">
<el-tree-select
v-model="baTable.form.items!.admin_id"
class="w100"
value-format="YYYY-MM-DD"
v-model="baTable.form.items!.birthday"
type="date"
:placeholder="t('Please select field', { field: t('user.user.birthday') })"
clearable
filterable
:data="adminScopeTree"
:props="treeProps"
:render-after-expand="false"
:placeholder="t('user.user.admin_affiliation_placeholder')"
@update:model-value="onAdminTreeChange"
/>
</el-form-item>
<el-form-item v-if="baTable.form.operate == 'Edit'" :label="t('user.user.balance')">
<el-input v-model="baTable.form.items!.money" readonly>
<template #append>
<el-button @click="changeAccount('money')">{{ t('user.user.Adjustment balance') }}</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item v-if="baTable.form.operate == 'Edit'" :label="t('user.user.integral')">
<el-input v-model="baTable.form.items!.score" readonly>
<template #append>
<el-button @click="changeAccount('score')">{{ t('user.user.Adjust integral') }}</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item prop="password" :label="t('user.user.password')">
<el-input
v-model="baTable.form.items!.password"
type="password"
autocomplete="new-password"
:placeholder="
baTable.form.operate == 'Add'
? t('Please input field', { field: t('user.user.password') })
: t('user.user.Please leave blank if not modified')
"
></el-input>
</el-form-item>
<el-form-item prop="motto" :label="t('user.user.Personal signature')">
<el-input
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
v-model="baTable.form.items!.motto"
type="textarea"
:placeholder="t('Please input field', { field: t('user.user.Personal signature') })"
></el-input>
</el-form-item>
<FormItem
:label="t('State')"
v-if="baTable.form.operate === 'Edit'"
:label="t('user.user.uuid')"
type="string"
v-model="baTable.form.items!.uuid"
prop="uuid"
:input-attr="{ readonly: true, disabled: true }"
/>
<el-divider content-position="left">{{ t('user.user.section_register') }}</el-divider>
<FormItem
:label="t('user.user.register_invite_code')"
type="string"
v-model="baTable.form.items!.register_invite_code"
prop="register_invite_code"
:input-attr="{ readonly: true }"
:placeholder="t('user.user.register_invite_code_auto_placeholder')"
/>
<el-divider content-position="left">{{ t('user.user.section_finance') }}</el-divider>
<FormItem
:label="t('user.user.coin')"
type="number"
v-model="baTable.form.items!.coin"
prop="coin"
:input-attr="{ step: 0.0001, min: 0, precision: 4 }"
:placeholder="t('user.user.coin_placeholder')"
/>
<FormItem
:label="t('user.user.total_deposit_coin')"
type="number"
v-model="baTable.form.items!.total_deposit_coin"
prop="total_deposit_coin"
:input-attr="{ step: 0.0001, min: 0, precision: 4 }"
/>
<FormItem
:label="t('user.user.total_valid_bet_coin')"
type="number"
v-model="baTable.form.items!.total_valid_bet_coin"
prop="total_valid_bet_coin"
:input-attr="{ step: 0.0001, min: 0, precision: 4 }"
/>
<el-divider content-position="left">{{ t('user.user.section_risk') }}</el-divider>
<el-form-item :label="t('user.user.risk_flags')">
<div class="risk-flag-row">
<el-tag
:type="riskBits.noLogin ? 'danger' : 'info'"
:effect="riskBits.noLogin ? 'dark' : 'plain'"
class="risk-flag-tag"
@click="riskBits.noLogin = !riskBits.noLogin"
>
{{ t('user.user.risk_no_login') }}
</el-tag>
<el-tag
:type="riskBits.noBet ? 'danger' : 'info'"
:effect="riskBits.noBet ? 'dark' : 'plain'"
class="risk-flag-tag"
@click="riskBits.noBet = !riskBits.noBet"
>
{{ t('user.user.risk_no_bet') }}
</el-tag>
<el-tag
:type="riskBits.noWithdraw ? 'danger' : 'info'"
:effect="riskBits.noWithdraw ? 'dark' : 'plain'"
class="risk-flag-tag"
@click="riskBits.noWithdraw = !riskBits.noWithdraw"
>
{{ t('user.user.risk_no_withdraw') }}
</el-tag>
</div>
</el-form-item>
<el-divider content-position="left">{{ t('user.user.section_streak') }}</el-divider>
<FormItem
:label="t('user.user.current_streak')"
type="number"
v-model="baTable.form.items!.current_streak"
prop="current_streak"
:input-attr="{ step: 1, min: 0, precision: 0 }"
/>
<FormItem
:label="t('user.user.last_bet_period_no')"
type="string"
v-model="baTable.form.items!.last_bet_period_no"
prop="last_bet_period_no"
:placeholder="t('user.user.last_bet_period_no_placeholder')"
/>
<el-divider content-position="left">{{ t('user.user.section_other') }}</el-divider>
<FormItem
:label="t('user.user.remark')"
type="textarea"
v-model="baTable.form.items!.remark"
prop="remark"
:input-attr="{ rows: 3 }"
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
:placeholder="t('Please input field', { field: t('user.user.remark') })"
/>
<FormItem
:label="t('user.user.status')"
type="switch"
v-model="baTable.form.items!.status"
type="radio"
:input-attr="{
border: true,
content: { disable: t('Disable'), enable: t('Enable') },
}"
prop="status"
:input-attr="{ content: { '0': t('user.user.status 0'), '1': t('user.user.status 1') } }"
/>
</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 @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
@@ -145,15 +202,18 @@
</template>
<script setup lang="ts">
import { reactive, inject, watch, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import { regularPassword } from '/@/utils/validate'
import type { FormItemRule } from 'element-plus'
import { inject, nextTick, onMounted, reactive, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import router from '/@/router/index'
import { buildValidatorData } from '/@/utils/validate'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
import { buildValidatorData, regularPassword } from '/@/utils/validate'
import createAxios from '/@/utils/axios'
const RISK_LOGIN = 1
const RISK_BET = 2
const RISK_WITHDRAW = 4
const config = useConfig()
const formRef = useTemplateRef('formRef')
@@ -161,78 +221,281 @@ const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
username: [buildValidatorData({ name: 'required', title: t('user.user.User name') }), buildValidatorData({ name: 'account' })],
nickname: [buildValidatorData({ name: 'required', title: t('user.user.nickname') })],
group_id: [buildValidatorData({ name: 'required', message: t('Please select field', { field: t('user.user.group') }) })],
email: [buildValidatorData({ name: 'email', title: t('user.user.email') })],
mobile: [buildValidatorData({ name: 'mobile' })],
password: [
{
validator: (rule: any, val: string, callback: Function) => {
if (baTable.form.operate == 'Add') {
if (!val) {
return callback(new Error(t('Please input field', { field: t('user.user.password') })))
}
} else {
if (!val) {
return callback()
}
}
if (!regularPassword(val)) {
return callback(new Error(t('validate.Please enter the correct password')))
}
return callback()
},
trigger: 'blur',
},
],
type TreeNode = {
value: string
label: string
disabled?: boolean
children?: TreeNode[]
channel_id?: number | null
is_leaf?: boolean
invite_code?: string
}
const adminScopeTree = ref<TreeNode[]>([])
const adminIdToChannelId = ref<Record<string, number>>({})
const adminIdToInviteCode = ref<Record<string, string>>({})
const treeProps = {
value: 'value',
label: 'label',
children: 'children',
disabled: 'disabled',
}
const riskBits = reactive({
noLogin: false,
noBet: false,
noWithdraw: false,
})
const changeAccount = (type: string) => {
baTable.toggleForm()
router.push({
name: type == 'money' ? 'user/moneyLog' : 'user/scoreLog',
query: {
user_id: baTable.form.items!.id,
},
})
function syncRiskFromFlags(flags: unknown) {
const f = typeof flags === 'number' ? flags : parseInt(String(flags ?? '0'), 10) || 0
riskBits.noLogin = (f & RISK_LOGIN) !== 0
riskBits.noBet = (f & RISK_BET) !== 0
riskBits.noWithdraw = (f & RISK_WITHDRAW) !== 0
}
function packRiskFlags(): number {
let r = 0
if (riskBits.noLogin) r |= RISK_LOGIN
if (riskBits.noBet) r |= RISK_BET
if (riskBits.noWithdraw) r |= RISK_WITHDRAW
return r
}
/** 璇嗗埆绠悊鍛樺彾瀛愶細value 闈?group_* 涓旀棤瀛愯妭鐐癸紙涓嶄緷璧?is_leaf锛岄伩鍏?el-tree 涓㈠純鑷畾涔夊瓧娈碉級 */
function isAdminTreeLeaf(n: TreeNode): boolean {
const v = n.value
if (v === undefined || v === null) return false
if (String(v).startsWith('group_')) return false
if (n.children && n.children.length > 0) return false
return true
}
function buildAdminMapsFromTree(nodes: TreeNode[]) {
const mapCh: Record<string, number> = {}
const mapInv: Record<string, string> = {}
const walk = (arr: TreeNode[]) => {
for (const n of arr) {
if (n.children?.length) {
walk(n.children)
}
if (!isAdminTreeLeaf(n)) {
continue
}
const id = String(n.value)
if (n.channel_id !== undefined && n.channel_id !== null && n.channel_id !== '') {
mapCh[id] = typeof n.channel_id === 'number' ? n.channel_id : parseInt(String(n.channel_id), 10)
}
if (typeof n.invite_code === 'string') {
mapInv[id] = n.invite_code
} else if (n.invite_code !== undefined && n.invite_code !== null) {
mapInv[id] = String(n.invite_code)
} else {
mapInv[id] = ''
}
}
}
walk(nodes)
return { mapCh, mapInv }
}
/** 鏄犲皠鏈懡涓椂浠庡師濮嬫爲鏌ユ壘锛堥槻姝?props 瑁佸壀鎴栧紓姝ユ椂搴忛棶棰橈級 */
function findAdminMetaInTree(nodes: TreeNode[], adminId: string): { channel_id?: number; invite_code: string } | null {
const target = String(adminId).trim()
for (const n of nodes) {
if (n.children?.length) {
const nested = findAdminMetaInTree(n.children, adminId)
if (nested) {
return nested
}
}
if (!isAdminTreeLeaf(n)) {
continue
}
if (String(n.value) !== target) {
continue
}
let channelId: number | undefined
if (n.channel_id !== undefined && n.channel_id !== null && n.channel_id !== '') {
channelId = typeof n.channel_id === 'number' ? n.channel_id : parseInt(String(n.channel_id), 10)
}
const invite =
typeof n.invite_code === 'string' ? n.invite_code : n.invite_code != null ? String(n.invite_code) : ''
return { channel_id: channelId, invite_code: invite }
}
return null
}
const loadAdminScopeTree = async () => {
const res = await createAxios({
url: '/admin/user.User/adminScopeTree',
method: 'get',
})
const list = (res.data?.list ?? []) as TreeNode[]
adminScopeTree.value = list
const { mapCh, mapInv } = buildAdminMapsFromTree(list)
adminIdToChannelId.value = mapCh
adminIdToInviteCode.value = mapInv
await nextTick()
const aid = baTable.form.items?.admin_id
if (aid !== undefined && aid !== null && aid !== '') {
onAdminTreeChange(aid as string | number)
}
}
const onAdminTreeChange = (val: string | number | null) => {
if (!baTable.form.items) {
return
}
if (val === null || val === undefined || val === '') {
baTable.form.items.register_invite_code = ''
return
}
const key = typeof val === 'number' ? String(val) : String(val).trim()
let channelId = adminIdToChannelId.value[key]
let inv = adminIdToInviteCode.value[key]
if (channelId === undefined || inv === undefined) {
const meta = findAdminMetaInTree(adminScopeTree.value, key)
if (meta) {
if (channelId === undefined && meta.channel_id !== undefined) {
channelId = meta.channel_id
}
if (inv === undefined) {
inv = meta.invite_code
}
}
}
if (channelId !== undefined) {
baTable.form.items.channel_id = channelId
}
baTable.form.items.register_invite_code = inv !== undefined && inv !== null ? inv : ''
}
onMounted(() => {
loadAdminScopeTree()
})
watch(
() => baTable.form.items?.admin_id,
(val) => {
if (val === undefined || val === null || val === '') return
onAdminTreeChange(val as any)
}
)
// 鏍戞暟鎹紓姝ュ姞杞藉畬鎴愬悗锛屾牴鎹凡閫夌鐞嗗憳鍚屾娓犻亾涓庨個璇风爜锛堢紪杈戝洖鏄撅級
watch(
() => [baTable.form.items?.admin_id, adminScopeTree.value.length] as const,
() => {
const id = baTable.form.items?.admin_id
if (id === undefined || id === null || id === '' || adminScopeTree.value.length === 0) {
return
}
onAdminTreeChange(id as string | number)
}
)
watch(
() => baTable.form.items?.risk_flags,
(v) => {
syncRiskFromFlags(v)
}
)
watch(
riskBits,
() => {
if (!baTable.form.items) return
baTable.form.items.risk_flags = packRiskFlags()
},
{ deep: true }
)
watch(
() => baTable.form.operate,
(newVal) => {
rules.password![0].required = newVal == 'Add'
(op) => {
if (op === 'Add') {
syncRiskFromFlags(0)
}
}
)
// 统一 el-tree-select 的 value 为字符串,避免编辑回显不选中
watch(
() => [baTable.form.operate, baTable.form.items?.id] as const,
() => {
const items = baTable.form.items
if (!items || baTable.form.operate !== 'Edit') {
return
}
if (items.admin_id !== undefined && items.admin_id !== null && items.admin_id !== '') {
items.admin_id = String(items.admin_id) as any
}
}
)
const validatorGameUserPassword = (rule: any, val: string, callback: (error?: Error) => void) => {
const operate = baTable.form.operate
const v = typeof val === 'string' ? val.trim() : ''
if (operate === 'Add') {
if (!v) return callback(new Error(t('Please input field', { field: t('user.user.password') })))
if (!regularPassword(v)) return callback(new Error(t('validate.Please enter the correct password')))
return callback()
}
if (!v) return callback()
if (!regularPassword(v)) return callback(new Error(t('validate.Please enter the correct password')))
return callback()
}
const decimalRule = (fieldTitle: string): FormItemRule => ({
trigger: 'blur',
validator: (_rule, val, callback) => {
if (val === null || val === undefined || val === '') {
return callback()
}
const n = typeof val === 'number' ? val : parseFloat(String(val).trim().replace(',', '.'))
if (!Number.isFinite(n) || n < 0) {
return callback(new Error(t('Please enter the correct field', { field: fieldTitle })))
}
return callback()
},
})
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
username: [buildValidatorData({ name: 'required', title: t('user.user.username') })],
password: [{ validator: validatorGameUserPassword, trigger: 'blur' }],
phone: [buildValidatorData({ name: 'required', title: t('user.user.phone') })],
coin: [decimalRule(t('user.user.coin'))],
total_deposit_coin: [decimalRule(t('user.user.total_deposit_coin'))],
total_valid_bet_coin: [decimalRule(t('user.user.total_valid_bet_coin'))],
admin_id: [buildValidatorData({ name: 'required', title: t('user.user.admin_affiliation') })],
create_time: [buildValidatorData({ name: 'date', title: t('user.user.create_time') })],
update_time: [buildValidatorData({ name: 'date', title: t('user.user.update_time') })],
})
</script>
<style scoped lang="scss">
.avatar-uploader {
.game-user-form-tip {
margin-bottom: 12px;
}
.risk-flag-row {
display: flex;
flex-wrap: wrap;
gap: 8px 10px;
align-items: center;
justify-content: center;
position: relative;
border-radius: var(--el-border-radius-small);
box-shadow: var(--el-box-shadow-light);
border: 1px dashed var(--el-border-color);
}
.risk-flag-tag {
cursor: pointer;
overflow: hidden;
width: 110px;
height: 110px;
}
.avatar-uploader:hover {
border-color: var(--el-color-primary);
}
.avatar {
width: 110px;
height: 110px;
display: block;
}
.image-slot {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
user-select: none;
}
</style>