feat: 手动充值、邀请码注册与后台管理增强

新增玩家手动充值全流程(收款方式配置、充值下单/审核、钱包上分),
支持邀请码注册、邀请历史与专属返水率;完善后台代理/玩家管理与响应式操作栏,
并补充前台注册、充值页及多语言错误码。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 12:20:11 +08:00
parent 618fb49511
commit 10485ecfaf
98 changed files with 7908 additions and 856 deletions

View File

@@ -86,7 +86,7 @@ const mainTrendOption = computed(() =>
[
{
name: t('dash.chart_stake'),
color: '#248f54',
color: '#d4d4d4',
values: s.value?.trend7d?.map((d) => toNum(d.stake)) ?? [],
},
{
@@ -111,7 +111,7 @@ const distributionOption = computed(() => {
const raw = s.value?.bets.todayByStatus ?? {};
const betColors: Record<string, string> = {
PENDING: '#fb923c',
WON: '#248f54',
WON: '#d4d4d4',
LOST: '#f87171',
VOID: '#6b7280',
REFUNDED: '#60a5fa',
@@ -122,7 +122,7 @@ const distributionOption = computed(() => {
{
label: t('agent_dash.credit_available'),
value: toNum(c.availableCredit),
color: '#248f54',
color: '#d4d4d4',
},
{
label: t('agent_dash.credit_used'),
@@ -142,7 +142,7 @@ const distributionOption = computed(() => {
const playerSegs = p
? [
{ label: t('dash.user_active'), value: p.active, color: '#248f54' },
{ label: t('dash.user_active'), value: p.active, color: '#d4d4d4' },
{ label: t('dash.user_suspended'), value: p.suspended, color: '#f87171' },
].filter((x) => x.value > 0)
: [];
@@ -322,9 +322,10 @@ const kpiSecondary = computed(() => {
}
.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-top: 12px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: #111111;
}
.overview-board :deep(.el-card__body) {
padding: 20px 22px 16px;
@@ -374,23 +375,23 @@ const kpiSecondary = computed(() => {
}
.kpi-cell--link:hover,
.kpi-cell--link:focus-visible {
border-color: rgba(77, 214, 138, 0.35);
background: rgba(36, 143, 84, 0.1);
border-color: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
outline: none;
}
.kpi-label {
display: block;
font-size: 11px;
color: #666;
font-size: 12px;
color: #737373;
margin-bottom: 6px;
}
.kpi-value {
display: block;
font-size: 22px;
font-weight: 800;
color: var(--green-text);
font-weight: 500;
color: #f5f5f5;
line-height: 1.15;
letter-spacing: -0.5px;
letter-spacing: -0.02em;
}
.kpi-value.sm {
font-size: 17px;

View File

@@ -33,6 +33,9 @@ import type { TableInstance } from 'element-plus';
import WalletTransferContext from '../../components/WalletTransferContext.vue';
import AgentCreditContext from '../../components/AgentCreditContext.vue';
import PlayerWalletLedgerDialog from '../../components/PlayerWalletLedgerDialog.vue';
import InviteManageDialog from '../../components/InviteManageDialog.vue';
import AdminPlayerRowActions from '../../components/AdminPlayerRowActions.vue';
import AdminRowActionsDropdown from '../../components/AdminRowActionsDropdown.vue';
import {
depositAmountCap,
parsePlayerAvailable,
@@ -46,6 +49,7 @@ import { formatAgentLevelNumeral } from '../../utils/agent-level-label';
const { t, localeTag } = useAdminLocale();
const auth = useAuthStore();
const inviteDialogOpen = ref(false);
const profile = ref<{
level?: number;
@@ -638,8 +642,14 @@ function statusTagType(s: string) {
</div>
</div>
<!-- Top-level tabs -->
<el-tabs v-model="activeTab" class="portal-top-tabs">
<InviteManageDialog v-model="inviteDialogOpen" />
<div class="portal-tabs-shell">
<el-button type="primary" class="invite-prominent-btn" @click="inviteDialogOpen = true">
{{ t('invite.menu_btn') }}
</el-button>
<!-- Top-level tabs -->
<el-tabs v-model="activeTab" class="portal-top-tabs portal-top-tabs--with-invite">
<!-- Tab: 直属玩家 -->
<el-tab-pane :label="`${t('nav.players')} (${players.length})`" name="players">
<div class="inner-toolbar">
@@ -657,6 +667,12 @@ function statusTagType(s: string) {
<template #empty><div class="empty-hint">{{ t('agent_portal.no_players') }}</div></template>
<el-table-column prop="id" :label="t('common.col_id')" width="60" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
<el-table-column :label="t('user.col.invite_code')" min-width="96" show-overflow-tooltip>
<template #default="{ row }">
<code v-if="row.inviteCode" class="invite-code-cell">{{ row.inviteCode }}</code>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="80" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
@@ -672,19 +688,17 @@ function statusTagType(s: string) {
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('user.col.created')" min-width="148">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="380" align="center" fixed="right">
<el-table-column :label="t('common.actions')" min-width="300" align="center" fixed="right">
<template #default="{ row }">
<div class="action-btns">
<el-button size="small" type="primary" link @click="openEdit(row)">{{ t('common.edit') }}</el-button>
<el-button size="small" type="primary" link @click="openPlayerWalletLedger(row.id, row.username)">{{ t('user.action.view_wallet_ledger') }}</el-button>
<el-button size="small" type="success" link @click="openTransfer('deposit', row)">{{ t('common.topup') }}</el-button>
<el-button size="small" type="warning" link @click="openTransfer('withdraw', row)">{{ t('agent_portal.withdraw_btn_label') }}</el-button>
<el-button v-if="row.status === 'ACTIVE'" size="small" link type="warning" @click="toggleFreeze(row)">{{ t('common.freeze') }}</el-button>
<el-button v-else size="small" link type="primary" @click="toggleFreeze(row)">{{ t('common.unfreeze') }}</el-button>
</div>
<AdminPlayerRowActions
:row="row"
:show-detail="false"
@ledger="openPlayerWalletLedger(row.id, row.username)"
@edit="openEdit(row)"
@deposit="openTransfer('deposit', row)"
@withdraw="openTransfer('withdraw', row)"
@freeze="toggleFreeze(row)"
/>
</template>
</el-table-column>
</el-table>
@@ -729,6 +743,12 @@ function statusTagType(s: string) {
<template #empty><div class="empty-hint">暂无数据</div></template>
<el-table-column prop="id" :label="t('common.col_id')" width="60" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
<el-table-column :label="t('user.col.invite_code')" min-width="96" show-overflow-tooltip>
<template #default="{ row: p }">
<code v-if="p.inviteCode" class="invite-code-cell">{{ p.inviteCode }}</code>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="80" align="center">
<template #default="{ row: p }">
<el-tag :type="statusTagType(p.status)" size="small">{{ statusLabel(p.status) }}</el-tag>
@@ -771,25 +791,22 @@ function statusTagType(s: string) {
<el-table-column :label="t('user.col.created')" min-width="148">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="280" align="center" fixed="right">
<el-table-column :label="t('common.actions')" width="96" align="center" fixed="right">
<template #default="{ row }">
<div class="action-btns" @click.stop>
<el-button size="small" type="primary" link @click="openEditSub(row)">{{ t('common.edit') }}</el-button>
<el-button size="small" type="primary" link @click="openCreditSub(row)">{{ t('common.adjust_credit') }}</el-button>
<el-button
v-if="subAgentAccountStatus(row) === 'ACTIVE'"
size="small"
link
type="warning"
@click="toggleFreezeSub(row)"
>{{ t('common.freeze') }}</el-button>
<el-button v-else size="small" link type="primary" @click="toggleFreezeSub(row)">{{ t('common.unfreeze') }}</el-button>
</div>
<AdminRowActionsDropdown>
<el-dropdown-item @click="openEditSub(row)">{{ t('common.edit') }}</el-dropdown-item>
<el-dropdown-item @click="openCreditSub(row)">{{ t('common.adjust_credit') }}</el-dropdown-item>
<el-dropdown-item v-if="subAgentAccountStatus(row) === 'ACTIVE'" divided @click="toggleFreezeSub(row)">
<span class="action-warning">{{ t('common.freeze') }}</span>
</el-dropdown-item>
<el-dropdown-item v-else divided @click="toggleFreezeSub(row)">{{ t('common.unfreeze') }}</el-dropdown-item>
</AdminRowActionsDropdown>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</div>
<!-- DIALOGS -->
@@ -921,9 +938,6 @@ function statusTagType(s: string) {
<el-input-number v-model="createSubForm.creditLimit" :min="0" :step="1000" style="width: 100%" />
<div class="field-hint">{{ t('agent.hint.credit_limit') }}</div>
</el-form-item>
<el-form-item :label="t('agent.field.cashback_rate')">
<el-input-number v-model="createSubForm.cashbackRate" :min="0" :max="1" :step="0.001" :precision="4" style="width: 100%" />
</el-form-item>
<el-form-item :label="t('agent.field.max_single_deposit')">
<el-input-number v-model="createSubForm.maxSingleDeposit" :min="0" :step="100" style="width: 100%" />
<div class="field-hint">{{ t('agent.hint.deposit_limit_empty') }}</div>
@@ -1029,20 +1043,70 @@ function statusTagType(s: string) {
.credit-item { display: flex; flex-direction: column; gap: 4px; }
.credit-label { font-size: 12px; color: #666; }
.credit-value { font-size: 18px; font-weight: 700; color: #e0e0e0; font-variant-numeric: tabular-nums; }
.credit-value.c-green { color: #67c23a; }
.credit-value.c-green { color: var(--gold-text); }
.credit-divider { width: 1px; height: 32px; background: #2a2a2a; }
/* ─── Tabs ─── */
.portal-top-tabs { margin-bottom: 8px; }
.portal-tabs-shell {
position: relative;
display: flex;
flex-direction: column;
margin-bottom: 8px;
}
.portal-top-tabs { margin-bottom: 0; }
.portal-top-tabs--with-invite :deep(.el-tabs__header) {
margin-bottom: 0;
padding-right: 108px;
}
.portal-top-tabs :deep(.el-tabs__item) { font-size: 14px; }
.invite-prominent-btn {
position: absolute;
right: 0;
top: 0;
z-index: 2;
min-width: 96px;
height: 38px;
padding: 0 22px;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.06em;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(201, 162, 39, 0.22);
}
.invite-code-cell {
font-family: ui-monospace, monospace;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--gold-text);
}
/* ─── Inner content ─── */
.inner-toolbar { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-bottom: 8px; }
.inner-table { border-radius: 6px; }
.expandable-table :deep(.row-expandable) { cursor: pointer; }
.action-btns {
display: flex; align-items: center; justify-content: center;
gap: 4px; flex-wrap: nowrap; white-space: nowrap;
.agent-portal-mgr :deep(.el-table) {
min-width: 720px;
}
.inner-toolbar {
flex-wrap: wrap;
row-gap: 8px;
}
@media (max-width: 900px) {
.portal-top-tabs--with-invite :deep(.el-tabs__header) {
padding-right: 0;
}
.invite-prominent-btn {
position: static;
align-self: flex-end;
margin: 0 0 8px;
}
}
/* ─── Expansion ─── */
@@ -1072,22 +1136,3 @@ function statusTagType(s: string) {
.edit-stats { margin-top: 8px; }
.compact-edit-form :deep(.el-form-item) { margin-bottom: 10px; }
</style>
<style>
/* 代理端冻结按钮:与管理端一致的金黄渐变 */
.agent-portal-mgr .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,.12) inset, 0 1px 6px rgba(0,0,0,.35) !important;
text-shadow: 0 1px 1px rgba(0,0,0,.2);
}
.agent-portal-mgr .el-button.is-link.el-button--warning:hover,
.agent-portal-mgr .el-button.is-link.el-button--warning:focus {
color: #ffffff !important;
background: linear-gradient(165deg, #f0bc62 0%, #d48a28 42%, #a86814 100%) !important;
border-color: rgba(240, 188, 98, 0.55) !important;
}
</style>

View File

@@ -301,9 +301,6 @@ function statusLabel(status: string) {
/>
<div class="field-hint">{{ t('agent_portal.sub_agent_credit_hint') }}</div>
</el-form-item>
<el-form-item :label="t('agent.field.cashback_rate')">
<el-input-number v-model="createForm.cashbackRate" :min="0" :max="1" :step="0.001" :precision="4" style="width: 100%" />
</el-form-item>
<el-form-item :label="t('agent.field.max_single_deposit')">
<el-input-number v-model="createForm.maxSingleDeposit" :min="0" :step="100" style="width: 100%" />
<div class="field-hint">{{ t('agent.hint.deposit_limit_empty') }}</div>
@@ -401,7 +398,7 @@ function statusLabel(status: string) {
font-variant-numeric: tabular-nums;
}
.credit-value.c-green {
color: #67c23a;
color: var(--gold-text);
}
.credit-divider {
width: 1px;

View File

@@ -16,6 +16,7 @@ export interface AgentPlayerRow {
username: string;
status: string;
createdAt: string;
inviteCode?: string | null;
wallet?: { availableBalance: string; frozenBalance?: string };
}

View File

@@ -79,7 +79,6 @@ export function buildAgentSubAgentCreatePayload(form: AgentSubAgentCreateForm) {
username: form.username.trim(),
password: form.password,
creditLimit: form.creditLimit,
cashbackRate: form.cashbackRate,
maxSingleDeposit: form.maxSingleDeposit > 0 ? form.maxSingleDeposit : undefined,
maxDailyDeposit: form.maxDailyDeposit > 0 ? form.maxDailyDeposit : undefined,
};