Files
tiantian-system/app/components/admin/MarkdownEditor.vue
2026-04-08 17:10:58 +08:00

610 lines
15 KiB
Vue

<template>
<div class="markdown-editor-wrapper" :class="{ 'fullscreen': isFullscreen }">
<!-- 编辑器工具栏 -->
<div class="editor-toolbar">
<div class="toolbar-left">
<a-space>
<a-tooltip title="标题">
<a-button size="small" @click="insertAtCursor('# ')">
<template #icon><span class="toolbar-icon">H</span></template>
</a-button>
</a-tooltip>
<a-divider type="vertical" />
<a-tooltip title="加粗">
<a-button size="small" @click="insertWrap('**', '**')">
<template #icon><span class="toolbar-icon bold">B</span></template>
</a-button>
</a-tooltip>
<a-tooltip title="斜体">
<a-button size="small" @click="insertWrap('*', '*')">
<template #icon><span class="toolbar-icon italic">I</span></template>
</a-button>
</a-tooltip>
<a-tooltip title="删除线">
<a-button size="small" @click="insertWrap('~~', '~~')">
<template #icon><span class="toolbar-icon">S</span></template>
</a-button>
</a-tooltip>
<a-divider type="vertical" />
<a-tooltip title="引用">
<a-button size="small" @click="insertAtCursor('> ')">
<template #icon><span class="toolbar-icon">&gt;</span></template>
</a-button>
</a-tooltip>
<a-tooltip title="代码块">
<a-button size="small" @click="insertCodeBlock">
<template #icon><span class="toolbar-icon">&lt;/&gt;</span></template>
</a-button>
</a-tooltip>
<a-tooltip title="行内代码">
<a-button size="small" @click="insertWrap('`', '`')">
<template #icon><span class="toolbar-icon">`</span></template>
</a-button>
</a-tooltip>
<a-divider type="vertical" />
<a-tooltip title="链接">
<a-button size="small" @click="insertLink">
<template #icon><LinkOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip title="图片">
<a-button size="small" @click="handleImageUpload">
<template #icon><PictureOutlined /></template>
</a-button>
</a-tooltip>
<a-divider type="vertical" />
<a-tooltip title="有序列表">
<a-button size="small" @click="insertAtCursor('1. ')">
<template #icon><UnorderedListOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip title="无序列表">
<a-button size="small" @click="insertAtCursor('- ')">
<template #icon><OrderedListOutlined /></template>
</a-button>
</a-tooltip>
<a-divider type="vertical" />
<a-tooltip title="水平线">
<a-button size="small" @click="insertAtCursor('\n---\n')">
<template #icon><MinusOutlined /></template>
</a-button>
</a-tooltip>
</a-space>
</div>
<div class="toolbar-right">
<a-button size="small" @click="toggleFullscreen">
<template #icon><FullscreenOutlined v-if="!isFullscreen" /><FullscreenExitOutlined v-else /></template>
</a-button>
</div>
</div>
<!-- 编辑区域 -->
<div class="editor-body">
<div class="editor-pane">
<textarea
ref="textareaRef"
v-model="localValue"
class="markdown-textarea"
:placeholder="placeholder"
:style="{ height: isFullscreen ? '100%' : 'auto' }"
@input="handleInput"
@keydown="handleKeydown"
@scroll="syncScroll"
/>
</div>
<div class="preview-pane" v-if="showPreview">
<div class="preview-label">预览</div>
<div class="markdown-preview" v-html="renderedHtml"></div>
</div>
</div>
<!-- 隐藏的文件上传 -->
<a-upload
ref="uploadRef"
accept="image/*"
:show-upload-list="false"
:before-upload="beforeImageUpload"
:custom-request="handleCoverUpload"
style="display: none"
/>
<!-- 插入链接弹窗 -->
<a-modal
v-model:open="showLinkModal"
title="插入链接"
:width="400"
@ok="insertLinkConfirm"
>
<a-form :model="linkForm" layout="vertical">
<a-form-item label="链接文本" required>
<a-input v-model:value="linkForm.text" placeholder="显示的文本" />
</a-form-item>
<a-form-item label="链接地址" required>
<a-input v-model:value="linkForm.url" placeholder="https://..." />
</a-form-item>
</a-form>
</a-modal>
<!-- 字数统计 -->
<div class="editor-footer">
<span class="word-count">{{ wordCount }} 字</span>
<span class="char-count">{{ charCount }} 字符</span>
</div>
</div>
</template>
<script setup lang="ts">
import {
LinkOutlined,
PictureOutlined,
UnorderedListOutlined,
OrderedListOutlined,
MinusOutlined,
FullscreenOutlined,
FullscreenExitOutlined,
} from '@ant-design/icons-vue'
import { marked } from 'marked'
import hljs from 'highlight.js'
import 'highlight.js/styles/github.css'
import { message } from 'ant-design-vue'
import { uploadFile } from '@/api/system/file'
interface Props {
modelValue: string
placeholder?: string
showPreview?: boolean
minHeight?: string
}
const props = withDefaults(defineProps<Props>(), {
placeholder: '请输入 Markdown 内容...',
showPreview: true,
minHeight: '300px',
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// 配置 marked
marked.setOptions({
highlight: function(code: string, lang: string) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value
}
return hljs.highlightAuto(code).value
},
breaks: true,
gfm: true,
})
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const uploadRef = ref<any>(null)
const localValue = ref(props.modelValue)
const isFullscreen = ref(false)
const showLinkModal = ref(false)
const linkForm = reactive({
text: '',
url: '',
})
// 监听外部值变化
watch(() => props.modelValue, (newVal) => {
if (newVal !== localValue.value) {
localValue.value = newVal
}
})
// 计算属性
const renderedHtml = computed(() => {
if (!localValue.value) return '<p class="placeholder-hint">预览区域</p>'
try {
return marked.parse(localValue.value) as string
} catch (e) {
return '<p class="error-hint">渲染出错</p>'
}
})
const wordCount = computed(() => {
const text = localValue.value.trim()
if (!text) return 0
return text.split(/\s+/).filter(Boolean).length
})
const charCount = computed(() => localValue.value.length)
// 方法
function handleInput() {
emit('update:modelValue', localValue.value)
}
function handleKeydown(e: KeyboardEvent) {
// Tab 键插入空格
if (e.key === 'Tab') {
e.preventDefault()
insertAtCursor(' ')
}
// Ctrl/Cmd + B 加粗
if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
e.preventDefault()
insertWrap('**', '**')
}
// Ctrl/Cmd + I 斜体
if ((e.ctrlKey || e.metaKey) && e.key === 'i') {
e.preventDefault()
insertWrap('*', '*')
}
// Ctrl/Cmd + K 链接
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault()
insertLink()
}
}
function insertAtCursor(text: string) {
const textarea = textareaRef.value
if (!textarea) return
const start = textarea.selectionStart
const end = textarea.selectionEnd
const before = localValue.value.substring(0, start)
const after = localValue.value.substring(end)
localValue.value = before + text + after
emit('update:modelValue', localValue.value)
nextTick(() => {
textarea.focus()
textarea.selectionStart = textarea.selectionEnd = start + text.length
})
}
function insertWrap(before: string, after: string) {
const textarea = textareaRef.value
if (!textarea) return
const start = textarea.selectionStart
const end = textarea.selectionEnd
const selectedText = localValue.value.substring(start, end) || '文本'
const text = before + selectedText + after
localValue.value = localValue.value.substring(0, start) + text + localValue.value.substring(end)
emit('update:modelValue', localValue.value)
nextTick(() => {
textarea.focus()
textarea.selectionStart = start + before.length
textarea.selectionEnd = start + before.length + selectedText.length
})
}
function insertCodeBlock() {
const textarea = textareaRef.value
if (!textarea) return
const start = textarea.selectionStart
const end = textarea.selectionEnd
const selectedText = localValue.value.substring(start, end) || '代码'
const text = '\n```javascript\n' + selectedText + '\n```\n'
localValue.value = localValue.value.substring(0, start) + text + localValue.value.substring(end)
emit('update:modelValue', localValue.value)
nextTick(() => {
textarea.focus()
textarea.selectionStart = start + 14 // 跳过 ```javascript\n
textarea.selectionEnd = start + 14 + selectedText.length
})
}
function insertLink() {
const textarea = textareaRef.value
if (textarea) {
const selectedText = localValue.value.substring(textarea.selectionStart, textarea.selectionEnd)
linkForm.text = selectedText || ''
}
showLinkModal.value = true
}
function insertLinkConfirm() {
if (!linkForm.text.trim()) {
message.warning('请输入链接文本')
return
}
if (!linkForm.url.trim()) {
message.warning('请输入链接地址')
return
}
insertAtCursor(`[${linkForm.text}](${linkForm.url})`)
showLinkModal.value = false
linkForm.text = ''
linkForm.url = ''
}
function handleImageUpload() {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) {
await uploadAndInsertImage(file)
}
}
input.click()
}
function beforeImageUpload(file: File) {
if (!file.type.startsWith('image/')) {
message.error('只能上传图片文件')
return false
}
if (file.size > 5 * 1024 * 1024) {
message.error('图片大小不能超过 5MB')
return false
}
return true
}
async function uploadAndInsertImage(file: File) {
try {
message.loading({ content: '上传图片中...', key: 'upload' })
const record = await uploadFile(file)
const url = (record?.url || record?.downloadUrl || '').trim()
if (!url) throw new Error('上传成功但未返回图片地址')
insertAtCursor(`![${file.name}](${url})`)
message.success({ content: '图片上传成功', key: 'upload' })
} catch (e: any) {
message.error({ content: e?.message || '图片上传失败', key: 'upload' })
}
}
async function handleCoverUpload(option: any) {
await uploadAndInsertImage(option.file)
option.onSuccess?.()
}
function syncScroll(e: Event) {
const textarea = e.target as HTMLTextAreaElement
const preview = textarea.parentElement?.nextElementSibling?.querySelector('.markdown-preview') as HTMLElement
if (preview) {
const ratio = textarea.scrollTop / (textarea.scrollHeight - textarea.clientHeight)
preview.scrollTop = ratio * (preview.scrollHeight - preview.clientHeight)
}
}
function toggleFullscreen() {
isFullscreen.value = !isFullscreen.value
}
// 暴露方法
defineExpose({
focus: () => textareaRef.value?.focus(),
insertText: insertAtCursor,
})
</script>
<style scoped>
.markdown-editor-wrapper {
border: 1px solid #d9d9d9;
border-radius: 8px;
overflow: hidden;
background: #fff;
display: flex;
flex-direction: column;
}
.markdown-editor-wrapper.fullscreen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
border-radius: 0;
}
.editor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
}
.toolbar-left {
flex: 1;
}
.toolbar-right {
margin-left: 12px;
}
.toolbar-icon {
font-family: Georgia, serif;
font-weight: 700;
font-size: 12px;
}
.toolbar-icon.bold {
font-weight: 900;
}
.toolbar-icon.italic {
font-style: italic;
}
.editor-body {
display: flex;
flex: 1;
min-height: v-bind(minHeight);
overflow: hidden;
}
.editor-pane {
flex: 1;
display: flex;
flex-direction: column;
}
.markdown-textarea {
flex: 1;
padding: 16px;
border: none;
outline: none;
resize: none;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
line-height: 1.7;
color: #24292e;
background: #fff;
}
.markdown-textarea::placeholder {
color: #aaa;
}
.preview-pane {
flex: 1;
border-left: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.preview-label {
padding: 8px 16px;
font-size: 12px;
color: #999;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
}
.markdown-preview {
flex: 1;
padding: 16px;
overflow-y: auto;
font-size: 14px;
line-height: 1.8;
color: #24292e;
}
.markdown-preview :deep(.placeholder-hint),
.markdown-preview :deep(.error-hint) {
color: #aaa;
font-style: italic;
}
.markdown-preview :deep(h1) {
font-size: 1.8em;
font-weight: 700;
margin: 0.5em 0;
padding-bottom: 0.3em;
border-bottom: 1px solid #eaecef;
}
.markdown-preview :deep(h2) {
font-size: 1.5em;
font-weight: 600;
margin: 0.8em 0 0.4em;
padding-bottom: 0.25em;
border-bottom: 1px solid #eaecef;
}
.markdown-preview :deep(h3) {
font-size: 1.25em;
font-weight: 600;
margin: 0.6em 0 0.3em;
}
.markdown-preview :deep(p) {
margin: 0.8em 0;
}
.markdown-preview :deep(code) {
padding: 0.2em 0.4em;
background: #f6f8fa;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.9em;
}
.markdown-preview :deep(pre) {
padding: 16px;
background: #f6f8fa;
border-radius: 8px;
overflow-x: auto;
}
.markdown-preview :deep(pre code) {
padding: 0;
background: transparent;
}
.markdown-preview :deep(blockquote) {
margin: 1em 0;
padding: 0.5em 1em;
border-left: 4px solid #dfe2e5;
color: #6a737d;
background: #f6f8fa;
}
.markdown-preview :deep(ul),
.markdown-preview :deep(ol) {
padding-left: 2em;
margin: 0.8em 0;
}
.markdown-preview :deep(li) {
margin: 0.3em 0;
}
.markdown-preview :deep(a) {
color: #0366d6;
text-decoration: none;
}
.markdown-preview :deep(a:hover) {
text-decoration: underline;
}
.markdown-preview :deep(img) {
max-width: 100%;
border-radius: 8px;
}
.markdown-preview :deep(hr) {
margin: 1.5em 0;
border: none;
border-top: 1px solid #dfe2e5;
}
.markdown-preview :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 1em 0;
}
.markdown-preview :deep(th),
.markdown-preview :deep(td) {
padding: 8px 12px;
border: 1px solid #dfe2e5;
}
.markdown-preview :deep(th) {
background: #f6f8fa;
font-weight: 600;
}
.editor-footer {
display: flex;
gap: 16px;
padding: 6px 12px;
font-size: 12px;
color: #999;
background: #fafafa;
border-top: 1px solid #f0f0f0;
}
</style>