6b55c18610
- 将业务页面路由统一改为动态导入,拆分首屏入口与各功能模块构建产物。 - 将网页端和桌面端布局改为异步组件,避免两套平台布局同时进入初始包。 - 新增 Element Plus 按需安装入口,并通过全局配置组件统一注入中文语言包。 - 提取 API 运行时钩子,在应用启动时注入项目清理、令牌续期和认证失效退出能力。 - 将权限监控面板及地图资源改为延迟加载,补充地图加载状态、失败提示和切换竞态保护。 - 删除已被现有工作流替代的项目成员、接口权限、中心绑定、培训表单及旧项目首页等页面。 - 清理废弃的快捷操作、项目选择、用户选择、FAQ 表单、风险占位组件和旧地图辅助模块。 - 移除未使用的 API 方法、类型、字典、状态机、展示工具、样式和项目详情编辑逻辑。 - 开启 TypeScript 未使用变量与参数检查,并同步收紧相关测试和组件暴露类型。 - 移除未使用的 updater、date-fns 和 Sass 前端依赖,更新锁文件并删除旧 CSS 清洗插件。 - 更新路由、Axios、ETMF、通知、权限监控和桌面布局测试以覆盖重构后的边界。
343 lines
9.3 KiB
Vue
343 lines
9.3 KiB
Vue
<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>
|