451 lines
13 KiB
Vue
451 lines
13 KiB
Vue
<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(``)
|
||
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 = ``
|
||
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>
|