410 lines
11 KiB
Vue
410 lines
11 KiB
Vue
<template>
|
|
<view class="rich-text-input">
|
|
<tm-form-item :name="name" :label="label" :required="required" :direction="direction">
|
|
<view class="input-container" @click="openEditor">
|
|
<view v-if="!modelValue || modelValue.trim() === ''" class="placeholder">
|
|
{{ placeholder }}
|
|
</view>
|
|
<view v-else class="content-preview">
|
|
<rich-text :nodes="previewNodes" class="preview-text"></rich-text>
|
|
</view>
|
|
<view class="input-actions">
|
|
<tm-icon name="edit" size="16" color="#999"></tm-icon>
|
|
</view>
|
|
</view>
|
|
</tm-form-item>
|
|
|
|
<!-- 弹窗编辑器 -->
|
|
<tm-modal v-model:show="showEditor" title="富文本编辑" :showClose="true" size="large" :height="'80vh'" :zIndex="1000">
|
|
<view class="editor-modal-content">
|
|
<rich-text-editor
|
|
v-model="editorContent"
|
|
:placeholder="placeholder"
|
|
@change="onEditorChange"
|
|
/>
|
|
</view>
|
|
<template #footer>
|
|
<view class="modal-footer">
|
|
<tm-button size="s" skin="thin" @click="showEditor = false">取消</tm-button>
|
|
<tm-button size="s" color="primary" @click="saveContent">保存</tm-button>
|
|
</view>
|
|
</template>
|
|
</tm-modal>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch } from 'vue'
|
|
import RichTextEditor from '../rich-text-editor/rich-text-editor.vue'
|
|
|
|
interface Props {
|
|
modelValue?: string
|
|
label?: string
|
|
name?: string
|
|
placeholder?: string
|
|
required?: boolean
|
|
direction?: 'horizontal' | 'vertical'
|
|
}
|
|
|
|
interface Emits {
|
|
(e: 'update:modelValue', value: string): void
|
|
(e: 'change', value: string): void
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
modelValue: '',
|
|
label: '内容',
|
|
placeholder: '点击编辑内容...',
|
|
required: false,
|
|
direction: 'horizontal'
|
|
})
|
|
|
|
const emit = defineEmits<Emits>()
|
|
|
|
const showEditor = ref(false)
|
|
const editorContent = ref('')
|
|
|
|
// 预览节点
|
|
const previewNodes = computed(() => {
|
|
if (!props.modelValue || props.modelValue.trim() === '') return []
|
|
return parseToRichText(props.modelValue)
|
|
})
|
|
|
|
// 监听内容变化
|
|
watch(() => props.modelValue, (newVal) => {
|
|
editorContent.value = newVal || ''
|
|
})
|
|
|
|
// 打开编辑器
|
|
const openEditor = () => {
|
|
editorContent.value = props.modelValue || ''
|
|
showEditor.value = true
|
|
}
|
|
|
|
// 编辑器内容变化
|
|
const onEditorChange = (content: string) => {
|
|
editorContent.value = content
|
|
}
|
|
|
|
// 保存内容
|
|
const saveContent = () => {
|
|
emit('update:modelValue', editorContent.value)
|
|
emit('change', editorContent.value)
|
|
showEditor.value = false
|
|
}
|
|
|
|
// HTML转Markdown函数
|
|
const convertHtmlToMarkdown = (html: string): string => {
|
|
if (!html) return ''
|
|
|
|
// 移除HTML标签,保留文本内容
|
|
let markdown = html
|
|
// 处理标题
|
|
.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n')
|
|
.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n')
|
|
.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n')
|
|
.replace(/<h4[^>]*>(.*?)<\/h4>/gi, '#### $1\n')
|
|
.replace(/<h5[^>]*>(.*?)<\/h5>/gi, '##### $1\n')
|
|
.replace(/<h6[^>]*>(.*?)<\/h6>/gi, '###### $1\n')
|
|
// 处理段落
|
|
.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n')
|
|
// 处理换行
|
|
.replace(/<br\s*\/?>/gi, '\n')
|
|
// 处理粗体
|
|
.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**')
|
|
.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**')
|
|
// 处理斜体
|
|
.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*')
|
|
.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*')
|
|
// 处理下划线
|
|
.replace(/<u[^>]*>(.*?)<\/u>/gi, '__$1__')
|
|
// 处理链接
|
|
.replace(/<a[^>]*href=["']([^"']*)["'][^>]*>(.*?)<\/a>/gi, '[$2]($1)')
|
|
// 处理图片 - 更全面的图片处理
|
|
.replace(/<img[^>]*src=["']([^"']*)["'][^>]*alt=["']([^"']*)["'][^>]*>/gi, (match, src, alt) => {
|
|
// 直接使用原始URL,不添加额外前缀
|
|
console.log('rich-text-input HTML图片转换 - 原始:', match)
|
|
console.log('rich-text-input HTML图片转换 - src:', src)
|
|
console.log('rich-text-input HTML图片转换 - alt:', alt)
|
|
return ``
|
|
})
|
|
.replace(/<img[^>]*src=["']([^"']*)["'][^>]*>/gi, (match, src) => {
|
|
// 直接使用原始URL,不添加额外前缀
|
|
console.log('rich-text-input HTML图片转换 - 原始:', match)
|
|
console.log('rich-text-input HTML图片转换 - src:', src)
|
|
return ``
|
|
})
|
|
// 处理列表
|
|
.replace(/<ul[^>]*>(.*?)<\/ul>/gis, (match, content) => {
|
|
return content.replace(/<li[^>]*>(.*?)<\/li>/gi, '- $1\n')
|
|
})
|
|
.replace(/<ol[^>]*>(.*?)<\/ol>/gis, (match, content) => {
|
|
let index = 1
|
|
return content.replace(/<li[^>]*>(.*?)<\/li>/gi, () => `${index++}. $1\n`)
|
|
})
|
|
// 处理引用
|
|
.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gis, (match, content) => {
|
|
return content.replace(/\n/g, '\n> ') + '\n'
|
|
})
|
|
// 处理代码
|
|
.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`')
|
|
.replace(/<pre[^>]*>(.*?)<\/pre>/gis, '```\n$1\n```')
|
|
// 处理分割线
|
|
.replace(/<hr[^>]*>/gi, '---\n')
|
|
// 处理表格(简单处理)
|
|
.replace(/<table[^>]*>(.*?)<\/table>/gis, (match, content) => {
|
|
return content.replace(/<tr[^>]*>(.*?)<\/tr>/gi, (trMatch, trContent) => {
|
|
const cells = trContent.match(/<t[hd][^>]*>(.*?)<\/t[hd]>/gi)
|
|
if (cells) {
|
|
return '| ' + cells.map(cell => cell.replace(/<[^>]*>/g, '')).join(' | ') + ' |\n'
|
|
}
|
|
return ''
|
|
})
|
|
})
|
|
// 移除剩余的HTML标签
|
|
.replace(/<[^>]*>/g, '')
|
|
// 清理多余的换行
|
|
.replace(/\n{3,}/g, '\n\n')
|
|
.trim()
|
|
|
|
return markdown
|
|
}
|
|
|
|
// 解析为富文本节点
|
|
const parseToRichText = (text: string) => {
|
|
if (!text) return []
|
|
|
|
// 如果包含HTML标签,先转换为Markdown
|
|
const markdownText = text.includes('<') ? convertHtmlToMarkdown(text) : text
|
|
|
|
const nodes = []
|
|
const lines = markdownText.split('\n')
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i]
|
|
|
|
if (line.startsWith('# ')) {
|
|
nodes.push({
|
|
name: 'h1',
|
|
attrs: { style: 'font-size: 16px; font-weight: bold; margin: 6px 0; color: #333;' },
|
|
children: [{ type: 'text', text: line.substring(2) }]
|
|
})
|
|
} else if (line.startsWith('## ')) {
|
|
nodes.push({
|
|
name: 'h2',
|
|
attrs: { style: 'font-size: 14px; font-weight: bold; margin: 4px 0; color: #333;' },
|
|
children: [{ type: 'text', text: line.substring(3) }]
|
|
})
|
|
} else if (line.startsWith('### ')) {
|
|
nodes.push({
|
|
name: 'h3',
|
|
attrs: { style: 'font-size: 13px; font-weight: bold; margin: 3px 0; color: #333;' },
|
|
children: [{ type: 'text', text: line.substring(4) }]
|
|
})
|
|
} else if (line.startsWith('- ')) {
|
|
nodes.push({
|
|
name: 'li',
|
|
attrs: { style: 'margin: 1px 0; padding-left: 12px; color: #666;' },
|
|
children: [{ type: 'text', text: '• ' + line.substring(2) }]
|
|
})
|
|
} else if (line.startsWith('> ')) {
|
|
nodes.push({
|
|
name: 'blockquote',
|
|
attrs: { style: 'border-left: 2px solid #ddd; padding-left: 6px; margin: 2px 0; color: #666; font-style: italic;' },
|
|
children: [{ type: 'text', text: line.substring(2) }]
|
|
})
|
|
} else if (line.startsWith('---')) {
|
|
nodes.push({
|
|
name: 'hr',
|
|
attrs: { style: 'border: none; border-top: 1px solid #eee; margin: 6px 0;' }
|
|
})
|
|
} else if (line.trim()) {
|
|
const formattedText = formatText(line)
|
|
nodes.push({
|
|
name: 'p',
|
|
attrs: { style: 'margin: 1px 0; line-height: 1.3; color: #666;' },
|
|
children: formattedText
|
|
})
|
|
} else {
|
|
nodes.push({
|
|
name: 'br'
|
|
})
|
|
}
|
|
}
|
|
|
|
return nodes
|
|
}
|
|
|
|
// 格式化文本
|
|
const formatText = (text: string) => {
|
|
const children = []
|
|
let remaining = text
|
|
|
|
while (remaining.length > 0) {
|
|
const boldMatch = remaining.match(/\*\*(.*?)\*\*/)
|
|
const italicMatch = remaining.match(/\*(.*?)\*/)
|
|
const linkMatch = remaining.match(/\[([^\]]+)\]\(([^)]+)\)/)
|
|
const imageMatch = remaining.match(/!\[([^\]]*)\]\(([^)]+)\)/)
|
|
|
|
let match = null
|
|
let type = ''
|
|
|
|
if (boldMatch && (!match || boldMatch.index < match.index)) {
|
|
match = boldMatch
|
|
type = 'bold'
|
|
}
|
|
if (italicMatch && (!match || italicMatch.index < match.index)) {
|
|
match = italicMatch
|
|
type = 'italic'
|
|
}
|
|
if (linkMatch && (!match || linkMatch.index < match.index)) {
|
|
match = linkMatch
|
|
type = 'link'
|
|
}
|
|
if (imageMatch && (!match || imageMatch.index < match.index)) {
|
|
match = imageMatch
|
|
type = 'image'
|
|
}
|
|
|
|
if (match) {
|
|
if (match.index > 0) {
|
|
children.push({
|
|
type: 'text',
|
|
text: remaining.substring(0, match.index)
|
|
})
|
|
}
|
|
|
|
if (type === 'bold') {
|
|
children.push({
|
|
name: 'strong',
|
|
children: [{ type: 'text', text: match[1] }]
|
|
})
|
|
} else if (type === 'italic') {
|
|
children.push({
|
|
name: 'em',
|
|
children: [{ type: 'text', text: match[1] }]
|
|
})
|
|
} else if (type === 'link') {
|
|
children.push({
|
|
name: 'a',
|
|
attrs: { href: match[2], style: 'color: #007bff; text-decoration: underline;' },
|
|
children: [{ type: 'text', text: match[1] }]
|
|
})
|
|
} else if (type === 'image') {
|
|
// 处理图片URL,确保能正确显示
|
|
let imageUrl = match[2]
|
|
console.log('原始图片URL:', imageUrl)
|
|
// 如果是相对路径,添加基础URL
|
|
if (imageUrl.startsWith('/api/') || imageUrl.startsWith('/admin/')) {
|
|
// 使用环境变量获取图片基础路径
|
|
const isDev = import.meta.env.DEV
|
|
let baseUrl = ''
|
|
|
|
if (isDev) {
|
|
// 开发环境
|
|
baseUrl = import.meta.env.VITE_IMAGE_DEV_PATH || 'http://127.0.0.1:9999'
|
|
} else {
|
|
// 生产环境
|
|
baseUrl = import.meta.env.VITE_APP_PROD_IMAGE || window.location.origin
|
|
}
|
|
|
|
console.log('环境:', isDev ? '开发' : '生产')
|
|
console.log('基础URL:', baseUrl)
|
|
console.log('原始图片URL:', imageUrl)
|
|
|
|
// 拼接URL
|
|
imageUrl = baseUrl + imageUrl
|
|
console.log('拼接后URL:', imageUrl)
|
|
}
|
|
|
|
children.push({
|
|
name: 'img',
|
|
attrs: {
|
|
src: imageUrl,
|
|
alt: match[1] || '图片',
|
|
style: 'max-width: 100%; height: auto; display: block; margin: 8px 0; border-radius: 4px;'
|
|
}
|
|
})
|
|
}
|
|
|
|
remaining = remaining.substring(match.index + match[0].length)
|
|
} else {
|
|
children.push({
|
|
type: 'text',
|
|
text: remaining
|
|
})
|
|
break
|
|
}
|
|
}
|
|
|
|
return children.length > 0 ? children : [{ type: 'text', text: text }]
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.rich-text-input {
|
|
width: 100%;
|
|
}
|
|
|
|
.input-container {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
padding: 12px;
|
|
background: #fff;
|
|
border: 1px solid #e9ecef;
|
|
border-radius: 6px;
|
|
min-height: 44px;
|
|
cursor: pointer;
|
|
transition: border-color 0.2s;
|
|
}
|
|
|
|
.input-container:active {
|
|
border-color: #007bff;
|
|
background: #f8f9fa;
|
|
}
|
|
|
|
.placeholder {
|
|
color: #999;
|
|
font-size: 14px;
|
|
flex: 1;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.content-preview {
|
|
flex: 1;
|
|
margin-right: 8px;
|
|
max-height: 80px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.preview-text {
|
|
font-size: 14px;
|
|
line-height: 1.4;
|
|
color: #333;
|
|
}
|
|
|
|
.input-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 24px;
|
|
height: 24px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.editor-modal-content {
|
|
height: 60vh;
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.modal-footer {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 12px;
|
|
padding: 12px;
|
|
}
|
|
</style>
|