Files
thebet365/apps/admin/src/views/Users.vue
Mars 80adc0e928 feat(admin): 管理端列表分页、控制台图表与赛事导入
- 玩家/代理/赛事/注单/审计列表分页,默认每页 10 条,无页面滚动条布局

- ECharts 控制台概览、注单管理中文化与列宽优化

- zhibo 赛事字段迁移与导入,玩家编辑可改所属代理

- 管理端 API 分页与 dashboard 统计接口

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 13:49:31 +08:00

586 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import api from '../api';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
emptyPlayerCreateForm,
emptyPlayerEditForm,
editFormFromDetail,
buildCreatePlayerPayload,
type PlayerRow,
type PlayerDetail,
type PlayerCreateForm,
type PlayerEditForm,
} from './user-form';
import {
formatAmount,
formatAmountFull,
shouldCompactAmount as shouldCompact,
} from '../utils/format-amount';
const users = ref<PlayerRow[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const keyword = ref('');
const filterStatus = ref('');
const filterParentId = ref('');
const agentOptions = ref<{ id: string; username: string }[]>([]);
const createVisible = ref(false);
const editVisible = ref(false);
const detailVisible = ref(false);
const depositVisible = ref(false);
const createLoading = ref(false);
const editLoading = ref(false);
const depositLoading = ref(false);
const createForm = ref<PlayerCreateForm>(emptyPlayerCreateForm());
const editForm = ref<PlayerEditForm>(emptyPlayerEditForm());
const detail = ref<PlayerDetail | null>(null);
const editingId = ref('');
const depositForm = ref({ userId: '', amount: 100, remark: '' });
onMounted(() => {
loadAgentOptions();
load();
});
async function loadAgentOptions() {
const { data } = await api.get('/admin/agents/options');
agentOptions.value = data.data;
}
async function load() {
const { data } = await api.get('/admin/users', {
params: {
page: page.value,
pageSize: pageSize.value,
keyword: keyword.value || undefined,
status: filterStatus.value || undefined,
parentId: filterParentId.value || undefined,
},
});
users.value = data.data.items;
total.value = data.data.total;
}
function onPageChange(p: number) {
page.value = p;
load();
}
function onSizeChange(size: number) {
pageSize.value = size;
page.value = 1;
load();
}
function openCreate() {
createForm.value = emptyPlayerCreateForm();
createVisible.value = true;
}
function parentLabel(row: PlayerRow) {
return row.parentUsername ?? '平台直属';
}
async function openDetail(id: string) {
const { data } = await api.get(`/admin/users/${id}`);
detail.value = data.data as PlayerDetail;
detailVisible.value = true;
}
async function openEdit(id: string) {
const { data } = await api.get(`/admin/users/${id}`);
const d = data.data as PlayerDetail;
editingId.value = id;
editForm.value = editFormFromDetail(d);
editVisible.value = true;
}
function openDeposit(row: PlayerRow) {
depositForm.value = { userId: row.id, amount: 100, remark: '管理员上分' };
depositVisible.value = true;
}
async function submitCreate() {
let payload: ReturnType<typeof buildCreatePlayerPayload>;
try {
payload = buildCreatePlayerPayload(createForm.value);
} catch (e) {
ElMessage.warning(e instanceof Error ? e.message : '请检查表单');
return;
}
createLoading.value = true;
try {
await api.post('/admin/users', payload);
ElMessage.success('玩家已创建');
createVisible.value = false;
load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? '创建失败');
} finally {
createLoading.value = false;
}
}
async function toggleFreeze(row: PlayerRow) {
const freeze = row.status === 'ACTIVE';
const action = freeze ? '冻结' : '解冻';
try {
await ElMessageBox.confirm(
`确定要${action}玩家「${row.username}」吗?${freeze ? '冻结后该账号将无法登录。' : ''}`,
`${action}账号`,
{ type: 'warning', confirmButtonText: action, cancelButtonText: '取消' },
);
} catch {
return;
}
try {
await api.put(`/admin/users/${row.id}`, {
status: freeze ? 'SUSPENDED' : 'ACTIVE',
});
ElMessage.success(`${action}`);
load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? `${action}失败`);
}
}
async function submitEdit() {
editLoading.value = true;
try {
await api.put(`/admin/users/${editingId.value}`, {
parentId: editForm.value.parentId || '',
phone: editForm.value.phone.trim() || undefined,
email: editForm.value.email.trim() || undefined,
});
ElMessage.success('已保存');
editVisible.value = false;
load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? '保存失败');
} finally {
editLoading.value = false;
}
}
async function submitDeposit() {
if (depositForm.value.amount <= 0) {
ElMessage.warning('金额须大于 0');
return;
}
depositLoading.value = true;
try {
await api.post('/admin/wallet/deposit', {
userId: depositForm.value.userId,
amount: depositForm.value.amount,
remark: depositForm.value.remark,
requestId: `dep-${depositForm.value.userId}-${Date.now()}`,
});
ElMessage.success('上分成功');
depositVisible.value = false;
load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? '上分失败');
} finally {
depositLoading.value = false;
}
}
function formatTime(v: string) {
if (!v) return '—';
return new Date(v).toLocaleString('zh-CN');
}
function formatLastLogin(v: string | null) {
if (!v) return '从未登录';
const d = new Date(v);
const now = new Date();
const sameDay =
d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate();
if (sameDay) {
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
}
return d.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function statusTagType(s: string) {
return s === 'ACTIVE' ? 'success' : 'warning';
}
function statusLabel(s: string) {
return s === 'ACTIVE' ? '正常' : s === 'SUSPENDED' ? '停用' : s;
}
</script>
<template>
<div class="admin-list-page users-page">
<div class="page-header">
<div>
<h2 class="page-title">玩家管理</h2>
<span class="page-desc">创建玩家查看余额与投注概况支持上分与状态管理</span>
</div>
<el-button type="primary" @click="openCreate">+ 新建玩家</el-button>
</div>
<el-card class="filter-card" shadow="never">
<el-form inline>
<el-form-item label="关键词">
<el-input
v-model="keyword"
placeholder="用户名"
clearable
style="width: 160px"
@keyup.enter="load"
/>
</el-form-item>
<el-form-item label="所属代理">
<el-select
v-model="filterParentId"
placeholder="全部"
clearable
style="width: 180px"
>
<el-option
v-for="a in agentOptions"
:key="a.id"
:label="a.username"
:value="a.id"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filterStatus" placeholder="全部" clearable style="width: 120px">
<el-option label="正常" value="ACTIVE" />
<el-option label="停用" value="SUSPENDED" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">查询</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="data-card" shadow="never">
<div class="table-wrap">
<el-table :data="users" stripe>
<el-table-column prop="id" label="ID" width="72" />
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column label="状态" width="88">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="所属代理" min-width="120">
<template #default="{ row }">{{ parentLabel(row) }}</template>
</el-table-column>
<el-table-column label="可用 / 冻结" min-width="128" align="right">
<template #default="{ row }">
<el-tooltip
:content="`${formatAmountFull(row.availableBalance)} / ${formatAmountFull(row.frozenBalance)}`"
placement="top"
>
<span class="amount-compact">
{{ formatAmount(row.availableBalance) }} / {{ formatAmount(row.frozenBalance) }}
</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="betCount" label="注单" width="64" align="center" />
<el-table-column label="投注 / 派彩" min-width="108" align="right">
<template #default="{ row }">
<span class="amount-compact">
{{ formatAmount(row.totalStake) }} / {{ formatAmount(row.totalReturn) }}
</span>
</template>
</el-table-column>
<el-table-column label="最后登录" width="108">
<template #default="{ row }">
<el-tooltip v-if="row.lastLoginAt" :content="formatTime(row.lastLoginAt)" placement="top">
<span>{{ formatLastLogin(row.lastLoginAt) }}</span>
</el-tooltip>
<span v-else class="text-muted">从未登录</span>
</template>
</el-table-column>
<el-table-column label="注册时间" width="108">
<template #default="{ row }">
<el-tooltip :content="formatTime(row.createdAt)" placement="top">
<span>{{ formatLastLogin(row.createdAt) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="操作" width="300" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="openDetail(row.id)">详情</el-button>
<el-button size="small" link type="primary" @click="openEdit(row.id)">编辑</el-button>
<el-button size="small" link type="primary" @click="openDeposit(row)">上分</el-button>
<el-button
v-if="row.status === 'ACTIVE'"
size="small"
link
type="warning"
@click="toggleFreeze(row)"
>
冻结
</el-button>
<el-button
v-else
size="small"
link
type="primary"
@click="toggleFreeze(row)"
>
解冻
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
background
@current-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</el-card>
<el-dialog v-model="createVisible" title="新建玩家" width="520px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="用户名" required>
<el-input v-model="createForm.username" placeholder="登录用户名,唯一" />
</el-form-item>
<el-form-item label="登录密码" required>
<el-input v-model="createForm.password" type="text" autocomplete="off" />
</el-form-item>
<el-form-item label="确认密码" required>
<el-input v-model="createForm.confirmPassword" type="text" autocomplete="off" />
</el-form-item>
<el-form-item label="所属代理">
<el-select
v-model="createForm.parentId"
placeholder="不设置(平台直属玩家)"
clearable
style="width: 100%"
>
<el-option
v-for="a in agentOptions"
:key="a.id"
:label="`${a.username} (#${a.id})`"
:value="a.id"
/>
</el-select>
<div class="field-hint">留空表示不挂靠代理由平台直接管理</div>
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="createForm.phone" placeholder="选填" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="createForm.email" placeholder="选填" />
</el-form-item>
<el-form-item label="初始余额">
<el-input-number v-model="createForm.initialDeposit" :min="0" :step="100" style="width: 100%" />
<div class="field-hint">创建后自动上分0 表示不开户赠金</div>
</el-form-item>
<el-form-item label="上分备注">
<el-input v-model="createForm.remark" placeholder="有初始余额时写入流水备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="createLoading" @click="submitCreate">创建</el-button>
</template>
</el-dialog>
<el-dialog v-model="editVisible" title="编辑玩家" width="560px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="玩家 ID">
<el-input :model-value="editForm.id" disabled />
</el-form-item>
<el-form-item label="用户名">
<el-input :model-value="editForm.username" disabled />
</el-form-item>
<el-form-item label="账号状态">
<el-tag :type="statusTagType(editForm.status)" size="small">
{{ statusLabel(editForm.status) }}
</el-tag>
<span class="field-hint inline-hint">冻结/解冻请在列表操作列进行</span>
</el-form-item>
<el-form-item label="所属代理">
<el-select
v-model="editForm.parentId"
placeholder="不设置(平台直属玩家)"
clearable
style="width: 100%"
>
<el-option
v-for="a in agentOptions"
:key="a.id"
:label="`${a.username} (#${a.id})`"
:value="a.id"
/>
</el-select>
<div class="field-hint">留空表示平台直属变更后会重算相关代理已用授信</div>
</el-form-item>
<el-form-item label="可用余额">
<el-input :model-value="formatAmount(editForm.availableBalance)" disabled />
</el-form-item>
<el-form-item label="冻结余额">
<el-input :model-value="formatAmount(editForm.frozenBalance)" disabled />
</el-form-item>
<el-form-item label="注单 / 投注">
<el-input
:model-value="`${editForm.betCount} / ${formatAmount(editForm.totalStake)}`"
disabled
/>
</el-form-item>
<el-form-item label="累计派彩">
<el-input :model-value="formatAmount(editForm.totalReturn)" disabled />
</el-form-item>
<el-form-item label="最后登录">
<el-input
:model-value="editForm.lastLoginAt ? formatTime(editForm.lastLoginAt) : '从未登录'"
disabled
/>
</el-form-item>
<el-form-item label="登录失败">
<el-input :model-value="`${editForm.loginFailCount} `" disabled />
</el-form-item>
<el-form-item label="注册时间">
<el-input :model-value="formatTime(editForm.createdAt)" disabled />
</el-form-item>
<el-divider />
<el-form-item label="手机号">
<el-input v-model="editForm.phone" placeholder="选填" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="editForm.email" placeholder="选填" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editLoading" @click="submitEdit">保存资料</el-button>
</template>
</el-dialog>
<el-dialog v-model="depositVisible" title="玩家上分" width="400px" destroy-on-close>
<el-form label-width="80px">
<el-form-item label="玩家 ID">
<el-input :model-value="depositForm.userId" disabled />
</el-form-item>
<el-form-item label="金额">
<el-input-number v-model="depositForm.amount" :min="0.01" :step="10" style="width: 100%" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="depositForm.remark" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="depositVisible = false">取消</el-button>
<el-button type="primary" :loading="depositLoading" @click="submitDeposit">确认上分</el-button>
</template>
</el-dialog>
<el-dialog v-model="detailVisible" title="玩家详情" width="560px" destroy-on-close>
<template v-if="detail">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="ID">{{ detail.id }}</el-descriptions-item>
<el-descriptions-item label="用户名">{{ detail.username }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusTagType(detail.status)" size="small">
{{ statusLabel(detail.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="所属代理">
{{ detail.parentUsername ?? '平台直属' }}
</el-descriptions-item>
<el-descriptions-item label="可用余额">
{{ formatAmount(detail.availableBalance) }}
<span v-if="shouldCompact(detail.availableBalance)" class="amount-full-hint">
{{ formatAmountFull(detail.availableBalance) }}
</span>
</el-descriptions-item>
<el-descriptions-item label="冻结余额">
{{ formatAmount(detail.frozenBalance) }}
<span v-if="shouldCompact(detail.frozenBalance)" class="amount-full-hint">
{{ formatAmountFull(detail.frozenBalance) }}
</span>
</el-descriptions-item>
<el-descriptions-item label="手机">{{ detail.phone ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ detail.email ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="注单数">{{ detail.betCount }}</el-descriptions-item>
<el-descriptions-item label="累计投注">{{ formatAmount(detail.totalStake) }}</el-descriptions-item>
<el-descriptions-item label="累计派彩">{{ formatAmount(detail.totalReturn) }}</el-descriptions-item>
<el-descriptions-item label="最后登录">
{{ detail.lastLoginAt ? formatTime(detail.lastLoginAt) : '从未登录' }}
</el-descriptions-item>
<el-descriptions-item label="登录失败">{{ detail.loginFailCount }} </el-descriptions-item>
<el-descriptions-item label="注册时间" :span="2">
{{ formatTime(detail.createdAt) }}
</el-descriptions-item>
</el-descriptions>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; margin: 0 0 4px; }
.page-desc { font-size: 13px; color: #666; }
.filter-card { margin-bottom: 16px; border-radius: 12px; }
.data-card { border-radius: 12px; }
.pager { margin-top: 16px; display: flex; justify-content: flex-end; }
.field-hint { font-size: 12px; color: #888; margin-top: 4px; }
.inline-hint { margin-top: 0; margin-left: 10px; display: inline-block; }
.amount-compact { white-space: nowrap; font-variant-numeric: tabular-nums; cursor: default; }
.amount-full-hint { font-size: 11px; color: #666; margin-left: 4px; }
.text-muted { color: #666; font-size: 12px; }
</style>
<style>
/* 玩家列表「冻结」:橙黄底白字 */
.users-page .el-button.is-link.el-button--warning {
color: #ffffff !important;
background: linear-gradient(165deg, #e8a84a 0%, #c47a18 42%, #9a5c10 100%) !important;
border: 1px solid rgba(232, 168, 74, 0.45) !important;
border-radius: 6px !important;
padding: 5px 11px !important;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.12) inset, 0 1px 6px rgba(0, 0, 0, 0.35) !important;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
}
.users-page .el-button.is-link.el-button--warning:hover,
.users-page .el-button.is-link.el-button--warning:focus {
color: #ffffff !important;
background: linear-gradient(165deg, #f0bc62 0%, #d48a28 42%, #a86814 100%) !important;
border-color: rgba(240, 188, 98, 0.55) !important;
}
</style>