1327 lines
38 KiB
Vue
1327 lines
38 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 } 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;
|
|
}
|
|
|
|
const inboxNotifySettings = ref<InboxNotifySettings>({
|
|
inboxEnabled: true,
|
|
deposit: true,
|
|
});
|
|
const inboxNotifySaving = ref(false);
|
|
|
|
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 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();
|
|
return;
|
|
}
|
|
void load();
|
|
});
|
|
|
|
onActivated(() => {
|
|
if (activeType.value === 'INBOX_NOTIFY') 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>
|
|
</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" 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>
|
|
</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;
|
|
}
|
|
</style>
|