feat: 发布v3.5版本

This commit is contained in:
ccnetcore
2026-01-26 21:08:21 +08:00
parent 9b5826a6b1
commit d4fcbdc390
81 changed files with 1169 additions and 7904 deletions

View File

@@ -0,0 +1,897 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { Marked } from 'marked';
import hljs from 'highlight.js';
import DOMPurify from 'dompurify';
import { ElDrawer } from 'element-plus';
import { useDesignStore } from '@/stores';
interface Props {
content: string;
theme?: 'light' | 'dark' | 'auto';
}
const props = withDefaults(defineProps<Props>(), {
content: '',
theme: 'auto',
});
const designStore = useDesignStore();
const { darkMode } = storeToRefs(designStore);
const containerRef = ref<HTMLElement | null>(null);
const renderedHtml = ref('');
// 抽屉相关状态
const drawerVisible = ref(false);
const previewHtml = ref('');
const iframeRef = ref<HTMLIFrameElement | null>(null);
// 创建 marked 实例
const marked = new Marked();
// 配置 marked
marked.setOptions({
gfm: true,
breaks: true,
});
// 自定义渲染器
const renderer = {
// 代码块渲染
code(token: { text: string; lang?: string }) {
const { text, lang } = token;
const language = lang && hljs.getLanguage(lang) ? lang : 'plaintext';
const validLanguage = hljs.getLanguage(language) ? language : 'plaintext';
let highlighted: string;
try {
highlighted = hljs.highlight(text, { language: validLanguage, ignoreIllegals: true }).value;
} catch {
highlighted = hljs.highlightAuto(text).value;
}
const langLabel = lang || 'code';
// 生成行号
const lines = text.split('\n');
const lineNumbers = lines.map((_, i) => `<span class="line-number">${i + 1}</span>`).join('');
// 判断是否为HTML代码
const isHtml = lang?.toLowerCase() === 'html' || lang?.toLowerCase() === 'htm';
// 预览按钮仅HTML显示
const previewBtn = isHtml ? `
<button class="preview-btn" data-html="${encodeURIComponent(text)}">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
<span>预览</span>
</button>
` : '';
return `<div class="code-block-wrapper${isHtml ? ' is-html' : ''}">
<div class="code-block-header">
<span class="code-lang">${langLabel}</span>
<div class="code-block-actions">
${previewBtn}
<button class="copy-btn" data-code="${encodeURIComponent(text)}">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span>复制</span>
</button>
</div>
</div>
<div class="code-block-body">
<div class="line-numbers">${lineNumbers}</div>
<pre class="hljs"><code class="language-${validLanguage}">${highlighted}</code></pre>
</div>
</div>`;
},
// 行内代码
codespan(token: { text: string }) {
return `<code class="inline-code">${token.text}</code>`;
},
// 链接
link(token: { href: string; title?: string; text: string }) {
const { href, title, text } = token;
const titleAttr = title ? ` title="${title}"` : '';
return `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`;
},
// 图片
image(token: { href: string; title?: string; text: string }) {
const { href, title, text } = token;
const titleAttr = title ? ` title="${title}"` : '';
return `<img src="${href}" alt="${text}"${titleAttr} loading="lazy" class="markdown-image" />`;
},
// 表格
table(token: { header: string; body: string }) {
return `<div class="table-wrapper"><table>${token.header}${token.body}</table></div>`;
},
};
marked.use({ renderer });
// 防抖渲染,优化流式性能
let renderTimer: ReturnType<typeof setTimeout> | null = null;
let lastContent = '';
const RENDER_DELAY = 16; // 约60fps
function scheduleRender(content: string) {
if (renderTimer) {
clearTimeout(renderTimer);
}
renderTimer = setTimeout(() => {
renderContent(content);
renderTimer = null;
}, RENDER_DELAY);
}
// 渲染内容
async function renderContent(content: string) {
if (!content) {
renderedHtml.value = '';
return;
}
// 如果内容没变化,跳过渲染
if (content === lastContent) {
return;
}
lastContent = content;
try {
const rawHtml = await marked.parse(content);
// 使用 DOMPurify 清理 HTML防止 XSS
renderedHtml.value = DOMPurify.sanitize(rawHtml, {
ADD_TAGS: ['iframe'],
ADD_ATTR: ['target', 'data-code', 'data-html'],
});
// 渲染后绑定按钮事件
nextTick(() => {
bindButtons();
});
} catch (error) {
console.error('Markdown 渲染错误:', error);
renderedHtml.value = `<pre>${content}</pre>`;
}
}
// 绑定按钮事件
function bindButtons() {
if (!containerRef.value) return;
// 绑定复制按钮
const copyButtons = containerRef.value.querySelectorAll('.copy-btn');
copyButtons.forEach((btn) => {
const newBtn = btn.cloneNode(true) as HTMLElement;
btn.parentNode?.replaceChild(newBtn, btn);
newBtn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const code = decodeURIComponent(newBtn.getAttribute('data-code') || '');
try {
await navigator.clipboard.writeText(code);
const spanEl = newBtn.querySelector('span');
if (spanEl) {
const originalText = spanEl.textContent;
spanEl.textContent = '已复制';
newBtn.classList.add('copied');
setTimeout(() => {
spanEl.textContent = originalText;
newBtn.classList.remove('copied');
}, 2000);
}
} catch (err) {
console.error('复制失败:', err);
}
});
});
// 绑定预览按钮
const previewButtons = containerRef.value.querySelectorAll('.preview-btn');
previewButtons.forEach((btn) => {
const newBtn = btn.cloneNode(true) as HTMLElement;
btn.parentNode?.replaceChild(newBtn, btn);
newBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const html = decodeURIComponent(newBtn.getAttribute('data-html') || '');
openHtmlPreview(html);
});
});
}
// 打开HTML预览抽屉
function openHtmlPreview(html: string) {
// 检查是否是完整的HTML文档如果不是则包装一下
let fullHtml = html;
if (!html.toLowerCase().includes('<!doctype') && !html.toLowerCase().includes('<html')) {
fullHtml = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>* { box-sizing: border-box; } body { margin: 0; padding: 16px; font-family: sans-serif; }</style>
</head>
<body>
${html}
</body>
</html>`;
}
previewHtml.value = fullHtml;
drawerVisible.value = true;
}
// 监听主题变化
const resolvedTheme = computed(() => {
if (props.theme === 'auto') {
// 从全局状态获取主题
return darkMode.value === 'light' ? 'light' : 'dark';
}
return props.theme;
});
// 监听内容变化
watch(
() => props.content,
(newContent) => {
scheduleRender(newContent);
},
{ immediate: true }
);
// 暴露方法供外部调用
defineExpose({
refresh: () => renderContent(props.content),
});
// 清理定时器
onUnmounted(() => {
if (renderTimer) {
clearTimeout(renderTimer);
}
});
</script>
<template>
<div
ref="containerRef"
class="marked-markdown"
:class="[`theme-${resolvedTheme}`]"
v-html="renderedHtml"
/>
<!-- HTML预览抽屉 -->
<ElDrawer
v-model="drawerVisible"
title="HTML 预览"
direction="rtl"
size="50%"
:append-to-body="true"
class="html-preview-drawer"
>
<div class="preview-container">
<iframe
ref="iframeRef"
class="preview-iframe"
:srcdoc="previewHtml"
sandbox="allow-scripts allow-modals allow-forms allow-popups allow-same-origin"
/>
</div>
</ElDrawer>
</template>
<style lang="scss">
.marked-markdown {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
font-size: 15px;
line-height: 1.7;
word-wrap: break-word;
overflow-wrap: break-word;
// 深色主题
&.theme-dark {
color: #e6edf3;
a {
color: #58a6ff;
&:hover {
text-decoration: underline;
}
}
h1, h2, h3, h4, h5, h6 {
color: #e6edf3;
border-bottom-color: #30363d;
}
hr {
background-color: #30363d;
}
blockquote {
color: #8b949e;
border-left-color: #3b434b;
}
code.inline-code {
background-color: rgba(110, 118, 129, 0.4);
color: #e6edf3;
}
.code-block-wrapper {
background-color: #161b22;
border: 1px solid #30363d;
.code-block-header {
background-color: #21262d;
border-bottom-color: #30363d;
.code-lang {
color: #8b949e;
}
.copy-btn,
.preview-btn {
color: #8b949e;
&:hover {
color: #e6edf3;
background-color: #30363d;
}
&.copied {
color: #3fb950;
}
}
}
.code-block-body {
.line-numbers {
background-color: #161b22;
border-right: 1px solid #30363d;
.line-number {
color: #6e7681;
}
}
}
pre.hljs {
background-color: #161b22;
}
}
table {
th {
background-color: #21262d;
}
td, th {
border-color: #30363d;
}
tr:nth-child(2n) {
background-color: #161b22;
}
}
.markdown-image {
background-color: transparent;
}
}
// 浅色主题
&.theme-light {
color: #24292f;
a {
color: #0969da;
&:hover {
text-decoration: underline;
}
}
h1, h2, h3, h4, h5, h6 {
color: #24292f;
border-bottom-color: #d0d7de;
}
hr {
background-color: #d0d7de;
}
blockquote {
color: #57606a;
border-left-color: #d0d7de;
}
code.inline-code {
background-color: rgba(175, 184, 193, 0.2);
color: #24292f;
}
.code-block-wrapper {
background-color: #f6f8fa;
border: 1px solid #d0d7de;
.code-block-header {
background-color: #f6f8fa;
border-bottom-color: #d0d7de;
.code-lang {
color: #57606a;
}
.copy-btn,
.preview-btn {
color: #57606a;
&:hover {
color: #24292f;
background-color: #d0d7de;
}
&.copied {
color: #1a7f37;
}
}
}
.code-block-body {
.line-numbers {
background-color: #f6f8fa;
border-right: 1px solid #d0d7de;
.line-number {
color: #8c959f;
}
}
}
pre.hljs {
background-color: #f6f8fa;
}
}
table {
th {
background-color: #f6f8fa;
}
td, th {
border-color: #d0d7de;
}
tr:nth-child(2n) {
background-color: #f6f8fa;
}
}
}
// 通用样式
> *:first-child {
margin-top: 0 !important;
}
> *:last-child {
margin-bottom: 0 !important;
}
// 标题
h1, h2, h3, h4, h5, h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
h1 {
font-size: 2em;
padding-bottom: 0.3em;
border-bottom: 1px solid;
}
h2 {
font-size: 1.5em;
padding-bottom: 0.3em;
border-bottom: 1px solid;
}
h3 {
font-size: 1.25em;
}
h4 {
font-size: 1em;
}
h5 {
font-size: 0.875em;
}
h6 {
font-size: 0.85em;
}
// 段落
p {
margin-top: 0;
margin-bottom: 16px;
}
// 列表
ul, ol {
padding-left: 2em;
margin-top: 0;
margin-bottom: 16px;
}
li {
margin-top: 0.25em;
}
li + li {
margin-top: 0.25em;
}
// 引用
blockquote {
margin: 0 0 16px 0;
padding: 0 1em;
border-left: 0.25em solid;
> :first-child {
margin-top: 0;
}
> :last-child {
margin-bottom: 0;
}
}
// 分割线
hr {
height: 0.25em;
padding: 0;
margin: 24px 0;
border: 0;
}
// 行内代码
code.inline-code {
padding: 0.2em 0.4em;
margin: 0;
font-size: 85%;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
border-radius: 6px;
}
// 代码块
.code-block-wrapper {
margin: 16px 0;
border-radius: 8px;
overflow: hidden;
.code-block-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
border-bottom: 1px solid;
.code-lang {
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
}
.code-block-actions {
display: flex;
align-items: center;
gap: 8px;
}
.copy-btn,
.preview-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
font-size: 12px;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
svg {
flex-shrink: 0;
}
}
}
.code-block-body {
display: flex;
overflow-x: auto;
// 自定义滚动条样式
&::-webkit-scrollbar {
height: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(128, 128, 128, 0.4);
border-radius: 3px;
&:hover {
background-color: rgba(128, 128, 128, 0.6);
}
}
.line-numbers {
display: flex;
flex-direction: column;
padding: 16px 0;
text-align: right;
user-select: none;
flex-shrink: 0;
.line-number {
padding: 0 12px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 14px;
//line-height: 21px;
height: 22.72px;
min-width: 40px;
}
}
}
pre.hljs {
margin: 0;
padding: 16px;
padding-left: 12px;
overflow-x: auto;
font-size: 14px;
line-height: 21px;
flex: 1;
white-space: pre;
code {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
background: transparent;
padding: 0;
white-space: pre;
}
}
}
// 表格
.table-wrapper {
overflow-x: auto;
margin: 16px 0;
}
table {
border-spacing: 0;
border-collapse: collapse;
width: 100%;
th, td {
padding: 8px 16px;
border: 1px solid;
}
th {
font-weight: 600;
text-align: left;
}
}
// 图片
.markdown-image {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 8px 0;
}
// 强调
strong {
font-weight: 600;
}
em {
font-style: italic;
}
// 删除线
del {
text-decoration: line-through;
}
// 任务列表
input[type="checkbox"] {
margin-right: 0.5em;
}
}
// highlight.js 深色主题样式
.marked-markdown.theme-dark {
.hljs {
color: #c9d1d9;
}
.hljs-comment,
.hljs-quote {
color: #8b949e;
font-style: italic;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-addition {
color: #ff7b72;
}
.hljs-number,
.hljs-string,
.hljs-meta .hljs-meta-string,
.hljs-literal,
.hljs-doctag,
.hljs-regexp {
color: #a5d6ff;
}
.hljs-title,
.hljs-section,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #d2a8ff;
}
.hljs-attribute,
.hljs-attr,
.hljs-variable,
.hljs-template-variable,
.hljs-class .hljs-title,
.hljs-type {
color: #79c0ff;
}
.hljs-symbol,
.hljs-bullet,
.hljs-subst,
.hljs-meta,
.hljs-meta .hljs-keyword,
.hljs-selector-attr,
.hljs-selector-pseudo,
.hljs-link {
color: #ffa657;
}
.hljs-built_in,
.hljs-deletion {
color: #ffa198;
}
.hljs-formula {
background-color: #21262d;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}
}
// highlight.js 浅色主题样式
.marked-markdown.theme-light {
.hljs {
color: #24292f;
}
.hljs-comment,
.hljs-quote {
color: #6e7781;
font-style: italic;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-addition {
color: #cf222e;
}
.hljs-number,
.hljs-string,
.hljs-meta .hljs-meta-string,
.hljs-literal,
.hljs-doctag,
.hljs-regexp {
color: #0a3069;
}
.hljs-title,
.hljs-section,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #8250df;
}
.hljs-attribute,
.hljs-attr,
.hljs-variable,
.hljs-template-variable,
.hljs-class .hljs-title,
.hljs-type {
color: #0550ae;
}
.hljs-symbol,
.hljs-bullet,
.hljs-subst,
.hljs-meta,
.hljs-meta .hljs-keyword,
.hljs-selector-attr,
.hljs-selector-pseudo,
.hljs-link {
color: #953800;
}
.hljs-built_in,
.hljs-deletion {
color: #82071e;
}
.hljs-formula {
background-color: #f6f8fa;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}
}
// HTML预览抽屉样式
.html-preview-drawer {
.el-drawer__header {
margin-bottom: 0;
padding: 16px 20px;
border-bottom: 1px solid #e4e7ed;
}
.el-drawer__body {
padding: 0;
overflow: hidden;
}
.preview-container {
width: 100%;
height: 100%;
background-color: #fff;
}
.preview-iframe {
width: 100%;
height: 100%;
border: none;
background-color: #fff;
}
}
.marked-markdown .code-block-wrapper pre.hljs {
padding-bottom: 4px;
&::-webkit-scrollbar {
height: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(128, 128, 128, 0.4);
border-radius: 3px;
&:hover {
background-color: rgba(128, 128, 128, 0.6);
}
}
}
</style>