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

@@ -0,0 +1,137 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useAdminLocale } from '../composables/useAdminLocale';
import { formatAmount, formatAmountFull, shouldCompactAmount } from '../utils/format-amount';
import {
maxCreditIncreaseAmount,
projectedCreditLimit,
type AgentCreditAdjustContext,
} from '../utils/agent-credit-context';
const props = defineProps<{
context: AgentCreditAdjustContext | null;
loading?: boolean;
adjustAmount?: number;
}>();
const { t } = useAdminLocale();
const maxIncrease = computed(() => maxCreditIncreaseAmount(props.context));
const afterLimit = computed(() => projectedCreditLimit(props.context, props.adjustAmount ?? 0));
function fmt(value: string | null | undefined) {
if (value == null || value === '') return '—';
return formatAmount(value);
}
function fmtFull(value: string | null | undefined) {
if (value == null || value === '') return '';
return shouldCompactAmount(value) ? formatAmountFull(value) : '';
}
</script>
<template>
<div v-loading="loading" class="credit-context">
<div v-if="context?.target" class="credit-context-section">
<div class="credit-context-title">{{ t('credit.context.target_section') }}</div>
<el-descriptions :column="2" size="small" border>
<el-descriptions-item :label="t('user.col.username')">
{{ context.target.username }}
<span v-if="context.target.level != null" class="level-tag">L{{ context.target.level }}</span>
</el-descriptions-item>
<el-descriptions-item :label="t('agent.field.available_credit')">
<span class="c-green">{{ fmt(context.target.availableCredit) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="t('agent.field.credit_limit')">{{ fmt(context.target.creditLimit) }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.field.used_credit')">{{ fmt(context.target.usedCredit) }}</el-descriptions-item>
<el-descriptions-item
v-if="context.target.directPlayerLiability != null"
:label="t('credit.context.direct_liability')"
>
{{ fmt(context.target.directPlayerLiability) }}
</el-descriptions-item>
<el-descriptions-item
v-if="context.target.childAgentExposure != null"
:label="t('credit.context.child_exposure')"
>
{{ fmt(context.target.childAgentExposure) }}
</el-descriptions-item>
</el-descriptions>
</div>
<div v-if="context?.parent" class="credit-context-section">
<div class="credit-context-title">
{{ t('credit.context.parent_section', { name: context.parent.username }) }}
</div>
<el-descriptions :column="2" size="small" border>
<el-descriptions-item :label="t('agent.field.credit_limit')">{{ fmt(context.parent.creditLimit) }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.field.used_credit')">{{ fmt(context.parent.usedCredit) }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.field.available_credit')" :span="2">
<span class="c-green">{{ fmt(context.parent.availableCredit) }}</span>
<span v-if="fmtFull(context.parent.availableCredit)" class="amount-full-hint">
{{ fmtFull(context.parent.availableCredit) }}
</span>
</el-descriptions-item>
<el-descriptions-item :label="t('credit.context.max_increase')" :span="2">
<span class="c-amber">{{ fmt(String(maxIncrease ?? 0)) }}</span>
</el-descriptions-item>
</el-descriptions>
</div>
<el-alert
v-else-if="context?.target && !loading"
type="info"
:closable="false"
show-icon
class="credit-context-alert"
:title="t('credit.context.no_parent')"
/>
<div v-if="afterLimit != null && adjustAmount !== 0" class="credit-context-preview">
{{ t('credit.context.after_adjust') }}
<span class="c-amber">{{ fmt(afterLimit) }}</span>
</div>
</div>
</template>
<style scoped>
.credit-context {
margin-bottom: 16px;
min-height: 48px;
}
.credit-context-section + .credit-context-section {
margin-top: 12px;
}
.credit-context-title {
font-size: 12px;
font-weight: 600;
color: #888;
margin-bottom: 8px;
}
.credit-context-alert {
margin-top: 0;
}
.credit-context-preview {
margin-top: 12px;
font-size: 13px;
color: #aaa;
}
.level-tag {
margin-left: 6px;
font-size: 11px;
color: #888;
}
.c-green {
color: #67c23a;
font-weight: 600;
}
.c-amber {
color: #e6a23c;
font-weight: 600;
}
.amount-full-hint {
font-size: 11px;
color: #666;
margin-left: 4px;
}
</style>

View File

@@ -0,0 +1,116 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useAdminLocale } from '../composables/useAdminLocale';
import { formatAmount, formatAmountFull, shouldCompactAmount } from '../utils/format-amount';
import { depositAmountCap, type WalletTransferContext } from '../utils/wallet-transfer-context';
const props = defineProps<{
context: WalletTransferContext | null;
mode: 'deposit' | 'withdraw';
loading?: boolean;
}>();
const { t } = useAdminLocale();
const credit = computed(() => props.context?.credit ?? null);
const player = computed(() => props.context?.player ?? null);
const depositCapText = computed(() => {
if (!props.context?.credit) return '—';
const cap = depositAmountCap(props.context);
if (cap === undefined) return t('transfer.context.unlimited');
return formatAmount(String(cap));
});
function fmt(value: string | null | undefined) {
if (value == null || value === '') return '—';
return formatAmount(value);
}
function fmtFull(value: string | null | undefined) {
if (value == null || value === '') return '';
return shouldCompactAmount(value) ? formatAmountFull(value) : '';
}
function limitLabel(value: string | null) {
return value ? fmt(value) : t('transfer.context.unlimited');
}
</script>
<template>
<div v-loading="loading" class="transfer-context">
<div v-if="player" class="transfer-context-section">
<div class="transfer-context-title">{{ t('transfer.context.player_section') }}</div>
<el-descriptions :column="2" size="small" border>
<el-descriptions-item :label="t('user.col.username')">{{ player.username }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.available')">
<span class="c-green">{{ fmt(player.availableBalance) }}</span>
<span v-if="fmtFull(player.availableBalance)" class="amount-full-hint">
{{ fmtFull(player.availableBalance) }}
</span>
</el-descriptions-item>
<el-descriptions-item v-if="mode === 'withdraw'" :label="t('transfer.context.withdrawable')" :span="2">
{{ fmt(player.availableBalance) }}
</el-descriptions-item>
<el-descriptions-item v-if="Number(player.frozenBalance) > 0" :label="t('user.field.frozen_balance')">
{{ fmt(player.frozenBalance) }}
</el-descriptions-item>
</el-descriptions>
</div>
<div v-if="mode === 'deposit' && credit" class="transfer-context-section">
<div class="transfer-context-title">
{{ t('transfer.context.agent_section', { name: credit.agentUsername, level: credit.agentLevel }) }}
</div>
<el-descriptions :column="2" size="small" border>
<el-descriptions-item :label="t('agent.field.credit_limit')">{{ fmt(credit.creditLimit) }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.field.used_credit')">{{ fmt(credit.usedCredit) }}</el-descriptions-item>
<el-descriptions-item :label="t('agent.field.available_credit')">
<span class="c-green">{{ fmt(credit.availableCredit) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="t('transfer.context.deposit_cap')">
<span class="c-amber">{{ depositCapText }}</span>
</el-descriptions-item>
<template v-if="credit.appliesDepositLimits">
<el-descriptions-item :label="t('agent.field.max_single_deposit')">
{{ limitLabel(credit.maxSingleDeposit) }}
</el-descriptions-item>
<el-descriptions-item :label="t('agent.field.max_daily_deposit')">
{{ limitLabel(credit.maxDailyDeposit) }}
</el-descriptions-item>
<el-descriptions-item v-if="credit.dailyDepositUsed != null" :label="t('transfer.context.daily_used')" :span="2">
{{ fmt(credit.dailyDepositUsed) }}
</el-descriptions-item>
</template>
<el-descriptions-item v-else :span="2">
<span class="field-hint">{{ t('transfer.context.admin_credit_only') }}</span>
</el-descriptions-item>
</el-descriptions>
</div>
<el-alert
v-else-if="mode === 'deposit' && player && !credit && !loading"
type="info"
:closable="false"
show-icon
class="transfer-context-alert"
:title="t('transfer.context.no_agent')"
/>
</div>
</template>
<style scoped>
.transfer-context { margin-bottom: 16px; min-height: 48px; }
.transfer-context-section + .transfer-context-section { margin-top: 12px; }
.transfer-context-title {
font-size: 12px;
font-weight: 600;
color: #888;
margin-bottom: 8px;
}
.transfer-context-alert { margin-bottom: 0; }
.c-green { color: #67c23a; font-weight: 600; }
.c-amber { color: #e6a23c; font-weight: 600; }
.amount-full-hint { font-size: 11px; color: #666; margin-left: 4px; }
.field-hint { font-size: 12px; color: #666; }
</style>