From 1210142a33887c12f2d6e874ad176ff38852cf2e Mon Sep 17 00:00:00 2001 From: Mars <3361409208a@gmail.com> Date: Mon, 22 Jun 2026 14:01:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin+api+player):=20=E7=AB=99=E5=86=85?= =?UTF-8?q?=E4=BF=A1=E6=89=8B=E5=8A=A8=E5=8F=91=E9=80=81=E3=80=81=E5=AA=92?= =?UTF-8?q?=E4=BD=93=E5=BA=93=E9=80=89=E6=8B=A9=E4=B8=8E=E5=8F=91=E9=80=81?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E8=AF=A6=E6=83=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增管理员手动发送站内信(三语富文本),支持发送记录查看与删除;修复全站「从媒体库选择」因分类过滤导致列表为空的问题;玩家端支持渲染管理员自定义消息;并优化后台列表页双层卡片布局。 --- apps/admin/src/App.vue | 22 +- .../src/components/ContentImageField.vue | 23 +- .../src/components/ContentRichEditor.vue | 23 +- apps/admin/src/i18n/admin-pages-ms.ts | 23 + apps/admin/src/i18n/admin-pages.ts | 2 + apps/admin/src/i18n/pages/en.ts | 23 + apps/admin/src/i18n/pages/zh.ts | 23 + apps/admin/src/utils/html.ts | 59 ++ apps/admin/src/utils/media-library.ts | 25 + apps/admin/src/views/Contents.vue | 561 +++++++++++++++++- .../migration.sql | 27 + .../migration.sql | 9 + apps/api/prisma/schema.prisma | 40 +- .../applications/admin/admin.controller.ts | 108 +++- .../player-messages.service.spec.ts | 66 ++- .../player-messages.service.ts | 250 +++++++- .../src/components/MessageListPanel.vue | 4 +- .../src/composables/usePlayerMessages.ts | 3 +- apps/player/src/views/MessageDetailView.vue | 34 +- packages/shared/src/api-errors.js | 35 ++ packages/shared/src/api-errors.ts | 35 ++ 21 files changed, 1355 insertions(+), 40 deletions(-) create mode 100644 apps/admin/src/utils/media-library.ts create mode 100644 apps/api/prisma/migrations/20260622120000_player_message_broadcasts/migration.sql create mode 100644 apps/api/prisma/migrations/20260622140000_player_message_broadcast_translations/migration.sql diff --git a/apps/admin/src/App.vue b/apps/admin/src/App.vue index 4d8bc84..6dc29bc 100644 --- a/apps/admin/src/App.vue +++ b/apps/admin/src/App.vue @@ -65,6 +65,7 @@ body::-webkit-scrollbar { height: 100%; min-height: 0; overflow: hidden; + gap: 14px; } .admin-list-page > .page-toolbar, .admin-list-page > .filter-card, @@ -78,16 +79,16 @@ body::-webkit-scrollbar { justify-content: flex-end; align-items: center; gap: 8px; - margin: 0 0 8px; + margin: 0; } .admin-list-page > .tool-card { - margin-bottom: 8px; + margin-bottom: 0; } .admin-list-page > .filter-card { - margin-bottom: 8px; + margin-bottom: 0; } .admin-list-page > .list-chrome { - margin-bottom: 8px; + margin-bottom: 0; display: flex; flex-direction: column; gap: 6px; @@ -204,7 +205,7 @@ body::-webkit-scrollbar { margin-right: 0; } .admin-list-page > .list-settings { - margin-bottom: 8px; + margin-bottom: 0; } .list-settings :deep(.el-collapse-item__header) { height: 36px; @@ -250,7 +251,16 @@ body::-webkit-scrollbar { min-height: 0; display: flex; 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 { flex: 1; diff --git a/apps/admin/src/components/ContentImageField.vue b/apps/admin/src/components/ContentImageField.vue index 1459588..69c9edb 100644 --- a/apps/admin/src/components/ContentImageField.vue +++ b/apps/admin/src/components/ContentImageField.vue @@ -3,6 +3,8 @@ import { ref } from 'vue'; import { ElMessage } from 'element-plus'; import { useAdminLocale } from '../composables/useAdminLocale'; import api from '../api'; +import { fetchMediaLibraryImages } from '../utils/media-library'; +import { resolveApiError } from '../i18n/form-validation'; const props = withDefaults( defineProps<{ @@ -23,7 +25,7 @@ const MAX_UPLOAD_SIZE = 5 * 1024 * 1024; const uploading = ref(false); const mediaPickerVisible = ref(false); -const mediaFiles = ref>([]); +const mediaFiles = ref>([]); const mediaLoading = ref(false); async function uploadImage(file: File) { @@ -67,12 +69,10 @@ async function openMediaPicker() { mediaPickerVisible.value = true; mediaLoading.value = true; try { - const res = await api.get('/admin/files', { - params: { category: props.category, pageSize: 200 }, - }); - mediaFiles.value = res.data.data.items ?? []; - } catch { + mediaFiles.value = await fetchMediaLibraryImages({ pageSize: 200 }); + } catch (err: unknown) { mediaFiles.value = []; + ElMessage.error(resolveApiError(err, t, 'content.upload.load_media_failed')); } finally { mediaLoading.value = false; } @@ -133,6 +133,7 @@ function pickMediaFile(url: string) { width="680px" destroy-on-close append-to-body + :z-index="4000" >
{{ t('common.loading') }}
{{ t('content.upload.no_media') }}
@@ -148,6 +149,7 @@ function pickMediaFile(url: string) {
SVG
{{ file.filename }}
+
{{ file.category }}
@@ -306,11 +308,18 @@ function pickMediaFile(url: string) { } .media-name { - padding: 6px 8px; + padding: 6px 8px 2px; font-size: 11px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.media-category { + padding: 0 8px 6px; + font-size: 10px; + color: var(--text-muted); + opacity: 0.85; +} diff --git a/apps/admin/src/components/ContentRichEditor.vue b/apps/admin/src/components/ContentRichEditor.vue index 4590f00..85d014b 100644 --- a/apps/admin/src/components/ContentRichEditor.vue +++ b/apps/admin/src/components/ContentRichEditor.vue @@ -3,6 +3,8 @@ import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'; import { ElMessage } from 'element-plus'; import { useAdminLocale } from '../composables/useAdminLocale'; import api from '../api'; +import { fetchMediaLibraryImages } from '../utils/media-library'; +import { resolveApiError } from '../i18n/form-validation'; const props = withDefaults( defineProps<{ @@ -27,7 +29,7 @@ const editorRef = ref(null); const mediaPickerVisible = ref(false); /** blob/object URL -> File,保存时由父组件调用 uploadPendingImages 上传 */ const pendingFiles = new Map(); -const mediaFiles = ref>([]); +const mediaFiles = ref>([]); const mediaLoading = ref(false); const syncing = ref(false); let savedRange: Range | null = null; @@ -241,12 +243,10 @@ async function openMediaPicker() { mediaPickerVisible.value = true; mediaLoading.value = true; try { - const res = await api.get('/admin/files', { - params: { category: props.uploadCategory, pageSize: 200 }, - }); - mediaFiles.value = res.data.data.items ?? []; - } catch { + mediaFiles.value = await fetchMediaLibraryImages({ pageSize: 200 }); + } catch (err: unknown) { mediaFiles.value = []; + ElMessage.error(resolveApiError(err, t, 'content.upload.load_media_failed')); } finally { mediaLoading.value = false; } @@ -352,6 +352,7 @@ onBeforeUnmount(() => { width="680px" destroy-on-close append-to-body + :z-index="4000" >
{{ t('common.loading') }}
{{ t('content.upload.no_media') }}
@@ -367,6 +368,7 @@ onBeforeUnmount(() => {
SVG
{{ file.filename }}
+
{{ file.category }}
@@ -521,11 +523,18 @@ onBeforeUnmount(() => { } .media-name { - padding: 6px 8px; + padding: 6px 8px 2px; font-size: 11px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.media-category { + padding: 0 8px 6px; + font-size: 10px; + color: var(--text-muted); + opacity: 0.85; +} diff --git a/apps/admin/src/i18n/admin-pages-ms.ts b/apps/admin/src/i18n/admin-pages-ms.ts index 650b82b..d2fdeb7 100644 --- a/apps/admin/src/i18n/admin-pages-ms.ts +++ b/apps/admin/src/i18n/admin-pages-ms.ts @@ -820,6 +820,28 @@ export const adminPagesMs: Record = { 'content.inbox_notify.manual_title': 'Tanda "Notifikasi peti mesej" semasa mencipta:', 'content.inbox_notify.banner_note': 'Promosi laman utama', '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.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', @@ -837,6 +859,7 @@ export const adminPagesMs: Record = { '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.no_media': 'Tiada imej dalam pustaka — muat naik dahulu', + 'content.upload.load_media_failed': 'Gagal memuat pustaka media', 'content.status.DRAFT': 'Draf', 'content.status.ACTIVE': 'Aktif', 'content.status.INACTIVE': 'Tidak aktif', diff --git a/apps/admin/src/i18n/admin-pages.ts b/apps/admin/src/i18n/admin-pages.ts index af7beb8..92b4033 100644 --- a/apps/admin/src/i18n/admin-pages.ts +++ b/apps/admin/src/i18n/admin-pages.ts @@ -888,6 +888,7 @@ export const adminPagesZh: Record = { 'content.upload.pick_media': '从媒体库选择', 'content.upload.pick_media_title': '选择图片', 'content.upload.no_media': '媒体库中暂无图片,请先上传', + 'content.upload.load_media_failed': '加载媒体库失败', 'content.upload.url_placeholder': '或手动粘贴图片 URL', 'content.upload.recommended_size': '建议尺寸:860 x 360 px,或 43:18 同比例图片;前台会完整显示并自动填充不合比例区域。', 'content.link.none': '无跳转', @@ -1987,6 +1988,7 @@ export const adminPagesEn: Record = { 'content.upload.pick_media': 'Pick from library', 'content.upload.pick_media_title': 'Select image', '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.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', diff --git a/apps/admin/src/i18n/pages/en.ts b/apps/admin/src/i18n/pages/en.ts index 54834b8..ee4ce17 100644 --- a/apps/admin/src/i18n/pages/en.ts +++ b/apps/admin/src/i18n/pages/en.ts @@ -829,6 +829,7 @@ const adminPages: Record = { 'content.upload.pick_media_title': 'Select image', '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.enable': 'Enable', 'content.btn.disable': 'Disable', @@ -849,6 +850,28 @@ const adminPages: Record = { 'content.inbox_notify.manual_title': 'Check "Inbox notify" when creating:', 'content.inbox_notify.banner_note': 'Homepage promo', '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.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', diff --git a/apps/admin/src/i18n/pages/zh.ts b/apps/admin/src/i18n/pages/zh.ts index 730a1b2..27cbabc 100644 --- a/apps/admin/src/i18n/pages/zh.ts +++ b/apps/admin/src/i18n/pages/zh.ts @@ -847,6 +847,28 @@ const adminPages: Record = { 'content.inbox_notify.manual_title': '以下类型在新建时勾选「邮箱通知」', 'content.inbox_notify.banner_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.announcement': '在玩家端顶部显示滚动跑马灯文字;填写各语言标题与滚动文案即可,纯文本无富文本。', 'content.section.publish': '发布设置', @@ -864,6 +886,7 @@ const adminPages: Record = { 'content.upload.cover_size_hint': '建议宽度 860px 以上;玩家端详情页会完整显示封面,正文内图片也会自适应宽度。', 'content.upload.pick_media_title': '选择图片', 'content.upload.no_media': '媒体库中暂无图片,请先上传', + 'content.upload.load_media_failed': '加载媒体库失败', 'content.status.DRAFT': '草稿', 'content.status.ACTIVE': '已启用', 'content.status.INACTIVE': '已停用', diff --git a/apps/admin/src/utils/html.ts b/apps/admin/src/utils/html.ts index 1d333f3..8f9da78 100644 --- a/apps/admin/src/utils/html.ts +++ b/apps/admin/src/utils/html.ts @@ -10,3 +10,62 @@ export function stripHtml(html: string): string { export function isHtmlEmpty(html: string): boolean { 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; +} diff --git a/apps/admin/src/utils/media-library.ts b/apps/admin/src/utils/media-library.ts new file mode 100644 index 0000000..a57bb6d --- /dev/null +++ b/apps/admin/src/utils/media-library.ts @@ -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 { + const params: Record = { + 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[]; +} diff --git a/apps/admin/src/views/Contents.vue b/apps/admin/src/views/Contents.vue index 97fe48f..6dd1a14 100644 --- a/apps/admin/src/views/Contents.vue +++ b/apps/admin/src/views/Contents.vue @@ -11,7 +11,7 @@ import api from '../api'; import AdminTableEmpty from '../components/AdminTableEmpty.vue'; import ContentImageField from '../components/ContentImageField.vue'; import ContentRichEditor from '../components/ContentRichEditor.vue'; -import { stripHtml } from '../utils/html'; +import { stripHtml, sanitizeAnnouncementHtml, isHtmlEmpty } from '../utils/html'; import { normalizeStartTimeForApi, normalizeStartTimeForPicker, @@ -77,12 +77,65 @@ interface InboxNotifySettings { deposit: boolean; } +interface MessageBroadcastItem { + id: string; + title: string; + body: string; + translations?: Record; + 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({ inboxEnabled: true, deposit: true, }); const inboxNotifySaving = ref(false); +const broadcastLoading = ref(false); +const broadcastSending = ref(false); +const broadcastDialogVisible = ref(false); +const broadcastActiveLocale = ref('zh-CN'); +const broadcastEditorRef = ref | null>(null); +const broadcastItems = ref([]); +const broadcastTotal = ref(0); +const broadcastPage = ref(1); +const broadcastPageSize = ref(10); +const broadcastDetailVisible = ref(false); +const broadcastDetailRow = ref(null); +const broadcastDetailLocale = ref('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({ sortOrder: 0, 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() { loading.value = true; try { @@ -234,13 +444,18 @@ watch([activeType, filterStatus], () => { tableRef.value?.clearSelection(); if (activeType.value === 'INBOX_NOTIFY') { void loadInboxNotifySettings(); + void loadBroadcasts(); return; } void load(); }); 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(); }); @@ -575,6 +790,18 @@ void load();
  • {{ t('content.inbox_notify.banner_note') }}
  • {{ t('content.inbox_notify.announcement_note') }}
  • + +
    + + {{ t('content.inbox_broadcast.title') }} + + {{ t('content.inbox_broadcast.hint') }} +
    @@ -613,6 +840,79 @@ void load(); + +
    + + + + + + + + + + + + + + + + + + +
    +
    + +
    +
    +
    + + + + + + + + + + + {{ t('content.inbox_broadcast.target_all') }} + {{ t('content.inbox_broadcast.target_user') }} + + + + + + +
    +
    +

    {{ t('content.section.content') }}

    +

    {{ t('content.inbox_broadcast.locale_fallback_hint') }}

    +
    + + + + + + + + +
    + {{ t('content.inbox_broadcast.field_body') }} + {{ localeLabel(broadcastActiveLocale) }} +
    + +
    +
    + +
    @@ -1323,4 +1757,127 @@ void load(); .inbox-notify-notes li { 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; +} diff --git a/apps/api/prisma/migrations/20260622120000_player_message_broadcasts/migration.sql b/apps/api/prisma/migrations/20260622120000_player_message_broadcasts/migration.sql new file mode 100644 index 0000000..6326b2b --- /dev/null +++ b/apps/api/prisma/migrations/20260622120000_player_message_broadcasts/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260622140000_player_message_broadcast_translations/migration.sql b/apps/api/prisma/migrations/20260622140000_player_message_broadcast_translations/migration.sql new file mode 100644 index 0000000..4cfa116 --- /dev/null +++ b/apps/api/prisma/migrations/20260622140000_player_message_broadcast_translations/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 6f720f8..c8cb87f 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -816,22 +816,44 @@ model DepositOrderAuditLog { } model PlayerMessage { - id BigInt @id @default(autoincrement()) - userId BigInt @map("user_id") - type String @db.VarChar(32) - title String @db.VarChar(256) - body String @db.Text - payload Json? - readAt DateTime? @map("read_at") - createdAt DateTime @default(now()) @map("created_at") + id BigInt @id @default(autoincrement()) + userId BigInt @map("user_id") + type String @db.VarChar(32) + title String @db.VarChar(256) + body String @db.Text + payload Json? + broadcastId BigInt? @map("broadcast_id") + readAt DateTime? @map("read_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, readAt]) + @@index([broadcastId]) @@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 ============ model SystemConfig { diff --git a/apps/api/src/applications/admin/admin.controller.ts b/apps/api/src/applications/admin/admin.controller.ts index 47da3ce..4897d78 100644 --- a/apps/api/src/applications/admin/admin.controller.ts +++ b/apps/api/src/applications/admin/admin.controller.ts @@ -64,6 +64,7 @@ import { IsIn, Min, Max, + MaxLength, Equals, ValidateIf, } from 'class-validator'; @@ -1111,6 +1112,33 @@ class InboxNotifySettingsDto { 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 { @IsOptional() @IsBoolean() @@ -3102,8 +3130,24 @@ export class AdminController { @Query('category') category?: string, @Query('page') page?: 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 skip = (Math.max(parseInt(page ?? '1', 10) || 1, 1) - 1) * take; @@ -3310,6 +3354,68 @@ export class AdminController { 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') @RequirePermissions(P.content, P.reports) async listContents( diff --git a/apps/api/src/domains/player-messages/player-messages.service.spec.ts b/apps/api/src/domains/player-messages/player-messages.service.spec.ts index 2b0a95a..cf6eaff 100644 --- a/apps/api/src/domains/player-messages/player-messages.service.spec.ts +++ b/apps/api/src/domains/player-messages/player-messages.service.spec.ts @@ -1,7 +1,8 @@ import { PlayerMessagesService } from './player-messages.service'; describe('PlayerMessagesService', () => { - const prisma = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const prisma: any = { playerMessage: { create: jest.fn(), findMany: jest.fn(), @@ -13,10 +14,19 @@ describe('PlayerMessagesService', () => { deleteMany: jest.fn(), createMany: jest.fn(), }, + playerMessageBroadcast: { + findMany: jest.fn(), + count: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + }, user: { findUnique: jest.fn(), findMany: jest.fn(), + findFirst: jest.fn(), }, + $transaction: jest.fn(async (fn: (tx: typeof prisma) => Promise) => fn(prisma)), }; 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: '

    World

    ', + translations: { + 'en-US': { title: 'Hello', body: '

    World

    ' }, + 'zh-CN': { title: '你好', body: '

    内容

    ' }, + }, + 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: '

    World

    ' }, + { locale: 'zh-CN', title: '你好', body: '

    内容

    ' }, + ], + 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 } }); + }); }); diff --git a/apps/api/src/domains/player-messages/player-messages.service.ts b/apps/api/src/domains/player-messages/player-messages.service.ts index 4341807..081adc8 100644 --- a/apps/api/src/domains/player-messages/player-messages.service.ts +++ b/apps/api/src/domains/player-messages/player-messages.service.ts @@ -1,13 +1,14 @@ import { Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; 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 = | 'DEPOSIT_APPROVED' | 'DEPOSIT_REJECTED' | 'BANNER_PROMO' - | 'ANNOUNCEMENT_PROMO'; + | 'ANNOUNCEMENT_PROMO' + | 'ADMIN_CUSTOM'; export type DepositMessagePayload = { depositOrderId: string; @@ -47,7 +48,7 @@ type MessageTemplate = { }; const MESSAGE_TEMPLATES: Record< - Exclude, + Exclude, Record > = { DEPOSIT_APPROVED: { @@ -200,12 +201,122 @@ function mapMessageRow(row: { }; } +export type BroadcastTranslation = { + title: string; + body: string; +}; + +export type BroadcastTranslations = Record; + +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(); + 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)) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue; + const row = raw as Record; + 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() export class PlayerMessagesService { constructor(private prisma: PrismaService) {} private buildDepositMessage( - type: Exclude, + type: Exclude, locale: string | null | undefined, payload: DepositMessagePayload, ) { @@ -434,4 +545,135 @@ export class PlayerMessagesService { const result = await this.prisma.playerMessage.deleteMany({ where: { userId } }); 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 }; + } } diff --git a/apps/player/src/components/MessageListPanel.vue b/apps/player/src/components/MessageListPanel.vue index b1e646e..a93d947 100644 --- a/apps/player/src/components/MessageListPanel.vue +++ b/apps/player/src/components/MessageListPanel.vue @@ -7,6 +7,7 @@ import GoldSpinner from './GoldSpinner.vue'; import ConfirmDialog from './ConfirmDialog.vue'; import { useAuthStore } from '../stores/auth'; import { usePlayerMessages, type DepositMessagePayload, type PlayerMessage } from '../composables/usePlayerMessages'; +import { stripHtml } from '../utils/html'; const router = useRouter(); const { t, locale } = useI18n(); @@ -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) { diff --git a/apps/player/src/composables/usePlayerMessages.ts b/apps/player/src/composables/usePlayerMessages.ts index 7c73689..4bafa7a 100644 --- a/apps/player/src/composables/usePlayerMessages.ts +++ b/apps/player/src/composables/usePlayerMessages.ts @@ -5,7 +5,8 @@ export type PlayerMessageType = | 'DEPOSIT_APPROVED' | 'DEPOSIT_REJECTED' | 'BANNER_PROMO' - | 'ANNOUNCEMENT_PROMO'; + | 'ANNOUNCEMENT_PROMO' + | 'ADMIN_CUSTOM'; export type DepositMessagePayload = { depositOrderId?: string; diff --git a/apps/player/src/views/MessageDetailView.vue b/apps/player/src/views/MessageDetailView.vue index 5b460bf..dce932f 100644 --- a/apps/player/src/views/MessageDetailView.vue +++ b/apps/player/src/views/MessageDetailView.vue @@ -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, () => {

    {{ formatDate(message.createdAt) }}

    {{ messageTitle(message) }}

    -

    {{ messageBody(message) }}

    +
    +

    {{ messageBody(message) }}

    { 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 { margin-top: 18px; padding: 14px; diff --git a/packages/shared/src/api-errors.js b/packages/shared/src/api-errors.js index d5ba5f7..6060efa 100644 --- a/packages/shared/src/api-errors.js +++ b/packages/shared/src/api-errors.js @@ -862,6 +862,41 @@ export const API_ERROR_MESSAGES = { 'en-US': 'Message not found', '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: { 'zh-CN': '启用 Banner 须至少一种语言配置图片地址', 'en-US': 'ACTIVE banner requires imageUrl in at least one locale', diff --git a/packages/shared/src/api-errors.ts b/packages/shared/src/api-errors.ts index 6864d51..65ea1b7 100644 --- a/packages/shared/src/api-errors.ts +++ b/packages/shared/src/api-errors.ts @@ -864,6 +864,41 @@ export const API_ERROR_MESSAGES = { 'en-US': 'Message not found', '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: { 'zh-CN': '启用 Banner 须至少一种语言配置图片地址', 'en-US': 'ACTIVE banner requires imageUrl in at least one locale',