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:
2026-04-23 09:05:55 +08:00
parent c5911ea34b
commit 4122dfa50d
95 changed files with 9581 additions and 801 deletions

View 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>

View 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[];
}

View File

@@ -0,0 +1,696 @@
<script setup lang="tsx">
import { computed, onMounted, reactive, ref } from 'vue';
import type { Component } from 'vue';
import { ElButton, ElTag } from 'element-plus';
import dayjs from 'dayjs';
import { CircleCheckFilled, DeleteFilled, FolderOpened, VideoPause } from '@element-plus/icons-vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
import { fetchGetProductPage, fetchGetUserSimpleList } from '@/service/api';
import { useDict } from '@/hooks/business/dict';
import { useRouterPush } from '@/hooks/common/router';
import { useUIPaginatedTable } from '@/hooks/common/table';
import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
import { getProductStatusLabel, getProductStatusTagType, isProductEditable } from '../shared/product-master-data';
import ProductOperateDialog from './modules/product-operate-dialog.vue';
import ProductSearch from './modules/product-search.vue';
defineOptions({ name: 'ProductList' });
interface StatusNavMeta {
key: Api.Product.ProductStatusCode;
label: string;
description: string;
tone: 'teal' | 'slate' | 'amber' | 'rose';
icon: Component;
}
type ProductPageResponse = Awaited<ReturnType<typeof fetchGetProductPage>>;
const PRODUCT_OPTION_PAGE_SIZE = 200;
const PRODUCT_ENTRY_ROUTE_PATH = '/product/list';
function getInitSearchParams(): Api.Product.ProductSearchParams {
return {
pageNo: 1,
pageSize: 10,
keyword: '',
directionCode: undefined,
managerUserId: undefined,
statusCode: undefined,
updateTime: undefined
};
}
function transformProductPage(response: ProductPageResponse, pageNo: number, pageSize: number) {
if (!response.error && response.data) {
return {
data: response.data.list,
pageNum: pageNo,
pageSize,
total: response.data.total
};
}
return {
data: [],
pageNum: pageNo,
pageSize,
total: 0
};
}
function sortManagerOptions(list: Api.SystemManage.UserSimple[]) {
return list.slice().sort((left, right) => left.nickname.localeCompare(right.nickname, 'zh-CN'));
}
function formatDateTime(value?: string | null) {
if (!value) {
return '--';
}
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
}
async function fetchProductTotal(params: Api.Product.ProductSearchParams) {
const { error, data } = await fetchGetProductPage({
...params,
pageNo: 1,
pageSize: 1
});
if (error || !data) {
return 0;
}
return data.total;
}
async function fetchAllProducts() {
async function collect(pageNo: number, list: Api.Product.Product[]): Promise<Api.Product.Product[] | null> {
const { error, data } = await fetchGetProductPage({
pageNo,
pageSize: PRODUCT_OPTION_PAGE_SIZE
});
if (error || !data) {
return null;
}
const nextList = list.concat(data.list);
if (nextList.length >= data.total || data.list.length === 0) {
return nextList;
}
return collect(pageNo + 1, nextList);
}
return collect(1, []);
}
function createManagerOptions(products: Api.Product.Product[], users: Api.SystemManage.UserSimple[]) {
const managerIdSet = new Set(products.map(item => String(item.managerUserId)).filter(Boolean));
const userMap = new Map(users.map(item => [String(item.id), item]));
const options = Array.from(managerIdSet).map(managerUserId => {
return (
userMap.get(managerUserId) || {
id: managerUserId,
nickname: String(managerUserId)
}
);
});
return sortManagerOptions(options);
}
const statusNavMetas: StatusNavMeta[] = [
{
key: 'active',
label: '启用产品',
description: '当前正常服务中的产品',
tone: 'teal',
icon: CircleCheckFilled
},
{
key: 'archived',
label: '归档产品',
description: '已完成阶段目标的产品',
tone: 'slate',
icon: FolderOpened
},
{
key: 'paused',
label: '暂停产品',
description: '阶段性暂停投入的产品',
tone: 'amber',
icon: VideoPause
},
{
key: 'abandoned',
label: '废弃产品',
description: '已明确停止建设的产品',
tone: 'rose',
icon: DeleteFilled
}
];
const searchParams = reactive(getInitSearchParams());
const selectedStatus = ref<Api.Product.ProductStatusCode>('active');
const managerFilterOptions = ref<Api.SystemManage.UserSimple[]>([]);
const managerUserOptions = ref<Api.SystemManage.UserSimple[]>([]);
const operateVisible = ref(false);
const editingRow = ref<Api.Product.Product | null>(null);
const { routerPush } = useRouterPush();
const { dictData: directionOptions, getLabel: getDirectionDictLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
const statusCounts = ref<Record<Api.Product.ProductStatusCode, number>>({
active: 0,
archived: 0,
paused: 0,
abandoned: 0
});
const recentUpdatedCount = ref(0);
const managerLabelMap = computed(() => {
return new Map(managerUserOptions.value.map(item => [String(item.id), item.nickname]));
});
const statusItems = computed(() =>
statusNavMetas.map(item => ({
...item,
count: statusCounts.value[item.key]
}))
);
const overviewMetrics = computed(() => [
{
label: '可见产品',
value: Object.values(statusCounts.value).reduce((sum, count) => sum + count, 0),
hint: '当前接口可查询到的产品总量'
},
{
label: '当前启用',
value: statusCounts.value.active,
hint: '正在持续服务和维护的产品'
},
{
label: '产品方向',
value: directionOptions.value.length,
hint: '已加载的方向字典项数量'
},
{
label: '30天内更新',
value: recentUpdatedCount.value,
hint: '最近 30 天内发生过更新的产品'
}
]);
function getDirectionLabel(directionCode?: string | null) {
return getDirectionDictLabel(directionCode, '--');
}
function getManagerLabel(managerUserId?: string | null) {
if (!managerUserId) {
return '--';
}
return managerLabelMap.value.get(String(managerUserId)) || String(managerUserId);
}
function createRequestParams(): Api.Product.ProductSearchParams {
return {
...searchParams,
keyword: searchParams.keyword?.trim() || undefined,
statusCode: selectedStatus.value
};
}
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
ProductPageResponse,
Api.Product.Product
>({
paginationProps: {
currentPage: searchParams.pageNo,
pageSize: searchParams.pageSize
},
api: () => fetchGetProductPage(createRequestParams()),
transform: response => transformProductPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
onPaginationParamsChange: params => {
searchParams.pageNo = params.currentPage ?? 1;
searchParams.pageSize = params.pageSize ?? 10;
},
columns: () => [
{ prop: 'index', type: 'index', label: '序号', width: 64 },
{
prop: 'name',
label: '产品名称',
minWidth: 220,
formatter: row => (
<ElButton link type="primary" class="product-name-link" onClick={() => enterProductContext(row)}>
{row.name}
</ElButton>
)
},
{ prop: 'code', label: '产品编码', minWidth: 140, showOverflowTooltip: true },
{
prop: 'managerUserId',
label: '产品经理',
minWidth: 120,
formatter: row => getManagerLabel(row.managerUserId)
},
{
prop: 'directionCode',
label: '产品方向',
minWidth: 140,
showOverflowTooltip: true,
formatter: row => getDirectionLabel(row.directionCode)
},
{
prop: 'statusCode',
label: '管理状态',
width: 120,
align: 'center',
formatter: row => (
<ElTag type={getProductStatusTagType(row.statusCode)}>{getProductStatusLabel(row.statusCode)}</ElTag>
)
},
{
prop: 'lastStatusReason',
label: '状态原因',
minWidth: 180,
showOverflowTooltip: true,
formatter: row => row.lastStatusReason?.trim() || '--'
},
{
prop: 'updateTime',
label: '最近更新',
width: 170,
align: 'center',
formatter: row => formatDateTime(row.updateTime)
},
{
prop: 'operate',
label: '操作',
width: 108,
align: 'center',
fixed: 'right',
formatter: row => (
<BusinessTableActionCell
actions={[
{
key: 'edit',
label: '编辑',
buttonType: 'primary',
disabled: !isProductEditable(row.statusCode),
onClick: () => openEdit(row)
}
]}
/>
)
}
]
});
async function loadManagerOptions() {
const [allProducts, userSimpleResult] = await Promise.all([fetchAllProducts(), fetchGetUserSimpleList()]);
const userSimpleList =
userSimpleResult.error || !userSimpleResult.data ? [] : sortManagerOptions(userSimpleResult.data);
managerUserOptions.value = userSimpleList;
if (!allProducts) {
managerFilterOptions.value = [];
return;
}
managerFilterOptions.value = createManagerOptions(allProducts, userSimpleList);
}
async function loadOverviewData() {
const end = dayjs().endOf('day').format('YYYY-MM-DD HH:mm:ss');
const start = dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DD HH:mm:ss');
const [activeTotal, archivedTotal, pausedTotal, abandonedTotal, recentTotal] = await Promise.all([
fetchProductTotal({ pageNo: 1, pageSize: 1, statusCode: 'active' }),
fetchProductTotal({ pageNo: 1, pageSize: 1, statusCode: 'archived' }),
fetchProductTotal({ pageNo: 1, pageSize: 1, statusCode: 'paused' }),
fetchProductTotal({ pageNo: 1, pageSize: 1, statusCode: 'abandoned' }),
fetchProductTotal({ pageNo: 1, pageSize: 1, updateTime: [start, end] })
]);
statusCounts.value = {
active: activeTotal,
archived: archivedTotal,
paused: pausedTotal,
abandoned: abandonedTotal
};
recentUpdatedCount.value = recentTotal;
}
async function reloadProductTable(page = searchParams.pageNo ?? 1) {
await getDataByPage(page);
}
async function refreshPageData(page = searchParams.pageNo ?? 1) {
await Promise.all([loadManagerOptions(), loadOverviewData(), reloadProductTable(page)]);
}
async function handleSearch() {
await reloadProductTable(1);
}
async function handleResetSearch() {
const pageSize = searchParams.pageSize ?? 10;
Object.assign(searchParams, getInitSearchParams(), {
pageSize
});
await reloadProductTable(1);
}
async function handleStatusChange(status: Api.Product.ProductStatusCode) {
selectedStatus.value = status;
await reloadProductTable(1);
}
function openCreate() {
editingRow.value = null;
operateVisible.value = true;
}
function openEdit(row: Api.Product.Product) {
editingRow.value = row;
operateVisible.value = true;
}
async function enterProductContext(row: Api.Product.Product) {
await routerPush({
path: PRODUCT_ENTRY_ROUTE_PATH,
query: {
[OBJECT_CONTEXT_QUERY_KEY]: row.id
}
});
}
async function handleProductSubmitted(productId?: string) {
const isEditing = Boolean(productId && editingRow.value?.id === productId);
await refreshPageData(isEditing ? (searchParams.pageNo ?? 1) : 1);
if (isEditing) {
editingRow.value = null;
}
}
onMounted(async () => {
await refreshPageData();
});
</script>
<template>
<div
class="min-h-560px gap-16px overflow-hidden xl:grid xl:grid-cols-[396px_minmax(0,1fr)] lt-xl:flex lt-xl:flex-col lt-xl:overflow-auto"
>
<div class="flex-col-stretch gap-16px xl:min-h-0">
<ElCard class="product-overview-card card-wrapper">
<div class="product-overview-card__stats">
<div v-for="item in overviewMetrics" :key="item.label" class="product-overview-card__stat">
<span class="product-overview-card__stat-label">{{ item.label }}</span>
<strong class="product-overview-card__stat-value">{{ item.value }}</strong>
<small class="product-overview-card__stat-hint">{{ item.hint }}</small>
</div>
</div>
<div class="product-status-panel__list">
<button
v-for="item in statusItems"
:key="item.key"
type="button"
class="product-status-item"
:class="[`product-status-item--${item.tone}`, { 'is-active': selectedStatus === item.key }]"
:aria-pressed="selectedStatus === item.key"
@click="handleStatusChange(item.key)"
>
<div class="product-status-item__icon">
<ElIcon>
<component :is="item.icon" />
</ElIcon>
</div>
<div class="product-status-item__main">
<div class="product-status-item__top">
<strong>{{ item.label }}</strong>
<em>{{ item.count }}</em>
</div>
<p class="product-status-item__desc">{{ item.description }}</p>
</div>
</button>
</div>
</ElCard>
</div>
<div class="flex-col-stretch gap-16px xl:min-h-0">
<ProductSearch
v-model:model="searchParams"
:manager-options="managerFilterOptions"
@reset="handleResetSearch"
@search="handleSearch"
/>
<ElCard class="card-wrapper xl:flex-1-hidden" body-class="product-table-card-body">
<template #header>
<div class="product-card-header">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-8px">
<p class="truncate text-16px font-600">产品列表</p>
<ElTag effect="plain" :type="getProductStatusTagType(selectedStatus)">
{{
statusItems.find(item => item.key === selectedStatus)?.label ||
getProductStatusLabel(selectedStatus)
}}
</ElTag>
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
</div>
</div>
<TableHeaderOperation
v-model:columns="columnChecks"
:disabled-delete="true"
:loading="loading"
@refresh="refreshPageData"
>
<template #default>
<ElButton plain type="primary" @click="openCreate">
<template #icon>
<icon-ic-round-plus class="text-icon" />
</template>
新增
</ElButton>
</template>
</TableHeaderOperation>
</div>
</template>
<div class="flex-1">
<ElTable v-loading="loading" height="100%" border row-key="id" :data="data">
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
<template #empty>
<ElEmpty description="当前筛选条件下暂无产品" />
</template>
</ElTable>
</div>
<div class="mt-20px flex justify-end">
<ElPagination
v-if="mobilePagination.total"
layout="total,prev,pager,next,sizes"
v-bind="mobilePagination"
@current-change="mobilePagination['current-change']"
@size-change="mobilePagination['size-change']"
/>
</div>
</ElCard>
</div>
<ProductOperateDialog
v-model:visible="operateVisible"
:manager-user-options="managerUserOptions"
:row-data="editingRow"
@submitted="handleProductSubmitted"
/>
</div>
</template>
<style lang="scss" scoped>
.product-overview-card {
overflow: hidden;
border: 1px solid rgb(226 232 240 / 92%);
background:
radial-gradient(circle at top left, rgb(15 118 110 / 8%), transparent 36%),
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
}
.product-overview-card__stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.product-overview-card__stat {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 18px;
background-color: rgb(255 255 255 / 84%);
}
.product-overview-card__stat-label {
color: rgb(100 116 139 / 90%);
font-size: 13px;
}
.product-overview-card__stat-value {
color: rgb(15 23 42 / 94%);
font-size: 24px;
font-weight: 700;
line-height: 1.1;
}
.product-overview-card__stat-hint {
color: rgb(100 116 139 / 90%);
font-size: 12px;
line-height: 1.5;
}
.product-status-panel__list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
}
.product-status-item {
display: flex;
align-items: center;
gap: 14px;
width: 100%;
padding: 14px;
border: 1px solid rgb(226 232 240 / 90%);
border-radius: 18px;
background-color: rgb(255 255 255 / 86%);
text-align: left;
transition:
transform 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.product-status-item:hover {
transform: translateY(-1px);
border-color: rgb(148 163 184 / 60%);
}
.product-status-item.is-active {
border-color: rgb(15 118 110 / 40%);
box-shadow: 0 10px 24px rgb(15 118 110 / 8%);
}
.product-status-item__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 14px;
font-size: 20px;
}
.product-status-item__main {
min-width: 0;
flex: 1;
}
.product-status-item__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.product-status-item__top strong {
color: rgb(15 23 42 / 94%);
font-size: 15px;
font-weight: 700;
}
.product-status-item__top em {
color: rgb(15 23 42 / 88%);
font-size: 18px;
font-style: normal;
font-weight: 700;
}
.product-status-item__desc {
color: rgb(100 116 139 / 94%);
font-size: 13px;
line-height: 1.6;
}
.product-status-item--teal .product-status-item__icon {
background-color: rgb(240 253 250 / 96%);
color: rgb(15 118 110 / 96%);
}
.product-status-item--slate .product-status-item__icon {
background-color: rgb(241 245 249 / 96%);
color: rgb(51 65 85 / 92%);
}
.product-status-item--amber .product-status-item__icon {
background-color: rgb(255 251 235 / 96%);
color: rgb(217 119 6 / 92%);
}
.product-status-item--rose .product-status-item__icon {
background-color: rgb(255 241 242 / 96%);
color: rgb(225 29 72 / 92%);
}
:deep(.product-table-card-body) {
display: flex;
flex: 1;
flex-direction: column;
}
.product-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.product-name-link {
padding: 0;
}
@media (width <= 1280px) {
.product-card-header,
.product-status-item__top {
align-items: flex-start;
flex-direction: column;
}
}
@media (width <= 640px) {
.product-overview-card__stats {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,111 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import dayjs from 'dayjs';
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import { fetchGetProduct } from '@/service/api';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import DictText from '@/components/custom/dict-text.vue';
import { getProductStatusLabel, getProductStatusTagType } from '../../shared/product-master-data';
defineOptions({ name: 'ProductDetailDialog' });
interface Props {
rowData?: Api.Product.Product | null;
managerOptions: Api.SystemManage.UserSimple[];
}
const props = defineProps<Props>();
const visible = defineModel<boolean>('visible', {
default: false
});
const detailLoading = ref(false);
const detailData = ref<Api.Product.Product | null>(null);
const title = computed(() => {
return detailData.value?.name ? `产品详情 - ${detailData.value.name}` : '产品详情';
});
const managerLabelMap = computed(() => {
return new Map(props.managerOptions.map(item => [String(item.id), item.nickname]));
});
function getManagerLabel(managerUserId?: string | null) {
if (!managerUserId) {
return '--';
}
return managerLabelMap.value.get(String(managerUserId)) || String(managerUserId);
}
function formatTime(value?: string | null) {
if (!value) {
return '--';
}
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
}
async function initDetail() {
detailData.value = props.rowData ? { ...props.rowData } : null;
if (!props.rowData) {
return;
}
detailLoading.value = true;
const { error, data } = await fetchGetProduct(props.rowData.id);
detailLoading.value = false;
if (!error) {
detailData.value = data;
}
}
watch(visible, value => {
if (value) {
initDetail();
}
});
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="title"
preset="lg"
:loading="detailLoading"
:show-footer="false"
:scrollbar="false"
>
<template v-if="detailData">
<div class="mb-16px flex flex-wrap items-center gap-8px">
<ElTag>{{ detailData.code }}</ElTag>
<ElTag :type="getProductStatusTagType(detailData.statusCode)">
{{ getProductStatusLabel(detailData.statusCode) }}
</ElTag>
</div>
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="产品名称">{{ detailData.name }}</ElDescriptionsItem>
<ElDescriptionsItem label="产品方向">
<DictText :dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE" :value="detailData.directionCode" />
</ElDescriptionsItem>
<ElDescriptionsItem label="产品经理">{{ getManagerLabel(detailData.managerUserId) }}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{ formatTime(detailData.createTime) }}</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间">{{ formatTime(detailData.updateTime) }}</ElDescriptionsItem>
<ElDescriptionsItem label="最近状态原因">{{ detailData.lastStatusReason || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="产品描述" :span="2">
<span class="whitespace-pre-wrap">{{ detailData.description || '--' }}</span>
</ElDescriptionsItem>
</ElDescriptions>
</template>
<ElEmpty v-else description="未获取到产品详情" />
</BusinessFormDialog>
</template>
<style scoped></style>

View File

@@ -0,0 +1,140 @@
<script setup lang="ts">
import { computed } from 'vue';
import { type DemoProduct, getProductHealthType, getProductStatusType } from '@/constants/product-demo';
defineOptions({ name: 'ProductEntryCard' });
interface Props {
product: DemoProduct;
entering?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
entering: false
});
interface Emits {
(e: 'enter', product: DemoProduct): void;
}
const emit = defineEmits<Emits>();
const quickFacts = computed(() => [
{ label: '版本', value: props.product.version },
{ label: '目标发版', value: props.product.releaseTarget },
{ label: '团队规模', value: `${props.product.teamCount}` }
]);
function handleEnter() {
emit('enter', props.product);
}
</script>
<template>
<ElCard class="product-entry-card h-full">
<div class="mb-14px flex items-start justify-between gap-12px">
<div class="min-w-0">
<div class="mb-8px flex flex-wrap items-center gap-8px">
<span class="product-entry-card__code">{{ product.code }}</span>
<ElTag :type="getProductStatusType(product.status)" round>{{ product.status }}</ElTag>
<ElTag :type="getProductHealthType(product.health)" effect="dark" round>{{ product.health }}</ElTag>
</div>
<h3 class="mb-6px text-18px text-[#0f172a] font-700">{{ product.name }}</h3>
<p class="text-13px text-[#64748b]">负责人{{ product.owner }} / 阶段{{ product.stage }}</p>
</div>
<div class="product-entry-card__pulse"></div>
</div>
<p class="mb-14px min-h-[66px] text-14px text-[#475569] leading-22px">{{ product.summary }}</p>
<div class="mb-14px flex flex-wrap gap-8px">
<ElTag v-for="tag in product.tags" :key="tag" effect="plain" round>{{ tag }}</ElTag>
</div>
<div class="grid mb-14px gap-10px sm:grid-cols-3">
<div v-for="item in quickFacts" :key="item.label" class="product-entry-card__fact">
<span class="product-entry-card__fact-label">{{ item.label }}</span>
<strong class="product-entry-card__fact-value">{{ item.value }}</strong>
</div>
</div>
<div class="mb-16px rounded-16px bg-[#f8fafc] p-12px">
<p class="mb-8px text-12px text-[#94a3b8] tracking-[0.08em] uppercase">当前聚焦</p>
<div class="flex flex-wrap gap-8px">
<span v-for="item in product.focus" :key="item" class="product-entry-card__focus-chip">
{{ item }}
</span>
</div>
</div>
<div class="flex items-center justify-between gap-12px">
<div class="text-13px text-[#64748b]">
<span>需求 {{ product.requirementCount }}</span>
<span class="mx-8px text-[#cbd5e1]">|</span>
<span>缺陷 {{ product.bugCount }}</span>
</div>
<ElButton type="primary" :loading="entering" @click="handleEnter">进入产品</ElButton>
</div>
</ElCard>
</template>
<style scoped>
.product-entry-card {
border: 1px solid rgb(226 232 240 / 92%);
background:
radial-gradient(circle at top right, rgb(16 185 129 / 7%), transparent 28%),
linear-gradient(180deg, rgb(255 255 255 / 98%), rgb(248 250 252 / 96%));
}
.product-entry-card__code {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 8px;
border-radius: 999px;
background-color: rgb(15 23 42 / 92%);
color: #fff;
font-size: 11px;
letter-spacing: 0.08em;
}
.product-entry-card__pulse {
width: 12px;
height: 12px;
margin-top: 6px;
border-radius: 999px;
background: radial-gradient(circle, rgb(14 165 233 / 82%), rgb(14 165 233 / 16%));
box-shadow: 0 0 0 6px rgb(14 165 233 / 8%);
}
.product-entry-card__fact {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border-radius: 14px;
background-color: rgb(241 245 249 / 88%);
}
.product-entry-card__fact-label {
color: rgb(100 116 139 / 88%);
font-size: 12px;
}
.product-entry-card__fact-value {
color: rgb(15 23 42 / 90%);
font-size: 15px;
}
.product-entry-card__focus-chip {
display: inline-flex;
align-items: center;
height: 30px;
padding: 0 12px;
border: 1px dashed rgb(125 211 252 / 80%);
border-radius: 999px;
color: rgb(14 116 144 / 92%);
font-size: 12px;
background-color: rgb(236 254 255 / 88%);
}
</style>

View File

@@ -0,0 +1,267 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import { fetchCreateProduct, fetchGetProduct, fetchUpdateProduct } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import DictSelect from '@/components/custom/dict-select.vue';
defineOptions({ name: 'ProductOperateDialog' });
interface Props {
managerUserOptions: Api.SystemManage.UserSimple[];
rowData?: Api.Product.Product | null;
}
const props = defineProps<Props>();
interface Emits {
(e: 'submitted', productId?: string): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
interface Model {
code: string;
directionCode: string;
name: string;
managerUserId: string | null;
description: string;
}
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const isEditMode = computed(() => Boolean(props.rowData?.id));
const dialogTitle = computed(() => (isEditMode.value ? '编辑产品' : '新增产品'));
const submitting = ref(false);
const loading = ref(false);
const model = ref<Model>(createDefaultModel());
const managerLabelMap = computed(() => new Map(props.managerUserOptions.map(item => [String(item.id), item.nickname])));
const managerDisplayName = computed(() => {
const managerUserId = model.value.managerUserId;
if (!managerUserId) {
return '';
}
return managerLabelMap.value.get(String(managerUserId)) || String(managerUserId);
});
const rules = {
directionCode: [createRequiredRule('请选择产品方向')],
name: [createRequiredRule('请输入产品名称')],
managerUserId: [createRequiredRule('请选择产品经理')]
} satisfies Record<string, App.Global.FormRule[]>;
function createDefaultModel(): Model {
return {
code: '',
directionCode: '',
name: '',
managerUserId: null,
description: ''
};
}
function getNullableText(value?: string | null) {
return value?.trim() || null;
}
function closeDialog() {
visible.value = false;
}
async function handleSubmit() {
await validate();
const managerUserId = model.value.managerUserId;
if (!managerUserId) {
return;
}
const payload: Api.Product.SaveProductParams = {
code: getNullableText(model.value.code),
directionCode: model.value.directionCode,
name: model.value.name.trim(),
// Long ID 必须以 string 提交,禁止再转成 number。
managerUserId,
description: getNullableText(model.value.description)
};
submitting.value = true;
if (isEditMode.value && props.rowData?.id) {
const result = await fetchUpdateProduct({
id: props.rowData.id,
...payload
});
submitting.value = false;
if (result.error) {
return;
}
window.$message?.success('产品编辑成功');
closeDialog();
emit('submitted', props.rowData.id);
return;
}
const result = await fetchCreateProduct(payload);
submitting.value = false;
if (result.error) {
return;
}
window.$message?.success('产品新增成功');
closeDialog();
emit('submitted', result.data);
}
watch(visible, async value => {
if (!value) {
return;
}
if (!isEditMode.value || !props.rowData?.id) {
model.value = createDefaultModel();
await nextTick();
formRef.value?.clearValidate();
return;
}
loading.value = true;
const { error, data } = await fetchGetProduct(props.rowData.id);
loading.value = false;
if (error || !data) {
return;
}
model.value = {
code: data.code || '',
directionCode: data.directionCode || '',
name: data.name || '',
managerUserId: data.managerUserId || null,
description: data.description || ''
};
await nextTick();
formRef.value?.clearValidate();
});
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="dialogTitle"
preset="lg"
:loading="loading"
:confirm-loading="submitting"
@confirm="handleSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem v-if="isEditMode" label="产品编码" prop="code">
<ElInput
:model-value="model.code"
readonly
class="product-operate-dialog__readonly-input"
placeholder="未获取到产品编码"
/>
</ElFormItem>
<ElFormItem v-else label="产品编码" prop="code">
<ElInput v-model="model.code" clearable placeholder="不填则由后端自动生成" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="产品名称" prop="name">
<ElInput v-model="model.name" clearable maxlength="128" placeholder="请输入产品名称" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="产品方向" prop="directionCode">
<DictSelect
v-model="model.directionCode"
:dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE"
filterable
placeholder="请选择产品方向"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem v-if="isEditMode">
<template #label>
<span class="business-form-label-with-tip">
<ElTooltip
content="如需调整产品经理,请到产品内的团队管理处处理。"
popper-class="business-form-label-tooltip"
placement="top-start"
>
<span class="business-form-label-tip">
<icon-fe:question />
</span>
</ElTooltip>
<span>产品经理</span>
</span>
</template>
<ElInput
:model-value="managerDisplayName"
readonly
class="product-operate-dialog__readonly-input"
placeholder="未配置产品经理"
/>
</ElFormItem>
<ElFormItem v-else label="产品经理" prop="managerUserId">
<ElSelect v-model="model.managerUserId" clearable filterable placeholder="请选择产品经理">
<ElOption v-for="item in managerUserOptions" :key="item.id" :label="item.nickname" :value="item.id" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="产品描述" prop="description">
<ElInput
v-model="model.description"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入产品描述"
/>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
:deep(.product-operate-dialog__readonly-input .el-input__wrapper) {
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
cursor: default;
}
:deep(.product-operate-dialog__readonly-input .el-input__wrapper:hover),
:deep(.product-operate-dialog__readonly-input.is-focus .el-input__wrapper) {
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
}
:deep(.product-operate-dialog__readonly-input .el-input__inner) {
color: rgb(51 65 85 / 96%);
cursor: default;
-webkit-text-fill-color: rgb(51 65 85 / 96%);
}
</style>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import DictSelect from '@/components/custom/dict-select.vue';
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
defineOptions({ name: 'ProductSearch' });
interface Props {
managerOptions: Api.SystemManage.UserSimple[];
}
defineProps<Props>();
interface Emits {
(e: 'reset'): void;
(e: 'search'): void;
}
const emit = defineEmits<Emits>();
const model = defineModel<Api.Product.ProductSearchParams>('model', { required: true });
function reset() {
emit('reset');
}
function search() {
emit('search');
}
</script>
<template>
<TableSearchPanel :model="model" :action-col-lg="6" @reset="reset" @search="search">
<ElCol :lg="6" :md="12" :sm="12">
<ElFormItem label="关键词">
<ElInput v-model="model.keyword" clearable placeholder="产品名称 / 编号" />
</ElFormItem>
</ElCol>
<ElCol :lg="6" :md="12" :sm="12">
<ElFormItem label="产品经理">
<ElSelect v-model="model.managerUserId" clearable filterable placeholder="筛选产品经理">
<ElOption v-for="item in managerOptions" :key="item.id" :label="item.nickname" :value="item.id" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :lg="6" :md="12" :sm="12">
<ElFormItem label="产品方向">
<DictSelect
v-model="model.directionCode"
:dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE"
filterable
placeholder="筛选产品方向"
/>
</ElFormItem>
</ElCol>
</TableSearchPanel>
</template>
<style scoped></style>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
defineOptions({ name: 'ProductRequirement' });
</script>
<template>
<h1>待开发</h1>
</template>

View File

@@ -0,0 +1,505 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useMediaQuery } from '@vueuse/core';
import { LAYOUT_SCROLL_EL_ID } from '@sa/materials';
import { objectContextDomainConfigs } from '@/constants/object-context';
import {
fetchChangeProductStatus,
fetchCreateProductMember,
fetchDeleteProduct,
fetchGetProductMembers,
fetchGetProductSettings,
fetchGetRoleSimpleList,
fetchGetUserSimpleList,
fetchInactiveProductMember,
fetchUpdateProductMember,
fetchUpdateProductSettingBaseInfo
} from '@/service/api';
import { useAuthStore } from '@/store/modules/auth';
import { useObjectContextStore } from '@/store/modules/object-context';
import { useThemeStore } from '@/store/modules/theme';
import { useRouterPush } from '@/hooks/common/router';
import { useCurrentProduct } from '../shared/use-current-product';
import BaseInfoDialog from './modules/base-info-dialog.vue';
import MemberOperateDialog from './modules/member-operate-dialog.vue';
import MemberRemoveDialog from './modules/member-remove-dialog.vue';
import ProductDeleteDialog from './modules/product-delete-dialog.vue';
import SettingAnchorNav from './modules/setting-anchor-nav.vue';
import SettingBaseInfoCard from './modules/setting-base-info-card.vue';
import SettingDangerZone from './modules/setting-danger-zone.vue';
import SettingLifecyclePanel from './modules/setting-lifecycle-panel.vue';
import SettingTeamPanel from './modules/setting-team-panel.vue';
import StatusActionDialog from './modules/status-action-dialog.vue';
import {
type ProductSettingSectionKey,
canManageProductTeam,
getProductSettingSectionKeys,
resolveVisibleProductSettingSectionKey,
resolveVisibleProductSettingSections
} from './shared';
defineOptions({ name: 'ProductSetting' });
const authStore = useAuthStore();
const objectContextStore = useObjectContextStore();
const themeStore = useThemeStore();
const { routerPush } = useRouterPush();
const { currentObjectId, currentProduct } = useCurrentProduct();
const isCompactLayout = useMediaQuery('(max-width: 1280px)');
const productDomainConfig = objectContextDomainConfigs.find(config => config.domainKey === 'product') || null;
const allAnchorItems = [
{ key: 'base-info', label: '基础信息' },
{ key: 'team', label: '团队管理' },
{ key: 'lifecycle', label: '生命周期管理' },
{ key: 'danger', label: '危险操作' }
] as const;
const anchorLabelMap = new Map(allAnchorItems.map(item => [item.key, item.label]));
const sectionIdMap: Record<ProductSettingSectionKey, string> = {
'base-info': 'product-setting-base-info',
team: 'product-setting-team',
lifecycle: 'product-setting-lifecycle',
danger: 'product-setting-danger'
};
const activeAnchorKey = ref<ProductSettingSectionKey>('base-info');
const pageLoading = ref(false);
const memberLoading = ref(false);
const baseInfoVisible = ref(false);
const memberOperateVisible = ref(false);
const memberRemoveVisible = ref(false);
const statusActionVisible = ref(false);
const deleteVisible = ref(false);
const memberOperateMode = ref<'create' | 'edit'>('create');
const selectedMember = ref<Api.Product.ProductMember | null>(null);
const selectedAction = ref<Api.Product.ProductLifecycleAction | null>(null);
const settings = ref<Api.Product.ProductSettings | null>(null);
const members = ref<Api.Product.ProductMember[]>([]);
const roleOptions = ref<Api.SystemManage.RoleSimple[]>([]);
const userOptions = ref<Api.SystemManage.UserSimple[]>([]);
const currentManager = computed(() => members.value.find(item => item.managerFlag && item.status === 0) || null);
const baseInfo = computed(() => settings.value?.baseInfo || null);
const lifecycle = computed(() => settings.value?.lifecycle || null);
const canManageTeam = computed(() =>
canManageProductTeam({
buttonCodes: objectContextStore.buttonCodes,
loginUserId: authStore.userInfo.userId,
currentManagerUserId: currentManager.value?.userId
})
);
const visibleSectionKeys = computed(() =>
resolveVisibleProductSettingSections(getProductSettingSectionKeys(), objectContextStore.buttonCodes)
);
const anchorItems = computed(() =>
visibleSectionKeys.value.map(key => ({
key,
label: anchorLabelMap.get(key) || key
}))
);
const layoutScrollTarget = `#${LAYOUT_SCROLL_EL_ID}`;
const anchorAffixOffset = computed(() => {
const fixedTopInset = themeStore.fixedHeaderAndTab
? themeStore.header.height + (themeStore.tabVisible ? themeStore.tab.height : 0)
: 0;
return fixedTopInset + 16;
});
const anchorShellInlineStyle = computed(() => ({
maxHeight: isCompactLayout.value ? '' : `calc(100vh - ${anchorAffixOffset.value + 16}px)`
}));
const showLifecycleSection = computed(() => visibleSectionKeys.value.includes('lifecycle'));
const showDangerSection = computed(() => visibleSectionKeys.value.includes('danger'));
async function loadSettings() {
if (!currentObjectId.value) {
settings.value = null;
return;
}
const { error, data } = await fetchGetProductSettings(currentObjectId.value);
if (error || !data) {
settings.value = null;
return;
}
settings.value = data;
}
async function loadMembers() {
if (!currentObjectId.value) {
members.value = [];
return;
}
memberLoading.value = true;
const { error, data } = await fetchGetProductMembers(currentObjectId.value);
memberLoading.value = false;
if (error || !data) {
members.value = [];
return;
}
members.value = data;
}
async function loadRoleOptions() {
const { error, data } = await fetchGetRoleSimpleList({
scopeType: 'object',
objectType: 'product'
});
if (error || !data) {
roleOptions.value = [];
return;
}
roleOptions.value = data;
}
async function loadUserOptions() {
const { error, data } = await fetchGetUserSimpleList();
if (error || !data) {
userOptions.value = [];
return;
}
userOptions.value = data;
}
async function refreshContextSummary() {
if (!productDomainConfig || !currentObjectId.value) {
return;
}
await objectContextStore.enterContext(productDomainConfig, currentObjectId.value);
}
async function loadPageData() {
if (!currentObjectId.value) {
return;
}
pageLoading.value = true;
await Promise.all([loadSettings(), loadMembers(), loadRoleOptions(), loadUserOptions()]);
pageLoading.value = false;
}
function scrollToSection(key: string) {
if (!(key in sectionIdMap)) {
return;
}
const resolvedKey = key as ProductSettingSectionKey;
if (!visibleSectionKeys.value.includes(resolvedKey)) {
return;
}
activeAnchorKey.value = resolvedKey;
const target = document.getElementById(sectionIdMap[resolvedKey]);
target?.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
function openCreateMember() {
memberOperateMode.value = 'create';
selectedMember.value = null;
memberOperateVisible.value = true;
}
function openEditMember(member: Api.Product.ProductMember) {
memberOperateMode.value = 'edit';
selectedMember.value = member;
memberOperateVisible.value = true;
}
function openRemoveMember(member: Api.Product.ProductMember) {
selectedMember.value = member;
memberRemoveVisible.value = true;
}
function openLifecycleAction(action: Api.Product.ProductLifecycleAction) {
selectedAction.value = action;
statusActionVisible.value = true;
}
async function handleSubmitBaseInfo(payload: Api.Product.UpdateProductSettingBaseInfoParams) {
if (!currentObjectId.value) {
return;
}
const result = await fetchUpdateProductSettingBaseInfo(currentObjectId.value, payload);
if (result.error) {
return;
}
window.$message?.success('基础信息更新成功');
baseInfoVisible.value = false;
await Promise.all([loadSettings(), refreshContextSummary()]);
}
async function handleSubmitMemberOperate(event: {
mode: 'create' | 'edit';
memberId?: string;
managerChanged: boolean;
payload: Api.Product.CreateProductMemberParams | Api.Product.UpdateProductMemberParams;
}) {
if (!currentObjectId.value) {
return;
}
const result =
event.mode === 'create'
? await fetchCreateProductMember(currentObjectId.value, event.payload as Api.Product.CreateProductMemberParams)
: await fetchUpdateProductMember(
currentObjectId.value,
event.memberId || '',
event.payload as Api.Product.UpdateProductMemberParams
);
if (result.error) {
return;
}
window.$message?.success(event.mode === 'create' ? '成员新增成功' : '成员角色调整成功');
memberOperateVisible.value = false;
await Promise.all([loadMembers(), loadSettings()]);
if (event.managerChanged) {
await refreshContextSummary();
}
}
async function handleSubmitRemoveMember(payload: Api.Product.InactiveProductMemberParams) {
if (!currentObjectId.value || !selectedMember.value?.id) {
return;
}
const result = await fetchInactiveProductMember(currentObjectId.value, selectedMember.value.id, payload);
if (result.error) {
return;
}
window.$message?.success('成员移出成功');
memberRemoveVisible.value = false;
await Promise.all([loadMembers(), loadSettings()]);
}
async function handleSubmitLifecycleAction(payload: Api.Product.ChangeProductStatusParams) {
if (!currentObjectId.value || !selectedAction.value) {
return;
}
const result = await fetchChangeProductStatus({
...payload,
id: currentObjectId.value
});
if (result.error) {
return;
}
window.$message?.success(`${selectedAction.value.actionName}成功`);
statusActionVisible.value = false;
await Promise.all([loadSettings(), refreshContextSummary()]);
}
async function handleSubmitDelete(payload: Api.Product.DeleteProductParams) {
const result = await fetchDeleteProduct(payload);
if (result.error) {
return;
}
window.$message?.success('产品删除成功');
deleteVisible.value = false;
objectContextStore.clearContext();
await routerPush({
path: '/product/list'
});
}
watch(
visibleSectionKeys,
sectionKeys => {
activeAnchorKey.value = resolveVisibleProductSettingSectionKey(activeAnchorKey.value, sectionKeys);
},
{
immediate: true
}
);
watch(
() => currentObjectId.value,
async objectId => {
if (!objectId) {
settings.value = null;
members.value = [];
return;
}
await loadPageData();
},
{ immediate: true }
);
</script>
<template>
<div v-loading="pageLoading" class="product-setting-page">
<div class="product-setting-page__body">
<div class="product-setting-page__aside">
<div v-if="isCompactLayout" class="product-setting-page__aside-shell" :style="anchorShellInlineStyle">
<SettingAnchorNav :items="anchorItems" :active-key="activeAnchorKey" @select="scrollToSection" />
</div>
<ElAffix
v-else
class="product-setting-page__aside-affix"
:offset="anchorAffixOffset"
:target="layoutScrollTarget"
teleported
>
<div class="product-setting-page__aside-shell" :style="anchorShellInlineStyle">
<SettingAnchorNav :items="anchorItems" :active-key="activeAnchorKey" @select="scrollToSection" />
</div>
</ElAffix>
</div>
<div class="product-setting-page__content">
<section :id="sectionIdMap['base-info']" class="product-setting-page__section">
<SettingBaseInfoCard :base-info="baseInfo" @edit="baseInfoVisible = true" />
</section>
<section :id="sectionIdMap.team" class="product-setting-page__section">
<SettingTeamPanel
:members="members"
:role-options="roleOptions"
:loading="memberLoading"
:readonly="!canManageTeam"
@create="openCreateMember"
@edit="openEditMember"
@remove="openRemoveMember"
/>
</section>
<section v-if="showLifecycleSection" :id="sectionIdMap.lifecycle" class="product-setting-page__section">
<SettingLifecyclePanel :lifecycle="lifecycle" @action="openLifecycleAction" />
</section>
<section v-if="showDangerSection" :id="sectionIdMap.danger" class="product-setting-page__section">
<SettingDangerZone
:product-name="baseInfo?.name || currentProduct?.name || ''"
@delete="deleteVisible = true"
/>
</section>
</div>
</div>
<BaseInfoDialog v-model:visible="baseInfoVisible" :base-info="baseInfo" @submit="handleSubmitBaseInfo" />
<MemberOperateDialog
v-model:visible="memberOperateVisible"
:mode="memberOperateMode"
:member="selectedMember"
:current-manager="currentManager"
:role-options="roleOptions"
:user-options="userOptions"
@submit="handleSubmitMemberOperate"
/>
<MemberRemoveDialog
v-model:visible="memberRemoveVisible"
:member="selectedMember"
@submit="handleSubmitRemoveMember"
/>
<StatusActionDialog
v-model:visible="statusActionVisible"
:action="selectedAction"
@submit="handleSubmitLifecycleAction"
/>
<ProductDeleteDialog
v-model:visible="deleteVisible"
:product-id="currentObjectId"
:product-name="baseInfo?.name || currentProduct?.name || ''"
@submit="handleSubmitDelete"
/>
</div>
</template>
<style scoped>
.product-setting-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.product-setting-page__body {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 16px;
align-items: stretch;
}
.product-setting-page__aside {
min-width: 0;
align-self: stretch;
}
.product-setting-page__aside-affix {
display: block;
width: 100%;
}
.product-setting-page__aside-shell {
min-height: 100%;
padding: 18px 16px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 20px;
background:
radial-gradient(circle at top left, rgb(15 118 110 / 7%), transparent 34%),
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
box-sizing: border-box;
overflow-y: auto;
scrollbar-gutter: stable;
}
.product-setting-page__content {
display: flex;
flex-direction: column;
gap: 16px;
}
.product-setting-page__section {
scroll-margin-top: 16px;
}
@media (width <= 1280px) {
.product-setting-page__body {
grid-template-columns: 1fr;
}
.product-setting-page__aside-shell {
min-height: auto;
overflow: visible;
}
}
</style>

View File

@@ -0,0 +1,218 @@
<script setup lang="ts">
import { computed, nextTick, reactive, watch } from 'vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import { useDict } from '@/hooks/business/dict';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import DictSelect from '@/components/custom/dict-select.vue';
import { getProductBaseInfoReadonlyMessage, isProductBaseInfoEditable } from '../shared';
defineOptions({ name: 'BaseInfoDialog' });
interface Props {
baseInfo: Api.Product.ProductSettingBaseInfo | null;
}
interface Emits {
(e: 'submit', payload: Api.Product.UpdateProductSettingBaseInfoParams): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { getLabel: getDirectionLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const model = reactive<Api.Product.UpdateProductSettingBaseInfoParams>({
directionCode: '',
name: '',
description: ''
});
const baseInfoEditable = computed(() => isProductBaseInfoEditable(props.baseInfo?.statusCode));
const readonlyMessage = computed(() => getProductBaseInfoReadonlyMessage(props.baseInfo?.statusCode));
const confirmDisabled = computed(() => {
if (!props.baseInfo) {
return true;
}
return !baseInfoEditable.value;
});
const directionDisplayName = computed(() => {
const directionCode = props.baseInfo?.directionCode;
if (!directionCode) {
return '';
}
return getDirectionLabel(directionCode, directionCode);
});
const rules = {
directionCode: [createRequiredRule('请选择产品方向')],
name: [createRequiredRule('请输入产品名称')]
} satisfies Record<string, App.Global.FormRule[]>;
async function handleConfirm() {
if (confirmDisabled.value) {
return;
}
await validate();
emit('submit', {
directionCode: model.directionCode,
name: model.name.trim(),
description: model.description?.trim() || null
});
}
watch(
() => visible.value,
async value => {
if (!value || !props.baseInfo) {
return;
}
model.directionCode = props.baseInfo.directionCode || '';
model.name = props.baseInfo.name || '';
model.description = props.baseInfo.description || '';
await nextTick();
formRef.value?.clearValidate();
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
title="编辑基础信息"
preset="lg"
:confirm-disabled="confirmDisabled"
@confirm="handleConfirm"
>
<ElAlert v-if="readonlyMessage" :title="readonlyMessage" type="warning" :closable="false" class="mb-16px" />
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="产品编码">
<ElInput :model-value="baseInfo?.code || ''" readonly class="base-info-dialog__readonly-input" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem>
<template #label>
<span class="business-form-label-with-tip">
<ElTooltip
content="如需调整产品经理,请到产品内的团队管理处处理。"
popper-class="business-form-label-tooltip"
placement="top-start"
>
<span class="business-form-label-tip">
<icon-fe:question />
</span>
</ElTooltip>
<span>产品经理</span>
</span>
</template>
<ElInput
:model-value="baseInfo?.managerUserNickname || baseInfo?.managerUserId || ''"
readonly
class="base-info-dialog__readonly-input"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="产品名称" prop="name">
<ElInput v-if="baseInfoEditable" v-model="model.name" maxlength="128" placeholder="请输入产品名称" />
<ElInput
v-else
:model-value="model.name"
readonly
class="base-info-dialog__readonly-input"
placeholder="未获取到产品名称"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="产品方向" prop="directionCode">
<DictSelect
v-if="baseInfoEditable"
v-model="model.directionCode"
:dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE"
filterable
placeholder="请选择产品方向"
/>
<ElInput
v-else
:model-value="directionDisplayName"
readonly
class="base-info-dialog__readonly-input"
placeholder="未获取到产品方向"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="产品描述">
<ElInput
v-if="baseInfoEditable"
v-model="model.description"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入产品描述"
/>
<ElInput
v-else
:model-value="model.description"
type="textarea"
:rows="4"
readonly
class="base-info-dialog__readonly-input"
placeholder="未填写产品描述"
/>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
:deep(.base-info-dialog__readonly-input .el-input__wrapper) {
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
cursor: default;
}
:deep(.base-info-dialog__readonly-input .el-textarea__inner) {
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
cursor: default;
resize: none;
}
:deep(.base-info-dialog__readonly-input .el-input__wrapper:hover),
:deep(.base-info-dialog__readonly-input.is-focus .el-input__wrapper),
:deep(.base-info-dialog__readonly-input .el-textarea__inner:hover),
:deep(.base-info-dialog__readonly-input .el-textarea__inner:focus) {
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
}
:deep(.base-info-dialog__readonly-input .el-input__inner),
:deep(.base-info-dialog__readonly-input .el-textarea__inner) {
color: rgb(51 65 85 / 96%);
cursor: default;
-webkit-text-fill-color: rgb(51 65 85 / 96%);
}
</style>

View File

@@ -0,0 +1,203 @@
<script setup lang="ts">
import { computed, nextTick, reactive, watch } from 'vue';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import { getPreviousManagerRoleOptions, shouldRequireManagerHandover } from '../shared';
defineOptions({ name: 'MemberOperateDialog' });
type OperateMode = 'create' | 'edit';
interface Props {
mode: OperateMode;
member: Api.Product.ProductMember | null;
currentManager: Api.Product.ProductMember | null;
roleOptions: Api.SystemManage.RoleSimple[];
userOptions: Api.SystemManage.UserSimple[];
}
interface SubmitPayload {
mode: OperateMode;
memberId?: string;
managerChanged: boolean;
payload: Api.Product.CreateProductMemberParams | Api.Product.UpdateProductMemberParams;
}
interface Emits {
(e: 'submit', payload: SubmitPayload): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
interface Model {
userId: string;
roleId: string;
remark: string;
reason: string;
previousManagerRoleId: string;
}
const model = reactive<Model>({
userId: '',
roleId: '',
remark: '',
reason: '',
previousManagerRoleId: ''
});
const dialogTitle = computed(() => (props.mode === 'create' ? '新增团队成员' : '调整成员角色'));
const selectedUserId = computed(() => (props.mode === 'create' ? model.userId : props.member?.userId || ''));
const showManagerHandover = computed(() => {
return (
shouldRequireManagerHandover(model.roleId, props.currentManager) &&
Boolean(selectedUserId.value) &&
selectedUserId.value !== props.currentManager?.userId
);
});
const previousManagerRoleOptions = computed(() =>
getPreviousManagerRoleOptions(props.roleOptions, props.currentManager?.roleId || '')
);
const userLabelMap = computed(() => new Map(props.userOptions.map(item => [item.id, item.nickname])));
const rules = computed(
() =>
({
userId: props.mode === 'create' ? [createRequiredRule('请选择成员用户')] : [],
roleId: [createRequiredRule('请选择角色')],
previousManagerRoleId: showManagerHandover.value ? [createRequiredRule('请选择原产品经理交接后角色')] : []
}) satisfies Record<string, App.Global.FormRule[]>
);
async function handleConfirm() {
await validate();
const sharedPayload = {
roleId: model.roleId,
remark: model.remark.trim() || null,
previousManagerUserId: showManagerHandover.value ? props.currentManager?.userId || null : null,
previousManagerRoleId: showManagerHandover.value ? model.previousManagerRoleId : null
};
if (props.mode === 'create') {
emit('submit', {
mode: 'create',
managerChanged: showManagerHandover.value,
payload: {
userId: model.userId,
roleId: sharedPayload.roleId,
remark: sharedPayload.remark,
previousManagerUserId: sharedPayload.previousManagerUserId,
previousManagerRoleId: sharedPayload.previousManagerRoleId
}
});
return;
}
emit('submit', {
mode: 'edit',
memberId: props.member?.id,
managerChanged: showManagerHandover.value,
payload: {
roleId: sharedPayload.roleId,
remark: sharedPayload.remark,
reason: model.reason.trim() || null,
previousManagerUserId: sharedPayload.previousManagerUserId,
previousManagerRoleId: sharedPayload.previousManagerRoleId
}
});
}
watch(
() => visible.value,
async value => {
if (!value) {
return;
}
model.userId = props.mode === 'create' ? '' : props.member?.userId || '';
model.roleId = props.mode === 'create' ? '' : props.member?.roleId || '';
model.remark = props.mode === 'create' ? '' : props.member?.remark || '';
model.reason = '';
model.previousManagerRoleId = '';
await nextTick();
formRef.value?.clearValidate();
}
);
</script>
<template>
<BusinessFormDialog v-model="visible" :title="dialogTitle" preset="lg" @confirm="handleConfirm">
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<BusinessFormSection title="成员信息">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem v-if="mode === 'create'" label="成员用户" prop="userId">
<ElSelect v-model="model.userId" class="w-full" filterable placeholder="请选择成员用户">
<ElOption v-for="item in userOptions" :key="item.id" :label="item.nickname" :value="item.id" />
</ElSelect>
</ElFormItem>
<ElFormItem v-else label="成员用户">
<ElInput :model-value="member?.userNickname || userLabelMap.get(member?.userId || '') || ''" readonly />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="目标角色" prop="roleId">
<ElSelect v-model="model.roleId" class="w-full" placeholder="请选择角色">
<ElOption v-for="item in roleOptions" :key="item.id" :label="item.name" :value="item.id" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="备注">
<ElInput
v-model="model.remark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请输入备注"
/>
</ElFormItem>
</ElCol>
</ElRow>
</BusinessFormSection>
<BusinessFormSection v-if="mode === 'edit'" title="角色调整说明">
<ElFormItem label="变更原因">
<ElInput
v-model="model.reason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请输入变更原因"
/>
</ElFormItem>
</BusinessFormSection>
<BusinessFormSection v-if="showManagerHandover" title="产品经理交接">
<ElAlert
:title="`当前产品经理 ${currentManager?.userNickname || currentManager?.userId || ''} 将完成交接,请选择其交接后角色。`"
type="warning"
:closable="false"
class="mb-16px"
/>
<ElFormItem label="原产品经理交接后角色" prop="previousManagerRoleId">
<ElSelect v-model="model.previousManagerRoleId" class="w-full" placeholder="请选择原产品经理交接后角色">
<ElOption v-for="item in previousManagerRoleOptions" :key="item.id" :label="item.name" :value="item.id" />
</ElSelect>
</ElFormItem>
</BusinessFormSection>
</ElForm>
</BusinessFormDialog>
</template>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { reactive, watch } from 'vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'MemberRemoveDialog' });
interface Props {
member: Api.Product.ProductMember | null;
}
interface Emits {
(e: 'submit', payload: Api.Product.InactiveProductMemberParams): void;
}
defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const model = reactive({
reason: ''
});
function handleConfirm() {
emit('submit', {
reason: model.reason.trim() || null
});
}
watch(
() => visible.value,
value => {
if (!value) {
return;
}
model.reason = '';
}
);
</script>
<template>
<BusinessFormDialog v-model="visible" title="移出成员" preset="sm" @confirm="handleConfirm">
<ElAlert
:title="`确认将 ${member?.userNickname || member?.userId || '--'} 从当前产品团队中移出吗?`"
type="warning"
:closable="false"
class="mb-16px"
/>
<ElForm label-position="top">
<ElFormItem label="移出原因">
<ElInput
v-model="model.reason"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入移出原因"
/>
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'ProductDeleteDialog' });
interface Props {
productId: string;
productName: string;
}
interface Emits {
(e: 'submit', payload: Api.Product.DeleteProductParams): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const model = reactive({
confirmName: '',
reason: ''
});
const confirmDisabled = computed(() => {
return !model.reason.trim() || model.confirmName.trim() !== props.productName;
});
function handleConfirm() {
emit('submit', {
id: props.productId,
productName: model.confirmName.trim(),
reason: model.reason.trim()
});
}
watch(
() => visible.value,
value => {
if (!value) {
return;
}
model.confirmName = '';
model.reason = '';
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
title="删除产品"
preset="sm"
:confirm-disabled="confirmDisabled"
confirm-text="确认删除"
@confirm="handleConfirm"
>
<ElAlert
:title="`请输入当前产品名称 ${productName || '--'} 完成二次确认,删除后将退出当前对象上下文。`"
type="error"
:closable="false"
class="mb-16px"
/>
<ElForm label-position="top">
<ElFormItem label="删除确认名称">
<ElInput v-model="model.confirmName" placeholder="请输入当前产品名称" />
</ElFormItem>
<ElFormItem label="删除原因">
<ElInput
v-model="model.reason"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入删除原因"
/>
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
defineOptions({ name: 'SettingAnchorNav' });
interface SettingAnchorItem {
key: string;
label: string;
}
interface Props {
items: readonly SettingAnchorItem[];
activeKey: string;
}
interface Emits {
(e: 'select', key: string): void;
}
defineProps<Props>();
const emit = defineEmits<Emits>();
</script>
<template>
<div class="setting-anchor-nav">
<div class="setting-anchor-nav__title">设置目录</div>
<div class="setting-anchor-nav__list">
<button
v-for="item in items"
:key="item.key"
type="button"
class="setting-anchor-nav__item"
:class="{ 'is-active': item.key === activeKey }"
@click="emit('select', item.key)"
>
{{ item.label }}
</button>
</div>
</div>
</template>
<style scoped>
.setting-anchor-nav {
display: flex;
flex-direction: column;
gap: 14px;
}
.setting-anchor-nav__title {
color: rgb(15 23 42 / 94%);
font-size: 15px;
font-weight: 700;
}
.setting-anchor-nav__list {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
}
.setting-anchor-nav__item {
display: flex;
align-items: center;
width: 100%;
min-height: 42px;
padding: 0 14px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 14px;
background-color: rgb(248 250 252 / 96%);
color: rgb(71 85 105 / 94%);
font-size: 14px;
text-align: left;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
color 0.2s ease,
transform 0.2s ease;
}
.setting-anchor-nav__item:hover {
transform: translateY(-1px);
border-color: rgb(148 163 184 / 56%);
}
.setting-anchor-nav__item.is-active {
border-color: rgb(13 148 136 / 42%);
background-color: rgb(240 253 250 / 98%);
color: rgb(15 118 110 / 96%);
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import DictText from '@/components/custom/dict-text.vue';
import { getProductStatusLabel, getProductStatusTagType } from '../../shared/product-master-data';
import { isProductBaseInfoEditable } from '../shared';
defineOptions({ name: 'SettingBaseInfoCard' });
interface Props {
baseInfo: Api.Product.ProductSettingBaseInfo | null;
}
interface Emits {
(e: 'edit'): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const editDisabled = computed(() => {
if (!props.baseInfo) {
return true;
}
return !isProductBaseInfoEditable(props.baseInfo.statusCode);
});
</script>
<template>
<ElCard class="card-wrapper">
<template #header>
<div class="flex items-center justify-between gap-12px">
<div>
<h3 class="text-16px text-[#0f172a] font-700">基础信息</h3>
</div>
<ElButton
v-auth="{ code: 'project:product:update', source: 'object' }"
type="primary"
plain
:disabled="editDisabled"
@click="emit('edit')"
>
编辑基础信息
</ElButton>
</div>
</template>
<ElDescriptions v-if="baseInfo" :column="2" border>
<ElDescriptionsItem label="产品编码">{{ baseInfo.code || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="产品名称">{{ baseInfo.name || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="产品方向">
<DictText :dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE" :value="baseInfo.directionCode" />
</ElDescriptionsItem>
<ElDescriptionsItem label="产品经理">
{{ baseInfo.managerUserNickname || baseInfo.managerUserId || '--' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="当前状态">
<ElTag :type="getProductStatusTagType(baseInfo.statusCode)">
{{ getProductStatusLabel(baseInfo.statusCode) }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="最近状态原因">{{ baseInfo.lastStatusReason || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="产品描述" :span="2">
<div class="setting-base-info-card__description">{{ baseInfo.description || '--' }}</div>
</ElDescriptionsItem>
</ElDescriptions>
<ElEmpty v-else description="未获取到基础信息" />
</ElCard>
</template>
<style scoped>
.setting-base-info-card__description {
white-space: pre-wrap;
line-height: 1.7;
}
</style>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
defineOptions({ name: 'SettingDangerZone' });
interface Props {
productName: string;
}
interface Emits {
(e: 'delete'): void;
}
defineProps<Props>();
const emit = defineEmits<Emits>();
</script>
<template>
<ElCard class="setting-danger-zone card-wrapper">
<div class="setting-danger-zone__content">
<div class="min-w-0 flex-1">
<h3 class="text-16px text-[#7f1d1d] font-700">危险操作</h3>
<p class="mt-8px text-14px text-[#991b1b] leading-24px">
删除后将退出当前产品对象上下文并返回产品入口页删除时必须输入当前产品名称
<strong>{{ productName || '--' }}</strong>
进行二次确认
</p>
</div>
<ElButton
v-auth="{ code: 'project:product:delete', source: 'object' }"
type="danger"
plain
@click="emit('delete')"
>
删除产品
</ElButton>
</div>
</ElCard>
</template>
<style scoped>
.setting-danger-zone {
border: 1px solid rgb(254 202 202 / 96%);
background:
radial-gradient(circle at top right, rgb(254 226 226 / 96%), transparent 35%),
linear-gradient(180deg, rgb(255 255 255 / 98%), rgb(254 242 242 / 96%));
}
.setting-danger-zone__content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
</style>

View File

@@ -0,0 +1,393 @@
<script setup lang="ts">
import { computed } from 'vue';
import { getProductStatusLabel } from '../../shared/product-master-data';
import { getProductLifecycleActionCardMeta, getProductLifecycleStatusSummary } from '../shared';
defineOptions({ name: 'SettingLifecyclePanel' });
interface Props {
lifecycle: Api.Product.ProductLifecycleInfo | null;
}
interface Emits {
(e: 'action', action: Api.Product.ProductLifecycleAction): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const statusSummary = computed(() => {
if (!props.lifecycle) {
return null;
}
return getProductLifecycleStatusSummary(props.lifecycle.statusCode);
});
const actionCards = computed(() =>
(props.lifecycle?.availableActions || []).map(action => ({
...action,
...getProductLifecycleActionCardMeta(action.actionCode)
}))
);
</script>
<template>
<ElCard class="card-wrapper">
<template #header>
<div>
<h3 class="text-16px text-[#0f172a] font-700">生命周期管理</h3>
</div>
</template>
<template v-if="lifecycle">
<div class="setting-lifecycle-panel__layout">
<section
class="setting-lifecycle-panel__hero"
:class="[`setting-lifecycle-panel__hero--${statusSummary?.tone || 'slate'}`]"
>
<div class="setting-lifecycle-panel__hero-top">
<div class="setting-lifecycle-panel__hero-main">
<div class="setting-lifecycle-panel__hero-status-row">
<span class="setting-lifecycle-panel__hero-status-label">当前状态</span>
<span class="setting-lifecycle-panel__hero-status-chip">
{{ getProductStatusLabel(lifecycle.statusCode) }}
</span>
</div>
<h4 class="setting-lifecycle-panel__hero-title">{{ statusSummary?.caption }}</h4>
</div>
</div>
<p class="setting-lifecycle-panel__hero-desc">
{{ statusSummary?.description }}
</p>
<div class="setting-lifecycle-panel__reason-card">
<span class="setting-lifecycle-panel__reason-label">最近状态原因</span>
<strong class="setting-lifecycle-panel__reason-value">
{{ lifecycle.lastStatusReason || '当前没有记录状态原因。' }}
</strong>
</div>
</section>
<section class="setting-lifecycle-panel__action-panel">
<div class="setting-lifecycle-panel__action-head">
<h4 class="setting-lifecycle-panel__action-title">可执行动作</h4>
</div>
<div v-if="actionCards.length > 0" class="setting-lifecycle-panel__action-grid">
<button
v-for="action in actionCards"
:key="action.actionCode"
type="button"
class="setting-lifecycle-panel__action-card"
:class="[`setting-lifecycle-panel__action-card--${action.tone}`]"
@click="emit('action', action)"
>
<div class="setting-lifecycle-panel__action-card-top">
<span class="setting-lifecycle-panel__action-dot" aria-hidden="true"></span>
<strong class="setting-lifecycle-panel__action-name">{{ action.actionName }}</strong>
</div>
<p class="setting-lifecycle-panel__action-desc">{{ action.description }}</p>
</button>
</div>
<div v-else class="setting-lifecycle-panel__empty-tip">当前状态下暂无可执行生命周期动作</div>
</section>
</div>
</template>
<ElEmpty v-else description="未获取到生命周期信息" />
</ElCard>
</template>
<style scoped>
.setting-lifecycle-panel__layout {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr);
gap: 16px;
align-items: start;
}
.setting-lifecycle-panel__hero,
.setting-lifecycle-panel__action-panel {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 100%;
padding: 18px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 20px;
background-color: rgb(248 250 252 / 96%);
}
.setting-lifecycle-panel__hero {
overflow: hidden;
background:
radial-gradient(circle at top left, rgb(15 118 110 / 10%), transparent 34%),
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
}
.setting-lifecycle-panel__hero--emerald {
border-color: rgb(16 185 129 / 22%);
}
.setting-lifecycle-panel__hero--amber {
border-color: rgb(245 158 11 / 22%);
background:
radial-gradient(circle at top left, rgb(245 158 11 / 10%), transparent 34%),
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(255 251 235 / 97%));
}
.setting-lifecycle-panel__hero--slate {
border-color: rgb(100 116 139 / 22%);
background:
radial-gradient(circle at top left, rgb(100 116 139 / 10%), transparent 34%),
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
}
.setting-lifecycle-panel__hero--rose {
border-color: rgb(244 63 94 / 22%);
background:
radial-gradient(circle at top left, rgb(244 63 94 / 10%), transparent 34%),
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(255 241 242 / 97%));
}
.setting-lifecycle-panel__hero-top,
.setting-lifecycle-panel__action-head {
display: flex;
align-items: center;
gap: 16px;
}
.setting-lifecycle-panel__hero-main {
display: flex;
flex-direction: column;
gap: 10px;
}
.setting-lifecycle-panel__hero-status-row {
display: flex;
align-items: center;
gap: 10px;
}
.setting-lifecycle-panel__hero-status-label {
color: rgb(71 85 105 / 94%);
font-size: 12px;
font-weight: 600;
}
.setting-lifecycle-panel__hero-status-chip {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border: 1px solid transparent;
border-radius: 999px;
font-size: 13px;
font-weight: 700;
line-height: 1.2;
}
.setting-lifecycle-panel__hero-title {
color: rgb(15 23 42 / 96%);
font-size: 22px;
font-weight: 700;
line-height: 1.25;
}
.setting-lifecycle-panel__action-title {
color: rgb(15 23 42 / 96%);
font-size: 18px;
font-weight: 700;
line-height: 1.25;
}
.setting-lifecycle-panel__hero-desc {
max-width: 560px;
color: rgb(71 85 105 / 94%);
font-size: 14px;
line-height: 1.7;
}
.setting-lifecycle-panel__reason-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 16px;
background-color: rgb(255 255 255 / 82%);
}
.setting-lifecycle-panel__reason-label {
color: rgb(100 116 139 / 92%);
font-size: 12px;
}
.setting-lifecycle-panel__reason-value {
color: rgb(15 23 42 / 94%);
font-size: 15px;
line-height: 1.7;
}
.setting-lifecycle-panel__action-panel {
background:
radial-gradient(circle at top right, rgb(59 130 246 / 7%), transparent 32%),
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
}
.setting-lifecycle-panel__action-grid {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 10px;
}
.setting-lifecycle-panel__action-card {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
padding: 14px 16px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 18px;
background-color: rgb(255 255 255 / 96%);
text-align: left;
transition:
transform 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.setting-lifecycle-panel__action-card:hover {
transform: translateY(-1px);
box-shadow: 0 10px 22px rgb(15 23 42 / 6%);
}
.setting-lifecycle-panel__action-card-top {
display: flex;
align-items: center;
gap: 10px;
}
.setting-lifecycle-panel__action-dot {
width: 10px;
height: 10px;
border-radius: 999px;
background-color: currentcolor;
flex: 0 0 auto;
}
.setting-lifecycle-panel__action-name {
color: rgb(15 23 42 / 96%);
font-size: 16px;
font-weight: 700;
}
.setting-lifecycle-panel__action-desc {
color: rgb(71 85 105 / 94%);
font-size: 13px;
line-height: 1.6;
}
.setting-lifecycle-panel__empty-tip {
padding: 18px 16px;
border: 1px dashed rgb(203 213 225 / 92%);
border-radius: 16px;
color: rgb(100 116 139 / 92%);
font-size: 13px;
line-height: 1.7;
}
.setting-lifecycle-panel__hero--emerald .setting-lifecycle-panel__hero-status-chip {
border-color: rgb(16 185 129 / 24%);
background-color: rgb(236 253 245 / 90%);
color: rgb(4 120 87 / 96%);
}
.setting-lifecycle-panel__hero--amber .setting-lifecycle-panel__hero-status-chip {
border-color: rgb(245 158 11 / 24%);
background-color: rgb(255 247 237 / 94%);
color: rgb(180 83 9 / 96%);
}
.setting-lifecycle-panel__hero--slate .setting-lifecycle-panel__hero-status-chip {
border-color: rgb(148 163 184 / 28%);
background-color: rgb(241 245 249 / 94%);
color: rgb(71 85 105 / 96%);
}
.setting-lifecycle-panel__hero--rose .setting-lifecycle-panel__hero-status-chip {
border-color: rgb(244 63 94 / 24%);
background-color: rgb(255 241 242 / 94%);
color: rgb(190 24 93 / 96%);
}
.setting-lifecycle-panel__action-card--emerald {
border-color: rgb(16 185 129 / 22%);
background: linear-gradient(90deg, rgb(236 253 245 / 90%), rgb(255 255 255 / 96%) 26%);
color: rgb(4 120 87 / 96%);
}
.setting-lifecycle-panel__action-card--amber {
border-color: rgb(245 158 11 / 22%);
background: linear-gradient(90deg, rgb(255 247 237 / 92%), rgb(255 255 255 / 96%) 26%);
color: rgb(180 83 9 / 96%);
}
.setting-lifecycle-panel__action-card--slate {
border-color: rgb(148 163 184 / 26%);
background: linear-gradient(90deg, rgb(241 245 249 / 92%), rgb(255 255 255 / 96%) 26%);
color: rgb(71 85 105 / 96%);
}
.setting-lifecycle-panel__action-card--rose {
border-color: rgb(244 63 94 / 22%);
background: linear-gradient(90deg, rgb(255 241 242 / 92%), rgb(255 255 255 / 96%) 26%);
color: rgb(190 24 93 / 96%);
}
.setting-lifecycle-panel__action-card--emerald:hover {
box-shadow: 0 10px 22px rgb(16 185 129 / 12%);
}
.setting-lifecycle-panel__action-card--amber:hover {
box-shadow: 0 10px 22px rgb(245 158 11 / 12%);
}
.setting-lifecycle-panel__action-card--slate:hover {
box-shadow: 0 10px 22px rgb(100 116 139 / 10%);
}
.setting-lifecycle-panel__action-card--rose:hover {
box-shadow: 0 10px 22px rgb(244 63 94 / 12%);
}
.setting-lifecycle-panel__action-card--emerald .setting-lifecycle-panel__action-name {
color: rgb(6 95 70 / 96%);
}
.setting-lifecycle-panel__action-card--amber .setting-lifecycle-panel__action-name {
color: rgb(146 64 14 / 96%);
}
.setting-lifecycle-panel__action-card--slate .setting-lifecycle-panel__action-name {
color: rgb(51 65 85 / 96%);
}
.setting-lifecycle-panel__action-card--rose .setting-lifecycle-panel__action-name {
color: rgb(159 18 57 / 96%);
}
@media (width <= 1280px) {
.setting-lifecycle-panel__layout {
grid-template-columns: 1fr;
}
}
@media (width <= 640px) {
.setting-lifecycle-panel__hero-top,
.setting-lifecycle-panel__action-head {
align-items: flex-start;
}
}
</style>

View File

@@ -0,0 +1,201 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { filterProductMembers, formatProductMemberDate, getProductTeamTableHeight } from '../shared';
defineOptions({ name: 'SettingTeamPanel' });
interface Props {
members: Api.Product.ProductMember[];
roleOptions?: Api.SystemManage.RoleSimple[];
loading?: boolean;
readonly?: boolean;
}
interface Emits {
(e: 'create'): void;
(e: 'edit', member: Api.Product.ProductMember): void;
(e: 'remove', member: Api.Product.ProductMember): void;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
readonly: false,
roleOptions: () => []
});
const emit = defineEmits<Emits>();
const searchKeyword = ref('');
const selectedRoleId = ref('');
const teamTableHeight = getProductTeamTableHeight(5);
const roleFilterOptions = computed(() => {
const roleMap = new Map<string, string>();
props.roleOptions.forEach(role => {
if (!roleMap.has(role.id)) {
roleMap.set(role.id, role.name);
}
});
return [...roleMap.entries()].map(([value, label]) => ({
value,
label
}));
});
const filteredMembers = computed(() =>
filterProductMembers(props.members, {
keyword: searchKeyword.value,
roleId: selectedRoleId.value
})
);
const hasFilter = computed(() => Boolean(searchKeyword.value.trim() || selectedRoleId.value));
watch(roleFilterOptions, options => {
if (selectedRoleId.value && !options.some(item => item.value === selectedRoleId.value)) {
selectedRoleId.value = '';
}
});
function getMemberStatusLabel(status: Api.Product.ProductMemberStatus) {
return status === 0 ? '有效' : '失效';
}
function getMemberStatusTagType(status: Api.Product.ProductMemberStatus) {
return status === 0 ? 'success' : 'info';
}
</script>
<template>
<ElCard class="card-wrapper">
<template #header>
<div class="setting-team-panel__header">
<div>
<h3 class="text-16px text-[#0f172a] font-700">团队管理</h3>
</div>
<div class="setting-team-panel__toolbar">
<ElSelect v-model="selectedRoleId" clearable placeholder="筛选角色" class="setting-team-panel__role-filter">
<ElOption
v-for="option in roleFilterOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
<ElInput v-model="searchKeyword" clearable placeholder="搜索成员姓名" class="setting-team-panel__search" />
<ElButton
v-if="!props.readonly"
v-auth="{ code: 'project:product:update', source: 'object' }"
type="primary"
plain
@click="emit('create')"
>
新增成员
</ElButton>
</div>
</div>
</template>
<ElTable
v-loading="props.loading"
:data="filteredMembers"
:height="teamTableHeight"
:empty-text="hasFilter ? '未找到匹配成员' : '暂无成员'"
border
row-key="id"
>
<ElTableColumn type="index" label="序号" width="64" align="center" />
<ElTableColumn prop="userNickname" label="成员姓名" min-width="140" />
<ElTableColumn prop="roleName" label="当前角色" min-width="140" />
<ElTableColumn label="成员状态" width="110" align="center">
<template #default="{ row }">
<ElTag :type="getMemberStatusTagType(row.status)">{{ getMemberStatusLabel(row.status) }}</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="joinedTime" label="加入时间" min-width="132" align="center">
<template #default="{ row }">
{{ formatProductMemberDate(row.joinedTime) }}
</template>
</ElTableColumn>
<ElTableColumn prop="leftTime" label="退出时间" min-width="170">
<template #default="{ row }">
{{ formatProductMemberDate(row.leftTime) }}
</template>
</ElTableColumn>
<ElTableColumn prop="remark" label="备注" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
{{ row.remark || '--' }}
</template>
</ElTableColumn>
<ElTableColumn v-if="!props.readonly" label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<div class="setting-team-panel__actions">
<ElButton
v-auth="{ code: 'project:product:update', source: 'object' }"
link
type="primary"
:disabled="row.status !== 0 || row.managerFlag"
@click="emit('edit', row)"
>
调整角色
</ElButton>
<ElButton
v-auth="{ code: 'project:product:update', source: 'object' }"
link
type="danger"
:disabled="row.status !== 0 || row.managerFlag"
@click="emit('remove', row)"
>
移出成员
</ElButton>
</div>
</template>
</ElTableColumn>
</ElTable>
</ElCard>
</template>
<style scoped>
.setting-team-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.setting-team-panel__toolbar {
display: inline-flex;
align-items: center;
gap: 12px;
}
.setting-team-panel__search {
width: 220px;
}
.setting-team-panel__role-filter {
width: 180px;
}
.setting-team-panel__actions {
display: inline-flex;
align-items: center;
gap: 12px;
}
@media (width <= 768px) {
.setting-team-panel__header {
align-items: flex-start;
flex-direction: column;
}
.setting-team-panel__toolbar {
width: 100%;
}
.setting-team-panel__search {
width: 100%;
}
.setting-team-panel__role-filter {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'StatusActionDialog' });
interface Props {
action: Api.Product.ProductLifecycleAction | null;
}
interface Emits {
(e: 'submit', payload: Api.Product.ChangeProductStatusParams): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const model = reactive({
reason: ''
});
const confirmDisabled = computed(() => Boolean(props.action?.needReason && !model.reason.trim()));
function handleConfirm() {
if (!props.action) {
return;
}
emit('submit', {
id: '',
actionCode: props.action.actionCode,
reason: model.reason.trim() || null
});
}
watch(
() => visible.value,
value => {
if (!value) {
return;
}
model.reason = '';
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="action ? `${action.actionName}产品` : '生命周期动作'"
preset="sm"
:confirm-disabled="confirmDisabled"
@confirm="handleConfirm"
>
<ElForm label-position="top">
<ElFormItem :label="action?.needReason ? '动作原因(必填)' : '动作原因(选填)'">
<ElInput
v-model="model.reason"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入动作原因"
/>
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>

View File

@@ -0,0 +1,233 @@
import dayjs from 'dayjs';
export interface ProductManagerMemberLike {
roleId: string;
}
interface ProductTeamManageContext {
buttonCodes: readonly string[];
loginUserId: string | null | undefined;
currentManagerUserId: string | null | undefined;
}
interface ProductLifecycleStatusSummary {
tone: 'emerald' | 'amber' | 'slate' | 'rose';
caption: string;
description: string;
}
interface ProductLifecycleActionCardMeta {
tone: 'emerald' | 'amber' | 'slate' | 'rose';
description: string;
}
export const productSettingSectionKeys = ['base-info', 'team', 'lifecycle', 'danger'] as const;
export type ProductSettingSectionKey = (typeof productSettingSectionKeys)[number];
const productSettingSectionAuthCodeMap: Partial<Record<ProductSettingSectionKey, string>> = {
lifecycle: 'project:product:status',
danger: 'project:product:delete'
};
const productBaseInfoReadonlyMessageMap: Partial<Record<Api.Product.ProductStatusCode, string>> = {
paused: '当前产品已暂停,基础信息仅支持查看,不可编辑。',
archived: '当前产品已归档,基础信息仅支持查看,不可编辑。',
abandoned: '当前产品已废弃,基础信息仅支持查看,不可编辑。'
};
const productLifecycleStatusSummaryMap: Record<Api.Product.ProductStatusCode, ProductLifecycleStatusSummary> = {
active: {
tone: 'emerald',
caption: '产品正常服务中',
description: '当前可以执行暂停、归档或废弃。'
},
paused: {
tone: 'amber',
caption: '产品已暂停推进',
description: '条件恢复后可重新启用,也可继续归档或废弃。'
},
archived: {
tone: 'slate',
caption: '产品已收口归档',
description: '保留历史信息,当前不再开放新的生命周期动作。'
},
abandoned: {
tone: 'rose',
caption: '产品已停止建设',
description: '产品已结束推进,当前不再开放新的生命周期动作。'
}
};
const productLifecycleActionCardMetaMap: Record<Api.Product.ProductStatusActionCode, ProductLifecycleActionCardMeta> = {
pause: {
tone: 'amber',
description: '暂停当前产品,后续仍可恢复或归档。'
},
resume: {
tone: 'emerald',
description: '恢复启用后,继续推进产品协作。'
},
archive: {
tone: 'slate',
description: '收口当前产品,保留历史记录并结束维护。'
},
abandon: {
tone: 'rose',
description: '终止当前产品建设,请谨慎确认。'
}
};
const productSettingErrorMessageMap: Record<string, string> = {
'1008001002': '产品名称已存在,请更换名称',
'1008001007': '当前产品状态不允许编辑基础信息',
'1008001008': '当前产品已暂停,基础信息仅支持查看,不可编辑。',
'1008001013': '请选择原产品经理交接后的角色',
'1008001014': '当前产品经理不能直接移出,请先完成经理交接',
'1008001015': '当前产品经理不能直接调整为非经理角色,请先完成经理转交',
'1008001004': '当前状态不支持该动作',
'1008001005': '当前动作必须填写原因',
'1008001006': '删除确认名称与当前产品名称不一致'
};
const productTeamTableHeaderHeight = 40;
const productTeamTableRowHeight = 40;
export function shouldRequireManagerHandover(
targetRoleId: string,
currentManager: ProductManagerMemberLike | null | undefined
) {
if (!currentManager?.roleId) {
return false;
}
return targetRoleId === currentManager.roleId;
}
export function getPreviousManagerRoleOptions(roleOptions: Api.SystemManage.RoleSimple[], managerRoleId: string) {
return roleOptions.filter(role => role.id !== managerRoleId);
}
export function getProductSettingSectionKeys() {
return [...productSettingSectionKeys];
}
export function isProductBaseInfoEditable(status: Api.Product.ProductStatusCode | null | undefined) {
return status === 'active';
}
export function resolveVisibleProductSettingSections(
sectionKeys: readonly ProductSettingSectionKey[],
buttonCodes: readonly string[]
) {
return sectionKeys.filter(sectionKey => {
const authCode = productSettingSectionAuthCodeMap[sectionKey];
if (!authCode) {
return true;
}
return buttonCodes.includes(authCode);
});
}
export function resolveVisibleProductSettingSectionKey(
currentKey: ProductSettingSectionKey | string | null | undefined,
visibleSectionKeys: readonly ProductSettingSectionKey[]
) {
if (!visibleSectionKeys.length) {
return 'base-info' satisfies ProductSettingSectionKey;
}
if (currentKey && visibleSectionKeys.includes(currentKey as ProductSettingSectionKey)) {
return currentKey as ProductSettingSectionKey;
}
return visibleSectionKeys[0];
}
export function getProductBaseInfoReadonlyMessage(status: Api.Product.ProductStatusCode | null | undefined) {
if (!status || isProductBaseInfoEditable(status)) {
return '';
}
return productBaseInfoReadonlyMessageMap[status] || '当前产品状态不允许编辑基础信息。';
}
export function getProductLifecycleStatusSummary(status: Api.Product.ProductStatusCode) {
return productLifecycleStatusSummaryMap[status];
}
export function formatProductMemberDate(value: string | number | null | undefined) {
if (value === null || value === undefined || value === '') {
return '--';
}
const normalizedValue = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value;
const parsedDate = dayjs(normalizedValue);
if (!parsedDate.isValid()) {
return String(value);
}
return parsedDate.format('YYYY-MM-DD');
}
export function filterProductMembersByKeyword(
members: readonly Api.Product.ProductMember[],
keyword: string | null | undefined
) {
return filterProductMembers(members, { keyword });
}
export function filterProductMembers(
members: readonly Api.Product.ProductMember[],
filters: {
keyword?: string | null | undefined;
roleId?: string | null | undefined;
}
) {
const normalizedKeyword = String(filters.keyword || '')
.trim()
.toLocaleLowerCase();
const normalizedRoleId = String(filters.roleId || '').trim();
if (!normalizedKeyword && !normalizedRoleId) {
return [...members];
}
return members.filter(member => {
const matchesKeyword = !normalizedKeyword || member.userNickname.toLocaleLowerCase().includes(normalizedKeyword);
const matchesRole = !normalizedRoleId || member.roleId === normalizedRoleId;
return matchesKeyword && matchesRole;
});
}
export function getProductTeamTableHeight(visibleRows: number) {
const normalizedRows = Math.max(0, visibleRows);
return productTeamTableHeaderHeight + normalizedRows * productTeamTableRowHeight;
}
export function getProductLifecycleActionCardMeta(actionCode: Api.Product.ProductStatusActionCode) {
return productLifecycleActionCardMetaMap[actionCode];
}
export function canManageProductTeam(context: ProductTeamManageContext) {
const hasUpdateAuth = context.buttonCodes.includes('project:product:update');
const loginUserId = String(context.loginUserId || '');
const currentManagerUserId = String(context.currentManagerUserId || '');
if (!hasUpdateAuth || !loginUserId || !currentManagerUserId) {
return false;
}
return loginUserId === currentManagerUserId;
}
export function getProductSettingErrorMessage(code: string | number | null | undefined, backendMessage: string) {
const normalizedCode = String(code || '');
return productSettingErrorMessageMap[normalizedCode] || backendMessage;
}

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import { useDict } from '@/hooks/business/dict';
import { getProductStatusLabel, getProductStatusTagType } from './product-master-data';
import type { CurrentProductSummary } from './product-context-shared';
defineOptions({ name: 'ProductContextBanner' });
interface Props {
product: CurrentProductSummary | null;
caption: string;
}
const props = defineProps<Props>();
const { getLabel: getDirectionLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
const productStatusCode = computed(() => props.product?.statusCode as Api.Product.ProductStatusCode | undefined);
const summaryItems = computed(() => {
if (!props.product) {
return [];
}
return [
{ label: '产品 ID', value: props.product.id || '--' },
{ label: '产品编码', value: props.product.code || '--' },
{ label: '产品方向', value: getDirectionLabel(props.product.directionCode, '--') },
{ label: '产品经理', value: props.product.managerUserId || '--' }
];
});
</script>
<template>
<ElCard class="product-context-banner card-wrapper">
<template v-if="product">
<div class="flex flex-col gap-20px lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0 flex-1">
<div class="mb-12px flex flex-wrap items-center gap-10px">
<span class="product-context-banner__code">{{ product.code }}</span>
<ElTag :type="getProductStatusTagType(productStatusCode!)" effect="light" round>
{{ getProductStatusLabel(productStatusCode!) }}
</ElTag>
</div>
<div class="mb-10px flex flex-wrap items-center gap-12px">
<h2 class="text-24px text-[#0f172a] font-700">{{ product.name }}</h2>
<span class="text-14px text-[#64748b]">{{ caption }}</span>
</div>
<div class="flex flex-wrap gap-x-18px gap-y-8px text-13px text-[#64748b] leading-22px">
<span>对象 ID{{ product.id || '--' }}</span>
<span>方向{{ getDirectionLabel(product.directionCode, '--') }}</span>
<span>产品经理{{ product.managerUserId || '--' }}</span>
</div>
</div>
<div class="product-context-banner__stats">
<div v-for="item in summaryItems" :key="item.label" class="product-context-banner__stat-card">
<span class="product-context-banner__stat-label">{{ item.label }}</span>
<strong class="product-context-banner__stat-value">{{ item.value }}</strong>
</div>
</div>
</div>
</template>
<ElEmpty v-else description="未获取到当前产品上下文" :image-size="84" />
</ElCard>
</template>
<style scoped>
.product-context-banner {
overflow: hidden;
border: 1px solid rgb(226 232 240 / 88%);
background:
radial-gradient(circle at top left, rgb(14 165 233 / 10%), transparent 32%),
linear-gradient(135deg, rgb(255 255 255 / 98%), rgb(248 250 252 / 96%));
}
.product-context-banner__code {
display: inline-flex;
align-items: center;
height: 28px;
padding: 0 10px;
border-radius: 999px;
background-color: rgb(15 23 42 / 88%);
color: #fff;
font-size: 12px;
letter-spacing: 0.08em;
}
.product-context-banner__stats {
display: grid;
flex-shrink: 0;
grid-template-columns: repeat(2, minmax(132px, 1fr));
gap: 12px;
width: min(100%, 320px);
}
.product-context-banner__stat-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px;
border: 1px solid rgb(148 163 184 / 18%);
border-radius: 16px;
background-color: rgb(255 255 255 / 72%);
}
.product-context-banner__stat-label {
color: rgb(100 116 139 / 90%);
font-size: 12px;
}
.product-context-banner__stat-value {
color: rgb(15 23 42 / 94%);
font-size: 20px;
line-height: 1.2;
}
</style>

View File

@@ -0,0 +1,43 @@
export interface CurrentProductSummary {
id: string;
code: string;
directionCode: string;
name: string;
managerUserId: string;
statusCode: string;
}
export function resolveObjectIdFromQuery(
routeObjectId: string | null | Array<string | null> | undefined,
fallbackObjectId: string
) {
if (Array.isArray(routeObjectId)) {
return String(routeObjectId[0] || fallbackObjectId || '');
}
if (routeObjectId === null || routeObjectId === undefined || routeObjectId === '') {
return fallbackObjectId;
}
return String(routeObjectId);
}
export function normalizeCurrentProductSummary(
objectSummary: App.ObjectContext.Summary | null | undefined,
objectName: string
): CurrentProductSummary | null {
const currentProduct = objectSummary?.currentProduct;
if (!currentProduct || typeof currentProduct !== 'object') {
return null;
}
return {
id: String((currentProduct as Record<string, unknown>).id || ''),
code: String((currentProduct as Record<string, unknown>).code || ''),
directionCode: String((currentProduct as Record<string, unknown>).directionCode || ''),
name: String((currentProduct as Record<string, unknown>).name || objectName || ''),
managerUserId: String((currentProduct as Record<string, unknown>).managerUserId || ''),
statusCode: String((currentProduct as Record<string, unknown>).statusCode || '')
};
}

View File

@@ -0,0 +1,68 @@
import { transformRecordToOption } from '@/utils/common';
export const productStatusRecord: Record<Api.Product.ProductStatusCode, string> = {
active: '启用',
paused: '暂停',
archived: '归档',
abandoned: '废弃'
};
export const productStatusOptions = transformRecordToOption(productStatusRecord);
export const productStatusActionRecord: Record<Api.Product.ProductStatusActionCode, string> = {
pause: '暂停产品',
resume: '恢复产品',
archive: '归档产品',
abandon: '废弃产品'
};
export function getProductStatusLabel(status: Api.Product.ProductStatusCode) {
return productStatusRecord[status];
}
export function getProductStatusTagType(status: Api.Product.ProductStatusCode): UI.ThemeColor {
const statusTagTypeMap: Record<Api.Product.ProductStatusCode, UI.ThemeColor> = {
active: 'success',
paused: 'warning',
archived: 'info',
abandoned: 'danger'
};
return statusTagTypeMap[status];
}
export function isProductEditable(status: Api.Product.ProductStatusCode) {
return status === 'active' || status === 'paused';
}
export function isProductEditLimited(status: Api.Product.ProductStatusCode) {
return status === 'paused';
}
export function getAllowedProductStatusActions(
status: Api.Product.ProductStatusCode
): Api.Product.ProductStatusActionCode[] {
const actionMap: Record<Api.Product.ProductStatusCode, Api.Product.ProductStatusActionCode[]> = {
active: ['pause', 'archive', 'abandon'],
paused: ['resume', 'archive', 'abandon'],
archived: [],
abandoned: []
};
return actionMap[status];
}
export function getProductStatusActionLabel(actionCode: Api.Product.ProductStatusActionCode) {
return productStatusActionRecord[actionCode];
}
export function getProductStatusActionOptions(status: Api.Product.ProductStatusCode) {
return getAllowedProductStatusActions(status).map(actionCode => ({
value: actionCode,
label: getProductStatusActionLabel(actionCode)
}));
}
export function isProductActionReasonRequired(actionCode: Api.Product.ProductStatusActionCode) {
return actionCode !== 'resume';
}

View File

@@ -0,0 +1,23 @@
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
import { useObjectContextStore } from '@/store/modules/object-context';
import { normalizeCurrentProductSummary, resolveObjectIdFromQuery } from './product-context-shared';
export function useCurrentProduct() {
const route = useRoute();
const objectContextStore = useObjectContextStore();
const currentObjectId = computed(() => {
return resolveObjectIdFromQuery(route.query[OBJECT_CONTEXT_QUERY_KEY], objectContextStore.objectId);
});
const currentProduct = computed(() =>
normalizeCurrentProductSummary(objectContextStore.objectSummary, objectContextStore.objectName)
);
return {
currentObjectId,
currentProduct
};
}