feat(admin): 管理端列表分页、控制台图表与赛事导入
- 玩家/代理/赛事/注单/审计列表分页,默认每页 10 条,无页面滚动条布局 - ECharts 控制台概览、注单管理中文化与列宽优化 - zhibo 赛事字段迁移与导入,玩家编辑可改所属代理 - 管理端 API 分页与 dashboard 统计接口 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,8 +11,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"echarts": "^6.1.0",
|
||||
"element-plus": "^2.9.3",
|
||||
"vue": "^3.5.13",
|
||||
"vue-echarts": "^8.0.1",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -2,3 +2,275 @@
|
||||
import { RouterView } from 'vue-router';
|
||||
</script>
|
||||
<template><RouterView /></template>
|
||||
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button { cursor: pointer; font-family: inherit; }
|
||||
|
||||
:root {
|
||||
/* 质感绿:深底 + 渐变高光,避免扁平荧光绿 */
|
||||
--green-deep: #145c38;
|
||||
--green-mid: #1f8a52;
|
||||
--green-bright: #2fb56a;
|
||||
--green-glow: #4dd68a;
|
||||
--green-surface: rgba(20, 72, 46, 0.45);
|
||||
--green-border: rgba(77, 214, 138, 0.28);
|
||||
--green-text: #9ae8bc;
|
||||
--primary: var(--green-mid);
|
||||
--primary-dark: var(--green-deep);
|
||||
--primary-light: var(--green-bright);
|
||||
--primary-link: var(--green-text);
|
||||
--primary-on: #ffffff;
|
||||
--primary-grad: linear-gradient(165deg, #3cc474 0%, #248f54 42%, #1a6b40 100%);
|
||||
--primary-grad-hover: linear-gradient(165deg, #4dd68a 0%, #2ea864 42%, #1f7a48 100%);
|
||||
--primary-shadow: 0 1px 0 rgba(255, 255, 255, 0.14) inset, 0 2px 10px rgba(0, 0, 0, 0.45), 0 0 20px rgba(36, 143, 84, 0.18);
|
||||
--bg-body: #000000;
|
||||
--bg-card: rgba(20, 20, 20, 0.85);
|
||||
--bg-elevated: rgba(28, 28, 28, 0.9);
|
||||
--bg-hover: rgba(36, 143, 84, 0.1);
|
||||
--text: #ffffff;
|
||||
--text-muted: #8e8e93;
|
||||
--border: #2a2a2a;
|
||||
--border-soft: var(--green-border);
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--shadow: 0 4px 20px rgba(0, 0, 0, 0.55);
|
||||
|
||||
/* Element Plus dark overrides */
|
||||
--el-bg-color: #141414;
|
||||
--el-bg-color-page: #000000;
|
||||
--el-bg-color-overlay: #1c1c1c;
|
||||
--el-text-color-primary: #ffffff;
|
||||
--el-text-color-regular: #cccccc;
|
||||
--el-text-color-secondary: #8e8e93;
|
||||
--el-text-color-placeholder:#4a4a4a;
|
||||
--el-border-color: #2a2a2a;
|
||||
--el-border-color-light: #222;
|
||||
--el-border-color-lighter: #1a1a1a;
|
||||
--el-fill-color: #1a1a1a;
|
||||
--el-fill-color-blank: #0d0d0d;
|
||||
--el-fill-color-light: #141414;
|
||||
--el-color-primary: #248f54;
|
||||
--el-color-primary-light-3: rgba(47, 181, 106, 0.35);
|
||||
--el-color-primary-light-5: rgba(47, 181, 106, 0.2);
|
||||
--el-color-primary-light-7: rgba(47, 181, 106, 0.12);
|
||||
--el-color-primary-light-9: rgba(47, 181, 106, 0.06);
|
||||
--el-color-primary-dark-2: #1a6b40;
|
||||
--el-table-bg-color: transparent;
|
||||
--el-table-tr-bg-color: transparent;
|
||||
--el-table-header-bg-color: rgba(255,255,255,0.03);
|
||||
--el-table-row-hover-bg-color: rgba(36, 143, 84, 0.08);
|
||||
--el-table-border-color: #222;
|
||||
--el-table-text-color: #ccc;
|
||||
--el-table-header-text-color: #666;
|
||||
--el-card-bg-color: rgba(20,20,20,0.85);
|
||||
--el-card-border-color: #2a2a2a;
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 隐藏滚动条,表格区域仍可滚动 */
|
||||
* {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 管理端列表页:占满主区域,表头固定、表体滚动,底部分页 */
|
||||
.admin-list-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.admin-list-page > .page-header,
|
||||
.admin-list-page > .filter-card,
|
||||
.admin-list-page > .tool-card {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.admin-list-page > .tool-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.admin-list-page > .filter-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.admin-list-page > .page-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.admin-list-page > .data-card {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.admin-list-page > .data-card .el-card__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
.admin-list-page .table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.admin-list-page .table-wrap .el-table {
|
||||
height: 100% !important;
|
||||
}
|
||||
.admin-list-page .pager {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
/* 控制台等非列表页:允许内部滚动(滚动条已全局隐藏) */
|
||||
.dashboard-page,
|
||||
.page-scroll {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
background:
|
||||
url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.12'/%3E%3C/svg%3E"),
|
||||
linear-gradient(rgba(0,0,0,0.97), rgba(0,0,0,0.97)),
|
||||
radial-gradient(ellipse 90% 45% at 50% -8%, rgba(31, 138, 82, 0.14), transparent 55%),
|
||||
radial-gradient(ellipse 60% 30% at 80% 100%, rgba(20, 92, 56, 0.08), transparent 50%);
|
||||
background-size: 150px, cover, cover;
|
||||
background-color: #000;
|
||||
color: #fff;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ── Element Plus 全局暗色覆盖 ── */
|
||||
.el-card {
|
||||
background: var(--bg-card) !important;
|
||||
border-color: var(--border) !important;
|
||||
border-radius: var(--radius) !important;
|
||||
box-shadow: var(--shadow) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.el-card__header {
|
||||
border-bottom-color: #1e1e1e !important;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.el-table { background: transparent !important; color: #ccc !important; }
|
||||
.el-table::before { background-color: #222 !important; }
|
||||
.el-table th.el-table__cell {
|
||||
background: rgba(255,255,255,0.02) !important;
|
||||
color: #555 !important;
|
||||
font-size: 11px; font-weight: 700;
|
||||
letter-spacing: 0.06em; text-transform: uppercase;
|
||||
border-bottom-color: #1e1e1e !important;
|
||||
}
|
||||
.el-table td.el-table__cell { border-bottom-color: #161616 !important; color: #bbb !important; }
|
||||
.el-table--striped .el-table__body tr.el-table__row--striped td { background: rgba(255,255,255,0.015) !important; }
|
||||
.el-table__body tr:hover > td { background: rgba(36, 143, 84, 0.07) !important; }
|
||||
|
||||
.el-input__wrapper {
|
||||
background: #0d0d0d !important;
|
||||
box-shadow: 0 0 0 1px #2a2a2a inset !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
}
|
||||
.el-input__wrapper:hover { box-shadow: 0 0 0 1px #3a3a3a inset !important; }
|
||||
.el-input__wrapper.is-focus {
|
||||
box-shadow: 0 0 0 1px var(--green-mid) inset, 0 0 0 3px rgba(47, 181, 106, 0.15) !important;
|
||||
}
|
||||
.el-input__inner { color: #fff !important; background: transparent !important; }
|
||||
.el-input__inner:-webkit-autofill,
|
||||
.el-input__inner:-webkit-autofill:focus {
|
||||
-webkit-box-shadow: 0 0 0 1000px #0d0d0d inset !important;
|
||||
-webkit-text-fill-color: #fff !important;
|
||||
}
|
||||
|
||||
.el-button { background: #141414 !important; border-color: #2a2a2a !important; color: #aaa !important; }
|
||||
.el-button:hover { background: #1e1e1e !important; border-color: #3a3a3a !important; color: #fff !important; }
|
||||
.el-button--primary {
|
||||
background: var(--primary-grad) !important;
|
||||
border: 1px solid var(--green-border) !important;
|
||||
color: var(--primary-on) !important;
|
||||
font-weight: 700 !important;
|
||||
box-shadow: var(--primary-shadow) !important;
|
||||
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.el-button--primary:hover {
|
||||
background: var(--primary-grad-hover) !important;
|
||||
border-color: rgba(120, 230, 170, 0.4) !important;
|
||||
color: var(--primary-on) !important;
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.18) inset, 0 4px 14px rgba(0, 0, 0, 0.5), 0 0 24px rgba(47, 181, 106, 0.28) !important;
|
||||
}
|
||||
.el-button--success {
|
||||
background: var(--green-surface) !important;
|
||||
border: 1px solid var(--green-border) !important;
|
||||
color: var(--green-text) !important;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.el-button--success:hover {
|
||||
background: rgba(36, 143, 84, 0.35) !important;
|
||||
border-color: rgba(120, 230, 170, 0.45) !important;
|
||||
color: #d4fde5 !important;
|
||||
}
|
||||
.el-button--warning { background: rgba(251,191,36,0.1) !important; border-color: rgba(251,191,36,0.35) !important; color: #fbbf24 !important; }
|
||||
.el-button--danger { background: rgba(255,69,58,0.1) !important; border-color: rgba(255,69,58,0.35) !important; color: #ff453a !important; }
|
||||
|
||||
.el-tag { border-radius: 4px !important; font-size: 11px !important; font-weight: 600 !important; }
|
||||
.el-tag--success {
|
||||
background: linear-gradient(135deg, rgba(36, 143, 84, 0.35), rgba(20, 92, 56, 0.5)) !important;
|
||||
border: 1px solid var(--green-border) !important;
|
||||
color: #c8f5d8 !important;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.el-tag--warning { background: rgba(251,191,36,0.1) !important; border-color: rgba(251,191,36,0.3) !important; color: #fbbf24 !important; }
|
||||
.el-tag--danger { background: rgba(255,69,58,0.1) !important; border-color: rgba(255,69,58,0.3) !important; color: #ff453a !important; }
|
||||
.el-tag--info { background: rgba(255,255,255,0.06) !important; border-color: #3a3a3a !important; color: #aaa !important; }
|
||||
|
||||
/* 表格操作按钮:渐变绿 + 白字 */
|
||||
.el-button.is-link.el-button--primary,
|
||||
.el-button.is-link.el-button--success {
|
||||
color: #ffffff !important;
|
||||
background: var(--primary-grad) !important;
|
||||
border: 1px solid var(--green-border) !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);
|
||||
}
|
||||
.el-button.is-link.el-button--primary:hover,
|
||||
.el-button.is-link.el-button--primary:focus,
|
||||
.el-button.is-link.el-button--success:hover,
|
||||
.el-button.is-link.el-button--success:focus {
|
||||
color: #ffffff !important;
|
||||
background: var(--primary-grad-hover) !important;
|
||||
border-color: rgba(120, 230, 170, 0.45) !important;
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.16) inset, 0 2px 10px rgba(0, 0, 0, 0.4), 0 0 16px rgba(47, 181, 106, 0.25) !important;
|
||||
}
|
||||
|
||||
.el-form-item__label { color: var(--text-muted) !important; font-size: 11px !important; font-weight: 600 !important; letter-spacing: 0.04em !important; }
|
||||
|
||||
.el-statistic__head { color: #555 !important; font-size: 11px !important; font-weight: 700 !important; letter-spacing: 0.06em !important; text-transform: uppercase !important; }
|
||||
.el-statistic__content .el-statistic__number { font-size: 26px !important; font-weight: 800 !important; color: #fff !important; }
|
||||
|
||||
.el-input-number .el-input__wrapper { background: #0d0d0d !important; }
|
||||
.el-date-editor .el-input__wrapper { background: #0d0d0d !important; }
|
||||
</style>
|
||||
|
||||
121
apps/admin/src/components/RobotVerify.vue
Normal file
121
apps/admin/src/components/RobotVerify.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
|
||||
const input = ref('');
|
||||
const code = ref('');
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const honeypot = ref('');
|
||||
|
||||
function generateCode() {
|
||||
code.value = String(Math.floor(1000 + Math.random() * 9000));
|
||||
}
|
||||
|
||||
function drawCaptcha() {
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return;
|
||||
const w = 108, h = 44;
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.fillStyle = '#7c3aed';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
for (let i = 0; i < 28; i++) {
|
||||
ctx.fillStyle = `rgba(255,255,255,${0.15 + Math.random() * 0.35})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(Math.random() * w, Math.random() * h, Math.random() * 2.2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
ctx.strokeStyle = `rgba(255,255,255,${0.2 + Math.random() * 0.3})`;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(Math.random() * w, Math.random() * h);
|
||||
ctx.lineTo(Math.random() * w, Math.random() * h);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(w / 2, h / 2);
|
||||
ctx.rotate((Math.random() - 0.5) * 0.12);
|
||||
ctx.font = 'italic bold 26px Arial, sans-serif';
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(code.value, 0, 1);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
generateCode();
|
||||
input.value = '';
|
||||
drawCaptcha();
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
if (honeypot.value) { refresh(); return false; }
|
||||
return input.value.trim() === code.value;
|
||||
}
|
||||
|
||||
onMounted(refresh);
|
||||
defineExpose({ validate, refresh });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="captcha-row">
|
||||
<input v-model="honeypot" type="text" name="website" tabindex="-1"
|
||||
autocomplete="off" class="hp-field" aria-hidden="true" />
|
||||
<input v-model="input" type="text" inputmode="numeric" maxlength="4"
|
||||
class="captcha-input" placeholder="Captcha" autocomplete="off" />
|
||||
<canvas ref="canvasRef" class="captcha-canvas"
|
||||
title="点击刷新" role="button" tabindex="0"
|
||||
@click="refresh" @keydown.enter="refresh" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.captcha-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.hp-field {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.captcha-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #333;
|
||||
border-right: none;
|
||||
border-radius: 8px 0 0 8px;
|
||||
background: #0d0d0d;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.captcha-input::placeholder { color: #555; }
|
||||
.captcha-input:focus { border-color: rgba(0, 196, 65, 0.6); }
|
||||
|
||||
.captcha-canvas {
|
||||
flex-shrink: 0;
|
||||
width: 108px;
|
||||
height: 44px;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
</style>
|
||||
44
apps/admin/src/components/dashboard/EChartPanel.vue
Normal file
44
apps/admin/src/components/dashboard/EChartPanel.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import VChart from 'vue-echarts';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import './echarts-setup';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string;
|
||||
option: EChartsOption;
|
||||
height?: string;
|
||||
}>(),
|
||||
{ title: '', height: '300px' },
|
||||
);
|
||||
|
||||
const style = computed(() => ({ height: props.height, width: '100%' }));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chart-panel">
|
||||
<div v-if="title" class="chart-title">{{ title }}</div>
|
||||
<v-chart class="chart-canvas" :option="option" :style="style" autoresize />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chart-panel {
|
||||
padding: 16px 18px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #1e1e1e;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.chart-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #ccc;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.chart-canvas {
|
||||
min-height: 200px;
|
||||
}
|
||||
</style>
|
||||
22
apps/admin/src/components/dashboard/echarts-setup.ts
Normal file
22
apps/admin/src/components/dashboard/echarts-setup.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { use } from 'echarts/core';
|
||||
import { BarChart, LineChart, PieChart } from 'echarts/charts';
|
||||
import {
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
TitleComponent,
|
||||
GraphicComponent,
|
||||
} from 'echarts/components';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
BarChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
TitleComponent,
|
||||
GraphicComponent,
|
||||
]);
|
||||
@@ -8,24 +8,32 @@ const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const adminMenus = [
|
||||
{ path: '/', label: '控制台' },
|
||||
{ path: '/users', label: '玩家管理' },
|
||||
{ path: '/agents', label: '代理管理' },
|
||||
{ path: '/matches', label: '赛事管理' },
|
||||
{ path: '/bets', label: '注单管理' },
|
||||
{ path: '/cashback', label: '返水管理' },
|
||||
{ path: '/audit', label: '操作日志' },
|
||||
{ path: '/', label: '控制台' },
|
||||
{ path: '/users', label: '玩家管理' },
|
||||
{ path: '/agents', label: '代理管理' },
|
||||
{ path: '/matches', label: '赛事管理' },
|
||||
{ path: '/bets', label: '注单管理' },
|
||||
{ path: '/cashback', label: '返水管理' },
|
||||
{ path: '/audit', label: '操作日志' },
|
||||
];
|
||||
|
||||
const agentMenus = [
|
||||
{ path: '/', label: '概览' },
|
||||
{ path: '/my-players', label: '直属玩家' },
|
||||
{ path: '/sub-agents', label: '下级代理' },
|
||||
{ path: '/my-bets', label: '注单查询' },
|
||||
{ path: '/', label: '概览' },
|
||||
{ path: '/my-players', label: '直属玩家' },
|
||||
{ path: '/sub-agents', label: '下级代理' },
|
||||
{ path: '/my-bets', label: '注单查询' },
|
||||
];
|
||||
|
||||
const menus = computed(() => (auth.isAdmin.value ? adminMenus : agentMenus));
|
||||
|
||||
const currentLabel = computed(() =>
|
||||
menus.value.find(m => m.path === route.path)?.label ?? ''
|
||||
);
|
||||
|
||||
const userInitial = computed(() =>
|
||||
(auth.user?.username ?? '').charAt(0).toUpperCase()
|
||||
);
|
||||
|
||||
function logout() {
|
||||
auth.logout();
|
||||
router.push('/login');
|
||||
@@ -33,33 +41,224 @@ function logout() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container style="min-height: 100vh">
|
||||
<el-aside width="200px" style="background: #1a2332">
|
||||
<div style="padding: 20px">
|
||||
<img src="/logo.png" alt="TheBet365" style="height: 56px; width: auto; display: block" />
|
||||
<div style="margin-top: 8px; font-size: 12px; color: #888">{{ auth.portalLabel }}</div>
|
||||
<div v-if="auth.user" style="margin-top: 6px; font-size: 12px; color: #aaa">
|
||||
{{ auth.user.username }}
|
||||
</div>
|
||||
<div class="shell">
|
||||
<!-- ── Sidebar ── -->
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<img src="/logo.png" alt="TheBet365" class="brand-logo" />
|
||||
</div>
|
||||
<el-menu
|
||||
background-color="#1a2332"
|
||||
text-color="#ccc"
|
||||
active-text-color="#00a826"
|
||||
:default-active="route.path"
|
||||
>
|
||||
<el-menu-item v-for="m in menus" :key="m.path" :index="m.path">
|
||||
<RouterLink :to="m.path" style="color: inherit; width: 100%">{{ m.label }}</RouterLink>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-container>
|
||||
<el-header
|
||||
style="display: flex; justify-content: flex-end; align-items: center; border-bottom: 1px solid #eee"
|
||||
>
|
||||
<el-button @click="logout">退出</el-button>
|
||||
</el-header>
|
||||
<el-main><RouterView /></el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
|
||||
<nav class="nav">
|
||||
<RouterLink
|
||||
v-for="m in menus" :key="m.path" :to="m.path"
|
||||
class="nav-item" :class="{ active: route.path === m.path }"
|
||||
>
|
||||
{{ m.label }}
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-foot">TheBet365 © 2025</div>
|
||||
</aside>
|
||||
|
||||
<!-- ── Main ── -->
|
||||
<div class="main">
|
||||
<header class="topbar">
|
||||
<div class="topbar-title">
|
||||
<span class="topbar-accent" />
|
||||
<span>{{ currentLabel }}</span>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<div class="user-chip">
|
||||
<div class="avatar">{{ userInitial }}</div>
|
||||
<div class="user-info">
|
||||
<span class="user-name">{{ auth.user?.username }}</span>
|
||||
<span class="user-role">{{ auth.isAdmin ? '系统管理员' : '代理账号' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="portal-tag">{{ auth.portalLabel }}</div>
|
||||
<button class="btn-logout" @click="logout">退出</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="page-main">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shell {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.sidebar {
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
position: fixed;
|
||||
top: 0; left: 0; bottom: 0;
|
||||
background: rgba(6, 6, 6, 0.98);
|
||||
border-right: 1px solid #1c1c1c;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.brand {
|
||||
padding: 20px 16px 18px;
|
||||
border-bottom: 1px solid #181818;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.brand-logo {
|
||||
max-width: 140px;
|
||||
max-height: 48px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nav {
|
||||
flex: 1;
|
||||
padding: 10px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
border-radius: 7px;
|
||||
color: #aaa;
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s;
|
||||
border-left: 2px solid transparent;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.nav-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
}
|
||||
.nav-item.active {
|
||||
background: linear-gradient(90deg, rgba(36, 143, 84, 0.22), rgba(36, 143, 84, 0.04));
|
||||
color: var(--green-text);
|
||||
font-weight: 700;
|
||||
border-left-color: var(--green-bright);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.sidebar-foot {
|
||||
padding: 12px 16px;
|
||||
font-size: 10px;
|
||||
color: #282828;
|
||||
border-top: 1px solid #161616;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
.main {
|
||||
margin-left: 200px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky; top: 0; z-index: 90;
|
||||
height: 56px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
background: rgba(6, 6, 6, 0.98);
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
font-size: 15px; font-weight: 700;
|
||||
color: #e8e8e8;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.topbar-accent {
|
||||
width: 3px; height: 15px;
|
||||
background: linear-gradient(180deg, var(--green-glow), var(--green-deep));
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 8px rgba(47, 181, 106, 0.45);
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.avatar {
|
||||
width: 30px; height: 30px; border-radius: 50%;
|
||||
background: var(--primary-grad);
|
||||
border: 1px solid var(--green-border);
|
||||
box-shadow: var(--primary-shadow);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 12px; font-weight: 800; color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.user-info {
|
||||
display: flex; flex-direction: column; gap: 1px;
|
||||
}
|
||||
.user-name {
|
||||
font-size: 13px; font-weight: 600; color: #e0e0e0;
|
||||
line-height: 1;
|
||||
}
|
||||
.user-role {
|
||||
font-size: 10px; color: #555;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.portal-tag {
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--green-border);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--green-text);
|
||||
background: var(--green-surface);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
padding: 5px 14px;
|
||||
background: transparent;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 6px;
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-logout:hover { border-color: #444; color: #ccc; }
|
||||
|
||||
.page-main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 28px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.page-main > * {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
60
apps/admin/src/utils/bet-labels.ts
Normal file
60
apps/admin/src/utils/bet-labels.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export type BetTagType = '' | 'info' | 'success' | 'warning' | 'danger';
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
PENDING: '待结算',
|
||||
WON: '已赢',
|
||||
LOST: '已输',
|
||||
VOID: '作废',
|
||||
REFUNDED: '已退款',
|
||||
};
|
||||
|
||||
const STATUS_TAG: Record<string, BetTagType> = {
|
||||
PENDING: 'warning',
|
||||
WON: 'success',
|
||||
LOST: 'danger',
|
||||
VOID: 'info',
|
||||
REFUNDED: 'info',
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
SINGLE: '单关',
|
||||
PARLAY: '串关',
|
||||
};
|
||||
|
||||
export function betStatusLabel(status: string) {
|
||||
return STATUS_LABELS[status] ?? status;
|
||||
}
|
||||
|
||||
export function betStatusTagType(status: string): BetTagType {
|
||||
return STATUS_TAG[status] ?? 'info';
|
||||
}
|
||||
|
||||
export function betTypeLabel(betType: string) {
|
||||
return TYPE_LABELS[betType] ?? betType;
|
||||
}
|
||||
|
||||
const SETTLEMENT_LABELS: Record<string, string> = {
|
||||
PENDING: '待结算',
|
||||
SETTLED: '已结算',
|
||||
VOID: '已作废',
|
||||
};
|
||||
|
||||
export function betSettlementLabel(v: string | null | undefined) {
|
||||
if (!v) return '—';
|
||||
return SETTLEMENT_LABELS[v] ?? v;
|
||||
}
|
||||
|
||||
export const BET_STATUS_OPTIONS = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'PENDING', label: '待结算' },
|
||||
{ value: 'WON', label: '已赢' },
|
||||
{ value: 'LOST', label: '已输' },
|
||||
{ value: 'VOID', label: '作废' },
|
||||
{ value: 'REFUNDED', label: '已退款' },
|
||||
];
|
||||
|
||||
export const BET_TYPE_OPTIONS = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'SINGLE', label: '单关' },
|
||||
{ value: 'PARLAY', label: '串关' },
|
||||
];
|
||||
290
apps/admin/src/utils/dashboard-charts.ts
Normal file
290
apps/admin/src/utils/dashboard-charts.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import { formatAmount, formatAmountFull } from './format-amount';
|
||||
|
||||
const tooltipBase = {
|
||||
backgroundColor: '#141414',
|
||||
borderColor: '#2a2a2a',
|
||||
textStyle: { color: '#e0e0e0', fontSize: 12 },
|
||||
};
|
||||
|
||||
const axisLabel = { color: '#888', fontSize: 11 };
|
||||
const splitLine = { lineStyle: { color: '#252525' } };
|
||||
|
||||
export type ChartSeries = { name: string; color: string; values: number[] };
|
||||
export type PieSegment = { label: string; value: number; color: string };
|
||||
|
||||
export function buildBarChartOption(
|
||||
labels: string[],
|
||||
series: ChartSeries[],
|
||||
opts?: { amountAxis?: boolean },
|
||||
): EChartsOption {
|
||||
const amountAxis = opts?.amountAxis !== false;
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
color: series.map((s) => s.color),
|
||||
tooltip: {
|
||||
...tooltipBase,
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
valueFormatter: (v) =>
|
||||
amountAxis ? formatAmountFull(Number(v)) : fmtCount(Number(v)),
|
||||
},
|
||||
legend: {
|
||||
bottom: 0,
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
textStyle: { color: '#999', fontSize: 11 },
|
||||
},
|
||||
grid: { left: 52, right: 12, top: 20, bottom: 48 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: labels,
|
||||
axisLabel,
|
||||
axisLine: { lineStyle: { color: '#333' } },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
...axisLabel,
|
||||
formatter: (v: number) => (amountAxis ? formatAmount(v) : fmtCount(v)),
|
||||
},
|
||||
splitLine,
|
||||
},
|
||||
series: series.map((s) => ({
|
||||
name: s.name,
|
||||
type: 'bar',
|
||||
data: s.values,
|
||||
itemStyle: { color: s.color, borderRadius: [4, 4, 0, 0] },
|
||||
barMaxWidth: 22,
|
||||
emphasis: { focus: 'series' },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMultiLineChartOption(
|
||||
labels: string[],
|
||||
series: ChartSeries[],
|
||||
): EChartsOption {
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
color: series.map((s) => s.color),
|
||||
tooltip: {
|
||||
...tooltipBase,
|
||||
trigger: 'axis',
|
||||
valueFormatter: (v) => formatAmountFull(Number(v)),
|
||||
},
|
||||
legend: {
|
||||
bottom: 0,
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
textStyle: { color: '#999', fontSize: 11 },
|
||||
},
|
||||
grid: { left: 52, right: 12, top: 20, bottom: 48 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: labels,
|
||||
boundaryGap: false,
|
||||
axisLabel,
|
||||
axisLine: { lineStyle: { color: '#333' } },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { ...axisLabel, formatter: (v: number) => formatAmount(v) },
|
||||
splitLine,
|
||||
},
|
||||
series: series.map((s) => ({
|
||||
name: s.name,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
data: s.values,
|
||||
itemStyle: { color: s.color },
|
||||
lineStyle: { color: s.color, width: 2 },
|
||||
areaStyle: { color: s.color, opacity: 0.12 },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPieChartOption(
|
||||
title: string,
|
||||
segments: PieSegment[],
|
||||
): EChartsOption {
|
||||
const data = segments.map((s) => ({
|
||||
name: s.label,
|
||||
value: s.value,
|
||||
itemStyle: { color: s.color },
|
||||
}));
|
||||
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
...tooltipBase,
|
||||
trigger: 'item',
|
||||
formatter: '{b}:{c}({d}%)',
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
right: 4,
|
||||
top: 'middle',
|
||||
itemWidth: 8,
|
||||
itemHeight: 8,
|
||||
textStyle: { color: '#999', fontSize: 11 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: title,
|
||||
type: 'pie',
|
||||
radius: ['42%', '68%'],
|
||||
center: ['36%', '50%'],
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: { borderRadius: 4, borderColor: '#111', borderWidth: 2 },
|
||||
label: {
|
||||
show: segments.length > 0,
|
||||
color: '#bbb',
|
||||
fontSize: 11,
|
||||
formatter: '{b}\n{d}%',
|
||||
},
|
||||
labelLine: { lineStyle: { color: '#444' } },
|
||||
emphasis: {
|
||||
label: { fontSize: 12, fontWeight: 'bold' },
|
||||
scaleSize: 6,
|
||||
},
|
||||
data: data.length ? data : [{ name: '暂无数据', value: 1, itemStyle: { color: '#333' } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function fmtCount(v: number) {
|
||||
return v.toLocaleString('zh-CN', { maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
/** 7 日金额折线 + 注单柱(双 Y 轴),一图看清趋势 */
|
||||
export function buildCombinedTrendOption(
|
||||
labels: string[],
|
||||
amountSeries: ChartSeries[],
|
||||
betCounts: number[],
|
||||
): EChartsOption {
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
color: [...amountSeries.map((s) => s.color), '#fb923c'],
|
||||
tooltip: {
|
||||
...tooltipBase,
|
||||
trigger: 'axis',
|
||||
formatter(params) {
|
||||
const items = Array.isArray(params) ? params : [params];
|
||||
return items
|
||||
.map((p) => {
|
||||
const v = Number(p.value ?? 0);
|
||||
const isCount = p.seriesName === '注单笔数';
|
||||
const val = isCount ? `${fmtCount(v)} 笔` : formatAmountFull(v);
|
||||
return `${p.marker ?? ''}${p.seriesName}:${val}`;
|
||||
})
|
||||
.join('<br/>');
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
top: 0,
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
textStyle: { color: '#999', fontSize: 11 },
|
||||
},
|
||||
grid: { left: 56, right: 48, top: 36, bottom: 28 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: labels,
|
||||
boundaryGap: true,
|
||||
axisLabel,
|
||||
axisLine: { lineStyle: { color: '#333' } },
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '金额',
|
||||
nameTextStyle: { color: '#666', fontSize: 10 },
|
||||
axisLabel: { ...axisLabel, formatter: (v: number) => formatAmount(v) },
|
||||
splitLine,
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '笔数',
|
||||
nameTextStyle: { color: '#666', fontSize: 10 },
|
||||
axisLabel: { ...axisLabel, formatter: (v: number) => fmtCount(v) },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
...amountSeries.map((s) => ({
|
||||
name: s.name,
|
||||
type: 'line' as const,
|
||||
yAxisIndex: 0,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
data: s.values,
|
||||
itemStyle: { color: s.color },
|
||||
lineStyle: { color: s.color, width: 2 },
|
||||
})),
|
||||
{
|
||||
name: '注单笔数',
|
||||
type: 'bar',
|
||||
yAxisIndex: 1,
|
||||
data: betCounts,
|
||||
barMaxWidth: 14,
|
||||
itemStyle: { color: 'rgba(251, 146, 60, 0.45)', borderRadius: [3, 3, 0, 0] },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** 三个饼图并排,占一张图 */
|
||||
export function buildTriplePieOption(
|
||||
blocks: { title: string; segments: PieSegment[] }[],
|
||||
): EChartsOption {
|
||||
const slots = [
|
||||
{ center: ['18%', '58%'] as [string, string], titleLeft: '14%' },
|
||||
{ center: ['50%', '58%'] as [string, string], titleLeft: '46%' },
|
||||
{ center: ['82%', '58%'] as [string, string], titleLeft: '78%' },
|
||||
];
|
||||
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
...tooltipBase,
|
||||
trigger: 'item',
|
||||
formatter: '{b}:{c}({d}%)',
|
||||
},
|
||||
graphic: blocks.map((b, i) => ({
|
||||
type: 'text' as const,
|
||||
left: slots[i]?.titleLeft ?? '50%',
|
||||
top: '6%',
|
||||
style: {
|
||||
text: b.title,
|
||||
fill: '#aaa',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
textAlign: 'center',
|
||||
},
|
||||
})),
|
||||
series: blocks.map((b, i) => {
|
||||
const data = b.segments.map((s) => ({
|
||||
name: s.label,
|
||||
value: s.value,
|
||||
itemStyle: { color: s.color },
|
||||
}));
|
||||
return {
|
||||
name: b.title,
|
||||
type: 'pie' as const,
|
||||
radius: ['32%', '48%'],
|
||||
center: slots[i]?.center ?? ['50%', '55%'],
|
||||
label: { show: false },
|
||||
labelLine: { show: false },
|
||||
data: data.length
|
||||
? data
|
||||
: [{ name: '暂无', value: 1, itemStyle: { color: '#333' } }],
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
43
apps/admin/src/utils/format-amount.ts
Normal file
43
apps/admin/src/utils/format-amount.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/** 完整数字(悬停提示、详情对照) */
|
||||
export function formatAmountFull(value: string | number | null | undefined): string {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return '—';
|
||||
return n.toLocaleString('zh-CN', { maximumFractionDigits: 4 });
|
||||
}
|
||||
|
||||
function unitPart(abs: number, divisor: number, maxDecimals: number): string {
|
||||
return (abs / divisor).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: maxDecimals,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 金额展示:≥1万用「万」,≥1亿用「亿」,避免表格撑破布局
|
||||
*/
|
||||
export function formatAmount(
|
||||
value: string | number | null | undefined,
|
||||
maxDecimals = 2,
|
||||
): string {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return '—';
|
||||
|
||||
const sign = n < 0 ? '-' : '';
|
||||
const abs = Math.abs(n);
|
||||
|
||||
if (abs >= 1e8) {
|
||||
return `${sign}${unitPart(abs, 1e8, maxDecimals)}亿`;
|
||||
}
|
||||
if (abs >= 1e4) {
|
||||
return `${sign}${unitPart(abs, 1e4, maxDecimals)}万`;
|
||||
}
|
||||
|
||||
return n.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: maxDecimals,
|
||||
});
|
||||
}
|
||||
|
||||
export function shouldCompactAmount(value: string | number | null | undefined): boolean {
|
||||
return Math.abs(Number(value) || 0) >= 1e4;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
96
apps/admin/src/views/agent-form.ts
Normal file
96
apps/admin/src/views/agent-form.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
40
apps/admin/src/views/bet-form.ts
Normal file
40
apps/admin/src/views/bet-form.ts
Normal 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[];
|
||||
}
|
||||
71
apps/admin/src/views/dashboard-types.ts
Normal file
71
apps/admin/src/views/dashboard-types.ts
Normal 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;
|
||||
}[];
|
||||
}
|
||||
78
apps/admin/src/views/match-form.ts
Normal file
78
apps/admin/src/views/match-form.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
120
apps/admin/src/views/user-form.ts
Normal file
120
apps/admin/src/views/user-form.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "teams" ADD COLUMN IF NOT EXISTS "external_id" INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS "logo_url" VARCHAR(500);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "matches" ADD COLUMN IF NOT EXISTS "official_match_no" INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS "stage" VARCHAR(32),
|
||||
ADD COLUMN IF NOT EXISTS "group_name" VARCHAR(8),
|
||||
ADD COLUMN IF NOT EXISTS "live_match_id" BIGINT,
|
||||
ADD COLUMN IF NOT EXISTS "addition_match_id" BIGINT,
|
||||
ADD COLUMN IF NOT EXISTS "channel_id" VARCHAR(64),
|
||||
ADD COLUMN IF NOT EXISTS "match_name" VARCHAR(200),
|
||||
ADD COLUMN IF NOT EXISTS "venue_json" JSONB,
|
||||
ADD COLUMN IF NOT EXISTS "kickoff_json" JSONB,
|
||||
ADD COLUMN IF NOT EXISTS "external_status" VARCHAR(32);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "teams_external_id_key" ON "teams"("external_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "matches_live_match_id_key" ON "matches"("live_match_id");
|
||||
@@ -227,12 +227,14 @@ model League {
|
||||
}
|
||||
|
||||
model Team {
|
||||
id BigInt @id @default(autoincrement())
|
||||
sportType String @default("FOOTBALL") @map("sport_type") @db.VarChar(20)
|
||||
code String @unique @db.VarChar(64)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
sportType String @default("FOOTBALL") @map("sport_type") @db.VarChar(20)
|
||||
code String @unique @db.VarChar(64)
|
||||
externalId Int? @unique @map("external_id")
|
||||
logoUrl String? @map("logo_url") @db.VarChar(500)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
homeMatches Match[] @relation("HomeTeam")
|
||||
awayMatches Match[] @relation("AwayTeam")
|
||||
@@ -256,23 +258,33 @@ model EntityTranslation {
|
||||
}
|
||||
|
||||
model Match {
|
||||
id BigInt @id @default(autoincrement())
|
||||
sportType String @default("FOOTBALL") @map("sport_type") @db.VarChar(20)
|
||||
leagueId BigInt @map("league_id")
|
||||
homeTeamId BigInt @map("home_team_id")
|
||||
awayTeamId BigInt @map("away_team_id")
|
||||
startTime DateTime @map("start_time")
|
||||
status String @default("DRAFT") @db.VarChar(32)
|
||||
isHot Boolean @default(false) @map("is_hot")
|
||||
displayOrder Int @default(0) @map("display_order")
|
||||
publishTime DateTime? @map("publish_time")
|
||||
closeTime DateTime? @map("close_time")
|
||||
isOutright Boolean @default(false) @map("is_outright")
|
||||
createdBy BigInt? @map("created_by")
|
||||
updatedBy BigInt? @map("updated_by")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
sportType String @default("FOOTBALL") @map("sport_type") @db.VarChar(20)
|
||||
leagueId BigInt @map("league_id")
|
||||
homeTeamId BigInt @map("home_team_id")
|
||||
awayTeamId BigInt @map("away_team_id")
|
||||
startTime DateTime @map("start_time")
|
||||
status String @default("DRAFT") @db.VarChar(32)
|
||||
isHot Boolean @default(false) @map("is_hot")
|
||||
displayOrder Int @default(0) @map("display_order")
|
||||
publishTime DateTime? @map("publish_time")
|
||||
closeTime DateTime? @map("close_time")
|
||||
isOutright Boolean @default(false) @map("is_outright")
|
||||
officialMatchNo Int? @map("official_match_no")
|
||||
stage String? @db.VarChar(32)
|
||||
groupName String? @map("group_name") @db.VarChar(8)
|
||||
liveMatchId BigInt? @unique @map("live_match_id")
|
||||
additionMatchId BigInt? @map("addition_match_id")
|
||||
channelId String? @map("channel_id") @db.VarChar(64)
|
||||
matchName String? @map("match_name") @db.VarChar(200)
|
||||
venueJson Json? @map("venue_json")
|
||||
kickoffJson Json? @map("kickoff_json")
|
||||
externalStatus String? @map("external_status") @db.VarChar(32)
|
||||
createdBy BigInt? @map("created_by")
|
||||
updatedBy BigInt? @map("updated_by")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
league League @relation(fields: [leagueId], references: [id])
|
||||
homeTeam Team @relation("HomeTeam", fields: [homeTeamId], references: [id])
|
||||
|
||||
209
apps/api/src/applications/admin/admin-dashboard.service.ts
Normal file
209
apps/api/src/applications/admin/admin-dashboard.service.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
|
||||
function dec(v: Decimal | null | undefined) {
|
||||
return v?.toString() ?? '0';
|
||||
}
|
||||
|
||||
function sub(a: Decimal | null | undefined, b: Decimal | null | undefined) {
|
||||
return new Decimal(a ?? 0).sub(b ?? 0).toString();
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminDashboardService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async getOverview() {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const trend7d = await Promise.all(
|
||||
Array.from({ length: 7 }, (_, i) => {
|
||||
const dayStart = new Date(today);
|
||||
dayStart.setDate(dayStart.getDate() - (6 - i));
|
||||
const dayEnd = new Date(dayStart);
|
||||
dayEnd.setDate(dayEnd.getDate() + 1);
|
||||
return this.prisma.bet
|
||||
.aggregate({
|
||||
where: { placedAt: { gte: dayStart, lt: dayEnd } },
|
||||
_sum: { stake: true, actualReturn: true },
|
||||
_count: true,
|
||||
})
|
||||
.then((agg) => ({
|
||||
date: dayStart.toISOString().slice(0, 10),
|
||||
label: `${dayStart.getMonth() + 1}/${dayStart.getDate()}`,
|
||||
betCount: agg._count,
|
||||
stake: dec(agg._sum.stake),
|
||||
payout: dec(agg._sum.actualReturn),
|
||||
ggr: sub(agg._sum.stake, agg._sum.actualReturn),
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
const playerWhere = { userType: 'PLAYER', deletedAt: null };
|
||||
|
||||
const [
|
||||
todayBets,
|
||||
yesterdayBets,
|
||||
pendingBets,
|
||||
betStatusToday,
|
||||
matchGroups,
|
||||
matchTotal,
|
||||
playerTotal,
|
||||
playerActive,
|
||||
playerSuspended,
|
||||
playerDirect,
|
||||
newPlayersToday,
|
||||
agentProfiles,
|
||||
agentsActive,
|
||||
walletAgg,
|
||||
recentBets,
|
||||
recentPlayers,
|
||||
] = await Promise.all([
|
||||
this.prisma.bet.aggregate({
|
||||
where: { placedAt: { gte: today } },
|
||||
_sum: { stake: true, actualReturn: true },
|
||||
_count: true,
|
||||
}),
|
||||
this.prisma.bet.aggregate({
|
||||
where: {
|
||||
placedAt: {
|
||||
gte: new Date(today.getTime() - 86400000),
|
||||
lt: today,
|
||||
},
|
||||
},
|
||||
_sum: { stake: true, actualReturn: true },
|
||||
_count: true,
|
||||
}),
|
||||
this.prisma.bet.count({ where: { status: 'PENDING' } }),
|
||||
this.prisma.bet.groupBy({
|
||||
by: ['status'],
|
||||
where: { placedAt: { gte: today } },
|
||||
_count: { _all: true },
|
||||
_sum: { stake: true },
|
||||
}),
|
||||
this.prisma.match.groupBy({
|
||||
by: ['status'],
|
||||
where: { deletedAt: null },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.match.count({ where: { deletedAt: null } }),
|
||||
this.prisma.user.count({ where: playerWhere }),
|
||||
this.prisma.user.count({ where: { ...playerWhere, status: 'ACTIVE' } }),
|
||||
this.prisma.user.count({ where: { ...playerWhere, status: 'SUSPENDED' } }),
|
||||
this.prisma.user.count({
|
||||
where: { ...playerWhere, parentId: null },
|
||||
}),
|
||||
this.prisma.user.count({
|
||||
where: { ...playerWhere, createdAt: { gte: today } },
|
||||
}),
|
||||
this.prisma.agentProfile.aggregate({
|
||||
_sum: { creditLimit: true, usedCredit: true },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.agentProfile.count({ where: { status: 'ACTIVE' } }),
|
||||
this.prisma.wallet.aggregate({
|
||||
where: { user: playerWhere },
|
||||
_sum: { availableBalance: true, frozenBalance: true },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.bet.findMany({
|
||||
take: 8,
|
||||
orderBy: { placedAt: 'desc' },
|
||||
include: { user: { select: { username: true } } },
|
||||
}),
|
||||
this.prisma.user.findMany({
|
||||
where: playerWhere,
|
||||
take: 6,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
parent: { select: { username: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const matchByStatus: Record<string, number> = {};
|
||||
for (const g of matchGroups) {
|
||||
matchByStatus[g.status] = g._count._all;
|
||||
}
|
||||
|
||||
const todayBetByStatus: Record<string, { count: number; stake: string }> = {};
|
||||
for (const g of betStatusToday) {
|
||||
todayBetByStatus[g.status] = {
|
||||
count: g._count._all,
|
||||
stake: dec(g._sum.stake),
|
||||
};
|
||||
}
|
||||
|
||||
const creditLimit = agentProfiles._sum.creditLimit ?? new Decimal(0);
|
||||
const usedCredit = agentProfiles._sum.usedCredit ?? new Decimal(0);
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
trend7d,
|
||||
today: {
|
||||
betCount: todayBets._count,
|
||||
stake: dec(todayBets._sum.stake),
|
||||
payout: dec(todayBets._sum.actualReturn),
|
||||
ggr: sub(todayBets._sum.stake, todayBets._sum.actualReturn),
|
||||
newPlayers: newPlayersToday,
|
||||
},
|
||||
yesterday: {
|
||||
betCount: yesterdayBets._count,
|
||||
stake: dec(yesterdayBets._sum.stake),
|
||||
payout: dec(yesterdayBets._sum.actualReturn),
|
||||
ggr: sub(yesterdayBets._sum.stake, yesterdayBets._sum.actualReturn),
|
||||
},
|
||||
users: {
|
||||
playersTotal: playerTotal,
|
||||
playersActive: playerActive,
|
||||
playersSuspended: playerSuspended,
|
||||
playersDirect: playerDirect,
|
||||
agentsTotal: agentProfiles._count._all,
|
||||
agentsActive,
|
||||
},
|
||||
wallets: {
|
||||
totalAvailable: dec(walletAgg._sum.availableBalance),
|
||||
totalFrozen: dec(walletAgg._sum.frozenBalance),
|
||||
playerWalletCount: walletAgg._count._all,
|
||||
},
|
||||
agents: {
|
||||
totalCreditLimit: dec(creditLimit),
|
||||
totalUsedCredit: dec(usedCredit),
|
||||
totalAvailableCredit: creditLimit.sub(usedCredit).toString(),
|
||||
},
|
||||
matches: {
|
||||
total: matchTotal,
|
||||
draft: matchByStatus.DRAFT ?? 0,
|
||||
published: matchByStatus.PUBLISHED ?? 0,
|
||||
closed: matchByStatus.CLOSED ?? 0,
|
||||
cancelled: matchByStatus.CANCELLED ?? 0,
|
||||
pendingSettlement: matchByStatus.PENDING_SETTLEMENT ?? 0,
|
||||
settled: matchByStatus.SETTLED ?? 0,
|
||||
},
|
||||
bets: {
|
||||
pendingTotal: pendingBets,
|
||||
todayByStatus: todayBetByStatus,
|
||||
},
|
||||
recentBets: recentBets.map((b) => ({
|
||||
betNo: b.betNo,
|
||||
username: b.user.username,
|
||||
stake: dec(b.stake),
|
||||
status: b.status,
|
||||
placedAt: b.placedAt,
|
||||
})),
|
||||
recentPlayers: recentPlayers.map((p) => ({
|
||||
id: p.id.toString(),
|
||||
username: p.username,
|
||||
status: p.status,
|
||||
parentUsername: p.parent?.username ?? null,
|
||||
createdAt: p.createdAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
@@ -24,7 +26,18 @@ import { I18nService } from '../../domains/operations/i18n/i18n.service';
|
||||
import { AuditService } from '../../domains/operations/audit/audit.service';
|
||||
import { BetsService } from '../../domains/betting/bets.service';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { IsString, IsNumber, IsOptional, IsArray, IsBoolean, MinLength } from 'class-validator';
|
||||
import { AdminDashboardService } from './admin-dashboard.service';
|
||||
import {
|
||||
IsString,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
MinLength,
|
||||
IsIn,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import type { ZhiboMatchExport, ZhiboMatchesBundleExport } from '../../domains/catalog/zhibo-match.types';
|
||||
|
||||
class CreateUserDto {
|
||||
@IsString()
|
||||
@@ -43,6 +56,116 @@ class CreateUserDto {
|
||||
creditLimit?: number;
|
||||
}
|
||||
|
||||
class CreatePlayerAdminDto {
|
||||
@IsString()
|
||||
username!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
locale?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
initialDeposit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
class UpdatePlayerAdminDto {
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'SUSPENDED'])
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
locale?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
email?: string;
|
||||
|
||||
/** 传空字符串表示改为平台直属(无代理) */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
class CreateAgentAdminDto {
|
||||
@IsString()
|
||||
username!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
creditLimit!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
locale?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
cashbackRate?: number;
|
||||
}
|
||||
|
||||
class UpdateAgentAdminDto {
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'SUSPENDED'])
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
locale?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
cashbackRate?: number;
|
||||
}
|
||||
|
||||
class DepositDto {
|
||||
@IsNumber()
|
||||
amount!: number;
|
||||
@@ -55,15 +178,24 @@ class DepositDto {
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
class CreateMatchDto {
|
||||
class CreatePlatformMatchDto {
|
||||
@IsString()
|
||||
leagueId!: string;
|
||||
leagueEn!: string;
|
||||
|
||||
@IsString()
|
||||
homeTeamId!: string;
|
||||
leagueZh!: string;
|
||||
|
||||
@IsString()
|
||||
awayTeamId!: string;
|
||||
homeTeamEn!: string;
|
||||
|
||||
@IsString()
|
||||
homeTeamZh!: string;
|
||||
|
||||
@IsString()
|
||||
awayTeamEn!: string;
|
||||
|
||||
@IsString()
|
||||
awayTeamZh!: string;
|
||||
|
||||
@IsString()
|
||||
startTime!: string;
|
||||
@@ -71,6 +203,15 @@ class CreateMatchDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isHot?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
displayOrder?: number;
|
||||
}
|
||||
|
||||
function isZhiboBundlePayload(body: unknown): body is ZhiboMatchesBundleExport {
|
||||
if (!body || typeof body !== 'object') return false;
|
||||
return Array.isArray((body as ZhiboMatchesBundleExport).matches);
|
||||
}
|
||||
|
||||
class ScoreDto {
|
||||
@@ -123,44 +264,73 @@ export class AdminController {
|
||||
private audit: AuditService,
|
||||
private bets: BetsService,
|
||||
private prisma: PrismaService,
|
||||
private readonly dashboardService: AdminDashboardService,
|
||||
) {}
|
||||
|
||||
@Get('dashboard')
|
||||
async dashboard() {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const [todayBets, pendingMatches, totalPlayers] = await Promise.all([
|
||||
this.prisma.bet.aggregate({
|
||||
where: { placedAt: { gte: today } },
|
||||
_sum: { stake: true, actualReturn: true },
|
||||
_count: true,
|
||||
}),
|
||||
this.prisma.match.count({ where: { status: 'PENDING_SETTLEMENT' } }),
|
||||
this.prisma.user.count({ where: { userType: 'PLAYER' } }),
|
||||
]);
|
||||
|
||||
return jsonResponse({
|
||||
todayBetCount: todayBets._count,
|
||||
todayStake: todayBets._sum.stake,
|
||||
todayPayout: todayBets._sum.actualReturn,
|
||||
pendingSettlement: pendingMatches,
|
||||
totalPlayers,
|
||||
});
|
||||
async getDashboard() {
|
||||
const overview = await this.dashboardService.getOverview();
|
||||
return jsonResponse(overview);
|
||||
}
|
||||
|
||||
@Get('users')
|
||||
async listUsers(@Query('page') page?: string) {
|
||||
const result = await this.users.listPlayers(page ? parseInt(page) : 1);
|
||||
async listUsers(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('parentId') parentId?: string,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
const result = await this.users.listPlayers(
|
||||
page ? parseInt(page, 10) : 1,
|
||||
pageSize ? parseInt(pageSize, 10) : 10,
|
||||
{
|
||||
keyword,
|
||||
parentId: parentId ? BigInt(parentId) : undefined,
|
||||
status,
|
||||
},
|
||||
);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('users/:id')
|
||||
async getUserDetail(@Param('id') id: string) {
|
||||
const detail = await this.users.getPlayerAdminDetail(BigInt(id));
|
||||
return jsonResponse(detail);
|
||||
}
|
||||
|
||||
@Put('users/:id')
|
||||
async updateUser(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdatePlayerAdminDto,
|
||||
) {
|
||||
const detail = await this.users.updatePlayerAdmin(BigInt(id), dto);
|
||||
await this.audit.log({
|
||||
operatorId,
|
||||
operatorType: 'ADMIN',
|
||||
action: 'UPDATE_PLAYER',
|
||||
module: 'USERS',
|
||||
targetId: id,
|
||||
});
|
||||
return jsonResponse(detail);
|
||||
}
|
||||
|
||||
@Post('users')
|
||||
async createPlayer(@CurrentUser('id') operatorId: bigint, @Body() dto: CreateUserDto) {
|
||||
async createPlayer(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Body() dto: CreatePlayerAdminDto,
|
||||
) {
|
||||
const user = await this.agents.createPlayer(operatorId, {
|
||||
username: dto.username,
|
||||
password: dto.password,
|
||||
parentId: dto.parentId ? BigInt(dto.parentId) : operatorId,
|
||||
parentId: dto.parentId ? BigInt(dto.parentId) : undefined,
|
||||
locale: dto.locale,
|
||||
phone: dto.phone,
|
||||
email: dto.email,
|
||||
initialDeposit: dto.initialDeposit,
|
||||
depositRemark: dto.remark,
|
||||
depositRequestId: `create-player-${dto.username}-${Date.now()}`,
|
||||
});
|
||||
await this.audit.log({
|
||||
operatorId,
|
||||
@@ -169,24 +339,73 @@ export class AdminController {
|
||||
module: 'USERS',
|
||||
targetId: user.id.toString(),
|
||||
});
|
||||
return jsonResponse(user);
|
||||
const detail = await this.users.getPlayerAdminDetail(user.id);
|
||||
return jsonResponse(detail);
|
||||
}
|
||||
|
||||
@Get('agents/options')
|
||||
async listAgentOptions() {
|
||||
const agents = await this.prisma.user.findMany({
|
||||
where: { userType: 'AGENT', deletedAt: null, agentLevel: 1 },
|
||||
select: { id: true, username: true },
|
||||
orderBy: { username: 'asc' },
|
||||
});
|
||||
return jsonResponse(
|
||||
agents.map((a) => ({ id: a.id.toString(), username: a.username })),
|
||||
);
|
||||
}
|
||||
|
||||
@Get('agents')
|
||||
async listAgents() {
|
||||
const agents = await this.prisma.agentProfile.findMany({
|
||||
include: { user: true },
|
||||
async listAgents(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
) {
|
||||
const result = await this.agents.listAgentsAdmin({
|
||||
page: page ? parseInt(page, 10) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize, 10) : 10,
|
||||
keyword,
|
||||
});
|
||||
return jsonResponse(agents);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('agents/:id')
|
||||
async getAgentDetail(@Param('id') id: string) {
|
||||
const detail = await this.agents.getAgentAdminDetail(BigInt(id));
|
||||
return jsonResponse(detail);
|
||||
}
|
||||
|
||||
@Put('agents/:id')
|
||||
async updateAgent(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateAgentAdminDto,
|
||||
) {
|
||||
const detail = await this.agents.updateAgentAdmin(BigInt(id), dto);
|
||||
await this.audit.log({
|
||||
operatorId,
|
||||
operatorType: 'ADMIN',
|
||||
action: 'UPDATE_AGENT',
|
||||
module: 'AGENTS',
|
||||
targetId: id,
|
||||
});
|
||||
return jsonResponse(detail);
|
||||
}
|
||||
|
||||
@Post('agents')
|
||||
async createAgent(@CurrentUser('id') operatorId: bigint, @Body() dto: CreateUserDto) {
|
||||
async createAgent(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Body() dto: CreateAgentAdminDto,
|
||||
) {
|
||||
const user = await this.agents.createAgent(operatorId, {
|
||||
username: dto.username,
|
||||
password: dto.password,
|
||||
level: 1,
|
||||
creditLimit: dto.creditLimit,
|
||||
locale: dto.locale,
|
||||
phone: dto.phone,
|
||||
email: dto.email,
|
||||
cashbackRate: dto.cashbackRate,
|
||||
});
|
||||
await this.audit.log({
|
||||
operatorId,
|
||||
@@ -195,7 +414,8 @@ export class AdminController {
|
||||
module: 'AGENTS',
|
||||
targetId: user.id.toString(),
|
||||
});
|
||||
return jsonResponse(user);
|
||||
const detail = await this.agents.getAgentAdminDetail(user.id);
|
||||
return jsonResponse(detail);
|
||||
}
|
||||
|
||||
@Post('agents/:id/credit')
|
||||
@@ -257,27 +477,100 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Get('matches')
|
||||
async listMatches() {
|
||||
const matches = await this.prisma.match.findMany({
|
||||
include: { markets: { include: { selections: true } } },
|
||||
orderBy: { startTime: 'desc' },
|
||||
async listMatches(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
) {
|
||||
const p = Math.max(1, page ? parseInt(page, 10) : 1);
|
||||
const size = Math.min(Math.max(1, pageSize ? parseInt(pageSize, 10) : 10), 100);
|
||||
const skip = (p - 1) * size;
|
||||
const where: { deletedAt: null; status?: string; OR?: object[] } = { deletedAt: null };
|
||||
if (status) where.status = status;
|
||||
const kw = keyword?.trim();
|
||||
if (kw) {
|
||||
where.OR = [
|
||||
{ matchName: { contains: kw, mode: 'insensitive' } },
|
||||
{ homeTeam: { code: { contains: kw, mode: 'insensitive' } } },
|
||||
{ awayTeam: { code: { contains: kw, mode: 'insensitive' } } },
|
||||
];
|
||||
}
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.match.findMany({
|
||||
where,
|
||||
include: {
|
||||
homeTeam: true,
|
||||
awayTeam: true,
|
||||
},
|
||||
orderBy: [{ displayOrder: 'asc' }, { startTime: 'desc' }],
|
||||
skip,
|
||||
take: size,
|
||||
}),
|
||||
this.prisma.match.count({ where }),
|
||||
]);
|
||||
return jsonResponse({ items, total, page: p, pageSize: size });
|
||||
}
|
||||
|
||||
@Get('matches/:id')
|
||||
async getMatch(@Param('id') id: string) {
|
||||
const match = await this.matches.getAdminMatchDetail(BigInt(id));
|
||||
return jsonResponse(match);
|
||||
}
|
||||
|
||||
@Put('matches/:id')
|
||||
async updateMatch(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CreatePlatformMatchDto,
|
||||
) {
|
||||
const match = await this.matches.updatePlatformMatch(BigInt(id), {
|
||||
leagueEn: dto.leagueEn,
|
||||
leagueZh: dto.leagueZh,
|
||||
homeTeamEn: dto.homeTeamEn,
|
||||
homeTeamZh: dto.homeTeamZh,
|
||||
awayTeamEn: dto.awayTeamEn,
|
||||
awayTeamZh: dto.awayTeamZh,
|
||||
startTime: new Date(dto.startTime),
|
||||
isHot: dto.isHot,
|
||||
displayOrder: dto.displayOrder,
|
||||
updatedBy: operatorId,
|
||||
});
|
||||
return jsonResponse(matches);
|
||||
return jsonResponse(match);
|
||||
}
|
||||
|
||||
@Delete('matches/:id')
|
||||
async deleteMatch(@Param('id') id: string) {
|
||||
await this.matches.deleteMatch(BigInt(id));
|
||||
return jsonResponse({ deleted: true });
|
||||
}
|
||||
|
||||
@Post('matches')
|
||||
async createMatch(@CurrentUser('id') operatorId: bigint, @Body() dto: CreateMatchDto) {
|
||||
const match = await this.matches.createMatch({
|
||||
leagueId: BigInt(dto.leagueId),
|
||||
homeTeamId: BigInt(dto.homeTeamId),
|
||||
awayTeamId: BigInt(dto.awayTeamId),
|
||||
async createMatch(@CurrentUser('id') operatorId: bigint, @Body() dto: CreatePlatformMatchDto) {
|
||||
const match = await this.matches.createPlatformMatch({
|
||||
leagueEn: dto.leagueEn,
|
||||
leagueZh: dto.leagueZh,
|
||||
homeTeamEn: dto.homeTeamEn,
|
||||
homeTeamZh: dto.homeTeamZh,
|
||||
awayTeamEn: dto.awayTeamEn,
|
||||
awayTeamZh: dto.awayTeamZh,
|
||||
startTime: new Date(dto.startTime),
|
||||
isHot: dto.isHot,
|
||||
displayOrder: dto.displayOrder,
|
||||
createdBy: operatorId,
|
||||
});
|
||||
return jsonResponse(match);
|
||||
}
|
||||
|
||||
@Post('matches/import')
|
||||
async importMatches(@CurrentUser('id') operatorId: bigint, @Body() dto: ZhiboMatchesBundleExport) {
|
||||
if (!isZhiboBundlePayload(dto)) {
|
||||
throw new BadRequestException('Invalid import payload: matches[] required');
|
||||
}
|
||||
const result = await this.matches.importZhiboMatchesBundle(dto, operatorId);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Post('matches/:id/publish')
|
||||
async publishMatch(@Param('id') id: string) {
|
||||
const match = await this.matches.publishMatch(BigInt(id));
|
||||
@@ -343,20 +636,31 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Get('bets')
|
||||
async listBets(@Query('status') status?: string, @Query('page') page?: string) {
|
||||
const skip = ((page ? parseInt(page) : 1) - 1) * 20;
|
||||
const where = status ? { status } : {};
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.bet.findMany({
|
||||
where,
|
||||
include: { selections: true, user: true },
|
||||
orderBy: { placedAt: 'desc' },
|
||||
skip,
|
||||
take: 20,
|
||||
}),
|
||||
this.prisma.bet.count({ where }),
|
||||
]);
|
||||
return jsonResponse({ items, total });
|
||||
async listBets(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('betType') betType?: string,
|
||||
@Query('placedFrom') placedFrom?: string,
|
||||
@Query('placedTo') placedTo?: string,
|
||||
) {
|
||||
const result = await this.bets.listBetsAdmin({
|
||||
page: page ? parseInt(page, 10) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize, 10) : 10,
|
||||
keyword,
|
||||
status: status || undefined,
|
||||
betType: betType || undefined,
|
||||
placedFrom,
|
||||
placedTo,
|
||||
});
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('bets/:id')
|
||||
async getBet(@Param('id') id: string) {
|
||||
const detail = await this.bets.getBetAdminDetail(BigInt(id));
|
||||
return jsonResponse(detail);
|
||||
}
|
||||
|
||||
@Post('cashbacks/preview')
|
||||
@@ -393,8 +697,16 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Get('audit-logs')
|
||||
async auditLogs(@Query('page') page?: string, @Query('module') module?: string) {
|
||||
const result = await this.audit.list(page ? parseInt(page) : 1, 50, module);
|
||||
async auditLogs(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('module') module?: string,
|
||||
) {
|
||||
const result = await this.audit.list(
|
||||
page ? parseInt(page, 10) : 1,
|
||||
pageSize ? parseInt(pageSize, 10) : 10,
|
||||
module || undefined,
|
||||
);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminDashboardService } from './admin-dashboard.service';
|
||||
import { UsersModule } from '../../domains/identity/users.module';
|
||||
import { AgentsModule } from '../../domains/agent/agents.module';
|
||||
import { WalletModule } from '../../domains/ledger/wallet.module';
|
||||
@@ -25,5 +26,6 @@ import { BetsModule } from '../../domains/betting/bets.module';
|
||||
BetsModule,
|
||||
],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminDashboardService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
|
||||
@@ -147,8 +147,14 @@ export class AgentPortalController {
|
||||
}
|
||||
|
||||
@Get('bets')
|
||||
async listBets(@CurrentUser('id') agentId: bigint, @Query('page') page?: string) {
|
||||
const skip = ((page ? parseInt(page) : 1) - 1) * 20;
|
||||
async listBets(
|
||||
@CurrentUser('id') agentId: bigint,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
const p = Math.max(1, page ? parseInt(page, 10) : 1);
|
||||
const size = Math.min(Math.max(1, pageSize ? parseInt(pageSize, 10) : 10), 100);
|
||||
const skip = (p - 1) * size;
|
||||
const descendants = await this.prisma.agentClosure.findMany({
|
||||
where: { ancestorId: agentId },
|
||||
});
|
||||
@@ -160,11 +166,11 @@ export class AgentPortalController {
|
||||
include: { selections: true, user: true },
|
||||
orderBy: { placedAt: 'desc' },
|
||||
skip,
|
||||
take: 20,
|
||||
take: size,
|
||||
}),
|
||||
this.prisma.bet.count({ where: { agentId: { in: agentIds } } }),
|
||||
]);
|
||||
return jsonResponse({ items, total });
|
||||
return jsonResponse({ items, total, page: p, pageSize: size });
|
||||
}
|
||||
|
||||
@Get('reports/summary')
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { WalletService } from '../ledger/wallet.service';
|
||||
import { AuthService } from '../identity/auth.service';
|
||||
@@ -147,6 +153,211 @@ export class AgentsService {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async listAgentsAdmin(params?: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
const page = Math.max(1, params?.page ?? 1);
|
||||
const pageSize = Math.min(Math.max(1, params?.pageSize ?? 10), 100);
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.AgentProfileWhereInput = {};
|
||||
const kw = params?.keyword?.trim();
|
||||
if (kw) {
|
||||
where.user = { username: { contains: kw, mode: 'insensitive' } };
|
||||
}
|
||||
|
||||
const [profiles, total] = await Promise.all([
|
||||
this.prisma.agentProfile.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: { include: { preferences: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.agentProfile.count({ where }),
|
||||
]);
|
||||
|
||||
const agentIds = profiles.map((p) => p.userId);
|
||||
const playerCounts =
|
||||
agentIds.length > 0
|
||||
? await this.prisma.user.groupBy({
|
||||
by: ['parentId'],
|
||||
where: {
|
||||
userType: 'PLAYER',
|
||||
parentId: { in: agentIds },
|
||||
deletedAt: null,
|
||||
},
|
||||
_count: { _all: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
const countMap = new Map(
|
||||
playerCounts.map((g) => [g.parentId?.toString(), g._count._all]),
|
||||
);
|
||||
|
||||
const items = profiles.map((p) => {
|
||||
const available = new Decimal(p.creditLimit).sub(p.usedCredit);
|
||||
return {
|
||||
id: p.id.toString(),
|
||||
userId: p.userId.toString(),
|
||||
username: p.user.username,
|
||||
userStatus: p.user.status,
|
||||
level: p.level,
|
||||
status: p.status,
|
||||
parentAgentId: p.parentAgentId?.toString() ?? null,
|
||||
creditLimit: p.creditLimit.toString(),
|
||||
usedCredit: p.usedCredit.toString(),
|
||||
availableCredit: available.toString(),
|
||||
directPlayerLiability: p.directPlayerLiability.toString(),
|
||||
childAgentExposure: p.childAgentExposure.toString(),
|
||||
cashbackRate: p.cashbackRate.toString(),
|
||||
directPlayerCount: countMap.get(p.userId.toString()) ?? 0,
|
||||
phone: p.user.preferences?.phone ?? null,
|
||||
email: p.user.preferences?.email ?? null,
|
||||
locale: p.user.locale,
|
||||
createdAt: p.createdAt,
|
||||
updatedAt: p.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async getAgentAdminDetail(agentId: bigint) {
|
||||
const profile = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: agentId },
|
||||
include: { user: { include: { preferences: true, auth: true } } },
|
||||
});
|
||||
if (!profile) throw new NotFoundException('代理不存在');
|
||||
|
||||
const [directPlayerCount, recentCredits] = await Promise.all([
|
||||
this.prisma.user.count({
|
||||
where: { parentId: agentId, userType: 'PLAYER', deletedAt: null },
|
||||
}),
|
||||
this.prisma.agentCreditTransaction.findMany({
|
||||
where: { agentId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
}),
|
||||
]);
|
||||
|
||||
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
|
||||
let parentUsername: string | null = null;
|
||||
if (profile.parentAgentId) {
|
||||
const parent = await this.prisma.user.findUnique({
|
||||
where: { id: profile.parentAgentId },
|
||||
select: { username: true },
|
||||
});
|
||||
parentUsername = parent?.username ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: profile.id.toString(),
|
||||
userId: profile.userId.toString(),
|
||||
username: profile.user.username,
|
||||
userStatus: profile.user.status,
|
||||
level: profile.level,
|
||||
status: profile.status,
|
||||
parentAgentId: profile.parentAgentId?.toString() ?? null,
|
||||
parentUsername,
|
||||
creditLimit: profile.creditLimit.toString(),
|
||||
usedCredit: profile.usedCredit.toString(),
|
||||
availableCredit: available.toString(),
|
||||
directPlayerLiability: profile.directPlayerLiability.toString(),
|
||||
childAgentExposure: profile.childAgentExposure.toString(),
|
||||
cashbackRate: profile.cashbackRate.toString(),
|
||||
directPlayerCount,
|
||||
phone: profile.user.preferences?.phone ?? null,
|
||||
email: profile.user.preferences?.email ?? null,
|
||||
locale: profile.user.locale,
|
||||
lastLoginAt: profile.user.auth?.lastLoginAt ?? null,
|
||||
createdAt: profile.createdAt,
|
||||
updatedAt: profile.updatedAt,
|
||||
recentCreditTransactions: recentCredits.map((t) => ({
|
||||
id: t.id.toString(),
|
||||
transactionType: t.transactionType,
|
||||
amount: t.amount.toString(),
|
||||
creditBefore: t.creditBefore.toString(),
|
||||
creditAfter: t.creditAfter.toString(),
|
||||
remark: t.remark,
|
||||
createdAt: t.createdAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async updateAgentAdmin(
|
||||
agentId: bigint,
|
||||
data: {
|
||||
status?: string;
|
||||
locale?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
cashbackRate?: number;
|
||||
},
|
||||
) {
|
||||
const profile = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: agentId },
|
||||
include: { user: true },
|
||||
});
|
||||
if (!profile) throw new NotFoundException('代理不存在');
|
||||
|
||||
if (data.status && !['ACTIVE', 'SUSPENDED'].includes(data.status)) {
|
||||
throw new BadRequestException('无效状态');
|
||||
}
|
||||
|
||||
if (data.status) {
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.user.update({
|
||||
where: { id: agentId },
|
||||
data: { status: data.status },
|
||||
}),
|
||||
this.prisma.agentProfile.update({
|
||||
where: { userId: agentId },
|
||||
data: { status: data.status },
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
if (data.locale) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: agentId },
|
||||
data: { locale: data.locale },
|
||||
});
|
||||
}
|
||||
|
||||
if (data.cashbackRate !== undefined) {
|
||||
await this.prisma.agentProfile.update({
|
||||
where: { userId: agentId },
|
||||
data: { cashbackRate: data.cashbackRate },
|
||||
});
|
||||
}
|
||||
|
||||
if (data.phone !== undefined || data.email !== undefined || data.locale) {
|
||||
const phone = data.phone !== undefined ? data.phone?.trim() || null : undefined;
|
||||
const email = data.email !== undefined ? data.email?.trim() || null : undefined;
|
||||
await this.prisma.userPreference.upsert({
|
||||
where: { userId: agentId },
|
||||
create: {
|
||||
userId: agentId,
|
||||
locale: data.locale ?? profile.user.locale,
|
||||
phone: phone ?? null,
|
||||
email: email ?? null,
|
||||
},
|
||||
update: {
|
||||
...(data.locale ? { locale: data.locale } : {}),
|
||||
...(phone !== undefined ? { phone } : {}),
|
||||
...(email !== undefined ? { email } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return this.getAgentAdminDetail(agentId);
|
||||
}
|
||||
|
||||
async createAgent(
|
||||
operatorId: bigint,
|
||||
data: {
|
||||
@@ -155,6 +366,10 @@ export class AgentsService {
|
||||
level: number;
|
||||
parentAgentId?: bigint;
|
||||
creditLimit?: number;
|
||||
locale?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
cashbackRate?: number;
|
||||
},
|
||||
) {
|
||||
if (data.level === 2 && !data.parentAgentId) {
|
||||
@@ -164,12 +379,14 @@ export class AgentsService {
|
||||
const hash = await this.auth.hashPassword(data.password);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const locale = data.locale ?? 'zh-CN';
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
username: data.username,
|
||||
userType: 'AGENT',
|
||||
parentId: data.parentAgentId,
|
||||
agentLevel: data.level,
|
||||
locale,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -177,12 +394,22 @@ export class AgentsService {
|
||||
data: { userId: user.id, passwordHash: hash },
|
||||
});
|
||||
|
||||
await tx.userPreference.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
locale,
|
||||
phone: data.phone?.trim() || null,
|
||||
email: data.email?.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.agentProfile.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
level: data.level,
|
||||
parentAgentId: data.parentAgentId,
|
||||
creditLimit: data.creditLimit ?? 0,
|
||||
cashbackRate: data.cashbackRate ?? 0,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -215,38 +442,81 @@ export class AgentsService {
|
||||
|
||||
async createPlayer(
|
||||
operatorId: bigint,
|
||||
data: { username: string; password: string; parentId: bigint },
|
||||
data: {
|
||||
username: string;
|
||||
password: string;
|
||||
parentId?: bigint;
|
||||
locale?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
initialDeposit?: number;
|
||||
depositRemark?: string;
|
||||
depositRequestId?: string;
|
||||
},
|
||||
) {
|
||||
const hash = await this.auth.hashPassword(data.password);
|
||||
let parentId: bigint | null = null;
|
||||
if (data.parentId != null) {
|
||||
const parent = await this.prisma.user.findUnique({ where: { id: data.parentId } });
|
||||
if (!parent || parent.userType !== 'AGENT') {
|
||||
throw new BadRequestException('上级必须为代理账号');
|
||||
}
|
||||
parentId = data.parentId;
|
||||
}
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.create({
|
||||
const hash = await this.auth.hashPassword(data.password);
|
||||
const locale = data.locale ?? 'zh-CN';
|
||||
|
||||
const user = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.user.create({
|
||||
data: {
|
||||
username: data.username,
|
||||
userType: 'PLAYER',
|
||||
parentId: data.parentId,
|
||||
parentId,
|
||||
locale,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.userAuth.create({
|
||||
data: { userId: user.id, passwordHash: hash },
|
||||
data: { userId: created.id, passwordHash: hash },
|
||||
});
|
||||
|
||||
await tx.wallet.create({
|
||||
data: { userId: user.id },
|
||||
data: { userId: created.id },
|
||||
});
|
||||
|
||||
await tx.userPreference.create({
|
||||
data: { userId: user.id },
|
||||
data: {
|
||||
userId: created.id,
|
||||
locale,
|
||||
phone: data.phone?.trim() || null,
|
||||
email: data.email?.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
const parent = await tx.user.findUnique({ where: { id: data.parentId } });
|
||||
if (parent?.userType === 'AGENT') {
|
||||
await this.recalculateUsedCredit(data.parentId);
|
||||
}
|
||||
|
||||
return user;
|
||||
return created;
|
||||
});
|
||||
|
||||
if (parentId) {
|
||||
await this.recalculateUsedCredit(parentId);
|
||||
}
|
||||
|
||||
const initial = data.initialDeposit ?? 0;
|
||||
if (initial > 0) {
|
||||
const requestId =
|
||||
data.depositRequestId ?? `admin-create-${user.id}-${Date.now()}`;
|
||||
await this.wallet.deposit(
|
||||
user.id,
|
||||
initial,
|
||||
operatorId,
|
||||
data.depositRemark ?? '开户初始余额',
|
||||
requestId,
|
||||
);
|
||||
if (parentId) {
|
||||
await this.recalculateUsedCredit(parentId);
|
||||
}
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async getDirectPlayers(agentId: bigint) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { Injectable, BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { WalletService } from '../ledger/wallet.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
@@ -208,4 +209,157 @@ export class BetsService {
|
||||
include: { selections: true },
|
||||
});
|
||||
}
|
||||
|
||||
private dec(v: Decimal | null | undefined) {
|
||||
return v?.toString() ?? '0';
|
||||
}
|
||||
|
||||
private formatBetListRow(
|
||||
b: {
|
||||
id: bigint;
|
||||
betNo: string;
|
||||
userId: bigint;
|
||||
agentId: bigint | null;
|
||||
betType: string;
|
||||
stake: Decimal;
|
||||
totalOdds: Decimal | null;
|
||||
potentialReturn: Decimal | null;
|
||||
actualReturn: Decimal;
|
||||
status: string;
|
||||
settlementStatus: string | null;
|
||||
currency: string;
|
||||
placedAt: Date;
|
||||
settledAt: Date | null;
|
||||
user: { id: bigint; username: string; parent: { username: string } | null };
|
||||
_count: { selections: number };
|
||||
},
|
||||
) {
|
||||
return {
|
||||
id: b.id.toString(),
|
||||
betNo: b.betNo,
|
||||
userId: b.userId.toString(),
|
||||
username: b.user.username,
|
||||
parentUsername: b.user.parent?.username ?? null,
|
||||
agentId: b.agentId?.toString() ?? null,
|
||||
betType: b.betType,
|
||||
stake: this.dec(b.stake),
|
||||
totalOdds: b.totalOdds ? this.dec(b.totalOdds) : null,
|
||||
potentialReturn: b.potentialReturn ? this.dec(b.potentialReturn) : null,
|
||||
actualReturn: this.dec(b.actualReturn),
|
||||
status: b.status,
|
||||
settlementStatus: b.settlementStatus,
|
||||
currency: b.currency,
|
||||
placedAt: b.placedAt,
|
||||
settledAt: b.settledAt,
|
||||
selectionCount: b._count.selections,
|
||||
};
|
||||
}
|
||||
|
||||
async listBetsAdmin(params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
status?: string;
|
||||
betType?: string;
|
||||
placedFrom?: string;
|
||||
placedTo?: string;
|
||||
}) {
|
||||
const page = Math.max(1, params.page ?? 1);
|
||||
const pageSize = Math.min(Math.max(1, params.pageSize ?? 10), 100);
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.BetWhereInput = {};
|
||||
|
||||
if (params.status) where.status = params.status;
|
||||
if (params.betType) where.betType = params.betType;
|
||||
|
||||
if (params.placedFrom || params.placedTo) {
|
||||
where.placedAt = {};
|
||||
if (params.placedFrom) {
|
||||
const from = new Date(params.placedFrom);
|
||||
from.setHours(0, 0, 0, 0);
|
||||
where.placedAt.gte = from;
|
||||
}
|
||||
if (params.placedTo) {
|
||||
const to = new Date(params.placedTo);
|
||||
to.setHours(23, 59, 59, 999);
|
||||
where.placedAt.lte = to;
|
||||
}
|
||||
}
|
||||
|
||||
const kw = params.keyword?.trim();
|
||||
if (kw) {
|
||||
where.OR = [
|
||||
{ betNo: { contains: kw, mode: 'insensitive' } },
|
||||
{ user: { username: { contains: kw, mode: 'insensitive' } } },
|
||||
];
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.bet.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
parent: { select: { username: true } },
|
||||
},
|
||||
},
|
||||
_count: { select: { selections: true } },
|
||||
},
|
||||
orderBy: { placedAt: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.bet.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((b) => this.formatBetListRow(b)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async getBetAdminDetail(betId: bigint) {
|
||||
const bet = await this.prisma.bet.findUnique({
|
||||
where: { id: betId },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
parent: { select: { username: true } },
|
||||
},
|
||||
},
|
||||
selections: { orderBy: { sortOrder: 'asc' } },
|
||||
},
|
||||
});
|
||||
if (!bet) throw new NotFoundException('注单不存在');
|
||||
|
||||
return {
|
||||
...this.formatBetListRow({
|
||||
...bet,
|
||||
_count: { selections: bet.selections.length },
|
||||
}),
|
||||
requestId: bet.requestId,
|
||||
createdAt: bet.createdAt,
|
||||
updatedAt: bet.updatedAt,
|
||||
selections: bet.selections.map((s) => ({
|
||||
id: s.id.toString(),
|
||||
matchId: s.matchId?.toString() ?? null,
|
||||
marketType: s.marketType,
|
||||
period: s.period,
|
||||
selectionName: s.selectionNameSnapshot,
|
||||
handicapLine: s.handicapLine ? this.dec(s.handicapLine) : null,
|
||||
totalLine: s.totalLine ? this.dec(s.totalLine) : null,
|
||||
odds: this.dec(s.odds),
|
||||
resultStatus: s.resultStatus,
|
||||
effectiveOdds: s.effectiveOdds ? this.dec(s.effectiveOdds) : null,
|
||||
sortOrder: s.sortOrder,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import type { ZhiboLeagueExport, ZhiboMatchExport, ZhiboMatchesBundleExport, ZhiboTeamExport } from './zhibo-match.types';
|
||||
import {
|
||||
leagueCodeFromExport,
|
||||
resolveInternalStatus,
|
||||
resolveIsHot,
|
||||
resolveStartTime,
|
||||
teamCodeFromExport,
|
||||
toKickoffJson,
|
||||
toVenueJson,
|
||||
translationsFromZhiboNames,
|
||||
} from './zhibo-match.mapper';
|
||||
|
||||
@Injectable()
|
||||
export class MatchesService {
|
||||
@@ -44,8 +56,24 @@ export class MatchesService {
|
||||
awayTeamId: bigint;
|
||||
startTime: Date;
|
||||
isHot?: boolean;
|
||||
displayOrder?: number;
|
||||
createdBy?: bigint;
|
||||
status?: string;
|
||||
publishTime?: Date;
|
||||
zhibo?: Partial<{
|
||||
officialMatchNo: number;
|
||||
stage: string;
|
||||
groupName: string;
|
||||
liveMatchId?: bigint;
|
||||
additionMatchId: bigint | null;
|
||||
channelId: string | null;
|
||||
matchName: string;
|
||||
venueJson: Prisma.InputJsonValue;
|
||||
kickoffJson: Prisma.InputJsonValue;
|
||||
externalStatus: string;
|
||||
}>;
|
||||
}) {
|
||||
const status = data.status ?? 'DRAFT';
|
||||
return this.prisma.match.create({
|
||||
data: {
|
||||
leagueId: data.leagueId,
|
||||
@@ -53,12 +81,384 @@ export class MatchesService {
|
||||
awayTeamId: data.awayTeamId,
|
||||
startTime: data.startTime,
|
||||
isHot: data.isHot ?? false,
|
||||
displayOrder: data.displayOrder ?? 0,
|
||||
createdBy: data.createdBy,
|
||||
status: 'DRAFT',
|
||||
status,
|
||||
publishTime: data.publishTime ?? (status === 'PUBLISHED' ? new Date() : undefined),
|
||||
officialMatchNo: data.zhibo?.officialMatchNo,
|
||||
stage: data.zhibo?.stage,
|
||||
groupName: data.zhibo?.groupName,
|
||||
liveMatchId: data.zhibo?.liveMatchId,
|
||||
additionMatchId: data.zhibo?.additionMatchId ?? undefined,
|
||||
channelId: data.zhibo?.channelId ?? undefined,
|
||||
matchName: data.zhibo?.matchName,
|
||||
venueJson: data.zhibo?.venueJson,
|
||||
kickoffJson: data.zhibo?.kickoffJson,
|
||||
externalStatus: data.zhibo?.externalStatus,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async upsertEntityTranslations(
|
||||
entityType: 'LEAGUE' | 'TEAM',
|
||||
entityId: bigint,
|
||||
translations: Record<string, string>,
|
||||
) {
|
||||
for (const [locale, value] of Object.entries(translations)) {
|
||||
await this.prisma.entityTranslation.upsert({
|
||||
where: {
|
||||
entityType_entityId_locale_fieldName: {
|
||||
entityType,
|
||||
entityId,
|
||||
locale,
|
||||
fieldName: 'name',
|
||||
},
|
||||
},
|
||||
create: { entityType, entityId, locale, fieldName: 'name', value },
|
||||
update: { value },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async upsertLeagueFromZhiboExport(league: ZhiboLeagueExport) {
|
||||
const code = leagueCodeFromExport(league);
|
||||
const record = await this.prisma.league.upsert({
|
||||
where: { code },
|
||||
create: { code, sportType: league.type || 'FOOTBALL' },
|
||||
update: { sportType: league.type || 'FOOTBALL' },
|
||||
});
|
||||
await this.upsertEntityTranslations('LEAGUE', record.id, {
|
||||
'zh-CN': league.zh,
|
||||
'en-US': league.en,
|
||||
});
|
||||
return record;
|
||||
}
|
||||
|
||||
async upsertTeamFromZhiboExport(team: ZhiboTeamExport) {
|
||||
const code = teamCodeFromExport(team);
|
||||
const translations = translationsFromZhiboNames(team.names, team.name);
|
||||
|
||||
let record =
|
||||
team.id != null
|
||||
? await this.prisma.team.findFirst({ where: { externalId: team.id } })
|
||||
: await this.prisma.team.findUnique({ where: { code } });
|
||||
|
||||
if (!record) {
|
||||
record = await this.prisma.team.create({
|
||||
data: {
|
||||
code,
|
||||
externalId: team.id ?? undefined,
|
||||
logoUrl: team.image || undefined,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
record = await this.prisma.team.update({
|
||||
where: { id: record.id },
|
||||
data: {
|
||||
logoUrl: team.image || record.logoUrl,
|
||||
externalId: team.id ?? record.externalId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await this.upsertEntityTranslations('TEAM', record.id, translations);
|
||||
return record;
|
||||
}
|
||||
|
||||
private async findExistingZhiboMatch(
|
||||
leagueId: bigint,
|
||||
homeTeamId: bigint,
|
||||
awayTeamId: bigint,
|
||||
item: ZhiboMatchExport,
|
||||
) {
|
||||
if (item.liveMatchId != null) {
|
||||
return this.prisma.match.findUnique({
|
||||
where: { liveMatchId: BigInt(item.liveMatchId) },
|
||||
});
|
||||
}
|
||||
if (item.officialMatchNo != null) {
|
||||
return this.prisma.match.findFirst({
|
||||
where: {
|
||||
leagueId,
|
||||
homeTeamId,
|
||||
awayTeamId,
|
||||
officialMatchNo: item.officialMatchNo,
|
||||
},
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async createPlatformMatch(data: {
|
||||
leagueEn: string;
|
||||
leagueZh: string;
|
||||
homeTeamZh: string;
|
||||
homeTeamEn: string;
|
||||
awayTeamZh: string;
|
||||
awayTeamEn: string;
|
||||
startTime: Date;
|
||||
isHot?: boolean;
|
||||
displayOrder?: number;
|
||||
createdBy?: bigint;
|
||||
}) {
|
||||
const homeEn = data.homeTeamEn.trim();
|
||||
const homeZh = data.homeTeamZh.trim();
|
||||
const awayEn = data.awayTeamEn.trim();
|
||||
const awayZh = data.awayTeamZh.trim();
|
||||
if ((!homeEn && !homeZh) || (!awayEn && !awayZh)) {
|
||||
throw new BadRequestException('请填写主客队中英文名至少各一项');
|
||||
}
|
||||
|
||||
const league = await this.upsertLeagueFromZhiboExport({
|
||||
type: 'FOOTBALL',
|
||||
en: data.leagueEn.trim(),
|
||||
zh: data.leagueZh.trim(),
|
||||
});
|
||||
const [homeTeam, awayTeam] = await Promise.all([
|
||||
this.upsertTeamFromZhiboExport({
|
||||
id: null,
|
||||
name: homeEn || homeZh,
|
||||
names: { zh: homeZh || null, en: homeEn || null, zhTw: '', vi: null, km: null, ms: null },
|
||||
image: '',
|
||||
}),
|
||||
this.upsertTeamFromZhiboExport({
|
||||
id: null,
|
||||
name: awayEn || awayZh,
|
||||
names: { zh: awayZh || null, en: awayEn || null, zhTw: '', vi: null, km: null, ms: null },
|
||||
image: '',
|
||||
}),
|
||||
]);
|
||||
|
||||
return this.createMatch({
|
||||
leagueId: league.id,
|
||||
homeTeamId: homeTeam.id,
|
||||
awayTeamId: awayTeam.id,
|
||||
startTime: data.startTime,
|
||||
isHot: data.isHot ?? false,
|
||||
displayOrder: data.displayOrder ?? 0,
|
||||
createdBy: data.createdBy,
|
||||
status: 'DRAFT',
|
||||
zhibo: {
|
||||
matchName: `${homeEn || homeZh} - ${awayEn || awayZh}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async requireAdminMatch(matchId: bigint) {
|
||||
const match = await this.prisma.match.findFirst({
|
||||
where: { id: matchId, deletedAt: null },
|
||||
include: { homeTeam: true, awayTeam: true },
|
||||
});
|
||||
if (!match) throw new NotFoundException('赛事不存在');
|
||||
return match;
|
||||
}
|
||||
|
||||
async getAdminMatchDetail(matchId: bigint) {
|
||||
const match = await this.requireAdminMatch(matchId);
|
||||
const [leagueEn, leagueZh, homeEn, homeZh, awayEn, awayZh] = await Promise.all([
|
||||
this.getTranslation('LEAGUE', match.leagueId, 'en-US'),
|
||||
this.getTranslation('LEAGUE', match.leagueId, 'zh-CN'),
|
||||
this.getTranslation('TEAM', match.homeTeamId, 'en-US'),
|
||||
this.getTranslation('TEAM', match.homeTeamId, 'zh-CN'),
|
||||
this.getTranslation('TEAM', match.awayTeamId, 'en-US'),
|
||||
this.getTranslation('TEAM', match.awayTeamId, 'zh-CN'),
|
||||
]);
|
||||
return {
|
||||
id: match.id.toString(),
|
||||
status: match.status,
|
||||
isOutright: match.isOutright,
|
||||
isHot: match.isHot,
|
||||
startTime: match.startTime.toISOString(),
|
||||
leagueEn,
|
||||
leagueZh,
|
||||
homeTeamEn: homeEn,
|
||||
homeTeamZh: homeZh,
|
||||
awayTeamEn: awayEn,
|
||||
awayTeamZh: awayZh,
|
||||
matchName: match.matchName ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
async updatePlatformMatch(
|
||||
matchId: bigint,
|
||||
data: {
|
||||
leagueEn: string;
|
||||
leagueZh: string;
|
||||
homeTeamZh: string;
|
||||
homeTeamEn: string;
|
||||
awayTeamZh: string;
|
||||
awayTeamEn: string;
|
||||
startTime: Date;
|
||||
isHot?: boolean;
|
||||
displayOrder?: number;
|
||||
updatedBy?: bigint;
|
||||
},
|
||||
) {
|
||||
const match = await this.requireAdminMatch(matchId);
|
||||
if (match.isOutright) {
|
||||
throw new BadRequestException('冠军盘请通过盘口管理维护');
|
||||
}
|
||||
if (!['DRAFT', 'PUBLISHED'].includes(match.status)) {
|
||||
throw new BadRequestException('当前状态不可编辑');
|
||||
}
|
||||
|
||||
const matchName = `${data.homeTeamEn.trim() || data.homeTeamZh.trim()} - ${data.awayTeamEn.trim() || data.awayTeamZh.trim()}`;
|
||||
|
||||
await Promise.all([
|
||||
this.upsertEntityTranslations('LEAGUE', match.leagueId, {
|
||||
'zh-CN': data.leagueZh.trim(),
|
||||
'en-US': data.leagueEn.trim(),
|
||||
}),
|
||||
this.upsertEntityTranslations('TEAM', match.homeTeamId, {
|
||||
'zh-CN': data.homeTeamZh.trim(),
|
||||
'en-US': data.homeTeamEn.trim(),
|
||||
}),
|
||||
this.upsertEntityTranslations('TEAM', match.awayTeamId, {
|
||||
'zh-CN': data.awayTeamZh.trim(),
|
||||
'en-US': data.awayTeamEn.trim(),
|
||||
}),
|
||||
]);
|
||||
|
||||
return this.prisma.match.update({
|
||||
where: { id: matchId },
|
||||
data: {
|
||||
startTime: data.startTime,
|
||||
isHot: data.isHot ?? match.isHot,
|
||||
displayOrder: data.displayOrder ?? match.displayOrder,
|
||||
matchName,
|
||||
updatedBy: data.updatedBy,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteMatch(matchId: bigint) {
|
||||
const match = await this.requireAdminMatch(matchId);
|
||||
if (match.isOutright) {
|
||||
throw new BadRequestException('冠军盘不可删除');
|
||||
}
|
||||
if (match.status !== 'DRAFT') {
|
||||
throw new BadRequestException('仅草稿状态可删除');
|
||||
}
|
||||
const betCount = await this.prisma.betSelection.count({ where: { matchId } });
|
||||
if (betCount > 0) {
|
||||
throw new BadRequestException('该赛事已有注单关联,无法删除');
|
||||
}
|
||||
return this.prisma.match.update({
|
||||
where: { id: matchId },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async createMatchFromZhiboExport(
|
||||
item: ZhiboMatchExport,
|
||||
createdBy?: bigint,
|
||||
opts?: { asDraft?: boolean },
|
||||
) {
|
||||
const league = await this.upsertLeagueFromZhiboExport(item.league);
|
||||
const [homeTeam, awayTeam] = await Promise.all([
|
||||
this.upsertTeamFromZhiboExport(item.homeTeam),
|
||||
this.upsertTeamFromZhiboExport(item.awayTeam),
|
||||
]);
|
||||
|
||||
const status = opts?.asDraft ? 'DRAFT' : resolveInternalStatus(item);
|
||||
const startTime = resolveStartTime(item.kickoff);
|
||||
const liveMatchId =
|
||||
item.liveMatchId != null ? BigInt(item.liveMatchId) : undefined;
|
||||
const payload = {
|
||||
leagueId: league.id,
|
||||
homeTeamId: homeTeam.id,
|
||||
awayTeamId: awayTeam.id,
|
||||
startTime,
|
||||
isHot: resolveIsHot(item),
|
||||
displayOrder: item.sortOrder,
|
||||
createdBy,
|
||||
status,
|
||||
publishTime: status === 'PUBLISHED' ? new Date() : undefined,
|
||||
zhibo: {
|
||||
officialMatchNo: item.officialMatchNo,
|
||||
stage: item.stage,
|
||||
groupName: item.groupName,
|
||||
liveMatchId,
|
||||
additionMatchId: item.additionMatchId != null ? BigInt(item.additionMatchId) : null,
|
||||
channelId: item.channelId,
|
||||
matchName: item.matchName,
|
||||
venueJson: toVenueJson(item.venue),
|
||||
kickoffJson: toKickoffJson(item.kickoff),
|
||||
externalStatus: item.status.state,
|
||||
},
|
||||
};
|
||||
|
||||
const existing = await this.findExistingZhiboMatch(
|
||||
league.id,
|
||||
homeTeam.id,
|
||||
awayTeam.id,
|
||||
item,
|
||||
);
|
||||
if (existing) {
|
||||
return this.prisma.match.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
leagueId: payload.leagueId,
|
||||
homeTeamId: payload.homeTeamId,
|
||||
awayTeamId: payload.awayTeamId,
|
||||
startTime: payload.startTime,
|
||||
isHot: payload.isHot,
|
||||
displayOrder: payload.displayOrder,
|
||||
status: payload.status,
|
||||
publishTime: existing.publishTime ?? payload.publishTime,
|
||||
officialMatchNo: payload.zhibo.officialMatchNo,
|
||||
stage: payload.zhibo.stage,
|
||||
groupName: payload.zhibo.groupName,
|
||||
liveMatchId: payload.zhibo.liveMatchId ?? undefined,
|
||||
additionMatchId: payload.zhibo.additionMatchId ?? undefined,
|
||||
channelId: payload.zhibo.channelId ?? undefined,
|
||||
matchName: payload.zhibo.matchName,
|
||||
venueJson: payload.zhibo.venueJson,
|
||||
kickoffJson: payload.zhibo.kickoffJson,
|
||||
externalStatus: payload.zhibo.externalStatus,
|
||||
updatedBy: createdBy,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return this.createMatch(payload);
|
||||
}
|
||||
|
||||
async importZhiboMatchesBundle(bundle: ZhiboMatchesBundleExport, createdBy?: bigint) {
|
||||
if (!bundle.matches?.length) {
|
||||
throw new BadRequestException('matches array is required');
|
||||
}
|
||||
|
||||
const results: Array<{ liveMatchId: string; id: string; status: string; skipped?: boolean; reason?: string }> = [];
|
||||
|
||||
for (const item of bundle.matches) {
|
||||
try {
|
||||
const match = await this.createMatchFromZhiboExport(item, createdBy, { asDraft: true });
|
||||
results.push({
|
||||
liveMatchId: item.liveMatchId != null ? String(item.liveMatchId) : '',
|
||||
id: match.id.toString(),
|
||||
status: match.status,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'import failed';
|
||||
results.push({
|
||||
liveMatchId: item.liveMatchId != null ? String(item.liveMatchId) : '',
|
||||
id: '',
|
||||
status: 'error',
|
||||
reason: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total: bundle.matches.length,
|
||||
imported: results.filter((r) => !r.skipped && r.status !== 'error').length,
|
||||
skipped: results.filter((r) => r.skipped).length,
|
||||
failed: results.filter((r) => r.status === 'error').length,
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
async publishMatch(matchId: bigint) {
|
||||
return this.prisma.match.update({
|
||||
where: { id: matchId },
|
||||
|
||||
64
apps/api/src/domains/catalog/zhibo-match.mapper.ts
Normal file
64
apps/api/src/domains/catalog/zhibo-match.mapper.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { ZhiboLeagueExport, ZhiboMatchExport, ZhiboTeamExport } from './zhibo-match.types';
|
||||
|
||||
const LOCALE_MAP: Record<string, string> = {
|
||||
zh: 'zh-CN',
|
||||
en: 'en-US',
|
||||
ms: 'ms-MY',
|
||||
};
|
||||
|
||||
export function slugTeamCode(name: string): string {
|
||||
return name
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-zA-Z0-9]+/g, '_')
|
||||
.replace(/^_|_$/g, '')
|
||||
.toUpperCase()
|
||||
.slice(0, 48) || 'TEAM';
|
||||
}
|
||||
|
||||
export function teamCodeFromExport(team: ZhiboTeamExport): string {
|
||||
if (team.id != null) return `ZIBO_${team.id}`;
|
||||
return `NAME_${slugTeamCode(team.name)}`;
|
||||
}
|
||||
|
||||
export function leagueCodeFromExport(league: ZhiboLeagueExport): string {
|
||||
if (league.en.includes('World Cup 2026')) return 'WC2026';
|
||||
return slugTeamCode(league.en).slice(0, 32);
|
||||
}
|
||||
|
||||
export function translationsFromZhiboNames(
|
||||
names: ZhiboTeamExport['names'],
|
||||
fallbackEn: string,
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, locale] of Object.entries(LOCALE_MAP)) {
|
||||
const v = names[key as keyof typeof names];
|
||||
if (typeof v === 'string' && v.trim()) out[locale] = v.trim();
|
||||
}
|
||||
if (!out['en-US'] && fallbackEn) out['en-US'] = fallbackEn;
|
||||
if (!out['zh-CN'] && names.zh) out['zh-CN'] = names.zh;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function resolveStartTime(kickoff: ZhiboMatchExport['kickoff']): Date {
|
||||
if (kickoff.utcIso) return new Date(kickoff.utcIso);
|
||||
return new Date(kickoff.utcTimeStart * 1000);
|
||||
}
|
||||
|
||||
export function resolveInternalStatus(item: ZhiboMatchExport): string {
|
||||
if (!item.isPublished || item.status.state === 'off') return 'DRAFT';
|
||||
return 'PUBLISHED';
|
||||
}
|
||||
|
||||
export function resolveIsHot(item: ZhiboMatchExport): boolean {
|
||||
return (item.status.isHot ?? 0) > 0;
|
||||
}
|
||||
|
||||
export function toKickoffJson(kickoff: ZhiboMatchExport['kickoff']): Prisma.InputJsonValue {
|
||||
return kickoff as unknown as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
export function toVenueJson(venue: ZhiboMatchExport['venue']): Prisma.InputJsonValue {
|
||||
return venue as unknown as Prisma.InputJsonValue;
|
||||
}
|
||||
67
apps/api/src/domains/catalog/zhibo-match.types.ts
Normal file
67
apps/api/src/domains/catalog/zhibo-match.types.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/** zhibo 导出(world_cup_match_ext + live_matches)对齐结构 */
|
||||
|
||||
export interface ZhiboLocalizedNames {
|
||||
zh?: string | null;
|
||||
en?: string | null;
|
||||
zhTw?: string | null;
|
||||
vi?: string | null;
|
||||
km?: string | null;
|
||||
ms?: string | null;
|
||||
}
|
||||
|
||||
export interface ZhiboLeagueExport {
|
||||
type: string;
|
||||
en: string;
|
||||
zh: string;
|
||||
}
|
||||
|
||||
export interface ZhiboKickoffExport {
|
||||
utcTimeStart: number;
|
||||
utcTimeStop: number;
|
||||
utcIso: string;
|
||||
chinaTime: string;
|
||||
venueTime: string;
|
||||
venueTimezone: string;
|
||||
}
|
||||
|
||||
export interface ZhiboTeamExport {
|
||||
id: number | null;
|
||||
name: string;
|
||||
names: ZhiboLocalizedNames;
|
||||
image: string;
|
||||
}
|
||||
|
||||
export interface ZhiboMatchExport {
|
||||
officialMatchNo: number;
|
||||
stage: string;
|
||||
groupName: string;
|
||||
liveMatchId: number | null;
|
||||
additionMatchId: number | null;
|
||||
channelId: string | null;
|
||||
matchName: string;
|
||||
league: ZhiboLeagueExport;
|
||||
kickoff: ZhiboKickoffExport;
|
||||
homeTeam: ZhiboTeamExport;
|
||||
awayTeam: ZhiboTeamExport;
|
||||
score: { home: number | string | null; away: number | string | null };
|
||||
status: {
|
||||
state: string;
|
||||
nowPlaying: number;
|
||||
isLive: number;
|
||||
isHot: number;
|
||||
};
|
||||
venue: {
|
||||
names: ZhiboLocalizedNames;
|
||||
city: ZhiboLocalizedNames;
|
||||
};
|
||||
sortOrder: number;
|
||||
isPublished: boolean;
|
||||
}
|
||||
|
||||
export interface ZhiboMatchesBundleExport {
|
||||
exportedAt?: string;
|
||||
source?: Record<string, unknown>;
|
||||
count?: number;
|
||||
groups?: string[];
|
||||
matches: ZhiboMatchExport[];
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
import { AgentsModule } from '../agent/agents.module';
|
||||
|
||||
@Module({
|
||||
imports: [AgentsModule],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
|
||||
@@ -1,9 +1,77 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { AgentsService } from '../agent/agents.service';
|
||||
|
||||
export type PlayerListFilters = {
|
||||
keyword?: string;
|
||||
parentId?: bigint;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private agents: AgentsService,
|
||||
) {}
|
||||
|
||||
private formatPlayerRow(
|
||||
u: {
|
||||
id: bigint;
|
||||
username: string;
|
||||
status: string;
|
||||
locale: string;
|
||||
parentId: bigint | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
wallet?: { availableBalance: { toString(): string }; frozenBalance: { toString(): string } } | null;
|
||||
preferences?: { phone: string | null; email: string | null } | null;
|
||||
parent?: { username: string } | null;
|
||||
auth?: { lastLoginAt: Date | null } | null;
|
||||
},
|
||||
bet?: { count: number; totalStake: string; totalReturn: string },
|
||||
) {
|
||||
return {
|
||||
id: u.id.toString(),
|
||||
username: u.username,
|
||||
status: u.status,
|
||||
locale: u.locale,
|
||||
parentId: u.parentId?.toString() ?? null,
|
||||
parentUsername: u.parent?.username ?? null,
|
||||
phone: u.preferences?.phone ?? null,
|
||||
email: u.preferences?.email ?? null,
|
||||
availableBalance: u.wallet?.availableBalance?.toString() ?? '0',
|
||||
frozenBalance: u.wallet?.frozenBalance?.toString() ?? '0',
|
||||
lastLoginAt: u.auth?.lastLoginAt ?? null,
|
||||
betCount: bet?.count ?? 0,
|
||||
totalStake: bet?.totalStake ?? '0',
|
||||
totalReturn: bet?.totalReturn ?? '0',
|
||||
createdAt: u.createdAt,
|
||||
updatedAt: u.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadBetStatsMap(userIds: bigint[]) {
|
||||
if (userIds.length === 0) return new Map<string, { count: number; totalStake: string; totalReturn: string }>();
|
||||
|
||||
const groups = await this.prisma.bet.groupBy({
|
||||
by: ['userId'],
|
||||
where: { userId: { in: userIds } },
|
||||
_count: { _all: true },
|
||||
_sum: { stake: true, actualReturn: true },
|
||||
});
|
||||
|
||||
return new Map(
|
||||
groups.map((g) => [
|
||||
g.userId.toString(),
|
||||
{
|
||||
count: g._count._all,
|
||||
totalStake: g._sum.stake?.toString() ?? '0',
|
||||
totalReturn: g._sum.actualReturn?.toString() ?? '0',
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
async findById(id: bigint) {
|
||||
return this.prisma.user.findUnique({
|
||||
@@ -36,19 +104,170 @@ export class UsersService {
|
||||
return { locale };
|
||||
}
|
||||
|
||||
async listPlayers(page = 1, pageSize = 20, parentId?: bigint) {
|
||||
const where = { userType: 'PLAYER', ...(parentId ? { parentId } : {}) };
|
||||
async listPlayers(
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
filters: PlayerListFilters = {},
|
||||
) {
|
||||
const where: {
|
||||
userType: string;
|
||||
deletedAt: null;
|
||||
parentId?: bigint;
|
||||
status?: string;
|
||||
OR?: { username?: { contains: string; mode: 'insensitive' } }[];
|
||||
} = {
|
||||
userType: 'PLAYER',
|
||||
deletedAt: null,
|
||||
};
|
||||
if (filters.parentId) where.parentId = filters.parentId;
|
||||
if (filters.status) where.status = filters.status;
|
||||
if (filters.keyword?.trim()) {
|
||||
const kw = filters.keyword.trim();
|
||||
where.OR = [{ username: { contains: kw, mode: 'insensitive' } }];
|
||||
}
|
||||
|
||||
const skip = (page - 1) * pageSize;
|
||||
const [items, total] = await Promise.all([
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
where,
|
||||
include: { wallet: true },
|
||||
include: {
|
||||
wallet: true,
|
||||
preferences: true,
|
||||
parent: { select: { id: true, username: true } },
|
||||
auth: { select: { lastLoginAt: true } },
|
||||
},
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
this.prisma.user.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
|
||||
const betMap = await this.loadBetStatsMap(rows.map((r) => r.id));
|
||||
return {
|
||||
items: rows.map((u) => this.formatPlayerRow(u, betMap.get(u.id.toString()))),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async getPlayerAdminDetail(playerId: bigint) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||
include: {
|
||||
wallet: true,
|
||||
preferences: true,
|
||||
parent: { select: { id: true, username: true, agentLevel: true } },
|
||||
auth: { select: { lastLoginAt: true, loginFailCount: true, lockedUntil: true } },
|
||||
},
|
||||
});
|
||||
if (!user) throw new NotFoundException('玩家不存在');
|
||||
|
||||
const [betCount, betStake] = await Promise.all([
|
||||
this.prisma.bet.count({ where: { userId: playerId } }),
|
||||
this.prisma.bet.aggregate({
|
||||
where: { userId: playerId },
|
||||
_sum: { stake: true, actualReturn: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
...this.formatPlayerRow(user),
|
||||
lastLoginAt: user.auth?.lastLoginAt ?? null,
|
||||
loginFailCount: user.auth?.loginFailCount ?? 0,
|
||||
lockedUntil: user.auth?.lockedUntil ?? null,
|
||||
betCount,
|
||||
totalStake: betStake._sum.stake?.toString() ?? '0',
|
||||
totalReturn: betStake._sum.actualReturn?.toString() ?? '0',
|
||||
};
|
||||
}
|
||||
|
||||
async updatePlayerAdmin(
|
||||
playerId: bigint,
|
||||
data: {
|
||||
status?: string;
|
||||
locale?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
parentId?: string | null;
|
||||
},
|
||||
) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||
});
|
||||
if (!user) throw new NotFoundException('玩家不存在');
|
||||
|
||||
if (data.status && !['ACTIVE', 'SUSPENDED'].includes(data.status)) {
|
||||
throw new BadRequestException('无效状态');
|
||||
}
|
||||
|
||||
if (data.status) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: playerId },
|
||||
data: { status: data.status },
|
||||
});
|
||||
}
|
||||
|
||||
if (data.parentId !== undefined) {
|
||||
const newParentId =
|
||||
data.parentId === null || data.parentId === ''
|
||||
? null
|
||||
: BigInt(data.parentId);
|
||||
|
||||
if (newParentId !== null) {
|
||||
const parent = await this.prisma.user.findUnique({
|
||||
where: { id: newParentId },
|
||||
});
|
||||
if (!parent || parent.userType !== 'AGENT') {
|
||||
throw new BadRequestException('上级必须为代理账号');
|
||||
}
|
||||
}
|
||||
|
||||
const oldParentId = user.parentId;
|
||||
const changed =
|
||||
(oldParentId?.toString() ?? null) !== (newParentId?.toString() ?? null);
|
||||
|
||||
if (changed) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: playerId },
|
||||
data: { parentId: newParentId },
|
||||
});
|
||||
if (oldParentId) {
|
||||
await this.agents.recalculateUsedCredit(oldParentId);
|
||||
}
|
||||
if (newParentId) {
|
||||
await this.agents.recalculateUsedCredit(newParentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.locale) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: playerId },
|
||||
data: { locale: data.locale },
|
||||
});
|
||||
}
|
||||
|
||||
if (data.phone !== undefined || data.email !== undefined || data.locale) {
|
||||
const phone = data.phone !== undefined ? data.phone?.trim() || null : undefined;
|
||||
const email = data.email !== undefined ? data.email?.trim() || null : undefined;
|
||||
await this.prisma.userPreference.upsert({
|
||||
where: { userId: playerId },
|
||||
create: {
|
||||
userId: playerId,
|
||||
locale: data.locale ?? user.locale,
|
||||
phone: phone ?? null,
|
||||
email: email ?? null,
|
||||
},
|
||||
update: {
|
||||
...(data.locale ? { locale: data.locale } : {}),
|
||||
...(phone !== undefined ? { phone } : {}),
|
||||
...(email !== undefined ? { email } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return this.getPlayerAdminDetail(playerId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export class AuditService {
|
||||
});
|
||||
}
|
||||
|
||||
async list(page = 1, pageSize = 50, module?: string) {
|
||||
async list(page = 1, pageSize = 10, module?: string) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
const where = module ? { module } : {};
|
||||
const [items, total] = await Promise.all([
|
||||
|
||||
BIN
apps/player/src/assets/images/足球赛事下注背景框.png
Normal file
BIN
apps/player/src/assets/images/足球赛事下注背景框.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
@@ -3,6 +3,7 @@ import { ref, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import api from '../../api';
|
||||
import { formatMoney, parseAmount } from '../../utils/localeDisplay';
|
||||
import { teamFlagUrl } from '../../utils/teamFlag';
|
||||
|
||||
export interface OutrightPick {
|
||||
selectionId: string;
|
||||
@@ -30,8 +31,26 @@ const balance = ref(0);
|
||||
const successBalance = ref(0);
|
||||
const successStake = ref(0);
|
||||
|
||||
const flagUrl = computed(() =>
|
||||
props.pick ? teamFlagUrl(props.pick.teamCode, props.pick.teamName) : null,
|
||||
);
|
||||
|
||||
const balanceText = computed(() => formatMoney(balance.value, locale.value));
|
||||
|
||||
const oddsNum = computed(() => {
|
||||
if (!props.pick) return 0;
|
||||
const n = parseFloat(props.pick.odds);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
});
|
||||
|
||||
const estReturn = computed(() => {
|
||||
const s = Number(stake.value);
|
||||
if (!s || s <= 0 || !oddsNum.value) return 0;
|
||||
return s * oddsNum.value;
|
||||
});
|
||||
|
||||
const estReturnText = computed(() => formatMoney(estReturn.value, locale.value));
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
async (v) => {
|
||||
@@ -56,8 +75,20 @@ function genRequestId() {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function setStake(amount: number) {
|
||||
stake.value = Math.max(0.01, Math.round(amount * 100) / 100);
|
||||
}
|
||||
|
||||
function setMaxStake() {
|
||||
if (balance.value > 0) setStake(balance.value);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!props.pick || stake.value <= 0) return;
|
||||
if (stake.value > balance.value) {
|
||||
error.value = t('bet.outright_insufficient');
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
@@ -88,13 +119,26 @@ function formatOdds(odds: string) {
|
||||
|
||||
<template>
|
||||
<div v-if="open && pick" class="overlay" @click.self="close">
|
||||
<div class="modal">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<button type="button" class="close-x" :aria-label="t('bet.cancel')" @click="close">✕</button>
|
||||
|
||||
<template v-if="step === 'form'">
|
||||
<h3 class="title">{{ t('bet.outright_enter_stake') }}</h3>
|
||||
<p class="team">{{ pick.teamName }}</p>
|
||||
<span class="odds-badge">{{ formatOdds(pick.odds) }}</span>
|
||||
<p class="balance-line">{{ t('bet.outright_balance') }}:{{ balanceText }}</p>
|
||||
<p class="hint">{{ t('bet.outright_enter_stake') }}</p>
|
||||
|
||||
<div class="hero">
|
||||
<img v-if="flagUrl" :src="flagUrl" alt="" class="flag" />
|
||||
<p class="team">{{ pick.teamName }}</p>
|
||||
<span class="odds-badge">@ {{ formatOdds(pick.odds) }}</span>
|
||||
</div>
|
||||
|
||||
<p class="event-title">{{ pick.eventTitle }}</p>
|
||||
|
||||
<div class="balance-row">
|
||||
<span class="balance-label">{{ t('bet.outright_balance') }}</span>
|
||||
<span class="balance-value">{{ balanceText }}</span>
|
||||
</div>
|
||||
|
||||
<label class="stake-label">{{ t('bet.stake_label') }}</label>
|
||||
<input
|
||||
v-model.number="stake"
|
||||
type="number"
|
||||
@@ -102,32 +146,63 @@ function formatOdds(odds: string) {
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
inputmode="decimal"
|
||||
:placeholder="t('bet.stake_placeholder')"
|
||||
/>
|
||||
|
||||
<div class="quick-stakes">
|
||||
<button type="button" class="chip" @click="setStake(50)">50</button>
|
||||
<button type="button" class="chip" @click="setStake(100)">100</button>
|
||||
<button type="button" class="chip" @click="setStake(500)">500</button>
|
||||
<button type="button" class="chip" @click="setMaxStake">{{ t('bet.stake_max') }}</button>
|
||||
</div>
|
||||
|
||||
<p class="est-return">
|
||||
{{ t('history.est_return') }}:<span class="est-value">{{ estReturnText }}</span>
|
||||
</p>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-confirm" :disabled="loading" @click="submit">
|
||||
{{ t('bet.place_bet_short') }}
|
||||
</button>
|
||||
<button type="button" class="btn-cancel" :disabled="loading" @click="close">
|
||||
{{ t('bet.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-confirm btn-gold-outline"
|
||||
:disabled="loading || stake <= 0"
|
||||
@click="submit"
|
||||
>
|
||||
{{ loading ? t('bet.placing') : t('bet.place_bet_short') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="success-icon">✓</div>
|
||||
<h3 class="title success-title">{{ t('bet.outright_success') }}</h3>
|
||||
<p class="team">{{ pick.teamName }}</p>
|
||||
<span class="odds-badge">{{ formatOdds(pick.odds) }}</span>
|
||||
<p class="balance-line">
|
||||
{{ t('bet.outright_stake_amount') }} : {{ successStake.toFixed(2) }}
|
||||
</p>
|
||||
<p class="balance-line">
|
||||
{{ t('bet.outright_balance') }} : {{ formatMoney(successBalance, locale) }}
|
||||
</p>
|
||||
<h3 class="success-title">{{ t('bet.outright_success') }}</h3>
|
||||
|
||||
<div class="hero hero--compact">
|
||||
<img v-if="flagUrl" :src="flagUrl" alt="" class="flag" />
|
||||
<p class="team">{{ pick.teamName }}</p>
|
||||
<span class="odds-badge">@ {{ formatOdds(pick.odds) }}</span>
|
||||
</div>
|
||||
|
||||
<p class="event-title">{{ pick.eventTitle }}</p>
|
||||
<button type="button" class="btn-share" disabled>Share</button>
|
||||
<button type="button" class="btn-done" @click="close">{{ t('bet.outright_done') }}</button>
|
||||
|
||||
<div class="summary">
|
||||
<div class="summary-row">
|
||||
<span>{{ t('bet.outright_stake_amount') }}</span>
|
||||
<span>{{ formatMoney(successStake, locale) }}</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span>{{ t('bet.outright_balance') }}</span>
|
||||
<span class="balance-value">{{ formatMoney(successBalance, locale) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-done btn-gold-outline" @click="close">
|
||||
{{ t('bet.outright_done') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,137 +213,266 @@ function formatOdds(odds: string) {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
padding: 20px 18px 16px;
|
||||
max-width: 300px;
|
||||
background: linear-gradient(165deg, #1a1810 0%, #121212 45%, #0a0a0a 100%);
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px 16px 16px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
box-shadow: var(--shadow), 0 0 24px rgba(212, 175, 55, 0.08);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
color: #b8942b;
|
||||
.close-x {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.success-title {
|
||||
margin-top: 8px;
|
||||
.hero--compact {
|
||||
margin-top: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.flag {
|
||||
width: 48px;
|
||||
height: 32px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.team {
|
||||
font-size: 22px;
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
color: #c9a227;
|
||||
margin-bottom: 8px;
|
||||
color: var(--primary-light);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.odds-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
background: #c62828;
|
||||
padding: 3px 10px;
|
||||
background: rgba(198, 40, 40, 0.85);
|
||||
border: 1px solid rgba(255, 120, 120, 0.35);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
border-radius: 3px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.balance-line {
|
||||
font-size: 13px;
|
||||
color: #444;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.4;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.balance-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 12px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.balance-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin: 8px 0 14px;
|
||||
line-height: 1.35;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.balance-value {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.stake-label {
|
||||
display: block;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.stake-input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 2px solid #5b9bd5;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
padding: 11px 12px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: #0a0a0a;
|
||||
color: var(--text);
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.stake-input:focus {
|
||||
border-color: var(--border-gold-soft);
|
||||
box-shadow: 0 0 0 1px rgba(212, 175, 55, 0.15);
|
||||
}
|
||||
|
||||
.stake-input::placeholder {
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.quick-stakes {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
flex: 1;
|
||||
padding: 6px 4px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: #141414;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chip:active {
|
||||
border-color: var(--border-gold-soft);
|
||||
color: var(--primary-light);
|
||||
background: rgba(212, 175, 55, 0.08);
|
||||
}
|
||||
|
||||
.est-return {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.est-value {
|
||||
color: var(--primary-light);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #c62828;
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-confirm {
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
background: #2e9e5e;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
grid-template-columns: 1fr 1.2fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
background: #555;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
border-radius: 6px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-confirm {
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto 4px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #2e9e5e;
|
||||
color: #2e9e5e;
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
line-height: 42px;
|
||||
.btn-confirm:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.btn-share {
|
||||
width: 100%;
|
||||
margin: 12px 0 8px;
|
||||
.success-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin: 0 auto 8px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(46, 158, 94, 0.8);
|
||||
background: rgba(46, 158, 94, 0.12);
|
||||
color: #4ade80;
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
.success-title {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.summary {
|
||||
margin: 12px 0 14px;
|
||||
padding: 10px;
|
||||
border-radius: 20px;
|
||||
background: #1877f2;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
opacity: 0.5;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.summary-row + .summary-row {
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-done {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
background: #1aa89a;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
padding: 11px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
</style>
|
||||
|
||||
2
apps/player/src/router/index.js
Normal file
2
apps/player/src/router/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// 兼容旧缓存/错误解析:转发到 TypeScript 源文件
|
||||
export { default } from './index.ts';
|
||||
37
pnpm-lock.yaml
generated
37
pnpm-lock.yaml
generated
@@ -13,12 +13,18 @@ importers:
|
||||
axios:
|
||||
specifier: ^1.7.9
|
||||
version: 1.16.1
|
||||
echarts:
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0
|
||||
element-plus:
|
||||
specifier: ^2.9.3
|
||||
version: 2.14.1(vue@3.5.35(typescript@5.7.3))
|
||||
vue:
|
||||
specifier: ^3.5.13
|
||||
version: 3.5.35(typescript@5.7.3)
|
||||
vue-echarts:
|
||||
specifier: ^8.0.1
|
||||
version: 8.0.1(echarts@6.1.0)(vue@3.5.35(typescript@5.7.3))
|
||||
vue-router:
|
||||
specifier: ^4.5.0
|
||||
version: 4.6.4(vue@3.5.35(typescript@5.7.3))
|
||||
@@ -2028,6 +2034,9 @@ packages:
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||
|
||||
echarts@6.1.0:
|
||||
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
|
||||
|
||||
ee-first@1.1.1:
|
||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||
|
||||
@@ -3460,6 +3469,9 @@ packages:
|
||||
resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tslib@2.3.0:
|
||||
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@@ -3616,6 +3628,12 @@ packages:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
vue-echarts@8.0.1:
|
||||
resolution: {integrity: sha512-23rJTFLu1OUEGRWjJGmdGt8fP+8+ja1gVgzMYPIPaHWpXegcO1viIAaeu2H4QHESlVeHzUAHIxKXGrwjsyXAaA==}
|
||||
peerDependencies:
|
||||
echarts: ^6.0.0
|
||||
vue: ^3.3.0
|
||||
|
||||
vue-i18n@11.4.4:
|
||||
resolution: {integrity: sha512-gIbXVSFQV4jcSJxfwdZ5zSZmZ+12CnX0K3vBkRSd6Zn+HSzCp+QwUgPwpD/uN0oKNKI9RzlUXPKVedEuMgNG0A==}
|
||||
engines: {node: '>= 22'}
|
||||
@@ -3719,6 +3737,9 @@ packages:
|
||||
resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
zrender@6.1.0:
|
||||
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@angular-devkit/core@19.2.24(chokidar@4.0.3)':
|
||||
@@ -5658,6 +5679,11 @@ snapshots:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
echarts@6.1.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
zrender: 6.1.0
|
||||
|
||||
ee-first@1.1.1: {}
|
||||
|
||||
effect@3.21.0:
|
||||
@@ -7304,6 +7330,8 @@ snapshots:
|
||||
minimist: 1.2.8
|
||||
strip-bom: 3.0.0
|
||||
|
||||
tslib@2.3.0: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
type-detect@4.0.8: {}
|
||||
@@ -7396,6 +7424,11 @@ snapshots:
|
||||
dependencies:
|
||||
vue: 3.5.35(typescript@5.7.3)
|
||||
|
||||
vue-echarts@8.0.1(echarts@6.1.0)(vue@3.5.35(typescript@5.7.3)):
|
||||
dependencies:
|
||||
echarts: 6.1.0
|
||||
vue: 3.5.35(typescript@5.7.3)
|
||||
|
||||
vue-i18n@11.4.4(vue@3.5.35(typescript@5.7.3)):
|
||||
dependencies:
|
||||
'@intlify/core-base': 11.4.4
|
||||
@@ -7529,3 +7562,7 @@ snapshots:
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
yoctocolors-cjs@2.1.3: {}
|
||||
|
||||
zrender@6.1.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
|
||||
Reference in New Issue
Block a user