368 lines
11 KiB
TypeScript
368 lines
11 KiB
TypeScript
|
|
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);
|
|||
|
|
}
|