feat(admin,api,player): 返水流程优化、账单详情与数据库重置

优化返水预览/确认/作废,新增玩家账变详情与后台一键重置为 seed 数据,并修复 dev 启动时 3000 端口占用。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-08 11:14:22 +08:00
parent 24fa1b275c
commit b2216abd0c
24 changed files with 2253 additions and 849 deletions

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import type { TableColumnCtx } from 'element-plus';
import { ElMessage } from 'element-plus';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import { formatAmount, formatAmountFull } from '../utils/format-amount';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import api from '../api';
interface CashbackBatch {
@@ -13,6 +14,12 @@ interface CashbackBatch {
periodEnd: string;
playerCount: number;
totalAmount: string;
totalEffectiveStake?: string;
totalBetCount?: number;
status: string;
operatorUsername?: string | null;
confirmedAt?: string | null;
createdAt: string;
}
interface CashbackPreviewItem {
@@ -20,6 +27,7 @@ interface CashbackPreviewItem {
username: string;
agentUsername: string | null;
effectiveStake: string;
betCount: number;
rate: string;
amount: string;
}
@@ -28,19 +36,35 @@ interface CashbackPreview {
batch: CashbackBatch;
items: CashbackPreviewItem[];
totalAmount: string;
totalEffectiveStake?: string;
totalBetCount?: number;
avgRate?: string;
replacedPreviewCount?: number;
}
const { t } = useAdminLocale();
const { t, localeTag } = useAdminLocale();
const preview = ref<CashbackPreview | null>(null);
const rulesVisible = ref(false);
const loading = ref(false);
const historyLoading = ref(false);
const detailLoading = ref(false);
const detailVisible = ref(false);
const detail = ref<CashbackPreview | null>(null);
const history = ref<CashbackBatch[]>([]);
const historyTotal = ref(0);
const historyPage = ref(1);
const historyPageSize = ref(10);
const historyStatus = ref('');
const period = ref({
start: new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10),
end: new Date().toISOString().slice(0, 10),
});
const previewItems = computed(() => preview.value?.items ?? []);
const detailItems = computed(() => detail.value?.items ?? []);
function formatRate(value: string | number | null | undefined) {
const n = Number(value);
@@ -53,22 +77,75 @@ function formatPeriodDate(value: string) {
return value.slice(0, 10);
}
function formatPeriodRange(row: Pick<CashbackBatch, 'periodStart' | 'periodEnd'>) {
return `${formatPeriodDate(row.periodStart)}${formatPeriodDate(row.periodEnd)}`;
}
function formatTime(value: string | null | undefined) {
if (!value) return '—';
return new Date(value).toLocaleString(localeTag.value, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function statusLabel(status: string) {
const key = `cashback.status.${status}`;
const label = t(key);
return label === key ? status : label;
}
function statusTagType(status: string) {
if (status === 'CONFIRMED') return 'success';
if (status === 'CANCELLED') return 'info';
return 'warning';
}
function apiErrorMessage(err: unknown, fallback: string) {
const msg = (err as { response?: { data?: { message?: string | string[] } } })?.response?.data?.message;
if (Array.isArray(msg)) return msg.join('');
if (typeof msg === 'string' && msg.trim()) return msg;
return fallback;
}
function tableSummary(param: {
columns: TableColumnCtx<CashbackPreviewItem>[];
data: CashbackPreviewItem[];
}) {
const { columns, data } = param;
const stakeSum = data.reduce((s, row) => s + Number(row.effectiveStake), 0);
const betSum = data.reduce((s, row) => s + Number(row.betCount ?? 0), 0);
const amountSum = data.reduce((s, row) => s + Number(row.amount), 0);
return columns.map((col, index) => {
if (index === 0) return t('cashback.table_total');
if (col.property === 'betCount') return String(betSum);
if (col.property === 'effectiveStake') return formatAmount(stakeSum);
if (col.property === 'amount') return formatAmount(amountSum);
return '';
});
}
async function loadHistory() {
historyLoading.value = true;
try {
const { data } = await api.get('/admin/cashbacks', {
params: {
page: historyPage.value,
pageSize: historyPageSize.value,
status: historyStatus.value || undefined,
},
});
history.value = (data.data.items ?? []) as CashbackBatch[];
historyTotal.value = data.data.total ?? 0;
} finally {
historyLoading.value = false;
}
}
async function generatePreview() {
loading.value = true;
try {
@@ -77,17 +154,98 @@ async function generatePreview() {
periodEnd: period.value.end,
});
preview.value = data.data as CashbackPreview;
const replaced = preview.value.replacedPreviewCount ?? 0;
if (replaced > 0) {
ElMessage.warning(t('msg.cashback_preview_replaced', { n: replaced }));
} else {
ElMessage.success(t('msg.cashback_preview_ready'));
}
await loadHistory();
} catch (err) {
ElMessage.error(apiErrorMessage(err, t('msg.error')));
} finally {
loading.value = false;
}
}
async function confirmBatchId(batchId: string) {
try {
await ElMessageBox.confirm(t('cashback.confirm_prompt'), t('common.confirm'), {
type: 'warning',
confirmButtonText: t('cashback.confirm_issue'),
cancelButtonText: t('common.cancel'),
});
} catch {
return;
}
try {
await api.post(`/admin/cashbacks/${batchId}/confirm`);
ElMessage.success(t('msg.cashback_issued'));
if (preview.value?.batch.id === batchId) preview.value = null;
if (detail.value?.batch.id === batchId) detailVisible.value = false;
await loadHistory();
} catch (err) {
ElMessage.error(apiErrorMessage(err, t('msg.error')));
}
}
async function cancelBatchId(batchId: string) {
try {
await ElMessageBox.confirm(t('cashback.cancel_prompt'), t('common.confirm'), {
type: 'warning',
confirmButtonText: t('cashback.cancel_issue'),
cancelButtonText: t('common.cancel'),
});
} catch {
return;
}
try {
await api.post(`/admin/cashbacks/${batchId}/cancel`);
ElMessage.success(t('msg.cashback_cancelled'));
if (preview.value?.batch.id === batchId) preview.value = null;
if (detail.value?.batch.id === batchId) detailVisible.value = false;
await loadHistory();
} catch (err) {
ElMessage.error(apiErrorMessage(err, t('msg.error')));
}
}
async function confirm() {
if (!preview.value?.batch) return;
await api.post(`/admin/cashbacks/${preview.value.batch.id}/confirm`);
ElMessage.success(t('msg.cashback_issued'));
preview.value = null;
await confirmBatchId(preview.value.batch.id);
}
async function openDetail(batchId: string) {
detailLoading.value = true;
detailVisible.value = true;
detail.value = null;
try {
const { data } = await api.get(`/admin/cashbacks/${batchId}`);
detail.value = data.data as CashbackPreview;
} finally {
detailLoading.value = false;
}
}
function onHistoryPageChange(p: number) {
historyPage.value = p;
loadHistory();
}
function onHistorySizeChange(size: number) {
historyPageSize.value = size;
historyPage.value = 1;
loadHistory();
}
function onHistoryStatusChange() {
historyPage.value = 1;
loadHistory();
}
onMounted(loadHistory);
</script>
<template>
@@ -143,29 +301,46 @@ async function confirm() {
<div class="preview-meta">
<span>{{ t('cashback.batch_no') }}{{ preview.batch.batchNo }}</span>
<span class="meta-sep">·</span>
<span>
{{ formatPeriodDate(preview.batch.periodStart) }}
{{ formatPeriodDate(preview.batch.periodEnd) }}
</span>
<span>{{ formatPeriodRange(preview.batch) }}</span>
</div>
</div>
<el-tag :type="statusTagType(preview.batch.status)" size="small">
{{ statusLabel(preview.batch.status) }}
</el-tag>
</div>
<el-row :gutter="20" class="preview-stats">
<el-col :span="8">
<el-row :gutter="12" class="preview-stats">
<el-col :xs="12" :sm="8" :md="4">
<div class="pstat">
<div class="pstat-value">{{ preview.batch.playerCount ?? 0 }}</div>
<div class="pstat-label">{{ t('cashback.stat.players') }}</div>
</div>
</el-col>
<el-col :span="8">
<el-col :xs="12" :sm="8" :md="4">
<div class="pstat">
<div class="pstat-value pstat-green">{{ formatAmount(preview.totalAmount) }}</div>
<div class="pstat-label">{{ t('cashback.stat.total') }}</div>
</div>
</el-col>
<el-col :span="8">
<el-col :xs="12" :sm="8" :md="4">
<div class="pstat">
<div class="pstat-value">{{ formatAmount(preview.totalEffectiveStake ?? 0) }}</div>
<div class="pstat-label">{{ t('cashback.stat.effective_stake') }}</div>
</div>
</el-col>
<el-col :xs="12" :sm="8" :md="4">
<div class="pstat">
<div class="pstat-value">{{ preview.totalBetCount ?? 0 }}</div>
<div class="pstat-label">{{ t('cashback.stat.bet_count') }}</div>
</div>
</el-col>
<el-col :xs="12" :sm="8" :md="4">
<div class="pstat">
<div class="pstat-value">{{ formatRate(preview.avgRate) }}</div>
<div class="pstat-label">{{ t('cashback.stat.avg_rate') }}</div>
</div>
</el-col>
<el-col :xs="12" :sm="8" :md="4">
<div class="pstat">
<div class="pstat-value">{{ previewItems.length }}</div>
<div class="pstat-label">{{ t('cashback.stat.lines') }}</div>
@@ -200,6 +375,12 @@ async function confirm() {
>
<template #default="{ row }">{{ row.agentUsername || '—' }}</template>
</el-table-column>
<el-table-column
prop="betCount"
:label="t('cashback.col.bet_count')"
width="88"
align="right"
/>
<el-table-column
prop="effectiveStake"
:label="t('cashback.col.effective_stake')"
@@ -225,11 +406,173 @@ async function confirm() {
</el-table>
</div>
<el-button type="success" class="confirm-btn" :disabled="previewItems.length === 0" @click="confirm">
{{ t('cashback.confirm_issue') }}
</el-button>
<div v-if="preview.batch.status === 'PREVIEW'" class="preview-actions">
<el-button type="success" :disabled="previewItems.length === 0" @click="confirm">
{{ t('cashback.confirm_issue') }}
</el-button>
<el-button type="danger" plain @click="cancelBatchId(preview.batch.id)">
{{ t('cashback.cancel_issue') }}
</el-button>
</div>
</el-card>
</template>
<el-card class="data-card history-card" shadow="never">
<div class="history-head">
<div class="table-title">{{ t('cashback.history_title') }}</div>
<el-select
v-model="historyStatus"
clearable
:placeholder="t('common.all')"
style="width: 140px"
@change="onHistoryStatusChange"
>
<el-option :label="t('cashback.status.PREVIEW')" value="PREVIEW" />
<el-option :label="t('cashback.status.CONFIRMED')" value="CONFIRMED" />
<el-option :label="t('cashback.status.CANCELLED')" value="CANCELLED" />
</el-select>
</div>
<div class="table-wrap">
<el-table v-loading="historyLoading" :data="history" stripe>
<template #empty>
<AdminTableEmpty :text="t('cashback.history_empty')" />
</template>
<el-table-column prop="batchNo" :label="t('cashback.batch_no')" min-width="140" show-overflow-tooltip />
<el-table-column :label="t('cashback.col.period')" min-width="190">
<template #default="{ row }">{{ formatPeriodRange(row) }}</template>
</el-table-column>
<el-table-column :label="t('cashback.col.status')" width="96" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="playerCount" :label="t('cashback.stat.players')" width="96" align="right" />
<el-table-column prop="totalBetCount" :label="t('cashback.col.bet_count')" width="88" align="right">
<template #default="{ row }">{{ row.totalBetCount ?? 0 }}</template>
</el-table-column>
<el-table-column :label="t('cashback.stat.effective_stake')" min-width="120" align="right">
<template #default="{ row }">{{ formatAmount(row.totalEffectiveStake ?? 0) }}</template>
</el-table-column>
<el-table-column :label="t('cashback.stat.total')" min-width="110" align="right">
<template #default="{ row }">
<span class="amount-cell">{{ formatAmount(row.totalAmount) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('cashback.col.created_at')" min-width="150">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column :label="t('cashback.col.confirmed_at')" min-width="150">
<template #default="{ row }">{{ formatTime(row.confirmedAt) }}</template>
</el-table-column>
<el-table-column :label="t('cashback.col.operator')" min-width="100" show-overflow-tooltip>
<template #default="{ row }">{{ row.operatorUsername || '—' }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row.id)">
{{ t('cashback.view_detail') }}
</el-button>
<template v-if="row.status === 'PREVIEW'">
<el-button link type="success" @click="confirmBatchId(row.id)">
{{ t('cashback.confirm_issue') }}
</el-button>
<el-button link type="danger" @click="cancelBatchId(row.id)">
{{ t('cashback.cancel_issue') }}
</el-button>
</template>
</template>
</el-table-column>
</el-table>
</div>
<div class="pager">
<el-pagination
v-model:current-page="historyPage"
v-model:page-size="historyPageSize"
:total="historyTotal"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
background
@current-change="onHistoryPageChange"
@size-change="onHistorySizeChange"
/>
</div>
</el-card>
<el-dialog
v-model="detailVisible"
:title="t('cashback.detail_title')"
width="920px"
destroy-on-close
>
<div v-loading="detailLoading">
<template v-if="detail">
<div class="detail-meta">
<span>{{ t('cashback.batch_no') }}{{ detail.batch.batchNo }}</span>
<span class="meta-sep">·</span>
<span>{{ formatPeriodRange(detail.batch) }}</span>
<el-tag :type="statusTagType(detail.batch.status)" size="small" class="detail-tag">
{{ statusLabel(detail.batch.status) }}
</el-tag>
</div>
<div class="detail-summary">{{ t('cashback.detail_summary') }}</div>
<el-row :gutter="12" class="preview-stats detail-stats">
<el-col :span="8">
<div class="pstat pstat-compact">
<div class="pstat-value">{{ detail.batch.playerCount ?? 0 }}</div>
<div class="pstat-label">{{ t('cashback.stat.players') }}</div>
</div>
</el-col>
<el-col :span="8">
<div class="pstat pstat-compact">
<div class="pstat-value pstat-green">{{ formatAmount(detail.totalAmount) }}</div>
<div class="pstat-label">{{ t('cashback.stat.total') }}</div>
</div>
</el-col>
<el-col :span="8">
<div class="pstat pstat-compact">
<div class="pstat-value">{{ detail.totalBetCount ?? 0 }}</div>
<div class="pstat-label">{{ t('cashback.stat.bet_count') }}</div>
</div>
</el-col>
</el-row>
<div class="table-wrap">
<el-table
:data="detailItems"
stripe
max-height="360"
show-summary
:summary-method="tableSummary"
>
<el-table-column type="index" :label="t('cashback.col.index')" width="56" align="center" />
<el-table-column prop="username" :label="t('cashback.col.player')" min-width="110" />
<el-table-column prop="agentUsername" :label="t('cashback.col.agent')" min-width="100">
<template #default="{ row }">{{ row.agentUsername || '—' }}</template>
</el-table-column>
<el-table-column prop="betCount" :label="t('cashback.col.bet_count')" width="88" align="right" />
<el-table-column prop="effectiveStake" :label="t('cashback.col.effective_stake')" min-width="110" align="right">
<template #default="{ row }">{{ formatAmount(row.effectiveStake) }}</template>
</el-table-column>
<el-table-column prop="rate" :label="t('cashback.col.rate')" width="96" align="right">
<template #default="{ row }">{{ formatRate(row.rate) }}</template>
</el-table-column>
<el-table-column prop="amount" :label="t('cashback.col.amount')" min-width="110" align="right">
<template #default="{ row }">
<span class="amount-cell">{{ formatAmount(row.amount) }}</span>
</template>
</el-table-column>
</el-table>
</div>
<div v-if="detail?.batch.status === 'PREVIEW'" class="detail-actions">
<el-button type="success" :disabled="detailItems.length === 0" @click="confirmBatchId(detail.batch.id)">
{{ t('cashback.confirm_issue') }}
</el-button>
<el-button type="danger" plain @click="cancelBatchId(detail.batch.id)">
{{ t('cashback.cancel_issue') }}
</el-button>
</div>
</template>
</div>
</el-dialog>
</div>
</template>
@@ -278,25 +621,44 @@ async function confirm() {
margin-bottom: 16px;
}
.preview-head {
.history-card {
margin-bottom: 0;
}
.preview-head,
.history-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.preview-title {
font-size: 15px;
.preview-title,
.table-title {
font-size: 14px;
font-weight: 600;
color: #e0e0e0;
}
.preview-meta {
.preview-meta,
.detail-meta {
margin-top: 6px;
font-size: 12px;
color: #888;
}
.detail-tag {
margin-left: 8px;
vertical-align: middle;
}
.detail-summary {
margin: 12px 0 8px;
font-size: 13px;
color: #aaa;
}
.meta-sep {
margin: 0 6px;
}
@@ -305,16 +667,25 @@ async function confirm() {
margin-bottom: 0;
}
.detail-stats {
margin-bottom: 12px;
}
.pstat {
padding: 16px;
padding: 14px 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid #2a2a2a;
border-radius: 10px;
text-align: center;
margin-bottom: 8px;
}
.pstat-compact {
padding: 12px 10px;
}
.pstat-value {
font-size: 26px;
font-size: 22px;
font-weight: 700;
color: #e0e0e0;
}
@@ -330,13 +701,6 @@ async function confirm() {
margin-top: 4px;
}
.table-title {
font-size: 14px;
font-weight: 600;
color: #e0e0e0;
margin-bottom: 12px;
}
.table-wrap {
overflow-x: auto;
}
@@ -346,7 +710,17 @@ async function confirm() {
font-weight: 600;
}
.confirm-btn {
.preview-actions,
.detail-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
.pager {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>

View File

@@ -1,11 +1,14 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import { resolveFormError } from '../i18n/form-validation';
import api from '../api';
import { clearStaffSession } from '../stores/auth';
const { t, localeTag } = useAdminLocale();
const router = useRouter();
import {
emptyPlayerCreateForm,
emptyPlayerEditForm,
@@ -58,15 +61,63 @@ const bettingLimits = ref({
});
const settingsSaving = ref(false);
const limitsSaving = ref(false);
const resetAllowed = ref(false);
const resetLoading = ref(false);
const resetConfirmPhrase = ref('');
const settingsCollapseOpen = ref<string[]>([]);
onMounted(() => {
loadAgentOptions();
loadPlayerSettings();
loadBettingLimits();
loadResetDatabaseStatus();
load();
});
async function loadResetDatabaseStatus() {
try {
const { data } = await api.get('/admin/system/reset-database');
resetAllowed.value = !!data.data?.allowed;
} catch {
resetAllowed.value = false;
}
}
async function resetDatabase() {
if (resetConfirmPhrase.value !== 'RESET') {
ElMessage.warning(t('user.reset_database_confirm_label'));
return;
}
try {
await ElMessageBox.confirm(t('user.reset_database_hint'), t('user.reset_database'), {
type: 'warning',
confirmButtonText: t('user.reset_database_btn'),
cancelButtonText: t('common.cancel'),
});
} catch {
return;
}
resetLoading.value = true;
try {
const { data } = await api.post('/admin/system/reset-database', {
confirmPhrase: 'RESET',
});
const accounts: string[] = data.data?.demoAccounts ?? [];
ElMessage.success({
message: `${t('user.reset_database_success')}\n${t('user.reset_database_accounts')}: ${accounts.join(' · ')}`,
duration: 8000,
});
clearStaffSession();
await router.push('/login');
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
resetLoading.value = false;
}
}
async function loadBettingLimits() {
try {
const { data } = await api.get('/admin/settings/betting-limits');
@@ -370,6 +421,40 @@ function statusLabel(s: string) {
</el-form-item>
</el-form>
</div>
<div class="list-settings-block list-settings-block--danger">
<p class="list-settings-title">{{ t('user.reset_database') }}</p>
<p class="list-settings-hint">{{ t('user.reset_database_hint') }}</p>
<el-alert
v-if="!resetAllowed"
type="warning"
:closable="false"
show-icon
class="reset-db-alert"
:title="t('user.reset_database_disabled_prod')"
/>
<el-form inline size="small" class="settings-form reset-db-form">
<el-form-item :label="t('user.reset_database_confirm_label')">
<el-input
v-model="resetConfirmPhrase"
:placeholder="t('user.reset_database_confirm_ph')"
style="width: 160px"
:disabled="!resetAllowed"
autocomplete="off"
/>
</el-form-item>
<el-form-item>
<el-button
type="danger"
plain
:loading="resetLoading"
:disabled="!resetAllowed || resetConfirmPhrase !== 'RESET'"
@click="resetDatabase"
>
{{ t('user.reset_database_btn') }}
</el-button>
</el-form-item>
</el-form>
</div>
</el-collapse-item>
</el-collapse>
@@ -793,6 +878,20 @@ function statusLabel(s: string) {
.edit-stats {
margin-top: 4px;
}
.list-settings-block--danger {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed rgba(245, 108, 108, 0.35);
}
.list-settings-hint {
font-size: 12px;
color: #888;
margin: 0 0 10px;
line-height: 1.5;
}
.reset-db-alert {
margin-bottom: 10px;
}
</style>
<style>