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

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

View File

@@ -1,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: '单关' },

View File

@@ -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' } }],
};
}),
};

View File

@@ -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,
});