feat(admin+api+player): 站内信手动发送、媒体库选择与发送记录详情

新增管理员手动发送站内信(三语富文本),支持发送记录查看与删除;修复全站「从媒体库选择」因分类过滤导致列表为空的问题;玩家端支持渲染管理员自定义消息;并优化后台列表页双层卡片布局。
This commit is contained in:
2026-06-22 14:01:31 +08:00
parent 648c314e23
commit 1210142a33
21 changed files with 1355 additions and 40 deletions

View File

@@ -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;

View File

@@ -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<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);
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"
>
<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>
@@ -148,6 +149,7 @@ function pickMediaFile(url: string) {
<div v-else class="media-svg">SVG</div>
</div>
<div class="media-name" :title="file.filename">{{ file.filename }}</div>
<div v-if="file.category" class="media-category">{{ file.category }}</div>
</div>
</div>
</el-dialog>
@@ -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;
}
</style>

View File

@@ -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<HTMLDivElement | null>(null);
const mediaPickerVisible = ref(false);
/** blob/object URL -> File保存时由父组件调用 uploadPendingImages 上传 */
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 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"
>
<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>
@@ -367,6 +368,7 @@ onBeforeUnmount(() => {
<div v-else class="media-svg">SVG</div>
</div>
<div class="media-name" :title="file.filename">{{ file.filename }}</div>
<div v-if="file.category" class="media-category">{{ file.category }}</div>
</div>
</div>
</el-dialog>
@@ -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;
}
</style>

View File

@@ -820,6 +820,28 @@ export const adminPagesMs: Record<string, string> = {
'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<string, string> = {
'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',

View File

@@ -888,6 +888,7 @@ export const adminPagesZh: Record<string, string> = {
'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<string, string> = {
'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',

View File

@@ -829,6 +829,7 @@ const adminPages: Record<string, string> = {
'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<string, string> = {
'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',

View File

@@ -847,6 +847,28 @@ const adminPages: Record<string, string> = {
'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<string, string> = {
'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': '已停用',

View File

@@ -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;
}

View 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[];
}

View File

@@ -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<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>({
inboxEnabled: true,
deposit: true,
});
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({
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();
<li>{{ t('content.inbox_notify.banner_note') }}</li>
<li>{{ t('content.inbox_notify.announcement_note') }}</li>
</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>
</template>
@@ -613,6 +840,79 @@ void load();
</el-form>
</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">
<div class="table-wrap">
<el-table
@@ -966,6 +1266,140 @@ void load();
</el-button>
</template>
</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>
</template>
@@ -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;
}
</style>