1517 lines
52 KiB
Vue
1517 lines
52 KiB
Vue
<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>
|