feat(product): 新增产品管理模块与字典组件功能
- 新增产品管理相关路由和页面(dashboard、list、requirement、setting) - 实现产品基础信息编辑弹窗组件(base-info-dialog.vue) - 添加运行时字典功能(dict-select、dict-text、dict-tag组件) - 集成字典管理store和API调用 - 规范ID类型定义为string避免精度丢失问题 - 完善国际化资源文件支持中英文对照 - 新增对象上下文业务域入口页导航实现说明 - 添加Vue DevTools浮动入口注释说明 - 统一权限控制支持全局和对象作用域区分 - 规范分页查询参数类型定义与使用方式
This commit is contained in:
867
src/views/product/dashboard/index.vue
Normal file
867
src/views/product/dashboard/index.vue
Normal file
@@ -0,0 +1,867 @@
|
||||
<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>
|
||||
267
src/views/product/dashboard/shared.ts
Normal file
267
src/views/product/dashboard/shared.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { getProductStatusLabel } from '../shared/product-master-data';
|
||||
|
||||
export interface ProductDashboardMetricCard {
|
||||
key: 'status' | 'team' | 'manager' | 'action';
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ProductDashboardTeamSummary {
|
||||
managerDisplayName: string;
|
||||
activeMemberCount: number;
|
||||
latestJoinedMemberLabel: string;
|
||||
roleSummaries: string[];
|
||||
}
|
||||
|
||||
export interface ProductDashboardQuickLink {
|
||||
key: 'requirement' | 'setting' | 'list';
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ProductDashboardActivityItem {
|
||||
key: string;
|
||||
title: string;
|
||||
content: string;
|
||||
time: string;
|
||||
tag: string;
|
||||
tone: 'sky' | 'emerald' | 'amber' | 'rose' | 'slate';
|
||||
}
|
||||
|
||||
export interface ProductDashboardPlaceholderPanel {
|
||||
title: string;
|
||||
description: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
export interface ProductDashboardGrowthModule {
|
||||
key: 'requirement-analysis' | 'project-progress' | 'rd-milestone';
|
||||
title: string;
|
||||
description: string;
|
||||
indicators: string[];
|
||||
}
|
||||
|
||||
function getActiveMembers(members: readonly Api.Product.ProductMember[]) {
|
||||
return members.filter(item => item.status === 0);
|
||||
}
|
||||
|
||||
function getTimeValue(value: string | null | undefined) {
|
||||
const parsed = dayjs(value);
|
||||
|
||||
return parsed.isValid() ? parsed.valueOf() : 0;
|
||||
}
|
||||
|
||||
function formatActivityTime(value: string | null | undefined) {
|
||||
const parsed = dayjs(value);
|
||||
|
||||
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm') : '--';
|
||||
}
|
||||
|
||||
export function getProductDashboardMetricCards(
|
||||
settings: Api.Product.ProductSettings | null,
|
||||
members: readonly Api.Product.ProductMember[]
|
||||
) {
|
||||
const activeMembers = getActiveMembers(members);
|
||||
const managerDisplayName =
|
||||
settings?.baseInfo.managerUserNickname || activeMembers.find(item => item.managerFlag)?.userNickname || '--';
|
||||
const actionCount = settings?.lifecycle.availableActions.length || 0;
|
||||
const statusLabel = settings ? getProductStatusLabel(settings.lifecycle.statusCode) : '--';
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'status',
|
||||
label: '当前状态',
|
||||
value: statusLabel
|
||||
},
|
||||
{
|
||||
key: 'team',
|
||||
label: '团队成员',
|
||||
value: `${activeMembers.length} 人`
|
||||
},
|
||||
{
|
||||
key: 'manager',
|
||||
label: '当前经理',
|
||||
value: managerDisplayName
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
label: '可执行动作',
|
||||
value: `${actionCount} 项`
|
||||
}
|
||||
] satisfies ProductDashboardMetricCard[];
|
||||
}
|
||||
|
||||
export function getProductDashboardTeamSummary(
|
||||
settings: Api.Product.ProductSettings | null,
|
||||
members: readonly Api.Product.ProductMember[]
|
||||
): ProductDashboardTeamSummary {
|
||||
const activeMembers = getActiveMembers(members);
|
||||
const latestJoinedMember = activeMembers
|
||||
.slice()
|
||||
.sort((left, right) => getTimeValue(right.joinedTime) - getTimeValue(left.joinedTime))[0];
|
||||
const latestJoinedDate = latestJoinedMember ? dayjs(latestJoinedMember.joinedTime) : null;
|
||||
|
||||
const roleCounter = new Map<string, number>();
|
||||
|
||||
activeMembers.forEach(member => {
|
||||
const roleName = member.roleName || '未命名角色';
|
||||
|
||||
roleCounter.set(roleName, (roleCounter.get(roleName) || 0) + 1);
|
||||
});
|
||||
|
||||
const roleSummaries = Array.from(roleCounter.entries())
|
||||
.sort((left, right) => {
|
||||
const leftManagerWeight = left[0].includes('经理') ? 0 : 1;
|
||||
const rightManagerWeight = right[0].includes('经理') ? 0 : 1;
|
||||
|
||||
if (leftManagerWeight !== rightManagerWeight) {
|
||||
return leftManagerWeight - rightManagerWeight;
|
||||
}
|
||||
|
||||
return left[0].localeCompare(right[0], 'zh-CN');
|
||||
})
|
||||
.map(([roleName, count]) => `${roleName} ${count} 人`);
|
||||
|
||||
return {
|
||||
managerDisplayName:
|
||||
settings?.baseInfo.managerUserNickname || activeMembers.find(item => item.managerFlag)?.userNickname || '--',
|
||||
activeMemberCount: activeMembers.length,
|
||||
latestJoinedMemberLabel:
|
||||
latestJoinedMember && latestJoinedDate?.isValid()
|
||||
? `${latestJoinedMember.userNickname} · ${latestJoinedDate.format('YYYY-MM-DD')}`
|
||||
: '--',
|
||||
roleSummaries
|
||||
};
|
||||
}
|
||||
|
||||
export function getProductDashboardQuickLinks() {
|
||||
return [
|
||||
{
|
||||
key: 'requirement',
|
||||
label: '进入需求页',
|
||||
description: '查看当前产品下的需求承接位'
|
||||
},
|
||||
{
|
||||
key: 'setting',
|
||||
label: '查看设置',
|
||||
description: '进入产品基础信息、团队和生命周期管理'
|
||||
},
|
||||
{
|
||||
key: 'list',
|
||||
label: '返回列表',
|
||||
description: '退出当前对象视角,回到产品入口页'
|
||||
}
|
||||
] satisfies ProductDashboardQuickLink[];
|
||||
}
|
||||
|
||||
export function getProductDashboardActivityItems(
|
||||
product: Api.Product.Product | null,
|
||||
settings: Api.Product.ProductSettings | null,
|
||||
members: readonly Api.Product.ProductMember[]
|
||||
) {
|
||||
const items: ProductDashboardActivityItem[] = [];
|
||||
|
||||
if (product?.createTime) {
|
||||
items.push({
|
||||
key: `product-create-${product.id}`,
|
||||
title: '创建产品',
|
||||
content: `产品 ${product.name || product.code} 已建立并纳入对象上下文。`,
|
||||
time: product.createTime,
|
||||
tag: '创建',
|
||||
tone: 'sky'
|
||||
});
|
||||
}
|
||||
|
||||
if (settings && settings.baseInfo.lastStatusReason && product?.updateTime) {
|
||||
const statusCode = settings.lifecycle.statusCode;
|
||||
let tone: ProductDashboardActivityItem['tone'] = 'slate';
|
||||
|
||||
if (statusCode === 'active') {
|
||||
tone = 'emerald';
|
||||
} else if (statusCode === 'paused') {
|
||||
tone = 'amber';
|
||||
}
|
||||
|
||||
items.push({
|
||||
key: `product-status-${product.id}-${product.updateTime}`,
|
||||
title: `状态调整为${getProductStatusLabel(settings.lifecycle.statusCode)}`,
|
||||
content: settings.baseInfo.lastStatusReason,
|
||||
time: product.updateTime,
|
||||
tag: '状态',
|
||||
tone
|
||||
});
|
||||
}
|
||||
|
||||
members.forEach(member => {
|
||||
if (member.joinedTime) {
|
||||
items.push({
|
||||
key: `member-join-${member.id}`,
|
||||
title: '成员加入',
|
||||
content: `${member.userNickname} 以${member.roleName}身份加入当前产品。`,
|
||||
time: member.joinedTime,
|
||||
tag: '团队',
|
||||
tone: member.managerFlag ? 'emerald' : 'sky'
|
||||
});
|
||||
}
|
||||
|
||||
if (member.status === 1 && member.leftTime) {
|
||||
items.push({
|
||||
key: `member-leave-${member.id}`,
|
||||
title: '成员退出',
|
||||
content: `${member.userNickname} 已退出当前产品团队。`,
|
||||
time: member.leftTime,
|
||||
tag: '团队',
|
||||
tone: 'rose'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return items
|
||||
.filter(item => getTimeValue(item.time) > 0)
|
||||
.sort((left, right) => getTimeValue(right.time) - getTimeValue(left.time))
|
||||
.slice(0, 6)
|
||||
.map(item => ({
|
||||
...item,
|
||||
time: formatActivityTime(item.time)
|
||||
}));
|
||||
}
|
||||
|
||||
export function getProductDashboardRecentActivityPlaceholder() {
|
||||
return {
|
||||
title: '最近动态',
|
||||
description: '当前先基于产品详情、生命周期与团队关系展示已知动态;后续接入审计日志后可继续扩充为完整时间线。',
|
||||
items: ['产品创建记录', '状态调整记录', '成员加入记录', '成员退出记录']
|
||||
} satisfies ProductDashboardPlaceholderPanel;
|
||||
}
|
||||
|
||||
export function getProductDashboardRdMilestonePlaceholder() {
|
||||
return {
|
||||
title: '研发令 / 里程碑摘要',
|
||||
description: '当前未接入研发令与里程碑聚合能力,后续将在这里展示年度研发令、关键节点和版本里程碑。',
|
||||
items: ['当前年度研发令', '历史研发令', '关键节点计划']
|
||||
} satisfies ProductDashboardPlaceholderPanel;
|
||||
}
|
||||
|
||||
export function getProductDashboardGrowthModules() {
|
||||
return [
|
||||
{
|
||||
key: 'requirement-analysis',
|
||||
title: '需求分析',
|
||||
description: '暂未接入需求统计接口,后续将展示需求总量、状态分布与优先级分布。',
|
||||
indicators: ['需求总数', '待处理数量', '高优先级数量']
|
||||
},
|
||||
{
|
||||
key: 'project-progress',
|
||||
title: '项目推进',
|
||||
description: '当前未汇总项目推进数据,后续将展示关联项目、里程碑与风险摘要。',
|
||||
indicators: ['关联项目数', '进行中项目', '近期里程碑']
|
||||
},
|
||||
{
|
||||
key: 'rd-milestone',
|
||||
title: '研发令与里程碑',
|
||||
description: '当前未接入研发令与里程碑聚合能力,后续将在此展示研发令编号与关键节点信息。',
|
||||
indicators: ['当前年度研发令', '历史研发令', '关键节点']
|
||||
}
|
||||
] satisfies ProductDashboardGrowthModule[];
|
||||
}
|
||||
Reference in New Issue
Block a user