You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

669 lines
19 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<!-- 能源报告审核任务列表页 -->
<template>
<div class="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"
:default-tab="logDefaultTab"
:start-review="logStartReview"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { 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";
import { getFileUrl } from "@/utils/file";
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 logDefaultTab = ref<"review" | "operation">("operation");
const logStartReview = 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: getFileUrl(row?.file?.bucket_name! + '/' + row?.file?.file_path! || ''),
name: row.original_name || "审核文档",
};
docPreviewVisible.value = true;
}
function handleLog(row: ReviewTaskItem) {
currentLogTask.value = row;
logStartReview.value = false;
logDefaultTab.value = "operation";
logVisible.value = true;
}
function handleViewResult(row: ReviewTaskItem) {
router.push({ name: "ReportReviewResult", query: { id: row.id, report_type: reportType.value } });
}
function handleReview(row: ReviewTaskItem) {
ElMessageBox.confirm(`确定开始审查任务「${row.task_name}」吗?`, "开始审查", {
type: "info",
confirmButtonText: "开始审查",
cancelButtonText: "取消",
})
.then(() => {
// 审查接口暂未提供,演示阶段确认后直接打开日志抽屉并展示审查日志
currentLogTask.value = row;
logStartReview.value = true;
logDefaultTab.value = "review";
logVisible.value = true;
})
.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>