增加公告图片编辑, 增加取消归档
This commit is contained in:
@@ -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(``)
|
||||
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>
|
||||
@@ -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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user