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 @@
@@ -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" {
}
}
+
+
+