sync(theme-3): 同步 main 最新 API/Admin 与玩家端逻辑

从 main 同步站内信手动发送、媒体库选择、列表子路由重构等 API/Admin 改动;玩家端仅合并 ADMIN_CUSTOM 富文本、消息预览与充值截图压缩逻辑,保留 theme-3 样式。
This commit is contained in:
2026-06-22 14:11:47 +08:00
parent f92c5e8a59
commit d8e08aaed0
50 changed files with 4653 additions and 1186 deletions

View File

@@ -6,6 +6,7 @@ import { formatLocalMatchDateTime } from '@thebet365/shared';
import GoldSpinner from './GoldSpinner.vue';
import ConfirmDialog from './ConfirmDialog.vue';
import { useAuthStore } from '../stores/auth';
import { stripHtml } from '../utils/html';
import { usePlayerMessages, type DepositMessagePayload, type PlayerMessage } from '../composables/usePlayerMessages';
const router = useRouter();
@@ -44,7 +45,8 @@ function messagePreview(item: PlayerMessage) {
const deposit = item.payload as DepositMessagePayload | null;
if (deposit?.orderNo) return deposit.orderNo;
}
return item.body.length > 80 ? `${item.body.slice(0, 80)}` : item.body;
const plain = stripHtml(item.body);
return plain.length > 80 ? `${plain.slice(0, 80)}` : plain;
}
function openDetail(id: string) {

View File

@@ -5,7 +5,8 @@ export type PlayerMessageType =
| 'DEPOSIT_APPROVED'
| 'DEPOSIT_REJECTED'
| 'BANNER_PROMO'
| 'ANNOUNCEMENT_PROMO';
| 'ANNOUNCEMENT_PROMO'
| 'ADMIN_CUSTOM';
export type DepositMessagePayload = {
depositOrderId?: string;

View File

@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
import { formatLocalMatchDateTime } from '@thebet365/shared';
import GoldSpinner from '../components/GoldSpinner.vue';
import ConfirmDialog from '../components/ConfirmDialog.vue';
import { sanitizeAnnouncementHtml } from '../utils/html';
import { formatMoney } from '../utils/localeDisplay';
import {
usePlayerMessages,
@@ -174,7 +175,12 @@ watch(messageId, () => {
</span>
<p v-if="message.createdAt" class="detail-date">{{ formatDate(message.createdAt) }}</p>
<h2 class="detail-title">{{ messageTitle(message) }}</h2>
<p class="detail-body">{{ messageBody(message) }}</p>
<div
v-if="message.type === 'ADMIN_CUSTOM'"
class="detail-body rich-body"
v-html="sanitizeAnnouncementHtml(message.body)"
/>
<p v-else class="detail-body">{{ messageBody(message) }}</p>
<div
v-if="message.type === 'DEPOSIT_REJECTED' && depositPayload?.rejectReason?.trim()"
@@ -323,6 +329,32 @@ watch(messageId, () => {
color: var(--text-secondary);
}
.rich-body :deep(p) {
margin: 0 0 12px;
}
.rich-body :deep(p:last-child) {
margin-bottom: 0;
}
.rich-body :deep(img) {
display: block;
max-width: 100%;
height: auto;
margin: 12px 0;
border-radius: 8px;
}
.rich-body :deep(ul),
.rich-body :deep(ol) {
margin: 0 0 12px;
padding-left: 20px;
}
.rich-body :deep(a) {
color: var(--primary);
}
.reason-box {
margin-top: 18px;
padding: 14px;

View File

@@ -5,10 +5,12 @@ import { useI18n } from 'vue-i18n';
import imageCompression from 'browser-image-compression';
import api from '../api';
import GoldSpinner from '../components/GoldSpinner.vue';
import { useDepositNotifications } from '../composables/useDepositNotifications';
const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const { trackPendingOrder } = useDepositNotifications();
const reapplyOrderId = computed(() => {
const id = route.query.orderId;
@@ -93,30 +95,43 @@ function selectMethod(m: PaymentMethod) {
}
const MAX_ORIGINAL_BYTES = 10 * 1024 * 1024;
const MAX_SCREENSHOT_BYTES = 1024 * 1024;
const MAX_SCREENSHOT_BYTES = 300 * 1024;
async function compressScreenshot(file: File): Promise<File> {
const baseOptions = {
maxSizeMB: 1,
maxWidthOrHeight: 1920,
maxSizeMB: 0.3,
maxWidthOrHeight: 1200,
useWebWorker: true,
maxIteration: 15,
maxIteration: 10,
fileType: 'image/webp',
} as const;
const attempts = [
{ ...baseOptions, initialQuality: 0.85 },
{ ...baseOptions, initialQuality: 0.65, maxWidthOrHeight: 1600 },
{ ...baseOptions, initialQuality: 0.5, maxWidthOrHeight: 1280 },
{ ...baseOptions, initialQuality: 0.8 },
{ ...baseOptions, initialQuality: 0.6, maxWidthOrHeight: 1000 },
];
let compressed: File | null = null;
for (const options of attempts) {
const compressed = (await imageCompression(file, options)) as File;
if (compressed.size <= MAX_SCREENSHOT_BYTES) {
return compressed;
try {
compressed = (await imageCompression(file, options)) as File;
if (compressed.size <= MAX_SCREENSHOT_BYTES) {
break;
}
} catch (err) {
console.error('Compression attempt failed:', err);
}
}
throw new Error('COMPRESS_TOO_LARGE');
if (!compressed) {
throw new Error('COMPRESS_FAILED');
}
const name = file.name.replace(/\.[^/.]+$/, '') + '.webp';
return new File([compressed], name, {
type: 'image/webp',
lastModified: Date.now(),
});
}
async function handleFileChange(event: Event) {
@@ -186,6 +201,7 @@ async function handleSubmit() {
const { data } = await api.post(`/player/deposit-orders/${reapplyOrderId.value}/reapply`, fd);
const result = data.data;
orderNo.value = result?.orderNo ?? '';
if (result?.id) trackPendingOrder(String(result.id));
success.value = true;
return;
}
@@ -194,6 +210,7 @@ async function handleSubmit() {
const { data } = await api.post('/player/deposit-orders', fd);
const result = data.data;
orderNo.value = result?.orderNo ?? '';
if (result?.id) trackPendingOrder(String(result.id));
success.value = true;
} catch (e: any) {
alert(e.response?.data?.message || t('recharge.submit_failed'));