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>
560 lines
20 KiB
Vue
560 lines
20 KiB
Vue
<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>
|