export const formatDuration = (milliseconds, forceHours = false) => { const totalSeconds = Math.max(0, Math.floor((Number(milliseconds) || 0) / 1000)); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; if (forceHours || hours > 0) { return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; } return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; }; export const formatChineseDate = (value) => { if (!value) return '—'; const date = new Date(value); if (Number.isNaN(date.getTime())) return '—'; return new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' }).format(date); }; export const formatChineseTime = (value) => { if (!value) return '—'; const date = new Date(value); if (Number.isNaN(date.getTime())) return '—'; return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(date); }; export const formatChineseDateTime = (value) => { if (!value) return '—'; return `${formatChineseDate(value)} ${formatChineseTime(value)}`; }; export const isContinuationEvent = (event) => ( event?.eventType === 'MARKER' && String(event?.textContent || '').startsWith('续录时间:') ); export const eventClientId = (event) => event?.clientId || event?.id; export const photoAssetClientId = (event) => `${eventClientId(event)}-photo`; export const findPhotoAsset = (event, assets = []) => { const expected = photoAssetClientId(event).toLowerCase(); return assets .filter((asset) => String(asset.kind).toUpperCase() === 'PHOTO') .find((asset) => String(asset.clientId).toLowerCase() === expected); }; export const selectLatestAudioAsset = (assets = []) => ( assets .filter((asset) => String(asset.kind).toUpperCase() === 'AUDIO') .sort((left, right) => { const leftTime = new Date(left.createdAt || 0).getTime(); const rightTime = new Date(right.createdAt || 0).getTime(); return rightTime - leftTime; })[0] || null ); export const sortEvents = (events = []) => ( [...events].sort((left, right) => { const delta = Number(left.relativeTimeMs || 0) - Number(right.relativeTimeMs || 0); if (delta !== 0) return delta; return String(left.createdAt || left.id).localeCompare(String(right.createdAt || right.id)); }) ); export const groupPhotoEvents = (events = []) => ( sortEvents(events.filter((event) => event.eventType === 'PHOTO')) .reduce((groups, event) => { const key = String(event.relativeTimeMs || 0); const existing = groups.find((group) => group.key === key); if (existing) existing.events.push(event); else groups.push({ key, relativeTimeMs: Number(event.relativeTimeMs || 0), events: [event] }); return groups; }, []) ); export const buildChronoFeedItems = (events = []) => { const photoGroups = new Map(groupPhotoEvents(events).map((group) => [group.key, group.events])); const emittedPhotoTimes = new Set(); return sortEvents(events).flatMap((event) => { if (event.eventType !== 'PHOTO') return [{ event, photoEvents: [] }]; const key = String(event.relativeTimeMs || 0); if (emittedPhotoTimes.has(key)) return []; emittedPhotoTimes.add(key); return [{ event, photoEvents: photoGroups.get(key) || [event] }]; }); }; export const countSessionEvents = (events = []) => ({ photoCount: events.filter((event) => event.eventType === 'PHOTO').length, noteCount: events.filter((event) => ( event.eventType === 'NOTE' || (event.eventType === 'MARKER' && !isContinuationEvent(event)) )).length }); export const makeLocation = (event) => { if (event?.latitude == null || event?.longitude == null) return null; return { name: event.locationName || event.locationAddress || '所在位置', address: event.locationAddress || '', latitude: Number(event.latitude), longitude: Number(event.longitude) }; }; export const applyLocation = (event, location) => ({ ...event, locationName: location?.name || '', locationAddress: location?.address || '', latitude: location?.latitude ?? null, longitude: location?.longitude ?? null }); export const createEvent = ({ relativeTimeMs, eventType, textContent = '', location = null }) => { const id = crypto.randomUUID(); return applyLocation({ id, clientId: id, relativeTimeMs: Math.round(relativeTimeMs), eventType, textContent, createdAt: new Date().toISOString() }, location); }; export const buildSessionPayload = ({ session, title = session.title, events = session.events || [], durationMs = session.durationMs, endTime = session.endTime, deletedEventClientIds = [] }) => ({ clientId: session.clientId || session.id, title: title.trim(), startTime: session.startTime, endTime, durationMs: Math.round(Number(durationMs) || 0), baseRevision: Number(session.revision) > 0 ? Number(session.revision) : undefined, deletedEventClientIds, events: events.map((event) => ({ clientId: eventClientId(event), relativeTimeMs: Math.round(Number(event.relativeTimeMs) || 0), eventType: event.eventType, textContent: event.textContent || '', voiceStartOffsetMs: event.voiceStartOffsetMs ?? null, voiceEndOffsetMs: event.voiceEndOffsetMs ?? null, locationName: event.locationName || '', locationAddress: event.locationAddress || '', latitude: event.latitude ?? null, longitude: event.longitude ?? null })) }); export const sanitizeAudioFileName = (title, fallbackExtension = 'm4a') => { const clean = String(title || '') .replace(/[/:\\?%*|"<>]/g, '-') .replace(/[\u0000-\u001f\u007f-\u009f\r\n]/g, '-') .replace(/[.\s]+$/g, '') .trim(); return `${clean || '现场记录'}.${fallbackExtension || 'm4a'}`; }; export const errorMessage = (error, fallback = '操作失败,请稍后重试。') => ( error?.response?.data?.message || error?.message || error?.data?.message || fallback );