feat(admin): 管理端列表分页、控制台图表与赛事导入

- 玩家/代理/赛事/注单/审计列表分页,默认每页 10 条,无页面滚动条布局

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-03 13:49:31 +08:00
parent 2c356b2048
commit 80adc0e928
45 changed files with 6564 additions and 499 deletions

View File

@@ -2,51 +2,468 @@
import { ref, onMounted } from 'vue';
import api from '../api';
import { ElMessage } from 'element-plus';
import {
emptyAgentCreateForm,
emptyAgentEditForm,
editFormFromAgentDetail,
buildCreateAgentPayload,
type AgentRow,
type AgentDetail,
type AgentCreateForm,
type AgentEditForm,
} from './agent-form';
import {
formatAmount,
formatAmountFull,
shouldCompactAmount as shouldCompact,
} from '../utils/format-amount';
const agents = ref<unknown[]>([]);
const form = ref({ username: '', password: 'Agent@123', creditLimit: 50000 });
const agents = ref<AgentRow[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const keyword = ref('');
function creditLine(row: AgentRow) {
return `${formatAmount(row.creditLimit)} / ${formatAmount(row.usedCredit)} / ${formatAmount(row.availableCredit)}`;
}
function creditLineFull(row: AgentRow) {
return `${formatAmountFull(row.creditLimit)} / ${formatAmountFull(row.usedCredit)} / ${formatAmountFull(row.availableCredit)}`;
}
const createVisible = ref(false);
const editVisible = ref(false);
const detailVisible = ref(false);
const creditVisible = ref(false);
const createLoading = ref(false);
const editLoading = ref(false);
const creditLoading = ref(false);
const createForm = ref<AgentCreateForm>(emptyAgentCreateForm());
const editForm = ref<AgentEditForm>(emptyAgentEditForm());
const detail = ref<AgentDetail | null>(null);
const editingId = ref('');
const creditForm = ref({ amount: 10000, remark: '' });
onMounted(load);
async function load() {
const { data } = await api.get('/admin/agents');
agents.value = data.data;
}
async function create() {
await api.post('/admin/agents', form.value);
ElMessage.success('创建成功');
load();
}
async function adjustCredit(agent: { userId: string }, amount: number) {
await api.post(`/admin/agents/${agent.userId}/credit`, {
amount,
requestId: `credit-${Date.now()}`,
const { data } = await api.get('/admin/agents', {
params: {
page: page.value,
pageSize: pageSize.value,
keyword: keyword.value.trim() || undefined,
},
});
ElMessage.success('额度已调整');
agents.value = data.data.items as AgentRow[];
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 = emptyAgentCreateForm();
createVisible.value = true;
}
async function openDetail(userId: string) {
const { data } = await api.get(`/admin/agents/${userId}`);
detail.value = data.data as AgentDetail;
detailVisible.value = true;
}
async function openEdit(userId: string) {
const { data } = await api.get(`/admin/agents/${userId}`);
const d = data.data as AgentDetail;
editingId.value = userId;
editForm.value = editFormFromAgentDetail(d);
editVisible.value = true;
}
function openCredit(row: AgentRow) {
editingId.value = row.userId;
creditForm.value = { amount: 10000, remark: '' };
creditVisible.value = true;
}
async function submitCreate() {
let payload: ReturnType<typeof buildCreateAgentPayload>;
try {
payload = buildCreateAgentPayload(createForm.value);
} catch (e) {
ElMessage.warning(e instanceof Error ? e.message : '请检查表单');
return;
}
createLoading.value = true;
try {
await api.post('/admin/agents', 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 submitEdit() {
editLoading.value = true;
try {
await api.put(`/admin/agents/${editingId.value}`, {
status: editForm.value.status,
phone: editForm.value.phone.trim() || undefined,
email: editForm.value.email.trim() || undefined,
cashbackRate: editForm.value.cashbackRate,
});
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 submitCredit() {
if (creditForm.value.amount === 0) {
ElMessage.warning('调整金额不能为 0');
return;
}
creditLoading.value = true;
try {
await api.post(`/admin/agents/${editingId.value}/credit`, {
amount: creditForm.value.amount,
requestId: `credit-${editingId.value}-${Date.now()}`,
remark: creditForm.value.remark || undefined,
});
ElMessage.success('授信已调整');
creditVisible.value = false;
load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? '调整失败');
} finally {
creditLoading.value = false;
}
}
function formatTime(v: string) {
if (!v) return '—';
return new Date(v).toLocaleString('zh-CN');
}
function statusTagType(s: string) {
return s === 'ACTIVE' ? 'success' : 'warning';
}
function statusLabel(s: string) {
return s === 'ACTIVE' ? '正常' : s === 'SUSPENDED' ? '停用' : s;
}
function creditTypeLabel(t: string) {
if (t === 'CREDIT_INCREASE') return '增加';
if (t === 'CREDIT_DECREASE') return '减少';
return t;
}
</script>
<template>
<h2>代理管理</h2>
<el-form inline style="margin: 16px 0">
<el-input v-model="form.username" placeholder="用户名" style="width: 120px" />
<el-input-number v-model="form.creditLimit" placeholder="额度" />
<el-button type="primary" @click="create">创建一级代理</el-button>
</el-form>
<el-table :data="agents">
<el-table-column label="用户名">
<template #default="{ row }">{{ (row as { user?: { username: string } }).user?.username }}</template>
</el-table-column>
<el-table-column prop="level" label="层级" />
<el-table-column prop="creditLimit" label="授信额度" />
<el-table-column prop="usedCredit" label="已用额度" />
<el-table-column label="操作">
<template #default="{ row }">
<el-button size="small" @click="adjustCredit(row as { userId: string }, 10000)">+10000</el-button>
</template>
</el-table-column>
</el-table>
<div class="admin-list-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: 180px"
@keyup.enter="load"
/>
</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="agents" stripe>
<el-table-column prop="userId" 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 prop="level" label="层级" width="72" align="center" />
<el-table-column label="授信 / 已用 / 可用" min-width="168" align="right">
<template #default="{ row }">
<el-tooltip :content="creditLineFull(row)" placement="top">
<span class="amount-compact">{{ creditLine(row) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="directPlayerCount" label="直属玩家" width="96" align="center" />
<el-table-column label="返水率" width="88" align="right">
<template #default="{ row }">{{ row.cashbackRate }}</template>
</el-table-column>
<el-table-column prop="phone" label="手机" min-width="110">
<template #default="{ row }">{{ row.phone ?? '—' }}</template>
</el-table-column>
<el-table-column label="创建时间" min-width="158">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="openDetail(row.userId)">详情</el-button>
<el-button size="small" link type="primary" @click="openEdit(row.userId)">编辑</el-button>
<el-button size="small" link type="primary" @click="openCredit(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="授信额度" required>
<el-input-number
v-model="createForm.creditLimit"
:min="0"
:step="10000"
style="width: 100%"
/>
<div class="field-hint">代理可向直属玩家上分的总额度上限</div>
</el-form-item>
<el-form-item label="返水比例">
<el-input-number
v-model="createForm.cashbackRate"
:min="0"
:max="1"
:step="0.001"
:precision="4"
style="width: 100%"
/>
<div class="field-hint">例如 0.01 表示 1%</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>
<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="480px" destroy-on-close>
<el-form label-width="88px">
<el-form-item label="账号状态">
<el-radio-group v-model="editForm.status">
<el-radio value="ACTIVE">正常</el-radio>
<el-radio value="SUSPENDED">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="返水比例">
<el-input-number
v-model="editForm.cashbackRate"
:min="0"
:max="1"
:step="0.001"
:precision="4"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="editForm.phone" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="editForm.email" />
</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="creditVisible" title="调整授信额度" width="420px" destroy-on-close>
<el-form label-width="88px">
<el-form-item label="代理 ID">
<el-input :model-value="editingId" disabled />
</el-form-item>
<el-form-item label="调整金额">
<el-input-number v-model="creditForm.amount" :step="1000" style="width: 100%" />
<div class="field-hint">正数为增加授信负数为减少</div>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="creditForm.remark" placeholder="选填,写入额度流水" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="creditVisible = false">取消</el-button>
<el-button type="primary" :loading="creditLoading" @click="submitCredit">确认调整</el-button>
</template>
</el-dialog>
<el-dialog v-model="detailVisible" title="代理详情" width="640px" destroy-on-close>
<template v-if="detail">
<el-descriptions :column="2" border size="small" class="detail-block">
<el-descriptions-item label="ID">{{ detail.userId }}</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="层级">L{{ detail.level }}</el-descriptions-item>
<el-descriptions-item label="授信额度">
{{ formatAmount(detail.creditLimit) }}
<span v-if="shouldCompact(detail.creditLimit)" class="amount-full-hint">
{{ formatAmountFull(detail.creditLimit) }}
</span>
</el-descriptions-item>
<el-descriptions-item label="已用额度">
{{ formatAmount(detail.usedCredit) }}
<span v-if="shouldCompact(detail.usedCredit)" class="amount-full-hint">
{{ formatAmountFull(detail.usedCredit) }}
</span>
</el-descriptions-item>
<el-descriptions-item label="可用授信">
{{ formatAmount(detail.availableCredit) }}
<span v-if="shouldCompact(detail.availableCredit)" class="amount-full-hint">
{{ formatAmountFull(detail.availableCredit) }}
</span>
</el-descriptions-item>
<el-descriptions-item label="直属玩家">{{ detail.directPlayerCount }} </el-descriptions-item>
<el-descriptions-item label="玩家负债">
{{ formatAmount(detail.directPlayerLiability) }}
</el-descriptions-item>
<el-descriptions-item label="下级代理敞口">
{{ formatAmount(detail.childAgentExposure) }}
</el-descriptions-item>
<el-descriptions-item label="返水率">{{ detail.cashbackRate }}</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="最后登录" :span="2">
{{ detail.lastLoginAt ? formatTime(detail.lastLoginAt) : '从未登录' }}
</el-descriptions-item>
<el-descriptions-item label="创建时间" :span="2">
{{ formatTime(detail.createdAt) }}
</el-descriptions-item>
</el-descriptions>
<div class="section-title">最近额度变动</div>
<el-table
:data="detail.recentCreditTransactions"
size="small"
stripe
empty-text="暂无记录"
>
<el-table-column label="类型" width="80">
<template #default="{ row }">{{ creditTypeLabel(row.transactionType) }}</template>
</el-table-column>
<el-table-column label="变动" width="96" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.amount)" placement="top">
<span>{{ formatAmount(row.amount) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="变动后" width="96" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.creditAfter)" placement="top">
<span>{{ formatAmount(row.creditAfter) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
<el-table-column label="时间" min-width="150">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
</el-table>
</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 { border-radius: 12px; }
.data-card { border-radius: 12px; }
.field-hint { font-size: 12px; color: #888; margin-top: 4px; }
.detail-block { margin-bottom: 16px; }
.section-title {
font-size: 13px;
font-weight: 600;
color: #666;
margin-bottom: 8px;
}
.amount-compact {
white-space: nowrap;
font-variant-numeric: tabular-nums;
cursor: default;
}
.amount-full-hint {
font-size: 11px;
color: #666;
margin-left: 4px;
}
</style>

View File

@@ -3,21 +3,94 @@ import { ref, onMounted } from 'vue';
import api from '../api';
const logs = ref<unknown[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const filterModule = ref('');
onMounted(async () => {
const { data } = await api.get('/admin/audit-logs');
onMounted(load);
async function load() {
const { data } = await api.get('/admin/audit-logs', {
params: {
page: page.value,
pageSize: pageSize.value,
module: filterModule.value || undefined,
},
});
logs.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();
}
</script>
<template>
<h2>操作日志</h2>
<el-table :data="logs">
<el-table-column prop="action" label="操作" />
<el-table-column prop="module" label="模块" />
<el-table-column prop="targetId" label="目标" />
<el-table-column label="时间">
<template #default="{ row }">{{ new Date((row as { createdAt: string }).createdAt).toLocaleString() }}</template>
</el-table-column>
</el-table>
<div class="admin-list-page">
<div class="page-header">
<h2 class="page-title">操作日志</h2>
<span class="page-desc">记录所有管理员操作行为</span>
</div>
<el-card class="filter-card" shadow="never">
<el-form inline>
<el-form-item label="模块">
<el-input
v-model="filterModule"
placeholder="如 USERS、AGENTS"
clearable
style="width: 160px"
@keyup.enter="load"
/>
</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="logs" stripe>
<el-table-column prop="action" label="操作" min-width="140" />
<el-table-column prop="module" label="模块" width="120" />
<el-table-column prop="targetId" label="目标ID" min-width="100" />
<el-table-column label="时间" min-width="160">
<template #default="{ row }">
{{ new Date((row as { createdAt: string }).createdAt).toLocaleString() }}
</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>
</div>
</template>
<style scoped>
.page-header { display: flex; align-items: baseline; gap: 12px; }
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; }
.page-desc { font-size: 13px; color: #3a3a3a; }
.filter-card { border-radius: 12px; }
.data-card { border-radius: 12px; }
</style>

View File

@@ -1,24 +1,319 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import api from '../api';
import { formatAmount, formatAmountFull } from '../utils/format-amount';
import {
betStatusLabel,
betStatusTagType,
betTypeLabel,
betSettlementLabel,
BET_STATUS_OPTIONS,
BET_TYPE_OPTIONS,
} from '../utils/bet-labels';
import type { BetListRow, BetDetail } from './bet-form';
const bets = ref<unknown[]>([]);
const bets = ref<BetListRow[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
onMounted(async () => {
const { data } = await api.get('/admin/bets');
bets.value = data.data.items;
});
const keyword = ref('');
const filterStatus = ref('');
const filterBetType = ref('');
const placedFrom = ref('');
const placedTo = ref('');
const detailVisible = ref(false);
const detail = ref<BetDetail | null>(null);
const detailLoading = ref(false);
onMounted(load);
async function load() {
const { data } = await api.get('/admin/bets', {
params: {
page: page.value,
pageSize: pageSize.value,
keyword: keyword.value.trim() || undefined,
status: filterStatus.value || undefined,
betType: filterBetType.value || undefined,
placedFrom: placedFrom.value || undefined,
placedTo: placedTo.value || undefined,
},
});
bets.value = data.data.items as BetListRow[];
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 resetFilters() {
keyword.value = '';
filterStatus.value = '';
filterBetType.value = '';
placedFrom.value = '';
placedTo.value = '';
page.value = 1;
load();
}
function parentLabel(row: BetListRow) {
return row.parentUsername ?? '平台直属';
}
function formatTime(v: string | null | undefined) {
if (!v) return '—';
return new Date(v).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
function resultStatusLabel(s: string | null | undefined) {
if (!s) return '—';
const map: Record<string, string> = {
WON: '赢',
LOST: '输',
VOID: '走水',
PUSH: '走盘',
HALF_WON: '半赢',
HALF_LOST: '半输',
};
return map[s] ?? s;
}
async function openDetail(row: BetListRow) {
detailLoading.value = true;
detailVisible.value = true;
detail.value = null;
try {
const { data } = await api.get(`/admin/bets/${row.id}`);
detail.value = data.data as BetDetail;
} finally {
detailLoading.value = false;
}
}
</script>
<template>
<h2>注单管理</h2>
<el-table :data="bets">
<el-table-column prop="betNo" label="注单号" />
<el-table-column prop="betType" label="类型" />
<el-table-column prop="stake" label="投注额" />
<el-table-column prop="status" label="状态" />
<el-table-column label="时间">
<template #default="{ row }">{{ new Date((row as { placedAt: string }).placedAt).toLocaleString() }}</template>
</el-table-column>
</el-table>
<div class="admin-list-page">
<div class="page-header">
<h2 class="page-title">注单管理</h2>
<span class="page-desc">筛选分页查看全平台注单支持详情与投注项</span>
</div>
<el-card class="filter-card" shadow="never">
<el-form inline>
<el-form-item label="关键词">
<el-input
v-model="keyword"
placeholder="流水编号 / 玩家用户名"
clearable
style="width: 200px"
@keyup.enter="load"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filterStatus" placeholder="全部" clearable style="width: 120px">
<el-option
v-for="o in BET_STATUS_OPTIONS.filter((x) => x.value !== '')"
:key="o.value"
:label="o.label"
:value="o.value"
/>
</el-select>
</el-form-item>
<el-form-item label="类型">
<el-select v-model="filterBetType" placeholder="全部" clearable style="width: 100px">
<el-option
v-for="o in BET_TYPE_OPTIONS.filter((x) => x.value !== '')"
:key="o.value"
:label="o.label"
:value="o.value"
/>
</el-select>
</el-form-item>
<el-form-item label="投注日起">
<el-date-picker
v-model="placedFrom"
type="date"
value-format="YYYY-MM-DD"
placeholder="开始"
style="width: 140px"
/>
</el-form-item>
<el-form-item label="止">
<el-date-picker
v-model="placedTo"
type="date"
value-format="YYYY-MM-DD"
placeholder="结束"
style="width: 140px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="data-card" shadow="never">
<div class="table-wrap">
<el-table :data="bets" stripe>
<el-table-column prop="id" label="单号" width="56" align="center" />
<el-table-column prop="betNo" label="流水编号" width="168" show-overflow-tooltip>
<template #default="{ row }">
<span class="bet-no">{{ row.betNo }}</span>
</template>
</el-table-column>
<el-table-column prop="username" label="玩家" width="100" show-overflow-tooltip />
<el-table-column label="所属代理" width="100" show-overflow-tooltip>
<template #default="{ row }">{{ parentLabel(row) }}</template>
</el-table-column>
<el-table-column label="类型" width="72" align="center">
<template #default="{ row }">
<el-tag type="info" size="small" effect="plain">{{ betTypeLabel(row.betType) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="选项" width="52" align="center">
<template #default="{ row }">{{ row.selectionCount }}</template>
</el-table-column>
<el-table-column label="投注额" width="96" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.stake)" placement="top">
<span>{{ formatAmount(row.stake) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="赔率" width="72" align="right">
<template #default="{ row }">{{ row.totalOdds ?? '—' }}</template>
</el-table-column>
<el-table-column label="派彩" width="96" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.actualReturn)" placement="top">
<span>{{ formatAmount(row.actualReturn) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="状态" width="88" align="center">
<template #default="{ row }">
<el-tag :type="betStatusTagType(row.status)" size="small">
{{ betStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="投注时间" width="160">
<template #default="{ row }">{{ formatTime(row.placedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="88" fixed="right" align="center">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="openDetail(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="detailVisible" title="注单详情" width="720px" destroy-on-close>
<div v-loading="detailLoading">
<template v-if="detail">
<el-descriptions :column="2" border size="small" class="detail-desc">
<el-descriptions-item label="单号">{{ detail.id }}</el-descriptions-item>
<el-descriptions-item label="流水编号">{{ detail.betNo }}</el-descriptions-item>
<el-descriptions-item label="玩家">{{ detail.username }}</el-descriptions-item>
<el-descriptions-item label="所属代理">{{ parentLabel(detail) }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ betTypeLabel(detail.betType) }}</el-descriptions-item>
<el-descriptions-item label="币种">{{ detail.currency }}</el-descriptions-item>
<el-descriptions-item label="投注额">
{{ formatAmountFull(detail.stake) }}
</el-descriptions-item>
<el-descriptions-item label="总赔率">{{ detail.totalOdds ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="可赢额">
{{ detail.potentialReturn ? formatAmountFull(detail.potentialReturn) : '—' }}
</el-descriptions-item>
<el-descriptions-item label="实际派彩">
{{ formatAmountFull(detail.actualReturn) }}
</el-descriptions-item>
<el-descriptions-item label="注单状态">
<el-tag :type="betStatusTagType(detail.status)" size="small">
{{ betStatusLabel(detail.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="结算状态">
{{ betSettlementLabel(detail.settlementStatus) }}
</el-descriptions-item>
<el-descriptions-item label="投注时间">{{ formatTime(detail.placedAt) }}</el-descriptions-item>
<el-descriptions-item label="结算时间">{{ formatTime(detail.settledAt) }}</el-descriptions-item>
<el-descriptions-item label="请求 ID" :span="2">{{ detail.requestId }}</el-descriptions-item>
</el-descriptions>
<div class="selections-title">投注项{{ detail.selections.length }}</div>
<el-table :data="detail.selections" size="small" stripe border>
<el-table-column type="index" label="#" width="44" />
<el-table-column prop="selectionName" label="选项" min-width="120" show-overflow-tooltip />
<el-table-column prop="marketType" label="玩法" width="100" />
<el-table-column prop="period" label="时段" width="72">
<template #default="{ row }">{{ row.period ?? '—' }}</template>
</el-table-column>
<el-table-column prop="odds" label="赔率" width="72" align="right" />
<el-table-column label="盘口" width="88">
<template #default="{ row }">
{{ row.handicapLine ?? row.totalLine ?? '—' }}
</template>
</el-table-column>
<el-table-column label="赛果" width="72" align="center">
<template #default="{ row }">{{ resultStatusLabel(row.resultStatus) }}</template>
</el-table-column>
</el-table>
</template>
</div>
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 20px; }
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; }
.page-desc { font-size: 13px; color: #666; }
.filter-card { margin-bottom: 16px; border-radius: 12px; }
.data-card { border-radius: 12px; }
.bet-no { font-size: 12px; color: #ccc; font-family: ui-monospace, monospace; }
.pager { display: flex; justify-content: flex-end; margin-top: 16px; }
.detail-desc { margin-bottom: 16px; }
.selections-title {
font-size: 13px;
font-weight: 700;
color: #aaa;
margin-bottom: 10px;
}
</style>

View File

@@ -21,19 +21,63 @@ async function confirm() {
if (!preview.value?.batch) return;
await api.post(`/admin/cashbacks/${(preview.value.batch as { id: string }).id}/confirm`);
ElMessage.success('返水已发放');
preview.value = null;
}
</script>
<template>
<h2>返水管理</h2>
<el-form inline>
<el-date-picker v-model="period.start" type="date" value-format="YYYY-MM-DD" />
<el-date-picker v-model="period.end" type="date" value-format="YYYY-MM-DD" />
<el-button @click="generatePreview">生成预览</el-button>
</el-form>
<el-card v-if="preview" style="margin-top: 16px">
<p>玩家数: {{ (preview.batch as { playerCount: number })?.playerCount }}</p>
<p>总金额: {{ preview.totalAmount }}</p>
<el-button type="success" @click="confirm">确认发放</el-button>
<div class="page-scroll">
<div class="page-header">
<h2 class="page-title">返水管理</h2>
<span class="page-desc">按周期生成返水并发放</span>
</div>
<el-card class="tool-card" shadow="never">
<div class="filter-row">
<el-form inline>
<el-form-item label="开始日期">
<el-date-picker v-model="period.start" type="date" value-format="YYYY-MM-DD" style="width: 150px" />
</el-form-item>
<el-form-item label="结束日期">
<el-date-picker v-model="period.end" type="date" value-format="YYYY-MM-DD" style="width: 150px" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="generatePreview">生成预览</el-button>
</el-form-item>
</el-form>
</div>
</el-card>
<el-card v-if="preview" class="preview-card" shadow="never">
<div class="preview-title">返水预览</div>
<el-row :gutter="20" class="preview-stats">
<el-col :span="8">
<div class="pstat">
<div class="pstat-value">{{ (preview.batch as { playerCount: number })?.playerCount ?? 0 }}</div>
<div class="pstat-label">涉及玩家数</div>
</div>
</el-col>
<el-col :span="8">
<div class="pstat">
<div class="pstat-value">{{ preview.totalAmount }}</div>
<div class="pstat-label">返水总金额</div>
</div>
</el-col>
</el-row>
<el-button type="success" @click="confirm" style="margin-top: 20px">确认发放</el-button>
</el-card>
</div>
</template>
<style scoped>
.page-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 20px; }
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; }
.page-desc { font-size: 13px; color: #3a3a3a; }
.tool-card { margin-bottom: 16px; border-radius: 12px; }
.preview-card { border-radius: 12px; }
.preview-title { font-size: 15px; font-weight: 600; color: #e0e0e0; margin-bottom: 16px; }
.preview-stats { }
.pstat { padding: 16px; background: #f9f9fb; border-radius: 10px; text-align: center; }
.pstat-value { font-size: 26px; font-weight: 700; color: var(--green-glow); }
.pstat-label { font-size: 12px; color: #3a3a3a; margin-top: 4px; }
</style>

View File

@@ -1,21 +1,329 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, computed, onMounted } from 'vue';
import api from '../api';
import { formatAmount, formatAmountFull } from '../utils/format-amount';
import type { AdminDashboard } from './dashboard-types';
import EChartPanel from '../components/dashboard/EChartPanel.vue';
import { buildCombinedTrendOption, buildTriplePieOption } from '../utils/dashboard-charts';
import { betStatusLabel } from '../utils/bet-labels';
const stats = ref<Record<string, unknown>>({});
const stats = ref<AdminDashboard | null>(null);
const loading = ref(true);
onMounted(async () => {
const { data } = await api.get('/admin/dashboard');
stats.value = data.data;
onMounted(load);
async function load() {
loading.value = true;
try {
const { data } = await api.get('/admin/dashboard');
stats.value = data.data as AdminDashboard;
} finally {
loading.value = false;
}
}
const s = computed(() => stats.value);
function fmtCount(val: number | undefined) {
return (val ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
}
function formatTime(v: string) {
return new Date(v).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function toNum(v: string | number | undefined) {
const n = typeof v === 'number' ? v : parseFloat(v ?? '0');
return Number.isFinite(n) ? n : 0;
}
function pctChange(today: string | number, yesterday: string | number) {
const t = toNum(today);
const y = toNum(yesterday);
if (y === 0) return t > 0 ? '+100%' : '—';
const p = ((t - y) / y) * 100;
const sign = p > 0 ? '+' : '';
return `${sign}${p.toFixed(1)}%`;
}
const trendLabels = computed(() => s.value?.trend7d?.map((d) => d.label) ?? []);
const mainTrendOption = computed(() =>
buildCombinedTrendOption(
trendLabels.value,
[
{
name: '投注额',
color: '#248f54',
values: s.value?.trend7d?.map((d) => toNum(d.stake)) ?? [],
},
{
name: '派彩',
color: '#60a5fa',
values: s.value?.trend7d?.map((d) => toNum(d.payout)) ?? [],
},
{
name: '毛利',
color: '#a78bfa',
values: s.value?.trend7d?.map((d) => toNum(d.ggr)) ?? [],
},
],
s.value?.trend7d?.map((d) => d.betCount) ?? [],
),
);
const distributionOption = computed(() => {
const m = s.value?.matches;
const u = s.value?.users;
const raw = s.value?.bets.todayByStatus ?? {};
const betColors: Record<string, string> = {
PENDING: '#fb923c',
WON: '#248f54',
LOST: '#f87171',
VOID: '#6b7280',
REFUNDED: '#60a5fa',
};
const matchSegs = m
? [
{ label: '草稿', value: m.draft, color: '#6b7280' },
{ label: '已发布', value: m.published, color: '#248f54' },
{ label: '已封盘', value: m.closed, color: '#60a5fa' },
{ label: '待结算', value: m.pendingSettlement, color: '#fb923c' },
{ label: '已结算', value: m.settled ?? 0, color: '#5eead4' },
].filter((x) => x.value > 0)
: [];
const betSegs = ['PENDING', 'WON', 'LOST', 'VOID', 'REFUNDED']
.filter((k) => raw[k]?.count)
.map((k) => ({
label: betStatusLabel(k),
value: raw[k].count,
color: betColors[k] ?? '#888',
}));
const userSegs = u
? [
{ label: '正常玩家', value: u.playersActive, color: '#248f54' },
{ label: '停用', value: u.playersSuspended, color: '#f87171' },
{ label: '直属', value: u.playersDirect, color: '#60a5fa' },
{ label: '代理', value: u.agentsTotal, color: '#a78bfa' },
].filter((x) => x.value > 0)
: [];
return buildTriplePieOption([
{ title: '赛事', segments: matchSegs },
{ title: '今日注单', segments: betSegs },
{ title: '用户', segments: userSegs },
]);
});
const kpiPrimary = computed(() => {
if (!s.value) return [];
const t = s.value.today;
const y = s.value.yesterday;
return [
{ label: '今日投注笔数', value: fmtCount(t.betCount), sub: `昨日 ${fmtCount(y.betCount)}`, delta: pctChange(t.betCount, y.betCount) },
{ label: '今日投注额', value: formatAmount(t.stake), sub: formatAmountFull(t.stake), delta: pctChange(t.stake, y.stake) },
{ label: '今日派彩', value: formatAmount(t.payout), sub: `昨日 ${formatAmount(y.payout)}`, delta: pctChange(t.payout, y.payout) },
{ label: '今日毛利', value: formatAmount(t.ggr), sub: `昨日 ${formatAmount(y.ggr)}`, delta: pctChange(t.ggr, y.ggr) },
];
});
const kpiSecondary = computed(() => {
if (!s.value) return [];
return [
{ label: '玩家 / 代理', value: `${fmtCount(s.value.users.playersTotal)} / ${fmtCount(s.value.users.agentsTotal)}`, sub: `今日新增 ${fmtCount(s.value.today.newPlayers)}` },
{ label: '待结算', value: `${fmtCount(s.value.bets.pendingTotal)}`, sub: `${fmtCount(s.value.matches.pendingSettlement)} 场赛事` },
{ label: '玩家余额', value: formatAmount(s.value.wallets.totalAvailable), sub: `冻结 ${formatAmount(s.value.wallets.totalFrozen)}` },
{ label: '代理授信', value: formatAmount(s.value.agents.totalAvailableCredit), sub: `已用 ${formatAmount(s.value.agents.totalUsedCredit)}` },
];
});
</script>
<template>
<h2>控制台</h2>
<el-row :gutter="16" style="margin-top: 16px">
<el-col :span="6"><el-statistic title="今日投注笔数" :value="(stats.todayBetCount as number) || 0" /></el-col>
<el-col :span="6"><el-statistic title="今日投注额" :value="Number(stats.todayStake) || 0" :precision="2" /></el-col>
<el-col :span="6"><el-statistic title="今日派彩" :value="Number(stats.todayPayout) || 0" :precision="2" /></el-col>
<el-col :span="6"><el-statistic title="待结算赛事" :value="(stats.pendingSettlement as number) || 0" /></el-col>
</el-row>
<div class="dashboard-page" v-loading="loading">
<div class="page-header">
<div>
<h2 class="page-title">控制台</h2>
<span class="page-desc">
平台整体运行概况
<template v-if="s?.generatedAt"> · 更新于 {{ formatTime(s.generatedAt) }}</template>
</span>
</div>
</div>
<template v-if="s">
<el-card class="overview-board" shadow="never">
<div class="board-head">
<span class="board-title">整体概览</span>
<span class="board-hint">一屏查看经营趋势与平台分布</span>
</div>
<div class="kpi-grid kpi-primary">
<div v-for="item in kpiPrimary" :key="item.label" class="kpi-cell">
<span class="kpi-label">{{ item.label }}</span>
<span class="kpi-value">{{ item.value }}</span>
<span class="kpi-sub">{{ item.sub }}</span>
<span
class="kpi-delta"
:class="{ up: item.delta.startsWith('+'), down: item.delta.startsWith('-') }"
>
较昨日 {{ item.delta }}
</span>
</div>
</div>
<div class="kpi-grid kpi-secondary">
<div v-for="item in kpiSecondary" :key="item.label" class="kpi-cell compact">
<span class="kpi-label">{{ item.label }}</span>
<span class="kpi-value sm">{{ item.value }}</span>
<span class="kpi-sub">{{ item.sub }}</span>
</div>
</div>
<div class="charts-stack">
<EChartPanel
title=""
:option="mainTrendOption"
height="300px"
class="chart-main"
/>
<div class="chart-main-caption"> 7 日经营趋势金额折线 + 注单柱</div>
<EChartPanel title="" :option="distributionOption" height="200px" class="chart-dist" />
</div>
</el-card>
</template>
</div>
</template>
<style scoped>
.dashboard-page { padding-bottom: 32px; }
.page-header { margin-bottom: 20px; }
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; margin: 0 0 4px; }
.page-desc { font-size: 13px; color: #666; }
.overview-board {
border-radius: 14px;
border: 1px solid #1e1e1e;
background: linear-gradient(180deg, rgba(36, 143, 84, 0.06) 0%, rgba(0, 0, 0, 0) 120px);
margin-bottom: 28px;
}
.overview-board :deep(.el-card__body) {
padding: 20px 22px 16px;
}
.board-head {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 18px;
}
.board-title {
font-size: 16px;
font-weight: 700;
color: #e8e8e8;
}
.board-hint {
font-size: 12px;
color: #555;
}
.kpi-grid {
display: grid;
gap: 10px;
margin-bottom: 10px;
}
.kpi-primary {
grid-template-columns: repeat(4, 1fr);
}
.kpi-secondary {
grid-template-columns: repeat(4, 1fr);
margin-bottom: 18px;
}
.kpi-cell {
padding: 12px 14px;
border-radius: 10px;
border: 1px solid #222;
background: rgba(255, 255, 255, 0.03);
}
.kpi-cell.compact {
padding: 10px 12px;
}
.kpi-label {
display: block;
font-size: 11px;
color: #666;
margin-bottom: 6px;
}
.kpi-value {
display: block;
font-size: 22px;
font-weight: 800;
color: var(--green-text);
line-height: 1.15;
letter-spacing: -0.5px;
}
.kpi-value.sm {
font-size: 17px;
}
.kpi-sub {
display: block;
font-size: 11px;
color: #555;
margin-top: 4px;
}
.kpi-delta {
display: inline-block;
margin-top: 6px;
font-size: 10px;
font-weight: 600;
color: #888;
padding: 2px 6px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.04);
}
.kpi-delta.up { color: #4ade80; }
.kpi-delta.down { color: #f87171; }
.charts-stack {
border-top: 1px solid #1a1a1a;
padding-top: 12px;
}
.chart-main-caption {
font-size: 11px;
color: #555;
text-align: center;
margin: -8px 0 8px;
}
.charts-stack :deep(.chart-panel) {
border: none;
background: transparent;
padding: 8px 0 0;
}
.charts-stack :deep(.chart-title:empty) {
display: none;
}
.chart-dist {
margin-top: 4px;
}
@media (max-width: 1200px) {
.kpi-primary,
.kpi-secondary {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.kpi-primary,
.kpi-secondary {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -4,6 +4,8 @@ import { useRoute, useRouter } from 'vue-router';
import api from '../api';
import { ElMessage } from 'element-plus';
import { useAuthStore, type StaffUser } from '../stores/auth';
import RobotVerify from '../components/RobotVerify.vue';
import bgImage from '../assets/images/bg.png';
const router = useRouter();
const route = useRoute();
@@ -11,17 +13,37 @@ const auth = useAuthStore();
const form = ref({ username: '', password: '' });
const loading = ref(false);
const captchaRef = ref<InstanceType<typeof RobotVerify> | null>(null);
async function quickLogin(username: string, password: string) {
loading.value = true;
try {
const { data } = await api.post('/manage/auth/login', { username, password });
const payload = data.data as { token: string; user: StaffUser };
auth.setSession(payload.token, payload.user);
router.push((route.query.redirect as string) || '/');
} catch {
ElMessage.error('快速登录失败');
} finally {
loading.value = false;
}
}
async function login() {
if (!captchaRef.value?.validate()) {
ElMessage.error('验证码错误,请重试');
captchaRef.value?.refresh();
return;
}
loading.value = true;
try {
const { data } = await api.post('/manage/auth/login', form.value);
const payload = data.data as { token: string; user: StaffUser };
auth.setSession(payload.token, payload.user);
const redirect = (route.query.redirect as string) || '/';
router.push(redirect);
router.push((route.query.redirect as string) || '/');
} catch {
ElMessage.error('登录失败,请检查账号与密码');
captchaRef.value?.refresh();
} finally {
loading.value = false;
}
@@ -29,28 +51,188 @@ async function login() {
</script>
<template>
<div style="min-height: 100vh; display: flex; align-items: center; justify-content: center">
<el-card style="width: 400px">
<div style="text-align: center; margin-bottom: 24px">
<img src="/logo.png" alt="TheBet365" style="height: 64px; width: auto" />
<h2 style="margin-top: 12px; font-size: 16px; font-weight: 500">管理后台登录</h2>
<p style="margin-top: 8px; font-size: 12px; color: #888">
管理员与代理使用同一入口系统将根据账号类型进入对应后台
</p>
</div>
<el-form @submit.prevent="login">
<el-form-item label="用户名">
<el-input v-model="form.username" autocomplete="username" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" autocomplete="current-password" />
</el-form-item>
<el-button type="primary" native-type="submit" :loading="loading" style="width: 100%">登录</el-button>
</el-form>
<p style="margin-top: 16px; font-size: 12px; color: #999; line-height: 1.6">
演示账号admin / Admin@123平台<br />
agent1 / Agent@123一级代理
</p>
</el-card>
<div class="login-page" :style="{ backgroundImage: `url(${bgImage})` }">
<div class="login-mask" />
<div class="login-wrap">
<form @submit.prevent="login" class="login-form" autocomplete="off">
<img src="/logo.png" alt="TheBet365" class="logo" />
<h2 class="title">管理后台</h2>
<label>账号</label>
<input v-model="form.username" class="field" placeholder="请输入用户名" autocomplete="off" required />
<label>密码</label>
<input v-model="form.password" class="field" type="password" placeholder="请输入密码" autocomplete="off" required />
<RobotVerify ref="captchaRef" />
<button type="submit" class="btn-login" :disabled="loading">
{{ loading ? '登录中...' : '登 录' }}
</button>
<div class="quick-label">快速登录调试</div>
<div class="quick-btns">
<button type="button" class="quick-btn" :disabled="loading" @click="quickLogin('admin', 'Admin@123')">
<span class="quick-role">管理员</span>
<span class="quick-acc">admin</span>
</button>
<button type="button" class="quick-btn" :disabled="loading" @click="quickLogin('agent1', 'Agent@123')">
<span class="quick-role">一级代理</span>
<span class="quick-acc">agent1</span>
</button>
</div>
</form>
</div>
</div>
</template>
<style scoped>
.login-page {
min-height: 100vh;
background-color: #000;
background-size: cover;
background-position: center top;
background-repeat: no-repeat;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.login-mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
}
.login-wrap {
position: relative;
z-index: 1;
width: 100%;
max-width: 400px;
padding: 0 20px;
}
.login-form {
display: flex;
flex-direction: column;
gap: 10px;
padding: 28px 22px 22px;
background: rgba(12, 12, 12, 0.95);
border: 1px solid var(--green-border);
border-radius: 14px;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(36, 143, 84, 0.08);
backdrop-filter: blur(20px);
}
.logo {
max-width: 160px;
max-height: 56px;
width: auto;
height: auto;
object-fit: contain;
display: block;
margin: 0 auto 2px;
}
.title {
text-align: center;
font-size: 15px;
font-weight: 700;
color: #aaa;
letter-spacing: 0.12em;
text-transform: uppercase;
margin-bottom: 4px;
}
label {
font-size: 11px;
color: #666;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.field {
width: 100%;
height: 44px;
padding: 0 14px;
background: #0a0a0a;
border: 1px solid #2a2a2a;
border-radius: 8px;
color: #fff;
font-size: 14px;
font-family: inherit;
outline: none;
box-sizing: border-box;
transition: border-color 0.2s;
}
.field::placeholder { color: #3a3a3a; }
.field:focus {
border-color: var(--green-mid);
box-shadow: 0 0 0 3px rgba(47, 181, 106, 0.15);
}
.field:-webkit-autofill,
.field:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0 1000px #0a0a0a inset;
-webkit-text-fill-color: #fff;
caret-color: #fff;
}
.btn-login {
margin-top: 6px;
height: 46px;
background: var(--primary-grad);
border: 1px solid var(--green-border);
border-radius: 8px;
color: #fff;
font-size: 15px;
font-weight: 800;
font-family: inherit;
letter-spacing: 0.15em;
box-shadow: var(--primary-shadow);
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.25);
transition: background 0.2s, box-shadow 0.2s;
}
.btn-login:hover:not(:disabled) {
background: var(--primary-grad-hover);
border-color: rgba(120, 230, 170, 0.45);
color: #fff;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.16) inset, 0 4px 14px rgba(0, 0, 0, 0.45), 0 0 20px rgba(47, 181, 106, 0.3);
}
.btn-login:disabled { opacity: 0.4; cursor: not-allowed; }
.quick-label {
font-size: 10px;
color: #2a2a2a;
text-align: center;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-top: 6px;
}
.quick-btns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.quick-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid #1e1e1e;
border-radius: 7px;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
}
.quick-btn:hover:not(:disabled) {
background: var(--green-surface);
border-color: var(--green-border);
}
.quick-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.quick-role {
font-size: 11px;
font-weight: 700;
color: #555;
letter-spacing: 0.04em;
}
.quick-acc {
font-size: 12px;
color: var(--green-text);
font-weight: 600;
font-family: monospace;
}
</style>

View File

@@ -1,31 +1,185 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api';
import { ElMessage } from 'element-plus';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
emptyMatchForm,
buildPlatformPayload,
formFromDetail,
type MatchCreateForm,
type AdminMatchDetail,
} from './match-form';
const router = useRouter();
const matches = ref<unknown[]>([]);
const form = ref({
leagueId: '',
homeTeamId: '',
awayTeamId: '',
startTime: '',
});
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const filterStatus = ref('');
const keyword = ref('');
const createVisible = ref(false);
const editVisible = ref(false);
const importVisible = ref(false);
const createLoading = ref(false);
const editLoading = ref(false);
const importLoading = ref(false);
const importJson = ref('');
const form = ref<MatchCreateForm>(emptyMatchForm());
const editingId = ref('');
const editingStatus = ref('');
const isEditPublished = computed(() => editingStatus.value === 'PUBLISHED');
onMounted(load);
async function load() {
const { data } = await api.get('/admin/matches');
matches.value = data.data;
const { data } = await api.get('/admin/matches', {
params: {
page: page.value,
pageSize: pageSize.value,
status: filterStatus.value || undefined,
keyword: keyword.value.trim() || undefined,
},
});
matches.value = data.data.items;
total.value = data.data.total;
}
async function create() {
await api.post('/admin/matches', form.value);
ElMessage.success('赛事已创建');
function onPageChange(p: number) {
page.value = p;
load();
}
function onSizeChange(size: number) {
pageSize.value = size;
page.value = 1;
load();
}
function openCreate() {
form.value = emptyMatchForm();
editingId.value = '';
createVisible.value = true;
}
function openImport() {
importJson.value = '';
importVisible.value = true;
}
async function openEdit(id: string) {
try {
const { data } = await api.get(`/admin/matches/${id}`);
const detail = data.data as AdminMatchDetail;
if (detail.isOutright) {
ElMessage.warning('冠军盘不支持在此编辑');
return;
}
editingId.value = id;
editingStatus.value = detail.status;
form.value = formFromDetail(detail);
editVisible.value = true;
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? '加载赛事失败');
}
}
async function submitCreate() {
let payload: ReturnType<typeof buildPlatformPayload>;
try {
payload = buildPlatformPayload(form.value);
} catch (e) {
ElMessage.warning(e instanceof Error ? e.message : '请检查表单');
return;
}
createLoading.value = true;
try {
await api.post('/admin/matches', 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 submitEdit() {
let payload: ReturnType<typeof buildPlatformPayload>;
try {
payload = buildPlatformPayload(form.value);
} catch (e) {
ElMessage.warning(e instanceof Error ? e.message : '请检查表单');
return;
}
editLoading.value = true;
try {
await api.put(`/admin/matches/${editingId.value}`, payload);
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 confirmDelete(row: unknown) {
const id = matchId(row);
const title = matchTitle(row);
try {
await ElMessageBox.confirm(`确定删除赛事「${title}」?仅草稿且无注单时可删除。`, '删除确认', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
});
await api.delete(`/admin/matches/${id}`);
ElMessage.success('已删除');
load();
} catch (e) {
if (e === 'cancel' || (e as { message?: string })?.message === 'cancel') return;
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? '删除失败');
}
}
async function submitImport() {
let payload: unknown;
try {
payload = JSON.parse(importJson.value);
} catch {
ElMessage.error('JSON 格式无效');
return;
}
importLoading.value = true;
try {
const { data } = await api.post('/admin/matches/import', payload);
const r = data.data as {
imported: number;
skipped: number;
failed: number;
total: number;
};
ElMessage.success(
`导入完成:成功 ${r.imported},跳过 ${r.skipped},失败 ${r.failed} / 共 ${r.total}`,
);
importVisible.value = false;
load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? '导入失败');
} finally {
importLoading.value = false;
}
}
async function publish(id: string) {
await api.post(`/admin/matches/${id}/publish`);
await api.post(`/admin/matches/${id}/markets/templates`, {
@@ -44,31 +198,298 @@ async function close(id: string) {
function settle(id: string) {
router.push(`/settlement/${id}`);
}
type TagType = '' | 'info' | 'success' | 'warning' | 'danger';
const statusLabels: Record<string, string> = {
DRAFT: '草稿',
PUBLISHED: '已发布',
CLOSED: '已封盘',
SETTLED: '已结算',
};
const statusTagTypes: Record<string, TagType> = {
DRAFT: 'info',
PUBLISHED: 'warning',
CLOSED: 'danger',
SETTLED: 'success',
};
function rowOf(row: unknown) {
return row as Record<string, unknown>;
}
function matchStatus(row: unknown) {
return String(rowOf(row).status ?? '');
}
function matchStatusLabel(row: unknown) {
return statusLabels[matchStatus(row)] ?? matchStatus(row);
}
function matchStatusType(row: unknown): TagType {
return statusTagTypes[matchStatus(row)] ?? 'info';
}
function matchId(row: unknown) {
return String(rowOf(row).id ?? '');
}
function matchTime(row: unknown) {
return new Date(String(rowOf(row).startTime)).toLocaleString();
}
function matchTitle(row: unknown) {
const r = rowOf(row);
if (r.matchName) return String(r.matchName);
const home = (r.homeTeam as { code?: string })?.code ?? '';
const away = (r.awayTeam as { code?: string })?.code ?? '';
return home && away ? `${home} vs ${away}` : '—';
}
function canEdit(row: unknown) {
const r = rowOf(row);
if (r.isOutright) return false;
return matchStatus(row) === 'DRAFT' || matchStatus(row) === 'PUBLISHED';
}
function canDelete(row: unknown) {
const r = rowOf(row);
if (r.isOutright) return false;
return matchStatus(row) === 'DRAFT';
}
</script>
<template>
<h2>赛事管理</h2>
<el-card style="margin-bottom: 16px">
<div class="admin-list-page">
<div class="page-header">
<div>
<h2 class="page-title">赛事管理</h2>
<span class="page-desc">草稿可编辑删除已发布可改开赛时间与热门</span>
</div>
<div class="header-actions">
<el-button @click="openImport">导入</el-button>
<el-button type="primary" @click="openCreate">+ 新增赛事</el-button>
</div>
</div>
<el-card class="filter-card" shadow="never">
<el-form inline>
<el-input v-model="form.leagueId" placeholder="联赛ID" style="width: 100px" />
<el-input v-model="form.homeTeamId" placeholder="主队ID" style="width: 100px" />
<el-input v-model="form.awayTeamId" placeholder="客队ID" style="width: 100px" />
<el-input v-model="form.startTime" placeholder="开赛时间 ISO" style="width: 200px" />
<el-button type="primary" @click="create">创建赛事</el-button>
<el-form-item label="关键词">
<el-input
v-model="keyword"
placeholder="赛事名 / 球队代码"
clearable
style="width: 200px"
@keyup.enter="load"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filterStatus" placeholder="全部" clearable style="width: 120px">
<el-option label="草稿" value="DRAFT" />
<el-option label="已发布" value="PUBLISHED" />
<el-option label="已封盘" value="CLOSED" />
<el-option label="已结算" value="SETTLED" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">查询</el-button>
</el-form-item>
</el-form>
</el-card>
<el-table :data="matches">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="status" label="状态" />
<el-table-column label="开赛时间">
<template #default="{ row }">{{ new Date((row as { startTime: string }).startTime).toLocaleString() }}</template>
</el-table-column>
<el-table-column label="操作" width="300">
<template #default="{ row }">
<el-button v-if="(row as { status: string }).status === 'DRAFT'" size="small" @click="publish((row as { id: string }).id)">发布</el-button>
<el-button v-if="(row as { status: string }).status === 'PUBLISHED'" size="small" @click="close((row as { id: string }).id)">封盘</el-button>
<el-button size="small" type="warning" @click="settle((row as { id: string }).id)">结算</el-button>
</template>
</el-table-column>
</el-table>
<el-card class="data-card" shadow="never">
<div class="table-wrap">
<el-table :data="matches" stripe>
<el-table-column prop="id" label="ID" width="72" />
<el-table-column label="对阵" min-width="200">
<template #default="{ row }">{{ matchTitle(row) }}</template>
</el-table-column>
<el-table-column label="状态" width="96">
<template #default="{ row }">
<el-tag :type="matchStatusType(row)" size="small">{{ matchStatusLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="开赛时间" min-width="160">
<template #default="{ row }">{{ matchTime(row) }}</template>
</el-table-column>
<el-table-column label="操作" width="340" align="center" fixed="right">
<template #default="{ row }">
<el-button
v-if="canEdit(row)"
size="small"
plain
@click="openEdit(matchId(row))"
>
编辑
</el-button>
<el-button
v-if="canDelete(row)"
size="small"
type="danger"
plain
@click="confirmDelete(row)"
>
删除
</el-button>
<el-button
v-if="matchStatus(row) === 'DRAFT'"
size="small"
type="primary"
plain
@click="publish(matchId(row))"
>
发布
</el-button>
<el-button
v-if="matchStatus(row) === 'PUBLISHED'"
size="small"
type="danger"
plain
@click="close(matchId(row))"
>
封盘
</el-button>
<el-button size="small" type="warning" plain @click="settle(matchId(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="96px">
<el-form-item label="联赛(英)">
<el-input v-model="form.leagueEn" placeholder="FIFA World Cup 2026" />
</el-form-item>
<el-form-item label="联赛(中)">
<el-input v-model="form.leagueZh" placeholder="2026 世界杯" />
</el-form-item>
<el-form-item label="开赛时间" required>
<el-input v-model="form.startTime" placeholder="2026-06-11T19:00:00Z" />
</el-form-item>
<el-form-item label="主队(英)">
<el-input v-model="form.homeTeamEn" placeholder="Mexico" />
</el-form-item>
<el-form-item label="主队(中)">
<el-input v-model="form.homeTeamZh" placeholder="墨西哥" />
</el-form-item>
<el-form-item label="客队(英)">
<el-input v-model="form.awayTeamEn" placeholder="South Africa" />
</el-form-item>
<el-form-item label="客队(中)">
<el-input v-model="form.awayTeamZh" placeholder="南非" />
</el-form-item>
<el-form-item label="热门">
<el-switch v-model="form.isHot" />
</el-form-item>
<p class="field-hint">创建后为草稿请在列表点击发布并生成盘口</p>
</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="520px" destroy-on-close>
<el-form label-width="96px">
<p v-if="isEditPublished" class="field-hint edit-hint">
已发布可修改开赛时间热门及显示名称封盘/已结算后不可编辑
</p>
<el-form-item label="联赛(英)">
<el-input v-model="form.leagueEn" :disabled="isEditPublished" />
</el-form-item>
<el-form-item label="联赛(中)">
<el-input v-model="form.leagueZh" :disabled="isEditPublished" />
</el-form-item>
<el-form-item label="开赛时间" required>
<el-input v-model="form.startTime" />
</el-form-item>
<el-form-item label="主队(英)">
<el-input v-model="form.homeTeamEn" :disabled="isEditPublished" />
</el-form-item>
<el-form-item label="主队(中)">
<el-input v-model="form.homeTeamZh" :disabled="isEditPublished" />
</el-form-item>
<el-form-item label="客队(英)">
<el-input v-model="form.awayTeamEn" :disabled="isEditPublished" />
</el-form-item>
<el-form-item label="客队(中)">
<el-input v-model="form.awayTeamZh" :disabled="isEditPublished" />
</el-form-item>
<el-form-item label="热门">
<el-switch v-model="form.isHot" />
</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="importVisible" title="导入赛事" width="640px" destroy-on-close>
<p class="dialog-hint">粘贴含 <code>matches</code> JSON导入后为草稿需在列表发布</p>
<el-input
v-model="importJson"
type="textarea"
:rows="14"
placeholder='{"matches":[...]}'
/>
<template #footer>
<el-button @click="importVisible = false">取消</el-button>
<el-button type="primary" :loading="importLoading" @click="submitImport">开始导入</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.page-title {
font-size: 20px;
font-weight: 700;
color: #e0e0e0;
margin: 0 0 4px;
}
.page-desc {
font-size: 13px;
color: #3a3a3a;
}
.header-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.filter-card { border-radius: 12px; }
.data-card {
border-radius: 12px;
}
.dialog-hint {
font-size: 13px;
color: #666;
margin: 0 0 12px;
line-height: 1.5;
}
.dialog-hint code {
color: #aaa;
}
.field-hint {
font-size: 12px;
color: #666;
margin: 0;
line-height: 1.5;
}
.edit-hint {
margin-bottom: 16px;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref } from 'vue';
import { useRoute } from 'vue-router';
import api from '../api';
import { ElMessage } from 'element-plus';
@@ -22,25 +22,105 @@ async function confirm() {
if (!preview.value?.batch) return;
await api.post(`/admin/settlement/${(preview.value.batch as { id: string }).id}/confirm`);
ElMessage.success('结算已确认');
preview.value = null;
}
</script>
<template>
<h2>赛事结算 #{{ route.params.id }}</h2>
<el-form inline style="margin: 16px 0">
<el-input-number v-model="score.htHome" :min="0" />
<el-input-number v-model="score.htAway" :min="0" />
<span>半场</span>
<el-input-number v-model="score.ftHome" :min="0" />
<el-input-number v-model="score.ftAway" :min="0" />
<span>全场</span>
<el-button @click="recordScore">录入比分</el-button>
<el-button type="primary" @click="previewSettlement">生成预览</el-button>
</el-form>
<el-card v-if="preview">
<p>单关注单: {{ preview.singleBetCount }}</p>
<p>预计派彩: {{ preview.totalPayout }}</p>
<p>退款: {{ preview.totalRefund }}</p>
<el-button type="success" @click="confirm">确认结算</el-button>
<div class="page-header">
<h2 class="page-title">赛事结算</h2>
<span class="page-id"># {{ route.params.id }}</span>
</div>
<el-card class="tool-card" shadow="never">
<div class="score-section">
<div class="score-block">
<div class="score-title">半场比分</div>
<div class="score-inputs">
<el-input-number v-model="score.htHome" :min="0" controls-position="right" style="width: 100px" />
<span class="score-sep"></span>
<el-input-number v-model="score.htAway" :min="0" controls-position="right" style="width: 100px" />
</div>
</div>
<div class="score-block">
<div class="score-title">全场比分</div>
<div class="score-inputs">
<el-input-number v-model="score.ftHome" :min="0" controls-position="right" style="width: 100px" />
<span class="score-sep"></span>
<el-input-number v-model="score.ftAway" :min="0" controls-position="right" style="width: 100px" />
</div>
</div>
</div>
<div class="action-row">
<el-button @click="recordScore">录入比分</el-button>
<el-button type="primary" @click="previewSettlement">生成结算预览</el-button>
</div>
</el-card>
<el-card v-if="preview" class="preview-card" shadow="never">
<div class="preview-title">结算预览</div>
<el-row :gutter="20">
<el-col :span="8">
<div class="pstat">
<div class="pstat-value">{{ preview.singleBetCount }}</div>
<div class="pstat-label">单关注单数</div>
</div>
</el-col>
<el-col :span="8">
<div class="pstat">
<div class="pstat-value pstat-green">{{ preview.totalPayout }}</div>
<div class="pstat-label">预计派彩</div>
</div>
</el-col>
<el-col :span="8">
<div class="pstat">
<div class="pstat-value pstat-orange">{{ preview.totalRefund }}</div>
<div class="pstat-label">退款金额</div>
</div>
</el-col>
</el-row>
<el-button type="success" @click="confirm" style="margin-top: 24px">确认结算</el-button>
</el-card>
</template>
<style scoped>
.page-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 20px; }
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; }
.page-id { font-size: 14px; color: #3a3a3a; font-family: monospace; }
.tool-card { margin-bottom: 16px; border-radius: 12px; }
.preview-card { border-radius: 12px; }
.score-section {
display: flex;
gap: 40px;
margin-bottom: 20px;
}
.score-block { }
.score-title {
font-size: 13px;
color: #3a3a3a;
margin-bottom: 8px;
font-weight: 500;
}
.score-inputs {
display: flex;
align-items: center;
gap: 10px;
}
.score-sep {
font-size: 18px;
color: #ccc;
font-weight: 300;
}
.action-row {
display: flex;
gap: 10px;
}
.preview-title { font-size: 15px; font-weight: 600; color: #e0e0e0; margin-bottom: 16px; }
.pstat { padding: 16px; background: #f9f9fb; border-radius: 10px; text-align: center; }
.pstat-value { font-size: 26px; font-weight: 700; color: #e0e0e0; }
.pstat-green { color: var(--green-glow); text-shadow: 0 0 20px rgba(47, 181, 106, 0.35); }
.pstat-orange { color: #c85a00; }
.pstat-label { font-size: 12px; color: #3a3a3a; margin-top: 4px; }
</style>

View File

@@ -1,38 +1,585 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import api from '../api';
import { ElMessage } from 'element-plus';
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<unknown[]>([]);
const form = ref({ username: '', password: 'Player@123', parentId: '' });
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('');
onMounted(load);
const agentOptions = ref<{ id: string; username: string }[]>([]);
async function load() {
const { data } = await api.get('/admin/users');
users.value = data.data.items;
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 create() {
await api.post('/admin/users', form.value);
ElMessage.success('创建成功');
form.value.username = '';
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>
<h2>玩家管理</h2>
<el-form inline style="margin: 16px 0">
<el-input v-model="form.username" placeholder="用户名" style="width: 150px" />
<el-input v-model="form.password" placeholder="密码" style="width: 150px" />
<el-button type="primary" @click="create">创建玩家</el-button>
</el-form>
<el-table :data="users">
<el-table-column prop="username" label="用户名" />
<el-table-column prop="status" label="状态" />
<el-table-column label="余额">
<template #default="{ row }">{{ (row as { wallet?: { availableBalance: string } }).wallet?.availableBalance }}</template>
</el-table-column>
</el-table>
<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>

View File

@@ -0,0 +1,96 @@
export interface AgentCreateForm {
username: string;
password: string;
confirmPassword: string;
creditLimit: number;
cashbackRate: number;
phone: string;
email: string;
}
export interface AgentEditForm {
status: string;
phone: string;
email: string;
cashbackRate: number;
}
export interface AgentRow {
userId: string;
username: string;
userStatus: string;
level: number;
status: string;
creditLimit: string;
usedCredit: string;
availableCredit: string;
directPlayerCount: number;
cashbackRate: string;
phone: string | null;
email: string | null;
locale: string;
createdAt: string;
}
export interface AgentDetail extends AgentRow {
parentAgentId: string | null;
parentUsername: string | null;
directPlayerLiability: string;
childAgentExposure: string;
lastLoginAt: string | null;
updatedAt: string;
recentCreditTransactions: {
id: string;
transactionType: string;
amount: string;
creditBefore: string;
creditAfter: string;
remark: string | null;
createdAt: string;
}[];
}
export function emptyAgentCreateForm(): AgentCreateForm {
return {
username: '',
password: 'Agent@123',
confirmPassword: 'Agent@123',
creditLimit: 50000,
cashbackRate: 0,
phone: '',
email: '',
};
}
export function emptyAgentEditForm(): AgentEditForm {
return {
status: 'ACTIVE',
phone: '',
email: '',
cashbackRate: 0,
};
}
export function editFormFromAgentDetail(d: AgentDetail): AgentEditForm {
return {
status: d.status,
phone: d.phone ?? '',
email: d.email ?? '',
cashbackRate: Number(d.cashbackRate),
};
}
export function buildCreateAgentPayload(form: AgentCreateForm) {
if (!form.username.trim()) throw new Error('请填写用户名');
if (form.password.length < 8) throw new Error('密码至少 8 位');
if (form.password !== form.confirmPassword) throw new Error('两次密码不一致');
if (form.creditLimit < 0) throw new Error('授信额度不能为负');
return {
username: form.username.trim(),
password: form.password,
creditLimit: form.creditLimit,
cashbackRate: form.cashbackRate,
phone: form.phone.trim() || undefined,
email: form.email.trim() || undefined,
};
}

View File

@@ -1,25 +1,105 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import api from '../../api';
import { formatAmount } from '../../utils/format-amount';
import { betStatusLabel, betStatusTagType, betTypeLabel } from '../../utils/bet-labels';
const bets = ref<unknown[]>([]);
interface BetRow {
id: string;
betNo: string;
betType?: string;
stake: string | number;
status: string;
placedAt?: string;
user?: { username?: string };
}
onMounted(async () => {
const { data } = await api.get('/agent/bets');
bets.value = data.data.items;
});
const bets = ref<BetRow[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
onMounted(load);
async function load() {
const { data } = await api.get('/agent/bets', {
params: { page: page.value, pageSize: pageSize.value },
});
bets.value = (data.data.items ?? []) as BetRow[];
total.value = data.data.total ?? 0;
}
function onPageChange(p: number) {
page.value = p;
load();
}
function onSizeChange(size: number) {
pageSize.value = size;
page.value = 1;
load();
}
</script>
<template>
<h2>下级注单</h2>
<el-table :data="bets">
<el-table-column prop="betNo" label="注单号" />
<el-table-column label="玩家">
<template #default="{ row }">
{{ (row as { user?: { username: string } }).user?.username }}
</template>
</el-table-column>
<el-table-column prop="stake" label="投注额" />
<el-table-column prop="status" label="状态" />
</el-table>
<div class="admin-list-page">
<div class="page-header">
<h2 class="page-title">注单查询</h2>
<span class="page-desc">下级玩家的全部投注记录</span>
</div>
<el-card class="data-card" shadow="never">
<div class="table-wrap">
<el-table :data="bets" stripe>
<el-table-column prop="id" label="单号" width="56" align="center" />
<el-table-column prop="betNo" label="流水编号" width="168" show-overflow-tooltip>
<template #default="{ row }">
<span class="bet-no">{{ row.betNo }}</span>
</template>
</el-table-column>
<el-table-column label="玩家" min-width="100">
<template #default="{ row }">{{ row.user?.username ?? '—' }}</template>
</el-table-column>
<el-table-column label="类型" width="88" align="center">
<template #default="{ row }">
<el-tag v-if="row.betType" type="info" size="small" effect="plain">
{{ betTypeLabel(row.betType) }}
</el-tag>
<span v-else></span>
</template>
</el-table-column>
<el-table-column label="投注额" width="110" align="right">
<template #default="{ row }">{{ formatAmount(row.stake) }}</template>
</el-table-column>
<el-table-column label="状态" width="96" align="center">
<template #default="{ row }">
<el-tag :type="betStatusTagType(row.status)" size="small">
{{ betStatusLabel(row.status) }}
</el-tag>
</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>
</div>
</template>
<style scoped>
.page-header { display: flex; align-items: baseline; gap: 12px; }
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; }
.page-desc { font-size: 13px; color: #3a3a3a; }
.data-card { border-radius: 12px; }
.bet-no { font-size: 12px; color: #ccc; font-family: ui-monospace, monospace; word-break: break-all; }
</style>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import api from '../../api';
import { formatAmount } from '../../utils/format-amount';
const summary = ref<Record<string, unknown>>({});
@@ -8,28 +9,96 @@ onMounted(async () => {
const { data } = await api.get('/agent/reports/summary');
summary.value = data.data;
});
function fmtCount(val: unknown) {
return (Number(val) || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
}
</script>
<template>
<h2>代理概览</h2>
<el-row :gutter="16" style="margin-top: 16px">
<div class="page-header">
<h2 class="page-title">代理概览</h2>
<span class="page-desc">实时数据总览</span>
</div>
<el-row :gutter="20" class="stat-row">
<el-col :span="6">
<el-statistic
title="授信额度"
:value="Number((summary.profile as { creditLimit?: string })?.creditLimit) || 0"
/>
<div class="stat-card c-blue">
<div class="stat-top">
<span class="stat-label">授信额度</span>
<span class="stat-badge">¥</span>
</div>
<div class="stat-value">{{ formatAmount((summary.profile as { creditLimit?: string })?.creditLimit) }}</div>
<div class="stat-foot">总额度</div>
</div>
</el-col>
<el-col :span="6">
<el-statistic
title="已用额度"
:value="Number((summary.profile as { usedCredit?: string })?.usedCredit) || 0"
/>
<div class="stat-card c-orange">
<div class="stat-top">
<span class="stat-label">已用额度</span>
<span class="stat-badge">¥</span>
</div>
<div class="stat-value">{{ formatAmount((summary.profile as { usedCredit?: string })?.usedCredit) }}</div>
<div class="stat-foot">已占用</div>
</div>
</el-col>
<el-col :span="6">
<el-statistic title="直属玩家" :value="(summary.directPlayerCount as number) || 0" />
<div class="stat-card c-green">
<div class="stat-top">
<span class="stat-label">直属玩家</span>
<span class="stat-badge"></span>
</div>
<div class="stat-value">{{ fmtCount(summary.directPlayerCount) }}</div>
<div class="stat-foot">玩家数</div>
</div>
</el-col>
<el-col :span="6">
<el-statistic title="今日投注" :value="Number(summary.todayStake) || 0" :precision="2" />
<div class="stat-card c-purple">
<div class="stat-top">
<span class="stat-label">今日投注</span>
<span class="stat-badge">¥</span>
</div>
<div class="stat-value">{{ formatAmount(summary.todayStake) }}</div>
<div class="stat-foot">人民币</div>
</div>
</el-col>
</el-row>
</template>
<style scoped>
.page-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 24px; }
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; }
.page-desc { font-size: 13px; color: #3a3a3a; }
.stat-card {
border-radius: 12px;
padding: 22px 20px 18px;
border: 1px solid #1e1e1e;
transition: transform 0.2s, border-color 0.2s;
backdrop-filter: blur(10px);
}
.stat-card:hover { transform: translateY(-2px); border-color: #2a2a2a; }
.stat-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; }
.stat-label { font-size: 11px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }
.stat-badge { width: 30px; height: 30px; border-radius: 7px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; }
.stat-value { font-size: 28px; font-weight: 800; line-height: 1; margin-bottom: 8px; letter-spacing: -1px; }
.stat-foot { font-size: 11px; opacity: 0.4; font-weight: 600; letter-spacing: 0.04em; }
.c-blue { background: rgba(22,78,180,0.1); color: #60a5fa; }
.c-blue .stat-badge { background: rgba(96,165,250,0.12); color: #60a5fa; }
.c-orange { background: rgba(180,80,0,0.1); color: #fb923c; }
.c-orange .stat-badge { background: rgba(251,146,60,0.12); color: #fb923c; }
.c-green {
background: linear-gradient(145deg, rgba(36, 143, 84, 0.18), rgba(20, 92, 56, 0.08));
border: 1px solid var(--green-border);
color: var(--green-text);
}
.c-green .stat-badge {
background: var(--primary-grad);
border: 1px solid var(--green-border);
color: #fff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}
.c-purple { background: rgba(100,40,200,0.1); color: #a78bfa; }
.c-purple .stat-badge { background: rgba(167,139,250,0.12); color: #a78bfa; }
</style>

View File

@@ -2,6 +2,7 @@
import { ref, onMounted } from 'vue';
import api from '../../api';
import { ElMessage } from 'element-plus';
import { formatAmount, formatAmountFull } from '../../utils/format-amount';
const players = ref<unknown[]>([]);
const form = ref({ username: '', password: 'Player@123' });
@@ -41,28 +42,97 @@ async function withdraw(playerId: string, amount: number) {
</script>
<template>
<h2>直属玩家</h2>
<el-form inline style="margin-bottom: 16px">
<el-input v-model="form.username" placeholder="用户名" />
<el-button type="primary" @click="create">创建玩家</el-button>
</el-form>
<el-form inline style="margin-bottom: 16px">
<el-input v-model="depositForm.playerId" placeholder="玩家ID" style="width: 100px" />
<el-input-number v-model="depositForm.amount" :min="1" />
<el-button type="success" @click="deposit">上分</el-button>
</el-form>
<el-table :data="players">
<el-table-column prop="id" label="ID" />
<el-table-column prop="username" label="用户名" />
<el-table-column label="余额">
<template #default="{ row }">
{{ (row as { wallet?: { availableBalance: string } }).wallet?.availableBalance }}
</template>
</el-table-column>
<el-table-column label="操作">
<template #default="{ row }">
<el-button size="small" @click="withdraw((row as { id: string }).id, 50)">下分50</el-button>
</template>
</el-table-column>
</el-table>
<div class="admin-list-page">
<div class="page-header">
<h2 class="page-title">直属玩家</h2>
<span class="page-desc">管理你名下的直属玩家</span>
</div>
<el-card class="tool-card" shadow="never">
<div class="tool-row">
<div class="tool-section">
<div class="tool-section-title">创建玩家</div>
<el-form inline>
<el-form-item label="用户名">
<el-input v-model="form.username" placeholder="输入用户名" style="width: 150px" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="create">+ 创建玩家</el-button>
</el-form-item>
</el-form>
</div>
<div class="tool-divider" />
<div class="tool-section">
<div class="tool-section-title">上分操作</div>
<el-form inline>
<el-form-item label="玩家ID">
<el-input v-model="depositForm.playerId" placeholder="玩家ID" style="width: 110px" />
</el-form-item>
<el-form-item label="金额">
<el-input-number v-model="depositForm.amount" :min="1" style="width: 130px" />
</el-form-item>
<el-form-item>
<el-button type="success" @click="deposit">上分</el-button>
</el-form-item>
</el-form>
</div>
</div>
</el-card>
<el-card class="data-card" shadow="never">
<div class="table-wrap">
<el-table :data="players" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column label="可用余额" min-width="100" align="right">
<template #default="{ row }">
<template v-if="(row as { wallet?: { availableBalance: string } }).wallet?.availableBalance != null">
<el-tooltip
:content="formatAmountFull((row as { wallet: { availableBalance: string } }).wallet.availableBalance)"
placement="top"
>
<span>{{ formatAmount((row as { wallet: { availableBalance: string } }).wallet.availableBalance) }}</span>
</el-tooltip>
</template>
<span v-else></span>
</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center">
<template #default="{ row }">
<el-button size="small" type="warning" plain @click="withdraw((row as { id: string }).id, 50)">下分 50</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</div>
</template>
<style scoped>
.page-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 20px; }
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; }
.page-desc { font-size: 13px; color: #3a3a3a; }
.tool-card { margin-bottom: 16px; border-radius: 12px; }
.data-card { border-radius: 12px; }
.tool-row {
display: flex;
gap: 0;
align-items: flex-start;
}
.tool-section { flex: 1; padding-right: 24px; }
.tool-section-title {
font-size: 13px;
font-weight: 600;
color: #666;
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.tool-divider {
width: 1px;
background: #eee;
align-self: stretch;
margin: 0 24px 0 0;
}
</style>

View File

@@ -2,6 +2,7 @@
import { ref, onMounted } from 'vue';
import api from '../../api';
import { ElMessage } from 'element-plus';
import { formatAmount, formatAmountFull } from '../../utils/format-amount';
const agents = ref<unknown[]>([]);
const form = ref({ username: '', password: 'Agent@123', creditLimit: 10000 });
@@ -21,19 +22,58 @@ async function create() {
</script>
<template>
<h2>下级代理仅一级代理可见</h2>
<el-form inline style="margin-bottom: 16px">
<el-input v-model="form.username" placeholder="用户名" />
<el-input-number v-model="form.creditLimit" />
<el-button type="primary" @click="create">创建二级代理</el-button>
</el-form>
<el-table :data="agents">
<el-table-column label="用户名">
<template #default="{ row }">
{{ (row as { user?: { username: string } }).user?.username }}
</template>
</el-table-column>
<el-table-column prop="creditLimit" label="额度" />
<el-table-column prop="usedCredit" label="已用" />
</el-table>
<div class="admin-list-page">
<div class="page-header">
<h2 class="page-title">下级代理</h2>
<span class="page-desc">仅一级代理可见</span>
</div>
<el-card class="tool-card" shadow="never">
<el-form inline>
<el-form-item label="用户名">
<el-input v-model="form.username" placeholder="代理用户名" style="width: 150px" />
</el-form-item>
<el-form-item label="授信额度">
<el-input-number v-model="form.creditLimit" :min="0" :step="1000" style="width: 150px" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="create">+ 创建二级代理</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="data-card" shadow="never">
<div class="table-wrap">
<el-table :data="agents" stripe>
<el-table-column label="用户名" min-width="140">
<template #default="{ row }">
{{ (row as { user?: { username: string } }).user?.username }}
</template>
</el-table-column>
<el-table-column label="授信额度" min-width="100" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull((row as { creditLimit: string }).creditLimit)" placement="top">
<span>{{ formatAmount((row as { creditLimit: string }).creditLimit) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="已用额度" min-width="100" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull((row as { usedCredit: string }).usedCredit)" placement="top">
<span>{{ formatAmount((row as { usedCredit: string }).usedCredit) }}</span>
</el-tooltip>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</div>
</template>
<style scoped>
.page-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 20px; }
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; }
.page-desc { font-size: 13px; color: #3a3a3a; }
.tool-card { margin-bottom: 16px; border-radius: 12px; }
.data-card { border-radius: 12px; }
</style>

View File

@@ -0,0 +1,40 @@
export interface BetListRow {
id: string;
betNo: string;
userId: string;
username: string;
parentUsername: string | null;
agentId: string | null;
betType: string;
stake: string;
totalOdds: string | null;
potentialReturn: string | null;
actualReturn: string;
status: string;
settlementStatus: string | null;
currency: string;
placedAt: string;
settledAt: string | null;
selectionCount: number;
}
export interface BetSelectionDetail {
id: string;
matchId: string | null;
marketType: string;
period: string | null;
selectionName: string;
handicapLine: string | null;
totalLine: string | null;
odds: string;
resultStatus: string | null;
effectiveOdds: string | null;
sortOrder: number;
}
export interface BetDetail extends BetListRow {
requestId: string;
createdAt: string;
updatedAt: string;
selections: BetSelectionDetail[];
}

View File

@@ -0,0 +1,71 @@
export interface DashboardTrendDay {
date: string;
label: string;
betCount: number;
stake: string;
payout: string;
ggr: string;
}
export interface AdminDashboard {
generatedAt: string;
trend7d: DashboardTrendDay[];
today: {
betCount: number;
stake: string;
payout: string;
ggr: string;
newPlayers: number;
};
yesterday: {
betCount: number;
stake: string;
payout: string;
ggr: string;
};
users: {
playersTotal: number;
playersActive: number;
playersSuspended: number;
playersDirect: number;
agentsTotal: number;
agentsActive: number;
};
wallets: {
totalAvailable: string;
totalFrozen: string;
playerWalletCount: number;
};
agents: {
totalCreditLimit: string;
totalUsedCredit: string;
totalAvailableCredit: string;
};
matches: {
total: number;
draft: number;
published: number;
closed: number;
cancelled: number;
pendingSettlement: number;
settled: number;
};
bets: {
pendingTotal: number;
todayByStatus: Record<string, { count: number; stake: string }>;
};
recentBets: {
betNo: string;
username: string;
stake: string;
status: string;
placedAt: string;
}[];
recentPlayers: {
id: string;
username: string;
status: string;
parentUsername: string | null;
createdAt: string;
}[];
}

View File

@@ -0,0 +1,78 @@
/** 后台手动新增赛事(投注平台最小字段) */
export interface MatchCreateForm {
leagueEn: string;
leagueZh: string;
startTime: string;
homeTeamZh: string;
homeTeamEn: string;
awayTeamZh: string;
awayTeamEn: string;
isHot: boolean;
}
export function emptyMatchForm(): MatchCreateForm {
return {
leagueEn: 'FIFA World Cup 2026',
leagueZh: '2026 世界杯',
startTime: '',
homeTeamZh: '',
homeTeamEn: '',
awayTeamZh: '',
awayTeamEn: '',
isHot: false,
};
}
export type AdminMatchDetail = {
id: string;
status: string;
isOutright: boolean;
isHot: boolean;
startTime: string;
leagueEn: string;
leagueZh: string;
homeTeamEn: string;
homeTeamZh: string;
awayTeamEn: string;
awayTeamZh: string;
matchName: string;
};
export function formFromDetail(d: AdminMatchDetail): MatchCreateForm {
return {
leagueEn: d.leagueEn,
leagueZh: d.leagueZh,
startTime: d.startTime,
homeTeamZh: d.homeTeamZh,
homeTeamEn: d.homeTeamEn,
awayTeamZh: d.awayTeamZh,
awayTeamEn: d.awayTeamEn,
isHot: d.isHot,
};
}
export function buildPlatformPayload(form: MatchCreateForm) {
if (!form.startTime.trim()) {
throw new Error('请填写开赛时间');
}
const homeOk = form.homeTeamZh.trim() || form.homeTeamEn.trim();
const awayOk = form.awayTeamZh.trim() || form.awayTeamEn.trim();
if (!homeOk || !awayOk) {
throw new Error('请填写主客队名称(中文或英文至少一项)');
}
if (!form.leagueZh.trim() && !form.leagueEn.trim()) {
throw new Error('请填写联赛名称');
}
return {
leagueEn: form.leagueEn.trim(),
leagueZh: form.leagueZh.trim(),
homeTeamEn: form.homeTeamEn.trim(),
homeTeamZh: form.homeTeamZh.trim(),
awayTeamEn: form.awayTeamEn.trim(),
awayTeamZh: form.awayTeamZh.trim(),
startTime: form.startTime.trim(),
isHot: form.isHot,
};
}

View File

@@ -0,0 +1,120 @@
export interface PlayerCreateForm {
username: string;
password: string;
confirmPassword: string;
parentId: string;
phone: string;
email: string;
initialDeposit: number;
remark: string;
}
export interface PlayerEditForm {
id: string;
username: string;
status: string;
parentId: string;
parentUsername: string | null;
availableBalance: string;
frozenBalance: string;
betCount: number;
totalStake: string;
totalReturn: string;
createdAt: string;
lastLoginAt: string | null;
loginFailCount: number;
phone: string;
email: string;
}
export interface PlayerRow {
id: string;
username: string;
status: string;
locale: string;
parentId: string | null;
parentUsername: string | null;
phone: string | null;
email: string | null;
availableBalance: string;
frozenBalance: string;
lastLoginAt: string | null;
betCount: number;
totalStake: string;
totalReturn: string;
createdAt: string;
}
export interface PlayerDetail extends PlayerRow {
loginFailCount: number;
lockedUntil: string | null;
updatedAt: string;
}
export function emptyPlayerCreateForm(): PlayerCreateForm {
return {
username: '',
password: 'Player@123',
confirmPassword: 'Player@123',
parentId: '',
phone: '',
email: '',
initialDeposit: 0,
remark: '',
};
}
export function emptyPlayerEditForm(): PlayerEditForm {
return {
id: '',
username: '',
status: 'ACTIVE',
parentId: '',
parentUsername: null,
availableBalance: '0',
frozenBalance: '0',
betCount: 0,
totalStake: '0',
totalReturn: '0',
createdAt: '',
lastLoginAt: null,
loginFailCount: 0,
phone: '',
email: '',
};
}
export function editFormFromDetail(d: PlayerDetail): PlayerEditForm {
return {
id: d.id,
username: d.username,
status: d.status,
parentId: d.parentId ?? '',
parentUsername: d.parentUsername,
availableBalance: d.availableBalance,
frozenBalance: d.frozenBalance,
betCount: d.betCount,
totalStake: d.totalStake,
totalReturn: d.totalReturn,
createdAt: d.createdAt,
lastLoginAt: d.lastLoginAt,
loginFailCount: d.loginFailCount,
phone: d.phone ?? '',
email: d.email ?? '',
};
}
export function buildCreatePlayerPayload(form: PlayerCreateForm) {
if (!form.username.trim()) throw new Error('请填写用户名');
if (form.password.length < 8) throw new Error('密码至少 8 位');
if (form.password !== form.confirmPassword) throw new Error('两次密码不一致');
return {
username: form.username.trim(),
password: form.password,
parentId: form.parentId || undefined,
phone: form.phone.trim() || undefined,
email: form.email.trim() || undefined,
initialDeposit: form.initialDeposit > 0 ? form.initialDeposit : undefined,
remark: form.remark.trim() || undefined,
};
}