项目里程碑初步优化
This commit is contained in:
@@ -1,13 +1,728 @@
|
||||
<template>
|
||||
<ModulePlaceholder
|
||||
:title="TEXT.modules.projectMilestones.title"
|
||||
:subtitle="TEXT.modules.projectMilestones.subtitle"
|
||||
:list-title="TEXT.modules.projectMilestones.listTitle"
|
||||
:empty-description="TEXT.modules.projectMilestones.emptyDescription"
|
||||
/>
|
||||
<div class="page">
|
||||
<div v-if="study.currentStudy" class="unified-shell">
|
||||
<section class="unified-section">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<div class="card-title">{{ TEXT.modules.projectMilestones.listTitle }}</div>
|
||||
<div class="card-subtitle">
|
||||
{{ TEXT.modules.projectMilestones.sourceLabel }}{{ dataSourceLabel }}
|
||||
<span class="dot-sep">·</span>
|
||||
{{ TEXT.common.labels.updatedAt }}{{ updatedAtLabel }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-tag size="small" effect="plain">
|
||||
{{ TEXT.modules.projectMilestones.statTotal }}{{ rows.length }}
|
||||
</el-tag>
|
||||
<el-tag size="small" type="success" effect="plain">
|
||||
{{ TEXT.modules.projectMilestones.statDone }}{{ doneCount }}
|
||||
</el-tag>
|
||||
<el-button size="small" @click="loadMilestones">{{ TEXT.common.actions.refresh }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="rows" v-loading="loading" class="ctms-table" style="width: 100%">
|
||||
<el-table-column prop="name" :label="TEXT.modules.projectMilestones.columns.name" min-width="140" />
|
||||
<el-table-column prop="planDate" :label="TEXT.modules.projectMilestones.columns.planDate" min-width="170">
|
||||
<template #default="scope">
|
||||
<div class="plan-time-cell">
|
||||
<div class="plan-time-line">
|
||||
<span class="plan-time-dot plan-time-dot-initial" />
|
||||
<span class="plan-time-label">开始:</span>
|
||||
<span>{{ scope.row.startDate || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
<div class="plan-time-line">
|
||||
<span class="plan-time-dot plan-time-dot-initial" />
|
||||
<span class="plan-time-label">结束:</span>
|
||||
<span>{{ scope.row.endDate || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
<div class="plan-time-line">
|
||||
<span class="plan-time-dot plan-time-dot-initial" />
|
||||
<span class="plan-time-label">耗时:</span>
|
||||
<span>{{ scope.row.durationText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.projectMilestones.columns.adjustedPlanDate" min-width="170">
|
||||
<template #default="scope">
|
||||
<div class="plan-time-cell">
|
||||
<div class="plan-time-line">
|
||||
<span class="plan-time-dot plan-time-dot-adjusted" />
|
||||
<span class="plan-time-label">开始:</span>
|
||||
<span>{{ scope.row.adjustedStartDate || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
<div class="plan-time-line">
|
||||
<span class="plan-time-dot plan-time-dot-adjusted" />
|
||||
<span class="plan-time-label">结束:</span>
|
||||
<span>{{ scope.row.adjustedEndDate || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
<div class="plan-time-line">
|
||||
<span class="plan-time-dot plan-time-dot-adjusted" />
|
||||
<span class="plan-time-label">耗时:</span>
|
||||
<span>{{ scope.row.adjustedDurationText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.projectMilestones.columns.actualTime" min-width="170">
|
||||
<template #default="scope">
|
||||
<div class="plan-time-cell">
|
||||
<div class="plan-time-line">
|
||||
<span class="plan-time-dot plan-time-dot-actual" />
|
||||
<span class="plan-time-label">开始:</span>
|
||||
<span>{{ scope.row.actualStartDate || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
<div class="plan-time-line">
|
||||
<span class="plan-time-dot plan-time-dot-actual" />
|
||||
<span class="plan-time-label">结束:</span>
|
||||
<span>{{ scope.row.actualEndDate || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
<div class="plan-time-line">
|
||||
<span class="plan-time-dot plan-time-dot-actual" />
|
||||
<span class="plan-time-label">耗时:</span>
|
||||
<span>{{ scope.row.actualDurationText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.modules.projectMilestones.columns.status" min-width="120">
|
||||
<template #default="scope">
|
||||
<div class="status-cell" :class="`status-${getStatusView(scope.row).tone}`">
|
||||
<div class="status-main">
|
||||
<span class="status-dot" />
|
||||
<span>{{ getStatusView(scope.row).title }}<span v-if="getStatusView(scope.row).detail">,</span></span>
|
||||
</div>
|
||||
<div v-if="getStatusView(scope.row).detail" class="status-detail">{{ getStatusView(scope.row).detail }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" :label="TEXT.modules.projectMilestones.columns.remark" min-width="100">
|
||||
<template #default="scope">{{ scope.row.remark || TEXT.common.fallback }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.projectMilestones.columns.operations" width="84" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button link type="primary" @click="openEditor(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<StateEmpty
|
||||
v-if="!loading && rows.length === 0"
|
||||
:description="TEXT.modules.projectMilestones.emptyDescription"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
|
||||
|
||||
<el-drawer
|
||||
v-model="editorVisible"
|
||||
direction="rtl"
|
||||
size="460px"
|
||||
:close-on-click-modal="false"
|
||||
:show-close="false"
|
||||
class="milestone-editor-drawer"
|
||||
>
|
||||
<template #header>
|
||||
<div class="time-editor-title">编辑里程碑</div>
|
||||
</template>
|
||||
<el-form label-position="top" class="time-editor-form">
|
||||
<div class="time-editor-group">
|
||||
<div class="time-editor-group-title">
|
||||
<span class="group-dot group-dot-adjusted"></span>
|
||||
调整计划时间
|
||||
</div>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="开始日期">
|
||||
<el-date-picker v-model="editorForm.adjustedStartDate" 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="editorForm.adjustedEndDate" type="date" value-format="YYYY-MM-DD" class="w-full" placeholder="选择日期" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div class="duration-inline">
|
||||
<span class="duration-label">耗时</span>
|
||||
<span class="duration-value">{{ toDurationText(calcDurationDays(editorForm.adjustedStartDate, editorForm.adjustedEndDate)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="time-editor-group">
|
||||
<div class="time-editor-group-title">
|
||||
<span class="group-dot group-dot-actual"></span>
|
||||
实际时间
|
||||
</div>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="开始日期">
|
||||
<el-date-picker v-model="editorForm.actualStartDate" 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="editorForm.actualEndDate" type="date" value-format="YYYY-MM-DD" class="w-full" placeholder="选择日期" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div class="duration-inline">
|
||||
<span class="duration-label">耗时</span>
|
||||
<span class="duration-value">{{ toDurationText(calcDurationDays(editorForm.actualStartDate, editorForm.actualEndDate)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="time-editor-group">
|
||||
<div class="time-editor-group-title">
|
||||
<span class="group-dot group-dot-remark"></span>
|
||||
备注
|
||||
</div>
|
||||
<el-input v-model="editorForm.remark" type="textarea" :rows="3" placeholder="请输入备注" />
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="time-editor-footer">
|
||||
<el-button @click="editorVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button type="primary" @click="saveEditor">{{ TEXT.common.actions.save }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { fetchStudySetupConfig } from "../../api/studies";
|
||||
import { TEXT } from "../../locales";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
import { displayDateTime } from "../../utils/display";
|
||||
import type { ProjectMilestoneDraft, StudySetupConfigResponse } from "../../types/setupConfig";
|
||||
|
||||
interface MilestoneRow {
|
||||
id: string;
|
||||
name: string;
|
||||
planDate: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
durationDays: number | null;
|
||||
durationText: string;
|
||||
adjustedStartDate: string;
|
||||
adjustedEndDate: string;
|
||||
adjustedDurationDays: number | null;
|
||||
adjustedDurationText: string;
|
||||
actualStartDate: string;
|
||||
actualEndDate: string;
|
||||
actualDurationDays: number | null;
|
||||
actualDurationText: string;
|
||||
owner: string;
|
||||
status: string;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
type MilestoneLocalEdit = {
|
||||
adjustedStartDate?: string;
|
||||
adjustedEndDate?: string;
|
||||
actualStartDate?: string;
|
||||
actualEndDate?: string;
|
||||
remark?: string;
|
||||
};
|
||||
|
||||
const study = useStudyStore();
|
||||
const rows = ref<MilestoneRow[]>([]);
|
||||
const loading = ref(false);
|
||||
const dataSourceLabel = ref<string>(TEXT.modules.projectMilestones.sourceDraft);
|
||||
const updatedAtLabel = ref<string>(TEXT.common.fallback);
|
||||
const editorVisible = ref(false);
|
||||
const editingRowId = ref("");
|
||||
const localEdits = ref<Record<string, MilestoneLocalEdit>>({});
|
||||
const editorForm = reactive<MilestoneLocalEdit>({
|
||||
adjustedStartDate: "",
|
||||
adjustedEndDate: "",
|
||||
actualStartDate: "",
|
||||
actualEndDate: "",
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const doneCount = computed(() => rows.value.filter((item) => getStatusView(item).completed).length);
|
||||
|
||||
const getLocalEditKey = (studyId: string) => `ctms_project_milestones_edits_${studyId}`;
|
||||
|
||||
const toDateOnly = (value?: string): Date | null => {
|
||||
if (!value) return null;
|
||||
const d = new Date(`${value}T00:00:00`);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return d;
|
||||
};
|
||||
|
||||
const calcDurationDays = (start?: string, end?: string): number | null => {
|
||||
if (!start || !end) return null;
|
||||
const startDate = toDateOnly(start);
|
||||
const endDate = toDateOnly(end);
|
||||
if (!startDate || !endDate || endDate < startDate) return null;
|
||||
return Math.floor((endDate.getTime() - startDate.getTime()) / 86400000) + 1;
|
||||
};
|
||||
|
||||
const toDurationText = (days: number | null) => (days && days > 0 ? `${days}天` : TEXT.common.fallback);
|
||||
const toPositiveInt = (value: unknown): number | null => {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
return Math.floor(n);
|
||||
};
|
||||
|
||||
const applyEditToRow = (row: MilestoneRow): MilestoneRow => {
|
||||
const patch = localEdits.value[row.id] || {};
|
||||
const adjustedStartDate = String(patch.adjustedStartDate ?? row.adjustedStartDate ?? "").trim();
|
||||
const adjustedEndDate = String(patch.adjustedEndDate ?? row.adjustedEndDate ?? "").trim();
|
||||
const actualStartDate = String(patch.actualStartDate ?? row.actualStartDate ?? "").trim();
|
||||
const actualEndDate = String(patch.actualEndDate ?? row.actualEndDate ?? "").trim();
|
||||
const adjustedDurationDays = calcDurationDays(adjustedStartDate, adjustedEndDate);
|
||||
const actualDurationDays = calcDurationDays(actualStartDate, actualEndDate);
|
||||
return {
|
||||
...row,
|
||||
adjustedStartDate,
|
||||
adjustedEndDate,
|
||||
adjustedDurationDays,
|
||||
adjustedDurationText: toDurationText(adjustedDurationDays),
|
||||
actualStartDate,
|
||||
actualEndDate,
|
||||
actualDurationDays,
|
||||
actualDurationText: toDurationText(actualDurationDays),
|
||||
remark: String(patch.remark ?? row.remark ?? "").trim(),
|
||||
};
|
||||
};
|
||||
|
||||
const loadLocalEdits = () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
try {
|
||||
const raw = localStorage.getItem(getLocalEditKey(studyId));
|
||||
localEdits.value = raw ? (JSON.parse(raw) as Record<string, MilestoneLocalEdit>) : {};
|
||||
} catch {
|
||||
localEdits.value = {};
|
||||
}
|
||||
};
|
||||
|
||||
const persistLocalEdits = () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
localStorage.setItem(getLocalEditKey(studyId), JSON.stringify(localEdits.value));
|
||||
};
|
||||
|
||||
const normalizeRows = (items: ProjectMilestoneDraft[]) =>
|
||||
items
|
||||
.map((item, index) => {
|
||||
const planDate = String(item.planDate || "").trim();
|
||||
const startDate = String(item.startDate || planDate).trim();
|
||||
const endDate = String(item.endDate || planDate).trim();
|
||||
const adjustedStartDate = String((item as any).adjustedStartDate || "").trim();
|
||||
const adjustedEndDate = String((item as any).adjustedEndDate || "").trim();
|
||||
const actualStartDate = String((item as any).actualStartDate || "").trim();
|
||||
const actualEndDate = String((item as any).actualEndDate || "").trim();
|
||||
const rawDays = toPositiveInt(item.durationDays);
|
||||
const adjustedRawDays = toPositiveInt((item as any).adjustedDurationDays) ?? calcDurationDays(adjustedStartDate, adjustedEndDate);
|
||||
const actualRawDays = toPositiveInt((item as any).actualDurationDays) ?? calcDurationDays(actualStartDate, actualEndDate);
|
||||
const durationDays = rawDays ?? (startDate || endDate ? 1 : null);
|
||||
const adjustedDurationDays = adjustedRawDays ?? (adjustedStartDate || adjustedEndDate ? 1 : null);
|
||||
const actualDurationDays = actualRawDays ?? (actualStartDate || actualEndDate ? 1 : null);
|
||||
return {
|
||||
id: item.id || `setup-project-milestone-${index + 1}`,
|
||||
name: String(item.name || "").trim(),
|
||||
planDate,
|
||||
startDate,
|
||||
endDate,
|
||||
durationDays,
|
||||
durationText: durationDays ? `${durationDays}天` : TEXT.common.fallback,
|
||||
adjustedStartDate,
|
||||
adjustedEndDate,
|
||||
adjustedDurationDays,
|
||||
adjustedDurationText: adjustedDurationDays ? `${adjustedDurationDays}天` : TEXT.common.fallback,
|
||||
actualStartDate,
|
||||
actualEndDate,
|
||||
actualDurationDays,
|
||||
actualDurationText: actualDurationDays ? `${actualDurationDays}天` : TEXT.common.fallback,
|
||||
owner: String(item.owner || "").trim(),
|
||||
status: String(item.status || "未开始").trim(),
|
||||
remark: String(item.remark || "").trim(),
|
||||
};
|
||||
})
|
||||
.filter((item) => item.name || item.startDate || item.endDate || item.owner || item.remark);
|
||||
|
||||
const pickProjectMilestones = (setup: StudySetupConfigResponse): ProjectMilestoneDraft[] => {
|
||||
const publishedRows = normalizeRows(setup.published_data?.projectMilestones || []);
|
||||
if (publishedRows.length > 0) {
|
||||
dataSourceLabel.value = TEXT.modules.projectMilestones.sourcePublished;
|
||||
return setup.published_data?.projectMilestones || [];
|
||||
}
|
||||
dataSourceLabel.value = setup.published_data ? TEXT.modules.projectMilestones.sourceDraftFallback : TEXT.modules.projectMilestones.sourceDraft;
|
||||
return setup.data?.projectMilestones || [];
|
||||
};
|
||||
|
||||
const loadMilestones = async () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await fetchStudySetupConfig(studyId);
|
||||
loadLocalEdits();
|
||||
rows.value = normalizeRows(pickProjectMilestones(data)).map(applyEditToRow);
|
||||
updatedAtLabel.value = displayDateTime(data.published_at || data.updated_at);
|
||||
} catch (error: any) {
|
||||
rows.value = [];
|
||||
updatedAtLabel.value = TEXT.common.fallback;
|
||||
ElMessage.error(error?.response?.data?.detail || TEXT.common.messages.loadFailed);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const daysDiff = (start: string, end: string): number => {
|
||||
const a = toDateOnly(start);
|
||||
const b = toDateOnly(end);
|
||||
if (!a || !b) return 0;
|
||||
return Math.floor((b.getTime() - a.getTime()) / 86400000);
|
||||
};
|
||||
|
||||
const todayDateText = () => {
|
||||
const now = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||
};
|
||||
|
||||
const getStatusView = (row: MilestoneRow): { title: string; detail: string; tone: "success" | "warning" | "danger" | "info"; completed: boolean } => {
|
||||
const planEnd = row.adjustedEndDate || row.endDate || "";
|
||||
const actualStart = row.actualStartDate || "";
|
||||
const actualEnd = row.actualEndDate || "";
|
||||
const today = todayDateText();
|
||||
|
||||
if (actualEnd) {
|
||||
if (planEnd && actualEnd > planEnd) {
|
||||
const lateDays = daysDiff(planEnd, actualEnd);
|
||||
return {
|
||||
title: "延期完成",
|
||||
detail: lateDays > 0 ? `延迟 ${lateDays} 天` : "",
|
||||
tone: "danger",
|
||||
completed: true,
|
||||
};
|
||||
}
|
||||
if (planEnd && actualEnd < planEnd) {
|
||||
const earlyDays = daysDiff(actualEnd, planEnd);
|
||||
return {
|
||||
title: "提前完成",
|
||||
detail: earlyDays > 0 ? `提前 ${earlyDays} 天` : "",
|
||||
tone: "success",
|
||||
completed: true,
|
||||
};
|
||||
}
|
||||
return { title: "已完成", detail: "", tone: "success", completed: true };
|
||||
}
|
||||
|
||||
if (actualStart) {
|
||||
if (planEnd && today > planEnd) {
|
||||
const lateDays = daysDiff(planEnd, today);
|
||||
return {
|
||||
title: "延期进行中",
|
||||
detail: lateDays > 0 ? `延迟 ${lateDays} 天` : "",
|
||||
tone: "danger",
|
||||
completed: false,
|
||||
};
|
||||
}
|
||||
return { title: "进行中", detail: "", tone: "warning", completed: false };
|
||||
}
|
||||
|
||||
if (planEnd && today > planEnd) {
|
||||
const lateDays = daysDiff(planEnd, today);
|
||||
return {
|
||||
title: "延期未开始",
|
||||
detail: lateDays > 0 ? `延迟 ${lateDays} 天` : "",
|
||||
tone: "danger",
|
||||
completed: false,
|
||||
};
|
||||
}
|
||||
|
||||
return { title: "未开始", detail: "", tone: "info", completed: false };
|
||||
};
|
||||
|
||||
const validateDateRange = (start?: string, end?: string, label?: string) => {
|
||||
if ((start && !end) || (!start && end)) {
|
||||
ElMessage.warning(`${label}开始和结束日期需同时填写`);
|
||||
return false;
|
||||
}
|
||||
if (start && end && start > end) {
|
||||
ElMessage.warning(`${label}结束日期不能早于开始日期`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const openEditor = (row: MilestoneRow) => {
|
||||
editingRowId.value = row.id;
|
||||
editorForm.adjustedStartDate = row.adjustedStartDate || "";
|
||||
editorForm.adjustedEndDate = row.adjustedEndDate || "";
|
||||
editorForm.actualStartDate = row.actualStartDate || "";
|
||||
editorForm.actualEndDate = row.actualEndDate || "";
|
||||
editorForm.remark = row.remark || "";
|
||||
editorVisible.value = true;
|
||||
};
|
||||
|
||||
const saveEditor = () => {
|
||||
if (!editingRowId.value) return;
|
||||
if (!validateDateRange(editorForm.adjustedStartDate, editorForm.adjustedEndDate, "调整计划时间")) return;
|
||||
if (!validateDateRange(editorForm.actualStartDate, editorForm.actualEndDate, "实际时间")) return;
|
||||
const patch: MilestoneLocalEdit = {
|
||||
adjustedStartDate: editorForm.adjustedStartDate || "",
|
||||
adjustedEndDate: editorForm.adjustedEndDate || "",
|
||||
actualStartDate: editorForm.actualStartDate || "",
|
||||
actualEndDate: editorForm.actualEndDate || "",
|
||||
remark: (editorForm.remark || "").trim(),
|
||||
};
|
||||
localEdits.value = {
|
||||
...localEdits.value,
|
||||
[editingRowId.value]: patch,
|
||||
};
|
||||
persistLocalEdits();
|
||||
rows.value = rows.value.map((item) => (item.id === editingRowId.value ? applyEditToRow(item) : item));
|
||||
editorVisible.value = false;
|
||||
ElMessage.success("里程碑时间已更新");
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadMilestones();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => study.currentStudy?.id,
|
||||
() => {
|
||||
rows.value = [];
|
||||
localEdits.value = {};
|
||||
updatedAtLabel.value = TEXT.common.fallback;
|
||||
loadMilestones();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0f2345;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.dot-sep {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plan-time-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.plan-time-line {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--ctms-text-main);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.plan-time-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #2f84c6;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.plan-time-label {
|
||||
color: #4b5563;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.plan-time-dot-initial {
|
||||
border-color: #2f84c6;
|
||||
}
|
||||
|
||||
.plan-time-dot-adjusted {
|
||||
border-color: #f0ad2c;
|
||||
}
|
||||
|
||||
.plan-time-dot-actual {
|
||||
border-color: #67c23a;
|
||||
}
|
||||
|
||||
.time-editor-form {
|
||||
padding-top: 4px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.time-editor-group {
|
||||
border: 1px solid #e8eef6;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px 10px;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.time-editor-group + .time-editor-group {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.time-editor-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.time-editor-group-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
color: #1a3560;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.group-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.group-dot-adjusted {
|
||||
background: #f0ad2c;
|
||||
}
|
||||
|
||||
.group-dot-actual {
|
||||
background: #67c23a;
|
||||
}
|
||||
|
||||
.group-dot-remark {
|
||||
background: #909399;
|
||||
}
|
||||
|
||||
.duration-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0 2px;
|
||||
}
|
||||
|
||||
.duration-label {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.duration-value {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1a3560;
|
||||
}
|
||||
|
||||
.time-editor-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.time-editor-footer :deep(.el-button--primary) {
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.time-editor-form :deep(.el-form-item) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.time-editor-form :deep(.el-form-item__label) {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #4a6283;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.status-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
line-height: 1.3;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.status-main {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
.status-detail {
|
||||
padding-left: 14px;
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.status-success .status-dot {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.status-warning .status-dot {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.status-danger .status-dot {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.status-info .status-dot {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
:deep(.ctms-table th.el-table__cell .cell),
|
||||
:deep(.ctms-table td.el-table__cell .cell) {
|
||||
font-size: 11px;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<section class="unified-section">
|
||||
<div class="overview-meta-inline">
|
||||
<el-tag v-if="usingDemo" effect="plain" type="info" class="demo-tag">示例数据</el-tag>
|
||||
<div class="updated-at">更新:{{ nowLabel }}</div>
|
||||
<div class="updated-at">更新:{{ updatedAtLabel }}</div>
|
||||
<el-button size="small" @click="loadOverview">刷新</el-button>
|
||||
</div>
|
||||
<div class="card-header">
|
||||
@@ -61,7 +61,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { TEXT } from "../../locales";
|
||||
import { displayDateTime } from "../../utils/display";
|
||||
@@ -71,7 +71,7 @@ import StateEmpty from "../../components/StateEmpty.vue";
|
||||
import StateLoading from "../../components/StateLoading.vue";
|
||||
import CenterProgressRow from "./project-overview/CenterProgressRow.vue";
|
||||
import EnrollmentBarChart, { type EnrollmentBarItem } from "./project-overview/EnrollmentBarChart.vue";
|
||||
import { adaptProjectOverview, type ProjectOverviewViewModel } from "./project-overview/overview.adapter";
|
||||
import { adaptProjectOverview, type CenterOverview, type ProjectOverviewViewModel } from "./project-overview/overview.adapter";
|
||||
import { overviewMock } from "./project-overview/overview.mock";
|
||||
|
||||
const study = useStudyStore();
|
||||
@@ -79,26 +79,27 @@ const loading = ref(false);
|
||||
const usingDemo = ref(false);
|
||||
const overview = ref<ProjectOverviewViewModel | null>(null);
|
||||
const chartMode = ref<"center" | "month">("center");
|
||||
const updatedAtLabel = ref(displayDateTime(new Date()));
|
||||
|
||||
const preferApi = String(import.meta.env.VITE_USE_OVERVIEW_API || "").toLowerCase() === "true";
|
||||
|
||||
const centers = computed(() => overview.value?.centers || []);
|
||||
const nowLabel = ref(displayDateTime(new Date()));
|
||||
|
||||
let clockTimer: number | undefined;
|
||||
const startClock = () => {
|
||||
if (clockTimer) return;
|
||||
clockTimer = window.setInterval(() => {
|
||||
nowLabel.value = displayDateTime(new Date());
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const stopClock = () => {
|
||||
if (clockTimer) {
|
||||
window.clearInterval(clockTimer);
|
||||
clockTimer = undefined;
|
||||
}
|
||||
};
|
||||
const buildFallbackCentersFromSites = (siteList: any[]): CenterOverview[] =>
|
||||
siteList.map((site: any) => ({
|
||||
center_id: String(site?.id || ""),
|
||||
center_name: String(site?.name || TEXT.common.fallback),
|
||||
institution_initiation_status: "NOT_STARTED",
|
||||
ethics_status: "NOT_STARTED",
|
||||
contract_sign_status: "NOT_STARTED",
|
||||
startup_status: "NOT_STARTED",
|
||||
enrollment_status: "NOT_STARTED",
|
||||
inspection_status: "NOT_STARTED",
|
||||
closeout_status: "NOT_STARTED",
|
||||
enrollment_target: Number(site?.enrollment_target || 0),
|
||||
enrollment_actual: 0,
|
||||
is_active: site?.is_active !== false,
|
||||
}));
|
||||
|
||||
const enrollmentSummary = computed(() => {
|
||||
const summary = overview.value?.summary;
|
||||
@@ -140,11 +141,21 @@ const loadOverview = async () => {
|
||||
const { data } = await fetchProjectOverview(studyId);
|
||||
overview.value = adaptProjectOverview(data);
|
||||
usingDemo.value = false;
|
||||
updatedAtLabel.value = overview.value.updated_at
|
||||
? displayDateTime(overview.value.updated_at)
|
||||
: displayDateTime(new Date());
|
||||
} else {
|
||||
overview.value = adaptProjectOverview(overviewMock);
|
||||
usingDemo.value = true;
|
||||
updatedAtLabel.value = overview.value.updated_at
|
||||
? displayDateTime(overview.value.updated_at)
|
||||
: displayDateTime(new Date());
|
||||
}
|
||||
|
||||
|
||||
if (overview.value && overview.value.centers.length === 0 && siteList.length > 0) {
|
||||
overview.value.centers = buildFallbackCentersFromSites(siteList);
|
||||
}
|
||||
|
||||
if (overview.value) {
|
||||
overview.value.centers.forEach(c => {
|
||||
if (siteActiveMap.has(c.center_id)) {
|
||||
@@ -157,6 +168,9 @@ const loadOverview = async () => {
|
||||
} catch {
|
||||
overview.value = adaptProjectOverview(overviewMock);
|
||||
usingDemo.value = true;
|
||||
updatedAtLabel.value = overview.value.updated_at
|
||||
? displayDateTime(overview.value.updated_at)
|
||||
: displayDateTime(new Date());
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -165,15 +179,11 @@ const loadOverview = async () => {
|
||||
const reset = () => {
|
||||
overview.value = null;
|
||||
usingDemo.value = false;
|
||||
updatedAtLabel.value = displayDateTime(new Date());
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadOverview();
|
||||
startClock();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopClock();
|
||||
});
|
||||
|
||||
watch(
|
||||
|
||||
@@ -77,6 +77,7 @@ export const overviewMock: ProjectOverviewResponse = {
|
||||
center_name: "中心05",
|
||||
institution_initiation_status: "IN_PROGRESS",
|
||||
ethics_status: "IN_PROGRESS",
|
||||
contract_sign_status: "NOT_STARTED",
|
||||
startup_status: "NOT_STARTED",
|
||||
enrollment_status: "NOT_STARTED",
|
||||
inspection_status: "NOT_STARTED",
|
||||
|
||||
Reference in New Issue
Block a user