feat(counts): unify contacts and export tab counters

This commit is contained in:
tisonhuang
2026-03-02 10:23:36 +08:00
parent 794a306f89
commit da7d354436
8 changed files with 222 additions and 136 deletions

View File

@@ -920,6 +920,10 @@ function registerIpcHandlers() {
return chatService.getExportTabCounts() return chatService.getExportTabCounts()
}) })
ipcMain.handle('chat:getContactTypeCounts', async () => {
return chatService.getContactTypeCounts()
})
ipcMain.handle('chat:getSessionMessageCounts', async (_, sessionIds: string[]) => { ipcMain.handle('chat:getSessionMessageCounts', async (_, sessionIds: string[]) => {
return chatService.getSessionMessageCounts(sessionIds) return chatService.getSessionMessageCounts(sessionIds)
}) })

View File

@@ -132,6 +132,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
getSessions: () => ipcRenderer.invoke('chat:getSessions'), getSessions: () => ipcRenderer.invoke('chat:getSessions'),
getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames), getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames),
getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'), getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'),
getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'),
getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds), getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds),
enrichSessionsContactInfo: (usernames: string[]) => enrichSessionsContactInfo: (usernames: string[]) =>
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames), ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),

View File

@@ -762,111 +762,73 @@ class ChatService {
} }
/** /**
* 获取导出页会话分类数量(轻量接口,优先用于顶部 Tab 数量展示 * 获取联系人类型数量(好友、群聊、公众号、曾经的好友
*/ */
async getExportTabCounts(): Promise<{ success: boolean; counts?: ExportTabCounts; error?: string }> { async getContactTypeCounts(): Promise<{ success: boolean; counts?: ExportTabCounts; error?: string }> {
try { try {
const connectResult = await this.ensureConnected() const connectResult = await this.ensureConnected()
if (!connectResult.success) { if (!connectResult.success) {
return { success: false, error: connectResult.error } return { success: false, error: connectResult.error }
} }
const sessionResult = await wcdbService.getSessions() const excludeExpr = Array.from(FRIEND_EXCLUDE_USERNAMES)
if (!sessionResult.success || !sessionResult.sessions) { .map((username) => `'${this.escapeSqlString(username)}'`)
return { success: false, error: sessionResult.error || '获取会话失败' } .join(',')
const countsSql = `
SELECT
SUM(CASE WHEN username LIKE '%@chatroom' THEN 1 ELSE 0 END) AS group_count,
SUM(CASE WHEN username LIKE 'gh_%' THEN 1 ELSE 0 END) AS official_count,
SUM(
CASE
WHEN username NOT LIKE '%@chatroom'
AND username NOT LIKE 'gh_%'
AND local_type = 1
AND username NOT IN (${excludeExpr})
THEN 1 ELSE 0
END
) AS private_count,
SUM(
CASE
WHEN username NOT LIKE '%@chatroom'
AND username NOT LIKE 'gh_%'
AND local_type = 0
AND COALESCE(quan_pin, '') != ''
THEN 1 ELSE 0
END
) AS former_friend_count
FROM contact
WHERE username IS NOT NULL
AND username != ''
`
const result = await wcdbService.execQuery('contact', null, countsSql)
if (!result.success || !result.rows || result.rows.length === 0) {
return { success: false, error: result.error || '获取联系人类型数量失败' }
} }
const row = result.rows[0] as Record<string, any>
const counts: ExportTabCounts = { const counts: ExportTabCounts = {
private: 0, private: this.getRowInt(row, ['private_count', 'privateCount'], 0),
group: 0, group: this.getRowInt(row, ['group_count', 'groupCount'], 0),
official: 0, official: this.getRowInt(row, ['official_count', 'officialCount'], 0),
former_friend: 0 former_friend: this.getRowInt(row, ['former_friend_count', 'formerFriendCount'], 0)
}
const nonGroupUsernames: string[] = []
const usernameSet = new Set<string>()
for (const row of sessionResult.sessions as Record<string, any>[]) {
const username =
row.username ||
row.user_name ||
row.userName ||
row.usrName ||
row.UsrName ||
row.talker ||
row.talker_id ||
row.talkerId ||
''
if (!this.shouldKeepSession(username)) continue
if (usernameSet.has(username)) continue
usernameSet.add(username)
if (username.endsWith('@chatroom')) {
counts.group += 1
} else {
nonGroupUsernames.push(username)
}
}
if (nonGroupUsernames.length === 0) {
return { success: true, counts }
}
const contactTypeMap = new Map<string, 'official' | 'former_friend'>()
const chunkSize = 400
for (let i = 0; i < nonGroupUsernames.length; i += chunkSize) {
const chunk = nonGroupUsernames.slice(i, i + chunkSize)
if (chunk.length === 0) continue
const usernamesExpr = chunk.map((name) => `'${this.escapeSqlString(name)}'`).join(',')
const contactSql = `
SELECT username, local_type, quan_pin
FROM contact
WHERE username IN (${usernamesExpr})
`
const contactResult = await wcdbService.execQuery('contact', null, contactSql)
if (!contactResult.success || !contactResult.rows) {
continue
}
for (const row of contactResult.rows as Record<string, any>[]) {
const username = String(row.username || '').trim()
if (!username) continue
if (username.startsWith('gh_')) {
contactTypeMap.set(username, 'official')
continue
}
const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0)
const quanPin = String(this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || '').trim()
if (localType === 0 && quanPin) {
contactTypeMap.set(username, 'former_friend')
}
}
}
for (const username of nonGroupUsernames) {
const type = contactTypeMap.get(username)
if (type === 'official') {
counts.official += 1
} else if (type === 'former_friend') {
counts.former_friend += 1
} else {
counts.private += 1
}
} }
return { success: true, counts } return { success: true, counts }
} catch (e) { } catch (e) {
console.error('ChatService: 获取导出页会话分类数量失败:', e) console.error('ChatService: 获取联系人类型数量失败:', e)
return { success: false, error: String(e) } return { success: false, error: String(e) }
} }
} }
/**
* 获取导出页会话分类数量(轻量接口,优先用于顶部 Tab 数量展示)
*/
async getExportTabCounts(): Promise<{ success: boolean; counts?: ExportTabCounts; error?: string }> {
return this.getContactTypeCounts()
}
/** /**
* 批量获取会话消息总数(轻量接口,用于列表优先排序) * 批量获取会话消息总数(轻量接口,用于列表优先排序)
*/ */

