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