1695 lines
63 KiB
Vue
1695 lines
63 KiB
Vue
<script setup lang="ts">
|
||
defineOptions({ name: 'AgentPlayers' });
|
||
|
||
import { ref, computed, onMounted, onActivated, watch, reactive, h } from 'vue';
|
||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||
import { useAuthStore } from '../../stores/auth';
|
||
import api from '../../api';
|
||
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 WalletTransferContext from '../../components/WalletTransferContext.vue';
|
||
import InitialDepositRemarkField from '../../components/InitialDepositRemarkField.vue';
|
||
import AgentCreditContext from '../../components/AgentCreditContext.vue';
|
||
import PlayerWalletLedgerDialog from '../../components/PlayerWalletLedgerDialog.vue';
|
||
import InviteManageDialog from '../../components/InviteManageDialog.vue';
|
||
import AdminTableWrap from '../../components/AdminTableWrap.vue';
|
||
import AdminAgentRowActions from '../../components/AdminAgentRowActions.vue';
|
||
import AdminPlayerRowActions from '../../components/AdminPlayerRowActions.vue';
|
||
import AdminDetailGrid from '../../components/AdminDetailGrid.vue';
|
||
import AdminDetailItem from '../../components/AdminDetailItem.vue';
|
||
import {
|
||
depositAmountCap,
|
||
parsePlayerAvailable,
|
||
type WalletTransferContext as WalletTransferContextData,
|
||
} from '../../utils/wallet-transfer-context';
|
||
import {
|
||
snapshotFromAgentRow,
|
||
type AgentCreditAdjustContext,
|
||
} from '../../utils/agent-credit-context';
|
||
import { formatAgentLevelNumeral } from '../../utils/agent-level-label';
|
||
import { formatRatePercent } from '../../utils/rate-percent';
|
||
|
||
type ScopedPlayerRow = AgentPlayerRow & {
|
||
parentAgentId?: string | null;
|
||
parentAgentUsername?: string;
|
||
cashbackRate?: string;
|
||
inChain?: boolean;
|
||
isDirect?: boolean;
|
||
};
|
||
|
||
const { t, localeTag } = useAdminLocale();
|
||
const auth = useAuthStore();
|
||
const inviteDialogOpen = ref(false);
|
||
|
||
const profile = ref<{
|
||
level?: number;
|
||
maxAgentLevel?: number;
|
||
creditLimit?: string;
|
||
usedCredit?: string;
|
||
availableCredit?: string;
|
||
cashbackRate?: string;
|
||
canManageSubAgents?: boolean;
|
||
}>({});
|
||
|
||
const canManageSubAgents = computed(
|
||
() => profile.value.canManageSubAgents === true || auth.canManageSubAgents.value,
|
||
);
|
||
|
||
const myAgentId = computed(() => String(auth.user.value?.id ?? ''));
|
||
const myAgentLevel = computed(() => profile.value.level ?? auth.user.value?.agentLevel ?? 1);
|
||
const childAgentLevel = computed(() => myAgentLevel.value + 1);
|
||
const hierarchySettings = computed(() => ({ maxAgentLevel: profile.value.maxAgentLevel ?? 0 }));
|
||
|
||
function lvlLabel(level: number) {
|
||
return formatAgentLevelNumeral(level, localeTag.value);
|
||
}
|
||
|
||
function agentTierName(level: number) {
|
||
return t('agent.level_name', { level: lvlLabel(level) });
|
||
}
|
||
|
||
function agentLevelTabName(level: number) {
|
||
return `agentLevel-${level}`;
|
||
}
|
||
|
||
const agentLevelCounts = ref<Record<number, number>>({});
|
||
|
||
function agentLevelTabLabel(level: number) {
|
||
const count = agentLevelCounts.value[level] ?? 0;
|
||
return `${agentTierName(level)} (${count})`;
|
||
}
|
||
|
||
const visibleSubAgentTabLevels = computed(() => {
|
||
if (!canManageSubAgents.value) return [] as number[];
|
||
const counts = agentLevelCounts.value;
|
||
const max = hierarchySettings.value.maxAgentLevel;
|
||
const start = childAgentLevel.value;
|
||
const levels = new Set<number>([start]);
|
||
if (max === 0) {
|
||
for (const [lvl, cnt] of Object.entries(counts)) {
|
||
const n = Number(lvl);
|
||
if (n > myAgentLevel.value && cnt > 0) levels.add(n);
|
||
}
|
||
} else {
|
||
for (let n = start + 1; n <= max; n++) {
|
||
if (n > myAgentLevel.value && (counts[n] ?? 0) > 0) levels.add(n);
|
||
}
|
||
}
|
||
return [...levels].sort((a, b) => a - b);
|
||
});
|
||
|
||
const scopedAgentOptions = computed(() =>
|
||
agentOptions.value.filter(
|
||
(a) => a.id === myAgentId.value || a.level > myAgentLevel.value,
|
||
),
|
||
);
|
||
|
||
const createSubBtnLabel = computed(() => {
|
||
if (childAgentLevel.value === 2) return t('agent.create_sub_btn');
|
||
return t('agent.create_level_agent_btn', { level: lvlLabel(childAgentLevel.value) });
|
||
});
|
||
|
||
const createSubDialogTitle = computed(() => {
|
||
if (childAgentLevel.value === 2) return t('agent_portal.create_sub_agent_dialog');
|
||
return t('agent.dialog.create_level_agent', { level: lvlLabel(childAgentLevel.value) });
|
||
});
|
||
|
||
const expandReadonlyHint = computed(() =>
|
||
t('agent_portal.sub_agent_downline_readonly_level', { level: lvlLabel(childAgentLevel.value) }),
|
||
);
|
||
|
||
/* ─── View tab: players | agentLevel-N ─── */
|
||
const activeViewTab = ref('players');
|
||
|
||
/* ─── All players (subtree) ─── */
|
||
const allPlayers = ref<ScopedPlayerRow[]>([]);
|
||
const playerTotal = ref(0);
|
||
const playerPage = ref(1);
|
||
const playerPageSize = ref(20);
|
||
const playerKeyword = ref('');
|
||
const playerFilterStatus = ref('');
|
||
const playerFilterAgent = ref('');
|
||
const loadingPlayers = ref(false);
|
||
const agentOptions = ref<{ id: string; username: string; level: number; parentUsername?: string | null }[]>([]);
|
||
|
||
/* ─── Sub-agent lists by level ─── */
|
||
type SubAgentLevelState = {
|
||
agents: AgentSubAgentRow[];
|
||
total: number;
|
||
page: number;
|
||
pageSize: number;
|
||
keyword: string;
|
||
filterStatus: string;
|
||
};
|
||
|
||
const subAgentLevelState = reactive<Record<number, SubAgentLevelState>>({});
|
||
|
||
function ensureSubAgentState(level: number): SubAgentLevelState {
|
||
if (!subAgentLevelState[level]) {
|
||
subAgentLevelState[level] = {
|
||
agents: [],
|
||
total: 0,
|
||
page: 1,
|
||
pageSize: 20,
|
||
keyword: '',
|
||
filterStatus: '',
|
||
};
|
||
}
|
||
return subAgentLevelState[level];
|
||
}
|
||
|
||
/* ─── Agent row expansion (direct players) ─── */
|
||
const MAX_EXPANDED_AGENT_ROWS = 2;
|
||
const expandedSet = ref(new Set<string>());
|
||
const expandedRowKeys = computed(() => Array.from(expandedSet.value));
|
||
const agentPlayersMap = ref<Record<string, ScopedPlayerRow[]>>({});
|
||
const agentPlayersMeta = ref<
|
||
Record<string, { total: number; page: number; pageSize: number }>
|
||
>({});
|
||
const expandLoading = ref<Record<string, boolean>>({});
|
||
const EXPAND_PLAYER_PAGE_SIZE = 20;
|
||
|
||
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<ScopedPlayerRow | null>(null);
|
||
const transferAmount = ref(100);
|
||
const transferContext = ref<WalletTransferContextData | null>(null);
|
||
const transferContextLoading = ref(false);
|
||
|
||
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 playerTabLabel = computed(() => `${t('user.type.player')} (${playerTotal.value})`);
|
||
|
||
function agentOptionLabel(a: { username: string; id: string; level: number; parentUsername?: string | null }) {
|
||
const chain = a.parentUsername ? `${a.parentUsername} / ${a.username}` : a.username;
|
||
return `L${a.level} ${chain} (#${a.id})`;
|
||
}
|
||
|
||
function isDirectChildAgent(row: AgentSubAgentRow) {
|
||
return row.parentAgentId === myAgentId.value;
|
||
}
|
||
|
||
function canViewPlayer(row: ScopedPlayerRow) {
|
||
return row.inChain !== false;
|
||
}
|
||
|
||
function canOperatePlayer(row: ScopedPlayerRow) {
|
||
return row.isDirect === true && canViewPlayer(row);
|
||
}
|
||
|
||
function directPlayersTabLabel(ownerName: string, count: number) {
|
||
return `${t('agent.direct_players_title', { name: ownerName })} (${count})`;
|
||
}
|
||
|
||
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;
|
||
});
|
||
|
||
/** 勿用 el-input-number :max,否则会静默截断到上限 */
|
||
const transferAmountExceedsCap = computed(() => {
|
||
const max = transferAmountRange.value.max;
|
||
if (max === undefined) return false;
|
||
return transferAmount.value > max;
|
||
});
|
||
|
||
const transferAmountCapError = computed(() => {
|
||
if (!transferAmountExceedsCap.value) return '';
|
||
return transferType.value === 'deposit'
|
||
? t('err.insufficient_credit')
|
||
: t('transfer.context.withdraw_exceed');
|
||
});
|
||
|
||
/* ─── Init ─── */
|
||
onMounted(async () => {
|
||
await loadProfile();
|
||
await loadAgentOptions();
|
||
const tab = activeViewTab.value;
|
||
if (tab === 'players') {
|
||
await loadAllPlayers();
|
||
} else if (canManageSubAgents.value) {
|
||
await reloadSubAgentTabs();
|
||
const m = /^agentLevel-(\d+)$/.exec(tab);
|
||
if (m) loadSubAgentsAtLevel(Number(m[1]));
|
||
}
|
||
});
|
||
|
||
onActivated(() => {
|
||
const tab = activeViewTab.value;
|
||
if (tab === 'players' && allPlayers.value.length > 0) void loadAllPlayers();
|
||
else {
|
||
const m = /^agentLevel-(\d+)$/.exec(tab);
|
||
if (m) {
|
||
const lvl = Number(m[1]);
|
||
const st = subAgentLevelState[lvl];
|
||
if (st?.agents.length) loadSubAgentsAtLevel(lvl);
|
||
}
|
||
}
|
||
});
|
||
|
||
watch(activeViewTab, (tab) => {
|
||
const m = /^agentLevel-(\d+)$/.exec(tab);
|
||
if (m) {
|
||
if (!subAgentLevelState[Number(m[1])]?.agents.length) {
|
||
if (canManageSubAgents.value && !Object.keys(subAgentLevelState).length) {
|
||
void reloadSubAgentTabs().then(() => loadSubAgentsAtLevel(Number(m[1])));
|
||
} else {
|
||
loadSubAgentsAtLevel(Number(m[1]));
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
if (tab === 'players' && !allPlayers.value.length) void loadAllPlayers();
|
||
});
|
||
|
||
watch(visibleSubAgentTabLevels, (levels) => {
|
||
const tab = activeViewTab.value;
|
||
const m = /^agentLevel-(\d+)$/.exec(tab);
|
||
if (!m) return;
|
||
const activeLevel = Number(m[1]);
|
||
if (levels.includes(activeLevel)) {
|
||
const st = subAgentLevelState[activeLevel];
|
||
if (!st?.agents.length) loadSubAgentsAtLevel(activeLevel);
|
||
}
|
||
});
|
||
|
||
async function loadProfile() {
|
||
try {
|
||
const { data } = await api.get('/agent/profile');
|
||
profile.value = data.data;
|
||
} catch { /* empty */ }
|
||
}
|
||
|
||
async function loadAgentOptions() {
|
||
try {
|
||
const { data } = await api.get('/agent/agents/options');
|
||
agentOptions.value = data.data ?? [];
|
||
} catch {
|
||
agentOptions.value = [];
|
||
}
|
||
}
|
||
|
||
async function loadAllPlayers() {
|
||
loadingPlayers.value = true;
|
||
try {
|
||
const { data } = await api.get('/agent/players/scoped', {
|
||
params: {
|
||
page: playerPage.value,
|
||
pageSize: playerPageSize.value,
|
||
keyword: playerKeyword.value.trim() || undefined,
|
||
status: playerFilterStatus.value || undefined,
|
||
parentAgentId: playerFilterAgent.value || undefined,
|
||
},
|
||
});
|
||
allPlayers.value = (data.data.items ?? []) as ScopedPlayerRow[];
|
||
playerTotal.value = data.data.total ?? 0;
|
||
} catch {
|
||
allPlayers.value = [];
|
||
playerTotal.value = 0;
|
||
} finally {
|
||
loadingPlayers.value = false;
|
||
}
|
||
}
|
||
|
||
function searchPlayers() {
|
||
playerPage.value = 1;
|
||
loadAllPlayers();
|
||
}
|
||
|
||
function onPlayerPageChange(p: number) {
|
||
playerPage.value = p;
|
||
loadAllPlayers();
|
||
}
|
||
|
||
function onPlayerSizeChange(size: number) {
|
||
playerPageSize.value = size;
|
||
playerPage.value = 1;
|
||
loadAllPlayers();
|
||
}
|
||
|
||
async function loadAgentLevelCounts() {
|
||
try {
|
||
const { data } = await api.get('/agent/agents/level-counts');
|
||
const raw = data.data ?? {};
|
||
const normalized: Record<number, number> = {};
|
||
for (const [lvl, cnt] of Object.entries(raw)) {
|
||
normalized[Number(lvl)] = Number(cnt) || 0;
|
||
}
|
||
agentLevelCounts.value = normalized;
|
||
} catch {
|
||
agentLevelCounts.value = {};
|
||
}
|
||
}
|
||
|
||
async function loadSubAgentsAtLevel(level: number) {
|
||
const st = ensureSubAgentState(level);
|
||
try {
|
||
const { data } = await api.get('/agent/agents/by-level', {
|
||
params: {
|
||
level,
|
||
page: st.page,
|
||
pageSize: st.pageSize,
|
||
keyword: st.keyword.trim() || undefined,
|
||
status: st.filterStatus || undefined,
|
||
},
|
||
});
|
||
st.agents = (data.data.items ?? []) as AgentSubAgentRow[];
|
||
st.total = data.data.total ?? 0;
|
||
} catch {
|
||
st.agents = [];
|
||
st.total = 0;
|
||
}
|
||
}
|
||
|
||
function onSubAgentPageChange(level: number, p: number) {
|
||
ensureSubAgentState(level).page = p;
|
||
loadSubAgentsAtLevel(level);
|
||
}
|
||
|
||
function onSubAgentSizeChange(level: number, size: number) {
|
||
const st = ensureSubAgentState(level);
|
||
st.pageSize = size;
|
||
st.page = 1;
|
||
loadSubAgentsAtLevel(level);
|
||
}
|
||
|
||
function bindSubAgentPageChange(level: number) {
|
||
return (p: number) => onSubAgentPageChange(level, p);
|
||
}
|
||
|
||
function bindSubAgentSizeChange(level: number) {
|
||
return (size: number) => onSubAgentSizeChange(level, size);
|
||
}
|
||
|
||
function searchSubAgentsAtLevel(level: number) {
|
||
ensureSubAgentState(level).page = 1;
|
||
loadSubAgentsAtLevel(level);
|
||
}
|
||
|
||
async function reloadSubAgentTabs() {
|
||
await loadAgentLevelCounts();
|
||
for (const lvl of visibleSubAgentTabLevels.value) {
|
||
await loadSubAgentsAtLevel(lvl);
|
||
}
|
||
}
|
||
|
||
const walletLedgerVisible = ref(false);
|
||
const walletLedgerPlayerId = ref('');
|
||
const walletLedgerPlayerUsername = ref<string | null>(null);
|
||
|
||
function openPlayerWalletLedger(
|
||
playerId: string,
|
||
playerUsername?: string | null,
|
||
row?: ScopedPlayerRow,
|
||
) {
|
||
if (row && !canViewPlayer(row)) return;
|
||
walletLedgerPlayerId.value = playerId;
|
||
walletLedgerPlayerUsername.value = playerUsername ?? null;
|
||
walletLedgerVisible.value = true;
|
||
}
|
||
|
||
/* ─── Agent row expansion ─── */
|
||
async function onExpandChange(row: AgentSubAgentRow, expandedRows: AgentSubAgentRow[]) {
|
||
const ids = expandedRows.map((r) => r.userId).slice(0, MAX_EXPANDED_AGENT_ROWS);
|
||
expandedSet.value = new Set(ids);
|
||
if (expandedSet.value.has(row.userId) && !agentPlayersMap.value[row.userId]) {
|
||
await loadExpansionData(row.userId);
|
||
}
|
||
}
|
||
|
||
function onSubAgentRowClick(row: AgentSubAgentRow, _column: unknown, event: MouseEvent) {
|
||
if (!shouldToggleExpandOnRowClick(event)) return;
|
||
const userId = row.userId;
|
||
const next = new Set(expandedSet.value);
|
||
if (next.has(userId)) {
|
||
next.delete(userId);
|
||
} else {
|
||
if (next.size >= MAX_EXPANDED_AGENT_ROWS) {
|
||
const [first] = next;
|
||
if (first) next.delete(first);
|
||
}
|
||
next.add(userId);
|
||
if (!agentPlayersMap.value[userId]) void loadExpansionData(userId);
|
||
}
|
||
expandedSet.value = next;
|
||
}
|
||
|
||
async function loadExpansionData(agentUserId: string, page = 1) {
|
||
expandLoading.value[agentUserId] = true;
|
||
try {
|
||
const { data } = await api.get(`/agent/agents/${agentUserId}/players`, {
|
||
params: { page, pageSize: EXPAND_PLAYER_PAGE_SIZE },
|
||
});
|
||
const payload = data.data as {
|
||
items: ScopedPlayerRow[];
|
||
total: number;
|
||
page: number;
|
||
pageSize: number;
|
||
};
|
||
agentPlayersMap.value[agentUserId] = payload.items ?? [];
|
||
agentPlayersMeta.value[agentUserId] = {
|
||
total: payload.total ?? 0,
|
||
page: payload.page ?? page,
|
||
pageSize: payload.pageSize ?? EXPAND_PLAYER_PAGE_SIZE,
|
||
};
|
||
} catch {
|
||
agentPlayersMap.value[agentUserId] = [];
|
||
agentPlayersMeta.value[agentUserId] = {
|
||
total: 0,
|
||
page: 1,
|
||
pageSize: EXPAND_PLAYER_PAGE_SIZE,
|
||
};
|
||
} finally {
|
||
expandLoading.value[agentUserId] = false;
|
||
}
|
||
}
|
||
|
||
function getPlayers(agentUserId: string) {
|
||
return agentPlayersMap.value[agentUserId] ?? [];
|
||
}
|
||
|
||
function getPlayersMeta(agentUserId: string) {
|
||
return (
|
||
agentPlayersMeta.value[agentUserId] ?? {
|
||
total: getPlayers(agentUserId).length,
|
||
page: 1,
|
||
pageSize: EXPAND_PLAYER_PAGE_SIZE,
|
||
}
|
||
);
|
||
}
|
||
|
||
function onExpandPlayersPageChange(agentUserId: string, page: number) {
|
||
void loadExpansionData(agentUserId, page);
|
||
}
|
||
|
||
function refreshExpandedAgentPlayers() {
|
||
for (const uid of expandedSet.value) {
|
||
void loadExpansionData(uid);
|
||
}
|
||
}
|
||
|
||
/* ─── 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', payload);
|
||
ElMessage.success(t('user.msg.created_with_password', { password }));
|
||
createVisible.value = false;
|
||
loadAllPlayers();
|
||
} 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 openEdit(row: ScopedPlayerRow) {
|
||
if (!canOperatePlayer(row)) return;
|
||
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 }));
|
||
loadAllPlayers();
|
||
return;
|
||
}
|
||
ElMessage.success(t('msg.saved'));
|
||
editVisible.value = false;
|
||
loadAllPlayers();
|
||
refreshExpandedAgentPlayers();
|
||
} 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: ScopedPlayerRow) {
|
||
if (!canOperatePlayer(row)) return;
|
||
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 }));
|
||
loadAllPlayers();
|
||
refreshExpandedAgentPlayers();
|
||
} catch (e: unknown) {
|
||
const err = e as { response?: { data?: { error?: string } } };
|
||
ElMessage.error(err.response?.data?.error ?? t('msg.freeze_failed', { action }));
|
||
}
|
||
}
|
||
|
||
/* ─── Delete ─── */
|
||
async function deletePlayerRow(row: ScopedPlayerRow) {
|
||
if (!canOperatePlayer(row)) return;
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
t('msg.delete_player_body', { name: row.username }),
|
||
t('msg.delete_player_title'),
|
||
{
|
||
type: 'warning',
|
||
confirmButtonText: t('common.delete'),
|
||
cancelButtonText: t('common.cancel'),
|
||
},
|
||
);
|
||
} catch { return; }
|
||
const input = ref('');
|
||
try {
|
||
await ElMessageBox({
|
||
title: t('msg.delete_player_confirm_title'),
|
||
message: () =>
|
||
h('div', {}, [
|
||
h('p', { style: 'margin: 0 0 8px; font-size: 13px; color: var(--el-text-color-regular)' },
|
||
t('msg.delete_player_confirm_hint', { name: row.username })),
|
||
h('input', {
|
||
value: input.value,
|
||
placeholder: row.username,
|
||
onInput: (e: Event) => { input.value = (e.target as HTMLInputElement).value; },
|
||
style: 'width: 100%; padding: 6px 8px; border: 1px solid var(--el-border-color); border-radius: 4px; font-size: 13px; box-sizing: border-box',
|
||
}),
|
||
]),
|
||
showCancelButton: true,
|
||
confirmButtonText: t('common.delete'),
|
||
cancelButtonText: t('common.cancel'),
|
||
beforeClose: (action: string, _instance: unknown, done: () => void) => {
|
||
if (action === 'confirm') {
|
||
if (input.value.trim() !== row.username) {
|
||
ElMessage.warning(t('msg.delete_player_mismatch'));
|
||
return;
|
||
}
|
||
}
|
||
done();
|
||
},
|
||
});
|
||
} catch { return; }
|
||
try {
|
||
await api.delete(`/agent/players/${row.id}`);
|
||
ElMessage.success(t('msg.delete_player_done'));
|
||
loadAllPlayers();
|
||
refreshExpandedAgentPlayers();
|
||
} catch (e: unknown) {
|
||
const err = e as { response?: { data?: { error?: string } } };
|
||
ElMessage.error(err.response?.data?.error ?? t('msg.delete_player_failed'));
|
||
}
|
||
}
|
||
|
||
/* ─── Transfer ─── */
|
||
async function openTransfer(type: 'deposit' | 'withdraw', row: ScopedPlayerRow) {
|
||
if (!canOperatePlayer(row)) return;
|
||
transferType.value = type;
|
||
transferTarget.value = row;
|
||
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')); 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;
|
||
const amount = transferAmount.value;
|
||
transferLoading.value = true;
|
||
try {
|
||
const requestId = `${transferType.value === 'deposit' ? 'dep' : 'wd'}-${playerId}-${Date.now()}`;
|
||
if (transferType.value === 'deposit') {
|
||
await api.post(`/agent/players/${playerId}/deposit`, { amount, requestId });
|
||
ElMessage.success(t('msg.topup_ok'));
|
||
} else {
|
||
await api.post(`/agent/players/${playerId}/withdraw`, { amount, requestId });
|
||
ElMessage.success(t('msg.withdraw_ok'));
|
||
}
|
||
transferVisible.value = false;
|
||
loadAllPlayers();
|
||
loadProfile();
|
||
} catch (e: unknown) {
|
||
const err = e as { response?: { data?: { error?: string } } };
|
||
ElMessage.error(err.response?.data?.error ?? t('msg.transfer_failed'));
|
||
} finally {
|
||
transferLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function transferTitle() {
|
||
const name = transferTarget.value?.username ?? '';
|
||
return transferType.value === 'deposit'
|
||
? 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;
|
||
await reloadSubAgentTabs();
|
||
loadProfile();
|
||
activeViewTab.value = agentLevelTabName(childAgentLevel.value);
|
||
} 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 }));
|
||
await reloadSubAgentTabs();
|
||
return;
|
||
}
|
||
ElMessage.success(t('msg.saved'));
|
||
editSubVisible.value = false;
|
||
await reloadSubAgentTabs();
|
||
} 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.value?.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;
|
||
await reloadSubAgentTabs();
|
||
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 }));
|
||
await reloadSubAgentTabs();
|
||
} catch (e: unknown) {
|
||
const err = e as { response?: { data?: { error?: string } } };
|
||
ElMessage.error(err.response?.data?.error ?? t('msg.freeze_failed', { action }));
|
||
}
|
||
}
|
||
|
||
/* ─── Helpers ─── */
|
||
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 agent-portal-mgr">
|
||
<!-- ─── Credit strip ─── -->
|
||
<div class="credit-strip">
|
||
<div class="credit-item">
|
||
<span class="credit-label">{{ t('agent_portal.my_cashback_rate') }}</span>
|
||
<span class="credit-value c-green">{{ formatRatePercent(profile.cashbackRate) }}</span>
|
||
</div>
|
||
<div class="credit-divider" />
|
||
<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>
|
||
|
||
<InviteManageDialog v-model="inviteDialogOpen" />
|
||
|
||
<div class="portal-tabs-shell">
|
||
<el-button type="primary" class="invite-prominent-btn" @click="inviteDialogOpen = true">
|
||
{{ t('invite.menu_btn') }}
|
||
</el-button>
|
||
<!-- ─── Top-level tabs ─── -->
|
||
<el-tabs v-model="activeViewTab" class="portal-top-tabs portal-top-tabs--with-invite">
|
||
<!-- ══════ Tab: 玩家 ══════ -->
|
||
<el-tab-pane :label="playerTabLabel" name="players">
|
||
<div class="inner-toolbar list-panel-toolbar">
|
||
<el-form inline size="small" class="list-chrome__grow">
|
||
<el-form-item :label="t('common.keyword')">
|
||
<el-input
|
||
v-model="playerKeyword"
|
||
:placeholder="t('agent_portal.search_player_ph')"
|
||
clearable
|
||
style="width: 180px"
|
||
@keyup.enter="searchPlayers"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item :label="t('user.filter.agent')">
|
||
<el-select
|
||
v-model="playerFilterAgent"
|
||
:placeholder="t('user.filter.agent_ph')"
|
||
clearable
|
||
style="width: 200px"
|
||
>
|
||
<el-option
|
||
v-for="a in scopedAgentOptions"
|
||
:key="a.id"
|
||
:label="agentOptionLabel(a)"
|
||
:value="a.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item :label="t('common.status')">
|
||
<el-select v-model="playerFilterStatus" :placeholder="t('common.all')" clearable style="width: 120px">
|
||
<el-option :label="t('user.status.ACTIVE')" value="ACTIVE" />
|
||
<el-option :label="t('user.status.SUSPENDED')" value="SUSPENDED" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" size="small" @click="searchPlayers">{{ t('common.search') }}</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
<el-button type="primary" size="small" @click="openCreate">
|
||
+ {{ t('agent_portal.create_player_btn') }}
|
||
</el-button>
|
||
</div>
|
||
|
||
<AdminTableWrap>
|
||
<el-table v-loading="loadingPlayers" :data="allPlayers" 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('user.col.agent')" min-width="140" show-overflow-tooltip>
|
||
<template #default="{ row }">
|
||
<span>{{ row.parentAgentUsername ?? '—' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('user.col.player_cashback')" min-width="88" align="right">
|
||
<template #default="{ row }">{{ formatRatePercent(row.cashbackRate) }}</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('user.col.invite_code')" min-width="96" show-overflow-tooltip>
|
||
<template #default="{ row }">
|
||
<code v-if="row.inviteCode" class="invite-code-cell">{{ row.inviteCode }}</code>
|
||
<span v-else>—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<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="canViewPlayer(row) && row.wallet?.availableBalance != null">
|
||
<el-tooltip :content="formatAmountFull(row.wallet.availableBalance)" placement="top">
|
||
<span>{{ formatAmount(row.wallet.availableBalance) }}</span>
|
||
</el-tooltip>
|
||
</template>
|
||
<span v-else>—</span>
|
||
</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')" min-width="300" align="center">
|
||
<template #default="{ row }">
|
||
<AdminPlayerRowActions
|
||
v-if="canOperatePlayer(row)"
|
||
:row="row"
|
||
:show-detail="false"
|
||
@ledger="openPlayerWalletLedger(row.id, row.username, row)"
|
||
@edit="openEdit(row)"
|
||
@deposit="openTransfer('deposit', row)"
|
||
@withdraw="openTransfer('withdraw', row)"
|
||
@freeze="toggleFreeze(row)"
|
||
@delete="deletePlayerRow(row)"
|
||
/>
|
||
<el-button
|
||
v-else-if="canViewPlayer(row)"
|
||
link
|
||
type="primary"
|
||
size="small"
|
||
@click="openPlayerWalletLedger(row.id, row.username, row)"
|
||
>
|
||
{{ t('user.action.ledger_short') }}
|
||
</el-button>
|
||
<span v-else>—</span>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</AdminTableWrap>
|
||
<div class="pager">
|
||
<el-pagination
|
||
v-model:current-page="playerPage"
|
||
v-model:page-size="playerPageSize"
|
||
:total="playerTotal"
|
||
:page-sizes="[10, 20, 50, 100]"
|
||
layout="total, sizes, prev, pager, next"
|
||
background
|
||
small
|
||
@current-change="onPlayerPageChange"
|
||
@size-change="onPlayerSizeChange"
|
||
/>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<!-- ══════ Tab: 各级代理 ══════ -->
|
||
<el-tab-pane
|
||
v-for="agentLevel in visibleSubAgentTabLevels"
|
||
:key="agentLevel"
|
||
:label="agentLevelTabLabel(agentLevel)"
|
||
:name="agentLevelTabName(agentLevel)"
|
||
>
|
||
<div class="inner-toolbar list-panel-toolbar">
|
||
<el-form inline size="small" class="list-chrome__grow">
|
||
<el-form-item :label="t('common.keyword')">
|
||
<el-input
|
||
v-model="ensureSubAgentState(agentLevel).keyword"
|
||
:placeholder="t('agent_portal.search_sub_agent_ph')"
|
||
clearable
|
||
style="width: 180px"
|
||
@keyup.enter="searchSubAgentsAtLevel(agentLevel)"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item :label="t('common.status')">
|
||
<el-select
|
||
v-model="ensureSubAgentState(agentLevel).filterStatus"
|
||
:placeholder="t('common.all')"
|
||
clearable
|
||
style="width: 120px"
|
||
>
|
||
<el-option :label="t('user.status.ACTIVE')" value="ACTIVE" />
|
||
<el-option :label="t('user.status.SUSPENDED')" value="SUSPENDED" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" size="small" @click="searchSubAgentsAtLevel(agentLevel)">{{ t('common.search') }}</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
<el-button
|
||
v-if="agentLevel === childAgentLevel"
|
||
type="primary"
|
||
size="small"
|
||
@click="openCreateSub"
|
||
>
|
||
{{ createSubBtnLabel }}
|
||
</el-button>
|
||
</div>
|
||
|
||
<AdminTableWrap>
|
||
<el-table
|
||
:data="ensureSubAgentState(agentLevel).agents"
|
||
stripe
|
||
size="small"
|
||
row-key="userId"
|
||
:expand-row-keys="expandedRowKeys"
|
||
:row-class-name="expandableTableRowClassName"
|
||
class="inner-table expandable-table"
|
||
@expand-change="onExpandChange"
|
||
@row-click="onSubAgentRowClick"
|
||
>
|
||
<template #empty>
|
||
<div class="empty-hint">
|
||
{{ agentLevel === childAgentLevel
|
||
? (childAgentLevel === 2 ? t('agent_portal.no_sub_agents') : t('agent_portal.no_sub_agents_level', { level: lvlLabel(agentLevel) }))
|
||
: t('agent_portal.no_sub_agents_level', { level: lvlLabel(agentLevel) }) }}
|
||
</div>
|
||
</template>
|
||
|
||
<el-table-column type="expand">
|
||
<template #default="{ row }">
|
||
<div class="expand-panel">
|
||
<div v-if="expandLoading[row.userId]" class="expand-loading">
|
||
{{ t('common.loading') }}
|
||
</div>
|
||
<div v-else class="expand-panel-body">
|
||
<p v-if="!isDirectChildAgent(row)" class="expand-readonly-hint">{{ expandReadonlyHint }}</p>
|
||
<div class="expand-section-title">
|
||
{{ directPlayersTabLabel(row.username, getPlayersMeta(row.userId).total) }}
|
||
</div>
|
||
<el-table
|
||
:data="getPlayers(row.userId)"
|
||
stripe
|
||
size="small"
|
||
class="inner-table nested-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('user.col.player_cashback')" min-width="88" align="right">
|
||
<template #default="{ row: player }">
|
||
{{ formatRatePercent(player.cashbackRate) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('user.col.invite_code')" min-width="96" show-overflow-tooltip>
|
||
<template #default="{ row: player }">
|
||
<code v-if="player.inviteCode" class="invite-code-cell">{{ player.inviteCode }}</code>
|
||
<span v-else>—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('common.status')" width="80" align="center">
|
||
<template #default="{ row: player }">
|
||
<el-tag :type="statusTagType(player.status)" size="small">{{ statusLabel(player.status) }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('user.field.available')" min-width="100" align="right">
|
||
<template #default="{ row: player }">
|
||
<template v-if="canViewPlayer(player) && player.wallet?.availableBalance != null">
|
||
<span>{{ formatAmount(player.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: player }">{{ formatTime(player.createdAt) }}</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<div
|
||
v-if="getPlayersMeta(row.userId).total > getPlayersMeta(row.userId).pageSize"
|
||
class="expand-pager"
|
||
>
|
||
<el-pagination
|
||
small
|
||
layout="total, prev, pager, next"
|
||
:total="getPlayersMeta(row.userId).total"
|
||
:current-page="getPlayersMeta(row.userId).page"
|
||
:page-size="getPlayersMeta(row.userId).pageSize"
|
||
@current-change="(p: number) => onExpandPlayersPageChange(row.userId, p)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</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 v-if="agentLevel > childAgentLevel" :label="t('agent.col.parent_chain')" min-width="140" show-overflow-tooltip>
|
||
<template #default="{ row }">{{ row.parentChainLabel ?? row.parentUsername ?? '—' }}</template>
|
||
</el-table-column>
|
||
<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 :label="t('agent.col.cashback')" min-width="80" align="right">
|
||
<template #default="{ row }">{{ formatRatePercent(row.cashbackRate) }}</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')" min-width="240" align="center">
|
||
<template #default="{ row }">
|
||
<AdminAgentRowActions
|
||
v-if="isDirectChildAgent(row)"
|
||
:row="{ userId: row.userId, status: subAgentAccountStatus(row) }"
|
||
:show-detail="false"
|
||
@edit="openEditSub(row)"
|
||
@credit="openCreditSub(row)"
|
||
@freeze="toggleFreezeSub(row)"
|
||
/>
|
||
<span v-else class="readonly-actions-hint">—</span>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</AdminTableWrap>
|
||
<div class="pager">
|
||
<el-pagination
|
||
v-model:current-page="ensureSubAgentState(agentLevel).page"
|
||
v-model:page-size="ensureSubAgentState(agentLevel).pageSize"
|
||
:total="ensureSubAgentState(agentLevel).total"
|
||
:page-sizes="[10, 20, 50, 100]"
|
||
layout="total, sizes, prev, pager, next"
|
||
background
|
||
small
|
||
@current-change="bindSubAgentPageChange(agentLevel)"
|
||
@size-change="bindSubAgentSizeChange(agentLevel)"
|
||
/>
|
||
</div>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</div>
|
||
|
||
<!-- ═══════════ 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('user.ph.username_player')" />
|
||
<div class="field-hint">{{ t('user.hint.username_player') }}</div>
|
||
</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.initial_deposit_kind')"
|
||
>
|
||
<InitialDepositRemarkField
|
||
v-model:kind="createForm.initialDepositRemarkKind"
|
||
v-model:custom="createForm.initialDepositRemarkCustom"
|
||
operator="agent"
|
||
/>
|
||
</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="560px" 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>
|
||
|
||
<div class="edit-form-section">
|
||
<div class="section-title">{{ t('user.section.basic_info') }}</div>
|
||
<el-form-item :label="t('user.col.username')">
|
||
<el-input v-model="editForm.username" :placeholder="t('user.ph.username_player')" />
|
||
<div class="field-hint">{{ t('user.hint.username_player') }}</div>
|
||
</el-form-item>
|
||
</div>
|
||
|
||
<div class="edit-form-section">
|
||
<div class="password-mgmt-block">
|
||
<div class="block-title">{{ t('user.section.password_mgmt') }}</div>
|
||
<div class="password-field-row">
|
||
<span class="password-field-label">{{ t('user.field.current_password') }}</span>
|
||
<span v-if="editForm.managedPassword" class="password-plain">{{ editForm.managedPassword }}</span>
|
||
<span v-else class="password-empty">—</span>
|
||
</div>
|
||
<p v-if="!editForm.managedPassword" class="field-hint block-hint">{{ t('user.hint.password_reset_to_view') }}</p>
|
||
<div class="password-field-row">
|
||
<span class="password-field-label">{{ t('user.field.reset_password') }}</span>
|
||
<el-input v-model="editForm.newPassword" type="text" autocomplete="off" :placeholder="t('user.ph.reset_password_short')" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="edit-form-section">
|
||
<div class="section-title">{{ t('user.section.contact') }}</div>
|
||
<el-row :gutter="12" class="contact-row">
|
||
<el-col :span="12">
|
||
<el-form-item :label="t('user.field.phone')">
|
||
<el-input v-model="editForm.phone" :placeholder="t('common.optional')" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item :label="t('user.field.email')">
|
||
<el-input v-model="editForm.email" :placeholder="t('common.optional')" />
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
</div>
|
||
|
||
<div class="edit-form-section edit-stats-panel">
|
||
<div class="section-title">{{ t('user.section.account_overview') }}</div>
|
||
<AdminDetailGrid :columns="3">
|
||
<AdminDetailItem :label="t('user.field.available')">{{ formatAmount(editForm.availableBalance) }}</AdminDetailItem>
|
||
<AdminDetailItem :label="t('user.field.frozen_balance')">{{ formatAmount(editForm.frozenBalance) }}</AdminDetailItem>
|
||
<AdminDetailItem :label="t('user.field.bets_summary')">
|
||
{{ t('user.bets_edit_value', { n: editForm.betCount, stake: formatAmount(editForm.totalStake) }) }}
|
||
</AdminDetailItem>
|
||
<AdminDetailItem :label="t('user.field.total_payout')">{{ formatAmount(editForm.totalReturn) }}</AdminDetailItem>
|
||
<AdminDetailItem :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 }) }}
|
||
</AdminDetailItem>
|
||
</AdminDetailGrid>
|
||
</div>
|
||
</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')" :error="transferAmountCapError">
|
||
<el-input-number
|
||
v-model="transferAmount"
|
||
:min="transferAmountRange.min"
|
||
: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" :disabled="transferAmountDisabled || transferAmountExceedsCap" @click="submitTransfer">{{ t('common.confirm') }}</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- ── Create Sub-Agent ── -->
|
||
<el-dialog v-model="createSubVisible" :title="createSubDialogTitle" 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.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 class="agent-edit-dialog">
|
||
<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>
|
||
|
||
<PlayerWalletLedgerDialog
|
||
v-model="walletLedgerVisible"
|
||
:player-id="walletLedgerPlayerId"
|
||
:player-username="walletLedgerPlayerUsername"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* ─── 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));
|
||
}
|
||
.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: var(--gold-text); }
|
||
.credit-divider { width: 1px; height: 32px; background: #2a2a2a; }
|
||
|
||
/* ─── Tabs ─── */
|
||
.portal-tabs-shell {
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.portal-top-tabs { margin-bottom: 0; }
|
||
.portal-top-tabs--with-invite :deep(.el-tabs__header) {
|
||
margin-bottom: 0;
|
||
padding-right: 108px;
|
||
}
|
||
|
||
.portal-top-tabs :deep(.el-tabs__item) { font-size: 14px; }
|
||
|
||
.invite-prominent-btn {
|
||
position: absolute;
|
||
right: 0;
|
||
top: 0;
|
||
z-index: 2;
|
||
min-width: 96px;
|
||
height: 38px;
|
||
padding: 0 22px;
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.06em;
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 10px rgba(201, 162, 39, 0.22);
|
||
}
|
||
|
||
.invite-code-cell {
|
||
font-family: ui-monospace, monospace;
|
||
font-weight: 700;
|
||
letter-spacing: 0.06em;
|
||
color: var(--gold-text);
|
||
}
|
||
|
||
/* ─── 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; }
|
||
.agent-portal-mgr :deep(.el-table) {
|
||
min-width: 720px;
|
||
}
|
||
|
||
.inner-toolbar {
|
||
flex-wrap: wrap;
|
||
row-gap: 8px;
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.portal-top-tabs--with-invite :deep(.el-tabs__header) {
|
||
padding-right: 0;
|
||
}
|
||
|
||
.invite-prominent-btn {
|
||
position: static;
|
||
align-self: flex-end;
|
||
margin: 0 0 8px;
|
||
}
|
||
}
|
||
|
||
/* ─── 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-section-title--spaced { margin-top: 14px; }
|
||
.expand-readonly-hint { font-size: 12px; color: #999; margin: 0 0 8px; }
|
||
.nested-table { margin-bottom: 4px; }
|
||
.expand-pager {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
padding-top: 8px;
|
||
}
|
||
|
||
/* ─── 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; }
|
||
|
||
.credit-strip {
|
||
border-color: var(--border);
|
||
background: #ffffff;
|
||
box-shadow: var(--shadow);
|
||
}
|
||
|
||
.credit-label,
|
||
.expand-loading,
|
||
.expand-section-title,
|
||
.expand-readonly-hint,
|
||
.field-hint,
|
||
.empty-hint {
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.credit-value {
|
||
color: var(--text);
|
||
}
|
||
|
||
.credit-value.c-green,
|
||
.invite-code-cell {
|
||
color: var(--success-text);
|
||
}
|
||
|
||
.credit-divider {
|
||
background: var(--border);
|
||
}
|
||
|
||
.invite-prominent-btn {
|
||
box-shadow: none;
|
||
}
|
||
|
||
.expand-panel {
|
||
margin-top: 6px;
|
||
padding: 12px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
background: #fbfaf7;
|
||
}
|
||
|
||
@media (max-width: 760px) {
|
||
.credit-strip {
|
||
align-items: stretch;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.credit-divider {
|
||
width: 100%;
|
||
height: 1px;
|
||
}
|
||
|
||
.inner-toolbar,
|
||
.expand-pager {
|
||
justify-content: flex-start;
|
||
}
|
||
}
|
||
</style>
|