Files
ctms/frontend/src/views/admin/ProjectDetail.vue
T
2026-02-27 09:06:06 +08:00

5365 lines
189 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">
<div class="setup-shell unified-shell">
<div class="setup-header">
<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-switcher-wrap">
<div v-if="project?.is_locked" class="setup-switcher">
<el-tag type="warning" effect="plain">
<el-icon><Lock /></el-icon>
已锁定
</el-tag>
</div>
<div class="setup-save-meta">
<span class="meta-text">{{ setupSaveMetaText }}</span>
<el-tag size="small" :type="setupSyncTagType">{{ setupSyncTagLabel }}</el-tag>
<el-tag size="small" :type="setupPublishStatus === 'PUBLISHED' ? 'success' : 'info'">
{{ setupPublishStatus === "PUBLISHED" ? "已发布" : "草稿中" }}
</el-tag>
<el-tag v-if="setupPublishStatus === 'PUBLISHED'" size="small" type="warning">
发布时间 {{ setupPublishedAt || "-" }}
</el-tag>
</div>
<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="published">发布预览</el-radio-button>
</el-radio-group>
</div>
</div>
<div class="setup-toolbar-actions">
<el-button type="success" plain :loading="primarySaveLoading" @click="handlePrimarySaveConfig">保存配置</el-button>
<el-button 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"
: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>
<template v-else-if="stepActionMode === 'monitoring-strategy'">
<el-dropdown trigger="click" @command="onMonitoringStrategyTemplateSelect">
<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 monitoringStrategyTemplates"
: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="addMonitoringStrategy">新增</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">
<span class="info-label">{{ item.label }}</span>
<span class="info-value">{{ item.value || "-" }}</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">
<span class="info-label">{{ item.label }}</span>
<span class="info-value">{{ item.value || "-" }}</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">
<span class="info-label">{{ item.label }}</span>
<span class="info-value">{{ item.value || "-" }}</span>
</div>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane v-if="showStep1SummaryTab" label="方案摘要" name="summary">
<el-form v-if="canEditStep1Summary" label-width="120px" class="detail-form" label-position="top">
<el-row :gutter="24">
<el-col :span="6">
<el-form-item :label="TEXT.common.fields.visitTotal">
<el-input-number v-model="form.visit_total" :min="1" :max="50" :step="1" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item :label="TEXT.common.fields.visitIntervalDays">
<el-input-number v-model="form.visit_interval_days" :min="1" :max="365" :step="1" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item :label="TEXT.common.fields.visitWindowStartOffset">
<el-input-number v-model="form.visit_window_start_offset" :min="-365" :max="365" :step="1" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item :label="TEXT.common.fields.visitWindowEndOffset">
<el-input-number v-model="form.visit_window_end_offset" :min="-365" :max="365" :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 v-model="form.summary_note" type="textarea" :rows="3" placeholder="用于展示项目信息概览说明" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="研究目标摘要">
<el-input v-model="form.objective_note" type="textarea" :rows="3" placeholder="用于展示研究目标摘要" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<div v-else class="info-grid">
<div v-for="item in summaryInfoItems" :key="item.label" class="info-item">
<span class="info-label">{{ item.label }}</span>
<span class="info-value">{{ item.value || "-" }}</span>
</div>
</div>
</el-tab-pane>
</el-tabs>
</template>
<StateLoading v-else :rows="4" />
</section>
<section v-show="activeStep === 1" class="setup-section">
<el-table :data="currentSetupDraft.projectMilestones" class="ctms-table setup-milestone-table">
<el-table-column label="里程碑" min-width="180">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.name || "-" }}</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>{{ resolveProjectMilestoneStart(scope.row) || "-" }}</span>
</div>
<div class="milestone-plan-line">
<span class="milestone-plan-dot" />
<span class="milestone-plan-label">结束:</span>
<span>{{ resolveProjectMilestoneEnd(scope.row) || "-" }}</span>
</div>
<div class="milestone-plan-line">
<span class="milestone-plan-dot" />
<span class="milestone-plan-label">耗时:</span>
<span>{{ formatProjectMilestoneDuration(scope.row) }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="负责人" min-width="160">
<template #default="scope">
<span class="milestone-cell-text">{{ normalizeOwnerDisplay(scope.row.owner) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="150">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.status || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="备注" min-width="220">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.remark || "-" }}</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 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="enrollment-inline-value">{{ currentSetupDraft.enrollmentPlan.startDate || "-" }}</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="enrollment-inline-value">{{ currentSetupDraft.enrollmentPlan.endDate || "-" }}</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="enrollment-inline-value">{{ enrollmentPlanCycleLabel }}</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="enrollment-inline-value">{{ toDisplayNumber(currentSetupDraft.enrollmentPlan.totalTarget) }}</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="enrollmentPlanCycle === '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="milestone-cell-text">{{ displayEnrollmentPeriodTarget(scope.row.cells[month - 1].key) }}</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="milestone-cell-text">{{ displayEnrollmentPeriodTarget(scope.row.cells[columnIndex].key) }}</span>
</template>
<span v-else class="month-empty">-</span>
</template>
</el-table-column>
</el-table>
</section>
<section v-show="activeStep === 3" class="setup-section">
<el-table :data="currentSetupDraft.siteMilestones" class="ctms-table">
<el-table-column label="里程碑" min-width="220">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.milestone || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="计划日期" width="180">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.planDate || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="负责人" min-width="160">
<template #default="scope">
<span class="milestone-cell-text">{{ normalizeOwnerDisplay(scope.row.owner) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="150">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.status || "未开始" }}</span>
</template>
</el-table-column>
<el-table-column label="备注" min-width="180">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.remark || "-" }}</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 enrollment-plan-section">
<div class="enrollment-plan-actions enrollment-site-filter-row">
<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="site.id"
:label="site.name"
:value="site.id"
/>
</el-select>
</div>
</div>
<div class="enrollment-plan-toolbar">
<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="enrollment-inline-value">{{ selectedSiteEnrollmentPlan?.startDate || "-" }}</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="enrollment-inline-value">{{ selectedSiteEnrollmentPlan?.endDate || "-" }}</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="enrollment-inline-value">{{ enrollmentPlanCycleLabel }}</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="enrollment-inline-value">{{ toDisplayNumber(selectedSiteEnrollmentPlan?.target) }}</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="enrollmentPlanCycle === '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="milestone-cell-text">{{ displaySiteEnrollmentPeriodTarget(selectedSiteEnrollmentPlan, scope.row.cells[month - 1].key) }}</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="milestone-cell-text">{{ displaySiteEnrollmentPeriodTarget(selectedSiteEnrollmentPlan, scope.row.cells[columnIndex].key) }}</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">
<el-table :data="currentSetupDraft.monitoringStrategies" class="ctms-table">
<el-table-column label="监查类型" min-width="180">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.strategyType || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="策略详情" min-width="430">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.detail || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="监查次数" width="140">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.frequency || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="更新时间" width="180">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.updatedAt || "-" }}</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="openMonitoringStrategyEditor(scope.$index)">编辑</el-button>
<el-button link type="danger" :disabled="!canEditSetup" @click="removeMonitoringStrategy(scope.$index)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
</section>
<section v-show="activeStep === 6" class="setup-section">
<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="milestone-cell-text">{{ toDisplayNumber(scope.row.target) }}</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="milestone-cell-text">{{ scope.row.startDate || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="入组结束时间" width="150">
<template #default="scope">
<span class="milestone-cell-text">{{ scope.row.endDate || "-" }}</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 append-to=".layout-main .content-wrapper" 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>
<template #footer>
<el-button @click="publishConfirmVisible = false">取消</el-button>
<el-button type="primary" :loading="publishConfirmLoading" @click="confirmPublishConfig">确认发布</el-button>
</template>
</el-dialog>
<el-dialog append-to=".layout-main .content-wrapper" v-model="rollbackDialogVisible" title="选择回滚版本" width="760px" top="10vh">
<el-table :data="setupVersionHistory" height="360" v-loading="rollbackDialogLoading">
<el-table-column prop="version" label="发布版本" width="120">
<template #default="scope">v{{ scope.row.display_version || scope.row.version }}</template>
</el-table-column>
<el-table-column prop="published_by_name" label="发布人" min-width="150" />
<el-table-column prop="published_at" label="发布时间" min-width="200" />
<el-table-column label="操作" width="260">
<template #default="scope">
<el-button link type="danger" @click="selectRollbackVersion(scope.row.version)">回滚</el-button>
<el-button link type="primary" @click="downloadVersionRaw(scope.row)">下载</el-button>
<el-button
link
type="danger"
:disabled="isLatestPublishedVersion(scope.row.version)"
@click="removeVersion(scope.row.version)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="rollbackDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
<el-drawer
v-model="projectMilestoneEditorVisible"
direction="rtl"
size="420px"
:close-on-click-modal="false"
:show-close="false"
class="setup-milestone-editor-drawer"
>
<template #header>
<div class="setup-milestone-editor-header">
<span>编辑里程碑</span>
</div>
</template>
<el-form label-position="top" class="setup-milestone-editor-form">
<el-form-item label="里程碑">
<el-input v-model="projectMilestoneEditorForm.name" placeholder="例如:首例入组" />
</el-form-item>
<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-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-form-item label="耗时">
<span>{{ formatProjectMilestoneDuration(projectMilestoneEditorForm) }}</span>
</el-form-item>
<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-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-form-item label="备注">
<el-input v-model="projectMilestoneEditorForm.remark" placeholder="备注" />
</el-form-item>
</el-form>
<template #footer>
<div class="setup-milestone-editor-footer">
<el-button @click="projectMilestoneEditorVisible = false">取消</el-button>
<el-button type="primary" @click="saveProjectMilestoneEditor">保存</el-button>
</div>
</template>
</el-drawer>
<el-drawer
v-model="siteMilestoneEditorVisible"
direction="rtl"
size="420px"
:close-on-click-modal="false"
:show-close="false"
class="setup-milestone-editor-drawer"
>
<template #header>
<div class="setup-milestone-editor-header">
<span>编辑中心里程碑</span>
</div>
</template>
<el-form label-position="top" class="setup-milestone-editor-form">
<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>
<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-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-form-item label="备注">
<el-input v-model="siteMilestoneEditorForm.remark" placeholder="备注" />
</el-form-item>
</el-form>
<template #footer>
<div class="setup-milestone-editor-footer">
<el-button @click="siteMilestoneEditorVisible = false">取消</el-button>
<el-button type="primary" @click="saveSiteMilestoneEditor">保存</el-button>
</div>
</template>
</el-drawer>
<el-drawer
v-model="siteEnrollmentEditorVisible"
direction="rtl"
size="420px"
:close-on-click-modal="false"
:show-close="false"
class="setup-milestone-editor-drawer"
>
<template #header>
<div class="setup-milestone-editor-header">
<span>编辑中心入组计划</span>
</div>
</template>
<el-form label-position="top" class="setup-milestone-editor-form">
<el-form-item label="中心">
<el-select v-model="siteEnrollmentEditorForm.siteId" filterable clearable placeholder="选择中心" class="w-full">
<el-option
v-for="site in siteOptions"
:key="site.id"
:label="site.name"
:value="site.id"
:disabled="isSiteEnrollmentSiteTaken(site.id)"
/>
</el-select>
</el-form-item>
<el-form-item label="计划例数">
<el-input-number v-model="siteEnrollmentEditorForm.target" :min="0" controls-position="right" class="w-full" />
</el-form-item>
<el-form-item label="启动日期">
<el-date-picker v-model="siteEnrollmentEditorForm.startDate" type="date" value-format="YYYY-MM-DD" class="w-full" />
</el-form-item>
<el-form-item label="完成日期">
<el-date-picker v-model="siteEnrollmentEditorForm.endDate" type="date" value-format="YYYY-MM-DD" class="w-full" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="siteEnrollmentEditorForm.note" placeholder="备注" />
</el-form-item>
</el-form>
<template #footer>
<div class="setup-milestone-editor-footer">
<el-button @click="siteEnrollmentEditorVisible = false">取消</el-button>
<el-button type="primary" @click="saveSiteEnrollmentEditor">保存</el-button>
</div>
</template>
</el-drawer>
<el-drawer
v-model="monitoringStrategyEditorVisible"
direction="rtl"
size="420px"
:close-on-click-modal="false"
:show-close="false"
class="setup-milestone-editor-drawer"
>
<template #header>
<div class="setup-milestone-editor-header">
<span>编辑监查策略</span>
</div>
</template>
<el-form label-position="top" class="setup-milestone-editor-form">
<el-form-item label="监查类型">
<el-select v-model="monitoringStrategyEditorForm.strategyType" class="w-full" placeholder="选择类型">
<el-option
v-for="item in monitoringStrategyTypeOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
<el-form-item label="策略详情">
<el-input v-model="monitoringStrategyEditorForm.detail" placeholder="填写监查触发规则与执行策略" />
</el-form-item>
<el-form-item label="监查次数">
<el-input v-model="monitoringStrategyEditorForm.frequency" placeholder="如:不限 / 2次" />
</el-form-item>
</el-form>
<template #footer>
<div class="setup-milestone-editor-footer">
<el-button @click="monitoringStrategyEditorVisible = false">取消</el-button>
<el-button type="primary" @click="saveMonitoringStrategyEditor">保存</el-button>
</div>
</template>
</el-drawer>
<el-drawer
v-model="centerConfirmEditorVisible"
direction="rtl"
size="420px"
:close-on-click-modal="false"
:show-close="false"
class="setup-milestone-editor-drawer"
>
<template #header>
<div class="setup-milestone-editor-header">
<span>编辑中心确认</span>
</div>
</template>
<el-form label-position="top" class="setup-milestone-editor-form">
<el-form-item label="中心">
<el-select v-model="centerConfirmEditorForm.siteId" filterable clearable placeholder="选择中心" class="w-full">
<el-option v-for="site in siteOptions" :key="site.id" :label="site.name" :value="site.id" />
</el-select>
</el-form-item>
<el-form-item label="确认人">
<el-input v-model="centerConfirmEditorForm.confirmer" placeholder="确认人" />
</el-form-item>
<el-form-item label="确认状态">
<el-select v-model="centerConfirmEditorForm.confirmStatus" class="w-full" placeholder="状态">
<el-option label="待确认" value="待确认" />
<el-option label="已确认" value="已确认" />
<el-option label="退回" value="退回" />
</el-select>
</el-form-item>
<el-form-item label="确认日期">
<el-date-picker v-model="centerConfirmEditorForm.confirmDate" type="date" value-format="YYYY-MM-DD" class="w-full" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="centerConfirmEditorForm.note" placeholder="备注" />
</el-form-item>
</el-form>
<template #footer>
<div class="setup-milestone-editor-footer">
<el-button @click="centerConfirmEditorVisible = false">取消</el-button>
<el-button type="primary" @click="saveCenterConfirmEditor">保存</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { computed, 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 } from "@element-plus/icons-vue";
import {
fetchStudyDetail,
updateStudy,
} from "../../api/studies";
import { listMembers } from "../../api/members";
import { fetchSites } from "../../api/sites";
import { fetchUsers } from "../../api/users";
import type { Site, Study } from "../../types/api";
import type {
CenterConfirmDraft,
MonitoringStrategyDraft,
ProjectMilestoneDraft,
SetupConfigDraft,
SiteEnrollmentPlanDraft,
SiteMilestoneDraft,
StudySetupConfigResponse,
StudySetupConfigVersionItem,
StudySetupConfigUpsertPayload,
} 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 { groupErrorsBySection, parseFieldPath, type SetupValidationError } from "../../utils/setupFieldLocator";
import { buildSetupReadableDiffRows, serializeDiffValue, type SetupDiffRow } from "../../utils/setupDiffRows";
import { useSetupConfig } from "../../composables/useSetupConfig";
type SetupStepKey =
| "project-info"
| "project-milestone"
| "project-enrollment"
| "site-milestone"
| "site-enrollment"
| "monitoring-strategy"
| "site-confirm";
type Step1EditSection = "all" | "project" | "research" | "execution" | "summary";
type StepActionMode =
| "none"
| "edit"
| "project-milestone"
| "site-milestone"
| "monitoring-strategy";
const DRAFT_VERSION = 1;
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: "monitoring-strategy", 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 monitoringStrategyTypeOptions = [
"启动访视",
"筛选访视",
"监查访视",
"协同访视",
"风险访视",
"末次访视",
];
const monitoringStrategyTemplates: Array<{
key: string;
label: string;
rows: Array<{ strategyType: string; detail: string; frequency: string }>;
}> = [
{
key: "by-times-default",
label: "基础模板",
rows: monitoringStrategyTypeOptions.map((type) => ({
strategyType: type,
detail: `${type}按方案执行`,
frequency: "1次",
})),
},
];
const route = useRoute();
const router = useRouter();
const studyStore = useStudyStore();
const authStore = useAuthStore();
const {
getConfig: fetchSetupConfig,
saveConfig: upsertSetupConfig,
publishConfig,
listVersions: fetchSetupVersions,
rollbackConfig,
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 drawerVisible = computed(() => {
if (activeStep.value === 2 || activeStep.value === 4) return false;
return isEditing.value || drawerClosing.value;
});
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 projectDirtySinceLastPersist = 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<number | null>(null);
const setupPublishStatus = ref<"DRAFT" | "PUBLISHED" | string>("DRAFT");
const setupPublishedAt = ref<string>("");
const setupViewMode = ref<"draft" | "published">("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);
const monitoringStrategyEditorVisible = ref(false);
const monitoringStrategyEditingIndex = ref<number>(-1);
const centerConfirmEditorVisible = ref(false);
const centerConfirmEditingIndex = 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 monitoringStrategyEditor: IndexedEditorController = {
visible: monitoringStrategyEditorVisible,
index: monitoringStrategyEditingIndex,
};
const centerConfirmEditor: IndexedEditorController = {
visible: centerConfirmEditorVisible,
index: centerConfirmEditingIndex,
};
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: "",
});
const monitoringStrategyEditorForm = reactive<MonitoringStrategyDraft>({
id: "",
strategyType: "",
detail: "",
frequency: "",
updatedAt: "",
enabled: true,
});
const centerConfirmEditorForm = reactive<CenterConfirmDraft>({
id: "",
siteId: "",
siteName: "",
confirmer: "",
confirmStatus: "待确认",
confirmDate: "",
note: "",
});
type ProjectPublishSnapshot = {
code: string;
name: string;
project_full_name: string;
sponsor: string;
protocol_no: string;
lead_unit: string;
principal_investigator: string;
main_pm: string;
research_analysis: string;
research_product: string;
control_product: string;
indication: string;
research_population: string;
research_design: string;
plan_start_date: string;
plan_end_date: string;
planned_site_count: number | null;
planned_enrollment_count: number | null;
summary_note: string;
objective_note: string;
status: string;
visit_interval_days: number | null;
visit_total: number | null;
visit_window_start_offset: number | null;
visit_window_end_offset: number | null;
};
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 setupServerSnapshot = ref("");
const projectServerSnapshot = ref("");
const setupSaveMeta = ref<{ updatedAt: string; savedBy: string | null; serverSynced: boolean }>({
updatedAt: "",
savedBy: null,
serverSynced: true,
});
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: "国内",
summary_note: "",
objective_note: "",
visit_interval_days: null as number | null,
visit_total: null as number | null,
visit_window_start_offset: null as number | null,
visit_window_end_offset: null as number | null,
});
const formBaselineSnapshot = ref("");
const setupDraft = reactive<SetupConfigDraft>({
projectMilestones: [],
enrollmentPlan: {
totalTarget: 0,
startDate: "",
endDate: "",
monthlyGoalNote: "",
stageBreakdown: "",
},
siteMilestones: [],
siteEnrollmentPlans: [],
monitoringStrategies: [],
centerConfirm: [],
});
const storageKey = computed(() => `ctms_setup_config_draft_${String(route.params.projectId || "")}`);
const projectDraftStorageKey = computed(() => `ctms_setup_project_draft_${String(route.params.projectId || "")}`);
const publishedProjectSignatureKey = computed(() => `ctms_setup_published_project_sig_${String(route.params.projectId || "")}`);
const setupRole = computed(() => String(project.value?.role_in_study || authStore.user?.role || "").toUpperCase());
const canManageSetup = computed(() => ["ADMIN", "PM"].includes(setupRole.value));
const isPublishedView = computed(() => setupViewMode.value === "published");
const currentSetupDraft = computed<SetupConfigDraft>(() => {
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 canEditStep1Summary = computed(
() => activeStep.value === 0 && isEditing.value && (step1EditSection.value === "all" || step1EditSection.value === "summary")
);
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 infoTab.value === "summary" ? "edit" : "none";
case 1:
return "project-milestone";
case 3:
return "site-milestone";
case 4:
return "edit";
case 5:
return "monitoring-strategy";
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 `v${setupPublishedVersion.value ?? 0}`;
});
const setupSaveMetaText = computed(() => {
const when = setupSaveMeta.value.updatedAt || "-";
return `最近保存:${when}|发布版本:${setupPublishedVersionText.value}`;
});
const setupSyncTagLabel = computed(() => {
if (setupDirtySinceLastPersist.value || projectDirtySinceLastPersist.value || autoSaveTimer.value) return "编辑中未保存";
return setupSaveMeta.value.serverSynced ? "已保存到服务端" : "仅本地暂存";
});
const setupSyncTagType = computed<"success" | "warning" | "danger">(() => {
if (setupDirtySinceLastPersist.value || projectDirtySinceLastPersist.value || autoSaveTimer.value) return "warning";
return setupSaveMeta.value.serverSynced ? "success" : "danger";
});
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,
summary_note: form.value.summary_note || "",
objective_note: form.value.objective_note || "",
phase: form.value.phase || "",
status: form.value.status || "",
visit_interval_days: form.value.visit_interval_days,
visit_total: form.value.visit_total,
visit_window_start_offset: form.value.visit_window_start_offset,
visit_window_end_offset: form.value.visit_window_end_offset,
});
const serializeSetupForCompare = (): string => JSON.stringify(clone(setupDraft));
const buildProjectPublishSnapshot = (): ProjectPublishSnapshot => ({
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,
summary_note: form.value.summary_note || "",
objective_note: form.value.objective_note || "",
status: form.value.status || "",
visit_interval_days: form.value.visit_interval_days,
visit_total: form.value.visit_total,
visit_window_start_offset: form.value.visit_window_start_offset,
visit_window_end_offset: form.value.visit_window_end_offset,
});
const serializeProjectForPublishCompare = (): string => JSON.stringify(buildProjectPublishSnapshot());
const hasFormUnsavedChanges = computed(() => Boolean(formBaselineSnapshot.value && serializeFormForCompare() !== formBaselineSnapshot.value));
const hasUnsavedChanges = computed(
() => hasFormUnsavedChanges.value || projectDirtySinceLastPersist.value || setupDirtySinceLastPersist.value || Boolean(autoSaveTimer.value)
);
const hasSetupDraftUnsavedChanges = computed(() => setupDirtySinceLastPersist.value || Boolean(autoSaveTimer.value));
const hasLeaveGuardChanges = computed(() => hasFormUnsavedChanges.value || hasSetupDraftUnsavedChanges.value);
const setupSectionStepMap: Record<string, number> = {
projectMilestones: 1,
enrollmentPlan: 2,
siteMilestones: 3,
siteEnrollmentPlans: 4,
monitoringStrategies: 5,
centerConfirm: 6,
};
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] || [];
});
const projectInfoItems = computed(() => [
{ label: "项目名称", value: form.value.name || project.value?.name || "-" },
{ label: "项目全称", value: form.value.project_full_name || project.value?.project_full_name || "-" },
{ label: "申办方", value: form.value.sponsor || project.value?.sponsor || "-" },
{ label: "项目编号", value: form.value.code || project.value?.code || "-" },
{ label: "方案号", value: form.value.protocol_no || project.value?.protocol_no || "-" },
{ label: "组长单位", value: form.value.lead_unit || project.value?.lead_unit || "-" },
{ label: "主要研究者", value: form.value.principal_investigator || project.value?.principal_investigator || "-" },
{ label: "主PM", value: form.value.main_pm || project.value?.main_pm || "-" },
]);
const researchInfoItems = computed(() => [
{ label: "研究分期", value: form.value.research_analysis || project.value?.research_analysis || "-" },
{ label: "研究产品", value: form.value.research_product || project.value?.research_product || "-" },
{ label: "对照产品", value: form.value.control_product || project.value?.control_product || "-" },
{ label: "适应症", value: form.value.indication || project.value?.indication || "-" },
{ label: "研究人群", value: form.value.research_population || project.value?.research_population || "-" },
{ label: "研究设计", value: form.value.research_design || project.value?.research_design || "-" },
]);
const executionInfoItems = computed(() => [
{ label: "计划开始日期", value: form.value.plan_start_date || project.value?.plan_start_date || "-" },
{ label: "计划结束日期", value: form.value.plan_end_date || project.value?.plan_end_date || "-" },
{ label: "项目状态", value: statusLabel(form.value.status || project.value?.status || "DRAFT") },
{ label: "计划中心数", value: toDisplayNumber(form.value.planned_site_count) },
{ label: "计划入组数", value: toDisplayNumber(setupDraft.enrollmentPlan.totalTarget) },
]);
const summaryInfoItems = computed(() => [
{ label: "访视总数", value: toDisplayNumber(form.value.visit_total) },
{ label: "访视间隔(天)", value: toDisplayNumber(form.value.visit_interval_days) },
{ label: "窗口期起始偏移", value: toDisplayNumber(form.value.visit_window_start_offset) },
{ label: "窗口期结束偏移", value: toDisplayNumber(form.value.visit_window_end_offset) },
{ label: "方案摘要说明", value: form.value.summary_note || "-" },
{ label: "研究目标摘要", value: form.value.objective_note || "-" },
]);
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 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 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 currentEnrollmentSlots = computed(() =>
buildEnrollmentSlotsByCycle(enrollmentPlanCycle.value, setupDraft.enrollmentPlan.startDate, setupDraft.enrollmentPlan.endDate)
);
const enrollmentPlanMonthSlots = computed(() => {
const slots = buildEnrollmentSlotsByCycle("month", setupDraft.enrollmentPlan.startDate, setupDraft.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[enrollmentPlanCycle.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 (enrollmentPlanCycle.value === "month") return [];
return buildEnrollmentCycleGridColumns();
});
const enrollmentCycleRowLabel = computed(() => "年份");
const enrollmentCycleGridRows = computed<EnrollmentCycleGridRow[]>(() => {
if (enrollmentPlanCycle.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 = enrollmentTargetsByCycle.value[enrollmentPlanCycle.value]?.[key];
if (value === null || value === undefined) return 0;
return normalizeEnrollmentTarget(value);
};
const displayEnrollmentPeriodTarget = (key: string | null): string => {
if (!key) return "-";
const value = enrollmentTargetsByCycle.value[enrollmentPlanCycle.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(setupDraft.enrollmentPlan.totalTarget);
return Math.max(totalTarget - projectMonthlyPlannedCount.value, 0);
});
const projectMonthlyOverflowCount = computed(() => {
const totalTarget = normalizeEnrollmentTarget(setupDraft.enrollmentPlan.totalTarget);
return Math.max(projectMonthlyPlannedCount.value - totalTarget, 0);
});
const centerEnrollmentTargetMap = computed(() => {
const map = new Map<string, number>();
setupDraft.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(setupDraft.enrollmentPlan.totalTarget);
return Math.max(totalTarget - centerPlannedCount.value, 0);
});
const centerOverflowCount = computed(() => {
const totalTarget = normalizeEnrollmentTarget(setupDraft.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[enrollmentPlanCycle.value] || {};
let sum = 0;
Object.entries(activeTargets).forEach(([key, value]) => {
if (!slotKeys.has(key)) return;
sum += normalizeEnrollmentTarget(value);
});
return sum;
};
const centerCyclePlannedCount = computed(() => {
return setupDraft.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[enrollmentPlanCycle.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[enrollmentPlanCycle.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;
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("monitoringStrategies")) return stepIndex === 5;
if (path.startsWith("centerConfirm")) return stepIndex === 6;
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 (
setupDraft.monitoringStrategies.length > 0 &&
setupDraft.monitoringStrategies.every(
(item) => (item.strategyType || "").trim().length > 0 && (item.detail || "").trim().length > 0 && (item.frequency || "").trim().length > 0
)
);
}
if (stepIndex === 6) {
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 getSiteName = (siteId: string): string => {
return siteOptions.value.find((s) => s.id === siteId)?.name || "";
};
const normalizeSiteId = (siteId: string | null | undefined): string => String(siteId || "").trim();
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>();
setupDraft.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: site.name || "",
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) => {
map.set(normalizeSiteId(site.id), site);
});
setupDraft.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 || siteId,
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 setupDraft.siteEnrollmentPlans.findIndex((row) => normalizeSiteId(row.siteId) === selectedSiteId);
});
const selectedSiteEnrollmentPlan = computed(() => {
const index = selectedSiteEnrollmentPlanIndex.value;
if (index < 0) return null;
return setupDraft.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 =
setupDraft.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, () => setupDraft.siteEnrollmentPlans.map((row) => normalizeSiteId(row.siteId)).join("|")],
syncSelectedSiteEnrollmentPlanSiteId,
{ immediate: true }
);
const createDefaultSetupDraft = (_sites: Site[]): SetupConfigDraft => {
return {
projectMilestones: [],
enrollmentPlan: {
totalTarget: 0,
startDate: "",
endDate: "",
monthlyGoalNote: "",
stageBreakdown: "",
},
siteMilestones: [],
siteEnrollmentPlans: [],
monitoringStrategies: [],
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 (
Array.isArray(data.projectMilestones) &&
!!data.enrollmentPlan &&
Array.isArray(data.siteMilestones) &&
Array.isArray(data.siteEnrollmentPlans) &&
Array.isArray(data.monitoringStrategies) &&
Array.isArray(data.centerConfirm)
);
};
const applySetupDraft = (draft: SetupConfigDraft) => {
suppressDraftWatch.value = true;
setupDraft.projectMilestones = normalizeProjectMilestoneRows(clone(draft.projectMilestones));
setupDraft.enrollmentPlan = clone(draft.enrollmentPlan);
setupDraft.siteMilestones = clone(draft.siteMilestones);
setupDraft.siteEnrollmentPlans = normalizeSiteEnrollmentPlans(clone(draft.siteEnrollmentPlans));
setupDraft.monitoringStrategies = clone(draft.monitoringStrategies);
setupDraft.centerConfirm = clone(draft.centerConfirm);
suppressDraftWatch.value = false;
};
const loadLocalSetupDraft = (): SetupConfigDraft | null => {
try {
const raw = localStorage.getItem(storageKey.value);
if (!raw) return null;
const parsed = JSON.parse(raw) as { version?: number; data?: unknown };
if (parsed.version !== DRAFT_VERSION || !isSetupDraftShape(parsed.data)) return null;
return clone(parsed.data);
} catch {
return null;
}
};
const persistSetupDraftToLocal = () => {
try {
localStorage.setItem(
storageKey.value,
JSON.stringify({
version: DRAFT_VERSION,
data: clone(setupDraft),
savedAt: nowString(),
})
);
setupSaveMeta.value = {
updatedAt: nowString(),
savedBy: authStore.user?.full_name || authStore.user?.username || authStore.user?.email || "当前用户",
serverSynced: false,
};
} catch {
// ignore local storage errors
}
};
const cacheSetupDraftToLocal = () => {
try {
localStorage.setItem(
storageKey.value,
JSON.stringify({
version: DRAFT_VERSION,
data: clone(setupDraft),
savedAt: nowString(),
})
);
} catch {
// ignore local storage errors
}
};
const loadLocalProjectDraft = (): Record<string, unknown> | null => {
try {
const raw = localStorage.getItem(projectDraftStorageKey.value);
if (!raw) return null;
const parsed = JSON.parse(raw) as { version?: number; data?: unknown };
if (parsed.version !== DRAFT_VERSION || !parsed.data || typeof parsed.data !== "object") return null;
return parsed.data as Record<string, unknown>;
} catch {
return null;
}
};
const applyProjectDraft = (draft: Record<string, unknown>) => {
const keys = Object.keys(form.value) as Array<keyof typeof form.value>;
keys.forEach((key) => {
if (Object.prototype.hasOwnProperty.call(draft, key)) {
(form.value[key] as any) = draft[key as string] as any;
}
});
};
const persistProjectDraftToLocal = () => {
try {
localStorage.setItem(
projectDraftStorageKey.value,
JSON.stringify({
version: DRAFT_VERSION,
data: clone(form.value),
savedAt: nowString(),
})
);
projectDirtySinceLastPersist.value = Boolean(
projectServerSnapshot.value && serializeFormForCompare() !== projectServerSnapshot.value
);
setupSaveMeta.value = {
updatedAt: nowString(),
savedBy: authStore.user?.full_name || authStore.user?.username || authStore.user?.email || "当前用户",
serverSynced: false,
};
} catch {
// ignore local storage errors
}
};
const cacheProjectDraftToLocal = () => {
try {
localStorage.setItem(
projectDraftStorageKey.value,
JSON.stringify({
version: DRAFT_VERSION,
data: clone(form.value),
savedAt: nowString(),
})
);
} catch {
// ignore local storage errors
}
};
const clearLocalProjectDraft = () => {
try {
localStorage.removeItem(projectDraftStorageKey.value);
} catch {
// ignore local storage errors
}
};
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;
applySetupResponseMeta(data);
setupSaveMeta.value = {
updatedAt: formatDisplayTime(data.updated_at),
savedBy: data.saved_by_name || resolveSavedByDisplay(data.saved_by),
serverSynced: true,
};
return clone(data.data);
} 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: "monitoringStrategies", label: "监查计划策略" },
{ key: "centerConfirm", label: "中心确认" },
];
const emptySetupDraft: SetupConfigDraft = {
projectMilestones: [],
enrollmentPlan: {
totalTarget: 0,
startDate: "",
endDate: "",
monthlyGoalNote: "",
stageBreakdown: "",
},
siteMilestones: [],
siteEnrollmentPlans: [],
monitoringStrategies: [],
centerConfirm: [],
};
const buildConflictDiffLines = (localDraft: SetupConfigDraft | null, serverDraft: SetupConfigDraft | null): string[] => {
if (!localDraft || !serverDraft) return [];
const lines: string[] = [];
setupModuleLabels.forEach((item) => {
const localChunk = JSON.stringify(localDraft[item.key] ?? null);
const serverChunk = JSON.stringify(serverDraft[item.key] ?? null);
if (localChunk !== serverChunk) {
lines.push(`${item.label} 存在差异`);
}
});
return lines;
};
const publishCompareBase = computed<SetupConfigDraft>(() => clone(publishedSetupDraft.value || emptySetupDraft));
const isFirstPublish = computed(() => !publishedSetupDraft.value);
const hasProjectPublishDiff = computed(() => {
if (!publishedProjectSnapshot.value) return false;
return serializeProjectForPublishCompare() !== JSON.stringify(publishedProjectSnapshot.value);
});
const canStrictlyDetectNoDiff = computed(() => Boolean(!isFirstPublish.value && publishedProjectSnapshot.value));
const hasPublishableDiff = computed(() => publishDiffLines.value.length > 0 || hasProjectPublishDiff.value);
const publishDiffLines = computed(() => {
const lines = buildConflictDiffLines(setupDraft, publishCompareBase.value);
if (hasProjectPublishDiff.value) {
lines.unshift("项目信息存在差异");
}
return lines;
});
const publishFieldDiffRows = computed(() => [...buildProjectDiffRows(), ...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 loadPublishedProjectSignatureFromLocal = () => {
try {
const raw = localStorage.getItem(publishedProjectSignatureKey.value);
if (!raw) {
publishedProjectSnapshot.value = null;
return;
}
const parsed = JSON.parse(raw) as ProjectPublishSnapshot;
if (!parsed || typeof parsed !== "object") {
publishedProjectSnapshot.value = null;
return;
}
publishedProjectSnapshot.value = parsed;
} catch {
publishedProjectSnapshot.value = null;
}
};
const persistPublishedProjectSignatureToLocal = () => {
try {
const snapshot = buildProjectPublishSnapshot();
publishedProjectSnapshot.value = snapshot;
localStorage.setItem(publishedProjectSignatureKey.value, JSON.stringify(snapshot));
} catch {
// ignore local storage errors
}
};
type DiffRow = SetupDiffRow;
const projectFieldLabelMap: Record<keyof ProjectPublishSnapshot, string> = {
code: "项目编号",
name: "项目名称",
project_full_name: "项目全称",
sponsor: "申办方",
protocol_no: "方案号",
lead_unit: "组长单位",
principal_investigator: "主要研究者",
main_pm: "主PM",
research_analysis: "研究分期",
research_product: "研究产品",
control_product: "对照产品",
indication: "适应症",
research_population: "研究人群",
research_design: "研究设计",
plan_start_date: "计划开始日期",
plan_end_date: "计划结束日期",
planned_site_count: "计划中心数",
planned_enrollment_count: "计划入组数",
summary_note: "方案摘要说明",
objective_note: "研究目标摘要",
status: "项目状态",
visit_interval_days: "访视间隔(天)",
visit_total: "访视总数",
visit_window_start_offset: "窗口期起始偏移",
visit_window_end_offset: "窗口期结束偏移",
};
const buildProjectDiffRows = (): DiffRow[] => {
if (!publishedProjectSnapshot.value) return [];
const current = buildProjectPublishSnapshot();
const previous = publishedProjectSnapshot.value;
const rows: DiffRow[] = [];
(Object.keys(projectFieldLabelMap) as Array<keyof ProjectPublishSnapshot>).forEach((key) => {
if (current[key] === previous[key]) return;
rows.push({
moduleLabel: "项目信息",
path: projectFieldLabelMap[key],
changeType: "修改",
localValue: serializeDiffValue(current[key]),
serverValue: serializeDiffValue(previous[key]),
});
});
return rows;
};
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;
setupPublishStatus.value = data.publish_status || "DRAFT";
setupPublishedAt.value = formatDisplayTime(data.published_at);
publishedSetupDraft.value = isSetupDraftShape(data?.published_data) ? clone(data.published_data) : null;
};
const refreshSetupDraftFromServer = async () => {
const latest = await loadSetupDraftFromServer();
if (!latest) return false;
applySetupDraft(latest);
clearSetupValidationErrors();
markSetupSynced();
ElMessage.info("已刷新服务端最新配置");
return true;
};
const persistSetupDraft = async (showMessage = false): Promise<{ serverSynced: boolean }> => {
if (!project.value) return { serverSynced: false };
const payload: StudySetupConfigUpsertPayload = {
expected_version: setupRevision.value,
data: clone(setupDraft),
};
try {
const { data } = await upsertSetupConfig(project.value.id, payload);
applySetupResponseMeta(data);
clearSetupValidationErrors();
markSetupSynced();
setupServerSnapshot.value = serializeSetupForCompare();
setupSaveMeta.value = {
updatedAt: formatDisplayTime(data.updated_at),
savedBy: data.saved_by_name || resolveSavedByDisplay(data.saved_by),
serverSynced: true,
};
if (autoSaveTimer.value) {
window.clearTimeout(autoSaveTimer.value);
autoSaveTimer.value = undefined;
}
localStorage.setItem(
storageKey.value,
JSON.stringify({
version: DRAFT_VERSION,
data: clone(setupDraft),
savedAt: nowString(),
})
);
return { serverSynced: true };
} catch (e: any) {
if (handleValidationFailure(e, "配置保存失败,请修正后重试")) {
persistSetupDraftToLocal();
setupDirtySinceLastPersist.value = true;
return { serverSynced: false };
}
persistSetupDraftToLocal();
setupDirtySinceLastPersist.value = true;
if (e?.response?.status === 409) {
ElMessage.warning("配置已更新,正在刷新最新版本");
await refreshSetupDraftFromServer();
return { serverSynced: 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 };
}
};
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 = setupServerSnapshot.value
? serializeSetupForCompare() !== setupServerSnapshot.value
: true;
setupDirtySinceLastPersist.value = changed;
if (!changed) 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 === "published") {
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.summary_note = data?.summary_note || "";
form.value.objective_note = data?.objective_note || "";
form.value.visit_interval_days = data?.visit_interval_days ?? null;
form.value.visit_total = data?.visit_total ?? null;
form.value.visit_window_start_offset = data?.visit_window_start_offset ?? null;
form.value.visit_window_end_offset = data?.visit_window_end_offset ?? null;
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) {
applySetupDraft(localDraft);
setupDirtySinceLastPersist.value = true;
setupSaveMeta.value = {
updatedAt: nowString(),
savedBy: authStore.user?.full_name || authStore.user?.username || authStore.user?.email || "当前用户",
serverSynced: false,
};
draftReady.value = true;
return;
}
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 and fallback to users API below.
}
try {
const { data } = await fetchUsers({ limit: 1000 });
const users = (data as any)?.items || [];
users.forEach((user: any) => {
const userId = String(user?.id || "").trim();
if (!userId || nextMap[userId]) return;
nextMap[userId] = user?.full_name || user?.username || user?.email || userId;
});
} catch {
// ignore
}
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;
const { data } = await fetchStudyDetail(projectId);
project.value = data as Study;
syncFormFromProjectWithoutSetupLink(project.value);
projectServerSnapshot.value = serializeFormForCompare();
const serverProjectSnapshot = serializeFormForCompare();
const localProjectDraft = loadLocalProjectDraft();
if (localProjectDraft) {
applyProjectDraft(localProjectDraft);
const localSnapshot = serializeFormForCompare();
if (localSnapshot !== serverProjectSnapshot) {
projectDirtySinceLastPersist.value = true;
setupSaveMeta.value = {
updatedAt: nowString(),
savedBy: authStore.user?.full_name || authStore.user?.username || authStore.user?.email || "当前用户",
serverSynced: false,
};
} else {
clearLocalProjectDraft();
projectDirtySinceLastPersist.value = false;
}
} else {
projectDirtySinceLastPersist.value = false;
}
formBaselineSnapshot.value = serializeFormForCompare();
if (setupSaveMeta.value.serverSynced && !projectDirtySinceLastPersist.value) {
setupDirtySinceLastPersist.value = false;
if (autoSaveTimer.value) {
window.clearTimeout(autoSaveTimer.value);
autoSaveTimer.value = undefined;
}
}
loadPublishedProjectSignatureFromLocal();
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);
projectServerSnapshot.value = serializeFormForCompare();
formBaselineSnapshot.value = serializeFormForCompare();
if (studyStore.currentStudy?.id === project.value.id) {
studyStore.setCurrentStudy({ ...(studyStore.currentStudy as Study), ...project.value } as Study);
}
} 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;
}
};
const cancelEdit = () => {
syncFormFromProjectWithoutSetupLink(project.value);
if (projectDirtySinceLastPersist.value) {
const localProjectDraft = loadLocalProjectDraft();
if (localProjectDraft) {
applyProjectDraft(localProjectDraft);
formBaselineSnapshot.value = serializeFormForCompare();
}
}
isEditing.value = false;
step1EditSection.value = "all";
clearSetupValidationErrors();
formBaselineSnapshot.value = serializeFormForCompare();
};
const closeEditDrawer = () => {
if (!isEditing.value || drawerClosing.value) 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;
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 handleDrawerConfirm = async () => {
if (!canEditSetup.value) return;
drawerConfirmLoading.value = true;
try {
let saved = false;
if (activeStep.value === 0) {
if (formRef.value) {
await formRef.value.validate();
}
persistProjectDraftToLocal();
formBaselineSnapshot.value = serializeFormForCompare();
saved = true;
} else {
persistSetupDraftToLocal();
setupDirtySinceLastPersist.value = true;
saved = true;
}
if (!saved) return;
closeEditDrawerAfterSave();
ElMessage.success("当前步骤已保存");
} finally {
drawerConfirmLoading.value = false;
}
};
const saveEdit = async (closeAfterSave = true, showSuccessMessage = true): Promise<boolean> => {
if (!project.value) return false;
if (formRef.value) {
await formRef.value.validate();
}
submitting.value = true;
try {
const { data } = await updateStudy(project.value.id, {
code: form.value.code.trim(),
name: form.value.name.trim(),
project_full_name: form.value.project_full_name?.trim() || null,
sponsor: form.value.sponsor?.trim() || null,
protocol_no: form.value.protocol_no?.trim() || null,
lead_unit: form.value.lead_unit?.trim() || null,
principal_investigator: form.value.principal_investigator?.trim() || null,
main_pm: form.value.main_pm?.trim() || null,
research_analysis: form.value.research_analysis?.trim() || null,
research_product: form.value.research_product?.trim() || null,
control_product: form.value.control_product?.trim() || null,
indication: form.value.indication?.trim() || null,
research_population: form.value.research_population?.trim() || null,
research_design: form.value.research_design?.trim() || null,
plan_start_date: form.value.plan_start_date || null,
plan_end_date: form.value.plan_end_date || null,
planned_site_count: form.value.planned_site_count,
planned_enrollment_count: form.value.planned_enrollment_count,
summary_note: form.value.summary_note?.trim() || null,
objective_note: form.value.objective_note?.trim() || null,
phase: form.value.phase?.trim() || null,
status: form.value.status,
visit_interval_days: form.value.visit_interval_days,
visit_total: form.value.visit_total,
visit_window_start_offset: form.value.visit_window_start_offset,
visit_window_end_offset: form.value.visit_window_end_offset,
});
project.value = data as Study;
syncFormFromProjectWithoutSetupLink(project.value);
projectServerSnapshot.value = serializeFormForCompare();
projectDirtySinceLastPersist.value = false;
clearLocalProjectDraft();
formBaselineSnapshot.value = serializeFormForCompare();
if (closeAfterSave) {
isEditing.value = false;
step1EditSection.value = "all";
}
if (showSuccessMessage) {
ElMessage.success("项目信息已保存");
}
return true;
} catch (e: any) {
ElMessage.error(getApiErrorMessage(e, TEXT.common.messages.saveFailed));
return false;
} finally {
submitting.value = false;
}
};
const saveConfigNow = async (): Promise<boolean> => {
if (!canSaveDraftAction.value) return false;
const result = await persistSetupDraft(false);
if (!result.serverSynced) return false;
if (hasFormUnsavedChanges.value || projectDirtySinceLastPersist.value) {
cacheProjectDraftToLocal();
projectDirtySinceLastPersist.value = Boolean(
projectServerSnapshot.value && serializeFormForCompare() !== projectServerSnapshot.value
);
}
formBaselineSnapshot.value = serializeFormForCompare();
return true;
};
const doPublishConfigNow = async (): Promise<boolean> => {
if (!project.value || !canPublishAction.value) return false;
try {
if (hasFormUnsavedChanges.value || projectDirtySinceLastPersist.value) {
const basicSaved = await saveEdit(false, false);
if (!basicSaved) return false;
}
const { data } = await publishConfig(project.value.id, { expected_version: setupRevision.value });
applySetupResponseMeta(data);
clearSetupValidationErrors();
markSetupSynced();
setupViewMode.value = "published";
setupSaveMeta.value = {
updatedAt: formatDisplayTime(data.updated_at),
savedBy: data.saved_by_name || resolveSavedByDisplay(data.saved_by),
serverSynced: true,
};
persistPublishedProjectSignatureToLocal();
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, "发布失败,请先修正配置")) {
return false;
}
if (e?.response?.status === 409) {
ElMessage.warning("发布失败:配置已更新,正在刷新最新版本");
await refreshSetupDraftFromServer();
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;
if (hasSetupDraftUnsavedChanges.value) {
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;
setupPublishedVersion.value = history.length ? (history[0].display_version || history[0].version) : 0;
} catch (e: any) {
ElMessage.error(getApiErrorMessage(e, "获取版本历史失败"));
} finally {
rollbackDialogLoading.value = false;
}
};
const getDisplayVersion = (rawVersion: number): number => {
const found = setupVersionHistory.value.find((item) => item.version === rawVersion);
if (!found) return rawVersion;
return found.display_version || found.version;
};
const isLatestPublishedVersion = (rawVersion: number): boolean => {
return setupVersionHistory.value.length > 0 && setupVersionHistory.value[0].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 = getDisplayVersion(targetVersion);
try {
await ElMessageBox.confirm(`将当前草稿回滚到发布版本 v${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();
setupViewMode.value = "draft";
setupSaveMeta.value = {
updatedAt: formatDisplayTime(data.updated_at),
savedBy: data.saved_by_name || resolveSavedByDisplay(data.saved_by),
serverSynced: true,
};
rollbackDialogVisible.value = false;
ElMessage.success(`已回滚到版本 v${targetDisplayVersion}`);
} catch (e: any) {
if (e?.response?.status === 409) {
ElMessage.warning("回滚失败:版本冲突,正在刷新最新配置");
await refreshSetupDraftFromServer();
await loadSetupVersionHistory();
return;
}
ElMessage.error(getApiErrorMessage(e, "版本回滚失败"));
} finally {
rollbackDialogLoading.value = false;
}
};
const downloadVersionRaw = (versionItem: StudySetupConfigVersionItem) => {
if (!project.value) return;
const displayVersion = getDisplayVersion(versionItem.version);
const payload = {
study_id: versionItem.study_id,
version: versionItem.version,
display_version: displayVersion,
published_by: versionItem.published_by || null,
published_by_name: versionItem.published_by_name || null,
published_at: versionItem.published_at || null,
config: versionItem.config,
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json;charset=utf-8" });
const url = URL.createObjectURL(blob);
const filename = `setup-config-raw-v${displayVersion}-${project.value.code || project.value.id}.json`;
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 = getDisplayVersion(targetVersion);
try {
await ElMessageBox.confirm(`确认删除发布版本 v${displayVersion} 的快照?此操作不可恢复。`, "删除确认", {
type: "warning",
confirmButtonText: "确认删除",
cancelButtonText: "取消",
});
} catch {
return;
}
rollbackDialogLoading.value = true;
try {
await deleteSetupVersion(project.value.id, targetVersion);
await loadSetupVersionHistory();
ElMessage.success(`已删除发布版本 v${displayVersion}`);
} catch (e: any) {
ElMessage.error(getApiErrorMessage(e, "删除版本失败"));
} finally {
rollbackDialogLoading.value = false;
}
};
const handlePrimarySaveConfig = async () => {
if (!canSaveDraftAction.value) 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 = () => {
if (!project.value) return;
studyStore.setCurrentStudy({ ...project.value, role_in_study: project.value.role_in_study || "PM" } as Study);
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 addMonitoringStrategy = () => {
if (!canMutateDraft()) return;
setupDraft.monitoringStrategies.push({ id: makeId(), strategyType: "", detail: "", frequency: "", updatedAt: nowString(), enabled: true });
};
const applyMonitoringStrategyTemplate = (templateKey: string) => {
if (!canMutateDraft()) return;
const template = monitoringStrategyTemplates.find((item) => item.key === templateKey);
if (!template) {
ElMessage.warning("未找到对应模板");
return;
}
setupDraft.monitoringStrategies = template.rows.map((item) => ({
id: makeId(),
strategyType: item.strategyType,
detail: item.detail,
frequency: item.frequency,
updatedAt: nowString(),
enabled: true,
}));
ElMessage.success(`已应用模板:${template.label}`);
};
const onMonitoringStrategyTemplateSelect = (command: string | number | object) => {
applyMonitoringStrategyTemplate(String(command));
};
const removeMonitoringStrategy = (index: number) => {
if (!canMutateDraft()) return;
setupDraft.monitoringStrategies.splice(index, 1);
};
const openMonitoringStrategyEditor = (index: number) => {
openIndexedEditor(setupDraft.monitoringStrategies, index, monitoringStrategyEditor, (row) => {
monitoringStrategyEditorForm.id = row.id || "";
monitoringStrategyEditorForm.strategyType = row.strategyType || "";
monitoringStrategyEditorForm.detail = row.detail || "";
monitoringStrategyEditorForm.frequency = row.frequency || "";
monitoringStrategyEditorForm.updatedAt = row.updatedAt || "";
monitoringStrategyEditorForm.enabled = row.enabled ?? true;
});
};
const saveMonitoringStrategyEditor = () => {
if (!canMutateDraft()) return;
const index = getEditingIndex(monitoringStrategyEditor, setupDraft.monitoringStrategies.length);
if (index < 0) return;
if (!(monitoringStrategyEditorForm.strategyType || "").trim()) {
ElMessage.warning("请选择监查类型");
return;
}
if (!(monitoringStrategyEditorForm.detail || "").trim()) {
ElMessage.warning("请填写策略详情");
return;
}
if (!(monitoringStrategyEditorForm.frequency || "").trim()) {
ElMessage.warning("请填写监查次数");
return;
}
setupDraft.monitoringStrategies[index] = {
id: monitoringStrategyEditorForm.id || makeId(),
strategyType: monitoringStrategyEditorForm.strategyType.trim(),
detail: monitoringStrategyEditorForm.detail.trim(),
frequency: monitoringStrategyEditorForm.frequency.trim(),
updatedAt: nowString(),
enabled: monitoringStrategyEditorForm.enabled ?? true,
};
closeIndexedEditor(monitoringStrategyEditor, "监查策略已更新");
};
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 openCenterConfirmEditor = (index: number) => {
openIndexedEditor(setupDraft.centerConfirm, index, centerConfirmEditor, (row) => {
centerConfirmEditorForm.id = row.id || "";
centerConfirmEditorForm.siteId = row.siteId || "";
centerConfirmEditorForm.siteName = row.siteName || "";
centerConfirmEditorForm.confirmer = row.confirmer || "";
centerConfirmEditorForm.confirmStatus = row.confirmStatus || "待确认";
centerConfirmEditorForm.confirmDate = row.confirmDate || "";
centerConfirmEditorForm.note = row.note || "";
});
};
const saveCenterConfirmEditor = () => {
if (!canMutateDraft()) return;
const index = getEditingIndex(centerConfirmEditor, setupDraft.centerConfirm.length);
if (index < 0) return;
if (!centerConfirmEditorForm.siteId) {
ElMessage.warning("请选择中心");
return;
}
if (!(centerConfirmEditorForm.confirmStatus || "").trim()) {
ElMessage.warning("请选择确认状态");
return;
}
if (centerConfirmEditorForm.confirmStatus === "已确认" && !centerConfirmEditorForm.confirmDate) {
ElMessage.warning("已确认状态需要填写确认日期");
return;
}
if (centerConfirmEditorForm.confirmStatus === "退回" && !(centerConfirmEditorForm.note || "").trim()) {
ElMessage.warning("退回状态请填写备注");
return;
}
const { start: planStart, end: planEnd } = getProjectPlanWindow();
const confirmDate = normalizePlanDate(centerConfirmEditorForm.confirmDate);
if (confirmDate && planStart && confirmDate < planStart) {
ElMessage.warning("中心确认日期不能早于项目计划开始日期");
return;
}
if (confirmDate && planEnd && confirmDate > planEnd) {
ElMessage.warning("中心确认日期不能晚于项目计划结束日期");
return;
}
setupDraft.centerConfirm[index] = {
id: centerConfirmEditorForm.id || makeId(),
siteId: centerConfirmEditorForm.siteId,
siteName: getSiteName(centerConfirmEditorForm.siteId),
confirmer: (centerConfirmEditorForm.confirmer || "").trim(),
confirmStatus: centerConfirmEditorForm.confirmStatus,
confirmDate: centerConfirmEditorForm.confirmDate || "",
note: (centerConfirmEditorForm.note || "").trim(),
};
closeIndexedEditor(centerConfirmEditor, "中心确认已更新");
};
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("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: 1px solid #d8e2ef;
border-radius: 16px;
background: #ffffff;
overflow: hidden;
box-shadow: 0 6px 20px rgba(17, 42, 88, 0.05);
}
.setup-header {
position: relative;
z-index: 1250;
background: linear-gradient(105deg, #091633 0%, #163d9a 52%, #2f8ce8 100%);
border-bottom: 1px solid rgba(199, 218, 255, 0.2);
padding: 12px 14px;
}
.setup-flow {
display: flex;
align-items: center;
justify-content: space-between;
overflow: hidden;
padding-bottom: 2px;
gap: 0;
}
.flow-step {
display: flex;
align-items: center;
flex: 1 1 0;
min-width: 0;
white-space: nowrap;
cursor: pointer;
color: rgba(226, 240, 255, 0.86);
}
.flow-index {
width: 24px;
height: 24px;
border-radius: 50%;
border: 1.5px solid rgba(220, 236, 255, 0.92);
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
font-size: 12px;
font-weight: 700;
color: #ffffff;
flex: 0 0 auto;
}
.flow-label {
font-size: 13px;
line-height: 1;
font-weight: 600;
color: inherit;
flex: 0 1 auto;
}
.flow-done-icon {
margin-left: 4px;
color: #22c55e;
font-size: 13px;
flex: 0 0 auto;
}
.flow-connector {
width: auto;
min-width: 8px;
flex: 1 1 auto;
height: 2px;
background: rgba(206, 227, 255, 0.55);
margin: 0 6px;
border-radius: 999px;
}
.flow-step.active {
color: #ffffff;
}
.flow-step.has-error .flow-index {
border-color: #ff8d8d;
color: #ffd1d1;
}
.flow-step.active .flow-index {
background: rgba(255, 255, 255, 0.2);
border-color: #ffffff;
color: #ffffff;
}
.flow-step.done {
color: #ffffff;
}
.flow-step.done .flow-index {
border-color: #d8e9ff;
color: #d8e9ff;
}
.setup-toolbar {
margin-top: 4px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.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-switcher {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 14px;
background: rgba(11, 30, 75, 0.45);
border: 1px solid rgba(187, 217, 255, 0.28);
}
.setup-switcher-wrap {
display: flex;
flex-direction: column;
gap: 4px;
}
.setup-save-meta {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
font-size: 12px;
color: rgba(226, 240, 255, 0.9);
padding-left: 4px;
}
.setup-save-meta .meta-text {
color: rgba(226, 240, 255, 0.9);
}
.setup-view-mode {
padding-left: 2px;
}
.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: 7px;
flex-wrap: wrap;
}
.setup-toolbar-actions :deep(.el-button) {
border-radius: 10px;
}
.setup-toolbar-actions :deep(.el-button--primary),
.setup-toolbar-actions :deep(.el-button--success.is-plain),
.setup-toolbar-actions :deep(.el-button--warning.is-plain),
.setup-toolbar-actions :deep(.el-button--info.is-plain) {
background: rgba(11, 30, 75, 0.42);
border-color: rgba(167, 199, 255, 0.52);
color: #ffffff;
}
.setup-toolbar-actions :deep(.el-button--primary:hover),
.setup-toolbar-actions :deep(.el-button--success.is-plain:hover),
.setup-toolbar-actions :deep(.el-button--warning.is-plain:hover),
.setup-toolbar-actions :deep(.el-button--info.is-plain:hover) {
background: rgba(26, 62, 147, 0.66);
color: #ffffff;
}
.secondary-menu-btn {
border-color: rgba(167, 199, 255, 0.52);
background: rgba(12, 36, 92, 0.25);
color: #ffffff;
}
.setup-section {
padding: 8px 12px 12px;
border-bottom: 1px solid #edf2f8;
background: #ffffff;
}
.enrollment-plan-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.enrollment-plan-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 18px;
}
.enrollment-inline-group {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.enrollment-inline-label {
font-size: 13px;
font-weight: 600;
color: #4a6283;
background: #f3f6fb;
padding: 4px 10px;
border-radius: 6px;
height: 28px;
display: inline-flex;
align-items: center;
border: 1px solid #e6edf7;
}
.enrollment-inline-label.required::before {
content: "*";
color: #d14141;
margin-right: 4px;
}
.enrollment-date-cell {
min-width: auto;
}
.enrollment-date-cell :deep(.el-date-editor) {
min-width: 148px;
}
.enrollment-target-cell {
min-width: 112px;
}
.enrollment-inline-value {
font-size: 14px;
font-weight: 600;
color: #11264d;
}
.enrollment-sep {
color: #8da3c7;
font-weight: 600;
align-self: center;
display: inline-flex;
align-items: center;
height: 32px;
line-height: 32px;
flex-shrink: 0;
}
.enrollment-plan-summary {
border: 1px solid #e2ebf8;
border-radius: 10px;
background: #f8fbff;
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.enrollment-summary-line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
font-size: 13px;
color: #38537b;
}
.summary-site-name {
font-weight: 700;
color: #11264d;
}
.summary-title {
font-weight: 700;
color: #284a7b;
}
.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;
}
.enrollment-site-filter-row {
justify-content: flex-start;
}
.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: #b3bfd5;
}
.site-plan-site-select {
width: 320px;
max-width: 100%;
}
.setup-content {
position: relative;
}
.setup-content-step-title {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 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: 8px;
}
.setup-content-step-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
.setup-content-step-actions :deep(.step-action-btn) {
min-width: 88px;
border-radius: 10px;
font-weight: 600;
}
.setup-content-step-actions :deep(.step-action-btn.el-button--primary) {
background: #3f617d;
border-color: #3f617d;
color: #ffffff;
}
.setup-content-step-actions :deep(.step-action-btn.el-button--primary:hover) {
background: #355472;
border-color: #355472;
color: #ffffff;
}
.setup-content-step-actions :deep(.step-action-btn-cancel) {
background: #f3f6fb;
border-color: #d7e1ee;
color: #4a6283;
}
.setup-content-step-actions :deep(.step-action-btn-cancel:hover) {
background: #e8eff8;
border-color: #c7d5e8;
color: #3b5476;
}
.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: #ffffff !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: 10px;
border: 1px solid #e6edf7;
border-radius: 10px;
background: #fcfdff;
}
.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-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: 10px 24px;
}
.basic-info-groups {
display: flex;
flex-direction: column;
gap: 10px;
}
.info-group {
padding: 16px 20px;
background: #ffffff;
border: 1px solid #eef3fb;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(22, 47, 86, 0.03);
}
.info-group + .info-group {
margin-top: 14px;
}
.info-group-title {
font-size: 14px;
font-weight: 700;
color: #284a7a;
margin-bottom: 12px;
}
.info-group-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.info-group-edit-btn {
min-width: 72px;
border-radius: 10px;
background: #3f617d;
border-color: #3f617d;
color: #ffffff;
font-size: 14px;
font-weight: 600;
}
.info-group-edit-btn:deep(span) {
font-size: 14px;
line-height: 1;
color: #ffffff !important;
opacity: 1 !important;
}
.info-group-edit-btn:hover,
.info-group-edit-btn:focus {
background: #355472;
border-color: #355472;
color: #ffffff;
}
.info-group-edit-btn.is-disabled,
.info-group-edit-btn.is-disabled:hover {
background: #94a3b8;
border-color: #94a3b8;
color: #ffffff;
}
.basic-form-group-title {
margin: 0 0 14px;
font-size: 14px;
font-weight: 700;
color: #284a7a;
}
.basic-edit-group {
padding: 18px 20px 4px;
background: #f8fbff;
border: 1px solid #e6edf7;
border-radius: 12px;
margin-bottom: 16px;
}
.basic-edit-group + .basic-edit-group {
margin-top: 0;
padding-top: 18px;
border-top: 1px solid #e6edf7;
}
.info-item {
display: flex;
align-items: flex-start;
gap: 12px;
min-height: 28px;
padding: 6px 0;
}
.info-label {
color: #5f7499;
min-width: 112px;
font-size: 14px;
background: #f4f8ff;
padding: 4px 10px;
border-radius: 6px;
display: inline-flex;
align-items: center;
}
.info-value {
color: #11264d;
font-weight: 600;
font-size: 15px;
word-break: break-word;
padding-top: 4px;
}
.setup-info-tabs {
margin-top: -8px;
}
.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) {
border-radius: 10px;
overflow: hidden;
border: 1px solid #eef3fb;
}
.setup-section :deep(.el-table th.el-table__cell) {
background: #f8fbff;
color: #35527d;
font-weight: 600;
}
.setup-section :deep(.el-table td.el-table__cell) {
border-bottom-color: #edf2f8;
}
.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: 12px;
font-weight: 700;
color: #000;
}
.milestone-cell-text {
display: inline-block;
font-size: 12px;
font-weight: 400;
color: #000;
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;
}
.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;
}
.conflict-readable-diff :deep(.el-table th.el-table__cell) {
background: #f7faff;
color: #2f4672;
}
@media (max-width: 960px) {
.setup-switcher {
width: 100%;
}
.setup-toolbar-actions {
width: 100%;
}
.info-grid {
grid-template-columns: 1fr;
}
.flow-label {
font-size: 14px;
}
.switcher-title {
font-size: 16px;
}
}
</style>