fix(webui): optimize markdown rendering and remove redundant code (#4415)

* fix(dashboard): optimize markdown rendering and remove redundant code

* style: format code and refactor ReadmeDialog for i18n/isolation

* fix: robust clipboard fallback for http context

* refactor: optimize markdown rendering and fix table styles in ReadmeDialog
This commit is contained in:
clown145
2026-01-12 17:39:53 +08:00
committed by GitHub
parent eedb62a5a3
commit 3416e8990c
4 changed files with 398 additions and 181 deletions

View File

@@ -14,7 +14,6 @@
},
"dependencies": {
"@guolao/vue-monaco-editor": "^1.5.4",
"@mdit/plugin-katex": "^0.24.1",
"@tiptap/starter-kit": "2.1.7",
"@tiptap/vue-3": "2.1.7",
"apexcharts": "3.42.0",
@@ -22,11 +21,13 @@
"axios-mock-adapter": "^1.22.0",
"chance": "1.1.11",
"date-fns": "2.30.0",
"dompurify": "^3.3.1",
"event-source-polyfill": "^1.0.31",
"highlight.js": "^11.11.1",
"js-md5": "^0.8.3",
"katex": "^0.16.27",
"lodash": "4.17.21",
"markdown-it": "^14.1.0",
"markstream-vue": "0.0.3-beta.7",
"mermaid": "^11.12.2",
"pinia": "2.1.6",
@@ -49,6 +50,8 @@
"@mdi/font": "7.2.96",
"@rushstack/eslint-patch": "1.3.3",
"@types/chance": "1.1.3",
"@types/dompurify": "^3.0.5",
"@types/markdown-it": "^14.1.2",
"@types/node": "^20.5.7",
"@vitejs/plugin-vue": "4.3.3",
"@vue/eslint-config-prettier": "8.0.0",
@@ -65,4 +68,4 @@
"vue-tsc": "1.8.8",
"vuetify-loader": "^2.0.0-alpha.9"
}
}
}

View File

