发布候选:整合桌面端界面与发布稳定化里程碑
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled

This commit is contained in:
Cheng Zhou
2026-07-01 10:53:24 +08:00
parent b283cf1e5c
commit b491b6a146
132 changed files with 17337 additions and 2375 deletions
@@ -132,20 +132,17 @@
<el-icon><Document /></el-icon>
<span>问题导入模板</span>
</el-button>
<el-upload
<el-button
v-if="canCreateIssue"
ref="importUploadRef"
:auto-upload="false"
:show-file-list="false"
accept=".xlsx,.csv"
:on-change="onImportChange"
:disabled="importing"
class="toolbar-button toolbar-button-warning"
type="warning"
plain
:loading="importing"
@click="selectImportFile"
>
<el-button class="toolbar-button toolbar-button-warning" type="warning" plain :loading="importing">
<el-icon><Upload /></el-icon>
<span>导入问题</span>
</el-button>
</el-upload>
<el-icon><Upload /></el-icon>
<span>导入问题</span>
</el-button>
</div>
<div class="toolbar-icons">
<el-button circle title="刷新" @click="loadIssues">
@@ -455,7 +452,7 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules, type UploadFile, type UploadInstance } from "element-plus";
import { 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,
@@ -469,6 +466,7 @@ import {
import { fetchSites } from "../../api/sites";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
import { pickFiles, saveFile } from "../../runtime";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import type { Site } from "../../types/api";
@@ -523,7 +521,6 @@ 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 sitesLoading = ref(false);
@@ -981,14 +978,7 @@ const handleExportExcel = async () => {
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);
await saveFile({ suggestedName: filename, mimeType: contentType, data: blob });
ElMessage.success("导出成功");
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.downloadFailed);
@@ -997,11 +987,9 @@ const handleExportExcel = async () => {
}
};
const onImportChange = async (uploadFile: UploadFile) => {
const importIssueFile = async (file: File) => {
const studyId = study.currentStudy?.id;
if (!studyId || !canCreateIssue.value) return;
const file = uploadFile.raw;
if (!file) return;
importing.value = true;
try {
@@ -1018,10 +1006,18 @@ const onImportChange = async (uploadFile: UploadFile) => {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.uploadFailed);
} finally {
importing.value = false;
importUploadRef.value?.clearFiles();
}
};
const selectImportFile = async () => {
const [file] = await pickFiles({
multiple: false,
accept: ["xlsx", "csv"],
title: "导入监查访视问题",
});
if (file) await importIssueFile(file);
};
watch(
() => study.currentStudy?.id,
(studyId) => {
@@ -27,3 +27,15 @@ describe("SubjectManagement drawer editor", () => {
expect(source).not.toContain('router.push("/subjects/new")');
});
});
describe("SubjectManagement desktop list workflow", () => {
it("selects rows for preview and opens details on explicit desktop actions", () => {
const source = readSubjectManagementSource();
expect(source).toContain("@row-click=\"selectSubject\"");
expect(source).toContain("@row-dblclick=\"openSubjectDetail\"");
expect(source).toContain("@row-contextmenu=\"openSubjectContextMenu\"");
expect(source).toContain("subject-preview-pane");
expect(source).toContain("subject-context-menu");
});
});
+323 -59
View File
@@ -24,6 +24,7 @@
</div>
</div>
<div class="toolbar-right">
<span v-if="selectedRows.length" class="selection-count">已选 {{ selectedRows.length }} </span>
<el-button v-if="canCreateSubject" type="primary" @click="goNew" class="create-btn">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }}
@@ -31,77 +32,140 @@
</div>
</div>
<el-table
:data="pagedItems"
v-loading="loading"
style="width: 100%"
class="subject-table"
:row-class-name="subjectRowClass"
@row-click="onRowClick"
table-layout="fixed"
>
<el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" show-overflow-tooltip>
<template #default="scope">
<div class="subject-info-cell">
<span class="cell-mono cell-nowrap">{{ scope.row.subject_no || TEXT.common.fallback }}</span>
<span v-for="badge in getAeBadges(scope.row)" :key="badge.type" :class="['ae-badge', `ae-badge-${badge.type}`]">
<div class="subject-workbench">
<div class="subject-table-pane" tabindex="0" @keydown.enter.prevent="openSelectedSubject">
<el-table
:data="pagedItems"
v-loading="loading"
style="width: 100%"
class="subject-table"
:row-class-name="subjectRowClass"
highlight-current-row
@selection-change="onSelectionChange"
@row-click="selectSubject"
@row-dblclick="openSubjectDetail"
@row-contextmenu="openSubjectContextMenu"
table-layout="fixed"
>
<el-table-column type="selection" width="42" />
<el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" show-overflow-tooltip>
<template #default="scope">
<div class="subject-info-cell">
<span class="cell-mono cell-nowrap">{{ scope.row.subject_no || TEXT.common.fallback }}</span>
<span v-for="badge in getAeBadges(scope.row)" :key="badge.type" :class="['ae-badge', `ae-badge-${badge.type}`]">
{{ badge.label }}
</span>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.site" show-overflow-tooltip>
<template #default="scope"><span class="cell-nowrap">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</span></template>
</el-table-column>
<el-table-column prop="consent_date" :label="TEXT.common.fields.consentDate">
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.consent_date) }}</span></template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status">
<template #default="scope"><span class="cell-nowrap">{{ displaySubjectStatus(scope.row) }}</span></template>
</el-table-column>
<el-table-column prop="enrollment_date" :label="TEXT.common.fields.enrollmentDate">
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.enrollment_date) }}</span></template>
</el-table-column>
<el-table-column prop="completion_date" :label="TEXT.common.fields.completionDate">
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.completion_date) }}</span></template>
</el-table-column>
<el-table-column v-if="canDeleteSubject" :label="TEXT.common.labels.actions" width="90" fixed="right">
<template #default="scope">
<div class="cell-actions">
<el-button v-if="canDeleteSubject" link type="danger" size="small" :disabled="isInactiveSite(scope.row.site_id)" @click.stop="remove(scope.row)">
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="table-empty">
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<span>{{ TEXT.modules.subjectManagement.empty }}</span>
</div>
</template>
</el-table>
<div v-if="filteredItems.length > 0" class="pagination-wrap">
<el-pagination
v-model:current-page="pagination.currentPage"
v-model:page-size="pagination.pageSize"
:page-sizes="[5, 10, 20]"
:total="filteredItems.length"
layout="prev, pager, next, sizes, total"
small
/>
</div>
</div>
<aside class="subject-preview-pane">
<template v-if="selectedSubject">
<div class="preview-head">
<div>
<div class="preview-kicker">当前参与者</div>
<div class="preview-title">{{ selectedSubject.subject_no || TEXT.common.fallback }}</div>
</div>
<el-button size="small" type="primary" @click="openSubjectDetail(selectedSubject)">打开详情</el-button>
</div>
<dl class="preview-list">
<dt>{{ TEXT.common.fields.site }}</dt>
<dd>{{ siteMap[selectedSubject.site_id] || TEXT.common.fallback }}</dd>
<dt>{{ TEXT.common.fields.status }}</dt>
<dd>{{ displaySubjectStatus(selectedSubject) }}</dd>
<dt>{{ TEXT.common.fields.consentDate }}</dt>
<dd>{{ displayDate(selectedSubject.consent_date) }}</dd>
<dt>{{ TEXT.common.fields.enrollmentDate }}</dt>
<dd>{{ displayDate(selectedSubject.enrollment_date) }}</dd>
<dt>{{ TEXT.common.fields.completionDate }}</dt>
<dd>{{ displayDate(selectedSubject.completion_date) }}</dd>
</dl>
<div v-if="getAeBadges(selectedSubject).length" class="preview-badges">
<span v-for="badge in getAeBadges(selectedSubject)" :key="badge.type" :class="['ae-badge', `ae-badge-${badge.type}`]">
{{ badge.label }}
</span>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.site" show-overflow-tooltip>
<template #default="scope"><span class="cell-nowrap">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</span></template>
</el-table-column>
<el-table-column prop="consent_date" :label="TEXT.common.fields.consentDate">
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.consent_date) }}</span></template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status">
<template #default="scope"><span class="cell-nowrap">{{ displaySubjectStatus(scope.row) }}</span></template>
</el-table-column>
<el-table-column prop="enrollment_date" :label="TEXT.common.fields.enrollmentDate">
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.enrollment_date) }}</span></template>
</el-table-column>
<el-table-column prop="completion_date" :label="TEXT.common.fields.completionDate">
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.completion_date) }}</span></template>
</el-table-column>
<el-table-column v-if="canDeleteSubject" :label="TEXT.common.labels.actions" width="90" fixed="right">
<template #default="scope">
<div class="cell-actions">
<el-button v-if="canDeleteSubject" link type="danger" size="small" :disabled="isInactiveSite(scope.row.site_id)" @click.stop="remove(scope.row)">
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="table-empty">
<div v-else class="preview-empty">
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg>
</div>
<span>{{ TEXT.modules.subjectManagement.empty }}</span>
<span>选择一行查看详情摘要</span>
</div>
</template>
</el-table>
<div v-if="filteredItems.length > 0" class="pagination-wrap">
<el-pagination
v-model:current-page="pagination.currentPage"
v-model:page-size="pagination.pageSize"
:page-sizes="[5, 10, 20]"
:total="filteredItems.length"
layout="prev, pager, next, sizes, total"
small
/>
</aside>
</div>
</div>
<div
v-if="contextMenu.visible"
class="subject-context-menu"
:style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }"
@click.stop
>
<button type="button" @click="openSelectedSubject">打开详情</button>
<button type="button" @click="copySelectedSubjectNo">复制编号</button>
<button
v-if="canDeleteSubject"
type="button"
class="danger"
:disabled="!selectedSubject || isInactiveSite(selectedSubject.site_id)"
@click="selectedSubject && remove(selectedSubject)"
>
删除
</button>
</div>
<SubjectEditorDrawer v-model="subjectDrawerVisible" @success="handleSubjectEditorSuccess" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
@@ -114,6 +178,7 @@ import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { isSystemAdmin } from "../../utils/roles";
import { TEXT } from "../../locales";
import SubjectEditorDrawer from "../subjects/SubjectEditorDrawer.vue";
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
const router = useRouter();
const auth = useAuthStore();
@@ -121,6 +186,9 @@ const study = useStudyStore();
const loading = ref(false);
const subjectDrawerVisible = ref(false);
const items = ref<any[]>([]);
const selectedSubjectId = ref("");
const selectedRows = ref<any[]>([]);
const contextMenu = ref({ visible: false, x: 0, y: 0 });
const siteOptions = ref<Array<{ id: string; name: string }>>([]);
const siteMap = ref<Record<string, string>>({});
const siteActiveMap = ref<Record<string, boolean>>({});
@@ -133,6 +201,7 @@ const pagination = ref({
currentPage: 1,
pageSize: 20,
});
let desktopRefreshCleanup: (() => void) | undefined;
const statusOptions = Object.entries(TEXT.enums.subjectStatus).map(([value, label]) => ({ value, label }));
const resetFilters = () => {
filters.value.keyword = "";
@@ -185,8 +254,45 @@ const load = async () => {
const goNew = () => { subjectDrawerVisible.value = true; };
const goDetail = (id: string) => router.push(`/subjects/${id}`);
const handleSubjectEditorSuccess = () => { load(); };
const onRowClick = (row: any) => { if (!row?.id) return; goDetail(row.id); };
const selectedSubject = computed(() => items.value.find((item) => item.id === selectedSubjectId.value) || null);
const selectSubject = (row: any) => {
contextMenu.value.visible = false;
if (!row?.id) return;
selectedSubjectId.value = row.id;
};
const openSubjectDetail = (row: any) => {
if (!row?.id) return;
goDetail(row.id);
};
const openSelectedSubject = () => {
contextMenu.value.visible = false;
if (selectedSubject.value?.id) goDetail(selectedSubject.value.id);
};
const onSelectionChange = (rows: any[]) => {
selectedRows.value = rows;
};
const openSubjectContextMenu = (row: any, _column: unknown, event: MouseEvent) => {
event.preventDefault();
if (!row?.id) return;
selectedSubjectId.value = row.id;
contextMenu.value = {
visible: true,
x: event.clientX,
y: event.clientY,
};
};
const closeSubjectContextMenu = () => {
contextMenu.value.visible = false;
};
const copySelectedSubjectNo = async () => {
contextMenu.value.visible = false;
const subjectNo = selectedSubject.value?.subject_no;
if (!subjectNo) return;
await navigator.clipboard?.writeText(subjectNo);
ElMessage.success("编号已复制");
};
const remove = async (row: any) => {
contextMenu.value.visible = false;
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (!canDeleteSubject.value) return;
@@ -230,7 +336,11 @@ const pagedItems = computed(() => {
return sortedItems.value.slice(start, end);
});
const subjectRowClass = ({ row }: { row: any }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim();
[
row?.id ? "clickable-row" : "",
row?.id && row.id === selectedSubjectId.value ? "row-selected" : "",
isInactiveSite(row?.site_id) ? "row-inactive" : "",
].filter(Boolean).join(" ");
watch(() => [filters.value.keyword, filters.value.siteId, filters.value.status, pagination.value.pageSize], () => { pagination.value.currentPage = 1; });
watch(() => filteredItems.value.length, (total) => {
@@ -238,8 +348,30 @@ watch(() => filteredItems.value.length, (total) => {
if (pagination.value.currentPage > maxPage) pagination.value.currentPage = maxPage;
});
watch(() => study.currentSite, (newSite) => { filters.value.siteId = newSite?.id || ""; });
watch(pagedItems, (rows) => {
if (!rows.length) {
selectedSubjectId.value = "";
return;
}
if (!rows.some((item) => item.id === selectedSubjectId.value)) {
selectedSubjectId.value = rows[0].id;
}
});
onMounted(async () => { await loadSites(); load(); });
onMounted(async () => {
document.addEventListener("click", closeSubjectContextMenu);
desktopRefreshCleanup = onDesktopRefreshCurrentView(() => {
loadSites();
load();
});
await loadSites();
load();
});
onBeforeUnmount(() => {
document.removeEventListener("click", closeSubjectContextMenu);
desktopRefreshCleanup?.();
});
</script>
<style scoped>
@@ -256,6 +388,24 @@ onMounted(async () => { await loadSites(); load(); });
overflow: hidden;
}
.subject-workbench {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
min-height: 520px;
}
.subject-table-pane {
min-width: 0;
border-right: 1px solid #edf1f7;
outline: none;
}
.subject-preview-pane {
min-width: 0;
padding: 16px;
background: #fbfcff;
}
/* ==================== Toolbar ==================== */
.table-card-toolbar {
display: flex;
@@ -281,6 +431,13 @@ onMounted(async () => { await loadSites(); load(); });
flex-shrink: 0;
}
.selection-count {
color: #64748b;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.filter-item {
display: flex;
flex-direction: column;
@@ -342,6 +499,10 @@ onMounted(async () => { await loadSites(); load(); });
.subject-table :deep(.el-table__body tr) { cursor: pointer; transition: background 0.1s ease; }
.subject-table :deep(.el-table__body tr.row-selected > td) {
background: #edf5ff !important;
}
/* ==================== Cell Helpers ==================== */
.cell-nowrap { white-space: nowrap; }
.cell-mono { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 13px; color: #0a0a0a; font-weight: 600; }
@@ -357,6 +518,106 @@ onMounted(async () => { await loadSites(); load(); });
gap: 8px;
}
.preview-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding-bottom: 14px;
border-bottom: 1px solid #e2e8f0;
}
.preview-kicker {
color: #64748b;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.preview-title {
margin-top: 4px;
color: #0f172a;
font-size: 18px;
font-weight: 800;
word-break: break-word;
}
.preview-list {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 10px 12px;
margin: 16px 0 0;
}
.preview-list dt {
color: #64748b;
font-size: 12px;
}
.preview-list dd {
margin: 0;
color: #0f172a;
font-size: 13px;
font-weight: 600;
overflow-wrap: anywhere;
}
.preview-badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 16px;
}
.preview-empty {
min-height: 360px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: #94a3b8;
font-size: 13px;
}
.subject-context-menu {
position: fixed;
z-index: 3000;
min-width: 132px;
padding: 5px;
border: 1px solid #d7e2f0;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.18);
}
.subject-context-menu button {
display: block;
width: 100%;
height: 30px;
padding: 0 10px;
border: 0;
border-radius: 6px;
background: transparent;
color: #0f172a;
font-size: 13px;
text-align: left;
cursor: pointer;
}
.subject-context-menu button:hover:not(:disabled) {
background: #eef4ff;
}
.subject-context-menu button.danger {
color: #dc2626;
}
.subject-context-menu button:disabled {
color: #cbd5e1;
cursor: not-allowed;
}
.ae-badge {
display: inline-flex;
align-items: center;
@@ -419,6 +680,9 @@ onMounted(async () => { await loadSites(); load(); });
/* ==================== Responsive ==================== */
@media (max-width: 960px) {
.subject-workbench { grid-template-columns: 1fr; }
.subject-table-pane { border-right: 0; }
.subject-preview-pane { display: none; }
.toolbar-filters { flex-direction: column; align-items: stretch; }
.filter-input, .filter-select { width: 100%; }
.table-card-toolbar { flex-direction: column; gap: 12px; align-items: stretch; }