docs(api): 添加产品动态时间线前端API文档
- 新增产品动态时间线接口文档,明确前端调用规范 - 定义接口请求参数、响应结构和字段语义说明 - 提供请求示例和错误码说明 - 添加左侧筛选项映射规则和时间格式说明 feat(product): 实现产品首页动态时间线功能 - 重构产品首页布局结构,采用档案横幅型设计 - 新增对象基础概述横幅模块 - 实现产品动态时间线面板组件 - 集成需求池管理概览和最近变化区域 - 添加扩展信息区预留模块位 chore(docs): 更新代理工作说明和前端测试策略 - 添加前端任务测试策略说明 - 更新代理工作流程规范 - 明确git操作执行边界 - 优化组件类型声明更新
This commit is contained in:
390
src/views/product/dashboard/homepage.ts
Normal file
390
src/views/product/dashboard/homepage.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
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 ProductRequirementPoolSummarySource {
|
||||
total: number;
|
||||
todo: number;
|
||||
analyzing: number;
|
||||
planned: number;
|
||||
done: number;
|
||||
highPriorityTodo: number;
|
||||
}
|
||||
|
||||
export interface ProductRequirementPoolSummary {
|
||||
metrics: ProductHomepageMetric[];
|
||||
distribution: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>;
|
||||
total: number;
|
||||
todo: number;
|
||||
highPriorityTodo: number;
|
||||
}
|
||||
|
||||
export interface ProductRequirementPoolRecentChangeSource {
|
||||
id: string;
|
||||
title: string;
|
||||
actionLabel: string;
|
||||
time: string;
|
||||
statusLabel: string;
|
||||
}
|
||||
|
||||
export interface ProductRequirementPoolRecentChange {
|
||||
id: string;
|
||||
title: string;
|
||||
actionLabel: string;
|
||||
time: string;
|
||||
statusLabel: 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: ProductRequirementPoolSummarySource | null | undefined
|
||||
): ProductRequirementPoolSummary {
|
||||
const total = normalizeCount(source?.total);
|
||||
const todo = normalizeCount(source?.todo);
|
||||
const analyzing = normalizeCount(source?.analyzing);
|
||||
const planned = normalizeCount(source?.planned);
|
||||
const done = normalizeCount(source?.done);
|
||||
const highPriorityTodo = normalizeCount(source?.highPriorityTodo);
|
||||
const distribution = [
|
||||
{ label: '待处理', value: String(todo) },
|
||||
{ label: '分析中', value: String(analyzing) },
|
||||
{ label: '已规划', value: String(planned) },
|
||||
{ label: '已完成', value: String(done) }
|
||||
];
|
||||
|
||||
return {
|
||||
metrics: [
|
||||
{
|
||||
label: '需求总量',
|
||||
value: String(total),
|
||||
hint: '当前需求池累计收录的需求数量'
|
||||
},
|
||||
{
|
||||
label: '状态类型',
|
||||
value: String(distribution.length),
|
||||
hint: '首页当前重点展示的需求状态分层'
|
||||
},
|
||||
{
|
||||
label: '待处理',
|
||||
value: String(todo),
|
||||
hint: '等待进入分析或分派的需求数量'
|
||||
},
|
||||
{
|
||||
label: '高优先级待处理',
|
||||
value: String(highPriorityTodo),
|
||||
hint: '需要优先推进的待处理需求数量'
|
||||
}
|
||||
],
|
||||
distribution,
|
||||
total,
|
||||
todo,
|
||||
highPriorityTodo
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRequirementPoolRecentChanges(
|
||||
source: readonly ProductRequirementPoolRecentChangeSource[] | null | undefined
|
||||
) {
|
||||
return [...(source || [])]
|
||||
.filter(item => getTimeValue(item.time) > 0)
|
||||
.sort((left, right) => getTimeValue(right.time) - getTimeValue(left.time))
|
||||
.map(item => ({
|
||||
...item,
|
||||
time: formatDateTime(item.time)
|
||||
})) 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];
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
60
src/views/product/dashboard/mock.ts
Normal file
60
src/views/product/dashboard/mock.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type {
|
||||
ProductHomepageExtensionModule,
|
||||
ProductRequirementPoolRecentChangeSource,
|
||||
ProductRequirementPoolSummarySource
|
||||
} from './homepage';
|
||||
|
||||
export const productRequirementPoolMock = {
|
||||
summary: {
|
||||
total: 18,
|
||||
todo: 3,
|
||||
analyzing: 5,
|
||||
planned: 6,
|
||||
done: 4,
|
||||
highPriorityTodo: 2
|
||||
} satisfies ProductRequirementPoolSummarySource,
|
||||
recentChanges: [
|
||||
{
|
||||
id: 'req-1001',
|
||||
title: '支持产品资料标签归档',
|
||||
actionLabel: '新增需求',
|
||||
time: '2026-04-22 16:20:00',
|
||||
statusLabel: '待处理'
|
||||
},
|
||||
{
|
||||
id: 'req-1002',
|
||||
title: '统一需求池状态颜色',
|
||||
actionLabel: '状态流转',
|
||||
time: '2026-04-23 11:00:00',
|
||||
statusLabel: '分析中'
|
||||
},
|
||||
{
|
||||
id: 'req-1003',
|
||||
title: '补充对象首页需求池统计接口',
|
||||
actionLabel: '关闭需求',
|
||||
time: '2026-04-23 14:30:00',
|
||||
statusLabel: '已完成'
|
||||
}
|
||||
] satisfies ProductRequirementPoolRecentChangeSource[]
|
||||
};
|
||||
|
||||
export const productHomepageExtensionMock = [
|
||||
{
|
||||
key: 'milestone',
|
||||
title: '里程碑',
|
||||
description: '当前先承接产品对象下的版本节点与阶段目标,后续接真实里程碑聚合接口。',
|
||||
items: ['对象首页改版验收', '需求池统计接口接入', '产品资料结构梳理']
|
||||
},
|
||||
{
|
||||
key: 'risk',
|
||||
title: '风险点管理',
|
||||
description: '预留给跨需求、跨团队的产品级风险摘要,避免把风险信息挤进时间线。',
|
||||
items: ['需求池真实接口尚未接入', '对象首页长期指标来源待统一', '团队调整记录缺少专用日志接口']
|
||||
},
|
||||
{
|
||||
key: 'document',
|
||||
title: '产品资料',
|
||||
description: '用于承接产品说明、制度文档、对外资料等对象档案信息,当前先保留正式结构位。',
|
||||
items: ['产品定位说明', '对象上下文使用说明', '需求池维护约定']
|
||||
}
|
||||
] satisfies ProductHomepageExtensionModule[];
|
||||
@@ -0,0 +1,528 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { fetchGetProductActivityTimelinePage } from '@/service/api';
|
||||
import BusinessDateRangePicker from '@/components/custom/business-date-range-picker.vue';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import {
|
||||
DEFAULT_PRODUCT_ACTIVITY_PAGE_SIZE,
|
||||
PRODUCT_ACTIVITY_TIME_SHORTCUTS,
|
||||
PRODUCT_ACTIVITY_TYPE_OPTIONS,
|
||||
type ProductActivityFilterType,
|
||||
buildProductActivityDisplayItems,
|
||||
buildProductActivityRange
|
||||
} from '../product-activity';
|
||||
|
||||
defineOptions({ name: 'ProductActivityTimelineDialog' });
|
||||
|
||||
interface Props {
|
||||
productId: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const loadError = ref(false);
|
||||
const items = ref<ReturnType<typeof buildProductActivityDisplayItems>>([]);
|
||||
const pagination = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: DEFAULT_PRODUCT_ACTIVITY_PAGE_SIZE,
|
||||
total: 0
|
||||
});
|
||||
const filters = reactive<{
|
||||
activityType: ProductActivityFilterType;
|
||||
timeRange: [string, string];
|
||||
}>({
|
||||
activityType: 'all',
|
||||
timeRange: buildProductActivityRange(30)
|
||||
});
|
||||
|
||||
const timeRangeShortcuts = PRODUCT_ACTIVITY_TIME_SHORTCUTS.map(shortcut => ({
|
||||
label: shortcut.label,
|
||||
value: () => {
|
||||
const end = dayjs();
|
||||
const start = dayjs()
|
||||
.subtract(Math.max(shortcut.days - 1, 0), 'day')
|
||||
.startOf('day');
|
||||
|
||||
return [start.format('YYYY-MM-DD'), end.format('YYYY-MM-DD')] as [string, string];
|
||||
}
|
||||
}));
|
||||
|
||||
function buildDraftDateRange(timeRange: [string, string]) {
|
||||
const [startTime, endTime] = timeRange;
|
||||
|
||||
return [dayjs(startTime).format('YYYY-MM-DD'), dayjs(endTime).format('YYYY-MM-DD')] as [string, string];
|
||||
}
|
||||
|
||||
function buildApiTimeRange(dateRange: [string, string]): [string, string] {
|
||||
const [startDate, endDate] = dateRange;
|
||||
|
||||
return [
|
||||
dayjs(startDate).startOf('day').format('YYYY-MM-DD HH:mm:ss'),
|
||||
dayjs(endDate).endOf('day').format('YYYY-MM-DD HH:mm:ss')
|
||||
];
|
||||
}
|
||||
|
||||
function buildQueryParams(): Api.Product.ProductActivityTimelinePageParams {
|
||||
const [startTime, endTime] = filters.timeRange;
|
||||
|
||||
return {
|
||||
pageNo: pagination.pageNo,
|
||||
pageSize: pagination.pageSize,
|
||||
activityType: filters.activityType === 'all' ? null : filters.activityType,
|
||||
startTime,
|
||||
endTime
|
||||
};
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.activityType = 'all';
|
||||
filters.timeRange = buildProductActivityRange(30);
|
||||
pagination.pageNo = 1;
|
||||
pagination.pageSize = DEFAULT_PRODUCT_ACTIVITY_PAGE_SIZE;
|
||||
}
|
||||
|
||||
async function loadActivities() {
|
||||
if (!visible.value || !props.productId) {
|
||||
items.value = [];
|
||||
pagination.total = 0;
|
||||
loadError.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
loadError.value = false;
|
||||
|
||||
try {
|
||||
const result = await fetchGetProductActivityTimelinePage(props.productId, buildQueryParams());
|
||||
|
||||
if (result.error || !result.data) {
|
||||
items.value = [];
|
||||
pagination.total = 0;
|
||||
loadError.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
items.value = buildProductActivityDisplayItems(result.data.list);
|
||||
pagination.total = result.data.total;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reloadActivities() {
|
||||
loadActivities().catch(() => {
|
||||
loadError.value = true;
|
||||
});
|
||||
}
|
||||
|
||||
function handleQuery() {
|
||||
pagination.pageNo = 1;
|
||||
reloadActivities();
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
resetFilters();
|
||||
reloadActivities();
|
||||
}
|
||||
|
||||
function handlePageChange(pageNo: number) {
|
||||
pagination.pageNo = pageNo;
|
||||
reloadActivities();
|
||||
}
|
||||
|
||||
function handleDateRangeChange(dateRange: [string, string]) {
|
||||
filters.timeRange = buildApiTimeRange(dateRange);
|
||||
pagination.pageNo = 1;
|
||||
reloadActivities();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => filters.activityType,
|
||||
() => {
|
||||
if (!visible.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
pagination.pageNo = 1;
|
||||
reloadActivities();
|
||||
}
|
||||
);
|
||||
|
||||
watch([() => visible.value, () => props.productId], ([currentVisible, productId]) => {
|
||||
if (!currentVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!productId) {
|
||||
items.value = [];
|
||||
pagination.total = 0;
|
||||
loadError.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
reloadActivities();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
class="product-activity-dialog"
|
||||
title="产品动态时间线"
|
||||
width="1100px"
|
||||
:scrollbar="false"
|
||||
>
|
||||
<div class="product-activity-dialog__layout">
|
||||
<aside class="product-activity-dialog__filters">
|
||||
<section class="product-activity-dialog__section">
|
||||
<div class="product-activity-dialog__section-header">
|
||||
<h4>分类</h4>
|
||||
</div>
|
||||
|
||||
<ElRadioGroup v-model="filters.activityType" class="product-activity-dialog__radio-group">
|
||||
<ElRadioButton
|
||||
v-for="option in PRODUCT_ACTIVITY_TYPE_OPTIONS"
|
||||
:key="option.value"
|
||||
:label="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</ElRadioButton>
|
||||
</ElRadioGroup>
|
||||
</section>
|
||||
|
||||
<section class="product-activity-dialog__section">
|
||||
<div class="product-activity-dialog__section-header">
|
||||
<h4>时间范围</h4>
|
||||
</div>
|
||||
|
||||
<BusinessDateRangePicker
|
||||
:model-value="buildDraftDateRange(filters.timeRange)"
|
||||
:shortcuts="timeRangeShortcuts"
|
||||
placeholder="请选择时间范围"
|
||||
@update:model-value="handleDateRangeChange"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div class="product-activity-dialog__actions">
|
||||
<ElButton @click="handleReset">重置</ElButton>
|
||||
<ElButton type="primary" @click="handleQuery">查询</ElButton>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="product-activity-dialog__result">
|
||||
<div class="product-activity-dialog__result-header">
|
||||
<h4>查询结果</h4>
|
||||
<span v-if="pagination.total" class="product-activity-dialog__result-total">
|
||||
共 {{ pagination.total }} 条
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="product-activity-dialog__result-body">
|
||||
<div v-if="loadError" class="product-activity-dialog__state">
|
||||
<ElEmpty description="产品动态加载失败" :image-size="88" />
|
||||
<ElButton type="primary" plain @click="loadActivities">重新加载</ElButton>
|
||||
</div>
|
||||
|
||||
<ElScrollbar v-else-if="items.length" class="product-activity-dialog__scrollbar">
|
||||
<div class="product-activity-dialog__timeline">
|
||||
<article v-for="item in items" :key="item.id" class="product-activity-dialog__item">
|
||||
<div class="product-activity-dialog__rail">
|
||||
<span class="product-activity-dialog__dot" :class="`product-activity-dialog__dot--${item.tone}`" />
|
||||
<span class="product-activity-dialog__line" />
|
||||
</div>
|
||||
|
||||
<div class="product-activity-dialog__content">
|
||||
<div class="product-activity-dialog__meta">
|
||||
<div class="product-activity-dialog__meta-main">
|
||||
<ElTag effect="plain" size="small">{{ item.tagLabel }}</ElTag>
|
||||
</div>
|
||||
<span class="product-activity-dialog__time">{{ item.timeText }}</span>
|
||||
</div>
|
||||
|
||||
<p class="product-activity-dialog__sentence">
|
||||
<span class="product-activity-dialog__sentence-main">{{ item.compactText }}</span>
|
||||
<span v-if="item.statusTransition">,状态:{{ item.statusTransition }}</span>
|
||||
<span v-if="item.reasonText">,原因:{{ item.reasonText }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
|
||||
<ElEmpty v-else description="当前筛选条件下暂无产品动态" :image-size="88" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template #footer="{ close }">
|
||||
<div class="product-activity-dialog__footer-inner">
|
||||
<div class="product-activity-dialog__footer-pagination">
|
||||
<ElPagination
|
||||
v-if="pagination.total"
|
||||
layout="total,prev,pager,next"
|
||||
:current-page="pagination.pageNo"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ElButton @click="close">关闭</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-activity-dialog {
|
||||
:deep(.el-dialog) {
|
||||
max-width: calc(100vw - 32px);
|
||||
}
|
||||
}
|
||||
|
||||
.product-activity-dialog__layout {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
height: 640px;
|
||||
}
|
||||
|
||||
.product-activity-dialog__filters,
|
||||
.product-activity-dialog__result {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 18px;
|
||||
background-color: var(--el-fill-color-lighter);
|
||||
}
|
||||
|
||||
.product-activity-dialog__filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.product-activity-dialog__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.product-activity-dialog__section-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.product-activity-dialog__section-header h4,
|
||||
.product-activity-dialog__result-header h4 {
|
||||
margin: 0;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.product-activity-dialog__section-header span,
|
||||
.product-activity-dialog__result-total {
|
||||
margin: 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.product-activity-dialog__radio-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.product-activity-dialog__radio-group :deep(.el-radio-button__inner) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-activity-dialog__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.product-activity-dialog__time-range-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-activity-dialog__result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, var(--el-bg-color), var(--el-fill-color-lighter));
|
||||
}
|
||||
|
||||
.product-activity-dialog__result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.product-activity-dialog__result-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-activity-dialog__state {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.product-activity-dialog__scrollbar {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.product-activity-dialog__scrollbar :deep(.el-scrollbar__wrap) {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.product-activity-dialog__timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.product-activity-dialog__item {
|
||||
display: grid;
|
||||
grid-template-columns: 20px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.product-activity-dialog__rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.product-activity-dialog__dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-top: 6px;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 0 0 4px var(--el-bg-color);
|
||||
}
|
||||
|
||||
.product-activity-dialog__dot--sky {
|
||||
background-color: rgb(14 165 233 / 88%);
|
||||
}
|
||||
|
||||
.product-activity-dialog__dot--emerald {
|
||||
background-color: rgb(5 150 105 / 88%);
|
||||
}
|
||||
|
||||
.product-activity-dialog__dot--amber {
|
||||
background-color: rgb(217 119 6 / 88%);
|
||||
}
|
||||
|
||||
.product-activity-dialog__dot--rose {
|
||||
background-color: rgb(225 29 72 / 88%);
|
||||
}
|
||||
|
||||
.product-activity-dialog__dot--slate {
|
||||
background-color: rgb(100 116 139 / 88%);
|
||||
}
|
||||
|
||||
.product-activity-dialog__line {
|
||||
flex: 1;
|
||||
width: 2px;
|
||||
min-height: 24px;
|
||||
margin-top: 4px;
|
||||
background: linear-gradient(180deg, var(--el-border-color), transparent);
|
||||
}
|
||||
|
||||
.product-activity-dialog__item:last-child .product-activity-dialog__line {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.product-activity-dialog__content {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 16px;
|
||||
background-color: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.product-activity-dialog__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.product-activity-dialog__meta-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.product-activity-dialog__time {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.product-activity-dialog__sentence {
|
||||
margin: 6px 0 0;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.product-activity-dialog__sentence-main {
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.product-activity-dialog__footer-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-activity-dialog__footer-pagination {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (width <= 960px) {
|
||||
.product-activity-dialog__layout {
|
||||
grid-template-columns: 1fr;
|
||||
height: 640px;
|
||||
}
|
||||
|
||||
.product-activity-dialog__result-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.product-activity-dialog__footer-inner {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,275 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { fetchGetProductActivityTimelinePage } from '@/service/api';
|
||||
import {
|
||||
DEFAULT_PRODUCT_ACTIVITY_PAGE_SIZE,
|
||||
buildProductActivityDisplayItems,
|
||||
buildProductActivityRange,
|
||||
formatProductActivityTime
|
||||
} from '../product-activity';
|
||||
import ProductActivityTimelineDialog from './product-activity-timeline-dialog.vue';
|
||||
|
||||
defineOptions({ name: 'ProductActivityTimelinePanel' });
|
||||
|
||||
interface Props {
|
||||
productId: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'latest-time-change', value: string): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
const loading = ref(false);
|
||||
const loadError = ref(false);
|
||||
const items = ref<ReturnType<typeof buildProductActivityDisplayItems>>([]);
|
||||
|
||||
async function loadRecentActivities() {
|
||||
if (!props.productId) {
|
||||
items.value = [];
|
||||
loadError.value = false;
|
||||
emit('latest-time-change', '');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
loadError.value = false;
|
||||
|
||||
try {
|
||||
const [startTime, endTime] = buildProductActivityRange(30);
|
||||
const result = await fetchGetProductActivityTimelinePage(props.productId, {
|
||||
pageNo: 1,
|
||||
pageSize: DEFAULT_PRODUCT_ACTIVITY_PAGE_SIZE,
|
||||
startTime,
|
||||
endTime
|
||||
});
|
||||
|
||||
if (result.error || !result.data) {
|
||||
loadError.value = true;
|
||||
items.value = [];
|
||||
emit('latest-time-change', '');
|
||||
return;
|
||||
}
|
||||
|
||||
items.value = buildProductActivityDisplayItems(result.data.list);
|
||||
emit('latest-time-change', formatProductActivityTime(result.data.list[0]?.occurredAt) || '');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openDialog() {
|
||||
if (!props.productId) {
|
||||
return;
|
||||
}
|
||||
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.productId,
|
||||
async () => {
|
||||
await loadRecentActivities();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="product-activity-panel card-wrapper">
|
||||
<template #header>
|
||||
<div class="product-activity-panel__header">
|
||||
<div>
|
||||
<h3 class="product-activity-panel__title">产品动态时间线</h3>
|
||||
</div>
|
||||
|
||||
<ElButton text type="primary" :disabled="!productId" @click="openDialog">更多</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-loading="loading" class="product-activity-panel__body">
|
||||
<div v-if="loadError" class="product-activity-panel__state">
|
||||
<ElEmpty description="产品动态加载失败" :image-size="88" />
|
||||
<ElButton type="primary" plain @click="loadRecentActivities">重新加载</ElButton>
|
||||
</div>
|
||||
|
||||
<div v-else-if="items.length" class="product-activity-panel__timeline">
|
||||
<article v-for="item in items" :key="item.id" class="product-activity-panel__item">
|
||||
<div class="product-activity-panel__rail">
|
||||
<span class="product-activity-panel__dot" :class="`product-activity-panel__dot--${item.tone}`" />
|
||||
<span class="product-activity-panel__line" />
|
||||
</div>
|
||||
|
||||
<div class="product-activity-panel__content">
|
||||
<div class="product-activity-panel__meta">
|
||||
<div class="product-activity-panel__meta-main">
|
||||
<ElTag effect="plain" size="small">{{ item.tagLabel }}</ElTag>
|
||||
</div>
|
||||
<span class="product-activity-panel__time">{{ item.timeText }}</span>
|
||||
</div>
|
||||
|
||||
<p class="product-activity-panel__sentence">
|
||||
<span class="product-activity-panel__sentence-main">{{ item.compactText }}</span>
|
||||
<span v-if="item.statusTransition">,状态:{{ item.statusTransition }}</span>
|
||||
<span v-if="item.reasonText">,原因:{{ item.reasonText }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<ElEmpty v-else description="当前最近30天暂无可展示的产品动态" :image-size="88" />
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<ProductActivityTimelineDialog v-model:visible="dialogVisible" :product-id="productId" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-activity-panel {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-activity-panel__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.product-activity-panel__title {
|
||||
margin: 0;
|
||||
color: rgb(15 23 42 / 98%);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.product-activity-panel__desc {
|
||||
margin: 4px 0 0;
|
||||
color: rgb(100 116 139 / 92%);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.product-activity-panel__body {
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.product-activity-panel__state {
|
||||
display: flex;
|
||||
min-height: 420px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.product-activity-panel__timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.product-activity-panel__item {
|
||||
display: grid;
|
||||
grid-template-columns: 20px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.product-activity-panel__rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.product-activity-panel__dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-top: 6px;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 0 0 4px rgb(255 255 255 / 96%);
|
||||
}
|
||||
|
||||
.product-activity-panel__dot--sky {
|
||||
background-color: rgb(14 165 233 / 88%);
|
||||
}
|
||||
|
||||
.product-activity-panel__dot--emerald {
|
||||
background-color: rgb(5 150 105 / 88%);
|
||||
}
|
||||
|
||||
.product-activity-panel__dot--amber {
|
||||
background-color: rgb(217 119 6 / 88%);
|
||||
}
|
||||
|
||||
.product-activity-panel__dot--rose {
|
||||
background-color: rgb(225 29 72 / 88%);
|
||||
}
|
||||
|
||||
.product-activity-panel__dot--slate {
|
||||
background-color: rgb(100 116 139 / 88%);
|
||||
}
|
||||
|
||||
.product-activity-panel__line {
|
||||
flex: 1;
|
||||
width: 2px;
|
||||
min-height: 24px;
|
||||
margin-top: 4px;
|
||||
background: linear-gradient(180deg, rgb(203 213 225 / 96%), rgb(226 232 240 / 28%));
|
||||
}
|
||||
|
||||
.product-activity-panel__item:last-child .product-activity-panel__line {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.product-activity-panel__content {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgb(226 232 240 / 92%);
|
||||
border-radius: 16px;
|
||||
background-color: rgb(255 255 255 / 98%);
|
||||
}
|
||||
|
||||
.product-activity-panel__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.product-activity-panel__meta-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.product-activity-panel__time {
|
||||
color: rgb(100 116 139 / 90%);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.product-activity-panel__sentence {
|
||||
margin: 6px 0 0;
|
||||
color: rgb(71 85 105 / 94%);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.product-activity-panel__sentence-main {
|
||||
color: rgb(15 23 42 / 98%);
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.product-activity-panel__body {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.product-activity-panel__header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
367
src/views/product/dashboard/product-activity.ts
Normal file
367
src/views/product/dashboard/product-activity.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const productStatusLabelMap = {
|
||||
active: '启用',
|
||||
paused: '暂停',
|
||||
archived: '归档',
|
||||
abandoned: '废弃'
|
||||
} as const satisfies Record<Api.Product.ProductStatusCode, string>;
|
||||
|
||||
const activityTypeLabelMap = {
|
||||
product: '产品',
|
||||
status: '状态',
|
||||
member: '成员'
|
||||
} as const satisfies Record<Api.Product.ProductActivityType, string>;
|
||||
|
||||
export type ProductActivityFilterType = 'all' | Api.Product.ProductActivityType;
|
||||
|
||||
export type ProductActivityTone = 'sky' | 'emerald' | 'amber' | 'rose' | 'slate';
|
||||
|
||||
export interface ProductActivityDisplayItem extends Api.Product.ProductActivityTimelineItem {
|
||||
tagLabel: string;
|
||||
timeText: string;
|
||||
actionText: string;
|
||||
displaySummary: string;
|
||||
compactText: string;
|
||||
operatorText: string;
|
||||
reasonText: string;
|
||||
statusTransition: string;
|
||||
tone: ProductActivityTone;
|
||||
}
|
||||
|
||||
export const PRODUCT_ACTIVITY_TYPE_OPTIONS: Array<{ label: string; value: ProductActivityFilterType }> = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '产品', value: 'product' },
|
||||
{ label: '状态', value: 'status' },
|
||||
{ label: '成员', value: 'member' }
|
||||
];
|
||||
|
||||
export const PRODUCT_ACTIVITY_ACTION_OPTIONS: Array<{
|
||||
label: string;
|
||||
value: Api.Product.ProductActivityActionType;
|
||||
type: Api.Product.ProductActivityType;
|
||||
}> = [
|
||||
{ label: '产品创建', value: 'create', type: 'product' },
|
||||
{ label: '产品经理变更', value: 'change_manager', type: 'product' },
|
||||
{ label: '暂停', value: 'pause', type: 'status' },
|
||||
{ label: '恢复', value: 'resume', type: 'status' },
|
||||
{ label: '归档', value: 'archive', type: 'status' },
|
||||
{ label: '废弃', value: 'abandon', type: 'status' },
|
||||
{ label: '成员加入', value: 'add_member', type: 'member' },
|
||||
{ label: '成员调整', value: 'update_member', type: 'member' },
|
||||
{ label: '成员移出', value: 'remove_member', type: 'member' }
|
||||
];
|
||||
|
||||
export const PRODUCT_ACTIVITY_TIME_SHORTCUTS = [
|
||||
{ label: '最近7天', days: 7 },
|
||||
{ label: '最近30天', days: 30 },
|
||||
{ label: '最近90天', days: 90 }
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_PRODUCT_ACTIVITY_PAGE_SIZE = 10;
|
||||
|
||||
type ActivityDetailRecord = Record<string, unknown>;
|
||||
|
||||
function getStatusLabel(status: Api.Product.ProductStatusCode | null | undefined) {
|
||||
if (!status) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return productStatusLabelMap[status] || '--';
|
||||
}
|
||||
|
||||
function getActivityTone(item: Api.Product.ProductActivityTimelineItem): ProductActivityTone {
|
||||
if (item.type === 'status') {
|
||||
if (item.actionType === 'resume') {
|
||||
return 'emerald';
|
||||
}
|
||||
|
||||
if (item.actionType === 'pause') {
|
||||
return 'amber';
|
||||
}
|
||||
|
||||
if (item.actionType === 'abandon') {
|
||||
return 'rose';
|
||||
}
|
||||
|
||||
return 'slate';
|
||||
}
|
||||
|
||||
if (item.type === 'product') {
|
||||
return item.actionType === 'change_manager' ? 'emerald' : 'sky';
|
||||
}
|
||||
|
||||
return item.actionType === 'remove_member' ? 'rose' : 'sky';
|
||||
}
|
||||
|
||||
export function formatProductActivityTime(occurredAt: number | null | undefined) {
|
||||
if (!Number.isFinite(occurredAt)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parsed = dayjs(Number(occurredAt));
|
||||
|
||||
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm') : '';
|
||||
}
|
||||
|
||||
export function buildProductActivityRange(days: number): [string, string] {
|
||||
const end = dayjs().endOf('day');
|
||||
const start = dayjs()
|
||||
.subtract(Math.max(days - 1, 0), 'day')
|
||||
.startOf('day');
|
||||
|
||||
return [start.format('YYYY-MM-DD HH:mm:ss'), end.format('YYYY-MM-DD HH:mm:ss')];
|
||||
}
|
||||
|
||||
export function getProductActivityActionOptions(activityType: ProductActivityFilterType) {
|
||||
if (activityType === 'all') {
|
||||
return PRODUCT_ACTIVITY_ACTION_OPTIONS;
|
||||
}
|
||||
|
||||
return PRODUCT_ACTIVITY_ACTION_OPTIONS.filter(item => item.type === activityType);
|
||||
}
|
||||
|
||||
export function normalizeProductActivityActionTypes(
|
||||
activityType: ProductActivityFilterType,
|
||||
actionTypes: readonly Api.Product.ProductActivityActionType[]
|
||||
) {
|
||||
const allowed = new Set(getProductActivityActionOptions(activityType).map(item => item.value));
|
||||
|
||||
return actionTypes.filter(actionType => allowed.has(actionType));
|
||||
}
|
||||
|
||||
function parseActivityDetails(details: string | null | undefined): ActivityDetailRecord | null {
|
||||
if (!details?.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(details);
|
||||
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const normalized = parsed as ActivityDetailRecord;
|
||||
const fieldChanges = normalized.fieldChanges;
|
||||
|
||||
if (fieldChanges && typeof fieldChanges === 'object' && !Array.isArray(fieldChanges)) {
|
||||
return {
|
||||
...normalized,
|
||||
...(fieldChanges as ActivityDetailRecord)
|
||||
};
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getRecordValue(record: ActivityDetailRecord | null, keys: readonly string[]) {
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const matchedKey = keys.find(key => key in record);
|
||||
|
||||
return matchedKey ? record[matchedKey] : undefined;
|
||||
}
|
||||
|
||||
function getFieldChangeText(
|
||||
record: ActivityDetailRecord | null,
|
||||
keys: readonly string[],
|
||||
preferredSide: 'before' | 'after'
|
||||
) {
|
||||
const rawValue = getRecordValue(record, keys);
|
||||
|
||||
if (rawValue && typeof rawValue === 'object' && !Array.isArray(rawValue)) {
|
||||
const fieldChange = rawValue as { before?: unknown; after?: unknown };
|
||||
const preferredValue = fieldChange[preferredSide];
|
||||
|
||||
if (preferredValue !== null && preferredValue !== undefined && String(preferredValue).trim()) {
|
||||
return String(preferredValue).trim();
|
||||
}
|
||||
|
||||
const fallbackSide = preferredSide === 'after' ? fieldChange.before : fieldChange.after;
|
||||
|
||||
if (fallbackSide !== null && fallbackSide !== undefined && String(fallbackSide).trim()) {
|
||||
return String(fallbackSide).trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (rawValue !== null && rawValue !== undefined && String(rawValue).trim()) {
|
||||
return String(rawValue).trim();
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function getActivityTargetUserName(
|
||||
item: Api.Product.ProductActivityTimelineItem,
|
||||
detailsRecord: ActivityDetailRecord | null
|
||||
) {
|
||||
const targetUserName = item.targetUserName?.trim() || '';
|
||||
|
||||
if (targetUserName) {
|
||||
return targetUserName;
|
||||
}
|
||||
|
||||
const preferredSide = item.actionType === 'remove_member' ? 'before' : 'after';
|
||||
|
||||
return getFieldChangeText(
|
||||
detailsRecord,
|
||||
[
|
||||
'memberUserName',
|
||||
'memberUserNickname',
|
||||
'memberName',
|
||||
'userNickname',
|
||||
'userName',
|
||||
'targetUserName',
|
||||
'targetUserNickname'
|
||||
],
|
||||
preferredSide
|
||||
);
|
||||
}
|
||||
|
||||
function getActivityTargetRoleName(
|
||||
item: Api.Product.ProductActivityTimelineItem,
|
||||
detailsRecord: ActivityDetailRecord | null
|
||||
) {
|
||||
const preferredSide = item.actionType === 'remove_member' ? 'before' : 'after';
|
||||
|
||||
return getFieldChangeText(detailsRecord, ['roleName', 'memberRoleName', 'targetRoleName'], preferredSide);
|
||||
}
|
||||
|
||||
function getRoleTransitionText(detailsRecord: ActivityDetailRecord | null) {
|
||||
const beforeRoleName = getFieldChangeText(detailsRecord, ['roleName', 'memberRoleName', 'targetRoleName'], 'before');
|
||||
const afterRoleName = getFieldChangeText(detailsRecord, ['roleName', 'memberRoleName', 'targetRoleName'], 'after');
|
||||
|
||||
if (beforeRoleName && afterRoleName && beforeRoleName !== afterRoleName) {
|
||||
return `${beforeRoleName} -> ${afterRoleName}`;
|
||||
}
|
||||
|
||||
return afterRoleName || beforeRoleName;
|
||||
}
|
||||
|
||||
function isGenericActivitySummary(summaryText: string, actionText: string) {
|
||||
if (!summaryText) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return summaryText === actionText || summaryText === actionText.replace('执行了', '执行了');
|
||||
}
|
||||
|
||||
function buildMemberChangeSummary(
|
||||
item: Api.Product.ProductActivityTimelineItem,
|
||||
detailsRecord: ActivityDetailRecord | null,
|
||||
operatorText: string
|
||||
) {
|
||||
const memberName = getActivityTargetUserName(item, detailsRecord);
|
||||
const roleName = getActivityTargetRoleName(item, detailsRecord);
|
||||
|
||||
if (!memberName) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const memberDetail = roleName ? `${memberName}(${roleName})` : memberName;
|
||||
const actionLabel = item.actionType === 'add_member' ? '将成员加入产品' : '将成员移出产品';
|
||||
|
||||
return operatorText === '--' ? `${actionLabel}:${memberDetail}` : `${operatorText}${actionLabel}:${memberDetail}`;
|
||||
}
|
||||
|
||||
function buildMemberUpdateSummary(
|
||||
item: Api.Product.ProductActivityTimelineItem,
|
||||
detailsRecord: ActivityDetailRecord | null,
|
||||
operatorText: string
|
||||
) {
|
||||
const memberName = getActivityTargetUserName(item, detailsRecord);
|
||||
const roleTransitionText = getRoleTransitionText(detailsRecord);
|
||||
const memberText = memberName || '成员';
|
||||
const roleText = roleTransitionText ? `,角色:${roleTransitionText}` : '';
|
||||
|
||||
return operatorText === '--'
|
||||
? `调整成员:${memberText}${roleText}`
|
||||
: `${operatorText}调整成员:${memberText}${roleText}`;
|
||||
}
|
||||
|
||||
function buildManagerChangeSummary(detailsRecord: ActivityDetailRecord | null, operatorText: string) {
|
||||
const beforeManagerName = getFieldChangeText(
|
||||
detailsRecord,
|
||||
['beforeManagerUserName', 'beforeManagerUserNickname', 'managerUserName', 'managerUserNickname', 'managerName'],
|
||||
'before'
|
||||
);
|
||||
const afterManagerName = getFieldChangeText(
|
||||
detailsRecord,
|
||||
['afterManagerUserName', 'afterManagerUserNickname', 'managerUserName', 'managerUserNickname', 'managerName'],
|
||||
'after'
|
||||
);
|
||||
|
||||
if (!beforeManagerName && !afterManagerName) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const transitionText =
|
||||
beforeManagerName && afterManagerName
|
||||
? `${beforeManagerName} -> ${afterManagerName}`
|
||||
: afterManagerName || beforeManagerName;
|
||||
|
||||
return operatorText === '--' ? `变更产品经理:${transitionText}` : `${operatorText}变更产品经理:${transitionText}`;
|
||||
}
|
||||
|
||||
function resolveDetailedSummary(
|
||||
item: Api.Product.ProductActivityTimelineItem,
|
||||
operatorText: string,
|
||||
actionText: string
|
||||
) {
|
||||
const summaryText = item.summary?.trim() || '';
|
||||
const detailsRecord = parseActivityDetails(item.details);
|
||||
|
||||
if (!isGenericActivitySummary(summaryText, actionText)) {
|
||||
return summaryText;
|
||||
}
|
||||
|
||||
if (item.actionType === 'add_member' || item.actionType === 'remove_member') {
|
||||
return buildMemberChangeSummary(item, detailsRecord, operatorText) || summaryText || actionText;
|
||||
}
|
||||
|
||||
if (item.actionType === 'update_member') {
|
||||
return buildMemberUpdateSummary(item, detailsRecord, operatorText);
|
||||
}
|
||||
|
||||
if (item.actionType === 'change_manager') {
|
||||
return buildManagerChangeSummary(detailsRecord, operatorText) || summaryText || actionText;
|
||||
}
|
||||
|
||||
return summaryText || actionText;
|
||||
}
|
||||
|
||||
export function buildProductActivityDisplayItem(
|
||||
item: Api.Product.ProductActivityTimelineItem
|
||||
): ProductActivityDisplayItem {
|
||||
const operatorText = item.operatorName?.trim() || '--';
|
||||
const actionText =
|
||||
operatorText === '--' ? `执行了【${item.actionName}】` : `${operatorText}执行了【${item.actionName}】`;
|
||||
const displaySummary = item.type === 'status' ? actionText : resolveDetailedSummary(item, operatorText, actionText);
|
||||
const compactText = displaySummary;
|
||||
|
||||
return {
|
||||
...item,
|
||||
tagLabel: activityTypeLabelMap[item.type],
|
||||
timeText: formatProductActivityTime(item.occurredAt) || '--',
|
||||
actionText,
|
||||
displaySummary,
|
||||
compactText,
|
||||
operatorText,
|
||||
reasonText: item.reason?.trim() || '',
|
||||
statusTransition:
|
||||
item.type === 'status' && item.fromStatus && item.toStatus
|
||||
? `${getStatusLabel(item.fromStatus)} -> ${getStatusLabel(item.toStatus)}`
|
||||
: '',
|
||||
tone: getActivityTone(item)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildProductActivityDisplayItems(
|
||||
items: readonly Api.Product.ProductActivityTimelineItem[] | null | undefined
|
||||
) {
|
||||
return [...(items || [])].map(buildProductActivityDisplayItem);
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { getProductStatusLabel } from '../shared/product-master-data';
|
||||
|
||||
export interface ProductDashboardMetricCard {
|
||||
key: 'status' | 'team' | 'manager' | 'action';
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ProductDashboardTeamSummary {
|
||||
managerDisplayName: string;
|
||||
activeMemberCount: number;
|
||||
latestJoinedMemberLabel: string;
|
||||
roleSummaries: string[];
|
||||
}
|
||||
|
||||
export interface ProductDashboardQuickLink {
|
||||
key: 'requirement' | 'setting' | 'list';
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ProductDashboardActivityItem {
|
||||
key: string;
|
||||
title: string;
|
||||
content: string;
|
||||
time: string;
|
||||
tag: string;
|
||||
tone: 'sky' | 'emerald' | 'amber' | 'rose' | 'slate';
|
||||
}
|
||||
|
||||
export interface ProductDashboardPlaceholderPanel {
|
||||
title: string;
|
||||
description: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
export interface ProductDashboardGrowthModule {
|
||||
key: 'requirement-analysis' | 'project-progress' | 'rd-milestone';
|
||||
title: string;
|
||||
description: string;
|
||||
indicators: string[];
|
||||
}
|
||||
|
||||
function getActiveMembers(members: readonly Api.Product.ProductMember[]) {
|
||||
return members.filter(item => item.status === 0);
|
||||
}
|
||||
|
||||
function getTimeValue(value: string | null | undefined) {
|
||||
const parsed = dayjs(value);
|
||||
|
||||
return parsed.isValid() ? parsed.valueOf() : 0;
|
||||
}
|
||||
|
||||
function formatActivityTime(value: string | null | undefined) {
|
||||
const parsed = dayjs(value);
|
||||
|
||||
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm') : '--';
|
||||
}
|
||||
|
||||
export function getProductDashboardMetricCards(
|
||||
settings: Api.Product.ProductSettings | null,
|
||||
members: readonly Api.Product.ProductMember[]
|
||||
) {
|
||||
const activeMembers = getActiveMembers(members);
|
||||
const managerDisplayName =
|
||||
settings?.baseInfo.managerUserNickname || activeMembers.find(item => item.managerFlag)?.userNickname || '--';
|
||||
const actionCount = settings?.lifecycle.availableActions.length || 0;
|
||||
const statusLabel = settings ? getProductStatusLabel(settings.lifecycle.statusCode) : '--';
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'status',
|
||||
label: '当前状态',
|
||||
value: statusLabel
|
||||
},
|
||||
{
|
||||
key: 'team',
|
||||
label: '团队成员',
|
||||
value: `${activeMembers.length} 人`
|
||||
},
|
||||
{
|
||||
key: 'manager',
|
||||
label: '当前经理',
|
||||
value: managerDisplayName
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
label: '可执行动作',
|
||||
value: `${actionCount} 项`
|
||||
}
|
||||
] satisfies ProductDashboardMetricCard[];
|
||||
}
|
||||
|
||||
export function getProductDashboardTeamSummary(
|
||||
settings: Api.Product.ProductSettings | null,
|
||||
members: readonly Api.Product.ProductMember[]
|
||||
): ProductDashboardTeamSummary {
|
||||
const activeMembers = getActiveMembers(members);
|
||||
const latestJoinedMember = activeMembers
|
||||
.slice()
|
||||
.sort((left, right) => getTimeValue(right.joinedTime) - getTimeValue(left.joinedTime))[0];
|
||||
const latestJoinedDate = latestJoinedMember ? dayjs(latestJoinedMember.joinedTime) : null;
|
||||
|
||||
const roleCounter = new Map<string, number>();
|
||||
|
||||
activeMembers.forEach(member => {
|
||||
const roleName = member.roleName || '未命名角色';
|
||||
|
||||
roleCounter.set(roleName, (roleCounter.get(roleName) || 0) + 1);
|
||||
});
|
||||
|
||||
const roleSummaries = Array.from(roleCounter.entries())
|
||||
.sort((left, right) => {
|
||||
const leftManagerWeight = left[0].includes('经理') ? 0 : 1;
|
||||
const rightManagerWeight = right[0].includes('经理') ? 0 : 1;
|
||||
|
||||
if (leftManagerWeight !== rightManagerWeight) {
|
||||
return leftManagerWeight - rightManagerWeight;
|
||||
}
|
||||
|
||||
return left[0].localeCompare(right[0], 'zh-CN');
|
||||
})
|
||||
.map(([roleName, count]) => `${roleName} ${count} 人`);
|
||||
|
||||
return {
|
||||
managerDisplayName:
|
||||
settings?.baseInfo.managerUserNickname || activeMembers.find(item => item.managerFlag)?.userNickname || '--',
|
||||
activeMemberCount: activeMembers.length,
|
||||
latestJoinedMemberLabel:
|
||||
latestJoinedMember && latestJoinedDate?.isValid()
|
||||
? `${latestJoinedMember.userNickname} · ${latestJoinedDate.format('YYYY-MM-DD')}`
|
||||
: '--',
|
||||
roleSummaries
|
||||
};
|
||||
}
|
||||
|
||||
export function getProductDashboardQuickLinks() {
|
||||
return [
|
||||
{
|
||||
key: 'requirement',
|
||||
label: '进入需求页',
|
||||
description: '查看当前产品下的需求承接位'
|
||||
},
|
||||
{
|
||||
key: 'setting',
|
||||
label: '查看设置',
|
||||
description: '进入产品基础信息、团队和生命周期管理'
|
||||
},
|
||||
{
|
||||
key: 'list',
|
||||
label: '返回列表',
|
||||
description: '退出当前对象视角,回到产品入口页'
|
||||
}
|
||||
] satisfies ProductDashboardQuickLink[];
|
||||
}
|
||||
|
||||
export function getProductDashboardActivityItems(
|
||||
product: Api.Product.Product | null,
|
||||
settings: Api.Product.ProductSettings | null,
|
||||
members: readonly Api.Product.ProductMember[]
|
||||
) {
|
||||
const items: ProductDashboardActivityItem[] = [];
|
||||
|
||||
if (product?.createTime) {
|
||||
items.push({
|
||||
key: `product-create-${product.id}`,
|
||||
title: '创建产品',
|
||||
content: `产品 ${product.name || product.code} 已建立并纳入对象上下文。`,
|
||||
time: product.createTime,
|
||||
tag: '创建',
|
||||
tone: 'sky'
|
||||
});
|
||||
}
|
||||
|
||||
if (settings && settings.baseInfo.lastStatusReason && product?.updateTime) {
|
||||
const statusCode = settings.lifecycle.statusCode;
|
||||
let tone: ProductDashboardActivityItem['tone'] = 'slate';
|
||||
|
||||
if (statusCode === 'active') {
|
||||
tone = 'emerald';
|
||||
} else if (statusCode === 'paused') {
|
||||
tone = 'amber';
|
||||
}
|
||||
|
||||
items.push({
|
||||
key: `product-status-${product.id}-${product.updateTime}`,
|
||||
title: `状态调整为${getProductStatusLabel(settings.lifecycle.statusCode)}`,
|
||||
content: settings.baseInfo.lastStatusReason,
|
||||
time: product.updateTime,
|
||||
tag: '状态',
|
||||
tone
|
||||
});
|
||||
}
|
||||
|
||||
members.forEach(member => {
|
||||
if (member.joinedTime) {
|
||||
items.push({
|
||||
key: `member-join-${member.id}`,
|
||||
title: '成员加入',
|
||||
content: `${member.userNickname} 以${member.roleName}身份加入当前产品。`,
|
||||
time: member.joinedTime,
|
||||
tag: '团队',
|
||||
tone: member.managerFlag ? 'emerald' : 'sky'
|
||||
});
|
||||
}
|
||||
|
||||
if (member.status === 1 && member.leftTime) {
|
||||
items.push({
|
||||
key: `member-leave-${member.id}`,
|
||||
title: '成员退出',
|
||||
content: `${member.userNickname} 已退出当前产品团队。`,
|
||||
time: member.leftTime,
|
||||
tag: '团队',
|
||||
tone: 'rose'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return items
|
||||
.filter(item => getTimeValue(item.time) > 0)
|
||||
.sort((left, right) => getTimeValue(right.time) - getTimeValue(left.time))
|
||||
.slice(0, 6)
|
||||
.map(item => ({
|
||||
...item,
|
||||
time: formatActivityTime(item.time)
|
||||
}));
|
||||
}
|
||||
|
||||
export function getProductDashboardRecentActivityPlaceholder() {
|
||||
return {
|
||||
title: '最近动态',
|
||||
description: '当前先基于产品详情、生命周期与团队关系展示已知动态;后续接入审计日志后可继续扩充为完整时间线。',
|
||||
items: ['产品创建记录', '状态调整记录', '成员加入记录', '成员退出记录']
|
||||
} satisfies ProductDashboardPlaceholderPanel;
|
||||
}
|
||||
|
||||
export function getProductDashboardRdMilestonePlaceholder() {
|
||||
return {
|
||||
title: '研发令 / 里程碑摘要',
|
||||
description: '当前未接入研发令与里程碑聚合能力,后续将在这里展示年度研发令、关键节点和版本里程碑。',
|
||||
items: ['当前年度研发令', '历史研发令', '关键节点计划']
|
||||
} satisfies ProductDashboardPlaceholderPanel;
|
||||
}
|
||||
|
||||
export function getProductDashboardGrowthModules() {
|
||||
return [
|
||||
{
|
||||
key: 'requirement-analysis',
|
||||
title: '需求分析',
|
||||
description: '暂未接入需求统计接口,后续将展示需求总量、状态分布与优先级分布。',
|
||||
indicators: ['需求总数', '待处理数量', '高优先级数量']
|
||||
},
|
||||
{
|
||||
key: 'project-progress',
|
||||
title: '项目推进',
|
||||
description: '当前未汇总项目推进数据,后续将展示关联项目、里程碑与风险摘要。',
|
||||
indicators: ['关联项目数', '进行中项目', '近期里程碑']
|
||||
},
|
||||
{
|
||||
key: 'rd-milestone',
|
||||
title: '研发令与里程碑',
|
||||
description: '当前未接入研发令与里程碑聚合能力,后续将在此展示研发令编号与关键节点信息。',
|
||||
indicators: ['当前年度研发令', '历史研发令', '关键节点']
|
||||
}
|
||||
] satisfies ProductDashboardGrowthModule[];
|
||||
}
|
||||
Reference in New Issue
Block a user