View File

@@ -148,6 +148,17 @@
svg { svg {
opacity: 0.7; opacity: 0.7;
transition: transform 0.2s; transition: transform 0.2s;
flex-shrink: 0;
}
.chip-label {
min-width: 0;
}
.chip-count {
margin-left: auto;
text-align: right;
font-variant-numeric: tabular-nums;
} }
&:hover { &:hover {

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useMemo, useRef, type UIEvent } from
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX } from 'lucide-react' import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX } from 'lucide-react'
import { useChatStore } from '../stores/chatStore' import { useChatStore } from '../stores/chatStore'
import { toContactTypeCardCounts, useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
import './ContactsPage.scss' import './ContactsPage.scss'
interface ContactInfo { interface ContactInfo {
@@ -58,6 +59,8 @@ function ContactsPage() {
}) })
const [scrollTop, setScrollTop] = useState(0) const [scrollTop, setScrollTop] = useState(0)
const [listViewportHeight, setListViewportHeight] = useState(480) const [listViewportHeight, setListViewportHeight] = useState(480)
const sharedTabCounts = useContactTypeCountsStore(state => state.tabCounts)
const syncContactTypeCounts = useContactTypeCountsStore(state => state.syncFromContacts)
const applyEnrichedContacts = useCallback((enrichedMap: Record<string, ContactEnrichInfo>) => { const applyEnrichedContacts = useCallback((enrichedMap: Record<string, ContactEnrichInfo>) => {
if (!enrichedMap || Object.keys(enrichedMap).length === 0) return if (!enrichedMap || Object.keys(enrichedMap).length === 0) return
@@ -151,6 +154,7 @@ function ContactsPage() {
if (loadVersionRef.current !== loadVersion) return if (loadVersionRef.current !== loadVersion) return
if (contactsResult.success && contactsResult.contacts) { if (contactsResult.success && contactsResult.contacts) {
setContacts(contactsResult.contacts) setContacts(contactsResult.contacts)
syncContactTypeCounts(contactsResult.contacts)
setSelectedUsernames(new Set()) setSelectedUsernames(new Set())
setSelectedContact(prev => { setSelectedContact(prev => {
if (!prev) return prev if (!prev) return prev
@@ -167,7 +171,7 @@ function ContactsPage() {
setIsLoading(false) setIsLoading(false)
} }
} }
}, [enrichContactsInBackground]) }, [enrichContactsInBackground, syncContactTypeCounts])
useEffect(() => { useEffect(() => {
loadContacts() loadContacts()
@@ -206,6 +210,8 @@ function ContactsPage() {
return filtered return filtered
}, [contacts, contactTypes, debouncedSearchKeyword]) }, [contacts, contactTypes, debouncedSearchKeyword])
const contactTypeCounts = useMemo(() => toContactTypeCardCounts(sharedTabCounts), [sharedTabCounts])
useEffect(() => { useEffect(() => {
if (!listRef.current) return if (!listRef.current) return
listRef.current.scrollTop = 0 listRef.current.scrollTop = 0
@@ -428,19 +434,27 @@ function ContactsPage() {
<div className="type-filters"> <div className="type-filters">
<label className={`filter-chip ${contactTypes.friends ? 'active' : ''}`}> <label className={`filter-chip ${contactTypes.friends ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.friends} onChange={e => setContactTypes({ ...contactTypes, friends: e.target.checked })} /> <input type="checkbox" checked={contactTypes.friends} onChange={e => setContactTypes({ ...contactTypes, friends: e.target.checked })} />
<User size={16} /><span></span> <User size={16} />
<span className="chip-label"></span>
<span className="chip-count">{contactTypeCounts.friends}</span>
</label> </label>
<label className={`filter-chip ${contactTypes.groups ? 'active' : ''}`}> <label className={`filter-chip ${contactTypes.groups ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.groups} onChange={e => setContactTypes({ ...contactTypes, groups: e.target.checked })} /> <input type="checkbox" checked={contactTypes.groups} onChange={e => setContactTypes({ ...contactTypes, groups: e.target.checked })} />
<Users size={16} /><span></span> <Users size={16} />
<span className="chip-label"></span>
<span className="chip-count">{contactTypeCounts.groups}</span>
</label> </label>
<label className={`filter-chip ${contactTypes.officials ? 'active' : ''}`}> <label className={`filter-chip ${contactTypes.officials ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.officials} onChange={e => setContactTypes({ ...contactTypes, officials: e.target.checked })} /> <input type="checkbox" checked={contactTypes.officials} onChange={e => setContactTypes({ ...contactTypes, officials: e.target.checked })} />
<MessageSquare size={16} /><span></span> <MessageSquare size={16} />
<span className="chip-label"></span>
<span className="chip-count">{contactTypeCounts.officials}</span>
</label> </label>
<label className={`filter-chip ${contactTypes.deletedFriends ? 'active' : ''}`}> <label className={`filter-chip ${contactTypes.deletedFriends ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.deletedFriends} onChange={e => setContactTypes({ ...contactTypes, deletedFriends: e.target.checked })} /> <input type="checkbox" checked={contactTypes.deletedFriends} onChange={e => setContactTypes({ ...contactTypes, deletedFriends: e.target.checked })} />
<UserX size={16} /><span></span> <UserX size={16} />
<span className="chip-label"></span>
<span className="chip-count">{contactTypeCounts.deletedFriends}</span>
</label> </label>
</div> </div>

View File

@@ -22,6 +22,7 @@ import {
import type { ChatSession as AppChatSession, ContactInfo } from '../types/models' import type { ChatSession as AppChatSession, ContactInfo } from '../types/models'
import type { ExportOptions as ElectronExportOptions, ExportProgress } from '../types/electron' import type { ExportOptions as ElectronExportOptions, ExportProgress } from '../types/electron'
import * as configService from '../services/config' import * as configService from '../services/config'
import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
import './ExportPage.scss' import './ExportPage.scss'
type ConversationTab = 'private' | 'group' | 'official' | 'former_friend' type ConversationTab = 'private' | 'group' | 'official' | 'former_friend'
@@ -321,12 +322,10 @@ function ExportPage() {
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isSessionEnriching, setIsSessionEnriching] = useState(false) const [isSessionEnriching, setIsSessionEnriching] = useState(false)
const [isTabCountsLoading, setIsTabCountsLoading] = useState(true)
const [isSnsStatsLoading, setIsSnsStatsLoading] = useState(true) const [isSnsStatsLoading, setIsSnsStatsLoading] = useState(true)
const [isBaseConfigLoading, setIsBaseConfigLoading] = useState(true) const [isBaseConfigLoading, setIsBaseConfigLoading] = useState(true)
const [isTaskCenterExpanded, setIsTaskCenterExpanded] = useState(false) const [isTaskCenterExpanded, setIsTaskCenterExpanded] = useState(false)
const [sessions, setSessions] = useState<SessionRow[]>([]) const [sessions, setSessions] = useState<SessionRow[]>([])
const [prefetchedTabCounts, setPrefetchedTabCounts] = useState<Record<ConversationTab, number> | null>(null)
const [sessionMessageCounts, setSessionMessageCounts] = useState<Record<string, number>>({}) const [sessionMessageCounts, setSessionMessageCounts] = useState<Record<string, number>>({})
const [sessionMetrics, setSessionMetrics] = useState<Record<string, SessionMetrics>>({}) const [sessionMetrics, setSessionMetrics] = useState<Record<string, SessionMetrics>>({})
const [searchKeyword, setSearchKeyword] = useState('') const [searchKeyword, setSearchKeyword] = useState('')
@@ -374,6 +373,11 @@ function ExportPage() {
}) })
const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false) const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false)
const [nowTick, setNowTick] = useState(Date.now()) const [nowTick, setNowTick] = useState(Date.now())
const tabCounts = useContactTypeCountsStore(state => state.tabCounts)
const isSharedTabCountsLoading = useContactTypeCountsStore(state => state.isLoading)
const isSharedTabCountsReady = useContactTypeCountsStore(state => state.isReady)
const ensureSharedTabCountsLoaded = useContactTypeCountsStore(state => state.ensureLoaded)
const syncContactTypeCounts = useContactTypeCountsStore(state => state.syncFromContacts)
const progressUnsubscribeRef = useRef<(() => void) | null>(null) const progressUnsubscribeRef = useRef<(() => void) | null>(null)
const runningTaskIdRef = useRef<string | null>(null) const runningTaskIdRef = useRef<string | null>(null)
@@ -516,20 +520,6 @@ function ExportPage() {
} }
}, []) }, [])
const loadTabCounts = useCallback(async () => {
setIsTabCountsLoading(true)
try {
const result = await window.electronAPI.chat.getExportTabCounts()
if (result.success && result.counts) {
setPrefetchedTabCounts(result.counts)
}
} catch (error) {
console.error('加载导出页会话分类数量失败:', error)
} finally {
setIsTabCountsLoading(false)
}
}, [])
const loadSnsStats = useCallback(async (options?: { full?: boolean; silent?: boolean }) => { const loadSnsStats = useCallback(async (options?: { full?: boolean; silent?: boolean }) => {
if (!options?.silent) { if (!options?.silent) {
setIsSnsStatsLoading(true) setIsSnsStatsLoading(true)
@@ -641,6 +631,9 @@ function ExportPage() {
if (isStale()) return if (isStale()) return
const contacts: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : [] const contacts: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : []
if (contacts.length > 0) {
syncContactTypeCounts(contacts)
}
const nextContactMap = contacts.reduce<Record<string, ContactInfo>>((map, contact) => { const nextContactMap = contacts.reduce<Record<string, ContactInfo>>((map, contact) => {
map[contact.username] = contact map[contact.username] = contact
return map return map
@@ -694,11 +687,11 @@ function ExportPage() {
} finally { } finally {
if (!isStale()) setIsLoading(false) if (!isStale()) setIsLoading(false)
} }
}, []) }, [syncContactTypeCounts])
useEffect(() => { useEffect(() => {
void loadBaseConfig() void loadBaseConfig()
void loadTabCounts() void ensureSharedTabCountsLoaded()
void loadSessions() void loadSessions()
// 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。 // 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。
@@ -707,7 +700,7 @@ function ExportPage() {
}, 120) }, 120)
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
}, [loadTabCounts, loadBaseConfig, loadSessions, loadSnsStats]) }, [ensureSharedTabCountsLoaded, loadBaseConfig, loadSessions, loadSnsStats])
useEffect(() => { useEffect(() => {
preselectAppliedRef.current = false preselectAppliedRef.current = false
@@ -1363,29 +1356,6 @@ function ExportPage() {
return set return set
}, [tasks]) }, [tasks])
const sessionTabCounts = useMemo(() => {
const counts: Record<ConversationTab, number> = {
private: 0,
group: 0,
official: 0,
former_friend: 0
}
for (const session of sessions) {
counts[session.kind] += 1
}
return counts
}, [sessions])
const tabCounts = useMemo(() => {
if (sessions.length > 0) {
return sessionTabCounts
}
if (prefetchedTabCounts) {
return prefetchedTabCounts
}
return sessionTabCounts
}, [sessions.length, sessionTabCounts, prefetchedTabCounts])
const contentCards = useMemo(() => { const contentCards = useMemo(() => {
const scopeSessions = sessions.filter(session => session.kind === 'private' || session.kind === 'group') const scopeSessions = sessions.filter(session => session.kind === 'private' || session.kind === 'group')
const totalSessions = scopeSessions.length const totalSessions = scopeSessions.length
@@ -1617,8 +1587,7 @@ function ExportPage() {
const formatCandidateOptions = exportDialog.scope === 'sns' const formatCandidateOptions = exportDialog.scope === 'sns'
? formatOptions.filter(option => option.value === 'html' || option.value === 'json') ? formatOptions.filter(option => option.value === 'html' || option.value === 'json')
: formatOptions : formatOptions
const hasTabCountsSource = prefetchedTabCounts !== null || sessions.length > 0 const isTabCountComputing = isSharedTabCountsLoading && !isSharedTabCountsReady
const isTabCountComputing = isTabCountsLoading && !hasTabCountsSource
const isSessionCardStatsLoading = isLoading || isBaseConfigLoading const isSessionCardStatsLoading = isLoading || isBaseConfigLoading
const isSnsCardStatsLoading = !hasSeededSnsStats const isSnsCardStatsLoading = !hasSeededSnsStats
const taskRunningCount = tasks.filter(task => task.status === 'running').length const taskRunningCount = tasks.filter(task => task.status === 'running').length

View File

@@ -0,0 +1,115 @@
import { create } from 'zustand'
import type { ContactInfo } from '../types/models'
export interface ContactTypeTabCounts {
private: number
group: number
official: number
former_friend: number
}
export interface ContactTypeCardCounts {
friends: number
groups: number
officials: number
deletedFriends: number
}
const emptyTabCounts: ContactTypeTabCounts = {
private: 0,
group: 0,
official: 0,
former_friend: 0
}
let inflightPromise: Promise<ContactTypeTabCounts> | null = null
const normalizeCounts = (counts?: Partial<ContactTypeTabCounts> | null): ContactTypeTabCounts => {
return {
private: Number.isFinite(counts?.private) ? Math.max(0, Math.floor(Number(counts?.private))) : 0,
group: Number.isFinite(counts?.group) ? Math.max(0, Math.floor(Number(counts?.group))) : 0,
official: Number.isFinite(counts?.official) ? Math.max(0, Math.floor(Number(counts?.official))) : 0,
former_friend: Number.isFinite(counts?.former_friend) ? Math.max(0, Math.floor(Number(counts?.former_friend))) : 0
}
}
export const toContactTypeTabCountsFromContacts = (contacts: ContactInfo[]): ContactTypeTabCounts => {
const next = { ...emptyTabCounts }
for (const contact of contacts || []) {
if (contact.type === 'friend') next.private += 1
if (contact.type === 'group') next.group += 1
if (contact.type === 'official') next.official += 1
if (contact.type === 'former_friend') next.former_friend += 1
}
return next
}
export const toContactTypeCardCounts = (counts: ContactTypeTabCounts): ContactTypeCardCounts => {
return {
friends: counts.private,
groups: counts.group,
officials: counts.official,
deletedFriends: counts.former_friend
}
}
interface ContactTypeCountsState {
tabCounts: ContactTypeTabCounts
isLoading: boolean
isReady: boolean
updatedAt: number
setTabCounts: (counts: ContactTypeTabCounts) => void
syncFromContacts: (contacts: ContactInfo[]) => void
ensureLoaded: (options?: { force?: boolean }) => Promise<ContactTypeTabCounts>
}
export const useContactTypeCountsStore = create<ContactTypeCountsState>((set, get) => ({
tabCounts: { ...emptyTabCounts },
isLoading: false,
isReady: false,
updatedAt: 0,
setTabCounts: (counts) => {
const normalized = normalizeCounts(counts)
set({
tabCounts: normalized,
isReady: true,
updatedAt: Date.now()
})
},
syncFromContacts: (contacts) => {
const fromContacts = toContactTypeTabCountsFromContacts(contacts || [])
get().setTabCounts(fromContacts)
},
ensureLoaded: async (options) => {
if (!options?.force && get().isReady) {
return get().tabCounts
}
if (inflightPromise) {
return inflightPromise
}
set({ isLoading: true })
inflightPromise = (async () => {
try {
const result = await window.electronAPI.chat.getContactTypeCounts()
if (result?.success && result.counts) {
const normalized = normalizeCounts(result.counts)
set({
tabCounts: normalized,
isReady: true,
updatedAt: Date.now()
})
return normalized
}
} catch (error) {
console.error('加载联系人类型计数失败:', error)
}
return get().tabCounts
})().finally(() => {
inflightPromise = null
set({ isLoading: false })
})
return inflightPromise
}
}))

View File

@@ -89,6 +89,16 @@ export interface ElectronAPI {
} }
error?: string error?: string
}> }>
getContactTypeCounts: () => Promise<{
success: boolean
counts?: {
private: number
group: number
official: number
former_friend: number
}
error?: string
}>
getSessionMessageCounts: (sessionIds: string[]) => Promise<{ getSessionMessageCounts: (sessionIds: string[]) => Promise<{
success: boolean success: boolean
counts?: Record<string, number> counts?: Record<string, number>