Files
cn-rdms-web/src/views/project/project/overview/index.vue

786 lines
22 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.

<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
import { fetchGetProject, fetchGetProjectMembers, fetchGetProjectSettings } from '@/service/api';
import { useDict } from '@/hooks/business/dict';
import { useCurrentProject } from '../../shared/use-current-project';
import {
buildProjectHomepageBanner,
buildProjectHomepageTimeline,
buildProjectScheduleOverview,
buildProjectTeamOverview,
getProjectHomepageExtensionModules
} from './homepage';
import { projectHomepageExtensionMock } from './mock';
defineOptions({ name: 'ProjectOverview' });
const { currentObjectId, currentProject } = useCurrentProject();
const { getLabel: getDirectionLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
const { getLabel: getProjectTypeLabel } = useDict(RDMS_PROJECT_TYPE_DICT_CODE);
const pageLoading = ref(false);
const projectDetail = ref<Api.Project.Project | null>(null);
const settings = ref<Api.Project.ProjectSettings | null>(null);
const members = ref<Api.Project.ProjectMember[]>([]);
const latestActivityTime = ref('');
const timelineItems = computed(() => buildProjectHomepageTimeline(projectDetail.value, settings.value, members.value));
const scheduleOverview = computed(() => buildProjectScheduleOverview(projectDetail.value));
const teamOverview = computed(() => buildProjectTeamOverview(members.value));
const homepageBanner = computed(() =>
buildProjectHomepageBanner({
project: projectDetail.value,
settings: settings.value,
members: members.value,
latestActivityTime: latestActivityTime.value
})
);
const extensionModules = computed(() => getProjectHomepageExtensionModules(projectHomepageExtensionMock));
const directionLabel = computed(() => getDirectionLabel(homepageBanner.value.identity.directionCode, '--'));
const projectTypeLabel = computed(() => getProjectTypeLabel(homepageBanner.value.identity.projectType, '--'));
const bannerFacts = computed(() => [
{
label: '项目方向',
value: directionLabel.value,
fullWidth: false
},
{
label: '项目类型',
value: projectTypeLabel.value,
fullWidth: false
},
...homepageBanner.value.identity.facts
]);
const progressValue = computed(() => projectDetail.value?.progressRate ?? 0);
const bannerStatusClass = computed(() => {
const statusCode = homepageBanner.value.identity.statusCode;
return statusCode ? `project-homepage-banner--${statusCode}` : 'project-homepage-banner--default';
});
const bannerStatusWordClass = computed(() => {
const statusCode = homepageBanner.value.identity.statusCode;
return statusCode
? `project-homepage-banner__status-word--${statusCode}`
: 'project-homepage-banner__status-word--default';
});
async function loadOverviewData(objectId: string) {
pageLoading.value = true;
try {
const [projectResult, settingsResult, membersResult] = await Promise.all([
fetchGetProject(objectId),
fetchGetProjectSettings(objectId),
fetchGetProjectMembers(objectId)
]);
projectDetail.value = projectResult.error ? null : projectResult.data || null;
settings.value = settingsResult.error ? null : settingsResult.data || null;
members.value = membersResult.error ? [] : membersResult.data || [];
latestActivityTime.value = timelineItems.value[0]?.time || '';
} finally {
pageLoading.value = false;
}
}
watch(
() => currentObjectId.value,
async objectId => {
if (!objectId) {
projectDetail.value = null;
settings.value = null;
members.value = [];
latestActivityTime.value = '';
return;
}
await loadOverviewData(objectId);
},
{ immediate: true }
);
</script>
<template>
<div v-loading="pageLoading" class="project-homepage">
<section class="project-homepage-banner" :class="bannerStatusClass">
<div class="project-homepage-banner__identity">
<div class="project-homepage-banner__title-group">
<div class="project-homepage-banner__title-main min-w-0">
<div class="project-homepage-banner__title-row">
<h1 class="project-homepage-banner__title">
{{ homepageBanner.identity.name || currentProject?.projectName || '--' }}
</h1>
<span class="project-homepage-banner__status-word" :class="bannerStatusWordClass">
{{ homepageBanner.identity.statusLabel }}
</span>
</div>
<div class="project-homepage-banner__subtitle">
<span class="project-homepage-banner__code">编号 {{ homepageBanner.identity.code }}</span>
<p v-if="homepageBanner.identity.description" class="project-homepage-banner__description">
{{ homepageBanner.identity.description }}
</p>
</div>
</div>
</div>
<div class="project-homepage-banner__facts">
<div
v-for="item in bannerFacts"
:key="item.label"
class="project-homepage-banner__fact"
:class="{ 'project-homepage-banner__fact--full': item.fullWidth }"
>
<span class="project-homepage-banner__fact-label">{{ item.label }}</span>
<strong class="project-homepage-banner__fact-value">{{ item.value }}</strong>
</div>
</div>
</div>
<div class="project-homepage-banner__metrics">
<article v-for="item in homepageBanner.metrics" :key="item.label" class="project-homepage-banner__metric">
<span class="project-homepage-banner__metric-label">{{ item.label }}</span>
<strong class="project-homepage-banner__metric-value">{{ item.value }}</strong>
</article>
</div>
</section>
<section class="project-homepage-main">
<ElCard class="project-homepage-panel card-wrapper">
<template #header>
<div>
<h3 class="project-homepage-panel__title">项目动态时间线</h3>
<p class="project-homepage-panel__desc">
先展示项目创建状态动作实际日期和团队变化后续可替换为专用动态接口
</p>
</div>
</template>
<div v-if="timelineItems.length" class="project-homepage-timeline">
<article v-for="item in timelineItems" :key="item.key" class="project-homepage-timeline__item">
<div class="project-homepage-timeline__rail">
<span class="project-homepage-timeline__dot" :class="`project-homepage-timeline__dot--${item.tone}`" />
<span class="project-homepage-timeline__line" />
</div>
<div class="project-homepage-timeline__content">
<div class="project-homepage-timeline__meta">
<ElTag effect="plain" size="small">{{ item.tag }}</ElTag>
<span class="project-homepage-timeline__time">{{ item.time }}</span>
</div>
<p class="project-homepage-timeline__sentence">
<strong class="project-homepage-timeline__headline">{{ item.title }}</strong>
<span>{{ item.content }}</span>
</p>
</div>
</article>
</div>
<ElEmpty v-else description="当前暂无可展示的项目动态" :image-size="88" />
</ElCard>
<div class="project-homepage-main__aside">
<ElCard class="project-homepage-panel card-wrapper">
<template #header>
<div>
<h3 class="project-homepage-panel__title">计划进展概览</h3>
<p class="project-homepage-panel__desc">先看当前进度计划周期和实际执行日期是否已经闭环</p>
</div>
</template>
<div class="project-homepage-schedule">
<div class="project-homepage-schedule__progress">
<strong>{{ progressValue }}%</strong>
<ElProgress
:percentage="progressValue"
:stroke-width="8"
:show-text="false"
:color="progressValue >= 100 ? '#10b981' : progressValue >= 50 ? '#3b82f6' : '#6366f1'"
/>
</div>
<div class="project-homepage-summary-metrics">
<article
v-for="item in scheduleOverview.metrics"
:key="item.label"
class="project-homepage-summary-metrics__item"
>
<span class="project-homepage-summary-metrics__label">{{ item.label }}</span>
<strong class="project-homepage-summary-metrics__value">{{ item.value }}</strong>
</article>
</div>
<div class="project-homepage-schedule__dates">
<div v-for="item in scheduleOverview.dates" :key="item.label" class="project-homepage-schedule__date">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</div>
</ElCard>
<ElCard class="project-homepage-panel card-wrapper">
<template #header>
<div>
<h3 class="project-homepage-panel__title">项目团队概览</h3>
<p class="project-homepage-panel__desc">承接当前成员规模负责人和角色结构和设置页团队维护分开表达</p>
</div>
</template>
<div class="project-homepage-team">
<div class="project-homepage-summary-metrics">
<article
v-for="item in teamOverview.metrics"
:key="item.label"
class="project-homepage-summary-metrics__item"
>
<span class="project-homepage-summary-metrics__label">{{ item.label }}</span>
<strong class="project-homepage-summary-metrics__value">{{ item.value }}</strong>
</article>
</div>
<div v-if="teamOverview.roles.length" class="project-homepage-team__roles">
<div v-for="item in teamOverview.roles" :key="item.label" class="project-homepage-team__role">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
<ElEmpty v-else description="当前暂无有效团队成员" :image-size="72" />
</div>
</ElCard>
</div>
</section>
<section class="project-homepage-extension">
<ElCard v-for="module in extensionModules" :key="module.key" class="project-homepage-panel card-wrapper">
<template #header>
<div>
<h3 class="project-homepage-panel__title">{{ module.title }}</h3>
<p class="project-homepage-panel__desc">{{ module.description }}</p>
</div>
</template>
<div class="project-homepage-extension__list">
<div v-for="item in module.items" :key="item" class="project-homepage-extension__item">
<span class="project-homepage-extension__dot" />
<span>{{ item }}</span>
</div>
</div>
</ElCard>
</section>
</div>
</template>
<style scoped>
.project-homepage {
display: flex;
flex-direction: column;
gap: 16px;
}
.project-homepage-banner {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(320px, 1fr);
gap: 16px;
padding: 24px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 24px;
background:
radial-gradient(circle at top left, rgb(14 116 144 / 14%), transparent 34%),
radial-gradient(circle at bottom right, rgb(15 118 110 / 10%), transparent 28%),
linear-gradient(135deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 98%));
}
.project-homepage-banner--default {
border-color: rgb(226 232 240 / 92%);
background:
radial-gradient(circle at top left, rgb(14 116 144 / 14%), transparent 34%),
radial-gradient(circle at bottom right, rgb(15 118 110 / 10%), transparent 28%),
linear-gradient(135deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 98%));
}
.project-homepage-banner--pending,
.project-homepage-banner--archived {
border-color: rgb(203 213 225 / 92%);
background:
radial-gradient(circle at top left, rgb(100 116 139 / 14%), transparent 34%),
radial-gradient(circle at bottom right, rgb(148 163 184 / 10%), transparent 26%),
linear-gradient(135deg, rgb(248 250 252 / 99%), rgb(255 255 255 / 98%));
}
.project-homepage-banner--active,
.project-homepage-banner--completed {
border-color: rgb(167 243 208 / 88%);
background:
radial-gradient(circle at top left, rgb(5 150 105 / 16%), transparent 32%),
radial-gradient(circle at bottom right, rgb(16 185 129 / 14%), transparent 26%),
linear-gradient(135deg, rgb(236 253 245 / 99%), rgb(255 255 255 / 98%));
}
.project-homepage-banner--paused {
border-color: rgb(253 230 138 / 90%);
background:
radial-gradient(circle at top left, rgb(245 158 11 / 18%), transparent 32%),
radial-gradient(circle at bottom right, rgb(251 191 36 / 16%), transparent 24%),
linear-gradient(135deg, rgb(255 251 235 / 99%), rgb(255 255 255 / 98%));
}
.project-homepage-banner--cancelled {
border-color: rgb(254 205 211 / 92%);
background:
radial-gradient(circle at top left, rgb(244 63 94 / 16%), transparent 32%),
radial-gradient(circle at bottom right, rgb(251 113 133 / 14%), transparent 24%),
linear-gradient(135deg, rgb(255 241 242 / 99%), rgb(255 255 255 / 98%));
}
.project-homepage-banner__identity {
display: flex;
min-width: 0;
flex-direction: column;
gap: 16px;
}
.project-homepage-banner__title-group {
display: flex;
align-items: flex-start;
gap: 12px;
}
.project-homepage-banner__title-main {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 12px;
}
.project-homepage-banner__title-row {
display: flex;
min-width: 0;
align-items: baseline;
gap: 14px;
}
.project-homepage-banner__code {
margin: 0;
color: rgb(14 116 144 / 92%);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.project-homepage-banner__title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 34px;
line-height: 1.15;
letter-spacing: 0;
}
.project-homepage-banner__subtitle {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: baseline;
gap: 10px 14px;
}
.project-homepage-banner__description {
margin: 0;
min-width: 0;
color: rgb(71 85 105 / 94%);
font-size: 14px;
line-height: 1.8;
}
.project-homepage-banner__status-word {
flex-shrink: 0;
font-size: 26px;
font-weight: 800;
line-height: 1;
letter-spacing: 0.18em;
text-transform: uppercase;
user-select: none;
}
.project-homepage-banner__status-word--default {
color: rgb(148 163 184 / 48%);
}
.project-homepage-banner__status-word--pending,
.project-homepage-banner__status-word--archived {
color: transparent;
background: linear-gradient(180deg, rgb(71 85 105 / 92%), rgb(148 163 184 / 64%));
background-clip: text;
text-shadow: 0 10px 24px rgb(100 116 139 / 14%);
-webkit-text-fill-color: transparent;
}
.project-homepage-banner__status-word--active,
.project-homepage-banner__status-word--completed {
color: transparent;
background: linear-gradient(180deg, rgb(5 150 105 / 94%), rgb(16 185 129 / 70%));
background-clip: text;
text-shadow: 0 10px 24px rgb(5 150 105 / 16%);
-webkit-text-fill-color: transparent;
}
.project-homepage-banner__status-word--paused {
color: transparent;
background: linear-gradient(180deg, rgb(217 119 6 / 94%), rgb(245 158 11 / 70%));
background-clip: text;
text-shadow: 0 10px 24px rgb(245 158 11 / 16%);
-webkit-text-fill-color: transparent;
}
.project-homepage-banner__status-word--cancelled {
color: transparent;
background: linear-gradient(180deg, rgb(244 63 94 / 94%), rgb(251 113 133 / 68%));
background-clip: text;
text-shadow: 0 10px 24px rgb(244 63 94 / 16%);
-webkit-text-fill-color: transparent;
}
.project-homepage-banner__facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.project-homepage-banner__fact {
display: flex;
min-height: 58px;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 16px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 18px;
background-color: rgb(255 255 255 / 78%);
}
.project-homepage-banner__fact--full {
grid-column: 1 / -1;
align-items: flex-start;
}
.project-homepage-banner__fact-label {
color: rgb(100 116 139 / 94%);
font-size: 13px;
white-space: nowrap;
}
.project-homepage-banner__fact-value {
color: rgb(15 23 42 / 96%);
font-size: 15px;
line-height: 1.6;
text-align: right;
}
.project-homepage-banner__fact--full .project-homepage-banner__fact-value {
max-width: 72%;
text-align: left;
}
.project-homepage-banner__metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.project-homepage-banner__metric {
display: flex;
min-height: 112px;
flex-direction: column;
justify-content: center;
gap: 16px;
padding: 18px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 20px;
background: linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(241 245 249 / 98%));
}
.project-homepage-banner__metric-label {
color: rgb(100 116 139 / 92%);
font-size: 12px;
}
.project-homepage-banner__metric-value {
color: rgb(15 23 42 / 98%);
font-size: 28px;
line-height: 1.1;
letter-spacing: 0;
word-break: break-word;
}
.project-homepage-main {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.project-homepage-main__aside {
display: flex;
min-width: 0;
flex-direction: column;
gap: 16px;
}
.project-homepage-panel {
overflow: hidden;
}
.project-homepage-panel__title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 16px;
font-weight: 700;
}
.project-homepage-panel__desc {
margin: 4px 0 0;
color: rgb(100 116 139 / 92%);
font-size: 13px;
line-height: 1.7;
}
.project-homepage-timeline {
display: flex;
flex-direction: column;
gap: 10px;
}
.project-homepage-timeline__item {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
gap: 12px;
}
.project-homepage-timeline__rail {
display: flex;
flex-direction: column;
align-items: center;
}
.project-homepage-timeline__dot {
width: 12px;
height: 12px;
margin-top: 6px;
border-radius: 999px;
box-shadow: 0 0 0 4px rgb(255 255 255 / 96%);
}
.project-homepage-timeline__dot--sky {
background-color: rgb(14 165 233 / 88%);
}
.project-homepage-timeline__dot--emerald {
background-color: rgb(5 150 105 / 88%);
}
.project-homepage-timeline__dot--amber {
background-color: rgb(217 119 6 / 88%);
}
.project-homepage-timeline__dot--rose {
background-color: rgb(225 29 72 / 88%);
}
.project-homepage-timeline__dot--slate {
background-color: rgb(100 116 139 / 88%);
}
.project-homepage-timeline__line {
flex: 1;
width: 2px;
min-height: 30px;
margin-top: 4px;
background: linear-gradient(180deg, rgb(203 213 225 / 96%), rgb(226 232 240 / 28%));
}
.project-homepage-timeline__item:last-child .project-homepage-timeline__line {
opacity: 0;
}
.project-homepage-timeline__content {
padding: 12px 14px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 16px;
background-color: rgb(255 255 255 / 98%);
}
.project-homepage-timeline__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.project-homepage-timeline__time {
color: rgb(100 116 139 / 90%);
font-size: 12px;
}
.project-homepage-timeline__sentence {
margin: 6px 0 0;
color: rgb(71 85 105 / 94%);
font-size: 13px;
line-height: 1.65;
}
.project-homepage-timeline__headline {
margin-right: 6px;
color: rgb(15 23 42 / 98%);
font-weight: 600;
}
.project-homepage-schedule,
.project-homepage-team {
display: flex;
flex-direction: column;
gap: 16px;
}
.project-homepage-schedule__progress {
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px;
border-radius: 18px;
background: linear-gradient(180deg, rgb(248 250 252 / 98%), rgb(241 245 249 / 94%));
}
.project-homepage-schedule__progress strong {
color: rgb(15 23 42 / 98%);
font-size: 36px;
line-height: 1.1;
}
.project-homepage-summary-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.project-homepage-summary-metrics__item {
display: flex;
min-height: 100px;
flex-direction: column;
justify-content: center;
gap: 14px;
padding: 14px 16px;
border-radius: 18px;
background: linear-gradient(180deg, rgb(248 250 252 / 98%), rgb(241 245 249 / 94%));
}
.project-homepage-summary-metrics__label {
color: rgb(100 116 139 / 92%);
font-size: 12px;
}
.project-homepage-summary-metrics__value {
color: rgb(15 23 42 / 98%);
font-size: 22px;
line-height: 1.2;
word-break: break-word;
}
.project-homepage-schedule__dates,
.project-homepage-team__roles {
display: flex;
flex-direction: column;
gap: 10px;
}
.project-homepage-schedule__date,
.project-homepage-team__role {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 14px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 14px;
background-color: rgb(255 255 255 / 96%);
color: rgb(51 65 85 / 95%);
font-size: 14px;
}
.project-homepage-schedule__date strong,
.project-homepage-team__role strong {
color: rgb(15 23 42 / 98%);
font-size: 18px;
}
.project-homepage-extension {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.project-homepage-extension__list {
display: flex;
flex-direction: column;
gap: 10px;
}
.project-homepage-extension__item {
display: flex;
align-items: center;
gap: 10px;
padding: 13px 14px;
border-radius: 16px;
background-color: rgb(248 250 252 / 96%);
color: rgb(51 65 85 / 95%);
font-size: 14px;
line-height: 1.7;
}
.project-homepage-extension__dot {
width: 8px;
height: 8px;
flex-shrink: 0;
border-radius: 999px;
background-color: rgb(14 116 144 / 88%);
}
@media (width <= 1280px) {
.project-homepage-banner,
.project-homepage-main,
.project-homepage-extension {
grid-template-columns: 1fr;
}
}
@media (width <= 768px) {
.project-homepage-banner {
padding: 18px;
}
.project-homepage-banner__title-row {
flex-wrap: wrap;
}
.project-homepage-banner__title {
font-size: 28px;
}
.project-homepage-banner__status-word {
font-size: 22px;
}
.project-homepage-banner__facts,
.project-homepage-banner__metrics,
.project-homepage-summary-metrics {
grid-template-columns: 1fr;
}
.project-homepage-banner__fact--full .project-homepage-banner__fact-value {
max-width: none;
}
}
</style>