feat(动态切换对象域下的对象):对象域下的对象可以动态切换。 fix(产品需求、项目需求): 按照会议意见修改诸多细节。 fix(产品对象域的概览界面): 把假数据换成真实的需求统计数据。
382 lines
11 KiB
TypeScript
382 lines
11 KiB
TypeScript
import dayjs from 'dayjs';
|
||
|
||
const productStatusLabelMap = {
|
||
active: '启用',
|
||
paused: '暂停',
|
||
archived: '归档',
|
||
abandoned: '废弃'
|
||
} as const satisfies Record<Api.Product.ProductStatusCode, string>;
|
||
|
||
export interface ProductHomepageMetric {
|
||
label: string;
|
||
value: string;
|
||
hint: string;
|
||
}
|
||
|
||
export interface ProductHomepageFact {
|
||
label: string;
|
||
value: string;
|
||
}
|
||
|
||
export interface ProductHomepageBanner {
|
||
identity: {
|
||
name: string;
|
||
code: string;
|
||
directionCode: string;
|
||
statusCode: Api.Product.ProductStatusCode | null;
|
||
statusLabel: string;
|
||
managerLabel: string;
|
||
description: string;
|
||
facts: ProductHomepageFact[];
|
||
};
|
||
metrics: ProductHomepageMetric[];
|
||
}
|
||
|
||
export interface ProductHomepageBannerSource {
|
||
product: Api.Product.Product | null;
|
||
settings: Api.Product.ProductSettings | null;
|
||
members: readonly Api.Product.ProductMember[];
|
||
requirementSummary: ProductRequirementPoolSummary;
|
||
latestActivityTime?: string | null;
|
||
}
|
||
|
||
export interface ProductHomepageTimelineItem {
|
||
key: string;
|
||
tag: '对象' | '状态' | '团队';
|
||
title: string;
|
||
content: string;
|
||
time: string;
|
||
tone: 'sky' | 'emerald' | 'amber' | 'rose' | 'slate';
|
||
}
|
||
|
||
export interface ProductRequirementPoolSummary {
|
||
metrics: ProductHomepageMetric[];
|
||
distribution: Array<{
|
||
label: string;
|
||
value: string;
|
||
}>;
|
||
total: number;
|
||
todo: number;
|
||
highPriorityTodo: number;
|
||
completionRate: number;
|
||
}
|
||
|
||
export interface ProductRequirementPoolRecentChange {
|
||
id: string;
|
||
title: string;
|
||
actionType: Api.Product.ProductRequirementDashboardRecentChangeActionType;
|
||
actionLabel: string;
|
||
time: string;
|
||
content: string;
|
||
}
|
||
|
||
export interface ProductHomepageExtensionModule {
|
||
key: 'milestone' | 'risk' | 'document';
|
||
title: string;
|
||
description: string;
|
||
items: string[];
|
||
}
|
||
|
||
function normalizeCount(value: number | null | undefined) {
|
||
if (!Number.isFinite(value)) {
|
||
return 0;
|
||
}
|
||
|
||
return Math.max(0, Number(value));
|
||
}
|
||
|
||
function getTimeValue(value: string | null | undefined) {
|
||
const parsed = dayjs(value);
|
||
|
||
return parsed.isValid() ? parsed.valueOf() : 0;
|
||
}
|
||
|
||
function formatDateTime(value: string | null | undefined) {
|
||
const parsed = dayjs(value);
|
||
|
||
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm') : '--';
|
||
}
|
||
|
||
function getStatusLabel(status: Api.Product.ProductStatusCode | null | undefined) {
|
||
if (!status) {
|
||
return '--';
|
||
}
|
||
|
||
return productStatusLabelMap[status] || '--';
|
||
}
|
||
|
||
function getActiveMembers(members: readonly Api.Product.ProductMember[]) {
|
||
return members.filter(item => item.status === 0);
|
||
}
|
||
|
||
function getManagerLabel(settings: Api.Product.ProductSettings | null, members: readonly Api.Product.ProductMember[]) {
|
||
return (
|
||
settings?.baseInfo.managerUserNickname ||
|
||
getActiveMembers(members).find(item => item.managerFlag)?.userNickname ||
|
||
'--'
|
||
);
|
||
}
|
||
|
||
function getRoleSummary(members: readonly Api.Product.ProductMember[]) {
|
||
const activeMembers = getActiveMembers(members);
|
||
|
||
if (!activeMembers.length) {
|
||
return '--';
|
||
}
|
||
|
||
const roleCounter = new Map<string, number>();
|
||
|
||
activeMembers.forEach(member => {
|
||
const roleName = member.roleName || '未命名角色';
|
||
|
||
roleCounter.set(roleName, (roleCounter.get(roleName) || 0) + 1);
|
||
});
|
||
|
||
return Array.from(roleCounter.entries())
|
||
.sort((left, right) => {
|
||
const leftWeight = left[0].includes('经理') ? 0 : 1;
|
||
const rightWeight = right[0].includes('经理') ? 0 : 1;
|
||
|
||
if (leftWeight !== rightWeight) {
|
||
return leftWeight - rightWeight;
|
||
}
|
||
|
||
return left[0].localeCompare(right[0], 'zh-CN');
|
||
})
|
||
.map(([roleName, count]) => `${roleName} ${count} 人`)
|
||
.join(' / ');
|
||
}
|
||
|
||
function resolveLatestTimelineTime(
|
||
product: Api.Product.Product | null,
|
||
settings: Api.Product.ProductSettings | null,
|
||
members: readonly Api.Product.ProductMember[]
|
||
) {
|
||
const timeValues = [
|
||
product?.createTime,
|
||
product?.updateTime,
|
||
settings?.lifecycle.lastStatusReason ? product?.updateTime : null,
|
||
...members.flatMap(member => [member.joinedTime, member.leftTime || null])
|
||
];
|
||
|
||
const latestValue = timeValues.reduce((latest, current) => {
|
||
return Math.max(latest, getTimeValue(current));
|
||
}, 0);
|
||
|
||
return latestValue ? dayjs(latestValue).format('YYYY-MM-DD HH:mm') : '--';
|
||
}
|
||
|
||
export function buildRequirementPoolSummary(
|
||
source: Api.Product.ProductRequirementDashboardSummary | null | undefined
|
||
): ProductRequirementPoolSummary {
|
||
const total = normalizeCount(source?.total);
|
||
const todo = normalizeCount(source?.todo);
|
||
const pendingClaim = normalizeCount(source?.pendingClaim);
|
||
const pendingReview = normalizeCount(source?.pendingReview);
|
||
const pendingDispatch = normalizeCount(source?.pendingDispatch);
|
||
const completionRate = Math.min(100, normalizeCount(source?.completionRate));
|
||
const highPriorityTodo = normalizeCount(source?.highPriorityTodo);
|
||
const distribution = [
|
||
{ label: '等待处理', value: String(todo) },
|
||
{ label: '等待认领', value: String(pendingClaim) },
|
||
{ label: '等待评审', value: String(pendingReview) },
|
||
{ label: '等待指派', value: String(pendingDispatch) }
|
||
];
|
||
|
||
return {
|
||
metrics: [
|
||
{
|
||
label: '需求总量',
|
||
value: String(total),
|
||
hint: '当前需求池累计收录的需求数量'
|
||
},
|
||
{
|
||
label: '状态类型',
|
||
value: String(distribution.length),
|
||
hint: '首页当前重点展示的需求状态分层'
|
||
},
|
||
{
|
||
label: '待处理',
|
||
value: String(todo),
|
||
hint: '等待认领、评审、指派的需求,这些需求应该着重关注'
|
||
},
|
||
{
|
||
label: '高优先级待处理',
|
||
value: String(highPriorityTodo),
|
||
hint: '需要优先推进的待处理需求数量,P0、P1类型的需求'
|
||
}
|
||
],
|
||
distribution,
|
||
total,
|
||
todo,
|
||
highPriorityTodo,
|
||
completionRate
|
||
};
|
||
}
|
||
|
||
export function buildRequirementPoolRecentChanges(
|
||
source: readonly Api.Product.ProductRequirementDashboardRecentChange[] | null | undefined
|
||
) {
|
||
return [...(source || [])]
|
||
.filter(item => getTimeValue(item.occurredAt) > 0)
|
||
.sort((left, right) => getTimeValue(right.occurredAt) - getTimeValue(left.occurredAt))
|
||
.map(item => ({
|
||
id: item.id,
|
||
title: item.title,
|
||
actionType: item.actionType,
|
||
actionLabel: item.actionLabel,
|
||
content: item.content,
|
||
time: formatDateTime(item.occurredAt)
|
||
})) satisfies ProductRequirementPoolRecentChange[];
|
||
}
|
||
|
||
export function buildProductHomepageTimeline(
|
||
product: Api.Product.Product | null,
|
||
settings: Api.Product.ProductSettings | null,
|
||
members: readonly Api.Product.ProductMember[]
|
||
) {
|
||
const items: Array<Omit<ProductHomepageTimelineItem, 'time'> & { time: string | null | undefined }> = [];
|
||
|
||
if (product?.createTime) {
|
||
items.push({
|
||
key: `product-create-${product.id}`,
|
||
tag: '对象',
|
||
title: '创建产品',
|
||
content: `产品 ${product.name || product.code} 已创建并进入产品管理域。`,
|
||
time: product.createTime,
|
||
tone: 'sky'
|
||
});
|
||
}
|
||
|
||
const statusReason =
|
||
settings?.lifecycle.lastStatusReason || settings?.baseInfo.lastStatusReason || product?.lastStatusReason;
|
||
|
||
if (product?.updateTime && settings?.lifecycle.statusCode && statusReason) {
|
||
const statusCode = settings.lifecycle.statusCode;
|
||
const toneMap: Record<Api.Product.ProductStatusCode, ProductHomepageTimelineItem['tone']> = {
|
||
active: 'emerald',
|
||
paused: 'amber',
|
||
archived: 'slate',
|
||
abandoned: 'rose'
|
||
};
|
||
|
||
items.push({
|
||
key: `product-status-${product.id}-${product.updateTime}`,
|
||
tag: '状态',
|
||
title: `状态调整为${getStatusLabel(statusCode)}`,
|
||
content: statusReason,
|
||
time: product.updateTime,
|
||
tone: toneMap[statusCode]
|
||
});
|
||
}
|
||
|
||
members.forEach(member => {
|
||
if (member.joinedTime) {
|
||
items.push({
|
||
key: `member-join-${member.id}`,
|
||
tag: '团队',
|
||
title: '成员加入',
|
||
content: `${member.userNickname} 以${member.roleName}身份加入当前产品。`,
|
||
time: member.joinedTime,
|
||
tone: member.managerFlag ? 'emerald' : 'sky'
|
||
});
|
||
}
|
||
|
||
if (member.status === 1 && member.leftTime) {
|
||
items.push({
|
||
key: `member-leave-${member.id}`,
|
||
tag: '团队',
|
||
title: '成员移出',
|
||
content: `${member.userNickname} 已退出当前产品团队。`,
|
||
time: member.leftTime,
|
||
tone: 'rose'
|
||
});
|
||
}
|
||
});
|
||
|
||
return items
|
||
.filter(item => getTimeValue(item.time) > 0)
|
||
.sort((left, right) => getTimeValue(right.time) - getTimeValue(left.time))
|
||
.slice(0, 8)
|
||
.map(item => ({
|
||
...item,
|
||
time: formatDateTime(item.time)
|
||
})) satisfies ProductHomepageTimelineItem[];
|
||
}
|
||
|
||
function buildProductHomepageBannerIdentity(source: ProductHomepageBannerSource) {
|
||
const { product, settings, members } = source;
|
||
const managerLabel = getManagerLabel(settings, members);
|
||
const baseInfo = settings?.baseInfo;
|
||
const statusCode = resolveProductHomepageStatusCode(product, settings);
|
||
|
||
return {
|
||
name: product?.name || baseInfo?.name || '--',
|
||
code: product?.code || baseInfo?.code || '--',
|
||
directionCode: product?.directionCode || baseInfo?.directionCode || '',
|
||
statusCode,
|
||
statusLabel: getStatusLabel(statusCode),
|
||
managerLabel,
|
||
description: resolveProductHomepageDescription(product, settings),
|
||
facts: [
|
||
{ label: '产品经理', value: managerLabel },
|
||
{ label: '角色摘要', value: getRoleSummary(members) }
|
||
]
|
||
} satisfies ProductHomepageBanner['identity'];
|
||
}
|
||
|
||
function resolveProductHomepageStatusCode(
|
||
product: Api.Product.Product | null,
|
||
settings: Api.Product.ProductSettings | null
|
||
) {
|
||
return settings?.lifecycle.statusCode || product?.statusCode || null;
|
||
}
|
||
|
||
function resolveProductHomepageDescription(
|
||
product: Api.Product.Product | null,
|
||
settings: Api.Product.ProductSettings | null
|
||
) {
|
||
return product?.description?.trim() || settings?.baseInfo.description?.trim() || '';
|
||
}
|
||
|
||
function buildProductHomepageBannerMetrics(source: ProductHomepageBannerSource) {
|
||
const activeMembers = getActiveMembers(source.members);
|
||
const fallbackLatestTimelineTime = resolveLatestTimelineTime(source.product, source.settings, source.members);
|
||
const latestTimelineTime = source.latestActivityTime?.trim() || fallbackLatestTimelineTime || '--';
|
||
const { requirementSummary } = source;
|
||
|
||
return [
|
||
{
|
||
label: '团队人数',
|
||
value: String(activeMembers.length),
|
||
hint: '当前处于有效状态的团队成员数'
|
||
},
|
||
{
|
||
label: '需求总量',
|
||
value: String(requirementSummary.total),
|
||
hint: '需求池累计收录的需求数量'
|
||
},
|
||
{
|
||
label: '待处理需求',
|
||
value: String(requirementSummary.todo),
|
||
hint: '需要进行认领、评审、指派的需求,这些需求应该着重关注'
|
||
},
|
||
{
|
||
label: '最近动态时间',
|
||
value: latestTimelineTime,
|
||
hint: '对象或团队最近一次可确认的变动时间'
|
||
}
|
||
] satisfies ProductHomepageMetric[];
|
||
}
|
||
|
||
export function buildProductHomepageBanner(source: ProductHomepageBannerSource): ProductHomepageBanner {
|
||
return {
|
||
identity: buildProductHomepageBannerIdentity(source),
|
||
metrics: buildProductHomepageBannerMetrics(source)
|
||
};
|
||
}
|
||
|
||
export function getProductHomepageExtensionModules(modules: readonly ProductHomepageExtensionModule[]) {
|
||
return [...modules];
|
||
}
|