diff --git a/dashboard/package.json b/dashboard/package.json
index 7b4a7f071a..15d39d9b7d 100644
--- a/dashboard/package.json
+++ b/dashboard/package.json
@@ -23,6 +23,7 @@
"date-fns": "2.30.0",
"dompurify": "^3.3.1",
"event-source-polyfill": "^1.0.31",
+ "github-slugger": "^2.0.0",
"highlight.js": "^11.11.1",
"js-md5": "^0.8.3",
"katex": "^0.16.27",
diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml
index ea8636c615..349dda4e9c 100644
--- a/dashboard/pnpm-lock.yaml
+++ b/dashboard/pnpm-lock.yaml
@@ -38,6 +38,9 @@ importers:
event-source-polyfill:
specifier: ^1.0.31
version: 1.0.31
+ github-slugger:
+ specifier: ^2.0.0
+ version: 2.0.0
highlight.js:
specifier: ^11.11.1
version: 11.11.1
@@ -1607,6 +1610,9 @@ packages:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
+ github-slugger@2.0.0:
+ resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
+
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@@ -4359,6 +4365,8 @@ snapshots:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
+ github-slugger@2.0.0: {}
+
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
diff --git a/dashboard/src/components/shared/ReadmeDialog.vue b/dashboard/src/components/shared/ReadmeDialog.vue
index ddc27cd900..4aeb145ce6 100644
--- a/dashboard/src/components/shared/ReadmeDialog.vue
+++ b/dashboard/src/components/shared/ReadmeDialog.vue
@@ -4,6 +4,7 @@ import MarkdownIt from "markdown-it";
import hljs from "highlight.js";
import axios from "axios";
import DOMPurify from "dompurify";
+import GithubSlugger from "github-slugger";
import "highlight.js/styles/github-dark.css";
import { useI18n } from "@/i18n/composables";
@@ -18,6 +19,16 @@ const md = new MarkdownIt({
md.enable(["table", "strikethrough"]);
md.renderer.rules.table_open = () => '
';
md.renderer.rules.table_close = () => "
";
+md.renderer.rules.heading_open = (tokens, idx, options, env, self) => {
+ const headingToken = tokens[idx + 1];
+ const headingText = headingToken?.content || "";
+ const slugger = env?.slugger;
+ if (slugger && typeof slugger.slug === "function") {
+ const headingId = slugger.slug(headingText);
+ tokens[idx].attrSet("id", headingId || slugger.slug("section"));
+ }
+ return self.renderToken(tokens, idx, options);
+};
// 2. 复制按钮的 SVG 图标常量
const ICONS = {
@@ -28,6 +39,41 @@ const ICONS = {
COPY: '',
};
+function isInDialogAnchor(href) {
+ return href?.startsWith("#") && href.length > 1 && !href.startsWith("#/");
+}
+
+function safeDecodeAnchorId(anchorId) {
+ try {
+ return decodeURIComponent(anchorId);
+ } catch {
+ return anchorId;
+ }
+}
+
+function scrollToHeadingInDialog(anchorId, markdownContainer, scrollContainer) {
+ if (!anchorId || !markdownContainer) return;
+ const decodedId = safeDecodeAnchorId(anchorId);
+ const escapedId =
+ typeof CSS !== "undefined" && CSS.escape
+ ? CSS.escape(decodedId)
+ : decodedId.replace(/["\\]/g, "\\$&");
+ const target = markdownContainer.querySelector(`#${escapedId}`);
+ if (!target) return;
+
+ if (!scrollContainer) {
+ target.scrollIntoView({ behavior: "smooth", block: "start" });
+ return;
+ }
+
+ const offsetTop =
+ target.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top;
+ scrollContainer.scrollTo({
+ top: scrollContainer.scrollTop + offsetTop - 8,
+ behavior: "smooth",
+ });
+}
+
const props = defineProps({
show: { type: Boolean, default: false },
pluginName: { type: String, default: "" },
@@ -46,6 +92,7 @@ const content = ref(null);
const error = ref(null);
const loading = ref(false);
const isEmpty = ref(false);
+const contentContainer = ref(null);
const copyFeedbackTimer = ref(null);
const lastRequestId = ref(0);
@@ -79,7 +126,7 @@ const renderedHtml = computed(() => {
`;
};
- const rawHtml = md.render(content.value);
+ const rawHtml = md.render(content.value, { slugger: new GithubSlugger() });
const cleanHtml = DOMPurify.sanitize(rawHtml, {
ALLOWED_TAGS: [
@@ -251,17 +298,34 @@ watch(
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);
+ 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 link = event.target.closest("a");
+ if (!link) return;
+ const href = link.getAttribute("href")?.trim();
+ if (!isInDialogAnchor(href)) return;
+
+ event.preventDefault();
+ const markdownContainer = event.currentTarget;
+ if (markdownContainer) {
+ scrollToHeadingInDialog(
+ href.slice(1),
+ markdownContainer,
+ contentContainer.value,
+ );
}
}
@@ -326,7 +390,7 @@ const showActionArea = computed(() => {
mdi-close
-
+