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

@@ -46,6 +46,14 @@ import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import PlayerWalletLedgerDialog from '../components/PlayerWalletLedgerDialog.vue';
import WalletTransferContext from '../components/WalletTransferContext.vue';
import AgentCreditContext from '../components/AgentCreditContext.vue';
import RatePercentInput from '../components/RatePercentInput.vue';
import { formatRatePercent, percentToDecimalRate, decimalRateToPercent } from '../utils/rate-percent';
import InviteCodePanel from '../components/InviteCodePanel.vue';
import InviteManageDialog from '../components/InviteManageDialog.vue';
import AdminRowActionsDropdown from '../components/AdminRowActionsDropdown.vue';
import AdminPlayerRowActions from '../components/AdminPlayerRowActions.vue';
import AdminDetailGrid from '../components/AdminDetailGrid.vue';
import AdminDetailItem from '../components/AdminDetailItem.vue';
import { useAdminPlayerTransfer } from '../composables/useAdminPlayerTransfer';
import {
fetchAdminAgentCreditContext,
@@ -53,6 +61,8 @@ import {
type AgentCreditAdjustContext,
} from '../utils/agent-credit-context';
const inviteDialogOpen = ref(false);
/* ─── Tier-1 agent list ─── */
const tier1Agents = ref<AgentRow[]>([]);
const tier1Total = ref(0);
@@ -75,6 +85,10 @@ const subAgentLevelState = reactive<Record<number, SubAgentLevelState>>({});
const agentLevelCounts = ref<Record<number, number>>({});
const subAgentTableRefs = ref<Record<number, { toggleRowExpansion: (row: AgentRow) => void } | null>>({});
function setSubAgentTableRef(level: number, el: unknown) {
subAgentTableRefs.value[level] = el as { toggleRowExpansion: (row: AgentRow) => void } | null;
}
function ensureSubAgentState(level: number): SubAgentLevelState {
if (!subAgentLevelState[level]) {
subAgentLevelState[level] = {
@@ -189,7 +203,8 @@ const bettingLimits = ref({
});
const settingsSaving = ref(false);
const limitsSaving = ref(false);
const hierarchySettings = ref({ maxAgentLevel: 0, defaultSubAgentCreditRatio: 50 });
const hierarchySettings = ref({ maxAgentLevel: 0 });
const DEFAULT_SUB_AGENT_CREDIT_RATIO = 50;
const freezeAgentVisible = ref(false);
const freezeAgentLoading = ref(false);
const freezeAgentTarget = ref<AgentRow | null>(null);
@@ -199,6 +214,9 @@ const freezeAgentForm = ref({
unfreezeDirectPlayers: false,
});
const hierarchySaving = ref(false);
const platformDirectRate = ref(0);
const adminInviteRate = ref(0);
const platformDirectSaving = ref(false);
const resetAllowed = ref(false);
const resetLoading = ref(false);
const resetConfirmPhrase = ref('');
@@ -296,7 +314,7 @@ function computeSubAgentCreditByRatio(available: number, ratioPercent: number):
const creditQuickRatios = [10, 15, 20, 30] as const;
function computeDefaultSubAgentCreditLimit(available: number): number {
return computeSubAgentCreditByRatio(available, hierarchySettings.value.defaultSubAgentCreditRatio ?? 50);
return computeSubAgentCreditByRatio(available, DEFAULT_SUB_AGENT_CREDIT_RATIO);
}
function applyCreateSubAgentCreditRatio(ratioPercent: number) {
@@ -370,6 +388,7 @@ onMounted(() => {
loadPlayerSettings();
loadBettingLimits();
loadHierarchySettings();
loadPlatformDirectSettings();
loadResetDatabaseStatus();
loadAgentOptions();
loadAllPlayers();
@@ -453,6 +472,14 @@ function onSubAgentSizeChange(level: number, size: number) {
loadSubAgentsAtLevel(level);
}
function bindSubAgentPageChange(level: number) {
return (p: number) => onSubAgentPageChange(level, p);
}
function bindSubAgentSizeChange(level: number) {
return (size: number) => onSubAgentSizeChange(level, size);
}
function searchSubAgentsAtLevel(level: number) {
ensureSubAgentState(level).page = 1;
loadSubAgentsAtLevel(level);
@@ -537,8 +564,14 @@ function onTier1AgentRowClick(row: AgentRow, _column: unknown, event: MouseEvent
}
function onSubAgentRowClick(level: number, row: AgentRow, _column: unknown, event: MouseEvent) {
const tableRef = { value: subAgentTableRefs.value[level] };
onAgentRowClick(row, tableRef, _column, event);
if (!shouldToggleExpandOnRowClick(event)) return;
subAgentTableRefs.value[level]?.toggleRowExpansion(row);
}
function bindSubAgentRowClick(level: number) {
return (row: AgentRow, column: unknown, event: MouseEvent) => {
onSubAgentRowClick(level, row, column, event);
};
}
watch(activeViewTab, (tab) => {
@@ -682,9 +715,9 @@ async function savePlayerSettings() {
async function loadHierarchySettings() {
try {
const { data } = await api.get('/admin/agents/settings/hierarchy');
hierarchySettings.value = data.data;
hierarchySettings.value = { maxAgentLevel: data.data?.maxAgentLevel ?? 0 };
} catch {
hierarchySettings.value = { maxAgentLevel: 0, defaultSubAgentCreditRatio: 50 };
hierarchySettings.value = { maxAgentLevel: 0 };
}
}
@@ -692,7 +725,7 @@ async function saveHierarchySettings() {
hierarchySaving.value = true;
try {
const { data } = await api.put('/admin/agents/settings/hierarchy', hierarchySettings.value);
hierarchySettings.value = data.data;
hierarchySettings.value = { maxAgentLevel: data.data?.maxAgentLevel ?? hierarchySettings.value.maxAgentLevel };
ElMessage.success(t('msg.saved'));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
@@ -703,6 +736,38 @@ async function saveHierarchySettings() {
}
}
async function loadPlatformDirectSettings() {
try {
const { data } = await api.get('/admin/settings/cashback/platform-direct');
const payload = data.data as { platformDirectRate?: number | string; adminInviteRate?: number | string };
platformDirectRate.value = decimalRateToPercent(payload?.platformDirectRate ?? 0);
adminInviteRate.value = decimalRateToPercent(payload?.adminInviteRate ?? payload?.platformDirectRate ?? 0);
} catch {
platformDirectRate.value = 0;
adminInviteRate.value = 0;
}
}
async function savePlatformDirectSettings() {
platformDirectSaving.value = true;
try {
const { data } = await api.put('/admin/settings/cashback/platform-direct', {
platformDirectRate: percentToDecimalRate(platformDirectRate.value),
adminInviteRate: percentToDecimalRate(adminInviteRate.value),
});
const payload = data.data as { platformDirectRate?: number | string; adminInviteRate?: number | string };
platformDirectRate.value = decimalRateToPercent(payload?.platformDirectRate ?? 0);
adminInviteRate.value = decimalRateToPercent(payload?.adminInviteRate ?? payload?.platformDirectRate ?? 0);
ElMessage.success(t('msg.saved'));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
loadPlatformDirectSettings();
} finally {
platformDirectSaving.value = false;
}
}
const walletLedgerVisible = ref(false);
const walletLedgerPlayerId = ref('');
const walletLedgerPlayerUsername = ref<string | null>(null);
@@ -792,7 +857,7 @@ async function submitCreate() {
phone: createForm.value.phone.trim() || undefined,
email: createForm.value.email.trim() || undefined,
creditLimit: createForm.value.creditLimit,
cashbackRate: createForm.value.cashbackRate,
cashbackRate: percentToDecimalRate(createForm.value.cashbackRate),
maxSingleDeposit: createForm.value.maxSingleDeposit > 0 ? createForm.value.maxSingleDeposit : undefined,
maxDailyDeposit: createForm.value.maxDailyDeposit > 0 ? createForm.value.maxDailyDeposit : undefined,
};
@@ -857,12 +922,16 @@ async function submitEditPlayer() {
editPlayerLoading.value = true;
try {
const newPwd = editPlayerForm.value.newPassword.trim();
const { data } = await api.put(`/admin/users/${editingId.value}`, {
const payload: Record<string, unknown> = {
username: editPlayerForm.value.username.trim(),
phone: editPlayerForm.value.phone.trim() || undefined,
email: editPlayerForm.value.email.trim() || undefined,
password: newPwd || undefined,
});
cashbackRate: editPlayerForm.value.useCustomCashback
? percentToDecimalRate(editPlayerForm.value.customCashbackRate ?? 0)
: null,
};
const { data } = await api.put(`/admin/users/${editingId.value}`, payload);
const updated = data.data as PlayerDetail;
if (newPwd) {
editPlayerForm.value.managedPassword = updated.managedPassword ?? newPwd;
@@ -904,7 +973,7 @@ async function submitEditAgent() {
status: editAgentForm.value.status,
phone: editAgentForm.value.phone.trim() || undefined,
email: editAgentForm.value.email.trim() || undefined,
cashbackRate: editAgentForm.value.cashbackRate,
cashbackRate: percentToDecimalRate(editAgentForm.value.cashbackRate),
maxSingleDeposit: editAgentForm.value.maxSingleDeposit,
maxDailyDeposit: editAgentForm.value.maxDailyDeposit,
password: newPwd || undefined,
@@ -1178,22 +1247,27 @@ function creditTypeLabel(type: string) {
:disabled="hierarchySaving"
/>
</el-form-item>
<el-form-item :label="t('agent.hierarchy.default_sub_credit_ratio')">
<el-input-number
v-model="hierarchySettings.defaultSubAgentCreditRatio"
:min="1"
:max="100"
:step="5"
controls-position="right"
:disabled="hierarchySaving"
/>
<span class="list-settings-unit">%</span>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="hierarchySaving" @click="saveHierarchySettings">{{ t('common.save') }}</el-button>
</el-form-item>
</el-form>
</div>
<div class="list-settings-block">
<p class="list-settings-title">{{ t('cashback.settings_title') }}</p>
<el-form inline size="small" class="settings-form">
<el-form-item :label="t('cashback.platform_direct_default_rate')">
<RatePercentInput v-model="platformDirectRate" />
</el-form-item>
<p class="list-settings-hint block-hint">{{ t('cashback.platform_direct_default_hint') }}</p>
<el-form-item :label="t('cashback.admin_invite_default_rate')">
<RatePercentInput v-model="adminInviteRate" />
</el-form-item>
<p class="list-settings-hint block-hint">{{ t('cashback.admin_invite_default_hint') }}</p>
<el-form-item>
<el-button type="primary" :loading="platformDirectSaving" @click="savePlatformDirectSettings">{{ t('common.save') }}</el-button>
</el-form-item>
</el-form>
</div>
<div class="list-settings-block">
<p class="list-settings-title">{{ t('user.betting_limits') }}</p>
<el-form inline size="small" class="settings-form limits-form">
@@ -1236,7 +1310,13 @@ function creditTypeLabel(type: string) {
</el-collapse-item>
</el-collapse>
<el-tabs v-model="activeViewTab" class="mgr-top-tabs">
<InviteManageDialog v-model="inviteDialogOpen" />
<div class="mgr-tabs-shell">
<el-button type="primary" class="invite-prominent-btn" @click="inviteDialogOpen = true">
{{ t('invite.menu_btn') }}
</el-button>
<el-tabs v-model="activeViewTab" class="mgr-top-tabs mgr-top-tabs--with-invite">
<!-- Tab: 全部玩家默认 -->
<el-tab-pane :label="`${t('user.type.player')} (${playerTotal})`" name="players">
<section class="list-panel player-list-panel">
@@ -1297,6 +1377,12 @@ function creditTypeLabel(type: string) {
<el-tag size="small" type="info" class="affiliation-tag">{{ affiliationLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('user.col.invite_code')" min-width="100" 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('user.col.balance')" min-width="128" align="right">
<template #default="{ row }">
<el-tooltip :content="`${formatAmountFull(row.availableBalance)} / ${formatAmountFull(row.frozenBalance)}`" placement="top">
@@ -1310,32 +1396,17 @@ function creditTypeLabel(type: string) {
<span class="amount-compact">{{ formatAmount(row.totalStake) }} / {{ formatAmount(row.totalReturn) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('user.col.last_login')" width="108">
<el-table-column :label="t('common.actions')" min-width="360" align="center" fixed="right">
<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">{{ t('common.never_login') }}</span>
</template>
</el-table-column>
<el-table-column :label="t('user.col.created')" 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="t('common.actions')" width="420" fixed="right" align="center">
<template #default="{ row }">
<div class="action-btns">
<el-button size="small" link type="primary" @click="openDetailPlayer(row.id)">{{ t('common.detail') }}</el-button>
<el-button size="small" link type="primary" @click="openPlayerWalletLedger(row.id, row.username)">{{ t('user.action.view_wallet_ledger') }}</el-button>
<el-button size="small" link type="primary" @click="openEditPlayer(row.id)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="success" @click="openTransfer('deposit', row)">{{ t('common.topup') }}</el-button>
<el-button size="small" link type="warning" @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="toggleFreezePlayer(row)">{{ t('common.freeze') }}</el-button>
<el-button v-else size="small" link type="primary" @click="toggleFreezePlayer(row)">{{ t('common.unfreeze') }}</el-button>
</div>
<AdminPlayerRowActions
:row="row"
@detail="openDetailPlayer(row.id)"
@ledger="openPlayerWalletLedger(row.id, row.username)"
@edit="openEditPlayer(row.id)"
@deposit="openTransfer('deposit', row)"
@withdraw="openTransfer('withdraw', row)"
@freeze="toggleFreezePlayer(row)"
/>
</template>
</el-table-column>
</el-table>
@@ -1408,6 +1479,12 @@ function creditTypeLabel(type: string) {
<template #empty><AdminTableEmpty /></template>
<el-table-column prop="id" label="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: player }">
<code v-if="player.inviteCode" class="invite-code-cell">{{ player.inviteCode }}</code>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="80">
<template #default="{ row: player }">
<el-tag :type="statusTagType(player.status)" size="small">{{ statusLabel(player.status) }}</el-tag>
@@ -1426,24 +1503,17 @@ function creditTypeLabel(type: string) {
<span class="amount-compact">{{ formatAmount(player.totalStake) }} / {{ formatAmount(player.totalReturn) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('user.col.last_login')" width="100">
<el-table-column :label="t('common.actions')" min-width="320" align="center">
<template #default="{ row: player }">
<el-tooltip v-if="player.lastLoginAt" :content="formatTime(player.lastLoginAt)" placement="top">
<span>{{ formatLastLogin(player.lastLoginAt) }}</span>
</el-tooltip>
<span v-else class="text-muted">{{ t('common.never_login') }}</span>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="340" fixed="right" align="center">
<template #default="{ row: player }">
<div class="action-btns">
<el-button size="small" link type="primary" @click="openDetailPlayer(player.id)">{{ t('common.detail') }}</el-button>
<el-button size="small" link type="primary" @click="openEditPlayer(player.id)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="success" @click="openTransfer('deposit', player)">{{ t('common.topup') }}</el-button>
<el-button size="small" link type="warning" @click="openTransfer('withdraw', player)">{{ t('agent_portal.withdraw_btn_label') }}</el-button>
<el-button v-if="player.status === 'ACTIVE'" size="small" link type="warning" @click="toggleFreezePlayer(player)">{{ t('common.freeze') }}</el-button>
<el-button v-else size="small" link type="primary" @click="toggleFreezePlayer(player)">{{ t('common.unfreeze') }}</el-button>
</div>
<AdminPlayerRowActions
:row="player"
@detail="openDetailPlayer(player.id)"
@ledger="openPlayerWalletLedger(player.id, player.username)"
@edit="openEditPlayer(player.id)"
@deposit="openTransfer('deposit', player)"
@withdraw="openTransfer('withdraw', player)"
@freeze="toggleFreezePlayer(player)"
/>
</template>
</el-table-column>
</el-table>
@@ -1472,18 +1542,22 @@ function creditTypeLabel(type: string) {
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" min-width="72" align="center" />
<el-table-column prop="childAgentCount" :label="t('agent.col.sub_agents')" min-width="72" align="center" />
<el-table-column :label="t('agent.col.cashback')" min-width="80" align="right">
<template #default="{ row }">{{ row.cashbackRate }}</template>
<template #default="{ row }">{{ formatRatePercent(row.cashbackRate) }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="400" fixed="right" align="center">
<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" link type="primary" @click="openDetailAgent(row.userId)">{{ t('common.detail') }}</el-button>
<el-button size="small" link type="primary" @click="openEditAgent(row.userId)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="primary" @click="openCredit(row.userId)">{{ t('common.adjust_credit') }}</el-button>
<el-button v-if="canAgentCreateSub(row)" size="small" link type="primary" @click="openCreateSubAgent(row.userId)">{{ childAgentActionLabel(row.level) }}</el-button>
<el-button v-if="row.status === 'ACTIVE'" size="small" link type="warning" @click="toggleFreezeAgent(row)">{{ t('common.freeze') }}</el-button>
<el-button v-else size="small" link type="primary" @click="toggleFreezeAgent(row)">{{ t('common.unfreeze') }}</el-button>
</div>
<AdminRowActionsDropdown>
<el-dropdown-item @click="openDetailAgent(row.userId)">{{ t('common.detail') }}</el-dropdown-item>
<el-dropdown-item @click="openEditAgent(row.userId)">{{ t('common.edit') }}</el-dropdown-item>
<el-dropdown-item @click="openCredit(row.userId)">{{ t('common.adjust_credit') }}</el-dropdown-item>
<el-dropdown-item v-if="canAgentCreateSub(row)" @click="openCreateSubAgent(row.userId)">
{{ childAgentActionLabel(row.level) }}
</el-dropdown-item>
<el-dropdown-item v-if="row.status === 'ACTIVE'" divided @click="toggleFreezeAgent(row)">
<span class="action-warning">{{ t('common.freeze') }}</span>
</el-dropdown-item>
<el-dropdown-item v-else divided @click="toggleFreezeAgent(row)">{{ t('common.unfreeze') }}</el-dropdown-item>
</AdminRowActionsDropdown>
</template>
</el-table-column>
</el-table>
@@ -1545,14 +1619,14 @@ function creditTypeLabel(type: string) {
</div>
<div class="table-wrap">
<el-table
:ref="(el) => { subAgentTableRefs[agentLevel] = el as { toggleRowExpansion: (row: AgentRow) => void } | null }"
:ref="(el: unknown) => setSubAgentTableRef(agentLevel, el)"
:data="ensureSubAgentState(agentLevel).agents"
stripe
row-key="userId"
:row-class-name="expandableTableRowClassName"
class="expandable-table compact-agent-table"
@expand-change="onExpandChange"
@row-click="(row, column, event) => onSubAgentRowClick(agentLevel, row, column, event)"
@row-click="bindSubAgentRowClick(agentLevel)"
>
<template #empty>
<AdminTableEmpty />
@@ -1570,6 +1644,12 @@ function creditTypeLabel(type: string) {
<template #empty><AdminTableEmpty /></template>
<el-table-column prop="id" label="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: player }">
<code v-if="player.inviteCode" class="invite-code-cell">{{ player.inviteCode }}</code>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="80">
<template #default="{ row: player }">
<el-tag :type="statusTagType(player.status)" size="small">{{ statusLabel(player.status) }}</el-tag>
@@ -1580,14 +1660,17 @@ function creditTypeLabel(type: string) {
<span class="amount-compact">{{ formatAmount(player.availableBalance) }} / {{ formatAmount(player.frozenBalance) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="340" fixed="right" align="center">
<el-table-column :label="t('common.actions')" min-width="280" align="center">
<template #default="{ row: player }">
<div class="action-btns">
<el-button size="small" link type="primary" @click="openDetailPlayer(player.id)">{{ t('common.detail') }}</el-button>
<el-button size="small" link type="primary" @click="openEditPlayer(player.id)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="success" @click="openTransfer('deposit', player)">{{ t('common.topup') }}</el-button>
<el-button size="small" link type="warning" @click="openTransfer('withdraw', player)">{{ t('agent_portal.withdraw_btn_label') }}</el-button>
</div>
<AdminPlayerRowActions
:row="player"
@detail="openDetailPlayer(player.id)"
@ledger="openPlayerWalletLedger(player.id, player.username)"
@edit="openEditPlayer(player.id)"
@deposit="openTransfer('deposit', player)"
@withdraw="openTransfer('withdraw', player)"
@freeze="toggleFreezePlayer(player)"
/>
</template>
</el-table-column>
</el-table>
@@ -1613,16 +1696,20 @@ function creditTypeLabel(type: string) {
</template>
</el-table-column>
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" min-width="72" align="center" />
<el-table-column :label="t('common.actions')" width="400" fixed="right" align="center">
<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" link type="primary" @click="openDetailAgent(row.userId)">{{ t('common.detail') }}</el-button>
<el-button size="small" link type="primary" @click="openEditAgent(row.userId)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="primary" @click="openCredit(row.userId)">{{ t('common.adjust_credit') }}</el-button>
<el-button v-if="canAgentCreateSub(row)" size="small" link type="primary" @click="openCreateSubAgent(row.userId)">{{ childAgentActionLabel(row.level) }}</el-button>
<el-button v-if="subAgentAccountStatus(row) === 'ACTIVE'" size="small" link type="warning" @click="toggleFreezeAgent(row)">{{ t('common.freeze') }}</el-button>
<el-button v-else size="small" link type="primary" @click="toggleFreezeAgent(row)">{{ t('common.unfreeze') }}</el-button>
</div>
<AdminRowActionsDropdown>
<el-dropdown-item @click="openDetailAgent(row.userId)">{{ t('common.detail') }}</el-dropdown-item>
<el-dropdown-item @click="openEditAgent(row.userId)">{{ t('common.edit') }}</el-dropdown-item>
<el-dropdown-item @click="openCredit(row.userId)">{{ t('common.adjust_credit') }}</el-dropdown-item>
<el-dropdown-item v-if="canAgentCreateSub(row)" @click="openCreateSubAgent(row.userId)">
{{ childAgentActionLabel(row.level) }}
</el-dropdown-item>
<el-dropdown-item v-if="subAgentAccountStatus(row) === 'ACTIVE'" divided @click="toggleFreezeAgent(row)">
<span class="action-warning">{{ t('common.freeze') }}</span>
</el-dropdown-item>
<el-dropdown-item v-else divided @click="toggleFreezeAgent(row)">{{ t('common.unfreeze') }}</el-dropdown-item>
</AdminRowActionsDropdown>
</template>
</el-table-column>
</el-table>
@@ -1635,13 +1722,14 @@ function creditTypeLabel(type: string) {
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
background
@current-change="(p) => onSubAgentPageChange(agentLevel, p)"
@size-change="(size) => onSubAgentSizeChange(agentLevel, size)"
@current-change="bindSubAgentPageChange(agentLevel)"
@size-change="bindSubAgentSizeChange(agentLevel)"
/>
</div>
</section>
</el-tab-pane>
</el-tabs>
</div>
<!-- DIALOGS -->
@@ -1798,14 +1886,7 @@ function creditTypeLabel(type: string) {
<div v-else 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="createForm.cashbackRate"
:min="0"
:max="1"
:step="0.001"
:precision="4"
style="width: 100%"
/>
<RatePercentInput v-model="createForm.cashbackRate" />
</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%" />
@@ -1875,6 +1956,20 @@ function creditTypeLabel(type: string) {
<el-tag size="small" type="info" class="affiliation-tag">{{ affiliationLabel(editPlayerForm) }}</el-tag>
<div class="field-hint">{{ t('user.hint.agent_readonly') }}</div>
</el-form-item>
<el-form-item :label="t('agent.field.cashback_rate')">
<div class="cashback-edit-block">
<el-checkbox v-model="editPlayerForm.useCustomCashback">
{{ t('cashback.use_custom_rate') }}
</el-checkbox>
<RatePercentInput
v-if="editPlayerForm.useCustomCashback"
v-model="editPlayerForm.customCashbackRate"
/>
<p v-else class="field-hint block-hint">
{{ t('cashback.use_default_rate', { rate: `${editPlayerForm.defaultCashbackRate.toFixed(2)}%` }) }}
</p>
</div>
</el-form-item>
<el-form-item :label="t('user.field.phone')">
<el-input v-model="editPlayerForm.phone" :placeholder="t('common.optional')" />
</el-form-item>
@@ -1928,7 +2023,7 @@ function creditTypeLabel(type: string) {
</el-radio-group>
</el-form-item>
<el-form-item :label="t('agent.field.cashback_rate')">
<el-input-number v-model="editAgentForm.cashbackRate" :min="0" :max="1" :step="0.001" :precision="4" style="width: 100%" />
<RatePercentInput v-model="editAgentForm.cashbackRate" />
</el-form-item>
<el-form-item :label="t('agent.field.max_single_deposit')">
<el-input-number v-model="editAgentForm.maxSingleDeposit" :min="0" :step="100" style="width: 100%" />
@@ -2026,38 +2121,46 @@ function creditTypeLabel(type: string) {
</el-dialog>
<!-- Player Detail -->
<el-dialog v-model="detailPlayerVisible" :title="t('user.dialog.detail')" width="560px" destroy-on-close>
<el-dialog v-model="detailPlayerVisible" :title="t('user.dialog.detail')" width="780px" destroy-on-close class="entity-detail-dialog">
<template v-if="playerDetail">
<el-descriptions :column="2" border size="small">
<el-descriptions-item :label="t('common.col_id')">{{ playerDetail.id }}</el-descriptions-item>
<el-descriptions-item :label="t('user.col.username')">{{ playerDetail.username }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.current_password')">{{ playerDetail.managedPassword ?? '—' }}</el-descriptions-item>
<el-descriptions-item v-if="!playerDetail.managedPassword" :span="2">
<span class="field-hint">{{ t('user.hint.password_reset_to_view') }}</span>
</el-descriptions-item>
<el-descriptions-item :label="t('common.status')">
<AdminDetailGrid :columns="3">
<AdminDetailItem :label="t('common.col_id')">{{ playerDetail.id }}</AdminDetailItem>
<AdminDetailItem :label="t('user.col.username')">{{ playerDetail.username }}</AdminDetailItem>
<AdminDetailItem :label="t('common.status')">
<el-tag :type="statusTagType(playerDetail.status)" size="small">{{ statusLabel(playerDetail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item :label="t('user.col.agent')">{{ affiliationLabel(playerDetail) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.available')">
</AdminDetailItem>
<AdminDetailItem :label="t('user.col.agent')">{{ affiliationLabel(playerDetail) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.col.invite_code')">{{ playerDetail.inviteCode ?? '—' }}</AdminDetailItem>
<AdminDetailItem :label="t('agent.field.cashback_rate')">
{{
playerDetail.customCashbackRate != null
? formatRatePercent(playerDetail.customCashbackRate)
: t('cashback.use_default_rate', { rate: formatRatePercent(playerDetail.defaultCashbackRate) })
}}
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.available')">
{{ formatAmount(playerDetail.availableBalance) }}
<span v-if="shouldCompact(playerDetail.availableBalance)" class="amount-full-hint">{{ formatAmountFull(playerDetail.availableBalance) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="t('user.field.frozen_balance')">
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.frozen_balance')">
{{ formatAmount(playerDetail.frozenBalance) }}
<span v-if="shouldCompact(playerDetail.frozenBalance)" class="amount-full-hint">{{ formatAmountFull(playerDetail.frozenBalance) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="t('user.field.phone')">{{ playerDetail.phone ?? '—' }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.email')">{{ playerDetail.email ?? '—' }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.bet_count')">{{ playerDetail.betCount }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.total_stake')">{{ formatAmount(playerDetail.totalStake) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.total_payout')">{{ formatAmount(playerDetail.totalReturn) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.col.last_login')">
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.bet_count')">{{ playerDetail.betCount }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.total_stake')">{{ formatAmount(playerDetail.totalStake) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.total_payout')">{{ formatAmount(playerDetail.totalReturn) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.phone')">{{ playerDetail.phone ?? '—' }}</AdminDetailItem>
<AdminDetailItem :label="t('user.col.last_login')">
{{ playerDetail.lastLoginAt ? formatTime(playerDetail.lastLoginAt) : t('common.never_login') }}
</el-descriptions-item>
<el-descriptions-item :label="t('user.field.login_fail')">{{ t('user.login_fail_value', { n: playerDetail.loginFailCount }) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.registered_at')" :span="2">{{ formatTime(playerDetail.createdAt) }}</el-descriptions-item>
</el-descriptions>
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.login_fail')">{{ t('user.login_fail_value', { n: playerDetail.loginFailCount }) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.email')">{{ playerDetail.email ?? '—' }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.registered_at')">{{ formatTime(playerDetail.createdAt) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.current_password')" :span="2">
{{ playerDetail.managedPassword ?? '—' }}
<span v-if="!playerDetail.managedPassword" class="admin-detail-hint">{{ t('user.hint.password_reset_to_view') }}</span>
</AdminDetailItem>
</AdminDetailGrid>
<div class="detail-actions">
<el-button type="primary" link @click="openPlayerWalletLedger(playerDetail.id, playerDetail.username)">
{{ t('user.action.view_wallet_ledger') }}
@@ -2092,7 +2195,7 @@ function creditTypeLabel(type: string) {
<el-descriptions-item :label="t('agent.col.sub_agents')">{{ agentDetail.childAgentCount }} {{ t('common.people') }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.field.player_liability')">{{ formatAmount(agentDetail.directPlayerLiability) }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.field.sub_agent_exposure')">{{ formatAmount(agentDetail.childAgentExposure) }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.col.cashback')">{{ agentDetail.cashbackRate }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.col.cashback')">{{ formatRatePercent(agentDetail.cashbackRate) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.phone')">{{ agentDetail.phone ?? '—' }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.email')">{{ agentDetail.email ?? '—' }}</el-descriptions-item>
<el-descriptions-item :label="t('user.col.last_login')" :span="2">
@@ -2101,6 +2204,8 @@ function creditTypeLabel(type: string) {
<el-descriptions-item :label="t('agent.col.created')" :span="2">{{ formatTime(agentDetail.createdAt) }}</el-descriptions-item>
</el-descriptions>
<InviteCodePanel :invite-code="agentDetail.inviteCode" compact readonly class="detail-invite" />
<div class="section-title section-title--row">
<span>{{ t('agent.section.credit_log') }}</span>
<el-button
@@ -2147,18 +2252,57 @@ function creditTypeLabel(type: string) {
</template>
<style scoped>
.agent-mgr-page > .agent-list-panel,
.agent-mgr-page > .mgr-top-tabs {
flex: 1;
min-height: 0;
}
.agent-mgr-page > .mgr-top-tabs :deep(.el-tabs__content) {
.mgr-tabs-shell {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.agent-mgr-page > .mgr-top-tabs :deep(.el-tab-pane) {
.mgr-top-tabs--with-invite :deep(.el-tabs__header) {
padding-right: 108px;
}
.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(64, 158, 255, 0.28);
}
.agent-mgr-page > .mgr-tabs-shell {
flex: 1;
min-height: 0;
}
.mgr-tabs-shell .mgr-top-tabs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.agent-mgr-page > .agent-list-panel,
.agent-mgr-page > .mgr-tabs-shell .mgr-top-tabs {
flex: 1;
min-height: 0;
}
.agent-mgr-page > .mgr-tabs-shell .mgr-top-tabs :deep(.el-tabs__content) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.agent-mgr-page > .mgr-tabs-shell .mgr-top-tabs :deep(.el-tab-pane) {
flex: 1;
min-height: 0;
display: flex;
@@ -2195,14 +2339,21 @@ function creditTypeLabel(type: string) {
.list-panel-toolbar {
flex-shrink: 0;
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 10px 0 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
--list-chrome-control-h: 32px;
--el-component-size: 32px;
}
.list-panel-toolbar .list-chrome__grow {
flex: 1 1 280px;
min-width: 0;
flex-wrap: wrap;
row-gap: 8px;
}
.list-panel-toolbar .list-chrome__actions {
flex-shrink: 0;
}
@@ -2293,7 +2444,7 @@ function creditTypeLabel(type: string) {
font-weight: 700;
}
.inner-tabs :deep(.el-tabs__active-bar) {
background-color: var(--green-bright);
background-color: var(--gold-bright);
height: 2px;
}
.inner-tabs :deep(.el-tabs__content) {
@@ -2309,13 +2460,29 @@ function creditTypeLabel(type: string) {
font-size: 13px;
font-weight: 600;
}
.action-btns {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
flex-wrap: nowrap;
white-space: nowrap;
.player-list-panel .table-wrap :deep(.el-table),
.agent-list-panel .table-wrap :deep(.el-table) {
min-width: 880px;
}
.expand-panel .inner-table {
width: 100%;
}
.expand-panel :deep(.el-table__body-wrapper) {
overflow-x: auto;
}
@media (max-width: 900px) {
.mgr-top-tabs--with-invite :deep(.el-tabs__header) {
padding-right: 0;
}
.invite-prominent-btn {
position: static;
align-self: flex-end;
margin: 0 0 8px;
}
}
/* ─── Inherited from old pages ─── */
@@ -2388,8 +2555,15 @@ function creditTypeLabel(type: string) {
grid-template-columns: 1fr 1fr;
gap: 0 10px;
}
.c-green { color: #2fb56a; }
.c-green { color: var(--gold-text); }
.amount-compact { white-space: nowrap; font-variant-numeric: tabular-nums; cursor: default; }
.invite-code-cell {
font-family: ui-monospace, monospace;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--el-color-primary);
}
.affiliation-tag {
max-width: 100%;
overflow: hidden;
@@ -2464,22 +2638,3 @@ function creditTypeLabel(type: string) {
gap: 8px;
}
</style>
<style>
/* 玩家列表「冻结」:橙黄底白字 */
.agent-mgr-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);
}
.agent-mgr-page .el-button.is-link.el-button--warning:hover,
.agent-mgr-page .el-button.is-link.el-button--warning:focus {
color: #ffffff !important;
background: linear-gradient(165deg, #f0bc62 0%, #d48a28 42%, #a86814 100%) !important;
border-color: rgba(240, 188, 98, 0.55) !important;
}
</style>

View File

@@ -26,7 +26,10 @@ import {
shouldCompactAmount as shouldCompact,
} from '../utils/format-amount';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import InviteCodePanel from '../components/InviteCodePanel.vue';
import RatePercentInput from '../components/RatePercentInput.vue';
import AgentCreditContext from '../components/AgentCreditContext.vue';
import { formatRatePercent, percentToDecimalRate } from '../utils/rate-percent';
import {
fetchAdminAgentCreditContext,
maxCreditIncreaseAmount,
@@ -186,7 +189,7 @@ async function submitEdit() {
status: editForm.value.status,
phone: editForm.value.phone.trim() || undefined,
email: editForm.value.email.trim() || undefined,
cashbackRate: editForm.value.cashbackRate,
cashbackRate: percentToDecimalRate(editForm.value.cashbackRate),
});
ElMessage.success(t('msg.saved'));
editVisible.value = false;
@@ -298,7 +301,7 @@ function creditTypeLabel(type: string) {
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" width="96" align="center" />
<el-table-column prop="childAgentCount" :label="t('agent.col.sub_agents')" width="96" align="center" />
<el-table-column :label="t('agent.col.cashback')" width="88" align="right">
<template #default="{ row }">{{ row.cashbackRate }}</template>
<template #default="{ row }">{{ formatRatePercent(row.cashbackRate) }}</template>
</el-table-column>
<el-table-column prop="phone" :label="t('agent.col.phone')" min-width="110">
<template #default="{ row }">{{ row.phone ?? '—' }}</template>
@@ -361,14 +364,7 @@ function creditTypeLabel(type: string) {
<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="createForm.cashbackRate"
:min="0"
:max="1"
:step="0.001"
:precision="4"
style="width: 100%"
/>
<RatePercentInput v-model="createForm.cashbackRate" />
<div class="field-hint">{{ t('agent.hint.cashback_example') }}</div>
</el-form-item>
<el-form-item :label="t('user.field.phone')">
@@ -393,14 +389,7 @@ function creditTypeLabel(type: string) {
</el-radio-group>
</el-form-item>
<el-form-item :label="t('agent.field.cashback_rate')">
<el-input-number
v-model="editForm.cashbackRate"
:min="0"
:max="1"
:step="0.001"
:precision="4"
style="width: 100%"
/>
<RatePercentInput v-model="editForm.cashbackRate" />
</el-form-item>
<el-form-item :label="t('user.field.phone')">
<el-input v-model="editForm.phone" />
@@ -475,7 +464,7 @@ function creditTypeLabel(type: string) {
<el-descriptions-item :label="t('agent.field.sub_agent_exposure')">
{{ formatAmount(detail.childAgentExposure) }}
</el-descriptions-item>
<el-descriptions-item :label="t('agent.col.cashback')">{{ detail.cashbackRate }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.col.cashback')">{{ formatRatePercent(detail.cashbackRate) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.phone')">{{ detail.phone ?? '—' }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.email')">{{ detail.email ?? '—' }}</el-descriptions-item>
<el-descriptions-item :label="t('user.col.last_login')" :span="2">
@@ -486,6 +475,8 @@ function creditTypeLabel(type: string) {
</el-descriptions-item>
</el-descriptions>
<InviteCodePanel :invite-code="detail.inviteCode" compact readonly class="detail-invite" />
<div class="section-title section-title--row">
<span>{{ t('agent.section.credit_log') }}</span>
<el-button

View File

@@ -4,6 +4,7 @@ import type { TableColumnCtx } from 'element-plus';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import { formatAmount, formatAmountFull } from '../utils/format-amount';
import { formatRatePercent } from '../utils/rate-percent';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import { resolveApiError } from '../i18n/form-validation';
import api from '../api';
@@ -69,9 +70,7 @@ const previewItems = computed(() => preview.value?.items ?? []);
const detailItems = computed(() => detail.value?.items ?? []);
function formatRate(value: string | number | null | undefined) {
const n = Number(value);
if (!Number.isFinite(n)) return '—';
return `${(n * 100).toFixed(2)}%`;
return formatRatePercent(value);
}
function formatPeriodDate(value: string) {
@@ -613,9 +612,9 @@ onMounted(loadHistory);
}
.rules-help-btn:hover {
color: var(--green-glow);
border-color: rgba(47, 181, 106, 0.45);
background: rgba(47, 181, 106, 0.08);
color: #f5f5f5;
border-color: rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.04);
}
.rules-list {
@@ -708,7 +707,7 @@ onMounted(loadHistory);
.pstat-green {
color: var(--green-glow);
text-shadow: 0 0 20px rgba(47, 181, 106, 0.35);
text-shadow: none;
}
.pstat-label {

View File

@@ -835,15 +835,15 @@ void load();
font-size: 12px;
font-family: inherit;
cursor: pointer;
border: 1px solid rgba(47, 181, 106, 0.5);
background: rgba(47, 181, 106, 0.1);
color: #2fb56a;
border: 1px solid rgba(212, 175, 55, 0.5);
background: rgba(212, 175, 55, 0.1);
color: var(--gold-text);
font-weight: 600;
transition: all 0.15s;
}
.banner-upload-btn:hover {
background: rgba(47, 181, 106, 0.2);
background: rgba(212, 175, 55, 0.2);
}
.banner-upload-btn.is-uploading {
@@ -905,8 +905,8 @@ void load();
}
.media-picker-card:hover {
border-color: rgba(47, 181, 106, 0.5);
box-shadow: 0 2px 12px rgba(47, 181, 106, 0.15);
border-color: rgba(212, 175, 55, 0.5);
box-shadow: 0 2px 12px rgba(212, 175, 55, 0.15);
}
.media-picker-thumb {

View File

@@ -8,6 +8,7 @@ import EChartPanel from '../components/dashboard/EChartPanel.vue';
import { buildCombinedTrendOption, buildTriplePieOption } from '../utils/dashboard-charts';
import { betStatusLabel } from '../utils/bet-labels';
import { useAdminLocale } from '../composables/useAdminLocale';
import InviteCodePanel from '../components/InviteCodePanel.vue';
const { t, locale, localeTag } = useAdminLocale();
const router = useRouter();
@@ -86,7 +87,7 @@ const mainTrendOption = computed(() =>
[
{
name: t('dash.chart_stake'),
color: '#248f54',
color: '#d4d4d4',
values: s.value?.trend7d?.map((d) => toNum(d.stake)) ?? [],
},
{
@@ -111,7 +112,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',
@@ -120,7 +121,7 @@ const distributionOption = computed(() => {
const matchSegs = m
? [
{ label: t('dash.match_draft'), value: m.draft, color: '#6b7280' },
{ label: t('dash.match_published'), value: m.published, color: '#248f54' },
{ label: t('dash.match_published'), value: m.published, color: '#d4d4d4' },
{ label: t('dash.match_closed'), value: m.closed, color: '#60a5fa' },
{ label: t('dash.match_pending_settle'), value: m.pendingSettlement, color: '#fb923c' },
{ label: t('dash.match_settled'), value: m.settled ?? 0, color: '#5eead4' },
@@ -137,7 +138,7 @@ const distributionOption = computed(() => {
const userSegs = u
? [
{ label: t('dash.user_active'), value: u.playersActive, color: '#248f54' },
{ label: t('dash.user_active'), value: u.playersActive, color: '#d4d4d4' },
{ label: t('dash.user_suspended'), value: u.playersSuspended, color: '#f87171' },
{ label: t('dash.user_direct'), value: u.playersDirect, color: '#60a5fa' },
{ label: t('dash.user_agents'), value: u.agentsTotal, color: '#a78bfa' },
@@ -231,6 +232,7 @@ const kpiSecondary = computed(() => {
</el-card>
<template v-else-if="s">
<InviteCodePanel auto-load class="invite-board" />
<el-card class="overview-board" shadow="never">
<div v-if="s.generatedAt" class="board-head">
<span class="dash-updated">
@@ -301,10 +303,14 @@ const kpiSecondary = computed(() => {
margin-bottom: 14px;
}
.invite-board {
margin-bottom: 12px;
}
.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);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: #111111;
margin-bottom: 28px;
}
.overview-board :deep(.el-card__body) {
@@ -348,23 +354,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

@@ -0,0 +1,111 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAdminLocale } from '../composables/useAdminLocale';
import DepositOrders from './DepositOrders.vue';
import PaymentMethods from './PaymentMethods.vue';
const { t } = useAdminLocale();
const route = useRoute();
const router = useRouter();
const tabs = [
{ key: 'orders', label: () => t('deposit.deposit_orders_title') },
{ key: 'methods', label: () => t('deposit.payment_methods_title') },
] as const;
function getTabFromQuery(): string {
return route.query.tab === 'methods' ? 'methods' : 'orders';
}
const activeTab = ref(getTabFromQuery());
watch(() => route.query.tab, (val) => {
activeTab.value = val === 'methods' ? 'methods' : 'orders';
});
function switchTab(key: string) {
activeTab.value = key;
router.replace({ query: { ...route.query, tab: key } });
}
</script>
<template>
<div class="page-deposit-manage">
<div class="tab-bar">
<button
v-for="tab in tabs"
:key="tab.key"
class="tab-btn"
:class="{ active: activeTab === tab.key }"
@click="switchTab(tab.key)"
>
{{ tab.label() }}
</button>
</div>
<DepositOrders v-if="activeTab === 'orders'" />
<PaymentMethods v-if="activeTab === 'methods'" />
</div>
</template>
<style scoped>
.page-deposit-manage {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.tab-bar {
display: flex;
gap: 0;
border-bottom: 1px solid #2a2a2a;
margin-bottom: 0;
flex-shrink: 0;
}
.tab-btn {
position: relative;
padding: 10px 20px;
background: none;
border: none;
color: #888;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: color 0.15s;
letter-spacing: 0.02em;
}
.tab-btn:hover {
color: #ccc;
}
.tab-btn.active {
color: #fff;
}
.tab-btn.active::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: var(--primary, #409eff);
border-radius: 2px 2px 0 0;
}
.page-deposit-manage :deep(.page-deposit-orders),
.page-deposit-manage :deep(.page-payment-methods) {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.page-deposit-manage :deep(.page-deposit-orders .toolbar h2),
.page-deposit-manage :deep(.page-payment-methods .toolbar h2) {
display: none;
}
</style>

View File

@@ -0,0 +1,331 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import { formatAmount } from '../utils/format-amount';
const { t } = useAdminLocale();
interface DepositOrderRow {
id: string;
orderNo: string;
playerId: string;
playerUsername: string | null;
methodType: string;
amount: string;
screenshotUrl: string;
status: string;
approvedAmount: string | null;
reviewerId: string | null;
reviewerUsername: string | null;
rejectReason: string | null;
remark: string | null;
createdAt: string;
reviewedAt: string | null;
paymentMethodName: string | null;
}
const items = ref<DepositOrderRow[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const loading = ref(false);
// Filters
const statusFilter = ref('');
const keywordFilter = ref('');
const methodTypeFilter = ref('');
// Approve dialog
const approveDialogVisible = ref(false);
const approveTarget = ref<DepositOrderRow | null>(null);
const approveAmount = ref<number>(0);
const approveRemark = ref('');
// Reject dialog
const rejectDialogVisible = ref(false);
const rejectTarget = ref<DepositOrderRow | null>(null);
const rejectReason = ref('');
// Screenshot preview
const previewUrl = ref('');
const previewVisible = ref(false);
async function fetchList() {
loading.value = true;
try {
const params: any = { page: page.value, pageSize: pageSize.value };
if (statusFilter.value) params.status = statusFilter.value;
if (keywordFilter.value) params.keyword = keywordFilter.value;
if (methodTypeFilter.value) params.methodType = methodTypeFilter.value;
const { data } = await api.get('/admin/deposit-orders', { params });
const result = data.data ?? { items: [], total: 0 };
items.value = result.items ?? [];
total.value = result.total ?? 0;
} catch { /* */ } finally {
loading.value = false;
}
}
function openApprove(row: DepositOrderRow) {
approveTarget.value = row;
approveAmount.value = parseFloat(row.amount);
approveRemark.value = '';
approveDialogVisible.value = true;
}
function openReject(row: DepositOrderRow) {
rejectTarget.value = row;
rejectReason.value = '';
rejectDialogVisible.value = true;
}
function openScreenshot(url: string) {
previewUrl.value = url;
previewVisible.value = true;
}
async function handleApprove() {
if (!approveTarget.value) return;
try {
await api.post(`/admin/deposit-orders/${approveTarget.value.id}/approve`, {
approvedAmount: approveAmount.value,
remark: approveRemark.value || undefined,
});
approveDialogVisible.value = false;
await fetchList();
} catch (e: any) {
alert(e.response?.data?.message || 'Error');
}
}
async function handleReject() {
if (!rejectTarget.value) return;
if (!rejectReason.value.trim()) {
alert(t('deposit.reason_required'));
return;
}
try {
await api.post(`/admin/deposit-orders/${rejectTarget.value.id}/reject`, {
reason: rejectReason.value.trim(),
});
rejectDialogVisible.value = false;
await fetchList();
} catch (e: any) {
alert(e.response?.data?.message || 'Error');
}
}
function statusClass(s: string) {
if (s === 'APPROVED') return 'status-approved';
if (s === 'REJECTED') return 'status-rejected';
return 'status-pending';
}
function statusLabel(s: string) {
if (s === 'APPROVED') return '✓ ' + t('deposit.status_approved');
if (s === 'REJECTED') return '✗ ' + t('deposit.status_rejected');
return '● ' + t('deposit.status_pending');
}
function prevPage() { if (page.value > 1) { page.value--; fetchList(); } }
function nextPage() { if (page.value * pageSize.value < total.value) { page.value++; fetchList(); } }
onMounted(fetchList);
</script>
<template>
<div class="page-deposit-orders">
<div class="toolbar">
<h2>{{ t('deposit.deposit_orders_title') }}</h2>
</div>
<div class="filters">
<select v-model="statusFilter" @change="page = 1; fetchList()">
<option value="">{{ t('deposit.all_status') }}</option>
<option value="PENDING">{{ t('deposit.status_pending') }}</option>
<option value="APPROVED">{{ t('deposit.status_approved') }}</option>
<option value="REJECTED">{{ t('deposit.status_rejected') }}</option>
</select>
<select v-model="methodTypeFilter" @change="page = 1; fetchList()">
<option value="">{{ t('deposit.all_types') }}</option>
<option value="BANK">Bank</option>
<option value="USDT">USDT</option>
</select>
<input
v-model="keywordFilter"
:placeholder="t('deposit.search_player_ph')"
@keydown.enter="page = 1; fetchList()"
/>
<button class="btn-search" @click="page = 1; fetchList()">{{ t('common.search') }}</button>
</div>
<AdminTableEmpty v-if="!loading && !items.length" />
<table v-if="items.length" class="data-table">
<thead>
<tr>
<th>{{ t('deposit.order_no') }}</th>
<th>{{ t('deposit.player') }}</th>
<th>{{ t('common.type') }}</th>
<th>{{ t('deposit.amount') }}</th>
<th>{{ t('deposit.screenshot') }}</th>
<th>{{ t('common.status') }}</th>
<th>{{ t('deposit.approved_amount') }}</th>
<th>{{ t('deposit.reviewer') }}</th>
<th>{{ t('deposit.time') }}</th>
<th>{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in items" :key="row.id">
<td class="mono">{{ row.orderNo }}</td>
<td>{{ row.playerUsername || row.playerId }}</td>
<td><span :class="['badge', row.methodType === 'BANK' ? 'badge-blue' : 'badge-green']">{{ row.methodType }}</span></td>
<td class="amount">{{ formatAmount(row.amount) }}</td>
<td>
<img
v-if="row.screenshotUrl"
:src="row.screenshotUrl"
class="screenshot-thumb"
@click="openScreenshot(row.screenshotUrl)"
/>
</td>
<td><span :class="statusClass(row.status)">{{ statusLabel(row.status) }}</span></td>
<td>{{ row.approvedAmount ? formatAmount(row.approvedAmount) : '-' }}</td>
<td>{{ row.reviewerUsername || '-' }}</td>
<td class="time-cell">{{ new Date(row.createdAt).toLocaleString() }}</td>
<td>
<template v-if="row.status === 'PENDING'">
<button class="btn-sm btn-approve" @click="openApprove(row)">{{ t('deposit.approve') }}</button>
<button class="btn-sm btn-reject" @click="openReject(row)">{{ t('deposit.reject') }}</button>
</template>
<template v-else-if="row.status === 'REJECTED'">
<span class="reject-reason" :title="row.rejectReason || ''">{{ row.rejectReason }}</span>
</template>
</td>
</tr>
</tbody>
</table>
<div v-if="total > pageSize" class="pagination">
<button :disabled="page <= 1" @click="prevPage">{{ t('deposit.prev') }}</button>
<span>{{ page }} / {{ Math.ceil(total / pageSize) }}</span>
<button :disabled="page * pageSize >= total" @click="nextPage">{{ t('deposit.next') }}</button>
</div>
<!-- Approve Dialog -->
<div v-if="approveDialogVisible" class="dialog-overlay" @click.self="approveDialogVisible = false">
<div class="dialog-box">
<h3>{{ t('deposit.approve_title') }}</h3>
<div v-if="approveTarget" class="approve-content">
<div class="info-row">
<span>{{ t('deposit.player') }}:</span> <strong>{{ approveTarget.playerUsername }}</strong>
</div>
<div class="info-row">
<span>{{ t('deposit.submitted_amount') }}:</span> <strong>{{ formatAmount(approveTarget.amount) }}</strong>
</div>
<div class="info-row">
<span>{{ t('deposit.screenshot') }}:</span>
<img :src="approveTarget.screenshotUrl" class="approve-screenshot" @click="openScreenshot(approveTarget.screenshotUrl)" />
</div>
<div class="form-group">
<label>{{ t('deposit.approved_amount_label') }}</label>
<input v-model.number="approveAmount" type="number" step="0.01" min="0.01" />
</div>
<div class="form-group">
<label>{{ t('deposit.remark_label') }}</label>
<input v-model="approveRemark" />
</div>
</div>
<div class="dialog-actions">
<button class="btn-cancel" @click="approveDialogVisible = false">{{ t('common.cancel') }}</button>
<button class="btn-primary" @click="handleApprove">{{ t('deposit.confirm_approve') }}</button>
</div>
</div>
</div>
<!-- Reject Dialog -->
<div v-if="rejectDialogVisible" class="dialog-overlay" @click.self="rejectDialogVisible = false">
<div class="dialog-box">
<h3>{{ t('deposit.reject_title') }}</h3>
<div v-if="rejectTarget" class="reject-content">
<div class="info-row">
<span>{{ t('deposit.player') }}:</span> <strong>{{ rejectTarget.playerUsername }}</strong>
</div>
<div class="info-row">
<span>{{ t('deposit.amount') }}:</span> <strong>{{ formatAmount(rejectTarget.amount) }}</strong>
</div>
<div class="form-group">
<label>{{ t('deposit.reject_reason_label') }}</label>
<textarea v-model="rejectReason" rows="3" :placeholder="t('deposit.reject_reason_ph')"></textarea>
</div>
</div>
<div class="dialog-actions">
<button class="btn-cancel" @click="rejectDialogVisible = false">{{ t('common.cancel') }}</button>
<button class="btn-danger-full" @click="handleReject">{{ t('deposit.confirm_reject') }}</button>
</div>
</div>
</div>
<!-- Screenshot Preview -->
<div v-if="previewVisible" class="dialog-overlay" @click.self="previewVisible = false">
<div class="preview-box">
<img :src="previewUrl" class="preview-image" />
<button class="close-preview" @click="previewVisible = false"></button>
</div>
</div>
</div>
</template>
<style scoped>
.page-deposit-orders { padding: 16px; }
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.toolbar h2 { margin: 0; font-size: 18px; }
.filters { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
.filters select, .filters input { padding: 6px 10px; border-radius: 4px; border: 1px solid #444; background: #1e1e1e; color: #eee; font-size: 13px; }
.filters input { min-width: 160px; }
.btn-search { background: #409eff; color: #fff; border: none; border-radius: 4px; padding: 6px 14px; cursor: pointer; font-weight: 600; font-size: 13px; }
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.data-table th, .data-table td { padding: 10px 6px; border-bottom: 1px solid #333; text-align: left; }
.data-table th { font-weight: 700; color: #aaa; font-size: 11px; text-transform: uppercase; }
.mono { font-family: monospace; font-size: 11px; }
.amount { font-weight: 700; }
.time-cell { font-size: 11px; color: #888; }
.screenshot-thumb { width: 40px; height: 40px; object-fit: cover; border-radius: 4px; cursor: pointer; border: 1px solid #444; }
.badge { padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 700; }
.badge-blue { background: #1e3a5f; color: #66b1ff; }
.badge-green { background: rgba(201, 162, 39, 0.12); color: var(--gold-text); }
.status-pending { color: #e6a23c; font-weight: 700; font-size: 12px; }
.status-approved { color: var(--success-text); font-weight: 700; font-size: 12px; }
.status-rejected { color: #f56c6c; font-weight: 700; font-size: 12px; }
.btn-sm { border: none; border-radius: 4px; padding: 4px 10px; font-size: 11px; cursor: pointer; margin-right: 4px; }
.btn-approve { background: rgba(61, 115, 88, 0.16); color: var(--success-text); }
.btn-approve:hover { background: rgba(61, 115, 88, 0.24); }
.btn-reject { background: #3a1a1a; color: #f56c6c; }
.btn-reject:hover { background: #4a2525; }
.reject-reason { font-size: 11px; color: #f56c6c; max-width: 120px; display: inline-block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pagination { display: flex; justify-content: center; align-items: center; gap: 12px; margin-top: 16px; }
.pagination button { background: #333; color: #ddd; border: none; border-radius: 4px; padding: 6px 14px; cursor: pointer; }
.pagination button:disabled { opacity: 0.4; cursor: default; }
.dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 999; }
.dialog-box { background: #1e1e1e; border-radius: 8px; padding: 24px; min-width: 440px; max-width: 560px; }
.dialog-box h3 { margin: 0 0 16px; font-size: 16px; }
.info-row { margin-bottom: 8px; font-size: 13px; }
.info-row span { color: #888; margin-right: 4px; }
.approve-screenshot { width: 200px; max-height: 200px; object-fit: contain; border-radius: 4px; cursor: pointer; margin-top: 4px; }
.form-group { margin-bottom: 12px; }
.form-group label { display: block; font-size: 12px; color: #aaa; margin-bottom: 4px; font-weight: 600; }
.form-group input, .form-group textarea { width: 100%; padding: 8px; border: 1px solid #444; border-radius: 4px; background: #111; color: #eee; box-sizing: border-box; }
.form-group textarea { resize: vertical; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
.btn-cancel { background: #333; color: #ccc; border: none; border-radius: 4px; padding: 8px 16px; cursor: pointer; }
.btn-primary { background: #409eff; color: #fff; border: none; border-radius: 4px; padding: 8px 16px; cursor: pointer; font-weight: 600; }
.btn-danger-full { background: #f56c6c; color: #fff; border: none; border-radius: 4px; padding: 8px 16px; cursor: pointer; font-weight: 600; }
.preview-box { position: relative; max-width: 90vw; max-height: 90vh; }
.preview-image { max-width: 90vw; max-height: 85vh; object-fit: contain; border-radius: 8px; }
.close-preview { position: absolute; top: -12px; right: -12px; background: #333; color: #fff; border: none; border-radius: 50%; width: 28px; height: 28px; font-size: 14px; cursor: pointer; }
</style>

View File

@@ -238,8 +238,8 @@ label {
}
.field::placeholder { color: #3a3a3a; }
.field:focus {
border-color: var(--green-mid);
box-shadow: 0 0 0 3px rgba(47, 181, 106, 0.15);
border-color: rgba(255, 255, 255, 0.28);
box-shadow: none;
}
.field:-webkit-autofill,
.field:-webkit-autofill:focus {
@@ -249,24 +249,23 @@ label {
}
.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;
height: 44px;
background: #f5f5f5;
border: 1px solid #f5f5f5;
border-radius: 6px;
color: #0a0a0a;
font-size: 14px;
font-weight: 500;
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;
letter-spacing: 0;
box-shadow: none;
transition: background 0.15s, border-color 0.15s;
}
.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);
background: #ffffff;
border-color: #ffffff;
color: #0a0a0a;
box-shadow: none;
}
.btn-login:disabled { opacity: 0.4; cursor: not-allowed; }
.quick-label {
@@ -308,7 +307,7 @@ label {
}
.quick-acc {
font-size: 12px;
color: var(--green-text);
color: var(--gold-text);
font-weight: 600;
font-family: monospace;
}

View File

@@ -345,10 +345,10 @@ async function doUpload() {
}
.tab-btn:hover { border-color: #444; color: #ccc; }
.tab-btn.active {
background: rgba(47, 181, 106, 0.14);
border-color: rgba(47, 181, 106, 0.5);
color: #2fb56a;
font-weight: 600;
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
color: #f5f5f5;
font-weight: 500;
}
.btn {
@@ -369,13 +369,13 @@ async function doUpload() {
}
.btn-ghost:hover:not(:disabled) { border-color: #444; color: #ccc; }
.btn-primary {
background: linear-gradient(135deg, #2fb56a, #248f54);
color: #fff;
font-weight: 600;
border-color: transparent;
box-shadow: 0 2px 8px rgba(47, 181, 106, 0.3);
background: #f5f5f5;
color: #0a0a0a;
font-weight: 500;
border-color: #f5f5f5;
box-shadow: none;
}
.btn-primary:hover:not(:disabled) { filter: brightness(1.08); }
.btn-primary:hover:not(:disabled) { background: #ffffff; border-color: #ffffff; }
/* ── Grid ── */
.file-grid {
@@ -435,9 +435,9 @@ async function doUpload() {
letter-spacing: 0.04em;
}
.badge-used {
background: rgba(47, 181, 106, 0.2);
color: #2fb56a;
border: 1px solid rgba(47, 181, 106, 0.35);
background: rgba(212, 175, 55, 0.2);
color: #c9a227;
border: 1px solid rgba(212, 175, 55, 0.35);
}
.badge-unused {
background: rgba(120, 120, 120, 0.18);
@@ -611,8 +611,8 @@ async function doUpload() {
}
.drop-zone:hover,
.drop-zone.drop-active {
border-color: rgba(47, 181, 106, 0.5);
background: rgba(47, 181, 106, 0.04);
border-color: rgba(212, 175, 55, 0.5);
background: rgba(212, 175, 55, 0.04);
}
.drop-hint {
font-size: 13px;

View File

@@ -0,0 +1,380 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
const { t } = useAdminLocale();
interface PaymentMethod {
id: string;
methodType: string;
bankName: string | null;
accountHolder: string | null;
accountNumber: string | null;
usdtAddress: string | null;
qrCodeUrl: string | null;
displayName: string | null;
sortOrder: number;
isActive: boolean;
showOnPlayer: boolean;
createdAt: string;
translations?: {
displayName?: Record<string, string>;
bankName?: Record<string, string>;
};
}
const items = ref<PaymentMethod[]>([]);
const loading = ref(false);
const typeFilter = ref<'' | 'BANK' | 'USDT'>('');
// Dialog
const dialogVisible = ref(false);
const editingId = ref<string | null>(null);
const form = ref({
methodType: 'BANK' as 'BANK' | 'USDT',
bankName: '',
accountHolder: '',
accountNumber: '',
usdtAddress: '',
qrCodeUrl: '',
displayName: '',
sortOrder: 0,
isActive: true,
showOnPlayer: true,
translations: {
displayName: { 'zh-CN': '', 'en-US': '', 'ms-MY': '' },
bankName: { 'zh-CN': '', 'en-US': '', 'ms-MY': '' },
},
});
const filteredItems = computed(() => {
if (!typeFilter.value) return items.value;
return items.value.filter((m) => m.methodType === typeFilter.value);
});
async function fetchList() {
loading.value = true;
try {
const { data } = await api.get('/admin/payment-methods');
items.value = (data.data ?? []).map((m: any) => ({ ...m, id: String(m.id) }));
} catch { /* */ } finally {
loading.value = false;
}
}
function openCreate() {
editingId.value = null;
form.value = {
methodType: 'BANK',
bankName: '',
accountHolder: '',
accountNumber: '',
usdtAddress: '',
qrCodeUrl: '',
displayName: '',
sortOrder: 0,
isActive: true,
showOnPlayer: true,
translations: {
displayName: { 'zh-CN': '', 'en-US': '', 'ms-MY': '' },
bankName: { 'zh-CN': '', 'en-US': '', 'ms-MY': '' },
},
};
dialogVisible.value = true;
}
function openEdit(row: PaymentMethod) {
editingId.value = row.id;
const t = row.translations ?? {};
form.value = {
methodType: row.methodType as 'BANK' | 'USDT',
bankName: row.bankName ?? '',
accountHolder: row.accountHolder ?? '',
accountNumber: row.accountNumber ?? '',
usdtAddress: row.usdtAddress ?? '',
qrCodeUrl: row.qrCodeUrl ?? '',
displayName: row.displayName ?? '',
sortOrder: row.sortOrder,
isActive: row.isActive,
showOnPlayer: row.showOnPlayer,
translations: {
displayName: {
'zh-CN': t.displayName?.['zh-CN'] ?? '',
'en-US': t.displayName?.['en-US'] ?? '',
'ms-MY': t.displayName?.['ms-MY'] ?? '',
},
bankName: {
'zh-CN': t.bankName?.['zh-CN'] ?? '',
'en-US': t.bankName?.['en-US'] ?? '',
'ms-MY': t.bankName?.['ms-MY'] ?? '',
},
},
};
dialogVisible.value = true;
}
async function handleSave() {
const { translations, ...rest } = form.value;
// Only send non-empty translations
const cleanTranslations = {
displayName: Object.fromEntries(
Object.entries(translations.displayName).filter(([, v]) => v.trim()),
),
bankName: Object.fromEntries(
Object.entries(translations.bankName).filter(([, v]) => v.trim()),
),
};
const payload: any = {
...rest,
translations: (Object.keys(cleanTranslations.displayName).length || Object.keys(cleanTranslations.bankName).length)
? cleanTranslations
: undefined,
};
try {
if (editingId.value) {
await api.put(`/admin/payment-methods/${editingId.value}`, payload);
} else {
await api.post('/admin/payment-methods', payload);
}
dialogVisible.value = false;
await fetchList();
} catch (e: any) {
alert(e.response?.data?.message || 'Error');
}
}
async function handleDelete(row: PaymentMethod) {
if (!confirm(t('deposit.confirm_deactivate'))) return;
try {
await api.delete(`/admin/payment-methods/${row.id}`);
await fetchList();
} catch { /* */ }
}
async function toggleField(row: PaymentMethod, field: 'isActive' | 'showOnPlayer') {
try {
await api.put(`/admin/payment-methods/${row.id}`, { [field]: !row[field] });
await fetchList();
} catch { /* */ }
}
// QR code upload for USDT
const uploading = ref(false);
async function handleQrUpload(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
uploading.value = true;
try {
const fd = new FormData();
fd.append('file', file);
const { data } = await api.post('/admin/uploads?category=payments', fd);
form.value.qrCodeUrl = data.data?.url ?? '';
} catch { /* */ } finally {
uploading.value = false;
input.value = '';
}
}
onMounted(fetchList);
</script>
<template>
<div class="page-payment-methods">
<div class="toolbar">
<h2>{{ t('deposit.payment_methods_title') }}</h2>
<div class="actions">
<select v-model="typeFilter" class="filter-select">
<option value="">{{ t('common.all') }}</option>
<option value="BANK">Bank</option>
<option value="USDT">USDT</option>
</select>
<button class="btn-primary" @click="openCreate">{{ t('deposit.add_method') }}</button>
</div>
</div>
<AdminTableEmpty v-if="!loading && !filteredItems.length" />
<table v-if="filteredItems.length" class="data-table">
<thead>
<tr>
<th>{{ t('common.type') }}</th>
<th>{{ t('deposit.display_name') }}</th>
<th>{{ t('deposit.details') }}</th>
<th>{{ t('deposit.sort') }}</th>
<th>{{ t('deposit.active') }}</th>
<th>{{ t('deposit.show_player') }}</th>
<th>{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in filteredItems" :key="row.id">
<td><span :class="['badge', row.methodType === 'BANK' ? 'badge-blue' : 'badge-green']">{{ row.methodType }}</span></td>
<td>{{ row.displayName || '-' }}</td>
<td class="details-cell">
<template v-if="row.methodType === 'BANK'">
<div>{{ row.bankName }}</div>
<div class="sub">{{ row.accountHolder }} · {{ row.accountNumber }}</div>
</template>
<template v-else>
<div>{{ row.usdtAddress }}</div>
<img v-if="row.qrCodeUrl" :src="row.qrCodeUrl" class="qr-thumb" />
</template>
</td>
<td>{{ row.sortOrder }}</td>
<td>
<span :class="row.isActive ? 'status-on' : 'status-off'">{{ row.isActive ? 'ON' : 'OFF' }}</span>
</td>
<td>
<span :class="row.showOnPlayer ? 'status-on' : 'status-off'">{{ row.showOnPlayer ? 'ON' : 'OFF' }}</span>
</td>
<td class="actions-cell">
<button class="btn-sm" @click="openEdit(row)">{{ t('common.edit') }}</button>
<button class="btn-sm btn-toggle" @click="toggleField(row, 'isActive')">
{{ row.isActive ? t('common.disable') : t('common.enable') }}
</button>
<button class="btn-sm btn-toggle" @click="toggleField(row, 'showOnPlayer')">
{{ row.showOnPlayer ? t('common.hide_player') : t('common.show_player') }}
</button>
<button class="btn-sm btn-danger" @click="handleDelete(row)">{{ t('common.delete') }}</button>
</td>
</tr>
</tbody>
</table>
<!-- Create/Edit Dialog -->
<div v-if="dialogVisible" class="dialog-overlay" @click.self="dialogVisible = false">
<div class="dialog-box">
<h3>{{ editingId ? t('deposit.edit_method') : t('deposit.create_method') }}</h3>
<div class="form-group" v-if="!editingId">
<label>{{ t('common.type') }}</label>
<select v-model="form.methodType">
<option value="BANK">Bank</option>
<option value="USDT">USDT</option>
</select>
</div>
<div class="form-group">
<label>{{ t('deposit.display_name') }}</label>
<input v-model="form.displayName" :placeholder="t('deposit.display_name')" />
<div class="lang-section">
<div class="lang-hint">{{ t('deposit.multilingual_hint') }}</div>
<div class="lang-row">
<span class="lang-tag">{{ t('deposit.lang_zh') }}</span>
<input v-model="form.translations.displayName['zh-CN']" placeholder="中文名称" />
</div>
<div class="lang-row">
<span class="lang-tag">{{ t('deposit.lang_en') }}</span>
<input v-model="form.translations.displayName['en-US']" placeholder="English name" />
</div>
<div class="lang-row">
<span class="lang-tag">{{ t('deposit.lang_ms') }}</span>
<input v-model="form.translations.displayName['ms-MY']" placeholder="Nama Bahasa Melayu" />
</div>
</div>
</div>
<template v-if="form.methodType === 'BANK'">
<div class="form-group">
<label>{{ t('deposit.bank_name') }}</label>
<input v-model="form.bankName" :placeholder="t('deposit.bank_name')" />
<div class="lang-section">
<div class="lang-row">
<span class="lang-tag">{{ t('deposit.lang_zh') }}</span>
<input v-model="form.translations.bankName['zh-CN']" placeholder="银行名称(中文)" />
</div>
<div class="lang-row">
<span class="lang-tag">{{ t('deposit.lang_en') }}</span>
<input v-model="form.translations.bankName['en-US']" placeholder="Bank name (English)" />
</div>
<div class="lang-row">
<span class="lang-tag">{{ t('deposit.lang_ms') }}</span>
<input v-model="form.translations.bankName['ms-MY']" placeholder="Nama bank (Melayu)" />
</div>
</div>
</div>
<div class="form-group">
<label>{{ t('deposit.account_holder') }}</label>
<input v-model="form.accountHolder" />
</div>
<div class="form-group">
<label>{{ t('deposit.account_number') }}</label>
<input v-model="form.accountNumber" />
</div>
</template>
<template v-else>
<div class="form-group">
<label>{{ t('deposit.usdt_address') }}</label>
<input v-model="form.usdtAddress" />
</div>
<div class="form-group">
<label>{{ t('deposit.qr_code') }}</label>
<div class="qr-upload-row">
<img v-if="form.qrCodeUrl" :src="form.qrCodeUrl" class="qr-preview" />
<input type="file" accept="image/*" @change="handleQrUpload" :disabled="uploading" />
</div>
</div>
</template>
<div class="form-group">
<label>{{ t('deposit.sort_order') }}</label>
<input v-model.number="form.sortOrder" type="number" />
</div>
<div class="form-group row-checks">
<label><input type="checkbox" v-model="form.isActive" /> {{ t('deposit.active') }}</label>
<label><input type="checkbox" v-model="form.showOnPlayer" /> {{ t('deposit.show_on_player') }}</label>
</div>
<div class="dialog-actions">
<button class="btn-cancel" @click="dialogVisible = false">{{ t('common.cancel') }}</button>
<button class="btn-primary" @click="handleSave">{{ t('deposit.save') }}</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.page-payment-methods { padding: 16px; }
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.toolbar h2 { margin: 0; font-size: 18px; }
.actions { display: flex; gap: 8px; align-items: center; }
.filter-select { padding: 6px 10px; border-radius: 4px; border: 1px solid #444; background: #1e1e1e; color: #eee; }
.btn-primary { background: #409eff; color: #fff; border: none; border-radius: 4px; padding: 8px 16px; cursor: pointer; font-weight: 600; }
.btn-primary:hover { background: #337ecc; }
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.data-table th, .data-table td { padding: 10px 8px; border-bottom: 1px solid #333; text-align: left; }
.data-table th { font-weight: 700; color: #aaa; font-size: 12px; text-transform: uppercase; }
.details-cell .sub { font-size: 11px; color: #888; margin-top: 2px; }
.qr-thumb { width: 40px; height: 40px; object-fit: cover; border-radius: 4px; margin-top: 4px; }
.badge { padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 700; }
.badge-blue { background: #1e3a5f; color: #66b1ff; }
.badge-green { background: rgba(201, 162, 39, 0.12); color: var(--gold-text); }
.status-on { font-size: 12px; font-weight: 700; color: var(--success-text); }
.status-off { font-size: 12px; font-weight: 700; color: #666; }
.actions-cell { white-space: nowrap; }
.btn-sm { border: none; border-radius: 4px; padding: 4px 10px; font-size: 11px; cursor: pointer; background: #2d2d2d; color: #ddd; margin-right: 4px; }
.btn-sm:hover { background: #444; }
.btn-toggle { background: #1e2a3a; color: #66b1ff; }
.btn-toggle:hover { background: #253a4f; }
.btn-danger { color: #f56c6c; }
.btn-danger:hover { background: #3a1a1a; }
.dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 999; }
.dialog-box { background: #1e1e1e; border-radius: 8px; padding: 24px; min-width: 420px; max-width: 560px; max-height: 85vh; overflow-y: auto; }
.dialog-box h3 { margin: 0 0 16px; font-size: 16px; }
.form-group { margin-bottom: 12px; }
.form-group label { display: block; font-size: 12px; color: #aaa; margin-bottom: 4px; font-weight: 600; }
.form-group input, .form-group select { width: 100%; padding: 8px; border: 1px solid #444; border-radius: 4px; background: #111; color: #eee; box-sizing: border-box; }
.row-checks { display: flex; gap: 16px; }
.row-checks label { font-size: 13px; color: #ddd; display: flex; align-items: center; gap: 4px; }
.qr-upload-row { display: flex; align-items: center; gap: 12px; }
.qr-preview { width: 80px; height: 80px; object-fit: cover; border-radius: 4px; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
.btn-cancel { background: #333; color: #ccc; border: none; border-radius: 4px; padding: 8px 16px; cursor: pointer; }
.lang-section { margin-top: 6px; padding: 8px 10px; background: #161616; border-radius: 4px; border: 1px solid #2a2a2a; }
.lang-hint { font-size: 10px; color: #666; margin-bottom: 6px; line-height: 1.4; }
.lang-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.lang-row:last-child { margin-bottom: 0; }
.lang-tag { flex-shrink: 0; font-size: 10px; font-weight: 700; color: #aaa; background: #252525; padding: 2px 6px; border-radius: 3px; min-width: 48px; text-align: center; }
.lang-row input { flex: 1; padding: 5px 8px; border: 1px solid #333; border-radius: 3px; background: #0e0e0e; color: #ddd; font-size: 12px; box-sizing: border-box; }
</style>

View File

@@ -836,7 +836,7 @@ onMounted(() => {
.leg-badge {
margin-left: 4px;
font-size: 10px;
color: var(--green-text, #2fb56a);
color: var(--gold-text, #dcc078);
}
.bet-content-cell {
@@ -922,7 +922,7 @@ onMounted(() => {
.vs {
font-size: 13px;
font-weight: 700;
color: var(--green-text, #2fb56a);
color: var(--gold-text, #dcc078);
flex-shrink: 0;
}
@@ -1062,7 +1062,7 @@ onMounted(() => {
.pstat-green {
color: var(--green-glow);
text-shadow: 0 0 20px rgba(47, 181, 106, 0.35);
text-shadow: none;
}
.pstat-orange {

View File

@@ -27,6 +27,8 @@ import {
shouldCompactAmount as shouldCompact,
} from '../utils/format-amount';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import AdminDetailGrid from '../components/AdminDetailGrid.vue';
import AdminDetailItem from '../components/AdminDetailItem.vue';
import WalletTransferContext from '../components/WalletTransferContext.vue';
import { useAdminPlayerTransfer } from '../composables/useAdminPlayerTransfer';
@@ -788,50 +790,46 @@ function statusLabel(s: string) {
</template>
</el-dialog>
<el-dialog v-model="detailVisible" :title="t('user.dialog.detail')" width="560px" destroy-on-close>
<el-dialog v-model="detailVisible" :title="t('user.dialog.detail')" width="780px" destroy-on-close class="entity-detail-dialog">
<template v-if="detail">
<el-descriptions :column="2" border size="small">
<el-descriptions-item :label="t('common.col_id')">{{ detail.id }}</el-descriptions-item>
<el-descriptions-item :label="t('user.col.username')">{{ detail.username }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.current_password')">
{{ detail.managedPassword ?? '—' }}
</el-descriptions-item>
<el-descriptions-item v-if="!detail.managedPassword" :span="2">
<span class="field-hint">{{ t('user.hint.password_reset_to_view') }}</span>
</el-descriptions-item>
<el-descriptions-item :label="t('common.status')">
<AdminDetailGrid :columns="3">
<AdminDetailItem :label="t('common.col_id')">{{ detail.id }}</AdminDetailItem>
<AdminDetailItem :label="t('user.col.username')">{{ detail.username }}</AdminDetailItem>
<AdminDetailItem :label="t('common.status')">
<el-tag :type="statusTagType(detail.status)" size="small">
{{ statusLabel(detail.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item :label="t('user.col.agent')">
</AdminDetailItem>
<AdminDetailItem :label="t('user.col.agent')">
{{ detail.parentUsername ?? t('common.platform_direct') }}
</el-descriptions-item>
<el-descriptions-item :label="t('user.field.available')">
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.available')">
{{ formatAmount(detail.availableBalance) }}
<span v-if="shouldCompact(detail.availableBalance)" class="amount-full-hint">
{{ formatAmountFull(detail.availableBalance) }}
</span>
</el-descriptions-item>
<el-descriptions-item :label="t('user.field.frozen_balance')">
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.frozen_balance')">
{{ formatAmount(detail.frozenBalance) }}
<span v-if="shouldCompact(detail.frozenBalance)" class="amount-full-hint">
{{ formatAmountFull(detail.frozenBalance) }}
</span>
</el-descriptions-item>
<el-descriptions-item :label="t('user.field.phone')">{{ detail.phone ?? '—' }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.email')">{{ detail.email ?? '—' }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.bet_count')">{{ detail.betCount }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.total_stake')">{{ formatAmount(detail.totalStake) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.total_payout')">{{ formatAmount(detail.totalReturn) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.col.last_login')">
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.bet_count')">{{ detail.betCount }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.total_stake')">{{ formatAmount(detail.totalStake) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.total_payout')">{{ formatAmount(detail.totalReturn) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.phone')">{{ detail.phone ?? '—' }}</AdminDetailItem>
<AdminDetailItem :label="t('user.col.last_login')">
{{ detail.lastLoginAt ? formatTime(detail.lastLoginAt) : t('common.never_login') }}
</el-descriptions-item>
<el-descriptions-item :label="t('user.field.login_fail')">{{ t('user.login_fail_value', { n: detail.loginFailCount }) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.registered_at')" :span="2">
{{ formatTime(detail.createdAt) }}
</el-descriptions-item>
</el-descriptions>
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.login_fail')">{{ t('user.login_fail_value', { n: detail.loginFailCount }) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.email')">{{ detail.email ?? '—' }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.registered_at')">{{ formatTime(detail.createdAt) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.current_password')" :span="2">
{{ detail.managedPassword ?? '—' }}
<span v-if="!detail.managedPassword" class="admin-detail-hint">{{ t('user.hint.password_reset_to_view') }}</span>
</AdminDetailItem>
</AdminDetailGrid>
</template>
</el-dialog>
</div>
@@ -903,22 +901,3 @@ function statusLabel(s: string) {
margin-bottom: 10px;
}
</style>
<style>
/* 玩家列表「冻结」:橙黄底白字 */
.users-page .el-button.is-link.el-button--warning {
color: #ffffff !important;
background: linear-gradient(165deg, #e8a84a 0%, #c47a18 42%, #9a5c10 100%) !important;
border: 1px solid rgba(232, 168, 74, 0.45) !important;
border-radius: 6px !important;
padding: 5px 11px !important;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.12) inset, 0 1px 6px rgba(0, 0, 0, 0.35) !important;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
}
.users-page .el-button.is-link.el-button--warning:hover,
.users-page .el-button.is-link.el-button--warning:focus {
color: #ffffff !important;
background: linear-gradient(165deg, #f0bc62 0%, #d48a28 42%, #a86814 100%) !important;
border-color: rgba(240, 188, 98, 0.55) !important;
}
</style>

View File

@@ -1,4 +1,5 @@
import { FormValidationError } from '../i18n/form-validation';
import { decimalRateToPercent, percentToDecimalRate } from '../utils/rate-percent';
export interface PromotableUserOption {
id: string;
@@ -64,6 +65,7 @@ export interface AgentRow {
phone: string | null;
email: string | null;
locale: string;
inviteCode?: string | null;
createdAt: string;
}
@@ -131,7 +133,7 @@ export function editFormFromAgentDetail(d: AgentDetail): AgentEditForm {
status: d.status,
phone: d.phone ?? '',
email: d.email ?? '',
cashbackRate: Number(d.cashbackRate),
cashbackRate: decimalRateToPercent(d.cashbackRate),
maxSingleDeposit: d.maxSingleDeposit ? Number(d.maxSingleDeposit) : 0,
maxDailyDeposit: d.maxDailyDeposit ? Number(d.maxDailyDeposit) : 0,
managedPassword: d.managedPassword ?? null,
@@ -164,7 +166,7 @@ export function buildCreateAgentPayload(form: AgentCreateForm) {
return {
userId: form.userId,
creditLimit: form.creditLimit,
cashbackRate: form.cashbackRate,
cashbackRate: percentToDecimalRate(form.cashbackRate),
maxSingleDeposit: form.maxSingleDeposit > 0 ? form.maxSingleDeposit : undefined,
maxDailyDeposit: form.maxDailyDeposit > 0 ? form.maxDailyDeposit : undefined,
phone: form.phone.trim() || undefined,

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,
};

View File

@@ -38,7 +38,7 @@ const mainTrendOption = computed(() =>
[
{
name: t('dash.chart_stake'),
color: '#248f54',
color: '#d4d4d4',
values: s.value?.trend7d?.map((d) => toNum(d.stake)) ?? [],
},
{
@@ -62,7 +62,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',
@@ -71,7 +71,7 @@ const distributionOption = computed(() => {
const matchSegs = m
? [
{ label: t('dash.match_draft'), value: m.draft, color: '#6b7280' },
{ label: t('dash.match_published'), value: m.published, color: '#248f54' },
{ label: t('dash.match_published'), value: m.published, color: '#d4d4d4' },
{ label: t('dash.match_closed'), value: m.closed, color: '#60a5fa' },
{ label: t('dash.match_pending_settle'), value: m.pendingSettlement, color: '#fb923c' },
{ label: t('dash.match_settled'), value: m.settled ?? 0, color: '#5eead4' },

View File

@@ -54,7 +54,7 @@ const userDistributionOption = computed(() => {
const u = s.value?.users;
const userSegs = u
? [
{ label: t('dash.user_active'), value: u.playersActive, color: '#248f54' },
{ label: t('dash.user_active'), value: u.playersActive, color: '#d4d4d4' },
{ label: t('dash.user_suspended'), value: u.playersSuspended, color: '#f87171' },
{ label: t('dash.user_direct'), value: u.playersDirect, color: '#60a5fa' },
{ label: t('dash.user_agents'), value: u.agentsTotal, color: '#a78bfa' },

View File

@@ -3,30 +3,30 @@
}
.state-card {
border-radius: 14px;
border: 1px solid #2a2220;
background: rgba(255, 69, 58, 0.06);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 69, 58, 0.04);
text-align: center;
padding: 8px 0 4px;
}
.state-title {
font-size: 15px;
font-weight: 700;
color: #ff8a80;
font-size: 14px;
font-weight: 500;
color: #fca5a5;
margin-bottom: 6px;
}
.state-hint {
font-size: 13px;
color: #888;
color: #737373;
margin-bottom: 14px;
}
.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);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: #111111;
margin-bottom: 28px;
}
@@ -45,13 +45,12 @@
.board-hint {
font-size: 12px;
color: #666;
color: #737373;
}
.dash-updated {
font-size: 11px;
color: #555;
letter-spacing: 0.02em;
color: #525252;
}
.kpi-grid {
@@ -71,9 +70,9 @@
.kpi-cell {
padding: 12px 14px;
border-radius: 10px;
border: 1px solid #222;
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.02);
}
.kpi-cell.compact {
@@ -87,25 +86,25 @@
.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 {
@@ -115,7 +114,7 @@
.kpi-sub {
display: block;
font-size: 11px;
color: #555;
color: #525252;
margin-top: 4px;
}
@@ -123,15 +122,15 @@
display: inline-block;
margin-top: 6px;
font-size: 10px;
font-weight: 600;
color: #888;
font-weight: 500;
color: #737373;
padding: 2px 6px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.04);
}
.kpi-delta.up {
color: #4ade80;
color: #a3a3a3;
}
.kpi-delta.down {
@@ -139,13 +138,13 @@
}
.charts-stack {
border-top: 1px solid #1a1a1a;
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-top: 12px;
}
.chart-main-caption {
font-size: 11px;
color: #555;
color: #525252;
text-align: center;
margin: -8px 0 8px;
}

View File

@@ -169,8 +169,8 @@ watch(
margin-bottom: 10px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(47, 181, 106, 0.04);
border: 1px solid rgba(47, 181, 106, 0.14);
background: rgba(212, 175, 55, 0.04);
border: 1px solid rgba(212, 175, 55, 0.14);
}
.panel-head {
display: flex;

View File

@@ -1,4 +1,6 @@
import { FormValidationError } from '../i18n/form-validation';
import { decimalRateToPercent } from '../utils/rate-percent';
import { percentToDecimalRate } from '../utils/rate-percent';
/** 玩家用户名仅英文字母与数字332 位 */
export const PLAYER_USERNAME_PATTERN = /^[a-zA-Z0-9]{3,32}$/;
@@ -46,6 +48,9 @@ export interface PlayerEditForm {
email: string;
managedPassword: string | null;
newPassword: string;
customCashbackRate: number;
defaultCashbackRate: number;
useCustomCashback: boolean;
}
export interface PlayerRow {
@@ -57,6 +62,7 @@ export interface PlayerRow {
parentUsername: string | null;
/** 归属代理链:一级代理、二级代理(如有) */
affiliationAgents?: string[];
inviteCode?: string | null;
phone: string | null;
email: string | null;
managedPassword: string | null;
@@ -73,6 +79,8 @@ export interface PlayerDetail extends PlayerRow {
loginFailCount: number;
lockedUntil: string | null;
updatedAt: string;
customCashbackRate: string | null;
defaultCashbackRate: string;
}
/** 玩家归属标签,格式:玩家-平台 | 玩家-一级代理 | 玩家-一级代理-二级代理 */
@@ -124,6 +132,9 @@ export function emptyPlayerEditForm(): PlayerEditForm {
email: '',
managedPassword: null,
newPassword: '',
customCashbackRate: 0,
defaultCashbackRate: 0,
useCustomCashback: false,
};
}
@@ -146,6 +157,10 @@ export function editFormFromDetail(d: PlayerDetail): PlayerEditForm {
email: d.email ?? '',
managedPassword: d.managedPassword ?? null,
newPassword: '',
customCashbackRate:
d.customCashbackRate != null ? decimalRateToPercent(d.customCashbackRate) : 0,
defaultCashbackRate: decimalRateToPercent(d.defaultCashbackRate),
useCustomCashback: d.customCashbackRate != null,
};
}
@@ -164,7 +179,7 @@ export function buildCreatePlayerPayload(form: PlayerCreateForm) {
email: form.email.trim() || undefined,
asTier1Agent: true,
creditLimit: form.creditLimit,
cashbackRate: form.cashbackRate,
cashbackRate: percentToDecimalRate(form.cashbackRate),
maxSingleDeposit: form.maxSingleDeposit > 0 ? form.maxSingleDeposit : undefined,
maxDailyDeposit: form.maxDailyDeposit > 0 ? form.maxDailyDeposit : undefined,
};