feat: 手动充值、邀请码注册与后台管理增强
新增玩家手动充值全流程(收款方式配置、充值下单/审核、钱包上分), 支持邀请码注册、邀请历史与专属返水率;完善后台代理/玩家管理与响应式操作栏, 并补充前台注册、充值页及多语言错误码。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
111
apps/admin/src/views/DepositManage.vue
Normal file
111
apps/admin/src/views/DepositManage.vue
Normal 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>
|
||||
331
apps/admin/src/views/DepositOrders.vue
Normal file
331
apps/admin/src/views/DepositOrders.vue
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
380
apps/admin/src/views/PaymentMethods.vue
Normal file
380
apps/admin/src/views/PaymentMethods.vue
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface AgentPlayerRow {
|
||||
username: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
inviteCode?: string | null;
|
||||
wallet?: { availableBalance: string; frozenBalance?: string };
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { FormValidationError } from '../i18n/form-validation';
|
||||
import { decimalRateToPercent } from '../utils/rate-percent';
|
||||
import { percentToDecimalRate } from '../utils/rate-percent';
|
||||
|
||||
/** 玩家用户名:仅英文字母与数字,3–32 位 */
|
||||
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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user