5365 lines
189 KiB
Vue
5365 lines
189 KiB
Vue
<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>
|