feat(admin): 从已有玩家升级代理、修复 i18n 与过期 .js 冲突
- 新建一级代理改为选择已有玩家;新建用户可选一级代理 - 操作日志/注单等扁平 key 翻译;清理 src 内误生成 .js,Vite 优先解析 .ts Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,6 +11,8 @@ coverage/
|
|||||||
apps/player/src/**/*.js
|
apps/player/src/**/*.js
|
||||||
!apps/player/src/router/index.js
|
!apps/player/src/router/index.js
|
||||||
!apps/player/src/utils/localeDisplay.js
|
!apps/player/src/utils/localeDisplay.js
|
||||||
|
# 勿将 tsc/误生成产物留在 admin/src(Vite 会优先加载过期 .js)
|
||||||
|
apps/admin/src/**/*.js
|
||||||
apps/api/prisma/migrations/*_migration_lock.toml
|
apps/api/prisma/migrations/*_migration_lock.toml
|
||||||
|
|
||||||
# 用户上传文件(保留目录结构与示例 Banner)
|
# 用户上传文件(保留目录结构与示例 Banner)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"element-plus": "^2.9.3",
|
"element-plus": "^2.9.3",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-echarts": "^8.0.1",
|
"vue-echarts": "^8.0.1",
|
||||||
|
"vue-i18n": "^11.1.1",
|
||||||
"vue-router": "^4.5.0"
|
"vue-router": "^4.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -142,11 +142,14 @@ html, body, #app {
|
|||||||
.admin-list-page .table-wrap {
|
.admin-list-page .table-wrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
.admin-list-page .table-wrap .el-table {
|
.admin-list-page .table-wrap .el-table {
|
||||||
height: 100% !important;
|
height: 100% !important;
|
||||||
}
|
}
|
||||||
|
.admin-list-page .table-wrap .el-table th.el-table__cell .cell {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.admin-list-page .pager {
|
.admin-list-page .pager {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
42
apps/admin/src/components/AdminLocaleFlag.vue
Normal file
42
apps/admin/src/components/AdminLocaleFlag.vue
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import type { AdminLocale } from '../i18n/admin-messages';
|
||||||
|
|
||||||
|
const LOCALE_COUNTRY: Record<AdminLocale, string> = {
|
||||||
|
'zh-CN': 'cn',
|
||||||
|
'en-US': 'us',
|
||||||
|
'ms-MY': 'my',
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{ locale: AdminLocale | string; size?: number }>(),
|
||||||
|
{ size: 18 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const countryCode = computed(() => {
|
||||||
|
const code = props.locale as AdminLocale;
|
||||||
|
return LOCALE_COUNTRY[code] ?? 'us';
|
||||||
|
});
|
||||||
|
|
||||||
|
const src = computed(() => `/flags/${countryCode.value}.svg`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<img
|
||||||
|
:src="src"
|
||||||
|
:width="size"
|
||||||
|
:height="Math.round(size * 0.67)"
|
||||||
|
class="admin-locale-flag-img"
|
||||||
|
:alt="countryCode"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-locale-flag-img {
|
||||||
|
display: block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 2px;
|
||||||
|
object-fit: cover;
|
||||||
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useAdminLocale, type AdminLocale } from '../composables/useAdminLocale';
|
import { useAdminLocale, type AdminLocale } from '../composables/useAdminLocale';
|
||||||
|
import AdminLocaleFlag from './AdminLocaleFlag.vue';
|
||||||
|
|
||||||
const { locale, locales, setLocale, t } = useAdminLocale();
|
const { locale, locales, setLocale, t } = useAdminLocale();
|
||||||
|
|
||||||
const current = computed(() => locales.find((l) => l.code === locale.value) ?? locales[0]);
|
|
||||||
|
|
||||||
function onChange(e: Event) {
|
function onChange(e: Event) {
|
||||||
setLocale((e.target as HTMLSelectElement).value as AdminLocale);
|
setLocale((e.target as HTMLSelectElement).value as AdminLocale);
|
||||||
}
|
}
|
||||||
@@ -13,10 +11,10 @@ function onChange(e: Event) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<label class="admin-locale" :title="t('lang')">
|
<label class="admin-locale" :title="t('lang')">
|
||||||
<span class="admin-locale-flag" aria-hidden="true">{{ current.flag }}</span>
|
<AdminLocaleFlag :locale="locale" :size="20" />
|
||||||
<select :value="locale" class="admin-locale-select" :aria-label="t('lang')" @change="onChange">
|
<select :value="locale" class="admin-locale-select" :aria-label="t('lang')" @change="onChange">
|
||||||
<option v-for="l in locales" :key="l.code" :value="l.code">
|
<option v-for="l in locales" :key="l.code" :value="l.code">
|
||||||
{{ l.flag }} {{ l.label }}
|
{{ l.label }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@@ -26,22 +24,18 @@ function onChange(e: Event) {
|
|||||||
.admin-locale {
|
.admin-locale {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
}
|
|
||||||
.admin-locale-flag {
|
|
||||||
font-size: 18px;
|
|
||||||
line-height: 1;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
.admin-locale-select {
|
.admin-locale-select {
|
||||||
background: #0d0d0d;
|
background: #0d0d0d;
|
||||||
color: var(--green-text);
|
color: var(--green-text);
|
||||||
border: 1px solid var(--green-border);
|
border: 1px solid var(--green-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 4px 8px 4px 4px;
|
padding: 5px 24px 5px 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
min-width: 108px;
|
min-width: 120px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,34 +1,38 @@
|
|||||||
import { ref, computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import {
|
import {
|
||||||
ADMIN_LOCALES,
|
ADMIN_LOCALES,
|
||||||
adminT,
|
|
||||||
adminLocaleTag,
|
|
||||||
type AdminLocale,
|
type AdminLocale,
|
||||||
} from '../i18n/admin-messages';
|
} from '../i18n/admin-messages';
|
||||||
|
import { ADMIN_LOCALE_STORAGE_KEY } from '../i18n';
|
||||||
|
import { resolveAdminMessage } from '../i18n/resolve-message';
|
||||||
|
|
||||||
const STORAGE_KEY = 'admin_locale';
|
export type { AdminLocale };
|
||||||
|
export { getAdminLocale } from '../i18n';
|
||||||
const locale = ref<AdminLocale>(
|
|
||||||
(localStorage.getItem(STORAGE_KEY) as AdminLocale) || 'zh-CN',
|
|
||||||
);
|
|
||||||
|
|
||||||
export function getAdminLocale(): AdminLocale {
|
|
||||||
return locale.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAdminLocale() {
|
export function useAdminLocale() {
|
||||||
const t = (key: string, params?: Record<string, string | number>) =>
|
const { locale } = useI18n();
|
||||||
adminT(locale.value, key, params);
|
|
||||||
|
/** 扁平 key 查表 + 占位符,不依赖 vue-i18n 嵌套路径解析 */
|
||||||
|
function t(key: string, params?: Record<string, string | number>): string {
|
||||||
|
const loc = locale.value as AdminLocale;
|
||||||
|
let raw = resolveAdminMessage(key, loc) ?? key;
|
||||||
|
if (!params) return raw;
|
||||||
|
return Object.entries(params).reduce(
|
||||||
|
(s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v)),
|
||||||
|
raw,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function setLocale(code: AdminLocale) {
|
function setLocale(code: AdminLocale) {
|
||||||
locale.value = code;
|
locale.value = code;
|
||||||
localStorage.setItem(STORAGE_KEY, code);
|
localStorage.setItem(ADMIN_LOCALE_STORAGE_KEY, code);
|
||||||
document.cookie = `${STORAGE_KEY}=${encodeURIComponent(code)};path=/;max-age=31536000;SameSite=Lax`;
|
document.cookie = `${ADMIN_LOCALE_STORAGE_KEY}=${encodeURIComponent(code)};path=/;max-age=31536000;SameSite=Lax`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locale: computed(() => locale.value),
|
locale: computed(() => locale.value as AdminLocale),
|
||||||
localeTag: computed(() => adminLocaleTag(locale.value)),
|
localeTag: computed(() => String(locale.value)),
|
||||||
locales: ADMIN_LOCALES,
|
locales: ADMIN_LOCALES,
|
||||||
setLocale,
|
setLocale,
|
||||||
t,
|
t,
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ export type AdminLocale = 'zh-CN' | 'en-US' | 'ms-MY';
|
|||||||
export const ADMIN_LOCALES: {
|
export const ADMIN_LOCALES: {
|
||||||
code: AdminLocale;
|
code: AdminLocale;
|
||||||
label: string;
|
label: string;
|
||||||
flag: string;
|
|
||||||
}[] = [
|
}[] = [
|
||||||
{ code: 'zh-CN', label: '中文', flag: '🇨🇳' },
|
{ code: 'zh-CN', label: '中文' },
|
||||||
{ code: 'en-US', label: 'English', flag: '🇺🇸' },
|
{ code: 'en-US', label: 'English' },
|
||||||
{ code: 'ms-MY', label: 'Bahasa Melayu', flag: '🇲🇾' },
|
{ code: 'ms-MY', label: 'Bahasa Melayu' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const zh: Record<string, string> = {
|
const zh: Record<string, string> = {
|
||||||
@@ -458,32 +457,9 @@ const ms: Record<string, string> = {
|
|||||||
...adminPagesMs,
|
...adminPagesMs,
|
||||||
};
|
};
|
||||||
|
|
||||||
const messages: Record<AdminLocale, Record<string, string>> = {
|
/** vue-i18n 文案表(扁平 key,与原先 adminT 一致) */
|
||||||
|
export const adminMessages: Record<AdminLocale, Record<string, string>> = {
|
||||||
'zh-CN': zh,
|
'zh-CN': zh,
|
||||||
'en-US': en,
|
'en-US': en,
|
||||||
'ms-MY': ms,
|
'ms-MY': ms,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function adminT(locale: AdminLocale, key: string, params?: Record<string, string | number>): string {
|
|
||||||
const chain = [locale, 'en-US', 'zh-CN'];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
let raw = key;
|
|
||||||
for (const loc of chain) {
|
|
||||||
if (seen.has(loc)) continue;
|
|
||||||
seen.add(loc);
|
|
||||||
const v = messages[loc as AdminLocale]?.[key];
|
|
||||||
if (v) {
|
|
||||||
raw = v;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!params) return raw;
|
|
||||||
return Object.entries(params).reduce(
|
|
||||||
(s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v)),
|
|
||||||
raw,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function adminLocaleTag(locale: AdminLocale): string {
|
|
||||||
return locale;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ export const adminPagesMs: Record<string, string> = {
|
|||||||
'user.btn.save_profile': 'Simpan',
|
'user.btn.save_profile': 'Simpan',
|
||||||
'user.btn.confirm_deposit': 'Sahkan tambah baki',
|
'user.btn.confirm_deposit': 'Sahkan tambah baki',
|
||||||
'user.deposit_remark_default': 'Tambah baki admin',
|
'user.deposit_remark_default': 'Tambah baki admin',
|
||||||
|
'user.field.account_type': 'Jenis akaun',
|
||||||
|
'user.type.player': 'Pemain',
|
||||||
|
'user.type.tier1_agent': 'Ejen peringkat 1',
|
||||||
|
'user.hint.account_type': 'Ejen guna had kredit; pemain boleh di bawah ejen',
|
||||||
|
|
||||||
'agent.create_btn': '+ Ejen peringkat 1 baharu',
|
'agent.create_btn': '+ Ejen peringkat 1 baharu',
|
||||||
'agent.filter.username_ph': 'Nama pengguna',
|
'agent.filter.username_ph': 'Nama pengguna',
|
||||||
@@ -95,6 +99,9 @@ export const adminPagesMs: Record<string, string> = {
|
|||||||
'agent.col.credit_after': 'Selepas',
|
'agent.col.credit_after': 'Selepas',
|
||||||
'agent.col.no_records': 'Tiada rekod',
|
'agent.col.no_records': 'Tiada rekod',
|
||||||
'agent.btn.confirm_adjust': 'Sahkan',
|
'agent.btn.confirm_adjust': 'Sahkan',
|
||||||
|
'agent.field.select_user': 'Pilih pengguna',
|
||||||
|
'agent.ph.select_user': 'Cari nama pengguna pemain',
|
||||||
|
'agent.hint.select_user': 'Pilih akaun pemain sedia ada untuk naik taraf ke ejen peringkat 1',
|
||||||
|
|
||||||
'match.create_btn': '+ Perlawanan baharu',
|
'match.create_btn': '+ Perlawanan baharu',
|
||||||
'match.filter.keyword_ph': 'Nama perlawanan / kod pasukan',
|
'match.filter.keyword_ph': 'Nama perlawanan / kod pasukan',
|
||||||
@@ -123,6 +130,7 @@ export const adminPagesMs: Record<string, string> = {
|
|||||||
'bet.col.player': 'Pemain',
|
'bet.col.player': 'Pemain',
|
||||||
'bet.col.agent': 'Ejen',
|
'bet.col.agent': 'Ejen',
|
||||||
'bet.col.selection': 'Pilihan',
|
'bet.col.selection': 'Pilihan',
|
||||||
|
'bet.col.selection_count': 'Bil. pilihan',
|
||||||
'bet.col.stake': 'Stake',
|
'bet.col.stake': 'Stake',
|
||||||
'bet.col.odds': 'Odds',
|
'bet.col.odds': 'Odds',
|
||||||
'bet.col.payout': 'Bayaran',
|
'bet.col.payout': 'Bayaran',
|
||||||
@@ -147,6 +155,12 @@ export const adminPagesMs: Record<string, string> = {
|
|||||||
'audit.col.module': 'Modul',
|
'audit.col.module': 'Modul',
|
||||||
'audit.col.target_id': 'ID sasaran',
|
'audit.col.target_id': 'ID sasaran',
|
||||||
'audit.col.time': 'Masa',
|
'audit.col.time': 'Masa',
|
||||||
|
'audit.action.CREATE_PLAYER': 'Cipta pemain',
|
||||||
|
'audit.action.UPDATE_PLAYER': 'Kemas kini pemain',
|
||||||
|
'audit.action.CREATE_AGENT': 'Cipta ejen',
|
||||||
|
'audit.action.UPDATE_AGENT': 'Kemas kini ejen',
|
||||||
|
'audit.module.USERS': 'Pemain',
|
||||||
|
'audit.module.AGENTS': 'Ejen',
|
||||||
|
|
||||||
'cashback.start_date': 'Tarikh mula',
|
'cashback.start_date': 'Tarikh mula',
|
||||||
'cashback.end_date': 'Tarikh tamat',
|
'cashback.end_date': 'Tarikh tamat',
|
||||||
@@ -184,6 +198,9 @@ export const adminPagesMs: Record<string, string> = {
|
|||||||
'err.kickoff_required': 'Sila isi masa mula',
|
'err.kickoff_required': 'Sila isi masa mula',
|
||||||
'err.teams_required': 'Isi nama pasukan tuan rumah dan pelawat (ZH atau EN)',
|
'err.teams_required': 'Isi nama pasukan tuan rumah dan pelawat (ZH atau EN)',
|
||||||
'err.league_required': 'Sila isi nama liga',
|
'err.league_required': 'Sila isi nama liga',
|
||||||
|
'err.user_required': 'Sila pilih pengguna',
|
||||||
|
'err.agent_no_parent': 'Ejen peringkat 1 tidak boleh ada pemain induk',
|
||||||
|
'err.agent_no_initial_deposit': 'Jangan isi baki permulaan pemain apabila cipta ejen',
|
||||||
|
|
||||||
'settlement.ht_score': 'Skor separuh masa',
|
'settlement.ht_score': 'Skor separuh masa',
|
||||||
'settlement.ft_score': 'Skor penuh masa',
|
'settlement.ft_score': 'Skor penuh masa',
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'user.btn.save_profile': '保存资料',
|
'user.btn.save_profile': '保存资料',
|
||||||
'user.btn.confirm_deposit': '确认上分',
|
'user.btn.confirm_deposit': '确认上分',
|
||||||
'user.deposit_remark_default': '管理员上分',
|
'user.deposit_remark_default': '管理员上分',
|
||||||
|
'user.field.account_type': '账号类型',
|
||||||
|
'user.type.player': '玩家',
|
||||||
|
'user.type.tier1_agent': '一级代理',
|
||||||
|
'user.hint.account_type': '代理使用授信额度;玩家可挂靠代理并上分',
|
||||||
|
|
||||||
'agent.create_btn': '+ 新建一级代理',
|
'agent.create_btn': '+ 新建一级代理',
|
||||||
'agent.filter.username_ph': '用户名',
|
'agent.filter.username_ph': '用户名',
|
||||||
@@ -95,6 +99,9 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'agent.col.credit_after': '变动后',
|
'agent.col.credit_after': '变动后',
|
||||||
'agent.col.no_records': '暂无记录',
|
'agent.col.no_records': '暂无记录',
|
||||||
'agent.btn.confirm_adjust': '确认调整',
|
'agent.btn.confirm_adjust': '确认调整',
|
||||||
|
'agent.field.select_user': '选择用户',
|
||||||
|
'agent.ph.select_user': '搜索玩家用户名',
|
||||||
|
'agent.hint.select_user': '从已有玩家账号中选择,将其设为一级代理(不新建登录账号)',
|
||||||
|
|
||||||
'match.create_btn': '+ 新增赛事',
|
'match.create_btn': '+ 新增赛事',
|
||||||
'match.filter.keyword_ph': '赛事名 / 球队代码',
|
'match.filter.keyword_ph': '赛事名 / 球队代码',
|
||||||
@@ -123,6 +130,7 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'bet.col.player': '玩家',
|
'bet.col.player': '玩家',
|
||||||
'bet.col.agent': '所属代理',
|
'bet.col.agent': '所属代理',
|
||||||
'bet.col.selection': '选项',
|
'bet.col.selection': '选项',
|
||||||
|
'bet.col.selection_count': '投注项数',
|
||||||
'bet.col.stake': '投注额',
|
'bet.col.stake': '投注额',
|
||||||
'bet.col.odds': '赔率',
|
'bet.col.odds': '赔率',
|
||||||
'bet.col.payout': '派彩',
|
'bet.col.payout': '派彩',
|
||||||
@@ -147,6 +155,12 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'audit.col.module': '模块',
|
'audit.col.module': '模块',
|
||||||
'audit.col.target_id': '目标 ID',
|
'audit.col.target_id': '目标 ID',
|
||||||
'audit.col.time': '时间',
|
'audit.col.time': '时间',
|
||||||
|
'audit.action.CREATE_PLAYER': '新建玩家',
|
||||||
|
'audit.action.UPDATE_PLAYER': '更新玩家',
|
||||||
|
'audit.action.CREATE_AGENT': '新建代理',
|
||||||
|
'audit.action.UPDATE_AGENT': '更新代理',
|
||||||
|
'audit.module.USERS': '玩家',
|
||||||
|
'audit.module.AGENTS': '代理',
|
||||||
|
|
||||||
'cashback.start_date': '开始日期',
|
'cashback.start_date': '开始日期',
|
||||||
'cashback.end_date': '结束日期',
|
'cashback.end_date': '结束日期',
|
||||||
@@ -184,6 +198,9 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'err.kickoff_required': '请填写开赛时间',
|
'err.kickoff_required': '请填写开赛时间',
|
||||||
'err.teams_required': '请填写主客队名称(中文或英文至少一项)',
|
'err.teams_required': '请填写主客队名称(中文或英文至少一项)',
|
||||||
'err.league_required': '请填写联赛名称',
|
'err.league_required': '请填写联赛名称',
|
||||||
|
'err.user_required': '请选择用户',
|
||||||
|
'err.agent_no_parent': '一级代理不可设置上级玩家',
|
||||||
|
'err.agent_no_initial_deposit': '设为代理时请勿填写玩家初始余额',
|
||||||
|
|
||||||
'settlement.ht_score': '半场比分',
|
'settlement.ht_score': '半场比分',
|
||||||
'settlement.ft_score': '全场比分',
|
'settlement.ft_score': '全场比分',
|
||||||
@@ -301,6 +318,10 @@ export const adminPagesEn: Record<string, string> = {
|
|||||||
'user.btn.save_profile': 'Save',
|
'user.btn.save_profile': 'Save',
|
||||||
'user.btn.confirm_deposit': 'Confirm top-up',
|
'user.btn.confirm_deposit': 'Confirm top-up',
|
||||||
'user.deposit_remark_default': 'Admin top-up',
|
'user.deposit_remark_default': 'Admin top-up',
|
||||||
|
'user.field.account_type': 'Account type',
|
||||||
|
'user.type.player': 'Player',
|
||||||
|
'user.type.tier1_agent': 'Tier-1 agent',
|
||||||
|
'user.hint.account_type': 'Agents use credit limits; players can belong to an agent and receive top-ups',
|
||||||
|
|
||||||
'agent.create_btn': '+ New tier-1 agent',
|
'agent.create_btn': '+ New tier-1 agent',
|
||||||
'agent.filter.username_ph': 'Username',
|
'agent.filter.username_ph': 'Username',
|
||||||
@@ -334,6 +355,9 @@ export const adminPagesEn: Record<string, string> = {
|
|||||||
'agent.col.credit_after': 'After',
|
'agent.col.credit_after': 'After',
|
||||||
'agent.col.no_records': 'No records',
|
'agent.col.no_records': 'No records',
|
||||||
'agent.btn.confirm_adjust': 'Confirm',
|
'agent.btn.confirm_adjust': 'Confirm',
|
||||||
|
'agent.field.select_user': 'Select user',
|
||||||
|
'agent.ph.select_user': 'Search player username',
|
||||||
|
'agent.hint.select_user': 'Pick an existing player account to promote to tier-1 agent (no new login)',
|
||||||
|
|
||||||
'match.create_btn': '+ New match',
|
'match.create_btn': '+ New match',
|
||||||
'match.filter.keyword_ph': 'Match name / team code',
|
'match.filter.keyword_ph': 'Match name / team code',
|
||||||
@@ -362,6 +386,7 @@ export const adminPagesEn: Record<string, string> = {
|
|||||||
'bet.col.player': 'Player',
|
'bet.col.player': 'Player',
|
||||||
'bet.col.agent': 'Agent',
|
'bet.col.agent': 'Agent',
|
||||||
'bet.col.selection': 'Pick',
|
'bet.col.selection': 'Pick',
|
||||||
|
'bet.col.selection_count': 'Legs',
|
||||||
'bet.col.stake': 'Stake',
|
'bet.col.stake': 'Stake',
|
||||||
'bet.col.odds': 'Odds',
|
'bet.col.odds': 'Odds',
|
||||||
'bet.col.payout': 'Payout',
|
'bet.col.payout': 'Payout',
|
||||||
@@ -386,6 +411,12 @@ export const adminPagesEn: Record<string, string> = {
|
|||||||
'audit.col.module': 'Module',
|
'audit.col.module': 'Module',
|
||||||
'audit.col.target_id': 'Target ID',
|
'audit.col.target_id': 'Target ID',
|
||||||
'audit.col.time': 'Time',
|
'audit.col.time': 'Time',
|
||||||
|
'audit.action.CREATE_PLAYER': 'Create player',
|
||||||
|
'audit.action.UPDATE_PLAYER': 'Update player',
|
||||||
|
'audit.action.CREATE_AGENT': 'Create agent',
|
||||||
|
'audit.action.UPDATE_AGENT': 'Update agent',
|
||||||
|
'audit.module.USERS': 'Players',
|
||||||
|
'audit.module.AGENTS': 'Agents',
|
||||||
|
|
||||||
'cashback.start_date': 'Start date',
|
'cashback.start_date': 'Start date',
|
||||||
'cashback.end_date': 'End date',
|
'cashback.end_date': 'End date',
|
||||||
@@ -423,6 +454,9 @@ export const adminPagesEn: Record<string, string> = {
|
|||||||
'err.kickoff_required': 'Kickoff time is required',
|
'err.kickoff_required': 'Kickoff time is required',
|
||||||
'err.teams_required': 'Enter home and away team names (ZH or EN)',
|
'err.teams_required': 'Enter home and away team names (ZH or EN)',
|
||||||
'err.league_required': 'League name is required',
|
'err.league_required': 'League name is required',
|
||||||
|
'err.user_required': 'Please select a user',
|
||||||
|
'err.agent_no_parent': 'Tier-1 agents cannot have a parent player',
|
||||||
|
'err.agent_no_initial_deposit': 'Do not set initial player balance when creating an agent',
|
||||||
|
|
||||||
'settlement.ht_score': 'Half-time score',
|
'settlement.ht_score': 'Half-time score',
|
||||||
'settlement.ft_score': 'Full-time score',
|
'settlement.ft_score': 'Full-time score',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/** 表单校验错误(key 对应 admin-messages / admin-pages) */
|
/** 表单校验错误(key 对应 vue-i18n 文案) */
|
||||||
export class FormValidationError extends Error {
|
export class FormValidationError extends Error {
|
||||||
readonly key: string;
|
readonly key: string;
|
||||||
|
|
||||||
|
|||||||
19
apps/admin/src/i18n/index.ts
Normal file
19
apps/admin/src/i18n/index.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { createI18n } from 'vue-i18n';
|
||||||
|
import { adminMessages, type AdminLocale } from './admin-messages';
|
||||||
|
|
||||||
|
export const ADMIN_LOCALE_STORAGE_KEY = 'admin_locale';
|
||||||
|
|
||||||
|
const saved = (localStorage.getItem(ADMIN_LOCALE_STORAGE_KEY) as AdminLocale) || 'zh-CN';
|
||||||
|
|
||||||
|
export const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: saved,
|
||||||
|
fallbackLocale: ['en-US', 'zh-CN'],
|
||||||
|
messages: adminMessages,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function getAdminLocale(): AdminLocale {
|
||||||
|
return i18n.global.locale.value as AdminLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
16
apps/admin/src/i18n/resolve-message.ts
Normal file
16
apps/admin/src/i18n/resolve-message.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { adminMessages, type AdminLocale } from './admin-messages';
|
||||||
|
import { getAdminLocale } from './index';
|
||||||
|
|
||||||
|
/** 扁平 key 翻译(避免 vue-i18n 把 audit.action.X 当成嵌套路径) */
|
||||||
|
export function resolveAdminMessage(key: string, locale?: AdminLocale): string | undefined {
|
||||||
|
const chain: AdminLocale[] = [];
|
||||||
|
const primary = locale ?? getAdminLocale();
|
||||||
|
for (const loc of [primary, 'en-US', 'zh-CN'] as AdminLocale[]) {
|
||||||
|
if (!chain.includes(loc)) chain.push(loc);
|
||||||
|
}
|
||||||
|
for (const loc of chain) {
|
||||||
|
const v = adminMessages[loc]?.[key];
|
||||||
|
if (v) return v;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -3,5 +3,6 @@ import ElementPlus from 'element-plus';
|
|||||||
import 'element-plus/dist/index.css';
|
import 'element-plus/dist/index.css';
|
||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
import router from './router';
|
import router from './router';
|
||||||
|
import i18n from './i18n';
|
||||||
|
|
||||||
createApp(App).use(router).use(ElementPlus).mount('#app');
|
createApp(App).use(i18n).use(router).use(ElementPlus).mount('#app');
|
||||||
|
|||||||
19
apps/admin/src/utils/audit-labels.ts
Normal file
19
apps/admin/src/utils/audit-labels.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { resolveAdminMessage } from '../i18n/resolve-message';
|
||||||
|
import type { AdminLocale } from '../i18n/admin-messages';
|
||||||
|
import { getAdminLocale } from '../i18n';
|
||||||
|
|
||||||
|
export function auditActionLabel(action: string, locale?: AdminLocale) {
|
||||||
|
return resolveAdminMessage(`audit.action.${action}`, locale) ?? action;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function auditModuleLabel(module: string, locale?: AdminLocale) {
|
||||||
|
return resolveAdminMessage(`audit.module.${module}`, locale) ?? module;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuditLabels() {
|
||||||
|
const loc = () => getAdminLocale();
|
||||||
|
return {
|
||||||
|
auditActionLabel: (action: string) => auditActionLabel(action, loc()),
|
||||||
|
auditModuleLabel: (module: string) => auditModuleLabel(module, loc()),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { adminT, type AdminLocale } from '../i18n/admin-messages';
|
import { resolveAdminMessage } from '../i18n/resolve-message';
|
||||||
import { getAdminLocale, useAdminLocale } from '../composables/useAdminLocale';
|
import type { AdminLocale } from '../i18n/admin-messages';
|
||||||
|
import { getAdminLocale } from '../i18n';
|
||||||
|
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||||
|
|
||||||
export type BetTagType = '' | 'info' | 'success' | 'warning' | 'danger';
|
export type BetTagType = '' | 'info' | 'success' | 'warning' | 'danger';
|
||||||
|
|
||||||
@@ -12,13 +14,13 @@ const STATUS_TAG: Record<string, BetTagType> = {
|
|||||||
REFUNDED: 'info',
|
REFUNDED: 'info',
|
||||||
};
|
};
|
||||||
|
|
||||||
function loc(): AdminLocale {
|
function translateKey(key: string, locale?: AdminLocale): string {
|
||||||
return getAdminLocale();
|
return resolveAdminMessage(key, locale) ?? key;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function betStatusLabel(status: string) {
|
export function betStatusLabel(status: string, locale?: AdminLocale) {
|
||||||
const key = `bet.status.${status}`;
|
const key = `bet.status.${status}`;
|
||||||
const v = adminT(loc(), key);
|
const v = translateKey(key, locale);
|
||||||
return v !== key ? v : status;
|
return v !== key ? v : status;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,23 +28,23 @@ export function betStatusTagType(status: string): BetTagType {
|
|||||||
return STATUS_TAG[status] ?? 'info';
|
return STATUS_TAG[status] ?? 'info';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function betTypeLabel(betType: string) {
|
export function betTypeLabel(betType: string, locale?: AdminLocale) {
|
||||||
const key = `bet.type.${betType}`;
|
const key = `bet.type.${betType}`;
|
||||||
const v = adminT(loc(), key);
|
const v = translateKey(key, locale);
|
||||||
return v !== key ? v : betType;
|
return v !== key ? v : betType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function betSettlementLabel(v: string | null | undefined) {
|
export function betSettlementLabel(v: string | null | undefined, locale?: AdminLocale) {
|
||||||
if (!v) return '—';
|
if (!v) return '—';
|
||||||
const key = `bet.settlement.${v}`;
|
const key = `bet.settlement.${v}`;
|
||||||
const label = adminT(loc(), key);
|
const label = translateKey(key, locale);
|
||||||
return label !== key ? label : v;
|
return label !== key ? label : v;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function betResultLabel(s: string | null | undefined) {
|
export function betResultLabel(s: string | null | undefined, locale?: AdminLocale) {
|
||||||
if (!s) return '—';
|
if (!s) return '—';
|
||||||
const key = `bet.result.${s}`;
|
const key = `bet.result.${s}`;
|
||||||
const label = adminT(loc(), key);
|
const label = translateKey(key, locale);
|
||||||
return label !== key ? label : s;
|
return label !== key ? label : s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getAdminLocale } from '../composables/useAdminLocale';
|
import { getAdminLocale } from '../i18n';
|
||||||
import type { AdminLocale } from '../i18n/admin-messages';
|
import type { AdminLocale } from '../i18n/admin-messages';
|
||||||
|
|
||||||
function resolveLocale(locale?: AdminLocale): AdminLocale {
|
function resolveLocale(locale?: AdminLocale): AdminLocale {
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import {
|
|||||||
emptyAgentEditForm,
|
emptyAgentEditForm,
|
||||||
editFormFromAgentDetail,
|
editFormFromAgentDetail,
|
||||||
buildCreateAgentPayload,
|
buildCreateAgentPayload,
|
||||||
|
applyPromotableUserToForm,
|
||||||
type AgentRow,
|
type AgentRow,
|
||||||
type AgentDetail,
|
type AgentDetail,
|
||||||
type AgentCreateForm,
|
type AgentCreateForm,
|
||||||
type AgentEditForm,
|
type AgentEditForm,
|
||||||
} from './agent-form';
|
type PromotableUserOption,
|
||||||
|
} from './agent-form.ts';
|
||||||
import {
|
import {
|
||||||
formatAmount,
|
formatAmount,
|
||||||
formatAmountFull,
|
formatAmountFull,
|
||||||
@@ -45,6 +47,9 @@ const editLoading = ref(false);
|
|||||||
const creditLoading = ref(false);
|
const creditLoading = ref(false);
|
||||||
|
|
||||||
const createForm = ref<AgentCreateForm>(emptyAgentCreateForm());
|
const createForm = ref<AgentCreateForm>(emptyAgentCreateForm());
|
||||||
|
const promotableUsers = ref<PromotableUserOption[]>([]);
|
||||||
|
const promotableLoading = ref(false);
|
||||||
|
const promotableKeyword = ref('');
|
||||||
const editForm = ref<AgentEditForm>(emptyAgentEditForm());
|
const editForm = ref<AgentEditForm>(emptyAgentEditForm());
|
||||||
const detail = ref<AgentDetail | null>(null);
|
const detail = ref<AgentDetail | null>(null);
|
||||||
const editingId = ref('');
|
const editingId = ref('');
|
||||||
@@ -76,9 +81,38 @@ function onSizeChange(size: number) {
|
|||||||
load();
|
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() {
|
function openCreate() {
|
||||||
createForm.value = emptyAgentCreateForm();
|
createForm.value = emptyAgentCreateForm();
|
||||||
|
promotableKeyword.value = '';
|
||||||
createVisible.value = true;
|
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) {
|
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-dialog v-model="createVisible" :title="t('agent.dialog.create')" width="520px" destroy-on-close>
|
||||||
<el-form label-width="100px">
|
<el-form label-width="100px">
|
||||||
<el-form-item :label="t('user.col.username')" required>
|
<el-form-item :label="t('agent.field.select_user')" required>
|
||||||
<el-input v-model="createForm.username" :placeholder="t('user.ph.username_unique')" />
|
<el-select
|
||||||
</el-form-item>
|
v-model="createForm.userId"
|
||||||
<el-form-item :label="t('user.field.password')" required>
|
filterable
|
||||||
<el-input v-model="createForm.password" type="text" autocomplete="off" />
|
remote
|
||||||
</el-form-item>
|
:remote-method="onPromotableSearch"
|
||||||
<el-form-item :label="t('user.field.confirm_password')" required>
|
:loading="promotableLoading"
|
||||||
<el-input v-model="createForm.confirmPassword" type="text" autocomplete="off" />
|
: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>
|
||||||
<el-form-item :label="t('agent.field.credit_limit')" required>
|
<el-form-item :label="t('agent.field.credit_limit')" required>
|
||||||
<el-input-number
|
<el-input-number
|
||||||
|
|||||||
@@ -3,9 +3,28 @@ import { ref, onMounted } from 'vue';
|
|||||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||||
import api from '../api';
|
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 total = ref(0);
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
const pageSize = ref(10);
|
const pageSize = ref(10);
|
||||||
@@ -18,11 +37,11 @@ async function load() {
|
|||||||
params: {
|
params: {
|
||||||
page: page.value,
|
page: page.value,
|
||||||
pageSize: pageSize.value,
|
pageSize: pageSize.value,
|
||||||
module: filterModule.value || undefined,
|
module: filterModule.value.trim() || undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
logs.value = data.data.items;
|
logs.value = (data.data.items ?? []) as AuditRow[];
|
||||||
total.value = data.data.total;
|
total.value = data.data.total ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPageChange(p: number) {
|
function onPageChange(p: number) {
|
||||||
@@ -35,6 +54,17 @@ function onSizeChange(size: number) {
|
|||||||
page.value = 1;
|
page.value = 1;
|
||||||
load();
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -63,14 +93,16 @@ function onSizeChange(size: number) {
|
|||||||
|
|
||||||
<el-card class="data-card" shadow="never">
|
<el-card class="data-card" shadow="never">
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<el-table :data="logs" stripe>
|
<el-table :key="locale" :data="logs" stripe>
|
||||||
<el-table-column prop="action" :label="t('audit.col.action')" min-width="140" />
|
<el-table-column :label="t('audit.col.action')" min-width="140">
|
||||||
<el-table-column prop="module" :label="t('audit.col.module')" width="120" />
|
<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 prop="targetId" :label="t('audit.col.target_id')" min-width="100" />
|
||||||
<el-table-column :label="t('audit.col.time')" min-width="160">
|
<el-table-column :label="t('audit.col.time')" min-width="160">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||||
{{ new Date((row as { createdAt: string }).createdAt).toLocaleString() }}
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { useAdminLocale } from '../composables/useAdminLocale';
|
|||||||
|
|
||||||
const { t, localeTag } = useAdminLocale();
|
const { t, localeTag } = useAdminLocale();
|
||||||
const { statusOptions, typeOptions } = useBetFilterOptions();
|
const { statusOptions, typeOptions } = useBetFilterOptions();
|
||||||
import type { BetListRow, BetDetail } from './bet-form';
|
import type { BetListRow, BetDetail } from './bet-form.ts';
|
||||||
|
|
||||||
const bets = ref<BetListRow[]>([]);
|
const bets = ref<BetListRow[]>([]);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
@@ -104,14 +104,14 @@ async function openDetail(row: BetListRow) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="admin-list-page">
|
<div class="admin-list-page bets-page">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2 class="page-title">{{ t('page.bets.title') }}</h2>
|
<h2 class="page-title">{{ t('page.bets.title') }}</h2>
|
||||||
<span class="page-desc">{{ t('page.bets.desc') }}</span>
|
<span class="page-desc">{{ t('page.bets.desc') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card class="filter-card" shadow="never">
|
<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-form-item :label="t('common.keyword')">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="keyword"
|
v-model="keyword"
|
||||||
@@ -168,53 +168,53 @@ async function openDetail(row: BetListRow) {
|
|||||||
|
|
||||||
<el-card class="data-card" shadow="never">
|
<el-card class="data-card" shadow="never">
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<el-table :data="bets" stripe>
|
<el-table :data="bets" stripe class="bets-table">
|
||||||
<el-table-column prop="id" :label="t('bet.col.serial')" width="56" align="center" />
|
<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')" width="168" show-overflow-tooltip>
|
<el-table-column prop="betNo" :label="t('bet.col.bet_no')" min-width="200" show-overflow-tooltip>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="bet-no">{{ row.betNo }}</span>
|
<span class="bet-no">{{ row.betNo }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="username" :label="t('bet.col.player')" 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')" 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>
|
<template #default="{ row }">{{ parentLabel(row) }}</template>
|
||||||
</el-table-column>
|
</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 }">
|
<template #default="{ row }">
|
||||||
<el-tag type="info" size="small" effect="plain">{{ betTypeLabel(row.betType) }}</el-tag>
|
<el-tag type="info" size="small" effect="plain">{{ betTypeLabel(row.betType) }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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>
|
<template #default="{ row }">{{ row.selectionCount }}</template>
|
||||||
</el-table-column>
|
</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 }">
|
<template #default="{ row }">
|
||||||
<el-tooltip :content="formatAmountFull(row.stake)" placement="top">
|
<el-tooltip :content="formatAmountFull(row.stake)" placement="top">
|
||||||
<span>{{ formatAmount(row.stake) }}</span>
|
<span>{{ formatAmount(row.stake) }}</span>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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>
|
<template #default="{ row }">{{ row.totalOdds ?? '—' }}</template>
|
||||||
</el-table-column>
|
</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 }">
|
<template #default="{ row }">
|
||||||
<el-tooltip :content="formatAmountFull(row.actualReturn)" placement="top">
|
<el-tooltip :content="formatAmountFull(row.actualReturn)" placement="top">
|
||||||
<span>{{ formatAmount(row.actualReturn) }}</span>
|
<span>{{ formatAmount(row.actualReturn) }}</span>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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 }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="betStatusTagType(row.status)" size="small">
|
<el-tag :type="betStatusTagType(row.status)" size="small">
|
||||||
{{ betStatusLabel(row.status) }}
|
{{ betStatusLabel(row.status) }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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>
|
<template #default="{ row }">{{ formatTime(row.placedAt) }}</template>
|
||||||
</el-table-column>
|
</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 }">
|
<template #default="{ row }">
|
||||||
<el-button type="primary" link size="small" @click="openDetail(row)">{{ t('common.detail') }}</el-button>
|
<el-button type="primary" link size="small" @click="openDetail(row)">{{ t('common.detail') }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
@@ -302,7 +302,30 @@ async function openDetail(row: BetListRow) {
|
|||||||
.page-desc { font-size: 13px; color: #666; }
|
.page-desc { font-size: 13px; color: #666; }
|
||||||
.filter-card { margin-bottom: 16px; border-radius: 12px; }
|
.filter-card { margin-bottom: 16px; border-radius: 12px; }
|
||||||
.data-card { 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; }
|
.pager { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||||
.detail-desc { margin-bottom: 16px; }
|
.detail-desc { margin-bottom: 16px; }
|
||||||
.selections-title {
|
.selections-title {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
formFromDetail,
|
formFromDetail,
|
||||||
type MatchCreateForm,
|
type MatchCreateForm,
|
||||||
type AdminMatchDetail,
|
type AdminMatchDetail,
|
||||||
} from './match-form';
|
} from './match-form.ts';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const matches = ref<unknown[]>([]);
|
const matches = ref<unknown[]>([]);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
type PlayerDetail,
|
type PlayerDetail,
|
||||||
type PlayerCreateForm,
|
type PlayerCreateForm,
|
||||||
type PlayerEditForm,
|
type PlayerEditForm,
|
||||||
} from './user-form';
|
} from './user-form.ts';
|
||||||
import {
|
import {
|
||||||
formatAmount,
|
formatAmount,
|
||||||
formatAmountFull,
|
formatAmountFull,
|
||||||
@@ -121,7 +121,9 @@ async function submitCreate() {
|
|||||||
createLoading.value = true;
|
createLoading.value = true;
|
||||||
try {
|
try {
|
||||||
await api.post('/admin/users', payload);
|
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;
|
createVisible.value = false;
|
||||||
load();
|
load();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
@@ -388,35 +390,68 @@ function statusLabel(s: string) {
|
|||||||
<el-form-item :label="t('user.field.confirm_password')" required>
|
<el-form-item :label="t('user.field.confirm_password')" required>
|
||||||
<el-input v-model="createForm.confirmPassword" type="text" autocomplete="off" />
|
<el-input v-model="createForm.confirmPassword" type="text" autocomplete="off" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('user.filter.agent')">
|
<el-form-item :label="t('user.field.account_type')">
|
||||||
<el-select
|
<el-radio-group v-model="createForm.asTier1Agent">
|
||||||
v-model="createForm.parentId"
|
<el-radio :value="false">{{ t('user.type.player') }}</el-radio>
|
||||||
:placeholder="t('user.ph.no_agent')"
|
<el-radio :value="true">{{ t('user.type.tier1_agent') }}</el-radio>
|
||||||
clearable
|
</el-radio-group>
|
||||||
style="width: 100%"
|
<div class="field-hint">{{ t('user.hint.account_type') }}</div>
|
||||||
>
|
|
||||||
<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>
|
</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-form-item :label="t('user.field.phone')">
|
||||||
<el-input v-model="createForm.phone" :placeholder="t('common.optional')" />
|
<el-input v-model="createForm.phone" :placeholder="t('common.optional')" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('user.field.email')">
|
<el-form-item :label="t('user.field.email')">
|
||||||
<el-input v-model="createForm.email" :placeholder="t('common.optional')" />
|
<el-input v-model="createForm.email" :placeholder="t('common.optional')" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('user.field.initial_balance')">
|
<template v-if="!createForm.asTier1Agent">
|
||||||
<el-input-number v-model="createForm.initialDeposit" :min="0" :step="100" style="width: 100%" />
|
<el-form-item :label="t('user.field.initial_balance')">
|
||||||
<div class="field-hint">{{ t('user.hint.initial_balance') }}</div>
|
<el-input-number v-model="createForm.initialDeposit" :min="0" :step="100" style="width: 100%" />
|
||||||
</el-form-item>
|
<div class="field-hint">{{ t('user.hint.initial_balance') }}</div>
|
||||||
<el-form-item :label="t('user.field.deposit_remark')">
|
</el-form-item>
|
||||||
<el-input v-model="createForm.remark" :placeholder="t('user.ph.remark_initial')" />
|
<el-form-item :label="t('user.field.deposit_remark')">
|
||||||
</el-form-item>
|
<el-input v-model="createForm.remark" :placeholder="t('user.ph.remark_initial')" />
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="createVisible = false">{{ t('common.cancel') }}</el-button>
|
<el-button @click="createVisible = false">{{ t('common.cancel') }}</el-button>
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { FormValidationError } from '../i18n/form-validation';
|
import { FormValidationError } from '../i18n/form-validation';
|
||||||
|
|
||||||
export interface AgentCreateForm {
|
export interface PromotableUserOption {
|
||||||
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
status: string;
|
||||||
confirmPassword: string;
|
parentId: string | null;
|
||||||
|
parentUsername: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
email: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentCreateForm {
|
||||||
|
userId: string;
|
||||||
creditLimit: number;
|
creditLimit: number;
|
||||||
cashbackRate: number;
|
cashbackRate: number;
|
||||||
phone: string;
|
phone: string;
|
||||||
@@ -54,9 +62,7 @@ export interface AgentDetail extends AgentRow {
|
|||||||
|
|
||||||
export function emptyAgentCreateForm(): AgentCreateForm {
|
export function emptyAgentCreateForm(): AgentCreateForm {
|
||||||
return {
|
return {
|
||||||
username: '',
|
userId: '',
|
||||||
password: 'Agent@123',
|
|
||||||
confirmPassword: 'Agent@123',
|
|
||||||
creditLimit: 50000,
|
creditLimit: 50000,
|
||||||
cashbackRate: 0,
|
cashbackRate: 0,
|
||||||
phone: '',
|
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) {
|
export function buildCreateAgentPayload(form: AgentCreateForm) {
|
||||||
if (!form.username.trim()) throw new FormValidationError('err.username_required');
|
if (!form.userId) throw new FormValidationError('err.user_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.creditLimit < 0) throw new FormValidationError('err.credit_negative');
|
if (form.creditLimit < 0) throw new FormValidationError('err.credit_negative');
|
||||||
return {
|
return {
|
||||||
username: form.username.trim(),
|
userId: form.userId,
|
||||||
password: form.password,
|
|
||||||
creditLimit: form.creditLimit,
|
creditLimit: form.creditLimit,
|
||||||
cashbackRate: form.cashbackRate,
|
cashbackRate: form.cashbackRate,
|
||||||
phone: form.phone.trim() || undefined,
|
phone: form.phone.trim() || undefined,
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ export interface PlayerCreateForm {
|
|||||||
email: string;
|
email: string;
|
||||||
initialDeposit: number;
|
initialDeposit: number;
|
||||||
remark: string;
|
remark: string;
|
||||||
|
/** 创建为一级代理(非玩家) */
|
||||||
|
asTier1Agent: boolean;
|
||||||
|
creditLimit: number;
|
||||||
|
cashbackRate: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayerEditForm {
|
export interface PlayerEditForm {
|
||||||
@@ -63,6 +67,9 @@ export function emptyPlayerCreateForm(): PlayerCreateForm {
|
|||||||
email: '',
|
email: '',
|
||||||
initialDeposit: 0,
|
initialDeposit: 0,
|
||||||
remark: '',
|
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.username.trim()) throw new FormValidationError('err.username_required');
|
||||||
if (form.password.length < 8) throw new FormValidationError('err.password_min');
|
if (form.password.length < 8) throw new FormValidationError('err.password_min');
|
||||||
if (form.password !== form.confirmPassword) throw new FormValidationError('err.password_mismatch');
|
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 {
|
return {
|
||||||
username: form.username.trim(),
|
username: form.username.trim(),
|
||||||
password: form.password,
|
password: form.password,
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { resolve } from 'path';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
// 避免 src 内遗留的 .js 抢先于 .ts/.vue 被解析(曾导致 i18n 文案缺失)
|
||||||
|
extensions: ['.mjs', '.ts', '.mts', '.tsx', '.vue', '.js', '.jsx', '.json'],
|
||||||
|
},
|
||||||
publicDir: resolve(__dirname, '../../packages/shared/public'),
|
publicDir: resolve(__dirname, '../../packages/shared/public'),
|
||||||
server: {
|
server: {
|
||||||
port: 5174,
|
port: 5174,
|
||||||
|
|||||||
@@ -88,6 +88,20 @@ class CreatePlayerAdminDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
remark?: string;
|
remark?: string;
|
||||||
|
|
||||||
|
/** 创建为一级代理(非玩家) */
|
||||||
|
@IsOptional()
|
||||||
|
asTier1Agent?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
creditLimit?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
cashbackRate?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
class UpdatePlayerAdminDto {
|
class UpdatePlayerAdminDto {
|
||||||
@@ -114,21 +128,14 @@ class UpdatePlayerAdminDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class CreateAgentAdminDto {
|
class CreateAgentAdminDto {
|
||||||
|
/** 已有玩家用户 ID,升级为一级代理 */
|
||||||
@IsString()
|
@IsString()
|
||||||
username!: string;
|
userId!: string;
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@MinLength(8)
|
|
||||||
password!: string;
|
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
creditLimit!: number;
|
creditLimit!: number;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
locale?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
phone?: string;
|
phone?: string;
|
||||||
@@ -331,18 +338,41 @@ export class AdminController {
|
|||||||
initialDeposit: dto.initialDeposit,
|
initialDeposit: dto.initialDeposit,
|
||||||
depositRemark: dto.remark,
|
depositRemark: dto.remark,
|
||||||
depositRequestId: `create-player-${dto.username}-${Date.now()}`,
|
depositRequestId: `create-player-${dto.username}-${Date.now()}`,
|
||||||
|
asTier1Agent: dto.asTier1Agent,
|
||||||
|
creditLimit: dto.creditLimit,
|
||||||
|
cashbackRate: dto.cashbackRate,
|
||||||
});
|
});
|
||||||
await this.audit.log({
|
await this.audit.log({
|
||||||
operatorId,
|
operatorId,
|
||||||
operatorType: 'ADMIN',
|
operatorType: 'ADMIN',
|
||||||
action: 'CREATE_PLAYER',
|
action: dto.asTier1Agent ? 'CREATE_AGENT' : 'CREATE_PLAYER',
|
||||||
module: 'USERS',
|
module: dto.asTier1Agent ? 'AGENTS' : 'USERS',
|
||||||
targetId: user.id.toString(),
|
targetId: user.id.toString(),
|
||||||
});
|
});
|
||||||
|
if (dto.asTier1Agent) {
|
||||||
|
const detail = await this.agents.getAgentAdminDetail(user.id);
|
||||||
|
return jsonResponse(detail);
|
||||||
|
}
|
||||||
const detail = await this.users.getPlayerAdminDetail(user.id);
|
const detail = await this.users.getPlayerAdminDetail(user.id);
|
||||||
return jsonResponse(detail);
|
return jsonResponse(detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('users/promotable-for-agent')
|
||||||
|
async listPromotableForAgent(@Query('keyword') keyword?: string) {
|
||||||
|
const rows = await this.agents.listPromotablePlayers(keyword);
|
||||||
|
return jsonResponse(
|
||||||
|
rows.map((u) => ({
|
||||||
|
id: u.id.toString(),
|
||||||
|
username: u.username,
|
||||||
|
status: u.status,
|
||||||
|
parentId: u.parentId?.toString() ?? null,
|
||||||
|
parentUsername: u.parent?.username ?? null,
|
||||||
|
phone: u.preferences?.phone ?? null,
|
||||||
|
email: u.preferences?.email ?? null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('agents/options')
|
@Get('agents/options')
|
||||||
async listAgentOptions() {
|
async listAgentOptions() {
|
||||||
const agents = await this.prisma.user.findMany({
|
const agents = await this.prisma.user.findMany({
|
||||||
@@ -397,12 +427,8 @@ export class AdminController {
|
|||||||
@CurrentUser('id') operatorId: bigint,
|
@CurrentUser('id') operatorId: bigint,
|
||||||
@Body() dto: CreateAgentAdminDto,
|
@Body() dto: CreateAgentAdminDto,
|
||||||
) {
|
) {
|
||||||
const user = await this.agents.createAgent(operatorId, {
|
const user = await this.agents.promotePlayerToTier1Agent(BigInt(dto.userId), {
|
||||||
username: dto.username,
|
|
||||||
password: dto.password,
|
|
||||||
level: 1,
|
|
||||||
creditLimit: dto.creditLimit,
|
creditLimit: dto.creditLimit,
|
||||||
locale: dto.locale,
|
|
||||||
phone: dto.phone,
|
phone: dto.phone,
|
||||||
email: dto.email,
|
email: dto.email,
|
||||||
cashbackRate: dto.cashbackRate,
|
cashbackRate: dto.cashbackRate,
|
||||||
|
|||||||
@@ -358,6 +358,113 @@ export class AgentsService {
|
|||||||
return this.getAgentAdminDetail(agentId);
|
return this.getAgentAdminDetail(agentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 可升级为一级代理的玩家(尚无代理档案) */
|
||||||
|
async listPromotablePlayers(keyword?: string) {
|
||||||
|
const q = keyword?.trim();
|
||||||
|
return this.prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
userType: 'PLAYER',
|
||||||
|
deletedAt: null,
|
||||||
|
agentProfile: null,
|
||||||
|
...(q
|
||||||
|
? { username: { contains: q, mode: 'insensitive' } }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
status: true,
|
||||||
|
parentId: true,
|
||||||
|
preferences: { select: { phone: true, email: true } },
|
||||||
|
parent: { select: { username: true } },
|
||||||
|
},
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
take: 50,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将已有玩家账号升级为一级代理(不新建用户) */
|
||||||
|
async promotePlayerToTier1Agent(
|
||||||
|
userId: bigint,
|
||||||
|
data: {
|
||||||
|
creditLimit: number;
|
||||||
|
cashbackRate?: number;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
include: { agentProfile: true, preferences: true },
|
||||||
|
});
|
||||||
|
if (!user || user.deletedAt) {
|
||||||
|
throw new NotFoundException('用户不存在');
|
||||||
|
}
|
||||||
|
if (user.userType !== 'PLAYER') {
|
||||||
|
throw new BadRequestException('仅玩家账号可设为代理');
|
||||||
|
}
|
||||||
|
if (user.agentProfile) {
|
||||||
|
throw new BadRequestException('该用户已是代理');
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldParentId = user.parentId;
|
||||||
|
const phone =
|
||||||
|
data.phone !== undefined
|
||||||
|
? data.phone.trim() || null
|
||||||
|
: user.preferences?.phone ?? null;
|
||||||
|
const email =
|
||||||
|
data.email !== undefined
|
||||||
|
? data.email.trim() || null
|
||||||
|
: user.preferences?.email ?? null;
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
userType: 'AGENT',
|
||||||
|
agentLevel: 1,
|
||||||
|
parentId: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user.preferences) {
|
||||||
|
await tx.userPreference.update({
|
||||||
|
where: { userId },
|
||||||
|
data: { phone, email },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await tx.userPreference.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
locale: user.locale,
|
||||||
|
phone,
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.agentProfile.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
level: 1,
|
||||||
|
parentAgentId: null,
|
||||||
|
creditLimit: data.creditLimit,
|
||||||
|
cashbackRate: data.cashbackRate ?? 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.agentClosure.create({
|
||||||
|
data: { ancestorId: userId, descendantId: userId, depth: 0 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (oldParentId) {
|
||||||
|
await this.recalculateUsedCredit(oldParentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
}
|
||||||
|
|
||||||
async createAgent(
|
async createAgent(
|
||||||
operatorId: bigint,
|
operatorId: bigint,
|
||||||
data: {
|
data: {
|
||||||
@@ -455,8 +562,30 @@ export class AgentsService {
|
|||||||
initialDeposit?: number;
|
initialDeposit?: number;
|
||||||
depositRemark?: string;
|
depositRemark?: string;
|
||||||
depositRequestId?: string;
|
depositRequestId?: string;
|
||||||
|
asTier1Agent?: boolean;
|
||||||
|
creditLimit?: number;
|
||||||
|
cashbackRate?: number;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
if (data.asTier1Agent) {
|
||||||
|
if (data.parentId != null) {
|
||||||
|
throw new BadRequestException('一级代理不可设置上级玩家');
|
||||||
|
}
|
||||||
|
if (data.initialDeposit && data.initialDeposit > 0) {
|
||||||
|
throw new BadRequestException('设为代理时请使用授信额度,勿填玩家初始余额');
|
||||||
|
}
|
||||||
|
return this.createAgent(operatorId, {
|
||||||
|
username: data.username,
|
||||||
|
password: data.password,
|
||||||
|
level: 1,
|
||||||
|
creditLimit: data.creditLimit ?? 0,
|
||||||
|
cashbackRate: data.cashbackRate ?? 0,
|
||||||
|
locale: data.locale,
|
||||||
|
phone: data.phone,
|
||||||
|
email: data.email,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let parentId: bigint | null = null;
|
let parentId: bigint | null = null;
|
||||||
if (data.parentId != null) {
|
if (data.parentId != null) {
|
||||||
const parent = await this.prisma.user.findUnique({ where: { id: data.parentId } });
|
const parent = await this.prisma.user.findUnique({ where: { id: data.parentId } });
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -25,6 +25,9 @@ importers:
|
|||||||
vue-echarts:
|
vue-echarts:
|
||||||
specifier: ^8.0.1
|
specifier: ^8.0.1
|
||||||
version: 8.0.1(echarts@6.1.0)(vue@3.5.35(typescript@5.7.3))
|
version: 8.0.1(echarts@6.1.0)(vue@3.5.35(typescript@5.7.3))
|
||||||
|
vue-i18n:
|
||||||
|
specifier: ^11.1.1
|
||||||
|
version: 11.4.4(vue@3.5.35(typescript@5.7.3))
|
||||||
vue-router:
|
vue-router:
|
||||||
specifier: ^4.5.0
|
specifier: ^4.5.0
|
||||||
version: 4.6.4(vue@3.5.35(typescript@5.7.3))
|
version: 4.6.4(vue@3.5.35(typescript@5.7.3))
|
||||||
|
|||||||
Reference in New Issue
Block a user