Merge remote-tracking branch 'origin/master'

master
HuangHuiKang 16 hours ago
commit ecb9c993c6

@ -9,7 +9,7 @@
name="keywords"
content="vue,element-plus,typescript,vue-element-admin,vue3-element-admin"
/>
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico" />
<link rel="shortcut icon" type="image/x-icon" href="/logo.png" />
<style>
/* 防止 Vue 加载前出现白屏:背景色与主题初始 class 同步 */

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

File diff suppressed because it is too large Load Diff

@ -0,0 +1,77 @@
// 通用文件上传接口
import { request } from "@utils";
const FILE_PATH = "/common/file";
export const FileAPI = {
/**
*
* @param file
* @param business_type energy_report_template
* @param options query
*/
upload(
file: File,
business_type = "energy_report_template",
options?: { upload_type?: string; business_id?: number }
) {
const formData = new FormData();
formData.append("file", file);
const params: Record<string, any> = {
business_type,
upload_type: options?.upload_type || "file",
};
if (options?.business_id) {
params.business_id = options.business_id;
}
return request<ApiResponse<FileInfo>>({
url: `${FILE_PATH}/upload`,
method: "post",
data: formData,
params,
headers: { "Content-Type": "multipart/form-data" },
});
},
/** 生成文件预签名访问地址(私有桶使用) */
presigned(id: number) {
return request<ApiResponse<string>>({
url: `${FILE_PATH}/presigned/${id}`,
method: "get",
});
},
/** 删除文件记录(可选同时删 MinIO 对象) */
remove(ids: number[], delete_object = false) {
return request<ApiResponse>({
url: `${FILE_PATH}/delete`,
method: "delete",
data: ids,
params: delete_object ? { delete_object: true } : undefined,
});
},
};
export default FileAPI;
// ============ 类型定义 ============
/** 文件记录对象common_file */
export interface FileInfo {
id: number;
uuid?: string;
storage_type?: string;
bucket_name?: string;
object_name?: string;
original_name?: string;
origin_name?: string;
file_name?: string;
file_ext?: string;
content_type?: string;
file_size?: number;
file_path?: string;
file_url?: string;
upload_type?: string;
business_type?: string;
business_id?: number;
}

@ -0,0 +1,240 @@
// 报告模块接口(能源报告 / 光伏报告)
// 包含报告模板CRUD、章节标注与变量绑定
import { request } from "@utils";
import { FileAPI } from "@/api/common/file";
export type { FileInfo } from "@/api/common/file";
const TEMPLATE_PATH = "/attachment-template";
/** 报告类型1 能源报告 / 2 光伏报告 */
export type ReportType = 1 | 2;
// ============ 报告模板 ============
const ReportTemplateAPI = {
/** 查询模板分页列表 */
page(query?: ReportTemplatePageQuery) {
return request<ApiResponse<PageResult<ReportTemplateItem>>>({
url: `${TEMPLATE_PATH}/list`,
method: "get",
params: query,
});
},
/** 查询模板详情 */
detail(id: number, report_type?: ReportType) {
return request<ApiResponse<ReportTemplateItem>>({
url: `${TEMPLATE_PATH}/detail/${id}`,
method: "get",
params: { report_type },
});
},
/** 创建模板 */
create(body: ReportTemplateCreateForm) {
return request<ApiResponse<ReportTemplateItem>>({
url: `${TEMPLATE_PATH}/create`,
method: "post",
data: body,
});
},
/** 修改模板 */
update(id: number, body: ReportTemplateUpdateForm) {
return request<ApiResponse<ReportTemplateItem>>({
url: `${TEMPLATE_PATH}/update/${id}`,
method: "put",
data: body,
});
},
/** 删除模板(传 ID 数组) */
remove(ids: number[], report_type?: ReportType) {
return request<ApiResponse>({
url: `${TEMPLATE_PATH}/delete`,
method: "delete",
data: ids,
params: { report_type },
});
},
};
// ============ 章节标注与变量绑定 ============
const CHAPTER_PATH = "/chapter";
const CONFIG_PATH = "/attachment-template/config/chapter";
const VARIABLE_PATH = "/variable/chapter";
const ChapterAnnotationAPI = {
/** 查询模板下全部章节规则标注 */
listByTemplate(templateId: number, report_type?: ReportType) {
return request<ApiResponse<ChapterAnnotationItem[]>>({
url: `${CHAPTER_PATH}/list_by_template`,
method: "get",
params: { template_id: templateId, report_type },
});
},
/** 点击标注查看变量绑定 */
getVariableConfig(chapterId: number) {
return request<ApiResponse<ChapterVariableDetail>>({
url: `${VARIABLE_PATH}/${chapterId}`,
method: "get",
});
},
/** 新增单个章节标注并保存变量 */
create(body: ChapterConfigForm) {
return request<ApiResponse<ChapterVariableDetail>>({
url: `${CONFIG_PATH}/create`,
method: "post",
data: body,
});
},
/** 更新单个章节标注并保存变量 */
update(chapterId: number, body: ChapterConfigForm) {
return request<ApiResponse<ChapterVariableDetail>>({
url: `${CONFIG_PATH}/update/${chapterId}`,
method: "put",
data: body,
});
},
/** 删除章节规则标注(传 ID 数组) */
remove(ids: number[]) {
return request<ApiResponse>({
url: `${CHAPTER_PATH}/delete`,
method: "delete",
data: ids,
});
},
};
export { FileAPI, ReportTemplateAPI, ChapterAnnotationAPI };
export default { FileAPI, ReportTemplateAPI, ChapterAnnotationAPI };
// ============ 类型定义 ============
/** 模板状态0 启用 / 1 停用 */
export type ReportTemplateStatus = 0 | 1;
/** 模板分页查询参数 */
export interface ReportTemplatePageQuery {
page_no?: number;
page_size?: number;
order_by?: string;
template_name?: string;
template_type?: string;
version?: string;
status?: ReportTemplateStatus;
report_type?: ReportType;
created_time?: string[];
updated_time?: string[];
created_id?: number;
updated_id?: number;
tenant_id?: number;
}
/** 模板对象(后端返回) */
export interface ReportTemplateItem {
id: number;
uuid?: string;
template_name: string;
template_type: string;
version?: string;
file_id: number;
status: ReportTemplateStatus;
report_type?: ReportType;
order?: number;
description?: string;
/** 文件记录对象 */
file?: import("@/api/common/file").FileInfo;
/** 文件访问地址,可直接用于下载 */
file_url?: string;
/** 原始文件名 */
original_name?: string;
/** MinIO 对象名称 */
object_name?: string;
/** 标注数量(接口暂未返回,后续补充) */
annotation_count?: number;
tenant_id?: number;
created_id?: number;
created_by?: CommonType;
updated_id?: number;
updated_by?: CommonType;
created_time?: string;
updated_time?: string;
is_deleted?: boolean;
}
/** 创建模板表单 */
export interface ReportTemplateCreateForm {
template_name: string;
template_type?: string;
version?: string;
file_id: number;
status?: ReportTemplateStatus;
report_type?: ReportType;
order?: number;
description?: string;
}
/** 修改模板表单 */
export interface ReportTemplateUpdateForm extends ReportTemplateCreateForm {}
// ============ 章节标注与变量绑定类型 ============
/** 章节规则标注(列表项) */
export interface ChapterAnnotationItem {
id: number;
template_id: number;
chapter_name: string;
chapter_order?: number;
rule_id: number;
rule_name?: string;
description?: string;
}
/** 变量绑定中的章节信息 */
export interface VariableChapterInfo {
chapter_name: string;
annotation_ids: number[];
rules: { chapter_annotation_id: number; rule_id: number; rule_name: string }[];
}
/** 变量绑定项 */
export interface ChapterVariableItem {
variable_name: string;
chapter_names: string[];
chapters?: VariableChapterInfo[];
checked: boolean;
}
/** 点击标注查看变量绑定的返回结构 */
export interface ChapterVariableDetail {
chapter_annotation: {
id: number;
chapter_name: string;
rule_id: number;
rule_name?: string;
};
variables: ChapterVariableItem[];
}
/** 创建/更新章节标注的请求体 */
export interface ChapterConfigForm {
template_id: number;
chapters: {
id?: number;
chapter_name: string;
chapter_order?: number;
rule_id: number;
description?: string;
}[];
variables: {
variable_name: string;
chapter_names: string[];
checked?: boolean;
}[];
}

