Files
hfb_sys/frontend/src/features/admin/components/AnnouncementDialog.vue
T

451 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { ElInput, ElMessage } from 'element-plus'
import { Bell, Document, Operation, Picture, QuestionFilled, Warning } from '@element-plus/icons-vue'
import type { Announcement } from '@/features/announcement'
import type {
CreateAnnouncementRequest,
UpdateAnnouncementRequest,
} from '@/features/admin/api/adminAnnouncements'
import { uploadAdminFile } from '@/shared/api/files'
import { debugError } from '@/shared/utils/debug'
import { readError } from '@/shared/utils/error'
interface Props {
visible: boolean
mode: 'create' | 'edit'
announcement?: Announcement | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:visible': [value: boolean]
save: [data: CreateAnnouncementRequest | UpdateAnnouncementRequest]
}>()
const formRef = ref<FormInstance>()
const contentInputRef = ref<InstanceType<typeof ElInput>>()
const imageInputRef = ref<HTMLInputElement | null>(null)
const uploadingImage = ref(false)
const imageSettingsVisible = ref(false)
const imageSettingsRange = ref<{ start: number; end: number } | null>(null)
const imageSettingsForm = ref({
alt: '',
url: '',
width: 720,
useCustomWidth: true,
})
const formData = ref<CreateAnnouncementRequest>({
title: '',
content: '',
category: 'notice',
priority: 0,
is_pinned: false,
is_important: false,
})
const categories = [
{ value: 'notice', label: '通知公告', icon: Bell },
{ value: 'tutorial', label: '使用教程', icon: Document },
{ value: 'rule', label: '规则说明', icon: Warning },
{ value: 'faq', label: '常见问题', icon: QuestionFilled },
]
const rules: FormRules = {
title: [
{ required: true, message: '请输入公告标题', trigger: 'blur' },
{ max: 255, message: '标题不能超过255个字符', trigger: 'blur' },
],
content: [{ required: true, message: '请输入公告内容', trigger: 'blur' }],
category: [{ required: true, message: '请选择公告分类', trigger: 'change' }],
}
const dialogTitle = computed(() => {
return props.mode === 'create' ? '新建公告' : '编辑公告'
})
const imageSettingsPreviewStyle = computed(() => {
if (!imageSettingsForm.value.useCustomWidth) return {}
return {
width: `${imageSettingsForm.value.width}px`,
maxWidth: '100%',
}
})
watch(
() => props.visible,
val => {
if (val) {
if (props.mode === 'edit' && props.announcement) {
formData.value = {
title: props.announcement.title,
content: props.announcement.content,
category: props.announcement.category as any,
priority: props.announcement.priority,
is_pinned: props.announcement.is_pinned,
is_important: props.announcement.is_important,
}
} else {
formData.value = {
title: '',
content: '',
category: 'notice',
priority: 0,
is_pinned: false,
is_important: false,
}
}
formRef.value?.clearValidate()
}
}
)
function handleClose() {
emit('update:visible', false)
}
async function handleSave() {
if (!formRef.value) return
try {
await formRef.value.validate()
emit('save', formData.value)
} catch (error) {
debugError('表单验证失败', error)
}
}
function triggerImageUpload() {
if (uploadingImage.value) return
imageInputRef.value?.click()
}
async function handleImageChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || uploadingImage.value) return
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
ElMessage.warning('仅支持 JPG、PNG 或 WebP 图片')
return
}
uploadingImage.value = true
try {
const uploaded = await uploadAdminFile(file, 'announcement')
insertContentAtCursor(`![${imageAltText(uploaded.filename)}|w=720](${uploaded.medium_url || uploaded.url})`)
ElMessage.success('图片已插入')
} catch (error) {
ElMessage.error(readError(error, '图片上传失败'))
} finally {
uploadingImage.value = false
}
}
function insertContentAtCursor(markdown: string) {
const textarea = contentInputRef.value?.textarea
const content = formData.value.content || ''
if (!textarea) {
formData.value.content = appendMarkdown(content, markdown)
return
}
const start = textarea.selectionStart ?? content.length
const end = textarea.selectionEnd ?? content.length
const before = content.slice(0, start)
const after = content.slice(end)
const prefix = before === '' || before.endsWith('\n') ? '' : '\n\n'
const suffix = after === '' || after.startsWith('\n') ? '' : '\n\n'
const insertion = `${prefix}${markdown}${suffix}`
formData.value.content = `${before}${insertion}${after}`
requestAnimationFrame(() => {
const cursor = start + insertion.length
textarea.focus()
textarea.setSelectionRange(cursor, cursor)
})
}
function appendMarkdown(content: string, markdown: string) {
if (!content) return markdown
return `${content}${content.endsWith('\n') ? '' : '\n\n'}${markdown}`
}
function imageAltText(filename: string) {
return filename.replace(/\.[^.]+$/, '') || '公告图片'
}
function openImageSettings() {
const textarea = contentInputRef.value?.textarea
const cursor = textarea?.selectionStart ?? formData.value.content.length
const image = findImageMarkdownAtCursor(formData.value.content, cursor)
if (!image) {
ElMessage.warning('请先把光标放在一张图片语法中')
return
}
imageSettingsRange.value = { start: image.start, end: image.end }
imageSettingsForm.value = {
alt: image.alt,
url: image.url,
width: image.width || 720,
useCustomWidth: Boolean(image.width),
}
imageSettingsVisible.value = true
}
function applyImageSettings() {
const range = imageSettingsRange.value
if (!range) return
const alt = imageSettingsForm.value.alt.trim() || '公告图片'
const url = imageSettingsForm.value.url.trim()
if (!url) {
ElMessage.warning('图片地址不能为空')
return
}
const widthMark = imageSettingsForm.value.useCustomWidth ? `|w=${imageSettingsForm.value.width}` : ''
const nextMarkdown = `![${escapeMarkdownImageText(alt)}${widthMark}](${url})`
const content = formData.value.content
formData.value.content = `${content.slice(0, range.start)}${nextMarkdown}${content.slice(range.end)}`
imageSettingsVisible.value = false
requestAnimationFrame(() => {
const textarea = contentInputRef.value?.textarea
if (!textarea) return
const cursor = range.start + nextMarkdown.length
textarea.focus()
textarea.setSelectionRange(cursor, cursor)
})
}
function setImageWidth(width: number | null) {
if (width === null) {
imageSettingsForm.value.useCustomWidth = false
return
}
imageSettingsForm.value.width = width
imageSettingsForm.value.useCustomWidth = true
}
function findImageMarkdownAtCursor(content: string, cursor: number) {
const imagePattern = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g
let match: RegExpExecArray | null
while ((match = imagePattern.exec(content))) {
const start = match.index
const end = start + match[0].length
if (cursor < start || cursor > end) continue
const imageText = parseImageText(match[1] || '')
return {
start,
end,
alt: imageText.alt,
width: imageText.width,
url: match[2] || '',
}
}
return null
}
function parseImageText(text: string) {
const matched = text.match(/^(.*?)(?:\|w=(\d{2,4}))$/)
if (!matched) return { alt: text, width: null }
const width = Number(matched[2])
return {
alt: matched[1] || '',
width: Number.isFinite(width) ? width : null,
}
}
function escapeMarkdownImageText(text: string) {
return text.replace(/\[/g, '').replace(/\]/g, '')
}
</script>
<template>
<el-dialog :model-value="visible" :title="dialogTitle" width="800px" @close="handleClose">
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
<el-form-item label="公告标题" prop="title">
<el-input
v-model="formData.title"
placeholder="请输入公告标题"
maxlength="255"
show-word-limit
/>
</el-form-item>
<el-form-item label="公告分类" prop="category">
<el-select v-model="formData.category" placeholder="请选择分类">
<el-option
v-for="cat in categories"
:key="cat.value"
:label="cat.label"
:value="cat.value"
>
<el-icon style="margin-right: 8px"><component :is="cat.icon" /></el-icon>
{{ cat.label }}
</el-option>
</el-select>
</el-form-item>
<el-form-item label="公告内容" prop="content">
<div class="content-editor">
<div class="content-toolbar">
<el-button size="small" :loading="uploadingImage" @click="triggerImageUpload">
<el-icon><Picture /></el-icon>
插入图片
</el-button>
<el-button size="small" @click="openImageSettings">
<el-icon><Operation /></el-icon>
图片设置
</el-button>
<input
ref="imageInputRef"
class="hidden-file-input"
type="file"
accept="image/jpeg,image/png,image/webp"
@change="handleImageChange"
/>
</div>
<el-input
ref="contentInputRef"
v-model="formData.content"
type="textarea"
:rows="12"
placeholder="支持 Markdown 格式,可以使用标题、列表、链接和图片"
/>
</div>
</el-form-item>
<el-form-item label="优先级">
<el-input-number
v-model="formData.priority"
:min="0"
:max="999"
placeholder="数值越大越靠前"
/>
<span style="margin-left: 12px; color: #909399; font-size: 13px"
>数值越大越靠前,默认为0</span
>
</el-form-item>
<el-form-item label="标记">
<el-checkbox v-model="formData.is_pinned">置顶</el-checkbox>
<el-checkbox v-model="formData.is_important" style="margin-left: 20px">重要</el-checkbox>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="imageSettingsVisible" title="图片设置" width="520px">
<div class="image-settings-preview">
<img
v-if="imageSettingsForm.url"
:src="imageSettingsForm.url"
:alt="imageSettingsForm.alt"
:style="imageSettingsPreviewStyle"
/>
</div>
<el-form label-width="84px">
<el-form-item label="图片说明">
<el-input v-model="imageSettingsForm.alt" placeholder="用于图片替代文字" />
</el-form-item>
<el-form-item label="显示宽度">
<div class="image-width-control">
<el-switch v-model="imageSettingsForm.useCustomWidth" active-text="自定义" inactive-text="原宽" />
<el-slider
v-model="imageSettingsForm.width"
:min="120"
:max="1200"
:step="10"
:disabled="!imageSettingsForm.useCustomWidth"
/>
<el-input-number
v-model="imageSettingsForm.width"
:min="120"
:max="1200"
:step="10"
:disabled="!imageSettingsForm.useCustomWidth"
controls-position="right"
/>
</div>
</el-form-item>
<el-form-item label="快捷尺寸">
<div class="quick-widths">
<el-button size="small" @click="setImageWidth(320)">小</el-button>
<el-button size="small" @click="setImageWidth(720)">中</el-button>
<el-button size="small" @click="setImageWidth(960)">大</el-button>
<el-button size="small" @click="setImageWidth(null)">原宽</el-button>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="imageSettingsVisible = false">取消</el-button>
<el-button type="primary" @click="applyImageSettings">应用</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.content-editor {
width: 100%;
}
.content-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.hidden-file-input {
display: none;
}
.image-settings-preview {
display: flex;
align-items: center;
justify-content: center;
min-height: 180px;
margin-bottom: 20px;
padding: 16px;
border: 1px solid #e6eaf2;
border-radius: 8px;
background: #f8fafc;
}
.image-settings-preview img {
display: block;
max-width: 100%;
max-height: 260px;
height: auto;
border-radius: 6px;
}
.image-width-control {
display: grid;
grid-template-columns: auto minmax(160px, 1fr) 120px;
align-items: center;
width: 100%;
gap: 12px;
}
.quick-widths {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
:deep(.el-textarea__inner) {
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
font-size: 13px;
line-height: 1.6;
}
</style>