sync(theme-2): 同步 main 最新 API/Admin 与玩家端逻辑

从 main 同步站内信手动发送、媒体库选择、列表子路由重构等 API/Admin 改动;玩家端仅合并 ADMIN_CUSTOM 富文本、消息预览与充值截图压缩逻辑,保留 theme-2 样式。
This commit is contained in:
2026-06-22 14:10:54 +08:00
parent fa7b8cff25
commit 27dc9eb3b0
50 changed files with 4653 additions and 1186 deletions

View File

@@ -0,0 +1,233 @@
<script setup lang="ts">
import { ref, computed, inject, onMounted, onUnmounted, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useAdminLocale } from '../../composables/useAdminLocale';
import {
agentDirectPlayersReloadKey,
agentPlayerActionsKey,
} from '../../composables/agent-direct-players-context';
import api from '../../api';
import AdminSubNav from '../../components/AdminSubNav.vue';
import AdminTableWrap from '../../components/AdminTableWrap.vue';
import AdminTableEmpty from '../../components/AdminTableEmpty.vue';
import AdminPlayerStatusCell from '../../components/AdminPlayerStatusCell.vue';
import AdminPlayerRowActions from '../../components/AdminPlayerRowActions.vue';
import { formatAmount, formatAmountFull } from '../../utils/format-amount';
import type { PlayerRow } from '../user-form';
defineOptions({ name: 'AdminAgentDirectPlayersView' });
const route = useRoute();
const { t } = useAdminLocale();
const actions = inject(agentPlayerActionsKey);
const reloadRef = inject(agentDirectPlayersReloadKey);
const agentId = computed(() => String(route.params.agentId ?? ''));
const agentUsername = computed(() => {
const name = String(route.query.username ?? '').trim();
return name || `#${agentId.value}`;
});
const players = ref<PlayerRow[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const keyword = ref('');
const filterStatus = ref('');
const loading = ref(false);
const pageTitle = computed(() =>
t('agent.direct_players_title', { name: agentUsername.value }),
);
async function loadPlayers() {
if (!agentId.value) return;
loading.value = true;
try {
const { data } = await api.get('/admin/users', {
params: {
parentId: agentId.value,
page: page.value,
pageSize: pageSize.value,
keyword: keyword.value.trim() || undefined,
status: filterStatus.value || undefined,
},
});
players.value = (data.data.items ?? []) as PlayerRow[];
total.value = data.data.total ?? 0;
} finally {
loading.value = false;
}
}
function onSearch() {
page.value = 1;
void loadPlayers();
}
function onPageChange(p: number) {
page.value = p;
void loadPlayers();
}
function onSizeChange(size: number) {
pageSize.value = size;
page.value = 1;
void loadPlayers();
}
watch(agentId, () => {
page.value = 1;
void loadPlayers();
});
onMounted(() => {
if (reloadRef) reloadRef.value = () => void loadPlayers();
void loadPlayers();
});
onUnmounted(() => {
if (reloadRef) reloadRef.value = null;
});
</script>
<template>
<div class="admin-list-page agent-direct-players-page">
<AdminSubNav :title="pageTitle" :subtitle="t('agent.direct_players')" />
<section class="list-panel player-list-panel">
<div class="list-panel-toolbar">
<el-form inline class="list-chrome__grow">
<el-form-item :label="t('common.keyword')">
<el-input
v-model="keyword"
:placeholder="t('user.filter.username_ph')"
clearable
style="width: 180px"
@keyup.enter="onSearch"
/>
</el-form-item>
<el-form-item :label="t('common.status')">
<el-select v-model="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" @click="onSearch">{{ t('common.search') }}</el-button>
</el-form-item>
</el-form>
<div v-if="actions?.canCreatePlayer" class="list-chrome__actions">
<el-button type="primary" @click="actions?.openCreatePlayer(agentId)">
{{ t('user.create_btn') }}
</el-button>
</div>
</div>
<AdminTableWrap>
<el-table v-loading="loading" :data="players" stripe class="inner-table">
<template #empty>
<AdminTableEmpty />
</template>
<el-table-column
type="index"
:index="(i: number) => (page - 1) * pageSize + i + 1"
:label="t('common.seq')"
width="70"
align="center"
/>
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
<el-table-column :label="t('common.status')" min-width="128">
<template #default="{ row }">
<AdminPlayerStatusCell :status="row.status" :is-online="row.isOnline" />
</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('user.col.balance')" min-width="120" align="right">
<template #default="{ row }">
<el-tooltip
:content="`${formatAmountFull(row.availableBalance)} / ${formatAmountFull(row.frozenBalance)}`"
placement="top"
>
<span class="amount-compact">
{{ formatAmount(row.availableBalance) }} / {{ formatAmount(row.frozenBalance) }}
</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="betCount" :label="t('user.col.bets')" width="56" align="center" />
<el-table-column :label="t('user.col.stake_payout')" min-width="100" align="right">
<template #default="{ row }">
<span class="amount-compact">
{{ formatAmount(row.totalStake) }} / {{ formatAmount(row.totalReturn) }}
</span>
</template>
</el-table-column>
<el-table-column v-if="actions" :label="t('common.actions')" min-width="320" align="center">
<template #default="{ row }">
<AdminPlayerRowActions
v-bind="actions.playerActionFlags"
:row="row"
@detail="actions.openDetailPlayer(row.id)"
@ledger="actions.openPlayerWalletLedger(row.id, row.username)"
@edit="actions.openEditPlayer(row.id)"
@deposit="actions.openTransfer('deposit', row)"
@withdraw="actions.openTransfer('withdraw', row)"
@freeze="actions.toggleFreezePlayer(row)"
@delete="actions.deletePlayer(row)"
/>
</template>
</el-table-column>
</el-table>
</AdminTableWrap>
<div class="pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
background
@current-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</section>
</div>
</template>
<style scoped>
.agent-direct-players-page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.agent-direct-players-page :deep(.admin-subnav) {
margin-bottom: 10px;
}
.agent-direct-players-page .list-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.agent-direct-players-page .player-list-panel :deep(.admin-table-wrap) {
flex: 1;
min-height: 0;
}
.agent-direct-players-page .inner-table {
height: 100%;
}
</style>

View File

@@ -0,0 +1,368 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../../composables/useAdminLocale';
import { usePermissions } from '../../composables/usePermissions';
import { AdminPerm } from '../../constants/permissions';
import api from '../../api';
import { clearStaffSession } from '../../stores/auth';
import AdminSubNav from '../../components/AdminSubNav.vue';
import RatePercentInput from '../../components/RatePercentInput.vue';
import { percentToDecimalRate, decimalRateToPercent } from '../../utils/rate-percent';
defineOptions({ name: 'AdminGlobalSettingsView' });
const { t } = useAdminLocale();
const router = useRouter();
const { hasPermission } = usePermissions();
const canManageSettings = hasPermission(AdminPerm.settings);
const playerSettings = ref({ allowPasswordChange: true, allowUsernameChange: false });
const bettingLimits = ref({
minStake: 1,
maxStakeSingle: 50000,
maxStakeParlay: 20000,
maxPayoutSingle: 500000,
maxPayoutParlay: 1000000,
dailyStakeLimit: 200000,
});
const hierarchySettings = ref({ maxAgentLevel: 0 });
const platformDirectRate = ref(0);
const adminInviteRate = ref(0);
const resetAllowed = ref(false);
const resetConfirmPhrase = ref('');
const settingsSaving = ref(false);
const limitsSaving = ref(false);
const hierarchySaving = ref(false);
const platformDirectSaving = ref(false);
const resetLoading = ref(false);
const loading = ref(false);
async function loadSettings() {
loading.value = true;
try {
const { data } = await api.get('/admin/users/page-init');
const payload = data.data as {
playerSettings?: typeof playerSettings.value;
bettingLimits?: typeof bettingLimits.value;
hierarchySettings?: { maxAgentLevel: number };
platformDirect?: { platformDirectRate?: number | string; adminInviteRate?: number | string };
};
if (payload.playerSettings) playerSettings.value = payload.playerSettings;
if (payload.bettingLimits) bettingLimits.value = payload.bettingLimits;
if (payload.hierarchySettings) {
hierarchySettings.value = {
maxAgentLevel: payload.hierarchySettings.maxAgentLevel ?? 0,
};
}
if (payload.platformDirect) {
platformDirectRate.value = decimalRateToPercent(payload.platformDirect.platformDirectRate ?? 0);
adminInviteRate.value = decimalRateToPercent(
payload.platformDirect.adminInviteRate ?? payload.platformDirect.platformDirectRate ?? 0,
);
}
} catch {
/* keep defaults */
} finally {
loading.value = false;
}
}
async function loadResetDatabaseStatus() {
try {
const { data } = await api.get('/admin/system/reset-database');
resetAllowed.value = !!data.data?.allowed;
} catch {
resetAllowed.value = false;
}
}
async function savePlayerSettings() {
settingsSaving.value = true;
try {
const { data } = await api.put('/admin/users/settings/account', playerSettings.value);
playerSettings.value = data.data;
ElMessage.success(t('msg.saved'));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
settingsSaving.value = false;
}
}
async function saveHierarchySettings() {
hierarchySaving.value = true;
try {
const { data } = await api.put('/admin/agents/settings/hierarchy', hierarchySettings.value);
hierarchySettings.value = { maxAgentLevel: data.data?.maxAgentLevel ?? hierarchySettings.value.maxAgentLevel };
ElMessage.success(t('msg.saved'));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
hierarchySaving.value = false;
}
}
async function savePlatformDirectSettings() {
platformDirectSaving.value = true;
try {
const { data } = await api.put('/admin/settings/cashback/platform-direct', {
platformDirectRate: percentToDecimalRate(platformDirectRate.value),
adminInviteRate: percentToDecimalRate(adminInviteRate.value),
});
const payload = data.data as { platformDirectRate?: number | string; adminInviteRate?: number | string };
platformDirectRate.value = decimalRateToPercent(payload?.platformDirectRate ?? 0);
adminInviteRate.value = decimalRateToPercent(payload?.adminInviteRate ?? payload?.platformDirectRate ?? 0);
ElMessage.success(t('msg.saved'));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
platformDirectSaving.value = false;
}
}
async function saveBettingLimits() {
limitsSaving.value = true;
try {
const { data } = await api.put('/admin/settings/betting-limits', bettingLimits.value);
bettingLimits.value = data.data;
ElMessage.success(t('msg.saved'));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
limitsSaving.value = false;
}
}
async function resetDatabase() {
if (resetConfirmPhrase.value !== 'RESET') {
ElMessage.warning(t('user.reset_database_confirm_label'));
return;
}
try {
await ElMessageBox.confirm(t('user.reset_database_hint'), t('user.reset_database'), {
type: 'warning',
confirmButtonText: t('user.reset_database_btn'),
cancelButtonText: t('common.cancel'),
});
} catch {
return;
}
resetLoading.value = true;
try {
const { data } = await api.post('/admin/system/reset-database', { confirmPhrase: 'RESET' });
const accounts: string[] = data.data?.demoAccounts ?? [];
ElMessage.success({
message: `${t('user.reset_database_success')}\n${t('user.reset_database_accounts')}: ${accounts.join(' · ')}`,
duration: 8000,
});
clearStaffSession();
await router.push('/login');
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
resetLoading.value = false;
}
}
onMounted(() => {
if (!canManageSettings) {
void router.replace('/users');
return;
}
void Promise.all([loadSettings(), loadResetDatabaseStatus()]);
});
</script>
<template>
<div v-loading="loading" class="admin-list-page global-settings-page">
<AdminSubNav :title="t('user.page_settings')" />
<section class="global-settings-panel">
<div class="list-settings-block">
<p class="list-settings-title">{{ t('user.global_settings') }}</p>
<el-form inline size="small" class="settings-form">
<el-form-item :label="t('user.field.allow_password_change')">
<el-switch v-model="playerSettings.allowPasswordChange" :loading="settingsSaving" @change="savePlayerSettings" />
</el-form-item>
<el-form-item :label="t('user.field.allow_username_change')">
<el-switch v-model="playerSettings.allowUsernameChange" :loading="settingsSaving" @change="savePlayerSettings" />
</el-form-item>
</el-form>
</div>
<div class="list-settings-block">
<p class="list-settings-title">{{ t('agent.hierarchy.settings_title') }}</p>
<p class="list-settings-hint">{{ t('agent.hierarchy.settings_hint') }}</p>
<el-form inline size="small" class="settings-form">
<el-form-item :label="t('agent.hierarchy.max_level')">
<el-input-number
v-model="hierarchySettings.maxAgentLevel"
:min="0"
:step="1"
controls-position="right"
:disabled="hierarchySaving"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="hierarchySaving" @click="saveHierarchySettings">
{{ t('common.save') }}
</el-button>
</el-form-item>
</el-form>
</div>
<div class="list-settings-block">
<p class="list-settings-title">{{ t('cashback.settings_title') }}</p>
<el-form inline size="small" class="settings-form">
<el-form-item :label="t('cashback.platform_direct_default_rate')">
<RatePercentInput v-model="platformDirectRate" />
</el-form-item>
<p class="list-settings-hint block-hint">{{ t('cashback.platform_direct_default_hint') }}</p>
<el-form-item :label="t('cashback.admin_invite_default_rate')">
<RatePercentInput v-model="adminInviteRate" />
</el-form-item>
<p class="list-settings-hint block-hint">{{ t('cashback.admin_invite_default_hint') }}</p>
<el-form-item>
<el-button type="primary" :loading="platformDirectSaving" @click="savePlatformDirectSettings">
{{ t('common.save') }}
</el-button>
</el-form-item>
</el-form>
</div>
<div class="list-settings-block">
<p class="list-settings-title">{{ t('user.betting_limits') }}</p>
<el-form inline size="small" class="settings-form limits-form">
<el-form-item :label="t('user.limit.min_stake')">
<el-input-number v-model="bettingLimits.minStake" :min="0" :step="1" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.max_stake_single')">
<el-input-number v-model="bettingLimits.maxStakeSingle" :min="0" :step="100" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.max_stake_parlay')">
<el-input-number v-model="bettingLimits.maxStakeParlay" :min="0" :step="100" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.max_payout_single')">
<el-input-number v-model="bettingLimits.maxPayoutSingle" :min="0" :step="1000" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.max_payout_parlay')">
<el-input-number v-model="bettingLimits.maxPayoutParlay" :min="0" :step="1000" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.daily_stake')">
<el-input-number v-model="bettingLimits.dailyStakeLimit" :min="0" :step="1000" controls-position="right" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="limitsSaving" @click="saveBettingLimits">
{{ t('common.save') }}
</el-button>
</el-form-item>
</el-form>
</div>
<div class="list-settings-block list-settings-block--danger">
<p class="list-settings-title">{{ t('user.reset_database') }}</p>
<p class="list-settings-hint">{{ t('user.reset_database_hint') }}</p>
<el-alert
v-if="!resetAllowed"
type="warning"
:closable="false"
show-icon
class="reset-db-alert"
:title="t('user.reset_database_disabled_prod')"
/>
<el-form inline size="small" class="settings-form reset-db-form">
<el-form-item :label="t('user.reset_database_confirm_label')">
<el-input
v-model="resetConfirmPhrase"
:placeholder="t('user.reset_database_confirm_ph')"
style="width: 160px"
:disabled="!resetAllowed"
autocomplete="off"
/>
</el-form-item>
<el-form-item>
<el-button
type="danger"
plain
:loading="resetLoading"
:disabled="!resetAllowed || resetConfirmPhrase !== 'RESET'"
@click="resetDatabase"
>
{{ t('user.reset_database_btn') }}
</el-button>
</el-form-item>
</el-form>
</div>
</section>
</div>
</template>
<style scoped>
.global-settings-page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.global-settings-page :deep(.admin-subnav) {
margin-bottom: 10px;
}
.global-settings-panel {
flex: 1;
min-height: 0;
overflow: auto;
padding: 12px 14px 16px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-card);
}
.list-settings-block + .list-settings-block {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border-soft);
}
.list-settings-title {
font-size: 13px;
font-weight: 600;
color: var(--text);
margin: 0 0 8px;
}
.list-settings-hint {
font-size: 12px;
color: var(--text-muted);
margin: 0 0 10px;
line-height: 1.5;
}
.block-hint {
width: 100%;
margin-top: -4px;
}
.list-settings-block--danger {
border-top: 1px dashed var(--danger-border);
}
.reset-db-alert {
margin-bottom: 10px;
}
.limits-form {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
}
</style>