全新出发
This commit is contained in:
1 parent
b06c117b1e
commit
fa4edebed6
463 files changed
+181610
-125
No files matched your search
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* ExportV2 — Automation utility functions
|
||||
* Pure functions for automation task scheduling, date range resolution, and formatting.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ExportAutomationTask,
|
||||
ExportAutomationSchedule,
|
||||
ExportAutomationDateRangeConfig
|
||||
} from '../../../types/exportAutomation'
|
||||
import type { ExportDateRangeSelection, ExportDateRangePreset } from '../../../utils/exportDateRange'
|
||||
import {
|
||||
resolveExportDateRangeConfig,
|
||||
getExportDateRangeLabel,
|
||||
createDateRangeByLastNDays
|
||||
} from '../../../utils/exportDateRange'
|
||||
import { formatDurationMs } from './format'
|
||||
|
||||
// ─── Interval normalization ──────────────────────────────────
|
||||
|
||||
export const normalizeAutomationIntervalDays = (value: unknown): number =>
|
||||
Math.max(0, Math.floor(Number(value) || 0))
|
||||
|
||||
export const normalizeAutomationIntervalHours = (value: unknown): number =>
|
||||
Math.max(0, Math.min(23, Math.floor(Number(value) || 0)))
|
||||
|
||||
export const normalizeAutomationFirstTriggerAt = (value: unknown): number => {
|
||||
const numeric = Math.floor(Number(value) || 0)
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) return 0
|
||||
return numeric
|
||||
}
|
||||
|
||||
// ─── Interval calculation ────────────────────────────────────
|
||||
|
||||
export const resolveAutomationIntervalMs = (schedule: ExportAutomationSchedule): number => {
|
||||
const days = normalizeAutomationIntervalDays(schedule.intervalDays)
|
||||
const hours = normalizeAutomationIntervalHours(schedule.intervalHours)
|
||||
const totalHours = (days * 24) + hours
|
||||
if (totalHours <= 0) return 0
|
||||
return totalHours * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
// ─── Trigger timing ──────────────────────────────────────────
|
||||
|
||||
export const resolveAutomationInitialTriggerAt = (task: ExportAutomationTask): number | null => {
|
||||
const intervalMs = resolveAutomationIntervalMs(task.schedule)
|
||||
if (intervalMs <= 0) return null
|
||||
const firstTriggerAt = normalizeAutomationFirstTriggerAt(task.schedule.firstTriggerAt)
|
||||
if (firstTriggerAt > 0) return firstTriggerAt
|
||||
const createdAt = Math.max(0, Math.floor(Number(task.createdAt || 0)))
|
||||
if (!createdAt) return null
|
||||
return createdAt + intervalMs
|
||||
}
|
||||
|
||||
export const resolveAutomationNextTriggerAt = (task: ExportAutomationTask): number | null => {
|
||||
const intervalMs = resolveAutomationIntervalMs(task.schedule)
|
||||
if (intervalMs <= 0) return null
|
||||
const lastTriggeredAt = Math.max(0, Math.floor(Number(task.runState?.lastTriggeredAt || 0)))
|
||||
if (lastTriggeredAt > 0) return lastTriggeredAt + intervalMs
|
||||
return resolveAutomationInitialTriggerAt(task)
|
||||
}
|
||||
|
||||
export const resolveAutomationDueScheduleKey = (task: ExportAutomationTask, now: Date): string | null => {
|
||||
const intervalMs = resolveAutomationIntervalMs(task.schedule)
|
||||
if (intervalMs <= 0) return null
|
||||
const nowMs = now.getTime()
|
||||
const lastTriggeredAt = Math.max(0, Math.floor(Number(task.runState?.lastTriggeredAt || 0)))
|
||||
if (lastTriggeredAt > 0) {
|
||||
if (nowMs < lastTriggeredAt + intervalMs) return null
|
||||
return `interval:${lastTriggeredAt}:${Math.floor((nowMs - lastTriggeredAt) / intervalMs)}`
|
||||
}
|
||||
const initialTriggerAt = resolveAutomationInitialTriggerAt(task)
|
||||
if (!initialTriggerAt) return null
|
||||
if (nowMs < initialTriggerAt) return null
|
||||
return `first:${initialTriggerAt}`
|
||||
}
|
||||
|
||||
// ─── Schedule formatting ─────────────────────────────────────
|
||||
|
||||
export const formatAutomationScheduleLabel = (schedule: ExportAutomationSchedule): string => {
|
||||
const days = normalizeAutomationIntervalDays(schedule.intervalDays)
|
||||
const hours = normalizeAutomationIntervalHours(schedule.intervalHours)
|
||||
const parts: string[] = []
|
||||
if (days > 0) parts.push(`${days} 天`)
|
||||
if (hours > 0) parts.push(`${hours} 小时`)
|
||||
return `每间隔 ${parts.length > 0 ? parts.join(' ') : '0 小时'} 执行一次`
|
||||
}
|
||||
|
||||
export const resolveAutomationFirstTriggerSummary = (task: ExportAutomationTask): string => {
|
||||
const firstTriggerAt = normalizeAutomationFirstTriggerAt(task.schedule.firstTriggerAt)
|
||||
if (firstTriggerAt <= 0) return '未指定(默认按创建时间+间隔)'
|
||||
return new Date(firstTriggerAt).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
export const formatAutomationCurrentState = (
|
||||
task: ExportAutomationTask,
|
||||
queueState: 'queued' | 'running' | null,
|
||||
nowMs: number
|
||||
): string => {
|
||||
if (!task.enabled) return '已停用'
|
||||
if (queueState === 'running') return '执行中'
|
||||
if (queueState === 'queued') return '排队中'
|
||||
const nextTriggerAt = resolveAutomationNextTriggerAt(task)
|
||||
if (!nextTriggerAt) return '等待触发'
|
||||
const diff = nextTriggerAt - nowMs
|
||||
if (diff <= 0) return '即将触发'
|
||||
return `等待触发 · 下次 ${new Date(nextTriggerAt).toLocaleString('zh-CN')}(约 ${formatDurationMs(diff)} 后)`
|
||||
}
|
||||
|
||||
export const formatAutomationStopCondition = (task: ExportAutomationTask): string => {
|
||||
const endAt = Number(task.stopCondition?.endAt || 0)
|
||||
const maxRuns = Number(task.stopCondition?.maxRuns || 0)
|
||||
const labels: string[] = []
|
||||
if (endAt > 0) {
|
||||
labels.push(`截止到 ${new Date(endAt).toLocaleString('zh-CN')}`)
|
||||
}
|
||||
if (maxRuns > 0) {
|
||||
const successCount = Math.max(0, Math.floor(Number(task.runState?.successCount || 0)))
|
||||
labels.push(`成功 ${successCount}/${maxRuns} 次后停止`)
|
||||
}
|
||||
return labels.length > 0 ? labels.join(' · ') : '无'
|
||||
}
|
||||
|
||||
export const formatAutomationLastRunSummary = (task: ExportAutomationTask): string => {
|
||||
const status = task.runState?.lastRunStatus || 'idle'
|
||||
const label = (
|
||||
status === 'idle' ? '尚未执行' :
|
||||
status === 'queued' ? '已入队' :
|
||||
status === 'running' ? '执行中' :
|
||||
status === 'success' ? '执行成功' :
|
||||
status === 'error' ? '执行失败' :
|
||||
status === 'skipped' ? '已跳过' :
|
||||
status
|
||||
)
|
||||
const parts: string[] = [label]
|
||||
if (task.runState?.lastSuccessAt) {
|
||||
parts.push(`最近成功于 ${new Date(task.runState.lastSuccessAt).toLocaleString('zh-CN')}`)
|
||||
}
|
||||
if (task.runState?.lastSkipReason) parts.push(task.runState.lastSkipReason)
|
||||
if (task.runState?.lastError) parts.push(task.runState.lastError)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
// ─── Schedule builder ────────────────────────────────────────
|
||||
|
||||
export const buildAutomationSchedule = (
|
||||
intervalDays: number,
|
||||
intervalHours: number,
|
||||
firstTriggerAt: number
|
||||
): ExportAutomationSchedule => ({
|
||||
type: 'interval',
|
||||
intervalDays,
|
||||
intervalHours,
|
||||
firstTriggerAt: firstTriggerAt > 0 ? firstTriggerAt : undefined
|
||||
})
|
||||
|
||||
// ─── Date part helpers ───────────────────────────────────────
|
||||
|
||||
export const buildAutomationDatePart = (timestamp: number): string => {
|
||||
const date = new Date(timestamp)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
const year = date.getFullYear()
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||
const day = `${date.getDate()}`.padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
export const buildAutomationTodayDatePart = (): string => buildAutomationDatePart(Date.now())
|
||||
|
||||
export const normalizeAutomationDatePart = (value: string): string => {
|
||||
const text = String(value || '').trim()
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : ''
|
||||
}
|
||||
|
||||
export const normalizeAutomationTimePart = (value: string): string => {
|
||||
const text = String(value || '').trim()
|
||||
if (!/^\d{2}:\d{2}$/.test(text)) return '00:00'
|
||||
const [hoursText, minutesText] = text.split(':')
|
||||
const hours = Math.floor(Number(hoursText))
|
||||
const minutes = Math.floor(Number(minutesText))
|
||||
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return '00:00'
|
||||
const safeHours = Math.min(23, Math.max(0, hours))
|
||||
const safeMinutes = Math.min(59, Math.max(0, minutes))
|
||||
return `${`${safeHours}`.padStart(2, '0')}:${`${safeMinutes}`.padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// ─── Date range for automation ───────────────────────────────
|
||||
|
||||
export type AutomationRangeMode = 'all' | 'today' | 'yesterday' | 'last7days' | 'last30days' | 'last1year' | 'lastNDays' | 'custom'
|
||||
|
||||
export const AUTOMATION_RANGE_OPTIONS: Array<{ mode: AutomationRangeMode; label: string }> = [
|
||||
{ mode: 'all', label: '全部时间' },
|
||||
{ mode: 'yesterday', label: '往前1天' },
|
||||
{ mode: 'last7days', label: '往前7天' },
|
||||
{ mode: 'last30days', label: '往前30天' },
|
||||
{ mode: 'last1year', label: '往前1年' },
|
||||
{ mode: 'lastNDays', label: '往前N天' },
|
||||
{ mode: 'custom', label: '完整时间' }
|
||||
]
|
||||
|
||||
export const AUTOMATION_LAST_N_DAYS_MIN = 1
|
||||
export const AUTOMATION_LAST_N_DAYS_MAX = 3650
|
||||
export const AUTOMATION_LAST_N_DAYS_DEFAULT = 3
|
||||
|
||||
export const normalizeAutomationLastNDays = (value: unknown): number => {
|
||||
const parsed = Math.floor(Number(value) || 0)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return AUTOMATION_LAST_N_DAYS_DEFAULT
|
||||
return Math.min(AUTOMATION_LAST_N_DAYS_MAX, Math.max(AUTOMATION_LAST_N_DAYS_MIN, parsed))
|
||||
}
|
||||
|
||||
export const readAutomationLastNDays = (
|
||||
config: ExportAutomationDateRangeConfig | string | null | undefined
|
||||
): number | null => {
|
||||
if (!config || typeof config !== 'object') return null
|
||||
const raw = config as Record<string, unknown>
|
||||
const mode = String(raw.relativeMode || '').trim()
|
||||
if (mode !== 'last-n-days') return null
|
||||
const days = Math.floor(Number(raw.relativeDays) || 0)
|
||||
if (!Number.isFinite(days) || days <= 0) return null
|
||||
return Math.min(AUTOMATION_LAST_N_DAYS_MAX, Math.max(AUTOMATION_LAST_N_DAYS_MIN, days))
|
||||
}
|
||||
|
||||
export const buildAutomationLastNDaysConfig = (days: number): ExportAutomationDateRangeConfig => ({
|
||||
version: 1,
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
relativeMode: 'last-n-days',
|
||||
relativeDays: normalizeAutomationLastNDays(days)
|
||||
})
|
||||
|
||||
export const resolveAutomationDateRangeSelection = (
|
||||
config: ExportAutomationDateRangeConfig | string | null | undefined,
|
||||
now = new Date()
|
||||
): ExportDateRangeSelection => {
|
||||
const relativeDays = readAutomationLastNDays(config)
|
||||
if (relativeDays) {
|
||||
return {
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: createDateRangeByLastNDays(relativeDays, now)
|
||||
}
|
||||
}
|
||||
return resolveExportDateRangeConfig(config as any, now)
|
||||
}
|
||||
|
||||
export const resolveAutomationRangeMode = (
|
||||
config: ExportAutomationDateRangeConfig | string | null | undefined,
|
||||
selection: ExportDateRangeSelection
|
||||
): AutomationRangeMode => {
|
||||
if (readAutomationLastNDays(config)) return 'lastNDays'
|
||||
if (selection.useAllTime) return 'all'
|
||||
if (selection.preset === 'today') return 'today'
|
||||
if (selection.preset === 'yesterday') return 'yesterday'
|
||||
if (selection.preset === 'last7days') return 'last7days'
|
||||
if (selection.preset === 'last30days') return 'last30days'
|
||||
if (selection.preset === 'last1year') return 'last1year'
|
||||
return 'custom'
|
||||
}
|
||||
|
||||
export const createAutomationSelectionByMode = (
|
||||
mode: Exclude<AutomationRangeMode, 'custom' | 'lastNDays'>,
|
||||
now = new Date()
|
||||
): ExportDateRangeSelection => {
|
||||
const preset: ExportDateRangePreset = mode
|
||||
return resolveExportDateRangeConfig({
|
||||
version: 1,
|
||||
preset,
|
||||
useAllTime: mode === 'all'
|
||||
}, now)
|
||||
}
|
||||
|
||||
export const formatAutomationRangeLabel = (
|
||||
config: ExportAutomationDateRangeConfig | string | null | undefined,
|
||||
selection?: ExportDateRangeSelection
|
||||
): string => {
|
||||
const resolved = selection || resolveAutomationDateRangeSelection(config, new Date())
|
||||
const mode = resolveAutomationRangeMode(config, resolved)
|
||||
if (mode === 'all') return '每次触发导出全部历史消息'
|
||||
if (mode === 'today') return '每次触发导出当天'
|
||||
if (mode === 'yesterday') return '每次触发导出前1天(昨日)'
|
||||
if (mode === 'last7days') return '每次触发导出前7天'
|
||||
if (mode === 'last30days') return '每次触发导出前30天'
|
||||
if (mode === 'last1year') return '每次触发导出前1年'
|
||||
if (mode === 'lastNDays') {
|
||||
return `每次触发导出前 ${readAutomationLastNDays(config) || AUTOMATION_LAST_N_DAYS_DEFAULT} 天`
|
||||
}
|
||||
return `完整时间:${getExportDateRangeLabel(resolved)}`
|
||||
}
|
||||
|
||||
// ─── Task ID factories ───────────────────────────────────────
|
||||
|
||||
export const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
export const createAutomationTaskId = (): string => `auto-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* ExportV2 — Avatar utility functions
|
||||
* Pure functions for avatar URL normalization, caching logic, and merge operations.
|
||||
*/
|
||||
|
||||
import type { ContactInfo } from '../../../types/models'
|
||||
import type * as configService from '../../../services/config'
|
||||
import { INLINE_AVATAR_CACHE_MAX_LENGTH } from '../constants'
|
||||
|
||||
// ─── URL normalization ───────────────────────────────────────
|
||||
|
||||
export const normalizeExportAvatarUrl = (value?: string | null): string | undefined => {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) return undefined
|
||||
const lower = normalized.toLowerCase()
|
||||
if (lower === 'null' || lower === 'undefined') return undefined
|
||||
return normalized
|
||||
}
|
||||
|
||||
export const shouldPersistExportAvatarUrl = (value?: string | null): value is string => {
|
||||
const normalized = normalizeExportAvatarUrl(value)
|
||||
if (!normalized) return false
|
||||
if (!normalized.startsWith('data:')) return true
|
||||
return normalized.length <= INLINE_AVATAR_CACHE_MAX_LENGTH
|
||||
}
|
||||
|
||||
// ─── Contact map from caches ─────────────────────────────────
|
||||
|
||||
export const toContactMapFromCaches = (
|
||||
contacts: configService.ContactsListCacheContact[],
|
||||
avatarEntries: Record<string, configService.ContactsAvatarCacheEntry>
|
||||
): Record<string, ContactInfo> => {
|
||||
const map: Record<string, ContactInfo> = {}
|
||||
for (const contact of contacts || []) {
|
||||
if (!contact?.username) continue
|
||||
const cachedAvatarUrl = avatarEntries[contact.username]?.avatarUrl
|
||||
map[contact.username] = {
|
||||
...contact,
|
||||
avatarUrl: shouldPersistExportAvatarUrl(cachedAvatarUrl) ? cachedAvatarUrl : undefined
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// ─── Avatar cache compaction ─────────────────────────────────
|
||||
|
||||
export const compactExportAvatarEntries = (
|
||||
avatarEntries: Record<string, configService.ContactsAvatarCacheEntry>
|
||||
): {
|
||||
avatarEntries: Record<string, configService.ContactsAvatarCacheEntry>
|
||||
changed: boolean
|
||||
} => {
|
||||
const nextCache: Record<string, configService.ContactsAvatarCacheEntry> = {}
|
||||
let changed = false
|
||||
for (const [username, entry] of Object.entries(avatarEntries || {})) {
|
||||
const normalizedUsername = String(username || '').trim()
|
||||
const avatarUrl = normalizeExportAvatarUrl(entry?.avatarUrl)
|
||||
if (!normalizedUsername || !shouldPersistExportAvatarUrl(avatarUrl)) {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
nextCache[normalizedUsername] = {
|
||||
avatarUrl,
|
||||
updatedAt: Number(entry?.updatedAt || 0) || Date.now(),
|
||||
checkedAt: Number(entry?.checkedAt || 0) || Date.now()
|
||||
}
|
||||
if (
|
||||
normalizedUsername !== username ||
|
||||
avatarUrl !== entry?.avatarUrl ||
|
||||
nextCache[normalizedUsername].updatedAt !== entry?.updatedAt ||
|
||||
nextCache[normalizedUsername].checkedAt !== entry?.checkedAt
|
||||
) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return { avatarEntries: nextCache, changed }
|
||||
}
|
||||
|
||||
// ─── Merge avatar cache into contacts ────────────────────────
|
||||
|
||||
export const mergeAvatarCacheIntoContacts = (
|
||||
sourceContacts: ContactInfo[],
|
||||
avatarEntries: Record<string, configService.ContactsAvatarCacheEntry>
|
||||
): ContactInfo[] => {
|
||||
if (!sourceContacts.length || Object.keys(avatarEntries).length === 0) {
|
||||
return sourceContacts
|
||||
}
|
||||
|
||||
let changed = false
|
||||
const merged = sourceContacts.map((contact) => {
|
||||
const cachedAvatar = avatarEntries[contact.username]?.avatarUrl
|
||||
if (!shouldPersistExportAvatarUrl(cachedAvatar) || contact.avatarUrl) {
|
||||
return contact
|
||||
}
|
||||
changed = true
|
||||
return {
|
||||
...contact,
|
||||
avatarUrl: cachedAvatar
|
||||
}
|
||||
})
|
||||
|
||||
return changed ? merged : sourceContacts
|
||||
}
|
||||
|
||||
// ─── Upsert avatar cache from contacts ───────────────────────
|
||||
|
||||
export const upsertAvatarCacheFromContacts = (
|
||||
avatarEntries: Record<string, configService.ContactsAvatarCacheEntry>,
|
||||
sourceContacts: ContactInfo[],
|
||||
options?: { prune?: boolean; markCheckedUsernames?: string[]; now?: number }
|
||||
): {
|
||||
avatarEntries: Record<string, configService.ContactsAvatarCacheEntry>
|
||||
changed: boolean
|
||||
updatedAt: number | null
|
||||
} => {
|
||||
const compactedCache = compactExportAvatarEntries(avatarEntries)
|
||||
const nextCache = { ...compactedCache.avatarEntries }
|
||||
const now = options?.now || Date.now()
|
||||
const markCheckedSet = new Set((options?.markCheckedUsernames || []).filter(Boolean))
|
||||
const usernamesInSource = new Set<string>()
|
||||
let changed = compactedCache.changed
|
||||
|
||||
for (const contact of sourceContacts) {
|
||||
const username = String(contact.username || '').trim()
|
||||
if (!username) continue
|
||||
usernamesInSource.add(username)
|
||||
const prev = nextCache[username]
|
||||
const avatarUrl = normalizeExportAvatarUrl(contact.avatarUrl)
|
||||
if (!shouldPersistExportAvatarUrl(avatarUrl)) continue
|
||||
const updatedAt = !prev || prev.avatarUrl !== avatarUrl ? now : prev.updatedAt
|
||||
const checkedAt = markCheckedSet.has(username) ? now : (prev?.checkedAt || now)
|
||||
if (!prev || prev.avatarUrl !== avatarUrl || prev.updatedAt !== updatedAt || prev.checkedAt !== checkedAt) {
|
||||
nextCache[username] = { avatarUrl, updatedAt, checkedAt }
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
for (const username of markCheckedSet) {
|
||||
const prev = nextCache[username]
|
||||
if (!prev) continue
|
||||
if (prev.checkedAt !== now) {
|
||||
nextCache[username] = { ...prev, checkedAt: now }
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.prune) {
|
||||
for (const username of Object.keys(nextCache)) {
|
||||
if (usernamesInSource.has(username)) continue
|
||||
delete nextCache[username]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
avatarEntries: nextCache,
|
||||
changed,
|
||||
updatedAt: changed ? now : null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* ExportV2 — Formatting utilities
|
||||
* Pure functions for date, time, duration, and path formatting.
|
||||
*/
|
||||
|
||||
// ─── Duration ────────────────────────────────────────────────
|
||||
|
||||
export const formatDurationMs = (ms: number): string => {
|
||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000))
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
if (hours > 0) return `${hours}小时${minutes}分${seconds}秒`
|
||||
if (minutes > 0) return `${minutes}分${seconds}秒`
|
||||
return `${seconds}秒`
|
||||
}
|
||||
|
||||
// ─── Absolute dates ──────────────────────────────────────────
|
||||
|
||||
export const formatAbsoluteDate = (timestamp: number): string => {
|
||||
const d = new Date(timestamp)
|
||||
const y = d.getFullYear()
|
||||
const m = `${d.getMonth() + 1}`.padStart(2, '0')
|
||||
const day = `${d.getDate()}`.padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
export const formatYmdDateFromSeconds = (timestamp?: number): string => {
|
||||
if (!timestamp || !Number.isFinite(timestamp)) return '—'
|
||||
const d = new Date(timestamp * 1000)
|
||||
const y = d.getFullYear()
|
||||
const m = `${d.getMonth() + 1}`.padStart(2, '0')
|
||||
const day = `${d.getDate()}`.padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
export const formatYmdHmDateTime = (timestamp?: number): string => {
|
||||
if (!timestamp || !Number.isFinite(timestamp)) return '—'
|
||||
const d = new Date(timestamp)
|
||||
const y = d.getFullYear()
|
||||
const m = `${d.getMonth() + 1}`.padStart(2, '0')
|
||||
const day = `${d.getDate()}`.padStart(2, '0')
|
||||
const h = `${d.getHours()}`.padStart(2, '0')
|
||||
const min = `${d.getMinutes()}`.padStart(2, '0')
|
||||
return `${y}-${m}-${day} ${h}:${min}`
|
||||
}
|
||||
|
||||
// ─── Relative time ───────────────────────────────────────────
|
||||
|
||||
export const formatLatestMessageTimeFromSeconds = (
|
||||
timestamp?: number,
|
||||
now: number = Date.now()
|
||||
): { text: string; title: string } => {
|
||||
if (!timestamp || !Number.isFinite(timestamp) || timestamp <= 0) {
|
||||
return { text: '--', title: '' }
|
||||
}
|
||||
const ms = timestamp * 1000
|
||||
const absolute = formatYmdHmDateTime(ms)
|
||||
const diff = Math.max(0, now - ms)
|
||||
const minute = 60 * 1000
|
||||
const hour = 60 * minute
|
||||
const day = 24 * hour
|
||||
if (diff < minute) return { text: '刚刚', title: absolute }
|
||||
if (diff < hour) {
|
||||
const minutes = Math.max(1, Math.floor(diff / minute))
|
||||
return { text: `${minutes} 分钟前`, title: absolute }
|
||||
}
|
||||
if (diff < day) {
|
||||
const hours = Math.max(1, Math.floor(diff / hour))
|
||||
return { text: `${hours} 小时前`, title: absolute }
|
||||
}
|
||||
return { text: absolute, title: absolute }
|
||||
}
|
||||
|
||||
export const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => {
|
||||
if (!timestamp) return ''
|
||||
const diff = Math.max(0, now - timestamp)
|
||||
const minute = 60 * 1000
|
||||
const hour = 60 * minute
|
||||
const day = 24 * hour
|
||||
if (diff < hour) {
|
||||
const minutes = Math.max(1, Math.floor(diff / minute))
|
||||
return `${minutes} 分钟前`
|
||||
}
|
||||
if (diff < day) {
|
||||
const hours = Math.max(1, Math.floor(diff / hour))
|
||||
return `${hours} 小时前`
|
||||
}
|
||||
return formatAbsoluteDate(timestamp)
|
||||
}
|
||||
|
||||
// ─── Path formatting ─────────────────────────────────────────
|
||||
|
||||
export const formatPathBrief = (value: string, maxLength = 52): string => {
|
||||
const normalized = String(value || '')
|
||||
if (normalized.length <= maxLength) return normalized
|
||||
|
||||
// Try to split by path separator
|
||||
const separator = normalized.includes('\\') ? '\\' : '/'
|
||||
const parts = normalized.split(separator).filter(Boolean) // Handle trailing/leading slashes
|
||||
|
||||
if (parts.length <= 2) {
|
||||
// Cannot shorten by directory levels, fallback to string slice
|
||||
const headLength = Math.max(10, Math.floor(maxLength * 0.55))
|
||||
const tailLength = Math.max(8, maxLength - headLength - 1)
|
||||
return `${normalized.slice(0, headLength)}…${normalized.slice(-tailLength)}`
|
||||
}
|
||||
|
||||
// Handle Windows paths like C:\ keeping the root
|
||||
let first = parts[0]
|
||||
if (normalized.startsWith(separator)) {
|
||||
first = separator + first
|
||||
} else if (normalized.includes(':\\') || normalized.includes(':/')) {
|
||||
// Keep root drive like C:
|
||||
first = first + separator
|
||||
} else {
|
||||
first = first + separator
|
||||
}
|
||||
|
||||
const last = parts[parts.length - 1]
|
||||
|
||||
let result = `${first}…${separator}${last}`
|
||||
let leftIdx = 1
|
||||
let rightIdx = parts.length - 2
|
||||
|
||||
while (leftIdx <= rightIdx) {
|
||||
const nextLeft = `${parts[leftIdx]}${separator}`
|
||||
const nextRight = leftIdx < rightIdx ? `${separator}${parts[rightIdx]}` : ''
|
||||
|
||||
if (result.length + nextLeft.length + nextRight.length <= maxLength) {
|
||||
result = result.replace('…', `${nextLeft}…${nextRight}`)
|
||||
leftIdx++
|
||||
rightIdx--
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (result.length > maxLength) {
|
||||
const headLength = Math.max(10, Math.floor(maxLength * 0.55))
|
||||
const tailLength = Math.max(8, maxLength - headLength - 1)
|
||||
return `${normalized.slice(0, headLength)}…${normalized.slice(-tailLength)}`
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export const resolveParentDir = (value: string): string => {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) return ''
|
||||
const noTrailing = normalized.replace(/[\\/]+$/, '')
|
||||
if (!noTrailing) return normalized
|
||||
const lastSlash = Math.max(noTrailing.lastIndexOf('/'), noTrailing.lastIndexOf('\\'))
|
||||
if (lastSlash < 0) return normalized
|
||||
if (lastSlash === 0) return noTrailing.slice(0, 1)
|
||||
if (/^[A-Za-z]:$/.test(noTrailing.slice(0, lastSlash))) {
|
||||
return `${noTrailing.slice(0, lastSlash)}\\`
|
||||
}
|
||||
return noTrailing.slice(0, lastSlash)
|
||||
}
|
||||
|
||||
// ─── Avatar ──────────────────────────────────────────────────
|
||||
|
||||
export const getAvatarLetter = (name: string): string => {
|
||||
if (!name) return '?'
|
||||
return [...name][0] || '?'
|
||||
}
|
||||
|
||||
// ─── DateTime local value (for <input type="datetime-local">) ─
|
||||
|
||||
export const toDateTimeLocalValue = (timestamp: number): string => {
|
||||
const date = new Date(timestamp)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
const year = date.getFullYear()
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||
const day = `${date.getDate()}`.padStart(2, '0')
|
||||
const hours = `${date.getHours()}`.padStart(2, '0')
|
||||
const minutes = `${date.getMinutes()}`.padStart(2, '0')
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`
|
||||
}
|
||||
|
||||
export const parseDateTimeLocalValue = (value: string): number | null => {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) return null
|
||||
const parsed = new Date(text)
|
||||
const timestamp = parsed.getTime()
|
||||
if (!Number.isFinite(timestamp)) return null
|
||||
return Math.floor(timestamp)
|
||||
}
|
||||
|
||||
// ─── Number normalization ────────────────────────────────────
|
||||
|
||||
export const normalizeMessageCount = (value: unknown): number | undefined => {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return undefined
|
||||
return Math.floor(parsed)
|
||||
}
|
||||
|
||||
export const normalizeTimestampSeconds = (value: unknown): number | undefined => {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return undefined
|
||||
return Math.floor(parsed)
|
||||
}
|
||||
|
||||
export const mergeStableCount = (incoming: number | undefined, previous: number | undefined): number | undefined => {
|
||||
if (typeof incoming !== 'number') return previous
|
||||
if (incoming === 0 && typeof previous === 'number' && previous > 0) return previous
|
||||
return incoming
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* ExportV2 — Performance tracking utilities
|
||||
* Pure functions for task performance measurement (per-session timing, stage breakdown).
|
||||
*/
|
||||
|
||||
import type { ExportTask, TaskPerformance, TaskSessionPerformance, TaskPerfStage, ExportProgress } from '../types'
|
||||
import { TASK_PERFORMANCE_UPDATE_MIN_INTERVAL_MS } from '../constants'
|
||||
import { isTextBatchTask } from './progress'
|
||||
|
||||
// ─── Stage resolution ────────────────────────────────────────
|
||||
|
||||
export const resolvePerfStageByPhase = (phase?: ExportProgress['phase']): TaskPerfStage => {
|
||||
if (phase === 'preparing') return 'collect'
|
||||
if (phase === 'writing') return 'write'
|
||||
if (phase === 'exporting' || phase === 'exporting-media' || phase === 'exporting-voice') return 'build'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
// ─── Performance object factories ────────────────────────────
|
||||
|
||||
export const createEmptyTaskPerformance = (): TaskPerformance => ({
|
||||
stages: { collect: 0, build: 0, write: 0, other: 0 },
|
||||
sessions: {}
|
||||
})
|
||||
|
||||
export const cloneTaskPerformance = (performance?: TaskPerformance): TaskPerformance => ({
|
||||
stages: {
|
||||
collect: performance?.stages.collect || 0,
|
||||
build: performance?.stages.build || 0,
|
||||
write: performance?.stages.write || 0,
|
||||
other: performance?.stages.other || 0
|
||||
},
|
||||
sessions: { ...(performance?.sessions || {}) }
|
||||
})
|
||||
|
||||
// ─── Session name resolution ─────────────────────────────────
|
||||
|
||||
export const resolveTaskSessionName = (task: ExportTask, sessionId: string, fallback?: string): string => {
|
||||
const idx = task.payload.sessionIds.indexOf(sessionId)
|
||||
if (idx >= 0) {
|
||||
return task.payload.sessionNames[idx] || fallback || sessionId
|
||||
}
|
||||
return fallback || sessionId
|
||||
}
|
||||
|
||||
// ─── Apply progress to performance ──────────────────────────
|
||||
|
||||
export const applyProgressToTaskPerformance = (
|
||||
task: ExportTask,
|
||||
payload: ExportProgress,
|
||||
now: number
|
||||
): TaskPerformance | undefined => {
|
||||
if (!isTextBatchTask(task)) return task.performance
|
||||
const sessionId = String(payload.currentSessionId || '').trim()
|
||||
if (!sessionId) return task.performance || createEmptyTaskPerformance()
|
||||
|
||||
const currentPerformance = task.performance
|
||||
const currentSession = currentPerformance?.sessions?.[sessionId]
|
||||
if (
|
||||
payload.phase !== 'complete' &&
|
||||
currentSession &&
|
||||
currentSession.lastPhase === payload.phase &&
|
||||
typeof currentSession.lastPhaseStartedAt === 'number' &&
|
||||
now - currentSession.lastPhaseStartedAt < TASK_PERFORMANCE_UPDATE_MIN_INTERVAL_MS
|
||||
) {
|
||||
return currentPerformance
|
||||
}
|
||||
|
||||
const performance = cloneTaskPerformance(task.performance)
|
||||
const sessionName = resolveTaskSessionName(task, sessionId, payload.currentSession || sessionId)
|
||||
const existing = performance.sessions[sessionId]
|
||||
const session: TaskSessionPerformance = existing
|
||||
? { ...existing, sessionName: existing.sessionName || sessionName }
|
||||
: { sessionId, sessionName, startedAt: now, elapsedMs: 0 }
|
||||
|
||||
if (!session.finishedAt && session.lastPhase && typeof session.lastPhaseStartedAt === 'number') {
|
||||
const delta = Math.max(0, now - session.lastPhaseStartedAt)
|
||||
performance.stages[resolvePerfStageByPhase(session.lastPhase)] += delta
|
||||
}
|
||||
|
||||
session.elapsedMs = Math.max(session.elapsedMs, now - session.startedAt)
|
||||
|
||||
if (payload.phase === 'complete') {
|
||||
session.finishedAt = now
|
||||
session.lastPhase = undefined
|
||||
session.lastPhaseStartedAt = undefined
|
||||
} else {
|
||||
session.lastPhase = payload.phase
|
||||
session.lastPhaseStartedAt = now
|
||||
}
|
||||
|
||||
performance.sessions[sessionId] = session
|
||||
return performance
|
||||
}
|
||||
|
||||
// ─── Finalize performance on task completion ─────────────────
|
||||
|
||||
export const finalizeTaskPerformance = (task: ExportTask, now: number): TaskPerformance | undefined => {
|
||||
if (!isTextBatchTask(task) || !task.performance) return task.performance
|
||||
const performance = cloneTaskPerformance(task.performance)
|
||||
const nextSessions: Record<string, TaskSessionPerformance> = {}
|
||||
for (const [sessionId, sourceSession] of Object.entries(performance.sessions)) {
|
||||
const session: TaskSessionPerformance = { ...sourceSession }
|
||||
if (session.finishedAt) continue
|
||||
if (session.lastPhase && typeof session.lastPhaseStartedAt === 'number') {
|
||||
const delta = Math.max(0, now - session.lastPhaseStartedAt)
|
||||
performance.stages[resolvePerfStageByPhase(session.lastPhase)] += delta
|
||||
}
|
||||
session.elapsedMs = Math.max(session.elapsedMs, now - session.startedAt)
|
||||
session.finishedAt = now
|
||||
session.lastPhase = undefined
|
||||
session.lastPhaseStartedAt = undefined
|
||||
nextSessions[sessionId] = session
|
||||
}
|
||||
for (const [sessionId, sourceSession] of Object.entries(performance.sessions)) {
|
||||
if (nextSessions[sessionId]) continue
|
||||
nextSessions[sessionId] = { ...sourceSession }
|
||||
}
|
||||
performance.sessions = nextSessions
|
||||
return performance
|
||||
}
|
||||
|
||||
// ─── Stage totals (live, accounts for in-progress sessions) ──
|
||||
|
||||
export const getTaskPerformanceStageTotals = (
|
||||
performance: TaskPerformance | undefined,
|
||||
now: number
|
||||
): Record<TaskPerfStage, number> => {
|
||||
const totals: Record<TaskPerfStage, number> = {
|
||||
collect: performance?.stages.collect || 0,
|
||||
build: performance?.stages.build || 0,
|
||||
write: performance?.stages.write || 0,
|
||||
other: performance?.stages.other || 0
|
||||
}
|
||||
if (!performance) return totals
|
||||
for (const session of Object.values(performance.sessions)) {
|
||||
if (session.finishedAt) continue
|
||||
if (!session.lastPhase || typeof session.lastPhaseStartedAt !== 'number') continue
|
||||
const delta = Math.max(0, now - session.lastPhaseStartedAt)
|
||||
totals[resolvePerfStageByPhase(session.lastPhase)] += delta
|
||||
}
|
||||
return totals
|
||||
}
|
||||
|
||||
// ─── Top N slowest sessions ──────────────────────────────────
|
||||
|
||||
export const getTaskPerformanceTopSessions = (
|
||||
performance: TaskPerformance | undefined,
|
||||
now: number,
|
||||
limit = 5
|
||||
): Array<TaskSessionPerformance & { liveElapsedMs: number }> => {
|
||||
if (!performance) return []
|
||||
return Object.values(performance.sessions)
|
||||
.map((session) => {
|
||||
const liveElapsedMs = session.finishedAt
|
||||
? session.elapsedMs
|
||||
: Math.max(session.elapsedMs, now - session.startedAt)
|
||||
return { ...session, liveElapsedMs }
|
||||
})
|
||||
.sort((a, b) => b.liveElapsedMs - a.liveElapsedMs)
|
||||
.slice(0, limit)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* ExportV2 — Progress utility functions
|
||||
* Pure functions for progress normalization, comparison, and formatting.
|
||||
*/
|
||||
|
||||
import type { TaskProgress, ExportTask, ExportProgress } from '../types'
|
||||
|
||||
// ─── Progress comparison ─────────────────────────────────────
|
||||
|
||||
export const areTaskProgressEqual = (left: TaskProgress, right: TaskProgress): boolean => (
|
||||
left.current === right.current &&
|
||||
left.total === right.total &&
|
||||
left.currentName === right.currentName &&
|
||||
left.phase === right.phase &&
|
||||
left.phaseLabel === right.phaseLabel &&
|
||||
left.phaseProgress === right.phaseProgress &&
|
||||
left.phaseTotal === right.phaseTotal &&
|
||||
left.exportedMessages === right.exportedMessages &&
|
||||
left.estimatedTotalMessages === right.estimatedTotalMessages &&
|
||||
left.collectedMessages === right.collectedMessages &&
|
||||
left.writtenFiles === right.writtenFiles &&
|
||||
left.mediaDoneFiles === right.mediaDoneFiles &&
|
||||
left.mediaCacheHitFiles === right.mediaCacheHitFiles &&
|
||||
left.mediaCacheMissFiles === right.mediaCacheMissFiles &&
|
||||
left.mediaCacheFillFiles === right.mediaCacheFillFiles &&
|
||||
left.mediaDedupReuseFiles === right.mediaDedupReuseFiles &&
|
||||
left.mediaBytesWritten === right.mediaBytesWritten
|
||||
)
|
||||
|
||||
// ─── Progress normalization ──────────────────────────────────
|
||||
|
||||
export const normalizeProgressFloat = (value: unknown, digits = 3): number => {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return 0
|
||||
const factor = 10 ** digits
|
||||
return Math.round(parsed * factor) / factor
|
||||
}
|
||||
|
||||
export const normalizeProgressInt = (value: unknown): number => {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return 0
|
||||
return Math.max(0, Math.floor(parsed))
|
||||
}
|
||||
|
||||
// ─── Progress payload signature (for dedup) ──────────────────
|
||||
|
||||
export const buildProgressPayloadSignature = (payload: ExportProgress): string => ([
|
||||
String(payload.phase || ''),
|
||||
String(payload.currentSessionId || ''),
|
||||
String(payload.currentSession || ''),
|
||||
String(payload.phaseLabel || ''),
|
||||
normalizeProgressFloat(payload.current, 4),
|
||||
normalizeProgressFloat(payload.total, 4),
|
||||
normalizeProgressFloat(payload.phaseProgress, 2),
|
||||
normalizeProgressFloat(payload.phaseTotal, 2),
|
||||
normalizeProgressInt(payload.collectedMessages),
|
||||
normalizeProgressInt(payload.exportedMessages),
|
||||
normalizeProgressInt(payload.estimatedTotalMessages),
|
||||
normalizeProgressInt(payload.writtenFiles),
|
||||
normalizeProgressInt(payload.mediaDoneFiles),
|
||||
normalizeProgressInt(payload.mediaCacheHitFiles),
|
||||
normalizeProgressInt(payload.mediaCacheMissFiles),
|
||||
normalizeProgressInt(payload.mediaCacheFillFiles),
|
||||
normalizeProgressInt(payload.mediaDedupReuseFiles),
|
||||
normalizeProgressInt(payload.mediaBytesWritten)
|
||||
].join('|'))
|
||||
|
||||
// ─── Task status helpers ─────────────────────────────────────
|
||||
|
||||
export const isExportTaskActiveStatus = (status: import('../types').TaskStatus): boolean => (
|
||||
status === 'queued' ||
|
||||
status === 'running' ||
|
||||
status === 'pause_requested' ||
|
||||
status === 'paused' ||
|
||||
status === 'cancel_requested'
|
||||
)
|
||||
|
||||
export const getTaskStatusLabel = (task: ExportTask): string => {
|
||||
if (task.status === 'queued') return '排队中'
|
||||
if (task.status === 'running') return '进行中'
|
||||
if (task.status === 'pause_requested') return '暂停中'
|
||||
if (task.status === 'paused') return '已暂停'
|
||||
if (task.status === 'cancel_requested') return '取消中'
|
||||
if (task.status === 'success') return '已完成'
|
||||
return '失败'
|
||||
}
|
||||
|
||||
export const resolveExportTaskCardClass = (
|
||||
status: import('../types').TaskStatus
|
||||
): 'queued' | 'running' | 'paused' | 'stopped' | 'success' | 'error' => {
|
||||
if (status === 'pause_requested' || status === 'paused') return 'paused'
|
||||
if (status === 'cancel_requested') return 'stopped'
|
||||
return status
|
||||
}
|
||||
|
||||
export const resolveBackgroundTaskCardClass = (
|
||||
status: import('../../../types/backgroundTask').BackgroundTaskRecord['status']
|
||||
): 'running' | 'paused' | 'stopped' | 'success' | 'error' => {
|
||||
if (status === 'running') return 'running'
|
||||
if (status === 'pause_requested' || status === 'paused') return 'paused'
|
||||
if (status === 'cancel_requested' || status === 'canceled') return 'stopped'
|
||||
if (status === 'completed') return 'success'
|
||||
return 'error'
|
||||
}
|
||||
|
||||
// ─── Background task progress parsing ────────────────────────
|
||||
|
||||
export const parseBackgroundTaskProgress = (progressText?: string): {
|
||||
current: number
|
||||
total: number
|
||||
ratio: number | null
|
||||
} => {
|
||||
const normalized = String(progressText || '').trim()
|
||||
if (!normalized) return { current: 0, total: 0, ratio: null }
|
||||
const match = normalized.match(/(\d+)\s*\/\s*(\d+)/)
|
||||
if (!match) return { current: 0, total: 0, ratio: null }
|
||||
const current = Math.max(0, Math.floor(Number(match[1]) || 0))
|
||||
const total = Math.max(0, Math.floor(Number(match[2]) || 0))
|
||||
if (total <= 0) return { current, total, ratio: null }
|
||||
return {
|
||||
current,
|
||||
total,
|
||||
ratio: Math.max(0, Math.min(1, current / total))
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Task scope helpers ──────────────────────────────────────
|
||||
|
||||
export const isTextBatchTask = (task: ExportTask): boolean =>
|
||||
task.payload.scope === 'content' && task.payload.contentType === 'text'
|
||||
|
||||
export const isImageExportTask = (task: ExportTask): boolean => {
|
||||
if (task.payload.scope === 'sns') {
|
||||
return Boolean(task.payload.snsOptions?.exportImages)
|
||||
}
|
||||
if (task.payload.scope !== 'content') return false
|
||||
if (task.payload.contentType === 'image') return true
|
||||
return Boolean(task.payload.options?.exportImages)
|
||||
}
|
||||
|
||||
// ─── Task open directory resolution ──────────────────────────
|
||||
|
||||
import { resolveParentDir } from './format'
|
||||
|
||||
export const resolveTaskOpenDir = (task: ExportTask): string => {
|
||||
const sessionIds = Array.isArray(task.payload.sessionIds) ? task.payload.sessionIds : []
|
||||
if (sessionIds.length === 1) {
|
||||
const onlySessionId = String(sessionIds[0] || '').trim()
|
||||
const outputPath = onlySessionId ? String(task.sessionOutputPaths?.[onlySessionId] || '').trim() : ''
|
||||
if (outputPath) {
|
||||
return resolveParentDir(outputPath) || task.payload.outputDir
|
||||
}
|
||||
}
|
||||
return task.payload.outputDir
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* ExportV2 — Session utility functions
|
||||
* Pure functions for session classification, sorting, and merging.
|
||||
*/
|
||||
|
||||
import type { ChatSession as AppChatSession, ContactInfo } from '../../../types/models'
|
||||
import type { ConversationTab, SessionRow } from '../types'
|
||||
|
||||
// ─── Session type classification ─────────────────────────────
|
||||
|
||||
export const toKindByContactType = (session: AppChatSession, contact?: ContactInfo): ConversationTab => {
|
||||
if (session.username.endsWith('@chatroom')) return 'group'
|
||||
if (session.username.startsWith('gh_')) return 'official'
|
||||
if (contact?.type === 'official') return 'official'
|
||||
if (contact?.type === 'former_friend') return 'former_friend'
|
||||
return 'private'
|
||||
}
|
||||
|
||||
export const toKindByContact = (contact: ContactInfo): ConversationTab => {
|
||||
if (contact.type === 'group') return 'group'
|
||||
if (contact.type === 'official') return 'official'
|
||||
if (contact.type === 'former_friend') return 'former_friend'
|
||||
return 'private'
|
||||
}
|
||||
|
||||
// ─── Session filtering predicates ────────────────────────────
|
||||
|
||||
export const isContentScopeSession = (session: SessionRow): boolean =>
|
||||
session.kind === 'private' || session.kind === 'group' || session.kind === 'former_friend'
|
||||
|
||||
export const isExportConversationSession = (session: SessionRow): boolean =>
|
||||
session.kind === 'private' || session.kind === 'group' || session.kind === 'former_friend'
|
||||
|
||||
export const isSingleContactSession = (sessionId: string): boolean => {
|
||||
const normalized = String(sessionId || '').trim()
|
||||
if (!normalized) return false
|
||||
if (normalized.includes('@chatroom')) return false
|
||||
if (normalized.startsWith('gh_')) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export const matchesContactTab = (contact: ContactInfo, tab: ConversationTab): boolean => {
|
||||
if (tab === 'private') return contact.type === 'friend'
|
||||
if (tab === 'group') return contact.type === 'group'
|
||||
if (tab === 'official') return contact.type === 'official'
|
||||
return contact.type === 'former_friend'
|
||||
}
|
||||
|
||||
// ─── Build session rows from sessions + contacts ─────────────
|
||||
|
||||
export const toSessionRowsWithContacts = (
|
||||
sessions: AppChatSession[],
|
||||
contactMap: Record<string, ContactInfo>
|
||||
): SessionRow[] => {
|
||||
const sessionMap = new Map<string, AppChatSession>()
|
||||
for (const session of sessions || []) {
|
||||
sessionMap.set(session.username, session)
|
||||
}
|
||||
|
||||
const contacts = Object.values(contactMap)
|
||||
.filter((contact) => (
|
||||
contact.type === 'friend' ||
|
||||
contact.type === 'group' ||
|
||||
contact.type === 'official' ||
|
||||
contact.type === 'former_friend'
|
||||
))
|
||||
|
||||
if (contacts.length > 0) {
|
||||
return contacts
|
||||
.map((contact) => {
|
||||
const session = sessionMap.get(contact.username)
|
||||
const latestTs = session?.sortTimestamp || session?.lastTimestamp || 0
|
||||
return {
|
||||
...(session || {
|
||||
username: contact.username,
|
||||
type: 0,
|
||||
unreadCount: 0,
|
||||
summary: '',
|
||||
sortTimestamp: latestTs,
|
||||
lastTimestamp: latestTs,
|
||||
lastMsgType: 0
|
||||
}),
|
||||
username: contact.username,
|
||||
kind: toKindByContact(contact),
|
||||
wechatId: contact.username,
|
||||
displayName: contact.displayName || session?.displayName || contact.username,
|
||||
avatarUrl: session?.avatarUrl || contact.avatarUrl,
|
||||
remark: contact.remark,
|
||||
nickname: contact.nickname,
|
||||
hasSession: Boolean(session)
|
||||
} as SessionRow
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const latestA = a.sortTimestamp || a.lastTimestamp || 0
|
||||
const latestB = b.sortTimestamp || b.lastTimestamp || 0
|
||||
if (latestA !== latestB) return latestB - latestA
|
||||
return (a.displayName || a.username).localeCompare(b.displayName || b.username, 'zh-Hans-CN')
|
||||
})
|
||||
}
|
||||
|
||||
return sessions
|
||||
.map((session) => {
|
||||
const contact = contactMap[session.username]
|
||||
return {
|
||||
...session,
|
||||
kind: toKindByContactType(session, contact),
|
||||
wechatId: contact?.username || session.username,
|
||||
displayName: contact?.displayName || session.displayName || session.username,
|
||||
avatarUrl: session.avatarUrl || contact?.avatarUrl,
|
||||
remark: contact?.remark,
|
||||
nickname: contact?.nickname,
|
||||
hasSession: true
|
||||
} as SessionRow
|
||||
})
|
||||
.sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0))
|
||||
}
|
||||
|
||||
// ─── Array equality check ────────────────────────────────────
|
||||
|
||||
export const areStringArraysEqual = (left: string[], right: string[]): boolean => {
|
||||
if (left === right) return true
|
||||
if (left.length !== right.length) return false
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index] !== right[index]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export const getSelectionScopeFromRows = (rows: SessionRow[]): import('../types').TaskScope => {
|
||||
if (rows.length === 0) return 'single'
|
||||
if (rows.length === 1) return 'single'
|
||||
return 'multi'
|
||||
}
|
||||
|
||||
export const resolveScopeDisplayNames = (
|
||||
rows: SessionRow[],
|
||||
pref: import('../types').DisplayNamePreference
|
||||
): string[] => {
|
||||
return rows.map(r => r.remark || r.nickname || r.displayName || r.username)
|
||||
}
|
||||
|
||||
// ─── Name comparison helper ──────────────────────────────────
|
||||
|
||||
export const toComparableNameSet = (values: Array<string | undefined | null>): Set<string> => {
|
||||
const set = new Set<string>()
|
||||
for (const value of values) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) continue
|
||||
set.add(normalized)
|
||||
}
|
||||
return set
|
||||
}
|
||||
Reference in new issue
Block a user