feat(admin,api,player): 代理层级管理、额度上下分与玩家钱包详情

新增代理管理器与二级代理体系,完善信用额度/上下分上下文与冻结策略;代理端玩家与子代理管理增强;玩家端新增钱包详情页与交易筛选优化。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-08 15:34:12 +08:00
parent b2216abd0c
commit 414998ce36
54 changed files with 6641 additions and 481 deletions

View File

@@ -1,92 +1,451 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import api from '../../api';
import { formatAmount } from '../../utils/format-amount';
import { formatAmount, formatAmountFull } from '../../utils/format-amount';
import type { AgentDashboard } from '../dashboard-types';
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';
const { t, localeTag } = useAdminLocale();
const summary = ref<Record<string, unknown>>({});
const { t, locale, localeTag } = useAdminLocale();
const router = useRouter();
onMounted(async () => {
const { data } = await api.get('/agent/reports/summary');
summary.value = data.data;
const stats = ref<AgentDashboard | null>(null);
const loading = ref(true);
const loadError = ref(false);
onMounted(load);
async function load() {
loading.value = true;
loadError.value = false;
try {
const { data } = await api.get('/agent/reports/summary');
stats.value = data.data as AgentDashboard;
} catch {
stats.value = null;
loadError.value = true;
} finally {
loading.value = false;
}
}
type KpiLink = { path: string; query?: Record<string, string> };
function goKpiLink(link: KpiLink) {
router.push(link.query ? { path: link.path, query: link.query } : link.path);
}
const s = computed(() => stats.value);
function fmtCount(val: number | undefined) {
return (val ?? 0).toLocaleString(localeTag.value, { maximumFractionDigits: 0 });
}
function formatTime(v: string) {
return new Date(v).toLocaleString(localeTag.value, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function toNum(v: string | number | undefined) {
const n = typeof v === 'number' ? v : parseFloat(v ?? '0');
return Number.isFinite(n) ? n : 0;
}
function pctChange(today: string | number, yesterday: string | number) {
const t0 = toNum(today);
const y = toNum(yesterday);
if (y === 0) return t0 > 0 ? '+100%' : '—';
const p = ((t0 - y) / y) * 100;
const sign = p > 0 ? '+' : '';
return `${sign}${p.toFixed(1)}%`;
}
const chartI18n = computed(() => ({
locale: locale.value,
betCountSeries: t('dash.chart_bet_count'),
axisAmount: t('dash.axis_amount'),
axisCount: t('dash.axis_count'),
countSuffix: t('dash.count_suffix'),
pieTooltip: t('dash.chart_tooltip'),
noData: t('common.no_data'),
pieEmpty: t('dash.pie_empty'),
}));
const trendLabels = computed(() => s.value?.trend7d?.map((d) => d.label) ?? []);
const mainTrendOption = computed(() =>
buildCombinedTrendOption(
trendLabels.value,
[
{
name: t('dash.chart_stake'),
color: '#248f54',
values: s.value?.trend7d?.map((d) => toNum(d.stake)) ?? [],
},
{
name: t('dash.chart_payout'),
color: '#60a5fa',
values: s.value?.trend7d?.map((d) => toNum(d.payout)) ?? [],
},
{
name: t('dash.chart_ggr'),
color: '#a78bfa',
values: s.value?.trend7d?.map((d) => toNum(d.ggr)) ?? [],
},
],
s.value?.trend7d?.map((d) => d.betCount) ?? [],
chartI18n.value,
),
);
const distributionOption = computed(() => {
const c = s.value?.credit;
const p = s.value?.players;
const raw = s.value?.bets.todayByStatus ?? {};
const betColors: Record<string, string> = {
PENDING: '#fb923c',
WON: '#248f54',
LOST: '#f87171',
VOID: '#6b7280',
REFUNDED: '#60a5fa',
};
const creditSegs = c
? [
{
label: t('agent_dash.credit_available'),
value: toNum(c.availableCredit),
color: '#248f54',
},
{
label: t('agent_dash.credit_used'),
value: toNum(c.usedCredit),
color: '#fb923c',
},
].filter((x) => x.value > 0)
: [];
const betSegs = ['PENDING', 'WON', 'LOST', 'VOID', 'REFUNDED']
.filter((k) => raw[k]?.count)
.map((k) => ({
label: betStatusLabel(k),
value: raw[k].count,
color: betColors[k] ?? '#888',
}));
const playerSegs = p
? [
{ label: t('dash.user_active'), value: p.active, color: '#248f54' },
{ label: t('dash.user_suspended'), value: p.suspended, color: '#f87171' },
].filter((x) => x.value > 0)
: [];
return buildTriplePieOption(
[
{ title: t('agent_dash.pie_credit'), segments: creditSegs },
{ title: t('dash.pie_bets'), segments: betSegs },
{ title: t('agent_dash.pie_players'), segments: playerSegs },
],
chartI18n.value,
);
});
function fmtCount(val: unknown) {
return (Number(val) || 0).toLocaleString(localeTag.value, { maximumFractionDigits: 0 });
}
const kpiPrimary = computed(() => {
if (!s.value) return [];
const td = s.value.today;
const y = s.value.yesterday;
const yLabel = t('common.yesterday');
return [
{
label: t('dash.kpi_bet_count'),
value: fmtCount(td.betCount),
sub: `${yLabel} ${fmtCount(y.betCount)}`,
delta: pctChange(td.betCount, y.betCount),
},
{
label: t('dash.kpi_stake'),
value: formatAmount(td.stake, 2, locale.value),
sub: formatAmountFull(td.stake, locale.value),
delta: pctChange(td.stake, y.stake),
},
{
label: t('dash.kpi_payout'),
value: formatAmount(td.payout, 2, locale.value),
sub: `${yLabel} ${formatAmount(y.payout, 2, locale.value)}`,
delta: pctChange(td.payout, y.payout),
},
{
label: t('dash.kpi_ggr'),
value: formatAmount(td.ggr, 2, locale.value),
sub: `${yLabel} ${formatAmount(y.ggr, 2, locale.value)}`,
delta: pctChange(td.ggr, y.ggr),
},
];
});
const kpiSecondary = computed(() => {
if (!s.value) return [];
const pending = s.value.bets.pendingTotal;
return [
{
label: t('agent_dash.kpi_players'),
value: `${fmtCount(s.value.players.directTotal)} / ${fmtCount(s.value.subAgents.total)}`,
sub: t('dash.kpi_new_players', { n: fmtCount(s.value.today.newPlayers) }),
link: { path: '/my-players' },
},
{
label: t('dash.kpi_pending'),
value: `${fmtCount(pending)} ${t('common.bets_unit')}`,
sub: t('agent_dash.kpi_pending_sub', { bets: fmtCount(pending) }),
link: pending > 0 ? { path: '/my-bets' } : undefined,
},
{
label: t('dash.kpi_wallet'),
value: formatAmount(s.value.wallets.totalAvailable, 2, locale.value),
sub: `${t('common.frozen')} ${formatAmount(s.value.wallets.totalFrozen, 2, locale.value)}`,
},
{
label: t('dash.kpi_credit'),
value: formatAmount(s.value.credit.availableCredit, 2, locale.value),
sub: `${t('common.used')} ${formatAmount(s.value.credit.usedCredit, 2, locale.value)}`,
},
];
});
</script>
<template>
<div class="page-header">
<h2 class="page-title">{{ t('page.agent_dash.title') }}</h2>
<span class="page-desc">{{ t('page.agent_dash.desc') }}</span>
</div>
<div class="agent-dash" v-loading="loading">
<div class="page-header">
<h2 class="page-title">{{ t('page.agent_dash.title') }}</h2>
<span class="page-desc">{{ t('page.agent_dash.desc') }}</span>
</div>
<el-row :gutter="20" class="stat-row">
<el-col :span="6">
<div class="stat-card c-blue">
<div class="stat-top">
<span class="stat-label">{{ t('agent.credit_limit') }}</span>
<span class="stat-badge">¥</span>
<el-card v-if="!loading && loadError" class="state-card" shadow="never">
<p class="state-title">{{ t('msg.load_failed') }}</p>
<p class="state-hint">{{ t('agent_dash.load_error_hint') }}</p>
<el-button type="primary" size="small" @click="load">{{ t('common.retry') }}</el-button>
</el-card>
<template v-else-if="s">
<el-card class="overview-board" shadow="never">
<div v-if="s.generatedAt" class="board-head">
<span class="board-hint">{{ t('agent_dash.board_hint') }}</span>
<span class="dash-updated">
{{ t('common.updated_at') }} {{ formatTime(s.generatedAt) }}
</span>
</div>
<div class="stat-value">{{ formatAmount((summary.profile as { creditLimit?: string })?.creditLimit) }}</div>
<div class="stat-foot">{{ t('agent.credit_total') }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card c-orange">
<div class="stat-top">
<span class="stat-label">{{ t('agent.credit_used') }}</span>
<span class="stat-badge">¥</span>
<div class="kpi-grid kpi-primary">
<div v-for="item in kpiPrimary" :key="item.label" class="kpi-cell">
<span class="kpi-label">{{ item.label }}</span>
<span class="kpi-value">{{ item.value }}</span>
<span class="kpi-sub">{{ item.sub }}</span>
<span
class="kpi-delta"
:class="{ up: item.delta.startsWith('+'), down: item.delta.startsWith('-') }"
>
{{ t('common.vs_yesterday') }} {{ item.delta }}
</span>
</div>
</div>
<div class="stat-value">{{ formatAmount((summary.profile as { usedCredit?: string })?.usedCredit) }}</div>
<div class="stat-foot">{{ t('agent.credit_occupied') }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card c-green">
<div class="stat-top">
<span class="stat-label">{{ t('agent.direct_players') }}</span>
<span class="stat-badge">{{ t('common.people') }}</span>
<div class="kpi-grid kpi-secondary">
<div
v-for="item in kpiSecondary"
:key="item.label"
class="kpi-cell compact"
:class="{ 'kpi-cell--link': item.link }"
:role="item.link ? 'button' : undefined"
:tabindex="item.link ? 0 : undefined"
@click="item.link && goKpiLink(item.link)"
@keydown.enter.prevent="item.link && goKpiLink(item.link)"
>
<span class="kpi-label">{{ item.label }}</span>
<span class="kpi-value sm">{{ item.value }}</span>
<span class="kpi-sub">{{ item.sub }}</span>
</div>
</div>
<div class="stat-value">{{ fmtCount(summary.directPlayerCount) }}</div>
<div class="stat-foot">{{ t('agent.player_count') }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card c-purple">
<div class="stat-top">
<span class="stat-label">{{ t('agent.today_stake') }}</span>
<span class="stat-badge">¥</span>
<div class="charts-stack">
<EChartPanel title="" :option="mainTrendOption" height="300px" class="chart-main" />
<div class="chart-main-caption">{{ t('dash.trend_caption') }}</div>
<EChartPanel title="" :option="distributionOption" height="200px" class="chart-dist" />
</div>
<div class="stat-value">{{ formatAmount(summary.todayStake) }}</div>
<div class="stat-foot">{{ t('agent.currency_cny') }}</div>
</div>
</el-col>
</el-row>
</el-card>
</template>
</div>
</template>
<style scoped>
.page-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 24px; }
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; }
.page-desc { font-size: 13px; color: #3a3a3a; }
.agent-dash { padding-bottom: 32px; }
.stat-card {
border-radius: 12px;
padding: 22px 20px 18px;
border: 1px solid #1e1e1e;
transition: transform 0.2s, border-color 0.2s;
backdrop-filter: blur(10px);
.page-header {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 20px;
}
.page-title {
font-size: 20px;
font-weight: 700;
color: #e0e0e0;
}
.page-desc {
font-size: 13px;
color: #666;
}
.state-card {
border-radius: 14px;
border: 1px solid #2a2220;
background: rgba(255, 69, 58, 0.06);
text-align: center;
padding: 8px 0 4px;
}
.state-title {
font-size: 15px;
font-weight: 700;
color: #ff8a80;
margin-bottom: 6px;
}
.state-hint {
font-size: 13px;
color: #888;
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);
}
.overview-board :deep(.el-card__body) {
padding: 20px 22px 16px;
}
.board-head {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin: -4px 0 14px;
}
.board-hint {
font-size: 12px;
color: #666;
}
.dash-updated {
font-size: 11px;
color: #555;
letter-spacing: 0.02em;
}
.kpi-grid {
display: grid;
gap: 10px;
margin-bottom: 10px;
}
.kpi-primary {
grid-template-columns: repeat(4, 1fr);
}
.kpi-secondary {
grid-template-columns: repeat(4, 1fr);
margin-bottom: 18px;
}
.kpi-cell {
padding: 12px 14px;
border-radius: 10px;
border: 1px solid #222;
background: rgba(255, 255, 255, 0.03);
}
.kpi-cell.compact {
padding: 10px 12px;
}
.kpi-cell--link {
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.kpi-cell--link:hover,
.kpi-cell--link:focus-visible {
border-color: rgba(77, 214, 138, 0.35);
background: rgba(36, 143, 84, 0.1);
outline: none;
}
.kpi-label {
display: block;
font-size: 11px;
color: #666;
margin-bottom: 6px;
}
.kpi-value {
display: block;
font-size: 22px;
font-weight: 800;
color: var(--green-text);
line-height: 1.15;
letter-spacing: -0.5px;
}
.kpi-value.sm {
font-size: 17px;
}
.kpi-sub {
display: block;
font-size: 11px;
color: #555;
margin-top: 4px;
}
.kpi-delta {
display: inline-block;
margin-top: 6px;
font-size: 10px;
font-weight: 600;
color: #888;
padding: 2px 6px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.04);
}
.kpi-delta.up { color: #4ade80; }
.kpi-delta.down { color: #f87171; }
.charts-stack {
border-top: 1px solid #1a1a1a;
padding-top: 12px;
}
.chart-main-caption {
font-size: 11px;
color: #555;
text-align: center;
margin: -8px 0 8px;
}
.charts-stack :deep(.chart-panel) {
border: none;
background: transparent;
padding: 8px 0 0;
}
.charts-stack :deep(.chart-title:empty) {
display: none;
}
.chart-dist {
margin-top: 4px;
}
@media (max-width: 1200px) {
.kpi-primary,
.kpi-secondary {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.kpi-primary,
.kpi-secondary {
grid-template-columns: 1fr;
}
}
.stat-card:hover { transform: translateY(-2px); border-color: #2a2a2a; }
.stat-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.stat-label { font-size: 12px; color: #666; font-weight: 600; }
.stat-badge { font-size: 11px; color: #444; }
.stat-value { font-size: 26px; font-weight: 800; color: var(--green-text); margin-bottom: 6px; }
.stat-foot { font-size: 11px; color: #444; }
.c-blue { background: linear-gradient(135deg, rgba(96, 165, 250, 0.08), transparent); }
.c-orange { background: linear-gradient(135deg, rgba(251, 146, 60, 0.08), transparent); }
.c-green { background: linear-gradient(135deg, rgba(36, 143, 84, 0.1), transparent); }
.c-purple { background: linear-gradient(135deg, rgba(167, 139, 250, 0.08), transparent); }
</style>

View File

@@ -1,61 +1,353 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useAdminLocale } from '../../composables/useAdminLocale';
import { useAuthStore } from '../../stores/auth';
import api from '../../api';
import { ElMessage } from 'element-plus';
import { ElMessage, ElMessageBox } from 'element-plus';
import { formatAmount, formatAmountFull } from '../../utils/format-amount';
import { resolveFormError } from '../../i18n/form-validation';
import {
emptyAgentPlayerCreateForm,
emptyAgentPlayerEditForm,
buildAgentCreatePlayerPayload,
buildAgentUpdatePlayerPayload,
editFormFromAgentDetail,
type AgentPlayerRow,
type AgentPlayerDetail,
} from './agent-player-form';
import {
emptyAgentSubAgentCreateForm,
buildAgentSubAgentCreatePayload,
emptyAgentSubAgentEditForm,
editFormFromSubAgentDetail,
buildAgentSubAgentUpdatePayload,
subAgentAccountStatus,
type AgentSubAgentRow,
type AgentSubAgentDetail,
} from './agent-sub-agent-form';
import {
shouldToggleExpandOnRowClick,
expandableTableRowClassName,
} from '../../utils/expandable-table';
import type { TableInstance } from 'element-plus';
import WalletTransferContext from '../../components/WalletTransferContext.vue';
import AgentCreditContext from '../../components/AgentCreditContext.vue';
import {
depositAmountCap,
parsePlayerAvailable,
type WalletTransferContext as WalletTransferContextData,
} from '../../utils/wallet-transfer-context';
import {
snapshotFromAgentRow,
type AgentCreditAdjustContext,
} from '../../utils/agent-credit-context';
const { t } = useAdminLocale();
const { t, localeTag } = useAdminLocale();
const auth = useAuthStore();
type PlayerRow = {
id: string;
username: string;
wallet?: { availableBalance: string };
};
/* L1 agents can manage sub-agents; L2 cannot */
const isTier1 = computed(() => auth.isTier1Agent.value);
const players = ref<PlayerRow[]>([]);
const form = ref({ username: '', password: 'Player@123' });
/* ─── Credit profile ─── */
const profile = ref<{ creditLimit?: string; usedCredit?: string; availableCredit?: string }>({});
/* ─── Top-level tab: players | subAgents ─── */
const activeTab = ref('players');
/* ─── Players ─── */
const players = ref<AgentPlayerRow[]>([]);
const loadingPlayers = ref(false);
const keyword = ref('');
const createVisible = ref(false);
const createLoading = ref(false);
const createForm = ref(emptyAgentPlayerCreateForm());
const editVisible = ref(false);
const editLoading = ref(false);
const editForm = ref(emptyAgentPlayerEditForm());
const transferVisible = ref(false);
const transferLoading = ref(false);
const transferType = ref<'deposit' | 'withdraw'>('deposit');
const transferTarget = ref<PlayerRow | null>(null);
const transferTarget = ref<AgentPlayerRow | null>(null);
const transferAmount = ref(100);
const transferContext = ref<WalletTransferContextData | null>(null);
const transferContextLoading = ref(false);
onMounted(load);
/* ─── Sub-agents ─── */
const subAgentKeyword = ref('');
const subAgents = ref<AgentSubAgentRow[]>([]);
const loadingAgents = ref(false);
const subAgentTableRef = ref<TableInstance>();
async function load() {
const { data } = await api.get('/agent/players');
players.value = data.data as PlayerRow[];
/* Sub-agent expansion */
const expandedSet = ref(new Set<string>());
const subAgentPlayersMap = ref<Record<string, AgentPlayerRow[]>>({});
const subAgentExpandLoading = ref<Record<string, boolean>>({});
/* Sub-agent create dialog */
const createSubVisible = ref(false);
const createSubLoading = ref(false);
const createSubForm = ref(emptyAgentSubAgentCreateForm());
const editSubVisible = ref(false);
const editSubLoading = ref(false);
const editSubForm = ref(emptyAgentSubAgentEditForm());
const creditVisible = ref(false);
const creditLoading = ref(false);
const creditTarget = ref<AgentSubAgentRow | null>(null);
const creditAmount = ref(10000);
const creditRemark = ref('');
const creditContext = ref<AgentCreditAdjustContext | null>(null);
/* ─── Computed ─── */
const availableCreditNum = computed(() => {
const n = Number(profile.value.availableCredit ?? 0);
return Number.isFinite(n) ? Math.max(0, n) : 0;
});
const initialDepositRange = computed(() => ({ min: 0, max: availableCreditNum.value }));
const filteredPlayers = computed(() => {
const q = keyword.value.trim().toLowerCase();
if (!q) return players.value;
return players.value.filter(
(p) => p.username.toLowerCase().includes(q) || String(p.id).includes(q),
);
});
const filteredSubAgents = computed(() => {
const q = subAgentKeyword.value.trim().toLowerCase();
if (!q) return subAgents.value;
return subAgents.value.filter(
(a) => a.username.toLowerCase().includes(q) || a.userId.includes(q),
);
});
const transferAmountRange = computed(() => {
if (transferType.value === 'withdraw') {
const cap = parsePlayerAvailable(transferContext.value);
if (cap <= 0) return { min: 0, max: 0 };
return { min: Math.min(0.01, cap), max: cap };
}
const cap = depositAmountCap(transferContext.value);
if (cap === undefined) return { min: 0.01, max: undefined as number | undefined };
if (cap <= 0) return { min: 0, max: 0 };
return { min: Math.min(0.01, cap), max: cap };
});
const transferAmountDisabled = computed(() => {
const { max } = transferAmountRange.value;
return max === 0;
});
/* ─── Init ─── */
onMounted(async () => {
await loadProfile();
await loadPlayers();
if (isTier1.value) {
await loadSubAgents();
}
});
async function loadProfile() {
try {
const { data } = await api.get('/agent/profile');
profile.value = data.data;
} catch { /* empty */ }
}
async function create() {
if (!form.value.username.trim()) {
ElMessage.warning(t('err.username_required'));
async function loadPlayers() {
loadingPlayers.value = true;
try {
const { data } = await api.get('/agent/players');
players.value = data.data as AgentPlayerRow[];
} finally {
loadingPlayers.value = false;
}
}
async function loadSubAgents() {
loadingAgents.value = true;
try {
const { data } = await api.get('/agent/agents');
const items = data.data;
if (Array.isArray(items)) {
subAgents.value = items as AgentSubAgentRow[];
} else {
subAgents.value = [];
}
} catch {
subAgents.value = [];
} finally {
loadingAgents.value = false;
}
}
/* ─── Sub-agent expansion ─── */
async function onSubAgentExpand(row: AgentSubAgentRow, expandedRows: AgentSubAgentRow[]) {
expandedSet.value = new Set(expandedRows.map(r => r.userId));
if (expandedSet.value.has(row.userId) && !subAgentPlayersMap.value[row.userId]) {
await loadSubAgentPlayers(row.userId);
}
}
function onSubAgentRowClick(row: AgentSubAgentRow, _column: unknown, event: MouseEvent) {
if (!shouldToggleExpandOnRowClick(event)) return;
subAgentTableRef.value?.toggleRowExpansion(row);
}
async function loadSubAgentPlayers(subAgentUserId: string) {
subAgentExpandLoading.value[subAgentUserId] = true;
try {
const { data } = await api.get(`/agent/agents/${subAgentUserId}/players`);
subAgentPlayersMap.value[subAgentUserId] = data.data as AgentPlayerRow[];
} catch {
subAgentPlayersMap.value[subAgentUserId] = [];
} finally {
subAgentExpandLoading.value[subAgentUserId] = false;
}
}
function getSubAgentPlayers(userId: string) {
return subAgentPlayersMap.value[userId] || [];
}
/* ─── Player CRUD ─── */
function openCreate() {
createForm.value = emptyAgentPlayerCreateForm();
createVisible.value = true;
}
async function submitCreate() {
let payload: ReturnType<typeof buildAgentCreatePlayerPayload>;
try {
payload = buildAgentCreatePlayerPayload(createForm.value);
} catch (e) {
ElMessage.warning(resolveFormError(e, t));
return;
}
if (payload.initialDeposit != null && payload.initialDeposit > availableCreditNum.value) {
ElMessage.warning(t('err.insufficient_credit'));
return;
}
createLoading.value = true;
const password = createForm.value.password;
try {
await api.post('/agent/players', form.value);
ElMessage.success(t('msg.player_created'));
form.value.username = '';
load();
await api.post('/agent/players', payload);
ElMessage.success(t('user.msg.created_with_password', { password }));
createVisible.value = false;
loadPlayers();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.create_failed'));
} finally {
createLoading.value = false;
}
}
function openTransfer(type: 'deposit' | 'withdraw', row: PlayerRow) {
async function openEdit(row: AgentPlayerRow) {
try {
const { data } = await api.get(`/agent/players/${row.id}`);
editForm.value = editFormFromAgentDetail(data.data as AgentPlayerDetail);
editVisible.value = true;
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
}
}
async function submitEdit() {
let payload: ReturnType<typeof buildAgentUpdatePlayerPayload>;
try {
payload = buildAgentUpdatePlayerPayload(editForm.value);
} catch (e) {
ElMessage.warning(resolveFormError(e, t));
return;
}
editLoading.value = true;
const newPwd = editForm.value.newPassword.trim();
try {
const { data } = await api.put(`/agent/players/${editForm.value.id}`, payload);
const updated = data.data as AgentPlayerDetail;
if (newPwd) {
editForm.value.managedPassword = updated.managedPassword ?? newPwd;
editForm.value.newPassword = '';
ElMessage.success(t('user.msg.password_saved', { password: editForm.value.managedPassword }));
loadPlayers();
return;
}
ElMessage.success(t('msg.saved'));
editVisible.value = false;
loadPlayers();
refreshSubAgentPlayers();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
editLoading.value = false;
}
}
/* ─── Freeze ─── */
async function toggleFreeze(row: AgentPlayerRow) {
const freeze = row.status === 'ACTIVE';
const action = freeze ? t('common.freeze') : t('common.unfreeze');
try {
await ElMessageBox.confirm(
t('msg.freeze_confirm_body', { action, name: row.username, extra: freeze ? t('msg.freeze_extra') : '' }),
t('msg.freeze_confirm_title', { action }),
{ type: 'warning', confirmButtonText: action, cancelButtonText: t('common.cancel') },
);
} catch { return; }
try {
await api.put(`/agent/players/${row.id}`, { status: freeze ? 'SUSPENDED' : 'ACTIVE' });
ElMessage.success(t('msg.freeze_done', { action }));
loadPlayers();
refreshSubAgentPlayers();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.freeze_failed', { action }));
}
}
/* ─── Transfer ─── */
async function openTransfer(type: 'deposit' | 'withdraw', row: AgentPlayerRow) {
transferType.value = type;
transferTarget.value = row;
transferAmount.value = 100;
transferContext.value = null;
transferVisible.value = true;
transferContextLoading.value = true;
try {
const { data } = await api.get(`/agent/players/${row.id}/transfer-context`);
transferContext.value = data.data as WalletTransferContextData;
if (type === 'deposit') {
const cap = depositAmountCap(transferContext.value);
transferAmount.value =
cap !== undefined && cap > 0 ? Math.min(100, cap) : cap === undefined ? 100 : 0;
} else {
const cap = parsePlayerAvailable(transferContext.value);
transferAmount.value = cap > 0 ? Math.min(100, cap) : 0;
}
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
transferVisible.value = false;
} finally {
transferContextLoading.value = false;
}
}
async function submitTransfer() {
if (!transferTarget.value) return;
if (transferAmount.value <= 0) {
ElMessage.warning(t('msg.amount_gt_zero'));
if (transferAmount.value <= 0) { ElMessage.warning(t('msg.amount_gt_zero')); return; }
const max = transferAmountRange.value.max;
if (max !== undefined && transferAmount.value > max) {
ElMessage.warning(
transferType.value === 'deposit' ? t('err.insufficient_credit') : t('transfer.context.withdraw_exceed'),
);
return;
}
const playerId = transferTarget.value.id;
@@ -71,7 +363,8 @@ async function submitTransfer() {
ElMessage.success(t('msg.withdraw_ok'));
}
transferVisible.value = false;
load();
loadPlayers();
loadProfile();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.transfer_failed'));
@@ -86,32 +379,222 @@ function transferTitle() {
? t('agent_portal.transfer_title_deposit', { name })
: t('agent_portal.transfer_title_withdraw', { name });
}
/* ─── Create sub-agent ─── */
function openCreateSub() {
createSubForm.value = emptyAgentSubAgentCreateForm();
createSubVisible.value = true;
}
async function submitCreateSub() {
let payload: ReturnType<typeof buildAgentSubAgentCreatePayload>;
try {
payload = buildAgentSubAgentCreatePayload(createSubForm.value);
} catch (e) {
ElMessage.warning(resolveFormError(e, t));
return;
}
createSubLoading.value = true;
try {
await api.post('/agent/agents', payload);
ElMessage.success(t('msg.agent_created'));
createSubVisible.value = false;
loadSubAgents();
loadProfile();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.create_failed'));
} finally {
createSubLoading.value = false;
}
}
async function openEditSub(row: AgentSubAgentRow) {
try {
const { data } = await api.get(`/agent/agents/${row.userId}`);
editSubForm.value = editFormFromSubAgentDetail(data.data as AgentSubAgentDetail);
editSubVisible.value = true;
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
}
}
async function submitEditSub() {
let payload: ReturnType<typeof buildAgentSubAgentUpdatePayload>;
try {
payload = buildAgentSubAgentUpdatePayload(editSubForm.value);
} catch (e) {
ElMessage.warning(resolveFormError(e, t));
return;
}
editSubLoading.value = true;
const newPwd = editSubForm.value.newPassword.trim();
try {
const { data } = await api.put(`/agent/agents/${editSubForm.value.userId}`, payload);
const updated = data.data as AgentSubAgentDetail;
if (newPwd) {
editSubForm.value.managedPassword = updated.managedPassword ?? newPwd;
editSubForm.value.newPassword = '';
ElMessage.success(t('user.msg.password_saved', { password: editSubForm.value.managedPassword }));
loadSubAgents();
return;
}
ElMessage.success(t('msg.saved'));
editSubVisible.value = false;
loadSubAgents();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
editSubLoading.value = false;
}
}
function openCreditSub(row: AgentSubAgentRow) {
creditTarget.value = row;
creditAmount.value = 10000;
creditRemark.value = '';
creditContext.value = {
target: snapshotFromAgentRow(row),
parent: snapshotFromAgentRow({
username: auth.user?.username ?? t('credit.context.acting_agent'),
creditLimit: String(profile.value.creditLimit ?? 0),
usedCredit: String(profile.value.usedCredit ?? 0),
availableCredit: String(profile.value.availableCredit ?? 0),
}),
};
creditVisible.value = true;
}
async function submitCreditSub() {
if (!creditTarget.value) return;
if (creditAmount.value === 0) {
ElMessage.warning(t('msg.credit_zero'));
return;
}
if (creditAmount.value > 0 && creditAmount.value > availableCreditNum.value) {
ElMessage.warning(t('err.insufficient_credit'));
return;
}
creditLoading.value = true;
try {
await api.post(`/agent/agents/${creditTarget.value.userId}/credit`, {
amount: creditAmount.value,
requestId: `agent-credit-${creditTarget.value.userId}-${Date.now()}`,
remark: creditRemark.value.trim() || undefined,
});
ElMessage.success(t('msg.credit_adjusted'));
creditVisible.value = false;
loadSubAgents();
loadProfile();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.credit_adjust_failed'));
} finally {
creditLoading.value = false;
}
}
async function toggleFreezeSub(row: AgentSubAgentRow) {
const accountStatus = subAgentAccountStatus(row);
const freeze = accountStatus === 'ACTIVE';
const action = freeze ? t('common.freeze') : t('common.unfreeze');
try {
await ElMessageBox.confirm(
t('msg.freeze_confirm_body', { action, name: row.username, extra: freeze ? t('msg.freeze_extra') : '' }),
t('msg.freeze_confirm_title', { action }),
{ type: 'warning', confirmButtonText: action, cancelButtonText: t('common.cancel') },
);
} catch { return; }
try {
await api.put(`/agent/agents/${row.userId}`, { status: freeze ? 'SUSPENDED' : 'ACTIVE' });
ElMessage.success(t('msg.freeze_done', { action }));
loadSubAgents();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.freeze_failed', { action }));
}
}
/* ─── Helpers ─── */
function refreshSubAgentPlayers() {
for (const uid of expandedSet.value) {
loadSubAgentPlayers(uid);
}
}
function creditLine(row: AgentSubAgentRow) {
return `${formatAmount(row.creditLimit)} / ${formatAmount(row.usedCredit)} / ${formatAmount(row.availableCredit)}`;
}
function creditLineFull(row: AgentSubAgentRow) {
return `${formatAmountFull(row.creditLimit)} / ${formatAmountFull(row.usedCredit)} / ${formatAmountFull(row.availableCredit)}`;
}
function formatTime(v: string | null | undefined) {
if (!v) return '—';
return new Date(v).toLocaleString(localeTag.value, {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
});
}
function statusLabel(status: string) {
const key = `user.status.${status}`;
const label = t(key);
return label === key ? status : label;
}
function statusTagType(s: string) {
return s === 'ACTIVE' ? 'success' : 'warning';
}
</script>
<template>
<div class="admin-list-page">
<div class="page-header">
<h2 class="page-title">{{ t('page.agent_players.title') }}</h2>
<span class="page-desc">{{ t('page.agent_players.desc') }}</span>
<div class="admin-list-page agent-portal-mgr">
<!-- Credit strip -->
<div class="credit-strip">
<div class="credit-item">
<span class="credit-label">{{ t('agent.field.available_credit') }}</span>
<span class="credit-value c-green">{{ formatAmount(profile.availableCredit) }}</span>
</div>
<div class="credit-divider" />
<div class="credit-item">
<span class="credit-label">{{ t('agent.field.used_credit') }}</span>
<span class="credit-value">{{ formatAmount(profile.usedCredit) }}</span>
</div>
<div class="credit-divider" />
<div class="credit-item">
<span class="credit-label">{{ t('agent.field.credit_limit') }}</span>
<span class="credit-value">{{ formatAmount(profile.creditLimit) }}</span>
</div>
</div>
<el-card class="tool-card" shadow="never">
<div class="tool-section-title">{{ t('agent_portal.create_player_section') }}</div>
<el-form inline>
<el-form-item :label="t('user.col.username')">
<el-input v-model="form.username" :placeholder="t('agent_portal.username_ph')" style="width: 160px" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="create">{{ t('agent_portal.create_player_btn') }}</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- Top-level tabs -->
<el-tabs v-model="activeTab" class="portal-top-tabs">
<!-- Tab: 直属玩家 -->
<el-tab-pane :label="`${t('nav.players')} (${players.length})`" name="players">
<div class="inner-toolbar">
<el-form inline size="small" style="flex: 1">
<el-form-item :label="t('common.search')">
<el-input v-model="keyword" :placeholder="t('agent_portal.search_player_ph')" clearable style="width: 180px" />
</el-form-item>
</el-form>
<el-button type="primary" size="small" @click="openCreate">
+ {{ t('agent_portal.create_player_btn') }}
</el-button>
</div>
<el-card class="data-card" shadow="never">
<div class="table-wrap">
<el-table :data="players" stripe>
<el-table-column prop="id" :label="t('common.col_id')" width="72" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
<el-table v-loading="loadingPlayers" :data="filteredPlayers" stripe size="small" class="inner-table">
<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('common.status')" width="80" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('user.field.available')" min-width="100" align="right">
<template #default="{ row }">
<template v-if="row.wallet?.availableBalance != null">
@@ -122,74 +605,410 @@ function transferTitle() {
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="168" align="center" fixed="right">
<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="300" align="center" fixed="right">
<template #default="{ row }">
<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>
<div class="action-btns">
<el-button size="small" type="primary" link @click="openEdit(row)">{{ t('common.edit') }}</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>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</el-tab-pane>
<el-dialog v-model="transferVisible" :title="transferTitle()" width="360px" destroy-on-close>
<el-form label-width="72px">
<!-- Tab: 下级代理 (仅一级代理可见) -->
<el-tab-pane v-if="isTier1" :label="`${t('nav.subAgents')} (${subAgents.length})`" name="subAgents">
<div class="inner-toolbar">
<el-form inline size="small" style="flex: 1">
<el-form-item :label="t('common.search')">
<el-input v-model="subAgentKeyword" :placeholder="t('agent_portal.search_sub_agent_ph')" clearable style="width: 180px" />
</el-form-item>
</el-form>
<el-button type="primary" size="small" @click="openCreateSub">
+ {{ t('agent_portal.create_tier2_btn') }}
</el-button>
</div>
<el-table
ref="subAgentTableRef"
v-loading="loadingAgents"
:data="filteredSubAgents"
stripe
row-key="userId"
:row-class-name="expandableTableRowClassName"
class="inner-table expandable-table"
@expand-change="onSubAgentExpand"
@row-click="onSubAgentRowClick"
>
<template #empty><div class="empty-hint">{{ t('agent_portal.no_sub_agents') || '暂无下级代理' }}</div></template>
<el-table-column type="expand">
<template #default="{ row }">
<div class="expand-panel">
<div v-if="subAgentExpandLoading[row.userId]" class="expand-loading">
{{ t('common.loading') || '加载中...' }}
</div>
<template v-else>
<div class="expand-section-title">{{ t('nav.players') }} ({{ getSubAgentPlayers(row.userId).length }})</div>
<p class="expand-readonly-hint">{{ t('agent_portal.sub_agent_players_readonly') }}</p>
<el-table :data="getSubAgentPlayers(row.userId)" stripe size="small" class="inner-table nested-table">
<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('common.status')" width="80" align="center">
<template #default="{ row: p }">
<el-tag :type="statusTagType(p.status)" size="small">{{ statusLabel(p.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('user.field.available')" min-width="100" align="right">
<template #default="{ row: p }">
<template v-if="p.wallet?.availableBalance != null">
<span>{{ formatAmount(p.wallet.availableBalance) }}</span>
</template>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('user.col.created')" min-width="148">
<template #default="{ row: p }">{{ formatTime(p.createdAt) }}</template>
</el-table-column>
</el-table>
</template>
</div>
</template>
</el-table-column>
<el-table-column prop="userId" label="ID" width="60" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
<el-table-column :label="t('common.status')" width="80" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(subAgentAccountStatus(row))" size="small">
{{ statusLabel(subAgentAccountStatus(row)) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('agent.col.credit')" min-width="150" align="right">
<template #default="{ row }">
<el-tooltip :content="creditLineFull(row)" placement="top">
<span>{{ creditLine(row) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" width="80" align="center" />
<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">
<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>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<!-- DIALOGS -->
<!-- Create Player -->
<el-dialog v-model="createVisible" :title="t('agent_portal.create_player_dialog')" width="520px" destroy-on-close>
<el-alert
type="info" :closable="false" show-icon class="create-alert"
:title="t('agent_portal.credit_available_hint', { amount: formatAmount(profile.availableCredit) })"
/>
<el-form label-width="100px" class="create-form">
<el-form-item :label="t('user.col.username')" required>
<el-input v-model="createForm.username" :placeholder="t('agent_portal.username_ph')" />
</el-form-item>
<el-form-item :label="t('user.field.password')" required>
<el-input v-model="createForm.password" type="text" autocomplete="off" />
</el-form-item>
<el-form-item :label="t('user.field.confirm_password')" required>
<el-input v-model="createForm.confirmPassword" type="text" autocomplete="off" />
</el-form-item>
<el-form-item :label="t('user.field.phone')">
<el-input v-model="createForm.phone" :placeholder="t('common.optional')" />
</el-form-item>
<el-form-item :label="t('user.field.email')">
<el-input v-model="createForm.email" :placeholder="t('common.optional')" />
</el-form-item>
<el-form-item :label="t('user.field.initial_balance')">
<el-input-number v-model="createForm.initialDeposit" :min="initialDepositRange.min" :max="initialDepositRange.max" :step="100" style="width: 100%" />
<div class="field-hint">{{ t('agent_portal.initial_deposit_hint') }}</div>
</el-form-item>
<el-form-item v-if="createForm.initialDeposit > 0" :label="t('user.field.deposit_remark')">
<el-input v-model="createForm.remark" :placeholder="t('user.ph.remark_initial')" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="createLoading" @click="submitCreate">{{ t('user.btn.create') }}</el-button>
</template>
</el-dialog>
<!-- Edit Player -->
<el-dialog v-model="editVisible" :title="t('agent_portal.edit_player_dialog')" width="480px" destroy-on-close class="user-edit-dialog">
<el-form label-width="84px" size="small" class="compact-edit-form">
<div class="edit-meta">
<span>ID {{ editForm.id }}</span>
<el-tag :type="statusTagType(editForm.status)" size="small">{{ statusLabel(editForm.status) }}</el-tag>
</div>
<el-form-item :label="t('user.col.username')">
<el-input v-model="editForm.username" :placeholder="t('user.ph.username_unique')" />
</el-form-item>
<div class="password-mgmt-block">
<div class="block-title">{{ t('user.section.password_mgmt') }}</div>
<el-form-item :label="t('user.field.current_password')">
<span v-if="editForm.managedPassword" class="password-plain">{{ editForm.managedPassword }}</span>
<span v-else class="password-empty"></span>
</el-form-item>
<p v-if="!editForm.managedPassword" class="field-hint block-hint">{{ t('user.hint.password_reset_to_view') }}</p>
<el-form-item :label="t('user.field.reset_password')">
<el-input v-model="editForm.newPassword" type="text" autocomplete="off" :placeholder="t('user.ph.reset_password_short')" />
</el-form-item>
</div>
<el-form-item :label="t('user.field.phone')">
<el-input v-model="editForm.phone" :placeholder="t('common.optional')" />
</el-form-item>
<el-form-item :label="t('user.field.email')">
<el-input v-model="editForm.email" :placeholder="t('common.optional')" />
</el-form-item>
<el-descriptions :column="2" size="small" border class="edit-stats">
<el-descriptions-item :label="t('user.field.available')">{{ formatAmount(editForm.availableBalance) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.frozen_balance')">{{ formatAmount(editForm.frozenBalance) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.bets_summary')">
{{ t('user.bets_edit_value', { n: editForm.betCount, stake: formatAmount(editForm.totalStake) }) }}
</el-descriptions-item>
<el-descriptions-item :label="t('user.field.total_payout')">{{ formatAmount(editForm.totalReturn) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.col.last_login')" :span="2">
{{ editForm.lastLoginAt ? formatTime(editForm.lastLoginAt) : t('common.never_login') }}
· {{ t('user.login_fail_value', { n: editForm.loginFailCount }) }}
</el-descriptions-item>
</el-descriptions>
</el-form>
<template #footer>
<el-button size="small" @click="editVisible = false">{{ t('common.cancel') }}</el-button>
<el-button size="small" type="primary" :loading="editLoading" @click="submitEdit">{{ t('user.btn.save_profile') }}</el-button>
</template>
</el-dialog>
<!-- Transfer (Deposit/Withdraw) -->
<el-dialog v-model="transferVisible" :title="transferTitle()" width="520px" destroy-on-close>
<WalletTransferContext
:context="transferContext"
:mode="transferType"
:loading="transferContextLoading"
/>
<el-form label-width="88px">
<el-form-item :label="t('common.col_id')">
<span>{{ transferTarget?.id }}</span>
</el-form-item>
<el-form-item :label="t('user.field.amount')">
<el-input-number
v-model="transferAmount"
:min="0.01"
:step="10"
:precision="2"
style="width: 100%"
:min="transferAmountRange.min" :max="transferAmountRange.max"
:disabled="transferAmountDisabled" :step="10" :precision="2" style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="transferVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="transferLoading" @click="submitTransfer">
{{ t('common.confirm') }}
</el-button>
<el-button type="primary" :loading="transferLoading" :disabled="transferAmountDisabled" @click="submitTransfer">{{ t('common.confirm') }}</el-button>
</template>
</el-dialog>
<!-- Create Sub-Agent -->
<el-dialog v-model="createSubVisible" :title="t('agent_portal.create_tier2_btn')" width="480px" destroy-on-close>
<el-form label-width="100px">
<el-form-item :label="t('user.col.username')" required>
<el-input v-model="createSubForm.username" :placeholder="t('agent_portal.agent_username_ph')" />
</el-form-item>
<el-form-item :label="t('user.field.password')" required>
<el-input v-model="createSubForm.password" type="text" autocomplete="off" />
</el-form-item>
<el-form-item :label="t('user.field.confirm_password')" required>
<el-input v-model="createSubForm.confirmPassword" type="text" autocomplete="off" />
</el-form-item>
<el-form-item :label="t('agent.field.credit_limit')" required>
<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>
</el-form-item>
<el-form-item :label="t('agent.field.max_daily_deposit')">
<el-input-number v-model="createSubForm.maxDailyDeposit" :min="0" :step="1000" style="width: 100%" />
<div class="field-hint">{{ t('agent.hint.deposit_limit_empty') }}</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createSubVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="createSubLoading" @click="submitCreateSub">{{ t('user.btn.create') }}</el-button>
</template>
</el-dialog>
<!-- Edit Sub-Agent -->
<el-dialog v-model="editSubVisible" :title="t('agent.dialog.edit')" width="480px" destroy-on-close>
<el-form label-width="84px" size="small" class="compact-edit-form">
<div class="edit-meta">
<span>ID {{ editSubForm.userId }}</span>
<el-tag :type="statusTagType(editSubForm.status)" size="small">{{ statusLabel(editSubForm.status) }}</el-tag>
<el-tag size="small" type="info">L{{ editSubForm.level }}</el-tag>
</div>
<el-form-item :label="t('user.col.username')">
<el-input v-model="editSubForm.username" :placeholder="t('user.ph.username_unique')" />
</el-form-item>
<div class="password-mgmt-block">
<div class="block-title">{{ t('user.section.password_mgmt') }}</div>
<el-form-item :label="t('user.field.current_password')">
<span v-if="editSubForm.managedPassword" class="password-plain">{{ editSubForm.managedPassword }}</span>
<span v-else class="password-empty"></span>
</el-form-item>
<el-form-item :label="t('user.field.reset_password')">
<el-input v-model="editSubForm.newPassword" type="text" autocomplete="off" :placeholder="t('user.ph.reset_password_short')" />
</el-form-item>
</div>
<el-form-item :label="t('user.field.phone')">
<el-input v-model="editSubForm.phone" :placeholder="t('common.optional')" />
</el-form-item>
<el-form-item :label="t('user.field.email')">
<el-input v-model="editSubForm.email" :placeholder="t('common.optional')" />
</el-form-item>
<el-descriptions :column="2" size="small" border class="edit-stats">
<el-descriptions-item :label="t('agent.field.credit_limit')">{{ formatAmount(editSubForm.creditLimit) }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.field.available_credit')">{{ formatAmount(editSubForm.availableCredit) }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.col.direct_players')">{{ editSubForm.directPlayerCount }}</el-descriptions-item>
<el-descriptions-item :label="t('user.col.last_login')">
{{ editSubForm.lastLoginAt ? formatTime(editSubForm.lastLoginAt) : t('common.never_login') }}
</el-descriptions-item>
</el-descriptions>
</el-form>
<template #footer>
<el-button size="small" @click="editSubVisible = false">{{ t('common.cancel') }}</el-button>
<el-button size="small" type="primary" :loading="editSubLoading" @click="submitEditSub">{{ t('user.btn.save_profile') }}</el-button>
</template>
</el-dialog>
<!-- Adjust Sub-Agent Credit -->
<el-dialog
v-model="creditVisible"
:title="t('agent_portal.adjust_credit_dialog', { name: creditTarget?.username ?? '' })"
width="520px"
destroy-on-close
>
<AgentCreditContext
:context="creditContext"
:adjust-amount="creditAmount"
/>
<el-form label-width="88px">
<el-form-item :label="t('common.col_id')">
<span>{{ creditTarget?.userId }}</span>
</el-form-item>
<el-form-item :label="t('user.field.amount')">
<el-input-number v-model="creditAmount" :step="1000" :precision="2" style="width: 100%" />
<div class="field-hint">{{ t('agent_portal.credit_adjust_hint') }}</div>
</el-form-item>
<el-form-item :label="t('user.field.remark')">
<el-input v-model="creditRemark" :placeholder="t('common.optional')" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="creditVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="creditLoading" @click="submitCreditSub">{{ t('common.confirm') }}</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-header {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 20px;
/* ─── Credit strip ─── */
.credit-strip {
display: flex; align-items: center; gap: 20px;
padding: 14px 18px; margin-bottom: 16px;
border-radius: 12px; border: 1px solid #1e1e1e;
background: linear-gradient(135deg, rgba(30,30,30,.6), rgba(20,20,20,.4));
}
.page-title {
font-size: 20px;
font-weight: 700;
color: #e0e0e0;
.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-divider { width: 1px; height: 32px; background: #2a2a2a; }
/* ─── Tabs ─── */
.portal-top-tabs { margin-bottom: 8px; }
.portal-top-tabs :deep(.el-tabs__item) { font-size: 14px; }
/* ─── 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;
}
.page-desc {
font-size: 13px;
color: #3a3a3a;
/* ─── Expansion ─── */
.expand-panel { padding: 4px 16px 8px; }
.expand-loading { text-align: center; padding: 16px; color: #888; font-size: 13px; }
.expand-section-title { font-size: 13px; font-weight: 600; color: #888; margin-bottom: 6px; }
.expand-readonly-hint { font-size: 12px; color: #999; margin: 0 0 8px; }
.nested-table { margin-bottom: 4px; }
/* ─── Shared ─── */
.field-hint { margin-top: 6px; font-size: 12px; color: #666; line-height: 1.4; }
.empty-hint { padding: 32px 0; color: #666; font-size: 13px; }
.create-alert { margin-bottom: 16px; }
.create-form { margin-top: 4px; }
/* ─── Edit dialog ─── */
.edit-meta { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; font-size: 12px; color: #666; }
.password-mgmt-block {
margin: 8px 0 16px; padding: 12px 14px;
border-radius: 8px; border: 1px solid #1e1e1e;
background: rgba(255,255,255,.02);
}
.tool-card {
margin-bottom: 16px;
border-radius: 12px;
.block-title { font-size: 12px; font-weight: 600; color: #888; margin-bottom: 10px; }
.password-plain { font-family: ui-monospace, monospace; color: #e0e0e0; }
.password-empty { color: #555; }
.block-hint { margin: -4px 0 10px; }
.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);
}
.data-card {
border-radius: 12px;
}
.tool-section-title {
font-size: 13px;
font-weight: 600;
color: #666;
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
.agent-portal-mgr .el-button.is-link.el-button--warning:hover,
.agent-portal-mgr .el-button.is-link.el-button--warning:focus {
color: #ffffff !important;
background: linear-gradient(165deg, #f0bc62 0%, #d48a28 42%, #a86814 100%) !important;
border-color: rgba(240, 188, 98, 0.55) !important;
}
</style>

View File

@@ -1,26 +1,180 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useAdminLocale } from '../../composables/useAdminLocale';
import api from '../../api';
import { ElMessage } from 'element-plus';
import { formatAmount, formatAmountFull } from '../../utils/format-amount';
import { resolveFormError } from '../../i18n/form-validation';
import { useAuthStore } from '../../stores/auth';
import AgentCreditContext from '../../components/AgentCreditContext.vue';
import {
snapshotFromAgentRow,
type AgentCreditAdjustContext,
} from '../../utils/agent-credit-context';
import {
emptyAgentSubAgentCreateForm,
buildAgentSubAgentCreatePayload,
type AgentSubAgentRow,
} from './agent-sub-agent-form';
const { t } = useAdminLocale();
const { t, localeTag } = useAdminLocale();
const auth = useAuthStore();
const agents = ref<unknown[]>([]);
const form = ref({ username: '', password: 'Agent@123', creditLimit: 10000 });
const agents = ref<AgentSubAgentRow[]>([]);
const loading = ref(false);
const keyword = ref('');
const profile = ref<{ creditLimit?: string; usedCredit?: string; availableCredit?: string }>({});
const createVisible = ref(false);
const createLoading = ref(false);
const createForm = ref(emptyAgentSubAgentCreateForm());
const creditVisible = ref(false);
const creditLoading = ref(false);
const creditTarget = ref<AgentSubAgentRow | null>(null);
const creditAmount = ref(10000);
const creditRemark = ref('');
const creditContext = ref<AgentCreditAdjustContext | null>(null);
const availableCreditNum = computed(() => {
const n = Number(profile.value.availableCredit ?? 0);
return Number.isFinite(n) ? Math.max(0, n) : 0;
});
const createCreditRange = computed(() => ({
min: 0,
max: availableCreditNum.value,
}));
const filteredAgents = computed(() => {
const q = keyword.value.trim().toLowerCase();
if (!q) return agents.value;
return agents.value.filter(
(a) =>
a.username.toLowerCase().includes(q) ||
a.userId.includes(q),
);
});
onMounted(load);
async function load() {
const { data } = await api.get('/agent/agents');
agents.value = data.data;
loading.value = true;
try {
const [agentsRes, profileRes] = await Promise.all([
api.get('/agent/agents'),
api.get('/agent/profile'),
]);
agents.value = (agentsRes.data.data ?? []) as AgentSubAgentRow[];
profile.value = profileRes.data.data as typeof profile.value;
} finally {
loading.value = false;
}
}
async function create() {
await api.post('/agent/agents', form.value);
ElMessage.success(t('msg.agent_sub_created'));
load();
function openCreate() {
createForm.value = emptyAgentSubAgentCreateForm();
createVisible.value = true;
}
async function submitCreate() {
let payload: ReturnType<typeof buildAgentSubAgentCreatePayload>;
try {
payload = buildAgentSubAgentCreatePayload(createForm.value);
} catch (e) {
ElMessage.warning(resolveFormError(e, t));
return;
}
if (payload.creditLimit > availableCreditNum.value) {
ElMessage.warning(t('err.insufficient_credit'));
return;
}
createLoading.value = true;
const password = createForm.value.password;
try {
await api.post('/agent/agents', payload);
ElMessage.success(t('user.msg.created_with_password', { password }));
createVisible.value = false;
load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.create_failed'));
} finally {
createLoading.value = false;
}
}
function openCredit(row: AgentSubAgentRow) {
creditTarget.value = row;
creditAmount.value = 10000;
creditRemark.value = '';
creditContext.value = {
target: snapshotFromAgentRow(row),
parent: snapshotFromAgentRow({
username: auth.user?.username ?? t('credit.context.acting_agent'),
creditLimit: String(profile.value.creditLimit ?? 0),
usedCredit: String(profile.value.usedCredit ?? 0),
availableCredit: String(profile.value.availableCredit ?? 0),
}),
};
creditVisible.value = true;
}
async function submitCredit() {
if (!creditTarget.value) return;
if (creditAmount.value === 0) {
ElMessage.warning(t('msg.credit_zero'));
return;
}
if (creditAmount.value > 0 && creditAmount.value > availableCreditNum.value) {
ElMessage.warning(t('err.insufficient_credit'));
return;
}
creditLoading.value = true;
try {
await api.post(`/agent/agents/${creditTarget.value.userId}/credit`, {
amount: creditAmount.value,
requestId: `agent-credit-${creditTarget.value.userId}-${Date.now()}`,
remark: creditRemark.value.trim() || undefined,
});
ElMessage.success(t('msg.credit_adjusted'));
creditVisible.value = false;
load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.credit_adjust_failed'));
} finally {
creditLoading.value = false;
}
}
function creditLine(row: AgentSubAgentRow) {
return `${formatAmount(row.creditLimit)} / ${formatAmount(row.usedCredit)} / ${formatAmount(row.availableCredit)}`;
}
function creditLineFull(row: AgentSubAgentRow) {
return `${formatAmountFull(row.creditLimit)} / ${formatAmountFull(row.usedCredit)} / ${formatAmountFull(row.availableCredit)}`;
}
function formatTime(v: string | null | undefined) {
if (!v) return '—';
return new Date(v).toLocaleString(localeTag.value, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function statusLabel(status: string) {
const key = `user.status.${status}`;
const label = t(key);
return label === key ? status : label;
}
</script>
@@ -31,52 +185,256 @@ async function create() {
<span class="page-desc">{{ t('page.agent_sub.desc') }}</span>
</div>
<el-card class="tool-card" shadow="never">
<el-form inline>
<el-form-item :label="t('user.col.username')">
<el-input v-model="form.username" :placeholder="t('agent_portal.agent_username_ph')" style="width: 150px" />
</el-form-item>
<el-form-item :label="t('agent.field.credit_limit')">
<el-input-number v-model="form.creditLimit" :min="0" :step="1000" style="width: 150px" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="create">{{ t('agent_portal.create_tier2_btn') }}</el-button>
</el-form-item>
</el-form>
</el-card>
<div class="credit-strip">
<div class="credit-item">
<span class="credit-label">{{ t('agent.field.available_credit') }}</span>
<span class="credit-value c-green">{{ formatAmount(profile.availableCredit) }}</span>
</div>
<div class="credit-divider" />
<div class="credit-item">
<span class="credit-label">{{ t('agent.field.used_credit') }}</span>
<span class="credit-value">{{ formatAmount(profile.usedCredit) }}</span>
</div>
<div class="credit-divider" />
<div class="credit-item">
<span class="credit-label">{{ t('agent.field.credit_limit') }}</span>
<span class="credit-value">{{ formatAmount(profile.creditLimit) }}</span>
</div>
</div>
<el-card class="data-card" shadow="never">
<div class="list-chrome">
<div class="list-chrome__row">
<el-form inline class="list-chrome__grow">
<el-form-item :label="t('common.search')">
<el-input
v-model="keyword"
:placeholder="t('agent_portal.search_sub_agent_ph')"
clearable
style="width: 200px"
/>
</el-form-item>
</el-form>
<div class="list-chrome__actions">
<el-button type="primary" @click="openCreate">
{{ t('agent_portal.create_tier2_btn') }}
</el-button>
</div>
</div>
</div>
<section class="data-card">
<div class="table-wrap">
<el-table :data="agents" stripe>
<el-table-column :label="t('user.col.username')" min-width="140">
<el-table v-loading="loading" :data="filteredAgents" stripe>
<el-table-column prop="userId" :label="t('common.col_id')" width="72" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
<el-table-column :label="t('agent.col.level')" width="72" align="center">
<template #default="{ row }">L{{ row.level }}</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="88" align="center">
<template #default="{ row }">
{{ (row as { user?: { username: string } }).user?.username }}
<el-tag
:type="row.userStatus === 'ACTIVE' ? 'success' : 'info'"
size="small"
effect="plain"
>
{{ statusLabel(row.userStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('agent.field.credit_limit')" min-width="100" align="right">
<el-table-column :label="t('agent.col.credit')" min-width="168" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull((row as { creditLimit: string }).creditLimit)" placement="top">
<span>{{ formatAmount((row as { creditLimit: string }).creditLimit) }}</span>
<el-tooltip :content="creditLineFull(row)" placement="top">
<span class="amount-compact">{{ creditLine(row) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('agent.field.used_credit')" min-width="100" align="right">
<el-table-column :label="t('agent.col.direct_players')" width="96" align="center">
<template #default="{ row }">{{ row.directPlayerCount }}</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="120" align="center" fixed="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull((row as { usedCredit: string }).usedCredit)" placement="top">
<span>{{ formatAmount((row as { usedCredit: string }).usedCredit) }}</span>
</el-tooltip>
<el-button size="small" type="primary" link @click="openCredit(row)">
{{ t('common.adjust_credit') }}
</el-button>
</template>
</el-table-column>
<template #empty>
<div class="empty-hint">{{ t('agent_portal.no_sub_agents') }}</div>
</template>
</el-table>
</div>
</el-card>
</section>
<el-dialog
v-model="createVisible"
:title="t('agent_portal.create_sub_agent_dialog')"
width="520px"
destroy-on-close
>
<el-alert
type="info"
:closable="false"
show-icon
class="create-alert"
:title="t('agent_portal.credit_available_hint', { amount: formatAmount(profile.availableCredit) })"
/>
<el-form label-width="100px" class="create-form">
<el-form-item :label="t('user.col.username')" required>
<el-input v-model="createForm.username" :placeholder="t('agent_portal.agent_username_ph')" />
</el-form-item>
<el-form-item :label="t('user.field.password')" required>
<el-input v-model="createForm.password" type="text" autocomplete="off" />
</el-form-item>
<el-form-item :label="t('user.field.confirm_password')" required>
<el-input v-model="createForm.confirmPassword" type="text" autocomplete="off" />
</el-form-item>
<el-form-item :label="t('agent.field.credit_limit')" required>
<el-input-number
v-model="createForm.creditLimit"
:min="createCreditRange.min"
:max="createCreditRange.max"
:step="1000"
style="width: 100%"
/>
<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>
</el-form-item>
<el-form-item :label="t('agent.field.max_daily_deposit')">
<el-input-number v-model="createForm.maxDailyDeposit" :min="0" :step="1000" style="width: 100%" />
<div class="field-hint">{{ t('agent.hint.deposit_limit_empty') }}</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="createLoading" @click="submitCreate">
{{ t('user.btn.create') }}
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="creditVisible"
:title="t('agent_portal.adjust_credit_dialog', { name: creditTarget?.username ?? '' })"
width="520px"
destroy-on-close
>
<AgentCreditContext
:context="creditContext"
:adjust-amount="creditAmount"
/>
<el-form label-width="88px">
<el-form-item :label="t('common.col_id')">
<span>{{ creditTarget?.userId }}</span>
</el-form-item>
<el-form-item :label="t('user.field.amount')">
<el-input-number
v-model="creditAmount"
:step="1000"
:precision="2"
style="width: 100%"
/>
<div class="field-hint">{{ t('agent_portal.credit_adjust_hint') }}</div>
</el-form-item>
<el-form-item :label="t('user.field.remark')">
<el-input v-model="creditRemark" :placeholder="t('common.optional')" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="creditVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="creditLoading" @click="submitCredit">
{{ t('common.confirm') }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 20px; }
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; }
.page-desc { font-size: 13px; color: #3a3a3a; }
.tool-card { margin-bottom: 16px; border-radius: 12px; }
.data-card { border-radius: 12px; }
.page-header {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 20px;
}
.page-title {
font-size: 20px;
font-weight: 700;
color: #e0e0e0;
}
.page-desc {
font-size: 13px;
color: #666;
}
.credit-strip {
display: flex;
align-items: center;
gap: 20px;
padding: 14px 18px;
margin-bottom: 16px;
border-radius: 12px;
border: 1px solid #1e1e1e;
background: linear-gradient(135deg, rgba(30, 30, 30, 0.6), rgba(20, 20, 20, 0.4));
}
.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-divider {
width: 1px;
height: 32px;
background: #2a2a2a;
}
.data-card {
border-radius: 12px;
border: 1px solid #1e1e1e;
overflow: hidden;
}
.amount-compact {
font-variant-numeric: tabular-nums;
cursor: default;
}
.create-alert {
margin-bottom: 16px;
}
.create-form {
margin-top: 4px;
}
.field-hint {
margin-top: 6px;
font-size: 12px;
color: #666;
line-height: 1.4;
}
.empty-hint {
padding: 32px 0;
color: #666;
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,133 @@
import { FormValidationError } from '../../i18n/form-validation';
export interface AgentPlayerCreateForm {
username: string;
password: string;
confirmPassword: string;
phone: string;
email: string;
initialDeposit: number;
remark: string;
}
export interface AgentPlayerRow {
id: string;
username: string;
status: string;
createdAt: string;
wallet?: { availableBalance: string; frozenBalance?: string };
}
export interface AgentPlayerDetail {
id: string;
username: string;
status: string;
phone: string | null;
email: string | null;
managedPassword: string | null;
availableBalance: string;
frozenBalance: string;
lastLoginAt: string | null;
loginFailCount: number;
betCount: number;
totalStake: string;
totalReturn: string;
createdAt: string;
}
export interface AgentPlayerEditForm {
id: string;
username: string;
status: string;
phone: string;
email: string;
managedPassword: string | null;
newPassword: string;
availableBalance: string;
frozenBalance: string;
betCount: number;
totalStake: string;
totalReturn: string;
lastLoginAt: string | null;
loginFailCount: number;
}
export function emptyAgentPlayerCreateForm(): AgentPlayerCreateForm {
return {
username: '',
password: 'Player@123',
confirmPassword: 'Player@123',
phone: '',
email: '',
initialDeposit: 0,
remark: '',
};
}
export function buildAgentCreatePlayerPayload(form: AgentPlayerCreateForm) {
if (!form.username.trim()) throw new FormValidationError('err.username_required');
if (form.password.length < 8) throw new FormValidationError('err.password_min');
if (form.password !== form.confirmPassword) throw new FormValidationError('err.password_mismatch');
if (form.initialDeposit < 0) throw new FormValidationError('err.amount_negative');
return {
username: form.username.trim(),
password: form.password,
phone: form.phone.trim() || undefined,
email: form.email.trim() || undefined,
initialDeposit: form.initialDeposit > 0 ? form.initialDeposit : undefined,
remark: form.remark.trim() || undefined,
};
}
export function emptyAgentPlayerEditForm(): AgentPlayerEditForm {
return {
id: '',
username: '',
status: 'ACTIVE',
phone: '',
email: '',
managedPassword: null,
newPassword: '',
availableBalance: '0',
frozenBalance: '0',
betCount: 0,
totalStake: '0',
totalReturn: '0',
lastLoginAt: null,
loginFailCount: 0,
};
}
export function editFormFromAgentDetail(d: AgentPlayerDetail): AgentPlayerEditForm {
return {
id: d.id,
username: d.username,
status: d.status,
phone: d.phone ?? '',
email: d.email ?? '',
managedPassword: d.managedPassword,
newPassword: '',
availableBalance: d.availableBalance,
frozenBalance: d.frozenBalance,
betCount: d.betCount,
totalStake: d.totalStake,
totalReturn: d.totalReturn,
lastLoginAt: d.lastLoginAt,
loginFailCount: d.loginFailCount,
};
}
export function buildAgentUpdatePlayerPayload(form: AgentPlayerEditForm) {
if (!form.username.trim()) throw new FormValidationError('err.username_required');
if (form.newPassword && form.newPassword.length < 8) {
throw new FormValidationError('err.password_min');
}
return {
username: form.username.trim(),
phone: form.phone.trim() || undefined,
email: form.email.trim() || undefined,
password: form.newPassword.trim() || undefined,
};
}

View File

@@ -0,0 +1,151 @@
import { FormValidationError } from '../../i18n/form-validation';
export interface AgentSubAgentRow {
userId: string;
username: string;
userStatus: string;
status: string;
level: number;
creditLimit: string;
usedCredit: string;
availableCredit: string;
directPlayerCount: number;
createdAt: string;
}
export interface AgentSubAgentDetail {
userId: string;
username: string;
userStatus: string;
status: string;
level: number;
creditLimit: string;
usedCredit: string;
availableCredit: string;
directPlayerCount: number;
phone: string | null;
email: string | null;
managedPassword: string | null;
loginFailCount: number;
lastLoginAt: string | null;
}
export interface AgentSubAgentEditForm {
userId: string;
username: string;
status: string;
phone: string;
email: string;
managedPassword: string | null;
newPassword: string;
creditLimit: string;
usedCredit: string;
availableCredit: string;
directPlayerCount: number;
loginFailCount: number;
lastLoginAt: string | null;
level: number;
}
export interface AgentSubAgentCreateForm {
username: string;
password: string;
confirmPassword: string;
creditLimit: number;
cashbackRate: number;
maxSingleDeposit: number;
maxDailyDeposit: number;
}
export function emptyAgentSubAgentCreateForm(): AgentSubAgentCreateForm {
return {
username: '',
password: 'Agent@123',
confirmPassword: 'Agent@123',
creditLimit: 10000,
cashbackRate: 0,
maxSingleDeposit: 0,
maxDailyDeposit: 0,
};
}
export function buildAgentSubAgentCreatePayload(form: AgentSubAgentCreateForm) {
if (!form.username.trim()) throw new FormValidationError('err.username_required');
if (form.password.length < 8) throw new FormValidationError('err.password_min');
if (form.password !== form.confirmPassword) throw new FormValidationError('err.password_mismatch');
if (form.creditLimit < 0) throw new FormValidationError('err.amount_negative');
return {
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,
};
}
export function emptyAgentSubAgentEditForm(): AgentSubAgentEditForm {
return {
userId: '',
username: '',
status: 'ACTIVE',
phone: '',
email: '',
managedPassword: null,
newPassword: '',
creditLimit: '0',
usedCredit: '0',
availableCredit: '0',
directPlayerCount: 0,
loginFailCount: 0,
lastLoginAt: null,
level: 2,
};
}
export function editFormFromSubAgentDetail(d: AgentSubAgentDetail): AgentSubAgentEditForm {
return {
userId: d.userId,
username: d.username,
status: d.userStatus ?? d.status,
phone: d.phone ?? '',
email: d.email ?? '',
managedPassword: d.managedPassword ?? null,
newPassword: '',
creditLimit: d.creditLimit,
usedCredit: d.usedCredit,
availableCredit: d.availableCredit,
directPlayerCount: d.directPlayerCount,
loginFailCount: d.loginFailCount ?? 0,
lastLoginAt: d.lastLoginAt ?? null,
level: d.level,
};
}
export function buildAgentSubAgentUpdatePayload(form: AgentSubAgentEditForm) {
if (!form.username.trim()) throw new FormValidationError('err.username_required');
const payload: {
username: string;
phone?: string;
email?: string;
status: string;
password?: string;
} = {
username: form.username.trim(),
status: form.status,
phone: form.phone.trim() || undefined,
email: form.email.trim() || undefined,
};
const newPwd = form.newPassword.trim();
if (newPwd) {
if (newPwd.length < 8) throw new FormValidationError('err.password_min');
payload.password = newPwd;
}
return payload;
}
/** Prefer account lock status over profile status for list/actions. */
export function subAgentAccountStatus(row: Pick<AgentSubAgentRow, 'userStatus' | 'status'>) {
return row.userStatus ?? row.status;
}