feat: 审查结果新增大窗口预览模态框,支持独立预览审核报告内容

master^2
17 hours ago
parent 0702d247bd
commit 9fde6a92d5

@ -3,7 +3,7 @@
<!-- 顶部工具栏 -->
<div class="toolbar">
<div class="toolbar-left">
<ElButton :icon="ArrowLeft" @click="goBack"></ElButton>
<ElButton :icon="ArrowLeft" @click="goBack"></ElButton>
<span class="task-title">{{ task?.task_name || "审核结果" }}</span>
<ElTag v-if="task" :type="statusTagType(task.status)" effect="light" size="small">
{{ statusText(task.status) }}
@ -14,7 +14,7 @@
:icon="mdPanelCollapsed ? Expand : Fold"
@click="mdPanelCollapsed = !mdPanelCollapsed"
>
{{ mdPanelCollapsed ? "展开预览" : "收起预览" }}
{{ mdPanelCollapsed ? "展开结果" : "收起结果" }}
</ElButton>
<ElButton type="primary" :icon="Download" @click="handleExport" :loading="exporting">
导出
@ -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 }}
</li>
@ -69,13 +70,64 @@
<!-- MD 内容 -->
<div class="md-content-wrap" ref="mdContentWrapRef">
<div v-loading="mdLoading" class="md-content-scroll" @scroll="onMdScroll">
<div class="md-content-header">
<span class="md-content-title">预览</span>
<ElButton
link
:icon="FullScreen"
title="大窗口预览"
class="full-preview-btn"
@click="openFullPreview"
/>
</div>
<!-- @scroll="onMdScroll" -->
<div v-loading="mdLoading" class="md-content-scroll">
<div v-if="mdContent" class="markdown-body" v-html="renderedMdWithIds"></div>
<ElEmpty v-else-if="!mdLoading" description="审核结果接口暂未提供,等待后端接入" :image-size="80" />
</div>
</div>
</div>
</div>
<!-- 大窗口预览 -->
<ElDialog
v-model="fullPreviewVisible"
title="审核结果预览"
width="90%"
top="3vh"
append-to-body
destroy-on-close
class="full-preview-dialog"
>
<div class="full-preview-body">
<!-- 左侧大纲 -->
<div class="full-outline-wrap">
<div class="full-outline-header">大纲</div>
<div class="full-outline-body">
<ul v-if="fullHeadings.length" class="full-outline-list">
<li
v-for="h in fullHeadings"
:key="h.id"
:class="['full-outline-item', `level-${h.level}`, { active: fullActiveHeading === h.id }]"
:title="h.text"
@click="scrollToFullHeading(h.id)"
>
{{ h.text }}
</li>
</ul>
<div v-else class="no-outline">暂无大纲</div>
</div>
</div>
<!-- 右侧内容 -->
<div class="full-content-wrap" ref="fullContentWrapRef">
<!-- @scroll="onFullScroll" -->
<div class="full-content-scroll">
<div v-if="mdContent" class="markdown-body" v-html="renderedFullMd"></div>
<ElEmpty v-else description="暂无内容" :image-size="80" />
</div>
</div>
</div>
</ElDialog>
</div>
</template>
@ -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<HTMLElement | null>(null);
const fullContentWrapRef = ref<HTMLElement | null>(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<string, unknown> = {};
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(/<h([1-6])>([\s\S]*?)<\/h\1>/g, (_match, lvl, inner) => {
if (i >= headings.length) return _match;
const h = headings[i]!;
i++;
return `<h${lvl} id="${h.id}">${inner}</h${lvl}>`;
});
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" {
}
}
</style>
<!-- 大窗口预览弹窗样式append-to-body teleport body 需使用非 scoped 样式 -->
<style lang="scss">
.full-preview-dialog {
.el-dialog__body {
padding: 0;
}
.full-preview-body {
height: calc(100vh - 120px);
display: flex;
overflow: hidden;
background: #fff;
}
/* 左侧大纲 */
.full-outline-wrap {
flex: 0 0 220px;
background: #fafafa;
border-right: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.full-outline-header {
padding: 12px 14px 8px;
font-size: 13px;
font-weight: 600;
color: #606266;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
}
.full-outline-body {
flex: 1;
overflow-y: auto;
padding: 6px 0;
}
.full-outline-list {
list-style: none;
margin: 0;
padding: 0;
}
.full-outline-item {
padding: 6px 14px;
font-size: 13px;
color: #606266;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-left: 2px solid transparent;
line-height: 1.5;
&:hover {
background: #f0f5ff;
color: #409eff;
}
&.active {
color: #409eff;
background: #ecf5ff;
border-left-color: #409eff;
font-weight: 500;
}
&.level-1 { padding-left: 14px; font-weight: 600; }
&.level-2 { padding-left: 24px; }
&.level-3 { padding-left: 34px; font-size: 12px; }
&.level-4 { padding-left: 44px; font-size: 12px; color: #909399; }
&.level-5, &.level-6 { padding-left: 54px; font-size: 12px; color: #909399; }
}
/* 右侧内容 */
.full-content-wrap {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.full-content-scroll {
flex: 1;
overflow-y: auto;
padding: 24px 32px;
}
.markdown-body {
max-width: 900px;
margin: 0 auto;
font-size: 14px;
line-height: 1.8;
color: #303133;
word-break: break-word;
h1 {
font-size: 20px; font-weight: 700; margin: 18px 0 14px; padding-bottom: 8px;
border-bottom: 1px solid #eaecef; color: #111827;
}
h2 {
font-size: 17px; font-weight: 600; margin: 16px 0 12px; color: #1f2937;
}
h3 { font-size: 15px; font-weight: 600; margin: 14px 0 10px; color: #374151; }
h4, h5, h6 { font-size: 14px; font-weight: 600; margin: 12px 0 8px; color: #4b5563; }
p { margin: 10px 0; }
ul, ol { padding-left: 24px; margin: 10px 0; }
li { margin: 4px 0; }
table {
border-collapse: collapse;
width: 100%;
margin: 12px 0;
font-size: 13px;
}
th, td {
border: 1px solid #e5e7eb;
padding: 8px 12px;
text-align: left;
}
th { background: #f9fafb; font-weight: 600; }
code {
background: #f3f4f6;
padding: 2px 6px;
border-radius: 3px;
font-size: 13px;
color: #d63384;
}
pre.hljs {
background: #f6f8fa;
padding: 14px;
border-radius: 6px;
overflow-x: auto;
font-size: 13px;
line-height: 1.5;
}
pre code {
background: transparent;
padding: 0;
color: inherit;
}
blockquote {
border-left: 3px solid #409eff;
padding: 6px 14px;
margin: 12px 0;
background: #f0f7ff;
color: #606266;
font-size: 13px;
}
}
}
</style>

Loading…
Cancel
Save