Files
ctms/frontend/src/views/ia/RiskIssueMonitoringVisits.vue
T
chengchengzhou7 4863ade45b
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
build(release): 加固 v0.1.0 桌面发布链路 (#3)
2026-07-17 09:36:16 +08:00

1517 lines
52 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="issue-workspace">
<section class="template-filter-panel">
<div class="panel-strip"></div>
<el-form :model="filters" class="template-filter-grid" label-position="left" label-width="96px">
<el-form-item label="项目/中心">
<el-select
v-model="filters.siteId"
clearable
filterable
:loading="sitesLoading"
placeholder="请选择项目/中心"
@change="handleSiteChange"
>
<template #prefix>
<el-icon><Location /></el-icon>
</template>
<el-option label="全部中心" value="" />
<el-option v-for="site in siteOptions" :key="site.id" :label="site.name" :value="site.id" />
</el-select>
</el-form-item>
<el-form-item label="受试者筛选号">
<el-input v-model="filters.keyword" clearable placeholder="请输入受试者筛选号" />
</el-form-item>
<el-form-item label="分类">
<el-select v-model="filters.category" clearable filterable placeholder="请选择分类">
<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="状态">
<el-select v-model="filters.status" clearable placeholder="请选择状态">
<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="是否完成整改">
<el-select v-model="filters.rectificationCompleted" clearable placeholder="请选择是否完成整改">
<el-option label="全部" value="" />
<el-option label="是" value="YES" />
<el-option label="否" value="NO" />
</el-select>
</el-form-item>
<el-form-item label="严重程度">
<el-select v-model="filters.severity" clearable placeholder="请选择严重程度">
<el-option label="全部" value="" />
<el-option v-for="item in severityOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item label="是否超期">
<el-select v-model="filters.overdue" clearable placeholder="请选择是否超期">
<el-option label="全部" value="" />
<el-option label="是" value="YES" />
<el-option label="否" value="NO" />
</el-select>
</el-form-item>
<el-form-item label="标记">
<el-input v-model="filters.mark" clearable placeholder="请输入标记" />
</el-form-item>
<el-form-item label="截止时间">
<el-date-picker
v-model="filters.dueRange"
type="daterange"
start-placeholder="开始日期"
end-placeholder="结束日期"
range-separator="-"
value-format="YYYY-MM-DD"
/>
</el-form-item>
<el-form-item label="创建时间" class="filter-actions-item">
<div class="date-action-row">
<el-date-picker
v-model="filters.createdRange"
type="daterange"
start-placeholder="开始日期"
end-placeholder="结束日期"
range-separator="-"
value-format="YYYY-MM-DD"
/>
<div class="filter-actions">
<el-button type="primary" @click="handleSearch">{{ TEXT.common.actions.search }}</el-button>
<el-button @click="handleReset">{{ TEXT.common.actions.reset }}</el-button>
</div>
</div>
</el-form-item>
</el-form>
</section>
<section class="stats-grid" aria-label="监查访视问题统计">
<div v-for="card in statsCards" :key="card.label" class="stat-card">
<div class="stat-accent" :class="card.className"></div>
<div class="stat-label">{{ card.label }}</div>
<div class="stat-value" :class="card.className">{{ card.value }}</div>
</div>
</section>
<section class="issue-table-panel">
<div class="table-panel-header">
<div>
<div class="table-panel-title">监查访视问题列表</div>
</div>
<div class="table-panel-count">{{ filteredItems.length }} </div>
</div>
<div class="template-toolbar">
<div class="toolbar-left">
<el-button v-if="canCreateIssue" class="toolbar-button toolbar-button-primary" type="primary" @click="openCreateDialog">
<el-icon><Plus /></el-icon>
<span>新增</span>
</el-button>
<el-button class="toolbar-button toolbar-button-danger" type="danger" plain disabled>
<el-icon><Delete /></el-icon>
<span>删除</span>
</el-button>
<el-button class="toolbar-button toolbar-button-success" type="success" plain disabled>
<el-icon><CircleCheck /></el-icon>
<span>一键完成</span>
</el-button>
<el-button
v-if="canListIssues"
class="toolbar-button toolbar-button-success"
type="success"
plain
:loading="exporting"
@click="handleExportExcel"
>
<el-icon><Download /></el-icon>
<span>导出</span>
</el-button>
<el-button class="toolbar-button" plain disabled>
<el-icon><Document /></el-icon>
<span>问题导入模板</span>
</el-button>
<el-button
v-if="canCreateIssue"
class="toolbar-button toolbar-button-warning"
type="warning"
plain
:loading="importing"
@click="selectImportFile"
>
<el-icon><Upload /></el-icon>
<span>导入问题</span>
</el-button>
</div>
<div class="toolbar-icons">
<el-button circle title="刷新" @click="loadIssues">
<el-icon><Refresh /></el-icon>
</el-button>
<el-button circle title="搜索">
<el-icon><Search /></el-icon>
</el-button>
</div>
</div>
<div class="table-scroll">
<el-table
class="issue-table"
:data="tableRows"
style="width: 100%"
v-loading="loading"
:fit="true"
table-layout="fixed"
>
<el-table-column type="selection" width="46" />
<el-table-column prop="project_center" label="项目/中心" min-width="130" show-overflow-tooltip />
<el-table-column prop="subject_code" label="受试者筛选号" min-width="130" show-overflow-tooltip />
<el-table-column prop="visit_cycle" label="访视周期" min-width="110" show-overflow-tooltip />
<el-table-column prop="category" label="分类" min-width="120" show-overflow-tooltip />
<el-table-column prop="description" label="问题内容" min-width="210" show-overflow-tooltip />
<el-table-column prop="recommendation" label="建议" min-width="190" show-overflow-tooltip />
<el-table-column prop="center_query" label="中心质疑" min-width="180" show-overflow-tooltip />
<el-table-column prop="center_latest_reply" label="中心最新回复" min-width="180" show-overflow-tooltip />
<el-table-column v-if="canReadIssue || canUpdateIssue || canDeleteIssue" label="操作" width="140" align="center">
<template #default="{ row }">
<div class="action-cell">
<el-button v-if="canReadIssue" class="action-btn" link type="primary" size="small" @click="openViewDialog(row)">查看</el-button>
<el-button v-if="canUpdateIssue" class="action-btn" link type="primary" size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button v-if="canDeleteIssue" class="action-btn" link type="danger" size="small" @click="removeIssue(row)">删除</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="issue-table-empty">暂无监查访视问题</div>
</template>
</el-table>
</div>
<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>
</section>
</div>
<el-drawer
v-if="formDialogVisible"
v-model="formDialogVisible"
direction="rtl"
size="580px"
:close-on-click-modal="true"
:before-close="formDirtyGuard.beforeClose"
: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="24">
<el-form-item label="项目/中心">
<el-select v-model="createForm.site_id" class="dialog-select" clearable filterable placeholder="请选择项目/中心">
<el-option v-for="site in siteOptions" :key="site.id" :label="site.name" :value="site.id" />
</el-select>
</el-form-item>
</el-col>
<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-col :span="12">
<el-form-item label="严重程度">
<el-select v-model="createForm.severity" class="dialog-select" clearable placeholder="请选择">
<el-option v-for="item in severityOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="访视周期">
<el-input v-model="createForm.visit_cycle" placeholder="请输入" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="标记">
<el-input v-model="createForm.mark" 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-col :span="24">
<el-form-item label="中心质疑">
<el-input v-model="createForm.center_query" type="textarea" :rows="2" placeholder="请输入" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="中心最新回复">
<el-input v-model="createForm.center_latest_reply" type="textarea" :rows="2" 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-col :span="24">
<el-form-item label="是否完成整改">
<el-switch v-model="createForm.rectification_completed" active-text="是" inactive-text="否" />
</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 v-if="canSaveIssue" 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?.severity || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="访视周期">{{ viewIssue?.visit_cycle || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="标记">{{ viewIssue?.mark || 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="是否完成整改">{{ viewIssue?.rectification_completed ? "是" : "否" }}</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?.center_query || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="中心最新回复" :span="2">{{ viewIssue?.center_latest_reply || 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 } from "element-plus";
import { CircleCheck, Delete, Document, Download, Location, Plus, Refresh, Search, Upload } from "@element-plus/icons-vue";
import {
createMonitoringVisitIssue,
deleteMonitoringVisitIssue,
exportMonitoringVisitIssues,
getMonitoringVisitIssue,
importMonitoringVisitIssues,
listMonitoringVisitIssues,
updateMonitoringVisitIssue,
} from "../../api/monitoringVisitIssues";
import { fetchSites } from "../../api/sites";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
import { pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import type { Site } from "../../types/api";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { displayDateTime } from "../../utils/display";
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import { getContentDispositionFilename, getHttpHeaderString } from "../../utils/contentDisposition";
interface MonitoringIssueRow {
id: string;
site_id?: string | null;
issue_no: string;
open_duration: string;
category: string;
severity?: string | null;
mark?: string | null;
visit_cycle?: string | null;
project_center?: string | null;
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;
center_query?: string | null;
center_latest_reply?: string | null;
rectification_completed: boolean;
found_date?: string | null;
actual_resolve_date?: string | null;
closed_at?: string | null;
responsible_name?: string | null;
overdue: boolean;
due_at?: string | null;
}
type DateRange = [string, string] | [];
const auth = useAuthStore();
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 allItems = ref<MonitoringIssueRow[]>([]);
const viewIssue = ref<MonitoringIssueRow | null>(null);
const sitesLoading = ref(false);
const siteOptions = ref<Array<Pick<Site, "id" | "name" | "is_active">>>([]);
const siteMap = ref<Record<string, string>>({});
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
const canUseApiPermission = (operationKey: string) => {
if (isSystemAdmin(auth.user)) return true;
const role = projectRole.value;
if (!role) return false;
return isApiPermissionAllowed(study.currentPermissions?.[role]?.[operationKey]);
};
const canListIssues = computed(() => canUseApiPermission("monitoring_issues:read"));
const canReadIssue = computed(() => canUseApiPermission("monitoring_issues:read"));
const canCreateIssue = computed(() => canUseApiPermission("monitoring_issues:create"));
const canUpdateIssue = computed(() => canUseApiPermission("monitoring_issues:update"));
const canDeleteIssue = computed(() => canUseApiPermission("monitoring_issues:delete"));
const canSaveIssue = computed(() => (formMode.value === "edit" ? canUpdateIssue.value : canCreateIssue.value));
const currentProjectCenter = computed(() => study.currentStudy?.name || study.currentStudy?.project_full_name || "");
const filters = reactive({
siteId: study.currentSite?.id || "",
keyword: "",
category: "",
severity: "",
mark: "",
status: "",
overdue: "",
rectificationCompleted: "",
dueRange: [] as DateRange,
createdRange: [] as DateRange,
});
const appliedFilters = reactive({
siteId: study.currentSite?.id || "",
keyword: "",
category: "",
severity: "",
mark: "",
status: "",
overdue: "",
rectificationCompleted: "",
dueRange: [] as DateRange,
createdRange: [] as DateRange,
});
const selectedProjectCenter = computed(() => {
const siteId = filters.siteId || study.currentSite?.id || "";
const matched = siteOptions.value.find((site) => site.id === siteId);
return matched?.name || currentProjectCenter.value || TEXT.common.fallback;
});
const pagination = reactive({
page: 1,
pageSize: 10,
});
const createForm = reactive({
site_id: "",
issue_no: "",
source: "监查",
monitor_type: "监查访视",
monitor_item: "",
category: "",
severity: "",
mark: "",
visit_cycle: "",
recommendation: "",
subject_name: "",
subject_code: "",
description: "",
action_taken: "",
follow_up_progress: "",
center_query: "",
center_latest_reply: "",
rectification_completed: false,
found_date: "",
expected_resolve_date: "",
actual_resolve_date: "",
closed_date: "",
responsible_name: "",
});
const formDirtyGuard = useDrawerDirtyGuard(() => createForm);
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 defaultSeverityOptions = ["严重", "中等", "轻微"];
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 severityOptions = computed(() => {
const values = Array.from(new Set([...defaultSeverityOptions, ...allItems.value.map((item) => item.severity || "").filter(Boolean)]));
return values.sort((a, b) => a.localeCompare(b, "zh-CN"));
});
const inDateRange = (value: string | null | undefined, range: DateRange) => {
if (!range.length) return true;
if (!value) return false;
const dateText = String(value).slice(0, 10);
return dateText >= range[0] && dateText <= range[1];
};
const filteredItems = computed(() =>
allItems.value.filter((item) => {
const keyword = appliedFilters.keyword.trim().toLowerCase();
if (keyword) {
const haystack = [item.subject_code, item.issue_no, item.description, item.recommendation, item.center_query, item.center_latest_reply]
.map((value) => String(value || "").toLowerCase())
.join(" ");
if (!haystack.includes(keyword)) return false;
}
if (appliedFilters.category && item.category !== appliedFilters.category) return false;
if (appliedFilters.severity && item.severity !== appliedFilters.severity) return false;
if (appliedFilters.mark && !String(item.mark || "").includes(appliedFilters.mark)) 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;
if (appliedFilters.rectificationCompleted === "YES" && !item.rectification_completed) return false;
if (appliedFilters.rectificationCompleted === "NO" && item.rectification_completed) return false;
if (appliedFilters.siteId && item.site_id !== appliedFilters.siteId) return false;
if (!inDateRange(item.due_at, appliedFilters.dueRange)) return false;
if (!inDateRange(item.created_at, appliedFilters.createdRange)) return false;
return true;
})
);
const statsCards = computed(() => [
{ label: "问题总数", value: filteredItems.value.length, className: "stat-total" },
{ label: "待整改问题", value: filteredItems.value.filter((item) => !item.rectification_completed).length, className: "stat-pending" },
{ label: "超期数量", value: filteredItems.value.filter((item) => item.overdue).length, className: "stat-overdue" },
{
label: "本月新增",
value: filteredItems.value.filter((item) => String(item.created_at || "").slice(0, 7) === new Date().toISOString().slice(0, 7)).length,
className: "stat-month",
},
]);
const pagedItems = computed(() => {
const start = (pagination.page - 1) * pagination.pageSize;
return filteredItems.value.slice(start, start + pagination.pageSize);
});
const tableRows = computed<MonitoringIssueRow[]>(() =>
pagedItems.value.map((item) => ({
...item,
project_center: item.project_center || (item.site_id ? siteMap.value[item.site_id] : "") || selectedProjectCenter.value,
}))
);
const loadSites = async (studyId: string) => {
sitesLoading.value = true;
try {
const { data } = await fetchSites(studyId, { limit: 500 });
const list = Array.isArray(data) ? data : data.items || [];
siteOptions.value = list.map((site: Site) => ({ id: site.id, name: site.name, is_active: site.is_active }));
siteMap.value = siteOptions.value.reduce((acc: Record<string, string>, site) => {
acc[site.id] = site.name;
return acc;
}, {});
if (filters.siteId && !siteOptions.value.some((site) => site.id === filters.siteId)) {
filters.siteId = "";
study.setCurrentSite(null);
}
} catch {
siteOptions.value = [];
siteMap.value = {};
} finally {
sitesLoading.value = false;
}
};
const handleSiteChange = (siteId: string) => {
const matched = siteOptions.value.find((site) => site.id === siteId) || null;
study.setCurrentSite(matched);
appliedFilters.siteId = siteId || "";
};
const resetCreateForm = () => {
createForm.site_id = filters.siteId || study.currentSite?.id || "";
createForm.issue_no = "";
createForm.source = "监查";
createForm.monitor_type = "监查访视";
createForm.monitor_item = "";
createForm.category = "";
createForm.severity = "";
createForm.mark = "";
createForm.visit_cycle = "";
createForm.recommendation = "";
createForm.subject_name = "";
createForm.subject_code = "";
createForm.description = "";
createForm.action_taken = "";
createForm.follow_up_progress = "";
createForm.center_query = "";
createForm.center_latest_reply = "";
createForm.rectification_completed = false;
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.site_id = issue.site_id || "";
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.severity = issue.severity || "";
createForm.mark = issue.mark || "";
createForm.visit_cycle = issue.visit_cycle || "";
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.center_query = issue.center_query || "";
createForm.center_latest_reply = issue.center_latest_reply || "";
createForm.rectification_completed = Boolean(issue.rectification_completed);
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.siteId = filters.siteId;
appliedFilters.keyword = filters.keyword;
appliedFilters.category = filters.category;
appliedFilters.severity = filters.severity;
appliedFilters.mark = filters.mark;
appliedFilters.status = filters.status;
appliedFilters.overdue = filters.overdue;
appliedFilters.rectificationCompleted = filters.rectificationCompleted;
appliedFilters.dueRange = [...filters.dueRange] as DateRange;
appliedFilters.createdRange = [...filters.createdRange] as DateRange;
pagination.page = 1;
};
const handleReset = () => {
filters.siteId = "";
filters.keyword = "";
filters.category = "";
filters.severity = "";
filters.mark = "";
filters.status = "";
filters.overdue = "";
filters.rectificationCompleted = "";
filters.dueRange = [];
filters.createdRange = [];
study.setCurrentSite(null);
handleSearch();
};
const resetFiltersForStudy = () => {
filters.siteId = study.currentSite?.id || "";
filters.keyword = "";
filters.category = "";
filters.severity = "";
filters.mark = "";
filters.status = "";
filters.overdue = "";
filters.rectificationCompleted = "";
filters.dueRange = [];
filters.createdRange = [];
handleSearch();
};
const openCreateDialog = () => {
if (!canCreateIssue.value) return;
resetCreateForm();
formMode.value = "create";
editingIssueId.value = "";
formDirtyGuard.syncBaseline();
formDialogVisible.value = true;
};
const openEditDialog = async (row: MonitoringIssueRow) => {
const studyId = study.currentStudy?.id;
if (!studyId || !row?.id || !canUpdateIssue.value) return;
try {
const { data } = await getMonitoringVisitIssue(studyId, row.id);
const issue = (data || row) as MonitoringIssueRow;
formMode.value = "edit";
editingIssueId.value = row.id;
fillFormFromIssue(issue);
formDirtyGuard.syncBaseline();
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 || !canReadIssue.value) 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;
if (!canSaveIssue.value) {
ElMessage.warning("权限不足");
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 = {
site_id: createForm.site_id || null,
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,
severity: createForm.severity || undefined,
mark: createForm.mark || undefined,
visit_cycle: createForm.visit_cycle || undefined,
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,
center_query: createForm.center_query || undefined,
center_latest_reply: createForm.center_latest_reply || undefined,
rectification_completed: createForm.rectification_completed,
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 || !canDeleteIssue.value) 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 handleExportExcel = async () => {
const studyId = study.currentStudy?.id;
if (!studyId || !canListIssues.value) return;
if (filteredItems.value.length === 0) {
ElMessage.warning("暂无可导出数据");
return;
}
exporting.value = true;
try {
const params: Record<string, any> = {};
if (appliedFilters.siteId) params.site_id = appliedFilters.siteId;
if (appliedFilters.category) params.category = appliedFilters.category;
if (appliedFilters.severity) params.severity = appliedFilters.severity;
if (appliedFilters.mark) params.mark = appliedFilters.mark;
if (appliedFilters.status) params.status = appliedFilters.status;
if (appliedFilters.overdue === "YES") params.overdue = true;
if (appliedFilters.overdue === "NO") params.overdue = false;
if (appliedFilters.rectificationCompleted === "YES") params.rectification_completed = true;
if (appliedFilters.rectificationCompleted === "NO") params.rectification_completed = false;
if (appliedFilters.keyword) params.keyword = appliedFilters.keyword;
if (appliedFilters.dueRange.length === 2) {
params.due_from = appliedFilters.dueRange[0];
params.due_to = appliedFilters.dueRange[1];
}
if (appliedFilters.createdRange.length === 2) {
params.created_from = appliedFilters.createdRange[0];
params.created_to = appliedFilters.createdRange[1];
}
const response = await exportMonitoringVisitIssues(studyId, params);
const contentType = getHttpHeaderString(response.headers?.["content-type"]) || "application/octet-stream";
const filename =
getContentDispositionFilename(getHttpHeaderString(response.headers?.["content-disposition"])) || "监查访视问题.xlsx";
const blob = new Blob([response.data], { type: contentType });
await saveFileWithFeedback(
{ suggestedName: filename, mimeType: contentType, data: blob },
{
kind: "export",
title: "导出文件",
completedDetail: "导出文件已保存",
},
);
ElMessage.success("导出成功");
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.downloadFailed);
} finally {
exporting.value = false;
}
};
const importIssueFile = async (file: File) => {
const studyId = study.currentStudy?.id;
if (!studyId || !canCreateIssue.value) 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;
}
};
const selectImportFile = async () => {
const [file] = await pickFilesWithFeedback({
multiple: false,
accept: ["xlsx", "csv"],
title: "导入监查访视问题",
});
if (file) await importIssueFile(file);
};
watch(
() => study.currentStudy?.id,
(studyId) => {
resetFiltersForStudy();
if (studyId) {
loadSites(studyId);
} else {
siteOptions.value = [];
}
loadIssues();
},
{ immediate: true }
);
watch(
() => study.currentSite,
(site) => {
filters.siteId = site?.id || "";
appliedFilters.siteId = filters.siteId;
}
);
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
min-height: 100%;
background:
linear-gradient(180deg, #f8fbff 0, #f3f6fb 220px, #f5f7fb 100%);
}
.issue-workspace {
display: flex;
flex-direction: column;
gap: 10px;
padding: 8px 8px 12px;
}
.template-filter-panel,
.issue-table-panel {
background: #ffffff;
border: 1px solid #dbe5f1;
border-radius: 12px;
box-shadow: 0 12px 28px rgba(36, 57, 85, 0.055);
}
.template-filter-panel {
position: relative;
overflow: hidden;
padding: 16px 18px 14px 22px;
}
.panel-strip {
position: absolute;
inset: 0 auto 0 0;
width: 5px;
background: #405a73;
}
.template-filter-grid {
display: grid;
grid-template-columns: repeat(4, minmax(210px, 1fr));
column-gap: 18px;
row-gap: 10px;
width: 100%;
}
.template-filter-grid :deep(.el-form-item) {
margin-bottom: 0;
}
.template-filter-grid :deep(.el-form-item__label) {
color: #536174;
font-size: 12px;
font-weight: 700;
padding-right: 8px;
}
.template-filter-grid :deep(.el-input__wrapper),
.template-filter-grid :deep(.el-select__wrapper) {
min-height: 30px;
border-radius: 8px;
}
.template-filter-grid :deep(.el-input__inner),
.template-filter-grid :deep(.el-select__placeholder),
.template-filter-grid :deep(.el-range-input) {
font-size: 12px;
}
.template-filter-grid :deep(.el-date-editor .el-range-separator) {
font-size: 12px;
padding: 0 4px;
}
.template-filter-grid :deep(.el-select),
.template-filter-grid :deep(.el-input),
.template-filter-grid :deep(.el-date-editor) {
width: 100%;
}
.filter-actions-item {
grid-column: 1 / span 3;
}
.date-action-row {
display: grid;
grid-template-columns: minmax(280px, 1fr) auto;
gap: 10px;
width: 100%;
}
.filter-actions {
display: flex;
align-items: center;
gap: 8px;
}
.filter-actions :deep(.el-button) {
height: 30px;
min-width: 64px;
border-radius: 8px;
padding: 0 12px;
font-size: 12px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.stat-card {
position: relative;
height: 78px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 7px;
background: #ffffff;
border: 1px solid #dbe5f1;
border-radius: 8px;
box-shadow: 0 8px 20px rgba(36, 57, 85, 0.045);
}
.stat-accent {
position: absolute;
left: 0;
top: 14px;
bottom: 14px;
width: 4px;
border-radius: 0 4px 4px 0;
background: #303133;
}
.stat-label {
color: #7a8492;
font-size: 12px;
font-weight: 700;
}
.stat-value {
font-size: 26px;
line-height: 1;
font-weight: 800;
color: #303133;
}
.stat-pending {
color: #d89a28;
}
.stat-overdue {
color: #df6f6f;
}
.stat-month {
color: #2f8a2e;
}
.issue-table-panel {
padding: 12px 10px 12px;
min-height: 470px;
overflow: hidden;
}
.table-panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 10px;
}
.table-panel-title {
color: #25384f;
font-size: 14px;
font-weight: 800;
}
.table-panel-count {
min-width: 58px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
color: #405a73;
background: #f2f6fb;
border: 1px solid #dce6f2;
font-size: 11px;
font-weight: 800;
}
.template-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
margin-bottom: 8px;
padding: 8px;
border: 1px solid #edf2f7;
background: #fbfdff;
border-radius: 10px;
}
.toolbar-left,
.toolbar-icons,
.action-cell {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.toolbar-button {
min-width: 74px;
height: 30px;
border-radius: 8px;
padding: 0 10px;
font-size: 12px;
font-weight: 700;
}
.toolbar-button :deep(.el-icon) {
margin-right: 4px;
}
.toolbar-button-primary {
min-width: 78px;
color: #ffffff;
background: #405a73;
border-color: #405a73;
}
.toolbar-button-primary:hover,
.toolbar-button-primary:focus {
color: #ffffff;
background: #344b63;
border-color: #344b63;
}
.toolbar-button-success {
color: #4b9b36;
}
.toolbar-button-warning {
color: #d7902f;
}
.toolbar-button-danger {
color: #d56a6a;
}
.toolbar-icons :deep(.el-button) {
width: 30px;
height: 30px;
color: #405165;
border-color: #dfe7f2;
background: #ffffff;
}
.table-scroll {
width: 100%;
overflow-x: auto;
border: 1px solid #e7edf5;
border-radius: 8px;
background: #ffffff;
}
.action-cell {
justify-content: center;
white-space: nowrap;
}
.action-btn {
padding: 0;
min-width: auto;
}
:deep(.action-cell .el-button + .el-button) {
margin-left: 0;
}
.pagination-wrap {
margin: 8px 0 0;
display: flex;
justify-content: flex-end;
}
.issue-table-empty {
min-height: 330px;
display: flex;
align-items: center;
justify-content: center;
color: #8a97ab;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.02em;
}
.issue-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
.issue-table {
min-width: 1320px;
}
.issue-table :deep(.el-table__inner-wrapper) {
min-width: 1320px;
}
.issue-table :deep(.el-table__header th) {
background: #f6f8fb;
color: #697789;
height: 34px;
font-weight: 700;
font-size: 12px;
}
.issue-table :deep(.el-table__body td) {
height: 38px;
font-size: 12px;
}
.issue-table :deep(.el-table__cell) {
padding: 5px 0;
}
.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;
}
.issue-workspace {
padding: 6px;
gap: 8px;
}
.template-filter-panel,
.issue-table-panel {
border-radius: 12px;
}
.template-filter-panel {
padding: 12px 10px 12px 14px;
}
.template-filter-grid,
.date-action-row,
.stats-grid {
grid-template-columns: 1fr;
}
.filter-actions-item {
grid-column: auto;
}
.issue-table-panel {
padding: 10px 8px;
}
}
@media (max-width: 1200px) {
.template-filter-panel {
padding: 14px 16px 14px 20px;
}
.template-filter-grid {
grid-template-columns: repeat(2, minmax(220px, 1fr));
column-gap: 16px;
}
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>