feat(admin+api+player): 站内信手动发送、媒体库选择与发送记录详情
新增管理员手动发送站内信(三语富文本),支持发送记录查看与删除;修复全站「从媒体库选择」因分类过滤导致列表为空的问题;玩家端支持渲染管理员自定义消息;并优化后台列表页双层卡片布局。
This commit is contained in:
@@ -65,6 +65,7 @@ body::-webkit-scrollbar {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
gap: 14px;
|
||||||
}
|
}
|
||||||
.admin-list-page > .page-toolbar,
|
.admin-list-page > .page-toolbar,
|
||||||
.admin-list-page > .filter-card,
|
.admin-list-page > .filter-card,
|
||||||
@@ -78,16 +79,16 @@ body::-webkit-scrollbar {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin: 0 0 8px;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.admin-list-page > .tool-card {
|
.admin-list-page > .tool-card {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
.admin-list-page > .filter-card {
|
.admin-list-page > .filter-card {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
.admin-list-page > .list-chrome {
|
.admin-list-page > .list-chrome {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -204,7 +205,7 @@ body::-webkit-scrollbar {
|
|||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
.admin-list-page > .list-settings {
|
.admin-list-page > .list-settings {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
.list-settings :deep(.el-collapse-item__header) {
|
.list-settings :deep(.el-collapse-item__header) {
|
||||||
height: 36px;
|
height: 36px;
|
||||||
@@ -250,7 +251,16 @@ body::-webkit-scrollbar {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 0 12px 12px;
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* data-card 内不再嵌套第二层卡片边框,避免双层顶边重合 */
|
||||||
|
.admin-list-page > .data-card .table-wrap,
|
||||||
|
.admin-list-page > .data-card .admin-table-wrap {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
.admin-list-page .table-wrap {
|
.admin-list-page .table-wrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { ref } from 'vue';
|
|||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
import { fetchMediaLibraryImages } from '../utils/media-library';
|
||||||
|
import { resolveApiError } from '../i18n/form-validation';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -23,7 +25,7 @@ const MAX_UPLOAD_SIZE = 5 * 1024 * 1024;
|
|||||||
|
|
||||||
const uploading = ref(false);
|
const uploading = ref(false);
|
||||||
const mediaPickerVisible = ref(false);
|
const mediaPickerVisible = ref(false);
|
||||||
const mediaFiles = ref<Array<{ id: string; filename: string; url: string; mimeType: string }>>([]);
|
const mediaFiles = ref<Array<{ id: string; filename: string; url: string; mimeType: string; category?: string }>>([]);
|
||||||
const mediaLoading = ref(false);
|
const mediaLoading = ref(false);
|
||||||
|
|
||||||
async function uploadImage(file: File) {
|
async function uploadImage(file: File) {
|
||||||
@@ -67,12 +69,10 @@ async function openMediaPicker() {
|
|||||||
mediaPickerVisible.value = true;
|
mediaPickerVisible.value = true;
|
||||||
mediaLoading.value = true;
|
mediaLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/admin/files', {
|
mediaFiles.value = await fetchMediaLibraryImages({ pageSize: 200 });
|
||||||
params: { category: props.category, pageSize: 200 },
|
} catch (err: unknown) {
|
||||||
});
|
|
||||||
mediaFiles.value = res.data.data.items ?? [];
|
|
||||||
} catch {
|
|
||||||
mediaFiles.value = [];
|
mediaFiles.value = [];
|
||||||
|
ElMessage.error(resolveApiError(err, t, 'content.upload.load_media_failed'));
|
||||||
} finally {
|
} finally {
|
||||||
mediaLoading.value = false;
|
mediaLoading.value = false;
|
||||||
}
|
}
|
||||||
@@ -133,6 +133,7 @@ function pickMediaFile(url: string) {
|
|||||||
width="680px"
|
width="680px"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
append-to-body
|
append-to-body
|
||||||
|
:z-index="4000"
|
||||||
>
|
>
|
||||||
<div v-if="mediaLoading" class="media-state">{{ t('common.loading') }}</div>
|
<div v-if="mediaLoading" class="media-state">{{ t('common.loading') }}</div>
|
||||||
<div v-else-if="mediaFiles.length === 0" class="media-state">{{ t('content.upload.no_media') }}</div>
|
<div v-else-if="mediaFiles.length === 0" class="media-state">{{ t('content.upload.no_media') }}</div>
|
||||||
@@ -148,6 +149,7 @@ function pickMediaFile(url: string) {
|
|||||||
<div v-else class="media-svg">SVG</div>
|
<div v-else class="media-svg">SVG</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="media-name" :title="file.filename">{{ file.filename }}</div>
|
<div class="media-name" :title="file.filename">{{ file.filename }}</div>
|
||||||
|
<div v-if="file.category" class="media-category">{{ file.category }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -306,11 +308,18 @@ function pickMediaFile(url: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.media-name {
|
.media-name {
|
||||||
padding: 6px 8px;
|
padding: 6px 8px 2px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.media-category {
|
||||||
|
padding: 0 8px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
|||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
import { fetchMediaLibraryImages } from '../utils/media-library';
|
||||||
|
import { resolveApiError } from '../i18n/form-validation';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -27,7 +29,7 @@ const editorRef = ref<HTMLDivElement | null>(null);
|
|||||||
const mediaPickerVisible = ref(false);
|
const mediaPickerVisible = ref(false);
|
||||||
/** blob/object URL -> File,保存时由父组件调用 uploadPendingImages 上传 */
|
/** blob/object URL -> File,保存时由父组件调用 uploadPendingImages 上传 */
|
||||||
const pendingFiles = new Map<string, File>();
|
const pendingFiles = new Map<string, File>();
|
||||||
const mediaFiles = ref<Array<{ id: string; filename: string; url: string; mimeType: string }>>([]);
|
const mediaFiles = ref<Array<{ id: string; filename: string; url: string; mimeType: string; category?: string }>>([]);
|
||||||
const mediaLoading = ref(false);
|
const mediaLoading = ref(false);
|
||||||
const syncing = ref(false);
|
const syncing = ref(false);
|
||||||
let savedRange: Range | null = null;
|
let savedRange: Range | null = null;
|
||||||
@@ -241,12 +243,10 @@ async function openMediaPicker() {
|
|||||||
mediaPickerVisible.value = true;
|
mediaPickerVisible.value = true;
|
||||||
mediaLoading.value = true;
|
mediaLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/admin/files', {
|
mediaFiles.value = await fetchMediaLibraryImages({ pageSize: 200 });
|
||||||
params: { category: props.uploadCategory, pageSize: 200 },
|
} catch (err: unknown) {
|
||||||
});
|
|
||||||
mediaFiles.value = res.data.data.items ?? [];
|
|
||||||
} catch {
|
|
||||||
mediaFiles.value = [];
|
mediaFiles.value = [];
|
||||||
|
ElMessage.error(resolveApiError(err, t, 'content.upload.load_media_failed'));
|
||||||
} finally {
|
} finally {
|
||||||
mediaLoading.value = false;
|
mediaLoading.value = false;
|
||||||
}
|
}
|
||||||
@@ -352,6 +352,7 @@ onBeforeUnmount(() => {
|
|||||||
width="680px"
|
width="680px"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
append-to-body
|
append-to-body
|
||||||
|
:z-index="4000"
|
||||||
>
|
>
|
||||||
<div v-if="mediaLoading" class="media-state">{{ t('common.loading') }}</div>
|
<div v-if="mediaLoading" class="media-state">{{ t('common.loading') }}</div>
|
||||||
<div v-else-if="mediaFiles.length === 0" class="media-state">{{ t('content.upload.no_media') }}</div>
|
<div v-else-if="mediaFiles.length === 0" class="media-state">{{ t('content.upload.no_media') }}</div>
|
||||||
@@ -367,6 +368,7 @@ onBeforeUnmount(() => {
|
|||||||
<div v-else class="media-svg">SVG</div>
|
<div v-else class="media-svg">SVG</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="media-name" :title="file.filename">{{ file.filename }}</div>
|
<div class="media-name" :title="file.filename">{{ file.filename }}</div>
|
||||||
|
<div v-if="file.category" class="media-category">{{ file.category }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -521,11 +523,18 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.media-name {
|
.media-name {
|
||||||
padding: 6px 8px;
|
padding: 6px 8px 2px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.media-category {
|
||||||
|
padding: 0 8px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -820,6 +820,28 @@ export const adminPagesMs: Record<string, string> = {
|
|||||||
'content.inbox_notify.manual_title': 'Tanda "Notifikasi peti mesej" semasa mencipta:',
|
'content.inbox_notify.manual_title': 'Tanda "Notifikasi peti mesej" semasa mencipta:',
|
||||||
'content.inbox_notify.banner_note': 'Promosi laman utama',
|
'content.inbox_notify.banner_note': 'Promosi laman utama',
|
||||||
'content.inbox_notify.announcement_note': 'Notifikasi / ticker',
|
'content.inbox_notify.announcement_note': 'Notifikasi / ticker',
|
||||||
|
'content.inbox_broadcast.title': 'Hantar manual',
|
||||||
|
'content.inbox_broadcast.hint': 'Hantar mesej peti secara manual kepada pemain. Padam rekod juga membuang mesej di peti pemain.',
|
||||||
|
'content.inbox_broadcast.field_title': 'Tajuk',
|
||||||
|
'content.inbox_broadcast.field_body': 'Kandungan',
|
||||||
|
'content.inbox_broadcast.field_target': 'Penerima',
|
||||||
|
'content.inbox_broadcast.field_username': 'Nama pengguna pemain',
|
||||||
|
'content.inbox_broadcast.target_all': 'Semua pemain',
|
||||||
|
'content.inbox_broadcast.target_user': 'Pemain tertentu',
|
||||||
|
'content.inbox_broadcast.username_placeholder': 'Masukkan nama log masuk pemain',
|
||||||
|
'content.inbox_broadcast.send': 'Hantar',
|
||||||
|
'content.inbox_broadcast.send_success': 'Dihantar kepada {n} pemain',
|
||||||
|
'content.inbox_broadcast.form_invalid': 'Sila isi tajuk atau kandungan sekurang-kurangnya satu bahasa',
|
||||||
|
'content.inbox_broadcast.target_user_required': 'Nama pengguna pemain diperlukan',
|
||||||
|
'content.inbox_broadcast.history_title': 'Sejarah penghantaran',
|
||||||
|
'content.inbox_broadcast.view_title': 'Butiran penghantaran',
|
||||||
|
'content.inbox_broadcast.col_title': 'Tajuk',
|
||||||
|
'content.inbox_broadcast.col_target': 'Sasaran',
|
||||||
|
'content.inbox_broadcast.col_recipients': 'Bilangan',
|
||||||
|
'content.inbox_broadcast.col_sender': 'Penghantar',
|
||||||
|
'content.inbox_broadcast.col_time': 'Masa',
|
||||||
|
'content.inbox_broadcast.delete_confirm': 'Padam "{title}"? Salinan di peti pemain juga akan dibuang.',
|
||||||
|
'content.inbox_broadcast.locale_fallback_hint': 'Locale yang kosong akan guna susunan: bahasa pemain → Inggeris → Cina Ringkas → Melayu.',
|
||||||
'content.hint.banner': 'Dipaparkan dalam karusel laman utama; ketik untuk halaman butiran. Muat naik imej muka depan dan kandungan kaya seperti pengumuman laman rasmi.',
|
'content.hint.banner': 'Dipaparkan dalam karusel laman utama; ketik untuk halaman butiran. Muat naik imej muka depan dan kandungan kaya seperti pengumuman laman rasmi.',
|
||||||
'content.hint.announcement': 'Dipaparkan sebagai teks bergulir di bahagian atas aplikasi pemain. Isi tajuk dan teks bergulir setiap bahasa — teks biasa sahaja.',
|
'content.hint.announcement': 'Dipaparkan sebagai teks bergulir di bahagian atas aplikasi pemain. Isi tajuk dan teks bergulir setiap bahasa — teks biasa sahaja.',
|
||||||
'content.section.publish': 'Tetapan terbitan',
|
'content.section.publish': 'Tetapan terbitan',
|
||||||
@@ -837,6 +859,7 @@ export const adminPagesMs: Record<string, string> = {
|
|||||||
'content.upload.cover_size_hint': 'Lebar disyorkan 860px+. Imej muka depan dan dalam kandungan akan menyesuaikan lebar pada pemain.',
|
'content.upload.cover_size_hint': 'Lebar disyorkan 860px+. Imej muka depan dan dalam kandungan akan menyesuaikan lebar pada pemain.',
|
||||||
'content.upload.pick_media_title': 'Pilih imej',
|
'content.upload.pick_media_title': 'Pilih imej',
|
||||||
'content.upload.no_media': 'Tiada imej dalam pustaka — muat naik dahulu',
|
'content.upload.no_media': 'Tiada imej dalam pustaka — muat naik dahulu',
|
||||||
|
'content.upload.load_media_failed': 'Gagal memuat pustaka media',
|
||||||
'content.status.DRAFT': 'Draf',
|
'content.status.DRAFT': 'Draf',
|
||||||
'content.status.ACTIVE': 'Aktif',
|
'content.status.ACTIVE': 'Aktif',
|
||||||
'content.status.INACTIVE': 'Tidak aktif',
|
'content.status.INACTIVE': 'Tidak aktif',
|
||||||
|
|||||||
@@ -888,6 +888,7 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'content.upload.pick_media': '从媒体库选择',
|
'content.upload.pick_media': '从媒体库选择',
|
||||||
'content.upload.pick_media_title': '选择图片',
|
'content.upload.pick_media_title': '选择图片',
|
||||||
'content.upload.no_media': '媒体库中暂无图片,请先上传',
|
'content.upload.no_media': '媒体库中暂无图片,请先上传',
|
||||||
|
'content.upload.load_media_failed': '加载媒体库失败',
|
||||||
'content.upload.url_placeholder': '或手动粘贴图片 URL',
|
'content.upload.url_placeholder': '或手动粘贴图片 URL',
|
||||||
'content.upload.recommended_size': '建议尺寸:860 x 360 px,或 43:18 同比例图片;前台会完整显示并自动填充不合比例区域。',
|
'content.upload.recommended_size': '建议尺寸:860 x 360 px,或 43:18 同比例图片;前台会完整显示并自动填充不合比例区域。',
|
||||||
'content.link.none': '无跳转',
|
'content.link.none': '无跳转',
|
||||||
@@ -1987,6 +1988,7 @@ export const adminPagesEn: Record<string, string> = {
|
|||||||
'content.upload.pick_media': 'Pick from library',
|
'content.upload.pick_media': 'Pick from library',
|
||||||
'content.upload.pick_media_title': 'Select image',
|
'content.upload.pick_media_title': 'Select image',
|
||||||
'content.upload.no_media': 'No images in library — upload one first',
|
'content.upload.no_media': 'No images in library — upload one first',
|
||||||
|
'content.upload.load_media_failed': 'Failed to load media library',
|
||||||
'content.upload.url_placeholder': 'Or paste image URL',
|
'content.upload.url_placeholder': 'Or paste image URL',
|
||||||
'content.upload.recommended_size': 'Recommended size: 860 x 360 px, or any 43:18 image. The player carousel keeps the full image visible and fills extra space.',
|
'content.upload.recommended_size': 'Recommended size: 860 x 360 px, or any 43:18 image. The player carousel keeps the full image visible and fills extra space.',
|
||||||
'content.link.none': 'No link',
|
'content.link.none': 'No link',
|
||||||
|
|||||||
@@ -829,6 +829,7 @@ const adminPages: Record<string, string> = {
|
|||||||
|
|
||||||
'content.upload.pick_media_title': 'Select image',
|
'content.upload.pick_media_title': 'Select image',
|
||||||
'content.upload.no_media': 'No images in library — upload one first',
|
'content.upload.no_media': 'No images in library — upload one first',
|
||||||
|
'content.upload.load_media_failed': 'Failed to load media library',
|
||||||
'content.btn.create': 'New content',
|
'content.btn.create': 'New content',
|
||||||
'content.btn.enable': 'Enable',
|
'content.btn.enable': 'Enable',
|
||||||
'content.btn.disable': 'Disable',
|
'content.btn.disable': 'Disable',
|
||||||
@@ -849,6 +850,28 @@ const adminPages: Record<string, string> = {
|
|||||||
'content.inbox_notify.manual_title': 'Check "Inbox notify" when creating:',
|
'content.inbox_notify.manual_title': 'Check "Inbox notify" when creating:',
|
||||||
'content.inbox_notify.banner_note': 'Homepage promo',
|
'content.inbox_notify.banner_note': 'Homepage promo',
|
||||||
'content.inbox_notify.announcement_note': 'Announcements / ticker',
|
'content.inbox_notify.announcement_note': 'Announcements / ticker',
|
||||||
|
'content.inbox_broadcast.title': 'Manual send',
|
||||||
|
'content.inbox_broadcast.hint': 'Manually send inbox messages to players. Deleting a record also removes the message from player inboxes.',
|
||||||
|
'content.inbox_broadcast.field_title': 'Title',
|
||||||
|
'content.inbox_broadcast.field_body': 'Body',
|
||||||
|
'content.inbox_broadcast.field_target': 'Recipients',
|
||||||
|
'content.inbox_broadcast.field_username': 'Player username',
|
||||||
|
'content.inbox_broadcast.target_all': 'All players',
|
||||||
|
'content.inbox_broadcast.target_user': 'Single player',
|
||||||
|
'content.inbox_broadcast.username_placeholder': 'Enter player login username',
|
||||||
|
'content.inbox_broadcast.send': 'Send',
|
||||||
|
'content.inbox_broadcast.send_success': 'Sent to {n} player(s)',
|
||||||
|
'content.inbox_broadcast.form_invalid': 'Provide a title or body in at least one language',
|
||||||
|
'content.inbox_broadcast.target_user_required': 'Player username is required',
|
||||||
|
'content.inbox_broadcast.history_title': 'Send history',
|
||||||
|
'content.inbox_broadcast.view_title': 'Send details',
|
||||||
|
'content.inbox_broadcast.col_title': 'Title',
|
||||||
|
'content.inbox_broadcast.col_target': 'Target',
|
||||||
|
'content.inbox_broadcast.col_recipients': 'Count',
|
||||||
|
'content.inbox_broadcast.col_sender': 'Sender',
|
||||||
|
'content.inbox_broadcast.col_time': 'Sent at',
|
||||||
|
'content.inbox_broadcast.delete_confirm': 'Delete "{title}"? Player inbox copies will be removed too.',
|
||||||
|
'content.inbox_broadcast.locale_fallback_hint': 'Missing locales fall back in order: player language → English → Simplified Chinese → Malay.',
|
||||||
'content.hint.banner': 'Shown in the home carousel; tapping opens the detail page. Add a cover image and rich body like an official site announcement.',
|
'content.hint.banner': 'Shown in the home carousel; tapping opens the detail page. Add a cover image and rich body like an official site announcement.',
|
||||||
'content.hint.announcement': 'Shows as a scrolling marquee at the top of the player app. Enter title and marquee text per language — plain text only.',
|
'content.hint.announcement': 'Shows as a scrolling marquee at the top of the player app. Enter title and marquee text per language — plain text only.',
|
||||||
'content.section.publish': 'Publish settings',
|
'content.section.publish': 'Publish settings',
|
||||||
|
|||||||
@@ -847,6 +847,28 @@ const adminPages: Record<string, string> = {
|
|||||||
'content.inbox_notify.manual_title': '以下类型在新建时勾选「邮箱通知」',
|
'content.inbox_notify.manual_title': '以下类型在新建时勾选「邮箱通知」',
|
||||||
'content.inbox_notify.banner_note': '首页推广',
|
'content.inbox_notify.banner_note': '首页推广',
|
||||||
'content.inbox_notify.announcement_note': '通知公告 / 跑马灯',
|
'content.inbox_notify.announcement_note': '通知公告 / 跑马灯',
|
||||||
|
'content.inbox_broadcast.title': '手动发送',
|
||||||
|
'content.inbox_broadcast.hint': '手动向玩家发送站内信;删除记录将同时撤回玩家端对应消息。',
|
||||||
|
'content.inbox_broadcast.field_title': '标题',
|
||||||
|
'content.inbox_broadcast.field_body': '正文',
|
||||||
|
'content.inbox_broadcast.field_target': '发送对象',
|
||||||
|
'content.inbox_broadcast.field_username': '玩家账号',
|
||||||
|
'content.inbox_broadcast.target_all': '全部玩家',
|
||||||
|
'content.inbox_broadcast.target_user': '指定玩家',
|
||||||
|
'content.inbox_broadcast.username_placeholder': '输入玩家登录账号',
|
||||||
|
'content.inbox_broadcast.send': '发送',
|
||||||
|
'content.inbox_broadcast.send_success': '已发送给 {n} 位玩家',
|
||||||
|
'content.inbox_broadcast.form_invalid': '请至少填写一种语言的标题或正文',
|
||||||
|
'content.inbox_broadcast.target_user_required': '请填写玩家账号',
|
||||||
|
'content.inbox_broadcast.history_title': '发送记录',
|
||||||
|
'content.inbox_broadcast.view_title': '发送详情',
|
||||||
|
'content.inbox_broadcast.col_title': '标题',
|
||||||
|
'content.inbox_broadcast.col_target': '对象',
|
||||||
|
'content.inbox_broadcast.col_recipients': '人数',
|
||||||
|
'content.inbox_broadcast.col_sender': '发送人',
|
||||||
|
'content.inbox_broadcast.col_time': '发送时间',
|
||||||
|
'content.inbox_broadcast.delete_confirm': '确定删除「{title}」?玩家端对应消息将一并删除。',
|
||||||
|
'content.inbox_broadcast.locale_fallback_hint': '未填写的语言将按玩家语言 → 英文 → 简体中文 → 马来语顺序回退使用已有内容。',
|
||||||
'content.hint.banner': '用于首页轮播展示,点击后进入通知详情页;请填写封面图与正文,像官网发布活动通知一样编辑。',
|
'content.hint.banner': '用于首页轮播展示,点击后进入通知详情页;请填写封面图与正文,像官网发布活动通知一样编辑。',
|
||||||
'content.hint.announcement': '在玩家端顶部显示滚动跑马灯文字;填写各语言标题与滚动文案即可,纯文本无富文本。',
|
'content.hint.announcement': '在玩家端顶部显示滚动跑马灯文字;填写各语言标题与滚动文案即可,纯文本无富文本。',
|
||||||
'content.section.publish': '发布设置',
|
'content.section.publish': '发布设置',
|
||||||
@@ -864,6 +886,7 @@ const adminPages: Record<string, string> = {
|
|||||||
'content.upload.cover_size_hint': '建议宽度 860px 以上;玩家端详情页会完整显示封面,正文内图片也会自适应宽度。',
|
'content.upload.cover_size_hint': '建议宽度 860px 以上;玩家端详情页会完整显示封面,正文内图片也会自适应宽度。',
|
||||||
'content.upload.pick_media_title': '选择图片',
|
'content.upload.pick_media_title': '选择图片',
|
||||||
'content.upload.no_media': '媒体库中暂无图片,请先上传',
|
'content.upload.no_media': '媒体库中暂无图片,请先上传',
|
||||||
|
'content.upload.load_media_failed': '加载媒体库失败',
|
||||||
'content.status.DRAFT': '草稿',
|
'content.status.DRAFT': '草稿',
|
||||||
'content.status.ACTIVE': '已启用',
|
'content.status.ACTIVE': '已启用',
|
||||||
'content.status.INACTIVE': '已停用',
|
'content.status.INACTIVE': '已停用',
|
||||||
|
|||||||
@@ -10,3 +10,62 @@ export function stripHtml(html: string): string {
|
|||||||
export function isHtmlEmpty(html: string): boolean {
|
export function isHtmlEmpty(html: string): boolean {
|
||||||
return !stripHtml(html);
|
return !stripHtml(html);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ALLOWED_TAGS = new Set([
|
||||||
|
'p', 'br', 'strong', 'b', 'em', 'i', 'u', 'ul', 'ol', 'li',
|
||||||
|
'img', 'a', 'h2', 'h3', 'blockquote', 'div', 'span',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function sanitizeNode(node: Node): Node | null {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
return node.cloneNode(false);
|
||||||
|
}
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) return null;
|
||||||
|
|
||||||
|
const el = node as HTMLElement;
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
if (!ALLOWED_TAGS.has(tag)) {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const child of Array.from(el.childNodes)) {
|
||||||
|
const safe = sanitizeNode(child);
|
||||||
|
if (safe) frag.appendChild(safe);
|
||||||
|
}
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = document.createElement(tag);
|
||||||
|
if (tag === 'img') {
|
||||||
|
const src = el.getAttribute('src')?.trim();
|
||||||
|
if (!src || /^javascript:/i.test(src)) return null;
|
||||||
|
out.setAttribute('src', src);
|
||||||
|
const alt = el.getAttribute('alt');
|
||||||
|
if (alt) out.setAttribute('alt', alt);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (tag === 'a') {
|
||||||
|
const href = el.getAttribute('href')?.trim();
|
||||||
|
if (!href || /^javascript:/i.test(href)) return null;
|
||||||
|
out.setAttribute('href', href);
|
||||||
|
out.setAttribute('target', '_blank');
|
||||||
|
out.setAttribute('rel', 'noopener noreferrer');
|
||||||
|
}
|
||||||
|
for (const child of Array.from(el.childNodes)) {
|
||||||
|
const safe = sanitizeNode(child);
|
||||||
|
if (safe) out.appendChild(safe);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 富文本 HTML 白名单净化(公告/站内信预览) */
|
||||||
|
export function sanitizeAnnouncementHtml(html: string): string {
|
||||||
|
if (!html?.trim()) return '';
|
||||||
|
if (!/[<>]/.test(html)) return html;
|
||||||
|
|
||||||
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
const container = document.createElement('div');
|
||||||
|
for (const child of Array.from(doc.body.childNodes)) {
|
||||||
|
const safe = sanitizeNode(child);
|
||||||
|
if (safe) container.appendChild(safe);
|
||||||
|
}
|
||||||
|
return container.innerHTML;
|
||||||
|
}
|
||||||
|
|||||||
25
apps/admin/src/utils/media-library.ts
Normal file
25
apps/admin/src/utils/media-library.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
export type MediaLibraryItem = {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
url: string;
|
||||||
|
mimeType: string;
|
||||||
|
category?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 媒体库图片(默认全部分类,供「从媒体库选择」使用) */
|
||||||
|
export async function fetchMediaLibraryImages(opts?: {
|
||||||
|
category?: string;
|
||||||
|
pageSize?: number;
|
||||||
|
}): Promise<MediaLibraryItem[]> {
|
||||||
|
const params: Record<string, string | number> = {
|
||||||
|
pageSize: opts?.pageSize ?? 200,
|
||||||
|
imagesOnly: '1',
|
||||||
|
};
|
||||||
|
if (opts?.category?.trim()) {
|
||||||
|
params.category = opts.category.trim();
|
||||||
|
}
|
||||||
|
const { data } = await api.get('/admin/files', { params });
|
||||||
|
return (data.data?.items ?? []) as MediaLibraryItem[];
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import api from '../api';
|
|||||||
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
||||||
import ContentImageField from '../components/ContentImageField.vue';
|
import ContentImageField from '../components/ContentImageField.vue';
|
||||||
import ContentRichEditor from '../components/ContentRichEditor.vue';
|
import ContentRichEditor from '../components/ContentRichEditor.vue';
|
||||||
import { stripHtml } from '../utils/html';
|
import { stripHtml, sanitizeAnnouncementHtml, isHtmlEmpty } from '../utils/html';
|
||||||
import {
|
import {
|
||||||
normalizeStartTimeForApi,
|
normalizeStartTimeForApi,
|
||||||
normalizeStartTimeForPicker,
|
normalizeStartTimeForPicker,
|
||||||
@@ -77,12 +77,65 @@ interface InboxNotifySettings {
|
|||||||
deposit: boolean;
|
deposit: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MessageBroadcastItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
translations?: Record<string, { title: string; body: string }>;
|
||||||
|
targetType: 'ALL' | 'USER';
|
||||||
|
targetUserId: string | null;
|
||||||
|
targetUsername: string | null;
|
||||||
|
recipientCount: number;
|
||||||
|
createdById: string | null;
|
||||||
|
createdByUsername: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BroadcastTranslationForm {
|
||||||
|
locale: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
const inboxNotifySettings = ref<InboxNotifySettings>({
|
const inboxNotifySettings = ref<InboxNotifySettings>({
|
||||||
inboxEnabled: true,
|
inboxEnabled: true,
|
||||||
deposit: true,
|
deposit: true,
|
||||||
});
|
});
|
||||||
const inboxNotifySaving = ref(false);
|
const inboxNotifySaving = ref(false);
|
||||||
|
|
||||||
|
const broadcastLoading = ref(false);
|
||||||
|
const broadcastSending = ref(false);
|
||||||
|
const broadcastDialogVisible = ref(false);
|
||||||
|
const broadcastActiveLocale = ref<string>('zh-CN');
|
||||||
|
const broadcastEditorRef = ref<InstanceType<typeof ContentRichEditor> | null>(null);
|
||||||
|
const broadcastItems = ref<MessageBroadcastItem[]>([]);
|
||||||
|
const broadcastTotal = ref(0);
|
||||||
|
const broadcastPage = ref(1);
|
||||||
|
const broadcastPageSize = ref(10);
|
||||||
|
const broadcastDetailVisible = ref(false);
|
||||||
|
const broadcastDetailRow = ref<MessageBroadcastItem | null>(null);
|
||||||
|
const broadcastDetailLocale = ref<string>('zh-CN');
|
||||||
|
|
||||||
|
function emptyBroadcastTranslations(): BroadcastTranslationForm[] {
|
||||||
|
return LOCALES.map((locale) => ({
|
||||||
|
locale,
|
||||||
|
title: '',
|
||||||
|
body: '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const broadcastForm = ref({
|
||||||
|
targetType: 'ALL' as 'ALL' | 'USER',
|
||||||
|
targetUsername: '',
|
||||||
|
translations: emptyBroadcastTranslations(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const broadcastActiveTranslation = computed(
|
||||||
|
() =>
|
||||||
|
broadcastForm.value.translations.find((tr) => tr.locale === broadcastActiveLocale.value) ??
|
||||||
|
broadcastForm.value.translations[0],
|
||||||
|
);
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
status: 'DRAFT' as ContentStatus,
|
status: 'DRAFT' as ContentStatus,
|
||||||
@@ -194,6 +247,163 @@ async function saveInboxNotifySettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadBroadcasts() {
|
||||||
|
broadcastLoading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/admin/player-message-broadcasts', {
|
||||||
|
params: { page: broadcastPage.value, pageSize: broadcastPageSize.value },
|
||||||
|
});
|
||||||
|
broadcastItems.value = data.data?.items ?? [];
|
||||||
|
broadcastTotal.value = data.data?.total ?? 0;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: { error?: string } } };
|
||||||
|
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
|
||||||
|
} finally {
|
||||||
|
broadcastLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetBroadcastForm() {
|
||||||
|
broadcastForm.value = {
|
||||||
|
targetType: 'ALL',
|
||||||
|
targetUsername: '',
|
||||||
|
translations: emptyBroadcastTranslations(),
|
||||||
|
};
|
||||||
|
broadcastActiveLocale.value = 'zh-CN';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasBroadcastContent() {
|
||||||
|
return broadcastForm.value.translations.some(
|
||||||
|
(tr) => tr.title.trim() || tr.body.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBroadcastDialog() {
|
||||||
|
resetBroadcastForm();
|
||||||
|
broadcastDialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBroadcastDialog() {
|
||||||
|
if (broadcastSending.value) return;
|
||||||
|
broadcastDialogVisible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendBroadcast() {
|
||||||
|
if (!canManageContent.value) return;
|
||||||
|
|
||||||
|
const editor = broadcastEditorRef.value;
|
||||||
|
if (editor) {
|
||||||
|
broadcastActiveTranslation.value.body = editor.getHtml();
|
||||||
|
for (const tr of broadcastForm.value.translations) {
|
||||||
|
tr.body = await editor.uploadPendingImages(tr.body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasBroadcastContent()) {
|
||||||
|
ElMessage.warning(t('content.inbox_broadcast.form_invalid'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (broadcastForm.value.targetType === 'USER' && !broadcastForm.value.targetUsername.trim()) {
|
||||||
|
ElMessage.warning(t('content.inbox_broadcast.target_user_required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
broadcastSending.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/admin/player-message-broadcasts', {
|
||||||
|
translations: broadcastForm.value.translations.map((tr) => ({
|
||||||
|
locale: tr.locale,
|
||||||
|
title: tr.title.trim() || undefined,
|
||||||
|
body: tr.body.trim() || undefined,
|
||||||
|
})),
|
||||||
|
targetType: broadcastForm.value.targetType,
|
||||||
|
targetUsername:
|
||||||
|
broadcastForm.value.targetType === 'USER'
|
||||||
|
? broadcastForm.value.targetUsername.trim()
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
ElMessage.success(
|
||||||
|
t('content.inbox_broadcast.send_success', {
|
||||||
|
n: data.data?.recipientCount ?? 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
resetBroadcastForm();
|
||||||
|
broadcastPage.value = 1;
|
||||||
|
broadcastDialogVisible.value = false;
|
||||||
|
await loadBroadcasts();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: { error?: string } } };
|
||||||
|
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||||
|
} finally {
|
||||||
|
broadcastSending.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastTargetLabel(row: MessageBroadcastItem) {
|
||||||
|
if (row.targetType === 'ALL') return t('content.inbox_broadcast.target_all');
|
||||||
|
return row.targetUsername || `#${row.targetUserId ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBroadcastTime(value: string) {
|
||||||
|
if (!value) return '—';
|
||||||
|
return new Date(value).toLocaleString(localeTag.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBroadcastDetail(row: MessageBroadcastItem) {
|
||||||
|
broadcastDetailRow.value = row;
|
||||||
|
const tr = row.translations ?? {};
|
||||||
|
const firstWithContent =
|
||||||
|
LOCALES.find((locale) => {
|
||||||
|
const item = tr[locale];
|
||||||
|
return item && (item.title?.trim() || !isHtmlEmpty(item.body));
|
||||||
|
}) ?? 'zh-CN';
|
||||||
|
broadcastDetailLocale.value = firstWithContent;
|
||||||
|
broadcastDetailVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastDetailTranslation(locale: string) {
|
||||||
|
const row = broadcastDetailRow.value;
|
||||||
|
if (!row) return { title: '', body: '' };
|
||||||
|
return row.translations?.[locale] ?? { title: '', body: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBroadcast(row: MessageBroadcastItem) {
|
||||||
|
if (!canManageContent.value) return;
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
t('content.inbox_broadcast.delete_confirm', { title: row.title }),
|
||||||
|
t('common.confirm'),
|
||||||
|
{ type: 'warning' },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
broadcastLoading.value = true;
|
||||||
|
try {
|
||||||
|
await api.delete(`/admin/player-message-broadcasts/${row.id}`);
|
||||||
|
ElMessage.success(t('msg.saved'));
|
||||||
|
if (broadcastItems.value.length === 1 && broadcastPage.value > 1) {
|
||||||
|
broadcastPage.value -= 1;
|
||||||
|
}
|
||||||
|
await loadBroadcasts();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: { error?: string } } };
|
||||||
|
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||||
|
} finally {
|
||||||
|
broadcastLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBroadcastPageChange(page: number) {
|
||||||
|
broadcastPage.value = page;
|
||||||
|
void loadBroadcasts();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBroadcastSizeChange(size: number) {
|
||||||
|
broadcastPageSize.value = size;
|
||||||
|
broadcastPage.value = 1;
|
||||||
|
void loadBroadcasts();
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -234,13 +444,18 @@ watch([activeType, filterStatus], () => {
|
|||||||
tableRef.value?.clearSelection();
|
tableRef.value?.clearSelection();
|
||||||
if (activeType.value === 'INBOX_NOTIFY') {
|
if (activeType.value === 'INBOX_NOTIFY') {
|
||||||
void loadInboxNotifySettings();
|
void loadInboxNotifySettings();
|
||||||
|
void loadBroadcasts();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void load();
|
void load();
|
||||||
});
|
});
|
||||||
|
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
if (activeType.value === 'INBOX_NOTIFY') return;
|
if (activeType.value === 'INBOX_NOTIFY') {
|
||||||
|
void loadInboxNotifySettings();
|
||||||
|
if (broadcastItems.value.length > 0) void loadBroadcasts();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (items.value.length > 0) void load();
|
if (items.value.length > 0) void load();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -575,6 +790,18 @@ void load();
|
|||||||
<li>{{ t('content.inbox_notify.banner_note') }}</li>
|
<li>{{ t('content.inbox_notify.banner_note') }}</li>
|
||||||
<li>{{ t('content.inbox_notify.announcement_note') }}</li>
|
<li>{{ t('content.inbox_notify.announcement_note') }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<div v-if="inboxNotifySettings.inboxEnabled" class="inbox-toolbar">
|
||||||
|
<el-button
|
||||||
|
v-if="canManageContent"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="openBroadcastDialog"
|
||||||
|
>
|
||||||
|
{{ t('content.inbox_broadcast.title') }}
|
||||||
|
</el-button>
|
||||||
|
<span class="inbox-toolbar-hint">{{ t('content.inbox_broadcast.hint') }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -613,6 +840,79 @@ void load();
|
|||||||
</el-form>
|
</el-form>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<el-card
|
||||||
|
v-if="isInboxNotifyTab && inboxNotifySettings.inboxEnabled"
|
||||||
|
v-loading="broadcastLoading"
|
||||||
|
class="data-card"
|
||||||
|
shadow="never"
|
||||||
|
>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<el-table :data="broadcastItems" row-key="id" stripe size="small">
|
||||||
|
<template #empty>
|
||||||
|
<AdminTableEmpty />
|
||||||
|
</template>
|
||||||
|
<el-table-column
|
||||||
|
type="index"
|
||||||
|
:index="(i: number) => (broadcastPage - 1) * broadcastPageSize + i + 1"
|
||||||
|
:label="t('common.seq')"
|
||||||
|
width="70"
|
||||||
|
align="center"
|
||||||
|
/>
|
||||||
|
<el-table-column :label="t('content.inbox_broadcast.col_title')" min-width="160" prop="title" />
|
||||||
|
<el-table-column :label="t('content.inbox_broadcast.col_target')" width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ broadcastTargetLabel(row) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
:label="t('content.inbox_broadcast.col_recipients')"
|
||||||
|
width="90"
|
||||||
|
align="center"
|
||||||
|
prop="recipientCount"
|
||||||
|
/>
|
||||||
|
<el-table-column :label="t('content.inbox_broadcast.col_sender')" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.createdByUsername || '—' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('content.inbox_broadcast.col_time')" width="170">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatBroadcastTime(row.createdAt) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('common.actions')" width="130" align="center" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link size="small" @click="openBroadcastDetail(row)">
|
||||||
|
{{ t('common.detail') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="canManageContent"
|
||||||
|
type="danger"
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
@click="deleteBroadcast(row)"
|
||||||
|
>
|
||||||
|
{{ t('common.delete') }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
<div v-if="broadcastTotal > 0" class="pager-row">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="broadcastPage"
|
||||||
|
v-model:page-size="broadcastPageSize"
|
||||||
|
:total="broadcastTotal"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
background
|
||||||
|
small
|
||||||
|
@current-change="onBroadcastPageChange"
|
||||||
|
@size-change="onBroadcastSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
<el-card v-if="!isInboxNotifyTab" v-loading="loading" class="data-card" shadow="never">
|
<el-card v-if="!isInboxNotifyTab" v-loading="loading" class="data-card" shadow="never">
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<el-table
|
<el-table
|
||||||
@@ -966,6 +1266,140 @@ void load();
|
|||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="broadcastDetailVisible"
|
||||||
|
:title="t('content.inbox_broadcast.view_title')"
|
||||||
|
width="min(760px, 96vw)"
|
||||||
|
destroy-on-close
|
||||||
|
append-to-body
|
||||||
|
class="inbox-broadcast-detail-dialog"
|
||||||
|
>
|
||||||
|
<template v-if="broadcastDetailRow">
|
||||||
|
<dl class="broadcast-detail-meta">
|
||||||
|
<div class="meta-row">
|
||||||
|
<dt>{{ t('content.inbox_broadcast.col_target') }}</dt>
|
||||||
|
<dd>{{ broadcastTargetLabel(broadcastDetailRow) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<dt>{{ t('content.inbox_broadcast.col_recipients') }}</dt>
|
||||||
|
<dd>{{ broadcastDetailRow.recipientCount }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<dt>{{ t('content.inbox_broadcast.col_sender') }}</dt>
|
||||||
|
<dd>{{ broadcastDetailRow.createdByUsername || '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<dt>{{ t('content.inbox_broadcast.col_time') }}</dt>
|
||||||
|
<dd>{{ formatBroadcastTime(broadcastDetailRow.createdAt) }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<el-tabs v-model="broadcastDetailLocale" class="locale-tabs">
|
||||||
|
<el-tab-pane
|
||||||
|
v-for="locale in LOCALES"
|
||||||
|
:key="locale"
|
||||||
|
:label="localeLabel(locale)"
|
||||||
|
:name="locale"
|
||||||
|
>
|
||||||
|
<div class="broadcast-detail-block">
|
||||||
|
<div class="broadcast-detail-label">{{ t('content.inbox_broadcast.field_title') }}</div>
|
||||||
|
<div class="broadcast-detail-title">
|
||||||
|
{{ broadcastDetailTranslation(locale).title || '—' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="broadcast-detail-block">
|
||||||
|
<div class="broadcast-detail-label">{{ t('content.inbox_broadcast.field_body') }}</div>
|
||||||
|
<div
|
||||||
|
v-if="!isHtmlEmpty(broadcastDetailTranslation(locale).body)"
|
||||||
|
class="broadcast-detail-body rich-html"
|
||||||
|
v-html="sanitizeAnnouncementHtml(broadcastDetailTranslation(locale).body)"
|
||||||
|
/>
|
||||||
|
<div v-else class="broadcast-detail-empty">—</div>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="broadcastDetailVisible = false">{{ t('common.close') }}</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="broadcastDialogVisible"
|
||||||
|
:title="t('content.inbox_broadcast.title')"
|
||||||
|
width="min(920px, 96vw)"
|
||||||
|
destroy-on-close
|
||||||
|
:close-on-click-modal="!broadcastSending"
|
||||||
|
class="inbox-broadcast-dialog content-publish-dialog"
|
||||||
|
@close="closeBroadcastDialog"
|
||||||
|
>
|
||||||
|
<el-form label-position="top" class="inbox-broadcast-form" @submit.prevent>
|
||||||
|
<el-form-item :label="t('content.inbox_broadcast.field_target')">
|
||||||
|
<el-radio-group v-model="broadcastForm.targetType" :disabled="broadcastSending">
|
||||||
|
<el-radio value="ALL">{{ t('content.inbox_broadcast.target_all') }}</el-radio>
|
||||||
|
<el-radio value="USER">{{ t('content.inbox_broadcast.target_user') }}</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item
|
||||||
|
v-if="broadcastForm.targetType === 'USER'"
|
||||||
|
:label="t('content.inbox_broadcast.field_username')"
|
||||||
|
>
|
||||||
|
<el-input
|
||||||
|
v-model="broadcastForm.targetUsername"
|
||||||
|
:placeholder="t('content.inbox_broadcast.username_placeholder')"
|
||||||
|
:disabled="broadcastSending"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<section class="broadcast-content-section">
|
||||||
|
<div class="broadcast-content-head">
|
||||||
|
<h3 class="section-title">{{ t('content.section.content') }}</h3>
|
||||||
|
<p class="field-hint">{{ t('content.inbox_broadcast.locale_fallback_hint') }}</p>
|
||||||
|
</div>
|
||||||
|
<el-tabs v-model="broadcastActiveLocale" class="locale-tabs">
|
||||||
|
<el-tab-pane
|
||||||
|
v-for="tr in broadcastForm.translations"
|
||||||
|
:key="tr.locale"
|
||||||
|
:label="localeLabel(tr.locale)"
|
||||||
|
:name="tr.locale"
|
||||||
|
>
|
||||||
|
<el-form-item :label="t('content.inbox_broadcast.field_title')">
|
||||||
|
<el-input
|
||||||
|
v-model="tr.title"
|
||||||
|
maxlength="256"
|
||||||
|
show-word-limit
|
||||||
|
:placeholder="t('content.field.title_ph')"
|
||||||
|
:disabled="broadcastSending"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
|
||||||
|
<div class="broadcast-editor-head">
|
||||||
|
<span class="publish-body-label">{{ t('content.inbox_broadcast.field_body') }}</span>
|
||||||
|
<span class="locale-badge">{{ localeLabel(broadcastActiveLocale) }}</span>
|
||||||
|
</div>
|
||||||
|
<ContentRichEditor
|
||||||
|
ref="broadcastEditorRef"
|
||||||
|
v-model="broadcastActiveTranslation.body"
|
||||||
|
fill
|
||||||
|
upload-category="contents"
|
||||||
|
:placeholder="t('content.editor.placeholder')"
|
||||||
|
:disabled="broadcastSending"
|
||||||
|
class="broadcast-rich-editor"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button :disabled="broadcastSending" @click="closeBroadcastDialog">
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" :loading="broadcastSending" @click="sendBroadcast">
|
||||||
|
{{ t('content.inbox_broadcast.send') }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -1323,4 +1757,127 @@ void load();
|
|||||||
.inbox-notify-notes li {
|
.inbox-notify-notes li {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inbox-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox-toolbar-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox-broadcast-form :deep(.el-form-item) {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broadcast-content-section {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broadcast-content-head {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broadcast-content-head .section-title {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broadcast-editor-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 12px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broadcast-rich-editor {
|
||||||
|
min-height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox-broadcast-dialog :deep(.el-dialog__body) {
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broadcast-detail-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px 16px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fbfaf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row dt {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broadcast-detail-block + .broadcast-detail-block {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broadcast-detail-label {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.broadcast-detail-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.45;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broadcast-detail-body,
|
||||||
|
.broadcast-detail-empty {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broadcast-detail-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-html :deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-html :deep(p) {
|
||||||
|
margin: 0 0 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-html :deep(p:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "player_message_broadcasts" (
|
||||||
|
"id" BIGSERIAL NOT NULL,
|
||||||
|
"title" VARCHAR(256) NOT NULL,
|
||||||
|
"body" TEXT NOT NULL,
|
||||||
|
"target_type" VARCHAR(16) NOT NULL,
|
||||||
|
"target_user_id" BIGINT,
|
||||||
|
"target_username" VARCHAR(64),
|
||||||
|
"recipient_count" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"created_by_id" BIGINT,
|
||||||
|
"created_by_username" VARCHAR(64),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "player_message_broadcasts_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "player_messages" ADD COLUMN "broadcast_id" BIGINT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "player_message_broadcasts_created_at_idx" ON "player_message_broadcasts"("created_at" DESC);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "player_messages_broadcast_id_idx" ON "player_messages"("broadcast_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "player_messages" ADD CONSTRAINT "player_messages_broadcast_id_fkey" FOREIGN KEY ("broadcast_id") REFERENCES "player_message_broadcasts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "player_message_broadcasts" ADD COLUMN "translations" JSONB;
|
||||||
|
|
||||||
|
-- Backfill existing rows as English content
|
||||||
|
UPDATE "player_message_broadcasts"
|
||||||
|
SET "translations" = jsonb_build_object(
|
||||||
|
'en-US', jsonb_build_object('title', "title", 'body', "body")
|
||||||
|
)
|
||||||
|
WHERE "translations" IS NULL;
|
||||||
@@ -822,16 +822,38 @@ model PlayerMessage {
|
|||||||
title String @db.VarChar(256)
|
title String @db.VarChar(256)
|
||||||
body String @db.Text
|
body String @db.Text
|
||||||
payload Json?
|
payload Json?
|
||||||
|
broadcastId BigInt? @map("broadcast_id")
|
||||||
readAt DateTime? @map("read_at")
|
readAt DateTime? @map("read_at")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
broadcast PlayerMessageBroadcast? @relation(fields: [broadcastId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([userId, createdAt(sort: Desc)])
|
@@index([userId, createdAt(sort: Desc)])
|
||||||
@@index([userId, readAt])
|
@@index([userId, readAt])
|
||||||
|
@@index([broadcastId])
|
||||||
@@map("player_messages")
|
@@map("player_messages")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model PlayerMessageBroadcast {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
title String @db.VarChar(256)
|
||||||
|
body String @db.Text
|
||||||
|
translations Json? @map("translations")
|
||||||
|
targetType String @map("target_type") @db.VarChar(16)
|
||||||
|
targetUserId BigInt? @map("target_user_id")
|
||||||
|
targetUsername String? @map("target_username") @db.VarChar(64)
|
||||||
|
recipientCount Int @default(0) @map("recipient_count")
|
||||||
|
createdById BigInt? @map("created_by_id")
|
||||||
|
createdByUsername String? @map("created_by_username") @db.VarChar(64)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
messages PlayerMessage[]
|
||||||
|
|
||||||
|
@@index([createdAt(sort: Desc)])
|
||||||
|
@@map("player_message_broadcasts")
|
||||||
|
}
|
||||||
|
|
||||||
// ============ System Config & Audit ============
|
// ============ System Config & Audit ============
|
||||||
|
|
||||||
model SystemConfig {
|
model SystemConfig {
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import {
|
|||||||
IsIn,
|
IsIn,
|
||||||
Min,
|
Min,
|
||||||
Max,
|
Max,
|
||||||
|
MaxLength,
|
||||||
Equals,
|
Equals,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
@@ -1111,6 +1112,33 @@ class InboxNotifySettingsDto {
|
|||||||
deposit?: boolean;
|
deposit?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class BroadcastTranslationDto {
|
||||||
|
@IsString()
|
||||||
|
locale!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(256)
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
body?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CreatePlayerMessageBroadcastDto {
|
||||||
|
@IsArray()
|
||||||
|
translations!: BroadcastTranslationDto[];
|
||||||
|
|
||||||
|
@IsIn(['ALL', 'USER'])
|
||||||
|
targetType!: 'ALL' | 'USER';
|
||||||
|
|
||||||
|
@ValidateIf((dto: CreatePlayerMessageBroadcastDto) => dto.targetType === 'USER')
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
targetUsername?: string;
|
||||||
|
}
|
||||||
|
|
||||||
class UpdateDepositCleanupConfigDto {
|
class UpdateDepositCleanupConfigDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@@ -3102,8 +3130,24 @@ export class AdminController {
|
|||||||
@Query('category') category?: string,
|
@Query('category') category?: string,
|
||||||
@Query('page') page?: string,
|
@Query('page') page?: string,
|
||||||
@Query('pageSize') pageSize?: string,
|
@Query('pageSize') pageSize?: string,
|
||||||
|
@Query('imagesOnly') imagesOnly?: string,
|
||||||
) {
|
) {
|
||||||
const where = category && UPLOAD_CATEGORIES.includes(category as any) ? { category } : {};
|
const where: {
|
||||||
|
category?: string | { in: string[] };
|
||||||
|
mimeType?: { startsWith: string };
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
if (category && UPLOAD_CATEGORIES.includes(category as UploadCategory)) {
|
||||||
|
where.category = category;
|
||||||
|
} else if (imagesOnly === '1' || imagesOnly === 'true') {
|
||||||
|
// 媒体库选择器:默认可选 banners/teams/contents/payments,不含 deposits
|
||||||
|
where.category = { in: ['banners', 'teams', 'contents', 'payments'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imagesOnly === '1' || imagesOnly === 'true') {
|
||||||
|
where.mimeType = { startsWith: 'image/' };
|
||||||
|
}
|
||||||
|
|
||||||
const take = Math.min(parseInt(pageSize ?? '50', 10) || 50, 200);
|
const take = Math.min(parseInt(pageSize ?? '50', 10) || 50, 200);
|
||||||
const skip = (Math.max(parseInt(page ?? '1', 10) || 1, 1) - 1) * take;
|
const skip = (Math.max(parseInt(page ?? '1', 10) || 1, 1) - 1) * take;
|
||||||
|
|
||||||
@@ -3310,6 +3354,68 @@ export class AdminController {
|
|||||||
return jsonResponse(settings);
|
return jsonResponse(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('player-message-broadcasts')
|
||||||
|
@RequirePermissions(P.content, P.reports)
|
||||||
|
async listPlayerMessageBroadcasts(
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
const p = Math.max(1, page ? parseInt(page, 10) || 1 : 1);
|
||||||
|
const size = Math.min(Math.max(1, pageSize ? parseInt(pageSize, 10) : 20), 50);
|
||||||
|
const result = await this.playerMessages.listBroadcasts(p, size);
|
||||||
|
return jsonResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('player-message-broadcasts')
|
||||||
|
@RequirePermissions(P.content)
|
||||||
|
async createPlayerMessageBroadcast(
|
||||||
|
@CurrentUser('id') operatorId: bigint,
|
||||||
|
@Body() dto: CreatePlayerMessageBroadcastDto,
|
||||||
|
) {
|
||||||
|
const operator = await this.prisma.user.findUnique({
|
||||||
|
where: { id: operatorId },
|
||||||
|
select: { username: true },
|
||||||
|
});
|
||||||
|
const item = await this.playerMessages.createCustomBroadcast({
|
||||||
|
translations: dto.translations,
|
||||||
|
targetType: dto.targetType,
|
||||||
|
targetUsername: dto.targetUsername,
|
||||||
|
createdById: operatorId,
|
||||||
|
createdByUsername: operator?.username ?? null,
|
||||||
|
});
|
||||||
|
await this.audit.log({
|
||||||
|
operatorId,
|
||||||
|
operatorType: 'ADMIN',
|
||||||
|
action: 'SEND_PLAYER_MESSAGE_BROADCAST',
|
||||||
|
module: 'CONTENT',
|
||||||
|
afterData: JSON.stringify({
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
targetType: item.targetType,
|
||||||
|
recipientCount: item.recipientCount,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return jsonResponse(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('player-message-broadcasts/:id')
|
||||||
|
@RequirePermissions(P.content)
|
||||||
|
async deletePlayerMessageBroadcast(
|
||||||
|
@CurrentUser('id') operatorId: bigint,
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
const result = await this.playerMessages.deleteBroadcast(BigInt(id));
|
||||||
|
await this.audit.log({
|
||||||
|
operatorId,
|
||||||
|
operatorType: 'ADMIN',
|
||||||
|
action: 'DELETE_PLAYER_MESSAGE_BROADCAST',
|
||||||
|
module: 'CONTENT',
|
||||||
|
targetId: id,
|
||||||
|
afterData: JSON.stringify(result),
|
||||||
|
});
|
||||||
|
return jsonResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('contents')
|
@Get('contents')
|
||||||
@RequirePermissions(P.content, P.reports)
|
@RequirePermissions(P.content, P.reports)
|
||||||
async listContents(
|
async listContents(
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { PlayerMessagesService } from './player-messages.service';
|
import { PlayerMessagesService } from './player-messages.service';
|
||||||
|
|
||||||
describe('PlayerMessagesService', () => {
|
describe('PlayerMessagesService', () => {
|
||||||
const prisma = {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const prisma: any = {
|
||||||
playerMessage: {
|
playerMessage: {
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
@@ -13,10 +14,19 @@ describe('PlayerMessagesService', () => {
|
|||||||
deleteMany: jest.fn(),
|
deleteMany: jest.fn(),
|
||||||
createMany: jest.fn(),
|
createMany: jest.fn(),
|
||||||
},
|
},
|
||||||
|
playerMessageBroadcast: {
|
||||||
|
findMany: jest.fn(),
|
||||||
|
count: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
},
|
||||||
user: {
|
user: {
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
|
findFirst: jest.fn(),
|
||||||
},
|
},
|
||||||
|
$transaction: jest.fn(async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma)),
|
||||||
};
|
};
|
||||||
|
|
||||||
let service: PlayerMessagesService;
|
let service: PlayerMessagesService;
|
||||||
@@ -162,4 +172,58 @@ describe('PlayerMessagesService', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('creates custom broadcast to all active players', async () => {
|
||||||
|
prisma.user.findMany.mockResolvedValue([
|
||||||
|
{ id: 1n, username: 'p1', locale: 'zh-CN', preferences: { locale: 'zh-CN' } },
|
||||||
|
{ id: 2n, username: 'p2', locale: 'en-US', preferences: null },
|
||||||
|
]);
|
||||||
|
prisma.playerMessageBroadcast.create.mockResolvedValue({
|
||||||
|
id: 9n,
|
||||||
|
title: 'Hello',
|
||||||
|
body: '<p>World</p>',
|
||||||
|
translations: {
|
||||||
|
'en-US': { title: 'Hello', body: '<p>World</p>' },
|
||||||
|
'zh-CN': { title: '你好', body: '<p>内容</p>' },
|
||||||
|
},
|
||||||
|
targetType: 'ALL',
|
||||||
|
targetUserId: null,
|
||||||
|
targetUsername: null,
|
||||||
|
recipientCount: 2,
|
||||||
|
createdById: 99n,
|
||||||
|
createdByUsername: 'admin',
|
||||||
|
createdAt: new Date('2026-06-22T10:00:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.createCustomBroadcast({
|
||||||
|
translations: [
|
||||||
|
{ locale: 'en-US', title: 'Hello', body: '<p>World</p>' },
|
||||||
|
{ locale: 'zh-CN', title: '你好', body: '<p>内容</p>' },
|
||||||
|
],
|
||||||
|
targetType: 'ALL',
|
||||||
|
createdById: 99n,
|
||||||
|
createdByUsername: 'admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.recipientCount).toBe(2);
|
||||||
|
expect(prisma.playerMessage.createMany).toHaveBeenCalledWith({
|
||||||
|
data: [
|
||||||
|
expect.objectContaining({ userId: 1n, type: 'ADMIN_CUSTOM', title: '你好', broadcastId: 9n }),
|
||||||
|
expect.objectContaining({ userId: 2n, type: 'ADMIN_CUSTOM', title: 'Hello', broadcastId: 9n }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes broadcast and cascades player messages', async () => {
|
||||||
|
prisma.playerMessageBroadcast.findUnique.mockResolvedValue({
|
||||||
|
id: 5n,
|
||||||
|
recipientCount: 3,
|
||||||
|
});
|
||||||
|
prisma.playerMessageBroadcast.delete.mockResolvedValue({ id: 5n });
|
||||||
|
|
||||||
|
const result = await service.deleteBroadcast(5n);
|
||||||
|
|
||||||
|
expect(result).toEqual({ deleted: true, recipientCount: 3 });
|
||||||
|
expect(prisma.playerMessageBroadcast.delete).toHaveBeenCalledWith({ where: { id: 5n } });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||||
import { appNotFound } from '../../shared/common/app-error';
|
import { appBadRequest, appNotFound } from '../../shared/common/app-error';
|
||||||
|
|
||||||
export type PlayerMessageType =
|
export type PlayerMessageType =
|
||||||
| 'DEPOSIT_APPROVED'
|
| 'DEPOSIT_APPROVED'
|
||||||
| 'DEPOSIT_REJECTED'
|
| 'DEPOSIT_REJECTED'
|
||||||
| 'BANNER_PROMO'
|
| 'BANNER_PROMO'
|
||||||
| 'ANNOUNCEMENT_PROMO';
|
| 'ANNOUNCEMENT_PROMO'
|
||||||
|
| 'ADMIN_CUSTOM';
|
||||||
|
|
||||||
export type DepositMessagePayload = {
|
export type DepositMessagePayload = {
|
||||||
depositOrderId: string;
|
depositOrderId: string;
|
||||||
@@ -47,7 +48,7 @@ type MessageTemplate = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MESSAGE_TEMPLATES: Record<
|
const MESSAGE_TEMPLATES: Record<
|
||||||
Exclude<PlayerMessageType, 'BANNER_PROMO' | 'ANNOUNCEMENT_PROMO'>,
|
Exclude<PlayerMessageType, 'BANNER_PROMO' | 'ANNOUNCEMENT_PROMO' | 'ADMIN_CUSTOM'>,
|
||||||
Record<string, MessageTemplate>
|
Record<string, MessageTemplate>
|
||||||
> = {
|
> = {
|
||||||
DEPOSIT_APPROVED: {
|
DEPOSIT_APPROVED: {
|
||||||
@@ -200,12 +201,122 @@ function mapMessageRow(row: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BroadcastTranslation = {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BroadcastTranslations = Record<string, BroadcastTranslation>;
|
||||||
|
|
||||||
|
export type BroadcastTranslationInput = {
|
||||||
|
locale: string;
|
||||||
|
title?: string | null;
|
||||||
|
body?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeBroadcastTranslations(
|
||||||
|
inputs: BroadcastTranslationInput[],
|
||||||
|
): BroadcastTranslations {
|
||||||
|
const map: BroadcastTranslations = {};
|
||||||
|
for (const tr of inputs) {
|
||||||
|
const locale = resolveLocale(tr.locale);
|
||||||
|
map[locale] = {
|
||||||
|
title: tr.title?.trim() ?? '',
|
||||||
|
body: tr.body?.trim() ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBroadcastContent(
|
||||||
|
translations: BroadcastTranslations,
|
||||||
|
locale: string | null | undefined,
|
||||||
|
): BroadcastTranslation | null {
|
||||||
|
const chain = [resolveLocale(locale), 'en-US', 'zh-CN', 'ms-MY'];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const loc of chain) {
|
||||||
|
if (seen.has(loc)) continue;
|
||||||
|
seen.add(loc);
|
||||||
|
const tr = translations[loc];
|
||||||
|
if (!tr) continue;
|
||||||
|
const title = tr.title?.trim();
|
||||||
|
const body = tr.body?.trim();
|
||||||
|
if (title || body) {
|
||||||
|
return {
|
||||||
|
title: title || stripHtml(body).slice(0, 256) || 'Notification',
|
||||||
|
body: body || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const tr of Object.values(translations)) {
|
||||||
|
const title = tr.title?.trim();
|
||||||
|
const body = tr.body?.trim();
|
||||||
|
if (title || body) {
|
||||||
|
return {
|
||||||
|
title: title || stripHtml(body).slice(0, 256) || 'Notification',
|
||||||
|
body: body || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBroadcastTranslations(value: Prisma.JsonValue | null | undefined): BroadcastTranslations {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||||
|
const map: BroadcastTranslations = {};
|
||||||
|
for (const [locale, raw] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue;
|
||||||
|
const row = raw as Record<string, unknown>;
|
||||||
|
map[locale] = {
|
||||||
|
title: typeof row.title === 'string' ? row.title : '',
|
||||||
|
body: typeof row.body === 'string' ? row.body : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBroadcastRow(row: {
|
||||||
|
id: bigint;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
translations: Prisma.JsonValue | null;
|
||||||
|
targetType: string;
|
||||||
|
targetUserId: bigint | null;
|
||||||
|
targetUsername: string | null;
|
||||||
|
recipientCount: number;
|
||||||
|
createdById: bigint | null;
|
||||||
|
createdByUsername: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}) {
|
||||||
|
const translations = parseBroadcastTranslations(row.translations);
|
||||||
|
const hasTranslations = Object.keys(translations).length > 0;
|
||||||
|
const preview =
|
||||||
|
resolveBroadcastContent(hasTranslations ? translations : { 'en-US': { title: row.title, body: row.body } }, 'en-US') ??
|
||||||
|
{ title: row.title, body: row.body };
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id.toString(),
|
||||||
|
title: preview.title,
|
||||||
|
body: preview.body,
|
||||||
|
translations: hasTranslations
|
||||||
|
? translations
|
||||||
|
: { 'en-US': { title: row.title, body: row.body } },
|
||||||
|
targetType: row.targetType,
|
||||||
|
targetUserId: row.targetUserId?.toString() ?? null,
|
||||||
|
targetUsername: row.targetUsername,
|
||||||
|
recipientCount: row.recipientCount,
|
||||||
|
createdById: row.createdById?.toString() ?? null,
|
||||||
|
createdByUsername: row.createdByUsername,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PlayerMessagesService {
|
export class PlayerMessagesService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
private buildDepositMessage(
|
private buildDepositMessage(
|
||||||
type: Exclude<PlayerMessageType, 'BANNER_PROMO' | 'ANNOUNCEMENT_PROMO'>,
|
type: Exclude<PlayerMessageType, 'BANNER_PROMO' | 'ANNOUNCEMENT_PROMO' | 'ADMIN_CUSTOM'>,
|
||||||
locale: string | null | undefined,
|
locale: string | null | undefined,
|
||||||
payload: DepositMessagePayload,
|
payload: DepositMessagePayload,
|
||||||
) {
|
) {
|
||||||
@@ -434,4 +545,135 @@ export class PlayerMessagesService {
|
|||||||
const result = await this.prisma.playerMessage.deleteMany({ where: { userId } });
|
const result = await this.prisma.playerMessage.deleteMany({ where: { userId } });
|
||||||
return { deleted: result.count };
|
return { deleted: result.count };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listBroadcasts(page = 1, pageSize = 20) {
|
||||||
|
const safePage = Math.max(1, page);
|
||||||
|
const safePageSize = Math.min(50, Math.max(1, pageSize));
|
||||||
|
const skip = (safePage - 1) * safePageSize;
|
||||||
|
|
||||||
|
const [rows, total] = await Promise.all([
|
||||||
|
this.prisma.playerMessageBroadcast.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: safePageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.playerMessageBroadcast.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: rows.map(mapBroadcastRow),
|
||||||
|
total,
|
||||||
|
page: safePage,
|
||||||
|
pageSize: safePageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCustomBroadcast(data: {
|
||||||
|
translations: BroadcastTranslationInput[];
|
||||||
|
targetType: 'ALL' | 'USER';
|
||||||
|
targetUsername?: string | null;
|
||||||
|
createdById?: bigint | null;
|
||||||
|
createdByUsername?: string | null;
|
||||||
|
}) {
|
||||||
|
const translations = normalizeBroadcastTranslations(data.translations);
|
||||||
|
const preview = resolveBroadcastContent(translations, 'en-US');
|
||||||
|
if (!preview?.title && !preview?.body) {
|
||||||
|
throw appBadRequest('BROADCAST_CONTENT_REQUIRED');
|
||||||
|
}
|
||||||
|
const title = preview.title.slice(0, 256);
|
||||||
|
const body = preview.body;
|
||||||
|
if (!title && !body) throw appBadRequest('BROADCAST_CONTENT_REQUIRED');
|
||||||
|
|
||||||
|
let targetUsers: Array<{
|
||||||
|
id: bigint;
|
||||||
|
username: string;
|
||||||
|
locale: string | null;
|
||||||
|
preferences: { locale: string | null } | null;
|
||||||
|
}> = [];
|
||||||
|
let targetUserId: bigint | null = null;
|
||||||
|
let targetUsername: string | null = null;
|
||||||
|
|
||||||
|
if (data.targetType === 'ALL') {
|
||||||
|
targetUsers = await this.prisma.user.findMany({
|
||||||
|
where: { userType: 'PLAYER', deletedAt: null, status: 'ACTIVE' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
locale: true,
|
||||||
|
preferences: { select: { locale: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const username = data.targetUsername?.trim();
|
||||||
|
if (!username) throw appBadRequest('BROADCAST_TARGET_USER_REQUIRED');
|
||||||
|
const player = await this.prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
username,
|
||||||
|
userType: 'PLAYER',
|
||||||
|
deletedAt: null,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
locale: true,
|
||||||
|
preferences: { select: { locale: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!player) throw appNotFound('PLAYER_NOT_FOUND');
|
||||||
|
targetUsers = [player];
|
||||||
|
targetUserId = player.id;
|
||||||
|
targetUsername = player.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetUsers.length) throw appBadRequest('BROADCAST_NO_RECIPIENTS');
|
||||||
|
|
||||||
|
const broadcast = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const created = await tx.playerMessageBroadcast.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
translations: translations as Prisma.InputJsonValue,
|
||||||
|
targetType: data.targetType,
|
||||||
|
targetUserId,
|
||||||
|
targetUsername,
|
||||||
|
recipientCount: targetUsers.length,
|
||||||
|
createdById: data.createdById ?? null,
|
||||||
|
createdByUsername: data.createdByUsername ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = targetUsers.map((player) => {
|
||||||
|
const playerLocale = player.preferences?.locale ?? player.locale;
|
||||||
|
const content =
|
||||||
|
resolveBroadcastContent(translations, playerLocale) ?? preview;
|
||||||
|
return {
|
||||||
|
userId: player.id,
|
||||||
|
type: 'ADMIN_CUSTOM',
|
||||||
|
title: content.title.slice(0, 256),
|
||||||
|
body: content.body,
|
||||||
|
broadcastId: created.id,
|
||||||
|
payload: { broadcastId: created.id.toString() } as Prisma.InputJsonValue,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const batchSize = 200;
|
||||||
|
for (let i = 0; i < rows.length; i += batchSize) {
|
||||||
|
await tx.playerMessage.createMany({ data: rows.slice(i, i + batchSize) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapBroadcastRow(broadcast);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteBroadcast(broadcastId: bigint) {
|
||||||
|
const row = await this.prisma.playerMessageBroadcast.findUnique({
|
||||||
|
where: { id: broadcastId },
|
||||||
|
});
|
||||||
|
if (!row) throw appNotFound('BROADCAST_NOT_FOUND');
|
||||||
|
await this.prisma.playerMessageBroadcast.delete({ where: { id: broadcastId } });
|
||||||
|
return { deleted: true, recipientCount: row.recipientCount };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import GoldSpinner from './GoldSpinner.vue';
|
|||||||
import ConfirmDialog from './ConfirmDialog.vue';
|
import ConfirmDialog from './ConfirmDialog.vue';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
import { usePlayerMessages, type DepositMessagePayload, type PlayerMessage } from '../composables/usePlayerMessages';
|
import { usePlayerMessages, type DepositMessagePayload, type PlayerMessage } from '../composables/usePlayerMessages';
|
||||||
|
import { stripHtml } from '../utils/html';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
@@ -44,7 +45,8 @@ function messagePreview(item: PlayerMessage) {
|
|||||||
const deposit = item.payload as DepositMessagePayload | null;
|
const deposit = item.payload as DepositMessagePayload | null;
|
||||||
if (deposit?.orderNo) return deposit.orderNo;
|
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) {
|
function openDetail(id: string) {
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ export type PlayerMessageType =
|
|||||||
| 'DEPOSIT_APPROVED'
|
| 'DEPOSIT_APPROVED'
|
||||||
| 'DEPOSIT_REJECTED'
|
| 'DEPOSIT_REJECTED'
|
||||||
| 'BANNER_PROMO'
|
| 'BANNER_PROMO'
|
||||||
| 'ANNOUNCEMENT_PROMO';
|
| 'ANNOUNCEMENT_PROMO'
|
||||||
|
| 'ADMIN_CUSTOM';
|
||||||
|
|
||||||
export type DepositMessagePayload = {
|
export type DepositMessagePayload = {
|
||||||
depositOrderId?: string;
|
depositOrderId?: string;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||||
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||||
|
import { sanitizeAnnouncementHtml } from '../utils/html';
|
||||||
import { formatMoney } from '../utils/localeDisplay';
|
import { formatMoney } from '../utils/localeDisplay';
|
||||||
import {
|
import {
|
||||||
usePlayerMessages,
|
usePlayerMessages,
|
||||||
@@ -174,7 +175,12 @@ watch(messageId, () => {
|
|||||||
</span>
|
</span>
|
||||||
<p v-if="message.createdAt" class="detail-date">{{ formatDate(message.createdAt) }}</p>
|
<p v-if="message.createdAt" class="detail-date">{{ formatDate(message.createdAt) }}</p>
|
||||||
<h2 class="detail-title">{{ messageTitle(message) }}</h2>
|
<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
|
<div
|
||||||
v-if="message.type === 'DEPOSIT_REJECTED' && depositPayload?.rejectReason?.trim()"
|
v-if="message.type === 'DEPOSIT_REJECTED' && depositPayload?.rejectReason?.trim()"
|
||||||
@@ -323,6 +329,32 @@ watch(messageId, () => {
|
|||||||
color: #d0d0d0;
|
color: #d0d0d0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
.reason-box {
|
.reason-box {
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
|
|||||||
@@ -862,6 +862,41 @@ export const API_ERROR_MESSAGES = {
|
|||||||
'en-US': 'Message not found',
|
'en-US': 'Message not found',
|
||||||
'ms-MY': 'Mesej tidak dijumpai',
|
'ms-MY': 'Mesej tidak dijumpai',
|
||||||
},
|
},
|
||||||
|
BROADCAST_NOT_FOUND: {
|
||||||
|
'zh-CN': '发送记录不存在',
|
||||||
|
'en-US': 'Broadcast record not found',
|
||||||
|
'ms-MY': 'Rekod siaran tidak dijumpai',
|
||||||
|
},
|
||||||
|
BROADCAST_TITLE_REQUIRED: {
|
||||||
|
'zh-CN': '标题不能为空',
|
||||||
|
'en-US': 'Title is required',
|
||||||
|
'ms-MY': 'Tajuk diperlukan',
|
||||||
|
},
|
||||||
|
BROADCAST_BODY_REQUIRED: {
|
||||||
|
'zh-CN': '正文不能为空',
|
||||||
|
'en-US': 'Body is required',
|
||||||
|
'ms-MY': 'Kandungan diperlukan',
|
||||||
|
},
|
||||||
|
BROADCAST_TITLE_TOO_LONG: {
|
||||||
|
'zh-CN': '标题过长(最多 256 字符)',
|
||||||
|
'en-US': 'Title is too long (max 256 characters)',
|
||||||
|
'ms-MY': 'Tajuk terlalu panjang (maks 256 aksara)',
|
||||||
|
},
|
||||||
|
BROADCAST_TARGET_USER_REQUIRED: {
|
||||||
|
'zh-CN': '请指定玩家账号',
|
||||||
|
'en-US': 'Target player username is required',
|
||||||
|
'ms-MY': 'Nama pengguna pemain sasaran diperlukan',
|
||||||
|
},
|
||||||
|
BROADCAST_NO_RECIPIENTS: {
|
||||||
|
'zh-CN': '没有可发送的玩家',
|
||||||
|
'en-US': 'No eligible recipients',
|
||||||
|
'ms-MY': 'Tiada penerima yang layak',
|
||||||
|
},
|
||||||
|
BROADCAST_CONTENT_REQUIRED: {
|
||||||
|
'zh-CN': '请至少填写一种语言的标题或正文',
|
||||||
|
'en-US': 'Provide a title or body in at least one language',
|
||||||
|
'ms-MY': 'Sila isi tajuk atau kandungan sekurang-kurangnya satu bahasa',
|
||||||
|
},
|
||||||
CONTENT_ACTIVE_BANNER_INCOMPLETE: {
|
CONTENT_ACTIVE_BANNER_INCOMPLETE: {
|
||||||
'zh-CN': '启用 Banner 须至少一种语言配置图片地址',
|
'zh-CN': '启用 Banner 须至少一种语言配置图片地址',
|
||||||
'en-US': 'ACTIVE banner requires imageUrl in at least one locale',
|
'en-US': 'ACTIVE banner requires imageUrl in at least one locale',
|
||||||
|
|||||||
@@ -864,6 +864,41 @@ export const API_ERROR_MESSAGES = {
|
|||||||
'en-US': 'Message not found',
|
'en-US': 'Message not found',
|
||||||
'ms-MY': 'Mesej tidak dijumpai',
|
'ms-MY': 'Mesej tidak dijumpai',
|
||||||
},
|
},
|
||||||
|
BROADCAST_NOT_FOUND: {
|
||||||
|
'zh-CN': '发送记录不存在',
|
||||||
|
'en-US': 'Broadcast record not found',
|
||||||
|
'ms-MY': 'Rekod siaran tidak dijumpai',
|
||||||
|
},
|
||||||
|
BROADCAST_TITLE_REQUIRED: {
|
||||||
|
'zh-CN': '标题不能为空',
|
||||||
|
'en-US': 'Title is required',
|
||||||
|
'ms-MY': 'Tajuk diperlukan',
|
||||||
|
},
|
||||||
|
BROADCAST_BODY_REQUIRED: {
|
||||||
|
'zh-CN': '正文不能为空',
|
||||||
|
'en-US': 'Body is required',
|
||||||
|
'ms-MY': 'Kandungan diperlukan',
|
||||||
|
},
|
||||||
|
BROADCAST_TITLE_TOO_LONG: {
|
||||||
|
'zh-CN': '标题过长(最多 256 字符)',
|
||||||
|
'en-US': 'Title is too long (max 256 characters)',
|
||||||
|
'ms-MY': 'Tajuk terlalu panjang (maks 256 aksara)',
|
||||||
|
},
|
||||||
|
BROADCAST_TARGET_USER_REQUIRED: {
|
||||||
|
'zh-CN': '请指定玩家账号',
|
||||||
|
'en-US': 'Target player username is required',
|
||||||
|
'ms-MY': 'Nama pengguna pemain sasaran diperlukan',
|
||||||
|
},
|
||||||
|
BROADCAST_NO_RECIPIENTS: {
|
||||||
|
'zh-CN': '没有可发送的玩家',
|
||||||
|
'en-US': 'No eligible recipients',
|
||||||
|
'ms-MY': 'Tiada penerima yang layak',
|
||||||
|
},
|
||||||
|
BROADCAST_CONTENT_REQUIRED: {
|
||||||
|
'zh-CN': '请至少填写一种语言的标题或正文',
|
||||||
|
'en-US': 'Provide a title or body in at least one language',
|
||||||
|
'ms-MY': 'Sila isi tajuk atau kandungan sekurang-kurangnya satu bahasa',
|
||||||
|
},
|
||||||
CONTENT_ACTIVE_BANNER_INCOMPLETE: {
|
CONTENT_ACTIVE_BANNER_INCOMPLETE: {
|
||||||
'zh-CN': '启用 Banner 须至少一种语言配置图片地址',
|
'zh-CN': '启用 Banner 须至少一种语言配置图片地址',
|
||||||
'en-US': 'ACTIVE banner requires imageUrl in at least one locale',
|
'en-US': 'ACTIVE banner requires imageUrl in at least one locale',
|
||||||
|
|||||||
Reference in New Issue
Block a user