feat: WC2026 赛事 seed、生产上线初始化脚本与目录归档

重构 seed 为 WC2026 72 场小组赛与 48 强优胜盘;新增 production 模式仅保留 admin 与赛事示例;提供 prod-init-db 全量重置脚本;管理端 i18n 分包与赛事归档能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-12 18:17:00 +08:00
parent 8f14e85ebd
commit e7e938f261
94 changed files with 12332 additions and 976 deletions

View File

@@ -1,4 +1,13 @@
import axios from 'axios';
import { isApiErrorCode } from '@thebet365/shared';
import { useAuthStore } from '../stores/auth';
const ACCOUNT_BLOCKED_CODES = new Set([
'ACCOUNT_SUSPENDED',
'ACCOUNT_DISABLED',
'AGENT_ACCOUNT_SUSPENDED',
'PARENT_AGENT_SUSPENDED',
]);
const api = axios.create({ baseURL: '/api' });
@@ -12,16 +21,28 @@ api.interceptors.request.use((config) => {
return config;
});
function clearSession() {
localStorage.removeItem('token');
localStorage.removeItem('user');
try {
useAuthStore().logout();
} catch {
// Pinia may not be ready during bootstrap
}
}
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
const url: string = err.config?.url ?? '';
// Don't redirect on login/auth failures — let the caller handle the error
if (!url.includes('/auth/login') && !url.includes('/auth/register')) {
localStorage.removeItem('token');
// 不再强制跳转登录页,让调用方处理 401
}
const status = err.response?.status;
const url: string = err.config?.url ?? '';
const isAuthEndpoint = url.includes('/auth/login') || url.includes('/auth/register');
const code = err.response?.data?.code;
const blockedAccount =
typeof code === 'string' && isApiErrorCode(code) && ACCOUNT_BLOCKED_CODES.has(code);
if (!isAuthEndpoint && (status === 401 || (status === 403 && blockedAccount))) {
clearSession();
}
return Promise.reject(err);
},

View File