@@ -1,29 +1,37 @@
<script setup>
import { ref, watch, onMounted, computed } from "vue";
import { ref, watch, computed, onUnmounted } from "vue";
import MarkdownIt from "markdown-it";
import hljs from "highlight.js";
import axios from "axios";
import { MarkdownRender, enableKatex, enableMermaid } from "markstream-vue";
import "markstream-vue/index.css";
import "katex/dist/katex.min.css";
import "highlight.js/styles/github.css";
import DOMPurify from "dompurify";
import "highlight.js/styles/github-dark.css";
import { useI18n } from "@/i18n/composables";
enableKatex();
enableMermaid();
// 1. 在 setup 作用域创建 MarkdownIt 实例
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
breaks: false,
});
md.enable(["table", "strikethrough"]);
md.renderer.rules.table_open = () => '<div class="table-container"><table>';
md.renderer.rules.table_close = () => "</table></div>";
// 2. 复制按钮的 SVG 图标常量
const ICONS = {
SUCCESS:
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20,6 9,17 4,12"></polyline></svg>',
ERROR:
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>',
COPY: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>',
};
const props = defineProps({
show: {
type: Boolean,
default: false,
},
pluginName: {
type: String,
default: "",
},
repoUrl: {
type: String,
default: null,
},
// 模式: 'readme' 或 'changelog'
show: { type: Boolean, default: false },
pluginName: { type: String, default: "" },
repoUrl: { type: String, default: null },
mode: {
type: String,
default: "readme",
@@ -32,69 +40,146 @@ const props = defineProps({
});
const emit = defineEmits(["update:show"]);
// 国际化
const { t } = useI18n();
const { t, locale } = useI18n();
const content = ref(null);
const error = ref(null);
const loading = ref(false);
const isEmpty = ref(false); // 请求成功但无内容
const isEmpty = ref(false);
const copyFeedbackTimer = ref(null);
const lastRequestId = ref(0);
onUnmounted(() => {
if (copyFeedbackTimer.value) clearTimeout(copyFeedbackTimer.value);
});
// 渲染后的 HTML
const renderedHtml = computed(() => {
// 强制依赖 locale确保语言切换时重新渲染
const _ = locale?.value;
if (!content.value) return "";
// 设置 fence 规则,直接使用当前作用域的 t 函数
md.renderer.rules.fence = (tokens, idx) => {
const token = tokens[idx];
const lang = token.info.trim() || "";
const code = token.content;
const highlighted =
lang && hljs.getLanguage(lang)
? hljs.highlight(code, { language: lang }).value
: md.utils.escapeHtml(code);
return `<div class="code-block-wrapper">
${lang ? `<span class="code-lang-label">${lang}</span>` : ""}
<button class="copy-code-btn" title="${t("core.common.copy")}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>
</button>
<pre class="hljs"><code class="language-${lang}">${highlighted}</code></pre>
</div>`;
};
const rawHtml = md.render(content.value);
const cleanHtml = DOMPurify.sanitize(rawHtml, {
ALLOWED_TAGS: [
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"p",
"br",
"hr",
"ul",
"ol",
"li",
"blockquote",
"pre",
"code",
"a",
"img",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
"strong",
"em",
"del",
"s",
"details",
"summary",
"div",
"span",
"input",
"button",
"svg",
"rect",
"path",
"polyline",
],
ALLOWED_ATTR: [
"href",
"src",
"alt",
"title",
"class",
"id",
"target",
"rel",
"type",
"checked",
"disabled",
"open",
"align",
"width",
"height",
"viewBox",
"fill",
"stroke",
"stroke-width",
"points",
"d",
"x",
"y",
"rx",
"ry",
],
});
// 3. 后处理方案:完全隔离,安全性最高
const tempDiv = document.createElement("div");
tempDiv.innerHTML = cleanHtml;
tempDiv.querySelectorAll("a").forEach((link) => {
const href = link.getAttribute("href");
// 强制所有外部链接使用安全的 _blank 策略
if (href && (href.startsWith("http") || href.startsWith("//"))) {
link.setAttribute("target", "_blank");
link.setAttribute("rel", "noopener noreferrer");
}
});
return tempDiv.innerHTML;
});
// 根据模式返回不同的配置
const modeConfig = computed(() => {
if (props.mode === "changelog") {
return {
title: t("core.common.changelog.title"),
loading: t("core.common.changelog.loading"),
emptyTitle: t("core.common.changelog.empty.title"),
emptySubtitle: t("core.common.changelog.empty.subtitle"),
apiPath: "/api/plugin/changelog",
};
}
const isChangelog = props.mode === "changelog";
const keyBase = `core.common.${isChangelog ? "changelog" : "readme"}`;
return {
title: t("core.common.readme.title"),
loading: t("core.common.readme.loading"),
emptyTitle: t("core.common.readme.empty.title"),
emptySubtitle: t("core.common.readme.empty.subtitle"),
apiPath: "/api/plugin/readme",
title: t(`${keyBase}.title`),
loading: t(`${keyBase}.loading`),
emptyTitle: t(`${keyBase}.empty.title`),
emptySubtitle: t(`${keyBase}.empty.subtitle`),
apiPath: `/api/plugin/${isChangelog ? "changelog" : "readme"}`,
};
});
// 监听show的变化当显示对话框时加载内容
watch(
() => props.show,
(newVal) => {
if (newVal && props.pluginName) {
fetchContent();
}
},
);
// 监听pluginName的变化
watch(
() => props.pluginName,
(newVal) => {
if (props.show && newVal) {
fetchContent();
}
},
);
// 监听mode的变化
watch(
() => props.mode,
() => {
if (props.show && props.pluginName) {
fetchContent();
}
},
);
// 获取内容
async function fetchContent() {
if (!props.pluginName) return;
const requestId = ++lastRequestId.value;
loading.value = true;
content.value = null;
error.value = null;
@@ -104,44 +189,90 @@ async function fetchContent() {
const res = await axios.get(
`${modeConfig.value.apiPath}?name=${props.pluginName}`,
);
if (requestId !== lastRequestId.value) return;
if (res.data.status === "ok") {
if (res.data.data.content) {
content.value = res.data.data.content;
} else {
// 请求成功但无内容
isEmpty.value = true;
}
if (res.data.data.content) content.value = res.data.data.content;
else isEmpty.value = true;
} else {
error.value = res.data.message;
}
} catch (err) {
error.value = err.message;
if (requestId === lastRequestId.value) error.value = err.message;
} finally {
loading.value = false;
if (requestId === lastRequestId.value) loading.value = false;
}
}
// 打开GitHub中的仓库
function openRepoInNewTab() {
if (props.repoUrl) {
window.open(props.repoUrl, "_blank");
watch(
[() => props.show, () => props.pluginName, () => props.mode],
([show, name]) => {
if (show && name) fetchContent();
},
{ immediate: true },
);
function handleContainerClick(event) {
const btn = event.target.closest(".copy-code-btn");
if (!btn) return;
const code = btn.closest(".code-block-wrapper")?.querySelector("code");
if (code) {
if (navigator.clipboard?.writeText) {
navigator.clipboard
.writeText(code.textContent)
.then(() => showCopyFeedback(btn, true))
.catch(() => tryFallbackCopy(code.textContent, btn));
} else {
tryFallbackCopy(code.textContent, btn);
}
}
}
// 刷新内容
function refreshContent() {
fetchContent();
function tryFallbackCopy(text, btn) {
try {
const textArea = document.createElement("textarea");
textArea.value = text;
Object.assign(textArea.style, {
position: "absolute",
opacity: "0",
zIndex: "-1",
});
btn.parentNode.appendChild(textArea);
textArea.select();
const success = document.execCommand("copy");
btn.parentNode.removeChild(textArea);
showCopyFeedback(btn, success);
} catch (err) {
showCopyFeedback(btn, false);
}
}
function showCopyFeedback(btn, success) {
if (copyFeedbackTimer.value) clearTimeout(copyFeedbackTimer.value);
btn.setAttribute("title", t(`core.common.${success ? "copied" : "error"}`));
btn.innerHTML = success ? ICONS.SUCCESS : ICONS.ERROR;
btn.style.color = success ? "var(--v-theme-success)" : "var(--v-theme-error)";
copyFeedbackTimer.value = setTimeout(() => {
if (document.body.contains(btn)) {
btn.innerHTML = ICONS.COPY;
btn.style.color = "";
btn.setAttribute("title", t("core.common.copy"));
}
copyFeedbackTimer.value = null;
}, 2000);
}
// 计算属性处理双向绑定
const _show = computed({
get() {
return props.show;
},
set(value) {
emit("update:show", value);
},
get: () => props.show,
set: (val) => emit("update:show", val),
});
// 安全打开外部链接
function openExternalLink(url) {
if (!url) return;
window.open(url, "_blank", "noopener,noreferrer");
}
</script>
<template>
@@ -149,7 +280,7 @@ const _show = computed({
<v-card>
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h5">{{ modeConfig.title }}</span>
<v-btn icon @click="$emit('update:show', false)" variant="text">
<v-btn icon @click="_show = false" variant="text">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
@@ -160,20 +291,19 @@ const _show = computed({
v-if="repoUrl"
color="primary"
prepend-icon="mdi-github"
@click="openRepoInNewTab()"
@click="openExternalLink(repoUrl)"
>
{{ t("core.common.readme.buttons.viewOnGithub") }}
</v-btn>
<v-btn
color="secondary"
prepend-icon="mdi-refresh"
@click="refreshContent()"
@click="fetchContent"
>
{{ t("core.common.readme.buttons.refresh") }}
</v-btn>
</div>
<!-- 加载中 -->
<div
v-if="loading"
class="d-flex flex-column align-center justify-center"
@@ -188,16 +318,13 @@ const _show = computed({
<p class="text-body-1 text-center">{{ modeConfig.loading }}</p>
</div>
<!-- 内容显示 -->
<div v-else-if="content" class="markdown-body">
<MarkdownRender
:content="content"
:typewriter="false"
class="markdown-content"
/>
</div>
<div
v-else-if="renderedHtml"
class="markdown-body"
v-html="renderedHtml"
@click="handleContainerClick"
></div>
<!-- 错误提示 -->
<div
v-else-if="error"
class="d-flex flex-column align-center justify-center"
@@ -214,7 +341,6 @@ const _show = computed({
</p>
</div>
<!-- 无内容提示 -->
<div
v-else-if="isEmpty"
class="d-flex flex-column align-center justify-center"
@@ -234,11 +360,7 @@ const _show = computed({
<v-divider></v-divider>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="primary"
variant="tonal"
@click="$emit('update:show', false)"
>
<v-btn color="primary" variant="tonal" @click="_show = false">
{{ t("core.common.close") }}
</v-btn>
</v-card-actions>
@@ -246,8 +368,9 @@ const _show = computed({
</v-dialog>
</template>
<style>
.markdown-body {
<style scoped>
:deep(.markdown-body) {
--markdown-border: rgba(128, 128, 128, 0.3);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
sans-serif;
line-height: 1.6;
@@ -255,66 +378,112 @@ const _show = computed({
color: var(--v-theme-secondaryText);
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
:deep(.markdown-body [align="center"]) {
text-align: center;
}
:deep(.markdown-body [align="right"]) {
text-align: right;
}
:deep(.markdown-body h1),
:deep(.markdown-body h2),
:deep(.markdown-body h3),
:deep(.markdown-body h4),
:deep(.markdown-body h5),
:deep(.markdown-body h6) {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
.markdown-body h1 {
:deep(.markdown-body h1) {
font-size: 2em;
border-bottom: 1px solid var(--v-theme-border);
padding-bottom: 0.3em;
}
.markdown-body h2 {
:deep(.markdown-body h2) {
font-size: 1.5em;
border-bottom: 1px solid var(--v-theme-border);
padding-bottom: 0.3em;
}
.markdown-body p {
:deep(.markdown-body p) {
margin-top: 0;
margin-bottom: 16px;
}
.markdown-body code {
padding: 0.2em 0.4em;
margin: 0;
background-color: var(--v-theme-codeBg);
border-radius: 3px;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 85%;
:deep(.markdown-body .code-block-wrapper) {
position: relative;
margin-bottom: 16px;
}
:deep(.markdown-body .code-lang-label) {
position: absolute;
top: 8px;
left: 12px;
font-size: 12px;
color: #8b949e;
text-transform: uppercase;
font-weight: 500;
z-index: 1;
}
.markdown-body pre {
:deep(.markdown-body .copy-code-btn) {
position: absolute;
top: 8px;
right: 8px;
background: rgba(110, 118, 129, 0.4);
border: none;
border-radius: 6px;
padding: 6px;
cursor: pointer;
color: #c9d1d9;
display: flex;
align-items: center;
justify-content: center;
transition:
background-color 0.2s,
color 0.2s;
z-index: 1;
}
:deep(.markdown-body .copy-code-btn:hover) {
background: rgba(110, 118, 129, 0.6);
color: #fff;
}
:deep(.markdown-body code) {
padding: 0.2em 0.4em;
margin: 0;
background-color: rgba(110, 118, 129, 0.2);
border-radius: 6px;
font-size: 85%;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
}
:deep(.markdown-body pre.hljs) {
padding: 16px;
padding-top: 32px;
overflow: auto;
font-size: 85%;
line-height: 1.45;
background-color: var(--v-theme-containerBg);
border-radius: 3px;
margin-bottom: 16px;
background-color: #0d1117;
border-radius: 6px;
margin: 0;
}
.markdown-body pre code {
:deep(.markdown-body pre.hljs code) {
background-color: transparent;
padding: 0;
border-radius: 0;
color: #c9d1d9;
}
.markdown-body ul,
.markdown-body ol {
:deep(.markdown-body ul),
:deep(.markdown-body ol) {
padding-left: 2em;
margin-bottom: 16px;
}
.markdown-body img {
:deep(.markdown-body img) {
max-width: 100%;
margin: 8px 0;
box-sizing: border-box;
@@ -322,69 +491,112 @@ const _show = computed({
border-radius: 3px;
}
.markdown-body blockquote {
:deep(.markdown-body img[src*="shields.io"]),
:deep(.markdown-body img[src*="badge"]) {
display: inline-block;
vertical-align: middle;
height: auto;
margin: 2px 4px;
background-color: transparent;
}
:deep(.markdown-body blockquote) {
padding: 0 1em;
color: var(--v-theme-secondaryText);
border-left: 0.25em solid var(--v-theme-border);
margin-bottom: 16px;
}
.markdown-body a {
:deep(.markdown-body a) {
color: var(--v-theme-primary);
text-decoration: none;
}
.markdown-body a:hover {
:deep(.markdown-body a:hover) {
text-decoration: underline;
}
.markdown-body table {
:deep(.markdown-body table) {
border-spacing: 0;
border-collapse: collapse;
width: 100%;
overflow: auto;
margin-bottom: 0;
border: 1px solid var(--markdown-border);
}
:deep(.markdown-body .table-container) {
width: 100%;
overflow-x: auto;
margin-bottom: 16px;
border: 1px solid var(--markdown-border);
border-radius: 6px;
}
.markdown-body table th,
.markdown-body table td {
:deep(.markdown-body table th),
:deep(.markdown-body table td) {
padding: 6px 13px;
border: 1px solid var(--v-theme-background);
border: 1px solid var(--markdown-border);
}
:deep(.markdown-body table th) {
font-weight: 600;
background-color: rgba(128, 128, 128, 0.1);
}
:deep(.markdown-body table tr) {
background-color: transparent;
}
:deep(.markdown-body table tr:nth-child(2n)) {
background-color: rgba(128, 128, 128, 0.05);
}
.markdown-body table tr {
background-color: var(--v-theme-surface);
border-top: 1px solid var(--v-theme-border);
}
.markdown-body table tr:nth-child(2n) {
background-color: var(--v-theme-background);
}
.markdown-body hr {
:deep(.markdown-body hr) {
height: 0.25em;
padding: 0;
margin: 24px 0;
background-color: var(--v-theme-containerBg);
border: 0;
}
</style>
<script>
export default {
name: "ReadmeDialog",
components: {
MarkdownRender,
},
computed: {
_show: {
get() {
return this.show;
},
set(value) {
this.$emit("update:show", value);
},
},
},
};
</script>
:deep(.markdown-body details) {
margin-bottom: 16px;
border: 1px solid var(--v-theme-border);
border-radius: 6px;
padding: 8px 12px;
background-color: var(--v-theme-surface);
}
:deep(.markdown-body details[open]) {
padding-bottom: 12px;
}
:deep(.markdown-body summary) {
cursor: pointer;
font-weight: 600;
padding: 4px 0;
list-style: none;
display: flex;
align-items: center;
gap: 6px;
}
:deep(.markdown-body summary::before) {
content: "▶";
font-size: 0.75em;
transition: transform 0.2s ease;
}
:deep(.markdown-body details[open] summary::before) {
transform: rotate(90deg);
}
:deep(.markdown-body summary::-webkit-details-marker) {
display: none;
}
:deep(.markdown-body details > *:not(summary)) {
margin-top: 12px;
}
:deep(.markdown-body .hljs-keyword),
:deep(.markdown-body .hljs-selector-tag),
:deep(.markdown-body .hljs-title),
:deep(.markdown-body .hljs-section),
:deep(.markdown-body .hljs-doctag),
:deep(.markdown-body .hljs-name),
:deep(.markdown-body .hljs-strong) {
font-weight: bold;
}
</style>

View File

@@ -3,6 +3,7 @@
"cancel": "Cancel",
"close": "Close",
"copy": "Copy",
"copied": "Copied",
"delete": "Delete",
"edit": "Edit",
"add": "Add",

View File

@@ -3,6 +3,7 @@
"cancel": "取消",
"close": "关闭",
"copy": "复制",
"copied": "已复制",
"delete": "删除",
"edit": "编辑",
"add": "添加",