feat(i18n): 管理端与玩家端三语支持(中/英/马来语)
- 管理后台 adminT 文案库、结算与代理端页面、表单校验 - 玩家端 vue-i18n 补全首页/公告/串关与 ms 文案 - Element Plus ms 语言包与共享 locale 工具
This commit is contained in:
@@ -1,7 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { RouterView } from 'vue-router';
|
||||
import { ElConfigProvider } from 'element-plus';
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn';
|
||||
import en from 'element-plus/es/locale/lang/en';
|
||||
import ms from 'element-plus/es/locale/lang/ms';
|
||||
import { useAdminLocale } from './composables/useAdminLocale';
|
||||
|
||||
const { locale } = useAdminLocale();
|
||||
|
||||
const elLocale = computed(() => {
|
||||
if (locale.value.startsWith('zh')) return zhCn;
|
||||
if (locale.value.startsWith('ms')) return ms;
|
||||
return en;
|
||||
});
|
||||
</script>
|
||||
<template><RouterView /></template>
|
||||
|
||||
<template>
|
||||
<ElConfigProvider :locale="elLocale">
|
||||
<RouterView />
|
||||
</ElConfigProvider>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
47
apps/admin/src/components/AdminLocaleSwitcher.vue
Normal file
47
apps/admin/src/components/AdminLocaleSwitcher.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useAdminLocale, type AdminLocale } from '../composables/useAdminLocale';
|
||||
|
||||
const { locale, locales, setLocale, t } = useAdminLocale();
|
||||
|
||||
const current = computed(() => locales.find((l) => l.code === locale.value) ?? locales[0]);
|
||||
|
||||
function onChange(e: Event) {
|
||||
setLocale((e.target as HTMLSelectElement).value as AdminLocale);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="admin-locale" :title="t('lang')">
|
||||
<span class="admin-locale-flag" aria-hidden="true">{{ current.flag }}</span>
|
||||
<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">
|
||||
{{ l.flag }} {{ l.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-locale {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.admin-locale-flag {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.admin-locale-select {
|
||||
background: #0d0d0d;
|
||||
color: var(--green-text);
|
||||
border: 1px solid var(--green-border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px 4px 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
min-width: 108px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
const input = ref('');
|
||||
const code = ref('');
|
||||
@@ -69,9 +72,9 @@ defineExpose({ validate, refresh });
|
||||
<input v-model="honeypot" type="text" name="website" tabindex="-1"
|
||||
autocomplete="off" class="hp-field" aria-hidden="true" />
|
||||
<input v-model="input" type="text" inputmode="numeric" maxlength="4"
|
||||
class="captcha-input" placeholder="Captcha" autocomplete="off" />
|
||||
class="captcha-input" :placeholder="t('login.captcha_ph')" autocomplete="off" />
|
||||
<canvas ref="canvasRef" class="captcha-canvas"
|
||||
title="点击刷新" role="button" tabindex="0"
|
||||
:title="t('login.captcha_refresh')" role="button" tabindex="0"
|
||||
@click="refresh" @keydown.enter="refresh" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -79,10 +82,10 @@ defineExpose({ validate, refresh });
|
||||
<style scoped>
|
||||
.captcha-row {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
align-items: stretch;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.hp-field {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
@@ -91,25 +94,21 @@ defineExpose({ validate, refresh });
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.captcha-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #333;
|
||||
border-right: none;
|
||||
border: none;
|
||||
border-radius: 8px 0 0 8px;
|
||||
background: #0d0d0d;
|
||||
color: #fff;
|
||||
background: #ffffff;
|
||||
color: #111;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.captcha-input::placeholder { color: #555; }
|
||||
.captcha-input:focus { border-color: rgba(0, 196, 65, 0.6); }
|
||||
|
||||
.captcha-input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
.captcha-canvas {
|
||||
flex-shrink: 0;
|
||||
width: 108px;
|
||||
|
||||
36
apps/admin/src/composables/useAdminLocale.ts
Normal file
36
apps/admin/src/composables/useAdminLocale.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import {
|
||||
ADMIN_LOCALES,
|
||||
adminT,
|
||||
adminLocaleTag,
|
||||
type AdminLocale,
|
||||
} from '../i18n/admin-messages';
|
||||
|
||||
const STORAGE_KEY = 'admin_locale';
|
||||
|
||||
const locale = ref<AdminLocale>(
|
||||
(localStorage.getItem(STORAGE_KEY) as AdminLocale) || 'zh-CN',
|
||||
);
|
||||
|
||||
export function getAdminLocale(): AdminLocale {
|
||||
return locale.value;
|
||||
}
|
||||
|
||||
export function useAdminLocale() {
|
||||
const t = (key: string, params?: Record<string, string | number>) =>
|
||||
adminT(locale.value, key, params);
|
||||
|
||||
function setLocale(code: AdminLocale) {
|
||||
locale.value = code;
|
||||
localStorage.setItem(STORAGE_KEY, code);
|
||||
document.cookie = `${STORAGE_KEY}=${encodeURIComponent(code)};path=/;max-age=31536000;SameSite=Lax`;
|
||||
}
|
||||
|
||||
return {
|
||||
locale: computed(() => locale.value),
|
||||
localeTag: computed(() => adminLocaleTag(locale.value)),
|
||||
locales: ADMIN_LOCALES,
|
||||
setLocale,
|
||||
t,
|
||||
};
|
||||
}
|
||||
489
apps/admin/src/i18n/admin-messages.ts
Normal file
489
apps/admin/src/i18n/admin-messages.ts
Normal file
@@ -0,0 +1,489 @@
|
||||
import { adminPagesEn, adminPagesZh } from './admin-pages';
|
||||
import { adminPagesMs } from './admin-pages-ms';
|
||||
|
||||
/** 管理后台:中文 + 英文 + 马来语 */
|
||||
export type AdminLocale = 'zh-CN' | 'en-US' | 'ms-MY';
|
||||
|
||||
export const ADMIN_LOCALES: {
|
||||
code: AdminLocale;
|
||||
label: string;
|
||||
flag: string;
|
||||
}[] = [
|
||||
{ code: 'zh-CN', label: '中文', flag: '🇨🇳' },
|
||||
{ code: 'en-US', label: 'English', flag: '🇺🇸' },
|
||||
{ code: 'ms-MY', label: 'Bahasa Melayu', flag: '🇲🇾' },
|
||||
];
|
||||
|
||||
const zh: Record<string, string> = {
|
||||
'login.title': '管理后台',
|
||||
'login.username': '用户名',
|
||||
'login.password': '密码',
|
||||
'login.submit': '登录',
|
||||
'login.username_ph': '请输入用户名',
|
||||
'login.password_ph': '请输入密码',
|
||||
'login.err_captcha': '验证码错误,请重试',
|
||||
'login.err_failed': '登录失败,请检查账号与密码',
|
||||
'login.err_quick': '快速登录失败',
|
||||
'login.quick_label': '快速登录(调试)',
|
||||
'login.quick_admin': '管理员',
|
||||
'login.quick_agent': '一级代理',
|
||||
'login.captcha_ph': '验证码',
|
||||
'login.captcha_refresh': '点击刷新',
|
||||
|
||||
'nav.dashboard': '控制台',
|
||||
'nav.users': '玩家管理',
|
||||
'nav.agents': '代理管理',
|
||||
'nav.matches': '赛事管理',
|
||||
'nav.bets': '注单管理',
|
||||
'nav.cashback': '返水管理',
|
||||
'nav.audit': '操作日志',
|
||||
'nav.players': '直属玩家',
|
||||
'nav.subAgents': '下级代理',
|
||||
'nav.myBets': '注单查询',
|
||||
'role.admin': '系统管理员',
|
||||
'role.agent': '代理账号',
|
||||
'logout': '退出',
|
||||
'lang': '语言',
|
||||
'portal.admin': '平台后台',
|
||||
'portal.agent': '代理后台',
|
||||
|
||||
'common.all': '全部',
|
||||
'common.search': '查询',
|
||||
'common.reset': '重置',
|
||||
'common.edit': '编辑',
|
||||
'common.delete': '删除',
|
||||
'common.cancel': '取消',
|
||||
'common.confirm': '确定',
|
||||
'common.status': '状态',
|
||||
'common.type': '类型',
|
||||
'common.keyword': '关键词',
|
||||
'common.actions': '操作',
|
||||
'common.loading': '加载中…',
|
||||
'common.no_data': '暂无数据',
|
||||
'common.yesterday': '昨日',
|
||||
'common.vs_yesterday': '较昨日',
|
||||
'common.people': '人',
|
||||
'common.bets_unit': '单',
|
||||
'common.matches_unit': '场赛事',
|
||||
'common.frozen': '冻结',
|
||||
'common.used': '已用',
|
||||
'common.platform_direct': '平台直属',
|
||||
'common.updated_at': '更新于',
|
||||
|
||||
'dash.title': '控制台',
|
||||
'dash.desc': '平台整体运行概况',
|
||||
'dash.board_title': '整体概览',
|
||||
'dash.board_hint': '一屏查看经营趋势与平台分布',
|
||||
'dash.kpi_bet_count': '今日投注笔数',
|
||||
'dash.kpi_stake': '今日投注额',
|
||||
'dash.kpi_payout': '今日派彩',
|
||||
'dash.kpi_ggr': '今日毛利',
|
||||
'dash.kpi_users': '玩家 / 代理',
|
||||
'dash.kpi_new_players': '今日新增 {n} 人',
|
||||
'dash.kpi_pending': '待结算',
|
||||
'dash.kpi_pending_sub': '{bets} 单 · {matches} 场赛事',
|
||||
'dash.kpi_wallet': '玩家余额',
|
||||
'dash.kpi_credit': '代理授信',
|
||||
'dash.trend_caption': '近 7 日经营趋势(金额折线 + 注单柱)',
|
||||
'dash.chart_stake': '投注额',
|
||||
'dash.chart_payout': '派彩',
|
||||
'dash.chart_ggr': '毛利',
|
||||
'dash.chart_bet_count': '注单笔数',
|
||||
'dash.axis_amount': '金额',
|
||||
'dash.axis_count': '笔数',
|
||||
'dash.count_suffix': '笔',
|
||||
'dash.chart_tooltip': '{b}:{c}({d}%)',
|
||||
'dash.pie_empty': '暂无',
|
||||
'dash.pie_matches': '赛事',
|
||||
'dash.pie_bets': '今日注单',
|
||||
'dash.pie_users': '用户',
|
||||
'dash.match_draft': '草稿',
|
||||
'dash.match_published': '已发布',
|
||||
'dash.match_closed': '已封盘',
|
||||
'dash.match_pending_settle': '待结算',
|
||||
'dash.match_settled': '已结算',
|
||||
'dash.user_active': '正常玩家',
|
||||
'dash.user_suspended': '停用',
|
||||
'dash.user_direct': '直属',
|
||||
'dash.user_agents': '代理',
|
||||
|
||||
'page.users.title': '玩家管理',
|
||||
'page.users.desc': '创建玩家、查看余额与投注概况,支持上分与状态管理',
|
||||
'page.agents.title': '代理管理',
|
||||
'page.agents.desc': '创建一级代理、调整授信额度、查看直属玩家与额度占用',
|
||||
'page.matches.title': '赛事管理',
|
||||
'page.matches.desc': '草稿可编辑、删除;已发布可改开赛时间与热门',
|
||||
'page.bets.title': '注单管理',
|
||||
'page.bets.desc': '筛选、分页查看全平台注单,支持详情与投注项',
|
||||
'page.cashback.title': '返水管理',
|
||||
'page.cashback.desc': '按周期生成返水并发放',
|
||||
'page.audit.title': '操作日志',
|
||||
'page.audit.desc': '记录所有管理员操作行为',
|
||||
'page.settlement.title': '赛事结算',
|
||||
'page.agent_dash.title': '代理概览',
|
||||
'page.agent_dash.desc': '实时数据总览',
|
||||
'page.agent_players.title': '直属玩家',
|
||||
'page.agent_players.desc': '管理你名下的直属玩家',
|
||||
'page.agent_sub.title': '下级代理',
|
||||
'page.agent_sub.desc': '仅一级代理可见',
|
||||
'page.agent_bets.title': '注单查询',
|
||||
'page.agent_bets.desc': '下级玩家的全部投注记录',
|
||||
|
||||
'agent.credit_limit': '授信额度',
|
||||
'agent.credit_total': '总额度',
|
||||
'agent.credit_used': '已用额度',
|
||||
'agent.credit_occupied': '已占用',
|
||||
'agent.direct_players': '直属玩家',
|
||||
'agent.player_count': '玩家数',
|
||||
'agent.today_stake': '今日投注',
|
||||
'agent.currency_cny': '人民币',
|
||||
|
||||
'bet.status.PENDING': '待结算',
|
||||
'bet.status.WON': '已赢',
|
||||
'bet.status.LOST': '已输',
|
||||
'bet.status.VOID': '作废',
|
||||
'bet.status.REFUNDED': '已退款',
|
||||
'bet.type.SINGLE': '单关',
|
||||
'bet.type.PARLAY': '串关',
|
||||
'bet.settlement.PENDING': '待结算',
|
||||
'bet.settlement.SETTLED': '已结算',
|
||||
'bet.settlement.VOID': '已作废',
|
||||
'bet.result.WON': '赢',
|
||||
'bet.result.LOST': '输',
|
||||
'bet.result.VOID': '走水',
|
||||
'bet.result.PUSH': '走盘',
|
||||
'bet.result.HALF_WON': '半赢',
|
||||
'bet.result.HALF_LOST': '半输',
|
||||
|
||||
'match.status.DRAFT': '草稿',
|
||||
'match.status.PUBLISHED': '已发布',
|
||||
'match.status.CLOSED': '已封盘',
|
||||
'match.status.SETTLED': '已结算',
|
||||
...adminPagesZh,
|
||||
};
|
||||
|
||||
const en: Record<string, string> = {
|
||||
'login.title': 'Admin Console',
|
||||
'login.username': 'Username',
|
||||
'login.password': 'Password',
|
||||
'login.submit': 'Sign in',
|
||||
'login.username_ph': 'Enter username',
|
||||
'login.password_ph': 'Enter password',
|
||||
'login.err_captcha': 'Captcha incorrect, try again',
|
||||
'login.err_failed': 'Sign-in failed. Check username and password',
|
||||
'login.err_quick': 'Quick sign-in failed',
|
||||
'login.quick_label': 'Quick sign-in (debug)',
|
||||
'login.quick_admin': 'Admin',
|
||||
'login.quick_agent': 'Tier-1 agent',
|
||||
'login.captcha_ph': 'Captcha',
|
||||
'login.captcha_refresh': 'Click to refresh',
|
||||
|
||||
'nav.dashboard': 'Dashboard',
|
||||
'nav.users': 'Players',
|
||||
'nav.agents': 'Agents',
|
||||
'nav.matches': 'Matches',
|
||||
'nav.bets': 'Bets',
|
||||
'nav.cashback': 'Cashback',
|
||||
'nav.audit': 'Audit Log',
|
||||
'nav.players': 'My Players',
|
||||
'nav.subAgents': 'Sub-Agents',
|
||||
'nav.myBets': 'Bet Search',
|
||||
'role.admin': 'Administrator',
|
||||
'role.agent': 'Agent',
|
||||
'logout': 'Logout',
|
||||
'lang': 'Language',
|
||||
'portal.admin': 'Platform Admin',
|
||||
'portal.agent': 'Agent Portal',
|
||||
|
||||
'common.all': 'All',
|
||||
'common.search': 'Search',
|
||||
'common.reset': 'Reset',
|
||||
'common.edit': 'Edit',
|
||||
'common.delete': 'Delete',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.confirm': 'OK',
|
||||
'common.status': 'Status',
|
||||
'common.type': 'Type',
|
||||
'common.keyword': 'Keyword',
|
||||
'common.actions': 'Actions',
|
||||
'common.loading': 'Loading…',
|
||||
'common.no_data': 'No data',
|
||||
'common.yesterday': 'Yesterday',
|
||||
'common.vs_yesterday': 'vs yesterday',
|
||||
'common.people': 'users',
|
||||
'common.bets_unit': 'bets',
|
||||
'common.matches_unit': 'matches',
|
||||
'common.frozen': 'Frozen',
|
||||
'common.used': 'Used',
|
||||
'common.platform_direct': 'Platform direct',
|
||||
'common.updated_at': 'Updated',
|
||||
|
||||
'dash.title': 'Dashboard',
|
||||
'dash.desc': 'Platform overview',
|
||||
'dash.board_title': 'Overview',
|
||||
'dash.board_hint': 'Trends and distribution at a glance',
|
||||
'dash.kpi_bet_count': "Today's bet count",
|
||||
'dash.kpi_stake': "Today's stake",
|
||||
'dash.kpi_payout': "Today's payout",
|
||||
'dash.kpi_ggr': "Today's GGR",
|
||||
'dash.kpi_users': 'Players / Agents',
|
||||
'dash.kpi_new_players': '{n} new today',
|
||||
'dash.kpi_pending': 'Pending settlement',
|
||||
'dash.kpi_pending_sub': '{bets} bets · {matches} matches',
|
||||
'dash.kpi_wallet': 'Player balance',
|
||||
'dash.kpi_credit': 'Agent credit',
|
||||
'dash.trend_caption': 'Last 7 days (amount lines + bet bars)',
|
||||
'dash.chart_stake': 'Stake',
|
||||
'dash.chart_payout': 'Payout',
|
||||
'dash.chart_ggr': 'GGR',
|
||||
'dash.chart_bet_count': 'Bet count',
|
||||
'dash.axis_amount': 'Amount',
|
||||
'dash.axis_count': 'Count',
|
||||
'dash.count_suffix': 'bets',
|
||||
'dash.chart_tooltip': '{b}: {c} ({d}%)',
|
||||
'dash.pie_empty': 'N/A',
|
||||
'dash.pie_matches': 'Matches',
|
||||
'dash.pie_bets': "Today's bets",
|
||||
'dash.pie_users': 'Users',
|
||||
'dash.match_draft': 'Draft',
|
||||
'dash.match_published': 'Published',
|
||||
'dash.match_closed': 'Closed',
|
||||
'dash.match_pending_settle': 'Pending settlement',
|
||||
'dash.match_settled': 'Settled',
|
||||
'dash.user_active': 'Active players',
|
||||
'dash.user_suspended': 'Suspended',
|
||||
'dash.user_direct': 'Direct',
|
||||
'dash.user_agents': 'Agents',
|
||||
|
||||
'page.users.title': 'Players',
|
||||
'page.users.desc': 'Create players, balances, stakes, top-ups, and status',
|
||||
'page.agents.title': 'Agents',
|
||||
'page.agents.desc': 'Tier-1 agents, credit limits, players, and usage',
|
||||
'page.matches.title': 'Matches',
|
||||
'page.matches.desc': 'Edit/delete drafts; adjust kickoff and featured when published',
|
||||
'page.bets.title': 'Bets',
|
||||
'page.bets.desc': 'Filter and paginate all bets with leg details',
|
||||
'page.cashback.title': 'Cashback',
|
||||
'page.cashback.desc': 'Generate and issue cashback by period',
|
||||
'page.audit.title': 'Audit Log',
|
||||
'page.audit.desc': 'Administrator action history',
|
||||
'page.settlement.title': 'Settlement',
|
||||
'page.agent_dash.title': 'Agent overview',
|
||||
'page.agent_dash.desc': 'Live summary',
|
||||
'page.agent_players.title': 'My players',
|
||||
'page.agent_players.desc': 'Players under your account',
|
||||
'page.agent_sub.title': 'Sub-agents',
|
||||
'page.agent_sub.desc': 'Tier-1 agents only',
|
||||
'page.agent_bets.title': 'Bet search',
|
||||
'page.agent_bets.desc': 'All bets from downstream players',
|
||||
|
||||
'agent.credit_limit': 'Credit limit',
|
||||
'agent.credit_total': 'Total limit',
|
||||
'agent.credit_used': 'Used credit',
|
||||
'agent.credit_occupied': 'In use',
|
||||
'agent.direct_players': 'Direct players',
|
||||
'agent.player_count': 'Players',
|
||||
'agent.today_stake': "Today's stake",
|
||||
'agent.currency_cny': 'CNY',
|
||||
|
||||
'bet.status.PENDING': 'Pending',
|
||||
'bet.status.WON': 'Won',
|
||||
'bet.status.LOST': 'Lost',
|
||||
'bet.status.VOID': 'Void',
|
||||
'bet.status.REFUNDED': 'Refunded',
|
||||
'bet.type.SINGLE': 'Single',
|
||||
'bet.type.PARLAY': 'Parlay',
|
||||
'bet.settlement.PENDING': 'Pending',
|
||||
'bet.settlement.SETTLED': 'Settled',
|
||||
'bet.settlement.VOID': 'Void',
|
||||
'bet.result.WON': 'Won',
|
||||
'bet.result.LOST': 'Lost',
|
||||
'bet.result.VOID': 'Void',
|
||||
'bet.result.PUSH': 'Push',
|
||||
'bet.result.HALF_WON': 'Half won',
|
||||
'bet.result.HALF_LOST': 'Half lost',
|
||||
|
||||
'match.status.DRAFT': 'Draft',
|
||||
'match.status.PUBLISHED': 'Published',
|
||||
'match.status.CLOSED': 'Closed',
|
||||
'match.status.SETTLED': 'Settled',
|
||||
...adminPagesEn,
|
||||
};
|
||||
|
||||
const ms: Record<string, string> = {
|
||||
'login.title': 'Konsol Admin',
|
||||
'login.username': 'Nama pengguna',
|
||||
'login.password': 'Kata laluan',
|
||||
'login.submit': 'Log masuk',
|
||||
'login.username_ph': 'Masukkan nama pengguna',
|
||||
'login.password_ph': 'Masukkan kata laluan',
|
||||
'login.err_captcha': 'Captcha salah, cuba lagi',
|
||||
'login.err_failed': 'Log masuk gagal. Semak nama pengguna dan kata laluan',
|
||||
'login.err_quick': 'Log masuk pantas gagal',
|
||||
'login.quick_label': 'Log masuk pantas (debug)',
|
||||
'login.quick_admin': 'Admin',
|
||||
'login.quick_agent': 'Ejen peringkat 1',
|
||||
'login.captcha_ph': 'Captcha',
|
||||
'login.captcha_refresh': 'Klik untuk muat semula',
|
||||
|
||||
'nav.dashboard': 'Papan pemuka',
|
||||
'nav.users': 'Pemain',
|
||||
'nav.agents': 'Ejen',
|
||||
'nav.matches': 'Perlawanan',
|
||||
'nav.bets': 'Pertaruhan',
|
||||
'nav.cashback': 'Rebat',
|
||||
'nav.audit': 'Log audit',
|
||||
'nav.players': 'Pemain saya',
|
||||
'nav.subAgents': 'Sub-ejen',
|
||||
'nav.myBets': 'Carian pertaruhan',
|
||||
'role.admin': 'Pentadbir',
|
||||
'role.agent': 'Ejen',
|
||||
'logout': 'Log keluar',
|
||||
'lang': 'Bahasa',
|
||||
'portal.admin': 'Admin Platform',
|
||||
'portal.agent': 'Portal Ejen',
|
||||
|
||||
'common.all': 'Semua',
|
||||
'common.search': 'Cari',
|
||||
'common.reset': 'Set semula',
|
||||
'common.edit': 'Edit',
|
||||
'common.delete': 'Padam',
|
||||
'common.cancel': 'Batal',
|
||||
'common.confirm': 'OK',
|
||||
'common.status': 'Status',
|
||||
'common.type': 'Jenis',
|
||||
'common.keyword': 'Kata kunci',
|
||||
'common.actions': 'Tindakan',
|
||||
'common.loading': 'Memuatkan…',
|
||||
'common.no_data': 'Tiada data',
|
||||
'common.yesterday': 'Semalam',
|
||||
'common.vs_yesterday': 'berbanding semalam',
|
||||
'common.people': 'pengguna',
|
||||
'common.bets_unit': 'pertaruhan',
|
||||
'common.matches_unit': 'perlawanan',
|
||||
'common.frozen': 'Dibekukan',
|
||||
'common.used': 'Digunakan',
|
||||
'common.platform_direct': 'Terus platform',
|
||||
'common.updated_at': 'Dikemas kini',
|
||||
|
||||
'dash.title': 'Papan pemuka',
|
||||
'dash.desc': 'Gambaran keseluruhan platform',
|
||||
'dash.board_title': 'Gambaran',
|
||||
'dash.board_hint': 'Trend dan taburan sepintas lalu',
|
||||
'dash.kpi_bet_count': 'Bilangan pertaruhan hari ini',
|
||||
'dash.kpi_stake': 'Stake hari ini',
|
||||
'dash.kpi_payout': 'Bayaran hari ini',
|
||||
'dash.kpi_ggr': 'GGR hari ini',
|
||||
'dash.kpi_users': 'Pemain / Ejen',
|
||||
'dash.kpi_new_players': '{n} baharu hari ini',
|
||||
'dash.kpi_pending': 'Menunggu penyelesaian',
|
||||
'dash.kpi_pending_sub': '{bets} pertaruhan · {matches} perlawanan',
|
||||
'dash.kpi_wallet': 'Baki pemain',
|
||||
'dash.kpi_credit': 'Kredit ejen',
|
||||
'dash.trend_caption': '7 hari lepas (garis jumlah + palang pertaruhan)',
|
||||
'dash.chart_stake': 'Stake',
|
||||
'dash.chart_payout': 'Bayaran',
|
||||
'dash.chart_ggr': 'GGR',
|
||||
'dash.chart_bet_count': 'Bilangan pertaruhan',
|
||||
'dash.axis_amount': 'Jumlah',
|
||||
'dash.axis_count': 'Kiraan',
|
||||
'dash.count_suffix': 'pertaruhan',
|
||||
'dash.chart_tooltip': '{b}: {c} ({d}%)',
|
||||
'dash.pie_empty': 'Tiada',
|
||||
'dash.pie_matches': 'Perlawanan',
|
||||
'dash.pie_bets': 'Pertaruhan hari ini',
|
||||
'dash.pie_users': 'Pengguna',
|
||||
'dash.match_draft': 'Draf',
|
||||
'dash.match_published': 'Diterbitkan',
|
||||
'dash.match_closed': 'Ditutup',
|
||||
'dash.match_pending_settle': 'Menunggu penyelesaian',
|
||||
'dash.match_settled': 'Diselesaikan',
|
||||
'dash.user_active': 'Pemain aktif',
|
||||
'dash.user_suspended': 'Digantung',
|
||||
'dash.user_direct': 'Terus',
|
||||
'dash.user_agents': 'Ejen',
|
||||
|
||||
'page.users.title': 'Pemain',
|
||||
'page.users.desc': 'Cipta pemain, baki, stake, tambah baki dan status',
|
||||
'page.agents.title': 'Ejen',
|
||||
'page.agents.desc': 'Ejen peringkat 1, had kredit, pemain dan penggunaan',
|
||||
'page.matches.title': 'Perlawanan',
|
||||
'page.matches.desc': 'Edit/padam draf; laraskan masa mula dan pilihan utama bila diterbitkan',
|
||||
'page.bets.title': 'Pertaruhan',
|
||||
'page.bets.desc': 'Tapis dan halaman semua pertaruhan dengan butiran pilihan',
|
||||
'page.cashback.title': 'Rebat',
|
||||
'page.cashback.desc': 'Jana dan keluarkan rebat mengikut tempoh',
|
||||
'page.audit.title': 'Log audit',
|
||||
'page.audit.desc': 'Sejarah tindakan pentadbir',
|
||||
'page.settlement.title': 'Penyelesaian',
|
||||
'page.agent_dash.title': 'Gambaran ejen',
|
||||
'page.agent_dash.desc': 'Ringkasan langsung',
|
||||
'page.agent_players.title': 'Pemain saya',
|
||||
'page.agent_players.desc': 'Pemain di bawah akaun anda',
|
||||
'page.agent_sub.title': 'Sub-ejen',
|
||||
'page.agent_sub.desc': 'Ejen peringkat 1 sahaja',
|
||||
'page.agent_bets.title': 'Carian pertaruhan',
|
||||
'page.agent_bets.desc': 'Semua pertaruhan pemain hiliran',
|
||||
|
||||
'agent.credit_limit': 'Had kredit',
|
||||
'agent.credit_total': 'Jumlah had',
|
||||
'agent.credit_used': 'Kredit digunakan',
|
||||
'agent.credit_occupied': 'Sedang digunakan',
|
||||
'agent.direct_players': 'Pemain terus',
|
||||
'agent.player_count': 'Pemain',
|
||||
'agent.today_stake': 'Stake hari ini',
|
||||
'agent.currency_cny': 'CNY',
|
||||
|
||||
'bet.status.PENDING': 'Menunggu',
|
||||
'bet.status.WON': 'Menang',
|
||||
'bet.status.LOST': 'Kalah',
|
||||
'bet.status.VOID': 'Batal',
|
||||
'bet.status.REFUNDED': 'Dibayar balik',
|
||||
'bet.type.SINGLE': 'Tunggal',
|
||||
'bet.type.PARLAY': 'Berganda',
|
||||
'bet.settlement.PENDING': 'Menunggu',
|
||||
'bet.settlement.SETTLED': 'Diselesaikan',
|
||||
'bet.settlement.VOID': 'Batal',
|
||||
'bet.result.WON': 'Menang',
|
||||
'bet.result.LOST': 'Kalah',
|
||||
'bet.result.VOID': 'Batal',
|
||||
'bet.result.PUSH': 'Seri',
|
||||
'bet.result.HALF_WON': 'Separuh menang',
|
||||
'bet.result.HALF_LOST': 'Separuh kalah',
|
||||
|
||||
'match.status.DRAFT': 'Draf',
|
||||
'match.status.PUBLISHED': 'Diterbitkan',
|
||||
'match.status.CLOSED': 'Ditutup',
|
||||
'match.status.SETTLED': 'Diselesaikan',
|
||||
...adminPagesMs,
|
||||
};
|
||||
|
||||
const messages: Record<AdminLocale, Record<string, string>> = {
|
||||
'zh-CN': zh,
|
||||
'en-US': en,
|
||||
'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;
|
||||
}
|
||||
239
apps/admin/src/i18n/admin-pages-ms.ts
Normal file
239
apps/admin/src/i18n/admin-pages-ms.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
/** 管理后台列表 / 弹窗 — Bahasa Melayu */
|
||||
export const adminPagesMs: Record<string, string> = {
|
||||
'common.detail': 'Butiran',
|
||||
'common.create': 'Cipta',
|
||||
'common.save': 'Simpan',
|
||||
'common.close': 'Tutup',
|
||||
'common.import': 'Import',
|
||||
'common.publish': 'Terbitkan',
|
||||
'common.topup': 'Tambah baki',
|
||||
'common.adjust_credit': 'Laraskan kredit',
|
||||
'common.freeze': 'Bekukan',
|
||||
'common.unfreeze': 'Nyahbeku',
|
||||
'common.settle': 'Selesaikan',
|
||||
'common.close_betting': 'Tutup pertaruhan',
|
||||
'common.never_login': 'Belum pernah log masuk',
|
||||
'common.optional': 'Pilihan',
|
||||
'common.to': 'Hingga',
|
||||
'common.module': 'Modul',
|
||||
'common.col_id': 'ID',
|
||||
'common.times': 'kali',
|
||||
'common.bets_count_unit': 'pertaruhan',
|
||||
|
||||
'user.create_btn': '+ Pemain baharu',
|
||||
'user.filter.username_ph': 'Nama pengguna',
|
||||
'user.filter.agent': 'Ejen',
|
||||
'user.filter.agent_ph': 'Semua',
|
||||
'user.col.username': 'Nama pengguna',
|
||||
'user.col.agent': 'Ejen',
|
||||
'user.col.balance': 'Tersedia / Dibekukan',
|
||||
'user.col.bets': 'Pertaruhan',
|
||||
'user.col.stake_payout': 'Stake / Bayaran',
|
||||
'user.col.last_login': 'Log masuk terakhir',
|
||||
'user.col.created': 'Daftar',
|
||||
'user.status.ACTIVE': 'Aktif',
|
||||
'user.status.SUSPENDED': 'Digantung',
|
||||
'user.dialog.create': 'Pemain baharu',
|
||||
'user.dialog.edit': 'Edit pemain',
|
||||
'user.dialog.deposit': 'Tambah baki pemain',
|
||||
'user.dialog.detail': 'Butiran pemain',
|
||||
'user.field.password': 'Kata laluan',
|
||||
'user.field.confirm_password': 'Sahkan kata laluan',
|
||||
'user.field.initial_balance': 'Baki permulaan',
|
||||
'user.field.deposit_remark': 'Nota tambah baki',
|
||||
'user.field.amount': 'Jumlah',
|
||||
'user.field.remark': 'Nota',
|
||||
'user.field.account_status': 'Status akaun',
|
||||
'user.field.available': 'Baki tersedia',
|
||||
'user.field.frozen_balance': 'Baki dibekukan',
|
||||
'user.field.bets_summary': 'Pertaruhan / stake',
|
||||
'user.field.total_payout': 'Jumlah bayaran',
|
||||
'user.field.login_fail': 'Log masuk gagal',
|
||||
'user.field.phone': 'Telefon',
|
||||
'user.field.email': 'E-mel',
|
||||
'user.ph.username_unique': 'Nama log masuk unik',
|
||||
'user.ph.no_agent': 'Tiada (terus platform)',
|
||||
'user.hint.no_agent': 'Biarkan kosong untuk pemain diurus platform',
|
||||
'user.hint.initial_balance': 'Auto tambah baki semasa cipta; 0 = tiada bonus',
|
||||
'user.hint.deposit_remark': 'Ditulis ke lejar jika baki permulaan > 0',
|
||||
'user.hint.freeze_in_list': 'Beku/nyahbeku dari lajur tindakan senarai',
|
||||
'user.hint.agent_change': 'Kosong = terus platform; perubahan dikira semula kredit ejen',
|
||||
'user.btn.create': 'Cipta',
|
||||
'user.btn.save_profile': 'Simpan',
|
||||
'user.btn.confirm_deposit': 'Sahkan tambah baki',
|
||||
'user.deposit_remark_default': 'Tambah baki admin',
|
||||
|
||||
'agent.create_btn': '+ Ejen peringkat 1 baharu',
|
||||
'agent.filter.username_ph': 'Nama pengguna',
|
||||
'agent.col.level': 'Peringkat',
|
||||
'agent.col.credit': 'Had / Digunakan / Tersedia',
|
||||
'agent.col.direct_players': 'Pemain terus',
|
||||
'agent.col.cashback': 'Kadar rebat',
|
||||
'agent.col.phone': 'Telefon',
|
||||
'agent.col.created': 'Dicipta',
|
||||
'agent.dialog.create': 'Ejen peringkat 1 baharu',
|
||||
'agent.dialog.edit': 'Edit ejen',
|
||||
'agent.dialog.credit': 'Laraskan had kredit',
|
||||
'agent.field.agent_id': 'ID ejen',
|
||||
'agent.dialog.detail': 'Butiran ejen',
|
||||
'agent.field.credit_limit': 'Had kredit',
|
||||
'agent.field.cashback_rate': 'Kadar rebat',
|
||||
'agent.field.adjust_amount': 'Pelarasan',
|
||||
'agent.field.used_credit': 'Kredit digunakan',
|
||||
'agent.field.available_credit': 'Kredit tersedia',
|
||||
'agent.field.player_liability': 'Liabiliti pemain',
|
||||
'agent.field.sub_agent_exposure': 'Pendedahan sub-ejen',
|
||||
'agent.hint.credit_limit': 'Had maksimum tambah baki untuk pemain terus',
|
||||
'agent.hint.cashback_example': 'cth. 0.01 = 1%',
|
||||
'agent.hint.credit_adjust': 'Positif menambah, negatif mengurangkan',
|
||||
'agent.hint.credit_remark': 'Pilihan, ditulis ke lejar kredit',
|
||||
'agent.section.credit_log': 'Perubahan kredit terkini',
|
||||
'agent.credit.increase': 'Tambah',
|
||||
'agent.credit.decrease': 'Kurang',
|
||||
'agent.col.credit_type': 'Jenis',
|
||||
'agent.col.credit_change': 'Perubahan',
|
||||
'agent.col.credit_after': 'Selepas',
|
||||
'agent.col.no_records': 'Tiada rekod',
|
||||
'agent.btn.confirm_adjust': 'Sahkan',
|
||||
|
||||
'match.create_btn': '+ Perlawanan baharu',
|
||||
'match.filter.keyword_ph': 'Nama perlawanan / kod pasukan',
|
||||
'match.col.matchup': 'Perlawanan',
|
||||
'match.col.kickoff': 'Masa mula',
|
||||
'match.dialog.create': 'Perlawanan baharu',
|
||||
'match.dialog.edit': 'Edit perlawanan',
|
||||
'match.dialog.import': 'Import perlawanan',
|
||||
'match.field.league_en': 'Liga (EN)',
|
||||
'match.field.league_zh': 'Liga (ZH)',
|
||||
'match.field.kickoff': 'Masa mula',
|
||||
'match.field.home_en': 'Tuan rumah (EN)',
|
||||
'match.field.home_zh': 'Tuan rumah (ZH)',
|
||||
'match.field.away_en': 'Pelawat (EN)',
|
||||
'match.field.away_zh': 'Pelawat (ZH)',
|
||||
'match.field.featured': 'Pilihan utama',
|
||||
'match.hint.create_draft': 'Disimpan sebagai draf; klik Terbitkan dalam senarai untuk buka pasaran.',
|
||||
'match.hint.edit_published': 'Diterbitkan: edit masa mula, pilihan utama, nama paparan; tertutup/selesai dikunci.',
|
||||
|
||||
'bet.filter.keyword_ph': 'No. pertaruhan / nama pengguna',
|
||||
'bet.filter.date_from': 'Tarikh pertaruhan dari',
|
||||
'bet.filter.date_start_ph': 'Mula',
|
||||
'bet.filter.date_end_ph': 'Tamat',
|
||||
'bet.col.serial': 'No.',
|
||||
'bet.col.bet_no': 'No. pertaruhan',
|
||||
'bet.col.player': 'Pemain',
|
||||
'bet.col.agent': 'Ejen',
|
||||
'bet.col.selection': 'Pilihan',
|
||||
'bet.col.stake': 'Stake',
|
||||
'bet.col.odds': 'Odds',
|
||||
'bet.col.payout': 'Bayaran',
|
||||
'bet.col.placed_at': 'Masa pertaruhan',
|
||||
'bet.dialog.detail': 'Butiran pertaruhan',
|
||||
'bet.field.total_odds': 'Jumlah odds',
|
||||
'bet.field.currency': 'Mata wang',
|
||||
'bet.field.potential_win': 'Menang berpotensi',
|
||||
'bet.field.actual_payout': 'Bayaran sebenar',
|
||||
'bet.field.bet_status': 'Status pertaruhan',
|
||||
'bet.field.settlement_status': 'Penyelesaian',
|
||||
'bet.field.settled_at': 'Diselesaikan pada',
|
||||
'bet.field.request_id': 'ID permintaan',
|
||||
'bet.selections_title': 'Pilihan ({n})',
|
||||
'bet.col.market': 'Pasaran',
|
||||
'bet.col.period': 'Tempoh',
|
||||
'bet.col.line': 'Garisan',
|
||||
'bet.col.result': 'Keputusan',
|
||||
|
||||
'audit.module_ph': 'cth. USERS, AGENTS',
|
||||
'audit.col.action': 'Tindakan',
|
||||
'audit.col.module': 'Modul',
|
||||
'audit.col.target_id': 'ID sasaran',
|
||||
'audit.col.time': 'Masa',
|
||||
|
||||
'cashback.start_date': 'Tarikh mula',
|
||||
'cashback.end_date': 'Tarikh tamat',
|
||||
'cashback.preview_btn': 'Pratonton',
|
||||
'cashback.preview_title': 'Pratonton rebat',
|
||||
'cashback.stat.players': 'Pemain',
|
||||
'cashback.stat.total': 'Jumlah rebat',
|
||||
'cashback.confirm_issue': 'Sahkan bayaran',
|
||||
|
||||
'user.field.player_id': 'ID pemain',
|
||||
'user.field.bet_count': 'Bilangan pertaruhan',
|
||||
'user.field.total_stake': 'Jumlah stake',
|
||||
'user.field.registered_at': 'Daftar',
|
||||
'user.ph.remark_initial': 'Nota lejar apabila baki permulaan > 0',
|
||||
'user.bets_edit_value': '{n} pertaruhan / {stake}',
|
||||
'user.login_fail_value': '{n} kali',
|
||||
|
||||
'match.import_hint': 'Tampal JSON dengan array matches. Import sebagai draf; terbitkan dari senarai.',
|
||||
'match.import_start': 'Import',
|
||||
'match.import_json_ph': '{"matches":[...]}',
|
||||
'match.delete_confirm_title': 'Padam perlawanan',
|
||||
'match.delete_confirm_body': 'Padam "{title}"? Hanya draf tanpa pertaruhan.',
|
||||
'match.ph.league_en': 'FIFA World Cup 2026',
|
||||
'match.ph.league_zh': 'Piala Dunia 2026',
|
||||
'match.ph.kickoff': '2026-06-11T19:00:00Z',
|
||||
'match.ph.home_en': 'Mexico',
|
||||
'match.ph.home_zh': 'Mexico',
|
||||
'match.ph.away_en': 'South Africa',
|
||||
'match.ph.away_zh': 'Afrika Selatan',
|
||||
|
||||
'err.username_required': 'Sila isi nama pengguna',
|
||||
'err.password_min': 'Kata laluan sekurang-kurangnya 8 aksara',
|
||||
'err.password_mismatch': 'Kata laluan tidak sepadan',
|
||||
'err.credit_negative': 'Had kredit tidak boleh negatif',
|
||||
'err.kickoff_required': 'Sila isi masa mula',
|
||||
'err.teams_required': 'Isi nama pasukan tuan rumah dan pelawat (ZH atau EN)',
|
||||
'err.league_required': 'Sila isi nama liga',
|
||||
|
||||
'settlement.ht_score': 'Skor separuh masa',
|
||||
'settlement.ft_score': 'Skor penuh masa',
|
||||
'settlement.record_score': 'Simpan skor',
|
||||
'settlement.preview_btn': 'Pratonton penyelesaian',
|
||||
'settlement.preview_title': 'Pratonton penyelesaian',
|
||||
'settlement.single_count': 'Pertaruhan tunggal',
|
||||
'settlement.est_payout': 'Anggaran bayaran',
|
||||
'settlement.refund_amount': 'Jumlah bayaran balik',
|
||||
'settlement.confirm_btn': 'Sahkan penyelesaian',
|
||||
'msg.score_recorded': 'Skor disimpan',
|
||||
'msg.settlement_confirmed': 'Penyelesaian disahkan',
|
||||
|
||||
'agent_portal.create_player_section': 'Cipta pemain',
|
||||
'agent_portal.deposit_section': 'Tambah baki',
|
||||
'agent_portal.create_player_btn': '+ Pemain baharu',
|
||||
'agent_portal.create_tier2_btn': '+ Sub-ejen baharu',
|
||||
'agent_portal.username_ph': 'Masukkan nama pengguna',
|
||||
'agent_portal.agent_username_ph': 'Nama pengguna ejen',
|
||||
'agent_portal.player_id_ph': 'ID pemain',
|
||||
'agent_portal.withdraw_btn': 'Keluarkan {amount}',
|
||||
'msg.agent_sub_created': 'Sub-ejen dicipta',
|
||||
'msg.withdraw_ok': 'Pengeluaran berjaya',
|
||||
|
||||
'msg.form_invalid': 'Sila semak borang',
|
||||
'msg.player_created': 'Pemain dicipta',
|
||||
'msg.agent_created': 'Ejen dicipta',
|
||||
'msg.create_failed': 'Gagal mencipta',
|
||||
'msg.saved': 'Disimpan',
|
||||
'msg.save_failed': 'Gagal menyimpan',
|
||||
'msg.deleted': 'Dipadam',
|
||||
'msg.delete_failed': 'Gagal memadam',
|
||||
'msg.match_created_draft': 'Perlawanan dicipta (draf)',
|
||||
'msg.published': 'Diterbitkan dengan pasaran',
|
||||
'msg.closed': 'Pertaruhan ditutup',
|
||||
'msg.invalid_json': 'JSON tidak sah',
|
||||
'msg.import_failed': 'Import gagal',
|
||||
'msg.import_done': 'Import: {imported} ok, {skipped} dilangkau, {failed} gagal / {total} jumlah',
|
||||
'msg.topup_ok': 'Tambah baki berjaya',
|
||||
'msg.topup_failed': 'Tambah baki gagal',
|
||||
'msg.amount_gt_zero': 'Jumlah mesti lebih daripada 0',
|
||||
'msg.credit_zero': 'Pelarasan tidak boleh 0',
|
||||
'msg.credit_adjusted': 'Kredit dikemas kini',
|
||||
'msg.credit_adjust_failed': 'Pelarasan gagal',
|
||||
'msg.outright_no_edit': 'Outright tidak boleh diedit di sini',
|
||||
'msg.load_matches_failed': 'Gagal memuatkan perlawanan',
|
||||
'msg.cashback_issued': 'Rebat telah dikeluarkan',
|
||||
'msg.freeze_confirm_title': '{action} akaun',
|
||||
'msg.freeze_confirm_body': '{action} pemain "{name}"?{extra}',
|
||||
'msg.freeze_extra': ' Mereka tidak akan dapat log masuk.',
|
||||
'msg.freeze_done': '{action} selesai',
|
||||
'msg.freeze_failed': '{action} gagal',
|
||||
};
|
||||
478
apps/admin/src/i18n/admin-pages.ts
Normal file
478
apps/admin/src/i18n/admin-pages.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
/** 列表页 / 弹窗文案(并入 admin-messages) */
|
||||
export const adminPagesZh: Record<string, string> = {
|
||||
'common.detail': '详情',
|
||||
'common.create': '创建',
|
||||
'common.save': '保存',
|
||||
'common.close': '关闭',
|
||||
'common.import': '导入',
|
||||
'common.publish': '发布',
|
||||
'common.topup': '上分',
|
||||
'common.adjust_credit': '调额',
|
||||
'common.freeze': '冻结',
|
||||
'common.unfreeze': '解冻',
|
||||
'common.settle': '结算',
|
||||
'common.close_betting': '封盘',
|
||||
'common.never_login': '从未登录',
|
||||
'common.optional': '选填',
|
||||
'common.to': '止',
|
||||
'common.module': '模块',
|
||||
'common.col_id': 'ID',
|
||||
'common.times': '次',
|
||||
'common.bets_count_unit': '笔',
|
||||
|
||||
'user.create_btn': '+ 新建玩家',
|
||||
'user.filter.username_ph': '用户名',
|
||||
'user.filter.agent': '所属代理',
|
||||
'user.filter.agent_ph': '全部',
|
||||
'user.col.username': '用户名',
|
||||
'user.col.agent': '所属代理',
|
||||
'user.col.balance': '可用 / 冻结',
|
||||
'user.col.bets': '注单',
|
||||
'user.col.stake_payout': '投注 / 派彩',
|
||||
'user.col.last_login': '最后登录',
|
||||
'user.col.created': '注册时间',
|
||||
'user.status.ACTIVE': '正常',
|
||||
'user.status.SUSPENDED': '停用',
|
||||
'user.dialog.create': '新建玩家',
|
||||
'user.dialog.edit': '编辑玩家',
|
||||
'user.dialog.deposit': '玩家上分',
|
||||
'user.dialog.detail': '玩家详情',
|
||||
'user.field.password': '登录密码',
|
||||
'user.field.confirm_password': '确认密码',
|
||||
'user.field.initial_balance': '初始余额',
|
||||
'user.field.deposit_remark': '上分备注',
|
||||
'user.field.amount': '金额',
|
||||
'user.field.remark': '备注',
|
||||
'user.field.account_status': '账号状态',
|
||||
'user.field.available': '可用余额',
|
||||
'user.field.frozen_balance': '冻结余额',
|
||||
'user.field.bets_summary': '注单 / 投注',
|
||||
'user.field.total_payout': '累计派彩',
|
||||
'user.field.login_fail': '登录失败',
|
||||
'user.field.phone': '手机',
|
||||
'user.field.email': '邮箱',
|
||||
'user.ph.username_unique': '登录用户名,唯一',
|
||||
'user.ph.no_agent': '不设置(平台直属玩家)',
|
||||
'user.hint.no_agent': '留空表示不挂靠代理,由平台直接管理',
|
||||
'user.hint.initial_balance': '创建后自动上分,0 表示不开户赠金',
|
||||
'user.hint.deposit_remark': '有初始余额时写入流水备注',
|
||||
'user.hint.freeze_in_list': '冻结/解冻请在列表操作列进行',
|
||||
'user.hint.agent_change': '留空表示平台直属;变更后会重算相关代理已用授信',
|
||||
'user.btn.create': '创建',
|
||||
'user.btn.save_profile': '保存资料',
|
||||
'user.btn.confirm_deposit': '确认上分',
|
||||
'user.deposit_remark_default': '管理员上分',
|
||||
|
||||
'agent.create_btn': '+ 新建一级代理',
|
||||
'agent.filter.username_ph': '用户名',
|
||||
'agent.col.level': '层级',
|
||||
'agent.col.credit': '授信 / 已用 / 可用',
|
||||
'agent.col.direct_players': '直属玩家',
|
||||
'agent.col.cashback': '返水率',
|
||||
'agent.col.phone': '手机',
|
||||
'agent.col.created': '创建时间',
|
||||
'agent.dialog.create': '新建一级代理',
|
||||
'agent.dialog.edit': '编辑代理',
|
||||
'agent.dialog.credit': '调整授信额度',
|
||||
'agent.field.agent_id': '代理 ID',
|
||||
'agent.dialog.detail': '代理详情',
|
||||
'agent.field.credit_limit': '授信额度',
|
||||
'agent.field.cashback_rate': '返水比例',
|
||||
'agent.field.adjust_amount': '调整金额',
|
||||
'agent.field.used_credit': '已用额度',
|
||||
'agent.field.available_credit': '可用授信',
|
||||
'agent.field.player_liability': '玩家负债',
|
||||
'agent.field.sub_agent_exposure': '下级代理敞口',
|
||||
'agent.hint.credit_limit': '代理可向直属玩家上分的总额度上限',
|
||||
'agent.hint.cashback_example': '例如 0.01 表示 1%',
|
||||
'agent.hint.credit_adjust': '正数为增加授信,负数为减少',
|
||||
'agent.hint.credit_remark': '选填,写入额度流水',
|
||||
'agent.section.credit_log': '最近额度变动',
|
||||
'agent.credit.increase': '增加',
|
||||
'agent.credit.decrease': '减少',
|
||||
'agent.col.credit_type': '类型',
|
||||
'agent.col.credit_change': '变动',
|
||||
'agent.col.credit_after': '变动后',
|
||||
'agent.col.no_records': '暂无记录',
|
||||
'agent.btn.confirm_adjust': '确认调整',
|
||||
|
||||
'match.create_btn': '+ 新增赛事',
|
||||
'match.filter.keyword_ph': '赛事名 / 球队代码',
|
||||
'match.col.matchup': '对阵',
|
||||
'match.col.kickoff': '开赛时间',
|
||||
'match.dialog.create': '新增赛事',
|
||||
'match.dialog.edit': '编辑赛事',
|
||||
'match.dialog.import': '导入赛事',
|
||||
'match.field.league_en': '联赛(英)',
|
||||
'match.field.league_zh': '联赛(中)',
|
||||
'match.field.kickoff': '开赛时间',
|
||||
'match.field.home_en': '主队(英)',
|
||||
'match.field.home_zh': '主队(中)',
|
||||
'match.field.away_en': '客队(英)',
|
||||
'match.field.away_zh': '客队(中)',
|
||||
'match.field.featured': '热门',
|
||||
'match.hint.create_draft': '创建后为草稿,请在列表点击「发布」并生成盘口。',
|
||||
'match.hint.edit_published': '已发布:可修改开赛时间、热门及显示名称;封盘/已结算后不可编辑。',
|
||||
|
||||
'bet.filter.keyword_ph': '流水编号 / 玩家用户名',
|
||||
'bet.filter.date_from': '投注日起',
|
||||
'bet.filter.date_start_ph': '开始',
|
||||
'bet.filter.date_end_ph': '结束',
|
||||
'bet.col.serial': '单号',
|
||||
'bet.col.bet_no': '流水编号',
|
||||
'bet.col.player': '玩家',
|
||||
'bet.col.agent': '所属代理',
|
||||
'bet.col.selection': '选项',
|
||||
'bet.col.stake': '投注额',
|
||||
'bet.col.odds': '赔率',
|
||||
'bet.col.payout': '派彩',
|
||||
'bet.col.placed_at': '投注时间',
|
||||
'bet.dialog.detail': '注单详情',
|
||||
'bet.field.total_odds': '总赔率',
|
||||
'bet.field.currency': '币种',
|
||||
'bet.field.potential_win': '可赢额',
|
||||
'bet.field.actual_payout': '实际派彩',
|
||||
'bet.field.bet_status': '注单状态',
|
||||
'bet.field.settlement_status': '结算状态',
|
||||
'bet.field.settled_at': '结算时间',
|
||||
'bet.field.request_id': '请求 ID',
|
||||
'bet.selections_title': '投注项({n})',
|
||||
'bet.col.market': '玩法',
|
||||
'bet.col.period': '时段',
|
||||
'bet.col.line': '盘口',
|
||||
'bet.col.result': '赛果',
|
||||
|
||||
'audit.module_ph': '如 USERS、AGENTS',
|
||||
'audit.col.action': '操作',
|
||||
'audit.col.module': '模块',
|
||||
'audit.col.target_id': '目标 ID',
|
||||
'audit.col.time': '时间',
|
||||
|
||||
'cashback.start_date': '开始日期',
|
||||
'cashback.end_date': '结束日期',
|
||||
'cashback.preview_btn': '生成预览',
|
||||
'cashback.preview_title': '返水预览',
|
||||
'cashback.stat.players': '涉及玩家数',
|
||||
'cashback.stat.total': '返水总金额',
|
||||
'cashback.confirm_issue': '确认发放',
|
||||
|
||||
'user.field.player_id': '玩家 ID',
|
||||
'user.field.bet_count': '注单数',
|
||||
'user.field.total_stake': '累计投注',
|
||||
'user.field.registered_at': '注册时间',
|
||||
'user.ph.remark_initial': '有初始余额时写入流水备注',
|
||||
'user.bets_edit_value': '{n} 笔 / {stake}',
|
||||
'user.login_fail_value': '{n} 次',
|
||||
|
||||
'match.import_hint': '粘贴含 matches 的 JSON,导入后为草稿,需在列表发布。',
|
||||
'match.import_start': '开始导入',
|
||||
'match.import_json_ph': '{"matches":[...]}',
|
||||
'match.delete_confirm_title': '删除确认',
|
||||
'match.delete_confirm_body': '确定删除赛事「{title}」?仅草稿且无注单时可删除。',
|
||||
'match.ph.league_en': 'FIFA World Cup 2026',
|
||||
'match.ph.league_zh': '2026 世界杯',
|
||||
'match.ph.kickoff': '2026-06-11T19:00:00Z',
|
||||
'match.ph.home_en': 'Mexico',
|
||||
'match.ph.home_zh': '墨西哥',
|
||||
'match.ph.away_en': 'South Africa',
|
||||
'match.ph.away_zh': '南非',
|
||||
|
||||
'err.username_required': '请填写用户名',
|
||||
'err.password_min': '密码至少 8 位',
|
||||
'err.password_mismatch': '两次密码不一致',
|
||||
'err.credit_negative': '授信额度不能为负',
|
||||
'err.kickoff_required': '请填写开赛时间',
|
||||
'err.teams_required': '请填写主客队名称(中文或英文至少一项)',
|
||||
'err.league_required': '请填写联赛名称',
|
||||
|
||||
'settlement.ht_score': '半场比分',
|
||||
'settlement.ft_score': '全场比分',
|
||||
'settlement.record_score': '录入比分',
|
||||
'settlement.preview_btn': '生成结算预览',
|
||||
'settlement.preview_title': '结算预览',
|
||||
'settlement.single_count': '单关注单数',
|
||||
'settlement.est_payout': '预计派彩',
|
||||
'settlement.refund_amount': '退款金额',
|
||||
'settlement.confirm_btn': '确认结算',
|
||||
'msg.score_recorded': '比分已录入',
|
||||
'msg.settlement_confirmed': '结算已确认',
|
||||
|
||||
'agent_portal.create_player_section': '创建玩家',
|
||||
'agent_portal.deposit_section': '上分操作',
|
||||
'agent_portal.create_player_btn': '+ 创建玩家',
|
||||
'agent_portal.create_tier2_btn': '+ 创建二级代理',
|
||||
'agent_portal.username_ph': '输入用户名',
|
||||
'agent_portal.agent_username_ph': '代理用户名',
|
||||
'agent_portal.player_id_ph': '玩家 ID',
|
||||
'agent_portal.withdraw_btn': '下分 {amount}',
|
||||
'msg.agent_sub_created': '下级代理已创建',
|
||||
'msg.withdraw_ok': '下分成功',
|
||||
|
||||
'msg.form_invalid': '请检查表单',
|
||||
'msg.player_created': '玩家已创建',
|
||||
'msg.agent_created': '一级代理已创建',
|
||||
'msg.create_failed': '创建失败',
|
||||
'msg.saved': '已保存',
|
||||
'msg.save_failed': '保存失败',
|
||||
'msg.deleted': '已删除',
|
||||
'msg.delete_failed': '删除失败',
|
||||
'msg.match_created_draft': '赛事已创建(草稿)',
|
||||
'msg.published': '已发布并生成盘口',
|
||||
'msg.closed': '已封盘',
|
||||
'msg.invalid_json': 'JSON 格式无效',
|
||||
'msg.import_failed': '导入失败',
|
||||
'msg.import_done': '导入完成:成功 {imported},跳过 {skipped},失败 {failed} / 共 {total}',
|
||||
'msg.topup_ok': '上分成功',
|
||||
'msg.topup_failed': '上分失败',
|
||||
'msg.amount_gt_zero': '金额须大于 0',
|
||||
'msg.credit_zero': '调整金额不能为 0',
|
||||
'msg.credit_adjusted': '授信已调整',
|
||||
'msg.credit_adjust_failed': '调整失败',
|
||||
'msg.outright_no_edit': '冠军盘不支持在此编辑',
|
||||
'msg.load_matches_failed': '加载赛事失败',
|
||||
'msg.cashback_issued': '返水已发放',
|
||||
'msg.freeze_confirm_title': '{action}账号',
|
||||
'msg.freeze_confirm_body': '确定要{action}玩家「{name}」吗?{extra}',
|
||||
'msg.freeze_extra': '冻结后该账号将无法登录。',
|
||||
'msg.freeze_done': '已{action}',
|
||||
'msg.freeze_failed': '{action}失败',
|
||||
};
|
||||
|
||||
export const adminPagesEn: Record<string, string> = {
|
||||
'common.detail': 'Details',
|
||||
'common.create': 'Create',
|
||||
'common.save': 'Save',
|
||||
'common.close': 'Close',
|
||||
'common.import': 'Import',
|
||||
'common.publish': 'Publish',
|
||||
'common.topup': 'Top up',
|
||||
'common.adjust_credit': 'Adjust credit',
|
||||
'common.freeze': 'Freeze',
|
||||
'common.unfreeze': 'Unfreeze',
|
||||
'common.settle': 'Settle',
|
||||
'common.close_betting': 'Close',
|
||||
'common.never_login': 'Never signed in',
|
||||
'common.optional': 'Optional',
|
||||
'common.to': 'To',
|
||||
'common.module': 'Module',
|
||||
'common.col_id': 'ID',
|
||||
'common.times': 'times',
|
||||
'common.bets_count_unit': 'bets',
|
||||
|
||||
'user.create_btn': '+ New player',
|
||||
'user.filter.username_ph': 'Username',
|
||||
'user.filter.agent': 'Agent',
|
||||
'user.filter.agent_ph': 'All',
|
||||
'user.col.username': 'Username',
|
||||
'user.col.agent': 'Agent',
|
||||
'user.col.balance': 'Available / Frozen',
|
||||
'user.col.bets': 'Bets',
|
||||
'user.col.stake_payout': 'Stake / Payout',
|
||||
'user.col.last_login': 'Last login',
|
||||
'user.col.created': 'Registered',
|
||||
'user.status.ACTIVE': 'Active',
|
||||
'user.status.SUSPENDED': 'Suspended',
|
||||
'user.dialog.create': 'New player',
|
||||
'user.dialog.edit': 'Edit player',
|
||||
'user.dialog.deposit': 'Top up player',
|
||||
'user.dialog.detail': 'Player details',
|
||||
'user.field.password': 'Password',
|
||||
'user.field.confirm_password': 'Confirm password',
|
||||
'user.field.initial_balance': 'Initial balance',
|
||||
'user.field.deposit_remark': 'Top-up note',
|
||||
'user.field.amount': 'Amount',
|
||||
'user.field.remark': 'Note',
|
||||
'user.field.account_status': 'Account status',
|
||||
'user.field.available': 'Available balance',
|
||||
'user.field.frozen_balance': 'Frozen balance',
|
||||
'user.field.bets_summary': 'Bets / stake',
|
||||
'user.field.total_payout': 'Total payout',
|
||||
'user.field.login_fail': 'Failed logins',
|
||||
'user.field.phone': 'Phone',
|
||||
'user.field.email': 'Email',
|
||||
'user.ph.username_unique': 'Unique login username',
|
||||
'user.ph.no_agent': 'None (platform direct)',
|
||||
'user.hint.no_agent': 'Leave empty for platform-managed player',
|
||||
'user.hint.initial_balance': 'Auto top-up on create; 0 = no bonus',
|
||||
'user.hint.deposit_remark': 'Written to ledger when initial balance > 0',
|
||||
'user.hint.freeze_in_list': 'Freeze/unfreeze from the list actions',
|
||||
'user.hint.agent_change': 'Empty = platform direct; changes recalc agent credit',
|
||||
'user.btn.create': 'Create',
|
||||
'user.btn.save_profile': 'Save',
|
||||
'user.btn.confirm_deposit': 'Confirm top-up',
|
||||
'user.deposit_remark_default': 'Admin top-up',
|
||||
|
||||
'agent.create_btn': '+ New tier-1 agent',
|
||||
'agent.filter.username_ph': 'Username',
|
||||
'agent.col.level': 'Level',
|
||||
'agent.col.credit': 'Limit / Used / Available',
|
||||
'agent.col.direct_players': 'Direct players',
|
||||
'agent.col.cashback': 'Cashback rate',
|
||||
'agent.col.phone': 'Phone',
|
||||
'agent.col.created': 'Created',
|
||||
'agent.dialog.create': 'New tier-1 agent',
|
||||
'agent.dialog.edit': 'Edit agent',
|
||||
'agent.dialog.credit': 'Adjust credit limit',
|
||||
'agent.field.agent_id': 'Agent ID',
|
||||
'agent.dialog.detail': 'Agent details',
|
||||
'agent.field.credit_limit': 'Credit limit',
|
||||
'agent.field.cashback_rate': 'Cashback rate',
|
||||
'agent.field.adjust_amount': 'Adjustment',
|
||||
'agent.field.used_credit': 'Used credit',
|
||||
'agent.field.available_credit': 'Available credit',
|
||||
'agent.field.player_liability': 'Player liability',
|
||||
'agent.field.sub_agent_exposure': 'Sub-agent exposure',
|
||||
'agent.hint.credit_limit': 'Max total top-up capacity for direct players',
|
||||
'agent.hint.cashback_example': 'e.g. 0.01 = 1%',
|
||||
'agent.hint.credit_adjust': 'Positive increases, negative decreases',
|
||||
'agent.hint.credit_remark': 'Optional, written to credit ledger',
|
||||
'agent.section.credit_log': 'Recent credit changes',
|
||||
'agent.credit.increase': 'Increase',
|
||||
'agent.credit.decrease': 'Decrease',
|
||||
'agent.col.credit_type': 'Type',
|
||||
'agent.col.credit_change': 'Change',
|
||||
'agent.col.credit_after': 'After',
|
||||
'agent.col.no_records': 'No records',
|
||||
'agent.btn.confirm_adjust': 'Confirm',
|
||||
|
||||
'match.create_btn': '+ New match',
|
||||
'match.filter.keyword_ph': 'Match name / team code',
|
||||
'match.col.matchup': 'Matchup',
|
||||
'match.col.kickoff': 'Kickoff',
|
||||
'match.dialog.create': 'New match',
|
||||
'match.dialog.edit': 'Edit match',
|
||||
'match.dialog.import': 'Import matches',
|
||||
'match.field.league_en': 'League (EN)',
|
||||
'match.field.league_zh': 'League (ZH)',
|
||||
'match.field.kickoff': 'Kickoff time',
|
||||
'match.field.home_en': 'Home (EN)',
|
||||
'match.field.home_zh': 'Home (ZH)',
|
||||
'match.field.away_en': 'Away (EN)',
|
||||
'match.field.away_zh': 'Away (ZH)',
|
||||
'match.field.featured': 'Featured',
|
||||
'match.hint.create_draft': 'Saved as draft; click Publish in the list to open markets.',
|
||||
'match.hint.edit_published': 'Published: edit kickoff, featured, display names; closed/settled are locked.',
|
||||
|
||||
'bet.filter.keyword_ph': 'Bet no. / username',
|
||||
'bet.filter.date_from': 'Placed from',
|
||||
'bet.filter.date_start_ph': 'Start',
|
||||
'bet.filter.date_end_ph': 'End',
|
||||
'bet.col.serial': 'No.',
|
||||
'bet.col.bet_no': 'Bet no.',
|
||||
'bet.col.player': 'Player',
|
||||
'bet.col.agent': 'Agent',
|
||||
'bet.col.selection': 'Pick',
|
||||
'bet.col.stake': 'Stake',
|
||||
'bet.col.odds': 'Odds',
|
||||
'bet.col.payout': 'Payout',
|
||||
'bet.col.placed_at': 'Placed at',
|
||||
'bet.dialog.detail': 'Bet details',
|
||||
'bet.field.total_odds': 'Total odds',
|
||||
'bet.field.currency': 'Currency',
|
||||
'bet.field.potential_win': 'Potential win',
|
||||
'bet.field.actual_payout': 'Actual payout',
|
||||
'bet.field.bet_status': 'Bet status',
|
||||
'bet.field.settlement_status': 'Settlement',
|
||||
'bet.field.settled_at': 'Settled at',
|
||||
'bet.field.request_id': 'Request ID',
|
||||
'bet.selections_title': 'Selections ({n})',
|
||||
'bet.col.market': 'Market',
|
||||
'bet.col.period': 'Period',
|
||||
'bet.col.line': 'Line',
|
||||
'bet.col.result': 'Result',
|
||||
|
||||
'audit.module_ph': 'e.g. USERS, AGENTS',
|
||||
'audit.col.action': 'Action',
|
||||
'audit.col.module': 'Module',
|
||||
'audit.col.target_id': 'Target ID',
|
||||
'audit.col.time': 'Time',
|
||||
|
||||
'cashback.start_date': 'Start date',
|
||||
'cashback.end_date': 'End date',
|
||||
'cashback.preview_btn': 'Preview',
|
||||
'cashback.preview_title': 'Cashback preview',
|
||||
'cashback.stat.players': 'Players',
|
||||
'cashback.stat.total': 'Total cashback',
|
||||
'cashback.confirm_issue': 'Confirm payout',
|
||||
|
||||
'user.field.player_id': 'Player ID',
|
||||
'user.field.bet_count': 'Bet count',
|
||||
'user.field.total_stake': 'Total stake',
|
||||
'user.field.registered_at': 'Registered',
|
||||
'user.ph.remark_initial': 'Ledger note when initial balance > 0',
|
||||
'user.bets_edit_value': '{n} bets / {stake}',
|
||||
'user.login_fail_value': '{n} times',
|
||||
|
||||
'match.import_hint': 'Paste JSON with matches array. Imports as draft; publish from the list.',
|
||||
'match.import_start': 'Import',
|
||||
'match.import_json_ph': '{"matches":[...]}',
|
||||
'match.delete_confirm_title': 'Delete match',
|
||||
'match.delete_confirm_body': 'Delete "{title}"? Draft with no bets only.',
|
||||
'match.ph.league_en': 'FIFA World Cup 2026',
|
||||
'match.ph.league_zh': '2026 World Cup',
|
||||
'match.ph.kickoff': '2026-06-11T19:00:00Z',
|
||||
'match.ph.home_en': 'Mexico',
|
||||
'match.ph.home_zh': 'Mexico',
|
||||
'match.ph.away_en': 'South Africa',
|
||||
'match.ph.away_zh': 'South Africa',
|
||||
|
||||
'err.username_required': 'Username is required',
|
||||
'err.password_min': 'Password must be at least 8 characters',
|
||||
'err.password_mismatch': 'Passwords do not match',
|
||||
'err.credit_negative': 'Credit limit cannot be negative',
|
||||
'err.kickoff_required': 'Kickoff time is required',
|
||||
'err.teams_required': 'Enter home and away team names (ZH or EN)',
|
||||
'err.league_required': 'League name is required',
|
||||
|
||||
'settlement.ht_score': 'Half-time score',
|
||||
'settlement.ft_score': 'Full-time score',
|
||||
'settlement.record_score': 'Save score',
|
||||
'settlement.preview_btn': 'Preview settlement',
|
||||
'settlement.preview_title': 'Settlement preview',
|
||||
'settlement.single_count': 'Single bets',
|
||||
'settlement.est_payout': 'Est. payout',
|
||||
'settlement.refund_amount': 'Refund amount',
|
||||
'settlement.confirm_btn': 'Confirm settlement',
|
||||
'msg.score_recorded': 'Score saved',
|
||||
'msg.settlement_confirmed': 'Settlement confirmed',
|
||||
|
||||
'agent_portal.create_player_section': 'Create player',
|
||||
'agent_portal.deposit_section': 'Top up',
|
||||
'agent_portal.create_player_btn': '+ New player',
|
||||
'agent_portal.create_tier2_btn': '+ New tier-2 agent',
|
||||
'agent_portal.username_ph': 'Enter username',
|
||||
'agent_portal.agent_username_ph': 'Agent username',
|
||||
'agent_portal.player_id_ph': 'Player ID',
|
||||
'agent_portal.withdraw_btn': 'Withdraw {amount}',
|
||||
'msg.agent_sub_created': 'Sub-agent created',
|
||||
'msg.withdraw_ok': 'Withdrawal successful',
|
||||
|
||||
'msg.form_invalid': 'Please check the form',
|
||||
'msg.player_created': 'Player created',
|
||||
'msg.agent_created': 'Agent created',
|
||||
'msg.create_failed': 'Create failed',
|
||||
'msg.saved': 'Saved',
|
||||
'msg.save_failed': 'Save failed',
|
||||
'msg.deleted': 'Deleted',
|
||||
'msg.delete_failed': 'Delete failed',
|
||||
'msg.match_created_draft': 'Match created (draft)',
|
||||
'msg.published': 'Published with markets',
|
||||
'msg.closed': 'Betting closed',
|
||||
'msg.invalid_json': 'Invalid JSON',
|
||||
'msg.import_failed': 'Import failed',
|
||||
'msg.import_done': 'Import: {imported} ok, {skipped} skipped, {failed} failed / {total} total',
|
||||
'msg.topup_ok': 'Top-up successful',
|
||||
'msg.topup_failed': 'Top-up failed',
|
||||
'msg.amount_gt_zero': 'Amount must be greater than 0',
|
||||
'msg.credit_zero': 'Adjustment cannot be 0',
|
||||
'msg.credit_adjusted': 'Credit updated',
|
||||
'msg.credit_adjust_failed': 'Adjustment failed',
|
||||
'msg.outright_no_edit': 'Outright cannot be edited here',
|
||||
'msg.load_matches_failed': 'Failed to load matches',
|
||||
'msg.cashback_issued': 'Cashback issued',
|
||||
'msg.freeze_confirm_title': '{action} account',
|
||||
'msg.freeze_confirm_body': '{action} player "{name}"?{extra}',
|
||||
'msg.freeze_extra': ' They will not be able to sign in.',
|
||||
'msg.freeze_done': '{action} completed',
|
||||
'msg.freeze_failed': '{action} failed',
|
||||
};
|
||||
16
apps/admin/src/i18n/form-validation.ts
Normal file
16
apps/admin/src/i18n/form-validation.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/** 表单校验错误(key 对应 admin-messages / admin-pages) */
|
||||
export class FormValidationError extends Error {
|
||||
readonly key: string;
|
||||
|
||||
constructor(key: string) {
|
||||
super(key);
|
||||
this.name = 'FormValidationError';
|
||||
this.key = key;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveFormError(e: unknown, t: (key: string) => string): string {
|
||||
if (e instanceof FormValidationError) return t(e.key);
|
||||
if (e instanceof Error && e.message.startsWith('err.')) return t(e.message);
|
||||
return t('msg.form_invalid');
|
||||
}
|
||||
@@ -2,29 +2,32 @@
|
||||
import { computed } from 'vue';
|
||||
import { RouterView, RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import AdminLocaleSwitcher from '../components/AdminLocaleSwitcher.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
const adminMenus = [
|
||||
{ path: '/', label: '控制台' },
|
||||
{ path: '/users', label: '玩家管理' },
|
||||
{ path: '/agents', label: '代理管理' },
|
||||
{ path: '/matches', label: '赛事管理' },
|
||||
{ path: '/bets', label: '注单管理' },
|
||||
{ path: '/cashback', label: '返水管理' },
|
||||
{ path: '/audit', label: '操作日志' },
|
||||
];
|
||||
const adminMenus = computed(() => [
|
||||
{ path: '/', label: t('nav.dashboard') },
|
||||
{ path: '/users', label: t('nav.users') },
|
||||
{ path: '/agents', label: t('nav.agents') },
|
||||
{ path: '/matches', label: t('nav.matches') },
|
||||
{ path: '/bets', label: t('nav.bets') },
|
||||
{ path: '/cashback', label: t('nav.cashback') },
|
||||
{ path: '/audit', label: t('nav.audit') },
|
||||
]);
|
||||
|
||||
const agentMenus = [
|
||||
{ path: '/', label: '概览' },
|
||||
{ path: '/my-players', label: '直属玩家' },
|
||||
{ path: '/sub-agents', label: '下级代理' },
|
||||
{ path: '/my-bets', label: '注单查询' },
|
||||
];
|
||||
const agentMenus = computed(() => [
|
||||
{ path: '/', label: t('nav.dashboard') },
|
||||
{ path: '/my-players', label: t('nav.players') },
|
||||
{ path: '/sub-agents', label: t('nav.subAgents') },
|
||||
{ path: '/my-bets', label: t('nav.myBets') },
|
||||
]);
|
||||
|
||||
const menus = computed(() => (auth.isAdmin.value ? adminMenus : agentMenus));
|
||||
const menus = computed(() => (auth.isAdmin.value ? adminMenus.value : agentMenus.value));
|
||||
|
||||
const currentLabel = computed(() =>
|
||||
menus.value.find(m => m.path === route.path)?.label ?? ''
|
||||
@@ -72,11 +75,12 @@ function logout() {
|
||||
<div class="avatar">{{ userInitial }}</div>
|
||||
<div class="user-info">
|
||||
<span class="user-name">{{ auth.user?.username }}</span>
|
||||
<span class="user-role">{{ auth.isAdmin ? '系统管理员' : '代理账号' }}</span>
|
||||
<span class="user-role">{{ auth.isAdmin ? t('role.admin') : t('role.agent') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="portal-tag">{{ auth.portalLabel }}</div>
|
||||
<button class="btn-logout" @click="logout">退出</button>
|
||||
<AdminLocaleSwitcher />
|
||||
<div class="portal-tag">{{ auth.isAdmin ? t('portal.admin') : t('portal.agent') }}</div>
|
||||
<button class="btn-logout" @click="logout">{{ t('logout') }}</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="page-main">
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
export type BetTagType = '' | 'info' | 'success' | 'warning' | 'danger';
|
||||
import { computed } from 'vue';
|
||||
import { adminT, type AdminLocale } from '../i18n/admin-messages';
|
||||
import { getAdminLocale, useAdminLocale } from '../composables/useAdminLocale';
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
PENDING: '待结算',
|
||||
WON: '已赢',
|
||||
LOST: '已输',
|
||||
VOID: '作废',
|
||||
REFUNDED: '已退款',
|
||||
};
|
||||
export type BetTagType = '' | 'info' | 'success' | 'warning' | 'danger';
|
||||
|
||||
const STATUS_TAG: Record<string, BetTagType> = {
|
||||
PENDING: 'warning',
|
||||
@@ -16,13 +12,14 @@ const STATUS_TAG: Record<string, BetTagType> = {
|
||||
REFUNDED: 'info',
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
SINGLE: '单关',
|
||||
PARLAY: '串关',
|
||||
};
|
||||
function loc(): AdminLocale {
|
||||
return getAdminLocale();
|
||||
}
|
||||
|
||||
export function betStatusLabel(status: string) {
|
||||
return STATUS_LABELS[status] ?? status;
|
||||
const key = `bet.status.${status}`;
|
||||
const v = adminT(loc(), key);
|
||||
return v !== key ? v : status;
|
||||
}
|
||||
|
||||
export function betStatusTagType(status: string): BetTagType {
|
||||
@@ -30,20 +27,44 @@ export function betStatusTagType(status: string): BetTagType {
|
||||
}
|
||||
|
||||
export function betTypeLabel(betType: string) {
|
||||
return TYPE_LABELS[betType] ?? betType;
|
||||
const key = `bet.type.${betType}`;
|
||||
const v = adminT(loc(), key);
|
||||
return v !== key ? v : betType;
|
||||
}
|
||||
|
||||
const SETTLEMENT_LABELS: Record<string, string> = {
|
||||
PENDING: '待结算',
|
||||
SETTLED: '已结算',
|
||||
VOID: '已作废',
|
||||
};
|
||||
|
||||
export function betSettlementLabel(v: string | null | undefined) {
|
||||
if (!v) return '—';
|
||||
return SETTLEMENT_LABELS[v] ?? v;
|
||||
const key = `bet.settlement.${v}`;
|
||||
const label = adminT(loc(), key);
|
||||
return label !== key ? label : v;
|
||||
}
|
||||
|
||||
export function betResultLabel(s: string | null | undefined) {
|
||||
if (!s) return '—';
|
||||
const key = `bet.result.${s}`;
|
||||
const label = adminT(loc(), key);
|
||||
return label !== key ? label : s;
|
||||
}
|
||||
|
||||
export function useBetFilterOptions() {
|
||||
const { t } = useAdminLocale();
|
||||
const statusOptions = computed(() => [
|
||||
{ value: '', label: t('common.all') },
|
||||
{ value: 'PENDING', label: t('bet.status.PENDING') },
|
||||
{ value: 'WON', label: t('bet.status.WON') },
|
||||
{ value: 'LOST', label: t('bet.status.LOST') },
|
||||
{ value: 'VOID', label: t('bet.status.VOID') },
|
||||
{ value: 'REFUNDED', label: t('bet.status.REFUNDED') },
|
||||
]);
|
||||
const typeOptions = computed(() => [
|
||||
{ value: '', label: t('common.all') },
|
||||
{ value: 'SINGLE', label: t('bet.type.SINGLE') },
|
||||
{ value: 'PARLAY', label: t('bet.type.PARLAY') },
|
||||
]);
|
||||
return { statusOptions, typeOptions };
|
||||
}
|
||||
|
||||
/** @deprecated 使用 useBetFilterOptions() */
|
||||
export const BET_STATUS_OPTIONS = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'PENDING', label: '待结算' },
|
||||
@@ -53,6 +74,7 @@ export const BET_STATUS_OPTIONS = [
|
||||
{ value: 'REFUNDED', label: '已退款' },
|
||||
];
|
||||
|
||||
/** @deprecated 使用 useBetFilterOptions() */
|
||||
export const BET_TYPE_OPTIONS = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'SINGLE', label: '单关' },
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import type { AdminLocale } from '../i18n/admin-messages';
|
||||
import { formatAmount, formatAmountFull } from './format-amount';
|
||||
|
||||
export type ChartI18n = {
|
||||
locale?: AdminLocale;
|
||||
betCountSeries: string;
|
||||
axisAmount: string;
|
||||
axisCount: string;
|
||||
countSuffix: string;
|
||||
pieTooltip: string;
|
||||
noData: string;
|
||||
pieEmpty: string;
|
||||
};
|
||||
|
||||
const tooltipBase = {
|
||||
backgroundColor: '#141414',
|
||||
borderColor: '#2a2a2a',
|
||||
@@ -110,7 +122,10 @@ export function buildMultiLineChartOption(
|
||||
export function buildPieChartOption(
|
||||
title: string,
|
||||
segments: PieSegment[],
|
||||
i18n?: Partial<ChartI18n>,
|
||||
): EChartsOption {
|
||||
const loc = i18n?.locale;
|
||||
const noData = i18n?.noData ?? '暂无数据';
|
||||
const data = segments.map((s) => ({
|
||||
name: s.label,
|
||||
value: s.value,
|
||||
@@ -122,7 +137,7 @@ export function buildPieChartOption(
|
||||
tooltip: {
|
||||
...tooltipBase,
|
||||
trigger: 'item',
|
||||
formatter: '{b}:{c}({d}%)',
|
||||
formatter: i18n?.pieTooltip ?? '{b}:{c}({d}%)',
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
@@ -151,14 +166,15 @@ export function buildPieChartOption(
|
||||
label: { fontSize: 12, fontWeight: 'bold' },
|
||||
scaleSize: 6,
|
||||
},
|
||||
data: data.length ? data : [{ name: '暂无数据', value: 1, itemStyle: { color: '#333' } }],
|
||||
data: data.length ? data : [{ name: noData, value: 1, itemStyle: { color: '#333' } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function fmtCount(v: number) {
|
||||
return v.toLocaleString('zh-CN', { maximumFractionDigits: 0 });
|
||||
function fmtCount(v: number, locale?: AdminLocale) {
|
||||
const tag = locale ?? 'zh-CN';
|
||||
return v.toLocaleString(tag, { maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
/** 7 日金额折线 + 注单柱(双 Y 轴),一图看清趋势 */
|
||||
@@ -166,7 +182,11 @@ export function buildCombinedTrendOption(
|
||||
labels: string[],
|
||||
amountSeries: ChartSeries[],
|
||||
betCounts: number[],
|
||||
i18n: ChartI18n,
|
||||
): EChartsOption {
|
||||
const loc = i18n.locale;
|
||||
const betCountName = i18n.betCountSeries;
|
||||
const countSuffix = i18n.countSuffix;
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
color: [...amountSeries.map((s) => s.color), '#fb923c'],
|
||||
@@ -178,9 +198,10 @@ export function buildCombinedTrendOption(
|
||||
return items
|
||||
.map((p) => {
|
||||
const v = Number(p.value ?? 0);
|
||||
const isCount = p.seriesName === '注单笔数';
|
||||
const val = isCount ? `${fmtCount(v)} 笔` : formatAmountFull(v);
|
||||
return `${p.marker ?? ''}${p.seriesName}:${val}`;
|
||||
const isCount = p.seriesName === betCountName;
|
||||
const val = isCount ? `${fmtCount(v, loc)} ${countSuffix}` : formatAmountFull(v, loc);
|
||||
const sep = loc?.startsWith('zh') ? ':' : ': ';
|
||||
return `${p.marker ?? ''}${p.seriesName}${sep}${val}`;
|
||||
})
|
||||
.join('<br/>');
|
||||
},
|
||||
@@ -202,16 +223,16 @@ export function buildCombinedTrendOption(
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '金额',
|
||||
name: i18n.axisAmount,
|
||||
nameTextStyle: { color: '#666', fontSize: 10 },
|
||||
axisLabel: { ...axisLabel, formatter: (v: number) => formatAmount(v) },
|
||||
axisLabel: { ...axisLabel, formatter: (v: number) => formatAmount(v, 2, loc) },
|
||||
splitLine,
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '笔数',
|
||||
name: i18n.axisCount,
|
||||
nameTextStyle: { color: '#666', fontSize: 10 },
|
||||
axisLabel: { ...axisLabel, formatter: (v: number) => fmtCount(v) },
|
||||
axisLabel: { ...axisLabel, formatter: (v: number) => fmtCount(v, loc) },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
@@ -228,7 +249,7 @@ export function buildCombinedTrendOption(
|
||||
lineStyle: { color: s.color, width: 2 },
|
||||
})),
|
||||
{
|
||||
name: '注单笔数',
|
||||
name: betCountName,
|
||||
type: 'bar',
|
||||
yAxisIndex: 1,
|
||||
data: betCounts,
|
||||
@@ -242,7 +263,10 @@ export function buildCombinedTrendOption(
|
||||
/** 三个饼图并排,占一张图 */
|
||||
export function buildTriplePieOption(
|
||||
blocks: { title: string; segments: PieSegment[] }[],
|
||||
i18n?: Partial<ChartI18n>,
|
||||
): EChartsOption {
|
||||
const pieEmpty = i18n?.pieEmpty ?? '暂无';
|
||||
const pieTooltip = i18n?.pieTooltip ?? '{b}:{c}({d}%)';
|
||||
const slots = [
|
||||
{ center: ['18%', '58%'] as [string, string], titleLeft: '14%' },
|
||||
{ center: ['50%', '58%'] as [string, string], titleLeft: '46%' },
|
||||
@@ -254,7 +278,7 @@ export function buildTriplePieOption(
|
||||
tooltip: {
|
||||
...tooltipBase,
|
||||
trigger: 'item',
|
||||
formatter: '{b}:{c}({d}%)',
|
||||
formatter: pieTooltip,
|
||||
},
|
||||
graphic: blocks.map((b, i) => ({
|
||||
type: 'text' as const,
|
||||
@@ -283,7 +307,7 @@ export function buildTriplePieOption(
|
||||
labelLine: { show: false },
|
||||
data: data.length
|
||||
? data
|
||||
: [{ name: '暂无', value: 1, itemStyle: { color: '#333' } }],
|
||||
: [{ name: pieEmpty, value: 1, itemStyle: { color: '#333' } }],
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,38 +1,57 @@
|
||||
/** 完整数字(悬停提示、详情对照) */
|
||||
export function formatAmountFull(value: string | number | null | undefined): string {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return '—';
|
||||
return n.toLocaleString('zh-CN', { maximumFractionDigits: 4 });
|
||||
import { getAdminLocale } from '../composables/useAdminLocale';
|
||||
import type { AdminLocale } from '../i18n/admin-messages';
|
||||
|
||||
function resolveLocale(locale?: AdminLocale): AdminLocale {
|
||||
return locale ?? getAdminLocale();
|
||||
}
|
||||
|
||||
function unitPart(abs: number, divisor: number, maxDecimals: number): string {
|
||||
return (abs / divisor).toLocaleString('zh-CN', {
|
||||
function localeTag(locale: AdminLocale): string {
|
||||
return locale;
|
||||
}
|
||||
|
||||
/** 完整数字(悬停提示、详情对照) */
|
||||
export function formatAmountFull(
|
||||
value: string | number | null | undefined,
|
||||
locale?: AdminLocale,
|
||||
): string {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return '—';
|
||||
return n.toLocaleString(localeTag(resolveLocale(locale)), { maximumFractionDigits: 4 });
|
||||
}
|
||||
|
||||
function unitPart(abs: number, divisor: number, maxDecimals: number, locale: AdminLocale): string {
|
||||
return (abs / divisor).toLocaleString(localeTag(locale), {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: maxDecimals,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 金额展示:≥1万用「万」,≥1亿用「亿」,避免表格撑破布局
|
||||
* 金额展示:中文用万/亿;英文用 K/M/B
|
||||
*/
|
||||
export function formatAmount(
|
||||
value: string | number | null | undefined,
|
||||
maxDecimals = 2,
|
||||
locale?: AdminLocale,
|
||||
): string {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return '—';
|
||||
|
||||
const loc = resolveLocale(locale);
|
||||
const sign = n < 0 ? '-' : '';
|
||||
const abs = Math.abs(n);
|
||||
const zh = loc.startsWith('zh');
|
||||
|
||||
if (abs >= 1e8) {
|
||||
return `${sign}${unitPart(abs, 1e8, maxDecimals)}亿`;
|
||||
}
|
||||
if (abs >= 1e4) {
|
||||
return `${sign}${unitPart(abs, 1e4, maxDecimals)}万`;
|
||||
if (zh) {
|
||||
if (abs >= 1e8) return `${sign}${unitPart(abs, 1e8, maxDecimals, loc)}亿`;
|
||||
if (abs >= 1e4) return `${sign}${unitPart(abs, 1e4, maxDecimals, loc)}万`;
|
||||
} else {
|
||||
if (abs >= 1e9) return `${sign}${unitPart(abs, 1e9, maxDecimals, loc)}B`;
|
||||
if (abs >= 1e6) return `${sign}${unitPart(abs, 1e6, maxDecimals, loc)}M`;
|
||||
if (abs >= 1e3) return `${sign}${unitPart(abs, 1e3, maxDecimals, loc)}K`;
|
||||
}
|
||||
|
||||
return n.toLocaleString('zh-CN', {
|
||||
return n.toLocaleString(localeTag(loc), {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: maxDecimals,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import { resolveFormError } from '../i18n/form-validation';
|
||||
import api from '../api';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
import { ElMessage } from 'element-plus';
|
||||
import {
|
||||
emptyAgentCreateForm,
|
||||
@@ -102,18 +106,18 @@ async function submitCreate() {
|
||||
try {
|
||||
payload = buildCreateAgentPayload(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/agents', payload);
|
||||
ElMessage.success('一级代理已创建');
|
||||
ElMessage.success(t('msg.agent_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;
|
||||
}
|
||||
@@ -128,12 +132,12 @@ async function submitEdit() {
|
||||
email: editForm.value.email.trim() || undefined,
|
||||
cashbackRate: editForm.value.cashbackRate,
|
||||
});
|
||||
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;
|
||||
}
|
||||
@@ -141,7 +145,7 @@ async function submitEdit() {
|
||||
|
||||
async function submitCredit() {
|
||||
if (creditForm.value.amount === 0) {
|
||||
ElMessage.warning('调整金额不能为 0');
|
||||
ElMessage.warning(t('msg.credit_zero'));
|
||||
return;
|
||||
}
|
||||
creditLoading.value = true;
|
||||
@@ -151,12 +155,12 @@ async function submitCredit() {
|
||||
requestId: `credit-${editingId.value}-${Date.now()}`,
|
||||
remark: creditForm.value.remark || undefined,
|
||||
});
|
||||
ElMessage.success('授信已调整');
|
||||
ElMessage.success(t('msg.credit_adjusted'));
|
||||
creditVisible.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.credit_adjust_failed'));
|
||||
} finally {
|
||||
creditLoading.value = false;
|
||||
}
|
||||
@@ -172,13 +176,15 @@ 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;
|
||||
}
|
||||
|
||||
function creditTypeLabel(t: string) {
|
||||
if (t === 'CREDIT_INCREASE') return '增加';
|
||||
if (t === 'CREDIT_DECREASE') return '减少';
|
||||
return t;
|
||||
function creditTypeLabel(type: string) {
|
||||
if (type === 'CREDIT_INCREASE') return t('agent.credit.increase');
|
||||
if (type === 'CREDIT_DECREASE') return t('agent.credit.decrease');
|
||||
return type;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -186,25 +192,25 @@ function creditTypeLabel(t: string) {
|
||||
<div class="admin-list-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">代理管理</h2>
|
||||
<span class="page-desc">创建一级代理、调整授信额度、查看直属玩家与额度占用</span>
|
||||
<h2 class="page-title">{{ t('page.agents.title') }}</h2>
|
||||
<span class="page-desc">{{ t('page.agents.desc') }}</span>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">+ 新建一级代理</el-button>
|
||||
<el-button type="primary" @click="openCreate">{{ t('agent.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('agent.filter.username_ph')"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
@keyup.enter="load"
|
||||
/>
|
||||
</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>
|
||||
@@ -213,37 +219,37 @@ function creditTypeLabel(t: string) {
|
||||
<div class="table-wrap">
|
||||
<el-table :data="agents" stripe>
|
||||
<el-table-column prop="userId" 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 prop="level" label="层级" width="72" align="center" />
|
||||
<el-table-column label="授信 / 已用 / 可用" min-width="168" align="right">
|
||||
<el-table-column prop="level" :label="t('agent.col.level')" width="72" align="center" />
|
||||
<el-table-column :label="t('agent.col.credit')" min-width="168" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="creditLineFull(row)" placement="top">
|
||||
<span class="amount-compact">{{ creditLine(row) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="directPlayerCount" label="直属玩家" width="96" align="center" />
|
||||
<el-table-column label="返水率" width="88" align="right">
|
||||
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" width="96" align="center" />
|
||||
<el-table-column :label="t('agent.col.cashback')" width="88" align="right">
|
||||
<template #default="{ row }">{{ row.cashbackRate }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="phone" label="手机" min-width="110">
|
||||
<el-table-column prop="phone" :label="t('agent.col.phone')" min-width="110">
|
||||
<template #default="{ row }">{{ row.phone ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" min-width="158">
|
||||
<el-table-column :label="t('agent.col.created')" min-width="158">
|
||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="240" fixed="right" align="center">
|
||||
<el-table-column :label="t('common.actions')" width="240" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link type="primary" @click="openDetail(row.userId)">详情</el-button>
|
||||
<el-button size="small" link type="primary" @click="openEdit(row.userId)">编辑</el-button>
|
||||
<el-button size="small" link type="primary" @click="openCredit(row)">调额</el-button>
|
||||
<el-button size="small" link type="primary" @click="openDetail(row.userId)">{{ t('common.detail') }}</el-button>
|
||||
<el-button size="small" link type="primary" @click="openEdit(row.userId)">{{ t('common.edit') }}</el-button>
|
||||
<el-button size="small" link type="primary" @click="openCredit(row)">{{ t('common.adjust_credit') }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -262,27 +268,27 @@ function creditTypeLabel(t: string) {
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="createVisible" title="新建一级代理" 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-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="授信额度" required>
|
||||
<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">代理可向直属玩家上分的总额度上限</div>
|
||||
<div class="field-hint">{{ t('agent.hint.credit_limit') }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="返水比例">
|
||||
<el-form-item :label="t('agent.field.cashback_rate')">
|
||||
<el-input-number
|
||||
v-model="createForm.cashbackRate"
|
||||
:min="0"
|
||||
@@ -291,30 +297,30 @@ function creditTypeLabel(t: string) {
|
||||
:precision="4"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div class="field-hint">例如 0.01 表示 1%</div>
|
||||
<div class="field-hint">{{ t('agent.hint.cashback_example') }}</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>
|
||||
<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="480px" destroy-on-close>
|
||||
<el-dialog v-model="editVisible" :title="t('agent.dialog.edit')" width="480px" destroy-on-close>
|
||||
<el-form label-width="88px">
|
||||
<el-form-item label="账号状态">
|
||||
<el-form-item :label="t('user.field.account_status')">
|
||||
<el-radio-group v-model="editForm.status">
|
||||
<el-radio value="ACTIVE">正常</el-radio>
|
||||
<el-radio value="SUSPENDED">停用</el-radio>
|
||||
<el-radio value="ACTIVE">{{ t('user.status.ACTIVE') }}</el-radio>
|
||||
<el-radio value="SUSPENDED">{{ t('user.status.SUSPENDED') }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="返水比例">
|
||||
<el-form-item :label="t('agent.field.cashback_rate')">
|
||||
<el-input-number
|
||||
v-model="editForm.cashbackRate"
|
||||
:min="0"
|
||||
@@ -324,111 +330,111 @@ function creditTypeLabel(t: string) {
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号">
|
||||
<el-form-item :label="t('user.field.phone')">
|
||||
<el-input v-model="editForm.phone" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-form-item :label="t('user.field.email')">
|
||||
<el-input v-model="editForm.email" />
|
||||
</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('common.save') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="creditVisible" title="调整授信额度" width="420px" destroy-on-close>
|
||||
<el-dialog v-model="creditVisible" :title="t('agent.dialog.credit')" width="420px" destroy-on-close>
|
||||
<el-form label-width="88px">
|
||||
<el-form-item label="代理 ID">
|
||||
<el-form-item :label="t('agent.field.agent_id')">
|
||||
<el-input :model-value="editingId" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="调整金额">
|
||||
<el-form-item :label="t('agent.field.adjust_amount')">
|
||||
<el-input-number v-model="creditForm.amount" :step="1000" style="width: 100%" />
|
||||
<div class="field-hint">正数为增加授信,负数为减少</div>
|
||||
<div class="field-hint">{{ t('agent.hint.credit_adjust') }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="creditForm.remark" placeholder="选填,写入额度流水" />
|
||||
<el-form-item :label="t('user.field.remark')">
|
||||
<el-input v-model="creditForm.remark" :placeholder="t('agent.hint.credit_remark')" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="creditVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="creditLoading" @click="submitCredit">确认调整</el-button>
|
||||
<el-button @click="creditVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="creditLoading" @click="submitCredit">{{ t('agent.btn.confirm_adjust') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="detailVisible" title="代理详情" width="640px" destroy-on-close>
|
||||
<el-dialog v-model="detailVisible" :title="t('agent.dialog.detail')" width="640px" destroy-on-close>
|
||||
<template v-if="detail">
|
||||
<el-descriptions :column="2" border size="small" class="detail-block">
|
||||
<el-descriptions-item label="ID">{{ detail.userId }}</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.userId }}</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="层级">L{{ detail.level }}</el-descriptions-item>
|
||||
<el-descriptions-item label="授信额度">
|
||||
<el-descriptions-item :label="t('agent.col.level')">L{{ detail.level }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('agent.field.credit_limit')">
|
||||
{{ formatAmount(detail.creditLimit) }}
|
||||
<span v-if="shouldCompact(detail.creditLimit)" class="amount-full-hint">
|
||||
({{ formatAmountFull(detail.creditLimit) }})
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="已用额度">
|
||||
<el-descriptions-item :label="t('agent.field.used_credit')">
|
||||
{{ formatAmount(detail.usedCredit) }}
|
||||
<span v-if="shouldCompact(detail.usedCredit)" class="amount-full-hint">
|
||||
({{ formatAmountFull(detail.usedCredit) }})
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="可用授信">
|
||||
<el-descriptions-item :label="t('agent.field.available_credit')">
|
||||
{{ formatAmount(detail.availableCredit) }}
|
||||
<span v-if="shouldCompact(detail.availableCredit)" class="amount-full-hint">
|
||||
({{ formatAmountFull(detail.availableCredit) }})
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="直属玩家">{{ detail.directPlayerCount }} 人</el-descriptions-item>
|
||||
<el-descriptions-item label="玩家负债">
|
||||
<el-descriptions-item :label="t('agent.col.direct_players')">{{ detail.directPlayerCount }} {{ t('common.people') }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('agent.field.player_liability')">
|
||||
{{ formatAmount(detail.directPlayerLiability) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="下级代理敞口">
|
||||
<el-descriptions-item :label="t('agent.field.sub_agent_exposure')">
|
||||
{{ formatAmount(detail.childAgentExposure) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="返水率">{{ detail.cashbackRate }}</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="最后登录" :span="2">
|
||||
{{ detail.lastLoginAt ? formatTime(detail.lastLoginAt) : '从未登录' }}
|
||||
<el-descriptions-item :label="t('agent.col.cashback')">{{ detail.cashbackRate }}</el-descriptions-item>
|
||||
<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.col.last_login')" :span="2">
|
||||
{{ detail.lastLoginAt ? formatTime(detail.lastLoginAt) : t('common.never_login') }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间" :span="2">
|
||||
<el-descriptions-item :label="t('agent.col.created')" :span="2">
|
||||
{{ formatTime(detail.createdAt) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="section-title">最近额度变动</div>
|
||||
<div class="section-title">{{ t('agent.section.credit_log') }}</div>
|
||||
<el-table
|
||||
:data="detail.recentCreditTransactions"
|
||||
size="small"
|
||||
stripe
|
||||
empty-text="暂无记录"
|
||||
:empty-text="t('agent.col.no_records')"
|
||||
>
|
||||
<el-table-column label="类型" width="80">
|
||||
<el-table-column :label="t('agent.col.credit_type')" width="80">
|
||||
<template #default="{ row }">{{ creditTypeLabel(row.transactionType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="变动" width="96" align="right">
|
||||
<el-table-column :label="t('agent.col.credit_change')" width="96" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="formatAmountFull(row.amount)" placement="top">
|
||||
<span>{{ formatAmount(row.amount) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="变动后" width="96" align="right">
|
||||
<el-table-column :label="t('agent.col.credit_after')" width="96" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="formatAmountFull(row.creditAfter)" placement="top">
|
||||
<span>{{ formatAmount(row.creditAfter) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="时间" min-width="150">
|
||||
<el-table-column prop="remark" :label="t('user.field.remark')" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column :label="t('audit.col.time')" min-width="150">
|
||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import api from '../api';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
const logs = ref<unknown[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
@@ -37,23 +40,23 @@ function onSizeChange(size: number) {
|
||||
<template>
|
||||
<div class="admin-list-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">操作日志</h2>
|
||||
<span class="page-desc">记录所有管理员操作行为</span>
|
||||
<h2 class="page-title">{{ t('page.audit.title') }}</h2>
|
||||
<span class="page-desc">{{ t('page.audit.desc') }}</span>
|
||||
</div>
|
||||
|
||||
<el-card class="filter-card" shadow="never">
|
||||
<el-form inline>
|
||||
<el-form-item label="模块">
|
||||
<el-form-item :label="t('common.module')">
|
||||
<el-input
|
||||
v-model="filterModule"
|
||||
placeholder="如 USERS、AGENTS"
|
||||
:placeholder="t('audit.module_ph')"
|
||||
clearable
|
||||
style="width: 160px"
|
||||
@keyup.enter="load"
|
||||
/>
|
||||
</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>
|
||||
@@ -61,10 +64,10 @@ 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="操作" min-width="140" />
|
||||
<el-table-column prop="module" label="模块" width="120" />
|
||||
<el-table-column prop="targetId" label="目标ID" min-width="100" />
|
||||
<el-table-column label="时间" min-width="160">
|
||||
<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-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>
|
||||
|
||||
@@ -7,9 +7,13 @@ import {
|
||||
betStatusTagType,
|
||||
betTypeLabel,
|
||||
betSettlementLabel,
|
||||
BET_STATUS_OPTIONS,
|
||||
BET_TYPE_OPTIONS,
|
||||
betResultLabel,
|
||||
useBetFilterOptions,
|
||||
} from '../utils/bet-labels';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
|
||||
const { t, localeTag } = useAdminLocale();
|
||||
const { statusOptions, typeOptions } = useBetFilterOptions();
|
||||
import type { BetListRow, BetDetail } from './bet-form';
|
||||
|
||||
const bets = ref<BetListRow[]>([]);
|
||||
@@ -67,12 +71,12 @@ function resetFilters() {
|
||||
}
|
||||
|
||||
function parentLabel(row: BetListRow) {
|
||||
return row.parentUsername ?? '平台直属';
|
||||
return row.parentUsername ?? t('common.platform_direct');
|
||||
}
|
||||
|
||||
function formatTime(v: string | null | undefined) {
|
||||
if (!v) return '—';
|
||||
return new Date(v).toLocaleString('zh-CN', {
|
||||
return new Date(v).toLocaleString(localeTag.value, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
@@ -83,16 +87,7 @@ function formatTime(v: string | null | undefined) {
|
||||
}
|
||||
|
||||
function resultStatusLabel(s: string | null | undefined) {
|
||||
if (!s) return '—';
|
||||
const map: Record<string, string> = {
|
||||
WON: '赢',
|
||||
LOST: '输',
|
||||
VOID: '走水',
|
||||
PUSH: '走盘',
|
||||
HALF_WON: '半赢',
|
||||
HALF_LOST: '半输',
|
||||
};
|
||||
return map[s] ?? s;
|
||||
return betResultLabel(s);
|
||||
}
|
||||
|
||||
async function openDetail(row: BetListRow) {
|
||||
@@ -111,62 +106,62 @@ async function openDetail(row: BetListRow) {
|
||||
<template>
|
||||
<div class="admin-list-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">注单管理</h2>
|
||||
<span class="page-desc">筛选、分页查看全平台注单,支持详情与投注项</span>
|
||||
<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-item label="关键词">
|
||||
<el-form-item :label="t('common.keyword')">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="流水编号 / 玩家用户名"
|
||||
:placeholder="t('bet.filter.keyword_ph')"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@keyup.enter="load"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filterStatus" placeholder="全部" clearable style="width: 120px">
|
||||
<el-form-item :label="t('common.status')">
|
||||
<el-select v-model="filterStatus" :placeholder="t('common.all')" clearable style="width: 120px">
|
||||
<el-option
|
||||
v-for="o in BET_STATUS_OPTIONS.filter((x) => x.value !== '')"
|
||||
v-for="o in statusOptions.filter((x) => x.value !== '')"
|
||||
:key="o.value"
|
||||
:label="o.label"
|
||||
:value="o.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="filterBetType" placeholder="全部" clearable style="width: 100px">
|
||||
<el-form-item :label="t('common.type')">
|
||||
<el-select v-model="filterBetType" :placeholder="t('common.all')" clearable style="width: 100px">
|
||||
<el-option
|
||||
v-for="o in BET_TYPE_OPTIONS.filter((x) => x.value !== '')"
|
||||
v-for="o in typeOptions.filter((x) => x.value !== '')"
|
||||
:key="o.value"
|
||||
:label="o.label"
|
||||
:value="o.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="投注日起">
|
||||
<el-form-item :label="t('bet.filter.date_from')">
|
||||
<el-date-picker
|
||||
v-model="placedFrom"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="开始"
|
||||
:placeholder="t('bet.filter.date_start_ph')"
|
||||
style="width: 140px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="止">
|
||||
<el-form-item :label="t('common.to')">
|
||||
<el-date-picker
|
||||
v-model="placedTo"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="结束"
|
||||
:placeholder="t('bet.filter.date_end_ph')"
|
||||
style="width: 140px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
<el-button type="primary" @click="load">{{ t('common.search') }}</el-button>
|
||||
<el-button @click="resetFilters">{{ t('common.reset') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
@@ -174,54 +169,54 @@ 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="单号" width="56" align="center" />
|
||||
<el-table-column prop="betNo" label="流水编号" width="168" show-overflow-tooltip>
|
||||
<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>
|
||||
<template #default="{ row }">
|
||||
<span class="bet-no">{{ row.betNo }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="username" label="玩家" width="100" show-overflow-tooltip />
|
||||
<el-table-column label="所属代理" width="100" show-overflow-tooltip>
|
||||
<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>
|
||||
<template #default="{ row }">{{ parentLabel(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="72" align="center">
|
||||
<el-table-column :label="t('common.type')" width="72" 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="选项" width="52" align="center">
|
||||
<el-table-column :label="t('bet.col.selection')" width="52" align="center">
|
||||
<template #default="{ row }">{{ row.selectionCount }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="投注额" width="96" align="right">
|
||||
<el-table-column :label="t('bet.col.stake')" width="96" 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="赔率" width="72" align="right">
|
||||
<el-table-column :label="t('bet.col.odds')" width="72" align="right">
|
||||
<template #default="{ row }">{{ row.totalOdds ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="派彩" width="96" align="right">
|
||||
<el-table-column :label="t('bet.col.payout')" width="96" 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="状态" width="88" align="center">
|
||||
<el-table-column :label="t('common.status')" width="88" 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="投注时间" width="160">
|
||||
<el-table-column :label="t('bet.col.placed_at')" width="160">
|
||||
<template #default="{ row }">{{ formatTime(row.placedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="88" fixed="right" align="center">
|
||||
<el-table-column :label="t('common.actions')" width="88" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="openDetail(row)">详情</el-button>
|
||||
<el-button type="primary" link size="small" @click="openDetail(row)">{{ t('common.detail') }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -241,61 +236,61 @@ async function openDetail(row: BetListRow) {
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="detailVisible" title="注单详情" width="720px" destroy-on-close>
|
||||
<el-dialog v-model="detailVisible" :title="t('bet.dialog.detail')" width="720px" destroy-on-close>
|
||||
<div v-loading="detailLoading">
|
||||
<template v-if="detail">
|
||||
<el-descriptions :column="2" border size="small" class="detail-desc">
|
||||
<el-descriptions-item label="单号">{{ detail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="流水编号">{{ detail.betNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="玩家">{{ detail.username }}</el-descriptions-item>
|
||||
<el-descriptions-item label="所属代理">{{ parentLabel(detail) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="类型">{{ betTypeLabel(detail.betType) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="币种">{{ detail.currency }}</el-descriptions-item>
|
||||
<el-descriptions-item label="投注额">
|
||||
<el-descriptions-item :label="t('bet.col.serial')">{{ detail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('bet.col.bet_no')">{{ detail.betNo }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('bet.col.player')">{{ detail.username }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('bet.col.agent')">{{ parentLabel(detail) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('common.type')">{{ betTypeLabel(detail.betType) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('bet.field.currency')">{{ detail.currency }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('bet.col.stake')">
|
||||
{{ formatAmountFull(detail.stake) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="总赔率">{{ detail.totalOdds ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="可赢额">
|
||||
<el-descriptions-item :label="t('bet.field.total_odds')">{{ detail.totalOdds ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('bet.field.potential_win')">
|
||||
{{ detail.potentialReturn ? formatAmountFull(detail.potentialReturn) : '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="实际派彩">
|
||||
<el-descriptions-item :label="t('bet.field.actual_payout')">
|
||||
{{ formatAmountFull(detail.actualReturn) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="注单状态">
|
||||
<el-descriptions-item :label="t('bet.field.bet_status')">
|
||||
<el-tag :type="betStatusTagType(detail.status)" size="small">
|
||||
{{ betStatusLabel(detail.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="结算状态">
|
||||
<el-descriptions-item :label="t('bet.field.settlement_status')">
|
||||
{{ betSettlementLabel(detail.settlementStatus) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="投注时间">{{ formatTime(detail.placedAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="结算时间">{{ formatTime(detail.settledAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="请求 ID" :span="2">{{ detail.requestId }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('bet.col.placed_at')">{{ formatTime(detail.placedAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('bet.field.settled_at')">{{ formatTime(detail.settledAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('bet.field.request_id')" :span="2">{{ detail.requestId }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="selections-title">投注项({{ detail.selections.length }})</div>
|
||||
<div class="selections-title">{{ t('bet.selections_title', { n: detail.selections.length }) }}</div>
|
||||
<el-table :data="detail.selections" size="small" stripe border>
|
||||
<el-table-column type="index" label="#" width="44" />
|
||||
<el-table-column prop="selectionName" label="选项" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="marketType" label="玩法" width="100" />
|
||||
<el-table-column prop="period" label="时段" width="72">
|
||||
<el-table-column prop="selectionName" :label="t('bet.col.selection')" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="marketType" :label="t('bet.col.market')" width="100" />
|
||||
<el-table-column prop="period" :label="t('bet.col.period')" width="72">
|
||||
<template #default="{ row }">{{ row.period ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="odds" label="赔率" width="72" align="right" />
|
||||
<el-table-column label="盘口" width="88">
|
||||
<el-table-column prop="odds" :label="t('bet.col.odds')" width="72" align="right" />
|
||||
<el-table-column :label="t('bet.col.line')" width="88">
|
||||
<template #default="{ row }">
|
||||
{{ row.handicapLine ?? row.totalLine ?? '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="赛果" width="72" align="center">
|
||||
<el-table-column :label="t('bet.col.result')" width="72" align="center">
|
||||
<template #default="{ row }">{{ resultStatusLabel(row.resultStatus) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
<el-button @click="detailVisible = false">{{ t('common.close') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import api from '../api';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import api from '../api';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
const preview = ref<Record<string, unknown> | null>(null);
|
||||
const period = ref({
|
||||
@@ -20,7 +23,7 @@ async function generatePreview() {
|
||||
async function confirm() {
|
||||
if (!preview.value?.batch) return;
|
||||
await api.post(`/admin/cashbacks/${(preview.value.batch as { id: string }).id}/confirm`);
|
||||
ElMessage.success('返水已发放');
|
||||
ElMessage.success(t('msg.cashback_issued'));
|
||||
preview.value = null;
|
||||
}
|
||||
</script>
|
||||
@@ -28,43 +31,43 @@ async function confirm() {
|
||||
<template>
|
||||
<div class="page-scroll">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">返水管理</h2>
|
||||
<span class="page-desc">按周期生成返水并发放</span>
|
||||
<h2 class="page-title">{{ t('page.cashback.title') }}</h2>
|
||||
<span class="page-desc">{{ t('page.cashback.desc') }}</span>
|
||||
</div>
|
||||
|
||||
<el-card class="tool-card" shadow="never">
|
||||
<div class="filter-row">
|
||||
<el-form inline>
|
||||
<el-form-item label="开始日期">
|
||||
<el-form-item :label="t('cashback.start_date')">
|
||||
<el-date-picker v-model="period.start" type="date" value-format="YYYY-MM-DD" style="width: 150px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="结束日期">
|
||||
<el-form-item :label="t('cashback.end_date')">
|
||||
<el-date-picker v-model="period.end" type="date" value-format="YYYY-MM-DD" style="width: 150px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="generatePreview">生成预览</el-button>
|
||||
<el-button type="primary" @click="generatePreview">{{ t('cashback.preview_btn') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="preview" class="preview-card" shadow="never">
|
||||
<div class="preview-title">返水预览</div>
|
||||
<div class="preview-title">{{ t('cashback.preview_title') }}</div>
|
||||
<el-row :gutter="20" class="preview-stats">
|
||||
<el-col :span="8">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value">{{ (preview.batch as { playerCount: number })?.playerCount ?? 0 }}</div>
|
||||
<div class="pstat-label">涉及玩家数</div>
|
||||
<div class="pstat-label">{{ t('cashback.stat.players') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value">{{ preview.totalAmount }}</div>
|
||||
<div class="pstat-label">返水总金额</div>
|
||||
<div class="pstat-label">{{ t('cashback.stat.total') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-button type="success" @click="confirm" style="margin-top: 20px">确认发放</el-button>
|
||||
<el-button type="success" @click="confirm" style="margin-top: 20px">{{ t('cashback.confirm_issue') }}</el-button>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -6,6 +6,9 @@ import type { AdminDashboard } from './dashboard-types';
|
||||
import EChartPanel from '../components/dashboard/EChartPanel.vue';
|
||||
import { buildCombinedTrendOption, buildTriplePieOption } from '../utils/dashboard-charts';
|
||||
import { betStatusLabel } from '../utils/bet-labels';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
|
||||
const { t, locale, localeTag } = useAdminLocale();
|
||||
|
||||
const stats = ref<AdminDashboard | null>(null);
|
||||
const loading = ref(true);
|
||||
@@ -25,11 +28,11 @@ async function load() {
|
||||
const s = computed(() => stats.value);
|
||||
|
||||
function fmtCount(val: number | undefined) {
|
||||
return (val ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
|
||||
return (val ?? 0).toLocaleString(localeTag.value, { maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
function formatTime(v: string) {
|
||||
return new Date(v).toLocaleString('zh-CN', {
|
||||
return new Date(v).toLocaleString(localeTag.value, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
@@ -43,14 +46,25 @@ function toNum(v: string | number | undefined) {
|
||||
}
|
||||
|
||||
function pctChange(today: string | number, yesterday: string | number) {
|
||||
const t = toNum(today);
|
||||
const t0 = toNum(today);
|
||||
const y = toNum(yesterday);
|
||||
if (y === 0) return t > 0 ? '+100%' : '—';
|
||||
const p = ((t - y) / y) * 100;
|
||||
if (y === 0) return t0 > 0 ? '+100%' : '—';
|
||||
const p = ((t0 - y) / y) * 100;
|
||||
const sign = p > 0 ? '+' : '';
|
||||
return `${sign}${p.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
const chartI18n = computed(() => ({
|
||||
locale: locale.value,
|
||||
betCountSeries: t('dash.chart_bet_count'),
|
||||
axisAmount: t('dash.axis_amount'),
|
||||
axisCount: t('dash.axis_count'),
|
||||
countSuffix: t('dash.count_suffix'),
|
||||
pieTooltip: t('dash.chart_tooltip'),
|
||||
noData: t('common.no_data'),
|
||||
pieEmpty: t('dash.pie_empty'),
|
||||
}));
|
||||
|
||||
const trendLabels = computed(() => s.value?.trend7d?.map((d) => d.label) ?? []);
|
||||
|
||||
const mainTrendOption = computed(() =>
|
||||
@@ -58,22 +72,23 @@ const mainTrendOption = computed(() =>
|
||||
trendLabels.value,
|
||||
[
|
||||
{
|
||||
name: '投注额',
|
||||
name: t('dash.chart_stake'),
|
||||
color: '#248f54',
|
||||
values: s.value?.trend7d?.map((d) => toNum(d.stake)) ?? [],
|
||||
},
|
||||
{
|
||||
name: '派彩',
|
||||
name: t('dash.chart_payout'),
|
||||
color: '#60a5fa',
|
||||
values: s.value?.trend7d?.map((d) => toNum(d.payout)) ?? [],
|
||||
},
|
||||
{
|
||||
name: '毛利',
|
||||
name: t('dash.chart_ggr'),
|
||||
color: '#a78bfa',
|
||||
values: s.value?.trend7d?.map((d) => toNum(d.ggr)) ?? [],
|
||||
},
|
||||
],
|
||||
s.value?.trend7d?.map((d) => d.betCount) ?? [],
|
||||
chartI18n.value,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -91,11 +106,11 @@ const distributionOption = computed(() => {
|
||||
|
||||
const matchSegs = m
|
||||
? [
|
||||
{ label: '草稿', value: m.draft, color: '#6b7280' },
|
||||
{ label: '已发布', value: m.published, color: '#248f54' },
|
||||
{ label: '已封盘', value: m.closed, color: '#60a5fa' },
|
||||
{ label: '待结算', value: m.pendingSettlement, color: '#fb923c' },
|
||||
{ label: '已结算', value: m.settled ?? 0, color: '#5eead4' },
|
||||
{ label: t('dash.match_draft'), value: m.draft, color: '#6b7280' },
|
||||
{ label: t('dash.match_published'), value: m.published, color: '#248f54' },
|
||||
{ label: t('dash.match_closed'), value: m.closed, color: '#60a5fa' },
|
||||
{ label: t('dash.match_pending_settle'), value: m.pendingSettlement, color: '#fb923c' },
|
||||
{ label: t('dash.match_settled'), value: m.settled ?? 0, color: '#5eead4' },
|
||||
].filter((x) => x.value > 0)
|
||||
: [];
|
||||
|
||||
@@ -109,39 +124,82 @@ const distributionOption = computed(() => {
|
||||
|
||||
const userSegs = u
|
||||
? [
|
||||
{ label: '正常玩家', value: u.playersActive, color: '#248f54' },
|
||||
{ label: '停用', value: u.playersSuspended, color: '#f87171' },
|
||||
{ label: '直属', value: u.playersDirect, color: '#60a5fa' },
|
||||
{ label: '代理', value: u.agentsTotal, color: '#a78bfa' },
|
||||
{ label: t('dash.user_active'), value: u.playersActive, color: '#248f54' },
|
||||
{ label: t('dash.user_suspended'), value: u.playersSuspended, color: '#f87171' },
|
||||
{ label: t('dash.user_direct'), value: u.playersDirect, color: '#60a5fa' },
|
||||
{ label: t('dash.user_agents'), value: u.agentsTotal, color: '#a78bfa' },
|
||||
].filter((x) => x.value > 0)
|
||||
: [];
|
||||
|
||||
return buildTriplePieOption([
|
||||
{ title: '赛事', segments: matchSegs },
|
||||
{ title: '今日注单', segments: betSegs },
|
||||
{ title: '用户', segments: userSegs },
|
||||
]);
|
||||
return buildTriplePieOption(
|
||||
[
|
||||
{ title: t('dash.pie_matches'), segments: matchSegs },
|
||||
{ title: t('dash.pie_bets'), segments: betSegs },
|
||||
{ title: t('dash.pie_users'), segments: userSegs },
|
||||
],
|
||||
chartI18n.value,
|
||||
);
|
||||
});
|
||||
|
||||
const kpiPrimary = computed(() => {
|
||||
if (!s.value) return [];
|
||||
const t = s.value.today;
|
||||
const td = s.value.today;
|
||||
const y = s.value.yesterday;
|
||||
const yLabel = t('common.yesterday');
|
||||
return [
|
||||
{ label: '今日投注笔数', value: fmtCount(t.betCount), sub: `昨日 ${fmtCount(y.betCount)}`, delta: pctChange(t.betCount, y.betCount) },
|
||||
{ label: '今日投注额', value: formatAmount(t.stake), sub: formatAmountFull(t.stake), delta: pctChange(t.stake, y.stake) },
|
||||
{ label: '今日派彩', value: formatAmount(t.payout), sub: `昨日 ${formatAmount(y.payout)}`, delta: pctChange(t.payout, y.payout) },
|
||||
{ label: '今日毛利', value: formatAmount(t.ggr), sub: `昨日 ${formatAmount(y.ggr)}`, delta: pctChange(t.ggr, y.ggr) },
|
||||
{
|
||||
label: t('dash.kpi_bet_count'),
|
||||
value: fmtCount(td.betCount),
|
||||
sub: `${yLabel} ${fmtCount(y.betCount)}`,
|
||||
delta: pctChange(td.betCount, y.betCount),
|
||||
},
|
||||
{
|
||||
label: t('dash.kpi_stake'),
|
||||
value: formatAmount(td.stake, 2, locale.value),
|
||||
sub: formatAmountFull(td.stake, locale.value),
|
||||
delta: pctChange(td.stake, y.stake),
|
||||
},
|
||||
{
|
||||
label: t('dash.kpi_payout'),
|
||||
value: formatAmount(td.payout, 2, locale.value),
|
||||
sub: `${yLabel} ${formatAmount(y.payout, 2, locale.value)}`,
|
||||
delta: pctChange(td.payout, y.payout),
|
||||
},
|
||||
{
|
||||
label: t('dash.kpi_ggr'),
|
||||
value: formatAmount(td.ggr, 2, locale.value),
|
||||
sub: `${yLabel} ${formatAmount(y.ggr, 2, locale.value)}`,
|
||||
delta: pctChange(td.ggr, y.ggr),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const kpiSecondary = computed(() => {
|
||||
if (!s.value) return [];
|
||||
return [
|
||||
{ label: '玩家 / 代理', value: `${fmtCount(s.value.users.playersTotal)} / ${fmtCount(s.value.users.agentsTotal)}`, sub: `今日新增 ${fmtCount(s.value.today.newPlayers)} 人` },
|
||||
{ label: '待结算', value: `${fmtCount(s.value.bets.pendingTotal)} 单`, sub: `${fmtCount(s.value.matches.pendingSettlement)} 场赛事` },
|
||||
{ label: '玩家余额', value: formatAmount(s.value.wallets.totalAvailable), sub: `冻结 ${formatAmount(s.value.wallets.totalFrozen)}` },
|
||||
{ label: '代理授信', value: formatAmount(s.value.agents.totalAvailableCredit), sub: `已用 ${formatAmount(s.value.agents.totalUsedCredit)}` },
|
||||
{
|
||||
label: t('dash.kpi_users'),
|
||||
value: `${fmtCount(s.value.users.playersTotal)} / ${fmtCount(s.value.users.agentsTotal)}`,
|
||||
sub: t('dash.kpi_new_players', { n: fmtCount(s.value.today.newPlayers) }),
|
||||
},
|
||||
{
|
||||
label: t('dash.kpi_pending'),
|
||||
value: `${fmtCount(s.value.bets.pendingTotal)} ${t('common.bets_unit')}`,
|
||||
sub: t('dash.kpi_pending_sub', {
|
||||
bets: fmtCount(s.value.bets.pendingTotal),
|
||||
matches: fmtCount(s.value.matches.pendingSettlement),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: t('dash.kpi_wallet'),
|
||||
value: formatAmount(s.value.wallets.totalAvailable, 2, locale.value),
|
||||
sub: `${t('common.frozen')} ${formatAmount(s.value.wallets.totalFrozen, 2, locale.value)}`,
|
||||
},
|
||||
{
|
||||
label: t('dash.kpi_credit'),
|
||||
value: formatAmount(s.value.agents.totalAvailableCredit, 2, locale.value),
|
||||
sub: `${t('common.used')} ${formatAmount(s.value.agents.totalUsedCredit, 2, locale.value)}`,
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
@@ -150,10 +208,12 @@ const kpiSecondary = computed(() => {
|
||||
<div class="dashboard-page" v-loading="loading">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">控制台</h2>
|
||||
<h2 class="page-title">{{ t('dash.title') }}</h2>
|
||||
<span class="page-desc">
|
||||
平台整体运行概况
|
||||
<template v-if="s?.generatedAt"> · 更新于 {{ formatTime(s.generatedAt) }}</template>
|
||||
{{ t('dash.desc') }}
|
||||
<template v-if="s?.generatedAt">
|
||||
· {{ t('common.updated_at') }} {{ formatTime(s.generatedAt) }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -161,8 +221,8 @@ const kpiSecondary = computed(() => {
|
||||
<template v-if="s">
|
||||
<el-card class="overview-board" shadow="never">
|
||||
<div class="board-head">
|
||||
<span class="board-title">整体概览</span>
|
||||
<span class="board-hint">一屏查看经营趋势与平台分布</span>
|
||||
<span class="board-title">{{ t('dash.board_title') }}</span>
|
||||
<span class="board-hint">{{ t('dash.board_hint') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="kpi-grid kpi-primary">
|
||||
@@ -174,7 +234,7 @@ const kpiSecondary = computed(() => {
|
||||
class="kpi-delta"
|
||||
:class="{ up: item.delta.startsWith('+'), down: item.delta.startsWith('-') }"
|
||||
>
|
||||
较昨日 {{ item.delta }}
|
||||
{{ t('common.vs_yesterday') }} {{ item.delta }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,14 +248,8 @@ const kpiSecondary = computed(() => {
|
||||
</div>
|
||||
|
||||
<div class="charts-stack">
|
||||
<EChartPanel
|
||||
title=""
|
||||
:option="mainTrendOption"
|
||||
height="300px"
|
||||
class="chart-main"
|
||||
/>
|
||||
<div class="chart-main-caption">近 7 日经营趋势(金额折线 + 注单柱)</div>
|
||||
|
||||
<EChartPanel title="" :option="mainTrendOption" height="300px" class="chart-main" />
|
||||
<div class="chart-main-caption">{{ t('dash.trend_caption') }}</div>
|
||||
<EChartPanel title="" :option="distributionOption" height="200px" class="chart-dist" />
|
||||
</div>
|
||||
</el-card>
|
||||
@@ -325,5 +379,4 @@ const kpiSecondary = computed(() => {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -5,11 +5,14 @@ import api from '../api';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAuthStore, type StaffUser } from '../stores/auth';
|
||||
import RobotVerify from '../components/RobotVerify.vue';
|
||||
import AdminLocaleSwitcher from '../components/AdminLocaleSwitcher.vue';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import bgImage from '../assets/images/bg.png';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const auth = useAuthStore();
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
const form = ref({ username: '', password: '' });
|
||||
const loading = ref(false);
|
||||
@@ -23,7 +26,7 @@ async function quickLogin(username: string, password: string) {
|
||||
auth.setSession(payload.token, payload.user);
|
||||
router.push((route.query.redirect as string) || '/');
|
||||
} catch {
|
||||
ElMessage.error('快速登录失败');
|
||||
ElMessage.error(t('login.err_quick'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -31,7 +34,7 @@ async function quickLogin(username: string, password: string) {
|
||||
|
||||
async function login() {
|
||||
if (!captchaRef.value?.validate()) {
|
||||
ElMessage.error('验证码错误,请重试');
|
||||
ElMessage.error(t('login.err_captcha'));
|
||||
captchaRef.value?.refresh();
|
||||
return;
|
||||
}
|
||||
@@ -42,7 +45,7 @@ async function login() {
|
||||
auth.setSession(payload.token, payload.user);
|
||||
router.push((route.query.redirect as string) || '/');
|
||||
} catch {
|
||||
ElMessage.error('登录失败,请检查账号与密码');
|
||||
ElMessage.error(t('login.err_failed'));
|
||||
captchaRef.value?.refresh();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -54,29 +57,32 @@ async function login() {
|
||||
<div class="login-page" :style="{ backgroundImage: `url(${bgImage})` }">
|
||||
<div class="login-mask" />
|
||||
<div class="login-wrap">
|
||||
<div class="login-lang">
|
||||
<AdminLocaleSwitcher />
|
||||
</div>
|
||||
<form @submit.prevent="login" class="login-form" autocomplete="off">
|
||||
<img src="/logo.png" alt="TheBet365" class="logo" />
|
||||
<h2 class="title">管理后台</h2>
|
||||
<h2 class="title">{{ t('login.title') }}</h2>
|
||||
|
||||
<label>账号</label>
|
||||
<input v-model="form.username" class="field" placeholder="请输入用户名" autocomplete="off" required />
|
||||
<label>密码</label>
|
||||
<input v-model="form.password" class="field" type="password" placeholder="请输入密码" autocomplete="off" required />
|
||||
<label>{{ t('login.username') }}</label>
|
||||
<input v-model="form.username" class="field" :placeholder="t('login.username_ph')" autocomplete="off" required />
|
||||
<label>{{ t('login.password') }}</label>
|
||||
<input v-model="form.password" class="field" type="password" :placeholder="t('login.password_ph')" autocomplete="off" required />
|
||||
|
||||
<RobotVerify ref="captchaRef" />
|
||||
|
||||
<button type="submit" class="btn-login" :disabled="loading">
|
||||
{{ loading ? '登录中...' : '登 录' }}
|
||||
{{ loading ? '...' : t('login.submit') }}
|
||||
</button>
|
||||
|
||||
<div class="quick-label">快速登录(调试)</div>
|
||||
<div class="quick-label">{{ t('login.quick_label') }}</div>
|
||||
<div class="quick-btns">
|
||||
<button type="button" class="quick-btn" :disabled="loading" @click="quickLogin('admin', 'Admin@123')">
|
||||
<span class="quick-role">管理员</span>
|
||||
<span class="quick-role">{{ t('login.quick_admin') }}</span>
|
||||
<span class="quick-acc">admin</span>
|
||||
</button>
|
||||
<button type="button" class="quick-btn" :disabled="loading" @click="quickLogin('agent1', 'Agent@123')">
|
||||
<span class="quick-role">一级代理</span>
|
||||
<span class="quick-role">{{ t('login.quick_agent') }}</span>
|
||||
<span class="quick-acc">agent1</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -109,6 +115,12 @@ async function login() {
|
||||
max-width: 400px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.login-lang {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import { resolveFormError } from '../i18n/form-validation';
|
||||
import api from '../api';
|
||||
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
import {
|
||||
emptyMatchForm,
|
||||
buildPlatformPayload,
|
||||
@@ -74,7 +79,7 @@ async function openEdit(id: string) {
|
||||
const { data } = await api.get(`/admin/matches/${id}`);
|
||||
const detail = data.data as AdminMatchDetail;
|
||||
if (detail.isOutright) {
|
||||
ElMessage.warning('冠军盘不支持在此编辑');
|
||||
ElMessage.warning(t('msg.outright_no_edit'));
|
||||
return;
|
||||
}
|
||||
editingId.value = id;
|
||||
@@ -83,7 +88,7 @@ async function openEdit(id: string) {
|
||||
editVisible.value = true;
|
||||
} 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.load_matches_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,18 +97,18 @@ async function submitCreate() {
|
||||
try {
|
||||
payload = buildPlatformPayload(form.value);
|
||||
} catch (e) {
|
||||
ElMessage.warning(e instanceof Error ? e.message : '请检查表单');
|
||||
ElMessage.warning(resolveFormError(e, t));
|
||||
return;
|
||||
}
|
||||
createLoading.value = true;
|
||||
try {
|
||||
await api.post('/admin/matches', payload);
|
||||
ElMessage.success('赛事已创建(草稿)');
|
||||
ElMessage.success(t('msg.match_created_draft'));
|
||||
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;
|
||||
}
|
||||
@@ -114,18 +119,18 @@ async function submitEdit() {
|
||||
try {
|
||||
payload = buildPlatformPayload(form.value);
|
||||
} catch (e) {
|
||||
ElMessage.warning(e instanceof Error ? e.message : '请检查表单');
|
||||
ElMessage.warning(resolveFormError(e, t));
|
||||
return;
|
||||
}
|
||||
editLoading.value = true;
|
||||
try {
|
||||
await api.put(`/admin/matches/${editingId.value}`, payload);
|
||||
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;
|
||||
}
|
||||
@@ -135,18 +140,18 @@ async function confirmDelete(row: unknown) {
|
||||
const id = matchId(row);
|
||||
const title = matchTitle(row);
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除赛事「${title}」?仅草稿且无注单时可删除。`, '删除确认', {
|
||||
await ElMessageBox.confirm(t('match.delete_confirm_body', { title }), t('match.delete_confirm_title'), {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
confirmButtonText: t('common.delete'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
});
|
||||
await api.delete(`/admin/matches/${id}`);
|
||||
ElMessage.success('已删除');
|
||||
ElMessage.success(t('msg.deleted'));
|
||||
load();
|
||||
} catch (e) {
|
||||
if (e === 'cancel' || (e as { message?: string })?.message === 'cancel') return;
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? '删除失败');
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.delete_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +160,7 @@ async function submitImport() {
|
||||
try {
|
||||
payload = JSON.parse(importJson.value);
|
||||
} catch {
|
||||
ElMessage.error('JSON 格式无效');
|
||||
ElMessage.error(t('msg.invalid_json'));
|
||||
return;
|
||||
}
|
||||
importLoading.value = true;
|
||||
@@ -168,13 +173,18 @@ async function submitImport() {
|
||||
total: number;
|
||||
};
|
||||
ElMessage.success(
|
||||
`导入完成:成功 ${r.imported},跳过 ${r.skipped},失败 ${r.failed} / 共 ${r.total}`,
|
||||
t('msg.import_done', {
|
||||
imported: r.imported,
|
||||
skipped: r.skipped,
|
||||
failed: r.failed,
|
||||
total: r.total,
|
||||
}),
|
||||
);
|
||||
importVisible.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.import_failed'));
|
||||
} finally {
|
||||
importLoading.value = false;
|
||||
}
|
||||
@@ -183,15 +193,26 @@ async function submitImport() {
|
||||
async function publish(id: string) {
|
||||
await api.post(`/admin/matches/${id}/publish`);
|
||||
await api.post(`/admin/matches/${id}/markets/templates`, {
|
||||
marketTypes: ['FT_1X2', 'FT_HANDICAP', 'FT_OVER_UNDER', 'FT_ODD_EVEN'],
|
||||
marketTypes: [
|
||||
'FT_1X2',
|
||||
'FT_HANDICAP',
|
||||
'FT_OVER_UNDER',
|
||||
'FT_ODD_EVEN',
|
||||
'HT_1X2',
|
||||
'HT_HANDICAP',
|
||||
'HT_OVER_UNDER',
|
||||
'FT_CORRECT_SCORE',
|
||||
'HT_CORRECT_SCORE',
|
||||
'SH_CORRECT_SCORE',
|
||||
],
|
||||
});
|
||||
ElMessage.success('已发布并生成盘口');
|
||||
ElMessage.success(t('msg.published'));
|
||||
load();
|
||||
}
|
||||
|
||||
async function close(id: string) {
|
||||
await api.post(`/admin/matches/${id}/close`);
|
||||
ElMessage.success('已封盘');
|
||||
ElMessage.success(t('msg.closed'));
|
||||
load();
|
||||
}
|
||||
|
||||
@@ -200,12 +221,11 @@ function settle(id: string) {
|
||||
}
|
||||
|
||||
type TagType = '' | 'info' | 'success' | 'warning' | 'danger';
|
||||
const statusLabels: Record<string, string> = {
|
||||
DRAFT: '草稿',
|
||||
PUBLISHED: '已发布',
|
||||
CLOSED: '已封盘',
|
||||
SETTLED: '已结算',
|
||||
};
|
||||
function matchStatusText(status: string) {
|
||||
const key = `match.status.${status}`;
|
||||
const v = t(key);
|
||||
return v !== key ? v : status;
|
||||
}
|
||||
const statusTagTypes: Record<string, TagType> = {
|
||||
DRAFT: 'info',
|
||||
PUBLISHED: 'warning',
|
||||
@@ -220,7 +240,7 @@ function matchStatus(row: unknown) {
|
||||
return String(rowOf(row).status ?? '');
|
||||
}
|
||||
function matchStatusLabel(row: unknown) {
|
||||
return statusLabels[matchStatus(row)] ?? matchStatus(row);
|
||||
return matchStatusText(matchStatus(row));
|
||||
}
|
||||
function matchStatusType(row: unknown): TagType {
|
||||
return statusTagTypes[matchStatus(row)] ?? 'info';
|
||||
@@ -254,36 +274,36 @@ function canDelete(row: unknown) {
|
||||
<div class="admin-list-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">赛事管理</h2>
|
||||
<span class="page-desc">草稿可编辑、删除;已发布可改开赛时间与热门</span>
|
||||
<h2 class="page-title">{{ t('page.matches.title') }}</h2>
|
||||
<span class="page-desc">{{ t('page.matches.desc') }}</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button @click="openImport">导入</el-button>
|
||||
<el-button type="primary" @click="openCreate">+ 新增赛事</el-button>
|
||||
<el-button @click="openImport">{{ t('common.import') }}</el-button>
|
||||
<el-button type="primary" @click="openCreate">{{ t('match.create_btn') }}</el-button>
|
||||
</div>
|
||||
</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('match.filter.keyword_ph')"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@keyup.enter="load"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filterStatus" placeholder="全部" clearable style="width: 120px">
|
||||
<el-option label="草稿" value="DRAFT" />
|
||||
<el-option label="已发布" value="PUBLISHED" />
|
||||
<el-option label="已封盘" value="CLOSED" />
|
||||
<el-option label="已结算" value="SETTLED" />
|
||||
<el-form-item :label="t('common.status')">
|
||||
<el-select v-model="filterStatus" :placeholder="t('common.all')" clearable style="width: 120px">
|
||||
<el-option :label="t('match.status.DRAFT')" value="DRAFT" />
|
||||
<el-option :label="t('match.status.PUBLISHED')" value="PUBLISHED" />
|
||||
<el-option :label="t('match.status.CLOSED')" value="CLOSED" />
|
||||
<el-option :label="t('match.status.SETTLED')" value="SETTLED" />
|
||||
</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>
|
||||
@@ -292,18 +312,18 @@ function canDelete(row: unknown) {
|
||||
<div class="table-wrap">
|
||||
<el-table :data="matches" stripe>
|
||||
<el-table-column prop="id" label="ID" width="72" />
|
||||
<el-table-column label="对阵" min-width="200">
|
||||
<el-table-column :label="t('match.col.matchup')" min-width="200">
|
||||
<template #default="{ row }">{{ matchTitle(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="96">
|
||||
<el-table-column :label="t('common.status')" width="96">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="matchStatusType(row)" size="small">{{ matchStatusLabel(row) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开赛时间" min-width="160">
|
||||
<el-table-column :label="t('match.col.kickoff')" min-width="160">
|
||||
<template #default="{ row }">{{ matchTime(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="340" align="center" fixed="right">
|
||||
<el-table-column :label="t('common.actions')" width="340" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="canEdit(row)"
|
||||
@@ -311,7 +331,7 @@ function canDelete(row: unknown) {
|
||||
plain
|
||||
@click="openEdit(matchId(row))"
|
||||
>
|
||||
编辑
|
||||
{{ t('common.edit') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canDelete(row)"
|
||||
@@ -320,7 +340,7 @@ function canDelete(row: unknown) {
|
||||
plain
|
||||
@click="confirmDelete(row)"
|
||||
>
|
||||
删除
|
||||
{{ t('common.delete') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="matchStatus(row) === 'DRAFT'"
|
||||
@@ -329,7 +349,7 @@ function canDelete(row: unknown) {
|
||||
plain
|
||||
@click="publish(matchId(row))"
|
||||
>
|
||||
发布
|
||||
{{ t('common.publish') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="matchStatus(row) === 'PUBLISHED'"
|
||||
@@ -338,10 +358,10 @@ function canDelete(row: unknown) {
|
||||
plain
|
||||
@click="close(matchId(row))"
|
||||
>
|
||||
封盘
|
||||
{{ t('common.close_betting') }}
|
||||
</el-button>
|
||||
<el-button size="small" type="warning" plain @click="settle(matchId(row))">
|
||||
结算
|
||||
{{ t('common.settle') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -361,87 +381,87 @@ function canDelete(row: unknown) {
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="createVisible" title="新增赛事" width="520px" destroy-on-close>
|
||||
<el-dialog v-model="createVisible" :title="t('match.dialog.create')" width="520px" destroy-on-close>
|
||||
<el-form label-width="96px">
|
||||
<el-form-item label="联赛(英)">
|
||||
<el-input v-model="form.leagueEn" placeholder="FIFA World Cup 2026" />
|
||||
<el-form-item :label="t('match.field.league_en')">
|
||||
<el-input v-model="form.leagueEn" :placeholder="t('match.ph.league_en')" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联赛(中)">
|
||||
<el-input v-model="form.leagueZh" placeholder="2026 世界杯" />
|
||||
<el-form-item :label="t('match.field.league_zh')">
|
||||
<el-input v-model="form.leagueZh" :placeholder="t('match.ph.league_zh')" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开赛时间" required>
|
||||
<el-input v-model="form.startTime" placeholder="2026-06-11T19:00:00Z" />
|
||||
<el-form-item :label="t('match.field.kickoff')" required>
|
||||
<el-input v-model="form.startTime" :placeholder="t('match.ph.kickoff')" />
|
||||
</el-form-item>
|
||||
<el-form-item label="主队(英)">
|
||||
<el-input v-model="form.homeTeamEn" placeholder="Mexico" />
|
||||
<el-form-item :label="t('match.field.home_en')">
|
||||
<el-input v-model="form.homeTeamEn" :placeholder="t('match.ph.home_en')" />
|
||||
</el-form-item>
|
||||
<el-form-item label="主队(中)">
|
||||
<el-input v-model="form.homeTeamZh" placeholder="墨西哥" />
|
||||
<el-form-item :label="t('match.field.home_zh')">
|
||||
<el-input v-model="form.homeTeamZh" :placeholder="t('match.ph.home_zh')" />
|
||||
</el-form-item>
|
||||
<el-form-item label="客队(英)">
|
||||
<el-input v-model="form.awayTeamEn" placeholder="South Africa" />
|
||||
<el-form-item :label="t('match.field.away_en')">
|
||||
<el-input v-model="form.awayTeamEn" :placeholder="t('match.ph.away_en')" />
|
||||
</el-form-item>
|
||||
<el-form-item label="客队(中)">
|
||||
<el-input v-model="form.awayTeamZh" placeholder="南非" />
|
||||
<el-form-item :label="t('match.field.away_zh')">
|
||||
<el-input v-model="form.awayTeamZh" :placeholder="t('match.ph.away_zh')" />
|
||||
</el-form-item>
|
||||
<el-form-item label="热门">
|
||||
<el-form-item :label="t('match.field.featured')">
|
||||
<el-switch v-model="form.isHot" />
|
||||
</el-form-item>
|
||||
<p class="field-hint">创建后为草稿,请在列表点击「发布」并生成盘口。</p>
|
||||
<p class="field-hint">{{ t('match.hint.create_draft') }}</p>
|
||||
</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="520px" destroy-on-close>
|
||||
<el-dialog v-model="editVisible" :title="t('match.dialog.edit')" width="520px" destroy-on-close>
|
||||
<el-form label-width="96px">
|
||||
<p v-if="isEditPublished" class="field-hint edit-hint">
|
||||
已发布:可修改开赛时间、热门及显示名称;封盘/已结算后不可编辑。
|
||||
{{ t('match.hint.edit_published') }}
|
||||
</p>
|
||||
<el-form-item label="联赛(英)">
|
||||
<el-form-item :label="t('match.field.league_en')">
|
||||
<el-input v-model="form.leagueEn" :disabled="isEditPublished" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联赛(中)">
|
||||
<el-form-item :label="t('match.field.league_zh')">
|
||||
<el-input v-model="form.leagueZh" :disabled="isEditPublished" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开赛时间" required>
|
||||
<el-form-item :label="t('match.field.kickoff')" required>
|
||||
<el-input v-model="form.startTime" />
|
||||
</el-form-item>
|
||||
<el-form-item label="主队(英)">
|
||||
<el-form-item :label="t('match.field.home_en')">
|
||||
<el-input v-model="form.homeTeamEn" :disabled="isEditPublished" />
|
||||
</el-form-item>
|
||||
<el-form-item label="主队(中)">
|
||||
<el-form-item :label="t('match.field.home_zh')">
|
||||
<el-input v-model="form.homeTeamZh" :disabled="isEditPublished" />
|
||||
</el-form-item>
|
||||
<el-form-item label="客队(英)">
|
||||
<el-form-item :label="t('match.field.away_en')">
|
||||
<el-input v-model="form.awayTeamEn" :disabled="isEditPublished" />
|
||||
</el-form-item>
|
||||
<el-form-item label="客队(中)">
|
||||
<el-form-item :label="t('match.field.away_zh')">
|
||||
<el-input v-model="form.awayTeamZh" :disabled="isEditPublished" />
|
||||
</el-form-item>
|
||||
<el-form-item label="热门">
|
||||
<el-form-item :label="t('match.field.featured')">
|
||||
<el-switch v-model="form.isHot" />
|
||||
</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('common.save') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="importVisible" title="导入赛事" width="640px" destroy-on-close>
|
||||
<p class="dialog-hint">粘贴含 <code>matches</code> 的 JSON,导入后为草稿,需在列表发布。</p>
|
||||
<el-dialog v-model="importVisible" :title="t('match.dialog.import')" width="640px" destroy-on-close>
|
||||
<p class="dialog-hint">{{ t('match.import_hint') }}</p>
|
||||
<el-input
|
||||
v-model="importJson"
|
||||
type="textarea"
|
||||
:rows="14"
|
||||
placeholder='{"matches":[...]}'
|
||||
:placeholder="t('match.import_json_ph')"
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="importVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="importLoading" @click="submitImport">开始导入</el-button>
|
||||
<el-button @click="importVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="importLoading" @click="submitImport">{{ t('match.import_start') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
@@ -3,14 +3,16 @@ import { ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import api from '../api';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
const route = useRoute();
|
||||
const score = ref({ htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 });
|
||||
const preview = ref<Record<string, unknown> | null>(null);
|
||||
|
||||
async function recordScore() {
|
||||
await api.post(`/admin/matches/${route.params.id}/settlement/score`, score.value);
|
||||
ElMessage.success('比分已录入');
|
||||
ElMessage.success(t('msg.score_recorded'));
|
||||
}
|
||||
|
||||
async function previewSettlement() {
|
||||
@@ -21,21 +23,21 @@ async function previewSettlement() {
|
||||
async function confirm() {
|
||||
if (!preview.value?.batch) return;
|
||||
await api.post(`/admin/settlement/${(preview.value.batch as { id: string }).id}/confirm`);
|
||||
ElMessage.success('结算已确认');
|
||||
ElMessage.success(t('msg.settlement_confirmed'));
|
||||
preview.value = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">赛事结算</h2>
|
||||
<h2 class="page-title">{{ t('page.settlement.title') }}</h2>
|
||||
<span class="page-id"># {{ route.params.id }}</span>
|
||||
</div>
|
||||
|
||||
<el-card class="tool-card" shadow="never">
|
||||
<div class="score-section">
|
||||
<div class="score-block">
|
||||
<div class="score-title">半场比分</div>
|
||||
<div class="score-title">{{ t('settlement.ht_score') }}</div>
|
||||
<div class="score-inputs">
|
||||
<el-input-number v-model="score.htHome" :min="0" controls-position="right" style="width: 100px" />
|
||||
<span class="score-sep">—</span>
|
||||
@@ -43,7 +45,7 @@ async function confirm() {
|
||||
</div>
|
||||
</div>
|
||||
<div class="score-block">
|
||||
<div class="score-title">全场比分</div>
|
||||
<div class="score-title">{{ t('settlement.ft_score') }}</div>
|
||||
<div class="score-inputs">
|
||||
<el-input-number v-model="score.ftHome" :min="0" controls-position="right" style="width: 100px" />
|
||||
<span class="score-sep">—</span>
|
||||
@@ -52,34 +54,34 @@ async function confirm() {
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<el-button @click="recordScore">录入比分</el-button>
|
||||
<el-button type="primary" @click="previewSettlement">生成结算预览</el-button>
|
||||
<el-button @click="recordScore">{{ t('settlement.record_score') }}</el-button>
|
||||
<el-button type="primary" @click="previewSettlement">{{ t('settlement.preview_btn') }}</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="preview" class="preview-card" shadow="never">
|
||||
<div class="preview-title">结算预览</div>
|
||||
<div class="preview-title">{{ t('settlement.preview_title') }}</div>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value">{{ preview.singleBetCount }}</div>
|
||||
<div class="pstat-label">单关注单数</div>
|
||||
<div class="pstat-label">{{ t('settlement.single_count') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value pstat-green">{{ preview.totalPayout }}</div>
|
||||
<div class="pstat-label">预计派彩</div>
|
||||
<div class="pstat-label">{{ t('settlement.est_payout') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value pstat-orange">{{ preview.totalRefund }}</div>
|
||||
<div class="pstat-label">退款金额</div>
|
||||
<div class="pstat-label">{{ t('settlement.refund_amount') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-button type="success" @click="confirm" style="margin-top: 24px">确认结算</el-button>
|
||||
<el-button type="success" @click="confirm" style="margin-top: 24px">{{ t('settlement.confirm_btn') }}</el-button>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { FormValidationError } from '../i18n/form-validation';
|
||||
|
||||
export interface AgentCreateForm {
|
||||
username: string;
|
||||
password: string;
|
||||
@@ -81,10 +83,10 @@ export function editFormFromAgentDetail(d: AgentDetail): AgentEditForm {
|
||||
}
|
||||
|
||||
export function buildCreateAgentPayload(form: AgentCreateForm) {
|
||||
if (!form.username.trim()) throw new Error('请填写用户名');
|
||||
if (form.password.length < 8) throw new Error('密码至少 8 位');
|
||||
if (form.password !== form.confirmPassword) throw new Error('两次密码不一致');
|
||||
if (form.creditLimit < 0) throw new Error('授信额度不能为负');
|
||||
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.creditLimit < 0) throw new FormValidationError('err.credit_negative');
|
||||
return {
|
||||
username: form.username.trim(),
|
||||
password: form.password,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import api from '../../api';
|
||||
import { formatAmount } from '../../utils/format-amount';
|
||||
import { betStatusLabel, betStatusTagType, betTypeLabel } from '../../utils/bet-labels';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
interface BetRow {
|
||||
id: string;
|
||||
betNo: string;
|
||||
@@ -44,23 +47,23 @@ function onSizeChange(size: number) {
|
||||
<template>
|
||||
<div class="admin-list-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">注单查询</h2>
|
||||
<span class="page-desc">下级玩家的全部投注记录</span>
|
||||
<h2 class="page-title">{{ t('page.agent_bets.title') }}</h2>
|
||||
<span class="page-desc">{{ t('page.agent_bets.desc') }}</span>
|
||||
</div>
|
||||
|
||||
<el-card class="data-card" shadow="never">
|
||||
<div class="table-wrap">
|
||||
<el-table :data="bets" stripe>
|
||||
<el-table-column prop="id" label="单号" width="56" align="center" />
|
||||
<el-table-column prop="betNo" label="流水编号" width="168" show-overflow-tooltip>
|
||||
<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>
|
||||
<template #default="{ row }">
|
||||
<span class="bet-no">{{ row.betNo }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="玩家" min-width="100">
|
||||
<el-table-column :label="t('bet.col.player')" min-width="100">
|
||||
<template #default="{ row }">{{ row.user?.username ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="88" align="center">
|
||||
<el-table-column :label="t('common.type')" width="88" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.betType" type="info" size="small" effect="plain">
|
||||
{{ betTypeLabel(row.betType) }}
|
||||
@@ -68,10 +71,10 @@ function onSizeChange(size: number) {
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="投注额" width="110" align="right">
|
||||
<el-table-column :label="t('bet.col.stake')" width="110" align="right">
|
||||
<template #default="{ row }">{{ formatAmount(row.stake) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="96" align="center">
|
||||
<el-table-column :label="t('common.status')" width="96" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="betStatusTagType(row.status)" size="small">
|
||||
{{ betStatusLabel(row.status) }}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import { ref, onMounted } from 'vue';
|
||||
import api from '../../api';
|
||||
import { formatAmount } from '../../utils/format-amount';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
|
||||
const { t, localeTag } = useAdminLocale();
|
||||
const summary = ref<Record<string, unknown>>({});
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -11,55 +13,55 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
function fmtCount(val: unknown) {
|
||||
return (Number(val) || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
|
||||
return (Number(val) || 0).toLocaleString(localeTag.value, { maximumFractionDigits: 0 });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">代理概览</h2>
|
||||
<span class="page-desc">实时数据总览</span>
|
||||
<h2 class="page-title">{{ t('page.agent_dash.title') }}</h2>
|
||||
<span class="page-desc">{{ t('page.agent_dash.desc') }}</span>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="20" class="stat-row">
|
||||
<el-col :span="6">
|
||||
<div class="stat-card c-blue">
|
||||
<div class="stat-top">
|
||||
<span class="stat-label">授信额度</span>
|
||||
<span class="stat-label">{{ t('agent.credit_limit') }}</span>
|
||||
<span class="stat-badge">¥</span>
|
||||
</div>
|
||||
<div class="stat-value">{{ formatAmount((summary.profile as { creditLimit?: string })?.creditLimit) }}</div>
|
||||
<div class="stat-foot">总额度</div>
|
||||
<div class="stat-foot">{{ t('agent.credit_total') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card c-orange">
|
||||
<div class="stat-top">
|
||||
<span class="stat-label">已用额度</span>
|
||||
<span class="stat-label">{{ t('agent.credit_used') }}</span>
|
||||
<span class="stat-badge">¥</span>
|
||||
</div>
|
||||
<div class="stat-value">{{ formatAmount((summary.profile as { usedCredit?: string })?.usedCredit) }}</div>
|
||||
<div class="stat-foot">已占用</div>
|
||||
<div class="stat-foot">{{ t('agent.credit_occupied') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card c-green">
|
||||
<div class="stat-top">
|
||||
<span class="stat-label">直属玩家</span>
|
||||
<span class="stat-badge">人</span>
|
||||
<span class="stat-label">{{ t('agent.direct_players') }}</span>
|
||||
<span class="stat-badge">{{ t('common.people') }}</span>
|
||||
</div>
|
||||
<div class="stat-value">{{ fmtCount(summary.directPlayerCount) }}</div>
|
||||
<div class="stat-foot">玩家数</div>
|
||||
<div class="stat-foot">{{ t('agent.player_count') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card c-purple">
|
||||
<div class="stat-top">
|
||||
<span class="stat-label">今日投注</span>
|
||||
<span class="stat-label">{{ t('agent.today_stake') }}</span>
|
||||
<span class="stat-badge">¥</span>
|
||||
</div>
|
||||
<div class="stat-value">{{ formatAmount(summary.todayStake) }}</div>
|
||||
<div class="stat-foot">人民币</div>
|
||||
<div class="stat-foot">{{ t('agent.currency_cny') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -78,27 +80,13 @@ function fmtCount(val: unknown) {
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.stat-card:hover { transform: translateY(-2px); border-color: #2a2a2a; }
|
||||
.stat-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; }
|
||||
.stat-label { font-size: 11px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }
|
||||
.stat-badge { width: 30px; height: 30px; border-radius: 7px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; }
|
||||
.stat-value { font-size: 28px; font-weight: 800; line-height: 1; margin-bottom: 8px; letter-spacing: -1px; }
|
||||
.stat-foot { font-size: 11px; opacity: 0.4; font-weight: 600; letter-spacing: 0.04em; }
|
||||
|
||||
.c-blue { background: rgba(22,78,180,0.1); color: #60a5fa; }
|
||||
.c-blue .stat-badge { background: rgba(96,165,250,0.12); color: #60a5fa; }
|
||||
.c-orange { background: rgba(180,80,0,0.1); color: #fb923c; }
|
||||
.c-orange .stat-badge { background: rgba(251,146,60,0.12); color: #fb923c; }
|
||||
.c-green {
|
||||
background: linear-gradient(145deg, rgba(36, 143, 84, 0.18), rgba(20, 92, 56, 0.08));
|
||||
border: 1px solid var(--green-border);
|
||||
color: var(--green-text);
|
||||
}
|
||||
.c-green .stat-badge {
|
||||
background: var(--primary-grad);
|
||||
border: 1px solid var(--green-border);
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.c-purple { background: rgba(100,40,200,0.1); color: #a78bfa; }
|
||||
.c-purple .stat-badge { background: rgba(167,139,250,0.12); color: #a78bfa; }
|
||||
.stat-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.stat-label { font-size: 12px; color: #666; font-weight: 600; }
|
||||
.stat-badge { font-size: 11px; color: #444; }
|
||||
.stat-value { font-size: 26px; font-weight: 800; color: var(--green-text); margin-bottom: 6px; }
|
||||
.stat-foot { font-size: 11px; color: #444; }
|
||||
.c-blue { background: linear-gradient(135deg, rgba(96, 165, 250, 0.08), transparent); }
|
||||
.c-orange { background: linear-gradient(135deg, rgba(251, 146, 60, 0.08), transparent); }
|
||||
.c-green { background: linear-gradient(135deg, rgba(36, 143, 84, 0.1), transparent); }
|
||||
.c-purple { background: linear-gradient(135deg, rgba(167, 139, 250, 0.08), transparent); }
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import api from '../../api';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { formatAmount, formatAmountFull } from '../../utils/format-amount';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
const players = ref<unknown[]>([]);
|
||||
const form = ref({ username: '', password: 'Player@123' });
|
||||
const depositForm = ref({ playerId: '', amount: 100, requestId: '' });
|
||||
@@ -17,7 +20,7 @@ async function load() {
|
||||
|
||||
async function create() {
|
||||
await api.post('/agent/players', form.value);
|
||||
ElMessage.success('玩家已创建');
|
||||
ElMessage.success(t('msg.player_created'));
|
||||
load();
|
||||
}
|
||||
|
||||
@@ -27,7 +30,7 @@ async function deposit() {
|
||||
amount: depositForm.value.amount,
|
||||
requestId: depositForm.value.requestId,
|
||||
});
|
||||
ElMessage.success('上分成功');
|
||||
ElMessage.success(t('msg.topup_ok'));
|
||||
load();
|
||||
}
|
||||
|
||||
@@ -36,75 +39,77 @@ async function withdraw(playerId: string, amount: number) {
|
||||
amount,
|
||||
requestId: `wd-${Date.now()}`,
|
||||
});
|
||||
ElMessage.success('下分成功');
|
||||
ElMessage.success(t('msg.withdraw_ok'));
|
||||
load();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-list-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">直属玩家</h2>
|
||||
<span class="page-desc">管理你名下的直属玩家</span>
|
||||
</div>
|
||||
|
||||
<el-card class="tool-card" shadow="never">
|
||||
<div class="tool-row">
|
||||
<div class="tool-section">
|
||||
<div class="tool-section-title">创建玩家</div>
|
||||
<el-form inline>
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.username" placeholder="输入用户名" style="width: 150px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="create">+ 创建玩家</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="tool-divider" />
|
||||
<div class="tool-section">
|
||||
<div class="tool-section-title">上分操作</div>
|
||||
<el-form inline>
|
||||
<el-form-item label="玩家ID">
|
||||
<el-input v-model="depositForm.playerId" placeholder="玩家ID" style="width: 110px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="金额">
|
||||
<el-input-number v-model="depositForm.amount" :min="1" style="width: 130px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="success" @click="deposit">上分</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">{{ t('page.agent_players.title') }}</h2>
|
||||
<span class="page-desc">{{ t('page.agent_players.desc') }}</span>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="data-card" shadow="never">
|
||||
<div class="table-wrap">
|
||||
<el-table :data="players" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="username" label="用户名" min-width="120" />
|
||||
<el-table-column label="可用余额" min-width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="(row as { wallet?: { availableBalance: string } }).wallet?.availableBalance != null">
|
||||
<el-tooltip
|
||||
:content="formatAmountFull((row as { wallet: { availableBalance: string } }).wallet.availableBalance)"
|
||||
placement="top"
|
||||
>
|
||||
<span>{{ formatAmount((row as { wallet: { availableBalance: string } }).wallet.availableBalance) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="warning" plain @click="withdraw((row as { id: string }).id, 50)">下分 50</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card class="tool-card" shadow="never">
|
||||
<div class="tool-row">
|
||||
<div class="tool-section">
|
||||
<div class="tool-section-title">{{ t('agent_portal.create_player_section') }}</div>
|
||||
<el-form inline>
|
||||
<el-form-item :label="t('user.col.username')">
|
||||
<el-input v-model="form.username" :placeholder="t('agent_portal.username_ph')" style="width: 150px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="create">{{ t('agent_portal.create_player_btn') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="tool-divider" />
|
||||
<div class="tool-section">
|
||||
<div class="tool-section-title">{{ t('agent_portal.deposit_section') }}</div>
|
||||
<el-form inline>
|
||||
<el-form-item :label="t('user.field.player_id')">
|
||||
<el-input v-model="depositForm.playerId" :placeholder="t('agent_portal.player_id_ph')" style="width: 110px" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.amount')">
|
||||
<el-input-number v-model="depositForm.amount" :min="1" style="width: 130px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="success" @click="deposit">{{ t('common.topup') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="data-card" shadow="never">
|
||||
<div class="table-wrap">
|
||||
<el-table :data="players" stripe>
|
||||
<el-table-column prop="id" :label="t('common.col_id')" width="80" />
|
||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
|
||||
<el-table-column :label="t('user.field.available')" min-width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="(row as { wallet?: { availableBalance: string } }).wallet?.availableBalance != null">
|
||||
<el-tooltip
|
||||
:content="formatAmountFull((row as { wallet: { availableBalance: string } }).wallet.availableBalance)"
|
||||
placement="top"
|
||||
>
|
||||
<span>{{ formatAmount((row as { wallet: { availableBalance: string } }).wallet.availableBalance) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.actions')" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="warning" plain @click="withdraw((row as { id: string }).id, 50)">
|
||||
{{ t('agent_portal.withdraw_btn', { amount: 50 }) }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import api from '../../api';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { formatAmount, formatAmountFull } from '../../utils/format-amount';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
const agents = ref<unknown[]>([]);
|
||||
const form = ref({ username: '', password: 'Agent@123', creditLimit: 10000 });
|
||||
|
||||
@@ -16,57 +19,57 @@ async function load() {
|
||||
|
||||
async function create() {
|
||||
await api.post('/agent/agents', form.value);
|
||||
ElMessage.success('下级代理已创建');
|
||||
ElMessage.success(t('msg.agent_sub_created'));
|
||||
load();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-list-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">下级代理</h2>
|
||||
<span class="page-desc">仅一级代理可见</span>
|
||||
</div>
|
||||
|
||||
<el-card class="tool-card" shadow="never">
|
||||
<el-form inline>
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.username" placeholder="代理用户名" style="width: 150px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="授信额度">
|
||||
<el-input-number v-model="form.creditLimit" :min="0" :step="1000" style="width: 150px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="create">+ 创建二级代理</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="data-card" shadow="never">
|
||||
<div class="table-wrap">
|
||||
<el-table :data="agents" stripe>
|
||||
<el-table-column label="用户名" min-width="140">
|
||||
<template #default="{ row }">
|
||||
{{ (row as { user?: { username: string } }).user?.username }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="授信额度" min-width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="formatAmountFull((row as { creditLimit: string }).creditLimit)" placement="top">
|
||||
<span>{{ formatAmount((row as { creditLimit: string }).creditLimit) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已用额度" min-width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="formatAmountFull((row as { usedCredit: string }).usedCredit)" placement="top">
|
||||
<span>{{ formatAmount((row as { usedCredit: string }).usedCredit) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">{{ t('page.agent_sub.title') }}</h2>
|
||||
<span class="page-desc">{{ t('page.agent_sub.desc') }}</span>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="tool-card" shadow="never">
|
||||
<el-form inline>
|
||||
<el-form-item :label="t('user.col.username')">
|
||||
<el-input v-model="form.username" :placeholder="t('agent_portal.agent_username_ph')" style="width: 150px" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('agent.field.credit_limit')">
|
||||
<el-input-number v-model="form.creditLimit" :min="0" :step="1000" style="width: 150px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="create">{{ t('agent_portal.create_tier2_btn') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="data-card" shadow="never">
|
||||
<div class="table-wrap">
|
||||
<el-table :data="agents" stripe>
|
||||
<el-table-column :label="t('user.col.username')" min-width="140">
|
||||
<template #default="{ row }">
|
||||
{{ (row as { user?: { username: string } }).user?.username }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('agent.field.credit_limit')" min-width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="formatAmountFull((row as { creditLimit: string }).creditLimit)" placement="top">
|
||||
<span>{{ formatAmount((row as { creditLimit: string }).creditLimit) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('agent.field.used_credit')" min-width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="formatAmountFull((row as { usedCredit: string }).usedCredit)" placement="top">
|
||||
<span>{{ formatAmount((row as { usedCredit: string }).usedCredit) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** 后台手动新增赛事(投注平台最小字段) */
|
||||
|
||||
import { FormValidationError } from '../i18n/form-validation';
|
||||
|
||||
export interface MatchCreateForm {
|
||||
leagueEn: string;
|
||||
leagueZh: string;
|
||||
@@ -54,15 +56,15 @@ export function formFromDetail(d: AdminMatchDetail): MatchCreateForm {
|
||||
|
||||
export function buildPlatformPayload(form: MatchCreateForm) {
|
||||
if (!form.startTime.trim()) {
|
||||
throw new Error('请填写开赛时间');
|
||||
throw new FormValidationError('err.kickoff_required');
|
||||
}
|
||||
const homeOk = form.homeTeamZh.trim() || form.homeTeamEn.trim();
|
||||
const awayOk = form.awayTeamZh.trim() || form.awayTeamEn.trim();
|
||||
if (!homeOk || !awayOk) {
|
||||
throw new Error('请填写主客队名称(中文或英文至少一项)');
|
||||
throw new FormValidationError('err.teams_required');
|
||||
}
|
||||
if (!form.leagueZh.trim() && !form.leagueEn.trim()) {
|
||||
throw new Error('请填写联赛名称');
|
||||
throw new FormValidationError('err.league_required');
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { FormValidationError } from '../i18n/form-validation';
|
||||
|
||||
export interface PlayerCreateForm {
|
||||
username: string;
|
||||
password: string;
|
||||
@@ -105,9 +107,9 @@ export function editFormFromDetail(d: PlayerDetail): PlayerEditForm {
|
||||
}
|
||||
|
||||
export function buildCreatePlayerPayload(form: PlayerCreateForm) {
|
||||
if (!form.username.trim()) throw new Error('请填写用户名');
|
||||
if (form.password.length < 8) throw new Error('密码至少 8 位');
|
||||
if (form.password !== form.confirmPassword) throw new Error('两次密码不一致');
|
||||
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');
|
||||
return {
|
||||
username: form.username.trim(),
|
||||
password: form.password,
|
||||
|
||||
7
apps/api/scripts/test-db.js
Normal file
7
apps/api/scripts/test-db.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const p = new PrismaClient();
|
||||
p.match
|
||||
.findMany({ take: 1, include: { homeTeam: true, awayTeam: true } })
|
||||
.then((r) => console.log('OK', r.length))
|
||||
.catch((e) => console.error('ERR', e.message))
|
||||
.finally(() => p.$disconnect());
|
||||
@@ -372,6 +372,9 @@ export class AgentsService {
|
||||
cashbackRate?: number;
|
||||
},
|
||||
) {
|
||||
if (data.level !== 1 && data.level !== 2) {
|
||||
throw new BadRequestException('Agent level must be 1 or 2');
|
||||
}
|
||||
if (data.level === 2 && !data.parentAgentId) {
|
||||
throw new BadRequestException('Level 2 agent requires parent');
|
||||
}
|
||||
|
||||
@@ -4,8 +4,13 @@ import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { WalletService } from '../ledger/wallet.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { generateBetNo } from '../../shared/common/decorators';
|
||||
import { isQuarterHandicapOrTotal } from '../settlement/domain/settlement-calculator';
|
||||
import { PARLAY_MIN_LEGS, PARLAY_MAX_LEGS } from '@thebet365/shared';
|
||||
import {
|
||||
PARLAY_MIN_LEGS,
|
||||
PARLAY_MAX_LEGS,
|
||||
canSelectForParlay,
|
||||
isPreMatchKickoff,
|
||||
isSupportedSport,
|
||||
} from '@thebet365/shared';
|
||||
|
||||
interface BetSelectionInput {
|
||||
selectionId: bigint;
|
||||
@@ -20,7 +25,11 @@ export class BetsService {
|
||||
private wallet: WalletService,
|
||||
) {}
|
||||
|
||||
private async validateSelection(selectionId: bigint, oddsVersion: bigint) {
|
||||
private async validateSelection(
|
||||
selectionId: bigint,
|
||||
oddsVersion: bigint,
|
||||
options?: { forParlay?: boolean },
|
||||
) {
|
||||
const selection = await this.prisma.marketSelection.findUnique({
|
||||
where: { id: selectionId },
|
||||
include: { market: { include: { match: true } } },
|
||||
@@ -32,10 +41,35 @@ export class BetsService {
|
||||
if (selection.market.match.status !== 'PUBLISHED') {
|
||||
throw new BadRequestException('Match not available for betting');
|
||||
}
|
||||
if (!isSupportedSport(selection.market.match.sportType)) {
|
||||
throw new BadRequestException('Only football betting is supported');
|
||||
}
|
||||
if (!selection.market.match.isOutright && !isPreMatchKickoff(selection.market.match.startTime)) {
|
||||
throw new BadRequestException('Pre-match betting only; match has started');
|
||||
}
|
||||
if (selection.oddsVersion !== oddsVersion) {
|
||||
throw new BadRequestException('Odds changed, please confirm again');
|
||||
}
|
||||
|
||||
if (options?.forParlay) {
|
||||
const line = selection.market.lineValue ? Number(selection.market.lineValue) : null;
|
||||
const check = canSelectForParlay({
|
||||
marketType: selection.market.marketType,
|
||||
lineValue: line,
|
||||
allowParlay: selection.market.allowParlay,
|
||||
isOutright: selection.market.match.isOutright,
|
||||
});
|
||||
if (!check.ok) {
|
||||
const msg =
|
||||
check.reason === 'OUTRIGHT'
|
||||
? 'Outright cannot be in parlay'
|
||||
: check.reason === 'QUARTER_LINE'
|
||||
? 'Quarter line markets cannot be in parlay'
|
||||
: 'Market not allowed in parlay';
|
||||
throw new BadRequestException(msg);
|
||||
}
|
||||
}
|
||||
|
||||
return selection;
|
||||
}
|
||||
|
||||
@@ -54,7 +88,7 @@ export class BetsService {
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
const selection = await this.validateSelection(selectionId, oddsVersion);
|
||||
const selection = await this.validateSelection(selectionId, oddsVersion, { forParlay: false });
|
||||
const odds = new Decimal(selection.odds.toString());
|
||||
const stakeDec = new Decimal(stake);
|
||||
const potentialReturn = stakeDec.mul(odds);
|
||||
@@ -117,21 +151,7 @@ export class BetsService {
|
||||
const matchIds = new Set<string>();
|
||||
|
||||
for (const leg of legs) {
|
||||
const sel = await this.validateSelection(leg.selectionId, leg.oddsVersion);
|
||||
|
||||
if (sel.market.marketType === 'OUTRIGHT_WINNER') {
|
||||
throw new BadRequestException('Outright cannot be in parlay');
|
||||
}
|
||||
|
||||
const line = sel.market.lineValue ? Number(sel.market.lineValue) : null;
|
||||
if (
|
||||
['FT_HANDICAP', 'HT_HANDICAP', 'FT_OVER_UNDER', 'HT_OVER_UNDER'].includes(
|
||||
sel.market.marketType,
|
||||
) &&
|
||||
isQuarterHandicapOrTotal(line)
|
||||
) {
|
||||
throw new BadRequestException('Quarter line markets cannot be in parlay');
|
||||
}
|
||||
const sel = await this.validateSelection(leg.selectionId, leg.oddsVersion, { forParlay: true });
|
||||
|
||||
const matchKey = sel.market.matchId.toString();
|
||||
if (matchIds.has(matchKey)) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { resolveTranslationFallback } from '@thebet365/shared';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
@@ -487,7 +488,7 @@ export class MatchesService {
|
||||
const map = Object.fromEntries(
|
||||
translations.filter((t) => t.fieldName === 'name').map((t) => [t.locale, t.value]),
|
||||
);
|
||||
return map[locale] || map['en-US'] || map['zh-CN'] || Object.values(map)[0] || '';
|
||||
return resolveTranslationFallback(map, locale);
|
||||
}
|
||||
|
||||
async enrichMatch(match: Record<string, unknown>, locale: string) {
|
||||
@@ -518,10 +519,13 @@ export class MatchesService {
|
||||
}
|
||||
|
||||
async listPublished(locale = 'en-US', leagueId?: bigint) {
|
||||
const now = new Date();
|
||||
const matches = await this.prisma.match.findMany({
|
||||
where: {
|
||||
status: 'PUBLISHED',
|
||||
isOutright: false,
|
||||
sportType: 'FOOTBALL',
|
||||
startTime: { gt: now },
|
||||
...(leagueId ? { leagueId } : {}),
|
||||
},
|
||||
include: {
|
||||
@@ -553,12 +557,15 @@ export class MatchesService {
|
||||
},
|
||||
});
|
||||
if (!match) throw new NotFoundException('Match not found');
|
||||
if (match.sportType !== 'FOOTBALL') {
|
||||
throw new NotFoundException('Match not found');
|
||||
}
|
||||
return this.enrichMatch(match, locale);
|
||||
}
|
||||
|
||||
async listOutrights(locale = 'en-US') {
|
||||
const matches = await this.prisma.match.findMany({
|
||||
where: { status: 'PUBLISHED', isOutright: true },
|
||||
where: { status: 'PUBLISHED', isOutright: true, sportType: 'FOOTBALL' },
|
||||
include: {
|
||||
markets: {
|
||||
where: { marketType: 'OUTRIGHT_WINNER', status: 'OPEN' },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { SUPPORTED_LOCALES } from '@thebet365/shared';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { AgentsService } from '../agent/agents.service';
|
||||
|
||||
@@ -92,6 +93,9 @@ export class UsersService {
|
||||
}
|
||||
|
||||
async updateLocale(userId: bigint, locale: string) {
|
||||
if (!(SUPPORTED_LOCALES as readonly string[]).includes(locale)) {
|
||||
throw new BadRequestException('Unsupported locale');
|
||||
}
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { locale },
|
||||
|
||||
@@ -156,6 +156,12 @@ export class MarketsService {
|
||||
odds: 6.0,
|
||||
})),
|
||||
},
|
||||
OUTRIGHT_WINNER: {
|
||||
period: 'OUTRIGHT',
|
||||
allowParlay: false,
|
||||
sortOrder: 1,
|
||||
selections: [],
|
||||
},
|
||||
};
|
||||
|
||||
const config = configs[marketType];
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../shared/prisma/prisma.service';
|
||||
|
||||
function pickContentTranslation<T extends { locale: string }>(
|
||||
translations: T[],
|
||||
locale: string,
|
||||
): T | undefined {
|
||||
const chain = [locale, 'en-US', 'zh-CN'];
|
||||
for (const loc of chain) {
|
||||
const hit = translations.find((tr) => tr.locale === loc);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return translations[0];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContentService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
@@ -19,10 +31,7 @@ export class ContentService {
|
||||
});
|
||||
|
||||
return items.map((item) => {
|
||||
const t =
|
||||
item.translations.find((tr) => tr.locale === locale) ||
|
||||
item.translations.find((tr) => tr.locale === 'en-US') ||
|
||||
item.translations[0];
|
||||
const t = pickContentTranslation(item.translations, locale);
|
||||
return { ...item, translation: t };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../shared/prisma/prisma.service';
|
||||
import { DEFAULT_LOCALE } from '@thebet365/shared';
|
||||
|
||||
const FALLBACK_ORDER = ['en-US', 'zh-CN', 'ms-MY'];
|
||||
import { resolveTranslationFallback } from '@thebet365/shared';
|
||||
|
||||
@Injectable()
|
||||
export class I18nService {
|
||||
@@ -10,7 +8,7 @@ export class I18nService {
|
||||
|
||||
async getMessages(locale: string) {
|
||||
const messages = await this.prisma.i18nMessage.findMany({
|
||||
where: { locale: { in: [locale, ...FALLBACK_ORDER] } },
|
||||
where: { locale: { in: [locale, 'en-US', 'zh-CN', 'ms-MY'] } },
|
||||
});
|
||||
|
||||
const byKey: Record<string, Record<string, string>> = {};
|
||||
@@ -21,10 +19,8 @@ export class I18nService {
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, locales] of Object.entries(byKey)) {
|
||||
result[key] =
|
||||
locales[locale] ||
|
||||
FALLBACK_ORDER.map((l) => locales[l]).find(Boolean) ||
|
||||
key;
|
||||
const resolved = resolveTranslationFallback(locales, locale);
|
||||
result[key] = resolved || key;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -259,10 +259,7 @@ export function calculateParlayPayout(
|
||||
return { betResult: 'WON', payout: s.mul(combinedOdds), effectiveOdds: combinedOdds };
|
||||
}
|
||||
|
||||
export function isQuarterHandicapOrTotal(line: number | null | undefined): boolean {
|
||||
if (line == null) return false;
|
||||
return isQuarterLine(line);
|
||||
}
|
||||
export { isQuarterHandicapOrTotal } from '@thebet365/shared';
|
||||
|
||||
export const FT_CORRECT_SCORE_TEMPLATE = [
|
||||
'SCORE_0_0', 'SCORE_1_1', 'SCORE_2_2', 'SCORE_3_3', 'SCORE_4_4', 'OTHER_DRAW',
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ items: string[]; embedded?: boolean }>(),
|
||||
@@ -15,7 +18,7 @@ const text = computed(() => {
|
||||
|
||||
<template>
|
||||
<div v-if="text" class="marquee-bar" :class="{ embedded }">
|
||||
<span class="marquee-badge">公告</span>
|
||||
<span class="marquee-badge">{{ t('home.announcement_badge') }}</span>
|
||||
<div class="marquee-viewport">
|
||||
<div class="marquee-track">
|
||||
<span class="marquee-text">{{ text }}</span>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
import defaultBannerImg from '../assets/images/banner.png';
|
||||
|
||||
export interface BannerItem {
|
||||
@@ -31,7 +34,7 @@ function onImgError(e: Event) {
|
||||
}
|
||||
|
||||
function title(banner: BannerItem) {
|
||||
return banner.translation?.title || 'Banner';
|
||||
return banner.translation?.title || t('home.banner_fallback');
|
||||
}
|
||||
|
||||
function goTo(index: number) {
|
||||
@@ -130,8 +133,8 @@ onUnmounted(stopAutoPlay);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button v-if="banners.length > 1" class="nav prev" type="button" aria-label="上一张" @click.stop="prev">‹</button>
|
||||
<button v-if="banners.length > 1" class="nav next" type="button" aria-label="下一张" @click.stop="next">›</button>
|
||||
<button v-if="banners.length > 1" class="nav prev" type="button" :aria-label="t('home.banner_prev')" @click.stop="prev">‹</button>
|
||||
<button v-if="banners.length > 1" class="nav next" type="button" :aria-label="t('home.banner_next')" @click.stop="next">›</button>
|
||||
|
||||
<div v-if="banners.length > 1" class="dots">
|
||||
<button
|
||||
@@ -140,7 +143,7 @@ onUnmounted(stopAutoPlay);
|
||||
type="button"
|
||||
class="dot"
|
||||
:class="{ active: i === active }"
|
||||
:aria-label="`第 ${i + 1} 张`"
|
||||
:aria-label="t('home.banner_slide', { n: i + 1 })"
|
||||
@click.stop="goTo(i)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
172
apps/player/src/components/BetGuideHelp.vue
Normal file
172
apps/player/src/components/BetGuideHelp.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string;
|
||||
ariaLabel?: string;
|
||||
/** 设置后首次自动弹出,关闭时写入 localStorage */
|
||||
storageKey?: string;
|
||||
}>(),
|
||||
{},
|
||||
);
|
||||
|
||||
const { t } = useI18n();
|
||||
const open = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.storageKey && !localStorage.getItem(props.storageKey)) {
|
||||
open.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
function close() {
|
||||
open.value = false;
|
||||
if (props.storageKey) {
|
||||
localStorage.setItem(props.storageKey, '1');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="help-btn"
|
||||
:aria-label="ariaLabel ?? t('bet.guide_help_aria')"
|
||||
:title="ariaLabel ?? t('bet.guide_help_aria')"
|
||||
@click="open = true"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="overlay" @click.self="close">
|
||||
<div class="modal" role="dialog" aria-modal="true" :aria-label="title">
|
||||
<button type="button" class="close-x" :aria-label="t('bet.cancel')" @click="close">✕</button>
|
||||
|
||||
<h3 class="title">{{ title }}</h3>
|
||||
|
||||
<div class="guide-body">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-ok btn-gold-outline" @click="close">
|
||||
{{ t('bet.guide_got_it') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.help-btn {
|
||||
flex-shrink: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
color: var(--primary-light);
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 205;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
background: linear-gradient(165deg, #1a1810 0%, #121212 45%, #0a0a0a 100%);
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 14px 14px;
|
||||
box-shadow: var(--shadow), 0 0 24px rgba(212, 175, 55, 0.08);
|
||||
}
|
||||
|
||||
.close-x {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
padding-right: 24px;
|
||||
}
|
||||
|
||||
.guide-body {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.guide-body :deep(.flow-name) {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.guide-body :deep(ol) {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.guide-body :deep(li + li) {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.guide-body :deep(.intro) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.guide-body :deep(.flow-block) {
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.guide-body :deep(.rules-link) {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.btn-ok {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { PARLAY_MIN_LEGS, PARLAY_MAX_LEGS } from '@thebet365/shared';
|
||||
import { useBetSlipStore } from '../stores/betSlip';
|
||||
import api from '../api';
|
||||
|
||||
@@ -29,11 +30,19 @@ async function placeBet() {
|
||||
success.value = '';
|
||||
|
||||
try {
|
||||
if (slip.mode === 'parlay' && slip.items.length >= 2) {
|
||||
if (slip.mode === 'parlay' && slip.items.length >= PARLAY_MIN_LEGS) {
|
||||
if (slip.hasSameMatch) {
|
||||
error.value = t('bet.parlay_same_match');
|
||||
return;
|
||||
}
|
||||
if (slip.items.length > PARLAY_MAX_LEGS) {
|
||||
error.value = t('bet.parlay_max_legs');
|
||||
return;
|
||||
}
|
||||
if (!slip.canPlaceParlay) {
|
||||
error.value = t('bet.parlay_need_more');
|
||||
return;
|
||||
}
|
||||
await api.post('/player/bets/parlay', {
|
||||
legs: slip.items.map((i) => ({
|
||||
selectionId: i.selectionId,
|
||||
@@ -110,7 +119,7 @@ async function placeBet() {
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
:disabled="loading || !slip.items.length"
|
||||
:disabled="loading || !slip.items.length || (slip.mode === 'parlay' && !slip.canPlaceParlay)"
|
||||
@click="placeBet"
|
||||
>
|
||||
{{ loading ? t('bet.placing') : t('bet.place_bet') }}
|
||||
|
||||
@@ -89,12 +89,14 @@ function close() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 6px 4px 6px;
|
||||
height: 36px;
|
||||
padding: 0 8px 0 8px;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: #141414;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.chip-body {
|
||||
@@ -105,21 +107,21 @@ function close() {
|
||||
}
|
||||
|
||||
.chip-label {
|
||||
font-size: 9px;
|
||||
font-size: 8px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.chip-amount {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
text-shadow: none;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
|
||||
65
apps/player/src/components/LocaleSwitcher.vue
Normal file
65
apps/player/src/components/LocaleSwitcher.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import LocaleFlag from './LocaleFlag.vue';
|
||||
import { useAppLocale } from '../composables/useAppLocale';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
compact?: boolean;
|
||||
}>(),
|
||||
{ compact: false },
|
||||
);
|
||||
|
||||
const { locale } = useI18n();
|
||||
const { locales, setLocale } = useAppLocale();
|
||||
|
||||
async function onChange(code: string) {
|
||||
await setLocale(code);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="locale-switch" :class="{ compact }">
|
||||
<LocaleFlag :locale="locale" :size="compact ? 16 : 18" />
|
||||
<select
|
||||
:value="locale"
|
||||
class="locale-select"
|
||||
:aria-label="compact ? 'Language' : undefined"
|
||||
@change="onChange(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option v-for="l in locales" :key="l.code" :value="l.code">
|
||||
{{ l.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.locale-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 36px;
|
||||
padding: 0 8px 0 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: #0d0d0d;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.locale-switch.compact {
|
||||
height: auto;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
.locale-select {
|
||||
background: transparent;
|
||||
color: var(--primary-light);
|
||||
border: none;
|
||||
padding: 2px 2px 2px 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
outline: none;
|
||||
max-width: 120px;
|
||||
}
|
||||
</style>
|
||||
@@ -57,8 +57,9 @@ function logout() {
|
||||
}
|
||||
|
||||
.avatar-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
background: linear-gradient(145deg, #2a2210, #141008);
|
||||
|
||||
@@ -1,156 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import BetGuideHelp from '../BetGuideHelp.vue';
|
||||
|
||||
const GUIDE_SEEN_KEY = 'thebet365_match_bet_guide_seen';
|
||||
|
||||
const { t } = useI18n();
|
||||
const open = ref(false);
|
||||
|
||||
function close() {
|
||||
open.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="help-btn"
|
||||
:aria-label="t('bet.guide_help_aria')"
|
||||
:title="t('bet.guide_help_aria')"
|
||||
@click="open = true"
|
||||
<BetGuideHelp
|
||||
:title="t('bet.guide_title')"
|
||||
:storage-key="GUIDE_SEEN_KEY"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="overlay" @click.self="close">
|
||||
<div class="modal" role="dialog" aria-modal="true" :aria-label="t('bet.guide_title')">
|
||||
<button type="button" class="close-x" :aria-label="t('bet.cancel')" @click="close">✕</button>
|
||||
|
||||
<h3 class="title">{{ t('bet.guide_title') }}</h3>
|
||||
|
||||
<div class="guide-body">
|
||||
<div class="flow">
|
||||
<p class="flow-name">{{ t('bet.guide_flow_normal') }}</p>
|
||||
<ol>
|
||||
<li>{{ t('bet.guide_normal_1') }}</li>
|
||||
<li>{{ t('bet.guide_normal_2') }}</li>
|
||||
<li>{{ t('bet.guide_normal_3') }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="flow flow--cs">
|
||||
<p class="flow-name">{{ t('bet.guide_flow_cs') }}</p>
|
||||
<ol>
|
||||
<li>{{ t('bet.guide_cs_1') }}</li>
|
||||
<li>{{ t('bet.guide_cs_2') }}</li>
|
||||
<li>{{ t('bet.guide_cs_3') }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-ok btn-gold-outline" @click="close">
|
||||
{{ t('bet.guide_got_it') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flow">
|
||||
<p class="flow-name">{{ t('bet.guide_flow_normal') }}</p>
|
||||
<ol>
|
||||
<li>{{ t('bet.guide_normal_1') }}</li>
|
||||
<li>{{ t('bet.guide_normal_2') }}</li>
|
||||
<li>{{ t('bet.guide_normal_3') }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</Teleport>
|
||||
<div class="flow flow-block">
|
||||
<p class="flow-name">{{ t('bet.guide_flow_cs') }}</p>
|
||||
<ol>
|
||||
<li>{{ t('bet.guide_cs_1') }}</li>
|
||||
<li>{{ t('bet.guide_cs_2') }}</li>
|
||||
<li>{{ t('bet.guide_cs_3') }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="flow flow-block">
|
||||
<p class="flow-name">{{ t('bet.guide_flow_parlay') }}</p>
|
||||
<p class="intro">{{ t('bet.guide_parlay_1') }}</p>
|
||||
</div>
|
||||
<p class="rules-link">{{ t('bet.guide_rules_link') }}</p>
|
||||
</BetGuideHelp>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.help-btn {
|
||||
flex-shrink: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
color: var(--primary-light);
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 205;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
background: linear-gradient(165deg, #1a1810 0%, #121212 45%, #0a0a0a 100%);
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 14px 14px;
|
||||
box-shadow: var(--shadow), 0 0 24px rgba(212, 175, 55, 0.08);
|
||||
}
|
||||
|
||||
.close-x {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
padding-right: 24px;
|
||||
}
|
||||
|
||||
.guide-body {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.flow-name {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.flow ol {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.flow li + li {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.flow--cs {
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-ok {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,9 @@ import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import api from '../../api';
|
||||
import { useBetSlipStore } from '../../stores/betSlip';
|
||||
import { PARLAY_MARKET_TYPES, SELECTION_SHORT } from '../../utils/parlayColumns';
|
||||
import { PARLAY_MAX_LEGS, canSelectForParlay } from '@thebet365/shared';
|
||||
import { PARLAY_MARKET_TYPES, PARLAY_SELECTION_KEYS } from '../../utils/parlayColumns';
|
||||
import BetGuideHelp from '../BetGuideHelp.vue';
|
||||
|
||||
type TimeFilter = 'all' | 'today';
|
||||
|
||||
@@ -18,6 +20,8 @@ interface Selection {
|
||||
interface Market {
|
||||
id: string;
|
||||
marketType: string;
|
||||
lineValue?: string | number | null;
|
||||
allowParlay?: boolean;
|
||||
selections: Selection[];
|
||||
}
|
||||
|
||||
@@ -51,10 +55,25 @@ onMounted(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
function parseLine(v: string | number | null | undefined) {
|
||||
if (v == null || v === '') return null;
|
||||
const n = typeof v === 'number' ? v : parseFloat(String(v));
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function isParlayEligibleMarket(market: Market) {
|
||||
if (!market.selections.length) return false;
|
||||
return canSelectForParlay({
|
||||
marketType: market.marketType,
|
||||
lineValue: parseLine(market.lineValue),
|
||||
allowParlay: market.allowParlay ?? true,
|
||||
}).ok;
|
||||
}
|
||||
|
||||
function hasParlayMarkets(m: ParlayMatch) {
|
||||
return parlayMarketKeys.some((key) => {
|
||||
const market = m.markets?.find((mk) => mk.marketType === key);
|
||||
return market && market.selections.length > 0;
|
||||
return market && isParlayEligibleMarket(market);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -98,15 +117,23 @@ function formatOdds(odds: string) {
|
||||
}
|
||||
|
||||
function selLabel(sel: Selection) {
|
||||
return SELECTION_SHORT[sel.selectionCode] ?? sel.selectionName.slice(0, 2);
|
||||
const key = PARLAY_SELECTION_KEYS[sel.selectionCode];
|
||||
if (key) return t(`bet.${key}`);
|
||||
return sel.selectionName.slice(0, 2);
|
||||
}
|
||||
|
||||
function colLabel(labelKey: string) {
|
||||
return t(`bet.${labelKey}`);
|
||||
}
|
||||
|
||||
function isPicked(selectionId: string) {
|
||||
return slip.items.some((i) => i.selectionId === selectionId);
|
||||
}
|
||||
|
||||
const parlayHint = ref('');
|
||||
|
||||
function pickSelection(match: ParlayMatch, market: Market, sel: Selection) {
|
||||
slip.addParlayLeg({
|
||||
const err = slip.addParlayLeg({
|
||||
selectionId: sel.id,
|
||||
oddsVersion: String(sel.oddsVersion),
|
||||
matchId: match.id,
|
||||
@@ -114,7 +141,14 @@ function pickSelection(match: ParlayMatch, market: Market, sel: Selection) {
|
||||
selectionName: `${selLabel(sel)} ${formatOdds(sel.odds)}`,
|
||||
odds: parseFloat(sel.odds),
|
||||
marketType: market.marketType,
|
||||
lineValue: parseLine(market.lineValue),
|
||||
allowParlay: market.allowParlay,
|
||||
});
|
||||
if (err === 'MAX_LEGS') parlayHint.value = t('bet.parlay_max_legs');
|
||||
else if (err === 'QUARTER_LINE') parlayHint.value = t('bet.parlay_block_quarter');
|
||||
else if (err === 'OUTRIGHT') parlayHint.value = t('bet.parlay_block_outright');
|
||||
else if (err === 'NOT_ALLOWED') parlayHint.value = t('bet.parlay_block_not_allowed');
|
||||
else parlayHint.value = '';
|
||||
}
|
||||
|
||||
function openSlip() {
|
||||
@@ -126,21 +160,28 @@ const showParlayFoot = computed(() => slip.mode === 'parlay' && slip.count > 0);
|
||||
|
||||
<template>
|
||||
<div class="parlay-panel" :class="{ 'has-fixed-foot': showParlayFoot }">
|
||||
<header class="panel-head">
|
||||
<div class="head-title">
|
||||
<span class="layers-icon" aria-hidden="true" />
|
||||
<h2>{{ t('bet.parlay_title') }}</h2>
|
||||
</div>
|
||||
<p class="head-desc">{{ t('bet.parlay_desc') }}</p>
|
||||
</header>
|
||||
|
||||
<div class="toolbar">
|
||||
<select v-model="timeFilter" class="filter-select">
|
||||
<div class="toolbar-filters">
|
||||
<select v-model="timeFilter" class="filter-select">
|
||||
<option value="all">{{ t('bet.parlay_filter_all') }}</option>
|
||||
<option value="today">{{ t('bet.tab_today') }}</option>
|
||||
</select>
|
||||
</select>
|
||||
<BetGuideHelp
|
||||
:title="t('bet.parlay_guide_title')"
|
||||
:aria-label="t('bet.parlay_guide_help')"
|
||||
storage-key="thebet365_parlay_guide_seen"
|
||||
>
|
||||
<p class="intro">{{ t('bet.parlay_desc') }}</p>
|
||||
<ol>
|
||||
<li>{{ t('bet.parlay_guide_1') }}</li>
|
||||
<li>{{ t('bet.parlay_guide_2') }}</li>
|
||||
<li>{{ t('bet.parlay_guide_3') }}</li>
|
||||
</ol>
|
||||
<p class="rules-link">{{ t('bet.guide_rules_link') }}</p>
|
||||
</BetGuideHelp>
|
||||
</div>
|
||||
<div class="col-headers">
|
||||
<span v-for="col in PARLAY_MARKET_TYPES" :key="col.key" class="col-head">{{ col.label }}</span>
|
||||
<span v-for="col in PARLAY_MARKET_TYPES" :key="col.key" class="col-head">{{ colLabel(col.labelKey) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -159,7 +200,12 @@ const showParlayFoot = computed(() => slip.mode === 'parlay' && slip.count > 0);
|
||||
:key="col.key"
|
||||
class="market-cell"
|
||||
>
|
||||
<template v-if="getMarket(match, col.key)?.selections.length">
|
||||
<template
|
||||
v-if="
|
||||
getMarket(match, col.key) &&
|
||||
isParlayEligibleMarket(getMarket(match, col.key)!)
|
||||
"
|
||||
>
|
||||
<button
|
||||
v-for="sel in getMarket(match, col.key)!.selections"
|
||||
:key="sel.id"
|
||||
@@ -185,7 +231,9 @@ const showParlayFoot = computed(() => slip.mode === 'parlay' && slip.count > 0);
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="showParlayFoot" class="parlay-foot-fixed">
|
||||
<p v-if="slip.count < 2" class="foot-hint">{{ t('bet.parlay_need_more') }}</p>
|
||||
<p v-if="parlayHint" class="foot-hint foot-hint--warn">{{ parlayHint }}</p>
|
||||
<p v-else-if="slip.count < 2" class="foot-hint">{{ t('bet.parlay_need_more') }}</p>
|
||||
<p v-else-if="slip.count > PARLAY_MAX_LEGS" class="foot-hint">{{ t('bet.parlay_max_legs') }}</p>
|
||||
<p v-else class="foot-meta">
|
||||
{{ t('bet.bet_slip') }} ({{ slip.count }}) · {{ t('bet.parlay') }}
|
||||
{{ slip.totalOdds.toFixed(2) }}
|
||||
@@ -193,7 +241,7 @@ const showParlayFoot = computed(() => slip.mode === 'parlay' && slip.count > 0);
|
||||
<button
|
||||
type="button"
|
||||
class="market-foot-btn"
|
||||
:disabled="slip.count < 2"
|
||||
:disabled="!slip.canPlaceParlay"
|
||||
@click="openSlip"
|
||||
>
|
||||
{{ t('bet.cs_confirm_cell') }}
|
||||
@@ -212,43 +260,11 @@ const showParlayFoot = computed(() => slip.mode === 'parlay' && slip.count > 0);
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
background: #141414;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 8px;
|
||||
padding: 14px 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.head-title {
|
||||
.toolbar-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.layers-icon {
|
||||
width: 26px;
|
||||
height: 20px;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
background:
|
||||
linear-gradient(#2e9e5e, #2e9e5e) 0 0 / 100% 4px no-repeat,
|
||||
linear-gradient(#2e9e5e, #2e9e5e) 0 8px / 85% 4px no-repeat,
|
||||
linear-gradient(#2e9e5e, #2e9e5e) 0 16px / 70% 4px no-repeat;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.head-title h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.head-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.45;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.parlay-foot-fixed {
|
||||
@@ -267,6 +283,10 @@ const showParlayFoot = computed(() => slip.mode === 'parlay' && slip.count > 0);
|
||||
.foot-meta {
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.foot-hint--warn {
|
||||
color: var(--danger);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -322,11 +342,12 @@ const showParlayFoot = computed(() => slip.mode === 'parlay' && slip.count > 0);
|
||||
}
|
||||
|
||||
.col-head {
|
||||
font-size: 9px;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.25;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import api from '../api';
|
||||
import { resolveAnnouncements } from '../constants/defaultAnnouncement';
|
||||
|
||||
@@ -20,14 +21,16 @@ function collectAnnouncementLines(data: {
|
||||
}
|
||||
|
||||
export function useAnnouncements() {
|
||||
const items = ref<string[]>(resolveAnnouncements([]));
|
||||
const { t } = useI18n();
|
||||
const items = ref<string[]>(resolveAnnouncements([], t('home.announcement_default')));
|
||||
|
||||
async function load() {
|
||||
const fallback = t('home.announcement_default');
|
||||
try {
|
||||
const { data } = await api.get('/player/home');
|
||||
items.value = resolveAnnouncements(collectAnnouncementLines(data.data));
|
||||
items.value = resolveAnnouncements(collectAnnouncementLines(data.data), fallback);
|
||||
} catch {
|
||||
items.value = resolveAnnouncements([]);
|
||||
items.value = resolveAnnouncements([], fallback);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
51
apps/player/src/composables/useAppLocale.ts
Normal file
51
apps/player/src/composables/useAppLocale.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { SUPPORTED_LOCALES, LOCALE_UI_LABELS } from '@thebet365/shared';
|
||||
import api from '../api';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const STORAGE_KEY = 'locale';
|
||||
const COOKIE_MAX_AGE = 365 * 24 * 60 * 60;
|
||||
|
||||
export const APP_LOCALES = SUPPORTED_LOCALES.map((code) => ({
|
||||
code,
|
||||
label: LOCALE_UI_LABELS[code] ?? code,
|
||||
}));
|
||||
|
||||
function persistLocale(code: string) {
|
||||
localStorage.setItem(STORAGE_KEY, code);
|
||||
document.cookie = `${STORAGE_KEY}=${encodeURIComponent(code)};path=/;max-age=${COOKIE_MAX_AGE};SameSite=Lax`;
|
||||
}
|
||||
|
||||
export function useAppLocale() {
|
||||
const { locale } = useI18n();
|
||||
const auth = useAuthStore();
|
||||
|
||||
function applyLocale(code: string) {
|
||||
if (!(SUPPORTED_LOCALES as readonly string[]).includes(code)) return;
|
||||
locale.value = code;
|
||||
persistLocale(code);
|
||||
}
|
||||
|
||||
async function setLocale(code: string) {
|
||||
applyLocale(code);
|
||||
if (auth.token) {
|
||||
try {
|
||||
await api.post('/player/language', { locale: code });
|
||||
if (auth.user) {
|
||||
auth.user = { ...auth.user, locale: code };
|
||||
localStorage.setItem('user', JSON.stringify(auth.user));
|
||||
}
|
||||
} catch {
|
||||
/* 离线或 token 过期时仍保留本地语言 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initFromUser(userLocale?: string | null) {
|
||||
if (userLocale && (SUPPORTED_LOCALES as readonly string[]).includes(userLocale)) {
|
||||
applyLocale(userLocale);
|
||||
}
|
||||
}
|
||||
|
||||
return { locales: APP_LOCALES, setLocale, applyLocale, initFromUser };
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
export const DEFAULT_ANNOUNCEMENTS = [
|
||||
'欢迎光临 TheBet365 · 足球赛事火热进行中 · 理性投注,量力而行',
|
||||
];
|
||||
|
||||
export function resolveAnnouncements(items: string[]): string[] {
|
||||
export function resolveAnnouncements(items: string[], fallback: string): string[] {
|
||||
const list = items.map((s) => s.trim()).filter(Boolean);
|
||||
return list.length ? list : DEFAULT_ANNOUNCEMENTS;
|
||||
return list.length ? list : [fallback];
|
||||
}
|
||||
|
||||
@@ -6,33 +6,26 @@ import { useBetSlipStore } from '../stores/betSlip';
|
||||
import BetSlipDrawer from '../components/BetSlipDrawer.vue';
|
||||
import CashBalanceChip from '../components/CashBalanceChip.vue';
|
||||
import UserAvatarMenu from '../components/UserAvatarMenu.vue';
|
||||
import LocaleFlag from '../components/LocaleFlag.vue';
|
||||
import LocaleSwitcher from '../components/LocaleSwitcher.vue';
|
||||
import { useAppLocale } from '../composables/useAppLocale';
|
||||
import AnnouncementMarquee from '../components/AnnouncementMarquee.vue';
|
||||
import BottomNavIcon from '../components/BottomNavIcon.vue';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { getLocaleDisplay } from '../utils/localeDisplay';
|
||||
import { useAnnouncements } from '../composables/useAnnouncements';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const { t } = useI18n();
|
||||
const auth = useAuthStore();
|
||||
const { initFromUser } = useAppLocale();
|
||||
const route = useRoute();
|
||||
const slip = useBetSlipStore();
|
||||
|
||||
const locales = [
|
||||
{ code: 'zh-CN', label: '中文' },
|
||||
{ code: 'en-US', label: 'EN' },
|
||||
{ code: 'ms-MY', label: 'BM' },
|
||||
];
|
||||
|
||||
const showAnnouncement = computed(() => !route.path.startsWith('/profile'));
|
||||
const { items: announcements, load: loadAnnouncements } = useAnnouncements();
|
||||
|
||||
onMounted(loadAnnouncements);
|
||||
|
||||
function setLocale(code: string) {
|
||||
locale.value = code;
|
||||
localStorage.setItem('locale', code);
|
||||
}
|
||||
onMounted(() => {
|
||||
loadAnnouncements();
|
||||
if (auth.user?.locale) initFromUser(auth.user.locale);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -40,14 +33,7 @@ function setLocale(code: string) {
|
||||
<header class="header">
|
||||
<img src="/logo.png" alt="TheBet365" class="logo" />
|
||||
<div class="header-actions">
|
||||
<div class="lang-select-wrap">
|
||||
<LocaleFlag :locale="locale" :size="18" />
|
||||
<select :value="locale" @change="setLocale(($event.target as HTMLSelectElement).value)" class="lang-select">
|
||||
<option v-for="l in locales" :key="l.code" :value="l.code">
|
||||
{{ getLocaleDisplay(l.code).label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<LocaleSwitcher />
|
||||
<CashBalanceChip v-if="auth.user" />
|
||||
<UserAvatarMenu v-if="auth.user" />
|
||||
</div>
|
||||
@@ -115,25 +101,20 @@ function setLocale(code: string) {
|
||||
height: 36px; width: auto; display: block;
|
||||
filter: drop-shadow(0 0 4px rgba(212, 175, 55, 0.2));
|
||||
}
|
||||
.header-actions { display: flex; gap: 6px; align-items: center; }
|
||||
.lang-select-wrap {
|
||||
.header-actions {
|
||||
--header-chip-h: 36px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 6px 3px 5px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: #0d0d0d;
|
||||
}
|
||||
.lang-select {
|
||||
background: transparent;
|
||||
color: var(--primary-light);
|
||||
border: none;
|
||||
padding: 2px 2px 2px 0;
|
||||
width: auto;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
outline: none;
|
||||
.header-actions :deep(.locale-switch:not(.compact)),
|
||||
.header-actions :deep(.cash-chip),
|
||||
.header-actions :deep(.avatar-btn) {
|
||||
height: var(--header-chip-h);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.header-actions :deep(.avatar-btn) {
|
||||
width: var(--header-chip-h);
|
||||
}
|
||||
.announce-strip {
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -8,10 +8,21 @@ import './styles.css';
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: localStorage.getItem('locale') || 'zh-CN',
|
||||
fallbackLocale: 'en-US',
|
||||
fallbackLocale: ['en-US', 'zh-CN'],
|
||||
messages: {
|
||||
'zh-CN': {
|
||||
nav: { home: '主页', bet: '投注', bet_history: '历史投注', wallet: '账单', profile: '我的' },
|
||||
home: {
|
||||
hot_matches: '热门赛事',
|
||||
no_matches: '暂无赛事',
|
||||
announcement_badge: '公告',
|
||||
announcement_default:
|
||||
'欢迎光临 TheBet365 · 足球赛事火热进行中 · 理性投注,量力而行',
|
||||
banner_prev: '上一张',
|
||||
banner_next: '下一张',
|
||||
banner_slide: '第 {n} 张',
|
||||
banner_fallback: 'Banner',
|
||||
},
|
||||
history: {
|
||||
league_default: '足球',
|
||||
stake: '投注 Stake',
|
||||
@@ -69,7 +80,16 @@ const i18n = createI18n({
|
||||
no_outright: '暂无冠军盘口',
|
||||
cancel: '取消',
|
||||
parlay_title: '串关投注',
|
||||
parlay_desc: '选择3-10场比赛来创建串关投注。组合赔率相乘可赢得更多!',
|
||||
parlay_guide_title: '串关怎么投?',
|
||||
parlay_guide_help: '查看串关说明',
|
||||
parlay_desc: '选择 2–5 场赛前赛事组合串关(2 串 1 至 5 串 1)。赔率相乘,不含滚球、冠军盘与四分盘让球/大小。',
|
||||
parlay_guide_1: '在列表中点击各场赔率,选中项显示金边;再点同一项可取消',
|
||||
parlay_guide_2: '须选 2–5 场不同赛事,不可同场串关;冠军盘与四分盘让球/大小不可选',
|
||||
parlay_guide_3: '选好后点底部「确认下单」打开投注单,填写金额并提交',
|
||||
parlay_max_legs: '串关最多 5 项',
|
||||
parlay_block_outright: '冠军盘不可串关',
|
||||
parlay_block_quarter: '四分盘让球/大小不可串关',
|
||||
parlay_block_not_allowed: '该玩法不可串关',
|
||||
parlay_filter_all: '全部',
|
||||
parlay_empty: '暂无可用串关赛事',
|
||||
parlay_same_match: '同一场比赛不能串关',
|
||||
@@ -91,6 +111,13 @@ const i18n = createI18n({
|
||||
market_ht_handicap: '半场 让球',
|
||||
market_ht_ou: '半场 大小',
|
||||
market_ht_1x2: '半场 独赢盘',
|
||||
parlay_sel_home: '主',
|
||||
parlay_sel_away: '客',
|
||||
parlay_sel_draw: '和',
|
||||
parlay_sel_over: '大',
|
||||
parlay_sel_under: '小',
|
||||
parlay_sel_odd: '单',
|
||||
parlay_sel_even: '双',
|
||||
col_home: '主场',
|
||||
col_draw: '平',
|
||||
col_away: '客场',
|
||||
@@ -111,6 +138,9 @@ const i18n = createI18n({
|
||||
guide_cs_1: '点「展开玩法」在表格里填各比分金额',
|
||||
guide_cs_2: '填好金额后点该玩法底部「确认下单」,核对后提交',
|
||||
guide_cs_3: '可一次填多个比分,会拆成多笔注单',
|
||||
guide_flow_parlay: '串关(2–5 场)',
|
||||
guide_parlay_1: '本页为单关/波胆。串关:底部导航点「投注」,在页面顶部切换到「串关投注」,选 2–5 场不同赛事后在投注单提交。',
|
||||
guide_rules_link: '完整规则见「我的」→ 投注规则。',
|
||||
mode_cs_tag: '本页直接下注',
|
||||
mode_slip_tag: '加入投注单',
|
||||
cs_confirm_btn: '确认下注',
|
||||
@@ -126,7 +156,7 @@ const i18n = createI18n({
|
||||
cs_top_hint: '① 在比分格填金额 ② 点上方「确认下注」',
|
||||
slip_empty_hint: '点击赔率加入投注单',
|
||||
slip_remove: '移除',
|
||||
slip_singles_hint: '共 {n} 笔单关(串关请用「串关投注」页)',
|
||||
slip_singles_hint: '共 {n} 笔单关(串关请到「投注」页顶部「串关投注」)',
|
||||
slip_stake_per_bet: '每笔投注金额',
|
||||
slip_est_return: '预计总返还',
|
||||
slip_parlay_odds: '组合赔率 {odds}',
|
||||
@@ -155,10 +185,27 @@ const i18n = createI18n({
|
||||
password_failed: '密码修改失败',
|
||||
password_mismatch: '两次新密码不一致',
|
||||
password_incomplete: '修改密码需填写当前密码、新密码及确认密码',
|
||||
rules_title: '投注规则',
|
||||
rules_p1: '本平台第一版仅支持足球赛前盘,不含滚球、Cash Out、改单及系统串关。',
|
||||
rules_p2: '串关为 2 串 1 至 5 串 1,不可同场串关;冠军盘、四分盘让球/大小不可进入串关。',
|
||||
rules_p3: '赛果由平台根据官方录入的半场/全场比分结算,结算预览经确认后入账。',
|
||||
rules_p4: '若本说明与后台公告冲突,以最新公告及实际盘口规则为准。',
|
||||
rules_p5: '操作步骤:进入任意赛事详情,点右上角「?」查看玩法说明。',
|
||||
},
|
||||
},
|
||||
'en-US': {
|
||||
nav: { home: 'Home', bet: 'Bet', bet_history: 'History', wallet: 'Wallet', profile: 'Profile' },
|
||||
home: {
|
||||
hot_matches: 'Hot matches',
|
||||
no_matches: 'No matches',
|
||||
announcement_badge: 'Notice',
|
||||
announcement_default:
|
||||
'Welcome to TheBet365 · Football events are live · Bet responsibly',
|
||||
banner_prev: 'Previous slide',
|
||||
banner_next: 'Next slide',
|
||||
banner_slide: 'Slide {n}',
|
||||
banner_fallback: 'Banner',
|
||||
},
|
||||
history: {
|
||||
league_default: 'Football',
|
||||
stake: 'Stake 投注',
|
||||
@@ -216,7 +263,16 @@ const i18n = createI18n({
|
||||
no_outright: 'No outright markets',
|
||||
cancel: 'Cancel',
|
||||
parlay_title: 'Parlay',
|
||||
parlay_desc: 'Pick 3-10 matches. Combined odds multiply your potential win!',
|
||||
parlay_guide_title: 'How to parlay',
|
||||
parlay_guide_help: 'Parlay help',
|
||||
parlay_desc: 'Combine 2–5 pre-match legs (2-fold to 5-fold). No live, outright, or quarter-ball HDP/O-U in parlay.',
|
||||
parlay_guide_1: 'Tap odds in the list; selected cells show a gold border. Tap again to remove',
|
||||
parlay_guide_2: 'Pick 2–5 different matches only. No same match, outright, or quarter-ball HDP/O-U',
|
||||
parlay_guide_3: 'Tap Confirm order at the bottom, enter stake in the bet slip, and submit',
|
||||
parlay_max_legs: 'Parlay allows up to 5 legs',
|
||||
parlay_block_outright: 'Outright cannot be parlayed',
|
||||
parlay_block_quarter: 'Quarter-ball HDP/O-U cannot be parlayed',
|
||||
parlay_block_not_allowed: 'This market cannot be parlayed',
|
||||
parlay_filter_all: 'All',
|
||||
parlay_empty: 'No matches available for parlay betting',
|
||||
parlay_same_match: 'Cannot parlay selections from the same match',
|
||||
@@ -238,6 +294,13 @@ const i18n = createI18n({
|
||||
market_ht_handicap: 'HT Handicap',
|
||||
market_ht_ou: 'HT O/U',
|
||||
market_ht_1x2: 'HT 1X2',
|
||||
parlay_sel_home: 'H',
|
||||
parlay_sel_away: 'A',
|
||||
parlay_sel_draw: 'D',
|
||||
parlay_sel_over: 'O',
|
||||
parlay_sel_under: 'U',
|
||||
parlay_sel_odd: 'Odd',
|
||||
parlay_sel_even: 'Even',
|
||||
col_home: 'Home',
|
||||
col_draw: 'Draw',
|
||||
col_away: 'Away',
|
||||
@@ -258,6 +321,9 @@ const i18n = createI18n({
|
||||
guide_cs_1: 'Expand and enter stake on each score',
|
||||
guide_cs_2: 'Enter stakes, then tap Place order at the bottom of that market',
|
||||
guide_cs_3: 'Multiple scores = multiple bets',
|
||||
guide_flow_parlay: 'Parlay (2–5 legs)',
|
||||
guide_parlay_1: 'This page is for singles and correct score. Parlay: tap Bet in the bottom nav, then the Parlay tab at the top of that page, pick 2–5 different matches, and submit from the bet slip.',
|
||||
guide_rules_link: 'Full rules: Profile → Betting Rules.',
|
||||
mode_cs_tag: 'Bet here',
|
||||
mode_slip_tag: 'Add to slip',
|
||||
cs_confirm_btn: 'Confirm bet',
|
||||
@@ -273,7 +339,7 @@ const i18n = createI18n({
|
||||
cs_top_hint: '① Enter stake ② Tap Confirm bet above',
|
||||
slip_empty_hint: 'Tap odds to add to bet slip',
|
||||
slip_remove: 'Remove',
|
||||
slip_singles_hint: '{n} single bet(s). Use Parlay tab for parlays.',
|
||||
slip_singles_hint: '{n} single bet(s). Parlay: Bet page → top Parlay tab.',
|
||||
slip_stake_per_bet: 'Stake per bet',
|
||||
slip_est_return: 'Est. total return',
|
||||
slip_parlay_odds: 'Combined odds {odds}',
|
||||
@@ -302,6 +368,12 @@ const i18n = createI18n({
|
||||
password_failed: 'Password change failed',
|
||||
password_mismatch: 'Passwords do not match',
|
||||
password_incomplete: 'Fill current, new and confirm password to change password',
|
||||
rules_title: 'Betting Rules',
|
||||
rules_p1: 'Football pre-match only in v1. No live betting, Cash Out, bet edits, or system parlays.',
|
||||
rules_p2: 'Parlays: 2–5 legs, different matches only. Outright and quarter-ball HDP/O-U are excluded from parlays.',
|
||||
rules_p3: 'Results use admin-entered half-time and full-time scores; payouts apply after settlement preview is confirmed.',
|
||||
rules_p4: 'If this text conflicts with site notices, the latest notice and market rules prevail.',
|
||||
rules_p5: 'How to bet: open any match, tap the ? icon on the top right.',
|
||||
},
|
||||
},
|
||||
'ms-MY': {
|
||||
@@ -312,18 +384,29 @@ const i18n = createI18n({
|
||||
wallet: 'Bil',
|
||||
profile: 'Profil',
|
||||
},
|
||||
home: {
|
||||
hot_matches: 'Perlawanan popular',
|
||||
no_matches: 'Tiada perlawanan',
|
||||
announcement_badge: 'Notis',
|
||||
announcement_default:
|
||||
'Selamat datang ke TheBet365 · Perlawanan bola sepak sedang berlangsung · Bertaruh secara bertanggungjawab',
|
||||
banner_prev: 'Slaid sebelumnya',
|
||||
banner_next: 'Slaid seterusnya',
|
||||
banner_slide: 'Slaid {n}',
|
||||
banner_fallback: 'Banner',
|
||||
},
|
||||
history: {
|
||||
league_default: 'Bola Sepak',
|
||||
stake: 'Stake 投注',
|
||||
return: 'Return 回报',
|
||||
est_return: 'Est. Return 预计回报',
|
||||
parlay_title: 'Parlay · {n} perlawanan',
|
||||
parlay_league: 'Parlay 串关',
|
||||
stake: 'Stake',
|
||||
return: 'Pulangan',
|
||||
est_return: 'Anggaran pulangan',
|
||||
parlay_title: 'Berganda · {n} perlawanan',
|
||||
parlay_league: 'Berganda',
|
||||
empty: 'Tiada rekod pertaruhan',
|
||||
status_won: 'WON 赢',
|
||||
status_pending: 'PENDING 待定',
|
||||
status_lost: 'LOST 输',
|
||||
status_push: 'PUSH 走盘',
|
||||
status_won: 'MENANG',
|
||||
status_pending: 'MENUNGGU',
|
||||
status_lost: 'KALAH',
|
||||
status_push: 'SERI',
|
||||
},
|
||||
auth: {
|
||||
login: 'Log Masuk',
|
||||
@@ -369,9 +452,18 @@ const i18n = createI18n({
|
||||
no_outright: 'Tiada pasaran juara',
|
||||
cancel: 'Batal',
|
||||
parlay_title: 'Pertaruhan Berganda',
|
||||
parlay_desc: 'Pilih 3-10 perlawanan. Gabungan odds didarab!',
|
||||
parlay_guide_title: 'Cara parlay',
|
||||
parlay_guide_help: 'Bantuan parlay',
|
||||
parlay_desc: 'Gabung 2–5 perlawanan pra-perlawanan (2 hingga 5 liputan). Tiada live, outright atau suku bola HDP/O-U.',
|
||||
parlay_guide_1: 'Ketik odds dalam senarai; pilihan dipilih ada sempadan emas. Ketik lagi untuk batal',
|
||||
parlay_guide_2: 'Pilih 2–5 perlawanan berbeza. Tiada perlawanan sama, outright atau suku bola HDP/O-U',
|
||||
parlay_guide_3: 'Ketik Sahkan pesanan di bawah, isi pegangan dalam slip, dan hantar',
|
||||
parlay_max_legs: 'Maksimum 5 pilihan parlay',
|
||||
parlay_block_outright: 'Outright tidak boleh parlay',
|
||||
parlay_block_quarter: 'HDP/O-U suku bola tidak boleh parlay',
|
||||
parlay_block_not_allowed: 'Pasaran ini tidak boleh parlay',
|
||||
parlay_filter_all: 'Semua',
|
||||
parlay_empty: 'No matches available for parlay betting',
|
||||
parlay_empty: 'Tiada perlawanan untuk pertaruhan berganda',
|
||||
parlay_same_match: 'Perlawanan sama tidak boleh berganda',
|
||||
parlay_need_more: 'Pilih sekurang-kurangnya 2 pilihan',
|
||||
back: 'Kembali',
|
||||
@@ -391,6 +483,13 @@ const i18n = createI18n({
|
||||
market_ht_handicap: 'Handicap Separuh',
|
||||
market_ht_ou: 'Atas/Bawah Separuh',
|
||||
market_ht_1x2: '1X2 Separuh',
|
||||
parlay_sel_home: 'R',
|
||||
parlay_sel_away: 'P',
|
||||
parlay_sel_draw: 'S',
|
||||
parlay_sel_over: 'Atas',
|
||||
parlay_sel_under: 'Bwh',
|
||||
parlay_sel_odd: 'G',
|
||||
parlay_sel_even: 'Gn',
|
||||
col_home: 'Home',
|
||||
col_draw: 'Seri',
|
||||
col_away: 'Away',
|
||||
@@ -411,6 +510,9 @@ const i18n = createI18n({
|
||||
guide_cs_1: 'Kembang dan isi jumlah setiap skor',
|
||||
guide_cs_2: 'Isi jumlah, kemudian Sahkan pesanan di bawah pasaran itu',
|
||||
guide_cs_3: 'Beberapa skor = beberapa pertaruhan',
|
||||
guide_flow_parlay: 'Parlay (2–5 perlawanan)',
|
||||
guide_parlay_1: 'Halaman ini untuk tunggal dan skor tepat. Parlay: ketik Pertaruhan di nav bawah, kemudian tab Parlay di bahagian atas halaman, pilih 2–5 perlawanan berbeza, hantar dari slip.',
|
||||
guide_rules_link: 'Peraturan penuh: Profil → Peraturan Pertaruhan.',
|
||||
mode_cs_tag: 'Pertaruhan di sini',
|
||||
mode_slip_tag: 'Tambah ke slip',
|
||||
cs_confirm_btn: 'Sahkan pertaruhan',
|
||||
@@ -426,7 +528,7 @@ const i18n = createI18n({
|
||||
cs_top_hint: '① Isi jumlah ② Ketik Sahkan di atas',
|
||||
slip_empty_hint: 'Ketik odds untuk tambah ke slip',
|
||||
slip_remove: 'Buang',
|
||||
slip_singles_hint: '{n} pertaruhan tunggal. Guna tab Berganda untuk parlay.',
|
||||
slip_singles_hint: '{n} pertaruhan tunggal. Parlay: halaman Pertaruhan → tab Berganda di atas.',
|
||||
slip_stake_per_bet: 'Jumlah setiap pertaruhan',
|
||||
slip_est_return: 'Anggaran pulangan',
|
||||
slip_parlay_odds: 'Odds gabungan {odds}',
|
||||
@@ -455,6 +557,12 @@ const i18n = createI18n({
|
||||
password_failed: 'Gagal tukar kata laluan',
|
||||
password_mismatch: 'Kata laluan tidak sepadan',
|
||||
password_incomplete: 'Isi kata laluan semasa, baharu dan pengesahan untuk menukar',
|
||||
rules_title: 'Peraturan Pertaruhan',
|
||||
rules_p1: 'Versi pertama: hanya bola sepak pra-perlawanan. Tiada live, Cash Out, edit pertaruhan atau parlay sistem.',
|
||||
rules_p2: 'Parlay 2–5 perlawanan, bukan perlawanan sama. Outright dan suku bola HDP/O-U tidak boleh parlay.',
|
||||
rules_p3: 'Keputusan berdasarkan skor separuh masa/penuh yang dimasukkan admin; bayaran selepas pratonton disahkan.',
|
||||
rules_p4: 'Jika bercanggah dengan notis laman, ikut notis terkini dan peraturan pasaran.',
|
||||
rules_p5: 'Langkah operasi: buka butiran perlawanan, ketik ikon ? di atas kanan.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,7 +4,9 @@ import api from '../api';
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref(localStorage.getItem('token') || '');
|
||||
const user = ref(JSON.parse(localStorage.getItem('user') || 'null'));
|
||||
const user = ref<{ id?: string; username?: string; locale?: string } | null>(
|
||||
JSON.parse(localStorage.getItem('user') || 'null'),
|
||||
);
|
||||
|
||||
async function login(username: string, password: string) {
|
||||
const { data } = await api.post('/player/auth/login', { username, password });
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import {
|
||||
PARLAY_MIN_LEGS,
|
||||
PARLAY_MAX_LEGS,
|
||||
canSelectForParlay,
|
||||
type ParlayRejectReason,
|
||||
} from '@thebet365/shared';
|
||||
|
||||
export interface SlipItem {
|
||||
selectionId: string;
|
||||
@@ -9,20 +15,30 @@ export interface SlipItem {
|
||||
selectionName: string;
|
||||
odds: number;
|
||||
marketType: string;
|
||||
lineValue?: number | null;
|
||||
allowParlay?: boolean;
|
||||
}
|
||||
|
||||
export const useBetSlipStore = defineStore('betSlip', () => {
|
||||
const items = ref<SlipItem[]>([]);
|
||||
const stake = ref<number>(10);
|
||||
const mode = ref<'single' | 'parlay'>('single');
|
||||
const lastParlayError = ref<ParlayRejectReason | 'MAX_LEGS' | null>(null);
|
||||
|
||||
const count = computed(() => items.value.length);
|
||||
const isParlay = computed(() => mode.value === 'parlay' && items.value.length >= 2);
|
||||
const isParlay = computed(() => mode.value === 'parlay' && items.value.length >= PARLAY_MIN_LEGS);
|
||||
|
||||
function parseLineValue(v: number | string | null | undefined): number | null {
|
||||
if (v == null || v === '') return null;
|
||||
const n = typeof v === 'number' ? v : parseFloat(String(v));
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** 球赛/详情页:仅单关,且投注单同时只能有 1 项 */
|
||||
function addItem(item: SlipItem) {
|
||||
if (mode.value === 'parlay') items.value = [];
|
||||
mode.value = 'single';
|
||||
lastParlayError.value = null;
|
||||
|
||||
const existing = items.value.findIndex(
|
||||
(i) => i.selectionId === item.selectionId,
|
||||
@@ -35,27 +51,48 @@ export const useBetSlipStore = defineStore('betSlip', () => {
|
||||
}
|
||||
|
||||
/** 串关页专用:跨场组合,同场只保留一个选项 */
|
||||
function addParlayLeg(item: SlipItem) {
|
||||
function addParlayLeg(item: SlipItem): ParlayRejectReason | 'MAX_LEGS' | null {
|
||||
if (mode.value === 'single') items.value = [];
|
||||
mode.value = 'parlay';
|
||||
|
||||
const samePick = items.value.findIndex((i) => i.selectionId === item.selectionId);
|
||||
if (samePick >= 0) {
|
||||
items.value.splice(samePick, 1);
|
||||
return;
|
||||
lastParlayError.value = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
const check = canSelectForParlay({
|
||||
marketType: item.marketType,
|
||||
lineValue: parseLineValue(item.lineValue),
|
||||
allowParlay: item.allowParlay,
|
||||
});
|
||||
if (!check.ok) {
|
||||
lastParlayError.value = check.reason;
|
||||
return check.reason;
|
||||
}
|
||||
|
||||
if (items.value.length >= PARLAY_MAX_LEGS) {
|
||||
lastParlayError.value = 'MAX_LEGS';
|
||||
return 'MAX_LEGS';
|
||||
}
|
||||
|
||||
items.value = items.value.filter((i) => i.matchId !== item.matchId);
|
||||
items.value.push(item);
|
||||
lastParlayError.value = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function removeItem(selectionId: string) {
|
||||
items.value = items.value.filter((i) => i.selectionId !== selectionId);
|
||||
if (!items.value.length) mode.value = 'single';
|
||||
lastParlayError.value = null;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
items.value = [];
|
||||
mode.value = 'single';
|
||||
lastParlayError.value = null;
|
||||
}
|
||||
|
||||
const totalOdds = computed(() =>
|
||||
@@ -64,7 +101,7 @@ export const useBetSlipStore = defineStore('betSlip', () => {
|
||||
|
||||
const potentialReturn = computed(() => {
|
||||
if (!items.value.length) return 0;
|
||||
if (mode.value === 'parlay' && items.value.length >= 2) {
|
||||
if (mode.value === 'parlay' && items.value.length >= PARLAY_MIN_LEGS) {
|
||||
return stake.value * totalOdds.value;
|
||||
}
|
||||
return stake.value * items.value[0].odds;
|
||||
@@ -75,6 +112,14 @@ export const useBetSlipStore = defineStore('betSlip', () => {
|
||||
return new Set(matchIds).size !== matchIds.length;
|
||||
});
|
||||
|
||||
const canPlaceParlay = computed(
|
||||
() =>
|
||||
mode.value === 'parlay' &&
|
||||
items.value.length >= PARLAY_MIN_LEGS &&
|
||||
items.value.length <= PARLAY_MAX_LEGS &&
|
||||
!hasSameMatch.value,
|
||||
);
|
||||
|
||||
const drawerOpen = ref(false);
|
||||
|
||||
function openDrawer() {
|
||||
@@ -94,6 +139,8 @@ export const useBetSlipStore = defineStore('betSlip', () => {
|
||||
totalOdds,
|
||||
potentialReturn,
|
||||
hasSameMatch,
|
||||
canPlaceParlay,
|
||||
lastParlayError,
|
||||
drawerOpen,
|
||||
addItem,
|
||||
addParlayLeg,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { LOCALE_UI_LABELS } from '@thebet365/shared';
|
||||
|
||||
export interface LocaleDisplay {
|
||||
countryCode: 'cn' | 'us' | 'my';
|
||||
currency: string;
|
||||
@@ -5,9 +7,9 @@ export interface LocaleDisplay {
|
||||
}
|
||||
|
||||
const LOCALE_DISPLAY: Record<string, LocaleDisplay> = {
|
||||
'zh-CN': { countryCode: 'cn', currency: 'CNY', label: '中文' },
|
||||
'en-US': { countryCode: 'us', currency: 'USD', label: 'English' },
|
||||
'ms-MY': { countryCode: 'my', currency: 'MYR', label: 'BM' },
|
||||
'zh-CN': { countryCode: 'cn', currency: 'CNY', label: LOCALE_UI_LABELS['zh-CN'] },
|
||||
'en-US': { countryCode: 'us', currency: 'USD', label: LOCALE_UI_LABELS['en-US'] },
|
||||
'ms-MY': { countryCode: 'my', currency: 'MYR', label: LOCALE_UI_LABELS['ms-MY'] },
|
||||
};
|
||||
|
||||
export function getLocaleDisplay(locale: string): LocaleDisplay {
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
/** 串关列表表头与玩法类型 */
|
||||
/** 串关列表表头与玩法类型(labelKey 对应 main.ts bet.*) */
|
||||
export const PARLAY_MARKET_TYPES = [
|
||||
{ key: 'FT_HANDICAP', label: 'FT.HDP' },
|
||||
{ key: 'FT_OVER_UNDER', label: 'FT.O/U' },
|
||||
{ key: 'FT_1X2', label: 'FT.1X2' },
|
||||
{ key: 'FT_ODD_EVEN', label: 'FT.O/E' },
|
||||
{ key: 'HT_HANDICAP', label: '1H.HDP' },
|
||||
{ key: 'HT_OVER_UNDER', label: '1H.O/U' },
|
||||
{ key: 'HT_1X2', label: '1H.1X2' },
|
||||
{ key: 'FT_HANDICAP', labelKey: 'market_ft_handicap' },
|
||||
{ key: 'FT_OVER_UNDER', labelKey: 'market_ft_ou' },
|
||||
{ key: 'FT_1X2', labelKey: 'market_ft_1x2' },
|
||||
{ key: 'FT_ODD_EVEN', labelKey: 'market_ft_oe' },
|
||||
{ key: 'HT_HANDICAP', labelKey: 'market_ht_handicap' },
|
||||
{ key: 'HT_OVER_UNDER', labelKey: 'market_ht_ou' },
|
||||
{ key: 'HT_1X2', labelKey: 'market_ht_1x2' },
|
||||
] as const;
|
||||
|
||||
export type ParlayMarketType = (typeof PARLAY_MARKET_TYPES)[number]['key'];
|
||||
|
||||
/** 选项简称(串关格内展示) */
|
||||
export const SELECTION_SHORT: Record<string, string> = {
|
||||
HOME: '主',
|
||||
AWAY: '客',
|
||||
DRAW: '和',
|
||||
OVER: '大',
|
||||
UNDER: '小',
|
||||
ODD: '单',
|
||||
EVEN: '双',
|
||||
/** 选项简称 i18n key(串关格内展示) */
|
||||
export const PARLAY_SELECTION_KEYS: Record<string, string> = {
|
||||
HOME: 'parlay_sel_home',
|
||||
AWAY: 'parlay_sel_away',
|
||||
DRAW: 'parlay_sel_draw',
|
||||
OVER: 'parlay_sel_over',
|
||||
UNDER: 'parlay_sel_under',
|
||||
ODD: 'parlay_sel_odd',
|
||||
EVEN: 'parlay_sel_even',
|
||||
};
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
import api from '../api';
|
||||
import emptyMatchesImg from '../assets/images/empty-matches.svg';
|
||||
import BannerCarousel from '../components/BannerCarousel.vue';
|
||||
@@ -49,7 +52,7 @@ function goMatch(id: string) {
|
||||
<div>
|
||||
<BannerCarousel :banners="displayBanners" />
|
||||
|
||||
<h2 class="section-title">热门赛事</h2>
|
||||
<h2 class="section-title">{{ t('home.hot_matches') }}</h2>
|
||||
<div v-for="match in home?.hotMatches || []" :key="match.id" class="card match-card" @click="goMatch(match.id)">
|
||||
<div class="match-teams">{{ match.homeTeamName }} vs {{ match.awayTeamName }}</div>
|
||||
<div class="match-time">{{ new Date(match.startTime).toLocaleString() }}</div>
|
||||
@@ -57,7 +60,7 @@ function goMatch(id: string) {
|
||||
|
||||
<div v-if="home && !home.hotMatches?.length" class="empty">
|
||||
<img :src="emptyMatchesImg" alt="" class="empty-icon" />
|
||||
<p>暂无赛事</p>
|
||||
<p>{{ t('home.no_matches') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,10 +3,13 @@ import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useAppLocale } from '../composables/useAppLocale';
|
||||
import LocaleSwitcher from '../components/LocaleSwitcher.vue';
|
||||
import RobotVerify from '../components/RobotVerify.vue';
|
||||
import loginBg from '../assets/images/h5bg.png';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { initFromUser } = useAppLocale();
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const captchaRef = ref<InstanceType<typeof RobotVerify> | null>(null);
|
||||
@@ -25,6 +28,7 @@ async function submit() {
|
||||
error.value = '';
|
||||
try {
|
||||
await auth.login(username.value, password.value);
|
||||
initFromUser(auth.user?.locale);
|
||||
router.push('/');
|
||||
} catch (e: unknown) {
|
||||
error.value = (e as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Login failed';
|
||||
@@ -36,6 +40,9 @@ async function submit() {
|
||||
|
||||
<template>
|
||||
<div class="login-page" :style="{ backgroundImage: `url(${loginBg})` }">
|
||||
<div class="login-lang">
|
||||
<LocaleSwitcher compact />
|
||||
</div>
|
||||
<form @submit.prevent="submit" class="login-form ps-gold-frame">
|
||||
<label>{{ t('auth.username') }}</label>
|
||||
<input v-model="username" class="ps-gold-input" required />
|
||||
@@ -51,7 +58,15 @@ async function submit() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-lang {
|
||||
position: absolute;
|
||||
top: max(12px, env(safe-area-inset-top));
|
||||
right: 16px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.login-page {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
min-height: 100dvh;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -24,7 +24,8 @@ interface Market {
|
||||
id: string;
|
||||
marketType: string;
|
||||
period: string;
|
||||
lineValue?: string;
|
||||
lineValue?: string | number | null;
|
||||
allowParlay?: boolean;
|
||||
selections: Selection[];
|
||||
}
|
||||
|
||||
@@ -212,6 +213,11 @@ function toggleSelection(sel: Selection, market: Market) {
|
||||
selectionName: sel.selectionName,
|
||||
odds: parseFloat(sel.odds),
|
||||
marketType: market.marketType,
|
||||
lineValue:
|
||||
market.lineValue != null && market.lineValue !== ''
|
||||
? parseFloat(String(market.lineValue))
|
||||
: null,
|
||||
allowParlay: market.allowParlay,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,16 +6,12 @@ import api from '../api';
|
||||
import { formatMoney } from '../utils/localeDisplay';
|
||||
import LocaleFlag from '../components/LocaleFlag.vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useAppLocale } from '../composables/useAppLocale';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const locales = [
|
||||
{ code: 'zh-CN', label: '中文' },
|
||||
{ code: 'en-US', label: 'EN' },
|
||||
{ code: 'ms-MY', label: 'BM' },
|
||||
] as const;
|
||||
const { locales, setLocale, initFromUser } = useAppLocale();
|
||||
|
||||
const profile = ref<{
|
||||
username?: string;
|
||||
@@ -25,12 +21,11 @@ const profile = ref<{
|
||||
onMounted(async () => {
|
||||
const { data } = await api.get('/player/profile');
|
||||
profile.value = data.data;
|
||||
initFromUser(data.data?.locale);
|
||||
});
|
||||
|
||||
async function changeLocale(code: string) {
|
||||
locale.value = code;
|
||||
localStorage.setItem('locale', code);
|
||||
await api.post('/player/language', { locale: code });
|
||||
await setLocale(code);
|
||||
}
|
||||
|
||||
function logout() {
|
||||
@@ -79,6 +74,19 @@ function logout() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-cell settings-cell--stack rules-cell">
|
||||
<div class="cell-head">
|
||||
<span class="cell-label">{{ t('profile.rules_title') }}</span>
|
||||
</div>
|
||||
<div class="rules-body">
|
||||
<p>{{ t('profile.rules_p1') }}</p>
|
||||
<p>{{ t('profile.rules_p2') }}</p>
|
||||
<p>{{ t('profile.rules_p3') }}</p>
|
||||
<p>{{ t('profile.rules_p4') }}</p>
|
||||
<p>{{ t('profile.rules_p5') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button type="button" class="logout-btn" @click="logout">
|
||||
@@ -236,4 +244,19 @@ function logout() {
|
||||
.logout-btn:active {
|
||||
background: rgba(255, 69, 58, 0.08);
|
||||
}
|
||||
|
||||
.rules-body {
|
||||
padding: 0 0 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.rules-body p {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.rules-body p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,10 @@ export default defineConfig({
|
||||
resolve: {
|
||||
// 避免删除 src 内过期 .js 后仍优先请求 index.js 导致 404
|
||||
extensions: ['.ts', '.tsx', '.mjs', '.js', '.mts', '.jsx', '.json', '.vue'],
|
||||
alias: {
|
||||
// shared 的 dist 为 CommonJS,Vite 无法按命名导出加载;直连源码
|
||||
'@thebet365/shared': resolve(__dirname, '../../packages/shared/src/index.ts'),
|
||||
},
|
||||
},
|
||||
publicDir: resolve(__dirname, '../../packages/shared/public'),
|
||||
server: {
|
||||
|
||||
Reference in New Issue
Block a user