400 lines
12 KiB
TypeScript
400 lines
12 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 ProductActivityTextPart {
|
||
text: string;
|
||
strong?: boolean;
|
||
}
|
||
|
||
export interface ProductActivityDisplayItem extends Api.Product.ProductActivityTimelineItem {
|
||
tagLabel: string;
|
||
timeText: string;
|
||
actionText: string;
|
||
displaySummary: string;
|
||
compactText: string;
|
||
compactTextParts: ProductActivityTextPart[];
|
||
operatorText: string;
|
||
subjectText: 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 isMemberActivityAction(actionType: Api.Product.ProductActivityActionType) {
|
||
return actionType === 'add_member' || actionType === 'remove_member' || actionType === 'update_member';
|
||
}
|
||
|
||
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;
|
||
|
||
return operatorText === '--'
|
||
? `执行了【${item.actionName}】:${memberDetail}`
|
||
: `${operatorText}执行了【${item.actionName}】:${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 === '--'
|
||
? `执行了【${item.actionName}】:${memberText}${roleText}`
|
||
: `${operatorText}执行了【${item.actionName}】:${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,
|
||
detailsRecord: ActivityDetailRecord | null,
|
||
texts: { operatorText: string; actionText: string }
|
||
) {
|
||
const { operatorText, actionText } = texts;
|
||
const summaryText = item.summary?.trim() || '';
|
||
|
||
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 (!isGenericActivitySummary(summaryText, actionText)) {
|
||
return summaryText;
|
||
}
|
||
|
||
if (item.actionType === 'change_manager') {
|
||
return buildManagerChangeSummary(detailsRecord, operatorText) || summaryText || actionText;
|
||
}
|
||
|
||
return summaryText || actionText;
|
||
}
|
||
|
||
function buildProductActivityTextParts(text: string, subjectText: string): ProductActivityTextPart[] {
|
||
const normalizedSubject = subjectText.trim();
|
||
const subjectIndex = normalizedSubject ? text.indexOf(normalizedSubject) : -1;
|
||
|
||
if (subjectIndex < 0) {
|
||
return [{ text }];
|
||
}
|
||
|
||
return [
|
||
{ text: text.slice(0, subjectIndex) },
|
||
{ text: normalizedSubject, strong: true },
|
||
{ text: text.slice(subjectIndex + normalizedSubject.length) }
|
||
].filter(part => part.text);
|
||
}
|
||
|
||
export function buildProductActivityDisplayItem(
|
||
item: Api.Product.ProductActivityTimelineItem
|
||
): ProductActivityDisplayItem {
|
||
const operatorText = item.operatorName?.trim() || '--';
|
||
const actionText =
|
||
operatorText === '--' ? `执行了【${item.actionName}】` : `${operatorText}执行了【${item.actionName}】`;
|
||
const detailsRecord = parseActivityDetails(item.details);
|
||
const subjectText = isMemberActivityAction(item.actionType) ? getActivityTargetUserName(item, detailsRecord) : '';
|
||
const displaySummary =
|
||
item.type === 'status' ? actionText : resolveDetailedSummary(item, detailsRecord, { operatorText, actionText });
|
||
const compactText = displaySummary;
|
||
|
||
return {
|
||
...item,
|
||
tagLabel: activityTypeLabelMap[item.type],
|
||
timeText: formatProductActivityTime(item.occurredAt) || '--',
|
||
actionText,
|
||
displaySummary,
|
||
compactText,
|
||
compactTextParts: buildProductActivityTextParts(compactText, subjectText),
|
||
operatorText,
|
||
subjectText,
|
||
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);
|
||
}
|