[游戏管理]用户管理-优化

This commit is contained in:
2026-04-15 11:27:14 +08:00
parent c01e6430db
commit 9d06c7a226
6 changed files with 654 additions and 100 deletions

View File

@@ -4,6 +4,7 @@ namespace app\admin\controller\game;
use Throwable;
use app\common\controller\Backend;
use support\think\Db;
use support\Response;
use Webman\Http\Request as WebmanRequest;
@@ -23,7 +24,7 @@ class User extends Backend
protected array $withJoinTable = ['channel', 'admin'];
protected string|array $quickSearchField = ['id', 'username', 'phone'];
protected string|array $quickSearchField = ['id', 'username', 'phone', 'email', 'register_invite_code'];
protected function initController(WebmanRequest $request): ?Response
{
@@ -205,6 +206,135 @@ class User extends Backend
]);
}
/**
* 角色组 → 管理员树(仅当前账号可管理的角色组及其下管理员;用于游戏用户归属)
* 同一管理员若属于多个组,只挂在 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

@@ -15,28 +15,22 @@ class GameUser extends Model
// 自动写入时间戳字段
protected $autoWriteTimestamp = true;
// 字段类型转换
// 字段类型转换(金额 decimal(18,4) 用字符串避免浮点误差)
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 getcoinAttr($value): ?float
{
return is_null($value) ? null : (float)$value;
}
public function channel(): \think\model\relation\BelongsTo
{
return $this->belongsTo(\app\common\model\Channel::class, 'channel_id', 'id');
}
public function gameChannel(): \think\model\relation\BelongsTo
{
return $this->channel();
}
public function admin(): \think\model\relation\BelongsTo
{
return $this->belongsTo(\app\admin\model\Admin::class, 'admin_id', 'id');

View File

@@ -1,19 +1,45 @@
export default {
id: 'id',
username: 'username',
password: 'password',
uuid: 'uuid',
phone: 'phone',
remark: 'remark',
coin: 'coin',
status: 'status',
'status 0': 'status 0',
'status 1': 'status 1',
channel_id: 'channel_id',
channel__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

@@ -4,16 +4,42 @@ export default {
password: '密码',
uuid: '用户唯一标识',
phone: '手机号',
email: '邮箱',
email_placeholder: '可选,与手机号二选一注册时填写',
head_image: '头像',
remark: '备注',
coin: '平台币',
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: '用户名',
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

@@ -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.user.quick Search Fields') })"
></TableHeader>
<!-- 表格 -->
<!-- 表格列有多种自定义渲染方式比如自定义组件具名插槽等参见文档 -->
<!-- 要使用 el-table 组件原有的属性直接加在 Table 标签上即可 -->
<Table ref="tableRef"></Table>
<!-- 表单 -->
<PopupForm />
</div>
</template>
@@ -37,9 +31,30 @@ const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
/**
* baTable 内包含了表格的所有数据且数据具备响应性,然后通过 provide 注入给了后代组件
*/
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('game.user.risk_no_login'))
if (f & 2) parts.push(t('game.user.risk_no_bet'))
if (f & 4) parts.push(t('game.user.risk_no_withdraw'))
if (!parts.length) parts.push(t('game.user.risk_none'))
return parts
}
const baTable = new baTableClass(
new baTableApi('/admin/game.User/'),
{
@@ -55,6 +70,24 @@ const baTable = new baTableClass(
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.email'),
prop: 'email',
align: 'center',
minWidth: 120,
showOverflowTooltip: true,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
},
{
label: t('game.user.head_image'),
prop: 'head_image',
align: 'center',
width: 72,
operator: false,
render: 'image',
},
{
label: t('game.user.uuid'),
prop: 'uuid',
@@ -64,8 +97,57 @@ const baTable = new baTableClass(
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.register_invite_code'),
prop: 'register_invite_code',
align: 'center',
minWidth: 100,
showOverflowTooltip: true,
operator: 'LIKE',
},
{ label: t('game.user.coin'), prop: 'coin', align: 'center', sortable: false, operator: 'RANGE', formatter: formatCoin },
{
label: t('game.user.total_deposit_coin'),
prop: 'total_deposit_coin',
align: 'center',
minWidth: 110,
sortable: false,
operator: 'RANGE',
formatter: formatCoin,
},
{
label: t('game.user.total_valid_bet_coin'),
prop: 'total_valid_bet_coin',
align: 'center',
minWidth: 110,
sortable: false,
operator: 'RANGE',
formatter: formatCoin,
},
{
label: t('game.user.risk_flags'),
prop: 'risk_flags',
align: 'center',
render: 'tags',
minWidth: 200,
operator: false,
formatter: formatRiskFlags,
custom: {
[t('game.user.risk_none')]: 'primary',
[t('game.user.risk_no_login')]: 'danger',
[t('game.user.risk_no_bet')]: 'danger',
[t('game.user.risk_no_withdraw')]: 'danger',
},
},
{ label: t('game.user.current_streak'), prop: 'current_streak', align: 'center', width: 90, operator: 'RANGE' },
{
label: t('game.user.last_bet_period_no'),
prop: 'last_bet_period_no',
align: 'center',
minWidth: 120,
showOverflowTooltip: true,
operator: 'LIKE',
},
{
label: t('game.user.status'),
prop: 'status',
@@ -95,7 +177,6 @@ const baTable = new baTableClass(
render: 'tags',
operator: 'LIKE',
comSearchRender: 'string',
//修改tag颜色
customRenderAttr: {
tag: () => ({
color: '#e8f3ff',
@@ -138,7 +219,18 @@ const baTable = new baTableClass(
dblClickNotEditColumn: [undefined, 'status'],
},
{
defaultItems: { status: '1' },
defaultItems: {
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: '',
},
}
)

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"
@@ -29,6 +26,11 @@
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
>
<el-alert class="game-user-form-tip" type="info" :closable="false" show-icon>
{{ t('game.user.form_tip') }}
</el-alert>
<el-divider content-position="left">{{ t('game.user.section_basic') }}</el-divider>
<FormItem
:label="t('game.user.username')"
type="string"
@@ -50,6 +52,124 @@
prop="phone"
:placeholder="t('Please input field', { field: t('game.user.phone') })"
/>
<FormItem
:label="t('game.user.email')"
type="string"
v-model="baTable.form.items!.email"
prop="email"
:placeholder="t('game.user.email_placeholder')"
/>
<FormItem
:label="t('game.user.head_image')"
type="image"
v-model="baTable.form.items!.head_image"
prop="head_image"
/>
<el-divider content-position="left">{{ t('game.user.section_admin_attribution') }}</el-divider>
<el-form-item :label="t('game.user.admin_affiliation')" prop="admin_id">
<el-tree-select
v-model="baTable.form.items!.admin_id"
class="w100"
clearable
filterable
:data="adminScopeTree"
:props="treeProps"
:render-after-expand="false"
:placeholder="t('game.user.admin_affiliation_placeholder')"
@update:model-value="onAdminTreeChange"
/>
</el-form-item>
<FormItem
v-if="baTable.form.operate === 'Edit'"
:label="t('game.user.uuid')"
type="string"
v-model="baTable.form.items!.uuid"
prop="uuid"
:input-attr="{ readonly: true, disabled: true }"
/>
<el-divider content-position="left">{{ t('game.user.section_register') }}</el-divider>
<FormItem
:label="t('game.user.register_invite_code')"
type="string"
v-model="baTable.form.items!.register_invite_code"
prop="register_invite_code"
:input-attr="{ readonly: true }"
:placeholder="t('game.user.register_invite_code_auto_placeholder')"
/>
<el-divider content-position="left">{{ t('game.user.section_finance') }}</el-divider>
<FormItem
:label="t('game.user.coin')"
type="number"
v-model="baTable.form.items!.coin"
prop="coin"
:input-attr="{ step: 0.0001, min: 0, precision: 4 }"
:placeholder="t('game.user.coin_placeholder')"
/>
<FormItem
:label="t('game.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('game.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('game.user.section_risk') }}</el-divider>
<el-form-item :label="t('game.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('game.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('game.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('game.user.risk_no_withdraw') }}
</el-tag>
</div>
</el-form-item>
<el-divider content-position="left">{{ t('game.user.section_streak') }}</el-divider>
<FormItem
:label="t('game.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('game.user.last_bet_period_no')"
type="string"
v-model="baTable.form.items!.last_bet_period_no"
prop="last_bet_period_no"
:placeholder="t('game.user.last_bet_period_no_placeholder')"
/>
<el-divider content-position="left">{{ t('game.user.section_other') }}</el-divider>
<FormItem
:label="t('game.user.remark')"
type="textarea"
@@ -60,14 +180,6 @@
@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') })"
/>
<FormItem
:label="t('game.user.status')"
type="switch"
@@ -75,19 +187,6 @@
prop="status"
:input-attr="{ content: { '0': t('game.user.status 0'), '1': t('game.user.status 1') } }"
/>
<el-form-item :label="t('game.user.channel_id')" prop="admin_id">
<el-tree-select
v-model="baTable.form.items!.admin_id"
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>
@@ -104,7 +203,7 @@
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, onMounted, reactive, ref, useTemplateRef, watch } from 'vue'
import { 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'
@@ -112,6 +211,10 @@ 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')
const baTable = inject('baTable') as baTableClass
@@ -123,12 +226,14 @@ type TreeNode = {
label: string
disabled?: boolean
children?: TreeNode[]
channel_id?: number
channel_id?: number | null
is_leaf?: boolean
invite_code?: string
}
const channelAdminTree = ref<TreeNode[]>([])
const adminScopeTree = ref<TreeNode[]>([])
const adminIdToChannelId = ref<Record<string, number>>({})
const adminIdToInviteCode = ref<Record<string, string>>({})
const treeProps = {
value: 'value',
@@ -137,41 +242,143 @@ const treeProps = {
disabled: 'disabled',
}
const loadChannelAdminTree = async () => {
const res = await createAxios({
url: '/admin/channel/adminTree',
method: 'get',
})
const list = (res.data?.list ?? []) as TreeNode[]
channelAdminTree.value = list
const riskBits = reactive({
noLogin: false,
noBet: false,
noWithdraw: false,
})
const map: Record<string, number> = {}
const walk = (nodes: TreeNode[]) => {
for (const n of nodes) {
if (n.children && n.children.length) {
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)
} else if (n.is_leaf && n.channel_id !== undefined) {
map[n.value] = n.channel_id
}
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(list)
adminIdToChannelId.value = map
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/game.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 (val === null || val === undefined || val === '') {
if (!baTable.form.items) {
return
}
const key = typeof val === 'number' ? String(val) : val
const channelId = adminIdToChannelId.value[key]
if (channelId !== undefined) {
baTable.form.items!.channel_id = channelId
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(() => {
loadChannelAdminTree()
loadAdminScopeTree()
})
watch(
@@ -182,32 +389,111 @@ watch(
}
)
// 树数据异步加载完成后,根据已选管理员同步渠道与邀请码(编辑回显)
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,
(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('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 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('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') })],
admin_id: [buildValidatorData({ name: 'required', title: t('game.user.admin_id') })],
coin: [decimalRule(t('game.user.coin'))],
total_deposit_coin: [decimalRule(t('game.user.total_deposit_coin'))],
total_valid_bet_coin: [decimalRule(t('game.user.total_valid_bet_coin'))],
admin_id: [buildValidatorData({ name: 'required', title: t('game.user.admin_affiliation') })],
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"></style>
<style scoped lang="scss">
.game-user-form-tip {
margin-bottom: 12px;
}
.risk-flag-row {
display: flex;
flex-wrap: wrap;
gap: 8px 10px;
align-items: center;
}
.risk-flag-tag {
cursor: pointer;
user-select: none;
}
</style>