414 lines
13 KiB
Vue
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>
|