@@ -52,6 +52,10 @@ watch(
.team-emblem {
flex-shrink: 0;
display: block;
}
/* 队徽(非国旗):圆角 + 投影 */
.team-emblem--logo {
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
}
@@ -74,6 +78,8 @@ watch(
/* 国旗:横向比例 + 铺满 */
.team-emblem:not(.team-emblem--logo) {
object-fit: cover;
border-radius: 3px;
background: transparent;
}
.team-emblem--sm:not(.team-emblem--logo) {

View File

@@ -78,6 +78,8 @@ export default {
go_register: 'No account? Register now',
have_account: 'Already have an account? Log in',
register_btn: 'Register',
registering: 'Registering…',
sending_sms: 'Sending…',
register_failed: 'Registration failed, please try again',
continue_browsing: 'Skip login',
username_placeholder: 'Enter username',
@@ -195,7 +197,7 @@ export default {
amount_label: 'Amount',
amount_placeholder: 'Enter recharge amount',
screenshot_label: 'Upload Screenshot',
upload_hint: 'Click to upload screenshot (max 5MB)',
upload_hint: 'Click to upload (max 10MB original, compressed to within 1MB)',
compressing: 'Compressing',
submit: 'Submit',
submitting: 'Submitting',
@@ -208,7 +210,8 @@ export default {
upload_screenshot: 'Please upload a screenshot',
submit_failed: 'Submit failed, please retry',
file_must_be_image: 'Please upload an image file',
file_too_large: 'File exceeds 10MB',
file_too_large: 'Original file must be 10MB or less',
compress_failed: 'Could not compress the image to within 1MB. Try a smaller screenshot.',
status_pending: 'Processing',
status_approved: 'Approved',
status_rejected: 'Rejected',

View File

@@ -84,6 +84,8 @@ export default {
go_register: 'Tiada akaun? Daftar sekarang',
have_account: 'Sudah ada akaun? Log masuk',
register_btn: 'Daftar',
registering: 'Mendaftar…',
sending_sms: 'Menghantar…',
register_failed: 'Pendaftaran gagal, sila cuba lagi',
continue_browsing: 'Langkau log masuk',
username_placeholder: 'Masukkan nama pengguna',
@@ -201,7 +203,7 @@ export default {
amount_label: 'Jumlah',
amount_placeholder: 'Masukkan jumlah topup',
screenshot_label: 'Muat Naik Screenshot',
upload_hint: 'Klik untuk muat naik (maks 5MB)',
upload_hint: 'Klik untuk muat naik (asal maks 10MB, dimampatkan ≤1MB)',
compressing: 'Memampat',
submit: 'Hantar',
submitting: 'Menghantar',
@@ -214,7 +216,8 @@ export default {
upload_screenshot: 'Sila muat naik screenshot',
submit_failed: 'Gagal, sila cuba lagi',
file_must_be_image: 'Sila muat naik fail imej',
file_too_large: 'Fail melebihi 10MB',
file_too_large: 'Fail asal melebihi 10MB',
compress_failed: 'Gagal memampatkan imej ke ≤1MB. Sila gunakan tangkapan skrin lebih kecil.',
status_pending: 'Memproses',
status_approved: 'Diluluskan',
status_rejected: 'Ditolak',

View File

@@ -78,6 +78,8 @@ export default {
go_register: '没有账号?立即注册',
have_account: '已有账号?去登录',
register_btn: '注册',
registering: '注册中…',
sending_sms: '发送中…',
register_failed: '注册失败,请重试',
continue_browsing: '暂不登录',
username_placeholder: '请输入账号',
@@ -195,7 +197,7 @@ export default {
amount_label: '充值金额',
amount_placeholder: '请输入充值金额',
screenshot_label: '上传转账截图',
upload_hint: '点击上传截图(最大 5MB',
upload_hint: '点击上传截图(原图不超过 10MB将自动压缩至 1MB 以内',
compressing: '压缩中',
submit: '提交充值',
submitting: '提交中',
@@ -208,7 +210,8 @@ export default {
upload_screenshot: '请上传转账截图',
submit_failed: '提交失败,请重试',
file_must_be_image: '请上传图片文件',
file_too_large: '文件不能超过 10MB',
file_too_large: '原图不能超过 10MB',
compress_failed: '图片压缩失败或未压到 1MB 以内,请换一张更小的截图',
status_pending: '充值中',
status_approved: '已通过',
status_rejected: '已拒绝',

View File

@@ -67,6 +67,7 @@ interface MatchDetail {
status?: string;
bettingOpen?: boolean;
matchPhase?: MatchPhase;
correctScoreEnabled?: boolean;
score?: {
htHome: number;
htAway: number;
@@ -138,6 +139,14 @@ const marketsByType = computed(() => {
return map;
});
const CS_MARKET_TYPES = new Set(['FT_CORRECT_SCORE', 'HT_CORRECT_SCORE', 'SH_CORRECT_SCORE']);
const visibleMarketTypes = computed(() => {
const csEnabled = match.value?.correctScoreEnabled ?? true;
if (csEnabled) return DETAIL_MARKET_TYPES;
return DETAIL_MARKET_TYPES.filter((t) => !CS_MARKET_TYPES.has(t));
});
function marketPromoLabel(marketType: string) {
const m = marketsByType.value.get(marketType);
return m?.promoLabel?.trim() || '';
@@ -472,7 +481,7 @@ function hasSlipPickForMarket(marketType: string) {
<div class="market-list">
<div
v-for="marketType in DETAIL_MARKET_TYPES"
v-for="marketType in visibleMarketTypes"
:key="marketType"
class="market-group"
:class="{ open: isExpanded(marketType) }"

View File

@@ -59,6 +59,33 @@ function selectMethod(m: PaymentMethod) {
selectedMethod.value = m;
}
const MAX_ORIGINAL_BYTES = 10 * 1024 * 1024;
const MAX_SCREENSHOT_BYTES = 1024 * 1024;
async function compressScreenshot(file: File): Promise<File> {
const baseOptions = {
maxSizeMB: 1,
maxWidthOrHeight: 1920,
useWebWorker: true,
maxIteration: 15,
} as const;
const attempts = [
{ ...baseOptions, initialQuality: 0.85 },
{ ...baseOptions, initialQuality: 0.65, maxWidthOrHeight: 1600 },
{ ...baseOptions, initialQuality: 0.5, maxWidthOrHeight: 1280 },
];
for (const options of attempts) {
const compressed = (await imageCompression(file, options)) as File;
if (compressed.size <= MAX_SCREENSHOT_BYTES) {
return compressed;
}
}
throw new Error('COMPRESS_TOO_LARGE');
}
async function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
@@ -70,27 +97,22 @@ async function handleFileChange(event: Event) {
return;
}
// Max 10MB before compression
if (file.size > 10 * 1024 * 1024) {
if (file.size > MAX_ORIGINAL_BYTES) {
alert(t('recharge.file_too_large'));
input.value = '';
return;
}
// Compress image
compressing.value = true;
try {
const compressed = await imageCompression(file, {
maxSizeMB: 1,
maxWidthOrHeight: 1920,
useWebWorker: true,
});
screenshotFile.value = compressed as File;
const compressed = await compressScreenshot(file);
screenshotFile.value = compressed;
screenshotPreview.value = URL.createObjectURL(compressed);
} catch {
// Fallback: use original if compression fails
screenshotFile.value = file;
screenshotPreview.value = URL.createObjectURL(file);
alert(t('recharge.compress_failed'));
screenshotFile.value = null;
screenshotPreview.value = '';
input.value = '';
} finally {
compressing.value = false;
}
@@ -115,6 +137,10 @@ async function handleSubmit() {
alert(t('recharge.upload_screenshot'));
return;
}
if (screenshotFile.value.size > MAX_SCREENSHOT_BYTES) {
alert(t('recharge.compress_failed'));
return;
}
submitting.value = true;
try {

View File

@@ -8,6 +8,7 @@ import { useAppLocale } from '../composables/useAppLocale';
import { useSmsCode } from '../composables/useSmsCode';
import LocaleSwitcher from '../components/LocaleSwitcher.vue';
import PhoneCountrySelect from '../components/PhoneCountrySelect.vue';
import GoldSpinner from '../components/GoldSpinner.vue';
import loginBg from '../assets/images/h5bg.webp';
const { t, locale } = useI18n();
@@ -104,7 +105,11 @@ const fieldError = () => {
<div class="login-lang">
<LocaleSwitcher compact />
</div>
<form @submit.prevent="submit" class="login-form ps-gold-frame">
<form @submit.prevent="submit" class="login-form ps-gold-frame" :class="{ 'is-busy': loading }">
<div v-if="loading" class="form-busy-overlay" aria-hidden="true">
<GoldSpinner :size="40" :active="true" />
<p class="form-busy-text">{{ t('auth.registering') }}</p>
</div>
<h2 class="form-title">{{ t('auth.register') }}</h2>
<div class="field">
@@ -116,6 +121,7 @@ const fieldError = () => {
autocomplete="username"
maxlength="32"
minlength="7"
:disabled="loading"
:placeholder="t('auth.username_register_placeholder')"
/>
</div>
@@ -130,6 +136,7 @@ const fieldError = () => {
type="tel"
required
autocomplete="tel-national"
:disabled="loading"
:placeholder="t('auth.phone_local_placeholder')"
/>
</div>
@@ -144,40 +151,55 @@ const fieldError = () => {
inputmode="numeric"
maxlength="6"
autocomplete="one-time-code"
:disabled="loading"
:placeholder="t('auth.sms_code_placeholder')"
/>
<button
type="button"
class="btn-secondary"
:disabled="sending || countdown > 0 || !phone.trim()"
:disabled="sending || countdown > 0 || !phone.trim() || loading"
@click="sendCode"
>
{{ countdown > 0 ? `${countdown}s` : t('auth.send_sms') }}
<span v-if="sending" class="btn-inline-loading">
<GoldSpinner :size="14" :active="true" />
{{ t('auth.sending_sms') }}
</span>
<span v-else>{{ countdown > 0 ? `${countdown}s` : t('auth.send_sms') }}</span>
</button>
</div>
</div>
<div class="field">
<label>{{ t('auth.password') }}</label>
<input v-model="password" class="field-input" type="password" required autocomplete="new-password" minlength="8" />
<input v-model="password" class="field-input" type="password" required autocomplete="new-password" minlength="8" :disabled="loading" />
</div>
<div class="field">
<label>{{ t('auth.confirm_password') }}</label>
<input v-model="confirmPassword" class="field-input" type="password" required autocomplete="new-password" minlength="8" />
<input v-model="confirmPassword" class="field-input" type="password" required autocomplete="new-password" minlength="8" :disabled="loading" />
</div>
<div class="field field-optional">
<label>{{ t('auth.invite_code') }} <span class="optional-tag">{{ t('auth.optional') }}</span></label>
<input v-model="inviteCode" class="field-input" autocomplete="off" />
<input v-model="inviteCode" class="field-input" autocomplete="off" :disabled="loading" />
</div>
<p v-if="fieldError()" class="error">{{ fieldError() }}</p>
<button type="submit" class="btn-login btn-gold-outline" :disabled="loading">
{{ t('auth.register_btn') }}
<button
type="submit"
class="btn-login btn-gold-outline"
:class="{ 'is-loading': loading }"
:disabled="loading"
:aria-busy="loading"
>
<span v-if="loading" class="btn-inline-loading">
<GoldSpinner :size="18" :active="true" />
{{ t('auth.registering') }}
</span>
<span v-else>{{ t('auth.register_btn') }}</span>
</button>
<button type="button" class="btn-skip" @click="goLogin">
<button type="button" class="btn-skip" :disabled="loading" @click="goLogin">
{{ t('auth.have_account') }}
</button>
</form>
@@ -212,6 +234,7 @@ const fieldError = () => {
}
.login-form {
position: relative;
width: 100%;
max-width: 320px;
display: flex;
@@ -220,6 +243,40 @@ const fieldError = () => {
padding: 12px;
}
.login-form.is-busy {
pointer-events: none;
}
.form-busy-overlay {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
border-radius: inherit;
background: rgba(8, 8, 8, 0.72);
backdrop-filter: blur(2px);
pointer-events: all;
}
.form-busy-text {
margin: 0;
font-size: 13px;
font-weight: 700;
color: rgba(240, 216, 117, 0.95);
letter-spacing: 0.04em;
}
.btn-inline-loading {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.form-title {
margin: 0 0 2px;
font-size: 16px;
@@ -315,6 +372,10 @@ label {
cursor: not-allowed;
}
.btn-login.is-loading:disabled {
opacity: 1;
}
.btn-skip {
margin-top: 2px;
padding: 8px 14px;