Files
TakeoutSaaS.AdminUI/src/components/announcement/AnnouncementPreview.vue
2026-01-29 04:21:09 +00:00

414 lines
13 KiB
Vue

<template>
<div
ref="rootRef"
class="announcement-preview h-full w-full"
:class="{ 'is-resizing': isResizing }"
>
<template v-if="isSplitMode">
<ElContainer class="h-full w-full overflow-hidden">
<ElMain class="h-full overflow-auto pr-4">
<slot name="editor" />
</ElMain>
<div
class="preview-resizer"
role="separator"
:aria-label="t('announcement.preview.resizeHandle')"
@pointerdown="handleResizeStart"
></div>
<ElAside
class="preview-aside flex h-full items-center justify-center border-l border-[var(--el-border-color)] bg-[var(--el-fill-color-blank)]"
>
<div class="flex h-full w-full items-center justify-center p-4">
<div
class="preview-phone flex h-full max-h-[720px] w-full max-w-[380px] flex-col overflow-hidden rounded-[32px] border border-[var(--el-border-color)] bg-[var(--el-bg-color)] shadow-xl"
>
<div class="border-b border-[var(--el-border-color)] px-4 py-3">
<div class="text-base font-semibold text-[var(--el-text-color-primary)]">
{{ titleText }}
</div>
<div class="mt-2 flex flex-wrap items-center gap-2">
<ElTag size="small" :type="typeTagType">{{ typeLabel }}</ElTag>
<ElTag size="small" :type="priorityTagType">{{ priorityText }}</ElTag>
</div>
<div class="mt-2 text-xs text-[var(--el-text-color-secondary)]">
<span class="mr-1">{{ $t('announcement.preview.publishAt') }}</span>
<span>{{ publishTimeText }}</span>
</div>
</div>
<ElScrollbar class="flex-1">
<div
class="announcement-preview__content px-4 py-4 text-sm leading-6 text-[var(--el-text-color-regular)]"
>
<div v-if="hasContent" v-html="sanitizedContent"></div>
<div v-else class="text-sm text-[var(--el-text-color-secondary)]">
{{ $t('announcement.preview.emptyContent') }}
</div>
</div>
</ElScrollbar>
</div>
</div>
</ElAside>
</ElContainer>
</template>
<template v-else>
<div class="h-full w-full">
<slot name="editor" />
</div>
<div
class="announcement-preview__action-bar sticky bottom-0 z-20 border-t border-[var(--el-border-color)] bg-[var(--el-bg-color)] px-4 py-3"
>
<ElButton type="primary" class="w-full" @click="drawerVisible = true">
{{ $t('announcement.preview.openPreview') }}
</ElButton>
</div>
<ElDrawer
v-model="drawerVisible"
:title="t('announcement.preview.drawerTitle')"
direction="btt"
:size="drawerSize"
append-to-body
>
<div class="flex w-full justify-center px-4 pb-4">
<div
class="preview-phone flex h-full max-h-[720px] w-full max-w-[380px] flex-col overflow-hidden rounded-[32px] border border-[var(--el-border-color)] bg-[var(--el-bg-color)] shadow-xl"
>
<div class="border-b border-[var(--el-border-color)] px-4 py-3">
<div class="text-base font-semibold text-[var(--el-text-color-primary)]">
{{ titleText }}
</div>
<div class="mt-2 flex flex-wrap items-center gap-2">
<ElTag size="small" :type="typeTagType">{{ typeLabel }}</ElTag>
<ElTag size="small" :type="priorityTagType">{{ priorityText }}</ElTag>
</div>
<div class="mt-2 text-xs text-[var(--el-text-color-secondary)]">
<span class="mr-1">{{ $t('announcement.preview.publishAt') }}</span>
<span>{{ publishTimeText }}</span>
</div>
</div>
<ElScrollbar class="flex-1">
<div
class="announcement-preview__content px-4 py-4 text-sm leading-6 text-[var(--el-text-color-regular)]"
>
<div v-if="hasContent" v-html="sanitizedContent"></div>
<div v-else class="text-sm text-[var(--el-text-color-secondary)]">
{{ $t('announcement.preview.emptyContent') }}
</div>
</div>
</ElScrollbar>
</div>
</div>
</ElDrawer>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useWindowSize } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import DOMPurify from 'dompurify'
import {
ElAside,
ElButton,
ElContainer,
ElDrawer,
ElMain,
ElScrollbar,
ElTag
} from 'element-plus'
import type { AnnouncementFormData, TenantAnnouncementDto } from '@/types/announcement'
import { TenantAnnouncementType } from '@/types/announcement'
defineOptions({ name: 'AnnouncementPreview' })
interface Props {
announcement: Partial<AnnouncementFormData> | TenantAnnouncementDto
mode?: 'split' | 'drawer'
}
// 1. 解析 Props 与基础依赖
const { announcement, mode = 'split' } = defineProps<Props>()
const { t, locale } = useI18n()
const { width } = useWindowSize()
// 2. 拖拽与布局状态
const rootRef = ref<HTMLElement | null>(null)
const isResizing = ref(false)
const drawerVisible = ref(false)
const PREVIEW_MIN_WIDTH = 320
const PREVIEW_MAX_WIDTH = 520
const PREVIEW_DEFAULT_WIDTH = 380
const EDITOR_MIN_WIDTH = 360
const DRAWER_BREAKPOINT = 1024
const previewWidth = ref(PREVIEW_DEFAULT_WIDTH)
// 3. 模式判定(桌面分栏 / 移动抽屉)
const isDrawerMode = computed(() => mode === 'drawer' || width.value < DRAWER_BREAKPOINT)
const isSplitMode = computed(() => !isDrawerMode.value)
const drawerSize = computed(() => (width.value < 640 ? '90%' : '85%'))
// 4. 预览数据统一读取
const announcementData = computed(
() => announcement as Partial<TenantAnnouncementDto> & Partial<AnnouncementFormData>
)
const titleText = computed(() => {
// 1. 取标题或占位文案
const title = announcementData.value.title?.trim()
return title && title.length > 0 ? title : t('announcement.preview.titlePlaceholder')
})
const rawContent = computed(() => {
// 1. 取原始内容
return announcementData.value.content?.trim() || ''
})
const sanitizedContent = computed(() => {
// 1. 使用 DOMPurify 清洗富文本
return rawContent.value ? DOMPurify.sanitize(rawContent.value) : ''
})
const hasContent = computed(() => sanitizedContent.value.length > 0)
const publishTimeText = computed(() => {
// 1. 选择发布时间字段
const candidate =
announcementData.value.publishedAt ||
announcementData.value.scheduledPublishAt ||
announcementData.value.effectiveFrom ||
null
// 2. 格式化时间
if (!candidate) {
return t('announcement.preview.publishAtUnknown')
}
const date = new Date(candidate)
if (Number.isNaN(date.getTime())) {
return t('announcement.preview.publishAtUnknown')
}
return date.toLocaleString(locale.value)
})
const typeLabel = computed(() => {
// 1. 根据枚举映射公告类型
switch (announcementData.value.announcementType) {
case TenantAnnouncementType.System:
return t('announcement.type.system')
case TenantAnnouncementType.Billing:
return t('announcement.type.billing')
case TenantAnnouncementType.Operation:
return t('announcement.type.operation')
case TenantAnnouncementType.SYSTEM_PLATFORM_UPDATE:
return t('announcement.type.systemPlatformUpdate')
case TenantAnnouncementType.SYSTEM_SECURITY_NOTICE:
return t('announcement.type.systemSecurityNotice')
case TenantAnnouncementType.SYSTEM_COMPLIANCE:
return t('announcement.type.systemCompliance')
case TenantAnnouncementType.TENANT_INTERNAL:
return t('announcement.type.tenantInternal')
case TenantAnnouncementType.TENANT_FINANCE:
return t('announcement.type.tenantFinance')
case TenantAnnouncementType.TENANT_OPERATION:
return t('announcement.type.tenantOperation')
default:
return t('announcement.type.unknown')
}
})
const typeTagType = computed(() => {
// 1. 映射类型标签颜色
switch (announcementData.value.announcementType) {
case TenantAnnouncementType.System:
case TenantAnnouncementType.SYSTEM_SECURITY_NOTICE:
case TenantAnnouncementType.SYSTEM_COMPLIANCE:
return 'danger'
case TenantAnnouncementType.Billing:
return 'warning'
case TenantAnnouncementType.TENANT_FINANCE:
return 'warning'
case TenantAnnouncementType.Operation:
case TenantAnnouncementType.SYSTEM_PLATFORM_UPDATE:
return 'info'
case TenantAnnouncementType.TENANT_INTERNAL:
case TenantAnnouncementType.TENANT_OPERATION:
return 'success'
default:
return 'info'
}
})
const priorityValue = computed(() => {
// 1. 解析优先级数值
const value = Number(announcementData.value.priority)
return Number.isFinite(value) ? value : null
})
const priorityText = computed(() => {
// 1. 输出优先级文案
if (priorityValue.value === null) {
return t('announcement.preview.priorityUnknown')
}
return t('announcement.preview.priorityTag', { level: priorityValue.value })
})
const priorityTagType = computed(() => {
// 1. 根据数值区间标识优先级
if (priorityValue.value === null) {
return 'info'
}
if (priorityValue.value >= 8) {
return 'danger'
}
if (priorityValue.value >= 5) {
return 'warning'
}
if (priorityValue.value >= 3) {
return 'info'
}
return 'success'
})
// 5. 预览宽度计算与写入
const resolveMaxWidth = () => {
// 1. 读取容器宽度并扣除编辑区最小值
const rect = rootRef.value?.getBoundingClientRect()
if (!rect) {
return PREVIEW_MAX_WIDTH
}
const maxByContainer = rect.width - EDITOR_MIN_WIDTH
return Math.min(PREVIEW_MAX_WIDTH, Math.max(PREVIEW_MIN_WIDTH, maxByContainer))
}
const applyPreviewWidth = (value: number) => {
// 1. 计算限制后的宽度
const maxWidth = resolveMaxWidth()
const nextWidth = Math.min(Math.max(value, PREVIEW_MIN_WIDTH), maxWidth)
// 2. 写入 CSS 变量并同步缓存
previewWidth.value = nextWidth
rootRef.value?.style.setProperty('--preview-width', `${nextWidth}px`)
}
// 6. 拖拽调整宽度
const handleResizeStart = (event: PointerEvent) => {
// 1. 仅在分栏模式启用拖拽
if (!isSplitMode.value) return
// 2. 阻止选中文本
event.preventDefault()
// 3. 初始化拖拽状态
isResizing.value = true
const startX = event.clientX
const startWidth = previewWidth.value
// 4. 绑定全局拖拽监听
const handleResizeMove = (moveEvent: PointerEvent) => {
// 1. 计算拖拽偏移量
const deltaX = startX - moveEvent.clientX
const nextWidth = startWidth + deltaX
// 2. 更新预览宽度
applyPreviewWidth(nextWidth)
}
const handleResizeEnd = () => {
// 1. 清理拖拽状态与监听
isResizing.value = false
window.removeEventListener('pointermove', handleResizeMove)
window.removeEventListener('pointerup', handleResizeEnd)
}
window.addEventListener('pointermove', handleResizeMove)
window.addEventListener('pointerup', handleResizeEnd)
}
// 7. 响应式监听与清理
watch(isSplitMode, (value) => {
// 1. 切回分栏时重置预览宽度
if (value) {
applyPreviewWidth(previewWidth.value)
return
}
// 2. 切换到抽屉时关闭预览
drawerVisible.value = false
})
watch(width, () => {
// 1. 窗口变化时校正预览宽度
if (isSplitMode.value) {
applyPreviewWidth(previewWidth.value)
}
})
onMounted(() => {
// 1. 初始化默认宽度
applyPreviewWidth(previewWidth.value)
})
onBeforeUnmount(() => {
// 1. 清理拖拽状态
isResizing.value = false
})
</script>
<style scoped>
.announcement-preview {
--preview-width: 380px;
}
.preview-aside {
width: var(--preview-width);
}
.preview-resizer {
@apply relative w-2 shrink-0 cursor-col-resize;
}
.preview-resizer::before {
content: '';
@apply absolute left-1/2 top-2 h-[calc(100%-16px)] w-px -translate-x-1/2 bg-[var(--el-border-color)];
}
.announcement-preview.is-resizing {
@apply cursor-col-resize select-none;
}
.announcement-preview__content :deep(p) {
@apply mb-3 last:mb-0;
}
.announcement-preview__content :deep(ul) {
@apply mb-3 list-inside list-disc;
}
.announcement-preview__content :deep(ol) {
@apply mb-3 list-inside list-decimal;
}
.announcement-preview__content :deep(h1),
.announcement-preview__content :deep(h2),
.announcement-preview__content :deep(h3) {
@apply mb-2 text-base font-semibold text-[var(--el-text-color-primary)];
}
</style>