Files
thebet365/apps/admin/src/views/Contents.vue
Mars cffad00652 sync(theme-4): 同步 main 最新 API/Admin 与玩家端逻辑
从 main 同步站内信手动发送、媒体库选择、列表子路由重构等 API/Admin 改动;玩家端仅合并 ADMIN_CUSTOM 富文本、消息预览与充值截图压缩逻辑,保留 theme-4 样式。
2026-06-22 14:12:40 +08:00

1884 lines
55 KiB
Vue

<script setup lang="ts">
defineOptions({ name: 'AdminContents' });
import { ref, computed, watch, onActivated } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import type { TableInstance } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import { usePermissions } from '../composables/usePermissions';
import { AdminPerm } from '../constants/permissions';
import api from '../api';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import ContentImageField from '../components/ContentImageField.vue';
import ContentRichEditor from '../components/ContentRichEditor.vue';
import { stripHtml, sanitizeAnnouncementHtml, isHtmlEmpty } from '../utils/html';
import {
normalizeStartTimeForApi,
normalizeStartTimeForPicker,
} from './match-form';
const { t, localeTag } = useAdminLocale();
const { hasPermission } = usePermissions();
const canManageContent = computed(() => hasPermission(AdminPerm.content));
type StoredContentType = 'BANNER' | 'NOTICE' | 'TICKER';
type AdminTab = 'BANNER' | 'ANNOUNCEMENT' | 'INBOX_NOTIFY';
type ContentStatus = 'DRAFT' | 'ACTIVE' | 'INACTIVE';
interface TranslationForm {
locale: string;
title: string;
body: string;
imageUrl: string;
}
interface ContentItem {
id: string;
contentType: StoredContentType;
sortOrder: number;
status: ContentStatus;
linkType: string | null;
linkTarget: string | null;
startTime: string | null;
endTime: string | null;
previewTitle: string;
previewImageUrl: string | null;
playerVisible: boolean;
playerHiddenReason: string | null;
translations: TranslationForm[];
}
const ADMIN_TABS: AdminTab[] = ['BANNER', 'ANNOUNCEMENT', 'INBOX_NOTIFY'];
const LOCALES = ['zh-CN', 'en-US', 'ms-MY'] as const;
const activeType = ref<AdminTab>('BANNER');
const filterStatus = ref<ContentStatus | ''>('');
const loading = ref(false);
const saving = ref(false);
const items = ref<ContentItem[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const tableRef = ref<TableInstance>();
const selectedRows = ref<ContentItem[]>([]);
const hasSelection = computed(() => selectedRows.value.length > 0);
const bannerDialogVisible = ref(false);
const announcementDialogVisible = ref(false);
const bannerEditorRef = ref<InstanceType<typeof ContentRichEditor> | null>(null);
const editingId = ref<string | null>(null);
const editingContentType = ref<StoredContentType>('TICKER');
const activeLocale = ref<string>('zh-CN');
const notifyInbox = ref(false);
interface InboxNotifySettings {
inboxEnabled: 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>({
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,
linkType: '' as '' | 'ROUTE' | 'URL',
linkTarget: '',
startTime: '' as string,
endTime: '' as string,
translations: emptyTranslations(),
});
function emptyTranslations(): TranslationForm[] {
return LOCALES.map((locale) => ({
locale,
title: '',
body: '',
imageUrl: '',
}));
}
function localeLabel(code: string) {
const key = `content.locale.${code}`;
const label = t(key);
return label === key ? code : label;
}
function statusLabel(status: string) {
const key = `content.status.${status}`;
const label = t(key);
return label === key ? status : label;
}
function statusTagType(status: string) {
if (status === 'ACTIVE') return 'success';
if (status === 'DRAFT') return 'info';
return 'warning';
}
function hiddenTip(reason: string | null) {
if (!reason) return '';
const key = `content.hidden_reason.${reason}`;
const label = t(key);
return label === key ? reason : label;
}
function formatTime(v: string | null) {
if (!v) return '—';
return new Date(v).toLocaleString(localeTag.value, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function previewText(row: ContentItem) {
const raw = row.previewTitle || row.translations.find((tr) => tr.body)?.body || '';
return stripHtml(raw) || '—';
}
const isBannerTab = computed(() => activeType.value === 'BANNER');
const isInboxNotifyTab = computed(() => activeType.value === 'INBOX_NOTIFY');
const activeTranslation = computed(
() =>
form.value.translations.find((tr) => tr.locale === activeLocale.value) ??
form.value.translations[0],
);
const bannerDialogTitle = computed(() =>
editingId.value ? t('content.dialog.edit_banner') : t('content.dialog.create_banner'),
);
const announcementDialogTitle = computed(() =>
editingId.value ? t('content.dialog.edit_notice') : t('content.dialog.create_notice'),
);
async function loadInboxNotifySettings() {
try {
const { data } = await api.get('/admin/contents/inbox-notify-settings');
inboxNotifySettings.value = {
inboxEnabled: data.data?.inboxEnabled !== false,
deposit: Boolean(data.data?.deposit),
};
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
}
}
async function saveInboxNotifySettings() {
if (!canManageContent.value) return;
inboxNotifySaving.value = true;
try {
const { data } = await api.put('/admin/contents/inbox-notify-settings', {
inboxEnabled: inboxNotifySettings.value.inboxEnabled,
deposit: inboxNotifySettings.value.deposit,
});
inboxNotifySettings.value = {
inboxEnabled: data.data?.inboxEnabled !== false,
deposit: Boolean(data.data?.deposit),
};
ElMessage.success(t('msg.saved'));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
await loadInboxNotifySettings();
} finally {
inboxNotifySaving.value = false;
}
}
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 {
const { data } = await api.get('/admin/contents', {
params: {
type: activeType.value,
status: filterStatus.value || undefined,
page: page.value,
pageSize: pageSize.value,
},
});
items.value = data.data.items ?? [];
total.value = data.data.total ?? 0;
selectedRows.value = [];
tableRef.value?.clearSelection();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
} finally {
loading.value = false;
}
}
function onPageChange(p: number) {
page.value = p;
load();
}
function onSizeChange(size: number) {
pageSize.value = size;
page.value = 1;
load();
}
watch([activeType, filterStatus], () => {
page.value = 1;
selectedRows.value = [];
tableRef.value?.clearSelection();
if (activeType.value === 'INBOX_NOTIFY') {
void loadInboxNotifySettings();
void loadBroadcasts();
return;
}
void load();
});
onActivated(() => {
if (activeType.value === 'INBOX_NOTIFY') {
void loadInboxNotifySettings();
if (broadcastItems.value.length > 0) void loadBroadcasts();
return;
}
if (items.value.length > 0) void load();
});
function onSelectionChange(rows: ContentItem[]) {
selectedRows.value = rows;
}
async function runBatch(
action: (row: ContentItem) => Promise<void>,
confirmKey?: string,
) {
const rows = [...selectedRows.value];
if (!rows.length) return;
if (confirmKey) {
try {
await ElMessageBox.confirm(t(confirmKey, { n: rows.length }), { type: 'warning' });
} catch {
return;
}
}
saving.value = true;
let ok = 0;
let fail = 0;
try {
for (const row of rows) {
try {
await action(row);
ok += 1;
} catch {
fail += 1;
}
}
if (fail === 0) {
ElMessage.success(t('content.batch.all_ok', { n: ok }));
} else {
ElMessage.warning(t('content.batch.partial', { ok, fail }));
}
await load();
} finally {
saving.value = false;
}
}
function batchEnable() {
void runBatch(
(row) => api.patch(`/admin/contents/${row.id}/status`, { status: 'ACTIVE' }),
'content.confirm_batch_enable',
);
}
function batchDisable() {
void runBatch(
(row) => api.patch(`/admin/contents/${row.id}/status`, { status: 'INACTIVE' }),
'content.confirm_batch_disable',
);
}
function batchDelete() {
void runBatch(
(row) => api.delete(`/admin/contents/${row.id}`),
'content.confirm_batch_delete',
);
}
function resetForm() {
activeLocale.value = 'zh-CN';
notifyInbox.value = false;
form.value = {
sortOrder: 0,
status: 'DRAFT',
linkType: '',
linkTarget: '',
startTime: '',
endTime: '',
translations: emptyTranslations(),
};
}
function loadRowIntoForm(row: ContentItem, plainBody = false) {
const byLocale = new Map(row.translations.map((tr) => [tr.locale, tr]));
form.value = {
sortOrder: row.sortOrder,
status: row.status,
linkType: (row.linkType as '' | 'ROUTE' | 'URL') || '',
linkTarget: row.linkTarget ?? '',
startTime: normalizeStartTimeForPicker(row.startTime ?? undefined),
endTime: normalizeStartTimeForPicker(row.endTime ?? undefined),
translations: LOCALES.map((locale) => {
const tr = byLocale.get(locale);
const rawBody = tr?.body ?? '';
return {
locale,
title: tr?.title ?? '',
body: plainBody ? stripHtml(rawBody) : rawBody,
imageUrl: tr?.imageUrl ?? '',
};
}),
};
}
function openCreate() {
editingId.value = null;
resetForm();
if (isBannerTab.value) {
editingContentType.value = 'BANNER';
bannerDialogVisible.value = true;
} else {
editingContentType.value = 'TICKER';
announcementDialogVisible.value = true;
}
}
function openEdit(row: ContentItem) {
editingId.value = row.id;
editingContentType.value = row.contentType;
activeLocale.value = 'zh-CN';
if (row.contentType === 'BANNER') {
loadRowIntoForm(row, false);
bannerDialogVisible.value = true;
} else {
loadRowIntoForm(row, true);
announcementDialogVisible.value = true;
}
}
function buildScheduleFields() {
return {
sortOrder: form.value.sortOrder,
status: form.value.status,
startTime: form.value.startTime ? normalizeStartTimeForApi(form.value.startTime) : null,
endTime: form.value.endTime ? normalizeStartTimeForApi(form.value.endTime) : null,
};
}
function buildBannerPayload() {
const payload = {
contentType: 'BANNER' as const,
...buildScheduleFields(),
linkType: form.value.linkType ? form.value.linkType : null,
linkTarget: form.value.linkType ? form.value.linkTarget.trim() : null,
translations: form.value.translations.map((tr) => ({
locale: tr.locale,
title: tr.title.trim() || undefined,
body: tr.body.trim() || undefined,
imageUrl: tr.imageUrl.trim() || undefined,
})),
};
if (!editingId.value) {
return { ...payload, notifyInbox: notifyInbox.value };
}
return payload;
}
function buildAnnouncementPayload() {
const contentType: StoredContentType = editingId.value ? editingContentType.value : 'TICKER';
const payload = {
contentType,
...buildScheduleFields(),
linkType: null,
linkTarget: null,
translations: form.value.translations.map((tr) => ({
locale: tr.locale,
title: tr.title.trim() || undefined,
body: tr.body.trim() || undefined,
})),
};
if (!editingId.value) {
return { ...payload, notifyInbox: notifyInbox.value };
}
return payload;
}
async function submitBannerForm() {
saving.value = true;
try {
const editor = bannerEditorRef.value;
if (editor) {
activeTranslation.value.body = editor.getHtml();
for (const tr of form.value.translations) {
tr.body = await editor.uploadPendingImages(tr.body);
}
}
const payload = buildBannerPayload();
const isCreate = !editingId.value;
if (editingId.value) {
const { contentType: _type, notifyInbox: _notify, ...updateBody } = payload as ReturnType<typeof buildBannerPayload> & { notifyInbox?: boolean };
await api.put(`/admin/contents/${editingId.value}`, updateBody);
ElMessage.success(t('msg.saved'));
} else {
const { data } = await api.post('/admin/contents', payload);
const notifiedCount = Number(data.data?.notifiedCount ?? 0);
if (notifyInbox.value && notifiedCount > 0) {
ElMessage.success(t('content.msg.notify_sent', { count: notifiedCount }));
} else {
ElMessage.success(t('msg.saved'));
}
}
bannerDialogVisible.value = false;
if (isCreate) page.value = 1;
await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string; message?: string | string[] } } };
const msg = err.response?.data?.error
?? (Array.isArray(err.response?.data?.message)
? err.response?.data?.message.join(', ')
: err.response?.data?.message)
?? (e instanceof Error ? e.message : t('msg.save_failed'));
ElMessage.error(String(msg));
} finally {
saving.value = false;
}
}
async function submitAnnouncementForm() {
saving.value = true;
try {
const payload = buildAnnouncementPayload();
const isCreate = !editingId.value;
if (editingId.value) {
const { contentType: _type, notifyInbox: _notify, ...updateBody } = payload as ReturnType<
typeof buildAnnouncementPayload
> & { notifyInbox?: boolean };
await api.put(`/admin/contents/${editingId.value}`, updateBody);
ElMessage.success(t('msg.saved'));
} else {
const { data } = await api.post('/admin/contents', payload);
const notifiedCount = Number(data.data?.notifiedCount ?? 0);
if (notifyInbox.value && notifiedCount > 0) {
ElMessage.success(t('content.msg.notify_sent', { count: notifiedCount }));
} else {
ElMessage.success(t('msg.saved'));
}
}
announcementDialogVisible.value = false;
if (isCreate) page.value = 1;
await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string; message?: string | string[] } } };
const msg = err.response?.data?.error
?? (Array.isArray(err.response?.data?.message)
? err.response?.data?.message.join(', ')
: err.response?.data?.message)
?? t('msg.save_failed');
ElMessage.error(String(msg));
} finally {
saving.value = false;
}
}
async function setStatus(row: ContentItem, status: ContentStatus) {
saving.value = true;
try {
await api.patch(`/admin/contents/${row.id}/status`, { status });
ElMessage.success(t('msg.saved'));
await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
saving.value = false;
}
}
async function removeItem(row: ContentItem) {
try {
await ElMessageBox.confirm(
t('content.confirm_delete', { title: previewText(row) }),
{ type: 'warning' },
);
} catch {
return;
}
saving.value = true;
try {
await api.delete(`/admin/contents/${row.id}`);
ElMessage.success(t('msg.saved'));
await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
saving.value = false;
}
}
void load();
</script>
<template>
<div class="admin-list-page contents-page">
<el-card class="filter-card" shadow="never">
<el-tabs v-model="activeType" class="type-tabs">
<el-tab-pane
v-for="tp in ADMIN_TABS"
:key="tp"
:label="t(`content.type.${tp}`)"
:name="tp"
/>
</el-tabs>
<template v-if="isInboxNotifyTab">
<div class="inbox-notify-panel">
<div class="inbox-notify-row">
<div class="inbox-notify-row-text">
<span class="inbox-notify-label">{{ t('content.inbox_notify.inbox_enabled') }}</span>
<span class="inbox-notify-hint">{{ t('content.inbox_notify.inbox_enabled_hint') }}</span>
</div>
<el-switch
v-model="inboxNotifySettings.inboxEnabled"
:disabled="!canManageContent || inboxNotifySaving"
@change="saveInboxNotifySettings"
/>
</div>
<div v-show="inboxNotifySettings.inboxEnabled" class="inbox-notify-row">
<div class="inbox-notify-row-text">
<span class="inbox-notify-label">{{ t('content.inbox_notify.deposit') }}</span>
<span class="inbox-notify-hint">{{ t('content.inbox_notify.deposit_hint') }}</span>
</div>
<el-switch
v-model="inboxNotifySettings.deposit"
:disabled="!canManageContent || inboxNotifySaving"
@change="saveInboxNotifySettings"
/>
</div>
<p v-show="inboxNotifySettings.inboxEnabled" class="inbox-notify-notes-title">{{ t('content.inbox_notify.manual_title') }}</p>
<ul v-show="inboxNotifySettings.inboxEnabled" class="inbox-notify-notes">
<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>
<el-form v-else inline class="filter-row">
<el-form-item :label="t('common.status')">
<el-select v-model="filterStatus" clearable style="width: 140px">
<el-option :label="t('common.all')" value="" />
<el-option
v-for="st in ['DRAFT', 'ACTIVE', 'INACTIVE']"
:key="st"
:label="statusLabel(st)"
:value="st"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" size="small" @click="load">{{ t('common.search') }}</el-button>
<el-button v-if="canManageContent" type="primary" plain size="small" @click="openCreate">
{{ t('content.btn.create') }}
</el-button>
<template v-if="canManageContent">
<span v-if="hasSelection" class="batch-hint">
{{ t('content.batch.selected', { n: selectedRows.length }) }}
</span>
<el-button size="small" :disabled="!hasSelection || saving" @click="batchEnable">
{{ t('content.batch.enable') }}
</el-button>
<el-button size="small" :disabled="!hasSelection || saving" @click="batchDisable">
{{ t('content.batch.disable') }}
</el-button>
<el-button type="danger" plain size="small" :disabled="!hasSelection || saving" @click="batchDelete">
{{ t('content.batch.delete') }}
</el-button>
</template>
</el-form-item>
</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
ref="tableRef"
:data="items"
row-key="id"
stripe
size="small"
@selection-change="onSelectionChange"
>
<template #empty>
<AdminTableEmpty />
</template>
<el-table-column type="selection" width="44" :selectable="() => !saving" />
<el-table-column type="index" :index="(i: number) => (page - 1) * pageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
<el-table-column prop="sortOrder" :label="t('content.col.sort')" width="64" align="center" />
<el-table-column :label="t('content.col.preview')" width="88" align="center">
<template #default="{ row }">
<img v-if="row.previewImageUrl" :src="row.previewImageUrl" alt="" class="thumb" />
<span v-else class="thumb-empty"></span>
</template>
</el-table-column>
<el-table-column :label="t('content.col.title')" min-width="180">
<template #default="{ row }">
<span class="preview-title">{{ previewText(row) }}</span>
<p v-if="!row.playerVisible && row.playerHiddenReason" class="hidden-tip">
{{ hiddenTip(row.playerHiddenReason) }}
</p>
</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="96" align="center">
<template #default="{ row }">
<el-tag size="small" :type="statusTagType(row.status)" effect="dark">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('content.col.player_visible')" width="88" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.playerVisible ? 'success' : 'warning'" effect="plain">
{{ row.playerVisible ? t('common.yes') : t('common.no') }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('content.col.schedule')" min-width="140">
<template #default="{ row }">
<span class="schedule-line">{{ formatTime(row.startTime) }}</span>
<span class="schedule-sep"></span>
<span class="schedule-line">{{ formatTime(row.endTime) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('content.col.link')" min-width="120">
<template #default="{ row }">
<template v-if="row.linkType">
{{ row.linkType }} · {{ row.linkTarget || '' }}
</template>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">
{{ t('common.edit') }}
</el-button>
<el-button
v-if="row.status !== 'ACTIVE'"
link
type="success"
:disabled="saving"
@click="setStatus(row, 'ACTIVE')"
>
{{ t('content.btn.enable') }}
</el-button>
<el-button v-else link type="warning" :disabled="saving" @click="setStatus(row, 'INACTIVE')">
{{ t('content.btn.disable') }}
</el-button>
<el-button link type="danger" :disabled="saving" @click="removeItem(row)">
{{ t('common.delete') }}
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
background
@current-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</el-card>
<el-dialog
v-model="bannerDialogVisible"
:title="bannerDialogTitle"
width="1000px"
class="content-publish-dialog"
destroy-on-close
>
<el-form label-width="84px" size="small" class="publish-form">
<div class="publish-layout">
<aside class="publish-meta">
<section class="publish-section publish-section--compact">
<div class="publish-section-head">{{ t('content.section.publish') }}</div>
<el-row :gutter="8" class="publish-grid">
<el-col :span="12">
<el-form-item :label="t('common.status')" label-position="top" class="publish-field">
<el-select v-model="form.status" style="width: 100%">
<el-option
v-for="st in ['DRAFT', 'ACTIVE', 'INACTIVE']"
:key="st"
:label="statusLabel(st)"
:value="st"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="t('content.col.sort')" label-position="top" class="publish-field">
<el-input-number
v-model="form.sortOrder"
:min="0"
:step="1"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="8" class="publish-grid">
<el-col :span="12">
<el-form-item :label="t('content.field.start_time')" label-position="top" class="publish-field">
<el-date-picker
v-model="form.startTime"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
clearable
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="t('content.field.end_time')" label-position="top" class="publish-field">
<el-date-picker
v-model="form.endTime"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
clearable
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="t('content.field.link_type')" label-position="top" class="publish-field">
<el-select v-model="form.linkType" clearable style="width: 100%">
<el-option :label="t('content.link.none')" value="" />
<el-option label="ROUTE" value="ROUTE" />
<el-option label="URL" value="URL" />
</el-select>
</el-form-item>
<el-form-item
v-if="form.linkType"
:label="t('content.field.link_target')"
label-position="top"
class="publish-field"
>
<el-input
v-model="form.linkTarget"
:placeholder="form.linkType === 'ROUTE' ? '/bet' : 'https://'"
/>
</el-form-item>
<el-form-item v-if="!editingId" label-position="top" class="publish-field notify-inbox-field">
<template #label>{{ t('content.field.notify_inbox') }}</template>
<div class="notify-inbox-block">
<el-switch v-model="notifyInbox" :disabled="form.status !== 'ACTIVE'" />
<p class="notify-inbox-hint">{{ t('content.field.notify_inbox_hint') }}</p>
</div>
</el-form-item>
</section>
<section class="publish-section publish-meta-fields">
<h3 class="section-title">{{ t('content.section.content') }}</h3>
<el-tabs v-model="activeLocale" class="locale-tabs">
<el-tab-pane
v-for="tr in form.translations"
:key="tr.locale"
:label="localeLabel(tr.locale)"
:name="tr.locale"
>
<el-form-item
:label="t('content.field.title')"
:required="form.status === 'ACTIVE'"
>
<el-input v-model="tr.title" :placeholder="t('content.field.title_ph')" />
</el-form-item>
<el-form-item
:label="t('content.field.cover_image')"
:required="form.status === 'ACTIVE'"
>
<ContentImageField
v-model="tr.imageUrl"
category="banners"
size-hint-key="content.upload.recommended_size"
/>
</el-form-item>
</el-tab-pane>
</el-tabs>
</section>
</aside>
<div class="publish-body">
<div class="publish-body-head">
<span class="publish-body-label">
{{ t('content.field.body') }}
<span v-if="form.status === 'ACTIVE'" class="required-mark">*</span>
</span>
<span class="locale-badge">{{ localeLabel(activeLocale) }}</span>
</div>
<ContentRichEditor
ref="bannerEditorRef"
v-model="activeTranslation.body"
fill
upload-category="banners"
:placeholder="t('content.editor.placeholder')"
class="publish-rich-editor"
/>
</div>
</div>
</el-form>
<template #footer>
<el-button @click="bannerDialogVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="saving" @click="submitBannerForm">
{{ t('common.save') }}
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="announcementDialogVisible"
:title="announcementDialogTitle"
width="640px"
class="announcement-dialog"
destroy-on-close
>
<p class="announcement-hint">{{ t('content.hint.announcement') }}</p>
<el-form label-width="96px" size="small" class="announcement-form">
<section class="announcement-publish">
<div class="publish-section-head">{{ t('content.section.publish') }}</div>
<el-row :gutter="8" class="announcement-publish-row">
<el-col :xs="12" :sm="6">
<el-form-item :label="t('common.status')" label-position="top" class="publish-field">
<el-select v-model="form.status" style="width: 100%">
<el-option
v-for="st in ['DRAFT', 'ACTIVE', 'INACTIVE']"
:key="st"
:label="statusLabel(st)"
:value="st"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :xs="12" :sm="6">
<el-form-item :label="t('content.col.sort')" label-position="top" class="publish-field">
<el-input-number
v-model="form.sortOrder"
:min="0"
:step="1"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :xs="12" :sm="6">
<el-form-item :label="t('content.field.start_time')" label-position="top" class="publish-field">
<el-date-picker
v-model="form.startTime"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
clearable
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :xs="12" :sm="6">
<el-form-item :label="t('content.field.end_time')" label-position="top" class="publish-field">
<el-date-picker
v-model="form.endTime"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
clearable
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item v-if="!editingId" label-position="top" class="publish-field notify-inbox-field">
<template #label>{{ t('content.field.notify_inbox') }}</template>
<div class="notify-inbox-block">
<el-switch v-model="notifyInbox" :disabled="form.status !== 'ACTIVE'" />
<p class="notify-inbox-hint">{{ t('content.field.notify_inbox_hint') }}</p>
</div>
</el-form-item>
</section>
<section class="announcement-content">
<h3 class="section-title">{{ t('content.section.content') }}</h3>
<el-tabs v-model="activeLocale" class="locale-tabs">
<el-tab-pane
v-for="tr in form.translations"
:key="tr.locale"
:label="localeLabel(tr.locale)"
:name="tr.locale"
>
<el-form-item :label="t('content.field.title')">
<el-input v-model="tr.title" :placeholder="t('content.field.ticker_title_ph')" />
</el-form-item>
<el-form-item
:label="t('content.field.announce_text')"
:required="form.status === 'ACTIVE'"
>
<el-input
v-model="tr.body"
type="textarea"
:rows="4"
:placeholder="t('content.field.ticker_body_ph')"
/>
<p class="field-hint compact-hint">{{ t('content.field.ticker_hint') }}</p>
</el-form-item>
</el-tab-pane>
</el-tabs>
</section>
</el-form>
<template #footer>
<el-button @click="announcementDialogVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="saving" @click="submitAnnouncementForm">
{{ t('common.save') }}
</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>
<style scoped>
.contents-page .type-tabs :deep(.el-tabs__header) {
margin-bottom: 12px;
}
.field-hint,
.batch-hint,
.schedule-line,
.schedule-sep,
.thumb-empty {
color: var(--text-muted);
}
.field-hint {
margin: 4px 0 0;
font-size: 11px;
line-height: 1.35;
}
.field-hint.inline-hint,
.field-hint.compact-hint {
margin-top: 0;
}
.field-hint.inline-hint {
margin-left: 8px;
}
.filter-row {
margin-top: 4px;
}
.batch-hint {
font-size: 12px;
margin: 0 4px;
white-space: nowrap;
}
.thumb {
width: 56px;
height: 32px;
object-fit: cover;
border-radius: 4px;
background: #f4f0e8;
}
.preview-title {
font-size: 13px;
color: var(--text);
font-weight: 700;
}
.hidden-tip {
margin: 4px 0 0;
font-size: 11px;
color: var(--warning-text);
}
.schedule-line {
font-size: 11px;
}
.schedule-sep {
margin: 0 4px;
font-size: 11px;
}
.content-publish-dialog :deep(.el-dialog__body) {
padding-top: 10px;
padding-bottom: 12px;
}
.content-publish-dialog :deep(.el-dialog__footer) {
padding-top: 10px;
}
.publish-layout {
display: flex;
gap: 14px;
align-items: stretch;
min-height: 480px;
}
.publish-meta {
flex: 0 0 380px;
max-width: 400px;
display: flex;
flex-direction: column;
gap: 10px;
}
.publish-meta-fields {
flex: 1;
min-height: 0;
}
.publish-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
min-height: 480px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fbfaf7;
}
.publish-body-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.publish-body-label {
font-size: 13px;
font-weight: 700;
color: var(--text);
}
.required-mark {
color: var(--el-color-danger);
margin-left: 2px;
}
.locale-badge {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: #fff;
white-space: nowrap;
}
.publish-rich-editor {
flex: 1;
min-height: 0;
}
.publish-form :deep(.el-form-item) {
margin-bottom: 8px;
}
.publish-form :deep(.el-form-item__label) {
padding-right: 8px;
line-height: 28px;
}
.publish-section {
margin-bottom: 10px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fbfaf7;
}
.publish-section--compact {
padding: 10px 12px 8px;
}
.publish-section:last-child {
margin-bottom: 0;
}
.section-title {
margin: 0 0 8px;
font-size: 13px;
font-weight: 700;
color: var(--text);
}
.publish-section-head {
margin: 0 0 8px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--text-muted);
}
.publish-section--compact :deep(.publish-field.el-form-item) {
margin-bottom: 12px;
}
.publish-section--compact :deep(.publish-field.el-form-item:last-child) {
margin-bottom: 0;
}
.publish-section--compact :deep(.publish-field .el-form-item__label) {
padding: 0 0 4px;
line-height: 1.35;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
}
.publish-section--compact :deep(.publish-field .el-form-item__content) {
line-height: normal;
}
.publish-grid {
margin-bottom: 12px;
}
.publish-grid :deep(.publish-field.el-form-item) {
margin-bottom: 0;
}
.notify-inbox-block {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
width: 100%;
}
.notify-inbox-hint {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--text-muted);
word-break: break-word;
}
.notify-inbox-field {
margin-top: 4px;
padding-top: 10px;
border-top: 1px dashed var(--border);
}
.publish-grid--secondary {
margin-top: 2px;
}
.announcement-dialog :deep(.el-dialog__body) {
padding-top: 8px;
}
.announcement-hint {
margin: 0 0 12px;
font-size: 12px;
line-height: 1.45;
color: var(--text-muted);
}
.announcement-publish {
margin-bottom: 12px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fbfaf7;
}
.announcement-content {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fbfaf7;
}
.announcement-form :deep(.el-form-item) {
margin-bottom: 10px;
}
.announcement-publish-row {
margin-top: 4px;
}
@media (max-width: 900px) {
.publish-layout {
flex-direction: column;
min-height: 0;
}
.publish-meta {
flex: none;
max-width: none;
width: 100%;
}
.publish-body {
min-height: 420px;
}
}
.locale-tabs :deep(.el-tabs__header) {
margin-bottom: 6px;
}
.locale-tabs :deep(.el-tabs__content) {
padding-top: 0;
}
@media (max-width: 700px) {
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
}
.inbox-notify-panel {
padding: 4px 0 12px;
max-width: 420px;
}
.inbox-notify-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.inbox-notify-row-text {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.inbox-notify-label {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.inbox-notify-hint {
font-size: 12px;
color: var(--text-muted);
line-height: 1.4;
}
.inbox-notify-notes-title {
margin: 14px 0 6px;
font-size: 12px;
color: var(--text-muted);
}
.inbox-notify-notes {
margin: 0 0 16px;
padding-left: 18px;
font-size: 13px;
color: var(--text);
line-height: 1.8;
}
.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>