Files
ctms/frontend/src/views/admin/ProjectDetail.vue
T
Cheng Zhou 934d114a29 feat(perm/frontend): PM 视角的权限管理界面与个人有效权限接入
- store/study 与 router 改为读取 /api-permissions/me 拉取当前用户在
  当前项目的有效权限,菜单与路由守卫据此判定,并在 PM 项目缺省时
  主动 ensureDefaultPmStudy。
- api/projectPermissions 暴露 fetchMyApiEndpointPermissions,并允许
  调用方关闭统一错误提示,便于在批量项目权限拉取场景下静默失败。
- 项目管理:Projects 页根据每个项目的有效权限决定项目入口的可点击
  状态,缺权限时禁用项目名跳转;ProjectDetail 据 setup_config 写权限
  渲染保存/回填/清空/发布按钮,并在无读取权限时提示并退出页面。
- 权限管理:PermissionManagement 切换为按角色提交,Admin 视角才能
  编辑 PM 行;按系统模块美化系统级权限展示并标注 PM 可访问项;项目
  成员表的角色下拉受 PM 等级约束,自定义角色入口下线。
- 监控:PermissionMonitoring/PermissionAccessLogs 增加 isAdmin 与
  showSecurityLog 入参,仅 ADMIN 才能看到底层安全日志与统计。
- ApiEndpointPermissions 表格根据当前用户角色禁用不可写行,避免
  误操作 PM 行;MODULE_LABELS 增加 setup_config 的中文展示。
- 测试同步覆盖以上行为:新版 router/Layout/Projects/PermissionManagement
  /PermissionAccessLogs/PermissionMonitoring/PermissionIpLocations 等
  组件均补足检测,并按需为相关组件提供 pinia + localStorage stub。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:38:54 +08:00

