Files
cn-rdms-web/src/views/product/dashboard/index.vue
hongawen 4122dfa50d feat(product): 新增产品管理模块与字典组件功能
- 新增产品管理相关路由和页面(dashboard、list、requirement、setting)
- 实现产品基础信息编辑弹窗组件(base-info-dialog.vue)
- 添加运行时字典功能(dict-select、dict-text、dict-tag组件)
- 集成字典管理store和API调用
- 规范ID类型定义为string避免精度丢失问题
- 完善国际化资源文件支持中英文对照
- 新增对象上下文业务域入口页导航实现说明
- 添加Vue DevTools浮动入口注释说明
- 统一权限控制支持全局和对象作用域区分
- 规范分页查询参数类型定义与使用方式
2026-04-23 09:05:55 +08:00

868 lines
24 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 { 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>