This commit is contained in:
weiwei committed 2025-09-12 17:08:07 +08:00
commit 0ff5f79b8d
4 files changed
+1353 -3

No files matched your search

+137 -3
View File
@@ -74,7 +74,11 @@
</tm-form-item>
<view class="sub-text-20 c-red! mb-2">注:图片大小不超过5M</view>
<tm-form-item name="description" label="详情描述" direction="vertical" required="">
<tm-input v-model="form.description" type="textarea" :showClear="false" placeholder="请输入详情描述"></tm-input>
<rich-text-input
v-model="form.description"
placeholder="点击编辑详情描述..."
label="详情描述"
/>
</tm-form-item>
<tm-form-item name="priceType" label="定价方式">
<tm-radio-group v-model="form.priceType" align="flex-end">
@@ -168,6 +172,7 @@ import { reactive, ref } from "vue"
import { hooks } from "@@/index"
import { prosperityShopApi } from "@/sheep/api"
import ShopCategaryPicker from "@/sheep/components/shop-categary-picker.vue"
import RichTextInput from "@/sheep/components/rich-text-input/rich-text-input.vue"
import { onLoad } from "@dcloudio/uni-app"
import { filePath } from "@/sheep/utils/file"
@@ -233,6 +238,12 @@ const getGoodsDetail = async (id: string) => {
const saleEndTime = data.saleEndTime
_saleStartTimeStr.value = saleStartTime
_saleEndTimeStr.value = saleEndTime
// 转换HTML为Markdown格式
const description = convertHtmlToMarkdown(data.description || '')
console.log('原始HTML:', data.description)
console.log('转换后Markdown:', description)
Object.assign(form, {
...data,
status: data.status.toString(),
@@ -242,10 +253,89 @@ const getGoodsDetail = async (id: string) => {
saleStartTime,
saleEndTime,
skuList,
description,
})
uni.hideLoading()
}
// 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('HTML图片转换 - 原始:', match)
console.log('HTML图片转换 - src:', src)
console.log('HTML图片转换 - alt:', alt)
return `![${alt || '图片'}](${src})`
})
.replace(/<img[^>]*src=["']([^"']*)["'][^>]*>/gi, (match, src) => {
// 直接使用原始URL,不添加额外前缀
console.log('HTML图片转换 - 原始:', match)
console.log('HTML图片转换 - src:', src)
return `![图片](${src})`
})
// 处理列表
.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 getGoodsCategory = async () => {
const { code, data, msg } = await prosperityShopApi.getGoodsCategory()
if (code != 0) return uni.showToast({ title: msg || "商品分类获取失败", icon: "none" })
@@ -287,10 +377,50 @@ const addSkuListItem = () => {
})
}
// Markdown转HTML函数
const convertMarkdownToHtml = (markdown: string): string => {
if (!markdown) return ''
let html = markdown
// 处理标题
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
.replace(/^#### (.*$)/gim, '<h4>$1</h4>')
.replace(/^##### (.*$)/gim, '<h5>$1</h5>')
.replace(/^###### (.*$)/gim, '<h6>$1</h6>')
// 处理粗体
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
// 处理斜体
.replace(/\*(.*?)\*/g, '<em>$1</em>')
// 处理链接
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
// 处理图片
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width: 100%; height: auto;" />')
// 处理段落
.replace(/^(?!<[h1-6]|<img|<ul|<ol|<li)(.+)$/gim, '<p>$1</p>')
// 处理换行
.replace(/\n/g, '<br>')
return html
}
// 保存
const onsubmit = async (evt: TM.FORM_SUBMIT_RESULT) => {
// console.log("校验结果:", evt)
console.log("提交的数据:", { ...form, coverImage: JSON.parse(form.coverImage[0].response).data.url })
// 将Markdown格式的description转换为HTML
const htmlDescription = convertMarkdownToHtml(form.description)
console.log("原始Markdown:", form.description)
console.log("转换后HTML:", htmlDescription)
const submitData = {
...form,
description: htmlDescription, // 使用转换后的HTML
coverImage: JSON.parse(form.coverImage[0].response).data.url
}
console.log("提交的数据:", submitData)
if (!evt.isPass) return
if (form.priceType == "1") {
@@ -307,7 +437,11 @@ const onsubmit = async (evt: TM.FORM_SUBMIT_RESULT) => {
}
try {
const params = { ...form, coverImage: JSON.parse(form.coverImage[0].response).data.url }
const params = {
...form,
description: htmlDescription, // 使用转换后的HTML
coverImage: JSON.parse(form.coverImage[0].response).data.url
}
uni.showLoading({ title: "提交中..." })
const { code, msg } = await (form.id ? prosperityShopApi.editGoods(params) : prosperityShopApi.addGoods(params))
if (code != 0) return uni.showToast({ title: msg || "提交失败", icon: "none" })
@@ -0,0 +1,83 @@
# 富文本编辑器组件
这是一个简单的富文本编辑器组件,专为移动端设计,支持Markdown语法。
## 组件文件
- `rich-text-editor.vue` - 核心编辑器组件
- `rich-text-input.vue` - 表单输入组件
## 使用方法
### 1. 在表单中使用
```vue
<template>
<tm-form-item name="description" label="详情描述" direction="vertical" required="">
<rich-text-input
v-model="form.description"
placeholder="点击编辑详情描述..."
label="详情描述"
/>
</tm-form-item>
</template>
<script setup>
import RichTextInput from '@/sheep/components/rich-text-input/rich-text-input.vue'
const form = reactive({
description: ''
})
</script>
```
### 2. 直接使用编辑器
```vue
<template>
<rich-text-editor
v-model="content"
placeholder="请输入内容..."
@change="onContentChange"
/>
</template>
<script setup>
import RichTextEditor from '@/sheep/components/rich-text-editor/rich-text-editor.vue'
const content = ref('')
const onContentChange = (newContent) => {
console.log('内容变化:', newContent)
}
</script>
```
## 功能特性
- ✅ 支持Markdown语法(标题、粗体、斜体、列表、表格、链接等)
- ✅ 实时预览功能
- ✅ 模板插入功能
- ✅ 移动端优化界面
- ✅ 弹窗编辑模式
- ✅ 表单集成
## 支持的Markdown语法
- `# 标题` - 一级标题
- `## 标题` - 二级标题
- `### 标题` - 三级标题
- `**粗体**` - 粗体文字
- `*斜体*` - 斜体文字
- `- 列表项` - 无序列表
- `> 引用` - 引用块
- `[链接文字](URL)` - 链接
- `![图片描述](图片URL)` - 图片
- `| 表格 |` - 表格
## 注意事项
1. 组件使用tm-ui的Modal组件,确保项目中已安装tm-ui
2. 图片上传功能需要配合项目的上传接口
3. 组件样式可以根据项目需求进行调整
4. 没有修改任何配置文件,只创建了组件文件
@@ -0,0 +1,724 @@
<template>
<view class="rich-text-editor">
<!-- 工具栏 -->
<view class="toolbar">
<view class="toolbar-row">
<view class="tool-item" @click="insertBold">
<text class="tool-text">粗体</text>
</view>
<view class="tool-item" @click="insertItalic">
<text class="tool-text">斜体</text>
</view>
<view class="tool-item" @click="insertTitle">
<text class="tool-text">标题</text>
</view>
<view class="tool-item" @click="insertList">
<text class="tool-text">列表</text>
</view>
</view>
<view class="toolbar-row">
<view class="tool-item" @click="insertLink">
<text class="tool-text">链接</text>
</view>
<view class="tool-item" @click="insertImage">
<text class="tool-text">图片</text>
</view>
<view class="tool-item" @click="insertTable">
<text class="tool-text">表格</text>
</view>
<view class="tool-item" @click="clearFormat">
<text class="tool-text">清空</text>
</view>
</view>
</view>
<!-- 编辑区域 -->
<view class="editor-container">
<textarea
v-model="content"
class="editor-textarea"
:placeholder="placeholder"
@input="onContentChange"
></textarea>
</view>
<!-- 预览区域 -->
<view v-if="showPreview" class="preview-container">
<view class="preview-title">预览效果</view>
<view class="preview-content">
<rich-text :nodes="previewNodes"></rich-text>
</view>
</view>
<!-- 底部操作栏 -->
<view class="bottom-actions">
<tm-button size="s" skin="thin" @click="togglePreview">
{{ showPreview ? '编辑' : '预览' }}
</tm-button>
<tm-button size="s" skin="thin" @click="insertTemplate">模板</tm-button>
</view>
<!-- 链接输入弹窗 -->
<tm-modal v-model:show="showLinkModal" title="插入链接" :showClose="true" size="small" :zIndex="1001">
<view class="link-modal-content">
<tm-input v-model="linkUrl" placeholder="请输入链接地址" class="link-input"></tm-input>
</view>
<template #footer>
<view class="modal-footer">
<tm-button size="s" skin="thin" @click="showLinkModal = false">取消</tm-button>
<tm-button size="s" color="primary" @click="confirmLink">确定</tm-button>
</view>
</template>
</tm-modal>
<!-- 模板选择弹窗 -->
<tm-modal v-model:show="showTemplateModal" title="选择模板" :showClose="true" size="large" :zIndex="1001">
<view class="template-modal-content">
<view class="template-list">
<view
v-for="(template, index) in templates"
:key="index"
class="template-item"
@click="selectTemplate(template)"
>
<text class="template-title">{{ template.title }}</text>
<text class="template-desc">{{ template.desc }}</text>
</view>
</view>
</view>
<template #footer>
<view class="modal-footer">
<tm-button size="s" skin="thin" @click="showTemplateModal = false">取消</tm-button>
</view>
</template>
</tm-modal>
<!-- 图片描述输入弹窗 -->
<tm-modal v-model:show="showImageModal" title="插入图片" :showClose="true" size="small" :zIndex="1001">
<view class="image-modal-content">
<tm-input v-model="imageDescription" placeholder="请输入图片描述" class="image-input"></tm-input>
</view>
<template #footer>
<view class="modal-footer">
<tm-button size="s" skin="thin" @click="showImageModal = false">取消</tm-button>
<tm-button size="s" color="primary" @click="confirmImage">确定</tm-button>
</view>
</template>
</tm-modal>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { hooks } from '@@/index'
interface Props {
modelValue?: string
placeholder?: string
height?: string
}
interface Emits {
(e: 'update:modelValue', value: string): void
(e: 'change', value: string): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
placeholder: '请输入内容...',
height: '300px'
})
const emit = defineEmits<Emits>()
const content = ref(props.modelValue)
const showPreview = ref(false)
const showLinkModal = ref(false)
const showTemplateModal = ref(false)
const showImageModal = ref(false)
const linkUrl = ref('')
const imageDescription = ref('')
const pendingImageUrl = ref('')
// 模板数据
const templates = ref([
{
title: '商品介绍模板',
desc: '包含产品特点、规格、使用方法等',
content: `# 商品介绍
## 产品特点
- 特点1:详细描述
- 特点2:详细描述
- 特点3:详细描述
## 产品规格
| 规格 | 参数 | 说明 |
|------|------|------|
| 尺寸 | 长x宽x高 | 具体尺寸 |
| 重量 | 重量 | 具体重量 |
| 材质 | 材质 | 具体材质 |
## 使用方法
1. **步骤一**:详细说明
2. **步骤二**:详细说明
3. **步骤三**:详细说明
## 注意事项
> 请仔细阅读以下注意事项:
> - 注意事项1
> - 注意事项2
> - 注意事项3`
},
{
title: '活动说明模板',
desc: '包含活动时间、规则、奖品设置等',
content: `# 活动说明
## 活动时间
**活动开始:** 2024年1月1日 00:00
**活动结束:** 2024年1月31日 23:59
## 活动规则
1. **参与条件**:详细说明参与条件
2. **活动流程**:详细说明活动流程
3. **奖励机制**:详细说明奖励机制
## 奖品设置
| 奖项 | 奖品名称 | 数量 | 价值 |
|------|----------|------|------|
| 一等奖 | 奖品1 | 1个 | ¥1000 |
| 二等奖 | 奖品2 | 3个 | ¥500 |
| 三等奖 | 奖品3 | 10个 | ¥100`
},
{
title: '使用说明模板',
desc: '包含安装步骤、功能介绍、常见问题等',
content: `# 使用说明
## 安装步骤
1. **下载安装包**
- 访问官网下载最新版本
- 确保系统兼容性
2. **运行安装程序**
- 双击安装包
- 按照提示完成安装
3. **首次配置**
- 启动程序
- 完成基本设置
## 功能介绍
- **功能一**:详细功能说明
- **功能二**:详细功能说明
- **功能三**:详细功能说明
## 常见问题
**Q: 问题一?**
A: 详细解答
**Q: 问题二?**
A: 详细解答
**Q: 问题三?**
A: 详细解答`
}
])
// 预览节点
const previewNodes = computed(() => {
if (!content.value) return []
return parseToRichText(content.value)
})
// 内容变化
const onContentChange = () => {
emit('update:modelValue', content.value)
emit('change', content.value)
}
// 插入粗体
const insertBold = () => {
insertText('**粗体文字**')
}
// 插入斜体
const insertItalic = () => {
insertText('*斜体文字*')
}
// 插入标题
const insertTitle = () => {
insertText('# 标题文字')
}
// 插入列表
const insertList = () => {
insertText('- 列表项1\n- 列表项2\n- 列表项3')
}
// 插入链接
const insertLink = () => {
linkUrl.value = ''
showLinkModal.value = true
}
// 确认插入链接
const confirmLink = () => {
if (linkUrl.value.trim()) {
insertText(`[链接文字](${linkUrl.value})`)
showLinkModal.value = false
}
}
// 插入图片
const insertImage = () => {
uni.chooseImage({
count: 1,
zIndex: 1001,
success: (res) => {
// 先上传图片
uploadImage(res.tempFilePaths[0])
}
})
}
// 上传图片
const uploadImage = (filePath: string) => {
uni.showLoading({ title: '上传中...' })
// 使用项目中的hooks获取请求头
const headers = hooks.useDefaultHeader()
uni.uploadFile({
url: '/api/admin/sys-file/upload',
filePath: filePath,
name: 'file',
header: headers,
success: (res) => {
try {
const data = JSON.parse(res.data)
if (data.code === 0 && data.data && data.data.url) {
// 修复URL格式问题,确保fileName参数格式正确
let imageUrl = data.data.url
// 如果URL中包含fileName参数但没有等号,则修复它
if (imageUrl.includes('fileName ') && !imageUrl.includes('fileName=')) {
imageUrl = imageUrl.replace('fileName ', 'fileName=')
}
// 存储时保持相对路径,不拼接完整URL
// 只在显示时才拼接完整URL
// 上传成功,显示描述输入弹窗
// 确保存储的是相对路径
pendingImageUrl.value = imageUrl
imageDescription.value = ''
showImageModal.value = true
uni.showToast({ title: '上传成功', icon: 'success' })
} else {
uni.showToast({ title: data.msg || '上传失败', icon: 'none' })
}
} catch (error) {
uni.showToast({ title: '上传失败', icon: 'none' })
}
},
fail: (error) => {
uni.showToast({ title: '上传失败', icon: 'none' })
},
complete: () => {
uni.hideLoading()
}
})
}
// 确认插入图片
const confirmImage = () => {
if (pendingImageUrl.value) {
const desc = imageDescription.value.trim() || '图片'
// 确保存储的是相对路径,移除可能存在的完整URL前缀
let imageUrl = pendingImageUrl.value
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
// 如果是完整URL,提取相对路径部分
const url = new URL(imageUrl)
imageUrl = url.pathname + url.search
}
insertText(`![${desc}](${imageUrl})`)
showImageModal.value = false
pendingImageUrl.value = ''
imageDescription.value = ''
}
}
// 插入表格
const insertTable = () => {
const tableText = `| 列1 | 列2 | 列3 |
|-----|-----|-----|
| 内容1 | 内容2 | 内容3 |`
insertText(tableText)
}
// 清空格式
const clearFormat = () => {
content.value = content.value
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
}
// 切换预览
const togglePreview = () => {
showPreview.value = !showPreview.value
}
// 插入模板
const insertTemplate = () => {
showTemplateModal.value = true
}
// 选择模板
const selectTemplate = (template: any) => {
content.value = template.content
onContentChange()
showTemplateModal.value = false
}
// 插入文本
const insertText = (text: string) => {
content.value += '\n' + text + '\n'
onContentChange()
}
// 解析为富文本节点
const parseToRichText = (text: string) => {
if (!text) return []
const nodes = []
const lines = text.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: 18px; font-weight: bold; margin: 8px 0; color: #333;' },
children: [{ type: 'text', text: line.substring(2) }]
})
} else if (line.startsWith('## ')) {
nodes.push({
name: 'h2',
attrs: { style: 'font-size: 16px; font-weight: bold; margin: 6px 0; color: #333;' },
children: [{ type: 'text', text: line.substring(3) }]
})
} else if (line.startsWith('### ')) {
nodes.push({
name: 'h3',
attrs: { style: 'font-size: 14px; font-weight: bold; margin: 4px 0; color: #333;' },
children: [{ type: 'text', text: line.substring(4) }]
})
} else if (line.startsWith('- ')) {
nodes.push({
name: 'li',
attrs: { style: 'margin: 2px 0; padding-left: 16px; color: #666;' },
children: [{ type: 'text', text: '• ' + line.substring(2) }]
})
} else if (line.startsWith('> ')) {
nodes.push({
name: 'blockquote',
attrs: { style: 'border-left: 3px solid #ddd; padding-left: 8px; margin: 4px 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: 8px 0;' }
})
} else if (line.trim()) {
const formattedText = formatText(line)
nodes.push({
name: 'p',
attrs: { style: 'margin: 2px 0; line-height: 1.4; 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-editor {
background: #fff;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e9ecef;
height: 100%;
display: flex;
flex-direction: column;
}
.toolbar {
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
padding: 8px;
flex-shrink: 0;
}
.toolbar-row {
display: flex;
justify-content: space-around;
margin-bottom: 6px;
}
.toolbar-row:last-child {
margin-bottom: 0;
}
.tool-item {
display: flex;
align-items: center;
justify-content: center;
padding: 6px 8px;
border-radius: 4px;
background: #fff;
border: 1px solid #e9ecef;
cursor: pointer;
transition: all 0.2s;
flex: 1;
margin: 0 2px;
}
.tool-item:active {
background: #e9ecef;
transform: scale(0.95);
}
.tool-text {
font-size: 11px;
color: #666;
white-space: nowrap;
}
.editor-container {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
}
.editor-textarea {
width: 100%;
flex: 1;
min-height: 200px;
padding: 12px;
border: none;
outline: none;
font-size: 14px;
line-height: 1.6;
resize: none;
background: #fff;
}
.preview-container {
border-top: 1px solid #e9ecef;
padding: 12px;
flex: 1;
overflow-y: auto;
}
.preview-title {
font-size: 12px;
font-weight: bold;
color: #666;
margin-bottom: 8px;
}
.preview-content {
font-size: 14px;
line-height: 1.6;
}
.bottom-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: #f8f9fa;
border-top: 1px solid #e9ecef;
flex-shrink: 0;
}
.link-modal-content {
padding: 16px;
}
.link-input {
margin-bottom: 16px;
}
.image-modal-content {
padding: 16px;
}
.image-input {
margin-bottom: 16px;
}
.template-modal-content {
padding: 16px;
max-height: 400px;
overflow-y: auto;
}
.template-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.template-item {
padding: 16px;
border: 1px solid #e9ecef;
border-radius: 8px;
background: #fff;
cursor: pointer;
transition: all 0.2s;
}
.template-item:active {
background: #f8f9fa;
border-color: #007bff;
}
.template-title {
display: block;
font-size: 16px;
font-weight: bold;
color: #333;
margin-bottom: 4px;
}
.template-desc {
display: block;
font-size: 14px;
color: #666;
line-height: 1.4;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 12px;
}
</style>
@@ -0,0 +1,409 @@
<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 `![${alt || '图片'}](${src})`
})
.replace(/<img[^>]*src=["']([^"']*)["'][^>]*>/gi, (match, src) => {
// 直接使用原始URL,不添加额外前缀
console.log('rich-text-input HTML图片转换 - 原始:', match)
console.log('rich-text-input HTML图片转换 - src:', src)
return `![图片](${src})`
})
// 处理列表
.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>