@ -0,0 +1,246 @@
// 报告审核任务接口(能源报告 / 光伏报告)
import { request } from "@utils";
import type { ReportType } from "@/api/module_report/report";
const TASK_PATH = "/report-review";
const LOG_PATH = "/report-review/log";
const RESULT_PATH = "/report-review/result";
// ============ 审核任务 ============
export const ReviewTaskAPI = {
/** 查询审核任务分页列表(返回 items + stats 统计) */
page(query?: ReviewTaskPageQuery) {
return request<ApiResponse<ReviewTaskListResult>>({
url: `${TASK_PATH}/list`,
method: "get",
params: query,
});
},
/** 查询任务详情 */
detail(id: number, report_type?: ReportType) {
return request<ApiResponse<ReviewTaskItem>>({
url: `${TASK_PATH}/detail/${id}`,
method: "get",
params: { report_type },
});
},
/** 创建审核任务 */
create(body: ReviewTaskCreateForm) {
return request<ApiResponse<ReviewTaskItem>>({
url: `${TASK_PATH}/create`,
method: "post",
data: body,
});
},
/** 修改审核任务 */
update(id: number, body: ReviewTaskUpdateForm) {
return request<ApiResponse<ReviewTaskItem>>({
url: `${TASK_PATH}/update/${id}`,
method: "put",
data: body,
});
},
/** 删除审核任务(传 ID 数组) */
remove(ids: number[], report_type?: ReportType) {
return request<ApiResponse>({
url: `${TASK_PATH}/delete`,
method: "delete",
data: ids,
params: { report_type },
});
},
// ============ 审核相关(接口暂未提供,预留) ============
/** 开始审核SSE 进度流,接口预留) */
startReview(id: number, report_type?: ReportType) {
return request<ApiResponse<void>>({
url: `${TASK_PATH}/start/${id}`,
method: "post",
params: { report_type },
});
},
// ============ 操作日志 ============
/** 查询操作记录列表 */
listLogs(params: ReviewLogPageQuery) {
return request<ApiResponse<PageResult<ReviewLogItem>>>({
url: `${LOG_PATH}/list`,
method: "get",
params,
});
},
/** 查询操作记录详情 */
detailLog(id: number) {
return request<ApiResponse<ReviewLogItem>>({
url: `${LOG_PATH}/detail/${id}`,
method: "get",
});
},
/** 创建操作记录(前端在审查动作时主动调用) */
createLog(body: ReviewLogCreateForm) {
return request<ApiResponse<ReviewLogItem>>({
url: `${LOG_PATH}/create`,
method: "post",
data: body,
});
},
// ============ 审核结果(接口暂未提供,预留) ============
/** 获取审核结果 markdown 文件内容 */
getResult(id: number, report_type?: ReportType) {
return request<ApiResponse<ReviewResult>>({
url: `${RESULT_PATH}/detail/${id}`,
method: "get",
params: { report_type },
});
},
/** 导出审核结果 */
exportResult(id: number, report_type?: ReportType) {
return request({
url: `${RESULT_PATH}/export/${id}`,
method: "get",
responseType: "blob",
params: { report_type },
});
},
};
export default ReviewTaskAPI;
// ============ 类型定义 ============
/** 审核状态0 未审查 / 1 审查中 / 2 审查完成 / 3 审查失败 */
export type ReviewStatus = 0 | 1 | 2 | 3;
/** 审核任务分页查询参数 */
export interface ReviewTaskPageQuery {
page_no?: number;
page_size?: number;
task_name?: string;
status?: ReviewStatus | "";
report_type?: ReportType;
reviewer_id?: number;
reviewer_name?: string;
created_time?: string[];
updated_time?: string[];
}
/** 审核任务列表项 */
export interface ReviewTaskItem {
id: number;
task_name: string;
template_id?: number;
template_name?: string;
file_id: number;
/** 文档访问地址,可直接用于预览/下载 */
file_url?: string;
original_name?: string;
file_ext?: string;
status: ReviewStatus;
report_type?: ReportType;
/** 审核进度0-100由 SSE 推送更新 */
progress?: number;
reviewer_id?: number;
reviewer_name?: string;
description?: string;
remark?: string;
start_time?: string;
end_time?: string;
created_time?: string;
updated_time?: string;
}
/** 创建审核任务表单 */
export interface ReviewTaskCreateForm {
task_name: string;
template_id?: number;
file_id: number;
report_type?: ReportType;
description?: string;
}
/** 修改审核任务表单 */
export interface ReviewTaskUpdateForm extends ReviewTaskCreateForm {}
/** 任务统计(列表接口随 items 一起返回) */
export interface ReviewTaskStatistics {
submitted_count?: number;
reviewed_count?: number;
pending_count?: number;
failed_count?: number;
}
/** 列表接口返回结构PageResult + stats */
export interface ReviewTaskListResult extends PageResult<ReviewTaskItem> {
stats?: ReviewTaskStatistics;
}
// ============ 操作日志类型 ============
/** 操作日志动作类型 */
export type ReviewLogActionType =
| "create_task"
| "start_review"
| "review_pass"
| "review_reject";
/** 操作记录分页查询参数 */
export interface ReviewLogPageQuery {
page_no?: number;
page_size?: number;
order_by?: string;
report_review_id?: number;
task_name?: string;
action_type?: ReviewLogActionType;
report_type?: ReportType;
created_time?: string[];
created_id?: number;
}
/** 操作日志列表项 */
export interface ReviewLogItem {
id: number;
report_review_id: number;
task_name?: string;
action_type: ReviewLogActionType;
action_name: string;
description?: string;
operator_id?: number;
operator_name?: string;
created_id?: number;
created_by?: CommonType;
created_time?: string;
}
/** 创建操作记录表单(操作人由后端自动写入) */
export interface ReviewLogCreateForm {
report_review_id: number;
action_type: ReviewLogActionType;
description?: string;
}
// ============ 审核结果类型(预留) ============
/** 审核结果 */
export interface ReviewResult {
id: number;
report_review_id: number;
/** markdown 内容 */
md_content?: string;
/** markdown 文件访问地址 */
md_file_url?: string;
/** 审核通过/驳回 */
result_status?: "pass" | "reject";
created_time?: string;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

@ -12,7 +12,7 @@
</template>
<script setup lang="ts">
import defaultLogoUrl from "@fa_imgs/logo.svg";
import defaultLogoUrl from "@fa_imgs/nwlogo.png";
defineOptions({ name: "FaLogo" });

@ -3,7 +3,7 @@
<div id="app-scroll-main" class="layout-content" :style="containerStyle">
<div id="app-content-header">
<!-- 节日滚动 -->
<FaFestivalTextScroll />
<!-- <FaFestivalTextScroll /> -->
<!-- 路由信息调试 -->
<div

@ -4,7 +4,7 @@
class="auth-top-bar pointer-events-none fixed left-0 right-0 top-0 z-100 flex items-center justify-between gap-3 bg-transparent px-5 py-4.5 md:gap-4 md:px-10"
>
<div class="pointer-events-auto flex min-w-0 flex-1 items-center gap-3">
<FaLogo class="icon shrink-0" size="46" :src="webLogoSrc" />
<FaLogo class="icon shrink-0" size="100" :src="webLogoSrc" />
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h1 class="auth-top-bar__site-title">{{ siteTitle }}</h1>

@ -238,7 +238,7 @@ export const useUserStore = defineStore(
const response = await UserAPI.getCurrentUserInfo();
const data = response.data.data;
const menus: MenuTable[] = data?.menus || [];
delete data?.menus;
// delete data?.menus;
info.value = { ...info.value, ...data } as Partial<UserInfo>;
setRoute(menus);
} catch (error) {

@ -67,13 +67,38 @@ export const getFirstMenuPath = (menuList: AppRouteRecordFromTypes[]): string =>
return "";
};
/** 在菜单树中递归查找 route_name 匹配的菜单项 */
const findMenuByRouteName = (menus: any[], routeName: string): any | null => {
for (const menu of menus) {
if (menu.route_name === routeName) return menu;
if (menu.children?.length) {
const found = findMenuByRouteName(menu.children, routeName);
if (found) return found;
}
}
return null;
};
export const openExternalLink = (link: string) => window.open(link, "_blank");
export const handleMenuJump = (item: AppRouteRecord, jumpToFirst: boolean = false) => {
export const handleMenuJump = async (item: AppRouteRecord, jumpToFirst: boolean = false) => {
// 在其他函数内部动态获取,避免模块加载时的循环依赖
const { useUserStoreHook } = await import("@stores");
const userStore = useUserStoreHook();
// 从菜单树中查找当前 item.name对应 route_name的菜单项提取 params 转为 query 参数
const targetMenu = findMenuByRouteName(userStore.info.menus || [], item.name as string);
const query: Record<string, string> = {};
if (targetMenu?.params) {
for (const p of targetMenu.params) {
query[p.key] = p.value;
}
}
const { link, isIframe: menuIsIframe } = item.meta;
if (link && !menuIsIframe) return openExternalLink(link);
if (!jumpToFirst || !item.children?.length) return router.push(item.path);
if (!jumpToFirst || !item.children?.length) return router.push({ path: item.path, query });
const findFirstLeafMenu = (items: AppRouteRecord[]): AppRouteRecord | undefined => {
for (const child of items) {
@ -85,9 +110,9 @@ export const handleMenuJump = (item: AppRouteRecord, jumpToFirst: boolean = fals
};
const firstChild = findFirstLeafMenu(item.children);
if (!firstChild) return router.push(item.path);
if (!firstChild) return router.push({ path: item.path, query });
if (firstChild.meta?.link) return openExternalLink(firstChild.meta.link);
return router.push(firstChild.path);
return router.push({ path: firstChild.path, query });
};
/**

@ -0,0 +1,26 @@
// 提示词模板变量解析工具
// 支持形如 ${variable_name} 或 ${{ variable_name }} 的占位符
/**
*
*
* - ${xxx}
* - ${{ xxx }}
*
*/
export function extractPromptVariables(prompt: string): string[] {
if (!prompt) return [];
const vars: string[] = [];
const set = new Set<string>();
// 同时匹配 ${var} 和 ${{ var }}
const re = /\$\{\s*([^{}]+?)\s*\}/g;
let m: RegExpExecArray | null;
while ((m = re.exec(prompt)) !== null) {
const name = m[1]?.trim();
if (name && !set.has(name)) {
set.add(name);
vars.push(name);
}
}
return vars;
}

@ -0,0 +1,61 @@
<template>
<ElDialog
:model-value="visible"
:title="title"
width="80%"
top="4vh"
:close-on-click-modal="false"
append-to-body
destroy-on-close
class="doc-preview-dialog"
@update:model-value="(v: boolean) => emit('update:visible', v)"
>
<div class="preview-body">
<FaDocxPreview
v-if="fileUrl"
:src="fileUrl"
:readonly="true"
class="preview-no-outline"
/>
</div>
</ElDialog>
</template>
<script setup lang="ts">
import { computed } from "vue";
import FaDocxPreview from "@/components/business/fa-docx-preview/index.vue";
defineOptions({ name: "DocPreviewDialog" });
const props = defineProps<{
visible: boolean;
title?: string;
fileUrl?: string;
}>();
const emit = defineEmits<{
"update:visible": [v: boolean];
}>();
const title = computed(() => props.title || "文档预览");
</script>
<style lang="scss" scoped>
:deep(.doc-preview-dialog .el-dialog__body) {
padding: 0;
overflow: hidden;
}
.preview-body {
height: calc(100vh - 180px);
background: #f5f7fa;
}
/* 隐藏左侧大纲栏和展开按钮,仅保留文档预览区域 */
// .preview-body :deep(.outline-sidebar) {
// display: none;
// }
// .preview-body :deep(.outline-expand-btn) {
// display: none;
// }
</style>

@ -0,0 +1,224 @@
<template>
<ElDrawer
:model-value="visible"
title="操作日志"
direction="rtl"
size="460px"
append-to-body
@update:model-value="(v: boolean) => emit('update:visible', v)"
>
<template v-if="taskId">
<!-- 任务简要信息 -->
<div v-if="task" class="task-summary">
<ElDescriptions :column="1" size="small" border>
<ElDescriptionsItem label="任务名称">{{ task.task_name }}</ElDescriptionsItem>
<ElDescriptionsItem label="任务编号">
#{{ task.id }}
</ElDescriptionsItem>
</ElDescriptions>
</div>
<!-- 操作记录 -->
<div class="log-section">
<h4 class="section-title">操作记录</h4>
<ElTimeline v-loading="loading" class="log-timeline">
<ElTimelineItem
v-for="log in logList"
:key="log.id"
:timestamp="formatDateTime(log.created_time)"
placement="top"
:type="logType(log.action_type)"
:hollow="log.action_type === 'start_review'"
>
<div class="log-card">
<div class="log-header">
<span class="log-action">{{ log.action_name }}</span>
<ElTag
size="small"
:type="logTagType(log.action_type)"
effect="light"
>
{{ logStatusText(log.action_type) }}
</ElTag>
</div>
<div class="log-operator">
<ElIcon><User /></ElIcon>
{{ log.operator_name || "系统" }}
</div>
<div v-if="log.description" class="log-desc">
{{ log.description }}
</div>
</div>
</ElTimelineItem>
<div v-if="!loading && logList.length === 0" class="empty-logs">
暂无操作记录
</div>
</ElTimeline>
</div>
</template>
</ElDrawer>
</template>
<script setup lang="ts">
import { ref, watch, computed } from "vue";
import { useRoute } from "vue-router";
import { User } from "@element-plus/icons-vue";
import { ReviewTaskAPI } from "@/api/module_report/review";
import type { ReviewTaskItem, ReviewLogItem, ReviewLogActionType } from "@/api/module_report/review";
import type { ReportType } from "@/api/module_report/report";
defineOptions({ name: "LogDrawer" });
const props = defineProps<{
visible: boolean;
taskId?: number | null;
task?: ReviewTaskItem | null;
}>();
const route = useRoute();
/** 报告类型1 能源报告 / 2 光伏报告(由路由参数传入) */
const reportType = computed<ReportType>(() => {
const t = route.query.report_type;
return t === "2" ? 2 : 1;
});
const emit = defineEmits<{
"update:visible": [v: boolean];
}>();
const loading = ref(false);
const logList = ref<ReviewLogItem[]>([]);
watch(
() => [props.visible, props.taskId] as const,
([v, id]) => {
if (v && id) {
loadLogs(id);
}
},
{ immediate: true }
);
async function loadLogs(taskId: number) {
loading.value = true;
logList.value = [];
try {
const resp = await ReviewTaskAPI.listLogs({
report_review_id: taskId,
page_no: 1,
page_size: 100,
});
logList.value = resp?.data?.data?.items ?? [];
} catch (e) {
logList.value = [];
} finally {
loading.value = false;
}
}
function formatDateTime(value?: string): string {
if (!value) return "-";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function logType(t: ReviewLogActionType): "primary" | "success" | "warning" | "danger" {
switch (t) {
case "create_task": return "primary";
case "start_review": return "warning";
case "review_pass": return "success";
case "review_reject": return "danger";
default: return "primary";
}
}
function logTagType(t: ReviewLogActionType): "primary" | "success" | "warning" | "danger" | "info" {
switch (t) {
case "create_task": return "primary";
case "start_review": return "warning";
case "review_pass": return "success";
case "review_reject": return "danger";
default: return "info";
}
}
function logStatusText(t: ReviewLogActionType): string {
switch (t) {
case "create_task": return "已创建";
case "start_review": return "进行中";
case "review_pass": return "已完成";
case "review_reject": return "已驳回";
default: return t;
}
}
</script>
<style lang="scss" scoped>
.task-summary {
margin-bottom: 16px;
}
.log-section {
.section-title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
color: #303133;
padding-left: 8px;
border-left: 3px solid #409eff;
}
}
.log-card {
background: #f9fafb;
border-radius: 6px;
padding: 10px 12px;
border: 1px solid #f0f0f0;
}
.log-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.log-action {
font-weight: 600;
font-size: 14px;
color: #303133;
}
.log-operator {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #909399;
margin-bottom: 4px;
}
.log-desc {
font-size: 12px;
color: #606266;
line-height: 1.6;
background: #fff;
padding: 6px 8px;
border-radius: 4px;
}
.empty-logs {
text-align: center;
color: #909399;
font-size: 13px;
padding: 24px 0;
}
:deep(.el-timeline-item__timestamp) {
color: #909399;
font-size: 12px;
}
</style>

@ -0,0 +1,340 @@
<template>
<ElDialog
:model-value="visible"
:title="isEdit ? '编辑审查任务' : '新增审查任务'"
width="620px"
:close-on-click-modal="false"
append-to-body
destroy-on-close
@update:model-value="(v: boolean) => emit('update:visible', v)"
>
<ElForm
ref="formRef"
:model="form"
:rules="rules"
label-width="90px"
>
<ElFormItem label="任务名称" prop="task_name" required>
<ElInput v-model="form.task_name" placeholder="请输入任务名称" :maxlength="128" />
</ElFormItem>
<ElFormItem label="选用模板" prop="template_id">
<ElSelect
v-model="form.template_id"
placeholder="请选择文档模板(可选)"
clearable
filterable
class="full-width"
:loading="templatesLoading"
@visible-change="onTemplateDropdownVisible"
>
<ElOption
v-for="t in templates"
:key="t.id"
:label="t.template_name"
:value="t.id"
/>
<template #footer>
<div class="template-select-footer">
<ElButton
link
:disabled="templatePageNo <= 1 || templatesLoading"
@click="loadTemplates(templatePageNo - 1)"
>
上一页
</ElButton>
<span class="page-info"> {{ templatePageNo }} </span>
<ElButton
link
:disabled="!templateHasNext || templatesLoading"
@click="loadTemplates(templatePageNo + 1)"
>
下一页
</ElButton>
</div>
</template>
</ElSelect>
</ElFormItem>
<ElFormItem label="上传文档" prop="file" required>
<ElUpload
ref="uploadRef"
:auto-upload="false"
:limit="1"
accept=".docx"
:file-list="fileList"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
:on-exceed="handleExceed"
drag
>
<ElIcon class="el-icon--upload"><UploadFilled /></ElIcon>
<div class="el-upload__text">
将文件拖到此处<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
<span v-if="isEdit && form.file_id && !form._newFile">
当前文件{{ currentFileName }}上传新文件将替换
</span>
<span v-else> .docx 20MB</span>
</div>
</template>
</ElUpload>
</ElFormItem>
<ElFormItem label="备注" prop="description">
<ElInput
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入备注信息(可选)"
:maxlength="500"
show-word-limit
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="emit('update:visible', false)">取消</ElButton>
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
{{ isEdit ? '保存' : '确定' }}
</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from "vue";
import { useRoute } from "vue-router";
import { ElMessage, type FormInstance, type FormRules, type UploadFile, type UploadFiles, type UploadInstance } from "element-plus";
import { UploadFilled } from "@element-plus/icons-vue";
import { FileAPI } from "@/api/common/file";
import { ReviewTaskAPI } from "@/api/module_report/review";
import { ReportTemplateAPI } from "@/api/module_report/report";
import type { ReviewTaskItem, ReviewTaskCreateForm, ReviewTaskUpdateForm } from "@/api/module_report/review";
import type { ReportTemplateItem, ReportType } from "@/api/module_report/report";
defineOptions({ name: "ReviewFormDialog" });
const props = defineProps<{
visible: boolean;
editData?: ReviewTaskItem | null;
}>();
const route = useRoute();
/** 报告类型1 能源报告 / 2 光伏报告(由路由参数传入) */
const reportType = computed<ReportType>(() => {
const t = route.query.report_type;
return t === "2" ? 2 : 1;
});
const emit = defineEmits<{
"update:visible": [v: boolean];
success: [];
}>();
const formRef = ref<FormInstance | null>(null);
const uploadRef = ref<UploadInstance | null>(null);
const submitting = ref(false);
const templatesLoading = ref(false);
const templates = ref<ReportTemplateItem[]>([]);
const fileList = ref<UploadFile[]>([]);
const templatePageNo = ref(1);
const templatePageSize = 50;
const templateHasNext = ref(false);
const templateLoaded = ref(false);
const isEdit = computed(() => !!props.editData?.id);
const currentFileName = computed(() => props.editData?.original_name || "已绑定文件");
const form = reactive<{
id: number;
task_name: string;
template_id: number | null;
file_id: number;
file: File | null;
_newFile: boolean;
description: string;
}>({
id: 0,
task_name: "",
template_id: null,
file_id: 0,
file: null,
_newFile: false,
description: "",
});
const rules: FormRules = {
task_name: [
{ required: true, message: "请输入任务名称", trigger: "blur" },
{ max: 128, message: "最多 128 个字符", trigger: "blur" },
],
};
watch(
() => props.visible,
(v) => {
if (v) {
resetForm();
if (props.editData) {
form.id = props.editData.id;
form.task_name = props.editData.task_name;
form.template_id = props.editData.template_id ?? null;
form.file_id = props.editData.file_id;
form.description = props.editData.description || "";
form._newFile = false;
form.file = null;
}
//
templateLoaded.value = false;
templatePageNo.value = 1;
templateHasNext.value = false;
templates.value = [];
loadTemplates(1);
}
}
);
function resetForm() {
form.id = 0;
form.task_name = "";
form.template_id = null;
form.file_id = 0;
form.file = null;
form._newFile = false;
form.description = "";
fileList.value = [];
formRef.value?.resetFields();
}
async function loadTemplates(page = 1) {
templatesLoading.value = true;
try {
const resp = await ReportTemplateAPI.page({
page_no: page,
page_size: templatePageSize,
status: 0,
});
const data = resp?.data?.data;
templates.value = data?.items ?? [];
templatePageNo.value = page;
templateHasNext.value = data?.has_next ?? false;
templateLoaded.value = true;
} catch (e) {
templates.value = [];
templateHasNext.value = false;
} finally {
templatesLoading.value = false;
}
}
function onTemplateDropdownVisible(visible: boolean) {
if (visible && !templateLoaded.value) {
loadTemplates(1);
}
}
function handleFileChange(file: UploadFile, files: UploadFiles) {
fileList.value = files.slice(-1);
if (file.raw) {
const ext = file.name.split(".").pop()?.toLowerCase();
if (ext !== "docx") {
ElMessage.warning("仅支持 .docx 格式");
fileList.value = [];
form.file = null;
return;
}
form.file = file.raw;
form._newFile = true;
if (!form.task_name && file.name) {
form.task_name = file.name.replace(/\.docx$/i, "");
}
}
}
function handleFileRemove() {
fileList.value = [];
form.file = null;
form._newFile = false;
}
function handleExceed() {
ElMessage.warning("仅支持上传一个文件,新文件将替换旧文件");
}
async function handleSubmit() {
try {
await formRef.value?.validate();
} catch {
return;
}
const needUpload = isEdit.value ? form._newFile : true;
if (needUpload && !form.file) {
ElMessage.warning("请先上传待审核文档");
return;
}
if (!isEdit.value && !form.file_id && !form.file) {
ElMessage.warning("请先上传待审核文档");
return;
}
submitting.value = true;
try {
let fileId = form.file_id;
if (needUpload && form.file) {
const uploadResp = await FileAPI.upload(form.file, "energy_report_review");
const newFileId = uploadResp?.data?.data?.id;
if (!newFileId) {
ElMessage.error("文件上传失败");
return;
}
fileId = newFileId;
}
if (isEdit.value) {
const body: ReviewTaskUpdateForm = {
task_name: form.task_name.trim(),
template_id: form.template_id ?? undefined,
file_id: fileId,
report_type: reportType.value,
description: form.description?.trim() || undefined,
};
await ReviewTaskAPI.update(form.id, body);
} else {
const body: ReviewTaskCreateForm = {
task_name: form.task_name.trim(),
template_id: form.template_id ?? undefined,
file_id: fileId,
report_type: reportType.value,
description: form.description?.trim() || undefined,
};
await ReviewTaskAPI.create(body);
}
emit("update:visible", false);
emit("success");
} catch (e) {
} finally {
submitting.value = false;
}
}
</script>
<style lang="scss" scoped>
.full-width { width: 100%; }
.template-select-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 6px 0;
border-top: 1px solid #f0f0f0;
.page-info {
font-size: 12px;
color: #909399;
min-width: 60px;
text-align: center;
}
}
</style>

@ -0,0 +1,121 @@
<template>
<ElDialog
:model-value="visible"
title="任务详情"
width="640px"
:close-on-click-modal="false"
append-to-body
@update:model-value="(v: boolean) => emit('update:visible', v)"
>
<ElDescriptions v-if="task" :column="2" border size="default">
<ElDescriptionsItem label="任务名称" :span="2">
{{ task.task_name }}
</ElDescriptionsItem>
<ElDescriptionsItem label="审核状态">
<ElTag :type="statusTagType(task.status)" effect="light">
{{ statusText(task.status) }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="审核进度">
<ElProgress
v-if="task.status === 1"
:percentage="task.progress ?? 0"
:stroke-width="8"
status="success"
/>
<span v-else-if="task.status === 2" class="progress-text">已完成 100%</span>
<span v-else-if="task.status === 3" class="progress-text error">审核失败</span>
<span v-else class="progress-text">未开始</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="审查人">
{{ task.reviewer_name || "-" }}
</ElDescriptionsItem>
<ElDescriptionsItem label="模板名称">
{{ task.template_name || "-" }}
</ElDescriptionsItem>
<ElDescriptionsItem label="审核文档" :span="2">
<span v-if="task.original_name">
<ElLink type="primary" @click="onPreviewDoc">
<ElIcon style="margin-right:4px"><View /></ElIcon>
{{ task.original_name }}
</ElLink>
</span>
<span v-else>-</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="开始时间">
{{ formatDateTime(task.start_time) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="结束时间">
{{ formatDateTime(task.end_time) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">
{{ formatDateTime(task.created_time) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间">
{{ formatDateTime(task.updated_time) }}
</ElDescriptionsItem>
<ElDescriptionsItem v-if="task.description" label="备注" :span="2">
{{ task.description }}
</ElDescriptionsItem>
</ElDescriptions>
<template #footer>
<ElButton @click="emit('update:visible', false)">关闭</ElButton>
<ElButton v-if="task?.file_url" type="primary" @click="onPreviewDoc">
查看文档
</ElButton>
</template>
<DocPreviewDialog
v-model:visible="docPreviewVisible"
:title="task?.original_name || '审核文档'"
:file-url="task?.file_url"
/>
</ElDialog>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { View } from "@element-plus/icons-vue";
import type { ReviewTaskItem } from "@/api/module_report/review";
import DocPreviewDialog from "./DocPreviewDialog.vue";
defineOptions({ name: "TaskDetailDialog" });
const props = defineProps<{
visible: boolean;
task?: ReviewTaskItem | null;
}>();
const emit = defineEmits<{
"update:visible": [v: boolean];
}>();
const docPreviewVisible = ref(false);
function statusText(s: number) {
return ["未审查", "审查中", "审查完成", "审查失败"][s] ?? "未知";
}
function statusTagType(s: number): "info" | "warning" | "success" | "danger" {
return ["info", "warning", "success", "danger"][s] as any;
}
function formatDateTime(value?: string): string {
if (!value) return "-";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function onPreviewDoc() {
docPreviewVisible.value = true;
}
</script>
<style lang="scss" scoped>
.progress-text {
font-size: 13px;
color: #606266;
&.error { color: #f56c6c; }
}
</style>

@ -0,0 +1,668 @@
<!-- 能源报告审核任务列表页 -->
<template>
<div class="review-list-page">
<!-- 统计卡片 -->
<div class="stats-bar">
<div class="stat-card" @click="filterByStatus(undefined)" :class="{ active: filterStatus === undefined }">
<div class="stat-icon total">
<ElIcon size="28"><Document /></ElIcon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.total }}</div>
<div class="stat-label">提交任务数</div>
</div>
</div>
<div class="stat-card" @click="filterByStatus(2)" :class="{ active: filterStatus === 2 }">
<div class="stat-icon success">
<ElIcon size="28"><CircleCheck /></ElIcon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.reviewed }}</div>
<div class="stat-label">已审查任务</div>
</div>
</div>
<div class="stat-card" @click="filterByStatus(0)" :class="{ active: filterStatus === 0 }">
<div class="stat-icon pending">
<ElIcon size="28"><Clock /></ElIcon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.pending }}</div>
<div class="stat-label">待审查任务</div>
</div>
</div>
<div class="stat-card" @click="filterByStatus(3)" :class="{ active: filterStatus === 3 }">
<div class="stat-icon failed">
<ElIcon size="28"><CircleClose /></ElIcon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.failed }}</div>
<div class="stat-label">审查失败</div>
</div>
</div>
<div class="time-range-wrap">
<span class="time-label">时间范围</span>
<ElSelect v-model="timeRange" size="default" class="time-select" @change="loadList">
<ElOption label="今日" value="today" />
<ElOption label="近7天" value="7days" />
<ElOption label="近30天" value="30days" />
<ElOption label="全部" value="all" />
</ElSelect>
</div>
</div>
<!-- 查询条件 -->
<div class="search-bar">
<div class="search-row">
<div class="search-item">
<span class="search-label">任务名称</span>
<ElInput
v-model="queryParams.task_name"
placeholder="请输入任务名称"
clearable
class="search-input"
@keyup.enter="handleSearch"
/>
</div>
<div class="search-item">
<span class="search-label">状态</span>
<ElSelect v-model="queryParams.status" placeholder="请选择状态" clearable class="search-input">
<ElOption label="未审查" :value="0" />
<ElOption label="审查中" :value="1" />
<ElOption label="审查完成" :value="2" />
<ElOption label="审查失败" :value="3" />
</ElSelect>
</div>
<div class="search-item">
<span class="search-label">创建时间</span>
<ElDatePicker
v-model="queryParams.created_time"
type="daterange"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
class="search-input-date"
/>
</div>
<div class="search-item">
<span class="search-label">审查人</span>
<ElInput
v-model="queryParams.reviewer_name"
placeholder="请输入审查人"
clearable
class="search-input"
@keyup.enter="handleSearch"
/>
</div>
</div>
<div class="search-actions">
<ElButton type="primary" :icon="Search" @click="handleSearch"></ElButton>
<ElButton :icon="Refresh" @click="handleReset"></ElButton>
<ElButton type="primary" :icon="Plus" @click="handleCreate"></ElButton>
</div>
</div>
<!-- 任务表格 -->
<div class="table-card">
<ElTable
v-loading="loading"
:data="taskList"
stripe
border
style="width: 100%"
:header-cell-style="{ background: '#f9fafb', color: '#374151', fontWeight: 600 }"
>
<ElTableColumn prop="task_name" label="任务名称" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<ElLink type="primary" @click="handleDetail(row)">
{{ row.task_name }}
</ElLink>
</template>
</ElTableColumn>
<ElTableColumn prop="status" label="状态" width="110" align="center">
<template #default="{ row }">
<ElTag :type="statusTagType(row.status)" effect="light" size="small">
{{ statusText(row.status) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="审查进度" width="180" align="center">
<template #default="{ row }">
<div class="progress-cell">
<ElProgress
:percentage="row.progress ?? (row.status === 2 ? 100 : row.status === 0 ? 0 : row.progress ?? 0)"
:status="row.status === 3 ? 'exception' : row.status === 2 ? 'success' : undefined"
:stroke-width="10"
:show-text="true"
:format="(p: number) => row.status === 1 ? `${p}%` : (row.status === 2 ? '100%' : row.status === 3 ? '失败' : `${p}%`)"
/>
</div>
</template>
</ElTableColumn>
<ElTableColumn prop="updated_time" label="更新时间" width="170" align="center">
<template #default="{ row }">{{ formatDateTime(row.updated_time) }}</template>
</ElTableColumn>
<ElTableColumn prop="created_time" label="创建时间" width="170" align="center">
<template #default="{ row }">{{ formatDateTime(row.created_time) }}</template>
</ElTableColumn>
<ElTableColumn prop="reviewer_name" label="审查人" width="100" align="center">
<template #default="{ row }">{{ row.reviewer_name || "-" }}</template>
</ElTableColumn>
<ElTableColumn label="审核文档" align="center">
<template #default="{ row }">
<ElLink
v-if="row.file_url"
type="primary"
:icon="View"
@click="handlePreviewDoc(row)"
>
{{ row.original_name ? row.original_name : "未上传" }}
</ElLink>
<span v-else class="text-muted">未上传</span>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="260" align="center" fixed="right">
<template #default="{ row }">
<ElButton type="primary" link size="small" @click="handleViewResult(row)"></ElButton>
<ElButton
type="primary"
link
size="small"
:disabled="row.status !== 0 && row.status !== 3"
@click="handleReview(row)"
>
审查
</ElButton>
<ElButton
type="primary"
link
size="small"
:disabled="row.status === 1 || row.status === 2"
@click="handleEdit(row)"
>
编辑
</ElButton>
<ElButton type="primary" link size="small" @click="handleLog(row)"></ElButton>
<ElButton
type="danger"
link
size="small"
:disabled="row.status === 1"
@click="handleDelete(row)"
>
删除
</ElButton>
</template>
</ElTableColumn>
</ElTable>
<!-- 分页 -->
<div v-if="total > 0" class="pagination">
<span class="total-text"> {{ total }} </span>
<ElPagination
:current-page="pageNo"
:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="sizes, prev, pager, next, jumper"
@current-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<!-- 新增/编辑任务弹窗 -->
<ReviewFormDialog
v-model:visible="formVisible"
:edit-data="currentEditItem"
@success="onFormSuccess"
/>
<!-- 任务详情弹窗 -->
<TaskDetailDialog
v-model:visible="detailVisible"
:task="currentDetailItem"
/>
<!-- 文档预览弹窗 -->
<DocPreviewDialog
v-model:visible="docPreviewVisible"
:title="currentPreviewFile?.name || '审核文档'"
:file-url="currentPreviewFile?.url"
/>
<!-- 操作日志抽屉 -->
<LogDrawer
v-model:visible="logVisible"
:task-id="currentLogTask?.id"
:task="currentLogTask"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import {
Search,
Refresh,
Plus,
View,
Document,
CircleCheck,
CircleClose,
Clock,
} from "@element-plus/icons-vue";
import { ReviewTaskAPI } from "@/api/module_report/review";
import type { ReviewTaskItem, ReviewTaskPageQuery } from "@/api/module_report/review";
import type { ReportType } from "@/api/module_report/report";
import ReviewFormDialog from "./components/ReviewFormDialog.vue";
import TaskDetailDialog from "./components/TaskDetailDialog.vue";
import DocPreviewDialog from "./components/DocPreviewDialog.vue";
import LogDrawer from "./components/LogDrawer.vue";
defineOptions({ name: "EnergyReportReviewList" });
const route = useRoute();
const router = useRouter();
/** 报告类型1 能源报告 / 2 光伏报告(由路由参数传入) */
const reportType = computed<ReportType>(() => {
const t = route.query.report_type;
return t === "2" ? 2 : 1;
});
const loading = ref(false);
const taskList = ref<ReviewTaskItem[]>([]);
const total = ref(0);
const pageNo = ref(1);
const pageSize = ref(10);
const timeRange = ref("all");
const filterStatus = ref<number | undefined>(undefined);
const stats = reactive({
total: 0,
reviewed: 0,
pending: 0,
failed: 0,
});
const queryParams = reactive<{
task_name: string;
status: number | "";
created_time: string[] | null;
reviewer_name: string;
}>({
task_name: "",
status: "",
created_time: null,
reviewer_name: "",
});
//
const formVisible = ref(false);
const detailVisible = ref(false);
const docPreviewVisible = ref(false);
const logVisible = ref(false);
const currentEditItem = ref<ReviewTaskItem | null>(null);
const currentDetailItem = ref<ReviewTaskItem | null>(null);
const currentLogTask = ref<ReviewTaskItem | null>(null);
const currentPreviewFile = ref<{ url: string; name: string } | null>(null);
function getTimeRangeParam(): string[] | undefined {
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
const fmt = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
if (timeRange.value === "today") {
const s = fmt(now);
return [s + " 00:00:00", s + " 23:59:59"];
}
if (timeRange.value === "7days") {
const s = new Date(now.getTime() - 6 * 86400000);
return [fmt(s) + " 00:00:00", fmt(now) + " 23:59:59"];
}
if (timeRange.value === "30days") {
const s = new Date(now.getTime() - 29 * 86400000);
return [fmt(s) + " 00:00:00", fmt(now) + " 23:59:59"];
}
return undefined;
}
async function loadList() {
loading.value = true;
try {
const params: ReviewTaskPageQuery = {
page_no: pageNo.value,
page_size: pageSize.value,
task_name: queryParams.task_name?.trim() || undefined,
status: queryParams.status === "" ? undefined : (queryParams.status as any),
report_type: reportType.value,
reviewer_name: queryParams.reviewer_name?.trim() || undefined,
};
if (queryParams.created_time && queryParams.created_time.length === 2) {
params.created_time = [
queryParams.created_time[0] + " 00:00:00",
queryParams.created_time[1] + " 23:59:59",
];
} else {
params.created_time = getTimeRangeParam();
}
if (filterStatus.value !== undefined) {
params.status = filterStatus.value as any;
}
const resp = await ReviewTaskAPI.page(params);
const data = resp?.data?.data;
taskList.value = (data?.items ?? []).map((item) => ({
...item,
progress: item.progress ?? (item.status === 2 ? 100 : item.status === 0 ? 0 : 0),
}));
total.value = data?.total ?? 0;
//
const s = data?.stats;
stats.total = s?.submitted_count ?? 0;
stats.reviewed = s?.reviewed_count ?? 0;
stats.pending = s?.pending_count ?? 0;
stats.failed = s?.failed_count ?? 0;
} catch (e) {
taskList.value = [];
total.value = 0;
} finally {
loading.value = false;
}
}
function handleSearch() {
pageNo.value = 1;
loadList();
}
function handleReset() {
queryParams.task_name = "";
queryParams.status = "";
queryParams.created_time = null;
queryParams.reviewer_name = "";
filterStatus.value = undefined;
timeRange.value = "all";
pageNo.value = 1;
loadList();
}
function filterByStatus(status?: number) {
filterStatus.value = status;
pageNo.value = 1;
loadList();
}
function onPageChange(p: number) {
pageNo.value = p;
loadList();
}
function onSizeChange(s: number) {
pageSize.value = s;
pageNo.value = 1;
loadList();
}
// ========== ==========
function handleCreate() {
currentEditItem.value = null;
formVisible.value = true;
}
function handleEdit(row: ReviewTaskItem) {
currentEditItem.value = row;
formVisible.value = true;
}
function onFormSuccess() {
loadList();
}
function handleDetail(row: ReviewTaskItem) {
currentDetailItem.value = row;
detailVisible.value = true;
}
function handlePreviewDoc(row: ReviewTaskItem) {
if (!row.file_url) return;
currentPreviewFile.value = {
url: row.file_url,
name: row.original_name || "审核文档",
};
docPreviewVisible.value = true;
}
function handleLog(row: ReviewTaskItem) {
currentLogTask.value = row;
logVisible.value = true;
}
function handleViewResult(row: ReviewTaskItem) {
router.push({ name: "ReportReviewResult", query: { id: row.id, report_type: reportType.value } });
}
function handleReview(row: ReviewTaskItem) {
// TODO:
ElMessageBox.confirm(`确定开始审查任务「${row.task_name}」吗?`, "开始审查", {
type: "info",
confirmButtonText: "开始审查",
cancelButtonText: "取消",
})
.then(async () => {
try {
await ReviewTaskAPI.startReview(row.id, reportType.value);
// start_review
await ReviewTaskAPI.createLog({
report_review_id: row.id,
action_type: "start_review",
description: `开始审查:${row.task_name}`,
});
ElMessage.success("审查任务已提交");
loadList();
} catch (e) {}
})
.catch(() => {});
}
function handleDelete(row: ReviewTaskItem) {
ElMessageBox.confirm(`确定要删除任务「${row.task_name}」吗?`, "删除确认", {
type: "warning",
confirmButtonText: "删除",
cancelButtonText: "取消",
})
.then(async () => {
try {
await ReviewTaskAPI.remove([row.id], reportType.value);
loadList();
} catch (e) {}
})
.catch(() => {});
}
// ========== ==========
function statusText(s: number): string {
return ["未审查", "审查中", "审查完成", "审查失败"][s] ?? "未知";
}
function statusTagType(s: number): "info" | "warning" | "success" | "danger" {
return ["info", "warning", "success", "danger"][s] as any;
}
function formatDateTime(value?: string): string {
if (!value) return "-";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
onMounted(() => {
loadList();
});
</script>
<style lang="scss" scoped>
.review-list-page {
display: flex;
flex-direction: column;
height: 100%;
gap: 12px;
}
/* 统计卡片 */
.stats-bar {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 20px;
background: #fff;
border-radius: 8px;
border: 1px solid #e5e7eb;
}
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid transparent;
min-width: 160px;
&:hover {
background: #f9fafb;
border-color: #e5e7eb;
}
&.active {
background: #eff6ff;
border-color: #bfdbfe;
}
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
&.total { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
&.success { background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); }
&.pending { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); }
&.failed { background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%); }
}
.stat-info {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 22px;
font-weight: 700;
color: #111827;
line-height: 1.2;
}
.stat-label {
font-size: 12px;
color: #6b7280;
margin-top: 2px;
}
.time-range-wrap {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
.time-label {
font-size: 13px;
color: #606266;
}
.time-select {
width: 120px;
}
/* 搜索栏 */
.search-bar {
background: #fff;
border-radius: 8px;
border: 1px solid #e5e7eb;
padding: 16px 20px;
}
.search-row {
display: flex;
flex-wrap: wrap;
gap: 16px 24px;
margin-bottom: 12px;
}
.search-item {
display: flex;
align-items: center;
gap: 8px;
}
.search-label {
font-size: 13px;
color: #374151;
white-space: nowrap;
min-width: 56px;
text-align: right;
}
.search-input {
width: 200px;
}
.search-input-date {
width: 280px;
}
.search-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
/* 表格 */
.table-card {
flex: 1;
background: #fff;
border-radius: 8px;
border: 1px solid #e5e7eb;
padding: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.progress-cell {
padding: 0 8px;
}
.text-muted {
color: #c0c4cc;
font-size: 12px;
}
.pagination {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid #f0f0f0;
}
.total-text {
font-size: 13px;
color: #6b7280;
margin-right: auto;
}
</style>

@ -0,0 +1,861 @@
<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="task.file_url"
: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";
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>

@ -0,0 +1,706 @@
<!-- 能源报告模板详情页只读 -->
<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>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,760 @@
<!-- 能源报告模板列表页卡片视图 -->
<template>
<div class="template-list-page">
<!-- 顶部搜索栏 -->
<div class="search-bar">
<ElInput
v-model="keyword"
placeholder="请输入模板名称搜索"
clearable
class="search-input"
:prefix-icon="Search"
@keyup.enter="handleSearch"
@clear="handleSearch"
/>
<ElButton type="primary" :icon="Plus" @click="handleCreate">
新增模板
</ElButton>
</div>
<!-- 模板卡片网格 -->
<div v-loading="loading" class="template-grid">
<div
v-for="item in templateList"
:key="item.id"
class="template-card"
@click="handleView(item)"
>
<!-- 右上角下拉菜单 -->
<div class="card-menu" @click.stop>
<ElDropdown trigger="click" @command="(cmd: string) => handleCommand(cmd, item)">
<ElButton link :icon="MoreFilled" class="menu-trigger" />
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem command="edit" :icon="EditPen">修改</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
<div class="card-icon">
<ElIcon size="48"><Document /></ElIcon>
</div>
<h3 class="card-title" :title="item.template_name">{{ item.template_name }}</h3>
<p class="card-subtitle">
{{ templateTypeLabel(item.template_type) }}
<span v-if="item.version" class="version-tag">v{{ item.version }}</span>
</p>
<p class="card-time">
<ElIcon><Clock /></ElIcon>
最后更新{{ formatDateTime(item.updated_time || item.created_time) }}
</p>
<p class="card-desc" :title="item.description">
{{ item.description || "暂无描述" }}
</p>
<!-- 标注情况 -->
<div class="card-annotation">
<div class="annotation-header">
<ElIcon><CollectionTag /></ElIcon>
<span>标注情况</span>
<ElTag size="small" type="primary" effect="plain">
{{ item.annotation_count || 0 }}
</ElTag>
</div>
<!-- <div class="annotation-rules">
<span class="no-annotation">暂无标注</span>
</div> -->
</div>
<!-- 卡片底部操作按钮 -->
<div class="card-actions" @click.stop>
<ElButton type="primary" :icon="Edit" size="default" @click="handleEdit(item)"></ElButton>
<ElButton type="danger" :icon="Delete" size="default" plain @click="handleDelete(item)"></ElButton>
</div>
</div>
<ElEmpty
v-if="!loading && templateList.length === 0"
class="empty-state"
description="暂无模板,点击右上角「新增模板」开始创建"
/>
</div>
<!-- 分页 -->
<div v-if="total > 0" class="pagination">
<span class="total-text"> {{ total }} </span>
<ElPagination
:current-page="pageNo"
:page-size="pageSize"
:total="total"
:page-sizes="[12, 24, 48]"
layout="sizes, prev, pager, next, jumper"
@current-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
<!-- 新增模板弹窗 -->
<ElDialog
v-model="createVisible"
title="新增模板"
width="600px"
:close-on-click-modal="false"
destroy-on-close
>
<ElForm
ref="createFormRef"
:model="createForm"
:rules="createRules"
label-width="90px"
>
<ElFormItem label="模板名称" prop="template_name" required>
<ElInput v-model="createForm.template_name" placeholder="请输入模板名称" :maxlength="128" />
</ElFormItem>
<!-- <ElFormItem label="模板类型" prop="template_type" required>
<ElSelect v-model="createForm.template_type" placeholder="请选择模板类型" class="full-width">
<ElOption label="日报" value="daily" />
<ElOption label="周报" value="weekly" />
<ElOption label="月报" value="monthly" />
<ElOption label="年报" value="yearly" />
<ElOption label="自定义" value="custom" />
</ElSelect>
</ElFormItem> -->
<ElFormItem label="模板版本" prop="version">
<ElInput v-model="createForm.version" placeholder="如 1.0.0" :maxlength="32" />
</ElFormItem>
<ElFormItem label="模板文件" prop="file" required>
<ElUpload
ref="uploadRef"
:auto-upload="false"
:limit="1"
accept=".docx"
:file-list="fileList"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
:on-exceed="handleExceed"
drag
>
<ElIcon class="el-icon--upload"><UploadFilled /></ElIcon>
<div class="el-upload__text">
将文件拖到此处<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">仅支持 .docx 格式的 Word 文档</div>
</template>
</ElUpload>
</ElFormItem>
<ElFormItem label="模板描述" prop="description">
<ElInput
v-model="createForm.description"
type="textarea"
:rows="3"
placeholder="请输入模板描述(可选)"
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="createVisible = false">取消</ElButton>
<ElButton type="primary" :loading="submitting" @click="handleSubmitCreate">
确定
</ElButton>
</template>
</ElDialog>
<!-- 修改模板弹窗 -->
<ElDialog
v-model="editVisible"
title="修改模板"
width="600px"
:close-on-click-modal="false"
destroy-on-close
>
<ElForm
ref="editFormRef"
:model="editForm"
:rules="editRules"
label-width="90px"
>
<ElFormItem label="模板名称" prop="template_name" required>
<ElInput v-model="editForm.template_name" placeholder="请输入模板名称" :maxlength="128" />
</ElFormItem>
<ElFormItem label="模板版本" prop="version">
<ElInput v-model="editForm.version" placeholder="如 1.0.0" :maxlength="32" />
</ElFormItem>
<ElFormItem label="模板文件" prop="file">
<ElUpload
:auto-upload="false"
:limit="1"
accept=".docx"
:file-list="editFileList"
:on-change="handleEditFileChange"
:on-remove="handleEditFileRemove"
:on-exceed="handleExceed"
drag
>
<ElIcon class="el-icon--upload"><UploadFilled /></ElIcon>
<div class="el-upload__text">
将文件拖到此处<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
<span v-if="editForm.file_id && !editForm._newFile">
当前文件{{ currentEditItem?.original_name || "已绑定文件" }}上传新文件将替换
</span>
<span v-else> .docx Word </span>
</div>
</template>
</ElUpload>
</ElFormItem>
<ElFormItem label="模板描述" prop="description">
<ElInput
v-model="editForm.description"
type="textarea"
:rows="3"
placeholder="请输入模板描述(可选)"
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="editVisible = false">取消</ElButton>
<ElButton type="primary" :loading="editSubmitting" @click="handleSubmitEdit">
保存
</ElButton>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "EnergyReportTemplateList" });
import { ref, reactive, computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
ElMessage,
ElMessageBox,
type FormInstance,
type FormRules,
type UploadFile,
type UploadFiles,
type UploadInstance,
} from "element-plus";
import {
Search,
Plus,
Edit,
Delete,
EditPen,
Document,
Clock,
CollectionTag,
UploadFilled,
MoreFilled,
} from "@element-plus/icons-vue";
import { FileAPI, ReportTemplateAPI } from "@/api/module_report/report";
import type {
ReportTemplateItem,
ReportTemplateCreateForm,
ReportTemplateUpdateForm,
ReportType,
} from "@/api/module_report/report";
const route = useRoute();
const router = useRouter();
/** 报告类型1 能源报告 / 2 光伏报告(由路由参数传入) */
const reportType = computed<ReportType>(() => {
const t = route.query.report_type;
return t === "2" ? 2 : 1;
});
const loading = ref(false);
const templateList = ref<ReportTemplateItem[]>([]);
const keyword = ref("");
const total = ref(0);
const pageNo = ref(1);
const pageSize = ref(12);
async function loadList() {
loading.value = true;
try {
const resp = await ReportTemplateAPI.page({
page_no: pageNo.value,
page_size: pageSize.value,
template_name: keyword.value?.trim() || undefined,
report_type: reportType.value,
});
const data = resp?.data?.data;
templateList.value = data?.items ?? [];
total.value = data?.total ?? 0;
} catch (e) {
templateList.value = [];
total.value = 0;
} finally {
loading.value = false;
}
}
function handleSearch() {
pageNo.value = 1;
loadList();
}
function onPageChange(p: number) {
pageNo.value = p;
loadList();
}
function onSizeChange(s: number) {
pageSize.value = s;
pageNo.value = 1;
loadList();
}
function templateTypeLabel(type: string): string {
const map: Record<string, string> = {
daily: "日报模板",
weekly: "周报模板",
monthly: "月报模板",
yearly: "年报模板",
custom: "自定义模板",
};
return map[type] || type;
}
// ========== ==========
const createVisible = ref(false);
const submitting = ref(false);
const createFormRef = ref<FormInstance | null>(null);
const uploadRef = ref<UploadInstance | null>(null);
const fileList = ref<UploadFile[]>([]);
const createForm = reactive<{
template_name: string;
template_type: string;
version: string;
file: File | null;
description: string;
}>({
template_name: "",
template_type: "daily",
version: "1.0.0",
file: null,
description: "",
});
const createRules: FormRules = {
template_name: [
{ required: true, message: "请输入模板名称", trigger: "blur" },
{ max: 128, message: "最多 128 个字符", trigger: "blur" },
],
template_type: [{ required: true, message: "请选择模板类型", trigger: "change" }],
};
function handleCreate() {
createForm.template_name = "";
createForm.template_type = "daily";
createForm.version = "1.0.0";
createForm.file = null;
createForm.description = "";
fileList.value = [];
createVisible.value = true;
}
function handleFileChange(file: UploadFile, files: UploadFiles) {
fileList.value = files.slice(-1);
if (file.raw) {
createForm.file = file.raw;
if (!createForm.template_name && file.name) {
createForm.template_name = file.name.replace(/\.docx$/i, "");
}
}
}
function handleFileRemove() {
fileList.value = [];
createForm.file = null;
}
function handleExceed() {
ElMessage.warning("仅支持上传一个文件,新文件将替换旧文件");
}
async function handleSubmitCreate() {
try {
await createFormRef.value?.validate();
} catch {
return;
}
if (!createForm.file) {
ElMessage.warning("请先上传模板文件");
return;
}
submitting.value = true;
try {
// 1.
const uploadResp = await FileAPI.upload(createForm.file);
const fileId = uploadResp?.data?.data?.id;
if (!fileId) {
ElMessage.error("文件上传失败");
return;
}
// 2.
const body: ReportTemplateCreateForm = {
template_name: createForm.template_name.trim(),
// template_type: createForm.template_type,
version: createForm.version?.trim() || undefined,
file_id: fileId,
status: 0,
report_type: reportType.value,
description: createForm.description?.trim() || undefined,
};
const resp = await ReportTemplateAPI.create(body);
const data = resp?.data?.data;
createVisible.value = false;
await loadList();
// if (data?.id) {
// router.push({ name: "ReportTemplateEdit", query: { id: data.id, report_type: reportType.value } });
// }
} catch (e) {
} finally {
submitting.value = false;
}
}
// ========== ==========
function handleView(item: ReportTemplateItem) {
router.push({ name: "ReportTemplateDetail", query: { id: item.id, report_type: reportType.value } });
}
function handleEdit(item: ReportTemplateItem) {
router.push({ name: "ReportTemplateEdit", query: { id: item.id, report_type: reportType.value } });
}
// ========== ==========
function handleCommand(cmd: string, item: ReportTemplateItem) {
if (cmd === "edit") {
openEditDialog(item);
}
}
// ========== ==========
const editVisible = ref(false);
const editSubmitting = ref(false);
const editFormRef = ref<FormInstance | null>(null);
const editFileList = ref<UploadFile[]>([]);
const currentEditItem = ref<ReportTemplateItem | null>(null);
const editForm = reactive<{
id: number;
template_name: string;
version: string;
file_id: number;
file: File | null;
_newFile: boolean;
description: string;
}>({
id: 0,
template_name: "",
version: "",
file_id: 0,
file: null,
_newFile: false,
description: "",
});
const editRules: FormRules = {
template_name: [
{ required: true, message: "请输入模板名称", trigger: "blur" },
{ max: 128, message: "最多 128 个字符", trigger: "blur" },
],
};
function openEditDialog(item: ReportTemplateItem) {
currentEditItem.value = item;
editForm.id = item.id;
editForm.template_name = item.template_name;
editForm.version = item.version || "";
editForm.file_id = item.file_id;
editForm.file = null;
editForm._newFile = false;
editForm.description = item.description || "";
editFileList.value = [];
editVisible.value = true;
}
function handleEditFileChange(file: UploadFile, files: UploadFiles) {
editFileList.value = files.slice(-1);
if (file.raw) {
editForm.file = file.raw;
editForm._newFile = true;
}
}
function handleEditFileRemove() {
editFileList.value = [];
editForm.file = null;
editForm._newFile = false;
}
async function handleSubmitEdit() {
try {
await editFormRef.value?.validate();
} catch {
return;
}
editSubmitting.value = true;
try {
let fileId = editForm.file_id;
if (editForm._newFile && editForm.file) {
const uploadResp = await FileAPI.upload(editForm.file);
const newFileId = uploadResp?.data?.data?.id;
if (!newFileId) {
ElMessage.error("文件上传失败");
return;
}
fileId = newFileId;
}
const body: ReportTemplateUpdateForm = {
template_name: editForm.template_name.trim(),
version: editForm.version?.trim() || undefined,
file_id: fileId,
report_type: reportType.value,
description: editForm.description?.trim() || undefined,
};
await ReportTemplateAPI.update(editForm.id, body);
editVisible.value = false;
await loadList();
} catch (e) {
} finally {
editSubmitting.value = false;
}
}
function handleDelete(item: ReportTemplateItem) {
ElMessageBox.confirm(`确定要删除模板「${item.template_name}」吗?`, "删除确认", {
type: "warning",
confirmButtonText: "删除",
cancelButtonText: "取消",
})
.then(async () => {
try {
await ReportTemplateAPI.remove([item.id], reportType.value);
loadList();
} catch (e) {}
})
.catch(() => {});
}
function formatDateTime(value?: string): string {
if (!value) return "-";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(
d.getHours()
)}:${pad(d.getMinutes())}`;
}
onMounted(() => {
loadList();
});
</script>
<style lang="scss" scoped>
.template-list-page {
display: flex;
flex-direction: column;
height: 100%;
// padding: 16px;
gap: 8px;
}
.search-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
background: #fff;
border-radius: 8px;
border: 1px solid #e5e7eb;
}
.search-input {
width: 280px;
}
.template-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
align-content: start;
overflow-y: auto;
padding: 8px 0;
padding-right: 4px;
}
.template-card {
position: relative;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 24px 20px 16px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
flex-direction: column;
gap: 8px;
&:hover {
border-color: #409eff;
box-shadow: 0 4px 16px rgba(64, 158, 255, 0.12);
transform: translateY(-2px);
}
}
.card-actions {
display: flex;
gap: 8px;
padding-top: 10px;
border-top: 1px solid #f3f4f6;
margin-top: 4px;
.el-button {
flex: 1;
}
}
.card-menu {
position: absolute;
top: 8px;
right: 8px;
z-index: 1;
}
.menu-trigger {
padding: 4px;
font-size: 18px;
color: #9ca3af;
&:hover {
color: #409eff;
}
}
.card-icon {
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
margin-bottom: 4px;
border-radius: 12px;
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
color: #409eff;
}
.card-title {
margin: 4px 0 0;
font-size: 16px;
font-weight: 600;
color: #111827;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-right: 24px;
}
.card-subtitle {
margin: 0;
font-size: 12px;
color: #9ca3af;
display: flex;
align-items: center;
gap: 6px;
}
.version-tag {
background: #f3f4f6;
padding: 1px 6px;
border-radius: 3px;
font-size: 11px;
}
.card-time {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-size: 12px;
color: #9ca3af;
}
.card-desc {
margin: 0;
font-size: 12px;
color: #6b7280;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-annotation {
background: #f9fafb;
border-radius: 6px;
padding: 10px 12px;
margin-top: 4px;
flex: 1;
}
.annotation-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
color: #374151;
margin-bottom: 8px;
}
.annotation-rules {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.no-annotation {
font-size: 12px;
color: #c0c4cc;
}
.empty-state {
grid-column: 1 / -1;
padding: 80px 0;
}
.pagination {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 8px 0;
// background: #fff;
border-radius: 8px;
//border: 1px solid #e5e7eb;
padding: 12px 16px;
}
.total-text {
font-size: 13px;
color: #6b7280;
}
.full-width {
width: 100%;
}
</style>

@ -6,112 +6,110 @@
<span class="page-title">{{ isEdit ? "编辑规则" : "新建规则" }}</span>
</div>
<!-- 基础信息 -->
<ElCard class="form-card" shadow="never">
<template #header>
<span class="section-title">基础信息</span>
</template>
<ElForm
ref="formRef"
:model="form"
:rules="rules"
label-width="110px"
label-position="right"
>
<ElRow :gutter="24">
<ElCol :span="12">
<ElFormItem label="规则名称" prop="name" required>
<ElInput
v-model="form.name"
placeholder="请输入规则名称"
:maxlength="50"
show-word-limit
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="规则分类" prop="category_id" required>
<ElTreeSelect
v-model="form.category_id"
:data="categoryTree"
:props="treeSelectProps"
node-key="id"
check-strictly
placeholder="请选择规则分类"
filterable
clearable
style="width: 100%"
/>
</ElFormItem>
</ElCol>
</ElRow>
<ElFormItem label="规则描述" prop="description">
<ElInput
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入规则描述(可选)"
:maxlength="200"
show-word-limit
/>
</ElFormItem>
<ElFormItem label="状态" prop="status" required>
<ElRadioGroup v-model="form.status">
<ElRadio :value="0">启用</ElRadio>
<ElRadio :value="1">停用</ElRadio>
</ElRadioGroup>
</ElFormItem>
</ElForm>
</ElCard>
<!-- 提示词模板 -->
<ElCard class="form-card" shadow="never">
<template #header>
<span class="section-title">提示词模板</span>
</template>
<div class="prompt-section">
<!-- 提示栏 -->
<div class="prompt-hint-bar">
<span class="hint-item">
变量格式<code class="var-code">${'{变量名}'}</code>
<code class="var-code">${'{合同全文内容}'}</code>
</span>
<span class="hint-item">
知识库引用<code class="kb-code">@知识库/文档名称</code>
<code class="kb-code">@知识库/GB/T 50326-2018</code>
</span>
</div>
<!-- 左右布局基础信息 + 提示词模板 -->
<div class="edit-body">
<!-- 基础信息 -->
<ElCard class="form-card form-card--left" shadow="never">
<template #header>
<span class="section-title">基础信息</span>
</template>
<ElForm
ref="formRef"
:model="form"
:rules="rules"
label-width="110px"
label-position="right"
>
<ElFormItem label="规则名称" prop="name" required>
<ElInput
v-model="form.name"
placeholder="请输入规则名称"
:maxlength="50"
show-word-limit
/>
</ElFormItem>
<ElFormItem label="规则分类" prop="category_id" required>
<ElTreeSelect
v-model="form.category_id"
:data="categoryTree"
:props="treeSelectProps"
node-key="id"
check-strictly
placeholder="请选择规则分类"
filterable
clearable
style="width: 100%"
/>
</ElFormItem>
<ElFormItem label="规则描述" prop="description">
<ElInput
v-model="form.description"
type="textarea"
:rows="4"
placeholder="请输入规则描述(可选)"
:maxlength="200"
show-word-limit
/>
</ElFormItem>
<ElFormItem label="状态" prop="status" required>
<ElRadioGroup v-model="form.status">
<ElRadio :value="0">启用</ElRadio>
<ElRadio :value="1">停用</ElRadio>
</ElRadioGroup>
</ElFormItem>
</ElForm>
</ElCard>
<!-- 提示词模板 -->
<ElCard class="form-card form-card--right" shadow="never">
<template #header>
<span class="section-title">提示词模板</span>
</template>
<div class="prompt-section">
<!-- 提示栏 -->
<div class="prompt-hint-bar">
<span class="hint-item">
变量格式<code class="var-code">${变量名}</code>
<code class="var-code">${合同全文内容}</code>
</span>
<span class="hint-item">
知识库引用<code class="kb-code">@知识库/文档名称</code>
<code class="kb-code">@知识库/GB/T 50326-2018</code>
</span>
</div>
<!-- 代码编辑器样式 textarea -->
<div class="code-editor" ref="editorRef">
<div class="line-numbers">
<span
v-for="n in lineCount"
:key="n"
class="line-num"
>{{ n }}</span>
<!-- 代码编辑器样式 textarea -->
<div class="code-editor" ref="editorRef">
<div class="line-numbers">
<span
v-for="n in lineCount"
:key="n"
class="line-num"
>{{ n }}</span>
</div>
<textarea
ref="textareaRef"
v-model="form.prompt_template"
class="code-textarea"
spellcheck="false"
placeholder="请输入提示词模板。输入 @ 可弹出知识库引用选择器"
@input="handlePromptInput"
@keydown="handlePromptKeydown"
@scroll="syncScroll"
/>
</div>
<textarea
ref="textareaRef"
v-model="form.prompt_template"
class="code-textarea"
spellcheck="false"
placeholder="请输入提示词模板。输入 @ 可弹出知识库引用选择器"
@input="handlePromptInput"
@keydown="handlePromptKeydown"
@scroll="syncScroll"
/>
</div>
<!-- 底部状态栏 -->
<div class="editor-footer">
<span class="tab-hint"> Tab 键插入缩进</span>
<span class="char-count">{{ form.prompt_template.length }} 字符</span>
<!-- 底部状态栏 -->
<div class="editor-footer">
<span class="tab-hint"> Tab 键插入缩进</span>
<span class="char-count">{{ form.prompt_template.length }} 字符</span>
</div>
</div>
</div>
</ElCard>
</ElCard>
</div>
<div class="footer-actions">
<ElButton @click="handleBack"></ElButton>
@ -334,6 +332,7 @@ onMounted(async () => {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.page-title {
@ -342,6 +341,16 @@ onMounted(async () => {
color: #111827;
}
/* 左右布局主体 */
.edit-body {
flex: 1;
display: flex;
gap: 12px;
min-height: 0;
align-items: flex-start;
overflow: hidden;
}
.form-card {
border-radius: 8px;
border: 1px solid #e5e7eb;
@ -356,6 +365,18 @@ onMounted(async () => {
}
}
.form-card--left {
width: 420px;
min-width: 420px;
flex-shrink: 0;
}
.form-card--right {
flex: 1;
min-width: 0;
height: 100%;
}
.section-title {
font-size: 15px;
font-weight: 600;
@ -367,6 +388,7 @@ onMounted(async () => {
display: flex;
flex-direction: column;
gap: 0;
height: 100%;
}
.prompt-hint-bar {
@ -404,12 +426,11 @@ onMounted(async () => {
/* 代码编辑器样式 */
.code-editor {
flex: 1;
display: flex;
border: 1px solid #dcdfe6;
border-radius: 0 0 6px 6px;
background: #fff;
min-height: 360px;
max-height: 600px;
overflow: hidden;
position: relative;
@ -449,7 +470,7 @@ onMounted(async () => {
line-height: 20px;
color: #1f2937;
background: #fff;
min-height: 360px;
min-height: 400px;
tab-size: 2;
white-space: pre;
overflow-wrap: normal;

Loading…
Cancel
Save