Files
ctms/frontend/src/views/subjects/VisitEditorDrawer.vue
T
Cheng Zhou 6b55c18610
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
refactor(frontend): 按需加载页面并清理未使用代码
- 将业务页面路由统一改为动态导入,拆分首屏入口与各功能模块构建产物。

- 将网页端和桌面端布局改为异步组件,避免两套平台布局同时进入初始包。

- 新增 Element Plus 按需安装入口,并通过全局配置组件统一注入中文语言包。

- 提取 API 运行时钩子,在应用启动时注入项目清理、令牌续期和认证失效退出能力。

- 将权限监控面板及地图资源改为延迟加载,补充地图加载状态、失败提示和切换竞态保护。

- 删除已被现有工作流替代的项目成员、接口权限、中心绑定、培训表单及旧项目首页等页面。

- 清理废弃的快捷操作、项目选择、用户选择、FAQ 表单、风险占位组件和旧地图辅助模块。

- 移除未使用的 API 方法、类型、字典、状态机、展示工具、样式和项目详情编辑逻辑。

- 开启 TypeScript 未使用变量与参数检查,并同步收紧相关测试和组件暴露类型。

- 移除未使用的 updater、date-fns 和 Sass 前端依赖,更新锁文件并删除旧 CSS 清洗插件。

- 更新路由、Axios、ETMF、通知、权限监控和桌面布局测试以覆盖重构后的边界。
2026-07-16 14:58:47 +08:00