8198 lines
278 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>
<div class="ctms-page setup-page ctms-page-shell page--flush">
<div class="setup-shell unified-shell">
<div class="setup-header unified-action-bar">
<div class="setup-flow">
<div
v-for="(step, index) in steps"
:key="step.key"
class="flow-step"
:class="{ active: index === activeStep, done: isStepCompleted(index), 'has-error': stepHasErrors(index) }"
@click="goStep(index)"
>
<span class="flow-index">{{ index + 1 }}</span>
<span class="flow-label">{{ step.label }}</span>
<el-icon v-if="isStepCompleted(index) && !stepHasErrors(index)" class="flow-done-icon"><SuccessFilled /></el-icon>
<span v-if="index < steps.length - 1" class="flow-connector" />
</div>
</div>
<div class="setup-toolbar">
<div class="setup-toolbar-left">
<div class="setup-view-mode">
<el-radio-group v-model="setupViewMode" size="small">
<el-radio-button label="draft">草稿</el-radio-button>
<el-radio-button label="preview">预览</el-radio-button>
<el-radio-button label="published" :disabled="!publishedSetupDraft">已发布版</el-radio-button>
</el-radio-group>
</div>
<el-tag v-if="project?.is_locked" type="warning" effect="plain" class="locked-tag" size="small">
<el-icon><Lock /></el-icon>
已锁定
</el-tag>
<div class="setup-version-meta">
<div class="meta-item">
<span class="meta-label">当前发布</span>
<span class="meta-group">
<span class="meta-br">{{ setupPublishedBranchName || "main" }}</span>
<span class="meta-ver">{{ setupPublishedVersionText }}</span>
</span>
<el-tooltip v-if="setupPublishedAt" :content="setupPublishedAt" placement="top">
<span class="meta-time"><el-icon><Clock/></el-icon></span>
</el-tooltip>
</div>
<div class="meta-item" v-if="!isPublishedView">
<span class="meta-label">草稿基线</span>
<span class="meta-group draft-group">
<span class="meta-br">{{ setupDraftBranchDisplay }}</span>
<span class="meta-ver">{{ setupDraftBaseVersionDisplay }}</span>
</span>
<el-tag v-if="setupDraftStatusDisplay !== '-'" size="small" :type="setupWorkflowTagType" class="meta-status">{{ setupDraftStatusDisplay }}</el-tag>
</div>
</div>
</div>
<div class="setup-toolbar-actions">
<el-button v-if="canManageSetup" type="success" plain :loading="primarySaveLoading" :disabled="isPublishedView" @click="handlePrimarySaveConfig">保存配置</el-button>
<el-button v-if="canManageSetup" type="info" plain :disabled="isPublishedView || !publishedSetupDraft" @click="handleRefillDraft">一键回填</el-button>
<el-button v-if="canManageSetup" type="danger" plain :disabled="isPublishedView" @click="handleClearDraft">清空草稿</el-button>
<el-button v-if="canManageSetup" type="warning" plain :loading="publishConfirmLoading" @click="publishConfigNow">发布版本</el-button>
<el-button
type="info"
plain
@click="openRollbackDialog"
>
版本管理
</el-button>
<el-dropdown trigger="click" class="setup-secondary-menu" @command="handleSecondaryAction">
<el-button plain class="secondary-menu-btn">
更多
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="enter">
<el-icon><Promotion /></el-icon>
进入项目
</el-dropdown-item>
<el-dropdown-item command="members">
<el-icon><User /></el-icon>
项目账号/成员配置
</el-dropdown-item>
<el-dropdown-item command="sites">
<el-icon><OfficeBuilding /></el-icon>
中心管理
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
<div v-if="currentStepErrors.length" class="setup-step-errors">
<el-alert
type="error"
show-icon
:closable="false"
:title="`当前步骤有 ${currentStepErrors.length} 项需修正`"
:description="currentStepErrors[0]?.message"
/>
</div>
<div
v-if="drawerVisible"
class="setup-edit-backdrop"
:class="{ 'is-closing': drawerClosing }"
@click="closeEditDrawer"
/>
<div
class="setup-content unified-section"
:class="{
'setup-content-drawer': drawerVisible,
'is-closing': drawerClosing,
'is-published-preview': isPublishedView,
'is-draft-preview': !isPublishedView && !isEditing,
}"
>
<div v-if="!drawerVisible" class="setup-content-step-title">
<div class="setup-content-step-nav">
<el-button circle :disabled="activeStep <= 0" @click="prevStep">
<el-icon><ArrowLeft /></el-icon>
</el-button>
<el-button circle :disabled="activeStep >= steps.length - 1" @click="nextStep">
<el-icon><ArrowRight /></el-icon>
</el-button>
</div>
<span>{{ currentStepTitle }}</span>
<div class="setup-content-step-actions">
<template v-if="stepActionMode === 'edit'">
<template v-if="(activeStep === 2 || activeStep === 4) && isEditing && !isPublishedView">
<el-button
class="step-action-btn step-action-btn-cancel"
:disabled="drawerConfirmLoading || submitting"
@click="cancelEdit"
>
取消
</el-button>
<el-button
class="step-action-btn"
type="primary"
:loading="drawerConfirmLoading || submitting"
:disabled="!canEditSetup"
@click="handleDrawerConfirm"
>
保存
</el-button>
</template>
<el-button v-else class="step-action-btn" type="primary" :disabled="!canEditSetup" @click="startEdit">编辑</el-button>
</template>
<template v-else-if="stepActionMode === 'project-milestone'">
<el-dropdown trigger="click" @command="onProjectMilestoneTemplateSelect">
<el-button class="step-action-btn" type="primary" :disabled="!canEditSetup">
<span>选择模板</span>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="item in projectMilestoneTemplates"
:key="item.key"
:command="item.key"
>
{{ item.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button class="step-action-btn" type="primary" :disabled="!canEditSetup" @click="handleAddProjectMilestoneAction">新增</el-button>
</template>
<template v-else-if="stepActionMode === 'site-milestone'">
<el-dropdown trigger="click" @command="onSiteMilestoneTemplateSelect">
<el-button class="step-action-btn" type="primary" :disabled="!canEditSetup">
<span>选择模板</span>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="item in siteMilestoneTemplates"
:key="item.key"
:command="item.key"
>
{{ item.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button class="step-action-btn" type="primary" :disabled="!canEditSetup" @click="handleAddSiteMilestoneAction">新增</el-button>
</template>
</div>
</div>
<div v-if="drawerVisible" class="setup-drawer-header">
<div class="setup-drawer-title-wrap">
<div class="setup-drawer-title">{{ currentStepTitle }}</div>
</div>
</div>
<section v-show="activeStep === 0" class="setup-section">
<template v-if="project">
<el-tabs v-model="infoTab" class="setup-info-tabs">
<el-tab-pane v-if="showStep1BasicTab" label="基本信息" name="basic">
<el-form
v-if="isEditing && !isPublishedView"
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
class="detail-form"
label-position="top"
>
<div v-if="canEditStep1BasicGroup('project')" class="basic-edit-group">
<div class="basic-form-group-title">项目信息</div>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.projectName" prop="name">
<el-input v-model="form.name" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="项目全称">
<el-input v-model="form.project_full_name" placeholder="项目全称" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="申办方">
<el-input v-model="form.sponsor" :placeholder="TEXT.modules.adminProjects.sponsorPlaceholder" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.projectCode" prop="code">
<el-input v-model="form.code" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.protocolNo" prop="protocol_no">
<el-input v-model="form.protocol_no" :placeholder="TEXT.modules.adminProjects.protocolPlaceholder" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="组长单位">
<el-input v-model="form.lead_unit" placeholder="组长单位" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="主要研究者">
<el-input v-model="form.principal_investigator" placeholder="主要研究者" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="主PM">
<el-input v-model="form.main_pm" placeholder="主PM" />
</el-form-item>
</el-col>
</el-row>
</div>
<div v-if="canEditStep1BasicGroup('research')" class="basic-edit-group">
<div class="basic-form-group-title">研究信息</div>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="研究分期">
<el-input v-model="form.research_analysis" placeholder="研究分期" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="研究产品">
<el-input v-model="form.research_product" placeholder="研究产品" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="对照产品">
<el-input v-model="form.control_product" placeholder="对照产品" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="适应症">
<el-input v-model="form.indication" placeholder="适应症" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="研究人群">
<el-input v-model="form.research_population" placeholder="研究人群" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="研究设计">
<el-input v-model="form.research_design" placeholder="研究设计" />
</el-form-item>
</el-col>
</el-row>
</div>
<div v-if="canEditStep1BasicGroup('execution')" class="basic-edit-group">
<div class="basic-form-group-title">执行信息</div>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="计划开始日期">
<el-date-picker v-model="form.plan_start_date" type="date" value-format="YYYY-MM-DD" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="计划结束日期">
<el-date-picker v-model="form.plan_end_date" type="date" value-format="YYYY-MM-DD" class="w-full" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.status" prop="status">
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" class="w-full">
<el-option :label="TEXT.enums.projectStatus.DRAFT" value="DRAFT" />
<el-option :label="TEXT.enums.projectStatus.ACTIVE" value="ACTIVE" />
<el-option :label="TEXT.enums.projectStatus.CLOSED" value="CLOSED" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="计划中心数">
<el-input-number v-model="form.planned_site_count" :min="0" :step="1" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="计划入组数">
<el-input-number v-model="form.planned_enrollment_count" :min="0" :step="1" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
</el-row>
</div>
</el-form>
<div v-else class="basic-info-groups">
<div class="info-group">
<div class="info-group-title-row">
<div class="info-group-title">项目信息</div>
<el-button
v-if="!isPublishedView"
type="primary"
class="info-group-edit-btn"
:disabled="!canEditSetup"
@click="startStep1SectionEdit('project')"
>
编辑
</el-button>
</div>
<div class="info-grid">
<div
v-for="item in projectInfoItems"
:key="`project-${item.label}`"
class="info-item"
:class="{ 'is-updated': item.updated && shouldShowPreviewUpdateMark }"
>
<span class="info-label">{{ item.label }}</span>
<span class="info-value-wrap">
<span class="info-value">{{ item.value || "-" }}</span>
<el-tag
v-if="item.updated && shouldShowPreviewUpdateMark"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</div>
</div>
</div>
<div class="info-group">
<div class="info-group-title-row">
<div class="info-group-title">研究信息</div>
<el-button
v-if="!isPublishedView"
type="primary"
class="info-group-edit-btn"
:disabled="!canEditSetup"
@click="startStep1SectionEdit('research')"
>
编辑
</el-button>
</div>
<div class="info-grid">
<div
v-for="item in researchInfoItems"
:key="`research-${item.label}`"
class="info-item"
:class="{ 'is-updated': item.updated && shouldShowPreviewUpdateMark }"
>
<span class="info-label">{{ item.label }}</span>
<span class="info-value-wrap">
<span class="info-value">{{ item.value || "-" }}</span>
<el-tag
v-if="item.updated && shouldShowPreviewUpdateMark"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</div>
</div>
</div>
<div class="info-group">
<div class="info-group-title-row">
<div class="info-group-title">执行信息</div>
<el-button
v-if="!isPublishedView"
type="primary"
class="info-group-edit-btn"
:disabled="!canEditSetup"
@click="startStep1SectionEdit('execution')"
>
编辑
</el-button>
</div>
<div class="info-grid">
<div
v-for="item in executionInfoItems"
:key="`execution-${item.label}`"
class="info-item"
:class="{ 'is-updated': item.updated && shouldShowPreviewUpdateMark }"
>
<span class="info-label">{{ item.label }}</span>
<span class="info-value-wrap">
<span class="info-value">{{ item.value || "-" }}</span>
<el-tag
v-if="item.updated && shouldShowPreviewUpdateMark"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</div>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane v-if="showStep1SummaryTab" label="方案摘要" name="summary">
<div class="info-group summary-display-group">
<div class="info-group-title-row">
<div class="info-group-title">访视计划</div>
<div class="info-group-title-actions">
<el-tag
v-if="isProjectSnapshotFieldUpdated('visit_schedule') && shouldShowPreviewUpdateMark"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
<el-button
v-if="!isPublishedView"
type="primary"
class="info-group-edit-btn"
:disabled="!canEditSetup"
@click="startStep1SectionEdit('summary')"
>
编辑
</el-button>
</div>
</div>
<div class="visit-schedule-display">
<div class="visit-schedule-display-head">
<span>访视</span>
<span>基线后天数</span>
<span>访视窗</span>
</div>
<div
v-for="(row, index) in summaryVisitScheduleRows"
:key="`${row.visit_code || 'visit'}-${index}`"
class="visit-schedule-display-row"
>
<span class="visit-schedule-display-code">{{ row.visit_code || "-" }}</span>
<span>{{ row.baseline_offset_days }} </span>
<span>{{ formatVisitWindow(row) }}</span>
</div>
<div v-if="!summaryVisitScheduleRows.length" class="visit-schedule-display-empty">暂无访视计划</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</template>
<StateLoading v-else :rows="4" />
</section>
<section v-show="activeStep === 1" class="setup-section setup-section--table">
<el-table :data="currentSetupDraft.projectMilestones" class="ctms-table setup-milestone-table">
<el-table-column label="里程碑" min-width="180">
<template #default="scope">
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ scope.row.name || "-" }}</span>
<el-tag
v-if="isProjectMilestoneFieldUpdated(scope.row, scope.$index, 'name')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="计划时间" width="240">
<template #default="scope">
<div class="milestone-plan-cell">
<div class="milestone-plan-line">
<span class="milestone-plan-dot" />
<span class="milestone-plan-label">开始:</span>
<span class="updated-value-wrap">
<span>{{ resolveProjectMilestoneStart(scope.row) || "-" }}</span>
<el-tag
v-if="isProjectMilestoneFieldUpdated(scope.row, scope.$index, 'startDate')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</div>
<div class="milestone-plan-line">
<span class="milestone-plan-dot" />
<span class="milestone-plan-label">结束:</span>
<span class="updated-value-wrap">
<span>{{ resolveProjectMilestoneEnd(scope.row) || "-" }}</span>
<el-tag
v-if="isProjectMilestoneFieldUpdated(scope.row, scope.$index, 'endDate')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</div>
<div class="milestone-plan-line">
<span class="milestone-plan-dot" />
<span class="milestone-plan-label">耗时:</span>
<span class="updated-value-wrap">
<span>{{ formatProjectMilestoneDuration(scope.row) }}</span>
<el-tag
v-if="isProjectMilestoneFieldUpdated(scope.row, scope.$index, 'duration')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="负责人" min-width="160">
<template #default="scope">
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ normalizeOwnerDisplay(scope.row.owner) }}</span>
<el-tag
v-if="isProjectMilestoneFieldUpdated(scope.row, scope.$index, 'owner')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="状态" width="150">
<template #default="scope">
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ scope.row.status || "-" }}</span>
<el-tag
v-if="isProjectMilestoneFieldUpdated(scope.row, scope.$index, 'status')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="备注" min-width="220">
<template #default="scope">
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ scope.row.remark || "-" }}</span>
<el-tag
v-if="isProjectMilestoneFieldUpdated(scope.row, scope.$index, 'remark')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column v-if="!isPublishedView" label="操作" width="130">
<template #default="scope">
<div class="milestone-actions-inline">
<el-button link type="primary" :disabled="!canEditSetup" @click="openProjectMilestoneEditor(scope.$index)">编辑</el-button>
<el-button link type="danger" :disabled="!canEditSetup" @click="removeProjectMilestone(scope.$index)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
</section>
<section v-show="activeStep === 2" class="setup-section setup-section--table enrollment-plan-section">
<div class="enrollment-plan-toolbar">
<div class="enrollment-inline-group">
<span class="enrollment-inline-label required">计划入组日期</span>
<div class="field-cell enrollment-date-cell" data-field-path="enrollmentPlan.startDate">
<el-date-picker
v-if="isEditing && !isPublishedView"
v-model="currentSetupDraft.enrollmentPlan.startDate"
type="date"
value-format="YYYY-MM-DD"
:disabled="!canEditSetup"
class="w-full"
/>
<span v-else class="updated-value-wrap">
<span class="enrollment-inline-value">{{ currentSetupDraft.enrollmentPlan.startDate || "-" }}</span>
<el-tag
v-if="isEnrollmentPlanFieldUpdated('startDate')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
<div v-if="getFieldError('enrollmentPlan.startDate')" class="field-error">{{ getFieldError("enrollmentPlan.startDate") }}</div>
</div>
<span class="enrollment-sep">~</span>
<div class="field-cell enrollment-date-cell" data-field-path="enrollmentPlan.endDate">
<el-date-picker
v-if="isEditing && !isPublishedView"
v-model="currentSetupDraft.enrollmentPlan.endDate"
type="date"
value-format="YYYY-MM-DD"
:disabled="!canEditSetup"
class="w-full"
/>
<span v-else class="updated-value-wrap">
<span class="enrollment-inline-value">{{ currentSetupDraft.enrollmentPlan.endDate || "-" }}</span>
<el-tag
v-if="isEnrollmentPlanFieldUpdated('endDate')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
<div v-if="getFieldError('enrollmentPlan.endDate')" class="field-error">{{ getFieldError("enrollmentPlan.endDate") }}</div>
</div>
</div>
<div class="enrollment-inline-group">
<span class="enrollment-inline-label">入组周期</span>
<el-radio-group
v-if="isEditing && !isPublishedView"
v-model="enrollmentPlanCycle"
size="small"
class="enrollment-cycle-group"
>
<el-radio-button label="month"></el-radio-button>
<el-radio-button label="quarter">季度</el-radio-button>
</el-radio-group>
<span v-else class="updated-value-wrap">
<span class="enrollment-inline-value">{{ enrollmentPlanCycleLabel }}</span>
<el-tag
v-if="isEnrollmentPlanFieldUpdated('cycle')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</div>
<div class="enrollment-inline-group total-target-group">
<span class="enrollment-inline-label">计划入组总数</span>
<div class="field-cell enrollment-target-cell" data-field-path="enrollmentPlan.totalTarget">
<el-input-number
v-if="isEditing && !isPublishedView"
v-model="currentSetupDraft.enrollmentPlan.totalTarget"
:min="0"
:disabled="!canEditSetup"
controls-position="right"
class="w-full"
/>
<span v-else class="updated-value-wrap">
<span class="enrollment-inline-value">{{ toDisplayNumber(currentSetupDraft.enrollmentPlan.totalTarget) }}</span>
<el-tag
v-if="isEnrollmentPlanFieldUpdated('totalTarget')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
<div v-if="getFieldError('enrollmentPlan.totalTarget')" class="field-error">{{ getFieldError("enrollmentPlan.totalTarget") }}</div>
</div>
</div>
</div>
<div class="enrollment-plan-summary">
<div class="enrollment-summary-line">
<span class="summary-title">项目计划情况:</span>
<span>要求试验参与者数 <b>{{ toDisplayNumber(currentSetupDraft.enrollmentPlan.totalTarget) }}</b> </span>
<span>已填写计划数 <b>{{ toDisplayNumber(projectMonthlyPlannedCount) }}</b> </span>
<span v-if="projectMonthlyOverflowCount > 0" class="summary-danger">已超出 {{ projectMonthlyOverflowCount }} </span>
<span v-else>待分配 {{ projectMonthlyPendingCount }} </span>
</div>
<div class="enrollment-summary-line">
<span class="summary-title">中心计划情况:</span>
<span>已配置中心 <b>{{ centerConfiguredSiteCount }}</b>/<b>{{ centerActiveSiteCount }}</b> </span>
<span>中心计划总数 <b>{{ toDisplayNumber(centerPlannedCount) }}</b> </span>
<span>已填写计划数 <b>{{ toDisplayNumber(centerCyclePlannedCount) }}</b> </span>
<span v-if="centerCycleOverflowCount > 0" class="summary-danger">已超出 {{ centerCycleOverflowCount }} </span>
<span v-else-if="centerCyclePendingCount > 0">待分配 {{ centerCyclePendingCount }} </span>
<span v-else class="summary-success">已完成分配</span>
</div>
</div>
<div v-if="isEditing && !isPublishedView" class="enrollment-plan-actions">
<el-button link class="enrollment-plan-action-link" @click="clearEnrollmentPeriodPlan">清空</el-button>
<el-button link class="enrollment-plan-action-link" @click="quickFillEnrollmentPeriodPlan">快速填写</el-button>
</div>
<el-table
v-if="currentEnrollmentPlanCycle === 'month'"
:data="enrollmentPlanYearRows"
class="ctms-table enrollment-month-plan-table"
border
empty-text="请先填写有效的计划入组日期范围"
>
<el-table-column label="年份" width="88">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.year }}</span>
</template>
</el-table-column>
<el-table-column v-for="month in 12" :key="month" :label="`${month}月`" min-width="88">
<template #default="scope">
<template v-if="scope.row.cells[month - 1].key">
<el-input-number
v-if="isEditing && !isPublishedView"
:model-value="getEnrollmentPeriodTarget(scope.row.cells[month - 1].key)"
:min="0"
:step="1"
controls-position="right"
class="month-target-input"
@update:model-value="setEnrollmentPeriodTarget(scope.row.cells[month - 1].key, $event as number | null)"
/>
<span v-else class="updated-value-wrap">
<span class="milestone-cell-text">{{ displayEnrollmentPeriodTarget(scope.row.cells[month - 1].key) }}</span>
<el-tag
v-if="isProjectEnrollmentPeriodUpdated(scope.row.cells[month - 1].key)"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
<span v-else class="month-empty">-</span>
</template>
</el-table-column>
</el-table>
<el-table
v-else
:data="enrollmentCycleGridRows"
class="ctms-table enrollment-period-plan-table"
border
empty-text="请先填写有效的计划入组日期范围"
>
<el-table-column :label="enrollmentCycleRowLabel" min-width="120">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.groupLabel }}</span>
</template>
</el-table-column>
<el-table-column
v-for="(column, columnIndex) in enrollmentCycleGridColumns"
:key="column.key"
:label="column.label"
min-width="96"
>
<template #default="scope">
<template v-if="scope.row.cells[columnIndex]?.key">
<el-input-number
v-if="isEditing && !isPublishedView"
:model-value="getEnrollmentPeriodTarget(scope.row.cells[columnIndex].key)"
:min="0"
:step="1"
controls-position="right"
class="month-target-input"
@update:model-value="setEnrollmentPeriodTarget(scope.row.cells[columnIndex].key, $event as number | null)"
/>
<span v-else class="updated-value-wrap">
<span class="milestone-cell-text">{{ displayEnrollmentPeriodTarget(scope.row.cells[columnIndex].key) }}</span>
<el-tag
v-if="isProjectEnrollmentPeriodUpdated(scope.row.cells[columnIndex].key)"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
<span v-else class="month-empty">-</span>
</template>
</el-table-column>
</el-table>
</section>
<section v-show="activeStep === 3" class="setup-section setup-section--table">
<el-table :data="currentSetupDraft.siteMilestones" class="ctms-table">
<el-table-column label="里程碑" min-width="220">
<template #default="scope">
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ scope.row.milestone || "-" }}</span>
<el-tag
v-if="isSiteMilestoneFieldUpdated(scope.row, scope.$index, 'milestone')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="计划日期" width="180">
<template #default="scope">
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ scope.row.planDate || "-" }}</span>
<el-tag
v-if="isSiteMilestoneFieldUpdated(scope.row, scope.$index, 'planDate')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="负责人" min-width="160">
<template #default="scope">
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ normalizeOwnerDisplay(scope.row.owner) }}</span>
<el-tag
v-if="isSiteMilestoneFieldUpdated(scope.row, scope.$index, 'owner')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="状态" width="150">
<template #default="scope">
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ scope.row.status || "未开始" }}</span>
<el-tag
v-if="isSiteMilestoneFieldUpdated(scope.row, scope.$index, 'status')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="备注" min-width="180">
<template #default="scope">
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ scope.row.remark || "-" }}</span>
<el-tag
v-if="isSiteMilestoneFieldUpdated(scope.row, scope.$index, 'remark')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column v-if="!isPublishedView" label="操作" width="130">
<template #default="scope">
<div class="milestone-actions-inline">
<el-button link type="primary" :disabled="!canEditSetup" @click="openSiteMilestoneEditor(scope.$index)">编辑</el-button>
<el-button link type="danger" :disabled="!canEditSetup" @click="removeSiteMilestone(scope.$index)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
</section>
<section v-show="activeStep === 4" class="setup-section setup-section--table enrollment-plan-section">
<div class="enrollment-plan-toolbar">
<div class="site-plan-selector">
<span class="enrollment-inline-label">中心</span>
<el-select
v-model="selectedSiteEnrollmentPlanSiteId"
filterable
clearable
placeholder="选择中心"
class="site-plan-site-select"
>
<el-option
v-for="site in siteEnrollmentSelectableSites"
:key="normalizeSiteId(site.id)"
:label="site.name || getSiteOptionLabel(site)"
:value="normalizeSiteId(site.id)"
/>
</el-select>
</div>
<div class="enrollment-inline-group">
<span class="enrollment-inline-label required">中心入组日期</span>
<div class="field-cell enrollment-date-cell">
<el-date-picker
v-if="isEditing && !isPublishedView && selectedSiteEnrollmentPlan"
v-model="selectedSiteEnrollmentPlan.startDate"
type="date"
value-format="YYYY-MM-DD"
:disabled="!canEditSetup"
class="w-full"
/>
<span v-else class="updated-value-wrap">
<span class="enrollment-inline-value">{{ selectedSiteEnrollmentPlan?.startDate || "-" }}</span>
<el-tag
v-if="isSelectedSiteEnrollmentFieldUpdated('startDate')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</div>
<span class="enrollment-sep">~</span>
<div class="field-cell enrollment-date-cell">
<el-date-picker
v-if="isEditing && !isPublishedView && selectedSiteEnrollmentPlan"
v-model="selectedSiteEnrollmentPlan.endDate"
type="date"
value-format="YYYY-MM-DD"
:disabled="!canEditSetup"
class="w-full"
/>
<span v-else class="updated-value-wrap">
<span class="enrollment-inline-value">{{ selectedSiteEnrollmentPlan?.endDate || "-" }}</span>
<el-tag
v-if="isSelectedSiteEnrollmentFieldUpdated('endDate')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</div>
</div>
<div class="enrollment-inline-group">
<span class="enrollment-inline-label">入组周期</span>
<el-radio-group
v-if="isEditing && !isPublishedView"
v-model="enrollmentPlanCycle"
size="small"
class="enrollment-cycle-group"
>
<el-radio-button label="month"></el-radio-button>
<el-radio-button label="quarter">季度</el-radio-button>
</el-radio-group>
<span v-else class="updated-value-wrap">
<span class="enrollment-inline-value">{{ enrollmentPlanCycleLabel }}</span>
<el-tag
v-if="isSelectedSiteEnrollmentFieldUpdated('cycle')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</div>
<div class="enrollment-inline-group total-target-group">
<span class="enrollment-inline-label">中心计划总数</span>
<div class="field-cell enrollment-target-cell">
<el-input-number
v-if="isEditing && !isPublishedView && selectedSiteEnrollmentPlan"
v-model="selectedSiteEnrollmentPlan.target"
:min="0"
:disabled="!canEditSetup"
controls-position="right"
class="w-full"
/>
<span v-else class="updated-value-wrap">
<span class="enrollment-inline-value">{{ toDisplayNumber(selectedSiteEnrollmentPlan?.target) }}</span>
<el-tag
v-if="isSelectedSiteEnrollmentFieldUpdated('target')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</div>
</div>
</div>
<div class="enrollment-plan-summary">
<div class="enrollment-summary-line">
<span class="summary-title">项目计划情况:</span>
<span>要求试验参与者数 <b>{{ toDisplayNumber(currentSetupDraft.enrollmentPlan.totalTarget) }}</b> </span>
<span>已填写计划数 <b>{{ toDisplayNumber(projectMonthlyPlannedCount) }}</b> </span>
<span v-if="projectMonthlyOverflowCount > 0" class="summary-danger">已超出 {{ projectMonthlyOverflowCount }} </span>
<span v-else>待分配 {{ projectMonthlyPendingCount }} </span>
</div>
<div class="enrollment-summary-line">
<span class="summary-title">中心计划情况:</span>
<span class="summary-site-name">{{ selectedSitePlanName }}</span>
<span>计划入组数 <b>{{ toDisplayNumber(selectedSitePlanTarget) }}</b> </span>
<span>已填写计划数 <b>{{ toDisplayNumber(selectedSiteCyclePlannedCount) }}</b> </span>
<span>待分配 {{ selectedSiteCyclePendingCount }} </span>
</div>
</div>
<div class="enrollment-plan-actions">
<template v-if="isEditing && !isPublishedView">
<el-button
v-if="!selectedSiteEnrollmentPlan"
link
class="enrollment-plan-action-link"
@click="createSelectedSiteEnrollmentPlan"
>
初始化当前中心计划
</el-button>
<template v-else>
<el-button link class="enrollment-plan-action-link" @click="clearSiteEnrollmentPeriodPlan(selectedSiteEnrollmentPlanIndex)">清空</el-button>
<el-button link class="enrollment-plan-action-link" @click="quickFillSiteEnrollmentPeriodPlan(selectedSiteEnrollmentPlanIndex)">快速填写</el-button>
<el-button link type="danger" @click="removeSiteEnrollment(selectedSiteEnrollmentPlanIndex)">删除</el-button>
</template>
</template>
</div>
<div v-if="!selectedSiteEnrollmentPlanSiteId" class="month-empty">请先选择中心</div>
<div v-else-if="!selectedSiteEnrollmentPlan" class="month-empty">当前中心暂未配置入组计划</div>
<div v-else>
<el-table
v-if="currentEnrollmentPlanCycle === 'month'"
:data="enrollmentPlanYearRows"
class="ctms-table enrollment-month-plan-table"
border
empty-text="请先填写有效的计划入组日期范围"
>
<el-table-column label="年份" width="88">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.year }}</span>
</template>
</el-table-column>
<el-table-column v-for="month in 12" :key="month" :label="`${month}月`" min-width="88">
<template #default="scope">
<template v-if="scope.row.cells[month - 1].key">
<el-input-number
v-if="isEditing && !isPublishedView"
:model-value="getSiteEnrollmentPeriodTarget(selectedSiteEnrollmentPlan, scope.row.cells[month - 1].key)"
:min="0"
:step="1"
controls-position="right"
class="month-target-input"
@update:model-value="setSiteEnrollmentPeriodTarget(selectedSiteEnrollmentPlanIndex, scope.row.cells[month - 1].key, $event as number | null)"
/>
<span v-else class="updated-value-wrap">
<span class="milestone-cell-text">{{ displaySiteEnrollmentPeriodTarget(selectedSiteEnrollmentPlan, scope.row.cells[month - 1].key) }}</span>
<el-tag
v-if="isSelectedSiteEnrollmentPeriodUpdated(scope.row.cells[month - 1].key)"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
<span v-else class="month-empty">-</span>
</template>
</el-table-column>
</el-table>
<el-table
v-else
:data="enrollmentCycleGridRows"
class="ctms-table enrollment-period-plan-table"
border
empty-text="请先填写有效的计划入组日期范围"
>
<el-table-column :label="enrollmentCycleRowLabel" min-width="120">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.groupLabel }}</span>
</template>
</el-table-column>
<el-table-column
v-for="(column, columnIndex) in enrollmentCycleGridColumns"
:key="column.key"
:label="column.label"
min-width="96"
>
<template #default="scope">
<template v-if="scope.row.cells[columnIndex]?.key">
<el-input-number
v-if="isEditing && !isPublishedView"
:model-value="getSiteEnrollmentPeriodTarget(selectedSiteEnrollmentPlan, scope.row.cells[columnIndex].key)"
:min="0"
:step="1"
controls-position="right"
class="month-target-input"
@update:model-value="setSiteEnrollmentPeriodTarget(selectedSiteEnrollmentPlanIndex, scope.row.cells[columnIndex].key, $event as number | null)"
/>
<span v-else class="updated-value-wrap">
<span class="milestone-cell-text">{{ displaySiteEnrollmentPeriodTarget(selectedSiteEnrollmentPlan, scope.row.cells[columnIndex].key) }}</span>
<el-tag
v-if="isSelectedSiteEnrollmentPeriodUpdated(scope.row.cells[columnIndex].key)"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
<span v-else class="month-empty">-</span>
</template>
</el-table-column>
</el-table>
</div>
</section>
<section v-show="activeStep === 5" class="setup-section setup-section--table">
<el-table :data="centerConfirmDisplayRows" class="ctms-table">
<el-table-column label="中心" min-width="180">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.siteName || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="城市" min-width="120">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.city || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="PI" min-width="120">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.piName || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="负责人" min-width="180">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.ownerDisplay || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="计划入组数" width="140">
<template #default="scope">
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ toDisplayNumber(scope.row.target) }}</span>
<el-tag
v-if="isCenterConfirmPlanFieldUpdated(scope.row.siteId, 'target')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="scope">
<el-tag :type="scope.row.isActive ? 'success' : 'danger'" effect="light">
{{ scope.row.isActive ? "激活" : "锁定" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="入组开始时间" width="150">
<template #default="scope">
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ scope.row.startDate || "-" }}</span>
<el-tag
v-if="isCenterConfirmPlanFieldUpdated(scope.row.siteId, 'startDate')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="入组结束时间" width="150">
<template #default="scope">
<span class="updated-value-wrap">
<span class="milestone-cell-text">{{ scope.row.endDate || "-" }}</span>
<el-tag
v-if="isCenterConfirmPlanFieldUpdated(scope.row.siteId, 'endDate')"
size="small"
type="warning"
effect="plain"
class="field-updated-tag"
>
已更新
</el-tag>
</span>
</template>
</el-table-column>
</el-table>
</section>
<div v-if="drawerVisible" class="setup-drawer-footer">
<el-button @click="closeEditDrawer">取消</el-button>
<el-button type="primary" :loading="drawerConfirmLoading || submitting" :disabled="!canEditSetup" @click="handleDrawerConfirm">
保存
</el-button>
</div>
</div>
</div>
<el-dialog v-if="publishConfirmVisible" append-to="body" v-model="publishConfirmVisible" title="发布确认" width="920px" top="8vh" class="setup-publish-dialog">
<div class="conflict-summary">
<div>发布目标立项配置草稿</div>
<div>当前发布版本{{ setupPublishedVersionText }}</div>
</div>
<div v-if="incompleteSteps.length" class="publish-incomplete">
<el-alert
type="warning"
:closable="false"
show-icon
:title="`仍有 ${incompleteSteps.length} 个步骤待完善,建议补全后再发布`"
/>
<div class="publish-incomplete-list">
<el-tag v-for="item in incompleteSteps" :key="item.index" type="warning" effect="plain">
{{ item.index + 1 }}. {{ item.label }}
</el-tag>
</div>
</div>
<el-alert
v-if="isFirstPublish"
type="info"
:closable="false"
show-icon
title="当前为首次发布,将以当前草稿生成发布快照。"
/>
<div v-else-if="publishDiffLines.length" class="conflict-diff-lines">
<el-tag v-for="line in publishDiffLines" :key="line" effect="plain" type="warning">{{ line }}</el-tag>
</div>
<div v-if="publishFieldDiffRows.length" class="conflict-readable-diff">
<div class="publish-diff-overview">
<el-tag v-for="item in publishDiffModuleSummary" :key="item.module" type="info" effect="plain">
{{ item.module }}{{ item.count }} 项变更
</el-tag>
</div>
<el-table :data="publishFieldDiffRows" size="small" border max-height="320">
<el-table-column prop="moduleLabel" label="模块" width="140" />
<el-table-column prop="path" label="变更项" min-width="300" />
<el-table-column prop="changeType" label="变更类型" width="100">
<template #default="scope">
<el-tag :type="scope.row.changeType === '新增' ? 'success' : scope.row.changeType === '删除' ? 'danger' : 'warning'" effect="plain">
{{ scope.row.changeType }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="serverValue" label="上一版本值" min-width="220" show-overflow-tooltip />
<el-table-column prop="localValue" label="预发布版本值" min-width="220" show-overflow-tooltip />
</el-table>
</div>
<el-alert
v-else-if="!isFirstPublish"
type="info"
:closable="false"
show-icon
title="暂未检测到可展示的差异明细。"
/>
<template #footer>
<el-button @click="publishConfirmVisible = false">取消</el-button>
<el-button type="primary" :loading="publishConfirmLoading" @click="confirmPublishConfig">确认发布</el-button>
</template>
</el-dialog>
<el-dialog
v-if="rollbackDialogVisible"
append-to="body"
v-model="rollbackDialogVisible"
width="960px"
top="6vh"
:show-close="false"
class="version-mgr-dialog"
>
<template #header>
<div class="vm-dialog-header">
<div class="vm-dialog-header-left">
<span class="vm-dialog-icon">
<el-icon :size="20"><List /></el-icon>
</span>
<span class="vm-dialog-title">版本管理</span>
<el-tag class="vm-version-count-tag" size="small" effect="plain" round>
{{ rollbackAxisRows.length }} 个版本
</el-tag>
</div>
<el-button class="vm-dialog-close-btn" :icon="Close" circle size="small" @click="rollbackDialogVisible = false" />
</div>
</template>
<div class="vm-body" v-loading="rollbackDialogLoading">
<!-- 分支轨道图例 -->
<div class="vm-legend-bar">
<div class="vm-legend-section">
<span class="vm-legend-label">分支轨道</span>
<div class="vm-legend-tags">
<span
v-for="lane in rollbackAxisLanes"
:key="lane.branch"
class="vm-lane-badge"
:class="{ 'is-main': lane.branch === 'main' }"
>
<span class="vm-lane-badge-dot" />
{{ lane.branch }}
</span>
</div>
</div>
</div>
<!-- 轨道头部标签 -->
<div class="rollback-axis-lane-head" :style="{ width: `${rollbackAxisWidth}px` }">
<span
v-for="lane in rollbackAxisLanes"
:key="`head-${lane.branch}`"
class="rollback-axis-lane-head-item"
:class="{ 'is-main': lane.branch === 'main' }"
:style="getRollbackAxisLeftStyle(lane.x)"
>
{{ formatAxisLaneLabel(lane.branch) }}
</span>
</div>
<!-- 时间线内容 -->
<div class="rollback-axis-scroll" ref="rollbackAxisScrollRef">
<svg
v-if="rollbackAxisOverlayHeight > 0"
class="rollback-axis-overlay"
:width="rollbackAxisWidth"
:height="rollbackAxisOverlayHeight"
:viewBox="`0 0 ${rollbackAxisWidth} ${rollbackAxisOverlayHeight}`"
preserveAspectRatio="none"
>
<defs>
<linearGradient id="mergeGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#f59e0b" stop-opacity="0.8" />
<stop offset="100%" stop-color="#ef4444" stop-opacity="0.6" />
</linearGradient>
<linearGradient id="spawnGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.7" />
<stop offset="100%" stop-color="#8b5cf6" stop-opacity="0.6" />
</linearGradient>
</defs>
<!-- 同分支继承连接线 -->
<g v-for="connector in rollbackAxisSameBranchConnectors" :key="connector.key">
<line
:class="connector.isMain ? 'rollback-axis-branch-line is-main' : 'rollback-axis-branch-line'"
:x1="connector.x"
:y1="connector.y1"
:x2="connector.x"
:y2="connector.y2"
/>
</g>
<!-- 合并连接线 -->
<g v-for="connector in rollbackAxisMergeConnectors" :key="connector.key">
<path
class="rollback-axis-merge-path"
:d="`M ${connector.x1} ${connector.y1} C ${connector.x1} ${(connector.y1 + connector.y2) / 2}, ${connector.x2} ${(connector.y1 + connector.y2) / 2}, ${connector.x2} ${connector.y2}`"
/>
</g>
<!-- 派生连接线 -->
<g v-for="connector in rollbackAxisSpawnConnectors" :key="connector.key">
<path
class="rollback-axis-spawn-path"
:d="`M ${connector.x1} ${connector.y1} C ${connector.x1} ${(connector.y1 + connector.y2) / 2}, ${connector.x2} ${(connector.y1 + connector.y2) / 2}, ${connector.x2} ${connector.y2}`"
/>
</g>
</svg>
<div
v-for="row in rollbackAxisRows"
:key="row.id"
class="rollback-axis-row"
:class="{
'is-current-row': row.is_current_published,
'is-draft-base-row': row.is_active_draft_base,
}"
:ref="(el) => setRollbackAxisRowRef(el as HTMLElement | null, row.id)"
>
<div class="rollback-axis-graph" :style="{ width: `${rollbackAxisWidth}px` }">
<span
v-for="lane in rollbackAxisLanes.filter(l => l.branch === 'main')"
:key="`${row.id}-${lane.branch}`"
class="rollback-axis-lane-segment is-main-lane"
:style="getRollbackAxisLeftStyle(lane.x)"
/>
<span
class="rollback-axis-dot"
:class="{
'is-main': row.branch_name === 'main',
'is-current': row.is_current_published,
'is-merge': row.mergeFromId !== null,
}"
:style="getRollbackAxisLeftStyle(row.axisX)"
/>
</div>
<div class="rollback-axis-content">
<div class="rollback-axis-topline">
<span class="rollback-axis-version">{{ row.displayLabel }}</span>
<span v-if="row.is_current_published" class="vm-status-badge vm-badge-published">
<el-icon :size="12"><SuccessFilled /></el-icon>
当前发布
</span>
<span v-if="row.is_active_draft_base" class="vm-status-badge vm-badge-draft">
<el-icon :size="12"><EditPen /></el-icon>
草稿基线
</span>
<span v-if="row.mergeFromLabel" class="vm-status-badge vm-badge-merge">
<el-icon :size="12"><Connection /></el-icon>
来源 {{ row.mergeFromLabel }}
</span>
</div>
<div class="rollback-axis-meta">
<span class="vm-meta-item">
<el-icon :size="13"><FolderOpened /></el-icon>
{{ row.branch_name || "main" }}
</span>
<span class="vm-meta-item">
<el-icon :size="13"><User /></el-icon>
{{ row.published_by_name || "-" }}
</span>
<span class="vm-meta-item">
<el-icon :size="13"><Clock /></el-icon>
{{ row.published_at }}
</span>
</div>
<div class="rollback-axis-actions">
<el-tooltip content="回滚到此版本" placement="top" :show-after="300">
<el-button class="vm-action-btn vm-action-danger" size="small" @click="selectRollbackVersion(row.version)">
<el-icon :size="13"><RefreshLeft /></el-icon>
回滚
</el-button>
</el-tooltip>
<el-tooltip v-if="(row.branch_name || 'main') === 'main'" content="基于此版本新建分支草稿" placement="top" :show-after="300">
<el-button class="vm-action-btn vm-action-primary" size="small" @click="checkoutBranchDraftFromVersion(row.version)">
<el-icon :size="13"><DocumentCopy /></el-icon>
新建分支草稿
</el-button>
</el-tooltip>
<el-tooltip v-if="(row.branch_name || 'main') !== 'main'" content="合并至主线创建新版本" placement="top" :show-after="300">
<el-button class="vm-action-btn vm-action-primary" size="small" @click="mergeVersionToMain(row.version)">
<el-icon :size="13"><Connection /></el-icon>
合并主线新版本
</el-button>
</el-tooltip>
<el-tooltip content="下载版本数据" placement="top" :show-after="300">
<el-button class="vm-action-btn vm-action-default" size="small" @click="downloadVersionRaw(row)">
<el-icon :size="13"><Download /></el-icon>
下载
</el-button>
</el-tooltip>
<el-tooltip content="删除此版本" placement="top" :show-after="300">
<el-button
class="vm-action-btn vm-action-danger"
size="small"
:disabled="isLatestPublishedVersion(row.version)"
@click="removeVersion(row.version)"
>
<el-icon :size="13"><Delete /></el-icon>
删除
</el-button>
</el-tooltip>
</div>
</div>
</div>
</div>
</div>
<template #footer>
<div class="vm-dialog-footer">
<el-button round @click="rollbackDialogVisible = false">
<el-icon><Close /></el-icon>
关闭
</el-button>
</div>
</template>
</el-dialog>
<el-dialog
v-model="visitScheduleDialogVisible"
title="编辑访视计划"
width="min(860px, calc(100vw - 32px))"
:close-on-click-modal="false"
:show-close="false"
class="visit-schedule-dialog"
>
<el-form label-width="120px" class="detail-form visit-schedule-dialog-form" label-position="top">
<div class="summary-section-bar">
<el-button plain size="small" @click="addVisitScheduleRow">添加访视</el-button>
</div>
<div class="visit-schedule-editor">
<div class="visit-schedule-head">
<span>访视</span>
<span>基线后天数</span>
<span>窗口前</span>
<span>窗口后</span>
<span />
</div>
<div
v-for="(row, index) in form.visit_schedule"
:key="`${row.visit_code || 'visit'}-${index}`"
class="visit-schedule-row"
:class="{
'is-dragging': visitScheduleDragIndex === index,
'insert-before': isVisitScheduleInsertTarget(index, 'before'),
'insert-after': isVisitScheduleInsertTarget(index, 'after'),
}"
draggable="true"
aria-label="拖拽调整访视顺序"
@dragstart="handleVisitScheduleDragStart(index, $event)"
@dragover.prevent="handleVisitScheduleDragOver(index, $event)"
@drop.prevent="handleVisitScheduleDrop(index)"
@dragenter.prevent="handleVisitScheduleDragOver(index, $event)"
@dragend="handleVisitScheduleDragEnd"
>
<div class="visit-field visit-code-field">
<span class="visit-field-label">访视</span>
<el-select
v-model="row.visit_code"
filterable
allow-create
default-first-option
placeholder="选择或输入访视"
class="w-full"
@change="handleVisitScheduleCodeChange(index, $event)"
>
<el-option-group label="常规访视">
<el-option label="常规访视(自动生成 Vn" :value="regularVisitOptionValue" />
<el-option label="安全性随访(自动生成 Vn" :value="safetyFollowUpVisitOptionValue" />
</el-option-group>
<el-option-group label="计划特殊访视">
<el-option
v-for="option in specialVisitOptions"
:key="option"
:label="option"
:value="option"
/>
</el-option-group>
</el-select>
</div>
<div class="visit-field">
<span class="visit-field-label">基线后天数</span>
<el-input-number v-model="row.baseline_offset_days" :min="0" :max="3650" :step="1" controls-position="right" class="w-full" />
</div>
<div class="visit-field">
<span class="visit-field-label">窗口前</span>
<el-input-number v-model="row.window_before_days" :min="0" :max="365" :step="1" controls-position="right" class="w-full" />
</div>
<div class="visit-field">
<span class="visit-field-label">窗口后</span>
<el-input-number v-model="row.window_after_days" :min="0" :max="365" :step="1" controls-position="right" class="w-full" />
</div>
<el-button
:icon="Delete"
text
type="danger"
class="visit-schedule-remove"
@click="removeVisitScheduleRow(index)"
/>
</div>
</div>
<div class="visit-special-rule">
<div class="visit-special-rule-title">特殊访视规则</div>
<div class="visit-special-rule-body">
提前终止不作为常规计划访视配置受试者发生提前终止时在访视页通过提前终止单独录入系统会自动关闭后续未完成计划访视
</div>
</div>
</el-form>
<template #footer>
<div class="visit-schedule-dialog-footer">
<el-button :disabled="drawerConfirmLoading || submitting" @click="cancelVisitScheduleDialogEdit">取消</el-button>
<el-button type="primary" :loading="drawerConfirmLoading || submitting" :disabled="!canEditSetup" @click="saveVisitScheduleDialogEdit">
保存
</el-button>
</div>
</template>
</el-dialog>
<el-drawer
v-if="projectMilestoneEditorVisible"
v-model="projectMilestoneEditorVisible"
direction="rtl"
size="480px"
:close-on-click-modal="false"
:show-close="false"
class="setup-milestone-editor-drawer"
>
<template #header>
<div class="sme-header">
<div class="sme-header-title">编辑里程碑</div>
<div class="sme-header-subtitle">配置里程碑时间计划与负责人</div>
</div>
</template>
<el-form label-position="top" class="sme-form">
<!-- 基本信息 -->
<div class="sme-group">
<div class="sme-group-title"><span class="sme-dot sme-dot-blue"></span>基本信息</div>
<el-form-item label="里程碑名称">
<el-input v-model="projectMilestoneEditorForm.name" placeholder="例如:首例入组" />
</el-form-item>
</div>
<!-- 时间计划 -->
<div class="sme-group">
<div class="sme-group-title"><span class="sme-dot sme-dot-amber"></span>时间计划</div>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="开始日期">
<el-date-picker
v-model="projectMilestoneEditorForm.startDate"
type="date"
value-format="YYYY-MM-DD"
class="w-full"
placeholder="选择日期"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="结束日期">
<el-date-picker
v-model="projectMilestoneEditorForm.endDate"
type="date"
value-format="YYYY-MM-DD"
class="w-full"
placeholder="选择日期"
/>
</el-form-item>
</el-col>
</el-row>
<div class="sme-duration-line">
<span class="sme-duration-label">耗时</span>
<span class="sme-duration-value">{{ formatProjectMilestoneDuration(projectMilestoneEditorForm) }}</span>
</div>
</div>
<!-- 负责与状态 -->
<div class="sme-group">
<div class="sme-group-title"><span class="sme-dot sme-dot-green"></span>负责与状态</div>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="负责人">
<el-select
v-model="projectMilestoneEditorForm.owner"
filterable
clearable
class="w-full"
placeholder="选择负责人"
>
<el-option
v-for="option in projectMilestoneOwnerOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态">
<el-select v-model="projectMilestoneEditorForm.status" class="w-full" placeholder="状态">
<el-option label="未开始" value="未开始" />
<el-option label="进行中" value="进行中" />
<el-option label="已完成" value="已完成" />
<el-option label="延期" value="延期" />
</el-select>
</el-form-item>
</el-col>
</el-row>
</div>
<!-- 备注 -->
<div class="sme-group">
<div class="sme-group-title"><span class="sme-dot sme-dot-gray"></span>备注</div>
<el-input v-model="projectMilestoneEditorForm.remark" type="textarea" :rows="3" placeholder="请输入备注信息" />
</div>
</el-form>
<template #footer>
<div class="sme-footer">
<el-button @click="projectMilestoneEditorVisible = false">取消</el-button>
<el-button type="primary" @click="saveProjectMilestoneEditor">保存</el-button>
</div>
</template>
</el-drawer>
<el-drawer
v-if="siteMilestoneEditorVisible"
v-model="siteMilestoneEditorVisible"
direction="rtl"
size="480px"
:close-on-click-modal="false"
:show-close="false"
class="setup-milestone-editor-drawer"
>
<template #header>
<div class="sme-header">
<div class="sme-header-title">编辑中心里程碑</div>
<div class="sme-header-subtitle">配置中心里程碑计划日期与负责人</div>
</div>
</template>
<el-form label-position="top" class="sme-form">
<!-- 基本信息 -->
<div class="sme-group">
<div class="sme-group-title"><span class="sme-dot sme-dot-blue"></span>基本信息</div>
<el-form-item label="里程碑名称">
<el-input v-model="siteMilestoneEditorForm.milestone" placeholder="中心里程碑" />
</el-form-item>
<el-form-item label="计划日期">
<el-date-picker
v-model="siteMilestoneEditorForm.planDate"
type="date"
value-format="YYYY-MM-DD"
class="w-full"
placeholder="选择日期"
/>
</el-form-item>
</div>
<!-- 负责与状态 -->
<div class="sme-group">
<div class="sme-group-title"><span class="sme-dot sme-dot-green"></span>负责与状态</div>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="负责人">
<el-select
v-model="siteMilestoneEditorForm.owner"
filterable
clearable
class="w-full"
placeholder="选择负责人"
>
<el-option
v-for="option in siteMilestoneOwnerOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态">
<el-select v-model="siteMilestoneEditorForm.status" class="w-full" placeholder="状态">
<el-option label="未开始" value="未开始" />
<el-option label="进行中" value="进行中" />
<el-option label="已完成" value="已完成" />
<el-option label="延期" value="延期" />
</el-select>
</el-form-item>
</el-col>
</el-row>
</div>
<!-- 备注 -->
<div class="sme-group">
<div class="sme-group-title"><span class="sme-dot sme-dot-gray"></span>备注</div>
<el-input v-model="siteMilestoneEditorForm.remark" type="textarea" :rows="3" placeholder="请输入备注信息" />
</div>
</el-form>
<template #footer>
<div class="sme-footer">
<el-button @click="siteMilestoneEditorVisible = false">取消</el-button>
<el-button type="primary" @click="saveSiteMilestoneEditor">保存</el-button>
</div>
</template>
</el-drawer>
<el-drawer
v-if="siteEnrollmentEditorVisible"
v-model="siteEnrollmentEditorVisible"
direction="rtl"
size="480px"
:close-on-click-modal="false"
:show-close="false"
class="setup-milestone-editor-drawer"
>
<template #header>
<div class="sme-header">
<div class="sme-header-title">编辑中心入组计划</div>
<div class="sme-header-subtitle">配置中心入组目标与时间范围</div>
</div>
</template>
<el-form label-position="top" class="sme-form">
<!-- 中心选择 -->
<div class="sme-group">
<div class="sme-group-title"><span class="sme-dot sme-dot-blue"></span>中心选择</div>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="中心">
<el-select v-model="siteEnrollmentEditorForm.siteId" filterable clearable placeholder="选择中心" class="w-full">
<el-option
v-for="site in siteSelectOptions"
:key="site.id"
:label="site.label"
:value="site.id"
:disabled="isSiteEnrollmentSiteTaken(site.id)"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="计划例数">
<el-input-number v-model="siteEnrollmentEditorForm.target" :min="0" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
</el-row>
</div>
<!-- 时间计划 -->
<div class="sme-group">
<div class="sme-group-title"><span class="sme-dot sme-dot-amber"></span>时间计划</div>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="启动日期">
<el-date-picker v-model="siteEnrollmentEditorForm.startDate" type="date" value-format="YYYY-MM-DD" class="w-full" placeholder="选择日期" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="完成日期">
<el-date-picker v-model="siteEnrollmentEditorForm.endDate" type="date" value-format="YYYY-MM-DD" class="w-full" placeholder="选择日期" />
</el-form-item>
</el-col>
</el-row>
</div>
<!-- 备注 -->
<div class="sme-group">
<div class="sme-group-title"><span class="sme-dot sme-dot-gray"></span>备注</div>
<el-input v-model="siteEnrollmentEditorForm.note" type="textarea" :rows="3" placeholder="请输入备注信息" />
</div>
</el-form>
<template #footer>
<div class="sme-footer">
<el-button @click="siteEnrollmentEditorVisible = false">取消</el-button>
<el-button type="primary" @click="saveSiteEnrollmentEditor">保存</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch, type Ref } from "vue";
import { onBeforeRouteLeave, useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { ArrowLeft, ArrowRight, Promotion, User, OfficeBuilding, Lock, SuccessFilled, ArrowDown, Close, List, EditPen, Connection, FolderOpened, Clock, RefreshLeft, DocumentCopy, Download, Delete } from "@element-plus/icons-vue";
import {
fetchStudyDetail,
} from "../../api/studies";
import { fetchMyApiEndpointPermissions } from "../../api/projectPermissions";
import { listMembers } from "../../api/members";
import { fetchSites } from "../../api/sites";
import type { Site, Study } from "../../types/api";
import type {
CenterConfirmDraft,
ProjectPublishSnapshot,
ProjectMilestoneDraft,
SetupConfigDraft,
SiteEnrollmentPlanDraft,
SiteMilestoneDraft,
StudySetupConfigResponse,
StudySetupConfigVersionItem,
StudySetupConfigUpsertPayload,
VisitScheduleItem,
} from "../../types/setupConfig";
import StateLoading from "../../components/StateLoading.vue";
import { useStudyStore } from "../../store/study";
import { useAuthStore } from "../../store/auth";
import { TEXT, requiredMessage } from "../../locales";
import { isSystemAdmin } from "../../utils/roles";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { groupErrorsBySection, parseFieldPath, type SetupValidationError } from "../../utils/setupFieldLocator";
import { buildSetupReadableDiffRows, serializeDiffValue, type SetupDiffRow } from "../../utils/setupDiffRows";
import {
SETUP_WORKFLOW_TAG_META,
buildProjectDiffRows,
buildSetupModuleDiffLines,
hasProjectSnapshotDiff,
resolveProjectPublishCompareBase,
resolvePublishedProjectSnapshot,
resolveSetupWorkflowStatus,
shouldCaptureProjectCompareFallback,
type DraftSyncStatus,
type SetupWorkflowStatus,
type SetupWorkflowTagMeta,
} from "../../utils/setupPublishWorkflow";
import { useSetupConfig } from "../../composables/useSetupConfig";
type SetupStepKey =
| "project-info"
| "project-milestone"
| "project-enrollment"
| "site-milestone"
| "site-enrollment"
| "site-confirm";
type Step1EditSection = "all" | "project" | "research" | "execution" | "summary";
type StepActionMode =
| "none"
| "edit"
| "project-milestone"
| "site-milestone"
;
type RollbackAxisLane = { branch: string; index: number; x: number };
type RollbackAxisRow = StudySetupConfigVersionItem & {
displayLabel: string;
mergeFromId: string | null;
axisX: number;
mergeFromLabel: string;
spawnParentId: string | null;
};
const DRAFT_VERSION = 2;
const LOCAL_DRAFT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const SAVE_RETRY_DELAYS_MS = [800, 1600, 3200];
type LocalDraftEnvelope<T> = {
version?: number;
data?: T;
savedAt?: string;
savedAtMs?: number;
ttlMs?: number;
serverVersionAtCache?: number | null;
};
type LocalDraftLoadResult<T> = {
data: T;
savedAt: string;
expired: boolean;
conflict: boolean;
serverVersionAtCache: number | null;
};
const steps: Array<{ key: SetupStepKey; label: string }> = [
{ key: "project-info", label: "项目信息" },
{ key: "project-milestone", label: "项目里程碑" },
{ key: "project-enrollment", label: "项目入组计划" },
{ key: "site-milestone", label: "中心里程碑" },
{ key: "site-enrollment", label: "中心入组计划" },
{ key: "site-confirm", label: TEXT.modules.adminProjects.setupStepCenterConfirm || "中心确认" },
];
const projectMilestoneTemplates: Array<{ key: string; label: string; milestones: string[] }> = [
{
key: "startup-default",
label: "默认模板(立项关键节点)",
milestones: [
"中心可行性调研",
"试验启动前研究者会",
"方案定稿",
"首家中心伦理通过",
"首家中心启动",
"首例参与者入组",
"参与者入组5例",
"参与者入组20例",
"末例参与者出组",
"数据库锁定",
"临床试验总结",
],
},
];
const siteMilestoneTemplates: Array<{ key: string; label: string; milestones: string[] }> = [
{
key: "site-default",
label: "默认模板(中心关键节点)",
milestones: [
"中心调研",
"机构立项",
"取得伦理批件",
"合同签署",
"启动会",
"首例入组",
"末例入组",
"末例访视",
"数据库锁定",
"中心关闭",
],
},
];
const route = useRoute();
const router = useRouter();
const studyStore = useStudyStore();
const authStore = useAuthStore();
const {
getConfig: fetchSetupConfig,
saveConfig: upsertSetupConfig,
publishConfig,
mergeToMain: mergeSetupVersionToMain,
listVersions: fetchSetupVersions,
rollbackConfig,
checkoutBranchDraft: checkoutSetupBranchDraft,
clearDraft: clearSetupDraftOnServer,
refillDraft: refillSetupDraftOnServer,
deleteVersion: deleteSetupVersion,
} = useSetupConfig();
const project = ref<Study | null>(null);
const siteOptions = ref<Site[]>([]);
const memberDisplayMap = ref<Record<string, string>>({});
const projectMemberOptions = ref<Array<{ value: string; label: string }>>([]);
const activeStep = ref(0);
const infoTab = ref<"basic" | "summary">("basic");
const step1EditSection = ref<Step1EditSection>("all");
const isEditing = ref(false);
const drawerClosing = ref(false);
const visitScheduleDialogVisible = ref(false);
const drawerVisible = computed(() => {
if (visitScheduleDialogVisible.value) return false;
if (activeStep.value === 2 || activeStep.value === 4) return false;
return isEditing.value || drawerClosing.value;
});
const visitScheduleDragIndex = ref<number | null>(null);
const visitScheduleDragOverIndex = ref<number | null>(null);
type VisitScheduleInsertPosition = "before" | "after";
const visitScheduleInsertPosition = ref<VisitScheduleInsertPosition>("before");
const drawerCloseTimer = ref<number | undefined>(undefined);
const submitting = ref(false);
const formRef = ref<FormInstance>();
const draftReady = ref(false);
const suppressDraftWatch = ref(false);
const autoSaveTimer = ref<number | undefined>(undefined);
const setupDirtySinceLastPersist = ref(false);
const suppressEnrollmentFieldSync = ref(false);
const enrollmentPlanCycle = ref<EnrollmentCycle>("month");
const enrollmentTargetsByCycle = ref<Record<EnrollmentCycle, Record<string, number>>>({
month: {},
quarter: {},
});
const enrollmentStageSyncingFromModel = ref(false);
const enrollmentStageSyncingToModel = ref(false);
const setupRevision = ref<number>(1);
const setupPublishedVersion = ref<string>("v0");
const setupPublishedBranchName = ref<string>("main");
const setupCurrentBranchName = ref<string>("main");
const setupActiveDraftBaseVersion = ref<string>("");
const setupPublishStatus = ref<"DRAFT" | "PUBLISHED" | string>("DRAFT");
const setupPublishedAt = ref<string>("");
type SetupViewMode = "draft" | "preview" | "published";
const setupViewMode = ref<SetupViewMode>("draft");
const publishedSetupDraft = ref<SetupConfigDraft | null>(null);
const publishConfirmVisible = ref(false);
const publishConfirmLoading = ref(false);
const primarySaveLoading = ref(false);
const rollbackDialogVisible = ref(false);
const rollbackDialogLoading = ref(false);
const setupVersionHistory = ref<StudySetupConfigVersionItem[]>([]);
const drawerConfirmLoading = ref(false);
const projectMilestoneEditorVisible = ref(false);
const projectMilestoneEditingIndex = ref<number>(-1);
const siteMilestoneEditorVisible = ref(false);
const siteMilestoneEditingIndex = ref<number>(-1);
const siteEnrollmentEditorVisible = ref(false);
const siteEnrollmentEditingIndex = ref<number>(-1);
type IndexedEditorController = {
visible: Ref<boolean>;
index: Ref<number>;
};
const projectMilestoneEditor: IndexedEditorController = {
visible: projectMilestoneEditorVisible,
index: projectMilestoneEditingIndex,
};
const siteMilestoneEditor: IndexedEditorController = {
visible: siteMilestoneEditorVisible,
index: siteMilestoneEditingIndex,
};
const siteEnrollmentEditor: IndexedEditorController = {
visible: siteEnrollmentEditorVisible,
index: siteEnrollmentEditingIndex,
};
const projectMilestoneEditorForm = reactive<ProjectMilestoneDraft>({
id: "",
name: "",
planDate: "",
startDate: "",
endDate: "",
durationDays: 1,
owner: "",
remark: "",
status: "未开始",
});
const siteMilestoneEditorForm = reactive<SiteMilestoneDraft>({
id: "",
milestone: "",
planDate: "",
owner: "",
remark: "",
status: "未开始",
});
const siteEnrollmentEditorForm = reactive<SiteEnrollmentPlanDraft>({
id: "",
siteId: "",
siteName: "",
target: 0,
startDate: "",
endDate: "",
note: "",
stageBreakdown: "",
});
type EnrollmentCycle = "month" | "quarter";
type EnrollmentYearRow = {
year: number;
cells: Array<{ key: string | null }>;
};
type EnrollmentCycleGridRow = {
groupLabel: string;
cells: Array<{ key: string | null }>;
};
type EnrollmentCycleGridColumn = {
key: string;
label: string;
};
type EnrollmentPeriodSlot = {
key: string;
label: string;
groupLabel: string;
};
const publishedProjectSnapshot = ref<ProjectPublishSnapshot | null>(null);
const projectPublishCompareFallback = ref<ProjectPublishSnapshot | null>(null);
const projectSnapshotAtLoad = ref<ProjectPublishSnapshot | null>(null);
const setupServerSnapshot = ref("");
const setupLocalDraftSavedAt = ref("");
const setupSaveMeta = ref<{ updatedAt: string; savedBy: string | null; serverSynced: boolean }>({
updatedAt: "",
savedBy: null,
serverSynced: true,
});
const lastServerSaveMeta = ref<{ updatedAt: string; savedBy: string }>({
updatedAt: "-",
savedBy: "-",
});
const serverFieldErrors = ref<Record<string, string>>({});
const form = ref({
code: "",
name: "",
project_full_name: "",
short_name: "",
sponsor: "",
protocol_no: "",
lead_unit: "",
principal_investigator: "",
main_pm: "",
research_analysis: "",
research_product: "",
control_product: "",
indication: "",
research_population: "",
research_design: "",
plan_start_date: "",
plan_end_date: "",
planned_site_count: null as number | null,
planned_enrollment_count: null as number | null,
phase: "",
status: "DRAFT",
scope: "国内",
visit_schedule: [] as VisitScheduleItem[],
});
const formBaselineSnapshot = ref("");
const regularVisitOptionValue = "__REGULAR_VISIT__";
const safetyFollowUpVisitOptionValue = "__SAFETY_FOLLOW_UP_VISIT__";
const specialVisitOptions = ["筛选访视", "基线访视", "终止治疗"];
const getNextRegularVisitCode = (excludeIndex?: number): string => {
const maxRegularIndex = form.value.visit_schedule.reduce((maxIndex, row, rowIndex) => {
if (rowIndex === excludeIndex) return maxIndex;
const match = /^V(\d+)$/.exec((row.visit_code || "").trim());
if (!match) return maxIndex;
return Math.max(maxIndex, Number(match[1]));
}, 0);
return `V${maxRegularIndex + 1}`;
};
const getNextSafetyFollowUpVisitCode = (excludeIndex?: number): string => {
const maxSafetyFollowUpIndex = form.value.visit_schedule.reduce((maxIndex, row, rowIndex) => {
if (rowIndex === excludeIndex) return maxIndex;
const match = /^安全性随访V(\d+)$/.exec((row.visit_code || "").trim());
if (!match) return maxIndex;
return Math.max(maxIndex, Number(match[1]));
}, 0);
return `安全性随访V${maxSafetyFollowUpIndex + 1}`;
};
const hasDuplicateVisitCode = (visitCode: string, currentIndex: number): boolean => {
const normalizedCode = visitCode.trim();
if (!normalizedCode) return false;
return form.value.visit_schedule.some(
(row, rowIndex) => rowIndex !== currentIndex && (row.visit_code || "").trim() === normalizedCode
);
};
const normalizeVisitSchedule = (rows: VisitScheduleItem[] | undefined | null): VisitScheduleItem[] =>
(rows || []).map((row) => ({
visit_code: (row.visit_code || "").trim(),
baseline_offset_days: Number(row.baseline_offset_days || 0),
window_before_days: Number(row.window_before_days || 0),
window_after_days: Number(row.window_after_days || 0),
}));
const addVisitScheduleRow = () => {
form.value.visit_schedule.push({
visit_code: getNextRegularVisitCode(),
baseline_offset_days: 0,
window_before_days: 0,
window_after_days: 0,
});
};
const removeVisitScheduleRow = (index: number) => {
form.value.visit_schedule.splice(index, 1);
};
const handleVisitScheduleCodeChange = (index: number, value: string) => {
const row = form.value.visit_schedule[index];
if (!row) return;
let nextVisitCode = String(value || "").trim();
if (nextVisitCode === regularVisitOptionValue) {
nextVisitCode = getNextRegularVisitCode(index);
} else if (nextVisitCode === safetyFollowUpVisitOptionValue) {
nextVisitCode = getNextSafetyFollowUpVisitCode(index);
}
if (hasDuplicateVisitCode(nextVisitCode, index)) {
row.visit_code = "";
ElMessage.warning(`访视已存在:${nextVisitCode}`);
return;
}
row.visit_code = nextVisitCode;
};
const handleVisitScheduleDragStart = (index: number, event: DragEvent) => {
visitScheduleDragIndex.value = index;
visitScheduleDragOverIndex.value = index;
visitScheduleInsertPosition.value = "before";
event.dataTransfer?.setData("text/plain", String(index));
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
}
};
const handleVisitScheduleDragOver = (index: number, event: DragEvent) => {
if (visitScheduleDragIndex.value === null) return;
const target = event.currentTarget as HTMLElement | null;
if (target) {
const rect = target.getBoundingClientRect();
visitScheduleInsertPosition.value = event.clientY < rect.top + rect.height / 2 ? "before" : "after";
}
visitScheduleDragOverIndex.value = index;
};
const handleVisitScheduleDrop = (targetIndex: number) => {
const sourceIndex = visitScheduleDragIndex.value;
if (sourceIndex === null) {
handleVisitScheduleDragEnd();
return;
}
let insertIndex = visitScheduleInsertPosition.value === "after" ? targetIndex + 1 : targetIndex;
if (sourceIndex === targetIndex || sourceIndex + 1 === insertIndex) {
handleVisitScheduleDragEnd();
return;
}
const nextRows = [...form.value.visit_schedule];
const [movedRow] = nextRows.splice(sourceIndex, 1);
if (!movedRow) {
handleVisitScheduleDragEnd();
return;
}
if (sourceIndex < insertIndex) {
insertIndex -= 1;
}
nextRows.splice(insertIndex, 0, movedRow);
form.value.visit_schedule = nextRows;
handleVisitScheduleDragEnd();
};
const handleVisitScheduleDragEnd = () => {
visitScheduleDragIndex.value = null;
visitScheduleDragOverIndex.value = null;
visitScheduleInsertPosition.value = "before";
};
const isVisitScheduleInsertTarget = (index: number, position: VisitScheduleInsertPosition) =>
visitScheduleDragIndex.value !== null &&
visitScheduleDragIndex.value !== index &&
visitScheduleDragOverIndex.value === index &&
visitScheduleInsertPosition.value === position;
const formatVisitSchedule = (rows: VisitScheduleItem[] | undefined | null): string => {
const normalized = normalizeVisitSchedule(rows);
if (!normalized.length) return "-";
return normalized
.map(
(row) =>
`${row.visit_code || "-"}:基线+${row.baseline_offset_days}天,窗口-${row.window_before_days}/+${row.window_after_days}天`
)
.join("");
};
const formatVisitWindow = (row: VisitScheduleItem): string => `-${row.window_before_days} / +${row.window_after_days} 天`;
const emptyProjectInfoDraft = (): ProjectPublishSnapshot => ({
code: "",
name: "",
project_full_name: "",
sponsor: "",
protocol_no: "",
lead_unit: "",
principal_investigator: "",
main_pm: "",
research_analysis: "",
research_product: "",
control_product: "",
indication: "",
research_population: "",
research_design: "",
plan_start_date: "",
plan_end_date: "",
planned_site_count: null,
planned_enrollment_count: null,
status: "",
visit_schedule: [],
});
const setupDraft = reactive<SetupConfigDraft>({
projectInfo: emptyProjectInfoDraft(),
projectMilestones: [],
enrollmentPlan: {
totalTarget: 0,
startDate: "",
endDate: "",
monthlyGoalNote: "",
stageBreakdown: "",
},
siteMilestones: [],
siteEnrollmentPlans: [],
centerConfirm: [],
});
const storageKey = computed(() => `ctms_setup_config_draft_${String(route.params.projectId || "")}`);
const projectDraftStorageKey = computed(() => `ctms_setup_project_draft_${String(route.params.projectId || "")}`);
const projectPermissions = ref<Record<string, Record<string, any>> | null>(null);
const hasSetupReadPermission = computed(() => {
if (isSystemAdmin(authStore.user)) return true;
if (!projectPermissions.value) return false;
const rolePerms = Object.values(projectPermissions.value)[0];
if (!rolePerms) return false;
return isApiPermissionAllowed(rolePerms["setup_config:update"]);
});
const canManageSetup = computed(() => {
if (isSystemAdmin(authStore.user)) return true;
if (!projectPermissions.value) return false;
const rolePerms = Object.values(projectPermissions.value)[0];
if (!rolePerms) return false;
return isApiPermissionAllowed(rolePerms["setup_config:update"]);
});
const isPreviewView = computed(() => setupViewMode.value === "preview");
const isPublishedVersionView = computed(() => setupViewMode.value === "published");
const isPublishedView = computed(() => isPreviewView.value || isPublishedVersionView.value);
const currentSetupDraft = computed<SetupConfigDraft>(() => {
if (isPublishedVersionView.value && publishedSetupDraft.value) {
return publishedSetupDraft.value;
}
return setupDraft;
});
const canEditSetup = computed(
() => Boolean(project.value && !project.value?.is_locked && canManageSetup.value && !isPublishedView.value)
);
const canMutateDraft = () => canEditSetup.value && !isPublishedView.value;
const canEditStep1BasicGroup = (group: Exclude<Step1EditSection, "all" | "summary">) =>
activeStep.value === 0 && isEditing.value && (step1EditSection.value === "all" || step1EditSection.value === group);
const isStep1BasicScopedEdit = computed(
() => activeStep.value === 0 && isEditing.value && ["project", "research", "execution"].includes(step1EditSection.value)
);
const showStep1BasicTab = computed(
() => !(activeStep.value === 0 && isEditing.value && step1EditSection.value === "summary")
);
const showStep1SummaryTab = computed(() => !isStep1BasicScopedEdit.value);
const stepActionMode = computed<StepActionMode>(() => {
if (isPublishedView.value) return "none";
switch (activeStep.value) {
case 2:
return "edit";
case 0:
return "none";
case 1:
return "project-milestone";
case 3:
return "site-milestone";
case 4:
return "edit";
default:
return "none";
}
});
const openIndexedEditor = <T>(
rows: T[],
rowIndex: number,
controller: IndexedEditorController,
fillForm: (row: T) => void
) => {
if (!canMutateDraft()) return false;
const row = rows[rowIndex];
if (!row) return false;
controller.index.value = rowIndex;
fillForm(row);
controller.visible.value = true;
return true;
};
const getEditingIndex = (controller: IndexedEditorController, length: number) => {
const idx = controller.index.value;
if (idx < 0 || idx >= length) return -1;
return idx;
};
const closeIndexedEditor = (controller: IndexedEditorController, successMessage: string) => {
controller.visible.value = false;
controller.index.value = -1;
ElMessage.success(successMessage);
};
const canSaveDraftAction = computed(() => Boolean(project.value && canManageSetup.value && !project.value?.is_locked && !isPublishedView.value));
const canPublishAction = computed(() => Boolean(project.value && canManageSetup.value && !project.value?.is_locked));
const currentStepTitle = computed(() => `第${activeStep.value + 1}步:${steps[activeStep.value]?.label || ""}`);
const setupPublishedVersionText = computed(() => {
return setupPublishedVersion.value || "v0";
});
const hasProjectPendingChanges = computed(() => hasFormUnsavedChanges.value);
const draftSyncStatus = computed<DraftSyncStatus>(() => {
const formDirty = Boolean(formBaselineSnapshot.value && serializeFormForCompare() !== formBaselineSnapshot.value);
if (formDirty || setupDirtySinceLastPersist.value || Boolean(autoSaveTimer.value)) {
return "DIRTY_UNSAVED";
}
return setupSaveMeta.value.serverSynced ? "SYNCED" : "LOCAL_ONLY";
});
const setupWorkflowStatus = computed<SetupWorkflowStatus>(() =>
resolveSetupWorkflowStatus(draftSyncStatus.value, {
canStrictlyDetectNoDiff: canStrictlyDetectNoDiff.value,
hasPublishableDiff: hasPublishableDiff.value,
})
);
const setupWorkflowTagMeta = computed<SetupWorkflowTagMeta>(() => SETUP_WORKFLOW_TAG_META[setupWorkflowStatus.value]);
const setupWorkflowTagLabel = computed(() => setupWorkflowTagMeta.value.label);
const setupWorkflowTagType = computed<"success" | "warning" | "info">(() => setupWorkflowTagMeta.value.type);
const isSetupDraftEffectivelyEmpty = (draft: SetupConfigDraft): boolean => {
if (!isProjectPublishSnapshotEmpty(draft.projectInfo)) return false;
if (draft.projectMilestones.length > 0) return false;
if (draft.siteMilestones.length > 0) return false;
if (draft.siteEnrollmentPlans.length > 0) return false;
if (draft.centerConfirm.length > 0) return false;
const plan = draft.enrollmentPlan;
const normalizedTotal = Number(plan.totalTarget || 0);
if (!Number.isFinite(normalizedTotal) || normalizedTotal !== 0) return false;
if ((plan.startDate || "").trim()) return false;
if ((plan.endDate || "").trim()) return false;
if ((plan.monthlyGoalNote || "").trim()) return false;
if ((plan.stageBreakdown || "").trim()) return false;
return true;
};
const isAtomicSetupDraft = (draft: SetupConfigDraft): boolean =>
!isProjectPublishSnapshotEmpty(draft.projectInfo) || isSetupDraftEffectivelyEmpty(draft);
const shouldShowEmptyDraftBaseInfo = computed(() => isSetupDraftEffectivelyEmpty(setupDraft));
const setupDraftBaseVersionText = computed(() => {
const explicit = (setupActiveDraftBaseVersion.value || "").trim();
if (explicit) return explicit;
const draftBranch = (setupCurrentBranchName.value || "main").trim() || "main";
const publishedBranch = (setupPublishedBranchName.value || "main").trim() || "main";
if (draftBranch === publishedBranch && setupPublishedVersionText.value && setupPublishedVersionText.value !== "v0") {
return setupPublishedVersionText.value;
}
return "-";
});
const setupDraftBaseVersionDisplay = computed(() => (shouldShowEmptyDraftBaseInfo.value ? "-" : setupDraftBaseVersionText.value));
const setupDraftBranchDisplay = computed(() =>
shouldShowEmptyDraftBaseInfo.value ? "-" : ((setupCurrentBranchName.value || "main").trim() || "main")
);
const setupDraftStatusDisplay = computed(() => (shouldShowEmptyDraftBaseInfo.value ? "-" : setupWorkflowTagLabel.value));
const serializeFormForCompare = (): string =>
JSON.stringify({
code: form.value.code || "",
name: form.value.name || "",
project_full_name: form.value.project_full_name || "",
sponsor: form.value.sponsor || "",
protocol_no: form.value.protocol_no || "",
lead_unit: form.value.lead_unit || "",
principal_investigator: form.value.principal_investigator || "",
main_pm: form.value.main_pm || "",
research_analysis: form.value.research_analysis || "",
research_product: form.value.research_product || "",
control_product: form.value.control_product || "",
indication: form.value.indication || "",
research_population: form.value.research_population || "",
research_design: form.value.research_design || "",
planned_site_count: form.value.planned_site_count,
// These three fields are setup-linked and persisted by setup draft/publish flow.
// Excluding them avoids false "project unsaved" prompts after saving setup config.
plan_start_date: "",
plan_end_date: "",
planned_enrollment_count: null,
phase: form.value.phase || "",
status: form.value.status || "",
visit_schedule: normalizeVisitSchedule(form.value.visit_schedule),
});
const setupDraftForPersistence = (): SetupConfigDraft => ({
...clone(setupDraft),
projectInfo: buildProjectPublishSnapshot(),
});
const syncProjectInfoIntoSetupDraft = () => {
setupDraft.projectInfo = buildProjectPublishSnapshot();
};
const serializeSetupForCompare = (): string => JSON.stringify(setupDraftForPersistence());
const buildProjectPublishSnapshotFromStudy = (study: Partial<Study> | null | undefined): ProjectPublishSnapshot => ({
code: study?.code || "",
name: study?.name || "",
project_full_name: study?.project_full_name || "",
sponsor: study?.sponsor || "",
protocol_no: study?.protocol_no || "",
lead_unit: study?.lead_unit || "",
principal_investigator: study?.principal_investigator || "",
main_pm: study?.main_pm || "",
research_analysis: study?.research_analysis || "",
research_product: study?.research_product || "",
control_product: study?.control_product || "",
indication: study?.indication || "",
research_population: study?.research_population || "",
research_design: study?.research_design || "",
plan_start_date: study?.plan_start_date || "",
plan_end_date: study?.plan_end_date || "",
planned_site_count: study?.planned_site_count ?? null,
planned_enrollment_count: study?.planned_enrollment_count ?? null,
status: study?.status || "",
visit_schedule: normalizeVisitSchedule(study?.visit_schedule),
});
const buildProjectPublishSnapshot = (): ProjectPublishSnapshot =>
buildProjectPublishSnapshotFromStudy({
code: form.value.code,
name: form.value.name,
project_full_name: form.value.project_full_name,
sponsor: form.value.sponsor,
protocol_no: form.value.protocol_no,
lead_unit: form.value.lead_unit,
principal_investigator: form.value.principal_investigator,
main_pm: form.value.main_pm,
research_analysis: form.value.research_analysis,
research_product: form.value.research_product,
control_product: form.value.control_product,
indication: form.value.indication,
research_population: form.value.research_population,
research_design: form.value.research_design,
plan_start_date: form.value.plan_start_date,
plan_end_date: form.value.plan_end_date,
planned_site_count: form.value.planned_site_count,
planned_enrollment_count: form.value.planned_enrollment_count,
status: form.value.status,
visit_schedule: normalizeVisitSchedule(form.value.visit_schedule),
});
const serializeProjectForPublishCompare = (): string => JSON.stringify(buildProjectPublishSnapshot());
const hasFormUnsavedChanges = computed(() => Boolean(formBaselineSnapshot.value && serializeFormForCompare() !== formBaselineSnapshot.value));
const hasSetupDiffFromServer = (): boolean =>
Boolean(setupServerSnapshot.value && serializeSetupForCompare() !== setupServerSnapshot.value);
const clearPendingAutoSave = () => {
if (!autoSaveTimer.value) return;
window.clearTimeout(autoSaveTimer.value);
autoSaveTimer.value = undefined;
};
const refreshSetupDirtyState = (): boolean => {
setupDirtySinceLastPersist.value = hasSetupDiffFromServer();
return setupDirtySinceLastPersist.value;
};
const markServerSyncedIfNoLocalChanges = () => {
if (setupDirtySinceLastPersist.value || autoSaveTimer.value) return;
if (setupSaveMeta.value.serverSynced) return;
setupSaveMeta.value = {
...setupSaveMeta.value,
serverSynced: true,
};
};
const reconcileDraftSyncStateBeforePublish = () => {
const changed = refreshSetupDirtyState();
if (changed) return;
clearPendingAutoSave();
markSetupSynced();
if (setupSaveMeta.value.serverSynced) {
clearLocalSetupDraft();
clearLocalProjectDraft();
}
};
const hasUnsavedChanges = computed(
() => hasFormUnsavedChanges.value || setupDirtySinceLastPersist.value || Boolean(autoSaveTimer.value)
);
const hasSetupDraftUnsavedChanges = computed(() => setupDirtySinceLastPersist.value || Boolean(autoSaveTimer.value));
const hasLeaveGuardChanges = computed(
() => hasFormUnsavedChanges.value || hasSetupDraftUnsavedChanges.value || draftSyncStatus.value !== "SYNCED"
);
const setupSectionStepMap: Record<string, number> = {
projectMilestones: 1,
enrollmentPlan: 2,
siteMilestones: 3,
siteEnrollmentPlans: 4,
centerConfirm: 5,
};
const currentStepErrors = computed(() => {
const grouped = groupErrorsBySection(
Object.entries(serverFieldErrors.value).map(([field, message]) => ({ field, message }))
);
const section = Object.keys(setupSectionStepMap).find((key) => setupSectionStepMap[key] === activeStep.value);
if (!section) return [];
return grouped[section] || [];
});
type DisplayInfoItem = {
label: string;
value: string | number | null | undefined;
updated?: boolean;
};
const shouldShowPreviewUpdateMark = computed(() => isPreviewView.value && !isFirstPublish.value);
const currentProjectPublishSnapshot = computed<ProjectPublishSnapshot>(() => {
if (isPublishedVersionView.value && publishedProjectSnapshot.value) {
return publishedProjectSnapshot.value;
}
return buildProjectPublishSnapshot();
});
const normalizeCompareValue = (value: unknown): string => serializeDiffValue(value);
const isPreviewValueUpdated = (currentValue: unknown, previousValue: unknown): boolean => {
if (!shouldShowPreviewUpdateMark.value) return false;
return normalizeCompareValue(currentValue) !== normalizeCompareValue(previousValue);
};
const normalizeIdentityText = (value: unknown): string => String(value ?? "").trim();
const resolveRowCompareTarget = <T extends { id?: string }>(
rows: T[] | null | undefined,
row: T | null | undefined,
index: number,
identityBuilder: (item: T) => string
): T | null => {
const sourceRows = Array.isArray(rows) ? rows : [];
if (!row || !sourceRows.length) return null;
const rowId = normalizeIdentityText(row.id);
if (rowId) {
const matchedById = sourceRows.find((item) => normalizeIdentityText(item.id) === rowId);
if (matchedById) return matchedById;
}
const rowIdentity = identityBuilder(row);
if (rowIdentity) {
const matchedByIdentity = sourceRows.filter((item) => identityBuilder(item) === rowIdentity);
if (matchedByIdentity.length === 1) return matchedByIdentity[0];
if (matchedByIdentity.length > 1) {
return matchedByIdentity[index] || matchedByIdentity[0];
}
}
return sourceRows[index] || null;
};
const isProjectSnapshotFieldUpdated = (field: keyof ProjectPublishSnapshot): boolean =>
isPreviewValueUpdated(currentProjectPublishSnapshot.value[field], projectPublishCompareBase.value?.[field]);
const isProjectMilestoneFieldUpdated = (
row: ProjectMilestoneDraft,
index: number,
field: "name" | "owner" | "status" | "remark" | "startDate" | "endDate" | "duration"
): boolean => {
const baseRow = resolveRowCompareTarget(
publishCompareBase.value.projectMilestones,
row,
index,
(item) => `${normalizeIdentityText(item.name)}|${normalizeIdentityText(item.owner)}`
);
if (field === "startDate") return isPreviewValueUpdated(resolveProjectMilestoneStart(row), resolveProjectMilestoneStart(baseRow || undefined));
if (field === "endDate") return isPreviewValueUpdated(resolveProjectMilestoneEnd(row), resolveProjectMilestoneEnd(baseRow || undefined));
if (field === "duration") {
return isPreviewValueUpdated(
resolveProjectMilestoneDurationDays(row),
resolveProjectMilestoneDurationDays(baseRow || undefined)
);
}
return isPreviewValueUpdated(row[field], baseRow?.[field]);
};
const publishedEnrollmentPlanParsed = computed(() =>
parseEnrollmentStageBreakdown(publishCompareBase.value.enrollmentPlan.stageBreakdown)
);
const isEnrollmentPlanFieldUpdated = (field: "startDate" | "endDate" | "totalTarget" | "cycle"): boolean => {
if (field === "cycle") {
return isPreviewValueUpdated(currentEnrollmentPlanCycle.value, publishedEnrollmentPlanParsed.value.cycle);
}
return isPreviewValueUpdated(currentSetupDraft.value.enrollmentPlan[field], publishCompareBase.value.enrollmentPlan[field]);
};
const isProjectEnrollmentPeriodUpdated = (key: string | null): boolean => {
if (!key) return false;
const cycle = currentEnrollmentPlanCycle.value;
const localValue = currentEnrollmentTargetsByCycle.value[cycle]?.[key];
const serverValue = publishedEnrollmentPlanParsed.value.targetsByCycle[cycle]?.[key];
return isPreviewValueUpdated(localValue, serverValue);
};
const isSiteMilestoneFieldUpdated = (
row: SiteMilestoneDraft,
index: number,
field: "milestone" | "planDate" | "owner" | "status" | "remark"
): boolean => {
const baseRow = resolveRowCompareTarget(
publishCompareBase.value.siteMilestones,
row,
index,
(item) => `${normalizeIdentityText(item.milestone)}|${normalizeIdentityText(item.owner)}`
);
return isPreviewValueUpdated(row[field], baseRow?.[field]);
};
const publishedSiteEnrollmentPlanMap = computed(() => {
const map = new Map<string, SiteEnrollmentPlanDraft>();
publishCompareBase.value.siteEnrollmentPlans.forEach((row) => {
const siteId = normalizeSiteId(row.siteId);
if (!siteId) return;
map.set(siteId, row);
});
return map;
});
const getPublishedSiteEnrollmentPlan = (siteId: string | null | undefined): SiteEnrollmentPlanDraft | null => {
const normalizedSiteId = normalizeSiteId(siteId || "");
if (!normalizedSiteId) return null;
return publishedSiteEnrollmentPlanMap.value.get(normalizedSiteId) || null;
};
const isSelectedSiteEnrollmentFieldUpdated = (field: "startDate" | "endDate" | "target" | "cycle"): boolean => {
if (field === "cycle") return isEnrollmentPlanFieldUpdated("cycle");
const currentPlan = selectedSiteEnrollmentPlan.value;
if (!currentPlan) return false;
const basePlan = getPublishedSiteEnrollmentPlan(currentPlan.siteId);
return isPreviewValueUpdated(currentPlan[field], basePlan?.[field]);
};
const isSelectedSiteEnrollmentPeriodUpdated = (key: string | null): boolean => {
if (!key || !selectedSiteEnrollmentPlan.value) return false;
const cycle = currentEnrollmentPlanCycle.value;
const currentTargets = getSiteEnrollmentTargetsByCycle(selectedSiteEnrollmentPlan.value);
const basePlan = getPublishedSiteEnrollmentPlan(selectedSiteEnrollmentPlan.value.siteId);
const baseTargets = basePlan ? parseSiteEnrollmentStageBreakdown(basePlan.stageBreakdown) : createEmptyEnrollmentTargetsByCycle();
return isPreviewValueUpdated(currentTargets[cycle]?.[key], baseTargets[cycle]?.[key]);
};
const isCenterConfirmPlanFieldUpdated = (siteId: string, field: "target" | "startDate" | "endDate"): boolean => {
const currentPlan = siteEnrollmentPlanMap.value.get(normalizeSiteId(siteId));
const basePlan = getPublishedSiteEnrollmentPlan(siteId);
return isPreviewValueUpdated(currentPlan?.[field], basePlan?.[field]);
};
const projectInfoItems = computed<DisplayInfoItem[]>(() => [
{ label: "项目名称", value: currentProjectPublishSnapshot.value.name || "-", updated: isProjectSnapshotFieldUpdated("name") },
{ label: "项目全称", value: currentProjectPublishSnapshot.value.project_full_name || "-", updated: isProjectSnapshotFieldUpdated("project_full_name") },
{ label: "申办方", value: currentProjectPublishSnapshot.value.sponsor || "-", updated: isProjectSnapshotFieldUpdated("sponsor") },
{ label: "项目编号", value: currentProjectPublishSnapshot.value.code || "-", updated: isProjectSnapshotFieldUpdated("code") },
{ label: "方案号", value: currentProjectPublishSnapshot.value.protocol_no || "-", updated: isProjectSnapshotFieldUpdated("protocol_no") },
{ label: "组长单位", value: currentProjectPublishSnapshot.value.lead_unit || "-", updated: isProjectSnapshotFieldUpdated("lead_unit") },
{ label: "主要研究者", value: currentProjectPublishSnapshot.value.principal_investigator || "-", updated: isProjectSnapshotFieldUpdated("principal_investigator") },
{ label: "主PM", value: currentProjectPublishSnapshot.value.main_pm || "-", updated: isProjectSnapshotFieldUpdated("main_pm") },
]);
const researchInfoItems = computed<DisplayInfoItem[]>(() => [
{ label: "研究分期", value: currentProjectPublishSnapshot.value.research_analysis || "-", updated: isProjectSnapshotFieldUpdated("research_analysis") },
{ label: "研究产品", value: currentProjectPublishSnapshot.value.research_product || "-", updated: isProjectSnapshotFieldUpdated("research_product") },
{ label: "对照产品", value: currentProjectPublishSnapshot.value.control_product || "-", updated: isProjectSnapshotFieldUpdated("control_product") },
{ label: "适应症", value: currentProjectPublishSnapshot.value.indication || "-", updated: isProjectSnapshotFieldUpdated("indication") },
{ label: "研究人群", value: currentProjectPublishSnapshot.value.research_population || "-", updated: isProjectSnapshotFieldUpdated("research_population") },
{ label: "研究设计", value: currentProjectPublishSnapshot.value.research_design || "-", updated: isProjectSnapshotFieldUpdated("research_design") },
]);
const executionInfoItems = computed<DisplayInfoItem[]>(() => [
{ label: "计划开始日期", value: currentProjectPublishSnapshot.value.plan_start_date || "-", updated: isProjectSnapshotFieldUpdated("plan_start_date") },
{ label: "计划结束日期", value: currentProjectPublishSnapshot.value.plan_end_date || "-", updated: isProjectSnapshotFieldUpdated("plan_end_date") },
{ label: "项目状态", value: statusLabel(currentProjectPublishSnapshot.value.status || "DRAFT"), updated: isProjectSnapshotFieldUpdated("status") },
{ label: "计划中心数", value: toDisplayNumber(currentProjectPublishSnapshot.value.planned_site_count), updated: isProjectSnapshotFieldUpdated("planned_site_count") },
{
label: "计划入组数",
value: toDisplayNumber(currentSetupDraft.value.enrollmentPlan.totalTarget),
updated: isEnrollmentPlanFieldUpdated("totalTarget"),
},
]);
const summaryVisitScheduleRows = computed<VisitScheduleItem[]>(() => normalizeVisitSchedule(currentProjectPublishSnapshot.value.visit_schedule));
const rules = ref<FormRules>({
code: [{ required: true, message: requiredMessage(TEXT.common.fields.projectCode), trigger: "blur" }],
name: [{ required: true, message: requiredMessage(TEXT.common.fields.projectName), trigger: "blur" }],
status: [{ required: true, message: requiredMessage(TEXT.common.fields.status), trigger: "change" }],
});
const makeId = (): string => `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const nowString = (): string => new Date().toLocaleString("zh-CN", { hour12: false });
const nowMs = (): number => Date.now();
const sleep = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
const formatDisplayTime = (value?: string | null): string => {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN", { hour12: false });
};
const clearSetupValidationErrors = () => {
serverFieldErrors.value = {};
};
const markSetupSynced = () => {
setupDirtySinceLastPersist.value = false;
};
const markSetupServerSynced = () => {
clearPendingAutoSave();
setupServerSnapshot.value = serializeSetupForCompare();
markSetupSynced();
clearLocalSetupDraft();
clearLocalProjectDraft();
};
const updateLastServerSaveMeta = (updatedAt?: string | null, savedBy?: string | null) => {
lastServerSaveMeta.value = {
updatedAt: formatDisplayTime(updatedAt) || lastServerSaveMeta.value.updatedAt || "-",
savedBy: savedBy || "-",
};
};
const parseLocalDraftEnvelope = <T>(
raw: string,
parseData: (candidate: unknown) => T | null,
serverVersion?: number
): LocalDraftLoadResult<T> | null => {
const parsed = JSON.parse(raw) as LocalDraftEnvelope<unknown>;
const parsedVersion = parsed?.version;
if (parsedVersion !== 1 && parsedVersion !== DRAFT_VERSION) return null;
const parsedData = parseData(parsed?.data);
if (!parsedData) return null;
const savedAtMs = typeof parsed?.savedAtMs === "number" ? parsed.savedAtMs : 0;
const ttlMs = typeof parsed?.ttlMs === "number" && parsed.ttlMs > 0 ? parsed.ttlMs : LOCAL_DRAFT_TTL_MS;
const serverVersionAtCache =
typeof parsed?.serverVersionAtCache === "number" ? parsed.serverVersionAtCache : null;
const expired = Boolean(savedAtMs && savedAtMs + ttlMs < nowMs());
const conflict = Boolean(
typeof serverVersion === "number" &&
serverVersionAtCache !== null &&
serverVersionAtCache !== serverVersion
);
return {
data: parsedData,
savedAt: parsed?.savedAt || nowString(),
expired,
conflict,
serverVersionAtCache,
};
};
const normalizeEnrollmentTarget = (value: number | null | undefined): number => {
if (value === null || value === undefined) return 0;
if (!Number.isFinite(value)) return 0;
if (value < 0) return 0;
return Math.floor(value);
};
const normalizePlanDate = (value: string | null | undefined): string => {
if (!value) return "";
return String(value);
};
const parseDateOnlyValue = (value: string | null | undefined): Date | null => {
const normalized = normalizePlanDate(value);
if (!normalized) return null;
const d = new Date(`${normalized}T00:00:00`);
if (Number.isNaN(d.getTime())) return null;
return d;
};
const calculateDurationDays = (startDate: string | null | undefined, endDate: string | null | undefined): number => {
const start = parseDateOnlyValue(startDate);
const end = parseDateOnlyValue(endDate);
if (!start || !end) return 0;
if (end < start) return 0;
const diffMs = end.getTime() - start.getTime();
return Math.floor(diffMs / 86400000) + 1;
};
const resolveProjectMilestoneStart = (row: Partial<ProjectMilestoneDraft> | null | undefined): string =>
normalizePlanDate(row?.startDate || row?.planDate || "");
const resolveProjectMilestoneEnd = (row: Partial<ProjectMilestoneDraft> | null | undefined): string =>
normalizePlanDate(row?.endDate || row?.planDate || "");
const resolveProjectMilestoneDurationDays = (row: Partial<ProjectMilestoneDraft> | null | undefined): number => {
const rawDays = Number((row as any)?.durationDays);
if (Number.isFinite(rawDays) && rawDays > 0) return Math.floor(rawDays);
const startDate = resolveProjectMilestoneStart(row);
const endDate = resolveProjectMilestoneEnd(row);
if (!startDate && !endDate) return 0;
return calculateDurationDays(startDate, endDate) || 1;
};
const formatProjectMilestoneDuration = (row: Partial<ProjectMilestoneDraft> | null | undefined): string => {
const days = resolveProjectMilestoneDurationDays(row);
return days > 0 ? `${days}天` : "-";
};
const normalizeProjectMilestoneRows = (rows: ProjectMilestoneDraft[]): ProjectMilestoneDraft[] =>
rows.map((row) => {
const startDate = resolveProjectMilestoneStart(row);
const endDate = resolveProjectMilestoneEnd(row);
const durationDays = resolveProjectMilestoneDurationDays({ ...row, startDate, endDate });
return {
...row,
planDate: startDate,
startDate,
endDate,
durationDays: durationDays > 0 ? durationDays : 1,
};
});
const getProjectPlanWindow = (): { start: string; end: string } => ({
start: normalizePlanDate(form.value.plan_start_date || project.value?.plan_start_date || ""),
end: normalizePlanDate(form.value.plan_end_date || project.value?.plan_end_date || ""),
});
const ENROLLMENT_STAGE_PAYLOAD_TYPE = "enrollment_plan_v2";
const SITE_ENROLLMENT_STAGE_PAYLOAD_TYPE = "site_enrollment_plan_v2";
const enrollmentCycleLabelMap: Record<EnrollmentCycle, string> = {
month: "月",
quarter: "季度",
};
const enrollmentKeyPatternMap: Record<EnrollmentCycle, RegExp> = {
month: /^\d{4}-(0[1-9]|1[0-2])$/,
quarter: /^\d{4}-Q[1-4]$/,
};
const normalizeEnrollmentCycle = (value: unknown): EnrollmentCycle => {
const normalized = String(value || "").trim().toLowerCase();
if (normalized === "quarter") return "quarter";
return "month";
};
const createEmptyEnrollmentTargetsByCycle = (): Record<EnrollmentCycle, Record<string, number>> => ({
month: {},
quarter: {},
});
const normalizeCycleTargets = (cycle: EnrollmentCycle, raw: unknown): Record<string, number> => {
if (!raw || typeof raw !== "object") return {};
const pattern = enrollmentKeyPatternMap[cycle];
const normalized: Record<string, number> = {};
Object.entries(raw as Record<string, unknown>).forEach(([key, value]) => {
if (!pattern.test(key)) return;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return;
normalized[key] = normalizeEnrollmentTarget(numeric);
});
return normalized;
};
const parseEnrollmentStageBreakdown = (
raw: string | null | undefined
): { cycle: EnrollmentCycle; targetsByCycle: Record<EnrollmentCycle, Record<string, number>> } => {
const empty = {
cycle: "month" as EnrollmentCycle,
targetsByCycle: createEmptyEnrollmentTargetsByCycle(),
};
if (!raw || !String(raw).trim()) return empty;
try {
const parsed = JSON.parse(String(raw));
if (!parsed || typeof parsed !== "object") return empty;
const data = parsed as Record<string, unknown>;
const cycle = normalizeEnrollmentCycle(data.cycle);
const byCycle = createEmptyEnrollmentTargetsByCycle();
const valuesByCycleRaw = data.valuesByCycle;
if (valuesByCycleRaw && typeof valuesByCycleRaw === "object") {
byCycle.month = normalizeCycleTargets("month", (valuesByCycleRaw as Record<string, unknown>).month);
byCycle.quarter = normalizeCycleTargets("quarter", (valuesByCycleRaw as Record<string, unknown>).quarter);
return { cycle, targetsByCycle: byCycle };
}
const legacyTargetsRaw = data.monthlyTargets || data.values;
const fallbackCycle = data.monthlyTargets ? "month" : cycle;
byCycle[fallbackCycle] = normalizeCycleTargets(fallbackCycle, legacyTargetsRaw);
return { cycle, targetsByCycle: byCycle };
} catch {
return empty;
}
};
const buildEnrollmentStageBreakdown = (
payload: { cycle: EnrollmentCycle; targetsByCycle: Record<EnrollmentCycle, Record<string, number>> }
): string => {
const byCycle: Record<EnrollmentCycle, Record<string, number>> = {
month: normalizeCycleTargets("month", payload.targetsByCycle?.month),
quarter: normalizeCycleTargets("quarter", payload.targetsByCycle?.quarter),
};
return JSON.stringify({
type: ENROLLMENT_STAGE_PAYLOAD_TYPE,
cycle: normalizeEnrollmentCycle(payload.cycle),
valuesByCycle: byCycle,
values: byCycle[normalizeEnrollmentCycle(payload.cycle)] || {},
});
};
const parseSiteEnrollmentStageBreakdown = (raw: string | null | undefined): Record<EnrollmentCycle, Record<string, number>> => {
if (!raw || !String(raw).trim()) return createEmptyEnrollmentTargetsByCycle();
try {
const parsed = JSON.parse(String(raw));
if (!parsed || typeof parsed !== "object") return createEmptyEnrollmentTargetsByCycle();
const data = parsed as Record<string, unknown>;
const byCycle = createEmptyEnrollmentTargetsByCycle();
const valuesByCycleRaw = data.valuesByCycle;
if (valuesByCycleRaw && typeof valuesByCycleRaw === "object") {
byCycle.month = normalizeCycleTargets("month", (valuesByCycleRaw as Record<string, unknown>).month);
byCycle.quarter = normalizeCycleTargets("quarter", (valuesByCycleRaw as Record<string, unknown>).quarter);
return byCycle;
}
byCycle.month = normalizeCycleTargets("month", data.values || data.monthlyTargets);
byCycle.quarter = normalizeCycleTargets("quarter", data.quarterlyTargets);
return byCycle;
} catch {
return createEmptyEnrollmentTargetsByCycle();
}
};
const buildSiteEnrollmentStageBreakdown = (payload: Record<EnrollmentCycle, Record<string, number>>): string => {
const byCycle: Record<EnrollmentCycle, Record<string, number>> = {
month: normalizeCycleTargets("month", payload.month),
quarter: normalizeCycleTargets("quarter", payload.quarter),
};
return JSON.stringify({
type: SITE_ENROLLMENT_STAGE_PAYLOAD_TYPE,
valuesByCycle: byCycle,
});
};
const parseDateParts = (value: string | null | undefined): { year: number; month: number; day: number } | null => {
const text = String(value || "").trim();
const matched = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
if (!matched) return null;
const year = Number(matched[1]);
const month = Number(matched[2]);
const day = Number(matched[3]);
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return null;
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
return { year, month, day };
};
const toDateOnly = (value: string | null | undefined): Date | null => {
const parts = parseDateParts(value);
if (!parts) return null;
const date = new Date(parts.year, parts.month - 1, parts.day);
if (date.getFullYear() !== parts.year || date.getMonth() !== parts.month - 1 || date.getDate() !== parts.day) {
return null;
}
return date;
};
const pad2 = (value: number): string => String(value).padStart(2, "0");
const getQuarter = (month: number): number => Math.floor((month - 1) / 3) + 1;
const toYearMonthIndex = (year: number, month: number): number => year * 12 + (month - 1);
const buildYearMonthKey = (year: number, month: number): string => `${year}-${String(month).padStart(2, "0")}`;
const buildEnrollmentSlotsByCycle = (
cycle: EnrollmentCycle,
startDateText: string | null | undefined,
endDateText: string | null | undefined
): EnrollmentPeriodSlot[] => {
const start = parseDateParts(startDateText);
const end = parseDateParts(endDateText);
if (!start || !end) return [];
if (toYearMonthIndex(start.year, start.month) > toYearMonthIndex(end.year, end.month)) return [];
if (cycle === "month") {
const result: EnrollmentPeriodSlot[] = [];
let year = start.year;
let month = start.month;
while (toYearMonthIndex(year, month) <= toYearMonthIndex(end.year, end.month)) {
result.push({
key: buildYearMonthKey(year, month),
label: `${month}月`,
groupLabel: `${year}`,
});
month += 1;
if (month > 12) {
month = 1;
year += 1;
}
}
return result;
}
const startDate = toDateOnly(startDateText);
const endDate = toDateOnly(endDateText);
if (!startDate || !endDate || startDate.getTime() > endDate.getTime()) return [];
const result: EnrollmentPeriodSlot[] = [];
let year = start.year;
let quarter = getQuarter(start.month);
const endQuarter = getQuarter(end.month);
while (year < end.year || (year === end.year && quarter <= endQuarter)) {
result.push({
key: `${year}-Q${quarter}`,
label: `Q${quarter}`,
groupLabel: `${year}`,
});
quarter += 1;
if (quarter > 4) {
quarter = 1;
year += 1;
}
}
return result;
};
const currentEnrollmentPlanParsed = computed(() =>
parseEnrollmentStageBreakdown(currentSetupDraft.value.enrollmentPlan.stageBreakdown)
);
const currentEnrollmentPlanCycle = computed<EnrollmentCycle>(() => {
if (isPublishedVersionView.value) return currentEnrollmentPlanParsed.value.cycle;
return enrollmentPlanCycle.value;
});
const currentEnrollmentTargetsByCycle = computed<Record<EnrollmentCycle, Record<string, number>>>(() => {
if (isPublishedVersionView.value) return currentEnrollmentPlanParsed.value.targetsByCycle;
return enrollmentTargetsByCycle.value;
});
const currentEnrollmentSlots = computed(() =>
buildEnrollmentSlotsByCycle(
currentEnrollmentPlanCycle.value,
currentSetupDraft.value.enrollmentPlan.startDate,
currentSetupDraft.value.enrollmentPlan.endDate
)
);
const enrollmentPlanMonthSlots = computed(() => {
const slots = buildEnrollmentSlotsByCycle(
"month",
currentSetupDraft.value.enrollmentPlan.startDate,
currentSetupDraft.value.enrollmentPlan.endDate
);
return slots.map((slot) => ({
year: Number(slot.key.slice(0, 4)),
month: Number(slot.key.slice(5, 7)),
key: slot.key,
}));
});
const enrollmentPlanYearRows = computed<EnrollmentYearRow[]>(() => {
const slots = enrollmentPlanMonthSlots.value;
if (!slots.length) return [];
const keySet = new Set(slots.map((slot) => slot.key));
const firstYear = slots[0].year;
const lastYear = slots[slots.length - 1].year;
const rows: EnrollmentYearRow[] = [];
for (let year = firstYear; year <= lastYear; year += 1) {
rows.push({
year,
cells: Array.from({ length: 12 }, (_, idx) => {
const key = buildYearMonthKey(year, idx + 1);
return { key: keySet.has(key) ? key : null };
}),
});
}
return rows;
});
const enrollmentPlanCycleLabel = computed(() => enrollmentCycleLabelMap[currentEnrollmentPlanCycle.value] || "月");
const buildEnrollmentCycleGridColumns = (): EnrollmentCycleGridColumn[] => {
return Array.from({ length: 4 }, (_, idx) => {
const quarter = idx + 1;
const key = `Q${quarter}`;
return { key, label: key };
});
};
const resolveEnrollmentCycleColumnKey = (periodKey: string): string | null => {
const matched = /^(\d{4})-Q([1-4])$/.exec(periodKey);
return matched ? `Q${matched[2]}` : null;
};
const enrollmentCycleGridColumns = computed<EnrollmentCycleGridColumn[]>(() => {
if (currentEnrollmentPlanCycle.value === "month") return [];
return buildEnrollmentCycleGridColumns();
});
const enrollmentCycleRowLabel = computed(() => "年份");
const enrollmentCycleGridRows = computed<EnrollmentCycleGridRow[]>(() => {
if (currentEnrollmentPlanCycle.value === "month") return [];
const columns = enrollmentCycleGridColumns.value;
if (!columns.length) return [];
const columnIndexMap = new Map<string, number>();
columns.forEach((column, idx) => columnIndexMap.set(column.key, idx));
const rowMap = new Map<string, EnrollmentCycleGridRow>();
currentEnrollmentSlots.value.forEach((slot) => {
const columnKey = resolveEnrollmentCycleColumnKey(slot.key);
if (!columnKey) return;
const columnIndex = columnIndexMap.get(columnKey);
if (columnIndex === undefined) return;
let row = rowMap.get(slot.groupLabel);
if (!row) {
row = {
groupLabel: slot.groupLabel,
cells: columns.map(() => ({ key: null })),
};
rowMap.set(slot.groupLabel, row);
}
row.cells[columnIndex] = { key: slot.key };
});
return Array.from(rowMap.values());
});
const getEnrollmentPeriodTarget = (key: string | null): number => {
if (!key) return 0;
const value = currentEnrollmentTargetsByCycle.value[currentEnrollmentPlanCycle.value]?.[key];
if (value === null || value === undefined) return 0;
return normalizeEnrollmentTarget(value);
};
const displayEnrollmentPeriodTarget = (key: string | null): string => {
if (!key) return "-";
const value = currentEnrollmentTargetsByCycle.value[currentEnrollmentPlanCycle.value]?.[key];
if (value === null || value === undefined) return "-";
return String(normalizeEnrollmentTarget(value));
};
const setEnrollmentPeriodTarget = (key: string | null, value: number | null) => {
if (!key) return;
const cycle = enrollmentPlanCycle.value;
const nextTargets = { ...(enrollmentTargetsByCycle.value[cycle] || {}) };
if (value === null || value === undefined) {
delete nextTargets[key];
} else {
nextTargets[key] = normalizeEnrollmentTarget(value);
}
enrollmentTargetsByCycle.value = {
...enrollmentTargetsByCycle.value,
[cycle]: nextTargets,
};
};
const clearEnrollmentPeriodPlan = () => {
if (!isEditing.value || isPublishedView.value || !canEditSetup.value) return;
const cycle = enrollmentPlanCycle.value;
const scopedKeys = new Set(currentEnrollmentSlots.value.map((item) => item.key));
if (!scopedKeys.size) {
ElMessage.warning("请先填写有效的计划入组日期范围");
return;
}
const nextTargets = { ...(enrollmentTargetsByCycle.value[cycle] || {}) };
scopedKeys.forEach((key) => {
delete nextTargets[key];
});
enrollmentTargetsByCycle.value = {
...enrollmentTargetsByCycle.value,
[cycle]: nextTargets,
};
ElMessage.success(`已清空当前日期范围内的${enrollmentPlanCycleLabel.value}周期计划`);
};
const quickFillEnrollmentPeriodPlan = () => {
if (!isEditing.value || isPublishedView.value || !canEditSetup.value) return;
const cycle = enrollmentPlanCycle.value;
const slots = currentEnrollmentSlots.value;
if (!slots.length) {
ElMessage.warning("请先填写有效的计划入组日期范围");
return;
}
const totalTarget = normalizeEnrollmentTarget(setupDraft.enrollmentPlan.totalTarget);
const base = Math.floor(totalTarget / slots.length);
let remainder = totalTarget - base * slots.length;
const nextTargets = { ...(enrollmentTargetsByCycle.value[cycle] || {}) };
slots.forEach((slot) => {
const extra = remainder > 0 ? 1 : 0;
nextTargets[slot.key] = base + extra;
if (remainder > 0) remainder -= 1;
});
enrollmentTargetsByCycle.value = {
...enrollmentTargetsByCycle.value,
[cycle]: nextTargets,
};
ElMessage.success(`已按${enrollmentPlanCycleLabel.value}周期快速均分入组计划`);
};
const projectMonthlyPlannedCount = computed(() => {
return currentEnrollmentSlots.value.reduce((sum, slot) => sum + getEnrollmentPeriodTarget(slot.key), 0);
});
const projectMonthlyPendingCount = computed(() => {
const totalTarget = normalizeEnrollmentTarget(currentSetupDraft.value.enrollmentPlan.totalTarget);
return Math.max(totalTarget - projectMonthlyPlannedCount.value, 0);
});
const projectMonthlyOverflowCount = computed(() => {
const totalTarget = normalizeEnrollmentTarget(currentSetupDraft.value.enrollmentPlan.totalTarget);
return Math.max(projectMonthlyPlannedCount.value - totalTarget, 0);
});
const centerEnrollmentTargetMap = computed(() => {
const map = new Map<string, number>();
currentSetupDraft.value.siteEnrollmentPlans.forEach((row) => {
const siteId = String(row.siteId || "").trim();
if (!siteId) return;
map.set(siteId, normalizeEnrollmentTarget(row.target));
});
return map;
});
const centerActiveSiteCount = computed(() => siteOptions.value.filter((site) => site.is_active !== false).length);
const centerConfiguredSiteCount = computed(() => {
const plannedSiteIds = centerEnrollmentTargetMap.value;
return siteOptions.value.filter((site) => site.is_active !== false && plannedSiteIds.has(String(site.id || "").trim())).length;
});
const centerPlannedCount = computed(() => {
return Array.from(centerEnrollmentTargetMap.value.values()).reduce((sum, target) => sum + target, 0);
});
const centerPendingCount = computed(() => {
const totalTarget = normalizeEnrollmentTarget(currentSetupDraft.value.enrollmentPlan.totalTarget);
return Math.max(totalTarget - centerPlannedCount.value, 0);
});
const centerOverflowCount = computed(() => {
const totalTarget = normalizeEnrollmentTarget(currentSetupDraft.value.enrollmentPlan.totalTarget);
return Math.max(centerPlannedCount.value - totalTarget, 0);
});
const centerUnplannedSiteCount = computed(() => {
const activeSites = siteOptions.value.filter((site) => site.is_active !== false);
if (!activeSites.length) return 0;
const plannedSiteIds = centerEnrollmentTargetMap.value;
return activeSites.filter((site) => !plannedSiteIds.has(site.id)).length;
});
const getSiteEnrollmentTargetsByCycle = (row: SiteEnrollmentPlanDraft): Record<EnrollmentCycle, Record<string, number>> => {
const parsed = parseSiteEnrollmentStageBreakdown(row.stageBreakdown);
return {
month: { ...parsed.month },
quarter: { ...parsed.quarter },
};
};
const sumSiteEnrollmentTargetForCurrentCycle = (targets: Record<EnrollmentCycle, Record<string, number>>): number => {
const slotKeys = new Set(currentEnrollmentSlots.value.map((item) => item.key));
const activeTargets = targets[currentEnrollmentPlanCycle.value] || {};
let sum = 0;
Object.entries(activeTargets).forEach(([key, value]) => {
if (!slotKeys.has(key)) return;
sum += normalizeEnrollmentTarget(value);
});
return sum;
};
const centerCyclePlannedCount = computed(() => {
return currentSetupDraft.value.siteEnrollmentPlans.reduce((sum, row) => {
return sum + sumSiteEnrollmentTargetForCurrentCycle(getSiteEnrollmentTargetsByCycle(row));
}, 0);
});
const centerCyclePendingCount = computed(() => Math.max(centerPlannedCount.value - centerCyclePlannedCount.value, 0));
const centerCycleOverflowCount = computed(() => Math.max(centerCyclePlannedCount.value - centerPlannedCount.value, 0));
const getSiteEnrollmentPeriodTarget = (row: SiteEnrollmentPlanDraft, key: string | null): number => {
if (!key) return 0;
const targets = getSiteEnrollmentTargetsByCycle(row);
const value = targets[currentEnrollmentPlanCycle.value]?.[key];
if (value === null || value === undefined) return 0;
return normalizeEnrollmentTarget(value);
};
const displaySiteEnrollmentPeriodTarget = (row: SiteEnrollmentPlanDraft, key: string | null): string => {
if (!key) return "-";
const targets = getSiteEnrollmentTargetsByCycle(row);
const value = targets[currentEnrollmentPlanCycle.value]?.[key];
if (value === null || value === undefined) return "-";
return String(normalizeEnrollmentTarget(value));
};
const setSiteEnrollmentPeriodTarget = (rowIndex: number, key: string | null, value: number | null) => {
if (!key) return;
const row = setupDraft.siteEnrollmentPlans[rowIndex];
if (!row) return;
const targets = getSiteEnrollmentTargetsByCycle(row);
const nextCycleTargets = { ...(targets[enrollmentPlanCycle.value] || {}) };
if (value === null || value === undefined) {
delete nextCycleTargets[key];
} else {
nextCycleTargets[key] = normalizeEnrollmentTarget(value);
}
targets[enrollmentPlanCycle.value] = nextCycleTargets;
row.stageBreakdown = buildSiteEnrollmentStageBreakdown(targets);
row.target = sumSiteEnrollmentTargetForCurrentCycle(targets);
};
const clearSiteEnrollmentPeriodPlan = (rowIndex: number) => {
const row = setupDraft.siteEnrollmentPlans[rowIndex];
if (!row) return;
const targets = getSiteEnrollmentTargetsByCycle(row);
const scopedKeys = new Set(currentEnrollmentSlots.value.map((item) => item.key));
const nextCycleTargets = { ...(targets[enrollmentPlanCycle.value] || {}) };
scopedKeys.forEach((key) => {
delete nextCycleTargets[key];
});
targets[enrollmentPlanCycle.value] = nextCycleTargets;
row.stageBreakdown = buildSiteEnrollmentStageBreakdown(targets);
row.target = sumSiteEnrollmentTargetForCurrentCycle(targets);
};
const quickFillSiteEnrollmentPeriodPlan = (rowIndex: number) => {
const row = setupDraft.siteEnrollmentPlans[rowIndex];
if (!row) return;
const slots = currentEnrollmentSlots.value;
if (!slots.length) {
ElMessage.warning("请先填写有效的计划入组日期范围");
return;
}
const rowTarget = normalizeEnrollmentTarget(row.target);
const base = Math.floor(rowTarget / slots.length);
let remainder = rowTarget - base * slots.length;
const targets = getSiteEnrollmentTargetsByCycle(row);
const nextCycleTargets = { ...(targets[enrollmentPlanCycle.value] || {}) };
slots.forEach((slot) => {
const extra = remainder > 0 ? 1 : 0;
nextCycleTargets[slot.key] = base + extra;
if (remainder > 0) remainder -= 1;
});
targets[enrollmentPlanCycle.value] = nextCycleTargets;
row.stageBreakdown = buildSiteEnrollmentStageBreakdown(targets);
row.target = sumSiteEnrollmentTargetForCurrentCycle(targets);
};
const applyEnrollmentStageFromDraft = () => {
const parsed = parseEnrollmentStageBreakdown(setupDraft.enrollmentPlan.stageBreakdown);
enrollmentStageSyncingFromModel.value = true;
enrollmentPlanCycle.value = parsed.cycle;
enrollmentTargetsByCycle.value = {
...createEmptyEnrollmentTargetsByCycle(),
...parsed.targetsByCycle,
};
enrollmentStageSyncingFromModel.value = false;
};
const getFieldError = (fieldPath: string): string => {
return serverFieldErrors.value[fieldPath] || "";
};
const extractValidationErrors = (error: any): SetupValidationError[] => {
const detail = error?.response?.data?.detail;
const list = detail?.errors || error?.response?.data?.errors;
if (!Array.isArray(list)) return [];
return list
.map((item: any) => ({
field: String(item?.field || ""),
message: String(item?.message || ""),
}))
.filter((item: SetupValidationError) => item.field && item.message);
};
const markValidationErrors = (errors: SetupValidationError[]) => {
const next: Record<string, string> = {};
errors.forEach((item) => {
next[item.field] = item.message;
});
serverFieldErrors.value = next;
};
const getApiErrorMessage = (error: any, fallback: string): string => {
const detail = error?.response?.data?.detail;
const errors = Array.isArray(detail?.errors) ? detail.errors : [];
const firstValidationMessage = typeof errors[0]?.message === "string" ? errors[0].message : "";
if (firstValidationMessage) {
if (typeof detail?.message === "string" && detail.message.trim()) {
return `${detail.message}${firstValidationMessage}`;
}
return firstValidationMessage;
}
if (typeof detail === "string") return detail;
if (typeof detail?.message === "string") return detail.message;
if (typeof error?.response?.data?.message === "string") return error.response.data.message;
return fallback;
};
const focusFirstValidationField = (errors: SetupValidationError[]) => {
const first = errors[0];
if (!first) return;
const parsed = parseFieldPath(first.field);
if (parsed && setupSectionStepMap[parsed.section] !== undefined) {
activeStep.value = setupSectionStepMap[parsed.section];
} else if (first.field.startsWith("enrollmentPlan.")) {
activeStep.value = 2;
}
requestAnimationFrame(() => {
const target = document.querySelector(`[data-field-path="${first.field}"]`) as HTMLElement | null;
target?.scrollIntoView({ behavior: "smooth", block: "center" });
});
};
const handleValidationFailure = (error: any, fallback: string): boolean => {
const validationErrors = extractValidationErrors(error);
if (!validationErrors.length) return false;
markValidationErrors(validationErrors);
focusFirstValidationField(validationErrors);
ElMessage.error(getApiErrorMessage(error, fallback));
return true;
};
const stepHasErrors = (stepIndex: number): boolean => {
return Object.keys(serverFieldErrors.value).some((path) => {
const parsed = parseFieldPath(path);
if (parsed) return setupSectionStepMap[parsed.section] === stepIndex;
if (path.startsWith("enrollmentPlan.")) return stepIndex === 2;
if (path.startsWith("projectMilestones")) return stepIndex === 1;
if (path.startsWith("siteMilestones")) return stepIndex === 3;
if (path.startsWith("siteEnrollmentPlans")) return stepIndex === 4;
if (path.startsWith("centerConfirm")) return stepIndex === 5;
return false;
});
};
const isStepCompleted = (stepIndex: number): boolean => {
if (stepHasErrors(stepIndex)) return false;
if (stepIndex === 0) {
return Boolean((form.value.name || "").trim() && (form.value.code || "").trim());
}
if (stepIndex === 1) {
return (
setupDraft.projectMilestones.length > 0 &&
setupDraft.projectMilestones.every((item) => {
const startDate = resolveProjectMilestoneStart(item);
const endDate = resolveProjectMilestoneEnd(item);
return (item.name || "").trim().length > 0 && !!startDate && !!endDate && startDate <= endDate;
})
);
}
if (stepIndex === 2) {
const plan = setupDraft.enrollmentPlan;
const { start: projectPlanStart, end: projectPlanEnd } = getProjectPlanWindow();
const enrollmentStart = normalizePlanDate(plan.startDate);
const enrollmentEnd = normalizePlanDate(plan.endDate);
if (!(plan.totalTarget >= 0 && enrollmentStart && enrollmentEnd && enrollmentStart <= enrollmentEnd)) return false;
if (projectPlanStart && enrollmentStart < projectPlanStart) return false;
if (projectPlanEnd && enrollmentStart > projectPlanEnd) return false;
if (projectPlanStart && enrollmentEnd < projectPlanStart) return false;
if (projectPlanEnd && enrollmentEnd > projectPlanEnd) return false;
return true;
}
if (stepIndex === 3) {
const { start: planStart, end: planEnd } = getProjectPlanWindow();
return (
setupDraft.siteMilestones.length > 0 &&
setupDraft.siteMilestones.every((item) => {
const planDate = normalizePlanDate(item.planDate);
if (!((item.milestone || "").trim().length > 0 && planDate)) return false;
if (planStart && planDate < planStart) return false;
if (planEnd && planDate > planEnd) return false;
return true;
})
);
}
if (stepIndex === 4) {
const { start: planStart, end: planEnd } = getProjectPlanWindow();
const siteIdSet = new Set<string>();
return (
setupDraft.siteEnrollmentPlans.length > 0 &&
setupDraft.siteEnrollmentPlans.every((item) => {
const siteId = normalizeSiteId(item.siteId);
if (!siteId) return false;
if (siteIdSet.has(siteId)) return false;
siteIdSet.add(siteId);
const startDate = normalizePlanDate(item.startDate);
const endDate = normalizePlanDate(item.endDate);
if (!(item.target >= 0 && startDate && endDate && startDate <= endDate)) return false;
if (planStart && startDate < planStart) return false;
if (planEnd && startDate > planEnd) return false;
if (planStart && endDate < planStart) return false;
if (planEnd && endDate > planEnd) return false;
return true;
})
);
}
if (stepIndex === 5) {
return true;
}
return false;
};
const incompleteSteps = computed(() => steps.map((step, index) => ({ index, label: step.label })).filter((item) => !isStepCompleted(item.index)));
const statusLabel = (status: string): string => {
if (status === "ACTIVE") return TEXT.enums.projectStatus.ACTIVE;
if (status === "CLOSED") return TEXT.enums.projectStatus.CLOSED;
return TEXT.enums.projectStatus.DRAFT;
};
const toDisplayNumber = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "-";
return String(value);
};
const normalizeSiteId = (siteId: string | null | undefined): string => String(siteId || "").trim();
const getSiteOptionLabel = (site: Partial<Site> | null | undefined): string => {
const name = String(site?.name || "").trim();
if (name) return name;
const siteId = normalizeSiteId((site?.id as string | undefined) || "");
if (siteId) return `中心-${siteId.slice(0, 8)}`;
return "未命名中心";
};
const siteSelectOptions = computed(() =>
siteOptions.value
.map((site) => ({
id: normalizeSiteId(String(site.id || "")),
label: getSiteOptionLabel(site),
}))
.filter((site) => Boolean(site.id))
);
const getSiteName = (siteId: string): string => {
const normalizedSiteId = normalizeSiteId(siteId);
const matched = siteOptions.value.find((s) => normalizeSiteId(String(s.id || "")) === normalizedSiteId);
return getSiteOptionLabel(matched || { id: normalizedSiteId, name: "" });
};
const normalizeOwnerDisplay = (value: string | null | undefined): string => {
if (!value) return "-";
return value
.split(/[,\uFF0C;\uFF1B]/)
.map((item) => item.trim().replace(/^[\[\]"'`]+|[\[\]"'`]+$/g, ""))
.filter(Boolean)
.map((item) => memberDisplayMap.value[item] || item)
.join("、") || "-";
};
const projectMilestoneOwnerOptions = computed(() => {
const options = [...projectMemberOptions.value];
const currentOwner = String(projectMilestoneEditorForm.owner || "").trim();
if (currentOwner && !options.some((option) => option.value === currentOwner)) {
options.unshift({
value: currentOwner,
label: normalizeOwnerDisplay(currentOwner),
});
}
return options;
});
const siteMilestoneOwnerOptions = computed(() => {
const options = [...projectMemberOptions.value];
const currentOwner = String(siteMilestoneEditorForm.owner || "").trim();
if (currentOwner && !options.some((option) => option.value === currentOwner)) {
options.unshift({
value: currentOwner,
label: normalizeOwnerDisplay(currentOwner),
});
}
return options;
});
const siteEnrollmentPlanMap = computed(() => {
const map = new Map<string, SiteEnrollmentPlanDraft>();
currentSetupDraft.value.siteEnrollmentPlans.forEach((row) => {
const siteId = normalizeSiteId(row.siteId);
if (!siteId) return;
map.set(siteId, row);
});
return map;
});
const centerConfirmDisplayRows = computed(() => {
return siteOptions.value.map((site) => {
const siteId = normalizeSiteId(site.id);
const plan = siteEnrollmentPlanMap.value.get(siteId);
return {
siteId,
siteName: getSiteOptionLabel(site),
city: site.city || "",
piName: site.pi_name || "",
ownerDisplay: normalizeOwnerDisplay(site.contact),
target: normalizeEnrollmentTarget(plan?.target),
isActive: site.is_active !== false,
startDate: plan?.startDate || "",
endDate: plan?.endDate || "",
};
});
});
const selectedSiteEnrollmentPlanSiteId = ref("");
const siteEnrollmentSelectableSites = computed(() => {
const map = new Map<string, Site>();
siteOptions.value.forEach((site) => {
const siteId = normalizeSiteId(site.id);
if (!siteId) return;
map.set(siteId, {
...site,
id: siteId,
name: getSiteOptionLabel(site),
});
});
currentSetupDraft.value.siteEnrollmentPlans.forEach((row) => {
const siteId = normalizeSiteId(row.siteId);
if (!siteId || map.has(siteId)) return;
map.set(siteId, {
id: siteId,
study_id: project.value?.id || "",
name: (row.siteName || "").trim() || `中心-${siteId.slice(0, 8)}`,
city: "",
pi_name: "",
contact: "",
enrollment_target: row.target || 0,
is_active: true,
});
});
return Array.from(map.values());
});
const selectedSiteEnrollmentPlanIndex = computed(() => {
const selectedSiteId = normalizeSiteId(selectedSiteEnrollmentPlanSiteId.value);
if (!selectedSiteId) return -1;
return currentSetupDraft.value.siteEnrollmentPlans.findIndex((row) => normalizeSiteId(row.siteId) === selectedSiteId);
});
const selectedSiteEnrollmentPlan = computed(() => {
const index = selectedSiteEnrollmentPlanIndex.value;
if (index < 0) return null;
return currentSetupDraft.value.siteEnrollmentPlans[index] || null;
});
const selectedSitePlanName = computed(() => {
const nameFromPlan = (selectedSiteEnrollmentPlan.value?.siteName || "").trim();
if (nameFromPlan) return nameFromPlan;
const selectedSiteId = normalizeSiteId(selectedSiteEnrollmentPlanSiteId.value);
if (!selectedSiteId) return "当前中心";
const matched = siteEnrollmentSelectableSites.value.find((site) => normalizeSiteId(site.id) === selectedSiteId);
const name = (matched?.name || "").trim();
return name || "当前中心";
});
const selectedSitePlanTarget = computed(() => normalizeEnrollmentTarget(selectedSiteEnrollmentPlan.value?.target));
const selectedSiteCyclePlannedCount = computed(() => {
const row = selectedSiteEnrollmentPlan.value;
if (!row) return 0;
return sumSiteEnrollmentTargetForCurrentCycle(getSiteEnrollmentTargetsByCycle(row));
});
const selectedSiteCyclePendingCount = computed(() =>
Math.max(selectedSitePlanTarget.value - selectedSiteCyclePlannedCount.value, 0)
);
const syncSelectedSiteEnrollmentPlanSiteId = () => {
const selectedSiteId = normalizeSiteId(selectedSiteEnrollmentPlanSiteId.value);
if (selectedSiteId && siteEnrollmentSelectableSites.value.some((site) => normalizeSiteId(site.id) === selectedSiteId)) {
return;
}
const firstConfiguredSiteId =
currentSetupDraft.value.siteEnrollmentPlans.map((row) => normalizeSiteId(row.siteId)).find((siteId) => Boolean(siteId)) ||
"";
if (firstConfiguredSiteId) {
selectedSiteEnrollmentPlanSiteId.value = firstConfiguredSiteId;
return;
}
selectedSiteEnrollmentPlanSiteId.value = normalizeSiteId(siteEnrollmentSelectableSites.value[0]?.id || "");
};
const createSelectedSiteEnrollmentPlan = () => {
if (!canMutateDraft()) return;
const selectedSiteId = normalizeSiteId(selectedSiteEnrollmentPlanSiteId.value);
if (!selectedSiteId) {
ElMessage.warning("请先选择中心");
return;
}
if (selectedSiteEnrollmentPlanIndex.value >= 0) return;
setupDraft.siteEnrollmentPlans.push({
id: makeId(),
siteId: selectedSiteId,
siteName: getSiteName(selectedSiteId),
target: 0,
startDate: setupDraft.enrollmentPlan.startDate || "",
endDate: setupDraft.enrollmentPlan.endDate || "",
note: "",
stageBreakdown: "",
});
};
const getSiteEnrollmentEditorIndex = () => getEditingIndex(siteEnrollmentEditor, setupDraft.siteEnrollmentPlans.length);
const isSiteEnrollmentDuplicate = (siteId: string, excludeIndex: number): boolean => {
const normalizedSiteId = normalizeSiteId(siteId);
if (!normalizedSiteId) return false;
return setupDraft.siteEnrollmentPlans.some(
(row, rowIndex) => rowIndex !== excludeIndex && normalizeSiteId(row.siteId) === normalizedSiteId
);
};
const isSiteEnrollmentSiteTaken = (siteId: string): boolean => {
const editingIndex = getSiteEnrollmentEditorIndex();
return isSiteEnrollmentDuplicate(siteId, editingIndex);
};
watch(
[siteOptions, () => currentSetupDraft.value.siteEnrollmentPlans.map((row) => normalizeSiteId(row.siteId)).join("|")],
syncSelectedSiteEnrollmentPlanSiteId,
{ immediate: true }
);
const createDefaultSetupDraft = (_sites: Site[]): SetupConfigDraft => {
return {
projectInfo: buildProjectPublishSnapshot(),
projectMilestones: [],
enrollmentPlan: {
totalTarget: 0,
startDate: "",
endDate: "",
monthlyGoalNote: "",
stageBreakdown: "",
},
siteMilestones: [],
siteEnrollmentPlans: [],
centerConfirm: [],
};
};
const normalizeSiteEnrollmentPlans = (rows: SiteEnrollmentPlanDraft[]): SiteEnrollmentPlanDraft[] =>
rows.map((row) => ({
...row,
stageBreakdown: row.stageBreakdown || "",
}));
const isSetupDraftShape = (value: unknown): value is SetupConfigDraft => {
if (!value || typeof value !== "object") return false;
const data = value as Record<string, unknown>;
return (
isProjectPublishSnapshotShape(data.projectInfo) &&
Array.isArray(data.projectMilestones) &&
!!data.enrollmentPlan &&
Array.isArray(data.siteMilestones) &&
Array.isArray(data.siteEnrollmentPlans) &&
Array.isArray(data.centerConfirm)
);
};
const isProjectPublishSnapshotShape = (value: unknown): value is ProjectPublishSnapshot => {
if (!value || typeof value !== "object") return false;
const data = value as Record<string, unknown>;
return (
typeof data.code === "string" &&
typeof data.name === "string" &&
typeof data.project_full_name === "string" &&
typeof data.status === "string"
);
};
function isProjectPublishSnapshotEmpty(snapshot: ProjectPublishSnapshot): boolean {
return JSON.stringify(snapshot) === JSON.stringify(emptyProjectInfoDraft());
}
const applyProjectInfoDraft = (snapshot: ProjectPublishSnapshot) => {
suppressEnrollmentFieldSync.value = true;
form.value.code = snapshot.code || "";
form.value.name = snapshot.name || "";
form.value.project_full_name = snapshot.project_full_name || "";
form.value.sponsor = snapshot.sponsor || "";
form.value.protocol_no = snapshot.protocol_no || "";
form.value.lead_unit = snapshot.lead_unit || "";
form.value.principal_investigator = snapshot.principal_investigator || "";
form.value.main_pm = snapshot.main_pm || "";
form.value.research_analysis = snapshot.research_analysis || "";
form.value.research_product = snapshot.research_product || "";
form.value.control_product = snapshot.control_product || "";
form.value.indication = snapshot.indication || "";
form.value.research_population = snapshot.research_population || "";
form.value.research_design = snapshot.research_design || "";
form.value.plan_start_date = snapshot.plan_start_date || "";
form.value.plan_end_date = snapshot.plan_end_date || "";
form.value.planned_site_count = snapshot.planned_site_count ?? null;
form.value.planned_enrollment_count = snapshot.planned_enrollment_count ?? null;
form.value.status = snapshot.status || "DRAFT";
form.value.visit_schedule = normalizeVisitSchedule(snapshot.visit_schedule);
suppressEnrollmentFieldSync.value = false;
};
const applySetupDraft = (draft: SetupConfigDraft) => {
suppressDraftWatch.value = true;
const projectInfo = isProjectPublishSnapshotShape(draft.projectInfo) ? clone(draft.projectInfo) : buildProjectPublishSnapshot();
setupDraft.projectInfo = projectInfo;
setupDraft.projectMilestones = normalizeProjectMilestoneRows(clone(draft.projectMilestones));
setupDraft.enrollmentPlan = clone(draft.enrollmentPlan);
setupDraft.siteMilestones = clone(draft.siteMilestones);
setupDraft.siteEnrollmentPlans = normalizeSiteEnrollmentPlans(clone(draft.siteEnrollmentPlans));
setupDraft.centerConfirm = clone(draft.centerConfirm);
applyProjectInfoDraft(projectInfo);
formBaselineSnapshot.value = serializeFormForCompare();
suppressDraftWatch.value = false;
};
const loadLocalSetupDraft = (): LocalDraftLoadResult<SetupConfigDraft> | null => {
try {
const raw = localStorage.getItem(storageKey.value);
if (!raw) return null;
return parseLocalDraftEnvelope(
raw,
(candidate) => (isSetupDraftShape(candidate) ? clone(candidate) : null),
setupRevision.value
);
} catch {
return null;
}
};
const persistSetupDraftToLocal = () => {
try {
const savedAt = nowString();
localStorage.setItem(
storageKey.value,
JSON.stringify({
version: DRAFT_VERSION,
data: setupDraftForPersistence(),
savedAt,
savedAtMs: nowMs(),
ttlMs: LOCAL_DRAFT_TTL_MS,
serverVersionAtCache: setupRevision.value,
})
);
setupLocalDraftSavedAt.value = savedAt;
setupSaveMeta.value = {
updatedAt: savedAt,
savedBy: authStore.user?.full_name || authStore.user?.username || authStore.user?.email || "当前用户",
serverSynced: false,
};
} catch {
// ignore local storage errors
}
};
const cacheSetupDraftToLocal = () => {
try {
const savedAt = nowString();
localStorage.setItem(
storageKey.value,
JSON.stringify({
version: DRAFT_VERSION,
data: setupDraftForPersistence(),
savedAt,
savedAtMs: nowMs(),
ttlMs: LOCAL_DRAFT_TTL_MS,
serverVersionAtCache: setupRevision.value,
})
);
setupLocalDraftSavedAt.value = savedAt;
} catch {
// ignore local storage errors
}
};
const clearLocalProjectDraft = () => {
try {
localStorage.removeItem(projectDraftStorageKey.value);
} catch {
// ignore local storage errors
}
};
const clearLocalSetupDraft = () => {
try {
localStorage.removeItem(storageKey.value);
setupLocalDraftSavedAt.value = "";
} catch {
// ignore local storage errors
}
};
const confirmRestoreLocalDraft = async (
title: string,
savedAt: string,
options?: { expired?: boolean; conflict?: boolean }
): Promise<boolean> => {
const tips: string[] = [`检测到本地草稿(保存时间:${savedAt || "-"})。`];
if (options?.expired) {
tips.push("该草稿已超过保留周期,可能过期。");
}
if (options?.conflict) {
tips.push("服务端版本已更新,直接恢复可能产生冲突。");
}
tips.push("是否恢复并继续编辑?");
try {
await ElMessageBox.confirm(tips.join("\n"), title, {
type: "warning",
confirmButtonText: "恢复草稿",
cancelButtonText: "忽略草稿",
});
return true;
} catch {
return false;
}
};
const loadSetupDraftFromServer = async (): Promise<SetupConfigDraft | null> => {
if (!project.value) return null;
try {
const { data } = await fetchSetupConfig(project.value.id);
if (!isSetupDraftShape(data?.data)) return null;
const draft = {
...clone(data.data),
projectInfo: isProjectPublishSnapshotShape(data.data.projectInfo) ? clone(data.data.projectInfo) : buildProjectPublishSnapshot(),
};
applySetupResponseMeta(data);
updateLastServerSaveMeta(data.updated_at, data.saved_by_name || resolveSavedByDisplay(data.saved_by));
setupSaveMeta.value = {
updatedAt: formatDisplayTime(data.updated_at),
savedBy: data.saved_by_name || resolveSavedByDisplay(data.saved_by),
serverSynced: true,
};
return draft;
} catch {
return null;
}
};
const resolveSavedByDisplay = (savedBy?: string | null): string => {
if (!savedBy) return "-";
if (savedBy === authStore.user?.id) {
return authStore.user?.full_name || authStore.user?.username || authStore.user?.email || "当前用户";
}
return savedBy;
};
const setupModuleLabels: Array<{ key: keyof SetupConfigDraft; label: string }> = [
{ key: "projectMilestones", label: "项目里程碑" },
{ key: "enrollmentPlan", label: "项目入组计划" },
{ key: "siteMilestones", label: "中心里程碑" },
{ key: "siteEnrollmentPlans", label: "中心入组计划" },
{ key: "centerConfirm", label: "中心确认" },
];
const emptySetupDraft: SetupConfigDraft = {
projectInfo: emptyProjectInfoDraft(),
projectMilestones: [],
enrollmentPlan: {
totalTarget: 0,
startDate: "",
endDate: "",
monthlyGoalNote: "",
stageBreakdown: "",
},
siteMilestones: [],
siteEnrollmentPlans: [],
centerConfirm: [],
};
const publishCompareBase = computed<SetupConfigDraft>(() => clone(publishedSetupDraft.value || emptySetupDraft));
const isFirstPublish = computed(() => !publishedSetupDraft.value);
const hasBranchPublishDiff = computed(() => {
const draftBranch = (setupCurrentBranchName.value || "main").trim() || "main";
const publishedBranch = (setupPublishedBranchName.value || "main").trim() || "main";
return draftBranch !== publishedBranch;
});
const projectPublishCompareBase = computed<ProjectPublishSnapshot | null>(() => {
const base = resolveProjectPublishCompareBase(publishedProjectSnapshot.value, projectPublishCompareFallback.value);
if (base) return clone(base);
return isFirstPublish.value ? emptyProjectInfoDraft() : null;
});
const hasProjectPublishDiff = computed(() => {
return hasProjectSnapshotDiff(buildProjectPublishSnapshot(), projectPublishCompareBase.value);
});
const canStrictlyDetectNoDiff = computed(() => Boolean(!isFirstPublish.value && publishedProjectSnapshot.value));
const hasPublishableDiff = computed(() => hasBranchPublishDiff.value || publishDiffLines.value.length > 0 || hasProjectPublishDiff.value);
const publishDiffLines = computed(() => {
const lines = buildSetupModuleDiffLines(setupDraft, publishCompareBase.value, setupModuleLabels);
if (hasBranchPublishDiff.value) {
lines.unshift(`发布分支差异:草稿 ${setupCurrentBranchName.value || "main"},当前发布 ${setupPublishedBranchName.value || "main"}`);
}
if (hasProjectPublishDiff.value) {
lines.unshift("项目信息存在差异");
}
return lines;
});
type DiffRow = SetupDiffRow;
const projectPublishDiffRows = computed<DiffRow[]>(() =>
buildProjectDiffRows(buildProjectPublishSnapshot(), projectPublishCompareBase.value, serializeDiffValue).map(
normalizeDiffRowForBusiness
)
);
const publishFieldDiffRows = computed(() => [...projectPublishDiffRows.value, ...buildReadableDiffRows(setupDraft, publishCompareBase.value)]);
const publishDiffModuleSummary = computed(() => {
const grouped = new Map<string, number>();
publishFieldDiffRows.value.forEach((row) => {
grouped.set(row.moduleLabel, (grouped.get(row.moduleLabel) || 0) + 1);
});
return Array.from(grouped.entries()).map(([module, count]) => ({ module, count }));
});
const normalizeDiffRowForBusiness = (row: DiffRow): DiffRow => {
if (row.path.includes("负责人")) {
return {
...row,
localValue: row.localValue === "未填写" ? row.localValue : normalizeOwnerDisplay(row.localValue),
serverValue: row.serverValue === "未填写" ? row.serverValue : normalizeOwnerDisplay(row.serverValue),
};
}
return row;
};
const buildReadableDiffRows = (localDraft: SetupConfigDraft | null, serverDraft: SetupConfigDraft | null): DiffRow[] =>
buildSetupReadableDiffRows(localDraft, serverDraft, setupModuleLabels).map(normalizeDiffRowForBusiness);
const applySetupResponseMeta = (data: StudySetupConfigResponse) => {
setupRevision.value = typeof data.version === "number" ? data.version : setupRevision.value;
setupCurrentBranchName.value = String(data.current_branch_name || setupCurrentBranchName.value || "main");
if (typeof data.current_published_version_label === "string" && data.current_published_version_label.trim()) {
setupPublishedVersion.value = data.current_published_version_label.trim();
}
setupActiveDraftBaseVersion.value =
typeof data.active_branch_base_version_label === "string" ? data.active_branch_base_version_label.trim() : "";
setupPublishStatus.value = data.publish_status || "DRAFT";
setupPublishedAt.value = formatDisplayTime(data.published_at);
publishedSetupDraft.value = isSetupDraftShape(data?.published_data) ? clone(data.published_data) : null;
const previousProjectSnapshot = publishedProjectSnapshot.value ? clone(publishedProjectSnapshot.value) : null;
const apiProjectSnapshot = isProjectPublishSnapshotShape(data?.published_project_snapshot)
? clone(data.published_project_snapshot)
: null;
const resolved = resolvePublishedProjectSnapshot({
publishStatus: setupPublishStatus.value,
apiProjectSnapshot,
previousSnapshot: previousProjectSnapshot,
projectSnapshotAtLoad: projectSnapshotAtLoad.value ? clone(projectSnapshotAtLoad.value) : null,
currentProjectSnapshot: buildProjectPublishSnapshot(),
});
publishedProjectSnapshot.value = resolved.snapshot ? clone(resolved.snapshot) : null;
if (resolved.clearFallback) {
projectPublishCompareFallback.value = null;
}
};
const refreshSetupDraftFromServer = async (options?: { silent?: boolean }) => {
const latest = await loadSetupDraftFromServer();
if (!latest) return false;
applySetupDraft(latest);
clearSetupValidationErrors();
markSetupServerSynced();
if (!options?.silent) {
ElMessage.info("已刷新服务端最新配置");
}
return true;
};
const persistSetupDraft = async (
showMessage = false,
options?: { forceDraft?: boolean }
): Promise<{ serverSynced: boolean; retryable: boolean }> => {
if (!project.value) return { serverSynced: false, retryable: false };
const payload: StudySetupConfigUpsertPayload = {
expected_version: setupRevision.value,
data: setupDraftForPersistence(),
force_draft: Boolean(options?.forceDraft),
};
try {
const { data } = await upsertSetupConfig(project.value.id, payload);
applySetupResponseMeta(data);
clearSetupValidationErrors();
markSetupServerSynced();
setupSaveMeta.value = {
updatedAt: formatDisplayTime(data.updated_at),
savedBy: data.saved_by_name || resolveSavedByDisplay(data.saved_by),
serverSynced: true,
};
updateLastServerSaveMeta(data.updated_at, data.saved_by_name || resolveSavedByDisplay(data.saved_by));
return { serverSynced: true, retryable: false };
} catch (e: any) {
if (handleValidationFailure(e, "配置保存失败,请修正后重试")) {
persistSetupDraftToLocal();
setupDirtySinceLastPersist.value = true;
return { serverSynced: false, retryable: false };
}
persistSetupDraftToLocal();
setupDirtySinceLastPersist.value = true;
if (e?.response?.status === 409) {
ElMessage.warning("配置已更新,正在刷新最新版本");
await refreshSetupDraftFromServer({ silent: true });
return { serverSynced: false, retryable: false };
}
if (e?.response?.status === 403) {
if (project.value) {
project.value.is_locked = true;
}
isEditing.value = false;
}
if (showMessage) {
if (e?.response?.status === 403) {
ElMessage.warning("项目已锁定,配置未同步到服务器,仅保留本地草稿");
} else {
ElMessage.warning(getApiErrorMessage(e, "服务器同步失败,已保存为本地草稿"));
}
}
return { serverSynced: false, retryable: !e?.response?.status };
}
};
const persistSetupDraftWithRetry = async (
showMessage = false,
options?: { forceDraft?: boolean }
): Promise<{ serverSynced: boolean }> => {
for (let attempt = 0; attempt <= SAVE_RETRY_DELAYS_MS.length; attempt += 1) {
const result = await persistSetupDraft(showMessage && attempt === SAVE_RETRY_DELAYS_MS.length, options);
if (result.serverSynced) {
return { serverSynced: true };
}
if (!result.retryable || attempt === SAVE_RETRY_DELAYS_MS.length) {
return { serverSynced: false };
}
await sleep(SAVE_RETRY_DELAYS_MS[attempt]);
}
return { serverSynced: false };
};
const schedulePersistDraft = () => {
if (autoSaveTimer.value) {
window.clearTimeout(autoSaveTimer.value);
}
autoSaveTimer.value = window.setTimeout(() => {
autoSaveTimer.value = undefined;
persistSetupDraftToLocal();
}, 500);
};
watch(
() => setupDraft.enrollmentPlan.stageBreakdown,
() => {
if (enrollmentStageSyncingToModel.value) return;
applyEnrollmentStageFromDraft();
},
{ immediate: true }
);
watch(
[enrollmentPlanCycle, enrollmentTargetsByCycle],
() => {
if (enrollmentStageSyncingFromModel.value) return;
if (!draftReady.value || !isEditing.value || isPublishedView.value || !canEditSetup.value) return;
const nextStageBreakdown = buildEnrollmentStageBreakdown({
cycle: enrollmentPlanCycle.value,
targetsByCycle: enrollmentTargetsByCycle.value,
});
if (nextStageBreakdown === setupDraft.enrollmentPlan.stageBreakdown) return;
enrollmentStageSyncingToModel.value = true;
setupDraft.enrollmentPlan.stageBreakdown = nextStageBreakdown;
enrollmentStageSyncingToModel.value = false;
},
{ deep: true }
);
watch(
setupDraft,
() => {
if (!draftReady.value || suppressDraftWatch.value || !canEditSetup.value) return;
const changed = refreshSetupDirtyState();
if (!changed) {
clearPendingAutoSave();
return;
}
schedulePersistDraft();
},
{ deep: true }
);
watch(
() => form.value.planned_enrollment_count,
(next) => {
if (!draftReady.value || suppressEnrollmentFieldSync.value) return;
const normalized = normalizeEnrollmentTarget(next);
if (
setupDraft.enrollmentPlan.totalTarget === normalized &&
form.value.planned_enrollment_count === normalized
) {
return;
}
suppressEnrollmentFieldSync.value = true;
form.value.planned_enrollment_count = normalized;
setupDraft.enrollmentPlan.totalTarget = normalized;
suppressEnrollmentFieldSync.value = false;
}
);
watch(
() => setupDraft.enrollmentPlan.totalTarget,
(next) => {
if (!draftReady.value || suppressEnrollmentFieldSync.value) return;
const normalized = normalizeEnrollmentTarget(next);
if (
form.value.planned_enrollment_count === normalized &&
setupDraft.enrollmentPlan.totalTarget === normalized
) {
return;
}
suppressEnrollmentFieldSync.value = true;
setupDraft.enrollmentPlan.totalTarget = normalized;
form.value.planned_enrollment_count = normalized;
suppressEnrollmentFieldSync.value = false;
}
);
watch(setupViewMode, (mode) => {
if (mode !== "draft") {
isEditing.value = false;
step1EditSection.value = "all";
}
});
const syncForm = (data: Study | null) => {
form.value.code = data?.code || "";
form.value.name = data?.name || "";
form.value.project_full_name = data?.project_full_name || "";
form.value.short_name = "";
form.value.sponsor = data?.sponsor || "";
form.value.protocol_no = data?.protocol_no || "";
form.value.lead_unit = data?.lead_unit || "";
form.value.principal_investigator = data?.principal_investigator || "";
form.value.main_pm = data?.main_pm || "";
form.value.research_analysis = data?.research_analysis || "";
form.value.research_product = data?.research_product || "";
form.value.control_product = data?.control_product || "";
form.value.indication = data?.indication || "";
form.value.research_population = data?.research_population || "";
form.value.research_design = data?.research_design || "";
form.value.plan_start_date = data?.plan_start_date || "";
form.value.plan_end_date = data?.plan_end_date || "";
form.value.planned_site_count = data?.planned_site_count ?? null;
form.value.planned_enrollment_count = data?.planned_enrollment_count ?? null;
form.value.phase = data?.phase || "";
form.value.status = data?.status || "DRAFT";
form.value.scope = "国内";
form.value.visit_schedule = normalizeVisitSchedule(data?.visit_schedule);
formBaselineSnapshot.value = serializeFormForCompare();
};
const syncFormFromProjectWithoutSetupLink = (data: Study | null) => {
suppressEnrollmentFieldSync.value = true;
syncForm(data);
suppressEnrollmentFieldSync.value = false;
};
const initializeSetupDraft = async () => {
clearSetupValidationErrors();
const serverDraft = await loadSetupDraftFromServer();
if (serverDraft) {
applySetupDraft(serverDraft);
setupServerSnapshot.value = serializeSetupForCompare();
markSetupSynced();
cacheSetupDraftToLocal();
draftReady.value = true;
return;
}
const localDraft = loadLocalSetupDraft();
if (localDraft) {
if (!isAtomicSetupDraft(localDraft.data)) {
clearLocalSetupDraft();
} else {
const shouldRestore = await confirmRestoreLocalDraft("恢复立项配置草稿", localDraft.savedAt, {
expired: localDraft.expired,
conflict: localDraft.conflict,
});
if (shouldRestore) {
applySetupDraft(localDraft.data);
setupDirtySinceLastPersist.value = true;
setupLocalDraftSavedAt.value = localDraft.savedAt;
setupSaveMeta.value = {
updatedAt: localDraft.savedAt || nowString(),
savedBy: authStore.user?.full_name || authStore.user?.username || authStore.user?.email || "当前用户",
serverSynced: false,
};
draftReady.value = true;
return;
}
clearLocalSetupDraft();
}
}
applySetupDraft(createDefaultSetupDraft(siteOptions.value));
setupServerSnapshot.value = "";
markSetupSynced();
draftReady.value = true;
persistSetupDraftToLocal();
};
const loadMemberDisplayMap = async (studyId: string) => {
const nextMap: Record<string, string> = {};
const memberOptionMap = new Map<string, string>();
try {
const { data } = await listMembers(studyId, { limit: 500 });
const rows = Array.isArray(data) ? data : (data as any).items || [];
rows.forEach((row: any) => {
if (row?.is_active === false) return;
const userId = String(row?.user_id || row?.userId || "").trim();
const memberId = String(row?.id || "").trim();
if (!userId) return;
const nestedUser = row?.user || {};
const displayName =
nestedUser?.full_name ||
nestedUser?.username ||
nestedUser?.email ||
row?.full_name ||
row?.username ||
row?.email ||
userId;
const roleInStudy = String(row?.role_in_study || row?.roleInStudy || "").trim();
const optionLabel = roleInStudy ? `${displayName}${roleInStudy}` : displayName;
nextMap[userId] = displayName;
if (memberId) nextMap[memberId] = displayName;
if (!memberOptionMap.has(userId)) {
memberOptionMap.set(userId, optionLabel);
}
});
} catch {
// Ignore member API errors.
}
memberDisplayMap.value = nextMap;
projectMemberOptions.value = Array.from(memberOptionMap.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label, "zh-CN"));
};
const loadProject = async () => {
try {
const projectId = route.params.projectId as string;
projectPublishCompareFallback.value = null;
const [studyRes, permRes] = await Promise.all([
fetchStudyDetail(projectId),
fetchMyApiEndpointPermissions(projectId, { suppressErrorMessage: true }).catch(() => ({ data: null })),
]);
const { data } = studyRes;
project.value = data as Study;
projectPermissions.value = permRes.data as any;
if (!hasSetupReadPermission.value) {
ElMessage.warning("当前角色无权访问立项配置");
router.replace("/admin/projects");
return;
}
syncFormFromProjectWithoutSetupLink(project.value);
projectSnapshotAtLoad.value = buildProjectPublishSnapshot();
clearLocalProjectDraft();
formBaselineSnapshot.value = serializeFormForCompare();
if (setupSaveMeta.value.serverSynced) {
setupDirtySinceLastPersist.value = false;
if (autoSaveTimer.value) {
window.clearTimeout(autoSaveTimer.value);
autoSaveTimer.value = undefined;
}
}
isEditing.value = false;
const siteRes = await fetchSites(projectId, { limit: 500 });
const list = Array.isArray(siteRes.data) ? siteRes.data : (siteRes.data as any).items || [];
siteOptions.value = list;
await loadMemberDisplayMap(projectId);
await initializeSetupDraft();
await loadSetupVersionHistory();
} catch (e: any) {
ElMessage.error(getApiErrorMessage(e, TEXT.modules.adminProjects.loadFailed));
}
};
const refreshProjectDisplayAfterPublish = async () => {
if (!project.value) return;
try {
const { data } = await fetchStudyDetail(project.value.id);
project.value = data as Study;
syncFormFromProjectWithoutSetupLink(project.value);
formBaselineSnapshot.value = serializeFormForCompare();
if (studyStore.currentStudy?.id === project.value.id) {
studyStore.setCurrentStudy({ ...(studyStore.currentStudy as Study), ...project.value } as Study);
await studyStore.loadCurrentStudyPermissions().catch(() => {});
}
} catch {
// Ignore refresh errors to avoid breaking publish success flow.
}
};
const startEdit = () => {
if (!project.value) return;
if (activeStep.value !== 0 && activeStep.value !== 2 && activeStep.value !== 4) return;
if (isPublishedView.value) {
ElMessage.info("当前为只读视图,请先切换到草稿后再编辑");
return;
}
if (!canManageSetup.value) {
ElMessage.warning("当前角色无权编辑立项配置");
return;
}
if (project.value.is_locked) {
ElMessage.warning("项目已锁定,无法编辑。");
return;
}
clearSetupValidationErrors();
if (activeStep.value === 0) {
step1EditSection.value = infoTab.value === "summary" ? "summary" : "all";
}
formBaselineSnapshot.value = serializeFormForCompare();
if (drawerCloseTimer.value) {
window.clearTimeout(drawerCloseTimer.value);
drawerCloseTimer.value = undefined;
}
drawerClosing.value = false;
isEditing.value = true;
};
const startStep1SectionEdit = (section: Exclude<Step1EditSection, "all">) => {
if (activeStep.value !== 0) return;
if (section !== "summary") {
infoTab.value = "basic";
} else {
infoTab.value = "summary";
}
startEdit();
if (isEditing.value) {
step1EditSection.value = section;
if (section === "summary") {
visitScheduleDialogVisible.value = true;
}
}
};
const cancelEdit = () => {
applyProjectInfoDraft(setupDraft.projectInfo);
isEditing.value = false;
step1EditSection.value = "all";
visitScheduleDialogVisible.value = false;
clearSetupValidationErrors();
formBaselineSnapshot.value = serializeFormForCompare();
};
const closeEditDrawer = () => {
if (!isEditing.value || drawerClosing.value) return;
if (visitScheduleDialogVisible.value) {
cancelEdit();
return;
}
drawerClosing.value = true;
drawerCloseTimer.value = window.setTimeout(() => {
cancelEdit();
drawerClosing.value = false;
drawerCloseTimer.value = undefined;
}, 220);
};
const closeEditDrawerAfterSave = (onClosed?: () => void) => {
if (!isEditing.value || drawerClosing.value) return;
if (visitScheduleDialogVisible.value) {
visitScheduleDialogVisible.value = false;
isEditing.value = false;
step1EditSection.value = "all";
clearSetupValidationErrors();
formBaselineSnapshot.value = serializeFormForCompare();
onClosed?.();
return;
}
drawerClosing.value = true;
drawerCloseTimer.value = window.setTimeout(() => {
isEditing.value = false;
step1EditSection.value = "all";
clearSetupValidationErrors();
formBaselineSnapshot.value = serializeFormForCompare();
drawerClosing.value = false;
drawerCloseTimer.value = undefined;
onClosed?.();
}, 220);
};
const cancelVisitScheduleDialogEdit = () => {
cancelEdit();
};
const saveVisitScheduleDialogEdit = async () => {
if (!canEditSetup.value) return;
drawerConfirmLoading.value = true;
try {
syncProjectInfoIntoSetupDraft();
const setupChanged = refreshSetupDirtyState();
if (setupChanged) {
persistSetupDraftToLocal();
} else {
clearLocalSetupDraft();
clearPendingAutoSave();
markServerSyncedIfNoLocalChanges();
}
clearLocalProjectDraft();
formBaselineSnapshot.value = serializeFormForCompare();
visitScheduleDialogVisible.value = false;
isEditing.value = false;
step1EditSection.value = "all";
clearSetupValidationErrors();
ElMessage.success("访视计划已保存");
} finally {
drawerConfirmLoading.value = false;
}
};
const handleDrawerConfirm = async () => {
if (!canEditSetup.value) return;
drawerConfirmLoading.value = true;
try {
let saved = false;
if (activeStep.value === 0) {
if (formRef.value) {
await formRef.value.validate();
}
syncProjectInfoIntoSetupDraft();
const setupChanged = refreshSetupDirtyState();
if (setupChanged) {
persistSetupDraftToLocal();
} else {
clearLocalSetupDraft();
clearPendingAutoSave();
markServerSyncedIfNoLocalChanges();
}
clearLocalProjectDraft();
formBaselineSnapshot.value = serializeFormForCompare();
saved = true;
} else {
const setupChanged = refreshSetupDirtyState();
if (setupChanged) {
persistSetupDraftToLocal();
} else {
clearLocalSetupDraft();
clearPendingAutoSave();
markServerSyncedIfNoLocalChanges();
}
saved = true;
}
if (!saved) return;
closeEditDrawerAfterSave();
ElMessage.success("当前步骤已保存");
} finally {
drawerConfirmLoading.value = false;
}
};
const captureProjectCompareFallbackBeforeSave = (): ProjectPublishSnapshot | null => {
if (
!shouldCaptureProjectCompareFallback({
hasProjectPendingChanges: hasFormUnsavedChanges.value,
hasPublishedProjectSnapshot: Boolean(publishedProjectSnapshot.value),
isFirstPublish: isFirstPublish.value,
})
) {
return null;
}
return buildProjectPublishSnapshotFromStudy(project.value);
};
const applyCapturedProjectCompareFallback = (snapshot: ProjectPublishSnapshot | null) => {
if (!snapshot || publishedProjectSnapshot.value) return;
projectPublishCompareFallback.value = clone(snapshot);
};
const saveConfigNow = async (): Promise<boolean> => {
if (!canSaveDraftAction.value) return false;
const projectSnapshotBeforeSave = captureProjectCompareFallbackBeforeSave();
if (hasFormUnsavedChanges.value) {
syncProjectInfoIntoSetupDraft();
setupDirtySinceLastPersist.value = true;
}
const result = await persistSetupDraftWithRetry(false, { forceDraft: true });
if (!result.serverSynced) return false;
applyCapturedProjectCompareFallback(projectSnapshotBeforeSave);
formBaselineSnapshot.value = serializeFormForCompare();
return true;
};
const doPublishConfigNow = async (): Promise<boolean> => {
if (!project.value || !canPublishAction.value) return false;
try {
if (hasFormUnsavedChanges.value) {
syncProjectInfoIntoSetupDraft();
setupDirtySinceLastPersist.value = true;
}
if (setupDirtySinceLastPersist.value || Boolean(autoSaveTimer.value)) {
const draftSaved = await persistSetupDraftWithRetry(false, { forceDraft: true });
if (!draftSaved.serverSynced) return false;
}
const { data } = await publishConfig(project.value.id, { expected_version: setupRevision.value });
applySetupResponseMeta(data);
clearSetupValidationErrors();
markSetupSynced();
clearLocalSetupDraft();
setupViewMode.value = "preview";
setupSaveMeta.value = {
updatedAt: formatDisplayTime(data.updated_at),
savedBy: data.saved_by_name || resolveSavedByDisplay(data.saved_by),
serverSynced: true,
};
updateLastServerSaveMeta(data.updated_at, data.saved_by_name || resolveSavedByDisplay(data.saved_by));
const projectionStatus = String(data?.projection_status || "").toLowerCase();
const projectionSummary = data?.projection_summary || {};
const updatedCount = Number(projectionSummary.site_updated_count || 0);
const skippedCount = Number(projectionSummary.site_skipped_count || 0);
if (projectionStatus === "partial_success") {
ElMessage.success(`配置已发布,已同步${updatedCount}个中心,跳过${skippedCount}个中心`);
} else {
ElMessage.success("配置已发布,已同步项目计划与中心入组目标");
}
const warnings = Array.isArray(projectionSummary.warnings) ? projectionSummary.warnings : [];
if (warnings.length > 0) {
await ElMessageBox.alert(warnings.slice(0, 3).join("\n"), "发布联动提示", {
confirmButtonText: "我知道了",
});
}
await refreshProjectDisplayAfterPublish();
return true;
} catch (e: any) {
if (handleValidationFailure(e, "发布失败,请先修正配置")) {
publishConfirmVisible.value = false;
setupViewMode.value = "draft";
return false;
}
if (e?.response?.status === 409) {
ElMessage.warning("发布失败:配置已更新,正在刷新最新版本");
await refreshSetupDraftFromServer({ silent: true });
return false;
}
if (e?.response?.status === 403) {
ElMessage.warning("项目已锁定,无法发布");
if (project.value) project.value.is_locked = true;
return false;
}
ElMessage.error(getApiErrorMessage(e, "发布失败"));
return false;
}
};
const publishConfigNow = async () => {
if (!project.value || !canManageSetup.value) return;
reconcileDraftSyncStateBeforePublish();
if (hasSetupDraftUnsavedChanges.value) {
ElMessage.warning("当前存在未上传的本地草稿,请先点击“保存配置”");
return;
}
if (draftSyncStatus.value === "LOCAL_ONLY") {
ElMessage.warning("当前草稿仅保存在本地,需先同步到服务端后才可发布");
return;
}
if (canStrictlyDetectNoDiff.value && !hasPublishableDiff.value) {
ElMessage.info("当前草稿与已发布快照一致,无需重复发布");
return;
}
publishConfirmVisible.value = true;
};
const loadSetupVersionHistory = async () => {
if (!project.value) return;
rollbackDialogLoading.value = true;
try {
const { data } = await fetchSetupVersions(project.value.id);
const history = (data || []).map((item) => ({
...item,
published_at: formatDisplayTime(item.published_at),
}));
setupVersionHistory.value = history;
const currentPublished = history.find((item) => item.is_current_published) || history[0];
setupPublishedVersion.value = currentPublished
? (currentPublished.version_label || `v${currentPublished.display_version || currentPublished.version}`)
: "v0";
setupPublishedBranchName.value = currentPublished?.branch_name || "main";
const activeDraftBase = history.find((item) => item.is_active_draft_base);
if (activeDraftBase) {
setupActiveDraftBaseVersion.value = activeDraftBase.version_label || `v${activeDraftBase.display_version || activeDraftBase.version}`;
} else if (currentPublished && (currentPublished.branch_name || "main") === (setupCurrentBranchName.value || "main")) {
setupActiveDraftBaseVersion.value = currentPublished.version_label || `v${currentPublished.display_version || currentPublished.version}`;
} else {
setupActiveDraftBaseVersion.value = "";
}
} catch (e: any) {
ElMessage.error(getApiErrorMessage(e, "获取版本历史失败"));
} finally {
rollbackDialogLoading.value = false;
}
};
const getDisplayVersionLabel = (rawVersion: number): string => {
const found = setupVersionHistory.value.find((item) => item.version === rawVersion);
if (!found) return `v${rawVersion}`;
return found.version_label || `v${found.display_version || found.version}`;
};
const ROLLBACK_AXIS_LANE_GAP = 34;
const ROLLBACK_AXIS_LANE_START = 18;
const getRollbackAxisLeftStyle = (axisX?: number | null) => ({
left: `${Number.isFinite(axisX) ? axisX : ROLLBACK_AXIS_LANE_START}px`,
});
const normalizeAxisBranch = (branchName?: string | null): string => (branchName || "main").trim() || "main";
const formatAxisLaneLabel = (branchName: string): string =>
branchName === "main" ? "main" : branchName.replace(/^release\//, "");
const rollbackAxisLanes = computed<RollbackAxisLane[]>(() => {
const laneMap = new Map<string, number>();
laneMap.set("main", 0);
setupVersionHistory.value.forEach((item) => {
const branch = normalizeAxisBranch(item.branch_name);
if (!laneMap.has(branch)) {
laneMap.set(branch, laneMap.size);
}
});
return Array.from(laneMap.entries()).map(([branch, index]) => ({
branch,
index,
x: ROLLBACK_AXIS_LANE_START + index * ROLLBACK_AXIS_LANE_GAP,
}));
});
const rollbackAxisLaneMap = computed(() => {
const map = new Map<string, RollbackAxisLane>();
rollbackAxisLanes.value.forEach((lane) => map.set(lane.branch, lane));
return map;
});
const rollbackAxisWidth = computed(() => {
const laneCount = rollbackAxisLanes.value.length;
return Math.max(110, ROLLBACK_AXIS_LANE_START * 2 + Math.max(0, laneCount - 1) * ROLLBACK_AXIS_LANE_GAP + 12);
});
const resolveSpawnParentVersionId = (
item: StudySetupConfigVersionItem,
idMap: Map<string, StudySetupConfigVersionItem>
): string | null => {
const itemBranch = normalizeAxisBranch(item.branch_name);
if (itemBranch === "main") return null;
const parentFromId = String(item.parent_version_id || "").trim();
if (parentFromId) {
const parent = idMap.get(parentFromId);
if (parent && normalizeAxisBranch(parent.branch_name) !== itemBranch) {
return parent.id;
}
}
if (Number(item.branch_seq || 0) <= 1) {
const fallbackMainParent = setupVersionHistory.value.find(
(candidate) =>
normalizeAxisBranch(candidate.branch_name) === "main" &&
Number(candidate.display_version || 0) === Number(item.display_version || 0)
);
return fallbackMainParent?.id || null;
}
return null;
};
const rollbackAxisRows = computed<RollbackAxisRow[]>(() => {
const idMap = new Map<string, StudySetupConfigVersionItem>();
setupVersionHistory.value.forEach((item) => {
idMap.set(item.id, item);
});
return setupVersionHistory.value.map((item) => {
const branch = normalizeAxisBranch(item.branch_name);
const lane = rollbackAxisLaneMap.value.get(branch);
const axisX = lane?.x ?? ROLLBACK_AXIS_LANE_START;
const mergedFromId = String(item.merged_from_version_id || "").trim();
const mergedFrom = mergedFromId ? idMap.get(mergedFromId) : undefined;
return {
...item,
displayLabel: item.version_label || `v${item.display_version || item.version}`,
mergeFromId: mergedFrom?.id || null,
axisX,
mergeFromLabel: mergedFrom ? (mergedFrom.version_label || `v${mergedFrom.display_version || mergedFrom.version}`) : "",
spawnParentId: resolveSpawnParentVersionId(item, idMap),
};
});
});
const rollbackAxisScrollRef = ref<HTMLElement | null>(null);
const rollbackAxisRowTopMap = ref<Record<string, number>>({});
const rollbackAxisOverlayHeight = ref(0);
const rollbackAxisRowElementMap = new Map<string, HTMLElement>();
const setRollbackAxisRowRef = (el: HTMLElement | null, rowId: string) => {
if (el) {
rollbackAxisRowElementMap.set(rowId, el);
} else {
rollbackAxisRowElementMap.delete(rowId);
}
};
const syncRollbackAxisLayout = () => {
const scrollEl = rollbackAxisScrollRef.value;
if (!scrollEl) return;
const topMap: Record<string, number> = {};
rollbackAxisRows.value.forEach((row) => {
const rowEl = rollbackAxisRowElementMap.get(row.id);
if (rowEl) {
topMap[row.id] = rowEl.offsetTop;
}
});
rollbackAxisRowTopMap.value = topMap;
rollbackAxisOverlayHeight.value = scrollEl.scrollHeight;
};
const rollbackAxisSpawnConnectors = computed(() => {
const rowMap = new Map<string, RollbackAxisRow>();
rollbackAxisRows.value.forEach((row) => rowMap.set(row.id, row));
return rollbackAxisRows.value
.map((row) => {
const parentId = row.spawnParentId;
if (!parentId) return null;
const parent = rowMap.get(parentId);
if (!parent) return null;
const sourceY = (rollbackAxisRowTopMap.value[parent.id] ?? -1) + 28;
const targetY = (rollbackAxisRowTopMap.value[row.id] ?? -1) + 28;
if (sourceY <= 0 || targetY <= 0) return null;
return {
key: `${parent.id}-${row.id}`,
x1: parent.axisX,
y1: sourceY,
x2: row.axisX,
y2: targetY,
};
})
.filter(
(
item
): item is {
key: string;
x1: number;
y1: number;
x2: number;
y2: number;
} => Boolean(item)
);
});
const rollbackAxisMergeConnectors = computed(() => {
const rowMap = new Map<string, RollbackAxisRow>();
rollbackAxisRows.value.forEach((row) => rowMap.set(row.id, row));
return rollbackAxisRows.value
.map((row) => {
const sourceId = row.mergeFromId;
if (!sourceId) return null;
const source = rowMap.get(sourceId);
if (!source) return null;
const sourceY = (rollbackAxisRowTopMap.value[source.id] ?? -1) + 28;
const targetY = (rollbackAxisRowTopMap.value[row.id] ?? -1) + 28;
if (sourceY <= 0 || targetY <= 0) return null;
return {
key: `${source.id}->${row.id}`,
x1: source.axisX,
y1: sourceY,
x2: row.axisX,
y2: targetY,
};
})
.filter(
(
item
): item is {
key: string;
x1: number;
y1: number;
x2: number;
y2: number;
} => Boolean(item)
);
});
/* 同分支连续版本间的连接器 - 显示 release 分支内的继承关系 */
const rollbackAxisSameBranchConnectors = computed(() => {
const branchGroups = new Map<string, RollbackAxisRow[]>();
rollbackAxisRows.value.forEach((row) => {
const branch = normalizeAxisBranch(row.branch_name);
if (!branchGroups.has(branch)) {
branchGroups.set(branch, []);
}
branchGroups.get(branch)!.push(row);
});
const connectors: { key: string; x: number; y1: number; y2: number; isMain: boolean }[] = [];
branchGroups.forEach((rows, branch) => {
if (rows.length < 2) return;
for (let i = 0; i < rows.length - 1; i++) {
const topRow = rows[i];
const bottomRow = rows[i + 1];
const topY = (rollbackAxisRowTopMap.value[topRow.id] ?? -1) + 28;
const bottomY = (rollbackAxisRowTopMap.value[bottomRow.id] ?? -1) + 28;
if (topY <= 0 || bottomY <= 0) continue;
connectors.push({
key: `sb-${topRow.id}-${bottomRow.id}`,
x: topRow.axisX,
y1: topY,
y2: bottomY,
isMain: branch === "main",
});
}
});
return connectors;
});
watch(
() => [rollbackDialogVisible.value, rollbackAxisRows.value.map((item) => item.id).join("|"), rollbackAxisWidth.value],
async ([visible]) => {
if (!visible) return;
await nextTick();
syncRollbackAxisLayout();
}
);
onMounted(() => {
window.addEventListener("resize", syncRollbackAxisLayout);
});
const isLatestPublishedVersion = (rawVersion: number): boolean => {
const currentPublished = setupVersionHistory.value.find((item) => item.is_current_published) || setupVersionHistory.value[0];
return Boolean(currentPublished && currentPublished.version === rawVersion);
};
const openRollbackDialog = async () => {
if (!project.value || !canManageSetup.value) return;
await loadSetupVersionHistory();
rollbackDialogVisible.value = true;
};
const selectRollbackVersion = async (targetVersion: number) => {
if (!project.value) return;
const targetDisplayVersion = getDisplayVersionLabel(targetVersion);
try {
await ElMessageBox.confirm(
`将立即回滚并替换当前发布版本为 ${targetDisplayVersion},同时将草稿切换到对应分支继续开发,是否继续?`,
"回滚确认",
{
type: "warning",
confirmButtonText: "确认回滚",
cancelButtonText: "取消",
}
);
} catch {
return;
}
rollbackDialogLoading.value = true;
try {
const { data } = await rollbackConfig(project.value.id, {
expected_version: setupRevision.value,
target_version: targetVersion,
});
applySetupResponseMeta(data);
applySetupDraft(clone(data.data));
clearSetupValidationErrors();
markSetupSynced();
clearLocalSetupDraft();
setupViewMode.value = "draft";
setupSaveMeta.value = {
updatedAt: formatDisplayTime(data.updated_at),
savedBy: data.saved_by_name || resolveSavedByDisplay(data.saved_by),
serverSynced: true,
};
updateLastServerSaveMeta(data.updated_at, data.saved_by_name || resolveSavedByDisplay(data.saved_by));
await loadSetupVersionHistory();
rollbackDialogVisible.value = false;
const currentBranch = setupCurrentBranchName.value || "main";
ElMessage.success(`已回滚并替换当前发布为 ${setupPublishedVersionText.value},当前版本分支 ${currentBranch}`);
} catch (e: any) {
if (e?.response?.status === 409) {
ElMessage.warning("回滚失败:版本冲突,正在刷新最新配置");
await refreshSetupDraftFromServer({ silent: true });
await loadSetupVersionHistory();
return;
}
ElMessage.error(getApiErrorMessage(e, "版本回滚失败"));
} finally {
rollbackDialogLoading.value = false;
}
};
const checkoutBranchDraftFromVersion = async (targetVersion: number) => {
if (!project.value) return;
const targetDisplayVersion = getDisplayVersionLabel(targetVersion);
try {
await ElMessageBox.confirm(
`将基于 ${targetDisplayVersion} 创建/切换分支草稿,不会改变当前发布版本,是否继续?`,
"新建分支草稿确认",
{
type: "warning",
confirmButtonText: "确认切换",
cancelButtonText: "取消",
}
);
} catch {
return;
}
rollbackDialogLoading.value = true;
try {
const { data } = await checkoutSetupBranchDraft(project.value.id, {
expected_version: setupRevision.value,
target_version: targetVersion,
});
applySetupResponseMeta(data);
applySetupDraft(clone(data.data));
clearSetupValidationErrors();
markSetupSynced();
clearLocalSetupDraft();
setupViewMode.value = "draft";
setupSaveMeta.value = {
updatedAt: formatDisplayTime(data.updated_at),
savedBy: data.saved_by_name || resolveSavedByDisplay(data.saved_by),
serverSynced: true,
};
updateLastServerSaveMeta(data.updated_at, data.saved_by_name || resolveSavedByDisplay(data.saved_by));
const checkoutRevision = data.version;
try {
const { data: refillData } = await refillSetupDraftOnServer(project.value.id, {
expected_version: checkoutRevision,
});
applySetupResponseMeta(refillData);
applySetupDraft(clone(refillData.data));
clearSetupValidationErrors();
markSetupSynced();
clearLocalSetupDraft();
setupViewMode.value = "draft";
setupSaveMeta.value = {
updatedAt: formatDisplayTime(refillData.updated_at),
savedBy: refillData.saved_by_name || resolveSavedByDisplay(refillData.saved_by),
serverSynced: true,
};
updateLastServerSaveMeta(refillData.updated_at, refillData.saved_by_name || resolveSavedByDisplay(refillData.saved_by));
await refreshSetupDraftFromServer({ silent: true });
await loadSetupVersionHistory();
const currentBranch = setupCurrentBranchName.value || "main";
ElMessage.success(`已切换到分支草稿 ${currentBranch},并自动回填为 ${targetDisplayVersion}`);
} catch (refillError: any) {
if (refillError?.response?.status === 409) {
ElMessage.warning("分支已切换,但自动回填发生版本冲突,正在刷新最新配置");
} else {
ElMessage.warning(getApiErrorMessage(refillError, "分支已切换,但自动回填失败"));
}
await refreshSetupDraftFromServer({ silent: true });
await loadSetupVersionHistory();
}
} catch (e: any) {
if (e?.response?.status === 409) {
ElMessage.warning("切换失败:版本冲突,正在刷新最新配置");
await refreshSetupDraftFromServer({ silent: true });
await loadSetupVersionHistory();
return;
}
ElMessage.error(getApiErrorMessage(e, "新建分支草稿失败"));
} finally {
rollbackDialogLoading.value = false;
}
};
const excelEscapeXml = (value: string): string =>
value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
const normalizeExcelCellValue = (value: unknown): string | number => {
if (value === null || value === undefined) return "";
if (typeof value === "number") return Number.isFinite(value) ? value : "";
if (typeof value === "boolean") return value ? "是" : "否";
if (typeof value === "string") return value;
try {
return JSON.stringify(value);
} catch {
return String(value);
}
};
const buildExcelCellXml = (value: unknown, styleId = "cell"): string => {
const normalized = normalizeExcelCellValue(value);
if (typeof normalized === "number") {
return `<Cell ss:StyleID="${styleId}"><Data ss:Type="Number">${normalized}</Data></Cell>`;
}
return `<Cell ss:StyleID="${styleId}"><Data ss:Type="String">${excelEscapeXml(normalized)}</Data></Cell>`;
};
const sanitizeExcelSheetName = (name: string): string => {
const replaced = String(name || "")
.replace(/[\\/:*?\[\]]/g, "_")
.trim();
if (!replaced) return "Sheet";
return replaced.slice(0, 31);
};
const formatEnrollmentTargetsText = (raw: Record<string, number>): string => {
const entries = Object.entries(raw || {})
.filter(([key, value]) => key && value !== null && value !== undefined)
.sort(([a], [b]) => a.localeCompare(b, "zh-CN"));
if (!entries.length) return "-";
return entries.map(([key, value]) => `${key}:${normalizeEnrollmentTarget(value)}`).join("");
};
const buildExcelWorksheetXml = (
name: string,
metaRows: Array<[string, unknown]>,
headers: string[],
rows: unknown[][]
): string => {
const sheetName = sanitizeExcelSheetName(name);
const columnCount = Math.max(1, headers.length);
const safeRows = rows.length > 0 ? rows : [[...Array(columnCount - 1).fill(""), "无数据"]];
const metaXml = metaRows
.map(
([key, value]) =>
`<Row>${buildExcelCellXml(key, "metaKey")}<Cell ss:StyleID="metaValue" ss:MergeAcross="${Math.max(columnCount - 2, 0)}"><Data ss:Type="String">${excelEscapeXml(
String(normalizeExcelCellValue(value) || "-")
)}</Data></Cell></Row>`
)
.join("");
const headerXml = `<Row>${headers.map((header) => buildExcelCellXml(header, "header")).join("")}</Row>`;
const dataXml = safeRows
.map((row) => `<Row>${headers.map((_, idx) => buildExcelCellXml(row[idx] ?? "", "cell")).join("")}</Row>`)
.join("");
return `<Worksheet ss:Name="${excelEscapeXml(sheetName)}"><Table>${metaXml}<Row/>${headerXml}${dataXml}</Table></Worksheet>`;
};
const downloadVersionRaw = (versionItem: StudySetupConfigVersionItem) => {
if (!project.value) return;
const displayVersion = getDisplayVersionLabel(versionItem.version);
const branchName = versionItem.branch_name || "main";
const configDraft = isSetupDraftShape(versionItem.config) ? versionItem.config : createDefaultSetupDraft([]);
const projectSnapshot = isProjectPublishSnapshotShape(versionItem.published_project_snapshot)
? versionItem.published_project_snapshot
: null;
const metaRows: Array<[string, unknown]> = [
["导出版本", displayVersion],
["版本分支", branchName],
["发布人", versionItem.published_by_name || "-"],
["发布时间", versionItem.published_at || "-"],
];
const step1Headers = ["字段", "值"];
const step1Rows: unknown[][] = [
["项目名称", projectSnapshot?.name || "-"],
["项目全称", projectSnapshot?.project_full_name || "-"],
["申办方", projectSnapshot?.sponsor || "-"],
["项目编号", projectSnapshot?.code || "-"],
["方案号", projectSnapshot?.protocol_no || "-"],
["组长单位", projectSnapshot?.lead_unit || "-"],
["主要研究者", projectSnapshot?.principal_investigator || "-"],
["主PM", projectSnapshot?.main_pm || "-"],
["研究分期", projectSnapshot?.research_analysis || "-"],
["研究产品", projectSnapshot?.research_product || "-"],
["对照产品", projectSnapshot?.control_product || "-"],
["适应症", projectSnapshot?.indication || "-"],
["研究人群", projectSnapshot?.research_population || "-"],
["研究设计", projectSnapshot?.research_design || "-"],
["计划开始日期", projectSnapshot?.plan_start_date || "-"],
["计划结束日期", projectSnapshot?.plan_end_date || "-"],
["项目状态", projectSnapshot?.status ? statusLabel(projectSnapshot.status) : "-"],
["计划中心数", projectSnapshot?.planned_site_count ?? "-"],
["计划入组数", projectSnapshot?.planned_enrollment_count ?? "-"],
["访视计划", formatVisitSchedule(projectSnapshot?.visit_schedule)],
];
const step2Headers = ["里程碑", "计划日期", "开始日期", "结束日期", "耗时(天)", "负责人", "状态", "备注"];
const step2Rows = configDraft.projectMilestones.map((item) => [
item.name || "-",
item.planDate || "-",
item.startDate || "-",
item.endDate || "-",
item.durationDays ?? "-",
item.owner || "-",
item.status || "-",
item.remark || "-",
]);
const step3Headers = ["计划开始日期", "计划结束日期", "当前周期", "计划入组总数", "周期类型", "周期", "计划人数", "备注"];
const parsedPlan = parseEnrollmentStageBreakdown(configDraft.enrollmentPlan.stageBreakdown);
const step3CycleRows: unknown[][] = [];
(["month", "quarter"] as EnrollmentCycle[]).forEach((cycle) => {
const entries = Object.entries(parsedPlan.targetsByCycle[cycle] || {})
.filter(([key, value]) => key && value !== null && value !== undefined)
.sort(([a], [b]) => a.localeCompare(b, "zh-CN"));
entries.forEach(([key, value]) => {
step3CycleRows.push([
configDraft.enrollmentPlan.startDate || "-",
configDraft.enrollmentPlan.endDate || "-",
enrollmentCycleLabelMap[parsedPlan.cycle] || parsedPlan.cycle,
normalizeEnrollmentTarget(configDraft.enrollmentPlan.totalTarget),
enrollmentCycleLabelMap[cycle] || cycle,
key,
normalizeEnrollmentTarget(value),
configDraft.enrollmentPlan.monthlyGoalNote || "-",
]);
});
});
if (!step3CycleRows.length) {
step3CycleRows.push([
configDraft.enrollmentPlan.startDate || "-",
configDraft.enrollmentPlan.endDate || "-",
enrollmentCycleLabelMap[parsedPlan.cycle] || parsedPlan.cycle,
normalizeEnrollmentTarget(configDraft.enrollmentPlan.totalTarget),
"-",
"-",
"-",
configDraft.enrollmentPlan.monthlyGoalNote || "-",
]);
}
const step4Headers = ["中心ID", "中心名称", "里程碑", "计划日期", "负责人", "状态", "备注"];
const step4Rows = configDraft.siteMilestones.map((item) => [
item.id || "-",
item.id ? getSiteName(item.id) : "-",
item.milestone || "-",
item.planDate || "-",
item.owner || "-",
item.status || "-",
item.remark || "-",
]);
const step5Headers = ["中心ID", "中心名称", "计划入组数", "开始日期", "结束日期", "月度分解", "季度分解", "备注"];
const step5Rows = configDraft.siteEnrollmentPlans.map((item) => {
const parsed = parseSiteEnrollmentStageBreakdown(item.stageBreakdown);
return [
item.siteId || "-",
item.siteName || "-",
normalizeEnrollmentTarget(item.target),
item.startDate || "-",
item.endDate || "-",
formatEnrollmentTargetsText(parsed.month),
formatEnrollmentTargetsText(parsed.quarter),
item.note || "-",
];
});
const step6Headers = ["中心ID", "中心名称", "确认人", "确认状态", "确认日期", "备注"];
const step6Rows = configDraft.centerConfirm.map((item) => [
item.siteId || "-",
item.siteName || "-",
item.confirmer || "-",
item.confirmStatus || "-",
item.confirmDate || "-",
item.note || "-",
]);
const workbookXml = `<?xml version="1.0" encoding="UTF-8"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:x="urn:schemas-microsoft-com:office:excel"
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet"
xmlns:html="http://www.w3.org/TR/REC-html40">
<Styles>
<Style ss:ID="Default" ss:Name="Normal">
<Alignment ss:Vertical="Center"/>
<Font ss:FontName="Microsoft YaHei" ss:Size="10"/>
</Style>
<Style ss:ID="header">
<Font ss:FontName="Microsoft YaHei" ss:Size="10" ss:Bold="1"/>
<Interior ss:Color="#EAF1FB" ss:Pattern="Solid"/>
<Borders>
<Border ss:Position="Bottom" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#C8D6EA"/>
<Border ss:Position="Top" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#C8D6EA"/>
<Border ss:Position="Left" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#C8D6EA"/>
<Border ss:Position="Right" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#C8D6EA"/>
</Borders>
</Style>
<Style ss:ID="cell">
<Borders>
<Border ss:Position="Bottom" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#E1E8F3"/>
<Border ss:Position="Top" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#E1E8F3"/>
<Border ss:Position="Left" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#E1E8F3"/>
<Border ss:Position="Right" ss:LineStyle="Continuous" ss:Weight="1" ss:Color="#E1E8F3"/>
</Borders>
</Style>
<Style ss:ID="metaKey">
<Font ss:FontName="Microsoft YaHei" ss:Size="10" ss:Bold="1"/>
<Interior ss:Color="#F4F7FC" ss:Pattern="Solid"/>
</Style>
<Style ss:ID="metaValue">
<Alignment ss:Vertical="Center" ss:WrapText="1"/>
<Interior ss:Color="#FCFDFF" ss:Pattern="Solid"/>
</Style>
</Styles>
${buildExcelWorksheetXml("第1步-项目信息", metaRows, step1Headers, step1Rows)}
${buildExcelWorksheetXml("第2步-项目里程碑", metaRows, step2Headers, step2Rows)}
${buildExcelWorksheetXml("第3步-项目入组计划", metaRows, step3Headers, step3CycleRows)}
${buildExcelWorksheetXml("第4步-中心里程碑", metaRows, step4Headers, step4Rows)}
${buildExcelWorksheetXml("第5步-中心入组计划", metaRows, step5Headers, step5Rows)}
${buildExcelWorksheetXml("第6步-中心确认", metaRows, step6Headers, step6Rows)}
</Workbook>`;
const blob = new Blob([`\uFEFF${workbookXml}`], { type: "application/vnd.ms-excel;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const safeVersionLabel = displayVersion.replace(/[^\w.-]/g, "_");
const filename = `setup-config-${safeVersionLabel}-${project.value.code || project.value.id}.xls`;
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const removeVersion = async (targetVersion: number) => {
if (!project.value) return;
if (isLatestPublishedVersion(targetVersion)) {
ElMessage.warning("当前发布版本不能删除");
return;
}
const displayVersion = getDisplayVersionLabel(targetVersion);
try {
await ElMessageBox.confirm(`确认删除发布版本 ${displayVersion} 的快照?此操作不可恢复。`, "删除确认", {
type: "warning",
confirmButtonText: "确认删除",
cancelButtonText: "取消",
});
} catch {
return;
}
rollbackDialogLoading.value = true;
try {
await deleteSetupVersion(project.value.id, targetVersion);
await loadSetupVersionHistory();
ElMessage.success(`已删除发布版本 ${displayVersion}`);
} catch (e: any) {
ElMessage.error(getApiErrorMessage(e, "删除版本失败"));
} finally {
rollbackDialogLoading.value = false;
}
};
const mergeVersionToMain = async (sourceVersion: number) => {
if (!project.value) return;
const sourceVersionLabel = getDisplayVersionLabel(sourceVersion);
try {
await ElMessageBox.confirm(
`确认将 ${sourceVersionLabel} 合并到主分支并生成新的主版本号?`,
"合并主分支确认",
{
type: "warning",
confirmButtonText: "确认合并",
cancelButtonText: "取消",
}
);
} catch {
return;
}
rollbackDialogLoading.value = true;
try {
const { data } = await mergeSetupVersionToMain(project.value.id, {
expected_version: setupRevision.value,
source_version: sourceVersion,
});
applySetupResponseMeta(data);
applySetupDraft(clone(data.data));
clearSetupValidationErrors();
markSetupSynced();
clearLocalSetupDraft();
setupViewMode.value = "preview";
setupSaveMeta.value = {
updatedAt: formatDisplayTime(data.updated_at),
savedBy: data.saved_by_name || resolveSavedByDisplay(data.saved_by),
serverSynced: true,
};
updateLastServerSaveMeta(data.updated_at, data.saved_by_name || resolveSavedByDisplay(data.saved_by));
await loadSetupVersionHistory();
ElMessage.success(`${sourceVersionLabel} 已合并到主分支,当前发布为 ${setupPublishedVersionText.value}`);
} catch (e: any) {
if (e?.response?.status === 409) {
ElMessage.warning("合并失败:版本冲突,正在刷新最新配置");
await refreshSetupDraftFromServer({ silent: true });
await loadSetupVersionHistory();
return;
}
ElMessage.error(getApiErrorMessage(e, "合并主分支失败"));
} finally {
rollbackDialogLoading.value = false;
}
};
const handleRefillDraft = async () => {
if (!project.value || !canManageSetup.value) return;
try {
await ElMessageBox.confirm(
`确认将草稿一键回填为当前发布版本 ${setupPublishedVersionText.value} 的内容?`,
"一键回填确认",
{
type: "warning",
confirmButtonText: "确认回填",
cancelButtonText: "取消",
}
);
} catch {
return;
}
primarySaveLoading.value = true;
try {
const { data } = await refillSetupDraftOnServer(project.value.id, {
expected_version: setupRevision.value,
});
applySetupResponseMeta(data);
applySetupDraft(clone(data.data));
clearSetupValidationErrors();
markSetupSynced();
clearLocalSetupDraft();
setupViewMode.value = "draft";
setupSaveMeta.value = {
updatedAt: formatDisplayTime(data.updated_at),
savedBy: data.saved_by_name || resolveSavedByDisplay(data.saved_by),
serverSynced: true,
};
updateLastServerSaveMeta(data.updated_at, data.saved_by_name || resolveSavedByDisplay(data.saved_by));
await refreshSetupDraftFromServer({ silent: true });
await loadSetupVersionHistory();
ElMessage.success(`草稿已回填为 ${setupPublishedVersionText.value}`);
} catch (e: any) {
if (e?.response?.status === 409) {
ElMessage.warning("回填失败:版本冲突,正在刷新最新配置");
await refreshSetupDraftFromServer({ silent: true });
await loadSetupVersionHistory();
return;
}
ElMessage.error(getApiErrorMessage(e, "草稿回填失败"));
} finally {
primarySaveLoading.value = false;
}
};
const handleClearDraft = async () => {
if (!project.value || !canManageSetup.value) return;
try {
await ElMessageBox.confirm(
"确认清空当前立项配置草稿的所有数据?该操作不会影响已发布版本。",
"清空草稿确认",
{
type: "warning",
confirmButtonText: "确认清空",
cancelButtonText: "取消",
}
);
} catch {
return;
}
primarySaveLoading.value = true;
try {
const { data } = await clearSetupDraftOnServer(project.value.id, {
expected_version: setupRevision.value,
});
applySetupResponseMeta(data);
applySetupDraft(clone(data.data));
clearSetupValidationErrors();
markSetupSynced();
clearLocalSetupDraft();
clearLocalProjectDraft();
setupViewMode.value = "draft";
setupSaveMeta.value = {
updatedAt: formatDisplayTime(data.updated_at),
savedBy: data.saved_by_name || resolveSavedByDisplay(data.saved_by),
serverSynced: true,
};
updateLastServerSaveMeta(data.updated_at, data.saved_by_name || resolveSavedByDisplay(data.saved_by));
await refreshSetupDraftFromServer({ silent: true });
await loadSetupVersionHistory();
ElMessage.success(`草稿已清空(当前发布仍为 ${setupPublishedVersionText.value}`);
} catch (e: any) {
if (e?.response?.status === 409) {
ElMessage.warning("清空失败:版本冲突,正在刷新最新配置");
await refreshSetupDraftFromServer({ silent: true });
await loadSetupVersionHistory();
return;
}
ElMessage.error(getApiErrorMessage(e, "清空草稿失败"));
} finally {
primarySaveLoading.value = false;
}
};
const ensureCanSaveDraftAction = (): boolean => {
if (!project.value) {
ElMessage.warning("项目信息尚未加载完成,请稍后重试");
return false;
}
if (!canManageSetup.value) {
ElMessage.warning("当前角色无权保存立项配置");
return false;
}
if (project.value.is_locked) {
ElMessage.warning("项目已锁定,无法保存配置");
return false;
}
if (isPublishedView.value) {
ElMessage.info("当前为只读视图,请先切换到草稿后再保存");
return false;
}
return true;
};
const handlePrimarySaveConfig = async () => {
if (!ensureCanSaveDraftAction()) return;
if (incompleteSteps.value.length > 0) {
try {
await ElMessageBox.confirm(
`当前仍有 ${incompleteSteps.value.length} 个步骤未完成,确认后将继续保存草稿。`,
"步骤未完成",
{
type: "warning",
confirmButtonText: "确认保存",
cancelButtonText: "继续编辑",
}
);
} catch {
return;
}
}
primarySaveLoading.value = true;
try {
const saved = await saveConfigNow();
if (!saved) return;
closeEditDrawerAfterSave();
ElMessage.success("配置草稿已保存");
} finally {
primarySaveLoading.value = false;
}
};
const confirmPublishConfig = async () => {
publishConfirmLoading.value = true;
try {
const success = await doPublishConfigNow();
if (success) {
publishConfirmVisible.value = false;
await loadSetupVersionHistory();
}
} finally {
publishConfirmLoading.value = false;
}
};
const enterProject = async () => {
if (!project.value) return;
studyStore.setCurrentStudy(project.value);
await studyStore.loadCurrentStudyPermissions().catch(() => {});
router.push("/project/overview");
};
const goMembers = () => {
if (!project.value) return;
router.push(`/admin/projects/${project.value.id}/members`);
};
const goSites = () => {
if (!project.value) return;
router.push(`/admin/projects/${project.value.id}/sites`);
};
const handleSecondaryAction = (command: string) => {
if (command === "enter") {
enterProject();
return;
}
if (command === "members") {
goMembers();
return;
}
if (command === "sites") {
goSites();
return;
}
};
const prevStep = () => {
if (isEditing.value) return;
if (activeStep.value <= 0) return;
activeStep.value -= 1;
};
const nextStep = () => {
if (isEditing.value) return;
if (activeStep.value >= steps.length - 1) return;
activeStep.value += 1;
};
const goStep = (index: number) => {
if (isEditing.value) return;
activeStep.value = index;
};
const addProjectMilestone = () => {
if (!canMutateDraft()) return;
setupDraft.projectMilestones.push({
id: makeId(),
name: "",
planDate: "",
startDate: "",
endDate: "",
durationDays: 1,
owner: "",
remark: "",
status: "未开始",
});
};
const ensureStep2Editable = (): boolean => {
if (!project.value) return false;
if (isPublishedView.value) {
ElMessage.info("当前为只读视图,请先切换到草稿后再操作");
return false;
}
if (!canManageSetup.value) {
ElMessage.warning("当前角色无权编辑立项配置");
return false;
}
if (project.value.is_locked) {
ElMessage.warning("项目已锁定,无法编辑。");
return false;
}
return true;
};
const handleAddProjectMilestoneAction = () => {
if (!ensureStep2Editable()) return;
addProjectMilestone();
};
const onProjectMilestoneTemplateSelect = async (templateKey: string) => {
if (!ensureStep2Editable()) return;
const template = projectMilestoneTemplates.find((item) => item.key === templateKey);
if (!template) return;
if (setupDraft.projectMilestones.length > 0) {
try {
await ElMessageBox.confirm("应用模板将覆盖当前第2步里程碑列表,是否继续?", "应用模板", {
type: "warning",
confirmButtonText: "覆盖并应用",
cancelButtonText: "取消",
});
} catch {
return;
}
}
setupDraft.projectMilestones = template.milestones.map((name) => ({
id: makeId(),
name,
planDate: "",
startDate: "",
endDate: "",
durationDays: 1,
owner: "",
remark: "",
status: "未开始",
}));
ElMessage.success(`已应用模板:${template.label}`);
};
const removeProjectMilestone = (index: number) => {
if (!canMutateDraft()) return;
setupDraft.projectMilestones.splice(index, 1);
};
const openProjectMilestoneEditor = (index: number) => {
openIndexedEditor(setupDraft.projectMilestones, index, projectMilestoneEditor, (row) => {
projectMilestoneEditorForm.id = row.id || "";
projectMilestoneEditorForm.name = row.name || "";
projectMilestoneEditorForm.startDate = resolveProjectMilestoneStart(row);
projectMilestoneEditorForm.endDate = resolveProjectMilestoneEnd(row);
projectMilestoneEditorForm.durationDays = resolveProjectMilestoneDurationDays(row) || 1;
projectMilestoneEditorForm.planDate = resolveProjectMilestoneStart(row);
projectMilestoneEditorForm.owner = row.owner || "";
projectMilestoneEditorForm.remark = row.remark || "";
projectMilestoneEditorForm.status = row.status || "未开始";
});
};
const saveProjectMilestoneEditor = () => {
if (!canMutateDraft()) return;
const index = getEditingIndex(projectMilestoneEditor, setupDraft.projectMilestones.length);
if (index < 0) return;
const name = (projectMilestoneEditorForm.name || "").trim();
if (!name) {
ElMessage.warning("请填写里程碑名称");
return;
}
const startDate = normalizePlanDate(projectMilestoneEditorForm.startDate || projectMilestoneEditorForm.planDate);
const endDate = normalizePlanDate(projectMilestoneEditorForm.endDate || projectMilestoneEditorForm.planDate);
if (!startDate) {
ElMessage.warning("请选择开始日期");
return;
}
if (!endDate) {
ElMessage.warning("请选择结束日期");
return;
}
if (startDate > endDate) {
ElMessage.warning("结束日期不能早于开始日期");
return;
}
const durationDays = calculateDurationDays(startDate, endDate);
setupDraft.projectMilestones[index] = {
id: projectMilestoneEditorForm.id || makeId(),
name,
planDate: startDate,
startDate,
endDate,
durationDays: durationDays > 0 ? durationDays : 1,
owner: (projectMilestoneEditorForm.owner || "").trim(),
remark: (projectMilestoneEditorForm.remark || "").trim(),
status: projectMilestoneEditorForm.status || "未开始",
};
closeIndexedEditor(projectMilestoneEditor, "里程碑已更新");
};
const addSiteMilestone = () => {
if (!canMutateDraft()) return;
setupDraft.siteMilestones.push({ id: makeId(), milestone: "", planDate: "", owner: "", remark: "", status: "未开始" });
};
const handleAddSiteMilestoneAction = () => {
if (!ensureStep2Editable()) return;
addSiteMilestone();
};
const onSiteMilestoneTemplateSelect = async (templateKey: string) => {
if (!ensureStep2Editable()) return;
const template = siteMilestoneTemplates.find((item) => item.key === templateKey);
if (!template) return;
if (setupDraft.siteMilestones.length > 0) {
try {
await ElMessageBox.confirm("应用模板将覆盖当前第4步中心里程碑列表,是否继续?", "应用模板", {
type: "warning",
confirmButtonText: "覆盖并应用",
cancelButtonText: "取消",
});
} catch {
return;
}
}
setupDraft.siteMilestones = template.milestones.map((milestone) => ({
id: makeId(),
milestone,
planDate: "",
owner: "",
remark: "",
status: "未开始",
}));
ElMessage.success(`已应用模板:${template.label}`);
};
const removeSiteMilestone = (index: number) => {
if (!canMutateDraft()) return;
setupDraft.siteMilestones.splice(index, 1);
};
const openSiteMilestoneEditor = (index: number) => {
openIndexedEditor(setupDraft.siteMilestones, index, siteMilestoneEditor, (row) => {
siteMilestoneEditorForm.id = row.id || "";
siteMilestoneEditorForm.milestone = row.milestone || "";
siteMilestoneEditorForm.planDate = row.planDate || "";
siteMilestoneEditorForm.owner = row.owner || "";
siteMilestoneEditorForm.remark = row.remark || "";
siteMilestoneEditorForm.status = row.status || "未开始";
});
};
const saveSiteMilestoneEditor = () => {
if (!canMutateDraft()) return;
const index = getEditingIndex(siteMilestoneEditor, setupDraft.siteMilestones.length);
if (index < 0) return;
const milestone = (siteMilestoneEditorForm.milestone || "").trim();
if (!milestone) {
ElMessage.warning("请填写里程碑");
return;
}
if (!siteMilestoneEditorForm.planDate) {
ElMessage.warning("请选择计划日期");
return;
}
const { start: planStart, end: planEnd } = getProjectPlanWindow();
const planDate = normalizePlanDate(siteMilestoneEditorForm.planDate);
if (planStart && planDate < planStart) {
ElMessage.warning("中心里程碑日期不能早于项目计划开始日期");
return;
}
if (planEnd && planDate > planEnd) {
ElMessage.warning("中心里程碑日期不能晚于项目计划结束日期");
return;
}
setupDraft.siteMilestones[index] = {
id: siteMilestoneEditorForm.id || makeId(),
milestone,
planDate: siteMilestoneEditorForm.planDate,
owner: (siteMilestoneEditorForm.owner || "").trim(),
remark: (siteMilestoneEditorForm.remark || "").trim(),
status: siteMilestoneEditorForm.status || "未开始",
};
closeIndexedEditor(siteMilestoneEditor, "中心里程碑已更新");
};
const removeSiteEnrollment = (index: number) => {
if (!canMutateDraft()) return;
setupDraft.siteEnrollmentPlans.splice(index, 1);
};
const openSiteEnrollmentEditor = (index: number) => {
openIndexedEditor(setupDraft.siteEnrollmentPlans, index, siteEnrollmentEditor, (row) => {
siteEnrollmentEditorForm.id = row.id || "";
siteEnrollmentEditorForm.siteId = row.siteId || "";
siteEnrollmentEditorForm.siteName = row.siteName || "";
siteEnrollmentEditorForm.target = row.target ?? 0;
siteEnrollmentEditorForm.startDate = row.startDate || "";
siteEnrollmentEditorForm.endDate = row.endDate || "";
siteEnrollmentEditorForm.note = row.note || "";
siteEnrollmentEditorForm.stageBreakdown = row.stageBreakdown || "";
});
};
const saveSiteEnrollmentEditor = () => {
if (!canMutateDraft()) return;
const index = getEditingIndex(siteEnrollmentEditor, setupDraft.siteEnrollmentPlans.length);
if (index < 0) return;
if (!siteEnrollmentEditorForm.siteId) {
ElMessage.warning("请选择中心");
return;
}
if (isSiteEnrollmentDuplicate(siteEnrollmentEditorForm.siteId, index)) {
ElMessage.warning("该中心已配置入组计划,请勿重复配置");
return;
}
if (!siteEnrollmentEditorForm.startDate || !siteEnrollmentEditorForm.endDate) {
ElMessage.warning("请填写启动日期和完成日期");
return;
}
if (siteEnrollmentEditorForm.startDate > siteEnrollmentEditorForm.endDate) {
ElMessage.warning("完成日期不能早于启动日期");
return;
}
const { start: planStart, end: planEnd } = getProjectPlanWindow();
const startDate = normalizePlanDate(siteEnrollmentEditorForm.startDate);
const endDate = normalizePlanDate(siteEnrollmentEditorForm.endDate);
if (planStart && startDate < planStart) {
ElMessage.warning("中心启动日期不能早于项目计划开始日期");
return;
}
if (planEnd && startDate > planEnd) {
ElMessage.warning("中心启动日期不能晚于项目计划结束日期");
return;
}
if (planStart && endDate < planStart) {
ElMessage.warning("中心完成日期不能早于项目计划开始日期");
return;
}
if (planEnd && endDate > planEnd) {
ElMessage.warning("中心完成日期不能晚于项目计划结束日期");
return;
}
setupDraft.siteEnrollmentPlans[index] = {
id: siteEnrollmentEditorForm.id || makeId(),
siteId: siteEnrollmentEditorForm.siteId,
siteName: getSiteName(siteEnrollmentEditorForm.siteId),
target: Math.max(0, Number(siteEnrollmentEditorForm.target || 0)),
startDate: siteEnrollmentEditorForm.startDate,
endDate: siteEnrollmentEditorForm.endDate,
note: (siteEnrollmentEditorForm.note || "").trim(),
stageBreakdown: siteEnrollmentEditorForm.stageBreakdown || "",
};
closeIndexedEditor(siteEnrollmentEditor, "中心入组计划已更新");
};
const addCenterConfirm = () => {
if (!canMutateDraft()) return;
setupDraft.centerConfirm.push({ id: makeId(), siteId: "", siteName: "", confirmer: "", confirmStatus: "待确认", confirmDate: "", note: "" });
};
const removeCenterConfirm = (index: number) => {
if (!canMutateDraft()) return;
setupDraft.centerConfirm.splice(index, 1);
};
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!hasLeaveGuardChanges.value) return;
event.preventDefault();
event.returnValue = "";
};
onBeforeRouteLeave(async () => {
if (!hasLeaveGuardChanges.value) return true;
try {
await ElMessageBox.confirm("当前有未保存或未同步到服务端的修改,确定离开当前页面?", "离开确认", {
type: "warning",
confirmButtonText: "确定离开",
cancelButtonText: "继续编辑",
});
return true;
} catch {
return false;
}
});
onMounted(() => {
void loadProject();
window.addEventListener("beforeunload", handleBeforeUnload);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", syncRollbackAxisLayout);
window.removeEventListener("beforeunload", handleBeforeUnload);
if (autoSaveTimer.value) {
window.clearTimeout(autoSaveTimer.value);
}
if (drawerCloseTimer.value) {
window.clearTimeout(drawerCloseTimer.value);
}
});
</script>
<style scoped>
.setup-page {
display: flex;
flex-direction: column;
gap: 0;
background: #f3f6fb;
}
.setup-shell {
border: 0;
background: #ffffff;
overflow: hidden;
box-shadow: none;
}
.setup-header {
position: relative;
z-index: 1250;
background: linear-gradient(135deg, #0b1426 0%, #17346b 50%, #2f69d2 100%);
padding: 6px 16px; /* 极限垂直留白压缩 */
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.setup-flow {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px; /* 极限缩短行间距 */
gap: 0;
}
.flow-step {
display: flex;
align-items: center;
flex: 1 1 0;
min-width: 0;
white-space: nowrap;
cursor: pointer;
color: rgba(255, 255, 255, 0.55);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.flow-step:hover {
color: rgba(255, 255, 255, 0.85);
}
.flow-step.active {
color: #ffffff;
text-shadow: 0 0 12px rgba(255, 255, 255, 0.4);
}
.flow-step.done {
color: #a7f3d0;
}
.flow-index {
width: 26px;
height: 26px;
border-radius: 50%;
border: 1.5px solid rgba(255, 255, 255, 0.3);
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 8px;
font-size: 13px;
font-weight: 700;
background: rgba(0, 0, 0, 0.2);
color: inherit;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
flex: 0 0 auto;
}
.flow-step.active .flow-index {
background: #3b82f6;
border-color: #60a5fa;
box-shadow: 0 0 12px rgba(59, 130, 246, 0.6);
color: #ffffff;
}
.flow-step.done .flow-index {
background: #10b981;
border-color: #34d399;
color: #ffffff;
}
.flow-label {
font-size: 14px;
font-weight: 600;
letter-spacing: 0.02em;
flex: 0 1 auto;
}
.flow-done-icon {
margin-left: 6px;
color: #10b981;
font-size: 15px;
flex: 0 0 auto;
}
.flow-connector {
flex: 1 1 auto;
min-width: 8px;
height: 2px;
background: rgba(255, 255, 255, 0.12);
margin: 0 12px;
border-radius: 999px;
position: relative;
overflow: hidden;
transition: background 0.3s ease;
}
.flow-step.done .flow-connector {
background: rgba(16, 185, 129, 0.4);
}
.flow-step.has-error .flow-index {
border-color: #f87171;
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
}
.setup-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: nowrap;
white-space: nowrap;
overflow-x: auto;
}
.setup-toolbar::-webkit-scrollbar {
display: none;
}
.setup-step-errors {
padding: 8px 12px 0;
background: #ffffff;
}
.setup-import-errors {
margin-top: 10px;
}
.setup-import-error-list {
margin-top: 8px;
border: 1px solid #f2c5c5;
background: #fff7f7;
border-radius: 8px;
padding: 8px 10px;
max-height: 220px;
overflow: auto;
}
.setup-import-error-item {
display: grid;
grid-template-columns: minmax(220px, 1fr) 2fr;
gap: 10px;
padding: 4px 0;
border-bottom: 1px dashed #f3d9d9;
}
.setup-import-error-item:last-child {
border-bottom: none;
}
.setup-import-error-field {
font-size: 12px;
color: #9f4646;
}
.setup-import-error-message {
font-size: 12px;
color: #c23b3b;
}
.setup-import-error-toggle {
margin-top: 6px;
}
.setup-toolbar-left {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: nowrap;
}
.setup-version-meta {
display: flex;
align-items: center;
gap: 12px;
}
.meta-item {
display: flex;
align-items: center;
gap: 8px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
padding: 4px 10px;
border-radius: 8px;
font-size: 12px;
color: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(8px);
}
.meta-label {
font-weight: 600;
color: rgba(255, 255, 255, 0.5);
}
.meta-item:first-child .meta-label {
color: #a7f3d0;
}
.meta-group {
display: flex;
align-items: center;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.meta-br {
background: rgba(255, 255, 255, 0.08);
padding: 2px 6px;
color: #93c5fd;
}
.draft-group .meta-br {
color: #c4b5fd;
}
.meta-ver {
padding: 2px 6px;
font-weight: 700;
color: #ffffff;
}
.meta-time {
color: rgba(255, 255, 255, 0.4);
display: inline-flex;
align-items: center;
cursor: help;
transition: color 0.2s;
font-size: 14px;
}
.meta-time:hover {
color: rgba(255, 255, 255, 0.8);
}
.meta-status {
margin-left: 2px;
border: none;
background: rgba(255, 255, 255, 0.1) !important;
}
.locked-tag {
background: rgba(245, 158, 11, 0.1) !important;
border-color: rgba(245, 158, 11, 0.3) !important;
}
.setup-view-mode :deep(.el-radio-group) {
background: rgba(11, 30, 75, 0.24);
border-radius: 8px;
}
.setup-view-mode :deep(.el-radio-button__inner) {
border-color: rgba(187, 217, 255, 0.32);
color: rgba(237, 247, 255, 0.92);
background: transparent;
}
.setup-view-mode :deep(.el-radio-button:first-child .el-radio-button__inner) {
border-left-color: rgba(187, 217, 255, 0.32);
}
.setup-view-mode :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
background: rgba(255, 255, 255, 0.18);
border-color: rgba(227, 240, 255, 0.9);
color: #ffffff;
box-shadow: none;
}
.hidden-file-input {
display: none;
}
.switcher-title {
font-size: 18px;
line-height: 1;
font-weight: 600;
color: #ffffff;
}
.setup-switcher :deep(.el-button) {
border-color: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.92);
}
.setup-toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
padding-top: 2px;
}
.setup-toolbar-actions :deep(.el-button) {
border-radius: 10px;
}
/* ========== 版本管理对话框 - 美化样式 ========== */
/* 对话框整体样式 */
.version-mgr-dialog :deep(.el-dialog) {
border-radius: 16px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(15, 30, 60, 0.18), 0 2px 12px rgba(15, 30, 60, 0.08);
}
.version-mgr-dialog :deep(.el-dialog__header) {
padding: 0;
margin: 0;
}
.version-mgr-dialog :deep(.el-dialog__body) {
padding: 0;
}
.version-mgr-dialog :deep(.el-dialog__footer) {
padding: 12px 20px;
background: #f8fafc;
border-top: 1px solid #edf2f7;
}
/* 对话框头部 */
.vm-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: linear-gradient(135deg, #1e3a5f 0%, #2d5a8e 50%, #1a4478 100%);
color: #ffffff;
}
.vm-dialog-header-left {
display: flex;
align-items: center;
gap: 10px;
}
.vm-dialog-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(8px);
}
.vm-dialog-title {
font-size: 17px;
font-weight: 700;
letter-spacing: 0.02em;
}
.vm-version-count-tag {
background: rgba(255, 255, 255, 0.18) !important;
border-color: rgba(255, 255, 255, 0.25) !important;
color: rgba(255, 255, 255, 0.92) !important;
font-weight: 600;
font-size: 11px;
backdrop-filter: blur(4px);
}
.vm-dialog-close-btn {
background: rgba(255, 255, 255, 0.12) !important;
border: 1px solid rgba(255, 255, 255, 0.2) !important;
color: rgba(255, 255, 255, 0.85) !important;
transition: all 0.2s ease;
}
.vm-dialog-close-btn:hover {
background: rgba(255, 255, 255, 0.25) !important;
color: #ffffff !important;
transform: rotate(90deg);
}
/* 对话框主体 */
.vm-body {
padding: 16px 20px 12px;
min-height: 200px;
background: linear-gradient(180deg, #f0f5fb 0%, #f8fafc 60px, #ffffff 100%);
}
/* 图例栏 */
.vm-legend-bar {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 14px;
padding: 10px 14px;
background: #ffffff;
border-radius: 10px;
border: 1px solid #e8eef6;
box-shadow: 0 1px 4px rgba(15, 40, 80, 0.04);
}
.vm-legend-section {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.vm-legend-label {
font-size: 12px;
font-weight: 600;
color: #475569;
white-space: nowrap;
padding-right: 4px;
border-right: 2px solid #e2e8f0;
}
.vm-legend-tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.vm-lane-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px 3px 7px;
font-size: 12px;
font-weight: 600;
color: #64748b;
background: #f1f5f9;
border-radius: 20px;
border: 1px solid #e2e8f0;
transition: all 0.2s ease;
}
.vm-lane-badge:hover {
background: #e8eff8;
border-color: #c8d8ea;
}
.vm-lane-badge.is-main {
color: #1e40af;
background: linear-gradient(135deg, #eff6ff, #dbeafe);
border-color: #bfdbfe;
}
.vm-lane-badge-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #94a3b8;
}
.vm-lane-badge.is-main .vm-lane-badge-dot {
background: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
/* 轨道头部标签 */
.rollback-axis-lane-head {
position: relative;
height: 28px;
margin: 0 0 8px;
}
.rollback-axis-lane-head-item {
position: absolute;
top: 0;
transform: translateX(-50%);
font-size: 10px;
line-height: 1;
color: #8896aa;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
padding: 3px 6px;
background: #f0f4f8;
border-radius: 4px;
}
.rollback-axis-lane-head-item.is-main {
color: #3b6db5;
background: #e4edf8;
}
/* 时间线滚动区域 */
.rollback-axis-scroll {
position: relative;
max-height: 400px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 6px;
padding: 6px 0;
}
/* SVG 覆盖层 */
.rollback-axis-overlay {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
z-index: 1;
}
/* 版本行 */
.rollback-axis-row {
position: relative;
z-index: 2;
display: flex;
gap: 10px;
padding: 4px 6px 4px 0;
border-radius: 10px;
transition: all 0.22s ease;
}
.rollback-axis-row:hover {
background: rgba(59, 130, 246, 0.04);
}
.rollback-axis-row.is-current-row {
background: linear-gradient(90deg, rgba(34, 197, 94, 0.06) 0%, rgba(34, 197, 94, 0.02) 100%);
box-shadow: inset 3px 0 0 #22c55e;
}
.rollback-axis-row.is-draft-base-row:not(.is-current-row) {
background: linear-gradient(90deg, rgba(99, 102, 241, 0.05) 0%, rgba(99, 102, 241, 0.01) 100%);
box-shadow: inset 3px 0 0 #8b9cf7;
}
/* 轨道图形 */
.rollback-axis-graph {
position: relative;
min-height: 74px;
flex: 0 0 auto;
}
/* 轨道竖线段 */
.rollback-axis-lane-segment {
position: absolute;
top: -8px;
bottom: -8px;
width: 2px;
background: linear-gradient(180deg, #d4dfe8, #cedbed);
transform: translateX(-50%);
opacity: 0.85;
border-radius: 1px;
}
.rollback-axis-lane-segment.is-main-lane {
width: 3px;
background: linear-gradient(180deg, #5b8fd4, #4a7ec8);
opacity: 0.9;
}
.rollback-axis-lane-segment.is-active-lane {
width: 2.5px;
background: linear-gradient(180deg, #818cf8, #6366f1);
opacity: 0.85;
}
/* SVG 同分支继承连接线 */
.rollback-axis-branch-line {
stroke: #a5b4fc;
stroke-width: 3;
stroke-linecap: round;
opacity: 0.7;
}
.rollback-axis-branch-line.is-main {
stroke: #60a5fa;
stroke-width: 3.5;
opacity: 0.6;
}
/* SVG 连接线 */
.rollback-axis-spawn-path {
fill: none;
stroke: url(#spawnGrad);
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0.85;
}
.rollback-axis-merge-path {
fill: none;
stroke: url(#mergeGrad);
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 6 3;
opacity: 0.85;
}
/* 轨道节点 */
.rollback-axis-dot {
position: absolute;
top: 24px;
width: 14px;
height: 14px;
border-radius: 50%;
transform: translateX(-50%);
background: #94a3b8;
border: 2.5px solid #ffffff;
box-shadow: 0 0 0 2px rgba(148, 163, 184, 0.25), 0 2px 4px rgba(0, 0, 0, 0.08);
z-index: 3;
transition: all 0.25s ease;
}
.rollback-axis-row:hover .rollback-axis-dot {
transform: translateX(-50%) scale(1.2);
}
.rollback-axis-dot.is-main {
background: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3), 0 2px 6px rgba(59, 130, 246, 0.15);
}
.rollback-axis-dot.is-current {
background: #22c55e;
width: 16px;
height: 16px;
top: 23px;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.25), 0 2px 8px rgba(34, 197, 94, 0.2);
animation: dotPulse 2.5s ease-in-out infinite;
}
@keyframes dotPulse {
0%, 100% { box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.25), 0 2px 8px rgba(34, 197, 94, 0.2); }
50% { box-shadow: 0 0 0 5px rgba(34, 197, 94, 0.12), 0 2px 12px rgba(34, 197, 94, 0.3); }
}
.rollback-axis-dot.is-merge {
background: #f59e0b;
border-color: #fffbeb;
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.25), 0 2px 4px rgba(245, 158, 11, 0.1);
}
/* 版本内容区域 */
.rollback-axis-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 0 10px;
border-bottom: 1px solid #f0f4f8;
transition: border-color 0.2s ease;
}
.rollback-axis-row:last-child .rollback-axis-content {
border-bottom: none;
}
.rollback-axis-row:hover .rollback-axis-content {
border-bottom-color: transparent;
}
/* 顶行标题 */
.rollback-axis-topline {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
/* 版本号 */
.rollback-axis-version {
font-size: 17px;
line-height: 1.15;
color: #1e293b;
font-weight: 800;
letter-spacing: -0.01em;
}
/* 状态徽章 */
.vm-status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 9px 2px 6px;
font-size: 11px;
font-weight: 600;
border-radius: 20px;
line-height: 1.5;
white-space: nowrap;
}
.vm-badge-published {
color: #166534;
background: linear-gradient(135deg, #dcfce7, #bbf7d0);
border: 1px solid #86efac;
}
.vm-badge-draft {
color: #3730a3;
background: linear-gradient(135deg, #e8e5ff, #ddd6fe);
border: 1px solid #c4b5fd;
}
.vm-badge-merge {
color: #92400e;
background: linear-gradient(135deg, #fef3c7, #fde68a);
border: 1px solid #fcd34d;
}
/* 元数据行 */
.rollback-axis-meta {
display: flex;
gap: 14px;
flex-wrap: wrap;
font-size: 12px;
color: #64748b;
}
.vm-meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
.vm-meta-item .el-icon {
color: #94a3b8;
}
/* 操作按钮行 */
.rollback-axis-actions {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-top: 2px;
}
.vm-action-btn {
border-radius: 6px !important;
font-size: 12px !important;
font-weight: 500 !important;
padding: 4px 10px !important;
height: 28px !important;
transition: all 0.2s ease !important;
border: 1px solid transparent !important;
}
.vm-action-btn .el-icon {
margin-right: 2px;
}
.vm-action-primary {
color: #3b82f6 !important;
background: #eff6ff !important;
border-color: #bfdbfe !important;
}
.vm-action-primary:hover {
color: #ffffff !important;
background: #3b82f6 !important;
border-color: #3b82f6 !important;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.25);
}
.vm-action-danger {
color: #ef4444 !important;
background: #fef2f2 !important;
border-color: #fecaca !important;
}
.vm-action-danger:hover {
color: #ffffff !important;
background: #ef4444 !important;
border-color: #ef4444 !important;
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.25);
}
.vm-action-default {
color: #475569 !important;
background: #f8fafc !important;
border-color: #e2e8f0 !important;
}
.vm-action-default:hover {
color: #1e293b !important;
background: #f1f5f9 !important;
border-color: #cbd5e1 !important;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
}
.vm-action-btn.is-disabled {
opacity: 0.4 !important;
cursor: not-allowed !important;
}
/* 对话框尾部 */
.vm-dialog-footer {
display: flex;
justify-content: flex-end;
}
.setup-toolbar-actions :deep(.el-button) {
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
background: rgba(255, 255, 255, 0.08) !important;
border: 1px solid rgba(255, 255, 255, 0.25) !important;
color: #ffffff !important;
border-radius: 8px;
padding: 0 12px;
height: 32px;
font-weight: 500;
white-space: nowrap;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.setup-toolbar-actions :deep(.el-button:hover) {
background: rgba(255, 255, 255, 0.18) !important;
border-color: rgba(255, 255, 255, 0.4) !important;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.setup-toolbar-actions :deep(.el-button.is-disabled) {
opacity: 0.5;
background: rgba(255, 255, 255, 0.04) !important;
border-color: rgba(255, 255, 255, 0.1) !important;
color: rgba(255, 255, 255, 0.4) !important;
transform: none;
box-shadow: none;
}
/* 按钮特异性颜色提示 (利用边框和发光,不改变毛玻璃主体颜色) */
.setup-toolbar-actions :deep(.el-button--success.is-plain) {
border-color: rgba(34, 197, 94, 0.4) !important;
color: #a7f3d0 !important;
}
.setup-toolbar-actions :deep(.el-button--success.is-plain:hover) {
background: rgba(34, 197, 94, 0.15) !important;
border-color: rgba(34, 197, 94, 0.6) !important;
color: #ffffff !important;
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.2);
}
.setup-toolbar-actions :deep(.el-button--danger.is-plain) {
border-color: rgba(239, 68, 68, 0.4) !important;
color: #fca5a5 !important;
}
.setup-toolbar-actions :deep(.el-button--danger.is-plain:hover) {
background: rgba(239, 68, 68, 0.15) !important;
border-color: rgba(239, 68, 68, 0.6) !important;
color: #ffffff !important;
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.2);
}
.setup-toolbar-actions :deep(.el-button--warning.is-plain) {
border-color: rgba(245, 158, 11, 0.4) !important;
color: #fde68a !important;
}
.setup-toolbar-actions :deep(.el-button--warning.is-plain:hover) {
background: rgba(245, 158, 11, 0.15) !important;
border-color: rgba(245, 158, 11, 0.6) !important;
color: #ffffff !important;
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.2);
}
.secondary-menu-btn {
border-color: rgba(255, 255, 255, 0.25) !important;
background: rgba(255, 255, 255, 0.08) !important;
}
/* 模式切换单选按钮美化 */
.setup-view-mode {
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.setup-view-mode :deep(.el-radio-button__inner) {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.15);
color: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-shadow: none !important;
padding: 6px 14px;
font-weight: 500;
transition: all 0.2s;
}
.setup-view-mode :deep(.el-radio-button:not(:first-child) .el-radio-button__inner) {
border-left: 0;
}
.setup-view-mode :deep(.el-radio-button__inner:hover) {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.9);
}
.setup-view-mode :deep(.el-radio-button.is-active .el-radio-button__inner) {
background: #3b82f6;
border-color: #60a5fa;
color: #ffffff;
font-weight: 600;
}
.setup-section {
position: relative;
padding: 8px 12px 12px;
border-bottom: 1px solid #edf2f8;
background: #ffffff;
}
.setup-section--table {
padding-left: 0;
padding-right: 0;
padding-top: 0;
}
.enrollment-plan-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.enrollment-plan-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 32px;
padding: 4px 0 8px 0;
}
.enrollment-inline-group {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.enrollment-inline-label {
font-size: 13px;
font-weight: 500;
color: #64748b;
background: transparent;
padding: 0;
display: inline-flex;
align-items: center;
border: none;
}
.enrollment-inline-label:not(:empty)::after {
content: ":";
margin-left: 2px;
}
.enrollment-inline-label.required::before {
content: "*";
color: #d14141;
margin-right: 4px;
}
.enrollment-date-cell {
min-width: auto;
}
.enrollment-date-cell :deep(.el-date-editor.el-input),
.enrollment-date-cell :deep(.el-date-editor) {
width: 135px !important;
min-width: 135px !important;
}
.enrollment-target-cell {
min-width: auto;
}
.enrollment-target-cell :deep(.el-input-number) {
width: 110px !important;
min-width: 110px !important;
}
.enrollment-inline-value {
font-size: 14px;
font-weight: 500;
color: #0f172a;
}
.updated-value-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
gap: 4px;
}
.field-updated-tag {
position: static;
transform: none;
left: auto;
top: auto;
margin: 0 !important;
z-index: 1;
pointer-events: none;
/* 极简指示风格:仅用文字高亮,去背景和边框 */
background: transparent !important;
border: none !important;
color: #ea580c !important; /* 精致的警惕橙 */
padding: 0 !important;
height: auto !important;
line-height: 1 !important;
font-size: 11px !important;
font-weight: 600;
white-space: nowrap;
}
.enrollment-plan-toolbar .updated-value-wrap {
display: inline-flex;
align-items: center;
gap: 4px;
}
.enrollment-sep {
color: #94a3b8;
font-weight: 500;
margin: 0 4px;
display: inline-flex;
align-items: center;
height: 28px;
line-height: 28px;
flex-shrink: 0;
}
.enrollment-plan-summary {
border: none;
border-radius: 0;
background: transparent;
padding: 12px 0;
border-top: 1px solid #f1f5f9;
border-bottom: 1px solid #f1f5f9;
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.enrollment-summary-line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px;
font-size: 13px;
color: #64748b;
}
.enrollment-summary-line b {
color: #0f172a;
font-weight: 600;
font-size: 14px;
margin: 0 4px;
}
.summary-site-name {
font-weight: 700;
color: #11264d;
}
.summary-title {
font-weight: 600;
color: #0f172a;
min-width: 90px;
margin-right: -4px;
}
.summary-danger {
color: #d14141;
font-weight: 600;
}
.summary-success {
color: #2f7a4d;
font-weight: 600;
}
.enrollment-plan-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
}
.site-plan-selector {
display: inline-flex;
align-items: center;
gap: 8px;
margin-right: 12px;
}
.enrollment-plan-action-link {
color: #5f7499;
font-weight: 600;
}
.enrollment-plan-action-link:hover {
color: #3f617d;
}
.enrollment-month-plan-table :deep(th.el-table__cell .cell) {
text-align: center;
}
.enrollment-month-plan-table :deep(td.el-table__cell .cell) {
text-align: center;
padding-top: 5px;
padding-bottom: 5px;
}
.enrollment-period-plan-table :deep(th.el-table__cell .cell) {
text-align: center;
}
.enrollment-period-plan-table :deep(td.el-table__cell .cell) {
text-align: center;
padding-top: 6px;
padding-bottom: 6px;
}
.month-target-input {
width: 82px;
}
.month-empty {
color: #64748b;
font-size: 14px;
text-align: center;
padding: 40px 0;
background: #f8fafc;
border-radius: 8px;
border: 1px dashed #e2e8f0;
margin-top: 12px;
}
.site-plan-site-select {
width: 200px;
max-width: 100%;
}
.setup-content {
position: relative;
padding-left: 0 !important;
padding-right: 0 !important;
padding-bottom: 0 !important;
}
.setup-content-step-title {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
font-size: 18px;
font-weight: 700;
color: #162f57;
border-bottom: 1px solid #e8eff8;
background: #ffffff;
}
.setup-content-step-nav {
display: flex;
align-items: center;
gap: 6px;
}
.setup-content-step-nav :deep(.el-button.is-circle) {
width: 32px;
height: 32px;
padding: 0;
background: #f1f5f9 !important;
border: 1.5px solid #cbd5e1 !important;
color: #475569 !important;
font-size: 14px;
transition: all 0.2s;
}
.setup-content-step-nav :deep(.el-button.is-circle:hover) {
background: #e2e8f0 !important;
border-color: #94a3b8 !important;
color: #1e40af !important;
}
.setup-content-step-nav :deep(.el-button.is-circle.is-disabled),
.setup-content-step-nav :deep(.el-button.is-circle.is-disabled:hover) {
background: #f8fafc !important;
border-color: #e2e8f0 !important;
color: #cbd5e1 !important;
cursor: not-allowed;
}
.setup-content-step-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
.setup-content-step-actions :deep(.step-action-btn) {
min-width: 64px;
height: 28px;
padding: 0 12px;
border-radius: 6px;
font-weight: 500;
font-size: 13px;
transition: all 0.2s;
}
.setup-content-step-actions :deep(.step-action-btn.el-button--primary) {
background: transparent !important;
border: 1px solid #3b82f6 !important;
color: #3b82f6 !important;
}
.setup-content-step-actions :deep(.step-action-btn.el-button--primary:hover) {
background: #eff6ff !important;
border-color: #2563eb !important;
color: #2563eb !important;
}
.setup-content-step-actions :deep(.step-action-btn-cancel) {
background: #f8fafc !important;
border: 1px solid #e2e8f0 !important;
color: #64748b !important;
}
.setup-content-step-actions :deep(.step-action-btn-cancel:hover) {
background: #f1f5f9 !important;
border-color: #cbd5e1 !important;
color: #334155 !important;
}
.setup-content-step-actions :deep(.step-action-btn.el-button--primary .el-button__text),
.setup-content-step-actions :deep(.step-action-btn.el-button--primary > span),
.setup-content-step-actions :deep(.step-action-btn.el-button--primary .el-button__inner) {
color: inherit !important;
opacity: 1 !important;
}
.setup-edit-backdrop {
position: fixed;
inset: 0;
z-index: 1200;
background: rgba(10, 20, 40, 0.45);
backdrop-filter: blur(2px);
animation: setup-backdrop-fade-in 0.2s ease-out;
}
.setup-content.setup-content-drawer {
position: fixed;
top: 0;
right: 0;
z-index: 1300;
width: min(380px, 100vw);
height: 100vh;
overflow-y: auto;
background: #ffffff;
box-shadow: -12px 0 36px rgba(7, 22, 50, 0.22);
display: flex;
flex-direction: column;
animation: setup-drawer-slide-in 0.24s cubic-bezier(0.2, 0.8, 0.2, 1);
will-change: transform, opacity;
}
.setup-edit-backdrop.is-closing {
animation: setup-backdrop-fade-out 0.2s ease-in forwards;
}
.setup-content.setup-content-drawer.is-closing {
animation: setup-drawer-slide-out 0.22s ease-in forwards;
}
.setup-content.setup-content-drawer .setup-section {
border-bottom: 1px solid #edf2f8;
border-radius: 0;
padding: 12px 16px;
}
.setup-drawer-header {
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid #e6edf7;
background: #ffffff;
}
.setup-drawer-title-wrap {
display: flex;
align-items: center;
min-height: 32px;
}
.setup-drawer-title {
font-size: 18px;
font-weight: 700;
color: #11264d;
}
.setup-drawer-footer {
position: sticky;
bottom: 0;
z-index: 2;
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 12px 16px;
border-top: 1px solid #e6edf7;
background: #ffffff;
}
.drawer-form-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.drawer-form-card {
padding: 16px 0;
border: none;
border-bottom: 1px solid #f1f5f9;
background: transparent;
border-radius: 0;
}
.drawer-form-card:last-child {
border-bottom: none;
}
.drawer-form-card :deep(.el-form-item) {
margin-bottom: 10px;
}
.drawer-form-card :deep(.el-form-item:last-child) {
margin-bottom: 0;
}
.drawer-form-card-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 4px;
}
@keyframes setup-drawer-slide-in {
from {
transform: translateX(100%);
opacity: 0.9;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes setup-backdrop-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes setup-drawer-slide-out {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0.94;
}
}
@keyframes setup-backdrop-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.field-cell {
display: flex;
flex-direction: column;
gap: 4px;
}
.field-error {
font-size: 12px;
line-height: 1.3;
color: #d14141;
}
.setup-section:last-of-type {
border-bottom: 0;
}
.setup-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-bottom: 8px;
margin-bottom: 10px;
}
.setup-section--table .setup-section-header {
margin-bottom: 0;
padding-left: 12px;
padding-right: 12px;
}
.setup-content.setup-content-drawer .setup-section-header {
padding-bottom: 0;
margin-bottom: 0;
min-height: 0;
}
.published-view-tip {
margin-bottom: 8px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid #cfe1ff;
background: #f4f8ff;
color: #35527d;
font-size: 12px;
}
.section-title {
font-size: 15px;
font-weight: 700;
color: var(--ctms-text-main);
}
.w-full {
width: 100%;
}
.info-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 24px; /* 进一步压缩行间距 */
}
.basic-info-groups {
display: flex;
flex-direction: column;
gap: 0;
}
.info-group {
padding: 16px 0;
background: transparent;
border: none;
border-bottom: 1px solid #eaeff8;
border-radius: 0;
box-shadow: none;
}
.info-group:last-child {
border-bottom: none;
}
.info-group + .info-group {
margin-top: 0;
}
.info-group-title {
font-size: 15px;
font-weight: 700;
color: #0f172a;
margin-bottom: 12px;
display: flex;
align-items: center;
}
.info-group-title::before {
content: "";
display: inline-block;
width: 3px;
height: 14px;
background: #3b82f6;
border-radius: 4px;
margin-right: 8px;
}
.info-group-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.info-group-title-actions {
display: flex;
align-items: center;
gap: 10px;
}
.info-group-edit-btn {
min-width: 64px;
height: 28px;
padding: 0 12px;
border-radius: 6px;
background: transparent !important;
border: 1px solid #3b82f6 !important;
color: #3b82f6 !important;
font-size: 13px !important;
font-weight: 500 !important;
transition: all 0.2s;
}
.info-group-edit-btn:deep(span) {
font-size: 13px;
line-height: 1;
color: #3b82f6 !important;
opacity: 1 !important;
}
.info-group-edit-btn:hover,
.info-group-edit-btn:focus {
background: #eff6ff !important;
border-color: #2563eb !important;
color: #2563eb !important;
}
.info-group-edit-btn:hover:deep(span),
.info-group-edit-btn:focus:deep(span) {
color: #2563eb !important;
}
.info-group-edit-btn.is-disabled,
.info-group-edit-btn.is-disabled:hover {
background: transparent !important;
border-color: #cbd5e1 !important;
color: #94a3b8 !important;
}
.info-group-edit-btn.is-disabled:deep(span),
.info-group-edit-btn.is-disabled:hover:deep(span) {
color: #94a3b8 !important;
}
.basic-form-group-title {
margin: 0 0 14px;
font-size: 14px;
font-weight: 700;
color: #0f172a;
display: flex;
align-items: center;
}
.basic-form-group-title::before {
content: "";
display: inline-block;
width: 3px;
height: 14px;
background: #3b82f6;
border-radius: 4px;
margin-right: 8px;
}
.basic-edit-group {
padding: 20px 0 4px;
background: transparent;
border: none;
border-bottom: 1px solid #eaeff8;
border-radius: 0;
margin-bottom: 0;
}
.basic-edit-group:last-child {
border-bottom: none;
}
.basic-edit-group + .basic-edit-group {
margin-top: 0;
padding-top: 20px;
border-top: none;
}
.info-item {
display: flex;
align-items: flex-start;
gap: 12px;
min-height: 28px;
padding: 2px 0; /* 大幅精简项内垂直留白 */
}
.info-label {
color: #64748b;
min-width: 100px;
font-size: 13px;
background: transparent;
padding: 2px 0;
display: inline-flex;
align-items: center;
}
.info-value {
color: #0f172a;
font-weight: 500;
font-size: 14px;
word-break: break-word;
padding-top: 1px;
}
.info-value-wrap {
display: inline-block;
position: relative;
vertical-align: middle;
}
.setup-info-tabs {
margin-top: -8px;
position: relative;
}
.setup-section :deep(.el-tabs__item) {
font-size: 15px;
font-weight: 600;
}
.setup-section :deep(.el-tabs__nav-wrap::after) {
background-color: #edf2f8;
}
.setup-section :deep(.el-table),
.setup-section :deep(.el-table__inner-wrapper) {
border-radius: 0;
overflow: visible;
border: none !important;
background: transparent !important;
box-shadow: none !important;
--el-table-border-color: #f1f5f9;
}
.setup-section :deep(.el-table::before),
.setup-section :deep(.el-table::after),
.setup-section :deep(.el-table__inner-wrapper::before),
.setup-section :deep(.el-table__inner-wrapper::after) {
display: none !important; /* 去除原本表格底部及两侧的系统原生粗线 */
}
.setup-section :deep(.el-table th.el-table__cell) {
background: #f7f9fc !important;
color: #34506f !important;
font-weight: 600 !important;
height: 40px;
border-bottom: 1px solid #e2e8f0 !important;
border-right: none !important;
border-left: none !important;
}
.setup-section :deep(.el-table td.el-table__cell) {
border-bottom: 1px solid #f1f5f9 !important;
border-right: none !important;
border-left: none !important;
background: transparent !important;
padding-top: 7px;
padding-bottom: 7px;
}
/* 覆盖悬停状态颜色,保持极简高级 */
.setup-section :deep(.el-table--enable-row-hover .el-table__body tr:hover > td.el-table__cell) {
background-color: #f8fafc;
}
.setup-milestone-table :deep(th.el-table__cell),
.setup-milestone-table :deep(td.el-table__cell) {
height: 36px;
}
.setup-milestone-table :deep(th.el-table__cell .cell),
.setup-milestone-table :deep(td.el-table__cell .cell) {
padding-top: 4px;
padding-bottom: 4px;
}
.setup-milestone-table :deep(th.el-table__cell .cell) {
font-size: 13px;
font-weight: 500;
color: #64748b;
}
.milestone-cell-text {
display: inline-block;
font-size: 13px;
font-weight: 500;
color: #0f172a;
line-height: 16px;
}
.milestone-plan-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.milestone-plan-line {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
line-height: 16px;
color: #000;
}
.milestone-plan-dot {
width: 10px;
height: 10px;
border-radius: 50%;
border: 3px solid #2f84c6;
box-sizing: border-box;
}
.milestone-plan-label {
color: #4b5563;
}
.milestone-actions-inline {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
/* ========== 编辑抽屉 - 头部 ========== */
.sme-header {
display: flex;
flex-direction: column;
gap: 4px;
}
.sme-header-title {
font-size: 20px;
font-weight: 700;
color: #11264d;
line-height: 1.2;
}
.sme-header-subtitle {
font-size: 13px;
color: var(--ctms-text-secondary);
font-weight: 400;
}
/* ========== 编辑抽屉 - 表单 ========== */
.sme-form {
padding: 4px 4px 0 0;
}
/* ========== 编辑抽屉 - 分组卡片 ========== */
.sme-group {
border: 1px solid #e8eef6;
border-radius: 10px;
padding: 16px 18px 8px;
background: #fbfcfe;
transition: border-color 0.2s ease;
}
.sme-group:hover {
border-color: #d0dced;
}
.sme-group + .sme-group {
margin-top: 14px;
}
.sme-group-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 14px;
color: #1a3560;
display: flex;
align-items: center;
gap: 8px;
}
.sme-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.sme-dot-blue {
background: #3b82f6;
}
.sme-dot-amber {
background: #f0ad2c;
}
.sme-dot-green {
background: #22c55e;
}
.sme-dot-gray {
background: #909399;
}
/* ========== 编辑抽屉 - 耗时展示 ========== */
.sme-duration-line {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0 8px;
}
.sme-duration-label {
font-size: 13px;
color: #6b7280;
font-weight: 500;
}
.sme-duration-value {
font-size: 14px;
font-weight: 700;
color: #1a3560;
}
/* ========== 编辑抽屉 - 底部按钮 ========== */
.sme-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* ========== 编辑抽屉 - 表单元素微调 ========== */
.sme-form :deep(.el-form-item) {
margin-bottom: 12px;
}
.sme-form :deep(.el-form-item__label) {
font-size: 13px;
font-weight: 600;
color: #4a6283;
padding-bottom: 4px;
}
/* 兼容旧类名(保留不影响) */
.setup-milestone-editor-header {
font-size: 22px;
font-weight: 700;
color: #11264d;
}
.setup-milestone-editor-form {
padding-right: 4px;
}
.setup-milestone-editor-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.setup-content.is-published-preview .drawer-form-card-actions {
display: none;
}
.setup-content.is-published-preview :deep(.el-input.is-disabled .el-input__wrapper),
.setup-content.is-published-preview :deep(.el-textarea.is-disabled .el-textarea__inner),
.setup-content.is-published-preview :deep(.el-select .el-select__wrapper.is-disabled),
.setup-content.is-published-preview :deep(.el-input-number.is-disabled .el-input__wrapper),
.setup-content.is-draft-preview :deep(.el-input.is-disabled .el-input__wrapper),
.setup-content.is-draft-preview :deep(.el-textarea.is-disabled .el-textarea__inner),
.setup-content.is-draft-preview :deep(.el-select .el-select__wrapper.is-disabled),
.setup-content.is-draft-preview :deep(.el-input-number.is-disabled .el-input__wrapper) {
background: transparent;
box-shadow: none;
border: 0;
padding-left: 0;
padding-right: 0;
}
.setup-content.is-published-preview :deep(.el-input.is-disabled .el-input__inner),
.setup-content.is-published-preview :deep(.el-textarea.is-disabled .el-textarea__inner),
.setup-content.is-published-preview :deep(.el-select .el-select__selected-item),
.setup-content.is-published-preview :deep(.el-input-number.is-disabled .el-input__inner),
.setup-content.is-draft-preview :deep(.el-input.is-disabled .el-input__inner),
.setup-content.is-draft-preview :deep(.el-textarea.is-disabled .el-textarea__inner),
.setup-content.is-draft-preview :deep(.el-select .el-select__selected-item),
.setup-content.is-draft-preview :deep(.el-input-number.is-disabled .el-input__inner) {
color: #11264d;
-webkit-text-fill-color: #11264d;
font-weight: 600;
}
.setup-content.is-published-preview :deep(.el-input.is-disabled .el-input__suffix),
.setup-content.is-published-preview :deep(.el-select .el-select__caret),
.setup-content.is-published-preview :deep(.el-input-number__decrease),
.setup-content.is-published-preview :deep(.el-input-number__increase),
.setup-content.is-draft-preview :deep(.el-input.is-disabled .el-input__suffix),
.setup-content.is-draft-preview :deep(.el-select .el-select__caret),
.setup-content.is-draft-preview :deep(.el-input-number__decrease),
.setup-content.is-draft-preview :deep(.el-input-number__increase) {
display: none;
}
.detail-form :deep(.el-input),
.detail-form :deep(.el-select),
.detail-form :deep(.el-input-number) {
width: 100%;
}
.setup-content.setup-content-drawer :deep(.el-row) {
margin-left: 0 !important;
margin-right: 0 !important;
display: block;
}
.setup-content.setup-content-drawer :deep(.el-row > .el-col) {
max-width: 100% !important;
flex: 0 0 100% !important;
width: 100% !important;
padding-left: 0 !important;
padding-right: 0 !important;
}
.publish-incomplete {
margin-bottom: 12px;
}
.publish-incomplete-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.conflict-summary {
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
color: #334a75;
font-size: 13px;
}
.conflict-diff-lines {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.conflict-readable-diff {
margin-bottom: 12px;
}
.publish-diff-overview {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.summary-section-bar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin: 10px 0 8px;
}
.summary-display-group {
padding-top: 16px;
}
.visit-schedule-display {
width: 100%;
border: 1px solid #e1e9f3;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.visit-schedule-display-head,
.visit-schedule-display-row {
display: grid;
grid-template-columns: minmax(160px, 0.8fr) minmax(160px, 0.8fr) minmax(220px, 1fr);
gap: 16px;
align-items: center;
}
.visit-schedule-display-head {
min-height: 40px;
padding: 0 14px;
background: #f3f7fc;
border-bottom: 1px solid #dce6f2;
color: #35516f;
font-size: 13px;
font-weight: 800;
}
.visit-schedule-display-row {
min-height: 42px;
padding: 0 14px;
border-bottom: 1px solid #edf2f7;
color: #334155;
font-size: 13px;
}
.visit-schedule-display-row:last-of-type {
border-bottom: none;
}
.visit-schedule-display-code {
color: #0f172a;
font-weight: 700;
}
.visit-schedule-display-empty {
padding: 18px 14px;
color: #94a3b8;
font-size: 13px;
text-align: center;
}
.visit-schedule-editor {
width: 100%;
border: 1px solid #e1e9f3;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.visit-schedule-head,
.visit-schedule-row {
display: grid;
grid-template-columns: minmax(220px, 0.9fr) minmax(210px, 1fr) minmax(150px, 0.75fr) minmax(150px, 0.75fr) 48px;
gap: 12px;
align-items: center;
}
.visit-schedule-head {
min-height: 40px;
padding: 0 12px;
background: #f3f7fc;
border-bottom: 1px solid #dce6f2;
color: #35516f;
font-size: 13px;
font-weight: 800;
}
.visit-schedule-row {
position: relative;
padding: 9px 12px;
border-bottom: 1px solid #edf2f7;
cursor: grab;
transition: background-color 0.16s ease, box-shadow 0.16s ease, opacity 0.16s ease;
}
.visit-schedule-row:active {
cursor: grabbing;
}
.visit-schedule-row:last-child {
border-bottom: none;
}
.visit-schedule-row.is-dragging {
background: #f8fbff;
opacity: 0.62;
}
.visit-schedule-row.insert-before::before,
.visit-schedule-row.insert-after::after {
position: absolute;
left: 12px;
right: 12px;
z-index: 2;
height: 3px;
border-radius: 999px;
background: #2f80ed;
box-shadow: 0 0 0 3px rgba(47, 128, 237, 0.14);
content: "";
}
.visit-schedule-row.insert-before::before {
top: -2px;
}
.visit-schedule-row.insert-after::after {
bottom: -2px;
}
.visit-schedule-row.insert-before,
.visit-schedule-row.insert-after {
background: #f8fbff;
}
.visit-code-field {
min-width: 0;
}
.visit-field {
min-width: 0;
}
.visit-field-label {
display: none;
margin-bottom: 5px;
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.visit-schedule-remove {
justify-self: center;
width: 32px;
height: 32px;
}
.visit-schedule-add {
margin-top: 0;
}
.visit-special-rule {
margin-top: 14px;
padding: 12px 14px;
border: 1px solid #dce6f2;
border-radius: 8px;
background: #f8fbff;
}
.visit-special-rule-title {
color: #0f172a;
font-size: 13px;
font-weight: 800;
}
.visit-special-rule-body {
margin-top: 6px;
color: #475569;
font-size: 13px;
line-height: 1.6;
}
.visit-schedule-dialog :deep(.el-dialog) {
border-radius: 10px;
}
.visit-schedule-dialog :deep(.el-dialog__header) {
padding: 18px 20px 12px;
margin: 0;
border-bottom: 1px solid #e6edf7;
}
.visit-schedule-dialog :deep(.el-dialog__title) {
color: #0f172a;
font-size: 18px;
font-weight: 700;
}
.visit-schedule-dialog :deep(.el-dialog__body) {
padding: 14px 20px 18px;
}
.visit-schedule-dialog :deep(.el-dialog__footer) {
padding: 12px 20px;
border-top: 1px solid #e6edf7;
}
.visit-schedule-dialog-form .summary-section-bar {
margin-top: 0;
}
.visit-schedule-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.conflict-readable-diff :deep(.el-table th.el-table__cell) {
background: #f7faff;
color: #2f4672;
}
@media (max-width: 960px) {
.setup-switcher {
width: 100%;
}
.version-card {
width: 100%;
min-width: 0;
}
.setup-toolbar-actions {
width: 100%;
}
.info-grid {
grid-template-columns: 1fr;
}
.visit-schedule-head {
display: none;
}
.visit-schedule-display-head {
display: none;
}
.visit-schedule-display-row {
grid-template-columns: 1fr;
gap: 4px;
padding: 10px 14px;
}
.visit-schedule-row {
grid-template-columns: 1fr;
gap: 10px;
}
.visit-field-label {
display: block;
}
.visit-schedule-remove {
justify-self: flex-end;
}
.flow-label {
font-size: 14px;
}
.switcher-title {
font-size: 16px;
}
}
</style>