mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64995c25a8 | ||
|
|
1655b5ae78 | ||
|
|
3d3f6d058e | ||
|
|
104a04c5de | ||
|
|
e12193aa40 | ||
|
|
51101387f7 | ||
|
|
641a3bf2ab | ||
|
|
58f22f4bb2 | ||
|
|
562eac4249 |
13
.github/workflows/release.yml
vendored
13
.github/workflows/release.yml
vendored
@@ -119,7 +119,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
npx electron-builder --publish always
|
npx electron-builder --win nsis --x64 --publish always '--config.artifactName=${productName}-${version}-x64-Setup.${ext}'
|
||||||
|
|
||||||
release-windows-arm64:
|
release-windows-arm64:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
@@ -155,7 +155,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
npx electron-builder --win nsis --arm64 --publish always '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}'
|
npx electron-builder --win nsis --arm64 --publish always '--config.publish.channel=latest-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}'
|
||||||
|
|
||||||
update-release-notes:
|
update-release-notes:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -184,7 +184,10 @@ jobs:
|
|||||||
echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
|
echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("x64.*\\.exe$"))][0] // ""')"
|
||||||
|
if [ -z "$WINDOWS_ASSET" ]; then
|
||||||
WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("\\.exe$")) | select(test("arm64") | not)][0] // ""')"
|
WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("\\.exe$")) | select(test("arm64") | not)][0] // ""')"
|
||||||
|
fi
|
||||||
WINDOWS_ARM64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')"
|
WINDOWS_ARM64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')"
|
||||||
MAC_ASSET="$(pick_asset "\\.dmg$")"
|
MAC_ASSET="$(pick_asset "\\.dmg$")"
|
||||||
LINUX_DEB_ASSET="$(pick_asset "\\.deb$")"
|
LINUX_DEB_ASSET="$(pick_asset "\\.deb$")"
|
||||||
@@ -220,6 +223,12 @@ jobs:
|
|||||||
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
|
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
|
||||||
- linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}
|
- linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}
|
||||||
|
|
||||||
|
## macOS 安装提示(未知来源)
|
||||||
|
- 若打开时提示“来自未知开发者”或“无法验证开发者”,请到「系统设置 -> 隐私与安全性」中允许打开该应用。
|
||||||
|
- 如果仍被系统拦截,请在终端执行以下命令去除隔离标记:
|
||||||
|
- `xattr -dr com.apple.quarantine "/Applications/WeFlow.app"`
|
||||||
|
- 执行后重新打开 WeFlow。
|
||||||
|
|
||||||
> 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE
|
> 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -70,3 +70,4 @@ resources/wx_send
|
|||||||
概述.md
|
概述.md
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
/pnpm-workspace.yaml
|
/pnpm-workspace.yaml
|
||||||
|
wechat-research-site
|
||||||
@@ -36,6 +36,10 @@ import { messagePushService } from './services/messagePushService'
|
|||||||
autoUpdater.autoDownload = false
|
autoUpdater.autoDownload = false
|
||||||
autoUpdater.autoInstallOnAppQuit = true
|
autoUpdater.autoInstallOnAppQuit = true
|
||||||
autoUpdater.disableDifferentialDownload = true // 禁用差分更新,强制全量下载
|
autoUpdater.disableDifferentialDownload = true // 禁用差分更新,强制全量下载
|
||||||
|
// Windows x64 与 arm64 使用不同更新通道,避免 latest.yml 互相覆盖导致下错架构安装包。
|
||||||
|
if (process.platform === 'win32' && process.arch === 'arm64') {
|
||||||
|
autoUpdater.channel = 'latest-arm64'
|
||||||
|
}
|
||||||
const AUTO_UPDATE_ENABLED =
|
const AUTO_UPDATE_ENABLED =
|
||||||
process.env.AUTO_UPDATE_ENABLED === 'true' ||
|
process.env.AUTO_UPDATE_ENABLED === 'true' ||
|
||||||
process.env.AUTO_UPDATE_ENABLED === '1' ||
|
process.env.AUTO_UPDATE_ENABLED === '1' ||
|
||||||
@@ -142,33 +146,47 @@ const normalizeReleaseNotes = (rawReleaseNotes: unknown): string => {
|
|||||||
|
|
||||||
if (!merged.trim()) return ''
|
if (!merged.trim()) return ''
|
||||||
|
|
||||||
|
const shouldStripReleaseSection = (headingRaw: string): boolean => {
|
||||||
|
const heading = headingRaw.trim().toLowerCase()
|
||||||
|
if (heading === '下载' || heading === 'download') return true
|
||||||
|
|
||||||
|
const compactHeading = heading.replace(/\s+/g, '')
|
||||||
|
if (compactHeading.startsWith('macos安装提示')) return true
|
||||||
|
if (compactHeading.startsWith('mac安装提示')) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// 兼容 electron-updater 直接返回 HTML 的场景
|
// 兼容 electron-updater 直接返回 HTML 的场景
|
||||||
const removeDownloadSectionFromHtml = (input: string): string => {
|
const removeDownloadSectionFromHtml = (input: string): string => {
|
||||||
return input.replace(
|
return input
|
||||||
|
.replace(
|
||||||
/<h[1-6][^>]*>\s*(?:下载|download)\s*<\/h[1-6]>\s*[\s\S]*?(?=<h[1-6]\b|$)/gi,
|
/<h[1-6][^>]*>\s*(?:下载|download)\s*<\/h[1-6]>\s*[\s\S]*?(?=<h[1-6]\b|$)/gi,
|
||||||
''
|
''
|
||||||
)
|
)
|
||||||
|
.replace(
|
||||||
|
/<h[1-6][^>]*>\s*(?:mac\s*os|mac)\s*安装提示(?:\s*[((]\s*未知来源\s*[))])?\s*<\/h[1-6]>\s*[\s\S]*?(?=<h[1-6]\b|$)/gi,
|
||||||
|
''
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 兼容 Markdown 场景(Action 最终 release note 模板)
|
// 兼容 Markdown 场景(Action 最终 release note 模板)
|
||||||
const removeDownloadSectionFromMarkdown = (input: string): string => {
|
const removeDownloadSectionFromMarkdown = (input: string): string => {
|
||||||
const lines = input.split(/\r?\n/)
|
const lines = input.split(/\r?\n/)
|
||||||
const output: string[] = []
|
const output: string[] = []
|
||||||
let skipDownloadSection = false
|
let skipSection = false
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const headingMatch = line.match(/^\s*#{1,6}\s*(.+?)\s*$/)
|
const headingMatch = line.match(/^\s*#{1,6}\s*(.+?)\s*$/)
|
||||||
if (headingMatch) {
|
if (headingMatch) {
|
||||||
const heading = headingMatch[1].trim().toLowerCase()
|
if (shouldStripReleaseSection(headingMatch[1])) {
|
||||||
if (heading === '下载' || heading === 'download') {
|
skipSection = true
|
||||||
skipDownloadSection = true
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (skipDownloadSection) {
|
if (skipSection) {
|
||||||
skipDownloadSection = false
|
skipSection = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!skipDownloadSection) {
|
if (!skipSection) {
|
||||||
output.push(line)
|
output.push(line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1527,8 +1545,8 @@ function registerIpcHandlers() {
|
|||||||
return await chatService.resolveTransferDisplayNames(chatroomId, payerUsername, receiverUsername)
|
return await chatService.resolveTransferDisplayNames(chatroomId, payerUsername, receiverUsername)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('chat:getContacts', async () => {
|
ipcMain.handle('chat:getContacts', async (_, options?: { lite?: boolean }) => {
|
||||||
return await chatService.getContacts()
|
return await chatService.getContacts(options)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('chat:getCachedMessages', async (_, sessionId: string) => {
|
ipcMain.handle('chat:getCachedMessages', async (_, sessionId: string) => {
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.on('chat:voiceTranscriptPartial', listener)
|
ipcRenderer.on('chat:voiceTranscriptPartial', listener)
|
||||||
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
|
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
|
||||||
},
|
},
|
||||||
getContacts: () => ipcRenderer.invoke('chat:getContacts'),
|
getContacts: (options?: { lite?: boolean }) => ipcRenderer.invoke('chat:getContacts', options),
|
||||||
getMessage: (sessionId: string, localId: number) =>
|
getMessage: (sessionId: string, localId: number) =>
|
||||||
ipcRenderer.invoke('chat:getMessage', sessionId, localId),
|
ipcRenderer.invoke('chat:getMessage', sessionId, localId),
|
||||||
searchMessages: (keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) =>
|
searchMessages: (keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) =>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { GroupMyMessageCountCacheService, GroupMyMessageCountCacheEntry } from '
|
|||||||
import { exportCardDiagnosticsService } from './exportCardDiagnosticsService'
|
import { exportCardDiagnosticsService } from './exportCardDiagnosticsService'
|
||||||
import { voiceTranscribeService } from './voiceTranscribeService'
|
import { voiceTranscribeService } from './voiceTranscribeService'
|
||||||
import { ImageDecryptService } from './imageDecryptService'
|
import { ImageDecryptService } from './imageDecryptService'
|
||||||
|
import { CONTACT_REGION_LOOKUP_DATA } from './contactRegionLookupData'
|
||||||
import { LRUCache } from '../utils/LRUCache.js'
|
import { LRUCache } from '../utils/LRUCache.js'
|
||||||
|
|
||||||
export interface ChatSession {
|
export interface ChatSession {
|
||||||
@@ -153,10 +154,17 @@ export interface ContactInfo {
|
|||||||
remark?: string
|
remark?: string
|
||||||
nickname?: string
|
nickname?: string
|
||||||
alias?: string
|
alias?: string
|
||||||
|
labels?: string[]
|
||||||
|
detailDescription?: string
|
||||||
|
region?: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GetContactsOptions {
|
||||||
|
lite?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface ExportSessionStats {
|
interface ExportSessionStats {
|
||||||
totalMessages: number
|
totalMessages: number
|
||||||
voiceMessages: number
|
voiceMessages: number
|
||||||
@@ -293,6 +301,21 @@ class ChatService {
|
|||||||
private groupMyMessageCountCacheScope = ''
|
private groupMyMessageCountCacheScope = ''
|
||||||
private groupMyMessageCountMemoryCache = new Map<string, GroupMyMessageCountCacheEntry>()
|
private groupMyMessageCountMemoryCache = new Map<string, GroupMyMessageCountCacheEntry>()
|
||||||
private initFailureDialogShown = false
|
private initFailureDialogShown = false
|
||||||
|
private readonly contactExtendedFieldCandidates = [
|
||||||
|
'label_list', 'labelList', 'labels', 'label_names', 'labelNames', 'tags', 'tag_list', 'tagList',
|
||||||
|
'detail_description', 'detailDescription', 'description', 'desc', 'contact_description', 'contactDescription', 'signature', 'sign',
|
||||||
|
'country', 'province', 'city', 'region',
|
||||||
|
'profile', 'introduction', 'phone', 'mobile', 'telephone', 'tel', 'vcard', 'card_info', 'cardInfo',
|
||||||
|
'extra_buffer', 'extraBuffer'
|
||||||
|
]
|
||||||
|
private readonly contactExtendedFieldCandidateSet = new Set(this.contactExtendedFieldCandidates.map((name) => name.toLowerCase()))
|
||||||
|
private contactExtendedSelectableColumns: string[] | null = null
|
||||||
|
private contactLabelNameMapCache: Map<number, string> | null = null
|
||||||
|
private contactLabelNameMapCacheAt = 0
|
||||||
|
private readonly contactLabelNameMapCacheTtlMs = 10 * 60 * 1000
|
||||||
|
private contactsLoadInFlight: { mode: 'lite' | 'full'; promise: Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> } | null = null
|
||||||
|
private readonly contactDisplayNameCollator = new Intl.Collator('zh-CN')
|
||||||
|
private readonly slowGetContactsLogThresholdMs = 1200
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.configService = new ConfigService()
|
this.configService = new ConfigService()
|
||||||
@@ -1267,25 +1290,61 @@ class ChatService {
|
|||||||
/**
|
/**
|
||||||
* 获取通讯录列表
|
* 获取通讯录列表
|
||||||
*/
|
*/
|
||||||
async getContacts(): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> {
|
async getContacts(options?: GetContactsOptions): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> {
|
||||||
|
const mode: 'lite' | 'full' = options?.lite ? 'lite' : 'full'
|
||||||
|
const inFlight = this.contactsLoadInFlight
|
||||||
|
if (inFlight && (inFlight.mode === mode || (mode === 'lite' && inFlight.mode === 'full'))) {
|
||||||
|
return await inFlight.promise
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = this.getContactsInternal(options)
|
||||||
|
this.contactsLoadInFlight = { mode, promise }
|
||||||
try {
|
try {
|
||||||
|
return await promise
|
||||||
|
} finally {
|
||||||
|
if (this.contactsLoadInFlight?.promise === promise) {
|
||||||
|
this.contactsLoadInFlight = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getContactsInternal(options?: GetContactsOptions): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> {
|
||||||
|
const isLiteMode = options?.lite === true
|
||||||
|
const startedAt = Date.now()
|
||||||
|
const stageDurations: Array<{ stage: string; ms: number }> = []
|
||||||
|
const captureStage = (stage: string, stageStartedAt: number) => {
|
||||||
|
stageDurations.push({ stage, ms: Date.now() - stageStartedAt })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const connectStartedAt = Date.now()
|
||||||
const connectResult = await this.ensureConnected()
|
const connectResult = await this.ensureConnected()
|
||||||
|
captureStage('ensureConnected', connectStartedAt)
|
||||||
if (!connectResult.success) {
|
if (!connectResult.success) {
|
||||||
return { success: false, error: connectResult.error }
|
return { success: false, error: connectResult.error }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contactsCompactStartedAt = Date.now()
|
||||||
const contactResult = await wcdbService.getContactsCompact()
|
const contactResult = await wcdbService.getContactsCompact()
|
||||||
|
captureStage('getContactsCompact', contactsCompactStartedAt)
|
||||||
|
|
||||||
if (!contactResult.success || !contactResult.contacts) {
|
if (!contactResult.success || !contactResult.contacts) {
|
||||||
console.error('查询联系人失败:', contactResult.error)
|
console.error('查询联系人失败:', contactResult.error)
|
||||||
return { success: false, error: contactResult.error || '查询联系人失败' }
|
return { success: false, error: contactResult.error || '查询联系人失败' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let rows = contactResult.contacts as Record<string, any>[]
|
||||||
|
if (!isLiteMode) {
|
||||||
|
const hydrateStartedAt = Date.now()
|
||||||
|
rows = await this.hydrateContactsWithExtendedFields(rows)
|
||||||
|
captureStage('hydrateContactsWithExtendedFields', hydrateStartedAt)
|
||||||
|
}
|
||||||
|
|
||||||
const rows = contactResult.contacts as Record<string, any>[]
|
|
||||||
// 获取会话表的最后联系时间用于排序
|
// 获取会话表的最后联系时间用于排序
|
||||||
|
const sessionsStartedAt = Date.now()
|
||||||
const lastContactTimeMap = new Map<string, number>()
|
const lastContactTimeMap = new Map<string, number>()
|
||||||
const sessionResult = await wcdbService.getSessions()
|
const sessionResult = await wcdbService.getSessions()
|
||||||
|
captureStage('getSessions', sessionsStartedAt)
|
||||||
if (sessionResult.success && sessionResult.sessions) {
|
if (sessionResult.success && sessionResult.sessions) {
|
||||||
for (const session of sessionResult.sessions as any[]) {
|
for (const session of sessionResult.sessions as any[]) {
|
||||||
const username = session.username || session.user_name || session.userName || ''
|
const username = session.username || session.user_name || session.userName || ''
|
||||||
@@ -1297,9 +1356,14 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 转换为ContactInfo
|
// 转换为ContactInfo
|
||||||
|
const transformStartedAt = Date.now()
|
||||||
const contacts: (ContactInfo & { lastContactTime: number })[] = []
|
const contacts: (ContactInfo & { lastContactTime: number })[] = []
|
||||||
const excludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage'])
|
let contactLabelNameMap = new Map<number, string>()
|
||||||
|
if (!isLiteMode) {
|
||||||
|
const labelMapStartedAt = Date.now()
|
||||||
|
contactLabelNameMap = await this.getContactLabelNameMap()
|
||||||
|
captureStage('getContactLabelNameMap', labelMapStartedAt)
|
||||||
|
}
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const username = String(row.username || '').trim()
|
const username = String(row.username || '').trim()
|
||||||
|
|
||||||
@@ -1313,7 +1377,7 @@ class ChatService {
|
|||||||
type = 'group'
|
type = 'group'
|
||||||
} else if (username.startsWith('gh_')) {
|
} else if (username.startsWith('gh_')) {
|
||||||
type = 'official'
|
type = 'official'
|
||||||
} else if (localType === 1 && !excludeNames.has(username)) {
|
} else if (localType === 1 && !FRIEND_EXCLUDE_USERNAMES.has(username)) {
|
||||||
type = 'friend'
|
type = 'friend'
|
||||||
} else if (localType === 0 && quanPin) {
|
} else if (localType === 0 && quanPin) {
|
||||||
type = 'former_friend'
|
type = 'former_friend'
|
||||||
@@ -1322,6 +1386,9 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const displayName = row.remark || row.nick_name || row.alias || username
|
const displayName = row.remark || row.nick_name || row.alias || username
|
||||||
|
const labels = isLiteMode ? [] : this.parseContactLabels(row, contactLabelNameMap)
|
||||||
|
const detailDescription = isLiteMode ? '' : this.getContactSignature(row)
|
||||||
|
const region = isLiteMode ? '' : this.getContactRegion(row)
|
||||||
|
|
||||||
contacts.push({
|
contacts.push({
|
||||||
username,
|
username,
|
||||||
@@ -1329,16 +1396,19 @@ class ChatService {
|
|||||||
remark: row.remark || undefined,
|
remark: row.remark || undefined,
|
||||||
nickname: row.nick_name || undefined,
|
nickname: row.nick_name || undefined,
|
||||||
alias: row.alias || undefined,
|
alias: row.alias || undefined,
|
||||||
|
labels: labels.length > 0 ? labels : undefined,
|
||||||
|
detailDescription: detailDescription || undefined,
|
||||||
|
region: region || undefined,
|
||||||
avatarUrl: undefined,
|
avatarUrl: undefined,
|
||||||
type,
|
type,
|
||||||
lastContactTime: lastContactTimeMap.get(username) || 0
|
lastContactTime: lastContactTimeMap.get(username) || 0
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
captureStage('transformContacts', transformStartedAt)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 按最近联系时间排序
|
// 按最近联系时间排序
|
||||||
|
const sortStartedAt = Date.now()
|
||||||
contacts.sort((a, b) => {
|
contacts.sort((a, b) => {
|
||||||
const timeA = a.lastContactTime || 0
|
const timeA = a.lastContactTime || 0
|
||||||
const timeB = b.lastContactTime || 0
|
const timeB = b.lastContactTime || 0
|
||||||
@@ -1347,13 +1417,22 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
if (timeA && !timeB) return -1
|
if (timeA && !timeB) return -1
|
||||||
if (!timeA && timeB) return 1
|
if (!timeA && timeB) return 1
|
||||||
return a.displayName.localeCompare(b.displayName, 'zh-CN')
|
return this.contactDisplayNameCollator.compare(a.displayName, b.displayName)
|
||||||
})
|
})
|
||||||
|
captureStage('sortContacts', sortStartedAt)
|
||||||
|
|
||||||
// 移除临时的lastContactTime字段
|
// 移除临时的lastContactTime字段
|
||||||
|
const finalizeStartedAt = Date.now()
|
||||||
const result = contacts.map(({ lastContactTime, ...rest }) => rest)
|
const result = contacts.map(({ lastContactTime, ...rest }) => rest)
|
||||||
|
captureStage('finalizeResult', finalizeStartedAt)
|
||||||
|
|
||||||
|
const totalMs = Date.now() - startedAt
|
||||||
|
if (totalMs >= this.slowGetContactsLogThresholdMs) {
|
||||||
|
const stageSummary = stageDurations
|
||||||
|
.map((item) => `${item.stage}=${item.ms}ms`)
|
||||||
|
.join(', ')
|
||||||
|
console.warn(`[ChatService] getContacts(${isLiteMode ? 'lite' : 'full'}) 慢查询 total=${totalMs}ms, ${stageSummary}`)
|
||||||
|
}
|
||||||
return { success: true, contacts: result }
|
return { success: true, contacts: result }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('ChatService: 获取通讯录失败:', e)
|
console.error('ChatService: 获取通讯录失败:', e)
|
||||||
@@ -1880,6 +1959,568 @@ class ChatService {
|
|||||||
return Number.isFinite(parsed) ? parsed : fallback
|
return Number.isFinite(parsed) ? parsed : fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private hasAnyContactExtendedFieldKey(row: Record<string, any>): boolean {
|
||||||
|
for (const key of Object.keys(row || {})) {
|
||||||
|
if (this.contactExtendedFieldCandidateSet.has(String(key || '').toLowerCase())) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private async hydrateContactsWithExtendedFields(rows: Record<string, any>[]): Promise<Record<string, any>[]> {
|
||||||
|
if (!Array.isArray(rows) || rows.length === 0) return rows
|
||||||
|
const hasAnyExtendedFieldKey = rows.some((row) => this.hasAnyContactExtendedFieldKey(row || {}))
|
||||||
|
if (hasAnyExtendedFieldKey) {
|
||||||
|
// wcdb_get_contacts_compact 可能只给“部分联系人”返回 extra_buffer。
|
||||||
|
// 只有在每一行都能拿到可解析的 extra_buffer 时才跳过补偿查询。
|
||||||
|
const allRowsHaveUsableExtraBuffer = rows.every((row) => this.toExtraBufferBytes(row || {}) !== null)
|
||||||
|
if (allRowsHaveUsableExtraBuffer) return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let selectableColumns = this.contactExtendedSelectableColumns
|
||||||
|
if (!selectableColumns) {
|
||||||
|
const tableInfoResult = await wcdbService.execQuery('contact', null, 'PRAGMA table_info(contact)')
|
||||||
|
if (!tableInfoResult.success || !Array.isArray(tableInfoResult.rows)) {
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableColumns = new Map<string, string>()
|
||||||
|
for (const tableInfoRow of tableInfoResult.rows as Record<string, any>[]) {
|
||||||
|
const rawName = tableInfoRow.name ?? tableInfoRow.column_name ?? tableInfoRow.columnName
|
||||||
|
const name = String(rawName || '').trim()
|
||||||
|
if (!name) continue
|
||||||
|
availableColumns.set(name.toLowerCase(), name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedColumns: string[] = []
|
||||||
|
const seenColumns = new Set<string>()
|
||||||
|
for (const candidate of this.contactExtendedFieldCandidates) {
|
||||||
|
const actual = availableColumns.get(candidate.toLowerCase())
|
||||||
|
if (!actual) continue
|
||||||
|
const normalized = actual.toLowerCase()
|
||||||
|
if (seenColumns.has(normalized)) continue
|
||||||
|
seenColumns.add(normalized)
|
||||||
|
resolvedColumns.push(actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.contactExtendedSelectableColumns = resolvedColumns
|
||||||
|
selectableColumns = resolvedColumns
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectableColumns || selectableColumns.length === 0) return rows
|
||||||
|
|
||||||
|
const selectColumns = ['username', ...selectableColumns]
|
||||||
|
const sql = `SELECT ${selectColumns.map((column) => this.quoteSqlIdentifier(column)).join(', ')} FROM contact WHERE username IS NOT NULL AND username != ''`
|
||||||
|
const extendedResult = await wcdbService.execQuery('contact', null, sql)
|
||||||
|
if (!extendedResult.success || !Array.isArray(extendedResult.rows) || extendedResult.rows.length === 0) {
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
const extendedByUsername = new Map<string, Record<string, any>>()
|
||||||
|
for (const extendedRow of extendedResult.rows as Record<string, any>[]) {
|
||||||
|
const username = String(extendedRow.username || '').trim()
|
||||||
|
if (!username) continue
|
||||||
|
extendedByUsername.set(username, extendedRow)
|
||||||
|
}
|
||||||
|
if (extendedByUsername.size === 0) return rows
|
||||||
|
|
||||||
|
return rows.map((row) => {
|
||||||
|
const username = String(row.username || row.user_name || row.userName || '').trim()
|
||||||
|
if (!username) return row
|
||||||
|
const extended = extendedByUsername.get(username)
|
||||||
|
if (!extended) return row
|
||||||
|
return {
|
||||||
|
...extended,
|
||||||
|
...row
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('联系人扩展字段补偿查询失败:', error)
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getContactLabelNameMap(): Promise<Map<number, string>> {
|
||||||
|
const now = Date.now()
|
||||||
|
if (this.contactLabelNameMapCache && now - this.contactLabelNameMapCacheAt <= this.contactLabelNameMapCacheTtlMs) {
|
||||||
|
return new Map(this.contactLabelNameMapCache)
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelMap = new Map<number, string>()
|
||||||
|
try {
|
||||||
|
const tableInfoResult = await wcdbService.execQuery('contact', null, 'PRAGMA table_info(contact_label)')
|
||||||
|
if (!tableInfoResult.success || !Array.isArray(tableInfoResult.rows) || tableInfoResult.rows.length === 0) {
|
||||||
|
this.contactLabelNameMapCache = labelMap
|
||||||
|
this.contactLabelNameMapCacheAt = now
|
||||||
|
return labelMap
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableColumns = new Map<string, string>()
|
||||||
|
for (const tableInfoRow of tableInfoResult.rows as Record<string, any>[]) {
|
||||||
|
const rawName = tableInfoRow.name ?? tableInfoRow.column_name ?? tableInfoRow.columnName
|
||||||
|
const name = String(rawName || '').trim()
|
||||||
|
if (!name) continue
|
||||||
|
availableColumns.set(name.toLowerCase(), name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickColumn = (candidates: string[]): string | null => {
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const actual = availableColumns.get(candidate.toLowerCase())
|
||||||
|
if (actual) return actual
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const idColumn = pickColumn(['label_id_', 'label_id', 'labelId', 'labelid', 'id'])
|
||||||
|
const nameColumn = pickColumn(['label_name_', 'label_name', 'labelName', 'labelname', 'name'])
|
||||||
|
if (!idColumn || !nameColumn) {
|
||||||
|
this.contactLabelNameMapCache = labelMap
|
||||||
|
this.contactLabelNameMapCacheAt = now
|
||||||
|
return labelMap
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `SELECT ${this.quoteSqlIdentifier(idColumn)} AS label_id, ${this.quoteSqlIdentifier(nameColumn)} AS label_name FROM contact_label`
|
||||||
|
const result = await wcdbService.execQuery('contact', null, sql)
|
||||||
|
if (result.success && Array.isArray(result.rows)) {
|
||||||
|
for (const row of result.rows as Record<string, any>[]) {
|
||||||
|
const id = Number(String(row.label_id ?? row.labelId ?? '').trim())
|
||||||
|
const name = String(row.label_name ?? row.labelName ?? '').trim()
|
||||||
|
if (Number.isFinite(id) && id > 0 && name) {
|
||||||
|
labelMap.set(Math.floor(id), name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('读取 contact_label 失败:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.contactLabelNameMapCache = labelMap
|
||||||
|
this.contactLabelNameMapCacheAt = now
|
||||||
|
return new Map(labelMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
private toExtraBufferBytes(row: Record<string, any>): Buffer | null {
|
||||||
|
const raw = this.getRowField(row, ['extra_buffer', 'extraBuffer'])
|
||||||
|
if (raw === undefined || raw === null) return null
|
||||||
|
if (Buffer.isBuffer(raw)) return raw.length > 0 ? raw : null
|
||||||
|
if (raw instanceof Uint8Array) return raw.length > 0 ? Buffer.from(raw) : null
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
const bytes = Buffer.from(raw)
|
||||||
|
return bytes.length > 0 ? bytes : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = String(raw || '').trim()
|
||||||
|
if (!text) return null
|
||||||
|
const compact = text.replace(/\s+/g, '')
|
||||||
|
if (compact.length >= 2 && compact.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(compact)) {
|
||||||
|
try {
|
||||||
|
const bytes = Buffer.from(compact, 'hex')
|
||||||
|
return bytes.length > 0 ? bytes : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private readProtoVarint(buffer: Buffer, offset: number): { value: number; nextOffset: number } | null {
|
||||||
|
if (!buffer || offset < 0 || offset >= buffer.length) return null
|
||||||
|
let value = 0
|
||||||
|
let shift = 0
|
||||||
|
let index = offset
|
||||||
|
while (index < buffer.length) {
|
||||||
|
const byte = buffer[index]
|
||||||
|
index += 1
|
||||||
|
value += (byte & 0x7f) * Math.pow(2, shift)
|
||||||
|
if ((byte & 0x80) === 0) {
|
||||||
|
return { value, nextOffset: index }
|
||||||
|
}
|
||||||
|
shift += 7
|
||||||
|
if (shift > 56) return null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractExtraBufferTopLevelFieldStrings(row: Record<string, any>, targetField: number): string[] {
|
||||||
|
const bytes = this.toExtraBufferBytes(row)
|
||||||
|
if (!bytes || !Number.isFinite(targetField) || targetField <= 0) return []
|
||||||
|
const values: string[] = []
|
||||||
|
let offset = 0
|
||||||
|
while (offset < bytes.length) {
|
||||||
|
const tagResult = this.readProtoVarint(bytes, offset)
|
||||||
|
if (!tagResult) break
|
||||||
|
offset = tagResult.nextOffset
|
||||||
|
const fieldNumber = Math.floor(tagResult.value / 8)
|
||||||
|
const wireType = tagResult.value & 0x07
|
||||||
|
|
||||||
|
if (wireType === 0) {
|
||||||
|
const varint = this.readProtoVarint(bytes, offset)
|
||||||
|
if (!varint) break
|
||||||
|
offset = varint.nextOffset
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wireType === 1) {
|
||||||
|
if (offset + 8 > bytes.length) break
|
||||||
|
offset += 8
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wireType === 2) {
|
||||||
|
const lengthResult = this.readProtoVarint(bytes, offset)
|
||||||
|
if (!lengthResult) break
|
||||||
|
const payloadLength = Math.floor(lengthResult.value)
|
||||||
|
offset = lengthResult.nextOffset
|
||||||
|
if (payloadLength < 0 || offset + payloadLength > bytes.length) break
|
||||||
|
const payload = bytes.subarray(offset, offset + payloadLength)
|
||||||
|
offset += payloadLength
|
||||||
|
if (fieldNumber === targetField) {
|
||||||
|
const text = payload.toString('utf-8').replace(/\u0000/g, '').trim()
|
||||||
|
if (text) values.push(text)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wireType === 5) {
|
||||||
|
if (offset + 4 > bytes.length) break
|
||||||
|
offset += 4
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseContactLabelsFromExtraBuffer(row: Record<string, any>, labelNameMap?: Map<number, string>): string[] {
|
||||||
|
const labelNames: string[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const texts = this.extractExtraBufferTopLevelFieldStrings(row, 30)
|
||||||
|
for (const text of texts) {
|
||||||
|
const matches = text.match(/\d+/g) || []
|
||||||
|
for (const match of matches) {
|
||||||
|
const id = Number(match)
|
||||||
|
if (!Number.isFinite(id) || id <= 0) continue
|
||||||
|
const labelName = labelNameMap?.get(Math.floor(id))
|
||||||
|
if (!labelName) continue
|
||||||
|
if (seen.has(labelName)) continue
|
||||||
|
seen.add(labelName)
|
||||||
|
labelNames.push(labelName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return labelNames
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseContactLabels(row: Record<string, any>, labelNameMap?: Map<number, string>): string[] {
|
||||||
|
const raw = this.getRowField(row, [
|
||||||
|
'label_list', 'labelList', 'labels', 'label_names', 'labelNames', 'tags', 'tag_list', 'tagList'
|
||||||
|
])
|
||||||
|
const normalizedFromValue = (value: unknown): string[] => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
|
||||||
|
}
|
||||||
|
const text = String(value || '').trim()
|
||||||
|
if (!text) return []
|
||||||
|
return Array.from(new Set(
|
||||||
|
text
|
||||||
|
.replace(/[;;、|]+/g, ',')
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
const direct = normalizedFromValue(raw)
|
||||||
|
if (direct.length > 0) return direct
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(row)) {
|
||||||
|
const normalizedKey = key.toLowerCase()
|
||||||
|
if (!normalizedKey.includes('label') && !normalizedKey.includes('tag')) continue
|
||||||
|
if (normalizedKey.includes('img') || normalizedKey.includes('head')) continue
|
||||||
|
const fallback = normalizedFromValue(value)
|
||||||
|
if (fallback.length > 0) return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const extraBufferLabels = this.parseContactLabelsFromExtraBuffer(row, labelNameMap)
|
||||||
|
if (extraBufferLabels.length > 0) return extraBufferLabels
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
private getContactSignature(row: Record<string, any>): string {
|
||||||
|
const normalize = (raw: unknown): string => {
|
||||||
|
const text = String(raw || '').replace(/\u0000/g, '').trim()
|
||||||
|
if (!text) return ''
|
||||||
|
const lower = text.toLowerCase()
|
||||||
|
if (lower === '-' || lower === '--' || lower === '—' || lower === 'null' || lower === 'undefined' || lower === 'none') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = this.getRowField(row, [
|
||||||
|
'signature', 'sign', 'personal_signature', 'personalSignature', 'profile', 'introduction',
|
||||||
|
'detail_description', 'detailDescription', 'description', 'desc', 'contact_description', 'contactDescription'
|
||||||
|
])
|
||||||
|
const direct = normalize(value)
|
||||||
|
if (direct) return direct
|
||||||
|
|
||||||
|
for (const [key, rawValue] of Object.entries(row)) {
|
||||||
|
const normalizedKey = key.toLowerCase()
|
||||||
|
const isCandidate =
|
||||||
|
normalizedKey.includes('sign') ||
|
||||||
|
normalizedKey.includes('signature') ||
|
||||||
|
normalizedKey.includes('profile') ||
|
||||||
|
normalizedKey.includes('intro') ||
|
||||||
|
normalizedKey.includes('description') ||
|
||||||
|
normalizedKey.includes('detail') ||
|
||||||
|
normalizedKey.includes('desc')
|
||||||
|
if (!isCandidate) continue
|
||||||
|
if (
|
||||||
|
normalizedKey.includes('avatar') ||
|
||||||
|
normalizedKey.includes('img') ||
|
||||||
|
normalizedKey.includes('head') ||
|
||||||
|
normalizedKey.includes('label') ||
|
||||||
|
normalizedKey.includes('tag')
|
||||||
|
) continue
|
||||||
|
const text = normalize(rawValue)
|
||||||
|
if (text) return text
|
||||||
|
}
|
||||||
|
|
||||||
|
// contact.extra_buffer field 4: 个性签名兜底
|
||||||
|
const signatures = this.extractExtraBufferTopLevelFieldStrings(row, 4)
|
||||||
|
for (const signature of signatures) {
|
||||||
|
const text = normalize(signature)
|
||||||
|
if (!text) continue
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeContactRegionPart(raw: unknown): string {
|
||||||
|
const text = String(raw || '').replace(/\u0000/g, '').trim()
|
||||||
|
if (!text) return ''
|
||||||
|
const lower = text.toLowerCase()
|
||||||
|
if (lower === '-' || lower === '--' || lower === '—' || lower === 'null' || lower === 'undefined' || lower === 'none') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeRegionLookupKey(raw: string): string {
|
||||||
|
return String(raw || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRegionLookupCandidates(raw: string): string[] {
|
||||||
|
const normalized = this.normalizeRegionLookupKey(raw)
|
||||||
|
if (!normalized) return []
|
||||||
|
|
||||||
|
const candidates = new Set<string>([normalized])
|
||||||
|
const withoutTrailingDigits = normalized.replace(/\d+$/g, '')
|
||||||
|
if (withoutTrailingDigits) candidates.add(withoutTrailingDigits)
|
||||||
|
|
||||||
|
return Array.from(candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeChineseProvinceName(raw: string): string {
|
||||||
|
const text = String(raw || '').trim()
|
||||||
|
if (!text) return ''
|
||||||
|
return text
|
||||||
|
.replace(/特别行政区$/g, '')
|
||||||
|
.replace(/维吾尔自治区$/g, '')
|
||||||
|
.replace(/壮族自治区$/g, '')
|
||||||
|
.replace(/回族自治区$/g, '')
|
||||||
|
.replace(/自治区$/g, '')
|
||||||
|
.replace(/省$/g, '')
|
||||||
|
.replace(/市$/g, '')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeChineseCityName(raw: string): string {
|
||||||
|
const text = String(raw || '').trim()
|
||||||
|
if (!text) return ''
|
||||||
|
return text
|
||||||
|
.replace(/特别行政区$/g, '')
|
||||||
|
.replace(/自治州$/g, '')
|
||||||
|
.replace(/地区$/g, '')
|
||||||
|
.replace(/盟$/g, '')
|
||||||
|
.replace(/林区$/g, '')
|
||||||
|
.replace(/市$/g, '')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveProvinceLookupKey(raw: string): string {
|
||||||
|
const candidates = this.buildRegionLookupCandidates(raw)
|
||||||
|
if (candidates.length === 0) return ''
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const byName = CONTACT_REGION_LOOKUP_DATA.provinceKeyByName[candidate]
|
||||||
|
if (byName) return byName
|
||||||
|
if (CONTACT_REGION_LOOKUP_DATA.provinceNameByKey[candidate]) return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
private toChineseCountryName(raw: string): string {
|
||||||
|
const text = this.normalizeContactRegionPart(raw)
|
||||||
|
if (!text) return ''
|
||||||
|
|
||||||
|
const candidates = this.buildRegionLookupCandidates(text)
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const mapped = CONTACT_REGION_LOOKUP_DATA.countryNameByKey[candidate]
|
||||||
|
if (mapped) return mapped
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
private toChineseProvinceName(raw: string): string {
|
||||||
|
const text = this.normalizeContactRegionPart(raw)
|
||||||
|
if (!text) return ''
|
||||||
|
|
||||||
|
const candidates = this.buildRegionLookupCandidates(text)
|
||||||
|
if (candidates.length === 0) return text
|
||||||
|
const provinceKey = this.resolveProvinceLookupKey(text)
|
||||||
|
const mappedFromCandidates = candidates
|
||||||
|
.map((candidate) => CONTACT_REGION_LOOKUP_DATA.provinceNameByKey[candidate])
|
||||||
|
.find(Boolean)
|
||||||
|
const mapped = CONTACT_REGION_LOOKUP_DATA.provinceNameByKey[provinceKey] || mappedFromCandidates
|
||||||
|
if (mapped) return mapped
|
||||||
|
|
||||||
|
if (/[\u4e00-\u9fa5]/.test(text)) {
|
||||||
|
return this.normalizeChineseProvinceName(text) || text
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
private toChineseCityName(raw: string, provinceRaw?: string): string {
|
||||||
|
const text = this.normalizeContactRegionPart(raw)
|
||||||
|
if (!text) return ''
|
||||||
|
|
||||||
|
const candidates = this.buildRegionLookupCandidates(text)
|
||||||
|
if (candidates.length === 0) return text
|
||||||
|
|
||||||
|
const provinceKey = this.resolveProvinceLookupKey(String(provinceRaw || ''))
|
||||||
|
if (provinceKey) {
|
||||||
|
const byProvince = CONTACT_REGION_LOOKUP_DATA.cityNameByProvinceKey[provinceKey]
|
||||||
|
if (byProvince) {
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const mappedInProvince = byProvince[candidate]
|
||||||
|
if (mappedInProvince) return mappedInProvince
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const mapped = CONTACT_REGION_LOOKUP_DATA.cityNameByKey[candidate]
|
||||||
|
if (mapped) return mapped
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/[\u4e00-\u9fa5]/.test(text)) {
|
||||||
|
return this.normalizeChineseCityName(text) || text
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
private toChineseRegionText(raw: string): string {
|
||||||
|
const text = this.normalizeContactRegionPart(raw)
|
||||||
|
if (!text) return ''
|
||||||
|
const tokens = text
|
||||||
|
.split(/[\s,,、/|·]+/)
|
||||||
|
.map((item) => this.normalizeContactRegionPart(item))
|
||||||
|
.filter(Boolean)
|
||||||
|
if (tokens.length === 0) return text
|
||||||
|
|
||||||
|
let provinceContext = ''
|
||||||
|
const mapped = tokens.map((token) => {
|
||||||
|
const country = this.toChineseCountryName(token)
|
||||||
|
if (country !== token) return country
|
||||||
|
|
||||||
|
const province = this.toChineseProvinceName(token)
|
||||||
|
if (province !== token) {
|
||||||
|
provinceContext = province
|
||||||
|
return province
|
||||||
|
}
|
||||||
|
|
||||||
|
const city = this.toChineseCityName(token, provinceContext)
|
||||||
|
if (city !== token) return city
|
||||||
|
|
||||||
|
return token
|
||||||
|
})
|
||||||
|
return mapped.join(' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldHideCountryInRegion(country: string, hasProvinceOrCity: boolean): boolean {
|
||||||
|
if (!country) return true
|
||||||
|
const normalized = country.toLowerCase()
|
||||||
|
if (normalized === 'cn' || normalized === 'chn' || normalized === 'china' || normalized === '中国') {
|
||||||
|
return hasProvinceOrCity
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private getContactRegion(row: Record<string, any>): string {
|
||||||
|
const pickByTokens = (tokens: string[]): string => {
|
||||||
|
for (const [key, value] of Object.entries(row || {})) {
|
||||||
|
const normalizedKey = String(key || '').toLowerCase()
|
||||||
|
if (!normalizedKey) continue
|
||||||
|
if (normalizedKey.includes('avatar') || normalizedKey.includes('img') || normalizedKey.includes('head')) continue
|
||||||
|
if (!tokens.some((token) => normalizedKey.includes(token))) continue
|
||||||
|
const text = this.normalizeContactRegionPart(value)
|
||||||
|
if (text) return text
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const directCountry = this.normalizeContactRegionPart(this.getRowField(row, ['country', 'Country'])) || pickByTokens(['country'])
|
||||||
|
const directProvince = this.normalizeContactRegionPart(this.getRowField(row, ['province', 'Province'])) || pickByTokens(['province'])
|
||||||
|
const directCity = this.normalizeContactRegionPart(this.getRowField(row, ['city', 'City'])) || pickByTokens(['city'])
|
||||||
|
const directRegion =
|
||||||
|
this.normalizeContactRegionPart(this.getRowField(row, ['region', 'Region', 'location', 'area'])) ||
|
||||||
|
pickByTokens(['region', 'location', 'area', 'addr', 'address'])
|
||||||
|
|
||||||
|
if (directRegion) {
|
||||||
|
const normalizedRegion = this.toChineseRegionText(directRegion)
|
||||||
|
const parts = normalizedRegion
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((item) => this.normalizeContactRegionPart(item))
|
||||||
|
.filter(Boolean)
|
||||||
|
if (parts.length > 1 && this.shouldHideCountryInRegion(parts[0], true)) {
|
||||||
|
return parts.slice(1).join(' ').trim()
|
||||||
|
}
|
||||||
|
return normalizedRegion
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackCountry = this.normalizeContactRegionPart(this.extractExtraBufferTopLevelFieldStrings(row, 5)[0] || '')
|
||||||
|
const fallbackProvince = this.normalizeContactRegionPart(this.extractExtraBufferTopLevelFieldStrings(row, 6)[0] || '')
|
||||||
|
const fallbackCity = this.normalizeContactRegionPart(this.extractExtraBufferTopLevelFieldStrings(row, 7)[0] || '')
|
||||||
|
|
||||||
|
const country = this.toChineseCountryName(directCountry || fallbackCountry)
|
||||||
|
const province = this.toChineseProvinceName(directProvince || fallbackProvince)
|
||||||
|
const city = this.toChineseCityName(directCity || fallbackCity, directProvince || fallbackProvince)
|
||||||
|
|
||||||
|
const hasProvinceOrCity = Boolean(province || city)
|
||||||
|
const parts: string[] = []
|
||||||
|
if (!this.shouldHideCountryInRegion(country, hasProvinceOrCity)) {
|
||||||
|
parts.push(country)
|
||||||
|
}
|
||||||
|
if (province) {
|
||||||
|
parts.push(province)
|
||||||
|
}
|
||||||
|
if (city && city !== province) {
|
||||||
|
parts.push(city)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
private normalizeUnsignedIntegerToken(raw: any): string | undefined {
|
private normalizeUnsignedIntegerToken(raw: any): string | undefined {
|
||||||
if (raw === undefined || raw === null || raw === '') return undefined
|
if (raw === undefined || raw === null || raw === '') return undefined
|
||||||
|
|
||||||
@@ -5096,13 +5737,34 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 如果是群聊,尝试获取群昵称
|
// 如果是群聊,尝试获取群昵称
|
||||||
let groupNicknames: Record<string, string> = {}
|
const groupNicknames = new Map<string, string>()
|
||||||
if (chatroomId.endsWith('@chatroom')) {
|
if (chatroomId.endsWith('@chatroom')) {
|
||||||
const nickResult = await wcdbService.getGroupNicknames(chatroomId)
|
const nickResult = await wcdbService.getGroupNicknames(chatroomId)
|
||||||
if (nickResult.success && nickResult.nicknames) {
|
if (nickResult.success && nickResult.nicknames) {
|
||||||
groupNicknames = nickResult.nicknames
|
const nicknameBuckets = new Map<string, Set<string>>()
|
||||||
|
for (const [memberIdRaw, nicknameRaw] of Object.entries(nickResult.nicknames)) {
|
||||||
|
const memberId = String(memberIdRaw || '').trim().toLowerCase()
|
||||||
|
const nickname = String(nicknameRaw || '').trim()
|
||||||
|
if (!memberId || !nickname) continue
|
||||||
|
const slot = nicknameBuckets.get(memberId)
|
||||||
|
if (slot) {
|
||||||
|
slot.add(nickname)
|
||||||
|
} else {
|
||||||
|
nicknameBuckets.set(memberId, new Set([nickname]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const [memberId, nicknameSet] of nicknameBuckets.entries()) {
|
||||||
|
if (nicknameSet.size !== 1) continue
|
||||||
|
groupNicknames.set(memberId, Array.from(nicknameSet)[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lookupGroupNickname = (username?: string | null): string => {
|
||||||
|
const key = String(username || '').trim().toLowerCase()
|
||||||
|
if (!key) return ''
|
||||||
|
return groupNicknames.get(key) || ''
|
||||||
|
}
|
||||||
|
|
||||||
// 获取当前用户 wxid,用于识别"自己"
|
// 获取当前用户 wxid,用于识别"自己"
|
||||||
const myWxid = this.configService.get('myWxid')
|
const myWxid = this.configService.get('myWxid')
|
||||||
@@ -5113,7 +5775,7 @@ class ChatService {
|
|||||||
// 特判:如果是当前用户自己(contact 表通常不包含自己)
|
// 特判:如果是当前用户自己(contact 表通常不包含自己)
|
||||||
if (myWxid && (username === myWxid || username === cleanedMyWxid)) {
|
if (myWxid && (username === myWxid || username === cleanedMyWxid)) {
|
||||||
// 先查群昵称中是否有自己
|
// 先查群昵称中是否有自己
|
||||||
const myGroupNick = groupNicknames[username]
|
const myGroupNick = lookupGroupNickname(username) || lookupGroupNickname(myWxid)
|
||||||
if (myGroupNick) return myGroupNick
|
if (myGroupNick) return myGroupNick
|
||||||
// 尝试从缓存获取自己的昵称
|
// 尝试从缓存获取自己的昵称
|
||||||
const cached = this.avatarCache.get(username) || this.avatarCache.get(myWxid)
|
const cached = this.avatarCache.get(username) || this.avatarCache.get(myWxid)
|
||||||
@@ -5122,7 +5784,7 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 先查群昵称
|
// 先查群昵称
|
||||||
const groupNick = groupNicknames[username]
|
const groupNick = lookupGroupNickname(username)
|
||||||
if (groupNick) return groupNick
|
if (groupNick) return groupNick
|
||||||
|
|
||||||
// 再查联系人信息
|
// 再查联系人信息
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ class ContactExportService {
|
|||||||
displayName: c.displayName,
|
displayName: c.displayName,
|
||||||
remark: c.remark,
|
remark: c.remark,
|
||||||
nickname: c.nickname,
|
nickname: c.nickname,
|
||||||
|
alias: c.alias,
|
||||||
|
labels: Array.isArray(c.labels) ? c.labels : [],
|
||||||
|
detailDescription: c.detailDescription,
|
||||||
type: c.type
|
type: c.type
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -103,12 +106,15 @@ class ContactExportService {
|
|||||||
* 导出为CSV格式
|
* 导出为CSV格式
|
||||||
*/
|
*/
|
||||||
private async exportToCSV(contacts: any[], outputPath: string): Promise<void> {
|
private async exportToCSV(contacts: any[], outputPath: string): Promise<void> {
|
||||||
const headers = ['用户名', '显示名称', '备注', '昵称', '类型']
|
const headers = ['用户名', '显示名称', '备注', '昵称', '微信号', '标签', '详细描述', '类型']
|
||||||
const rows = contacts.map(c => [
|
const rows = contacts.map(c => [
|
||||||
c.username || '',
|
c.username || '',
|
||||||
c.displayName || '',
|
c.displayName || '',
|
||||||
c.remark || '',
|
c.remark || '',
|
||||||
c.nickname || '',
|
c.nickname || '',
|
||||||
|
c.alias || '',
|
||||||
|
Array.isArray(c.labels) ? c.labels.join(' | ') : '',
|
||||||
|
c.detailDescription || '',
|
||||||
this.getTypeLabel(c.type)
|
this.getTypeLabel(c.type)
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -137,9 +143,13 @@ class ContactExportService {
|
|||||||
lines.push(`NICKNAME:${c.nickname}`)
|
lines.push(`NICKNAME:${c.nickname}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 备注
|
const noteParts = [
|
||||||
if (c.remark) {
|
c.remark ? String(c.remark) : '',
|
||||||
lines.push(`NOTE:${c.remark}`)
|
Array.isArray(c.labels) && c.labels.length > 0 ? `标签: ${c.labels.join(', ')}` : '',
|
||||||
|
c.detailDescription ? `详细描述: ${c.detailDescription}` : ''
|
||||||
|
].filter(Boolean)
|
||||||
|
if (noteParts.length > 0) {
|
||||||
|
lines.push(`NOTE:${noteParts.join('\\n')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 微信ID
|
// 微信ID
|
||||||
|
|||||||
9440
electron/services/contactRegionLookupData.ts
Normal file
9440
electron/services/contactRegionLookupData.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1404,35 +1404,62 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取群成员群昵称。优先使用 DLL,必要时回退到 `contact.chat_room.ext_buffer` 解析。
|
* 获取群成员群昵称。后端结果为唯一业务真值,前端仅做冲突净化防串号。
|
||||||
*/
|
*/
|
||||||
async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
|
async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
|
||||||
const nicknameMap = new Map<string, string>()
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const dllResult = await wcdbService.getGroupNicknames(chatroomId)
|
const dllResult = await wcdbService.getGroupNicknames(chatroomId)
|
||||||
if (dllResult.success && dllResult.nicknames) {
|
if (!dllResult.success || !dllResult.nicknames) {
|
||||||
this.mergeGroupNicknameEntries(nicknameMap, Object.entries(dllResult.nicknames))
|
return new Map<string, string>()
|
||||||
}
|
}
|
||||||
|
return this.buildTrustedGroupNicknameMap(Object.entries(dllResult.nicknames), candidates)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('getGroupNicknamesForRoom dll error:', e)
|
console.error('getGroupNicknamesForRoom dll error:', e)
|
||||||
|
return new Map<string, string>()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
private normalizeGroupNicknameIdentity(value: string): string {
|
||||||
const result = await wcdbService.getChatRoomExtBuffer(chatroomId)
|
const raw = String(value || '').trim()
|
||||||
if (!result.success || !result.extBuffer) {
|
if (!raw) return ''
|
||||||
return nicknameMap
|
return raw.toLowerCase()
|
||||||
}
|
}
|
||||||
const extBuffer = this.decodeExtBuffer(result.extBuffer)
|
|
||||||
if (!extBuffer) return nicknameMap
|
private buildTrustedGroupNicknameMap(
|
||||||
this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries())
|
entries: Iterable<[string, string]>,
|
||||||
return nicknameMap
|
candidates: string[] = []
|
||||||
} catch (e) {
|
): Map<string, string> {
|
||||||
console.error('getGroupNicknamesForRoom error:', e)
|
const candidateSet = new Set(
|
||||||
return nicknameMap
|
this.buildGroupNicknameIdCandidates(candidates)
|
||||||
|
.map((id) => this.normalizeGroupNicknameIdentity(id))
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
|
||||||
|
const buckets = new Map<string, Set<string>>()
|
||||||
|
for (const [memberIdRaw, nicknameRaw] of entries) {
|
||||||
|
const identity = this.normalizeGroupNicknameIdentity(memberIdRaw || '')
|
||||||
|
if (!identity) continue
|
||||||
|
if (candidateSet.size > 0 && !candidateSet.has(identity)) continue
|
||||||
|
|
||||||
|
const nickname = this.normalizeGroupNickname(nicknameRaw || '')
|
||||||
|
if (!nickname) continue
|
||||||
|
|
||||||
|
const slot = buckets.get(identity)
|
||||||
|
if (slot) {
|
||||||
|
slot.add(nickname)
|
||||||
|
} else {
|
||||||
|
buckets.set(identity, new Set([nickname]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trusted = new Map<string, string>()
|
||||||
|
for (const [identity, nicknameSet] of buckets.entries()) {
|
||||||
|
if (nicknameSet.size !== 1) continue
|
||||||
|
trusted.set(identity, Array.from(nicknameSet)[0])
|
||||||
|
}
|
||||||
|
return trusted
|
||||||
|
}
|
||||||
|
|
||||||
private mergeGroupNicknameEntries(
|
private mergeGroupNicknameEntries(
|
||||||
target: Map<string, string>,
|
target: Map<string, string>,
|
||||||
entries: Iterable<[string, string]>
|
entries: Iterable<[string, string]>
|
||||||
@@ -1680,8 +1707,6 @@ class ExportService {
|
|||||||
const raw = String(rawValue || '').trim()
|
const raw = String(rawValue || '').trim()
|
||||||
if (!raw) continue
|
if (!raw) continue
|
||||||
set.add(raw)
|
set.add(raw)
|
||||||
const cleaned = this.cleanAccountDirName(raw)
|
|
||||||
if (cleaned && cleaned !== raw) set.add(cleaned)
|
|
||||||
}
|
}
|
||||||
return Array.from(set)
|
return Array.from(set)
|
||||||
}
|
}
|
||||||
@@ -1690,29 +1715,20 @@ class ExportService {
|
|||||||
const idCandidates = this.buildGroupNicknameIdCandidates(candidates)
|
const idCandidates = this.buildGroupNicknameIdCandidates(candidates)
|
||||||
if (idCandidates.length === 0) return ''
|
if (idCandidates.length === 0) return ''
|
||||||
|
|
||||||
|
let resolved = ''
|
||||||
for (const id of idCandidates) {
|
for (const id of idCandidates) {
|
||||||
const exact = this.normalizeGroupNickname(groupNicknamesMap.get(id) || '')
|
const normalizedId = this.normalizeGroupNicknameIdentity(id)
|
||||||
if (exact) return exact
|
if (!normalizedId) continue
|
||||||
const lower = this.normalizeGroupNickname(groupNicknamesMap.get(id.toLowerCase()) || '')
|
const candidateNickname = this.normalizeGroupNickname(groupNicknamesMap.get(normalizedId) || '')
|
||||||
if (lower) return lower
|
if (!candidateNickname) continue
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = candidateNickname
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (resolved !== candidateNickname) return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const id of idCandidates) {
|
return resolved
|
||||||
const lower = id.toLowerCase()
|
|
||||||
let found = ''
|
|
||||||
let matched = 0
|
|
||||||
for (const [key, value] of groupNicknamesMap.entries()) {
|
|
||||||
if (String(key || '').toLowerCase() !== lower) continue
|
|
||||||
const normalized = this.normalizeGroupNickname(value || '')
|
|
||||||
if (!normalized) continue
|
|
||||||
found = normalized
|
|
||||||
matched += 1
|
|
||||||
if (matched > 1) return ''
|
|
||||||
}
|
|
||||||
if (matched === 1 && found) return found
|
|
||||||
}
|
|
||||||
|
|
||||||
return ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -257,36 +257,62 @@ class GroupAnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从 DLL 获取群成员的群昵称
|
* 从后端获取群成员群昵称,并在前端进行唯一性净化防串号。
|
||||||
*/
|
*/
|
||||||
private async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
|
private async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
|
||||||
const nicknameMap = new Map<string, string>()
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const dllResult = await wcdbService.getGroupNicknames(chatroomId)
|
const dllResult = await wcdbService.getGroupNicknames(chatroomId)
|
||||||
if (dllResult.success && dllResult.nicknames) {
|
if (!dllResult.success || !dllResult.nicknames) {
|
||||||
this.mergeGroupNicknameEntries(nicknameMap, Object.entries(dllResult.nicknames))
|
return new Map<string, string>()
|
||||||
}
|
}
|
||||||
|
return this.buildTrustedGroupNicknameMap(Object.entries(dllResult.nicknames), candidates)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('getGroupNicknamesForRoom dll error:', e)
|
console.error('getGroupNicknamesForRoom dll error:', e)
|
||||||
|
return new Map<string, string>()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
private normalizeGroupNicknameIdentity(value: string): string {
|
||||||
const result = await wcdbService.getChatRoomExtBuffer(chatroomId)
|
const raw = String(value || '').trim()
|
||||||
if (!result.success || !result.extBuffer) {
|
if (!raw) return ''
|
||||||
return nicknameMap
|
return raw.toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
const extBuffer = this.decodeExtBuffer(result.extBuffer)
|
private buildTrustedGroupNicknameMap(
|
||||||
if (!extBuffer) return nicknameMap
|
entries: Iterable<[string, string]>,
|
||||||
this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries())
|
candidates: string[] = []
|
||||||
return nicknameMap
|
): Map<string, string> {
|
||||||
} catch (e) {
|
const candidateSet = new Set(
|
||||||
console.error('getGroupNicknamesForRoom error:', e)
|
this.buildGroupNicknameIdCandidates(candidates)
|
||||||
return nicknameMap
|
.map((id) => this.normalizeGroupNicknameIdentity(id))
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
|
||||||
|
const buckets = new Map<string, Set<string>>()
|
||||||
|
for (const [memberIdRaw, nicknameRaw] of entries) {
|
||||||
|
const identity = this.normalizeGroupNicknameIdentity(memberIdRaw || '')
|
||||||
|
if (!identity) continue
|
||||||
|
if (candidateSet.size > 0 && !candidateSet.has(identity)) continue
|
||||||
|
|
||||||
|
const nickname = this.normalizeGroupNickname(nicknameRaw || '')
|
||||||
|
if (!nickname) continue
|
||||||
|
|
||||||
|
const slot = buckets.get(identity)
|
||||||
|
if (slot) {
|
||||||
|
slot.add(nickname)
|
||||||
|
} else {
|
||||||
|
buckets.set(identity, new Set([nickname]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trusted = new Map<string, string>()
|
||||||
|
for (const [identity, nicknameSet] of buckets.entries()) {
|
||||||
|
if (nicknameSet.size !== 1) continue
|
||||||
|
trusted.set(identity, Array.from(nicknameSet)[0])
|
||||||
|
}
|
||||||
|
return trusted
|
||||||
|
}
|
||||||
|
|
||||||
private mergeGroupNicknameEntries(
|
private mergeGroupNicknameEntries(
|
||||||
target: Map<string, string>,
|
target: Map<string, string>,
|
||||||
entries: Iterable<[string, string]>
|
entries: Iterable<[string, string]>
|
||||||
@@ -475,6 +501,16 @@ class GroupAnalyticsService {
|
|||||||
return Array.from(set)
|
return Array.from(set)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildGroupNicknameIdCandidates(values: Array<string | undefined | null>): string[] {
|
||||||
|
const set = new Set<string>()
|
||||||
|
for (const rawValue of values) {
|
||||||
|
const raw = String(rawValue || '').trim()
|
||||||
|
if (!raw) continue
|
||||||
|
set.add(raw)
|
||||||
|
}
|
||||||
|
return Array.from(set)
|
||||||
|
}
|
||||||
|
|
||||||
private toNonNegativeInteger(value: unknown): number {
|
private toNonNegativeInteger(value: unknown): number {
|
||||||
const parsed = Number(value)
|
const parsed = Number(value)
|
||||||
if (!Number.isFinite(parsed)) return 0
|
if (!Number.isFinite(parsed)) return 0
|
||||||
@@ -663,30 +699,23 @@ class GroupAnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resolveGroupNicknameByCandidates(groupNicknames: Map<string, string>, candidates: string[]): string {
|
private resolveGroupNicknameByCandidates(groupNicknames: Map<string, string>, candidates: string[]): string {
|
||||||
const idCandidates = this.buildIdCandidates(candidates)
|
const idCandidates = this.buildGroupNicknameIdCandidates(candidates)
|
||||||
if (idCandidates.length === 0) return ''
|
if (idCandidates.length === 0) return ''
|
||||||
|
|
||||||
|
let resolved = ''
|
||||||
for (const id of idCandidates) {
|
for (const id of idCandidates) {
|
||||||
const exact = this.normalizeGroupNickname(groupNicknames.get(id) || '')
|
const normalizedId = this.normalizeGroupNicknameIdentity(id)
|
||||||
if (exact) return exact
|
if (!normalizedId) continue
|
||||||
|
const candidateNickname = this.normalizeGroupNickname(groupNicknames.get(normalizedId) || '')
|
||||||
|
if (!candidateNickname) continue
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = candidateNickname
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (resolved !== candidateNickname) return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const id of idCandidates) {
|
return resolved
|
||||||
const lower = id.toLowerCase()
|
|
||||||
let found = ''
|
|
||||||
let matched = 0
|
|
||||||
for (const [key, value] of groupNicknames.entries()) {
|
|
||||||
if (String(key || '').toLowerCase() !== lower) continue
|
|
||||||
const normalized = this.normalizeGroupNickname(value || '')
|
|
||||||
if (!normalized) continue
|
|
||||||
found = normalized
|
|
||||||
matched += 1
|
|
||||||
if (matched > 1) return ''
|
|
||||||
}
|
|
||||||
if (matched === 1 && found) return found
|
|
||||||
}
|
|
||||||
|
|
||||||
return ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private sanitizeWorksheetName(name: string): string {
|
private sanitizeWorksheetName(name: string): string {
|
||||||
|
|||||||
@@ -1017,13 +1017,31 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private lookupGroupNickname(groupNicknamesMap: Map<string, string>, sender: string): string {
|
private lookupGroupNickname(groupNicknamesMap: Map<string, string>, sender: string): string {
|
||||||
if (!sender) return ''
|
const key = String(sender || '').trim().toLowerCase()
|
||||||
const cleaned = this.normalizeAccountId(sender)
|
if (!key) return ''
|
||||||
return groupNicknamesMap.get(sender)
|
return groupNicknamesMap.get(key) || ''
|
||||||
|| groupNicknamesMap.get(sender.toLowerCase())
|
}
|
||||||
|| groupNicknamesMap.get(cleaned)
|
|
||||||
|| groupNicknamesMap.get(cleaned.toLowerCase())
|
private buildTrustedGroupNicknameMap(nicknames: Record<string, string>): Map<string, string> {
|
||||||
|| ''
|
const buckets = new Map<string, Set<string>>()
|
||||||
|
for (const [memberIdRaw, nicknameRaw] of Object.entries(nicknames || {})) {
|
||||||
|
const memberId = String(memberIdRaw || '').trim().toLowerCase()
|
||||||
|
const nickname = String(nicknameRaw || '').trim()
|
||||||
|
if (!memberId || !nickname) continue
|
||||||
|
const slot = buckets.get(memberId)
|
||||||
|
if (slot) {
|
||||||
|
slot.add(nickname)
|
||||||
|
} else {
|
||||||
|
buckets.set(memberId, new Set([nickname]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trusted = new Map<string, string>()
|
||||||
|
for (const [memberId, nicknameSet] of buckets.entries()) {
|
||||||
|
if (nicknameSet.size !== 1) continue
|
||||||
|
trusted.set(memberId, Array.from(nicknameSet)[0])
|
||||||
|
}
|
||||||
|
return trusted
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveChatLabSenderInfo(
|
private resolveChatLabSenderInfo(
|
||||||
@@ -1094,21 +1112,7 @@ class HttpService {
|
|||||||
try {
|
try {
|
||||||
const result = await wcdbService.getGroupNicknames(talkerId)
|
const result = await wcdbService.getGroupNicknames(talkerId)
|
||||||
if (result.success && result.nicknames) {
|
if (result.success && result.nicknames) {
|
||||||
groupNicknamesMap = new Map()
|
groupNicknamesMap = this.buildTrustedGroupNicknameMap(result.nicknames)
|
||||||
for (const [memberIdRaw, nicknameRaw] of Object.entries(result.nicknames)) {
|
|
||||||
const memberId = String(memberIdRaw || '').trim()
|
|
||||||
const nickname = String(nicknameRaw || '').trim()
|
|
||||||
if (!memberId || !nickname) continue
|
|
||||||
|
|
||||||
groupNicknamesMap.set(memberId, nickname)
|
|
||||||
groupNicknamesMap.set(memberId.toLowerCase(), nickname)
|
|
||||||
|
|
||||||
const cleaned = this.normalizeAccountId(memberId)
|
|
||||||
if (cleaned) {
|
|
||||||
groupNicknamesMap.set(cleaned, nickname)
|
|
||||||
groupNicknamesMap.set(cleaned.toLowerCase(), nickname)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[HttpService] Failed to get group nicknames:', e)
|
console.error('[HttpService] Failed to get group nicknames:', e)
|
||||||
@@ -1389,4 +1393,3 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const httpService = new HttpService()
|
export const httpService = new HttpService()
|
||||||
|
|
||||||
|
|||||||
@@ -304,11 +304,8 @@ class MessagePushService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const groupNicknames = await this.getGroupNicknames(chatroomId)
|
const groupNicknames = await this.getGroupNicknames(chatroomId)
|
||||||
const normalizedSender = this.normalizeAccountId(senderUsername)
|
const senderKey = senderUsername.toLowerCase()
|
||||||
const nickname = groupNicknames[senderUsername]
|
const nickname = groupNicknames[senderKey]
|
||||||
|| groupNicknames[senderUsername.toLowerCase()]
|
|
||||||
|| groupNicknames[normalizedSender]
|
|
||||||
|| groupNicknames[normalizedSender.toLowerCase()]
|
|
||||||
|
|
||||||
if (nickname) {
|
if (nickname) {
|
||||||
return nickname
|
return nickname
|
||||||
@@ -328,22 +325,33 @@ class MessagePushService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await wcdbService.getGroupNicknames(cacheKey)
|
const result = await wcdbService.getGroupNicknames(cacheKey)
|
||||||
const nicknames = result.success && result.nicknames ? result.nicknames : {}
|
const nicknames = result.success && result.nicknames
|
||||||
|
? this.sanitizeGroupNicknames(result.nicknames)
|
||||||
|
: {}
|
||||||
this.groupNicknameCache.set(cacheKey, { nicknames, updatedAt: Date.now() })
|
this.groupNicknameCache.set(cacheKey, { nicknames, updatedAt: Date.now() })
|
||||||
return nicknames
|
return nicknames
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeAccountId(value: string): string {
|
private sanitizeGroupNicknames(nicknames: Record<string, string>): Record<string, string> {
|
||||||
const trimmed = String(value || '').trim()
|
const buckets = new Map<string, Set<string>>()
|
||||||
if (!trimmed) return trimmed
|
for (const [memberIdRaw, nicknameRaw] of Object.entries(nicknames || {})) {
|
||||||
|
const memberId = String(memberIdRaw || '').trim().toLowerCase()
|
||||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
const nickname = String(nicknameRaw || '').trim()
|
||||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
if (!memberId || !nickname) continue
|
||||||
return match ? match[1] : trimmed
|
const slot = buckets.get(memberId)
|
||||||
|
if (slot) {
|
||||||
|
slot.add(nickname)
|
||||||
|
} else {
|
||||||
|
buckets.set(memberId, new Set([nickname]))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
const trusted: Record<string, string> = {}
|
||||||
return suffixMatch ? suffixMatch[1] : trimmed
|
for (const [memberId, nicknameSet] of buckets.entries()) {
|
||||||
|
if (nicknameSet.size !== 1) continue
|
||||||
|
trusted[memberId] = Array.from(nicknameSet)[0]
|
||||||
|
}
|
||||||
|
return trusted
|
||||||
}
|
}
|
||||||
|
|
||||||
private isRecentMessage(messageKey: string): boolean {
|
private isRecentMessage(messageKey: string): boolean {
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -625,7 +625,7 @@
|
|||||||
|
|
||||||
.bubble-content {
|
.bubble-content {
|
||||||
background: var(--primary-gradient);
|
background: var(--primary-gradient);
|
||||||
color: #fff;
|
color: var(--on-primary);
|
||||||
border-radius: 18px 18px 4px 18px;
|
border-radius: 18px 18px 4px 18px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -1962,7 +1962,7 @@
|
|||||||
|
|
||||||
.bubble-content {
|
.bubble-content {
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: white;
|
color: var(--on-primary);
|
||||||
border-radius: 18px 18px 4px 18px;
|
border-radius: 18px 18px 4px 18px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2453,15 +2453,15 @@
|
|||||||
|
|
||||||
// 自己发送的消息中的引用样式
|
// 自己发送的消息中的引用样式
|
||||||
.message-bubble.sent .quoted-message {
|
.message-bubble.sent .quoted-message {
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: color-mix(in srgb, var(--on-primary) 12%, var(--primary));
|
||||||
border-left-color: rgba(255, 255, 255, 0.5);
|
border-left-color: color-mix(in srgb, var(--on-primary) 36%, var(--primary));
|
||||||
|
|
||||||
.quoted-sender {
|
.quoted-sender {
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: color-mix(in srgb, var(--on-primary) 92%, var(--primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
.quoted-text {
|
.quoted-text {
|
||||||
color: rgba(255, 255, 255, 0.8);
|
color: color-mix(in srgb, var(--on-primary) 80%, var(--primary));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const AVATAR_ENRICH_BATCH_SIZE = 80
|
|||||||
const SEARCH_DEBOUNCE_MS = 120
|
const SEARCH_DEBOUNCE_MS = 120
|
||||||
const VIRTUAL_ROW_HEIGHT = 76
|
const VIRTUAL_ROW_HEIGHT = 76
|
||||||
const VIRTUAL_OVERSCAN = 10
|
const VIRTUAL_OVERSCAN = 10
|
||||||
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000
|
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 10000
|
||||||
const AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
const AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
interface ContactsLoadSession {
|
interface ContactsLoadSession {
|
||||||
@@ -397,6 +397,10 @@ function ContactsPage() {
|
|||||||
displayName: contact.displayName,
|
displayName: contact.displayName,
|
||||||
remark: contact.remark,
|
remark: contact.remark,
|
||||||
nickname: contact.nickname,
|
nickname: contact.nickname,
|
||||||
|
alias: contact.alias,
|
||||||
|
labels: contact.labels,
|
||||||
|
detailDescription: contact.detailDescription,
|
||||||
|
region: contact.region,
|
||||||
type: contact.type
|
type: contact.type
|
||||||
}))
|
}))
|
||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
@@ -1110,6 +1114,16 @@ function ContactsPage() {
|
|||||||
<div className="detail-row"><span className="detail-label">用户名</span><span className="detail-value">{selectedContact.username}</span></div>
|
<div className="detail-row"><span className="detail-label">用户名</span><span className="detail-value">{selectedContact.username}</span></div>
|
||||||
<div className="detail-row"><span className="detail-label">昵称</span><span className="detail-value">{selectedContact.nickname || selectedContact.displayName}</span></div>
|
<div className="detail-row"><span className="detail-label">昵称</span><span className="detail-value">{selectedContact.nickname || selectedContact.displayName}</span></div>
|
||||||
{selectedContact.remark && <div className="detail-row"><span className="detail-label">备注</span><span className="detail-value">{selectedContact.remark}</span></div>}
|
{selectedContact.remark && <div className="detail-row"><span className="detail-label">备注</span><span className="detail-value">{selectedContact.remark}</span></div>}
|
||||||
|
{selectedContact.alias && <div className="detail-row"><span className="detail-label">微信号</span><span className="detail-value">{selectedContact.alias}</span></div>}
|
||||||
|
{selectedContact.labels && selectedContact.labels.length > 0 && (
|
||||||
|
<div className="detail-row"><span className="detail-label">标签</span><span className="detail-value">{selectedContact.labels.join('、')}</span></div>
|
||||||
|
)}
|
||||||
|
{selectedContact.detailDescription && (
|
||||||
|
<div className="detail-row"><span className="detail-label">个性签名</span><span className="detail-value">{selectedContact.detailDescription}</span></div>
|
||||||
|
)}
|
||||||
|
{selectedContact.region && (
|
||||||
|
<div className="detail-row"><span className="detail-label">地区</span><span className="detail-value">{selectedContact.region}</span></div>
|
||||||
|
)}
|
||||||
<div className="detail-row"><span className="detail-label">类型</span><span className="detail-value">{getContactTypeName(selectedContact.type)}</span></div>
|
<div className="detail-row"><span className="detail-label">类型</span><span className="detail-value">{getContactTypeName(selectedContact.type)}</span></div>
|
||||||
{selectedContactSupportsSns && (
|
{selectedContactSupportsSns && (
|
||||||
<div className="detail-row">
|
<div className="detail-row">
|
||||||
|
|||||||
@@ -568,7 +568,7 @@ const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(
|
|||||||
const CONTACT_ENRICH_TIMEOUT_MS = 7000
|
const CONTACT_ENRICH_TIMEOUT_MS = 7000
|
||||||
const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000
|
const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000
|
||||||
const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80
|
const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80
|
||||||
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000
|
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 10000
|
||||||
const EXPORT_REENTER_SESSION_SOFT_REFRESH_MS = 5 * 60 * 1000
|
const EXPORT_REENTER_SESSION_SOFT_REFRESH_MS = 5 * 60 * 1000
|
||||||
const EXPORT_REENTER_CONTACTS_SOFT_REFRESH_MS = 5 * 60 * 1000
|
const EXPORT_REENTER_CONTACTS_SOFT_REFRESH_MS = 5 * 60 * 1000
|
||||||
const EXPORT_REENTER_SNS_SOFT_REFRESH_MS = 3 * 60 * 1000
|
const EXPORT_REENTER_SNS_SOFT_REFRESH_MS = 3 * 60 * 1000
|
||||||
@@ -1928,7 +1928,7 @@ function ExportPage() {
|
|||||||
|
|
||||||
setIsContactsListLoading(true)
|
setIsContactsListLoading(true)
|
||||||
try {
|
try {
|
||||||
const contactsResult = await window.electronAPI.chat.getContacts()
|
const contactsResult = await window.electronAPI.chat.getContacts({ lite: true })
|
||||||
if (contactsLoadVersionRef.current !== loadVersion) return
|
if (contactsLoadVersionRef.current !== loadVersion) return
|
||||||
|
|
||||||
if (contactsResult.success && contactsResult.contacts) {
|
if (contactsResult.success && contactsResult.contacts) {
|
||||||
@@ -3782,7 +3782,7 @@ function ExportPage() {
|
|||||||
|
|
||||||
if (isStale()) return
|
if (isStale()) return
|
||||||
if (detailStatsPriorityRef.current) return
|
if (detailStatsPriorityRef.current) return
|
||||||
const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS)
|
const contactsResult = await withTimeout(window.electronAPI.chat.getContacts({ lite: true }), CONTACT_ENRICH_TIMEOUT_MS)
|
||||||
if (isStale()) return
|
if (isStale()) return
|
||||||
|
|
||||||
const contactsFromNetwork: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : []
|
const contactsFromNetwork: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : []
|
||||||
|
|||||||
@@ -1145,9 +1145,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quote-layout-group {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.quote-layout-picker {
|
.quote-layout-picker {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
@@ -1155,32 +1159,146 @@
|
|||||||
.quote-layout-card {
|
.quote-layout-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 16px;
|
border-radius: 14px;
|
||||||
padding: 14px;
|
padding: 14px 14px 12px;
|
||||||
background: var(--bg-primary);
|
background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary));
|
||||||
color: inherit;
|
color: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease, background 0.2s ease;
|
transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color));
|
border-color: color-mix(in srgb, var(--primary) 32%, var(--border-color));
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
border-color: var(--primary);
|
border-color: color-mix(in srgb, var(--primary) 68%, var(--border-color));
|
||||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 14%, transparent);
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 12%, transparent);
|
||||||
background: color-mix(in srgb, var(--bg-primary) 92%, var(--primary) 8%);
|
background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.quote-layout-card-header {
|
.quote-layout-card-check {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid color-mix(in srgb, var(--primary) 46%, var(--border-color));
|
||||||
|
background: transparent;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: color-mix(in srgb, var(--primary) 78%, #6f8653);
|
||||||
|
transform: scale(0);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 78%, var(--border-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active::after {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-layout-preview-shell {
|
||||||
|
padding-left: 22px;
|
||||||
|
min-height: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-layout-preview-chat {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
}
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 12px;
|
.quote-layout-preview-chat .message-bubble {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
max-width: 70%;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-layout-preview-chat .message-bubble.sent {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-layout-preview-chat .message-bubble.sent .bubble-content {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--on-primary);
|
||||||
|
border-radius: 18px 18px 4px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-layout-preview-chat .bubble-content {
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-layout-preview-chat .message-text {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-layout-preview-chat .quoted-message {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
border-left: 2px solid var(--primary);
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-layout-preview-chat .quoted-sender {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-layout-preview-chat .quoted-sender::after {
|
||||||
|
content: ':';
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-layout-preview-chat .quoted-text {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-layout-preview-chat .message-bubble.sent .quoted-message {
|
||||||
|
background: color-mix(in srgb, var(--on-primary) 12%, var(--primary));
|
||||||
|
border-left-color: color-mix(in srgb, var(--on-primary) 36%, var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-layout-preview-chat .message-bubble.sent .quoted-sender {
|
||||||
|
color: color-mix(in srgb, var(--on-primary) 92%, var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-layout-preview-chat .message-bubble.sent .quoted-text {
|
||||||
|
color: color-mix(in srgb, var(--on-primary) 80%, var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-layout-preview-chat .bubble-content.quote-layout-top .quoted-message {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-layout-preview-chat .bubble-content.quote-layout-bottom .quoted-message {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-layout-card-footer {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-left: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quote-layout-card-title-group {
|
.quote-layout-card-title-group {
|
||||||
@@ -1190,89 +1308,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.quote-layout-card-title {
|
.quote-layout-card-title {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.quote-layout-card-desc {
|
.quote-layout-card-desc {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.quote-layout-card-check {
|
@media (max-width: 760px) {
|
||||||
width: 22px;
|
.quote-layout-picker {
|
||||||
height: 22px;
|
grid-template-columns: 1fr;
|
||||||
border-radius: 50%;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
color: transparent;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
border-color: var(--primary);
|
|
||||||
background: var(--primary);
|
|
||||||
color: #fff;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.quote-layout-preview {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: color-mix(in srgb, var(--bg-secondary) 92%, var(--bg-primary));
|
|
||||||
border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
|
|
||||||
min-height: 112px;
|
|
||||||
|
|
||||||
&.quote-bottom {
|
|
||||||
.quote-layout-preview-message {
|
|
||||||
order: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quote-layout-preview-quote {
|
|
||||||
order: 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.quote-layout-preview-quote {
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-left: 2px solid var(--primary);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(0, 0, 0, 0.04);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quote-layout-preview-sender {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quote-layout-preview-text {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quote-layout-preview-message {
|
|
||||||
align-self: flex-start;
|
|
||||||
max-width: 88%;
|
|
||||||
padding: 9px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: color-mix(in srgb, var(--primary) 14%, var(--bg-primary));
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-grid {
|
.theme-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
|||||||
|
|
||||||
const isMac = navigator.userAgent.toLowerCase().includes('mac')
|
const isMac = navigator.userAgent.toLowerCase().includes('mac')
|
||||||
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
|
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
|
||||||
|
const isWindows = !isMac && !isLinux
|
||||||
|
|
||||||
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
|
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
|
||||||
const dbPathPlaceholder = isMac
|
const dbPathPlaceholder = isMac
|
||||||
@@ -193,9 +194,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
|
|
||||||
// 检查 Hello 可用性
|
// 检查 Hello 可用性
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window.PublicKeyCredential) {
|
setHelloAvailable(isWindows)
|
||||||
void PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then(setHelloAvailable)
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 检查 HTTP API 服务状态
|
// 检查 HTTP API 服务状态
|
||||||
@@ -1061,9 +1060,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group quote-layout-group">
|
||||||
<label>引用样式</label>
|
<label>引用消息样式</label>
|
||||||
<span className="form-hint">选择聊天中引用消息与正文的上下顺序,右侧预览会同步展示布局差异。</span>
|
<span className="form-hint">选择聊天中引用消息与正文的上下顺序,下方预览会同步展示布局差异。</span>
|
||||||
<div className="quote-layout-picker" role="radiogroup" aria-label="引用样式选择">
|
<div className="quote-layout-picker" role="radiogroup" aria-label="引用样式选择">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
@@ -1080,15 +1079,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
}
|
}
|
||||||
].map(option => {
|
].map(option => {
|
||||||
const selected = quoteLayout === option.value
|
const selected = quoteLayout === option.value
|
||||||
const quotePreview = (
|
const isQuoteBottom = option.value === 'quote-bottom'
|
||||||
<div className="quote-layout-preview-quote">
|
|
||||||
<span className="quote-layout-preview-sender">张三</span>
|
|
||||||
<span className="quote-layout-preview-text">这是一条被引用的消息</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
const messagePreview = (
|
|
||||||
<div className="quote-layout-preview-message">这是当前发送的回复内容</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -1104,27 +1095,37 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
role="radio"
|
role="radio"
|
||||||
aria-checked={selected}
|
aria-checked={selected}
|
||||||
>
|
>
|
||||||
<div className="quote-layout-card-header">
|
<span className={`quote-layout-card-check ${selected ? 'active' : ''}`} aria-hidden="true" />
|
||||||
|
<div className="quote-layout-preview-shell">
|
||||||
|
<div className="quote-layout-preview-chat">
|
||||||
|
<div className="message-bubble sent">
|
||||||
|
<div className={`bubble-content ${isQuoteBottom ? 'quote-layout-bottom' : 'quote-layout-top'}`}>
|
||||||
|
{isQuoteBottom ? (
|
||||||
|
<>
|
||||||
|
<div className="message-text">拍得真不错!</div>
|
||||||
|
<div className="quoted-message">
|
||||||
|
<span className="quoted-sender">张三</span>
|
||||||
|
<span className="quoted-text">那天去爬山的照片...</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="quoted-message">
|
||||||
|
<span className="quoted-sender">张三</span>
|
||||||
|
<span className="quoted-text">那天去爬山的照片...</span>
|
||||||
|
</div>
|
||||||
|
<div className="message-text">拍得真不错!</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="quote-layout-card-footer">
|
||||||
<div className="quote-layout-card-title-group">
|
<div className="quote-layout-card-title-group">
|
||||||
<span className="quote-layout-card-title">{option.label}</span>
|
<span className="quote-layout-card-title">{option.label}</span>
|
||||||
<span className="quote-layout-card-desc">{option.description}</span>
|
<span className="quote-layout-card-desc">{option.description}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={`quote-layout-card-check ${selected ? 'active' : ''}`}>
|
|
||||||
<Check size={14} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className={`quote-layout-preview ${option.value}`}>
|
|
||||||
{option.value === 'quote-bottom' ? (
|
|
||||||
<>
|
|
||||||
{messagePreview}
|
|
||||||
{quotePreview}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{quotePreview}
|
|
||||||
{messagePreview}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@@ -2037,33 +2038,29 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
showMessage('请输入当前密码以开启 Hello', false)
|
showMessage('请输入当前密码以开启 Hello', false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!isWindows) {
|
||||||
|
showMessage('当前系统不支持 Windows Hello', false)
|
||||||
|
return
|
||||||
|
}
|
||||||
setIsSettingHello(true)
|
setIsSettingHello(true)
|
||||||
try {
|
try {
|
||||||
const challenge = new Uint8Array(32)
|
const verifyResult = await window.electronAPI.auth.hello('请验证您的身份以开启 Windows Hello')
|
||||||
window.crypto.getRandomValues(challenge)
|
if (!verifyResult.success) {
|
||||||
|
showMessage(verifyResult.error || 'Windows Hello 验证失败', false)
|
||||||
const credential = await navigator.credentials.create({
|
return
|
||||||
publicKey: {
|
}
|
||||||
challenge,
|
|
||||||
rp: { name: 'WeFlow', id: 'localhost' },
|
const saveResult = await window.electronAPI.auth.setHelloSecret(helloPassword)
|
||||||
user: { id: new Uint8Array([1]), name: 'user', displayName: 'User' },
|
if (!saveResult.success) {
|
||||||
pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
|
showMessage('Windows Hello 配置保存失败', false)
|
||||||
authenticatorSelection: { userVerification: 'required' },
|
return
|
||||||
timeout: 60000
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
if (credential) {
|
|
||||||
// 存储密码作为 Hello Secret,以便 Hello 解锁时能派生密钥
|
|
||||||
await window.electronAPI.auth.setHelloSecret(helloPassword)
|
|
||||||
setAuthUseHello(true)
|
setAuthUseHello(true)
|
||||||
setHelloPassword('')
|
setHelloPassword('')
|
||||||
showMessage('Windows Hello 设置成功', true)
|
showMessage('Windows Hello 设置成功', true)
|
||||||
}
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.name !== 'NotAllowedError') {
|
showMessage(`Windows Hello 设置失败: ${e?.message || String(e)}`, false)
|
||||||
showMessage(`Windows Hello 设置失败: ${e.message}`, false)
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSettingHello(false)
|
setIsSettingHello(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import './WelcomePage.scss'
|
|||||||
|
|
||||||
const isMac = navigator.userAgent.toLowerCase().includes('mac')
|
const isMac = navigator.userAgent.toLowerCase().includes('mac')
|
||||||
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
|
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
|
||||||
|
const isWindows = !isMac && !isLinux
|
||||||
|
|
||||||
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
|
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
|
||||||
const dbPathPlaceholder = isMac
|
const dbPathPlaceholder = isMac
|
||||||
@@ -46,6 +47,19 @@ const formatDbKeyFailureMessage = (error?: string, logs?: string[]): string => {
|
|||||||
return `${base};最近状态:${tailLogs.join(' | ')}`
|
return `${base};最近状态:${tailLogs.join(' | ')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeDbKeyStatusMessage = (message: string): string => {
|
||||||
|
if (isWindows && message.includes('Hook安装成功')) {
|
||||||
|
return '已准备就绪,现在登录微信或退出登录后重新登录微信'
|
||||||
|
}
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDbKeyReadyMessage = (message: string): boolean => (
|
||||||
|
message.includes('现在可以登录')
|
||||||
|
|| message.includes('Hook安装成功')
|
||||||
|
|| message.includes('已准备就绪,现在登录微信或退出登录后重新登录微信')
|
||||||
|
)
|
||||||
|
|
||||||
function WelcomePage({ standalone = false }: WelcomePageProps) {
|
function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { isDbConnected, setDbConnected, setLoading } = useAppStore()
|
const { isDbConnected, setDbConnected, setLoading } = useAppStore()
|
||||||
@@ -89,9 +103,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
|
|
||||||
// 检查 Hello 可用性
|
// 检查 Hello 可用性
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window.PublicKeyCredential) {
|
setHelloAvailable(isWindows)
|
||||||
void PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then(setHelloAvailable)
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
async function sha256(message: string) {
|
async function sha256(message: string) {
|
||||||
@@ -103,35 +115,27 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSetupHello = async () => {
|
const handleSetupHello = async () => {
|
||||||
|
if (!isWindows) {
|
||||||
|
setError('当前系统不支持 Windows Hello')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!authPassword || authPassword !== authConfirmPassword) {
|
||||||
|
setError('请先设置并确认应用密码,再开启 Windows Hello')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setIsSettingHello(true)
|
setIsSettingHello(true)
|
||||||
try {
|
try {
|
||||||
// 注册凭证 (WebAuthn)
|
const result = await window.electronAPI.auth.hello('请验证您的身份以开启 Windows Hello')
|
||||||
const challenge = new Uint8Array(32)
|
if (!result.success) {
|
||||||
window.crypto.getRandomValues(challenge)
|
setError(`Windows Hello 设置失败: ${result.error || '验证失败'}`)
|
||||||
|
return
|
||||||
const credential = await navigator.credentials.create({
|
|
||||||
publicKey: {
|
|
||||||
challenge,
|
|
||||||
rp: { name: 'WeFlow', id: 'localhost' },
|
|
||||||
user: {
|
|
||||||
id: new Uint8Array([1]),
|
|
||||||
name: 'user',
|
|
||||||
displayName: 'User'
|
|
||||||
},
|
|
||||||
pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
|
|
||||||
authenticatorSelection: { userVerification: 'required' },
|
|
||||||
timeout: 60000
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
if (credential) {
|
|
||||||
setEnableHello(true)
|
setEnableHello(true)
|
||||||
// 成功提示?
|
setError('')
|
||||||
}
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.name !== 'NotAllowedError') {
|
setError(`Windows Hello 设置失败: ${e?.message || String(e)}`)
|
||||||
setError('Windows Hello 设置失败: ' + e.message)
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSettingHello(false)
|
setIsSettingHello(false)
|
||||||
}
|
}
|
||||||
@@ -139,8 +143,9 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
|
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
|
||||||
setDbKeyStatus(payload.message)
|
const normalizedMessage = normalizeDbKeyStatusMessage(payload.message)
|
||||||
if (payload.message.includes('现在可以登录') || payload.message.includes('Hook安装成功')) {
|
setDbKeyStatus(normalizedMessage)
|
||||||
|
if (isDbKeyReadyMessage(normalizedMessage)) {
|
||||||
window.electronAPI.notification?.show({
|
window.electronAPI.notification?.show({
|
||||||
title: 'WeFlow 准备就绪',
|
title: 'WeFlow 准备就绪',
|
||||||
content: '现在可以登录微信了',
|
content: '现在可以登录微信了',
|
||||||
@@ -487,7 +492,17 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
const hash = await sha256(authPassword)
|
const hash = await sha256(authPassword)
|
||||||
await configService.setAuthEnabled(true)
|
await configService.setAuthEnabled(true)
|
||||||
await configService.setAuthPassword(hash)
|
await configService.setAuthPassword(hash)
|
||||||
await configService.setAuthUseHello(enableHello)
|
if (enableHello) {
|
||||||
|
const helloResult = await window.electronAPI.auth.setHelloSecret(authPassword)
|
||||||
|
if (!helloResult.success) {
|
||||||
|
setError('Windows Hello 配置保存失败')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await window.electronAPI.auth.clearHelloSecret()
|
||||||
|
await configService.setAuthUseHello(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await configService.setOnboardingDone(true)
|
await configService.setOnboardingDone(true)
|
||||||
@@ -761,8 +776,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dbKeyStatus && <div className={`status-message ${dbKeyStatus.includes('现在可以登录') || dbKeyStatus.includes('Hook安装成功') ? 'is-success' : ''}`}>{dbKeyStatus}</div>}
|
{dbKeyStatus && <div className={`status-message ${isDbKeyReadyMessage(dbKeyStatus) ? 'is-success' : ''}`}>{dbKeyStatus}</div>}
|
||||||
<div className="field-hint">点击自动获取后微信将重启,请留意弹窗提示</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -663,6 +663,9 @@ export interface ContactsListCacheContact {
|
|||||||
remark?: string
|
remark?: string
|
||||||
nickname?: string
|
nickname?: string
|
||||||
alias?: string
|
alias?: string
|
||||||
|
labels?: string[]
|
||||||
|
detailDescription?: string
|
||||||
|
region?: string
|
||||||
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1137,16 +1140,18 @@ export async function setSnsPageCache(
|
|||||||
export async function getContactsLoadTimeoutMs(): Promise<number> {
|
export async function getContactsLoadTimeoutMs(): Promise<number> {
|
||||||
const value = await config.get(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS)
|
const value = await config.get(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS)
|
||||||
if (typeof value === 'number' && Number.isFinite(value) && value >= 1000 && value <= 60000) {
|
if (typeof value === 'number' && Number.isFinite(value) && value >= 1000 && value <= 60000) {
|
||||||
return Math.floor(value)
|
const normalized = Math.floor(value)
|
||||||
|
// 兼容历史默认值 3000ms:自动提升到新的更稳妥阈值。
|
||||||
|
return normalized === 3000 ? 10000 : normalized
|
||||||
}
|
}
|
||||||
return 3000
|
return 10000
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置通讯录加载超时阈值(毫秒)
|
// 设置通讯录加载超时阈值(毫秒)
|
||||||
export async function setContactsLoadTimeoutMs(timeoutMs: number): Promise<void> {
|
export async function setContactsLoadTimeoutMs(timeoutMs: number): Promise<void> {
|
||||||
const normalized = Number.isFinite(timeoutMs)
|
const normalized = Number.isFinite(timeoutMs)
|
||||||
? Math.min(60000, Math.max(1000, Math.floor(timeoutMs)))
|
? Math.min(60000, Math.max(1000, Math.floor(timeoutMs)))
|
||||||
: 3000
|
: 10000
|
||||||
await config.set(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS, normalized)
|
await config.set(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS, normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1176,6 +1181,11 @@ export async function getContactsListCache(scopeKey: string): Promise<ContactsLi
|
|||||||
remark: typeof item.remark === 'string' ? item.remark : undefined,
|
remark: typeof item.remark === 'string' ? item.remark : undefined,
|
||||||
nickname: typeof item.nickname === 'string' ? item.nickname : undefined,
|
nickname: typeof item.nickname === 'string' ? item.nickname : undefined,
|
||||||
alias: typeof item.alias === 'string' ? item.alias : undefined,
|
alias: typeof item.alias === 'string' ? item.alias : undefined,
|
||||||
|
labels: Array.isArray(item.labels)
|
||||||
|
? Array.from(new Set(item.labels.map((label) => String(label || '').trim()).filter(Boolean)))
|
||||||
|
: undefined,
|
||||||
|
detailDescription: typeof item.detailDescription === 'string' ? (item.detailDescription.trim() || undefined) : undefined,
|
||||||
|
region: typeof item.region === 'string' ? (item.region.trim() || undefined) : undefined,
|
||||||
type: (type === 'friend' || type === 'group' || type === 'official' || type === 'former_friend' || type === 'other')
|
type: (type === 'friend' || type === 'group' || type === 'official' || type === 'former_friend' || type === 'other')
|
||||||
? type
|
? type
|
||||||
: 'other'
|
: 'other'
|
||||||
@@ -1210,6 +1220,11 @@ export async function setContactsListCache(scopeKey: string, contacts: ContactsL
|
|||||||
remark: contact?.remark ? String(contact.remark) : undefined,
|
remark: contact?.remark ? String(contact.remark) : undefined,
|
||||||
nickname: contact?.nickname ? String(contact.nickname) : undefined,
|
nickname: contact?.nickname ? String(contact.nickname) : undefined,
|
||||||
alias: contact?.alias ? String(contact.alias) : undefined,
|
alias: contact?.alias ? String(contact.alias) : undefined,
|
||||||
|
labels: Array.isArray(contact?.labels)
|
||||||
|
? Array.from(new Set(contact.labels.map((label) => String(label || '').trim()).filter(Boolean)))
|
||||||
|
: undefined,
|
||||||
|
detailDescription: contact?.detailDescription ? (String(contact.detailDescription).trim() || undefined) : undefined,
|
||||||
|
region: contact?.region ? (String(contact.region).trim() || undefined) : undefined,
|
||||||
type
|
type
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
2
src/types/electron.d.ts
vendored
2
src/types/electron.d.ts
vendored
@@ -219,7 +219,7 @@ export interface ElectronAPI {
|
|||||||
updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => Promise<{ success: boolean; error?: string }>
|
updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => Promise<{ success: boolean; error?: string }>
|
||||||
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) => Promise<{ success: boolean; error?: string }>
|
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) => Promise<{ success: boolean; error?: string }>
|
||||||
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => Promise<{ payerName: string; receiverName: string }>
|
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => Promise<{ payerName: string; receiverName: string }>
|
||||||
getContacts: () => Promise<{
|
getContacts: (options?: { lite?: boolean }) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
contacts?: ContactInfo[]
|
contacts?: ContactInfo[]
|
||||||
error?: string
|
error?: string
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export interface ContactInfo {
|
|||||||
remark?: string
|
remark?: string
|
||||||
nickname?: string
|
nickname?: string
|
||||||
alias?: string
|
alias?: string
|
||||||
|
labels?: string[]
|
||||||
|
detailDescription?: string
|
||||||
|
region?: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user