From cf381bf02cd340897fcb9fa72cc696055866e4ab Mon Sep 17 00:00:00 2001 From: zhenhui <1276357500@qq.com> Date: Fri, 3 Apr 2026 17:50:32 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B8=B8=E6=88=8F-=E7=94=A8=E6=88=B7=E7=AE=A1?= =?UTF-8?q?=E7=90=86-=E4=BC=98=E5=8C=96=E6=A0=B7=E5=BC=8F=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/controller/game/User.php | 354 +++++++++++++++ web/src/lang/backend/en/game/user.ts | 16 + web/src/lang/backend/zh-cn/game/user.ts | 16 + web/src/utils/gameWeightFixed.ts | 117 +++++ .../game/user/GameUserTicketJsonCell.vue | 83 ++++ .../game/user/GameUserWeightJsonCell.vue | 82 ++++ web/src/views/backend/game/user/index.vue | 40 +- web/src/views/backend/game/user/popupForm.vue | 419 +++++++++++++++++- 8 files changed, 1125 insertions(+), 2 deletions(-) create mode 100644 web/src/utils/gameWeightFixed.ts create mode 100644 web/src/views/backend/game/user/GameUserTicketJsonCell.vue create mode 100644 web/src/views/backend/game/user/GameUserWeightJsonCell.vue diff --git a/app/admin/controller/game/User.php b/app/admin/controller/game/User.php index 63b9b3e..779d854 100644 --- a/app/admin/controller/game/User.php +++ b/app/admin/controller/game/User.php @@ -4,6 +4,8 @@ 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; @@ -35,6 +37,24 @@ class User extends Backend 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(); @@ -69,6 +89,10 @@ class User extends Backend } $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; @@ -100,6 +124,14 @@ class User extends Backend 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')); @@ -126,6 +158,8 @@ class User extends Backend 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) { @@ -156,6 +190,9 @@ class User extends Backend 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()) { @@ -187,6 +224,14 @@ class User extends Backend 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')); @@ -199,6 +244,51 @@ class User extends Backend ]); } + /** + * 删除后按 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 @@ -232,6 +322,270 @@ class User extends Backend ]); } + /** + * 新建用户时:超管可选各渠道 default_tier_weight / default_bigwin_weight(大奖仅 default_bigwin_weight;default_kill_score_weight 为 T1~T5 击杀分档位,不作大奖回退) + * + * @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|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 中对应的方法至此进行重写 */ diff --git a/web/src/lang/backend/en/game/user.ts b/web/src/lang/backend/en/game/user.ts index 7a211d5..6699cfe 100644 --- a/web/src/lang/backend/en/game/user.ts +++ b/web/src/lang/backend/en/game/user.ts @@ -6,6 +6,22 @@ export default { 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', diff --git a/web/src/lang/backend/zh-cn/game/user.ts b/web/src/lang/backend/zh-cn/game/user.ts index 5d876ea..390dfed 100644 --- a/web/src/lang/backend/zh-cn/game/user.ts +++ b/web/src/lang/backend/zh-cn/game/user.ts @@ -6,6 +6,22 @@ export default { phone: '手机号', 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 须为有效数字', status: '状态', 'status 0': '禁用', 'status 1': '启用', diff --git a/web/src/utils/gameWeightFixed.ts b/web/src/utils/gameWeightFixed.ts new file mode 100644 index 0000000..9003218 --- /dev/null +++ b/web/src/utils/gameWeightFixed.ts @@ -0,0 +1,117 @@ +/** 档位权重固定键(与 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 { + const map: Record = {} + 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 { + 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): WeightRow[] { + const rows: WeightRow[] = [] + for (const k of keys) { + rows.push({ key: k, val: map[k] ?? '' }) + } + return rows +} + +export function rowsToMap(rows: WeightRow[]): Record { + const m: Record = {} + for (const r of rows) { + m[r.key] = r.val + } + return m +} + +export function jsonStringFromFixedKeys(keys: readonly string[], map: Record): string { + const pairs: Record[] = [] + for (const k of keys) { + const one: Record = {} + 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() +} + +/** 当前行是否为大奖骰子键 5~30(与 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) + /** 击杀分权重与档位权重同为 T1~T5;库中 JSON 为 [{"T1":"0"},...],非骰子点 5~30 */ + 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 +} diff --git a/web/src/views/backend/game/user/GameUserTicketJsonCell.vue b/web/src/views/backend/game/user/GameUserTicketJsonCell.vue new file mode 100644 index 0000000..3e05c56 --- /dev/null +++ b/web/src/views/backend/game/user/GameUserTicketJsonCell.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/web/src/views/backend/game/user/GameUserWeightJsonCell.vue b/web/src/views/backend/game/user/GameUserWeightJsonCell.vue new file mode 100644 index 0000000..2cfeb46 --- /dev/null +++ b/web/src/views/backend/game/user/GameUserWeightJsonCell.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/web/src/views/backend/game/user/index.vue b/web/src/views/backend/game/user/index.vue index 85298de..f795c78 100644 --- a/web/src/views/backend/game/user/index.vue +++ b/web/src/views/backend/game/user/index.vue @@ -23,11 +23,14 @@ 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', @@ -66,6 +69,36 @@ const baTable = new baTableClass( }, { 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', @@ -138,7 +171,12 @@ const baTable = new baTableClass( dblClickNotEditColumn: [undefined, 'status'], }, { - defaultItems: { status: '1' }, + defaultItems: { + status: '1', + tier_weight: jsonStringFromFixedKeys(TIER_WEIGHT_KEYS, {}), + bigwin_weight: jsonStringFromFixedKeys(BIGWIN_WEIGHT_KEYS, {}), + ticket_count: '[]', + }, } ) diff --git a/web/src/views/backend/game/user/popupForm.vue b/web/src/views/backend/game/user/popupForm.vue index 16ea532..cb8e5a3 100644 --- a/web/src/views/backend/game/user/popupForm.vue +++ b/web/src/views/backend/game/user/popupForm.vue @@ -68,6 +68,65 @@ :input-attr="{ step: 1 }" :placeholder="t('Please input field', { field: t('game.user.coin') })" /> + + +
+
+ + : + +
+
{{ t('game.user.tier_weight_help') }}
+
+
+ + +
+
+ + : + +
+
{{ t('game.user.bigwin_weight_help') }}
+
+
+ + +
+
+ {{ t('game.user.ticket_ante') }} + + {{ t('game.user.ticket_count_times') }} + + {{ t('Delete') }} +
+
+
adminInfo.super === true) + type TreeNode = { value: string label: string @@ -194,6 +266,209 @@ onMounted(() => { loadChannelAdminTree() }) +type TicketRow = { ante: string; count: string } + +const tierWeightRows = ref(fixedRowsFromKeys(TIER_WEIGHT_KEYS, {})) +const bigwinWeightRows = ref(fixedRowsFromKeys(BIGWIN_WEIGHT_KEYS, {})) +const ticketRows = ref([{ 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 }) + } + 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 = '' + return + } + items.ticket_count = ticketRowsToJsonString(ticketRows.value) +} + +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) => { @@ -202,6 +477,62 @@ watch( } ) +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() : '' @@ -224,10 +555,96 @@ const rules: Partial> = reactive({ 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') })], }) - +