[游戏管理]用户管理-优化
This commit is contained in:
@@ -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 中对应的方法至此进行重写
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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: '其他',
|
||||
}
|
||||
|
||||
@@ -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: '',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
<template>
|
||||
<!-- 对话框表单 -->
|
||||
<!-- 建议使用 Prettier 格式化代码 -->
|
||||
<!-- el-form 内可以混用 el-form-item、FormItem、ba-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>
|
||||
|
||||
Reference in New Issue
Block a user