243 lines
12 KiB
Vue
243 lines
12 KiB
Vue
<script setup lang="ts">
|
||
import { ElMessage, ElMessageBox } from "element-plus";
|
||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||
|
||
import { api } from "../services/api";
|
||
import type { AgentBatchItem, AgentBatchJob, AgentBatchJobDetail } from "../types/api";
|
||
import AdminPagination from "./AdminPagination.vue";
|
||
import { formatDateTime } from "../utils/dateTime";
|
||
|
||
const props = defineProps<{
|
||
config: Record<string, unknown>;
|
||
modelName: string;
|
||
knowledgeSummary: string;
|
||
}>();
|
||
|
||
const fileInput = ref<HTMLInputElement | null>(null);
|
||
const selectedFile = ref<File | null>(null);
|
||
const taskName = ref("");
|
||
const executionMode = ref<"sequential" | "concurrent">("sequential");
|
||
const concurrencyLimit = ref(5);
|
||
const creating = ref(false);
|
||
const loading = ref(false);
|
||
const exportingId = ref<number | null>(null);
|
||
const jobs = ref<AgentBatchJob[]>([]);
|
||
const pager = reactive({ page: 1, pageSize: 10, total: 0 });
|
||
const detailOpen = ref(false);
|
||
const detailLoading = ref(false);
|
||
const selectedJob = ref<AgentBatchJobDetail | null>(null);
|
||
const detailPager = reactive({ page: 1, pageSize: 20, total: 0 });
|
||
let pollTimer: number | null = null;
|
||
|
||
const hasRunningJob = computed(() => jobs.value.some((job) => ["pending", "running"].includes(job.status)));
|
||
|
||
onMounted(() => void loadJobs());
|
||
onBeforeUnmount(stopPolling);
|
||
|
||
async function loadJobs(page = pager.page, pageSize = pager.pageSize, quiet = false) {
|
||
if (!quiet) loading.value = true;
|
||
try {
|
||
const result = await api.agentBatchJobs({ page, pageSize });
|
||
jobs.value = result.items;
|
||
Object.assign(pager, { page: result.page, pageSize: result.pageSize, total: result.total });
|
||
schedulePolling();
|
||
if (selectedJob.value && detailOpen.value) await loadDetail(selectedJob.value.id, detailPager.page, detailPager.pageSize, true);
|
||
} catch (error) {
|
||
if (!quiet) ElMessage.error(errorMessage(error, "批量测试任务加载失败"));
|
||
} finally {
|
||
if (!quiet) loading.value = false;
|
||
}
|
||
}
|
||
|
||
function schedulePolling() {
|
||
stopPolling();
|
||
if (!hasRunningJob.value) return;
|
||
pollTimer = window.setTimeout(() => void loadJobs(pager.page, pager.pageSize, true), 2500);
|
||
}
|
||
|
||
function stopPolling() {
|
||
if (pollTimer !== null) window.clearTimeout(pollTimer);
|
||
pollTimer = null;
|
||
}
|
||
|
||
function selectFile(event: Event) {
|
||
const input = event.target as HTMLInputElement;
|
||
selectedFile.value = input.files?.[0] || null;
|
||
if (selectedFile.value && !taskName.value.trim()) taskName.value = selectedFile.value.name.replace(/\.xlsx$/i, "");
|
||
}
|
||
|
||
function clearFile() {
|
||
selectedFile.value = null;
|
||
if (fileInput.value) fileInput.value.value = "";
|
||
}
|
||
|
||
async function downloadTemplate() {
|
||
try {
|
||
await api.downloadAgentBatchTemplate();
|
||
} catch (error) {
|
||
ElMessage.error(errorMessage(error, "模板下载失败"));
|
||
}
|
||
}
|
||
|
||
async function createJob() {
|
||
if (!selectedFile.value) return ElMessage.warning("请先选择填写好的 Excel 文件");
|
||
if (!String(props.config.modelId || "")) return ElMessage.warning("请先在调试配置中选择模型");
|
||
creating.value = true;
|
||
try {
|
||
const created = await api.createAgentBatch(selectedFile.value, {
|
||
...props.config,
|
||
name: taskName.value.trim(),
|
||
executionMode: executionMode.value,
|
||
concurrencyLimit: executionMode.value === "sequential" ? 1 : concurrencyLimit.value,
|
||
});
|
||
ElMessage.success(`已创建批量测试任务,共 ${created.totalCount} 个问题`);
|
||
clearFile();
|
||
taskName.value = "";
|
||
await loadJobs(1, pager.pageSize);
|
||
await openDetail(created);
|
||
} catch (error) {
|
||
ElMessage.error(errorMessage(error, "批量测试任务创建失败"));
|
||
} finally {
|
||
creating.value = false;
|
||
}
|
||
}
|
||
|
||
async function openDetail(job: AgentBatchJob) {
|
||
detailOpen.value = true;
|
||
detailPager.page = 1;
|
||
await loadDetail(job.id, 1, detailPager.pageSize);
|
||
}
|
||
|
||
async function loadDetail(jobId: number, page = detailPager.page, pageSize = detailPager.pageSize, quiet = false) {
|
||
if (!quiet) detailLoading.value = true;
|
||
try {
|
||
const result = await api.agentBatchJob(jobId, { page, pageSize });
|
||
selectedJob.value = result;
|
||
Object.assign(detailPager, { page: result.items.page, pageSize: result.items.pageSize, total: result.items.total });
|
||
} catch (error) {
|
||
if (!quiet) ElMessage.error(errorMessage(error, "任务详情加载失败"));
|
||
} finally {
|
||
if (!quiet) detailLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function exportJob(job: AgentBatchJob) {
|
||
exportingId.value = job.id;
|
||
try {
|
||
await api.exportAgentBatch(job.id);
|
||
ElMessage.success("测试结果已导出");
|
||
} catch (error) {
|
||
ElMessage.error(errorMessage(error, "结果导出失败"));
|
||
} finally {
|
||
exportingId.value = null;
|
||
}
|
||
}
|
||
|
||
async function cancelJob(job: AgentBatchJob) {
|
||
try {
|
||
await ElMessageBox.confirm("取消后尚未开始的问题不会继续生成,已完成结果仍可导出。", "取消批量测试", {
|
||
type: "warning", confirmButtonText: "确认取消", cancelButtonText: "继续执行",
|
||
});
|
||
} catch {
|
||
return;
|
||
}
|
||
try {
|
||
await api.cancelAgentBatch(job.id);
|
||
ElMessage.success("批量测试已取消");
|
||
await loadJobs(pager.page, pager.pageSize);
|
||
} catch (error) {
|
||
ElMessage.error(errorMessage(error, "取消失败"));
|
||
}
|
||
}
|
||
|
||
function statusLabel(status: string) {
|
||
return ({ pending: "等待执行", running: "生成中", completed: "已完成", completed_with_errors: "部分失败", cancelled: "已取消", success: "成功", failed: "失败" } as Record<string, string>)[status] || status;
|
||
}
|
||
|
||
function statusType(status: string) {
|
||
if (["completed", "success"].includes(status)) return "success";
|
||
if (["completed_with_errors", "failed"].includes(status)) return "danger";
|
||
if (status === "running") return "primary";
|
||
if (status === "cancelled") return "info";
|
||
return "warning";
|
||
}
|
||
|
||
function executionLabel(job: AgentBatchJob) {
|
||
return job.executionMode === "concurrent" ? `并发 · ${job.concurrencyLimit} 路` : "逐条串行";
|
||
}
|
||
|
||
function itemDuration(item: AgentBatchItem) {
|
||
return item.durationMs == null ? "—" : `${(item.durationMs / 1000).toFixed(1)} 秒`;
|
||
}
|
||
|
||
function errorMessage(error: unknown, fallback: string) {
|
||
return error instanceof Error ? error.message : fallback;
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="agent-batch-panel">
|
||
<div class="agent-section-intro">
|
||
<div><h3>Excel 批量测试</h3><p>普通管理员仅看到自己的任务,超级管理员可统一审计;创建时会固化当前提示词、模型、知识库和生成参数。</p></div>
|
||
<el-button @click="downloadTemplate">下载导入模板</el-button>
|
||
</div>
|
||
|
||
<section class="agent-batch-create-card">
|
||
<div class="agent-batch-config-summary">
|
||
<span><small>模型</small><strong>{{ modelName }}</strong></span>
|
||
<span><small>知识库</small><strong>{{ knowledgeSummary }}</strong></span>
|
||
<span><small>单次上限</small><strong>200 个问题</strong></span>
|
||
</div>
|
||
<div class="agent-batch-mode-setting">
|
||
<div class="agent-batch-mode-copy"><strong>执行方式</strong><span>串行适合日常回归;并发适合压测吞吐与稳定性,并发数会被系统安全上限约束。</span></div>
|
||
<el-radio-group v-model="executionMode" class="agent-batch-mode-options">
|
||
<el-radio-button value="sequential">逐条串行</el-radio-button>
|
||
<el-radio-button value="concurrent">并发测试</el-radio-button>
|
||
</el-radio-group>
|
||
<label v-if="executionMode === 'concurrent'" class="agent-batch-concurrency"><span>并发数</span><el-input-number v-model="concurrencyLimit" :min="2" :max="10" controls-position="right" /></label>
|
||
</div>
|
||
<div class="agent-batch-upload-row">
|
||
<label class="agent-batch-name"><span>任务名称</span><input v-model="taskName" maxlength="160" placeholder="例如:8月主提示词回归测试" /></label>
|
||
<label class="agent-batch-file" :class="{ selected: selectedFile }">
|
||
<input ref="fileInput" type="file" accept=".xlsx" @change="selectFile" />
|
||
<span>{{ selectedFile?.name || "选择填写好的 Excel 文件" }}</span>
|
||
<small>{{ selectedFile ? `${(selectedFile.size / 1024).toFixed(1)} KB` : "仅支持 .xlsx,最大 5MB" }}</small>
|
||
</label>
|
||
<el-button v-if="selectedFile" @click="clearFile">移除</el-button>
|
||
<el-button type="primary" :loading="creating" :disabled="!selectedFile" @click="createJob">开始批量测试</el-button>
|
||
</div>
|
||
</section>
|
||
|
||
<div class="agent-batch-list-head"><div><h4>测试记录</h4><p>任务在后台执行,离开页面不会中断。</p></div><el-button :loading="loading" @click="loadJobs()">刷新</el-button></div>
|
||
<el-table v-loading="loading" :data="jobs" stripe>
|
||
<el-table-column label="任务" min-width="190"><template #default="{ row }"><strong>{{ row.name }}</strong><small class="agent-batch-cell-note">#{{ row.id }} · {{ formatDateTime(row.createdAt, "—") }}</small></template></el-table-column>
|
||
<el-table-column label="配置快照" min-width="180"><template #default="{ row }"><span>{{ row.modelName }}</span><small class="agent-batch-cell-note">{{ row.knowledgeNames.join('、') }}</small></template></el-table-column>
|
||
<el-table-column label="进度" min-width="190"><template #default="{ row }"><el-progress :percentage="row.progress" :status="row.status === 'completed' ? 'success' : undefined" /><small class="agent-batch-cell-note">{{ row.processedCount }}/{{ row.totalCount }} · 成功 {{ row.successCount }} · 失败 {{ row.failedCount }}</small></template></el-table-column>
|
||
<el-table-column label="执行方式" width="118"><template #default="{ row }"><el-tag type="info">{{ executionLabel(row) }}</el-tag></template></el-table-column>
|
||
<el-table-column label="状态" width="108"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
|
||
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row)">查看</el-button><el-button link type="primary" :loading="exportingId === row.id" @click="exportJob(row)">导出</el-button><el-button v-if="['pending','running'].includes(row.status)" link type="danger" @click="cancelJob(row)">取消</el-button></template></el-table-column>
|
||
</el-table>
|
||
<AdminPagination :page="pager.page" :page-size="pager.pageSize" :total="pager.total" @change="loadJobs" />
|
||
</div>
|
||
|
||
<el-dialog v-model="detailOpen" width="min(1100px, 94vw)" title="批量测试详情" destroy-on-close>
|
||
<div v-loading="detailLoading" class="agent-batch-detail">
|
||
<template v-if="selectedJob">
|
||
<div class="agent-batch-detail-head">
|
||
<div><strong>{{ selectedJob.name }}</strong><span>{{ selectedJob.modelName }} · {{ selectedJob.knowledgeNames.join('、') }} · {{ executionLabel(selectedJob) }}</span></div>
|
||
<el-tag :type="statusType(selectedJob.status)">{{ statusLabel(selectedJob.status) }}</el-tag>
|
||
</div>
|
||
<el-table :data="selectedJob.items.items" stripe max-height="520">
|
||
<el-table-column prop="externalNo" label="序号" width="76" />
|
||
<el-table-column prop="question" label="问题" min-width="230" show-overflow-tooltip />
|
||
<el-table-column label="答案" min-width="360"><template #default="{ row }"><div class="agent-batch-answer">{{ row.answer || row.errorMessage || statusLabel(row.status) }}</div></template></el-table-column>
|
||
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag size="small" :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
|
||
<el-table-column label="耗时" width="92"><template #default="{ row }">{{ itemDuration(row) }}</template></el-table-column>
|
||
</el-table>
|
||
<AdminPagination :page="detailPager.page" :page-size="detailPager.pageSize" :total="detailPager.total" @change="(page, pageSize) => loadDetail(selectedJob!.id, page, pageSize)" />
|
||
</template>
|
||
</div>
|
||
<template #footer><el-button @click="detailOpen = false">关闭</el-button><el-button v-if="selectedJob" type="primary" :loading="exportingId === selectedJob.id" @click="exportJob(selectedJob)">导出 Excel</el-button></template>
|
||
</el-dialog>
|
||
</template>
|