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.

707 lines
18 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="template-detail-page">
<!-- 顶部导航栏 -->
<div class="page-header">
<div class="header-left">
<ElButton text :icon="ArrowLeft" @click="goBack"></ElButton>
<span class="page-title">{{ template?.template_name || "加载中..." }}</span>
<ElTag v-if="template" type="info" effect="plain" size="small" class="readonly-tag">
只读模式
</ElTag>
</div>
<div class="header-right">
<ElButton type="primary" :icon="Edit" @click="goEdit">编辑模板</ElButton>
</div>
</div>
<!-- 三栏主体 -->
<div class="page-body">
<!-- 中间:文档预览(左+中由 FaDocxPreview 组件一体呈现readonly 模式) -->
<div class="preview-wrapper">
<FaDocxPreview
v-if="template && docxSrc"
ref="previewRef"
:src="docxSrc"
:filename="template.original_name || template.template_name"
readonly
@ready="handlePreviewReady"
@heading-click="handleHeadingClick"
/>
<div v-else v-loading="loading" class="preview-loading">
<ElEmpty v-if="!loading" description="模板不存在" />
</div>
</div>
<!-- 右侧栏 -->
<aside class="right-panel">
<!-- 标注列表视图 -->
<div v-if="rightPanel === 'list'" class="panel-scroll">
<div class="panel-title">
<span>
<ElIcon><CollectionTag /></ElIcon>
标注列表
</span>
<span class="panel-count">共 {{ annotations.length }} 项</span>
</div>
<div v-if="annotations.length === 0" class="empty-panel">
<ElEmpty description="暂无标注" :image-size="80" />
</div>
<div v-else class="annotation-card-list">
<div
v-for="ann in annotations"
:key="ann.id"
class="annotation-card"
:class="{ active: activeId === ann.id }"
@click="focusAnnotation(ann)"
>
<div class="card-row">
<span class="label">章节</span>
<span class="chapter-link" :title="ann.chapter_name">
{{ ann.chapter_name }}
</span>
</div>
<div class="card-row">
<span class="label">关联规则</span>
<span class="rule-link" :title="ann.rule_name">
{{ ann.rule_name || "-" }}
</span>
</div>
<div v-if="ann.description" class="card-desc" :title="ann.description">
<span class="desc-text">{{ ann.description }}</span>
</div>
</div>
</div>
</div>
<!-- 标注详情视图(只读) -->
<div
v-else-if="rightPanel === 'detail' && activeAnnotation"
class="panel-scroll"
v-loading="detailLoading"
>
<div class="panel-title config-title">
<div class="selected-heading">
<span class="label">选中内容:</span>
<span class="heading-text">{{ activeAnnotation.chapter_name }}</span>
</div>
</div>
<!-- 关联规则 -->
<div class="config-section">
<div class="config-label">
关联规则:
<span class="rule-name">{{ activeAnnotation.rule_name || "未选择" }}</span>
</div>
<div v-if="activeAnnotation.rule_category_name" class="config-row">
<span class="sub-label">规则分类:</span>
<span>{{ activeAnnotation.rule_category_name }}</span>
</div>
<div v-if="activeAnnotation.rule_description" class="config-row">
<span class="sub-label">规则描述:</span>
<span>{{ activeAnnotation.rule_description }}</span>
</div>
<div v-if="activeAnnotation.rule_prompt_template" class="config-prompt">
<div class="prompt-header">
<span class="sub-label">提示词:</span>
<ElButton link size="small" :icon="View" @click="showRuleDetail">查看完整提示词</ElButton>
</div>
<pre>{{ truncatePrompt(activeAnnotation.rule_prompt_template) }}</pre>
</div>
</div>
<!-- 关联变量绑定(只读) -->
<div class="config-section">
<div class="config-label">关联变量绑定</div>
<div v-if="promptVariables.length === 0" class="no-var-hint">
该规则提示词中暂无变量
</div>
<div v-else class="var-bind-list">
<div
v-for="(varName, idx) in promptVariables"
:key="varName"
class="var-bind-row"
>
<span class="var-index">{{ idx + 1 }}</span>
<span class="var-name">{{ varName }}</span>
<div class="var-tags">
<template v-if="variableBindings[varName]?.length">
<ElTag
v-for="cn in variableBindings[varName]"
:key="cn"
size="small"
type="info"
effect="plain"
class="var-tag"
>
{{ cn }}
</ElTag>
</template>
<span v-else class="no-bind">未绑定</span>
</div>
</div>
</div>
</div>
<div class="config-footer">
<ElButton @click="switchPanel('list')">关闭</ElButton>
</div>
</div>
</aside>
</div>
<!-- 规则详情弹窗 -->
<ElDialog
v-model="ruleDetailVisible"
title="规则详情"
width="600px"
destroy-on-close
>
<el-descriptions v-if="activeAnnotation" :column="1" border label-width="120px">
<el-descriptions-item label="规则名称">{{ activeAnnotation.rule_name }}</el-descriptions-item>
<el-descriptions-item label="规则分类">
{{ activeAnnotation.rule_category_name || "-" }}
</el-descriptions-item>
<el-descriptions-item label="规则描述">{{ activeAnnotation.rule_description || "-" }}</el-descriptions-item>
<el-descriptions-item label="提示词模板">
<pre class="detail-prompt">{{ activeAnnotation.rule_prompt_template }}</pre>
</el-descriptions-item>
</el-descriptions>
<template #footer>
<ElButton type="primary" @click="ruleDetailVisible = false">关闭</ElButton>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "EnergyReportTemplateDetail" });
import { ref, reactive, computed, onMounted, nextTick } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ArrowLeft, Edit, CollectionTag, View } from "@element-plus/icons-vue";
import FaDocxPreview from "@/components/business/fa-docx-preview/index.vue";
import type { DocxOutlineItem } from "@/components/business/fa-docx-preview/useDocxOutline";
import { ReportTemplateAPI, ChapterAnnotationAPI } from "@/api/module_report/report";
import type {
ReportTemplateItem,
ChapterAnnotationItem,
ChapterVariableDetail,
ReportType,
} from "@/api/module_report/report";
import { RuleAPI } from "@/api/module_rule/rule";
import type { RuleItem } from "@/api/module_rule/rule";
import { extractPromptVariables } from "../promptVars";
const route = useRoute();
const router = useRouter();
const templateId = computed(() => {
const v = route.query.id;
const n = Number(v);
return Number.isFinite(n) && n > 0 ? n : undefined;
});
/** 报告类型1 能源报告 / 2 光伏报告(由路由参数传入) */
const reportType = computed<ReportType>(() => {
const t = route.query.report_type;
return t === "2" ? 2 : 1;
});
const loading = ref(false);
const detailLoading = ref(false);
const template = ref<ReportTemplateItem | null>(null);
const docxSrc = ref<string>("");
const previewRef = ref<InstanceType<typeof FaDocxPreview> | null>(null);
const outlineItems = ref<DocxOutlineItem[]>([]);
const annotations = ref<ChapterAnnotationItem[]>([]);
const activeId = ref<number | undefined>(undefined);
/** 当前查看的标注(含懒加载的规则详情) */
interface ActiveAnnotation {
id: number;
chapter_name: string;
rule_id: number;
rule_name?: string;
rule_category_name?: string;
rule_description?: string;
rule_prompt_template?: string;
}
const rightPanel = ref<"list" | "detail">("list");
const activeAnnotation = ref<ActiveAnnotation | null>(null);
/** 变量绑定variable_name → chapter_names 数组(只读展示) */
const variableBindings = reactive<Record<string, string[]>>({});
const promptVariables = computed(() => {
if (!activeAnnotation.value?.rule_prompt_template) return [];
return extractPromptVariables(activeAnnotation.value.rule_prompt_template);
});
const ruleDetailVisible = ref(false);
async function loadTemplate() {
if (!templateId.value) return;
loading.value = true;
try {
const resp = await ReportTemplateAPI.detail(templateId.value, reportType.value);
const data = resp?.data?.data;
if (data) {
template.value = data;
docxSrc.value = data.file_url || "";
}
} catch (e) {
template.value = null;
} finally {
loading.value = false;
}
}
async function loadAnnotations() {
if (!templateId.value) return;
try {
const resp = await ChapterAnnotationAPI.listByTemplate(templateId.value, reportType.value);
const list = resp?.data?.data;
annotations.value = Array.isArray(list) ? list : [];
} catch (e) {
annotations.value = [];
}
}
function handlePreviewReady(payload: { outline: DocxOutlineItem[] }) {
outlineItems.value = payload.outline;
}
function handleHeadingClick(item: DocxOutlineItem) {
const exist = annotations.value.find((a) => a.chapter_name === item.text);
if (exist) {
focusAnnotation(exist);
}
}
/** 通过章节名称查找大纲项 id用于滚动定位 */
function findOutlineIdByChapterName(name: string): string | undefined {
return outlineItems.value.find((i) => i.text === name)?.id;
}
/** 点击标注卡片:加载规则详情和变量绑定,切换到详情视图 */
async function focusAnnotation(ann: ChapterAnnotationItem) {
activeId.value = ann.id;
activeAnnotation.value = {
id: ann.id,
chapter_name: ann.chapter_name,
rule_id: ann.rule_id,
rule_name: ann.rule_name,
};
rightPanel.value = "detail";
detailLoading.value = true;
// 滚动到对应章节
const outlineId = findOutlineIdByChapterName(ann.chapter_name);
if (outlineId) {
nextTick(() => {
previewRef.value?.scrollToHeading(outlineId);
});
}
try {
// 并行加载变量绑定和规则详情
const [varResp, ruleResp] = await Promise.all([
ChapterAnnotationAPI.getVariableConfig(ann.id),
RuleAPI.detail(ann.rule_id),
]);
const varData: ChapterVariableDetail | undefined = varResp?.data?.data;
const ruleData: RuleItem | undefined = ruleResp?.data?.data;
// 更新当前标注的规则详情
if (ruleData && activeAnnotation.value?.id === ann.id) {
activeAnnotation.value = {
...activeAnnotation.value,
rule_name: ruleData.name,
rule_category_name: ruleData.category_name || ruleData.category_name_snapshot,
rule_description: ruleData.description,
rule_prompt_template: ruleData.prompt_template,
};
}
// 合并变量:从前端提取的完整变量列表 + 后端返回的已绑定变量
for (const key of Object.keys(variableBindings)) delete variableBindings[key];
const allVars = extractPromptVariables(ruleData?.prompt_template || "");
const apiVars = varData?.variables || [];
for (const v of allVars) {
const apiVar = apiVars.find((av) => av.variable_name === v);
variableBindings[v] = apiVar?.chapter_names ? [...apiVar.chapter_names] : [];
}
} catch (e) {
// 错误由全局拦截器处理
} finally {
detailLoading.value = false;
}
}
function switchPanel(p: "list" | "detail") {
rightPanel.value = p;
if (p === "list") {
activeAnnotation.value = null;
}
}
function showRuleDetail() {
ruleDetailVisible.value = true;
}
function truncatePrompt(text: string): string {
if (!text) return "";
if (text.length <= 300) return text;
return text.slice(0, 300) + "...";
}
function goEdit() {
if (templateId.value) {
router.push({ name: "ReportTemplateEdit", query: { id: templateId.value, report_type: reportType.value } });
}
}
function goBack() {
router.back();
// router.push({ path: "/report/template", query: { report_type: reportType.value } });
}
onMounted(async () => {
await loadTemplate();
await loadAnnotations();
});
</script>
<style lang="scss" scoped>
.template-detail-page {
display: flex;
flex-direction: column;
height: 100%;
background: #f5f6f8;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: #fff;
border-bottom: 1px solid #e5e7eb;
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 4px;
}
.page-title {
font-size: 15px;
font-weight: 600;
color: #111827;
margin-left: 8px;
}
.readonly-tag {
margin-left: 8px;
}
.page-body {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
}
.preview-wrapper {
flex: 1;
min-width: 0;
height: 100%;
overflow: hidden;
background: #fff;
border-right: 1px solid #e5e7eb;
}
.preview-loading {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.right-panel {
width: 340px;
min-width: 340px;
background: #fff;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.panel-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px;
}
.panel-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: #111827;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #f3f4f6;
}
.panel-count {
font-size: 12px;
font-weight: 400;
color: #9ca3af;
}
.empty-panel {
padding: 40px 0;
}
.annotation-card-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.annotation-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px;
cursor: pointer;
transition: all 0.15s;
&:hover {
border-color: #409eff;
background: #f9fbff;
}
&.active {
border-color: #409eff;
background: #ecf5ff;
}
}
.card-row {
display: flex;
gap: 8px;
align-items: flex-start;
font-size: 13px;
margin-bottom: 6px;
}
.label {
color: #6b7280;
flex-shrink: 0;
min-width: 52px;
}
.chapter-link {
color: #111827;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.rule-link {
color: #409eff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.card-desc {
font-size: 12px;
color: #6b7280;
line-height: 1.5;
padding: 6px 8px;
background: #f9fafb;
border-radius: 4px;
}
.desc-text {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ============ 标注详情面板(只读) ============ */
.config-title {
justify-content: flex-start;
}
.selected-heading {
display: flex;
align-items: center;
gap: 6px;
}
.heading-text {
font-weight: 600;
color: #409eff;
}
.config-section {
margin-bottom: 16px;
}
.config-label {
font-size: 13px;
font-weight: 600;
color: #111827;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.rule-name {
color: #409eff;
font-weight: 600;
}
.config-row {
font-size: 13px;
color: #4b5563;
margin-bottom: 6px;
line-height: 1.6;
}
.sub-label {
color: #6b7280;
margin-right: 4px;
}
.config-prompt {
background: #f9fafb;
border-radius: 6px;
padding: 10px;
margin-top: 8px;
}
.prompt-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.config-prompt pre {
margin: 0;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 12px;
line-height: 1.6;
color: #374151;
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow: auto;
}
.no-var-hint {
font-size: 12px;
color: #9ca3af;
text-align: center;
padding: 16px 0;
}
.var-bind-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.var-bind-row {
display: flex;
align-items: flex-start;
gap: 8px;
}
.var-index {
width: 22px;
height: 22px;
border-radius: 50%;
background: #eff6ff;
color: #409eff;
font-size: 12px;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.var-name {
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 12px;
color: #059669;
background: #ecfdf5;
padding: 2px 6px;
border-radius: 3px;
min-width: 80px;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
margin-top: 1px;
}
.var-tags {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
min-width: 0;
}
.var-tag {
max-width: 100%;
}
.no-bind {
font-size: 12px;
color: #c0c4cc;
}
.config-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #f3f4f6;
}
.detail-prompt {
white-space: pre-wrap;
word-break: break-word;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 12px;
line-height: 1.6;
margin: 0;
padding: 8px;
background: #f9fafb;
border-radius: 4px;
max-height: 260px;
overflow: auto;
}
</style>