astrbbbb / dashboard /src /components /shared /ReadmeDialog.vue
qa1145's picture
Upload 1245 files
8ede856 verified
<script setup>
import { ref, watch, computed, onUnmounted } from "vue";
import MarkdownIt from "markdown-it";
import hljs from "highlight.js";
import axios from "axios";
import DOMPurify from "dompurify";
import "highlight.js/styles/github-dark.css";
import { useI18n } from "@/i18n/composables";
// 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 },
mode: {
type: String,
default: "readme",
validator: (value) => ["readme", "changelog", "first-notice"].includes(value),
},
});
const emit = defineEmits(["update:show"]);
const { t, locale } = useI18n();
const content = ref(null);
const error = ref(null);
const loading = ref(false);
const isEmpty = ref(false);
const copyFeedbackTimer = ref(null);
const lastRequestId = ref(0);
const scrollContainer = ref(null);
function slugifyHeading(text, slugCounts) {
const base = (text || "")
.trim()
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\p{Letter}\p{Number}\s-]/gu, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-");
if (!base) return "";
const count = slugCounts.get(base) || 0;
slugCounts.set(base, count + 1);
return count === 0 ? base : `${base}-${count}`;
}
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;
const slugCounts = new Map();
tempDiv.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach((heading) => {
if (heading.id) {
slugCounts.set(heading.id, (slugCounts.get(heading.id) || 0) + 1);
return;
}
const slug = slugifyHeading(heading.textContent, slugCounts);
if (slug) heading.id = slug;
});
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",
showGithubButton: false,
showRefreshButton: true,
refreshLabel: t("core.common.readme.buttons.refresh"),
};
}
if (props.mode === "first-notice") {
return {
title: t("core.common.firstNotice.title"),
loading: t("core.common.firstNotice.loading"),
emptyTitle: t("core.common.firstNotice.empty.title"),
emptySubtitle: t("core.common.firstNotice.empty.subtitle"),
apiPath: "/api/stat/first-notice",
showGithubButton: false,
showRefreshButton: false,
refreshLabel: "",
};
}
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",
showGithubButton: true,
showRefreshButton: true,
refreshLabel: t("core.common.readme.buttons.refresh"),
};
});
const requiresPluginName = computed(
() => props.mode === "readme" || props.mode === "changelog",
);
async function fetchContent() {
if (requiresPluginName.value && !props.pluginName) return;
const requestId = ++lastRequestId.value;
loading.value = true;
content.value = null;
error.value = null;
isEmpty.value = false;
try {
let params;
if (requiresPluginName.value) {
params = { name: props.pluginName };
} else if (props.mode === "first-notice") {
params = { locale: locale.value };
}
const res = await axios.get(modeConfig.value.apiPath, { params });
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;
} else {
error.value = res.data.message;
}
} catch (err) {
if (requestId === lastRequestId.value) error.value = err.message;
} finally {
if (requestId === lastRequestId.value) loading.value = false;
}
}
watch(
[() => props.show, () => props.pluginName, () => props.mode],
([show, name]) => {
if (!show) return;
if (requiresPluginName.value && !name) return;
fetchContent();
},
{ immediate: true },
);
function handleContainerClick(event) {
const btn = event.target.closest(".copy-code-btn");
if (btn) {
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);
}
}
return;
}
const anchor = event.target.closest('a[href^="#"]');
if (!anchor) return;
const rawHref = anchor.getAttribute("href");
const targetId = rawHref ? decodeURIComponent(rawHref.slice(1)) : "";
if (!targetId) return;
const target = scrollContainer.value?.querySelector(
`#${CSS.escape(targetId)}`,
);
if (!target) return;
event.preventDefault();
target.scrollIntoView({ behavior: "smooth", block: "start" });
}
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: () => props.show,
set: (val) => emit("update:show", val),
});
// 安全打开外部链接
function openExternalLink(url) {
if (!url) return;
window.open(url, "_blank", "noopener,noreferrer");
}
const showActionArea = computed(() => {
const hasGithub = modeConfig.value.showGithubButton && !!props.repoUrl;
return hasGithub || modeConfig.value.showRefreshButton;
});
</script>
<template>
<v-dialog v-model="_show" width="800">
<v-card>
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h2 pa-2">{{ modeConfig.title }}</span>
<v-btn icon @click="_show = false" variant="text">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text ref="scrollContainer" style="overflow-y: auto">
<div v-if="showActionArea" class="d-flex justify-space-between mb-4">
<v-btn
v-if="modeConfig.showGithubButton && repoUrl"
color="primary"
prepend-icon="mdi-github"
@click="openExternalLink(repoUrl)"
>
{{ t("core.common.readme.buttons.viewOnGithub") }}
</v-btn>
<v-btn
v-if="modeConfig.showRefreshButton"
color="secondary"
prepend-icon="mdi-refresh"
@click="fetchContent"
>
{{ modeConfig.refreshLabel }}
</v-btn>
</div>
<div
v-if="loading"
class="d-flex flex-column align-center justify-center"
style="height: 100%"
>
<v-progress-circular
indeterminate
color="primary"
size="64"
class="mb-4"
></v-progress-circular>
<p class="text-body-1 text-center">{{ modeConfig.loading }}</p>
</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"
style="height: 100%"
>
<v-icon size="64" color="error" class="mb-4"
>mdi-alert-circle-outline</v-icon
>
<p class="text-body-1 text-center mb-2">
{{ t("core.common.error") }}
</p>
<p class="text-body-2 text-center text-medium-emphasis">
{{ error }}
</p>
</div>
<div
v-else-if="isEmpty"
class="d-flex flex-column align-center justify-center"
style="height: 100%"
>
<v-icon size="64" color="warning" class="mb-4"
>mdi-file-question-outline</v-icon
>
<p class="text-body-1 text-center mb-2">
{{ modeConfig.emptyTitle }}
</p>
<p class="text-body-2 text-center text-medium-emphasis">
{{ modeConfig.emptySubtitle }}
</p>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" variant="tonal" @click="_show = false">
{{ t("core.common.close") }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<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;
padding: 8px 0;
color: var(--v-theme-secondaryText);
}
: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;
scroll-margin-top: 12px;
}
:deep(.markdown-body h1) {
font-size: 2em;
border-bottom: 1px solid var(--v-theme-border);
padding-bottom: 0.3em;
}
:deep(.markdown-body h2) {
font-size: 1.5em;
border-bottom: 1px solid var(--v-theme-border);
padding-bottom: 0.3em;
}
:deep(.markdown-body p) {
margin-top: 0;
margin-bottom: 16px;
}
: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;
}
: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: #0d1117;
border-radius: 6px;
margin: 0;
}
:deep(.markdown-body pre.hljs code) {
background-color: transparent;
padding: 0;
border-radius: 0;
color: #c9d1d9;
}
:deep(.markdown-body ul),
:deep(.markdown-body ol) {
padding-left: 2em;
margin-bottom: 16px;
}
:deep(.markdown-body img) {
max-width: 100%;
margin: 8px 0;
box-sizing: border-box;
background-color: var(--v-theme-background);
border-radius: 3px;
}
: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;
}
:deep(.markdown-body a) {
color: var(--v-theme-primary);
text-decoration: none;
}
:deep(.markdown-body a:hover) {
text-decoration: underline;
}
:deep(.markdown-body table) {
border-spacing: 0;
border-collapse: collapse;
width: 100%;
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;
}
:deep(.markdown-body table th),
:deep(.markdown-body table td) {
padding: 6px 13px;
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);
}
:deep(.markdown-body hr) {
height: 0.25em;
padding: 0;
margin: 24px 0;
background-color: var(--v-theme-containerBg);
border: 0;
}
: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>