Files
cn-rdms-web/src/views/product/dashboard/index.vue

868 lines
24 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
import { fetchGetProduct, fetchGetProductMembers, fetchGetProductSettings } from '@/service/api';
import { useRouterPush } from '@/hooks/common/router';
import { useCurrentProduct } from '../shared/use-current-product';
import { getProductLifecycleStatusSummary } from '../setting/shared';
import { getProductStatusLabel, getProductStatusTagType } from '../shared/product-master-data';
import {
getProductDashboardActivityItems,
getProductDashboardGrowthModules,
getProductDashboardMetricCards,
getProductDashboardQuickLinks,
getProductDashboardRdMilestonePlaceholder,
getProductDashboardRecentActivityPlaceholder,
getProductDashboardTeamSummary
} from './shared';
defineOptions({ name: 'ProductDashboard' });
const { currentObjectId } = useCurrentProduct();
const { routerPush, routerPushByKey } = useRouterPush();
const pageLoading = ref(false);
const productDetail = ref<Api.Product.Product | null>(null);
const settings = ref<Api.Product.ProductSettings | null>(null);
const members = ref<Api.Product.ProductMember[]>([]);
const recentActivityPlaceholder = getProductDashboardRecentActivityPlaceholder();
const rdMilestonePlaceholder = getProductDashboardRdMilestonePlaceholder();
const growthModules = getProductDashboardGrowthModules();
const quickLinks = getProductDashboardQuickLinks();
const lifecycleTrackItems: Api.Product.ProductStatusCode[] = ['active', 'paused', 'archived', 'abandoned'];
const metricCards = computed(() => getProductDashboardMetricCards(settings.value, members.value));
const statusMetricCard = computed(() => metricCards.value.find(item => item.key === 'status') || null);
const secondaryMetricCards = computed(() => metricCards.value.filter(item => item.key !== 'status'));
const teamSummary = computed(() => getProductDashboardTeamSummary(settings.value, members.value));
const activityItems = computed(() =>
getProductDashboardActivityItems(productDetail.value, settings.value, members.value)
);
const lifecycle = computed(() => settings.value?.lifecycle || null);
const lifecycleSummary = computed(() =>
lifecycle.value ? getProductLifecycleStatusSummary(lifecycle.value.statusCode) : null
);
const lifecycleReason = computed(
() => lifecycle.value?.lastStatusReason || settings.value?.baseInfo.lastStatusReason || ''
);
async function loadDashboardData(objectId: string) {
pageLoading.value = true;
try {
const [productResult, settingsResult, membersResult] = await Promise.all([
fetchGetProduct(objectId),
fetchGetProductSettings(objectId),
fetchGetProductMembers(objectId)
]);
productDetail.value = productResult.error ? null : productResult.data || null;
settings.value = settingsResult.error ? null : settingsResult.data || null;
members.value = membersResult.error ? [] : membersResult.data || [];
} finally {
pageLoading.value = false;
}
}
async function goToQuickLink(target: 'requirement' | 'setting' | 'list') {
if (target === 'list') {
await routerPush({
path: '/product/list'
});
return;
}
if (!currentObjectId.value) {
return;
}
if (target === 'requirement') {
await routerPushByKey('product_requirement', {
query: {
[OBJECT_CONTEXT_QUERY_KEY]: currentObjectId.value
}
});
return;
}
await routerPushByKey('product_setting', {
query: {
[OBJECT_CONTEXT_QUERY_KEY]: currentObjectId.value
}
});
}
watch(
() => currentObjectId.value,
async objectId => {
if (!objectId) {
productDetail.value = null;
settings.value = null;
members.value = [];
return;
}
await loadDashboardData(objectId);
},
{ immediate: true }
);
</script>
<template>
<div v-loading="pageLoading" class="product-dashboard-page">
<section class="product-dashboard-page__metrics">
<article
v-if="statusMetricCard"
class="dashboard-status-hero"
:class="[lifecycle ? `dashboard-status-hero--${lifecycle.statusCode}` : 'dashboard-status-hero--default']"
>
<div class="dashboard-status-hero__head">
<span class="dashboard-status-hero__label">{{ statusMetricCard.label }}</span>
<span class="dashboard-status-hero__meta-chip">{{ lifecycle?.availableActions.length || 0 }} 项动作</span>
</div>
<div class="dashboard-status-hero__body">
<strong class="dashboard-status-hero__value">{{ statusMetricCard.value }}</strong>
<p class="dashboard-status-hero__reason">
{{ lifecycleReason || '当前没有补充状态原因' }}
</p>
</div>
</article>
<div class="dashboard-metric-stack">
<article v-for="card in secondaryMetricCards" :key="card.key" class="dashboard-metric-card">
<span class="dashboard-metric-card__label">{{ card.label }}</span>
<strong class="dashboard-metric-card__value">{{ card.value }}</strong>
</article>
</div>
</section>
<section class="product-dashboard-page__main">
<div class="product-dashboard-page__primary">
<ElCard class="card-wrapper">
<template #header>
<div>
<h3 class="text-16px text-[#0f172a] font-700">{{ recentActivityPlaceholder.title }}</h3>
<p class="mt-4px text-13px text-[#64748b]">当前先展示产品主数据状态与团队关系可确认的已知动态</p>
</div>
</template>
<div v-if="activityItems.length" class="dashboard-activity-list">
<div
v-for="item in activityItems"
:key="item.key"
class="dashboard-activity-item"
:class="[`dashboard-activity-item--${item.tone}`]"
>
<div class="dashboard-activity-item__meta">
<span class="dashboard-activity-item__tag">{{ item.tag }}</span>
<span class="dashboard-activity-item__time">{{ item.time }}</span>
</div>
<strong class="dashboard-activity-item__title">{{ item.title }}</strong>
<p class="dashboard-activity-item__content">{{ item.content }}</p>
</div>
</div>
<div v-else class="dashboard-placeholder-panel">
<p class="dashboard-placeholder-panel__description">{{ recentActivityPlaceholder.description }}</p>
<div class="dashboard-placeholder-panel__items">
<div
v-for="item in recentActivityPlaceholder.items"
:key="item"
class="dashboard-placeholder-panel__item"
>
<span class="dashboard-placeholder-panel__dot" />
<span>{{ item }}</span>
</div>
</div>
</div>
</ElCard>
<ElCard class="card-wrapper">
<template #header>
<div>
<h3 class="text-16px text-[#0f172a] font-700">生命周期概览</h3>
<p class="mt-4px text-13px text-[#64748b]">
{{ lifecycleSummary?.description || '当前未获取到生命周期信息。' }}
</p>
</div>
</template>
<div v-if="lifecycle" class="dashboard-lifecycle">
<div class="dashboard-lifecycle__summary">
<div class="dashboard-lifecycle__summary-main">
<ElTag :type="getProductStatusTagType(lifecycle.statusCode)" round effect="light">
{{ getProductStatusLabel(lifecycle.statusCode) }}
</ElTag>
<strong class="dashboard-lifecycle__summary-title">
{{ lifecycleSummary?.caption || '当前状态待确认' }}
</strong>
</div>
<p class="dashboard-lifecycle__reason">最近状态原因{{ lifecycleReason || '暂无记录' }}</p>
</div>
<div class="dashboard-lifecycle__actions">
<div
v-for="action in lifecycle.availableActions"
:key="action.actionCode"
class="dashboard-lifecycle__action-card"
>
<strong class="dashboard-lifecycle__action-name">{{ action.actionName }}</strong>
<span class="dashboard-lifecycle__action-hint">
{{ action.needReason ? '提交时需填写原因' : '提交时原因可选' }}
</span>
</div>
<ElEmpty
v-if="!lifecycle.availableActions.length"
description="当前状态下没有可执行动作"
:image-size="68"
/>
</div>
<div class="dashboard-lifecycle__track">
<div
v-for="status in lifecycleTrackItems"
:key="status"
class="dashboard-lifecycle__track-item"
:class="[{ 'dashboard-lifecycle__track-item--active': lifecycle.statusCode === status }]"
>
{{ getProductStatusLabel(status) }}
</div>
</div>
</div>
<ElEmpty v-else description="未获取到产品生命周期信息" :image-size="76" />
</ElCard>
</div>
<div class="product-dashboard-page__secondary">
<ElCard class="card-wrapper">
<template #header>
<div>
<h3 class="text-16px text-[#0f172a] font-700">团队摘要</h3>
<p class="mt-4px text-13px text-[#64748b]">当前先展示有效成员负责人和角色分布摘要</p>
</div>
</template>
<div class="dashboard-team-card">
<div class="dashboard-team-card__stat-grid">
<div class="dashboard-team-card__stat">
<span class="dashboard-team-card__stat-label">当前经理</span>
<strong class="dashboard-team-card__stat-value">{{ teamSummary.managerDisplayName }}</strong>
</div>
<div class="dashboard-team-card__stat">
<span class="dashboard-team-card__stat-label">有效成员</span>
<strong class="dashboard-team-card__stat-value">{{ teamSummary.activeMemberCount }} </strong>
</div>
</div>
<div class="dashboard-team-card__detail">
<span class="dashboard-team-card__detail-label">最近加入</span>
<strong class="dashboard-team-card__detail-value">{{ teamSummary.latestJoinedMemberLabel }}</strong>
</div>
<div class="dashboard-team-card__roles">
<span class="dashboard-team-card__detail-label">角色分布</span>
<div v-if="teamSummary.roleSummaries.length" class="dashboard-team-card__role-list">
<span v-for="item in teamSummary.roleSummaries" :key="item" class="dashboard-team-card__role-chip">
{{ item }}
</span>
</div>
<ElEmpty v-else description="当前暂无有效团队成员" :image-size="64" />
</div>
</div>
</ElCard>
<ElCard class="card-wrapper">
<template #header>
<div>
<h3 class="text-16px text-[#0f172a] font-700">快捷入口</h3>
<p class="mt-4px text-13px text-[#64748b]">首页只做导流不在这里承接重表单和重列表</p>
</div>
</template>
<div class="dashboard-link-list">
<button
v-for="link in quickLinks"
:key="link.key"
type="button"
class="dashboard-link-list__item"
@click="goToQuickLink(link.key)"
>
<strong class="dashboard-link-list__title">{{ link.label }}</strong>
<span class="dashboard-link-list__desc">{{ link.description }}</span>
</button>
</div>
</ElCard>
<ElCard class="card-wrapper">
<template #header>
<div>
<h3 class="text-16px text-[#0f172a] font-700">{{ rdMilestonePlaceholder.title }}</h3>
<p class="mt-4px text-13px text-[#64748b]">对象档案补充位后续接真实聚合数据后直接替换</p>
</div>
</template>
<div class="dashboard-placeholder-panel dashboard-placeholder-panel--compact">
<p class="dashboard-placeholder-panel__description">
{{ rdMilestonePlaceholder.description }}
</p>
<div class="dashboard-placeholder-panel__items">
<div v-for="item in rdMilestonePlaceholder.items" :key="item" class="dashboard-placeholder-panel__item">
<span class="dashboard-placeholder-panel__dot" />
<span>{{ item }}</span>
</div>
</div>
</div>
</ElCard>
</div>
</section>
<section class="product-dashboard-page__growth">
<ElCard v-for="module in growthModules" :key="module.key" class="card-wrapper">
<template #header>
<div>
<h3 class="text-16px text-[#0f172a] font-700">{{ module.title }}</h3>
<p class="mt-4px text-13px text-[#64748b]">当前保留正式布局位后续可直接接入真实统计</p>
</div>
</template>
<div class="dashboard-growth-card">
<p class="dashboard-growth-card__description">{{ module.description }}</p>
<div class="dashboard-growth-card__indicators">
<div v-for="item in module.indicators" :key="item" class="dashboard-growth-card__indicator">
<span class="dashboard-growth-card__indicator-label">{{ item }}</span>
<strong class="dashboard-growth-card__indicator-value">--</strong>
</div>
</div>
</div>
</ElCard>
</section>
</div>
</template>
<style scoped>
.product-dashboard-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.product-dashboard-page__metrics {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
gap: 12px;
}
.dashboard-status-hero {
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 152px;
padding: 18px 20px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 18px;
}
.dashboard-status-hero--default {
background: linear-gradient(135deg, rgb(248 250 252 / 98%), rgb(255 255 255 / 98%));
}
.dashboard-status-hero--active {
border-color: rgb(167 243 208 / 92%);
background:
radial-gradient(circle at top right, rgb(16 185 129 / 16%), transparent 38%),
linear-gradient(135deg, rgb(236 253 245 / 98%), rgb(255 255 255 / 98%));
}
.dashboard-status-hero--paused {
border-color: rgb(253 230 138 / 92%);
background:
radial-gradient(circle at top right, rgb(245 158 11 / 16%), transparent 38%),
linear-gradient(135deg, rgb(255 251 235 / 98%), rgb(255 255 255 / 98%));
}
.dashboard-status-hero--archived {
border-color: rgb(203 213 225 / 92%);
background:
radial-gradient(circle at top right, rgb(100 116 139 / 14%), transparent 38%),
linear-gradient(135deg, rgb(248 250 252 / 98%), rgb(255 255 255 / 98%));
}
.dashboard-status-hero--abandoned {
border-color: rgb(254 205 211 / 92%);
background:
radial-gradient(circle at top right, rgb(244 63 94 / 14%), transparent 38%),
linear-gradient(135deg, rgb(255 241 242 / 98%), rgb(255 255 255 / 98%));
}
.dashboard-status-hero__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.dashboard-status-hero__label {
color: rgb(100 116 139 / 92%);
font-size: 12px;
line-height: 1;
}
.dashboard-status-hero__meta-chip {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
background-color: rgb(255 255 255 / 78%);
color: rgb(51 65 85 / 92%);
font-size: 12px;
}
.dashboard-status-hero__body {
display: flex;
flex-direction: column;
gap: 12px;
}
.dashboard-status-hero__value {
color: rgb(15 23 42 / 98%);
font-size: 38px;
line-height: 1.1;
letter-spacing: -0.02em;
}
.dashboard-status-hero__reason {
color: rgb(51 65 85 / 92%);
font-size: 14px;
line-height: 1.75;
}
.dashboard-metric-stack {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.dashboard-metric-card {
display: flex;
min-height: 152px;
flex-direction: column;
justify-content: space-between;
padding: 18px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 18px;
background: linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
}
.dashboard-metric-card__label {
color: rgb(100 116 139 / 92%);
font-size: 12px;
line-height: 1;
}
.dashboard-metric-card__value {
color: rgb(15 23 42 / 96%);
font-size: 22px;
line-height: 1.2;
word-break: break-word;
}
.dashboard-metric-card--manager .dashboard-metric-card__value {
font-size: 18px;
}
.product-dashboard-page__main {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(320px, 1fr);
gap: 16px;
}
.product-dashboard-page__primary,
.product-dashboard-page__secondary {
display: flex;
flex-direction: column;
gap: 16px;
}
.dashboard-placeholder-panel {
display: flex;
flex-direction: column;
gap: 14px;
padding: 4px 2px;
}
.dashboard-placeholder-panel--compact {
gap: 12px;
}
.dashboard-placeholder-panel__description {
color: rgb(71 85 105 / 95%);
font-size: 14px;
line-height: 1.75;
}
.dashboard-placeholder-panel__items {
display: flex;
flex-direction: column;
gap: 10px;
}
.dashboard-placeholder-panel__item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-radius: 14px;
background-color: rgb(248 250 252 / 94%);
color: rgb(15 23 42 / 92%);
font-size: 14px;
}
.dashboard-placeholder-panel__dot {
width: 8px;
height: 8px;
border-radius: 999px;
background-color: rgb(14 165 233 / 86%);
flex-shrink: 0;
}
.dashboard-activity-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.dashboard-activity-item {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
border: 1px solid rgb(226 232 240 / 90%);
border-left-width: 4px;
border-radius: 16px;
background-color: rgb(255 255 255 / 96%);
}
.dashboard-activity-item--sky {
border-left-color: rgb(14 165 233 / 88%);
}
.dashboard-activity-item--emerald {
border-left-color: rgb(5 150 105 / 88%);
}
.dashboard-activity-item--amber {
border-left-color: rgb(217 119 6 / 88%);
}
.dashboard-activity-item--rose {
border-left-color: rgb(225 29 72 / 88%);
}
.dashboard-activity-item--slate {
border-left-color: rgb(71 85 105 / 88%);
}
.dashboard-activity-item__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.dashboard-activity-item__tag {
display: inline-flex;
align-items: center;
height: 26px;
padding: 0 10px;
border-radius: 999px;
background-color: rgb(241 245 249 / 96%);
color: rgb(51 65 85 / 94%);
font-size: 12px;
}
.dashboard-activity-item__time {
color: rgb(100 116 139 / 90%);
font-size: 12px;
}
.dashboard-activity-item__title {
color: rgb(15 23 42 / 96%);
font-size: 15px;
line-height: 1.6;
}
.dashboard-activity-item__content {
color: rgb(71 85 105 / 94%);
font-size: 14px;
line-height: 1.75;
}
.dashboard-lifecycle {
display: flex;
flex-direction: column;
gap: 16px;
}
.dashboard-lifecycle__summary {
padding: 16px;
border-radius: 16px;
background-color: rgb(248 250 252 / 96%);
}
.dashboard-lifecycle__summary-main {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.dashboard-lifecycle__summary-title {
color: rgb(15 23 42 / 96%);
font-size: 16px;
line-height: 1.4;
}
.dashboard-lifecycle__reason {
color: rgb(71 85 105 / 94%);
font-size: 14px;
line-height: 1.7;
}
.dashboard-lifecycle__actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.dashboard-lifecycle__action-card {
display: flex;
min-height: 96px;
flex-direction: column;
justify-content: space-between;
padding: 14px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 16px;
background-color: rgb(255 255 255 / 94%);
}
.dashboard-lifecycle__action-name {
color: rgb(15 23 42 / 95%);
font-size: 15px;
}
.dashboard-lifecycle__action-hint {
color: rgb(100 116 139 / 90%);
font-size: 12px;
line-height: 1.6;
}
.dashboard-lifecycle__track {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.dashboard-lifecycle__track-item {
display: inline-flex;
align-items: center;
height: 36px;
padding: 0 14px;
border-radius: 999px;
background-color: rgb(241 245 249 / 98%);
color: rgb(71 85 105 / 96%);
font-size: 13px;
}
.dashboard-lifecycle__track-item--active {
background-color: rgb(15 23 42 / 92%);
color: #fff;
}
.dashboard-team-card {
display: flex;
flex-direction: column;
gap: 16px;
}
.dashboard-team-card__stat-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.dashboard-team-card__stat {
display: flex;
min-height: 92px;
flex-direction: column;
justify-content: space-between;
padding: 14px;
border-radius: 16px;
background-color: rgb(248 250 252 / 96%);
}
.dashboard-team-card__stat-label,
.dashboard-team-card__detail-label {
color: rgb(100 116 139 / 90%);
font-size: 12px;
}
.dashboard-team-card__stat-value,
.dashboard-team-card__detail-value {
color: rgb(15 23 42 / 96%);
font-size: 18px;
line-height: 1.5;
}
.dashboard-team-card__detail {
display: flex;
flex-direction: column;
gap: 6px;
}
.dashboard-team-card__roles {
display: flex;
flex-direction: column;
gap: 10px;
}
.dashboard-team-card__role-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.dashboard-team-card__role-chip {
display: inline-flex;
align-items: center;
min-height: 34px;
padding: 0 12px;
border-radius: 999px;
background-color: rgb(239 246 255 / 96%);
color: rgb(30 64 175 / 92%);
font-size: 13px;
}
.dashboard-link-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.dashboard-link-list__item {
display: flex;
width: 100%;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
border: 1px solid rgb(226 232 240 / 90%);
border-radius: 16px;
background-color: rgb(255 255 255 / 98%);
text-align: left;
cursor: pointer;
transition:
border-color 0.2s ease,
transform 0.2s ease,
box-shadow 0.2s ease;
}
.dashboard-link-list__item:hover {
border-color: rgb(125 211 252 / 92%);
box-shadow: 0 12px 24px rgb(148 163 184 / 12%);
transform: translateY(-1px);
}
.dashboard-link-list__title {
color: rgb(15 23 42 / 96%);
font-size: 15px;
}
.dashboard-link-list__desc {
color: rgb(100 116 139 / 90%);
font-size: 13px;
line-height: 1.7;
}
.product-dashboard-page__growth {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.dashboard-growth-card {
display: flex;
flex-direction: column;
gap: 16px;
}
.dashboard-growth-card__description {
color: rgb(71 85 105 / 95%);
font-size: 14px;
line-height: 1.75;
}
.dashboard-growth-card__indicators {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.dashboard-growth-card__indicator {
display: flex;
min-height: 92px;
flex-direction: column;
justify-content: space-between;
padding: 14px;
border-radius: 14px;
background-color: rgb(248 250 252 / 96%);
}
.dashboard-growth-card__indicator-label {
color: rgb(100 116 139 / 92%);
font-size: 12px;
line-height: 1.5;
}
.dashboard-growth-card__indicator-value {
color: rgb(15 23 42 / 96%);
font-size: 20px;
}
@media (width <= 1280px) {
.product-dashboard-page__main {
grid-template-columns: 1fr;
}
.product-dashboard-page__metrics {
grid-template-columns: 1fr;
}
.dashboard-metric-stack,
.product-dashboard-page__growth {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (width <= 768px) {
.dashboard-metric-stack,
.product-dashboard-page__growth,
.dashboard-lifecycle__actions,
.dashboard-growth-card__indicators,
.dashboard-team-card__stat-grid {
grid-template-columns: 1fr;
}
.dashboard-lifecycle__summary-main {
align-items: flex-start;
flex-direction: column;
}
}
</style>