chore: 初始化平台管理端
This commit is contained in:
413
src/components/announcement/AnnouncementPreview.vue
Normal file
413
src/components/announcement/AnnouncementPreview.vue
Normal file
@@ -0,0 +1,413 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user