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:
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '已拒绝',
|
||||
|
||||
@@ -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) }"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user