Files
thebet365/apps/admin/src/views/Agents.vue
Mars d5e7c8edb3 feat: add smoke tests, agent credit ledger, and player cashback page
Introduce admin smoke-test suite with API probes, agent credit transaction history, and player cashback records; fix SmokeTestModule DI and polish admin/player UI assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 16:05:48 +08:00

560 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useAdminLocale } from '../composables/useAdminLocale';
import { resolveFormError, resolveApiError } from '../i18n/form-validation';
import api from '../api';
const { t } = useAdminLocale();
const router = useRouter();
import { ElMessage } from 'element-plus';
import {
emptyAgentCreateForm,
emptyAgentEditForm,
editFormFromAgentDetail,
buildCreateAgentPayload,
applyPromotableUserToForm,
type AgentRow,
type AgentDetail,
type AgentCreateForm,
type AgentEditForm,
type PromotableUserOption,
} from './agent-form';
import {
formatAmount,
formatAmountFull,
shouldCompactAmount as shouldCompact,
} from '../utils/format-amount';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import AgentCreditContext from '../components/AgentCreditContext.vue';
import {
fetchAdminAgentCreditContext,
maxCreditIncreaseAmount,
type AgentCreditAdjustContext,
} from '../utils/agent-credit-context';
const agents = ref<AgentRow[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const keyword = ref('');
function creditLine(row: AgentRow) {
return `${formatAmount(row.creditLimit)} / ${formatAmount(row.usedCredit)} / ${formatAmount(row.availableCredit)}`;
}
function creditLineFull(row: AgentRow) {
return `${formatAmountFull(row.creditLimit)} / ${formatAmountFull(row.usedCredit)} / ${formatAmountFull(row.availableCredit)}`;
}
const createVisible = ref(false);
const editVisible = ref(false);
const detailVisible = ref(false);
const creditVisible = ref(false);
const createLoading = ref(false);
const editLoading = ref(false);
const creditLoading = ref(false);
const createForm = ref<AgentCreateForm>(emptyAgentCreateForm());
const promotableUsers = ref<PromotableUserOption[]>([]);
const promotableLoading = ref(false);
const promotableKeyword = ref('');
const editForm = ref<AgentEditForm>(emptyAgentEditForm());
const detail = ref<AgentDetail | null>(null);
const editingId = ref('');
const creditForm = ref({ amount: 10000, remark: '' });
const creditContext = ref<AgentCreditAdjustContext | null>(null);
const creditContextLoading = ref(false);
onMounted(load);
async function load() {
const { data } = await api.get('/admin/agents', {
params: {
page: page.value,
pageSize: pageSize.value,
keyword: keyword.value.trim() || undefined,
},
});
agents.value = data.data.items as AgentRow[];
total.value = data.data.total;
}
function onPageChange(p: number) {
page.value = p;
load();
}
function onSizeChange(size: number) {
pageSize.value = size;
page.value = 1;
load();
}
async function loadPromotableUsers() {
promotableLoading.value = true;
try {
const { data } = await api.get('/admin/users/promotable-for-agent', {
params: { keyword: promotableKeyword.value.trim() || undefined },
});
promotableUsers.value = data.data as PromotableUserOption[];
} finally {
promotableLoading.value = false;
}
}
function openCreate() {
createForm.value = emptyAgentCreateForm();
promotableKeyword.value = '';
createVisible.value = true;
loadPromotableUsers();
}
function onPromotableSearch(q: string) {
promotableKeyword.value = q;
void loadPromotableUsers();
}
function onPromotableUserChange(userId: string) {
const user = promotableUsers.value.find((u) => u.id === userId);
if (user) applyPromotableUserToForm(createForm.value, user);
}
function formatPromotableLabel(u: PromotableUserOption) {
const parent = u.parentUsername ? ` · ${u.parentUsername}` : '';
return `${u.username} (#${u.id})${parent}`;
}
async function openDetail(userId: string) {
const { data } = await api.get(`/admin/agents/${userId}`);
detail.value = data.data as AgentDetail;
detailVisible.value = true;
}
async function openEdit(userId: string) {
const { data } = await api.get(`/admin/agents/${userId}`);
const d = data.data as AgentDetail;
editingId.value = userId;
editForm.value = editFormFromAgentDetail(d);
editVisible.value = true;
}
async function openCredit(row: AgentRow) {
editingId.value = row.userId;
creditForm.value = { amount: 10000, remark: '' };
creditContext.value = null;
creditVisible.value = true;
creditContextLoading.value = true;
try {
creditContext.value = await fetchAdminAgentCreditContext(row.userId);
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
creditVisible.value = false;
} finally {
creditContextLoading.value = false;
}
}
async function submitCreate() {
let payload: ReturnType<typeof buildCreateAgentPayload>;
try {
payload = buildCreateAgentPayload(createForm.value);
} catch (e) {
ElMessage.warning(resolveFormError(e, t));
return;
}
createLoading.value = true;
try {
await api.post('/admin/agents', payload);
ElMessage.success(t('msg.agent_created'));
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;
}
}
async function submitEdit() {
editLoading.value = true;
try {
await api.put(`/admin/agents/${editingId.value}`, {
status: editForm.value.status,
phone: editForm.value.phone.trim() || undefined,
email: editForm.value.email.trim() || undefined,
cashbackRate: editForm.value.cashbackRate,
});
ElMessage.success(t('msg.saved'));
editVisible.value = false;
load();
} 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;
}
}
async function submitCredit() {
if (creditForm.value.amount === 0) {
ElMessage.warning(t('msg.credit_zero'));
return;
}
const maxInc = maxCreditIncreaseAmount(creditContext.value);
if (creditForm.value.amount > 0 && maxInc !== undefined && creditForm.value.amount > maxInc) {
ElMessage.warning(t('err.insufficient_credit'));
return;
}
creditLoading.value = true;
try {
await api.post(`/admin/agents/${editingId.value}/credit`, {
amount: creditForm.value.amount,
requestId: `credit-${editingId.value}-${Date.now()}`,
remark: creditForm.value.remark || undefined,
});
ElMessage.success(t('msg.credit_adjusted'));
creditVisible.value = false;
load();
} catch (e: unknown) {
ElMessage.error(resolveApiError(e, t, 'msg.credit_adjust_failed'));
} finally {
creditLoading.value = false;
}
}
function formatTime(v: string) {
if (!v) return '—';
return new Date(v).toLocaleString('zh-CN');
}
function statusTagType(s: string) {
return s === 'ACTIVE' ? 'success' : 'warning';
}
function statusLabel(s: string) {
const key = `user.status.${s}`;
const v = t(key);
return v !== key ? v : s;
}
function creditTypeLabel(type: string) {
if (type === 'CREDIT_INCREASE') return t('agent.credit.increase');
if (type === 'CREDIT_DECREASE') return t('agent.credit.decrease');
return type;
}
</script>
<template>
<div class="admin-list-page">
<div class="list-chrome">
<div class="list-chrome__row">
<el-form inline class="list-chrome__grow">
<el-form-item :label="t('common.keyword')">
<el-input
v-model="keyword"
:placeholder="t('agent.filter.username_ph')"
clearable
style="width: 180px"
@keyup.enter="load"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">{{ t('common.search') }}</el-button>
</el-form-item>
</el-form>
<div class="list-chrome__actions">
<el-button type="primary" @click="openCreate">{{ t('agent.create_btn') }}</el-button>
</div>
</div>
</div>
<section class="list-panel">
<div class="table-wrap">
<el-table :data="agents" stripe>
<template #empty>
<AdminTableEmpty />
</template>
<el-table-column prop="userId" label="ID" width="72" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
<el-table-column :label="t('common.status')" width="88">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="level" :label="t('agent.col.level')" width="72" align="center" />
<el-table-column :label="t('agent.col.credit')" min-width="168" align="right">
<template #default="{ row }">
<el-tooltip :content="creditLineFull(row)" placement="top">
<span class="amount-compact">{{ creditLine(row) }}</span>
</el-tooltip>
</template>
</el-table-column>
<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>
</el-table-column>
<el-table-column prop="phone" :label="t('agent.col.phone')" min-width="110">
<template #default="{ row }">{{ row.phone ?? '—' }}</template>
</el-table-column>
<el-table-column :label="t('agent.col.created')" min-width="158">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="240" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="openDetail(row.userId)">{{ t('common.detail') }}</el-button>
<el-button size="small" link type="primary" @click="openEdit(row.userId)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="primary" @click="openCredit(row)">{{ t('common.adjust_credit') }}</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
background
@current-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</section>
<el-dialog v-model="createVisible" :title="t('agent.dialog.create')" width="520px" destroy-on-close>
<el-form label-width="100px">
<el-form-item :label="t('agent.field.select_user')" required>
<el-select
v-model="createForm.userId"
filterable
remote
:remote-method="onPromotableSearch"
:loading="promotableLoading"
:placeholder="t('agent.ph.select_user')"
style="width: 100%"
@change="onPromotableUserChange"
>
<el-option
v-for="u in promotableUsers"
:key="u.id"
:label="formatPromotableLabel(u)"
:value="u.id"
/>
</el-select>
<div class="field-hint">{{ t('agent.hint.select_user') }}</div>
</el-form-item>
<el-form-item :label="t('agent.field.credit_limit')" required>
<el-input-number
v-model="createForm.creditLimit"
:min="0"
:step="10000"
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="createForm.cashbackRate"
:min="0"
:max="1"
:step="0.001"
:precision="4"
style="width: 100%"
/>
<div class="field-hint">{{ t('agent.hint.cashback_example') }}</div>
</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>
<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="editVisible" :title="t('agent.dialog.edit')" width="480px" destroy-on-close>
<el-form label-width="88px">
<el-form-item :label="t('user.field.account_status')">
<el-radio-group v-model="editForm.status">
<el-radio value="ACTIVE">{{ t('user.status.ACTIVE') }}</el-radio>
<el-radio value="SUSPENDED">{{ t('user.status.SUSPENDED') }}</el-radio>
</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%"
/>
</el-form-item>
<el-form-item :label="t('user.field.phone')">
<el-input v-model="editForm.phone" />
</el-form-item>
<el-form-item :label="t('user.field.email')">
<el-input v-model="editForm.email" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="editLoading" @click="submitEdit">{{ t('common.save') }}</el-button>
</template>
</el-dialog>
<el-dialog v-model="creditVisible" :title="t('agent.dialog.credit')" width="520px" destroy-on-close>
<AgentCreditContext
:context="creditContext"
:loading="creditContextLoading"
:adjust-amount="creditForm.amount"
/>
<el-form label-width="88px">
<el-form-item :label="t('agent.field.agent_id')">
<el-input :model-value="editingId" disabled />
</el-form-item>
<el-form-item :label="t('agent.field.adjust_amount')">
<el-input-number v-model="creditForm.amount" :step="1000" style="width: 100%" />
<div class="field-hint">{{ t('agent.hint.credit_adjust') }}</div>
</el-form-item>
<el-form-item :label="t('user.field.remark')">
<el-input v-model="creditForm.remark" :placeholder="t('agent.hint.credit_remark')" />
</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('agent.btn.confirm_adjust') }}</el-button>
</template>
</el-dialog>
<el-dialog v-model="detailVisible" :title="t('agent.dialog.detail')" width="640px" destroy-on-close>
<template v-if="detail">
<el-descriptions :column="2" border size="small" class="detail-block">
<el-descriptions-item :label="t('common.col_id')">{{ detail.userId }}</el-descriptions-item>
<el-descriptions-item :label="t('user.col.username')">{{ detail.username }}</el-descriptions-item>
<el-descriptions-item :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('agent.col.level')">L{{ detail.level }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.field.credit_limit')">
{{ formatAmount(detail.creditLimit) }}
<span v-if="shouldCompact(detail.creditLimit)" class="amount-full-hint">
{{ formatAmountFull(detail.creditLimit) }}
</span>
</el-descriptions-item>
<el-descriptions-item :label="t('agent.field.used_credit')">
{{ formatAmount(detail.usedCredit) }}
<span v-if="shouldCompact(detail.usedCredit)" class="amount-full-hint">
{{ formatAmountFull(detail.usedCredit) }}
</span>
</el-descriptions-item>
<el-descriptions-item :label="t('agent.field.available_credit')">
{{ formatAmount(detail.availableCredit) }}
<span v-if="shouldCompact(detail.availableCredit)" class="amount-full-hint">
{{ formatAmountFull(detail.availableCredit) }}
</span>
</el-descriptions-item>
<el-descriptions-item :label="t('agent.col.direct_players')">{{ detail.directPlayerCount }} {{ t('common.people') }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.field.player_liability')">
{{ formatAmount(detail.directPlayerLiability) }}
</el-descriptions-item>
<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('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">
{{ detail.lastLoginAt ? formatTime(detail.lastLoginAt) : t('common.never_login') }}
</el-descriptions-item>
<el-descriptions-item :label="t('agent.col.created')" :span="2">
{{ formatTime(detail.createdAt) }}
</el-descriptions-item>
</el-descriptions>
<div class="section-title section-title--row">
<span>{{ t('agent.section.credit_log') }}</span>
<el-button
v-if="detail"
link
type="primary"
@click="router.push({ path: '/agent-credit-transactions', query: { agentId: detail.userId } })"
>
{{ t('agent.credit_tx.view_all') }}
</el-button>
</div>
<el-table
:data="detail.recentCreditTransactions"
size="small"
stripe
:empty-text="t('agent.col.no_records')"
>
<el-table-column :label="t('agent.col.credit_type')" width="80">
<template #default="{ row }">{{ creditTypeLabel(row.transactionType) }}</template>
</el-table-column>
<el-table-column :label="t('agent.col.credit_change')" width="96" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.amount)" placement="top">
<span>{{ formatAmount(row.amount) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('agent.col.credit_after')" width="96" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.creditAfter)" placement="top">
<span>{{ formatAmount(row.creditAfter) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="remark" :label="t('user.field.remark')" min-width="120" show-overflow-tooltip />
<el-table-column :label="t('audit.col.time')" min-width="150">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
</el-table>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.field-hint { font-size: 12px; color: #888; margin-top: 4px; }
.detail-block { margin-bottom: 16px; }
.section-title {
font-size: 13px;
font-weight: 600;
color: #666;
margin-bottom: 8px;
}
.section-title--row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.amount-compact {
white-space: nowrap;
font-variant-numeric: tabular-nums;
cursor: default;
}
.amount-full-hint {
font-size: 11px;
color: #666;
margin-left: 4px;
}
</style>