feat(admin,api,player): 代理层级管理、额度上下分与玩家钱包详情
新增代理管理器与二级代理体系,完善信用额度/上下分上下文与冻结策略;代理端玩家与子代理管理增强;玩家端新增钱包详情页与交易筛选优化。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user