补充监查访视问题模块内容、翻页功能UI统一

This commit is contained in:
Cheng Zhou
2026-02-28 09:51:13 +08:00
parent db2d38edbc
commit af0c1b4b7d
16 changed files with 2372 additions and 84 deletions
+25 -14
View File
@@ -32,14 +32,18 @@
:can-edit="canEdit"
@refresh="loadFaqs"
/>
<el-pagination
class="pagination"
layout="prev, pager, next"
:page-size="pageSize"
:current-page="page"
:total="total"
@current-change="onPageChange"
/>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[5, 10, 20]"
:total="total"
layout="prev, pager, next, sizes, total"
small
@current-change="onPageChange"
@size-change="onPageSizeChange"
/>
</div>
</div>
</el-col>
</el-row>
@@ -68,7 +72,7 @@ const categories = ref<any[]>([]);
const faqs = ref<any[]>([]);
const loading = ref(false);
const page = ref(1);
const pageSize = 10;
const pageSize = ref(10);
const total = ref(0);
const keyword = ref("");
const onlyActive = ref(true);
@@ -96,8 +100,8 @@ const loadFaqs = async () => {
loading.value = true;
try {
const params: Record<string, any> = {
skip: (page.value - 1) * pageSize,
limit: pageSize,
skip: (page.value - 1) * pageSize.value,
limit: pageSize.value,
study_id: study.currentStudy.id,
};
if (activeCategory.value) params.category_id = activeCategory.value;
@@ -123,6 +127,12 @@ const onPageChange = (p: number) => {
loadFaqs();
};
const onPageSizeChange = (size: number) => {
pageSize.value = size;
page.value = 1;
loadFaqs();
};
watch(activeCategory, () => {
page.value = 1;
loadFaqs();
@@ -161,8 +171,9 @@ onMounted(async () => {
.spacer {
flex: 1;
}
.pagination {
margin-top: 12px;
text-align: right;
.pagination-wrap {
margin-top: 14px;
display: flex;
justify-content: flex-end;
}
</style>
+90 -41
View File
@@ -4,17 +4,30 @@
<div class="audit-toolbar">
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item label="" class="filter-item-form">
<el-select v-model="filters.eventType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEvent" @change="loadLogs" class="filter-select-comp">
<el-select
v-model="filters.eventType"
clearable
:placeholder="TEXT.modules.adminAuditLogs.filterEvent"
@change="onServerFilterChange"
class="filter-select-comp"
>
<el-option v-for="opt in eventTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="" class="filter-item-form">
<el-select v-model="filters.operatorId" clearable :placeholder="TEXT.modules.adminAuditLogs.filterOperator" filterable @change="loadLogs" class="filter-select-comp">
<el-select
v-model="filters.operatorId"
clearable
:placeholder="TEXT.modules.adminAuditLogs.filterOperator"
filterable
@change="onServerFilterChange"
class="filter-select-comp"
>
<el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" />
</el-select>
</el-form-item>
<el-form-item label="" class="filter-item-form">
<el-select v-model="filters.result" clearable :placeholder="TEXT.modules.adminAuditLogs.filterResult" @change="loadLogs" class="filter-select-result">
<el-select v-model="filters.result" clearable :placeholder="TEXT.modules.adminAuditLogs.filterResult" @change="onLocalFilterChange" class="filter-select-result">
<el-option :label="TEXT.audit.resultSuccess" value="SUCCESS" />
<el-option :label="TEXT.audit.resultFail" value="FAIL" />
</el-select>
@@ -29,7 +42,7 @@
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
class="date-range-picker-comp"
@change="loadLogs"
@change="onLocalFilterChange"
/>
</el-form-item>
<div class="filter-spacer"></div>
@@ -89,14 +102,18 @@
</template>
</el-table-column>
</el-table>
<el-pagination
class="pagination"
layout="prev, pager, next"
:page-size="pageSize"
:current-page="page"
:total="total"
@current-change="onPageChange"
/>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[5, 10, 20]"
:total="total"
layout="prev, pager, next, sizes, total"
small
@current-change="onPageChange"
@size-change="onPageSizeChange"
/>
</div>
</div>
</el-card>
@@ -165,11 +182,11 @@ const router = useRouter();
const loading = ref(false);
const exportLoading = ref(false);
const rawLogs = ref<any[]>([]);
const logs = ref<any[]>([]);
const users = ref<any[]>([]);
const allLogs = ref<any[]>([]);
const page = ref(1);
const pageSize = 20;
const pageSize = ref(20);
const total = ref(0);
const detailVisible = ref(false);
const selectedLog = ref<any | null>(null);
@@ -224,17 +241,25 @@ const loadLogs = async () => {
if (!study.currentStudy) return;
loading.value = true;
try {
const batchLimit = 500;
let skip = 0;
const allItems: any[] = [];
const params: Record<string, any> = {
skip: (page.value - 1) * pageSize,
limit: pageSize,
skip,
limit: batchLimit,
action: filters.value.eventType || undefined,
operator_id: filters.value.operatorId || undefined,
};
const { data } = await fetchAuditLogs(study.currentStudy.id, params);
const items = Array.isArray(data) ? data : (data as any).items || [];
rawLogs.value = items;
total.value = (data as any).total || items.length;
enrichLogs();
while (true) {
params.skip = skip;
const { data } = await fetchAuditLogs(study.currentStudy.id, params);
const items = Array.isArray(data) ? data : (data as any).items || [];
allItems.push(...items);
if (items.length < batchLimit) break;
skip += items.length;
}
enrichLogs(allItems);
refreshPagedLogs();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminAuditLogs.loadFailed);
} finally {
@@ -242,30 +267,17 @@ const loadLogs = async () => {
}
};
const enrichLogs = () => {
const enrichLogs = (items: any[]) => {
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
acc[cur.id] = resolveUserDisplayName(cur);
return acc;
}, {});
const filtered = rawLogs.value.filter((log) => {
if (filters.value.eventType && String(log.action || "") !== filters.value.eventType) return false;
if (filters.value.operatorId && String(log.operator_id || "") !== filters.value.operatorId) return false;
if (filters.value.result && (log.result || "SUCCESS") !== filters.value.result) return false;
if (filters.value.range?.length === 2) {
const ts = new Date(log.created_at);
const start = new Date(filters.value.range[0]);
const end = new Date(filters.value.range[1]);
if (ts < start || ts > end) return false;
}
return true;
});
logs.value = filtered
allLogs.value = items
.map((log) => normalizeAuditEvent(log, userMap))
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, pageSize);
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
if (users.value.length === 0) {
const byActor = new Map<string, string>();
logs.value.forEach((log) => {
allLogs.value.forEach((log) => {
if (!byActor.has(log.actorId)) {
byActor.set(log.actorId, log.actorName || log.actorId);
}
@@ -274,9 +286,46 @@ const enrichLogs = () => {
}
};
const filterLogs = (items: any[]) =>
items.filter((log) => {
if (filters.value.result && (log.result || "SUCCESS") !== filters.value.result) return false;
if (filters.value.range?.length === 2) {
const ts = new Date(log.timestamp);
const start = new Date(filters.value.range[0]);
const end = new Date(filters.value.range[1]);
if (ts < start || ts > end) return false;
}
return true;
});
const refreshPagedLogs = () => {
const filtered = filterLogs(allLogs.value);
total.value = filtered.length;
const maxPage = Math.max(1, Math.ceil(total.value / pageSize.value));
if (page.value > maxPage) page.value = maxPage;
const start = (page.value - 1) * pageSize.value;
logs.value = filtered.slice(start, start + pageSize.value);
};
const onServerFilterChange = () => {
page.value = 1;
loadLogs();
};
const onLocalFilterChange = () => {
page.value = 1;
refreshPagedLogs();
};
const onPageChange = (p: number) => {
page.value = p;
loadLogs();
refreshPagedLogs();
};
const onPageSizeChange = (size: number) => {
pageSize.value = size;
page.value = 1;
refreshPagedLogs();
};
const getDiffPreview = (log: any) => {
@@ -459,8 +508,8 @@ onMounted(async () => {
color: #94a3b8;
}
.pagination {
margin-top: 12px;
.pagination-wrap {
margin-top: 14px;
display: flex;
justify-content: flex-end;
}
+20 -11
View File
@@ -71,15 +71,18 @@
</template>
</el-table-column>
</el-table>
<el-pagination
background
layout="prev, pager, next, total"
:page-size="pageSize"
:total="total"
v-model:current-page="page"
@current-change="loadUsers"
class="pagination"
/>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[5, 10, 20]"
:total="total"
layout="prev, pager, next, sizes, total"
small
@current-change="loadUsers"
@size-change="onPageSizeChange"
/>
</div>
</div>
</el-card>
<UserForm v-model:visible="formVisible" :user="editingUser" :admin-count="activeAdminCount" @saved="loadUsers" />
@@ -170,6 +173,12 @@ const loadUsers = async () => {
}
};
const onPageSizeChange = (size: number) => {
pageSize.value = size;
page.value = 1;
loadUsers();
};
const openCreate = () => {
editingUser.value = null;
formVisible.value = true;
@@ -309,8 +318,8 @@ onMounted(() => {
padding-bottom: 12px;
}
.pagination {
margin-top: 12px;
.pagination-wrap {
margin-top: 14px;
display: flex;
justify-content: flex-end;
}
@@ -1,13 +1,935 @@
<template>
<ModulePlaceholder
:title="TEXT.modules.riskIssueMonitoringVisits.title"
:subtitle="TEXT.modules.riskIssueMonitoringVisits.subtitle"
:list-title="TEXT.modules.riskIssueMonitoringVisits.listTitle"
:empty-description="TEXT.modules.riskIssueMonitoringVisits.emptyDescription"
/>
<div class="page">
<template v-if="study.currentStudy">
<el-card shadow="never" class="main-content-card unified-shell">
<div class="filter-container unified-action-bar">
<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">
<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>
<StateEmpty v-if="!loading" description="暂无监查访视问题" />
</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>
</el-card>
<el-drawer
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-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 ModulePlaceholder from "../../components/ModulePlaceholder.vue";
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;
padding: 16px 16px 0;
}
.filter-left {
flex: 1;
min-width: 580px;
}
.filter-form {
display: flex;
width: 100%;
gap: 12px;
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;
}
.table-section {
padding: 12px 0;
}
.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: 14px 16px 0;
display: flex;
justify-content: flex-end;
}
.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>
+6 -5
View File
@@ -106,13 +106,14 @@
</template>
</el-table-column>
</el-table>
<div class="table-pagination">
<div class="pagination-wrap" v-if="filteredItems.length > 0">
<el-pagination
v-model:current-page="pagination.currentPage"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50]"
:page-sizes="[5, 10, 20]"
:total="filteredItems.length"
layout="total, prev, pager, next, sizes"
layout="prev, pager, next, sizes, total"
small
/>
</div>
<StateEmpty v-if="!loading && sortedItems.length === 0" :description="TEXT.modules.subjectManagement.empty" />
@@ -354,10 +355,10 @@ onMounted(async () => {
background: #8b5cf6;
}
.table-pagination {
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 12px;
margin-top: 14px;
}
</style>