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