项目中心

This commit is contained in:
wangzzpeng
2026-09-28 17:47:07 +08:00
parent 43c2dc0d8d
commit b014fffacf
23 changed files with 1630 additions and 1577 deletions

View File

@@ -2,185 +2,168 @@
import type { Milestone } from '@/types/business/project'
interface Props {
progress: number
milestones: Milestone[]
startTime?: string
endTime?: string
}
defineProps<Props>()
const props = defineProps<Props>()
/** 三态 → 修饰类(设计稿:已完成=绿实心+白勾 / 进行中=白底粗蓝环 / 待开始=白底灰环) */
function stateClass(status: Milestone['status']): string {
if (status === '已完成') return 'is-done'
if (status === '进行中') return 'is-active'
return 'is-pending'
}
/**
* 连接线是否走「已完成」的绿色。
*
* <p>设计稿实测:只有<b>下一个节点已完成</b>时该段才是绿色(第二个节点已完成 → 第 1 段绿;
* 第三个节点进行中 → 第 2 段仍是灰色,不随「进行中」变蓝)。</p>
*/
function linkDone(index: number): boolean {
return props.milestones[index + 1]?.status === '已完成'
}
</script>
<template>
<section class="project-milestones">
<div class="project-milestones__head">
<h2 class="project-milestones__title">
总体进度
</h2>
<span class="project-milestones__percent">{{ progress }}%</span>
</div>
<div class="project-milestones__track">
<div
class="project-milestones__bar"
:style="{ width: `${progress}%` }"
/>
</div>
<h2 class="project-milestones__title">
项目进度(里程碑)
</h2>
<ol class="project-milestones__timeline">
<ol v-if="props.milestones.length > 0" class="project-milestones__track">
<li
v-for="node in milestones"
:key="node.name"
v-for="(node, index) in props.milestones"
:key="`${node.name}-${index}`"
class="project-milestones__node"
:class="`is-${node.status === '已完成' ? 'done' : node.status === '进行中' ? 'active' : 'pending'}`"
:class="[stateClass(node.status), { 'is-link-done': linkDone(index) }]"
>
<span class="project-milestones__dot" />
<div class="project-milestones__body">
<div class="project-milestones__row">
<span class="project-milestones__name">{{ node.name }}</span>
<span class="project-milestones__status">{{ node.status }}</span>
</div>
<span v-if="node.time" class="project-milestones__time text-secondary">{{ node.time }}</span>
</div>
</li>
<li v-if="milestones.length === 0" class="project-milestones__empty text-secondary">
暂无里程碑数据
<span class="project-milestones__dot">
<svg
v-if="node.status === '已完成'"
class="project-milestones__check"
viewBox="0 0 16 16"
aria-hidden="true"
>
<path d="M3.5 8.4 6.6 11.5 12.5 4.8" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
<span class="project-milestones__name">{{ node.name }}</span>
<!-- 设计稿:已完成节点显示完成时间,其余显示状态文案 -->
<span class="project-milestones__sub text-secondary">{{ node.time || node.status }}</span>
</li>
</ol>
<div class="project-milestones__range">
<span v-if="startTime" class="text-secondary">开始时间:{{ startTime }}</span>
<span v-if="endTime" class="text-secondary">结束时间:{{ endTime }}</span>
</div>
<p v-else class="project-milestones__empty text-secondary">
暂无里程碑数据
</p>
</section>
</template>
<style lang="less" scoped>
// 节点列宽 = 设计稿相邻节点中心距(237 设计px × 0.77 ≈ 182px)
@milestone-node-width: 182px;
@milestone-dot-size: 32px;
.project-milestones {
padding: @spacing-lg @spacing-xl;
.flex(column, flex-start, stretch);
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__head {
.flex(row, space-between, center);
}
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__percent {
font-size: @font-size-xl;
font-weight: 600;
color: @primary-color;
.section-title();
}
// 设计稿:轨迹从卡片左侧正文位起、**固定节距**左对齐(不铺满整宽)
&__track {
height: 8px;
margin-top: @spacing-base;
border-radius: @radius-round;
background: @bg-color;
overflow: hidden;
}
&__bar {
height: 100%;
border-radius: @radius-round;
background: @primary-color;
transition: width 0.3s;
}
&__timeline {
.flex(row, flex-start, flex-start);
margin-top: @spacing-lg;
padding-left: @spacing-xs;
padding-bottom: @spacing-xs;
overflow-x: auto;
.scrollbar();
}
&__node {
position: relative;
padding: 0 0 @spacing-base @spacing-lg;
border-left: 2px solid @border-color;
.flex(column, flex-start, center);
flex: 0 0 @milestone-node-width;
min-width: 0;
&:last-of-type {
border-left-color: transparent;
padding-bottom: 0;
// 圆点之间的连接线:从本节点圆点右缘连到下一节点圆点左缘
&:not(:last-child)::after {
content: '';
position: absolute;
top: (@milestone-dot-size / 2 - 1px);
left: calc(50% + @milestone-dot-size / 2);
width: calc(100% - @milestone-dot-size);
height: 2px;
background: @milestone-idle;
}
&.is-done {
border-left-color: @success-color;
}
&.is-active {
border-left-color: @primary-color;
&.is-link-done:not(:last-child)::after {
background: @milestone-done;
}
}
&__dot {
position: absolute;
top: 2px;
left: -7px;
width: 12px;
height: 12px;
border: 2px solid @border-color-dark;
.flex(row, center, center);
position: relative;
z-index: 1;
width: @milestone-dot-size;
height: @milestone-dot-size;
border: 2px solid @milestone-idle;
border-radius: @radius-round;
background: @white;
box-sizing: border-box;
.is-done & {
border-color: @success-color;
background: @success-color;
border-color: @milestone-done;
background: @milestone-done;
}
.is-active & {
border-color: @primary-color;
background: @primary-color;
border-width: 3px;
border-color: @project-accent;
}
}
&__body {
.flex(column, flex-start, stretch);
gap: 2px;
}
&__row {
.flex(row, center);
gap: @spacing-sm;
&__check {
width: 16px;
height: 16px;
}
&__name {
margin-top: @spacing-sm;
max-width: 100%;
font-size: @font-size-base;
color: @heading-color;
font-weight: 600;
}
&__status {
font-size: @font-size-sm;
text-align: center;
color: @text-color-secondary;
.multi-ellipsis(2);
.is-done & {
color: @success-color;
color: @milestone-done;
}
.is-active & {
color: @primary-color;
color: @project-accent;
}
}
&__time {
&__sub {
margin-top: @spacing-xs;
max-width: 100%;
font-size: @font-size-sm;
text-align: center;
.multi-ellipsis(2);
}
&__empty {
font-size: @font-size-base;
}
&__range {
.flex(row, flex-start, center);
gap: @spacing-lg;
margin-top: @spacing-base;
padding-top: @spacing-base;
border-top: 1px solid @border-color;
font-size: @font-size-sm;
padding-left: 21px;
font-size: @font-size-base;
}
}
</style>