docs(api): 添加产品动态时间线前端API文档
- 新增产品动态时间线接口文档,明确前端调用规范 - 定义接口请求参数、响应结构和字段语义说明 - 提供请求示例和错误码说明 - 添加左侧筛选项映射规则和时间格式说明 feat(product): 实现产品首页动态时间线功能 - 重构产品首页布局结构,采用档案横幅型设计 - 新增对象基础概述横幅模块 - 实现产品动态时间线面板组件 - 集成需求池管理概览和最近变化区域 - 添加扩展信息区预留模块位 chore(docs): 更新代理工作说明和前端测试策略 - 添加前端任务测试策略说明 - 更新代理工作流程规范 - 明确git操作执行边界 - 优化组件类型声明更新
This commit is contained in:
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);
|
||||
}
|
||||
Reference in New Issue
Block a user