You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

863 lines
22 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<div class="review-result-page">
<!-- 顶部工具栏 -->
<div class="toolbar">
<div class="toolbar-left">
<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) }}
</ElTag>
</div>
<div class="toolbar-right">
<ElButton
:icon="mdPanelCollapsed ? Expand : Fold"
@click="mdPanelCollapsed = !mdPanelCollapsed"
>
{{ mdPanelCollapsed ? "展开结果" : "收起结果" }}
</ElButton>
<ElButton type="primary" :icon="Download" @click="handleExport" :loading="exporting">
</ElButton>
</div>
</div>
<!-- -->
<div class="content-area">
<!-- 左侧文档预览 -->
<div class="docx-panel" :class="{ 'docx-panel--full': mdPanelCollapsed }">
<FaDocxPreview
v-if="task?.file_url"
:src="getFileUrl(task?.file?.bucket_name! + '/' + task?.file?.file_path! || '')"
:readonly="true"
class="docx-preview"
/>
<ElEmpty v-else class="empty-docx" description="暂无文档" />
</div>
<!-- 右侧 MD 预览 -->
<div v-show="!mdPanelCollapsed" class="md-panel" :class="{ 'md-outline-collapsed': mdOutlineCollapsed }">
<!-- MD 大纲(可折叠) -->
<div class="md-outline-wrap">
<div class="md-outline-inner" v-show="!mdOutlineCollapsed">
<div class="md-outline-header">
<span>大纲</span>
</div>
<div class="md-outline-body" ref="mdOutlineBodyRef">
<ul v-if="mdHeadings.length" class="md-outline-list">
<li
v-for="h in mdHeadings"
:key="h.id"
:class="['md-outline-item', `level-${h.level}`, { active: activeMdHeading === h.id }]"
@click="scrollToMdHeading(h.id)"
:title="h.text"
>
{{ h.text }}
</li>
</ul>
<div v-else class="no-outline">暂无大纲</div>
</div>
</div>
<button
type="button"
class="md-outline-toggle"
:title="mdOutlineCollapsed ? '展开大纲' : '收起大纲'"
@click="mdOutlineCollapsed = !mdOutlineCollapsed"
>
<ElIcon><ArrowRight v-if="mdOutlineCollapsed" /><ArrowLeft v-else /></ElIcon>
</button>
</div>
<!-- MD 内容 -->
<div class="md-content-wrap" ref="mdContentWrapRef">
<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>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import MarkdownIt from "markdown-it";
import hljs from "highlight.js";
import "highlight.js/styles/atom-one-light.css";
import {
ArrowLeft,
ArrowRight,
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";
import type { ReviewTaskItem } from "@/api/module_report/review";
import type { ReportType } from "@/api/module_report/report";
import { getFileUrl } from "@/utils/file";
defineOptions({ name: "ReviewResultPage" });
const route = useRoute();
const router = useRouter();
/** 报告类型1 能源报告 / 2 光伏报告(由路由参数传入) */
const reportType = computed<ReportType>(() => {
const t = route.query.report_type;
return t === "2" ? 2 : 1;
});
const task = ref<ReviewTaskItem | null>(null);
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
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function highlightCode(str: string, lang: string): string {
if (lang && hljs.getLanguage(lang)) {
try {
return `<pre class="hljs"><code>${hljs.highlight(str, { language: lang, ignoreIllegals: true }).value}</code></pre>`;
} catch (e) {}
}
return `<pre class="hljs"><code>${escapeHtml(str)}</code></pre>`;
}
const mdParser = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
breaks: true,
highlight: highlightCode,
});
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 });
}
const html = mdParser.renderer.render(tokens, mdParser.options, env);
return { html, headings };
}
/** 侧栏预览渲染结果(含标题 id 与大纲列表) */
const sideRender = computed(() => {
if (!mdContent.value) return { html: "", headings: [] as MdHeading[] };
return renderMarkdown(mdContent.value, "md-h-");
});
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;
});
onMounted(() => {
if (taskId.value) {
loadTask(taskId.value);
loadResult(taskId.value);
}
});
async function loadTask(id: number) {
try {
const resp = await ReviewTaskAPI.detail(id, reportType.value);
task.value = resp?.data?.data ?? null;
} catch (e) {
task.value = null;
}
}
async function loadResult(id: number) {
mdLoading.value = true;
try {
// TODO: 审查结果接口暂未提供,此处预留调用入口
// const resp = await ReviewTaskAPI.getResult(id);
// mdContent.value = resp?.data?.data?.md_content || "";
// 临时使用 public 目录下的 mock 文件,接口接入后删除
const resp = await fetch("/mock/审查结果-202605301641.md");
mdContent.value = await resp.text();
} catch (e) {
mdContent.value = "";
} finally {
mdLoading.value = false;
}
}
function goBack() {
router.back();
}
function openFullPreview() {
fullActiveHeading.value = "";
fullPreviewVisible.value = true;
}
async function handleExport() {
if (!taskId.value) return;
exporting.value = true;
try {
// TODO: 接口暂未提供,此处预留
// const resp = await ReviewTaskAPI.exportResult(taskId.value);
// const blob = new Blob([resp.data]);
// const url = URL.createObjectURL(blob);
// const a = document.createElement("a");
// a.href = url;
// a.download = `${task.value?.task_name || "审核结果"}.md`;
// a.click();
// URL.revokeObjectURL(url);
// 临时下载 md 内容
const blob = new Blob([mdContent.value], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${task.value?.task_name || "审核结果"}.md`;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
} finally {
exporting.value = false;
}
}
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) 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 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) {
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] ?? "未知";
}
function statusTagType(s: number): "info" | "warning" | "success" | "danger" {
return ["info", "warning", "success", "danger"][s] as any;
}
</script>
<style lang="scss" scoped>
.review-result-page {
display: flex;
flex-direction: column;
height: 100%;
background: #f5f7fa;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: #fff;
border-bottom: 1px solid #e5e7eb;
flex-shrink: 0;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.task-title {
font-size: 15px;
font-weight: 600;
color: #303133;
max-width: 400px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolbar-right {
display: flex;
gap: 8px;
}
.content-area {
flex: 1;
display: flex;
overflow: hidden;
padding: 12px;
gap: 12px;
}
.docx-panel {
flex: 1.8;
background: #fff;
border-radius: 8px;
border: 1px solid #e5e7eb;
overflow: hidden;
min-width: 0;
display: flex;
flex-direction: column;
&--full {
flex: 1;
}
}
:deep(.docx-preview) {
flex: 1;
height: 100%;
.outline-sidebar {
width: 200px;
}
}
.empty-docx {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
/* 右侧 MD 面板(动态宽度,与左侧文档区按比例分配) */
.md-panel {
flex: 1;
min-width: 320px;
background: #fff;
border-radius: 8px;
border: 1px solid #e5e7eb;
display: flex;
overflow: hidden;
&.md-outline-collapsed {
.md-outline-wrap { flex: 0 0 20px; min-width: 20px; }
.md-outline-inner { display: none; }
}
}
.md-outline-wrap {
flex: 0 0 30%;
min-width: 120px;
max-width: 240px;
background: #fafafa;
border-right: 1px solid #f0f0f0;
display: flex;
position: relative;
transition: flex-basis 0.3s ease;
}
.md-outline-inner {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.md-outline-header {
padding: 10px 10px 6px;
font-size: 12px;
font-weight: 600;
color: #606266;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
}
.md-outline-body {
flex: 1;
overflow-y: auto;
padding: 6px 0;
}
.md-outline-list {
list-style: none;
margin: 0;
padding: 0;
}
.md-outline-item {
padding: 5px 10px;
font-size: 12px;
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: 10px; font-weight: 600; }
&.level-2 { padding-left: 20px; }
&.level-3 { padding-left: 30px; font-size: 11px; }
&.level-4 { padding-left: 40px; font-size: 11px; color: #909399; }
&.level-5, &.level-6 { padding-left: 50px; font-size: 11px; color: #909399; }
}
.no-outline {
padding: 16px 8px;
text-align: center;
font-size: 12px;
color: #c0c4cc;
}
.md-outline-toggle {
position: absolute;
right: -1px;
top: 50%;
transform: translateY(-50%);
width: 14px;
height: 40px;
border: 1px solid #e5e7eb;
background: #fff;
border-radius: 0 4px 4px 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
color: #909399;
z-index: 2;
transition: all 0.2s;
&:hover {
color: #409eff;
background: #f0f5ff;
}
.el-icon { font-size: 10px; }
}
.md-content-wrap {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
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;
padding: 16px 20px;
}
/* markdown body 样式 */
:deep(.markdown-body) {
font-size: 13px;
line-height: 1.8;
color: #303133;
word-break: break-word;
h1 {
font-size: 18px; font-weight: 700; margin: 16px 0 12px; padding-bottom: 8px;
border-bottom: 1px solid #eaecef; color: #111827;
}
h2 {
font-size: 15px; font-weight: 600; margin: 14px 0 10px; color: #1f2937;
}
h3 { font-size: 14px; font-weight: 600; margin: 12px 0 8px; color: #374151; }
h4,h5,h6 { font-size: 13px; font-weight: 600; margin: 10px 0 6px; color: #4b5563; }
p { margin: 8px 0; }
ul, ol { padding-left: 22px; margin: 8px 0; }
li { margin: 3px 0; }
table {
border-collapse: collapse;
width: 100%;
margin: 10px 0;
font-size: 12px;
}
th, td {
border: 1px solid #e5e7eb;
padding: 6px 10px;
text-align: left;
}
th { background: #f9fafb; font-weight: 600; }
code {
background: #f3f4f6;
padding: 1px 5px;
border-radius: 3px;
font-size: 12px;
color: #d63384;
}
pre.hljs {
background: #f6f8fa;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
font-size: 12px;
line-height: 1.5;
}
pre code {
background: transparent;
padding: 0;
color: inherit;
}
blockquote {
border-left: 3px solid #409eff;
padding: 4px 12px;
margin: 10px 0;
background: #f0f7ff;
color: #606266;
font-size: 12px;
}
}
</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>