Files
ctms/frontend/src/views/ia/RiskIssueMonitoringVisits.vue
T

947 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page ctms-page-shell page--flush">
<template v-if="study.currentStudy">
<div class="main-content-flat unified-shell">
<div class="filter-container unified-action-bar bar--flush">
<div class="filter-left">
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item label="问题分类" class="filter-item">
<el-select v-model="filters.category" clearable placeholder="请选择" class="filter-select">
<el-option label="全部" value="" />
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item label="状态" class="filter-item">
<el-select v-model="filters.status" clearable placeholder="请选择" class="filter-select">
<el-option label="全部" value="" />
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="是否超期" class="filter-item">
<el-select v-model="filters.overdue" clearable placeholder="请选择" class="filter-select">
<el-option label="全部" value="" />
<el-option label="是" value="YES" />
<el-option label="否" value="NO" />
</el-select>
</el-form-item>
<el-form-item class="filter-actions">
<el-button type="primary" @click="handleSearch">{{ TEXT.common.actions.search }}</el-button>
<el-button link type="info" @click="handleReset">{{ TEXT.common.actions.reset }}</el-button>
</el-form-item>
</el-form>
</div>
<div class="toolbar-right">
<el-button type="primary" @click="openCreateDialog">新建</el-button>
<el-upload
ref="importUploadRef"
:auto-upload="false"
:show-file-list="false"
accept=".xlsx,.csv"
:on-change="onImportChange"
:disabled="importing"
>
<el-button :loading="importing">导入</el-button>
</el-upload>
<el-button :loading="exporting" @click="handleExportExcel">导出Excel</el-button>
</div>
</div>
<div class="unified-section table-section section--flush">
<el-table
class="issue-table"
:data="tableRows"
style="width: 100%"
v-loading="loading"
:fit="true"
:span-method="tableSpanMethod"
:row-class-name="tableRowClassName"
>
<el-table-column label="问题编号" min-width="130">
<template #default="{ row }">
<div v-if="isDescRow(row)" class="issue-desc">问题描述{{ row.description || TEXT.common.fallback }}</div>
<div v-else class="issue-no">{{ row.issue_no }}</div>
</template>
</el-table-column>
<el-table-column prop="open_duration" label="开放时长" min-width="130">
<template #default="{ row }">{{ isDescRow(row) ? "" : row.open_duration }}</template>
</el-table-column>
<el-table-column prop="category" label="问题分类" min-width="130">
<template #default="{ row }">{{ isDescRow(row) ? "" : row.category }}</template>
</el-table-column>
<el-table-column prop="subject_code" label="受试者" min-width="120">
<template #default="{ row }">{{ isDescRow(row) ? "" : row.subject_code || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column prop="monitor_item" label="监查项" min-width="130" show-overflow-tooltip>
<template #default="{ row }">{{ isDescRow(row) ? "" : row.monitor_item || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column prop="monitor_type" label="监查类型" min-width="130">
<template #default="{ row }">{{ isDescRow(row) ? "" : row.monitor_type || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column label="进度" min-width="120">
<template #default="{ row }">
<div v-if="!isDescRow(row)" class="progress-cell">
<span class="progress-dot" :class="`dot-${String(row.status).toLowerCase()}`"></span>
<span>{{ row.progress }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="操作" min-width="150" align="center">
<template #default="{ row }">
<template v-if="!isDescRow(row)">
<div class="action-cell">
<el-button class="action-btn" link type="primary" size="small" @click="openViewDialog(row)">查看</el-button>
<el-button class="action-btn" link type="primary" size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button class="action-btn" link type="danger" size="small" @click="removeIssue(row)">删除</el-button>
</div>
</template>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="issue-table-empty">暂无监查访视问题</div>
</template>
</el-table>
<div class="pagination-wrap" v-if="filteredItems.length > 0">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-sizes="[5, 10, 20]"
:total="filteredItems.length"
layout="prev, pager, next, sizes, total"
small
/>
</div>
</div>
</div>
<el-drawer
v-if="formDialogVisible"
v-model="formDialogVisible"
direction="rtl"
size="580px"
:close-on-click-modal="false"
:show-close="false"
class="issue-editor-drawer"
>
<template #header>
<div class="editor-header">
<div class="editor-title">{{ formMode === "create" ? "新建监查访视问题" : "编辑监查访视问题" }}</div>
<div class="editor-subtitle">
{{ formMode === "create" ? "记录监查发现问题与后续处理计划" : "更新问题状态、处理措施与时间节点" }}
</div>
</div>
</template>
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-position="top" class="issue-editor-form">
<div class="editor-group">
<div class="editor-group-title">
<span class="group-dot group-dot-basic"></span>
基础信息
</div>
<el-row :gutter="14">
<el-col :span="12">
<el-form-item label="问题编号">
<el-input v-model="createForm.issue_no" :placeholder="formMode === 'create' ? '可选,不填自动生成' : '请输入问题编号'" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="问题来源" prop="source">
<el-select v-model="createForm.source" class="dialog-select" placeholder="请选择">
<el-option v-for="item in sourceOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="监查类型">
<el-select v-model="createForm.monitor_type" class="dialog-select" placeholder="请选择">
<el-option v-for="item in monitorTypeOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="监查项">
<el-input v-model="createForm.monitor_item" placeholder="请输入" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="问题分类" prop="category">
<el-input v-model="createForm.category" placeholder="请输入" />
</el-form-item>
</el-col>
</el-row>
</div>
<div class="editor-group">
<div class="editor-group-title">
<span class="group-dot group-dot-issue"></span>
问题信息
</div>
<el-row :gutter="14">
<el-col :span="24">
<el-form-item label="建议措施">
<el-input v-model="createForm.recommendation" type="textarea" :rows="2" placeholder="请输入" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="受试者">
<el-input v-model="createForm.subject_name" placeholder="请输入" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="受试者缩写号">
<el-input v-model="createForm.subject_code" placeholder="请输入" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="问题描述" prop="description">
<el-input v-model="createForm.description" type="textarea" :rows="3" placeholder="请输入" />
</el-form-item>
</el-col>
</el-row>
</div>
<div class="editor-group">
<div class="editor-group-title">
<span class="group-dot group-dot-action"></span>
处理进展
</div>
<el-row :gutter="14">
<el-col :span="24">
<el-form-item label="采取措施">
<el-input v-model="createForm.action_taken" type="textarea" :rows="2" placeholder="请输入" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="跟进计划及进展">
<el-input v-model="createForm.follow_up_progress" type="textarea" :rows="2" placeholder="请输入" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="责任人">
<el-input v-model="createForm.responsible_name" placeholder="请输入" />
</el-form-item>
</el-col>
</el-row>
</div>
<div class="editor-group">
<div class="editor-group-title">
<span class="group-dot group-dot-time"></span>
时间节点
</div>
<el-row :gutter="14">
<el-col :span="12">
<el-form-item label="发现时间" prop="found_date">
<el-date-picker
v-model="createForm.found_date"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="请选择日期"
class="dialog-select"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="预计解决日期">
<el-date-picker
v-model="createForm.expected_resolve_date"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="请选择日期"
class="dialog-select"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="实际解决日期">
<el-date-picker
v-model="createForm.actual_resolve_date"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="请选择日期"
class="dialog-select"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="关闭日期">
<el-date-picker
v-model="createForm.closed_date"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="请选择日期"
class="dialog-select"
/>
</el-form-item>
</el-col>
</el-row>
</div>
</el-form>
<template #footer>
<div class="editor-footer">
<el-button @click="formDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="saving" @click="submitForm">{{ TEXT.common.actions.save }}</el-button>
</div>
</template>
</el-drawer>
<el-dialog v-if="viewDialogVisible" v-model="viewDialogVisible" title="监查访视问题详情" width="680px">
<el-descriptions :column="2" border>
<el-descriptions-item label="问题编号">{{ viewIssue?.issue_no || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="问题来源">{{ viewIssue?.source || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="监查类型">{{ viewIssue?.monitor_type || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="监查项">{{ viewIssue?.monitor_item || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="问题分类">{{ viewIssue?.category || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="建议措施">{{ viewIssue?.recommendation || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="受试者">{{ viewIssue?.subject_name || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="受试者缩写号">{{ viewIssue?.subject_code || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="进度">{{ viewIssue?.progress || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="创建者">{{ viewIssue?.creator_name || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="责任人">{{ viewIssue?.responsible_name || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ displayDateTime(viewIssue?.created_at) }}</el-descriptions-item>
<el-descriptions-item label="发现时间">{{ viewIssue?.found_date || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="开放时长">{{ viewIssue?.open_duration || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="预计解决日期">{{ displayDateTime(viewIssue?.due_at) }}</el-descriptions-item>
<el-descriptions-item label="实际解决日期">{{ viewIssue?.actual_resolve_date || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="关闭日期">{{ displayDateTime(viewIssue?.closed_at) }}</el-descriptions-item>
<el-descriptions-item label="问题描述" :span="2">{{ viewIssue?.description || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="采取措施" :span="2">{{ viewIssue?.action_taken || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="跟进计划及进展" :span="2">{{ viewIssue?.follow_up_progress || TEXT.common.fallback }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
</template>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules, type UploadFile, type UploadInstance } from "element-plus";
import {
createMonitoringVisitIssue,
deleteMonitoringVisitIssue,
exportMonitoringVisitIssues,
getMonitoringVisitIssue,
importMonitoringVisitIssues,
listMonitoringVisitIssues,
updateMonitoringVisitIssue,
} from "../../api/monitoringVisitIssues";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
import { useStudyStore } from "../../store/study";
import { displayDateTime } from "../../utils/display";
interface MonitoringIssueRow {
id: string;
issue_no: string;
open_duration: string;
category: string;
subject_name?: string | null;
subject_code: string | null;
monitor_item: string | null;
monitor_type: string | null;
progress: string;
status: "OPEN" | "CLOSED";
source: string | null;
creator_name: string | null;
recommendation?: string | null;
created_at: string;
description: string | null;
action_taken?: string | null;
follow_up_progress?: string | null;
found_date?: string | null;
actual_resolve_date?: string | null;
closed_at?: string | null;
responsible_name?: string | null;
overdue: boolean;
due_at?: string | null;
}
type MonitoringIssueTableRow = MonitoringIssueRow & { __rowType: "data" | "desc" };
const study = useStudyStore();
const loading = ref(false);
const saving = ref(false);
const importing = ref(false);
const exporting = ref(false);
const formDialogVisible = ref(false);
const viewDialogVisible = ref(false);
const formMode = ref<"create" | "edit">("create");
const editingIssueId = ref("");
const createFormRef = ref<FormInstance>();
const importUploadRef = ref<UploadInstance>();
const allItems = ref<MonitoringIssueRow[]>([]);
const viewIssue = ref<MonitoringIssueRow | null>(null);
const filters = reactive({
category: "",
status: "",
overdue: "",
});
const appliedFilters = reactive({
category: "",
status: "",
overdue: "",
});
const pagination = reactive({
page: 1,
pageSize: 10,
});
const createForm = reactive({
issue_no: "",
source: "监查",
monitor_type: "监查访视",
monitor_item: "",
category: "",
recommendation: "",
subject_name: "",
subject_code: "",
description: "",
action_taken: "",
follow_up_progress: "",
found_date: "",
expected_resolve_date: "",
actual_resolve_date: "",
closed_date: "",
responsible_name: "",
});
const createRules: FormRules = {
source: [{ required: true, message: "请选择问题来源", trigger: "change" }],
category: [{ required: true, message: "请输入问题分类", trigger: "blur" }],
description: [{ required: true, message: "请输入问题描述", trigger: "blur" }],
found_date: [{ required: true, message: "请选择发现时间", trigger: "change" }],
};
const sourceOptions = ["监查", "稽查", "其他"];
const monitorTypeOptions = ["监查访视", "远程监查", "现场核查", "稽查", "其他"];
const statusOptions = [
{ label: "开放中", value: "OPEN" },
{ label: "已关闭", value: "CLOSED" },
];
const categoryOptions = computed(() =>
Array.from(new Set(allItems.value.map((item) => item.category))).sort((a, b) => a.localeCompare(b, "zh-CN"))
);
const filteredItems = computed(() =>
allItems.value.filter((item) => {
if (appliedFilters.category && item.category !== appliedFilters.category) return false;
if (appliedFilters.status && item.status !== appliedFilters.status) return false;
if (appliedFilters.overdue === "YES" && !item.overdue) return false;
if (appliedFilters.overdue === "NO" && item.overdue) return false;
return true;
})
);
const pagedItems = computed(() => {
const start = (pagination.page - 1) * pagination.pageSize;
return filteredItems.value.slice(start, start + pagination.pageSize);
});
const tableRows = computed<MonitoringIssueTableRow[]>(() =>
pagedItems.value.flatMap((item) => [
{ ...item, __rowType: "data" as const },
{ ...item, __rowType: "desc" as const },
])
);
const isDescRow = (row: MonitoringIssueTableRow | MonitoringIssueRow) => (row as MonitoringIssueTableRow).__rowType === "desc";
const tableRowClassName = ({ row }: { row: MonitoringIssueTableRow }) => (isDescRow(row) ? "issue-desc-table-row" : "");
const tableSpanMethod = ({ row, columnIndex }: { row: MonitoringIssueTableRow; columnIndex: number }) => {
if (!isDescRow(row)) return { rowspan: 1, colspan: 1 };
if (columnIndex === 0) return { rowspan: 1, colspan: 8 };
return { rowspan: 0, colspan: 0 };
};
const resetCreateForm = () => {
createForm.issue_no = "";
createForm.source = "监查";
createForm.monitor_type = "监查访视";
createForm.monitor_item = "";
createForm.category = "";
createForm.recommendation = "";
createForm.subject_name = "";
createForm.subject_code = "";
createForm.description = "";
createForm.action_taken = "";
createForm.follow_up_progress = "";
createForm.found_date = "";
createForm.expected_resolve_date = "";
createForm.actual_resolve_date = "";
createForm.closed_date = "";
createForm.responsible_name = "";
createFormRef.value?.clearValidate();
};
const fillFormFromIssue = (issue: MonitoringIssueRow) => {
createForm.issue_no = issue.issue_no || "";
createForm.source = issue.source || "监查";
createForm.monitor_type = issue.monitor_type || "监查访视";
createForm.monitor_item = issue.monitor_item || "";
createForm.category = issue.category || "";
createForm.recommendation = issue.recommendation || "";
createForm.subject_name = issue.subject_name || "";
createForm.subject_code = issue.subject_code || "";
createForm.description = issue.description || "";
createForm.action_taken = issue.action_taken || "";
createForm.follow_up_progress = issue.follow_up_progress || "";
createForm.found_date = issue.found_date ? String(issue.found_date).slice(0, 10) : "";
createForm.expected_resolve_date = issue.due_at ? String(issue.due_at).slice(0, 10) : "";
createForm.actual_resolve_date = issue.actual_resolve_date ? String(issue.actual_resolve_date).slice(0, 10) : "";
createForm.closed_date = issue.closed_at ? String(issue.closed_at).slice(0, 10) : "";
createForm.responsible_name = issue.responsible_name || "";
createFormRef.value?.clearValidate();
};
const loadIssues = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) {
allItems.value = [];
return;
}
loading.value = true;
try {
const { data } = await listMonitoringVisitIssues(studyId, { limit: 2000 });
allItems.value = Array.isArray(data) ? data : [];
} catch (e: any) {
allItems.value = [];
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
}
};
const handleSearch = () => {
appliedFilters.category = filters.category;
appliedFilters.status = filters.status;
appliedFilters.overdue = filters.overdue;
pagination.page = 1;
};
const handleReset = () => {
filters.category = "";
filters.status = "";
filters.overdue = "";
handleSearch();
};
const openCreateDialog = () => {
resetCreateForm();
formMode.value = "create";
editingIssueId.value = "";
formDialogVisible.value = true;
};
const openEditDialog = async (row: MonitoringIssueRow) => {
const studyId = study.currentStudy?.id;
if (!studyId || !row?.id) return;
try {
const { data } = await getMonitoringVisitIssue(studyId, row.id);
const issue = (data || row) as MonitoringIssueRow;
formMode.value = "edit";
editingIssueId.value = row.id;
fillFormFromIssue(issue);
formDialogVisible.value = true;
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.loadFailed);
}
};
const openViewDialog = async (row: MonitoringIssueRow) => {
const studyId = study.currentStudy?.id;
if (!studyId || !row?.id) return;
try {
const { data } = await getMonitoringVisitIssue(studyId, row.id);
viewIssue.value = (data || row) as MonitoringIssueRow;
viewDialogVisible.value = true;
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.loadFailed);
}
};
const submitForm = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
const ok = await createFormRef.value?.validate().catch(() => false);
if (!ok) return;
saving.value = true;
try {
const resolvedStatus = createForm.closed_date ? "CLOSED" : "OPEN";
const payload = {
issue_no: createForm.issue_no || undefined,
source: createForm.source || undefined,
monitor_type: createForm.monitor_type || undefined,
monitor_item: createForm.monitor_item || undefined,
category: createForm.category,
recommendation: createForm.recommendation || undefined,
subject_name: createForm.subject_name || undefined,
subject_code: createForm.subject_code || undefined,
description: createForm.description || undefined,
action_taken: createForm.action_taken || undefined,
follow_up_progress: createForm.follow_up_progress || undefined,
found_date: createForm.found_date || undefined,
due_at: createForm.expected_resolve_date ? `${createForm.expected_resolve_date}T00:00:00` : undefined,
actual_resolve_date: createForm.actual_resolve_date || undefined,
closed_at: createForm.closed_date ? `${createForm.closed_date}T00:00:00` : undefined,
responsible_name: createForm.responsible_name || undefined,
status: resolvedStatus,
};
if (formMode.value === "create") {
await createMonitoringVisitIssue(studyId, payload);
ElMessage.success(TEXT.common.messages.createSuccess);
} else if (editingIssueId.value) {
await updateMonitoringVisitIssue(studyId, editingIssueId.value, payload);
ElMessage.success(TEXT.common.messages.updateSuccess);
}
formDialogVisible.value = false;
await loadIssues();
handleSearch();
} catch (e: any) {
ElMessage.error(
e?.response?.data?.detail || (formMode.value === "create" ? TEXT.common.messages.createFailed : TEXT.common.messages.updateFailed)
);
} finally {
saving.value = false;
}
};
const removeIssue = async (row: MonitoringIssueRow) => {
const studyId = study.currentStudy?.id;
if (!studyId || !row?.id) return;
const confirmed = await ElMessageBox.confirm(`确认删除问题「${row.issue_no}」?`, TEXT.common.labels.tips).catch(() => null);
if (!confirmed) return;
try {
await deleteMonitoringVisitIssue(studyId, row.id);
ElMessage.success(TEXT.common.messages.deleteSuccess);
await loadIssues();
handleSearch();
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.deleteFailed);
}
};
const getFilename = (header?: string | null) => {
if (!header) return null;
const match = /filename\*=UTF-8''([^;]+)|filename="?([^\";]+)"?/i.exec(header);
if (!match) return null;
return decodeURIComponent(match[1] || match[2] || "");
};
const handleExportExcel = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (filteredItems.value.length === 0) {
ElMessage.warning("暂无可导出数据");
return;
}
exporting.value = true;
try {
const params: Record<string, any> = {};
if (appliedFilters.category) params.category = appliedFilters.category;
if (appliedFilters.status) params.status = appliedFilters.status;
if (appliedFilters.overdue === "YES") params.overdue = true;
if (appliedFilters.overdue === "NO") params.overdue = false;
const response = await exportMonitoringVisitIssues(studyId, params);
const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || "监查访视问题.xlsx";
const blob = new Blob([response.data], { type: contentType });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
ElMessage.success("导出成功");
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.downloadFailed);
} finally {
exporting.value = false;
}
};
const onImportChange = async (uploadFile: UploadFile) => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
const file = uploadFile.raw;
if (!file) return;
importing.value = true;
try {
const { data } = await importMonitoringVisitIssues(studyId, file);
const summary = data || {};
const skippedTip = summary.skipped_count ? `,跳过 ${summary.skipped_count} 条` : "";
ElMessage.success(`导入完成:新增 ${summary.created_count || 0} 条,更新 ${summary.updated_count || 0}${skippedTip}`);
if (Array.isArray(summary.skipped_rows) && summary.skipped_rows.length > 0) {
ElMessage.warning(`部分行未导入:${summary.skipped_rows.slice(0, 3).join("")}`);
}
await loadIssues();
handleSearch();
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.uploadFailed);
} finally {
importing.value = false;
importUploadRef.value?.clearFiles();
}
};
watch(
() => study.currentStudy?.id,
() => {
handleReset();
loadIssues();
},
{ immediate: true }
);
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
}
.main-content-card :deep(.el-card__body) {
padding: 0;
}
.filter-container {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.filter-left {
flex: 1;
min-width: 580px;
}
.filter-form {
display: flex;
width: 100%;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.filter-item {
display: flex;
align-items: center;
margin-bottom: 0 !important;
margin-right: 0 !important;
}
.filter-select {
width: 150px;
}
.filter-actions {
display: flex;
align-items: center;
margin-bottom: 0 !important;
margin-right: 0 !important;
}
.toolbar-right {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.issue-no {
font-weight: 600;
color: #24324a;
}
.issue-desc {
margin: 0;
color: #71839f;
font-size: 12px;
}
:deep(.issue-desc-table-row .el-table__cell) {
background: #fbfdff;
}
.progress-cell {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.progress-dot {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
}
.action-cell {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
white-space: nowrap;
}
.action-btn {
padding: 0;
min-width: auto;
}
:deep(.action-cell .el-button + .el-button) {
margin-left: 0;
}
.dot-open {
background: #409eff;
}
.dot-closed {
background: #67c23a;
}
.pagination-wrap {
margin: 10px 0 0;
display: flex;
justify-content: flex-end;
}
.issue-table-empty {
min-height: 220px;
display: flex;
align-items: center;
justify-content: center;
color: #8a97ab;
font-size: 14px;
font-weight: 500;
letter-spacing: 0.02em;
}
.issue-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
.dialog-select {
width: 100%;
}
:deep(.issue-editor-drawer > .el-drawer__header) {
margin-bottom: 0;
padding: 16px 18px 10px;
border-bottom: 1px solid #edf2f8;
}
:deep(.issue-editor-drawer > .el-drawer__body) {
padding: 14px 18px 4px;
background: #f8fbff;
}
:deep(.issue-editor-drawer > .el-drawer__footer) {
padding: 12px 18px;
border-top: 1px solid #edf2f8;
background: #ffffff;
}
.editor-header {
display: flex;
flex-direction: column;
gap: 2px;
}
.editor-title {
font-size: 18px;
font-weight: 700;
color: #15315b;
}
.editor-subtitle {
font-size: 12px;
color: #7a8aa5;
}
.issue-editor-form {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 560px;
margin: 0 auto;
width: 100%;
}
.editor-group {
border: 1px solid #e6edf7;
border-radius: 12px;
background: #ffffff;
padding: 12px 12px 2px;
}
.editor-group-title {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: 14px;
font-weight: 700;
color: #203a63;
}
.group-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.group-dot-basic {
background: #409eff;
}
.group-dot-issue {
background: #e6a23c;
}
.group-dot-action {
background: #67c23a;
}
.group-dot-time {
background: #909399;
}
.editor-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
@media (max-width: 768px) {
:deep(.issue-editor-drawer) {
width: 100% !important;
}
}
@media (max-width: 1200px) {
.filter-left {
min-width: 100%;
}
}
</style>