diff --git a/src/views/module_report/report-review/result.vue b/src/views/module_report/report-review/result.vue index 5ff5837..985a046 100644 --- a/src/views/module_report/report-review/result.vue +++ b/src/views/module_report/report-review/result.vue @@ -3,7 +3,7 @@
- 返回列表 + 返回 {{ task?.task_name || "审核结果" }} {{ statusText(task.status) }} @@ -14,7 +14,7 @@ :icon="mdPanelCollapsed ? Expand : Fold" @click="mdPanelCollapsed = !mdPanelCollapsed" > - {{ mdPanelCollapsed ? "展开预览" : "收起预览" }} + {{ mdPanelCollapsed ? "展开结果" : "收起结果" }} 导出 @@ -50,6 +50,7 @@ :key="h.id" :class="['md-outline-item', `level-${h.level}`, { active: activeMdHeading === h.id }]" @click="scrollToMdHeading(h.id)" + :title="h.text" > {{ h.text }} @@ -69,13 +70,64 @@
-
+
+ 预览 + +
+ +
+ + + +
+ +
+
大纲
+
+
    +
  • + {{ h.text }} +
  • +
+
暂无大纲
+
+
+ +
+ +
+
+ +
+
+
+
@@ -91,6 +143,7 @@ import { Download, Fold, Expand, + FullScreen, } from "@element-plus/icons-vue"; import FaDocxPreview from "@/components/business/fa-docx-preview/index.vue"; import { ReviewTaskAPI } from "@/api/module_report/review"; @@ -113,12 +166,15 @@ const mdLoading = ref(false); const exporting = ref(false); const mdPanelCollapsed = ref(false); const mdOutlineCollapsed = ref(false); +const fullPreviewVisible = ref(false); const mdContent = ref(""); const mdHeadings = ref<{ id: string; text: string; level: number }[]>([]); const activeMdHeading = ref(""); const mdContentWrapRef = ref(null); +const fullContentWrapRef = ref(null); +const fullActiveHeading = ref(""); function escapeHtml(s: string): string { return s @@ -146,49 +202,72 @@ const mdParser = new MarkdownIt({ highlight: highlightCode, }); -function parseHeadings(md: string): { id: string; text: string; level: number }[] { - const headings: { id: string; text: string; level: number }[] = []; - const lines = md.split("\n"); - let inCodeBlock = false; - let idx = 0; - for (const line of lines) { - if (/^```/.test(line.trim())) { - inCodeBlock = !inCodeBlock; - continue; - } - if (inCodeBlock) continue; - const m = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line); - if (m) { - const level = m[1]?.length ?? 1; - const text = (m[2] ?? "").trim(); - const id = `md-h-${idx++}`; - headings.push({ id, text, level }); +interface MdHeading { id: string; text: string; level: number; } + +/** + * 渲染 markdown,同时收集标题并注入 id。 + * 直接操作 markdown-it token 流,确保大纲提取与渲染结果完全一致, + * 避免正则解析与渲染器输出不匹配(如 setext 标题、内联格式等)的问题。 + */ +function renderMarkdown(md: string, idPrefix = "md-h-"): { html: string; headings: MdHeading[] } { + const headings: MdHeading[] = []; + const env: Record = {}; + const tokens = mdParser.parse(md, env); + let counter = 0; + for (let i = 0; i < tokens.length; i++) { + const tok = tokens[i]; + if (!tok || tok.type !== "heading_open") continue; + const level = Number(tok.tag.substring(1)); + const id = `${idPrefix}${counter++}`; + tok.attrSet("id", id); + // 从后续 inline token 中提取标题纯文本 + let text = ""; + let j = i + 1; + while (j < tokens.length) { + const inner = tokens[j]; + if (!inner || inner.type === "heading_close") break; + if (inner.type === "inline" && inner.children) { + for (const child of inner.children) { + if (child.type === "text" || child.type === "code_inline") { + text += child.content; + } + } + } + j++; } + headings.push({ id, text: text.trim(), level }); } - return headings; -} - -function injectHeadingIds(html: string, headings: { id: string; text: string; level: number }[]): string { - let i = 0; - return html.replace(/([\s\S]*?)<\/h\1>/g, (_match, lvl, inner) => { - if (i >= headings.length) return _match; - const h = headings[i]!; - i++; - return `${inner}`; - }); + const html = mdParser.renderer.render(tokens, mdParser.options, env); + return { html, headings }; } -watch(mdContent, (val) => { - mdHeadings.value = parseHeadings(val); - activeMdHeading.value = ""; +/** 侧栏预览渲染结果(含标题 id 与大纲列表) */ +const sideRender = computed(() => { + if (!mdContent.value) return { html: "", headings: [] as MdHeading[] }; + return renderMarkdown(mdContent.value, "md-h-"); }); -const renderedMdWithIds = computed(() => { - if (!mdContent.value) return ""; - const html = mdParser.render(mdContent.value); - return injectHeadingIds(html, mdHeadings.value); +const renderedMdWithIds = computed(() => sideRender.value.html); + +/** 大窗口预览渲染结果(使用不同 id 前缀避免 DOM id 重复) */ +const fullRender = computed(() => { + if (!mdContent.value) return { html: "", headings: [] as MdHeading[] }; + return renderMarkdown(mdContent.value, "md-fh-"); }); +const renderedFullMd = computed(() => fullRender.value.html); +const fullHeadings = computed(() => fullRender.value.headings); + +// 大纲标题随渲染结果更新 +watch( + () => sideRender.value.headings, + (val) => { + mdHeadings.value = val; + activeMdHeading.value = ""; + }, + { immediate: true } +); + const taskId = computed(() => { const id = route.query.id || route.params.id; return id ? Number(id) : 0; @@ -231,6 +310,11 @@ function goBack() { router.back(); } +function openFullPreview() { + fullActiveHeading.value = ""; + fullPreviewVisible.value = true; +} + async function handleExport() { if (!taskId.value) return; exporting.value = true; @@ -263,27 +347,65 @@ function scrollToMdHeading(id: string) { const container = mdContentWrapRef.value?.querySelector(".md-content-scroll") as HTMLElement; if (!container) return; const el = container.querySelector(`[id="${id}"]`) as HTMLElement | null; - if (el) { - container.scrollTo({ top: el.offsetTop - 16, behavior: "smooth" }); - activeMdHeading.value = id; - } + if (!el) return; + // 使用 getBoundingClientRect 计算相对偏移,避免嵌套元素下 offsetTop 不准 + const containerRect = container.getBoundingClientRect(); + const elRect = el.getBoundingClientRect(); + const offset = elRect.top - containerRect.top + container.scrollTop - 16; + container.scrollTo({ top: Math.max(0, offset), behavior: "smooth" }); + activeMdHeading.value = id; } function onMdScroll(e: Event) { const container = e.target as HTMLElement; if (!container) return; - const scrollTop = container.scrollTop; + const containerRect = container.getBoundingClientRect(); const headings = mdHeadings.value; let current = ""; for (const h of headings) { const el = container.querySelector(`[id="${h.id}"]`) as HTMLElement | null; - if (el && el.offsetTop - 40 <= scrollTop) { - current = h.id; + if (el) { + const relTop = el.getBoundingClientRect().top - containerRect.top; + if (relTop - 40 <= 0) { + current = h.id; + } } } if (current) activeMdHeading.value = current; } +/** 大窗口预览:点击大纲定位 */ +function scrollToFullHeading(id: string) { + const container = fullContentWrapRef.value?.querySelector(".full-content-scroll") as HTMLElement; + if (!container) return; + const el = container.querySelector(`[id="${id}"]`) as HTMLElement | null; + if (!el) return; + const containerRect = container.getBoundingClientRect(); + const elRect = el.getBoundingClientRect(); + const offset = elRect.top - containerRect.top + container.scrollTop - 16; + container.scrollTo({ top: Math.max(0, offset), behavior: "smooth" }); + fullActiveHeading.value = id; +} + +/** 大窗口预览:滚动联动高亮 */ +function onFullScroll(e: Event) { + const container = e.target as HTMLElement; + if (!container) return; + const containerRect = container.getBoundingClientRect(); + const headings = fullHeadings.value; + let current = ""; + for (const h of headings) { + const el = container.querySelector(`[id="${h.id}"]`) as HTMLElement | null; + if (el) { + const relTop = el.getBoundingClientRect().top - containerRect.top; + if (relTop - 40 <= 0) { + current = h.id; + } + } + } + if (current) fullActiveHeading.value = current; +} + function statusText(s: number) { return ["未审查", "审查中", "审查完成", "审查失败"][s] ?? "未知"; } @@ -340,7 +462,7 @@ function statusTagType(s: number): "info" | "warning" | "success" | "danger" { } .docx-panel { - flex: 1; + flex: 1.8; background: #fff; border-radius: 8px; border: 1px solid #e5e7eb; @@ -370,31 +492,31 @@ function statusTagType(s: number): "info" | "warning" | "success" | "danger" { justify-content: center; } -/* 右侧 MD 面板 */ +/* 右侧 MD 面板(动态宽度,与左侧文档区按比例分配) */ .md-panel { - width: 480px; - flex-shrink: 0; + flex: 1; + min-width: 320px; background: #fff; border-radius: 8px; border: 1px solid #e5e7eb; display: flex; overflow: hidden; - transition: width 0.25s ease; &.md-outline-collapsed { - .md-outline-wrap { width: 20px; } + .md-outline-wrap { flex: 0 0 20px; min-width: 20px; } .md-outline-inner { display: none; } } } .md-outline-wrap { - width: 140px; - flex-shrink: 0; + flex: 0 0 30%; + min-width: 120px; + max-width: 240px; background: #fafafa; border-right: 1px solid #f0f0f0; display: flex; position: relative; - transition: width 0.2s ease; + transition: flex-basis 0.3s ease; } .md-outline-inner { @@ -494,6 +616,30 @@ function statusTagType(s: number): "info" | "warning" | "success" | "danger" { overflow: hidden; } +.md-content-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px 6px 12px; + border-bottom: 1px solid #f0f0f0; + flex-shrink: 0; + + .md-content-title { + font-size: 12px; + font-weight: 600; + color: #606266; + } + + .full-preview-btn { + color: #909399; + padding: 4px; + + &:hover { + color: #409eff; + } + } +} + .md-content-scroll { flex: 1; overflow-y: auto; @@ -561,3 +707,155 @@ function statusTagType(s: number): "info" | "warning" | "success" | "danger" { } } + + +