增加公告图片编辑, 增加取消归档

This commit is contained in:
yml
2026-06-15 20:37:13 +08:00
parent 85f2d9b3b3
commit 9271f47553
16 changed files with 765 additions and 26 deletions
+1
View File
@@ -49,6 +49,7 @@ declare module 'vue' {
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElSegmented: typeof import('element-plus/es')['ElSegmented']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSlider: typeof import('element-plus/es')['ElSlider']
ElStep: typeof import('element-plus/es')['ElStep']
ElSteps: typeof import('element-plus/es')['ElSteps']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
@@ -59,6 +59,10 @@ export async function archiveAnnouncement(id: number): Promise<void> {
await apiClient.post(`/admin/announcements/${id}/archive`)
}
export async function unarchiveAnnouncement(id: number): Promise<void> {
await apiClient.post(`/admin/announcements/${id}/unarchive`)
}
export async function deleteAnnouncement(id: number): Promise<void> {
await apiClient.delete(`/admin/announcements/${id}`)
}
@@ -1,13 +1,16 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { Bell, Document, QuestionFilled, Warning } from '@element-plus/icons-vue'
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
@@ -22,6 +25,17 @@ const emit = defineEmits<{
}>()
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: '',
@@ -51,6 +65,14 @@ 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 => {
@@ -93,6 +115,153 @@ async function handleSave() {
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>
@@ -122,12 +291,32 @@ async function handleSave() {
</el-form-item>
<el-form-item label="公告内容" prop="content">
<el-input
v-model="formData.content"
type="textarea"
:rows="12"
placeholder="支持 Markdown 或 HTML 格式,可以使用标题、列表、链接等"
/>
<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="优先级">
@@ -153,9 +342,106 @@ async function handleSave() {
<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;
@@ -18,6 +18,7 @@ import {
updateAnnouncement,
publishAnnouncement,
archiveAnnouncement,
unarchiveAnnouncement,
deleteAnnouncement,
type CreateAnnouncementRequest,
type UpdateAnnouncementRequest,
@@ -135,6 +136,23 @@ async function handleArchive(item: Announcement) {
}
}
async function handleUnarchive(item: Announcement) {
try {
await ElMessageBox.confirm('确认要取消归档该公告吗?取消后公告将恢复发布。', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
})
await unarchiveAnnouncement(item.id)
ElMessage.success('取消归档成功')
loadAnnouncements()
} catch (err) {
if (err !== 'cancel') {
ElMessage.error('取消归档失败')
}
}
}
async function handleDelete(item: Announcement) {
try {
await ElMessageBox.confirm('确认要删除该公告吗?此操作不可恢复。', '警告', {
@@ -314,6 +332,16 @@ function getStatusType(status: string): '' | 'success' | 'info' | 'warning' {
>
归档
</el-button>
<el-button
v-if="row.status === 'archived'"
type="warning"
size="small"
@click="handleUnarchive(row)"
text
style="color: #d97706; font-weight: 700"
>
取消归档
</el-button>
<el-button
type="danger"
size="small"
@@ -0,0 +1,60 @@
import { Marked } from 'marked'
const announcementMarkdown = new Marked({
breaks: true,
gfm: true,
renderer: {
html() {
return ''
},
link({ href, title, tokens }) {
const text = this.parser.parseInline(tokens)
if (!isSafeMarkdownURL(href)) return text
const safeTitle = title ? ` title="${escapeHTMLAttribute(title)}"` : ''
return `<a href="${escapeHTMLAttribute(href)}"${safeTitle} target="_blank" rel="noopener noreferrer">${text}</a>`
},
image({ href, title, text }) {
if (!isSafeMarkdownURL(href)) return ''
const imageText = parseImageText(text)
const safeTitle = title ? ` title="${escapeHTMLAttribute(title)}"` : ''
const widthStyle = imageText.width
? ` style="width:${imageText.width}px;max-width:100%;height:auto;"`
: ''
return `<img src="${escapeHTMLAttribute(href)}" alt="${escapeHTMLAttribute(imageText.alt)}"${safeTitle}${widthStyle}>`
},
},
})
export function renderAnnouncementMarkdown(content: string) {
return announcementMarkdown.parse(content) as string
}
function isSafeMarkdownURL(url: string) {
const value = url.trim().toLowerCase()
return (
value.startsWith('/') ||
value.startsWith('http://') ||
value.startsWith('https://') ||
value.startsWith('mailto:')
)
}
function escapeHTMLAttribute(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
function parseImageText(text: string) {
const matched = text.match(/^(.*?)(?:\|w=(\d{2,4}))$/)
if (!matched) return { alt: text }
const width = Number(matched[2])
if (!Number.isFinite(width) || width < 120 || width > 1200) return { alt: text }
return {
alt: matched[1] || '',
width,
}
}
@@ -11,25 +11,19 @@ import {
Warning,
} from '@element-plus/icons-vue'
import { fetchAnnouncementDetail, type Announcement } from '@/features/announcement'
import { renderAnnouncementMarkdown } from '@/features/announcement/utils/markdown'
import { debugError } from '@/shared/utils/debug'
import { formatDateTime } from '@/shared/utils/time'
import { marked } from 'marked'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const announcement = ref<Announcement | null>(null)
// 配置 marked 选项
marked.setOptions({
breaks: true, // 支持换行
gfm: true, // GitHub Flavored Markdown
})
// 计算属性:将 Markdown 转换为 HTML
const renderedContent = computed(() => {
if (!announcement.value?.content) return ''
return marked.parse(announcement.value.content)
return renderAnnouncementMarkdown(announcement.value.content)
})
const categories = [
@@ -2,8 +2,8 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast } from 'vant'
import { marked } from 'marked'
import { fetchAnnouncementDetail, type Announcement } from '@/features/announcement'
import { renderAnnouncementMarkdown } from '@/features/announcement/utils/markdown'
import { formatDateTime } from '@/shared/utils/time'
const route = useRoute()
@@ -11,11 +11,6 @@ const router = useRouter()
const loading = ref(false)
const announcement = ref<Announcement | null>(null)
marked.setOptions({
breaks: true,
gfm: true,
})
const categories = [
{ value: 'notice', label: '通知公告' },
{ value: 'tutorial', label: '使用教程' },
@@ -25,7 +20,7 @@ const categories = [
const renderedContent = computed(() => {
if (!announcement.value?.content) return ''
return marked.parse(announcement.value.content)
return renderAnnouncementMarkdown(announcement.value.content)
})
onMounted(loadAnnouncement)
+2
View File
@@ -1,6 +1,7 @@
const IMAGE_UPLOAD_MAX_SIDE: Record<string, number> = {
avatar: 512,
chat: 1280,
announcement: 1600,
'home-banner': 1920,
listing: 1920,
dispute: 1920,
@@ -11,6 +12,7 @@ const IMAGE_UPLOAD_MAX_SIDE: Record<string, number> = {
const IMAGE_UPLOAD_QUALITY: Record<string, number> = {
avatar: 0.82,
chat: 0.8,
announcement: 0.84,
'home-banner': 0.84,
listing: 0.84,
dispute: 0.86,