feat: 新增报告生成任务管理功能

master
2 days ago
parent 1e9803bada
commit b0190e2c5f

@ -0,0 +1,237 @@
// 报告生成任务接口(能源报告 / 光伏报告)
import { request } from "@utils";
import type { ReportType } from "@/api/module_report/report";
const TASK_PATH = "/report-generation";
const LOG_PATH = "/report-generation/log";
// ============ 报告生成任务 ============
export const GenerationTaskAPI = {
/** 查询报告生成任务分页列表(返回 items + stats 统计) */
page(query?: GenerationTaskPageQuery) {
return request<ApiResponse<GenerationTaskListResult>>({
url: `${TASK_PATH}/list`,
method: "get",
params: query,
});
},
/** 查询报告生成任务详情 */
detail(id: number, report_type?: ReportType) {
return request<ApiResponse<GenerationTaskItem>>({
url: `${TASK_PATH}/detail/${id}`,
method: "get",
params: { report_type },
});
},
/** 创建报告生成任务(创建成功后后端自动写入 create_task 日志) */
create(body: GenerationTaskCreateForm) {
return request<ApiResponse<GenerationTaskItem>>({
url: `${TASK_PATH}/create`,
method: "post",
data: body,
});
},
/** 修改报告生成任务 */
update(id: number, body: GenerationTaskUpdateForm) {
return request<ApiResponse<GenerationTaskItem>>({
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
* SSE "生成进度"
* SSE EventSource / fetch stream
* / createLog generation_success / generation_failed
*/
execute(id: number, report_type?: ReportType) {
return request<ApiResponse<void>>({
url: `${TASK_PATH}/execute/${id}`,
method: "post",
params: { report_type },
});
},
// ============ 操作日志 ============
/** 查询操作记录列表 */
listLogs(params: GenerationLogPageQuery) {
return request<ApiResponse<PageResult<GenerationLogItem>>>({
url: `${LOG_PATH}/list`,
method: "get",
params,
});
},
/** 查询操作记录详情 */
detailLog(id: number) {
return request<ApiResponse<GenerationLogItem>>({
url: `${LOG_PATH}/detail/${id}`,
method: "get",
});
},
/** 创建操作记录(前端在生成动作时主动调用) */
createLog(body: GenerationLogCreateForm) {
return request<ApiResponse<GenerationLogItem>>({
url: `${LOG_PATH}/create`,
method: "post",
data: body,
});
},
/** 删除操作记录(传 ID 数组) */
removeLogs(ids: number[]) {
return request<ApiResponse>({
url: `${LOG_PATH}/delete`,
method: "delete",
data: ids,
});
},
};
export default GenerationTaskAPI;
// ============ 类型定义 ============
/** 生成状态0 待生成 / 1 生成中 / 2 已生成 / 3 生成失败 */
export type GenerationStatus = 0 | 1 | 2 | 3;
/** 报告生成任务分页查询参数 */
export interface GenerationTaskPageQuery {
page_no?: number;
page_size?: number;
order_by?: string;
generation_name?: string;
report_type?: ReportType;
status?: GenerationStatus | "";
template_id?: number;
generator_id?: number;
generator_name?: string;
created_time?: string[];
updated_time?: string[];
}
/** 报告生成任务列表项 */
export interface GenerationTaskItem {
id: number;
generation_name: string;
report_type?: ReportType;
template_id?: number;
template_name?: string;
/** 生成源文档文件 ID */
file_id?: number;
/** 附件文件 ID */
attachment_file_id?: number;
status: GenerationStatus;
/** 生成进度0-100由 SSE 推送更新 */
progress?: number;
generator_id?: number;
generator_name?: string;
description?: string;
/** 生成源文档地址 */
file_url?: string;
original_name?: string;
/** 生成结果文件地址(推荐前端预览/下载使用) */
report_file_url?: string;
report_file_original_name?: string;
/** 附件地址 */
attachment_url?: string;
attachment_original_name?: string;
attachment_file_url?: string;
attachment_file_original_name?: string;
created_id?: number;
created_time?: string;
updated_id?: number;
updated_time?: string;
}
/** 创建报告生成任务表单 */
export interface GenerationTaskCreateForm {
generation_name: string;
report_type?: ReportType;
template_id: number;
file_id?: number;
attachment_file_id?: number | null;
status?: GenerationStatus;
generator_id?: number;
description?: string;
}
/** 修改报告生成任务表单 */
export interface GenerationTaskUpdateForm extends GenerationTaskCreateForm {}
/** 任务统计(列表接口随 items 一起返回) */
export interface GenerationTaskStatistics {
submitted_count?: number;
generating_count?: number;
generated_count?: number;
pending_count?: number;
failed_count?: number;
}
/** 列表接口返回结构PageResult + stats */
export interface GenerationTaskListResult extends PageResult<GenerationTaskItem> {
stats?: GenerationTaskStatistics;
}
// ============ 操作日志类型 ============
/** 操作日志动作类型 */
export type GenerationLogActionType =
| "create_task"
| "start_generation"
| "generation_success"
| "generation_failed";
/** 操作记录分页查询参数 */
export interface GenerationLogPageQuery {
page_no?: number;
page_size?: number;
order_by?: string;
report_generation_id?: number;
generation_name?: string;
action_type?: GenerationLogActionType;
created_time?: string[];
created_id?: number;
}
/** 操作日志列表项 */
export interface GenerationLogItem {
id: number;
report_generation_id: number;
generation_name?: string;
action_type: GenerationLogActionType;
action_name: string;
description?: string;
operator_id?: number;
operator_name?: string;
created_id?: number;
created_by?: CommonType;
created_time?: string;
}
/** 创建操作记录表单(操作人由后端自动写入) */
export interface GenerationLogCreateForm {
report_generation_id: number;
action_type: GenerationLogActionType;
description?: string;
}

@ -0,0 +1,253 @@
<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="generation_name" required>
<ElInput v-model="form.generation_name" placeholder="请输入任务名称" :maxlength="128" />
</ElFormItem>
<ElFormItem label="选用模板" prop="template_id" required>
<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="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 { type FormInstance, type FormRules } from "element-plus";
import { GenerationTaskAPI } from "@/api/module_report/generation";
import { ReportTemplateAPI } from "@/api/module_report/report";
import type { GenerationTaskItem, GenerationTaskCreateForm, GenerationTaskUpdateForm } from "@/api/module_report/generation";
import type { ReportTemplateItem, ReportType } from "@/api/module_report/report";
defineOptions({ name: "GenerationFormDialog" });
const props = defineProps<{
visible: boolean;
editData?: GenerationTaskItem | 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 submitting = ref(false);
const templatesLoading = ref(false);
const templates = ref<ReportTemplateItem[]>([]);
const templatePageNo = ref(1);
const templatePageSize = 50;
const templateHasNext = ref(false);
const templateLoaded = ref(false);
const isEdit = computed(() => !!props.editData?.id);
const form = reactive<{
id: number;
generation_name: string;
template_id: number | null;
description: string;
}>({
id: 0,
generation_name: "",
template_id: null,
description: "",
});
const rules: FormRules = {
generation_name: [
{ required: true, message: "请输入任务名称", trigger: "blur" },
{ max: 128, message: "最多 128 个字符", trigger: "blur" },
],
template_id: [
{ required: true, message: "请选择文档模板", trigger: "change" },
],
};
watch(
() => props.visible,
(v) => {
if (v) {
resetForm();
if (props.editData) {
form.id = props.editData.id;
form.generation_name = props.editData.generation_name;
form.template_id = props.editData.template_id ?? null;
form.description = props.editData.description || "";
}
//
templateLoaded.value = false;
templatePageNo.value = 1;
templateHasNext.value = false;
templates.value = [];
loadTemplates(1);
}
}
);
function resetForm() {
form.id = 0;
form.generation_name = "";
form.template_id = null;
form.description = "";
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,
report_type: reportType.value,
});
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);
}
}
async function handleSubmit() {
try {
await formRef.value?.validate();
} catch {
return;
}
if (!form.template_id) {
return;
}
submitting.value = true;
try {
if (isEdit.value) {
const body: GenerationTaskUpdateForm = {
generation_name: form.generation_name.trim(),
template_id: form.template_id,
report_type: reportType.value,
description: form.description?.trim() || undefined,
};
await GenerationTaskAPI.update(form.id, body);
} else {
const body: GenerationTaskCreateForm = {
generation_name: form.generation_name.trim(),
template_id: form.template_id,
report_type: reportType.value,
description: form.description?.trim() || undefined,
};
await GenerationTaskAPI.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,214 @@
<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.generation_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_generation'"
>
<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 } from "vue";
import { User } from "@element-plus/icons-vue";
import { GenerationTaskAPI } from "@/api/module_report/generation";
import type { GenerationTaskItem, GenerationLogItem, GenerationLogActionType } from "@/api/module_report/generation";
defineOptions({ name: "GenerationLogDrawer" });
const props = defineProps<{
visible: boolean;
taskId?: number | null;
task?: GenerationTaskItem | null;
}>();
const emit = defineEmits<{
"update:visible": [v: boolean];
}>();
const loading = ref(false);
const logList = ref<GenerationLogItem[]>([]);
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 GenerationTaskAPI.listLogs({
report_generation_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: GenerationLogActionType): "primary" | "success" | "warning" | "danger" {
switch (t) {
case "create_task": return "primary";
case "start_generation": return "warning";
case "generation_success": return "success";
case "generation_failed": return "danger";
default: return "primary";
}
}
function logTagType(t: GenerationLogActionType): "primary" | "success" | "warning" | "danger" | "info" {
switch (t) {
case "create_task": return "primary";
case "start_generation": return "warning";
case "generation_success": return "success";
case "generation_failed": return "danger";
default: return "info";
}
}
function logStatusText(t: GenerationLogActionType): string {
switch (t) {
case "create_task": return "已创建";
case "start_generation": return "进行中";
case "generation_success": return "已生成";
case "generation_failed": 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,88 @@
<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.generation_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.generator_name || "-" }}
</ElDescriptionsItem>
<ElDescriptionsItem label="模板名称">
{{ task.template_name || "-" }}
</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>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import type { GenerationTaskItem } from "@/api/module_report/generation";
defineOptions({ name: "GenerationTaskDetailDialog" });
defineProps<{
visible: boolean;
task?: GenerationTaskItem | null;
}>();
const emit = defineEmits<{
"update:visible": [v: boolean];
}>();
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())}`;
}
</script>
<style lang="scss" scoped>
.progress-text {
font-size: 13px;
color: #606266;
&.error { color: #f56c6c; }
}
</style>

@ -0,0 +1,633 @@
<!-- 报告生成任务列表页 -->
<template>
<div class="generation-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.generated }}</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.generation_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.generator_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="generation_name" label="任务名称" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<ElLink type="primary" @click="handleDetail(row)">
{{ row.generation_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="generator_name" label="生成人" width="100" align="center">
<template #default="{ row }">{{ row.generator_name || "-" }}</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="handleExecute(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>
<!-- 新增/编辑任务弹窗 -->
<GenerationFormDialog
v-model:visible="formVisible"
:edit-data="currentEditItem"
@success="onFormSuccess"
/>
<!-- 任务详情弹窗 -->
<TaskDetailDialog
v-model:visible="detailVisible"
:task="currentDetailItem"
/>
<!-- 操作日志抽屉 -->
<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,
Document,
CircleCheck,
CircleClose,
Clock,
} from "@element-plus/icons-vue";
import { GenerationTaskAPI } from "@/api/module_report/generation";
import type { GenerationTaskItem, GenerationTaskPageQuery } from "@/api/module_report/generation";
import type { ReportType } from "@/api/module_report/report";
import GenerationFormDialog from "./components/GenerationFormDialog.vue";
import TaskDetailDialog from "./components/TaskDetailDialog.vue";
import LogDrawer from "./components/LogDrawer.vue";
defineOptions({ name: "ReportGenerationList" });
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<GenerationTaskItem[]>([]);
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,
generated: 0,
pending: 0,
failed: 0,
});
const queryParams = reactive<{
generation_name: string;
status: number | "";
created_time: string[] | null;
generator_name: string;
}>({
generation_name: "",
status: "",
created_time: null,
generator_name: "",
});
//
const formVisible = ref(false);
const detailVisible = ref(false);
const logVisible = ref(false);
const currentEditItem = ref<GenerationTaskItem | null>(null);
const currentDetailItem = ref<GenerationTaskItem | null>(null);
const currentLogTask = ref<GenerationTaskItem | 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: GenerationTaskPageQuery = {
page_no: pageNo.value,
page_size: pageSize.value,
generation_name: queryParams.generation_name?.trim() || undefined,
status: queryParams.status === "" ? undefined : (queryParams.status as any),
report_type: reportType.value,
generator_name: queryParams.generator_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 GenerationTaskAPI.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.generated = s?.generated_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.generation_name = "";
queryParams.status = "";
queryParams.created_time = null;
queryParams.generator_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: GenerationTaskItem) {
currentEditItem.value = row;
formVisible.value = true;
}
function onFormSuccess() {
loadList();
}
function handleDetail(row: GenerationTaskItem) {
currentDetailItem.value = row;
detailVisible.value = true;
}
function handleLog(row: GenerationTaskItem) {
currentLogTask.value = row;
logVisible.value = true;
}
function handleViewResult(row: GenerationTaskItem) {
router.push({ path: "/report/generation/preview", query: { id: row.id, report_type: reportType.value } });
}
function handleExecute(row: GenerationTaskItem) {
// TODO: SSE
ElMessageBox.confirm(`确定开始生成任务「${row.generation_name}」吗?`, "开始生成", {
type: "info",
confirmButtonText: "开始生成",
cancelButtonText: "取消",
})
.then(async () => {
try {
await GenerationTaskAPI.execute(row.id, reportType.value);
// start_generation
await GenerationTaskAPI.createLog({
report_generation_id: row.id,
action_type: "start_generation",
description: `开始生成:${row.generation_name}`,
});
// TODO: SSE
// createLog({ action_type: "generation_success", description: `${row.generation_name}` })
// createLog({ action_type: "generation_failed", description: `${row.generation_name}` })
ElMessage.success("生成任务已提交");
loadList();
} catch (e) {}
})
.catch(() => {});
}
function handleDelete(row: GenerationTaskItem) {
ElMessageBox.confirm(`确定要删除任务「${row.generation_name}」吗?`, "删除确认", {
type: "warning",
confirmButtonText: "删除",
cancelButtonText: "取消",
})
.then(async () => {
try {
await GenerationTaskAPI.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>
.generation-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;
}
.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,143 @@
<!-- 报告生成结果预览页单纯 docx 预览 -->
<template>
<div class="generation-preview-page">
<!-- 顶部工具栏 -->
<div class="toolbar">
<div class="toolbar-left">
<ElButton :icon="ArrowLeft" @click="goBack"></ElButton>
<span class="task-title">{{ task?.generation_name || "生成结果预览" }}</span>
<ElTag v-if="task" :type="statusTagType(task.status)" effect="light" size="small">
{{ statusText(task.status) }}
</ElTag>
</div>
</div>
<!-- 文档预览 -->
<div class="preview-area" v-loading="loading">
<FaDocxPreview
v-if="docxSrc"
:src="docxSrc"
:filename="task?.report_file_original_name || task?.generation_name || '生成结果.docx'"
:readonly="true"
class="docx-preview"
/>
<ElEmpty v-else-if="!loading" description="暂无生成结果文件" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ArrowLeft } from "@element-plus/icons-vue";
import FaDocxPreview from "@/components/business/fa-docx-preview/index.vue";
import { GenerationTaskAPI } from "@/api/module_report/generation";
import type { GenerationTaskItem } from "@/api/module_report/generation";
import type { ReportType } from "@/api/module_report/report";
defineOptions({ name: "ReportGenerationPreview" });
const route = useRoute();
const router = useRouter();
/** 报告类型1 能源报告 / 2 光伏报告(由路由参数传入) */
const reportType = computed<ReportType>(() => {
const t = route.query.report_type;
return t === "2" ? 2 : 1;
});
const taskId = computed(() => {
const id = route.query.id || route.params.id;
return id ? Number(id) : 0;
});
const loading = ref(false);
const task = ref<GenerationTaskItem | null>(null);
/**
* 预览源优先使用任务返回的生成结果文件地址
* 接口暂未提供生成结果时临时使用 public 目录下的 mock 文件fa-docx-preview 内部以 fetch 加载
* 接口接入后删除 mock 分支即可
*/
const docxSrc = computed(() => {
if (task.value?.report_file_url) return task.value.report_file_url;
// mock
return "/节能项目可研-202605301642.docx";
});
onMounted(() => {
if (taskId.value) {
loadTask(taskId.value);
}
});
async function loadTask(id: number) {
loading.value = true;
try {
const resp = await GenerationTaskAPI.detail(id, reportType.value);
task.value = resp?.data?.data ?? null;
} catch (e) {
task.value = null;
} finally {
loading.value = false;
}
}
function goBack() {
router.back();
}
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>
.generation-preview-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;
}
.preview-area {
flex: 1;
overflow: hidden;
padding: 12px;
display: flex;
}
:deep(.docx-preview) {
flex: 1;
height: 100%;
}
</style>
Loading…
Cancel
Save