feat(admin): 从已有玩家升级代理、修复 i18n 与过期 .js 冲突

- 新建一级代理改为选择已有玩家;新建用户可选一级代理

- 操作日志/注单等扁平 key 翻译;清理 src 内误生成 .js,Vite 优先解析 .ts

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-03 15:42:15 +08:00
parent cbfa18d1d3
commit 3b739982a1
27 changed files with 625 additions and 165 deletions

View File

@@ -11,11 +11,13 @@ import {
emptyAgentEditForm,
editFormFromAgentDetail,
buildCreateAgentPayload,
applyPromotableUserToForm,
type AgentRow,
type AgentDetail,
type AgentCreateForm,
type AgentEditForm,
} from './agent-form';
type PromotableUserOption,
} from './agent-form.ts';
import {
formatAmount,
formatAmountFull,
@@ -45,6 +47,9 @@ const editLoading = ref(false);
const creditLoading = ref(false);
const createForm = ref<AgentCreateForm>(emptyAgentCreateForm());
const promotableUsers = ref<PromotableUserOption[]>([]);
const promotableLoading = ref(false);
const promotableKeyword = ref('');
const editForm = ref<AgentEditForm>(emptyAgentEditForm());
const detail = ref<AgentDetail | null>(null);
const editingId = ref('');
@@ -76,9 +81,38 @@ function onSizeChange(size: number) {
load();
}
async function loadPromotableUsers() {
promotableLoading.value = true;
try {
const { data } = await api.get('/admin/users/promotable-for-agent', {
params: { keyword: promotableKeyword.value.trim() || undefined },
});
promotableUsers.value = data.data as PromotableUserOption[];
} finally {
promotableLoading.value = false;
}
}
function openCreate() {
createForm.value = emptyAgentCreateForm();
promotableKeyword.value = '';
createVisible.value = true;
loadPromotableUsers();
}
function onPromotableSearch(q: string) {
promotableKeyword.value = q;
void loadPromotableUsers();
}
function onPromotableUserChange(userId: string) {
const user = promotableUsers.value.find((u) => u.id === userId);
if (user) applyPromotableUserToForm(createForm.value, user);
}
function formatPromotableLabel(u: PromotableUserOption) {
const parent = u.parentUsername ? ` · ${u.parentUsername}` : '';
return `${u.username} (#${u.id})${parent}`;
}
async function openDetail(userId: string) {
@@ -270,14 +304,25 @@ function creditTypeLabel(type: string) {
<el-dialog v-model="createVisible" :title="t('agent.dialog.create')" width="520px" destroy-on-close>
<el-form label-width="100px">
<el-form-item :label="t('user.col.username')" required>
<el-input v-model="createForm.username" :placeholder="t('user.ph.username_unique')" />
</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 :label="t('agent.field.select_user')" required>
<el-select
v-model="createForm.userId"
filterable
remote
:remote-method="onPromotableSearch"
:loading="promotableLoading"
:placeholder="t('agent.ph.select_user')"
style="width: 100%"
@change="onPromotableUserChange"
>
<el-option
v-for="u in promotableUsers"
:key="u.id"
:label="formatPromotableLabel(u)"
:value="u.id"
/>
</el-select>
<div class="field-hint">{{ t('agent.hint.select_user') }}</div>
</el-form-item>
<el-form-item :label="t('agent.field.credit_limit')" required>
<el-input-number

View File

@@ -3,9 +3,28 @@ import { ref, onMounted } from 'vue';
import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api';
const { t } = useAdminLocale();
const { t, locale, localeTag } = useAdminLocale();
const logs = ref<unknown[]>([]);
function auditActionLabel(action: string) {
const key = `audit.action.${action}`;
const label = t(key);
return label === key ? action : label;
}
function auditModuleLabel(module: string) {
const key = `audit.module.${module}`;
const label = t(key);
return label === key ? module : label;
}
interface AuditRow {
action: string;
module: string;
targetId: string | null;
createdAt: string;
}
const logs = ref<AuditRow[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
@@ -18,11 +37,11 @@ async function load() {
params: {
page: page.value,
pageSize: pageSize.value,
module: filterModule.value || undefined,
module: filterModule.value.trim() || undefined,
},
});
logs.value = data.data.items;
total.value = data.data.total;
logs.value = (data.data.items ?? []) as AuditRow[];
total.value = data.data.total ?? 0;
}
function onPageChange(p: number) {
@@ -35,6 +54,17 @@ function onSizeChange(size: number) {
page.value = 1;
load();
}
function formatTime(v: string) {
return new Date(v).toLocaleString(localeTag.value, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
</script>
<template>
@@ -63,14 +93,16 @@ function onSizeChange(size: number) {
<el-card class="data-card" shadow="never">
<div class="table-wrap">
<el-table :data="logs" stripe>
<el-table-column prop="action" :label="t('audit.col.action')" min-width="140" />
<el-table-column prop="module" :label="t('audit.col.module')" width="120" />
<el-table :key="locale" :data="logs" stripe>
<el-table-column :label="t('audit.col.action')" min-width="140">
<template #default="{ row }">{{ auditActionLabel(row.action) }}</template>
</el-table-column>
<el-table-column :label="t('audit.col.module')" width="120">
<template #default="{ row }">{{ auditModuleLabel(row.module) }}</template>
</el-table-column>
<el-table-column prop="targetId" :label="t('audit.col.target_id')" min-width="100" />
<el-table-column :label="t('audit.col.time')" min-width="160">
<template #default="{ row }">
{{ new Date((row as { createdAt: string }).createdAt).toLocaleString() }}
</template>
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
</el-table>
</div>

View File

@@ -14,7 +14,7 @@ import { useAdminLocale } from '../composables/useAdminLocale';
const { t, localeTag } = useAdminLocale();
const { statusOptions, typeOptions } = useBetFilterOptions();
import type { BetListRow, BetDetail } from './bet-form';
import type { BetListRow, BetDetail } from './bet-form.ts';
const bets = ref<BetListRow[]>([]);
const total = ref(0);
@@ -104,14 +104,14 @@ async function openDetail(row: BetListRow) {
</script>
<template>
<div class="admin-list-page">
<div class="admin-list-page bets-page">
<div class="page-header">
<h2 class="page-title">{{ t('page.bets.title') }}</h2>
<span class="page-desc">{{ t('page.bets.desc') }}</span>
</div>
<el-card class="filter-card" shadow="never">
<el-form inline>
<el-form inline class="bets-filter-form">
<el-form-item :label="t('common.keyword')">
<el-input
v-model="keyword"
@@ -168,53 +168,53 @@ async function openDetail(row: BetListRow) {
<el-card class="data-card" shadow="never">
<div class="table-wrap">
<el-table :data="bets" stripe>
<el-table-column prop="id" :label="t('bet.col.serial')" width="56" align="center" />
<el-table-column prop="betNo" :label="t('bet.col.bet_no')" width="168" show-overflow-tooltip>
<el-table :data="bets" stripe class="bets-table">
<el-table-column prop="id" :label="t('bet.col.serial')" width="64" align="center" />
<el-table-column prop="betNo" :label="t('bet.col.bet_no')" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span class="bet-no">{{ row.betNo }}</span>
</template>
</el-table-column>
<el-table-column prop="username" :label="t('bet.col.player')" width="100" show-overflow-tooltip />
<el-table-column :label="t('bet.col.agent')" width="100" show-overflow-tooltip>
<el-table-column prop="username" :label="t('bet.col.player')" min-width="100" show-overflow-tooltip />
<el-table-column :label="t('bet.col.agent')" min-width="108" show-overflow-tooltip>
<template #default="{ row }">{{ parentLabel(row) }}</template>
</el-table-column>
<el-table-column :label="t('common.type')" width="72" align="center">
<el-table-column :label="t('common.type')" min-width="92" align="center">
<template #default="{ row }">
<el-tag type="info" size="small" effect="plain">{{ betTypeLabel(row.betType) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('bet.col.selection')" width="52" align="center">
<el-table-column :label="t('bet.col.selection_count')" width="88" align="center">
<template #default="{ row }">{{ row.selectionCount }}</template>
</el-table-column>
<el-table-column :label="t('bet.col.stake')" width="96" align="right">
<el-table-column :label="t('bet.col.stake')" min-width="100" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.stake)" placement="top">
<span>{{ formatAmount(row.stake) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('bet.col.odds')" width="72" align="right">
<el-table-column :label="t('bet.col.odds')" width="80" align="right">
<template #default="{ row }">{{ row.totalOdds ?? '—' }}</template>
</el-table-column>
<el-table-column :label="t('bet.col.payout')" width="96" align="right">
<el-table-column :label="t('bet.col.payout')" min-width="100" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.actualReturn)" placement="top">
<span>{{ formatAmount(row.actualReturn) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="88" align="center">
<el-table-column :label="t('common.status')" min-width="100" align="center">
<template #default="{ row }">
<el-tag :type="betStatusTagType(row.status)" size="small">
{{ betStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('bet.col.placed_at')" width="160">
<el-table-column :label="t('bet.col.placed_at')" min-width="168">
<template #default="{ row }">{{ formatTime(row.placedAt) }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="88" fixed="right" align="center">
<el-table-column :label="t('common.actions')" width="100" fixed="right" align="center">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="openDetail(row)">{{ t('common.detail') }}</el-button>
</template>
@@ -302,7 +302,30 @@ async function openDetail(row: BetListRow) {
.page-desc { font-size: 13px; color: #666; }
.filter-card { margin-bottom: 16px; border-radius: 12px; }
.data-card { border-radius: 12px; }
.bet-no { font-size: 12px; color: #ccc; font-family: ui-monospace, monospace; }
.bets-filter-form {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 4px 8px;
}
.bets-filter-form :deep(.el-form-item) {
margin-right: 12px;
margin-bottom: 8px;
}
.bets-filter-form :deep(.el-form-item__label) {
padding-bottom: 0;
}
.bets-table {
min-width: 1180px;
}
.bet-no {
font-size: 12px;
color: #ccc;
font-family: ui-monospace, monospace;
white-space: nowrap;
}
.pager { display: flex; justify-content: flex-end; margin-top: 16px; }
.detail-desc { margin-bottom: 16px; }
.selections-title {

View File

@@ -14,7 +14,7 @@ import {
formFromDetail,
type MatchCreateForm,
type AdminMatchDetail,
} from './match-form';
} from './match-form.ts';
const router = useRouter();
const matches = ref<unknown[]>([]);

View File

@@ -15,7 +15,7 @@ import {
type PlayerDetail,
type PlayerCreateForm,
type PlayerEditForm,
} from './user-form';
} from './user-form.ts';
import {
formatAmount,
formatAmountFull,
@@ -121,7 +121,9 @@ async function submitCreate() {
createLoading.value = true;
try {
await api.post('/admin/users', payload);
ElMessage.success(t('msg.player_created'));
ElMessage.success(
createForm.value.asTier1Agent ? t('msg.agent_created') : t('msg.player_created'),
);
createVisible.value = false;
load();
} catch (e: unknown) {
@@ -388,35 +390,68 @@ function statusLabel(s: string) {
<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.filter.agent')">
<el-select
v-model="createForm.parentId"
:placeholder="t('user.ph.no_agent')"
clearable
style="width: 100%"
>
<el-option
v-for="a in agentOptions"
:key="a.id"
:label="`${a.username} (#${a.id})`"
:value="a.id"
/>
</el-select>
<div class="field-hint">{{ t('user.hint.no_agent') }}</div>
<el-form-item :label="t('user.field.account_type')">
<el-radio-group v-model="createForm.asTier1Agent">
<el-radio :value="false">{{ t('user.type.player') }}</el-radio>
<el-radio :value="true">{{ t('user.type.tier1_agent') }}</el-radio>
</el-radio-group>
<div class="field-hint">{{ t('user.hint.account_type') }}</div>
</el-form-item>
<template v-if="!createForm.asTier1Agent">
<el-form-item :label="t('user.filter.agent')">
<el-select
v-model="createForm.parentId"
:placeholder="t('user.ph.no_agent')"
clearable
style="width: 100%"
>
<el-option
v-for="a in agentOptions"
:key="a.id"
:label="`${a.username} (#${a.id})`"
:value="a.id"
/>
</el-select>
<div class="field-hint">{{ t('user.hint.no_agent') }}</div>
</el-form-item>
</template>
<template v-else>
<el-form-item :label="t('agent.field.credit_limit')" required>
<el-input-number
v-model="createForm.creditLimit"
:min="0"
:step="10000"
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="createForm.cashbackRate"
:min="0"
:max="1"
:step="0.001"
:precision="4"
style="width: 100%"
/>
<div class="field-hint">{{ t('agent.hint.cashback_example') }}</div>
</el-form-item>
</template>
<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="0" :step="100" style="width: 100%" />
<div class="field-hint">{{ t('user.hint.initial_balance') }}</div>
</el-form-item>
<el-form-item :label="t('user.field.deposit_remark')">
<el-input v-model="createForm.remark" :placeholder="t('user.ph.remark_initial')" />
</el-form-item>
<template v-if="!createForm.asTier1Agent">
<el-form-item :label="t('user.field.initial_balance')">
<el-input-number v-model="createForm.initialDeposit" :min="0" :step="100" style="width: 100%" />
<div class="field-hint">{{ t('user.hint.initial_balance') }}</div>
</el-form-item>
<el-form-item :label="t('user.field.deposit_remark')">
<el-input v-model="createForm.remark" :placeholder="t('user.ph.remark_initial')" />
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="createVisible = false">{{ t('common.cancel') }}</el-button>

View File

@@ -1,9 +1,17 @@
import { FormValidationError } from '../i18n/form-validation';
export interface AgentCreateForm {
export interface PromotableUserOption {
id: string;
username: string;
password: string;
confirmPassword: string;
status: string;
parentId: string | null;
parentUsername: string | null;
phone: string | null;
email: string | null;
}
export interface AgentCreateForm {
userId: string;
creditLimit: number;
cashbackRate: number;
phone: string;
@@ -54,9 +62,7 @@ export interface AgentDetail extends AgentRow {
export function emptyAgentCreateForm(): AgentCreateForm {
return {
username: '',
password: 'Agent@123',
confirmPassword: 'Agent@123',
userId: '',
creditLimit: 50000,
cashbackRate: 0,
phone: '',
@@ -82,14 +88,20 @@ export function editFormFromAgentDetail(d: AgentDetail): AgentEditForm {
};
}
export function applyPromotableUserToForm(
form: AgentCreateForm,
user: PromotableUserOption,
): void {
form.userId = user.id;
form.phone = user.phone ?? '';
form.email = user.email ?? '';
}
export function buildCreateAgentPayload(form: AgentCreateForm) {
if (!form.username.trim()) throw new FormValidationError('err.username_required');
if (form.password.length < 8) throw new FormValidationError('err.password_min');
if (form.password !== form.confirmPassword) throw new FormValidationError('err.password_mismatch');
if (!form.userId) throw new FormValidationError('err.user_required');
if (form.creditLimit < 0) throw new FormValidationError('err.credit_negative');
return {
username: form.username.trim(),
password: form.password,
userId: form.userId,
creditLimit: form.creditLimit,
cashbackRate: form.cashbackRate,
phone: form.phone.trim() || undefined,

View File

@@ -9,6 +9,10 @@ export interface PlayerCreateForm {
email: string;
initialDeposit: number;
remark: string;
/** 创建为一级代理(非玩家) */
asTier1Agent: boolean;
creditLimit: number;
cashbackRate: number;
}
export interface PlayerEditForm {
@@ -63,6 +67,9 @@ export function emptyPlayerCreateForm(): PlayerCreateForm {
email: '',
initialDeposit: 0,
remark: '',
asTier1Agent: false,
creditLimit: 50000,
cashbackRate: 0,
};
}
@@ -110,6 +117,20 @@ export function buildCreatePlayerPayload(form: PlayerCreateForm) {
if (!form.username.trim()) throw new FormValidationError('err.username_required');
if (form.password.length < 8) throw new FormValidationError('err.password_min');
if (form.password !== form.confirmPassword) throw new FormValidationError('err.password_mismatch');
if (form.asTier1Agent) {
if (form.parentId) throw new FormValidationError('err.agent_no_parent');
if (form.initialDeposit > 0) throw new FormValidationError('err.agent_no_initial_deposit');
if (form.creditLimit < 0) throw new FormValidationError('err.credit_negative');
return {
username: form.username.trim(),
password: form.password,
phone: form.phone.trim() || undefined,
email: form.email.trim() || undefined,
asTier1Agent: true,
creditLimit: form.creditLimit,
cashbackRate: form.cashbackRate,
};
}
return {
username: form.username.trim(),
password: form.password,