feat(i18n): 管理端与玩家端三语支持(中/英/马来语)

- 管理后台 adminT 文案库、结算与代理端页面、表单校验
- 玩家端 vue-i18n 补全首页/公告/串关与 ms 文案
- Element Plus ms 语言包与共享 locale 工具
This commit is contained in:
2026-06-03 15:05:36 +08:00
parent 80adc0e928
commit cbfa18d1d3
63 changed files with 3081 additions and 1038 deletions

View File

@@ -1,7 +1,11 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import api from '../api';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import { resolveFormError } from '../i18n/form-validation';
import api from '../api';
const { t, localeTag } = useAdminLocale();
import {
emptyPlayerCreateForm,
emptyPlayerEditForm,
@@ -84,7 +88,7 @@ function openCreate() {
}
function parentLabel(row: PlayerRow) {
return row.parentUsername ?? '平台直属';
return row.parentUsername ?? t('common.platform_direct');
}
async function openDetail(id: string) {
@@ -102,7 +106,7 @@ async function openEdit(id: string) {
}
function openDeposit(row: PlayerRow) {
depositForm.value = { userId: row.id, amount: 100, remark: '管理员上分' };
depositForm.value = { userId: row.id, amount: 100, remark: t('user.deposit_remark_default') };
depositVisible.value = true;
}
@@ -111,18 +115,18 @@ async function submitCreate() {
try {
payload = buildCreatePlayerPayload(createForm.value);
} catch (e) {
ElMessage.warning(e instanceof Error ? e.message : '请检查表单');
ElMessage.warning(resolveFormError(e, t));
return;
}
createLoading.value = true;
try {
await api.post('/admin/users', payload);
ElMessage.success('玩家已创建');
ElMessage.success(t('msg.player_created'));
createVisible.value = false;
load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? '创建失败');
ElMessage.error(err.response?.data?.error ?? t('msg.create_failed'));
} finally {
createLoading.value = false;
}
@@ -130,12 +134,16 @@ async function submitCreate() {
async function toggleFreeze(row: PlayerRow) {
const freeze = row.status === 'ACTIVE';
const action = freeze ? '冻结' : '解冻';
const action = freeze ? t('common.freeze') : t('common.unfreeze');
try {
await ElMessageBox.confirm(
`确定要${action}玩家「${row.username}」吗?${freeze ? '冻结后该账号将无法登录。' : ''}`,
`${action}账号`,
{ type: 'warning', confirmButtonText: action, cancelButtonText: '取消' },
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;
@@ -144,11 +152,11 @@ async function toggleFreeze(row: PlayerRow) {
await api.put(`/admin/users/${row.id}`, {
status: freeze ? 'SUSPENDED' : 'ACTIVE',
});
ElMessage.success(`${action}`);
ElMessage.success(t('msg.freeze_done', { action }));
load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? `${action}失败`);
ElMessage.error(err.response?.data?.error ?? t('msg.freeze_failed', { action }));
}
}
@@ -160,12 +168,12 @@ async function submitEdit() {
phone: editForm.value.phone.trim() || undefined,
email: editForm.value.email.trim() || undefined,
});
ElMessage.success('已保存');
ElMessage.success(t('msg.saved'));
editVisible.value = false;
load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? '保存失败');
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
editLoading.value = false;
}
@@ -173,7 +181,7 @@ async function submitEdit() {
async function submitDeposit() {
if (depositForm.value.amount <= 0) {
ElMessage.warning('金额须大于 0');
ElMessage.warning(t('msg.amount_gt_zero'));
return;
}
depositLoading.value = true;
@@ -184,12 +192,12 @@ async function submitDeposit() {
remark: depositForm.value.remark,
requestId: `dep-${depositForm.value.userId}-${Date.now()}`,
});
ElMessage.success('上分成功');
ElMessage.success(t('msg.topup_ok'));
depositVisible.value = false;
load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? '上分失败');
ElMessage.error(err.response?.data?.error ?? t('msg.topup_failed'));
} finally {
depositLoading.value = false;
}
@@ -197,11 +205,11 @@ async function submitDeposit() {
function formatTime(v: string) {
if (!v) return '—';
return new Date(v).toLocaleString('zh-CN');
return new Date(v).toLocaleString(localeTag.value);
}
function formatLastLogin(v: string | null) {
if (!v) return '从未登录';
if (!v) return t('common.never_login');
const d = new Date(v);
const now = new Date();
const sameDay =
@@ -209,9 +217,9 @@ function formatLastLogin(v: string | null) {
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate();
if (sameDay) {
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
return d.toLocaleTimeString(localeTag.value, { hour: '2-digit', minute: '2-digit' });
}
return d.toLocaleString('zh-CN', {
return d.toLocaleString(localeTag.value, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
@@ -224,7 +232,9 @@ function statusTagType(s: string) {
}
function statusLabel(s: string) {
return s === 'ACTIVE' ? '正常' : s === 'SUSPENDED' ? '停用' : s;
const key = `user.status.${s}`;
const v = t(key);
return v !== key ? v : s;
}
</script>
@@ -232,27 +242,27 @@ function statusLabel(s: string) {
<div class="admin-list-page users-page">
<div class="page-header">
<div>
<h2 class="page-title">玩家管理</h2>
<span class="page-desc">创建玩家查看余额与投注概况支持上分与状态管理</span>
<h2 class="page-title">{{ t('page.users.title') }}</h2>
<span class="page-desc">{{ t('page.users.desc') }}</span>
</div>
<el-button type="primary" @click="openCreate">+ 新建玩家</el-button>
<el-button type="primary" @click="openCreate">{{ t('user.create_btn') }}</el-button>
</div>
<el-card class="filter-card" shadow="never">
<el-form inline>
<el-form-item label="关键词">
<el-form-item :label="t('common.keyword')">
<el-input
v-model="keyword"
placeholder="用户名"
:placeholder="t('user.filter.username_ph')"
clearable
style="width: 160px"
@keyup.enter="load"
/>
</el-form-item>
<el-form-item label="所属代理">
<el-form-item :label="t('user.filter.agent')">
<el-select
v-model="filterParentId"
placeholder="全部"
:placeholder="t('user.filter.agent_ph')"
clearable
style="width: 180px"
>
@@ -264,14 +274,14 @@ function statusLabel(s: string) {
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filterStatus" placeholder="全部" clearable style="width: 120px">
<el-option label="正常" value="ACTIVE" />
<el-option label="停用" value="SUSPENDED" />
<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="load">查询</el-button>
<el-button type="primary" @click="load">{{ t('common.search') }}</el-button>
</el-form-item>
</el-form>
</el-card>
@@ -280,18 +290,18 @@ function statusLabel(s: string) {
<div class="table-wrap">
<el-table :data="users" stripe>
<el-table-column prop="id" label="ID" width="72" />
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column label="状态" width="88">
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
<el-table-column :label="t('common.status')" width="88">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="所属代理" min-width="120">
<el-table-column :label="t('user.col.agent')" min-width="120">
<template #default="{ row }">{{ parentLabel(row) }}</template>
</el-table-column>
<el-table-column label="可用 / 冻结" min-width="128" align="right">
<el-table-column :label="t('user.col.balance')" min-width="128" align="right">
<template #default="{ row }">
<el-tooltip
:content="`${formatAmountFull(row.availableBalance)} / ${formatAmountFull(row.frozenBalance)}`"
@@ -303,34 +313,34 @@ function statusLabel(s: string) {
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="betCount" label="注单" width="64" align="center" />
<el-table-column label="投注 / 派彩" min-width="108" align="right">
<el-table-column prop="betCount" :label="t('user.col.bets')" width="64" align="center" />
<el-table-column :label="t('user.col.stake_payout')" min-width="108" align="right">
<template #default="{ row }">
<span class="amount-compact">
{{ formatAmount(row.totalStake) }} / {{ formatAmount(row.totalReturn) }}
</span>
</template>
</el-table-column>
<el-table-column label="最后登录" width="108">
<el-table-column :label="t('user.col.last_login')" width="108">
<template #default="{ row }">
<el-tooltip v-if="row.lastLoginAt" :content="formatTime(row.lastLoginAt)" placement="top">
<span>{{ formatLastLogin(row.lastLoginAt) }}</span>
</el-tooltip>
<span v-else class="text-muted">从未登录</span>
<span v-else class="text-muted">{{ t('common.never_login') }}</span>
</template>
</el-table-column>
<el-table-column label="注册时间" width="108">
<el-table-column :label="t('user.col.created')" width="108">
<template #default="{ row }">
<el-tooltip :content="formatTime(row.createdAt)" placement="top">
<span>{{ formatLastLogin(row.createdAt) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="操作" width="300" fixed="right" align="center">
<el-table-column :label="t('common.actions')" width="300" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="openDetail(row.id)">详情</el-button>
<el-button size="small" link type="primary" @click="openEdit(row.id)">编辑</el-button>
<el-button size="small" link type="primary" @click="openDeposit(row)">上分</el-button>
<el-button size="small" link type="primary" @click="openDetail(row.id)">{{ t('common.detail') }}</el-button>
<el-button size="small" link type="primary" @click="openEdit(row.id)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="primary" @click="openDeposit(row)">{{ t('common.topup') }}</el-button>
<el-button
v-if="row.status === 'ACTIVE'"
size="small"
@@ -338,7 +348,7 @@ function statusLabel(s: string) {
type="warning"
@click="toggleFreeze(row)"
>
冻结
{{ t('common.freeze') }}
</el-button>
<el-button
v-else
@@ -347,7 +357,7 @@ function statusLabel(s: string) {
type="primary"
@click="toggleFreeze(row)"
>
解冻
{{ t('common.unfreeze') }}
</el-button>
</template>
</el-table-column>
@@ -367,21 +377,21 @@ function statusLabel(s: string) {
</div>
</el-card>
<el-dialog v-model="createVisible" title="新建玩家" width="520px" destroy-on-close>
<el-dialog v-model="createVisible" :title="t('user.dialog.create')" width="520px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="用户名" required>
<el-input v-model="createForm.username" placeholder="登录用户名,唯一" />
<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="登录密码" required>
<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="确认密码" required>
<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="所属代理">
<el-form-item :label="t('user.filter.agent')">
<el-select
v-model="createForm.parentId"
placeholder="不设置(平台直属玩家)"
:placeholder="t('user.ph.no_agent')"
clearable
style="width: 100%"
>
@@ -392,46 +402,46 @@ function statusLabel(s: string) {
:value="a.id"
/>
</el-select>
<div class="field-hint">留空表示不挂靠代理由平台直接管理</div>
<div class="field-hint">{{ t('user.hint.no_agent') }}</div>
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="createForm.phone" placeholder="选填" />
<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="邮箱">
<el-input v-model="createForm.email" placeholder="选填" />
<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="初始余额">
<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">创建后自动上分0 表示不开户赠金</div>
<div class="field-hint">{{ t('user.hint.initial_balance') }}</div>
</el-form-item>
<el-form-item label="上分备注">
<el-input v-model="createForm.remark" placeholder="有初始余额时写入流水备注" />
<el-form-item :label="t('user.field.deposit_remark')">
<el-input v-model="createForm.remark" :placeholder="t('user.ph.remark_initial')" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="createLoading" @click="submitCreate">创建</el-button>
<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>
<el-dialog v-model="editVisible" title="编辑玩家" width="560px" destroy-on-close>
<el-dialog v-model="editVisible" :title="t('user.dialog.edit')" width="560px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="玩家 ID">
<el-form-item :label="t('user.field.player_id')">
<el-input :model-value="editForm.id" disabled />
</el-form-item>
<el-form-item label="用户名">
<el-form-item :label="t('user.col.username')">
<el-input :model-value="editForm.username" disabled />
</el-form-item>
<el-form-item label="账号状态">
<el-form-item :label="t('user.field.account_status')">
<el-tag :type="statusTagType(editForm.status)" size="small">
{{ statusLabel(editForm.status) }}
</el-tag>
<span class="field-hint inline-hint">冻结/解冻请在列表操作列进行</span>
<span class="field-hint inline-hint">{{ t('user.hint.freeze_in_list') }}</span>
</el-form-item>
<el-form-item label="所属代理">
<el-form-item :label="t('user.filter.agent')">
<el-select
v-model="editForm.parentId"
placeholder="不设置(平台直属玩家)"
:placeholder="t('user.ph.no_agent')"
clearable
style="width: 100%"
>
@@ -442,102 +452,102 @@ function statusLabel(s: string) {
:value="a.id"
/>
</el-select>
<div class="field-hint">留空表示平台直属变更后会重算相关代理已用授信</div>
<div class="field-hint">{{ t('user.hint.agent_change') }}</div>
</el-form-item>
<el-form-item label="可用余额">
<el-form-item :label="t('user.field.available')">
<el-input :model-value="formatAmount(editForm.availableBalance)" disabled />
</el-form-item>
<el-form-item label="冻结余额">
<el-form-item :label="t('user.field.frozen_balance')">
<el-input :model-value="formatAmount(editForm.frozenBalance)" disabled />
</el-form-item>
<el-form-item label="注单 / 投注">
<el-form-item :label="t('user.field.bets_summary')">
<el-input
:model-value="`${editForm.betCount} / ${formatAmount(editForm.totalStake)}`"
:model-value="t('user.bets_edit_value', { n: editForm.betCount, stake: formatAmount(editForm.totalStake) })"
disabled
/>
</el-form-item>
<el-form-item label="累计派彩">
<el-form-item :label="t('user.field.total_payout')">
<el-input :model-value="formatAmount(editForm.totalReturn)" disabled />
</el-form-item>
<el-form-item label="最后登录">
<el-form-item :label="t('user.col.last_login')">
<el-input
:model-value="editForm.lastLoginAt ? formatTime(editForm.lastLoginAt) : '从未登录'"
:model-value="editForm.lastLoginAt ? formatTime(editForm.lastLoginAt) : t('common.never_login')"
disabled
/>
</el-form-item>
<el-form-item label="登录失败">
<el-input :model-value="`${editForm.loginFailCount} `" disabled />
<el-form-item :label="t('user.field.login_fail')">
<el-input :model-value="t('user.login_fail_value', { n: editForm.loginFailCount })" disabled />
</el-form-item>
<el-form-item label="注册时间">
<el-form-item :label="t('user.col.created')">
<el-input :model-value="formatTime(editForm.createdAt)" disabled />
</el-form-item>
<el-divider />
<el-form-item label="手机号">
<el-input v-model="editForm.phone" placeholder="选填" />
<el-form-item :label="t('user.field.phone')">
<el-input v-model="editForm.phone" :placeholder="t('common.optional')" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="editForm.email" placeholder="选填" />
<el-form-item :label="t('user.field.email')">
<el-input v-model="editForm.email" :placeholder="t('common.optional')" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editLoading" @click="submitEdit">保存资料</el-button>
<el-button @click="editVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="editLoading" @click="submitEdit">{{ t('user.btn.save_profile') }}</el-button>
</template>
</el-dialog>
<el-dialog v-model="depositVisible" title="玩家上分" width="400px" destroy-on-close>
<el-dialog v-model="depositVisible" :title="t('user.dialog.deposit')" width="400px" destroy-on-close>
<el-form label-width="80px">
<el-form-item label="玩家 ID">
<el-form-item :label="t('user.field.player_id')">
<el-input :model-value="depositForm.userId" disabled />
</el-form-item>
<el-form-item label="金额">
<el-form-item :label="t('user.field.amount')">
<el-input-number v-model="depositForm.amount" :min="0.01" :step="10" style="width: 100%" />
</el-form-item>
<el-form-item label="备注">
<el-form-item :label="t('user.field.remark')">
<el-input v-model="depositForm.remark" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="depositVisible = false">取消</el-button>
<el-button type="primary" :loading="depositLoading" @click="submitDeposit">确认上分</el-button>
<el-button @click="depositVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="depositLoading" @click="submitDeposit">{{ t('user.btn.confirm_deposit') }}</el-button>
</template>
</el-dialog>
<el-dialog v-model="detailVisible" title="玩家详情" width="560px" destroy-on-close>
<el-dialog v-model="detailVisible" :title="t('user.dialog.detail')" width="560px" destroy-on-close>
<template v-if="detail">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="ID">{{ detail.id }}</el-descriptions-item>
<el-descriptions-item label="用户名">{{ detail.username }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-descriptions-item :label="t('common.col_id')">{{ detail.id }}</el-descriptions-item>
<el-descriptions-item :label="t('user.col.username')">{{ detail.username }}</el-descriptions-item>
<el-descriptions-item :label="t('common.status')">
<el-tag :type="statusTagType(detail.status)" size="small">
{{ statusLabel(detail.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="所属代理">
{{ detail.parentUsername ?? '平台直属' }}
<el-descriptions-item :label="t('user.col.agent')">
{{ detail.parentUsername ?? t('common.platform_direct') }}
</el-descriptions-item>
<el-descriptions-item label="可用余额">
<el-descriptions-item :label="t('user.field.available')">
{{ formatAmount(detail.availableBalance) }}
<span v-if="shouldCompact(detail.availableBalance)" class="amount-full-hint">
{{ formatAmountFull(detail.availableBalance) }}
</span>
</el-descriptions-item>
<el-descriptions-item label="冻结余额">
<el-descriptions-item :label="t('user.field.frozen_balance')">
{{ formatAmount(detail.frozenBalance) }}
<span v-if="shouldCompact(detail.frozenBalance)" class="amount-full-hint">
{{ formatAmountFull(detail.frozenBalance) }}
</span>
</el-descriptions-item>
<el-descriptions-item label="手机">{{ detail.phone ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ detail.email ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="注单数">{{ detail.betCount }}</el-descriptions-item>
<el-descriptions-item label="累计投注">{{ formatAmount(detail.totalStake) }}</el-descriptions-item>
<el-descriptions-item label="累计派彩">{{ formatAmount(detail.totalReturn) }}</el-descriptions-item>
<el-descriptions-item label="最后登录">
{{ detail.lastLoginAt ? formatTime(detail.lastLoginAt) : '从未登录' }}
<el-descriptions-item :label="t('user.field.phone')">{{ detail.phone ?? '—' }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.email')">{{ detail.email ?? '—' }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.bet_count')">{{ detail.betCount }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.total_stake')">{{ formatAmount(detail.totalStake) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.total_payout')">{{ formatAmount(detail.totalReturn) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.col.last_login')">
{{ detail.lastLoginAt ? formatTime(detail.lastLoginAt) : t('common.never_login') }}
</el-descriptions-item>
<el-descriptions-item label="登录失败">{{ detail.loginFailCount }} </el-descriptions-item>
<el-descriptions-item label="注册时间" :span="2">
<el-descriptions-item :label="t('user.field.login_fail')">{{ t('user.login_fail_value', { n: detail.loginFailCount }) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.registered_at')" :span="2">
{{ formatTime(detail.createdAt) }}
</el-descriptions-item>
</el-descriptions>