343 lines
9.3 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<el-drawer
v-if="modelValue"
:model-value="modelValue"
direction="rtl"
size="640px"
:close-on-click-modal="true"
:before-close="drawerDirtyGuard.beforeClose"
:show-close="false"
class="visit-editor-drawer"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<div class="editor-header">
<div class="editor-title">{{ isEditing ? TEXT.common.actions.edit : TEXT.modules.subjectDetail.dialogVisit }}</div>
</div>
</template>
<el-form :model="form" label-position="top" class="visit-editor-form">
<div class="form-group">
<div class="form-group-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#3b82f6;flex-shrink:0"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
{{ TEXT.modules.subjectDetail.dialogVisit }}
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.visitCode" required>
<el-input v-model="form.visit_code" disabled />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.plannedDate" :required="!isEditing && isFirstVisit">
<el-input v-if="isEditing" :model-value="displayDate(form.planned_date) || TEXT.common.fallback" disabled />
<el-date-picker
v-else
v-model="form.planned_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
class="full-width"
:disabled="isFormReadOnly"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.actualDate">
<el-date-picker
v-model="form.actual_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
class="full-width"
:disabled="isFormReadOnly"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.status">
<el-tag
:type="getVisitStatusTagType(getVisitStatus(form, form.actual_date))"
effect="light"
class="visit-status-tag"
>
{{ getVisitStatusLabel(form, form.actual_date) }}
</el-tag>
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="TEXT.common.fields.notes">
<el-input
v-model="form.notes"
type="textarea"
:rows="3"
:disabled="isFormReadOnly"
:placeholder="TEXT.common.placeholders.input"
/>
</el-form-item>
<div v-if="isFormReadOnly" class="inactive-hint">
<span class="hint-icon">!</span>
<span>中心已停用当前记录不可编辑</span>
</div>
</div>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="emit('update:modelValue', false)">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="saving" :disabled="isFormReadOnly" @click="saveForm">
{{ TEXT.common.actions.save }}
</el-button>
</div>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { createVisit, updateVisit } from "../../api/visits";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import { TEXT } from "../../locales";
import { displayDate, displayEnum } from "../../utils/display";
const props = defineProps<{
modelValue: boolean;
studyId: string;
subjectId: string;
visit: any;
isReadOnly?: boolean;
canCreate?: boolean;
canUpdate?: boolean;
}>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
saved: [];
}>();
const saving = ref(false);
const form = reactive({
visit_code: "",
planned_date: "",
window_start: "",
window_end: "",
actual_date: "",
notes: "",
});
const isEditing = computed(() => !!props.visit?.id);
const isFirstVisit = computed(() => form.visit_code === "V1");
const canSave = computed(() => (isEditing.value ? !!props.canUpdate : !!props.canCreate));
const isFormReadOnly = computed(() => !canSave.value || !!props.isReadOnly);
const drawerDirtyGuard = useDrawerDirtyGuard(() => ({
form,
attachments: [],
}));
const parseDate = (value?: string | null) => {
if (!value) return null;
const date = new Date(value);
if (isNaN(date.getTime())) return null;
return date;
};
const getVisitStatus = (row: any, actualDateOverride?: string) => {
if (row?.status === "CANCELLED") return "CANCELLED";
const actualDate = parseDate(actualDateOverride ?? row?.actual_date);
const windowStart = parseDate(row?.window_start);
const windowEnd = parseDate(row?.window_end);
const plannedDate = parseDate(row?.planned_date);
if (actualDate) {
if ((windowStart && actualDate < windowStart) || (windowEnd && actualDate > windowEnd)) {
return "OVERDUE";
}
return "DONE";
}
const today = new Date();
const deadline = windowEnd || plannedDate;
if (deadline && today > deadline) {
return "LOST";
}
return "PLANNED";
};
const getVisitStatusTagType = (status: string) => {
switch (status) {
case "DONE": return "success";
case "OVERDUE": return "warning";
case "LOST": return "danger";
case "CANCELLED": return "info";
default: return "info";
}
};
const getVisitStatusLabel = (row: any, actualDateOverride?: string) => {
const status = getVisitStatus(row, actualDateOverride);
if (status === "CANCELLED" && String(row?.notes || "").includes("提前终止")) {
return "不适用";
}
return displayEnum(TEXT.enums.visitStatus, status);
};
const loadDetail = () => {
form.visit_code = props.visit?.visit_code || "";
form.planned_date = props.visit?.planned_date || "";
form.window_start = props.visit?.window_start || "";
form.window_end = props.visit?.window_end || "";
form.actual_date = props.visit?.actual_date || "";
form.notes = props.visit?.notes || "";
drawerDirtyGuard.syncBaseline();
};
const saveForm = async () => {
if (!props.studyId || !props.subjectId) return;
if (isFormReadOnly.value) {
ElMessage.warning(props.isReadOnly ? "中心已停用" : "权限不足");
return;
}
if (!isEditing.value && isFirstVisit.value && !form.planned_date) {
ElMessage.error("首次新增访视必须填写计划访视日期");
return;
}
saving.value = true;
try {
if (isEditing.value) {
const payload = {
actual_date: form.actual_date || null,
notes: form.notes || null,
};
await updateVisit(props.studyId, props.subjectId, props.visit.id, payload);
} else {
await createVisit(props.studyId, props.subjectId, {
subject_id: props.subjectId,
visit_code: form.visit_code,
planned_date: form.planned_date || null,
actual_date: form.actual_date || null,
notes: form.notes || null,
});
}
drawerDirtyGuard.syncBaseline();
emit("update:modelValue", false);
emit("saved");
ElMessage.success(TEXT.common.messages.saveSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
};
watch(
() => props.modelValue,
(visible) => {
if (!visible) return;
loadDetail();
},
{ immediate: true },
);
</script>
<style scoped>
:deep(.visit-editor-drawer > .el-drawer__header) {
margin-bottom: 0;
padding: 22px 20px 8px;
}
:deep(.visit-editor-drawer > .el-drawer__body) {
padding: 0 20px 4px;
}
.editor-header {
display: flex;
flex-direction: column;
}
.editor-title {
font-size: 20px;
font-weight: 700;
color: var(--ctms-text-main);
line-height: 1.2;
}
.visit-editor-form {
padding: 0 4px 0 0;
}
.form-group {
border: 1px solid #e8eef6;
border-radius: 10px;
padding: 16px 18px 8px;
background: #fbfcfe;
transition: border-color 0.2s ease;
}
.form-group:hover {
border-color: #d0dced;
}
.form-group + .form-group {
margin-top: 14px;
}
.form-group-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 14px;
color: #1a3560;
display: flex;
align-items: center;
gap: 8px;
}
.full-width {
width: 100%;
}
.inactive-hint {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: #fff7d6;
border: 1px solid #fde68a;
border-radius: 8px;
font-size: 13px;
color: #92400e;
margin-bottom: 8px;
}
.hint-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
background: #f59e0b;
color: #ffffff;
font-size: 12px;
line-height: 1;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 20px 16px;
}
.visit-status-tag {
pointer-events: none;
}
</style>