feat: 新增报告审核模块及api基础功能

master^2
18 hours ago
parent 462a54c0f4
commit 0702d247bd

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;
}

@ -1,62 +1,15 @@
// 能源报告模块接口
// 包含:文件上传(通用)、能源报告模板CRUD
// 报告模块接口(能源报告 / 光伏报告)
// 包含:报告模板CRUD、章节标注与变量绑定
import { request } from "@utils";
import { FileAPI } from "@/api/common/file";
export type { FileInfo } from "@/api/common/file";
const FILE_PATH = "/common/file";
const TEMPLATE_PATH = "/attachment-template";
// ============ 文件上传(通用) ============
/** 报告类型1 能源报告 / 2 光伏报告 */
export type ReportType = 1 | 2;
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,
});
},
};
// ============ 能源报告模板 ============
// ============ 报告模板 ============
const ReportTemplateAPI = {
/** 查询模板分页列表 */
@ -69,10 +22,11 @@ const ReportTemplateAPI = {
},
/** 查询模板详情 */
detail(id: number) {
detail(id: number, report_type?: ReportType) {
return request<ApiResponse<ReportTemplateItem>>({
url: `${TEMPLATE_PATH}/detail/${id}`,
method: "get",
params: { report_type },
});
},
@ -95,11 +49,12 @@ const ReportTemplateAPI = {
},
/** 删除模板(传 ID 数组) */
remove(ids: number[]) {
remove(ids: number[], report_type?: ReportType) {
return request<ApiResponse>({
url: `${TEMPLATE_PATH}/delete`,
method: "delete",
data: ids,
params: { report_type },
});
},
};
@ -112,11 +67,11 @@ const VARIABLE_PATH = "/variable/chapter";
const ChapterAnnotationAPI = {
/** 查询模板下全部章节规则标注 */
listByTemplate(templateId: number) {
listByTemplate(templateId: number, report_type?: ReportType) {
return request<ApiResponse<ChapterAnnotationItem[]>>({
url: `${CHAPTER_PATH}/list_by_template`,
method: "get",
params: { template_id: templateId },
params: { template_id: templateId, report_type },
});
},
@ -164,26 +119,6 @@ export default { FileAPI, ReportTemplateAPI, ChapterAnnotationAPI };
/** 模板状态0 启用 / 1 停用 */
export type ReportTemplateStatus = 0 | 1;
/** 文件记录对象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;
}
/** 模板分页查询参数 */
export interface ReportTemplatePageQuery {
page_no?: number;
@ -193,6 +128,7 @@ export interface ReportTemplatePageQuery {
template_type?: string;
version?: string;
status?: ReportTemplateStatus;
report_type?: ReportType;
created_time?: string[];
updated_time?: string[];
created_id?: number;
@ -209,10 +145,11 @@ export interface ReportTemplateItem {
version?: string;
file_id: number;
status: ReportTemplateStatus;
report_type?: ReportType;
order?: number;
description?: string;
/** 文件记录对象 */
file?: FileInfo;
file?: import("@/api/common/file").FileInfo;
/** 文件访问地址,可直接用于下载 */
file_url?: string;
/** 原始文件名 */
@ -238,6 +175,7 @@ export interface ReportTemplateCreateForm {
version?: string;
file_id: number;
status?: ReportTemplateStatus;
report_type?: ReportType;
order?: number;
description?: string;
}

@ -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;
}

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

@ -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,563 @@
<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)"
>
{{ 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 v-loading="mdLoading" class="md-content-scroll" @scroll="onMdScroll">
<div v-if="mdContent" class="markdown-body" v-html="renderedMdWithIds"></div>
<ElEmpty v-else-if="!mdLoading" description="审核结果接口暂未提供,等待后端接入" :image-size="80" />
</div>
</div>
</div>
</div>
</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,
} 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 mdContent = ref("");
const mdHeadings = ref<{ id: string; text: string; level: number }[]>([]);
const activeMdHeading = ref("");
const mdContentWrapRef = ref<HTMLElement | null>(null);
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,
});
function parseHeadings(md: string): { id: string; text: string; level: number }[] {
const headings: { id: string; text: string; level: number }[] = [];
const lines = md.split("\n");
let inCodeBlock = false;
let idx = 0;
for (const line of lines) {
if (/^```/.test(line.trim())) {
inCodeBlock = !inCodeBlock;
continue;
}
if (inCodeBlock) continue;
const m = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
if (m) {
const level = m[1]?.length ?? 1;
const text = (m[2] ?? "").trim();
const id = `md-h-${idx++}`;
headings.push({ id, text, level });
}
}
return headings;
}
function injectHeadingIds(html: string, headings: { id: string; text: string; level: number }[]): string {
let i = 0;
return html.replace(/<h([1-6])>([\s\S]*?)<\/h\1>/g, (_match, lvl, inner) => {
if (i >= headings.length) return _match;
const h = headings[i]!;
i++;
return `<h${lvl} id="${h.id}">${inner}</h${lvl}>`;
});
}
watch(mdContent, (val) => {
mdHeadings.value = parseHeadings(val);
activeMdHeading.value = "";
});
const renderedMdWithIds = computed(() => {
if (!mdContent.value) return "";
const html = mdParser.render(mdContent.value);
return injectHeadingIds(html, mdHeadings.value);
});
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();
}
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) {
container.scrollTo({ top: el.offsetTop - 16, behavior: "smooth" });
activeMdHeading.value = id;
}
}
function onMdScroll(e: Event) {
const container = e.target as HTMLElement;
if (!container) return;
const scrollTop = container.scrollTop;
const headings = mdHeadings.value;
let current = "";
for (const h of headings) {
const el = container.querySelector(`[id="${h.id}"]`) as HTMLElement | null;
if (el && el.offsetTop - 40 <= scrollTop) {
current = h.id;
}
}
if (current) activeMdHeading.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;
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 {
width: 480px;
flex-shrink: 0;
background: #fff;
border-radius: 8px;
border: 1px solid #e5e7eb;
display: flex;
overflow: hidden;
transition: width 0.25s ease;
&.md-outline-collapsed {
.md-outline-wrap { width: 20px; }
.md-outline-inner { display: none; }
}
}
.md-outline-wrap {
width: 140px;
flex-shrink: 0;
background: #fafafa;
border-right: 1px solid #f0f0f0;
display: flex;
position: relative;
transition: width 0.2s 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-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>

@ -4,7 +4,7 @@
<!-- 顶部导航栏 -->
<div class="page-header">
<div class="header-left">
<ElButton text :icon="ArrowLeft" @click="handleBack"></ElButton>
<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">
只读模式
@ -182,12 +182,13 @@ 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_energy_report/report";
import { ReportTemplateAPI, ChapterAnnotationAPI } from "@/api/module_report/report";
import type {
ReportTemplateItem,
ChapterAnnotationItem,
ChapterVariableDetail,
} from "@/api/module_energy_report/report";
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";
@ -201,6 +202,12 @@ const templateId = computed(() => {
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);
@ -238,7 +245,7 @@ async function loadTemplate() {
if (!templateId.value) return;
loading.value = true;
try {
const resp = await ReportTemplateAPI.detail(templateId.value);
const resp = await ReportTemplateAPI.detail(templateId.value, reportType.value);
const data = resp?.data?.data;
if (data) {
template.value = data;
@ -254,7 +261,7 @@ async function loadTemplate() {
async function loadAnnotations() {
if (!templateId.value) return;
try {
const resp = await ChapterAnnotationAPI.listByTemplate(templateId.value);
const resp = await ChapterAnnotationAPI.listByTemplate(templateId.value, reportType.value);
const list = resp?.data?.data;
annotations.value = Array.isArray(list) ? list : [];
} catch (e) {
@ -353,12 +360,13 @@ function truncatePrompt(text: string): string {
function goEdit() {
if (templateId.value) {
router.push(`/energy_report/template/edit?id=${templateId.value}`);
router.push({ name: "ReportTemplateEdit", query: { id: templateId.value, report_type: reportType.value } });
}
}
function handleBack() {
router.push("/energy_report/template");
function goBack() {
router.back();
// router.push({ path: "/report/template", query: { report_type: reportType.value } });
}
onMounted(async () => {

@ -265,13 +265,14 @@ import {
} 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_energy_report/report";
import { ReportTemplateAPI, ChapterAnnotationAPI } from "@/api/module_report/report";
import type {
ReportTemplateItem,
ChapterAnnotationItem,
ChapterConfigForm,
ChapterVariableDetail,
} from "@/api/module_energy_report/report";
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";
@ -285,6 +286,12 @@ const templateId = computed(() => {
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 saving = ref(false);
const configLoading = ref(false);
@ -368,7 +375,7 @@ async function loadTemplate() {
if (!templateId.value) return;
loading.value = true;
try {
const resp = await ReportTemplateAPI.detail(templateId.value);
const resp = await ReportTemplateAPI.detail(templateId.value, reportType.value);
const data = resp?.data?.data;
if (data) {
template.value = data;
@ -384,7 +391,7 @@ async function loadTemplate() {
async function loadAnnotations() {
if (!templateId.value) return;
try {
const resp = await ChapterAnnotationAPI.listByTemplate(templateId.value);
const resp = await ChapterAnnotationAPI.listByTemplate(templateId.value, reportType.value);
const list = resp?.data?.data;
annotations.value = Array.isArray(list) ? list : [];
} catch (e) {
@ -630,7 +637,8 @@ function truncatePrompt(text: string): string {
}
function handleBack() {
router.push("/energy_report/template");
router.back();
// router.push({ path: `/${reportType.value === 1 ? "energy-report" : "photovoltaic-report"}/template`, query: { report_type: reportType.value } });
}
onMounted(async () => {

@ -230,8 +230,8 @@
<script setup lang="ts">
defineOptions({ name: "EnergyReportTemplateList" });
import { ref, reactive, onMounted } from "vue";
import { useRouter } from "vue-router";
import { ref, reactive, computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
ElMessage,
ElMessageBox,
@ -253,15 +253,23 @@ import {
UploadFilled,
MoreFilled,
} from "@element-plus/icons-vue";
import { FileAPI, ReportTemplateAPI } from "@/api/module_energy_report/report";
import { FileAPI, ReportTemplateAPI } from "@/api/module_report/report";
import type {
ReportTemplateItem,
ReportTemplateCreateForm,
ReportTemplateUpdateForm,
} from "@/api/module_energy_report/report";
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("");
@ -276,6 +284,7 @@ async function loadList() {
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 ?? [];
@ -399,6 +408,7 @@ async function handleSubmitCreate() {
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);
@ -406,7 +416,7 @@ async function handleSubmitCreate() {
createVisible.value = false;
await loadList();
// if (data?.id) {
// router.push(`/energy_report/template/edit?id=${data.id}`);
// router.push({ name: "ReportTemplateEdit", query: { id: data.id, report_type: reportType.value } });
// }
} catch (e) {
} finally {
@ -416,11 +426,11 @@ async function handleSubmitCreate() {
// ========== ==========
function handleView(item: ReportTemplateItem) {
router.push(`/energy_report/template/detail?id=${item.id}`);
router.push({ name: "ReportTemplateDetail", query: { id: item.id, report_type: reportType.value } });
}
function handleEdit(item: ReportTemplateItem) {
router.push(`/energy_report/template/edit?id=${item.id}`);
router.push({ name: "ReportTemplateEdit", query: { id: item.id, report_type: reportType.value } });
}
// ========== ==========
@ -511,6 +521,7 @@ async function handleSubmitEdit() {
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);
@ -530,7 +541,7 @@ function handleDelete(item: ReportTemplateItem) {
})
.then(async () => {
try {
await ReportTemplateAPI.remove([item.id]);
await ReportTemplateAPI.remove([item.id], reportType.value);
loadList();
} catch (e) {}
})
Loading…
Cancel
Save