feat: 新增能源报告模板管理模块,规则关联绑定操作

master^2
2 days ago
parent ff02452556
commit 3502a333d6

@ -0,0 +1,302 @@
// 能源报告模块接口
// 包含文件上传通用、能源报告模板CRUD
import { request } from "@utils";
const FILE_PATH = "/common/file";
const TEMPLATE_PATH = "/attachment-template";
// ============ 文件上传(通用) ============
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 = {
/** 查询模板分页列表 */
page(query?: ReportTemplatePageQuery) {
return request<ApiResponse<PageResult<ReportTemplateItem>>>({
url: `${TEMPLATE_PATH}/list`,
method: "get",
params: query,
});
},
/** 查询模板详情 */
detail(id: number) {
return request<ApiResponse<ReportTemplateItem>>({
url: `${TEMPLATE_PATH}/detail/${id}`,
method: "get",
});
},
/** 创建模板 */
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[]) {
return request<ApiResponse>({
url: `${TEMPLATE_PATH}/delete`,
method: "delete",
data: ids,
});
},
};
// ============ 章节标注与变量绑定 ============
const CHAPTER_PATH = "/chapter";
const CONFIG_PATH = "/attachment-template/config/chapter";
const VARIABLE_PATH = "/variable/chapter";
const ChapterAnnotationAPI = {
/** 查询模板下全部章节规则标注 */
listByTemplate(templateId: number) {
return request<ApiResponse<ChapterAnnotationItem[]>>({
url: `${CHAPTER_PATH}/list_by_template`,
method: "get",
params: { template_id: templateId },
});
},
/** 点击标注查看变量绑定 */
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;
/** 文件记录对象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;
page_size?: number;
order_by?: string;
template_name?: string;
template_type?: string;
version?: string;
status?: ReportTemplateStatus;
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;
order?: number;
description?: string;
/** 文件记录对象 */
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;
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,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,698 @@
<!-- 能源报告模板详情页只读 -->
<template>
<div class="template-detail-page">
<!-- 顶部导航栏 -->
<div class="page-header">
<div class="header-left">
<ElButton text :icon="ArrowLeft" @click="handleBack"></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_energy_report/report";
import type {
ReportTemplateItem,
ChapterAnnotationItem,
ChapterVariableDetail,
} from "@/api/module_energy_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;
});
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);
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);
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(`/energy_report/template/edit?id=${templateId.value}`);
}
}
function handleBack() {
router.push("/energy_report/template");
}
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,749 @@
<!-- 能源报告模板列表页卡片视图 -->
<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, onMounted } from "vue";
import { 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_energy_report/report";
import type {
ReportTemplateItem,
ReportTemplateCreateForm,
ReportTemplateUpdateForm,
} from "@/api/module_energy_report/report";
const router = useRouter();
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,
});
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,
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(`/energy_report/template/edit?id=${data.id}`);
// }
} catch (e) {
} finally {
submitting.value = false;
}
}
// ========== ==========
function handleView(item: ReportTemplateItem) {
router.push(`/energy_report/template/detail?id=${item.id}`);
}
function handleEdit(item: ReportTemplateItem) {
router.push(`/energy_report/template/edit?id=${item.id}`);
}
// ========== ==========
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,
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]);
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>
Loading…
Cancel
Save