Files
ctms/frontend/src/views/ia/ProjectMilestones.vue
T
Cheng Zhou d5279b124f
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
release(main): 同步 dev 最新候选改动
2026-07-16 17:15:50 +08:00

697 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page ctms-page-shell page--flush">
<div v-if="study.currentStudy" class="unified-shell ctms-table-card">
<section class="unified-action-bar milestone-action-bar">
<div class="card-header ctms-page-header-row">
<div class="card-title">{{ TEXT.modules.projectMilestones.listTitle }}</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>
</section>
<section class="unified-section ctms-page-content-grid section--flush-x section--flush-top section--flush-bottom">
<el-table :data="rows" v-loading="loading" class="ctms-table" style="width: 100%" table-layout="fixed">
<el-table-column prop="name" :label="TEXT.modules.projectMilestones.columns.name" show-overflow-tooltip />
<el-table-column prop="planDate" :label="TEXT.modules.projectMilestones.columns.planDate">
<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">
<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">
<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">
<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" show-overflow-tooltip>
<template #default="scope">{{ scope.row.remark || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column v-if="canUpdateMilestones" :label="TEXT.modules.projectMilestones.columns.operations" width="100">
<template #default="scope">
<el-button v-if="canUpdateMilestones" link type="primary" @click="openEditor(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="table-empty">{{ TEXT.modules.projectMilestones.emptyDescription }}</div>
</template>
</el-table>
</section>
</div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
<el-drawer
v-if="editorVisible"
v-model="editorVisible"
direction="rtl"
size="460px"
:close-on-click-modal="true"
:before-close="editorDirtyGuard.beforeClose"
: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 v-if="canUpdateMilestones" type="primary" @click="saveEditor">{{ TEXT.common.actions.save }}</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study";
import { listProjectMilestones, updateProjectMilestone } from "../../api/projectMilestones";
import { TEXT } from "../../locales";
import StateEmpty from "../../components/StateEmpty.vue";
import { usePermission } from "../../utils/permission";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
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 { can } = usePermission();
const rows = ref<MilestoneRow[]>([]);
const loading = ref(false);
const editorVisible = ref(false);
const editingRowId = ref("");
const editorForm = reactive<MilestoneLocalEdit>({
adjustedStartDate: "",
adjustedEndDate: "",
actualStartDate: "",
actualEndDate: "",
remark: "",
});
const editorDirtyGuard = useDrawerDirtyGuard(() => editorForm);
const doneCount = computed(() => rows.value.filter((item) => getStatusView(item).completed).length);
const canUpdateMilestones = computed(() => can("project.milestones.update"));
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 normalizeRow = (row: any): MilestoneRow => {
const startDate = String(row?.planned_date || "").trim();
const endDate = String(row?.planned_date || "").trim();
const adjustedStartDate = String(row?.adjusted_start_date || "").trim();
const adjustedEndDate = String(row?.adjusted_end_date || "").trim();
const actualStartDate = String(row?.actual_start_date || "").trim();
const actualEndDate = String(row?.actual_end_date || "").trim();
const adjustedDurationDays = calcDurationDays(adjustedStartDate, adjustedEndDate);
const actualDurationDays = calcDurationDays(actualStartDate, actualEndDate);
return {
id: row?.id || "",
name: String(row?.name || "").trim(),
planDate: startDate,
startDate,
endDate,
durationDays: startDate ? 1 : null,
durationText: startDate ? "1天" : TEXT.common.fallback,
adjustedStartDate,
adjustedEndDate,
adjustedDurationDays,
adjustedDurationText: toDurationText(adjustedDurationDays),
actualStartDate,
actualEndDate,
actualDurationDays,
actualDurationText: toDurationText(actualDurationDays),
owner: String(row?.owner_name || "").trim(),
status: String(row?.status || "NOT_STARTED").trim(),
remark: String(row?.notes || "").trim(),
};
};
const loadMilestones = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
loading.value = true;
try {
const { data } = (await listProjectMilestones(studyId)) as any;
const list = Array.isArray(data) ? data : data?.items || [];
rows.value = list.map(normalizeRow);
} catch (error: any) {
rows.value = [];
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) => {
if (!canUpdateMilestones.value) {
ElMessage.warning("权限不足");
return;
}
editingRowId.value = row.id;
editorForm.adjustedStartDate = row.adjustedStartDate || "";
editorForm.adjustedEndDate = row.adjustedEndDate || "";
editorForm.actualStartDate = row.actualStartDate || "";
editorForm.actualEndDate = row.actualEndDate || "";
editorForm.remark = row.remark || "";
editorDirtyGuard.syncBaseline();
editorVisible.value = true;
};
const saveEditor = async () => {
const studyId = study.currentStudy?.id;
if (!studyId || !editingRowId.value) return;
if (!canUpdateMilestones.value) {
ElMessage.warning("权限不足");
return;
}
if (!validateDateRange(editorForm.adjustedStartDate, editorForm.adjustedEndDate, "调整计划时间")) return;
if (!validateDateRange(editorForm.actualStartDate, editorForm.actualEndDate, "实际时间")) return;
const patch = {
adjusted_start_date: editorForm.adjustedStartDate || null,
adjusted_end_date: editorForm.adjustedEndDate || null,
actual_start_date: editorForm.actualStartDate || null,
actual_end_date: editorForm.actualEndDate || null,
notes: (editorForm.remark || "").trim() || null,
};
try {
await updateProjectMilestone(studyId, editingRowId.value, patch);
await loadMilestones();
editorVisible.value = false;
ElMessage.success("里程碑时间已更新");
} catch (error: any) {
ElMessage.error(error?.response?.data?.detail || TEXT.common.messages.saveFailed);
}
};
onMounted(() => {
loadMilestones();
});
watch(
() => study.currentStudy?.id,
() => {
rows.value = [];
loadMilestones();
}
);
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
/* 撑满父容器(desktop-route-shell 已经是 height: 100%*/
height: 100%;
min-height: 0;
}
/* 让白色卡片壳撑满 .page 的剩余高度,并去掉圆角 */
.page > .unified-shell {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
border-radius: 0;
}
/* 让表格 section 撑满卡片壳的剩余高度 */
.page > .unified-shell > .unified-section {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* el-table 本身填满 section */
.page > .unified-shell > .unified-section :deep(.el-table),
.page > .unified-shell > .unified-section :deep(.el-table__inner-wrapper) {
height: 100%;
}
.ctms-page-content-grid {
gap: 0;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 0;
flex-wrap: wrap;
}
.milestone-action-bar {
min-height: 56px;
box-sizing: border-box;
}
.card-title {
font-size: 15px;
font-weight: 700;
color: var(--unified-title-color);
}
.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: var(--ctms-bg-card);
}
.plan-time-label {
color: var(--ctms-text-secondary);
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 var(--ctms-border-color);
border-radius: 10px;
padding: 14px 16px 10px;
background: var(--ctms-bg-muted);
}
.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: var(--ctms-text-main);
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: var(--ctms-text-secondary);
font-weight: 500;
}
.duration-value {
font-size: 14px;
font-weight: 700;
color: var(--ctms-text-main);
}
.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: var(--ctms-text-regular);
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: var(--ctms-text-secondary);
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;
}
.ctms-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
.table-empty {
min-height: 220px;
display: flex;
align-items: center;
justify-content: center;
color: var(--ctms-text-secondary);
font-size: 14px;
font-weight: 500;
letter-spacing: 0.02em;
}
</style>