feat(contacts): 新增联系人列表与导出能力

This commit is contained in:
2977094657
2026-02-09 00:15:07 +08:00
parent 36d9af2b28
commit 62f396e55b
8 changed files with 1999 additions and 1 deletions

View File

@@ -30,7 +30,7 @@ onBeforeUnmount(() => {
}) })
const route = useRoute() const route = useRoute()
const isChatRoute = computed(() => route.path?.startsWith('/chat') || route.path?.startsWith('/sns')) const isChatRoute = computed(() => route.path?.startsWith('/chat') || route.path?.startsWith('/sns') || route.path?.startsWith('/contacts'))
const rootClass = computed(() => { const rootClass = computed(() => {
const base = 'bg-gradient-to-br from-green-50 via-emerald-50 to-green-100' const base = 'bg-gradient-to-br from-green-50 via-emerald-50 to-green-100'

View File

@@ -292,6 +292,7 @@ export const useApi = () => {
message_types: Array.isArray(data.message_types) ? data.message_types : [], message_types: Array.isArray(data.message_types) ? data.message_types : [],
include_media: data.include_media == null ? true : !!data.include_media, include_media: data.include_media == null ? true : !!data.include_media,
media_kinds: Array.isArray(data.media_kinds) ? data.media_kinds : ['image', 'emoji', 'video', 'video_thumb', 'voice', 'file'], media_kinds: Array.isArray(data.media_kinds) ? data.media_kinds : ['image', 'emoji', 'video', 'video_thumb', 'voice', 'file'],
output_dir: data.output_dir == null ? null : String(data.output_dir || '').trim(),
allow_process_key_extract: !!data.allow_process_key_extract, allow_process_key_extract: !!data.allow_process_key_extract,
privacy_mode: !!data.privacy_mode, privacy_mode: !!data.privacy_mode,
file_name: data.file_name || null file_name: data.file_name || null
@@ -313,6 +314,36 @@ export const useApi = () => {
return await request(`/chat/exports/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' }) return await request(`/chat/exports/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' })
} }
// 联系人
const listChatContacts = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.keyword) query.set('keyword', params.keyword)
if (params && params.include_friends != null) query.set('include_friends', String(!!params.include_friends))
if (params && params.include_groups != null) query.set('include_groups', String(!!params.include_groups))
if (params && params.include_officials != null) query.set('include_officials', String(!!params.include_officials))
const url = '/chat/contacts' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
const exportChatContacts = async (payload = {}) => {
return await request('/chat/contacts/export', {
method: 'POST',
body: {
account: payload.account || null,
output_dir: payload.output_dir || '',
format: payload.format || 'json',
include_avatar_link: payload.include_avatar_link == null ? true : !!payload.include_avatar_link,
keyword: payload.keyword || null,
contact_types: {
friends: payload?.contact_types?.friends == null ? true : !!payload.contact_types.friends,
groups: payload?.contact_types?.groups == null ? true : !!payload.contact_types.groups,
officials: payload?.contact_types?.officials == null ? true : !!payload.contact_types.officials,
}
}
})
}
// WeChat Wrapped年度总结 // WeChat Wrapped年度总结
const getWrappedAnnual = async (params = {}) => { const getWrappedAnnual = async (params = {}) => {
const query = new URLSearchParams() const query = new URLSearchParams()
@@ -373,6 +404,8 @@ export const useApi = () => {
getChatExport, getChatExport,
listChatExports, listChatExports,
cancelChatExport, cancelChatExport,
listChatContacts,
exportChatContacts,
getWrappedAnnual, getWrappedAnnual,
getWrappedAnnualMeta, getWrappedAnnualMeta,
getWrappedAnnualCard getWrappedAnnualCard

572
frontend/pages/contacts.vue Normal file
View File

@@ -0,0 +1,572 @@
<template>
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
<div class="border-r border-gray-200 flex flex-col" style="background-color: #e8e7e7; width: 60px; min-width: 60px; max-width: 60px">
<div class="flex-1 flex flex-col justify-start pt-0 gap-0">
<div class="w-full h-[60px] flex items-center justify-center">
<div class="w-[40px] h-[40px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0">
<img v-if="selfAvatarUrl" :src="selfAvatarUrl" alt="avatar" class="w-full h-full object-cover" />
<div v-else class="w-full h-full flex items-center justify-center text-white text-xs font-bold" style="background-color: #4B5563"></div>
</div>
</div>
<div class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group" title="聊天" @click="goChat">
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
<div class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="isChatRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'">
<svg class="w-full h-full" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 19.8C17.52 19.8 22 15.99 22 11.3C22 6.6 17.52 2.8 12 2.8C6.48 2.8 2 6.6 2 11.3C2 13.29 2.8 15.12 4.15 16.57C4.6 17.05 4.82 17.29 4.92 17.44C5.14 17.79 5.21 17.99 5.23 18.4C5.24 18.59 5.22 18.81 5.16 19.26C5.1 19.75 5.07 19.99 5.13 20.16C5.23 20.49 5.53 20.71 5.87 20.72C6.04 20.72 6.27 20.63 6.72 20.43L8.07 19.86C8.43 19.71 8.61 19.63 8.77 19.59C8.95 19.55 9.04 19.54 9.22 19.54C9.39 19.53 9.64 19.57 10.14 19.65C10.74 19.75 11.37 19.8 12 19.8Z" />
</svg>
</div>
</div>
</div>
<div class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group" title="朋友圈" @click="goSns">
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
<div class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="isSnsRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'">
<svg class="w-full h-full" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<line x1="14.31" y1="8" x2="20.05" y2="17.94" />
<line x1="9.69" y1="8" x2="21.17" y2="8" />
<line x1="7.38" y1="12" x2="13.12" y2="2.06" />
<line x1="9.69" y1="16" x2="3.95" y2="6.06" />
<line x1="14.31" y1="16" x2="2.83" y2="16" />
<line x1="16.62" y1="12" x2="10.88" y2="21.94" />
</svg>
</div>
</div>
</div>
<div class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group" title="联系人">
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
<div class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)] text-[#07b75b]">
<svg class="w-full h-full" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M17 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2" />
<circle cx="10" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
</div>
</div>
<div class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group" title="年度总结" @click="goWrapped">
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
<div class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="isWrappedRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'">
<svg class="w-full h-full" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="4" y="4" width="16" height="16" rx="2" />
<path d="M8 16v-5" />
<path d="M12 16v-8" />
<path d="M16 16v-3" />
</svg>
</div>
</div>
</div>
<div class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group" @click="privacyMode = !privacyMode" :title="privacyMode ? '关闭隐私模式' : '开启隐私模式'">
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="privacyMode ? 'text-[#07b75b]' : 'text-[#5d5d5d]'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path v-if="privacyMode" stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
<path v-else stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<circle v-if="!privacyMode" cx="12" cy="12" r="3" />
</svg>
</div>
</div>
</div>
</div>
<div class="flex-1 flex flex-col min-h-0" style="background-color: #EDEDED">
<DesktopTitleBar />
<div class="flex-1 min-h-0 overflow-hidden p-4">
<div class="h-full grid grid-cols-1 lg:grid-cols-[400px_minmax(0,1fr)] gap-4">
<div class="bg-white border border-gray-200 rounded-lg flex flex-col min-h-0 overflow-hidden">
<div class="p-3 border-b border-gray-200" style="background-color: #F7F7F7">
<div class="flex items-center gap-2">
<div class="contact-search-wrapper flex-1" :class="{ 'privacy-blur': privacyMode }">
<svg class="contact-search-icon" fill="none" stroke="currentColor" viewBox="0 0 16 16">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7.33333 12.6667C10.2789 12.6667 12.6667 10.2789 12.6667 7.33333C12.6667 4.38781 10.2789 2 7.33333 2C4.38781 2 2 4.38781 2 7.33333C2 10.2789 4.38781 12.6667 7.33333 12.6667Z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14 14L11.1 11.1" />
</svg>
<input v-model="searchKeyword" class="contact-search-input" type="text" placeholder="搜索联系人" />
<button v-if="searchKeyword" type="button" class="contact-search-clear" @click="searchKeyword = ''">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<select v-if="availableAccounts.length > 1" v-model="selectedAccount" class="account-select">
<option v-for="acc in availableAccounts" :key="acc" :value="acc">{{ acc }}</option>
</select>
</div>
</div>
<div class="px-3 py-2 border-b border-gray-200 bg-white flex items-center gap-4 text-sm">
<label class="flex items-center gap-2">
<input v-model="contactTypes.friends" type="checkbox" />
<span>好友 {{ counts.friends }}</span>
</label>
<label class="flex items-center gap-2">
<input v-model="contactTypes.groups" type="checkbox" />
<span>群聊 {{ counts.groups }}</span>
</label>
<label class="flex items-center gap-2">
<input v-model="contactTypes.officials" type="checkbox" />
<span>公众号 {{ counts.officials }}</span>
</label>
<span class="ml-auto text-gray-500">总计 {{ counts.total }}</span>
</div>
<div class="flex-1 min-h-0 overflow-auto">
<div v-if="loading" class="p-4 text-sm text-gray-500">加载中</div>
<div v-else-if="error" class="p-4 text-sm text-red-500 whitespace-pre-wrap">{{ error }}</div>
<div v-else-if="contacts.length === 0" class="p-4 text-sm text-gray-500">暂无联系人</div>
<div v-else>
<div
v-for="contact in contacts"
:key="contact.username"
class="px-3 py-2 border-b border-gray-100 flex items-center gap-3"
>
<div class="w-10 h-10 rounded-md overflow-hidden bg-gray-300 shrink-0" :class="{ 'privacy-blur': privacyMode }">
<img v-if="contact.avatar" :src="contact.avatar" :alt="contact.displayName" class="w-full h-full object-cover" referrerpolicy="no-referrer" />
<div v-else class="w-full h-full flex items-center justify-center text-white text-xs font-bold" style="background-color:#4B5563">{{ contact.displayName?.charAt(0) || '?' }}</div>
</div>
<div class="min-w-0 flex-1" :class="{ 'privacy-blur': privacyMode }">
<div class="text-sm text-gray-900 truncate">{{ contact.displayName }}</div>
<div class="text-xs text-gray-500 truncate">{{ contact.username }}</div>
<div class="text-[11px] text-gray-500 truncate" v-if="contact.type !== 'group' && (contact.region || contact.source)">
<span v-if="contact.region">地区{{ contact.region }}</span>
<span v-if="contact.region && contact.source"> · </span>
<span
v-if="contact.source"
:title="contact.sourceScene != null ? `来源场景码:${contact.sourceScene}` : ''"
>来源{{ contact.source }}</span>
</div>
</div>
<div class="text-xs px-2 py-0.5 rounded" :class="typeBadgeClass(contact.type)">
{{ typeLabel(contact.type) }}
</div>
</div>
</div>
</div>
</div>
<div class="bg-white border border-gray-200 rounded-lg p-4 flex flex-col gap-3">
<div>
<div class="text-base font-medium text-gray-900">导出联系人</div>
<div class="text-xs text-gray-500 mt-1">支持 JSON / CSV默认包含头像链接</div>
</div>
<div class="space-y-2 text-sm">
<div class="font-medium text-gray-800">导出格式</div>
<label class="flex items-center gap-2"><input v-model="exportFormat" type="radio" value="json" /> JSON</label>
<label class="flex items-center gap-2"><input v-model="exportFormat" type="radio" value="csv" /> CSV (Excel)</label>
</div>
<div class="space-y-2 text-sm">
<div class="font-medium text-gray-800">导出类型多选</div>
<label class="flex items-center gap-2"><input v-model="exportTypes.friends" type="checkbox" /> 好友</label>
<label class="flex items-center gap-2"><input v-model="exportTypes.groups" type="checkbox" /> 群聊</label>
<label class="flex items-center gap-2"><input v-model="exportTypes.officials" type="checkbox" /> 公众号</label>
</div>
<label class="flex items-center gap-2 text-sm">
<input v-model="includeAvatarLink" type="checkbox" />
导出头像链接
</label>
<div class="space-y-2 text-sm">
<div class="font-medium text-gray-800">导出目录</div>
<div class="px-2 py-2 rounded border border-gray-200 bg-gray-50 text-xs break-all min-h-[38px]">{{ exportFolder || '未选择' }}</div>
<button type="button" class="w-full px-3 py-2 rounded border border-gray-200 hover:bg-gray-50" @click="chooseExportFolder">选择文件夹</button>
</div>
<button
type="button"
class="mt-2 w-full px-3 py-2 rounded text-white"
:class="canExport && !exporting ? 'bg-[#03C160] hover:bg-[#02ad56]' : 'bg-gray-300 cursor-not-allowed'"
:disabled="!canExport || exporting"
@click="startExport"
>
{{ exporting ? '导出中…' : '开始导出' }}
</button>
<div v-if="exportMsg" class="text-xs whitespace-pre-wrap" :class="exportOk ? 'text-green-600' : 'text-red-500'">{{ exportMsg }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
useHead({ title: '联系人 - 微信数据分析助手' })
const route = useRoute()
const api = useApi()
const isChatRoute = computed(() => route.path?.startsWith('/chat'))
const isSnsRoute = computed(() => route.path?.startsWith('/sns'))
const isWrappedRoute = computed(() => route.path?.startsWith('/wrapped'))
const PRIVACY_MODE_KEY = 'ui.privacy_mode'
const privacyMode = ref(false)
onMounted(() => {
if (!process.client) return
try {
privacyMode.value = localStorage.getItem(PRIVACY_MODE_KEY) === '1'
} catch {}
})
watch(() => privacyMode.value, (v) => {
if (!process.client) return
try {
localStorage.setItem(PRIVACY_MODE_KEY, v ? '1' : '0')
} catch {}
})
const sidebarMediaBase = process.client ? 'http://localhost:8000' : ''
const availableAccounts = ref([])
const selectedAccount = ref(null)
const searchKeyword = ref('')
const contactTypes = reactive({
friends: true,
groups: true,
officials: true,
})
const contacts = ref([])
const counts = reactive({
friends: 0,
groups: 0,
officials: 0,
total: 0,
})
const loading = ref(false)
const error = ref('')
const exportFormat = ref('json')
const includeAvatarLink = ref(true)
const exportTypes = reactive({
friends: true,
groups: true,
officials: true,
})
const exportFolder = ref('')
const exportFolderHandle = ref(null)
const exporting = ref(false)
const exportMsg = ref('')
const exportOk = ref(false)
const selfAvatarUrl = computed(() => {
const acc = String(selectedAccount.value || '').trim()
if (!acc) return ''
return `${sidebarMediaBase}/api/chat/avatar?account=${encodeURIComponent(acc)}&username=${encodeURIComponent(acc)}`
})
const typeLabel = (type) => {
if (type === 'friend') return '好友'
if (type === 'group') return '群聊'
if (type === 'official') return '公众号'
return '其他'
}
const typeBadgeClass = (type) => {
if (type === 'friend') return 'bg-blue-100 text-blue-700'
if (type === 'group') return 'bg-green-100 text-green-700'
if (type === 'official') return 'bg-orange-100 text-orange-700'
return 'bg-gray-100 text-gray-600'
}
const goChat = async () => {
await navigateTo('/chat')
}
const goSns = async () => {
await navigateTo('/sns')
}
const goWrapped = async () => {
await navigateTo('/wrapped')
}
const isDesktopExportRuntime = () => {
return !!(process.client && window?.wechatDesktop?.chooseDirectory)
}
const isWebDirectoryPickerSupported = () => {
return !!(process.client && typeof window.showDirectoryPicker === 'function')
}
const canExport = computed(() => {
const hasExportTarget = isDesktopExportRuntime()
? !!exportFolder.value
: !!exportFolderHandle.value
return !!selectedAccount.value && hasExportTarget && (exportTypes.friends || exportTypes.groups || exportTypes.officials)
})
const safeExportPart = (value) => {
const cleaned = String(value || '').trim().replace(/[^0-9A-Za-z._-]+/g, '_').replace(/^[._-]+|[._-]+$/g, '')
return cleaned || 'account'
}
const buildExportTimestamp = () => {
const now = new Date()
const pad = (n) => String(n).padStart(2, '0')
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
}
const escapeCsvCell = (value) => {
const text = String(value == null ? '' : value)
if (/[",\n\r]/.test(text)) return `"${text.replace(/"/g, '""')}"`
return text
}
const buildExportContactsPayload = async () => {
const resp = await api.listChatContacts({
account: selectedAccount.value,
keyword: searchKeyword.value || '',
include_friends: exportTypes.friends,
include_groups: exportTypes.groups,
include_officials: exportTypes.officials,
})
const contactsList = Array.isArray(resp?.contacts) ? resp.contacts : []
const exportContacts = contactsList.map((item) => {
const row = {
username: String(item?.username || ''),
displayName: String(item?.displayName || ''),
remark: String(item?.remark || ''),
nickname: String(item?.nickname || ''),
alias: String(item?.alias || ''),
type: String(item?.type || ''),
region: String(item?.region || ''),
country: String(item?.country || ''),
province: String(item?.province || ''),
city: String(item?.city || ''),
source: String(item?.source || ''),
sourceScene: item?.sourceScene == null ? '' : String(item?.sourceScene),
}
if (includeAvatarLink.value) {
row.avatarLink = String(item?.avatarLink || '')
}
return row
})
return {
account: String(selectedAccount.value || ''),
count: exportContacts.length,
contacts: exportContacts,
}
}
const writeWebExportFile = async ({ fileName, content }) => {
if (!exportFolderHandle.value || typeof exportFolderHandle.value.getFileHandle !== 'function') {
throw new Error('未选择浏览器导出目录')
}
const fileHandle = await exportFolderHandle.value.getFileHandle(fileName, { create: true })
const writable = await fileHandle.createWritable()
await writable.write(content)
await writable.close()
}
const exportContactsInWeb = async () => {
const fmt = String(exportFormat.value || 'json').toLowerCase()
if (fmt !== 'json' && fmt !== 'csv') {
throw new Error('网页端仅支持 JSON/CSV 导出')
}
if (!exportFolderHandle.value) {
throw new Error('请先选择导出目录')
}
const payload = await buildExportContactsPayload()
const fileName = `contacts_${safeExportPart(payload.account)}_${buildExportTimestamp()}.${fmt}`
if (fmt === 'json') {
const jsonPayload = {
exportedAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
account: payload.account,
count: payload.count,
filters: {
keyword: String(searchKeyword.value || ''),
contactTypes: {
friends: !!exportTypes.friends,
groups: !!exportTypes.groups,
officials: !!exportTypes.officials,
},
includeAvatarLink: !!includeAvatarLink.value,
},
contacts: payload.contacts,
}
await writeWebExportFile({ fileName, content: JSON.stringify(jsonPayload, null, 2) })
} else {
const columns = [
['username', '用户名'],
['displayName', '显示名称'],
['remark', '备注'],
['nickname', '昵称'],
['alias', '微信号'],
['type', '类型'],
['region', '地区'],
['country', '国家/地区码'],
['province', '省份'],
['city', '城市'],
['source', '来源'],
['sourceScene', '来源场景码'],
]
if (includeAvatarLink.value) {
columns.push(['avatarLink', '头像链接'])
}
const lines = [columns.map(([, label]) => escapeCsvCell(label)).join(',')]
for (const row of payload.contacts) {
lines.push(columns.map(([key]) => escapeCsvCell(row[key])).join(','))
}
await writeWebExportFile({ fileName, content: `\uFEFF${lines.join('\n')}` })
}
return {
count: payload.count,
outputPath: `${exportFolder.value}/${fileName}`,
}
}
const loadAccounts = async () => {
try {
const resp = await api.listChatAccounts()
const accounts = resp?.accounts || []
availableAccounts.value = accounts
selectedAccount.value = selectedAccount.value || resp?.default_account || accounts[0] || null
} catch (e) {
availableAccounts.value = []
selectedAccount.value = null
}
}
const loadContacts = async () => {
if (!selectedAccount.value) {
contacts.value = []
counts.friends = 0
counts.groups = 0
counts.officials = 0
counts.total = 0
return
}
loading.value = true
error.value = ''
try {
const resp = await api.listChatContacts({
account: selectedAccount.value,
keyword: searchKeyword.value || '',
include_friends: contactTypes.friends,
include_groups: contactTypes.groups,
include_officials: contactTypes.officials,
})
contacts.value = Array.isArray(resp?.contacts) ? resp.contacts : []
counts.friends = Number(resp?.counts?.friends || 0)
counts.groups = Number(resp?.counts?.groups || 0)
counts.officials = Number(resp?.counts?.officials || 0)
counts.total = Number(resp?.counts?.total || contacts.value.length)
} catch (e) {
contacts.value = []
error.value = e?.message || '加载联系人失败'
} finally {
loading.value = false
}
}
let keywordTimer = null
watch(() => searchKeyword.value, () => {
if (keywordTimer) clearTimeout(keywordTimer)
keywordTimer = setTimeout(() => {
void loadContacts()
}, 250)
})
watch(() => [selectedAccount.value, contactTypes.friends, contactTypes.groups, contactTypes.officials], () => {
void loadContacts()
})
const chooseExportFolder = async () => {
exportMsg.value = ''
exportOk.value = false
try {
if (!process.client) {
exportMsg.value = '当前环境不支持选择导出目录'
return
}
if (isDesktopExportRuntime()) {
const result = await window.wechatDesktop.chooseDirectory({ title: '选择导出目录' })
if (result && !result.canceled && Array.isArray(result.filePaths) && result.filePaths.length > 0) {
exportFolder.value = String(result.filePaths[0] || '')
exportFolderHandle.value = null
}
return
}
if (isWebDirectoryPickerSupported()) {
const handle = await window.showDirectoryPicker()
if (handle) {
exportFolderHandle.value = handle
exportFolder.value = `浏览器目录:${String(handle.name || '已选择')}`
}
return
}
exportMsg.value = '当前浏览器不支持目录选择,请使用桌面端或 Chromium 新版浏览器'
} catch (e) {
exportMsg.value = e?.message || '选择文件夹失败'
exportOk.value = false
}
}
const startExport = async () => {
exportMsg.value = ''
exportOk.value = false
if (!canExport.value) {
exportMsg.value = '请先选择账号、导出目录,并至少勾选一种联系人类型'
return
}
exporting.value = true
try {
const resp = isDesktopExportRuntime()
? await api.exportChatContacts({
account: selectedAccount.value,
output_dir: exportFolder.value,
format: exportFormat.value,
include_avatar_link: includeAvatarLink.value,
keyword: searchKeyword.value || '',
contact_types: {
friends: exportTypes.friends,
groups: exportTypes.groups,
officials: exportTypes.officials,
}
})
: await exportContactsInWeb()
exportOk.value = true
exportMsg.value = `导出成功:${resp?.outputPath || ''}\n共 ${Number(resp?.count || 0)} 个联系人`
} catch (e) {
exportOk.value = false
exportMsg.value = e?.message || '导出失败'
} finally {
exporting.value = false
}
}
onMounted(async () => {
await loadAccounts()
await loadContacts()
})
</script>
<style scoped>
.privacy-blur {
filter: blur(9px);
transition: filter 0.2s ease;
}
.privacy-blur:hover {
filter: none;
}
</style>

View File

@@ -68,6 +68,26 @@
</div> </div>
</div> </div>
<!-- 联系人图标 -->
<div
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
title="联系人"
@click="goContacts"
>
<div
class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]"
>
<div class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="isContactsRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'">
<svg class="w-full h-full" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M17 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2" />
<circle cx="10" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
</div>
</div>
<!-- 年度总结图标 --> <!-- 年度总结图标 -->
<div <div
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group" class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
@@ -411,6 +431,7 @@ const route = useRoute()
const isChatRoute = computed(() => route.path?.startsWith('/chat')) const isChatRoute = computed(() => route.path?.startsWith('/chat'))
const isSnsRoute = computed(() => route.path?.startsWith('/sns')) const isSnsRoute = computed(() => route.path?.startsWith('/sns'))
const isContactsRoute = computed(() => route.path?.startsWith('/contacts'))
const isWrappedRoute = computed(() => route.path?.startsWith('/wrapped')) const isWrappedRoute = computed(() => route.path?.startsWith('/wrapped'))
// 隐私模式(聊天/朋友圈共用本地开关) // 隐私模式(聊天/朋友圈共用本地开关)
@@ -1051,6 +1072,10 @@ const goSns = async () => {
await navigateTo('/sns') await navigateTo('/sns')
} }
const goContacts = async () => {
await navigateTo('/contacts')
}
const goWrapped = async () => { const goWrapped = async () => {
await navigateTo('/wrapped') await navigateTo('/wrapped')
} }

View File

@@ -13,6 +13,7 @@ from .logging_config import setup_logging, get_logger
from .path_fix import PathFixRoute from .path_fix import PathFixRoute
from .chat_realtime_autosync import CHAT_REALTIME_AUTOSYNC from .chat_realtime_autosync import CHAT_REALTIME_AUTOSYNC
from .routers.chat import router as _chat_router from .routers.chat import router as _chat_router
from .routers.chat_contacts import router as _chat_contacts_router
from .routers.chat_export import router as _chat_export_router from .routers.chat_export import router as _chat_export_router
from .routers.chat_media import router as _chat_media_router from .routers.chat_media import router as _chat_media_router
from .routers.decrypt import router as _decrypt_router from .routers.decrypt import router as _decrypt_router
@@ -52,6 +53,7 @@ app.include_router(_decrypt_router)
app.include_router(_keys_router) app.include_router(_keys_router)
app.include_router(_media_router) app.include_router(_media_router)
app.include_router(_chat_router) app.include_router(_chat_router)
app.include_router(_chat_contacts_router)
app.include_router(_chat_export_router) app.include_router(_chat_export_router)
app.include_router(_chat_media_router) app.include_router(_chat_media_router)
app.include_router(_sns_router) app.include_router(_sns_router)

View File

@@ -0,0 +1,749 @@
import csv
import json
import re
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Literal, Optional
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from ..chat_helpers import (
_build_avatar_url,
_pick_avatar_url,
_pick_display_name,
_resolve_account_dir,
_should_keep_session,
)
from ..path_fix import PathFixRoute
router = APIRouter(route_class=PathFixRoute)
_SYSTEM_USERNAMES = {
"filehelper",
"fmessage",
"floatbottle",
"medianote",
"newsapp",
"qmessage",
"qqmail",
"tmessage",
"brandsessionholder",
"brandservicesessionholder",
"notifymessage",
"opencustomerservicemsg",
"notification_messages",
"userexperience_alarm",
}
_SOURCE_SCENE_LABELS = {
1: "通过QQ号添加",
3: "通过微信号添加",
6: "通过手机号添加",
10: "通过名片添加",
14: "通过群聊添加",
30: "通过扫一扫添加",
}
_COUNTRY_LABELS = {
"CN": "中国大陆",
}
class ContactTypeFilter(BaseModel):
friends: bool = True
groups: bool = True
officials: bool = True
class ContactExportRequest(BaseModel):
account: Optional[str] = Field(None, description="账号目录名(可选,默认使用第一个)")
output_dir: str = Field(..., description="导出目录绝对路径")
format: str = Field("json", description="导出格式,仅支持 json/csv")
include_avatar_link: bool = Field(True, description="是否导出 avatarLink 字段")
contact_types: ContactTypeFilter = Field(default_factory=ContactTypeFilter)
keyword: Optional[str] = Field(None, description="关键词筛选(可选)")
def _normalize_text(v: Any) -> str:
if v is None:
return ""
return str(v).strip()
def _to_int(v: Any) -> int:
try:
return int(v or 0)
except Exception:
return 0
def _to_optional_int(v: Any) -> Optional[int]:
if v is None:
return None
if isinstance(v, bool):
return int(v)
if isinstance(v, int):
return v
s = _normalize_text(v)
if not s:
return None
try:
return int(s)
except Exception:
return None
def _decode_varint(raw: bytes, offset: int) -> tuple[Optional[int], int]:
value = 0
shift = 0
pos = int(offset)
n = len(raw)
while pos < n:
byte = raw[pos]
pos += 1
value |= (byte & 0x7F) << shift
if (byte & 0x80) == 0:
return value, pos
shift += 7
if shift > 63:
return None, n
return None, n
def _decode_proto_text(raw: bytes) -> str:
if not raw:
return ""
try:
text = raw.decode("utf-8", errors="ignore")
except Exception:
return ""
return re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", text).strip()
def _parse_contact_extra_buffer(extra_buffer: Any) -> dict[str, Any]:
out = {
"signature": "",
"country": "",
"province": "",
"city": "",
"source_scene": None,
}
if extra_buffer is None:
return out
raw: bytes
if isinstance(extra_buffer, memoryview):
raw = extra_buffer.tobytes()
elif isinstance(extra_buffer, (bytes, bytearray)):
raw = bytes(extra_buffer)
else:
return out
if not raw:
return out
idx = 0
n = len(raw)
while idx < n:
tag, idx_next = _decode_varint(raw, idx)
if tag is None:
break
idx = idx_next
field_no = tag >> 3
wire_type = tag & 0x7
if wire_type == 0:
val, idx_next = _decode_varint(raw, idx)
if val is None:
break
idx = idx_next
if field_no == 8:
out["source_scene"] = int(val)
continue
if wire_type == 2:
size, idx_next = _decode_varint(raw, idx)
if size is None:
break
idx = idx_next
end = idx + int(size)
if end > n:
break
chunk = raw[idx:end]
idx = end
if field_no in {4, 5, 6, 7}:
text = _decode_proto_text(chunk)
if field_no == 4:
out["signature"] = text
elif field_no == 5:
out["country"] = text
elif field_no == 6:
out["province"] = text
elif field_no == 7:
out["city"] = text
continue
if wire_type == 1:
idx += 8
continue
if wire_type == 5:
idx += 4
continue
break
return out
def _country_label(country: str) -> str:
c = _normalize_text(country)
if not c:
return ""
return _COUNTRY_LABELS.get(c.upper(), c)
def _source_scene_label(source_scene: Optional[int]) -> str:
if source_scene is None:
return ""
if source_scene in _SOURCE_SCENE_LABELS:
return _SOURCE_SCENE_LABELS[source_scene]
return f"场景码 {source_scene}"
def _build_region(country: str, province: str, city: str) -> str:
parts: list[str] = []
country_text = _country_label(country)
province_text = _normalize_text(province)
city_text = _normalize_text(city)
if country_text:
parts.append(country_text)
if province_text:
parts.append(province_text)
if city_text:
parts.append(city_text)
return "·".join(parts)
def _safe_export_part(s: str) -> str:
cleaned = re.sub(r"[^0-9A-Za-z._-]+", "_", str(s or "").strip())
cleaned = cleaned.strip("._-")
return cleaned or "account"
def _is_valid_contact_username(username: str) -> bool:
u = _normalize_text(username)
if not u:
return False
if u in _SYSTEM_USERNAMES:
return False
if u.startswith("fake_"):
return False
if not _should_keep_session(u, include_official=True) and not u.startswith("gh_") and u != "weixin":
return False
return True
def _get_table_columns(conn: sqlite3.Connection, table: str) -> set[str]:
try:
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
except Exception:
return set()
out: set[str] = set()
for row in rows:
try:
name = _normalize_text(row["name"] if "name" in row.keys() else row[1]).lower()
except Exception:
continue
if name:
out.add(name)
return out
def _build_contact_select_sql(table: str, columns: set[str]) -> Optional[str]:
if "username" not in columns:
return None
specs: list[tuple[str, str, str]] = [
("username", "username", "''"),
("remark", "remark", "''"),
("nick_name", "nick_name", "''"),
("alias", "alias", "''"),
("local_type", "local_type", "0"),
("verify_flag", "verify_flag", "0"),
("big_head_url", "big_head_url", "''"),
("small_head_url", "small_head_url", "''"),
("extra_buffer", "extra_buffer", "x''"),
]
select_parts: list[str] = []
for key, alias, fallback in specs:
if key in columns:
select_parts.append(key)
else:
select_parts.append(f"{fallback} AS {alias}")
return f"SELECT {', '.join(select_parts)} FROM {table}"
def _load_contact_rows_map(contact_db_path: Path) -> dict[str, dict[str, Any]]:
out: dict[str, dict[str, Any]] = {}
if not contact_db_path.exists():
return out
conn = sqlite3.connect(str(contact_db_path))
conn.row_factory = sqlite3.Row
try:
def read_rows(table: str) -> list[sqlite3.Row]:
columns = _get_table_columns(conn, table)
sql = _build_contact_select_sql(table, columns)
if not sql:
return []
try:
return conn.execute(sql).fetchall()
except Exception:
return []
return []
for table in ("contact", "stranger"):
rows = read_rows(table)
for row in rows:
username = _normalize_text(row["username"] if "username" in row.keys() else "")
if (not username) or (username in out):
continue
extra_info = _parse_contact_extra_buffer(
row["extra_buffer"] if "extra_buffer" in row.keys() else b""
)
out[username] = {
"username": username,
"remark": _normalize_text(row["remark"] if "remark" in row.keys() else ""),
"nick_name": _normalize_text(row["nick_name"] if "nick_name" in row.keys() else ""),
"alias": _normalize_text(row["alias"] if "alias" in row.keys() else ""),
"local_type": _to_int(row["local_type"] if "local_type" in row.keys() else 0),
"verify_flag": _to_int(row["verify_flag"] if "verify_flag" in row.keys() else 0),
"big_head_url": _normalize_text(row["big_head_url"] if "big_head_url" in row.keys() else ""),
"small_head_url": _normalize_text(row["small_head_url"] if "small_head_url" in row.keys() else ""),
"country": _normalize_text(extra_info.get("country")),
"province": _normalize_text(extra_info.get("province")),
"city": _normalize_text(extra_info.get("city")),
"source_scene": _to_optional_int(extra_info.get("source_scene")),
}
return out
finally:
conn.close()
def _load_session_sort_timestamps(session_db_path: Path) -> dict[str, int]:
out: dict[str, int] = {}
if not session_db_path.exists():
return out
conn = sqlite3.connect(str(session_db_path))
conn.row_factory = sqlite3.Row
try:
rows: list[sqlite3.Row] = []
queries = [
"SELECT username, COALESCE(sort_timestamp, 0) AS ts FROM SessionTable",
"SELECT username, COALESCE(last_timestamp, 0) AS ts FROM SessionTable",
]
for sql in queries:
try:
rows = conn.execute(sql).fetchall()
break
except Exception:
continue
for row in rows:
username = _normalize_text(row["username"] if "username" in row.keys() else "")
if not username:
continue
ts = _to_int(row["ts"] if "ts" in row.keys() else 0)
prev = out.get(username, 0)
if ts > prev:
out[username] = ts
return out
finally:
conn.close()
def _load_session_group_usernames(session_db_path: Path) -> set[str]:
out: set[str] = set()
if not session_db_path.exists():
return out
conn = sqlite3.connect(str(session_db_path))
conn.row_factory = sqlite3.Row
try:
queries = [
"SELECT username FROM SessionTable",
"SELECT username FROM sessiontable",
]
for sql in queries:
try:
rows = conn.execute(sql).fetchall()
except Exception:
continue
for row in rows:
username = _normalize_text(row["username"] if "username" in row.keys() else "")
if username and ("@chatroom" in username):
out.add(username)
return out
return out
finally:
conn.close()
def _infer_contact_type(username: str, row: dict[str, Any]) -> Optional[str]:
if not username:
return None
if "@chatroom" in username:
return "group"
verify_flag = _to_int(row.get("verify_flag"))
if username.startswith("gh_") or verify_flag != 0:
return "official"
local_type = _to_int(row.get("local_type"))
if local_type == 1:
return "friend"
return None
def _matches_keyword(contact: dict[str, Any], keyword: str) -> bool:
kw = _normalize_text(keyword).lower()
if not kw:
return True
fields = [
contact.get("username", ""),
contact.get("displayName", ""),
contact.get("remark", ""),
contact.get("nickname", ""),
contact.get("alias", ""),
contact.get("region", ""),
contact.get("source", ""),
contact.get("country", ""),
contact.get("province", ""),
contact.get("city", ""),
]
for field in fields:
if kw in _normalize_text(field).lower():
return True
return False
def _collect_contacts_for_account(
*,
account_dir: Path,
base_url: str,
keyword: Optional[str],
include_friends: bool,
include_groups: bool,
include_officials: bool,
) -> list[dict[str, Any]]:
if not (include_friends or include_groups or include_officials):
return []
contact_db_path = account_dir / "contact.db"
session_db_path = account_dir / "session.db"
contact_rows = _load_contact_rows_map(contact_db_path)
session_ts_map = _load_session_sort_timestamps(session_db_path)
session_group_usernames = _load_session_group_usernames(session_db_path)
contacts: list[dict[str, Any]] = []
for username, row in contact_rows.items():
if not _is_valid_contact_username(username):
continue
contact_type = _infer_contact_type(username, row)
if contact_type is None:
continue
if contact_type == "friend" and not include_friends:
continue
if contact_type == "group" and not include_groups:
continue
if contact_type == "official" and not include_officials:
continue
display_name = _pick_display_name(row, username)
if not display_name:
display_name = username
avatar_link = _normalize_text(_pick_avatar_url(row) or "")
avatar = base_url + _build_avatar_url(account_dir.name, username)
country = _normalize_text(row.get("country"))
province = _normalize_text(row.get("province"))
city = _normalize_text(row.get("city"))
source_scene = _to_optional_int(row.get("source_scene"))
item = {
"username": username,
"displayName": display_name,
"remark": _normalize_text(row.get("remark")),
"nickname": _normalize_text(row.get("nick_name")),
"alias": _normalize_text(row.get("alias")),
"type": contact_type,
"country": country,
"province": province,
"city": city,
"region": _build_region(country, province, city),
"sourceScene": source_scene,
"source": _source_scene_label(source_scene),
"avatar": avatar,
"avatarLink": avatar_link,
"_sortTs": _to_int(session_ts_map.get(username, 0)),
}
if not _matches_keyword(item, keyword or ""):
continue
contacts.append(item)
if include_groups:
for username in session_group_usernames:
if username in contact_rows:
continue
if not _is_valid_contact_username(username):
continue
avatar_link = ""
avatar = base_url + _build_avatar_url(account_dir.name, username)
item = {
"username": username,
"displayName": username,
"remark": "",
"nickname": "",
"alias": "",
"type": "group",
"country": "",
"province": "",
"city": "",
"region": "",
"sourceScene": None,
"source": "",
"avatar": avatar,
"avatarLink": avatar_link,
"_sortTs": _to_int(session_ts_map.get(username, 0)),
}
if not _matches_keyword(item, keyword or ""):
continue
contacts.append(item)
contacts.sort(
key=lambda x: (
-_to_int(x.get("_sortTs", 0)),
_normalize_text(x.get("displayName", "")).lower(),
_normalize_text(x.get("username", "")).lower(),
)
)
for item in contacts:
item.pop("_sortTs", None)
return contacts
def _build_counts(contacts: list[dict[str, Any]]) -> dict[str, int]:
counts = {
"friends": 0,
"groups": 0,
"officials": 0,
"total": 0,
}
for item in contacts:
t = _normalize_text(item.get("type"))
if t == "friend":
counts["friends"] += 1
elif t == "group":
counts["groups"] += 1
elif t == "official":
counts["officials"] += 1
counts["total"] = len(contacts)
return counts
def _build_export_contacts(
contacts: list[dict[str, Any]],
*,
include_avatar_link: bool,
) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for item in contacts:
row = {
"username": _normalize_text(item.get("username")),
"displayName": _normalize_text(item.get("displayName")),
"remark": _normalize_text(item.get("remark")),
"nickname": _normalize_text(item.get("nickname")),
"alias": _normalize_text(item.get("alias")),
"type": _normalize_text(item.get("type")),
"region": _normalize_text(item.get("region")),
"country": _normalize_text(item.get("country")),
"province": _normalize_text(item.get("province")),
"city": _normalize_text(item.get("city")),
"source": _normalize_text(item.get("source")),
"sourceScene": _to_optional_int(item.get("sourceScene")),
}
if include_avatar_link:
row["avatarLink"] = _normalize_text(item.get("avatarLink"))
out.append(row)
return out
def _write_json_export(
output_path: Path,
*,
account: str,
contacts: list[dict[str, Any]],
include_avatar_link: bool,
keyword: str,
contact_types: ContactTypeFilter,
) -> None:
payload = {
"exportedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"account": account,
"count": len(contacts),
"filters": {
"keyword": keyword,
"contactTypes": {
"friends": bool(contact_types.friends),
"groups": bool(contact_types.groups),
"officials": bool(contact_types.officials),
},
"includeAvatarLink": bool(include_avatar_link),
},
"contacts": contacts,
}
output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def _write_csv_export(
output_path: Path,
*,
contacts: list[dict[str, Any]],
include_avatar_link: bool,
) -> None:
columns: list[tuple[str, str]] = [
("username", "用户名"),
("displayName", "显示名称"),
("remark", "备注"),
("nickname", "昵称"),
("alias", "微信号"),
("type", "类型"),
("region", "地区"),
("country", "国家/地区码"),
("province", "省份"),
("city", "城市"),
("source", "来源"),
("sourceScene", "来源场景码"),
]
if include_avatar_link:
columns.append(("avatarLink", "头像链接"))
with output_path.open("w", encoding="utf-8-sig", newline="") as f:
writer = csv.writer(f)
writer.writerow([label for _, label in columns])
for item in contacts:
writer.writerow([_normalize_text(item.get(key, "")) for key, _ in columns])
@router.get("/api/chat/contacts", summary="获取联系人列表")
def list_chat_contacts(
request: Request,
account: Optional[str] = None,
keyword: Optional[str] = None,
include_friends: bool = True,
include_groups: bool = True,
include_officials: bool = True,
):
account_dir = _resolve_account_dir(account)
base_url = str(request.base_url).rstrip("/")
contacts = _collect_contacts_for_account(
account_dir=account_dir,
base_url=base_url,
keyword=keyword,
include_friends=bool(include_friends),
include_groups=bool(include_groups),
include_officials=bool(include_officials),
)
return {
"status": "success",
"account": account_dir.name,
"total": len(contacts),
"counts": _build_counts(contacts),
"contacts": contacts,
}
@router.post("/api/chat/contacts/export", summary="导出联系人")
def export_chat_contacts(request: Request, req: ContactExportRequest):
account_dir = _resolve_account_dir(req.account)
output_dir_raw = _normalize_text(req.output_dir)
if not output_dir_raw:
raise HTTPException(status_code=400, detail="output_dir is required.")
output_dir = Path(output_dir_raw).expanduser()
if not output_dir.is_absolute():
raise HTTPException(status_code=400, detail="output_dir must be an absolute path.")
try:
output_dir.mkdir(parents=True, exist_ok=True)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to prepare output_dir: {e}")
base_url = str(request.base_url).rstrip("/")
contacts = _collect_contacts_for_account(
account_dir=account_dir,
base_url=base_url,
keyword=req.keyword,
include_friends=bool(req.contact_types.friends),
include_groups=bool(req.contact_types.groups),
include_officials=bool(req.contact_types.officials),
)
export_contacts = _build_export_contacts(
contacts,
include_avatar_link=bool(req.include_avatar_link),
)
fmt = _normalize_text(req.format).lower()
if fmt not in {"json", "csv"}:
raise HTTPException(status_code=400, detail="Unsupported format, use 'json' or 'csv'.")
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_account = _safe_export_part(account_dir.name)
output_path = output_dir / f"contacts_{safe_account}_{ts}.{fmt}"
try:
if fmt == "json":
_write_json_export(
output_path,
account=account_dir.name,
contacts=export_contacts,
include_avatar_link=bool(req.include_avatar_link),
keyword=_normalize_text(req.keyword),
contact_types=req.contact_types,
)
else:
_write_csv_export(
output_path,
contacts=export_contacts,
include_avatar_link=bool(req.include_avatar_link),
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to export contacts: {e}")
return {
"status": "success",
"account": account_dir.name,
"format": fmt,
"outputPath": str(output_path),
"count": len(export_contacts),
}

View File

@@ -0,0 +1,71 @@
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
class TestContactTypeDetection(unittest.TestCase):
def test_infer_group(self):
from wechat_decrypt_tool.routers.chat_contacts import _infer_contact_type
row = {"local_type": 0, "alias": "", "remark": "", "nick_name": ""}
self.assertEqual(_infer_contact_type("123@chatroom", row), "group")
def test_infer_official_by_prefix(self):
from wechat_decrypt_tool.routers.chat_contacts import _infer_contact_type
row = {"local_type": 0, "verify_flag": 0, "alias": "", "remark": "", "nick_name": ""}
self.assertEqual(_infer_contact_type("gh_xxx", row), "official")
def test_infer_official_by_verify_flag(self):
from wechat_decrypt_tool.routers.chat_contacts import _infer_contact_type
row = {"local_type": 1, "verify_flag": 24, "alias": "", "remark": "", "nick_name": ""}
self.assertEqual(_infer_contact_type("wxid_xxx", row), "official")
def test_infer_none_for_local_type_3_without_verify(self):
from wechat_decrypt_tool.routers.chat_contacts import _infer_contact_type
row = {"local_type": 3, "verify_flag": 0, "alias": "", "remark": "", "nick_name": "普通联系人"}
self.assertIsNone(_infer_contact_type("wxid_xxx", row))
def test_infer_none_from_wxid_alias_when_local_type_not_1(self):
from wechat_decrypt_tool.routers.chat_contacts import _infer_contact_type
row = {"local_type": 0, "verify_flag": 0, "alias": "wechat_id", "remark": "", "nick_name": ""}
self.assertIsNone(_infer_contact_type("wxid_xxx", row))
def test_infer_friend_from_local_type_1(self):
from wechat_decrypt_tool.routers.chat_contacts import _infer_contact_type
row = {"local_type": 1, "verify_flag": 0, "alias": "", "remark": "", "nick_name": ""}
self.assertEqual(_infer_contact_type("wxid_xxx", row), "friend")
def test_infer_none_from_local_type_2(self):
from wechat_decrypt_tool.routers.chat_contacts import _infer_contact_type
row = {"local_type": 2, "verify_flag": 0, "alias": "", "remark": "", "nick_name": ""}
self.assertIsNone(_infer_contact_type("wxid_xxx", row))
def test_infer_none_when_empty_type_0(self):
from wechat_decrypt_tool.routers.chat_contacts import _infer_contact_type
row = {"local_type": 0, "verify_flag": 0, "alias": "", "remark": "", "nick_name": ""}
self.assertIsNone(_infer_contact_type("wxid_xxx", row))
def test_valid_contact_username_filters_system_accounts(self):
from wechat_decrypt_tool.routers.chat_contacts import _is_valid_contact_username
self.assertFalse(_is_valid_contact_username("filehelper"))
self.assertFalse(_is_valid_contact_username("notifymessage"))
self.assertFalse(_is_valid_contact_username("fake_abc"))
self.assertTrue(_is_valid_contact_username("weixin"))
self.assertTrue(_is_valid_contact_username("wxid_abc"))
self.assertTrue(_is_valid_contact_username("123@chatroom"))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,546 @@
import json
import os
import sqlite3
import sys
import unittest
import importlib
from pathlib import Path
from tempfile import TemporaryDirectory
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
class TestContactsExport(unittest.TestCase):
@staticmethod
def _encode_varint(value: int) -> bytes:
v = int(value)
out = bytearray()
while True:
b = v & 0x7F
v >>= 7
if v:
out.append(b | 0x80)
else:
out.append(b)
break
return bytes(out)
@classmethod
def _encode_field_len(cls, field_no: int, raw: bytes) -> bytes:
tag = (int(field_no) << 3) | 2
payload = bytes(raw)
return cls._encode_varint(tag) + cls._encode_varint(len(payload)) + payload
@classmethod
def _encode_field_varint(cls, field_no: int, value: int) -> bytes:
tag = int(field_no) << 3
return cls._encode_varint(tag) + cls._encode_varint(int(value))
@classmethod
def _build_extra_buffer(cls, *, country: str, province: str, city: str, source_scene: int) -> bytes:
return b"".join(
[
cls._encode_field_len(5, country.encode("utf-8")),
cls._encode_field_len(6, province.encode("utf-8")),
cls._encode_field_len(7, city.encode("utf-8")),
cls._encode_field_varint(8, source_scene),
]
)
def _seed_contact_db(self, path: Path) -> None:
conn = sqlite3.connect(str(path))
try:
conn.execute(
"""
CREATE TABLE contact (
username TEXT,
remark TEXT,
nick_name TEXT,
alias TEXT,
local_type INTEGER,
verify_flag INTEGER,
big_head_url TEXT,
small_head_url TEXT,
extra_buffer BLOB
)
"""
)
conn.execute(
"""
CREATE TABLE stranger (
username TEXT,
remark TEXT,
nick_name TEXT,
alias TEXT,
local_type INTEGER,
verify_flag INTEGER,
big_head_url TEXT,
small_head_url TEXT,
extra_buffer BLOB
)
"""
)
friend_extra_buffer = self._build_extra_buffer(
country="CN",
province="Sichuan",
city="Chengdu",
source_scene=14,
)
conn.execute(
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
"wxid_friend",
"好友备注",
"好友昵称",
"friend_alias",
1,
0,
"https://cdn.example.com/friend_big.jpg",
"https://cdn.example.com/friend_small.jpg",
friend_extra_buffer,
),
)
conn.execute(
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
"room@chatroom",
"",
"测试群",
"",
0,
0,
"https://cdn.example.com/group_big.jpg",
"",
b"",
),
)
conn.execute(
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
"gh_official",
"",
"公众号",
"",
4,
8,
"",
"https://cdn.example.com/official_small.jpg",
b"",
),
)
conn.execute(
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
"wxid_local_type_3",
"",
"不应计入联系人",
"",
3,
0,
"",
"",
b"",
),
)
conn.execute(
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
"weixin",
"",
"微信团队",
"",
1,
56,
"",
"",
b"",
),
)
conn.execute(
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
"filehelper",
"",
"文件传输助手",
"",
0,
0,
"",
"",
b"",
),
)
conn.execute(
"INSERT INTO stranger VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
"stranger_verified",
"",
"陌生人认证号",
"",
4,
24,
"",
"",
b"",
),
)
conn.commit()
finally:
conn.close()
def _seed_session_db(self, path: Path) -> None:
conn = sqlite3.connect(str(path))
try:
conn.execute(
"""
CREATE TABLE SessionTable (
username TEXT,
sort_timestamp INTEGER,
last_timestamp INTEGER
)
"""
)
conn.execute("INSERT INTO SessionTable VALUES (?, ?, ?)", ("room@chatroom", 300, 300))
conn.execute("INSERT INTO SessionTable VALUES (?, ?, ?)", ("wxid_friend", 200, 200))
conn.execute("INSERT INTO SessionTable VALUES (?, ?, ?)", ("gh_official", 100, 100))
conn.execute("INSERT INTO SessionTable VALUES (?, ?, ?)", ("missing@chatroom", 250, 250))
conn.commit()
finally:
conn.close()
def _seed_contact_db_legacy(self, path: Path) -> None:
conn = sqlite3.connect(str(path))
try:
conn.execute(
"""
CREATE TABLE contact (
username TEXT,
remark TEXT,
nick_name TEXT,
alias TEXT,
local_type INTEGER,
verify_flag INTEGER,
big_head_url TEXT,
small_head_url TEXT
)
"""
)
conn.execute(
"""
CREATE TABLE stranger (
username TEXT,
remark TEXT,
nick_name TEXT,
alias TEXT,
local_type INTEGER,
verify_flag INTEGER,
big_head_url TEXT,
small_head_url TEXT
)
"""
)
conn.execute(
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
"wxid_legacy_friend",
"旧版好友备注",
"旧版好友昵称",
"legacy_friend_alias",
1,
0,
"",
"",
),
)
conn.commit()
finally:
conn.close()
def test_export_json_and_csv(self):
from fastapi import FastAPI
from fastapi.testclient import TestClient
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_test"
account_dir = root / "output" / "databases" / account
account_dir.mkdir(parents=True, exist_ok=True)
self._seed_contact_db(account_dir / "contact.db")
self._seed_session_db(account_dir / "session.db")
prev = None
try:
prev = os.environ.get("WECHAT_TOOL_DATA_DIR")
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
import wechat_decrypt_tool.chat_helpers as chat_helpers
import wechat_decrypt_tool.routers.chat_contacts as chat_contacts
importlib.reload(chat_helpers)
importlib.reload(chat_contacts)
app = FastAPI()
app.include_router(chat_contacts.router)
client = TestClient(app)
list_resp = client.get(
"/api/chat/contacts",
params={
"account": account,
"include_friends": True,
"include_groups": True,
"include_officials": True,
},
)
self.assertEqual(list_resp.status_code, 200)
list_payload = list_resp.json()
self.assertEqual(list_payload["status"], "success")
self.assertEqual(list_payload["total"], 6)
self.assertEqual(list_payload["counts"]["friends"], 1)
self.assertEqual(list_payload["counts"]["groups"], 2)
self.assertEqual(list_payload["counts"]["officials"], 3)
usernames = {str(x.get("username")) for x in list_payload.get("contacts", [])}
self.assertIn("missing@chatroom", usernames)
self.assertIn("weixin", usernames)
self.assertNotIn("wxid_local_type_3", usernames)
first = list_payload["contacts"][0]
self.assertIn("avatarLink", first)
friend_contact = next(
(x for x in list_payload.get("contacts", []) if str(x.get("username")) == "wxid_friend"),
{},
)
self.assertEqual(friend_contact.get("country"), "CN")
self.assertEqual(friend_contact.get("province"), "Sichuan")
self.assertEqual(friend_contact.get("city"), "Chengdu")
self.assertEqual(friend_contact.get("region"), "中国大陆·Sichuan·Chengdu")
self.assertEqual(friend_contact.get("sourceScene"), 14)
self.assertEqual(friend_contact.get("source"), "通过群聊添加")
export_dir = root / "exports"
export_dir.mkdir(parents=True, exist_ok=True)
json_resp = client.post(
"/api/chat/contacts/export",
json={
"account": account,
"output_dir": str(export_dir),
"format": "json",
"include_avatar_link": True,
"contact_types": {
"friends": True,
"groups": True,
"officials": True,
},
},
)
self.assertEqual(json_resp.status_code, 200)
json_payload = json_resp.json()
self.assertEqual(json_payload["status"], "success")
self.assertEqual(json_payload["count"], 6)
json_path = Path(json_payload["outputPath"])
self.assertTrue(json_path.exists())
data = json.loads(json_path.read_text(encoding="utf-8"))
self.assertEqual(data["count"], 6)
self.assertIn("avatarLink", data["contacts"][0])
self.assertIn("region", data["contacts"][0])
self.assertIn("country", data["contacts"][0])
self.assertIn("province", data["contacts"][0])
self.assertIn("city", data["contacts"][0])
self.assertIn("source", data["contacts"][0])
self.assertIn("sourceScene", data["contacts"][0])
export_usernames = {str(x.get("username")) for x in data.get("contacts", [])}
self.assertIn("missing@chatroom", export_usernames)
self.assertNotIn("wxid_local_type_3", export_usernames)
friend_export = next(
(x for x in data.get("contacts", []) if str(x.get("username")) == "wxid_friend"),
{},
)
self.assertEqual(friend_export.get("region"), "中国大陆·Sichuan·Chengdu")
self.assertEqual(friend_export.get("sourceScene"), 14)
self.assertEqual(friend_export.get("source"), "通过群聊添加")
csv_resp = client.post(
"/api/chat/contacts/export",
json={
"account": account,
"output_dir": str(export_dir),
"format": "csv",
"include_avatar_link": False,
"contact_types": {
"friends": True,
"groups": False,
"officials": False,
},
},
)
self.assertEqual(csv_resp.status_code, 200)
csv_payload = csv_resp.json()
self.assertEqual(csv_payload["count"], 1)
csv_path = Path(csv_payload["outputPath"])
text = csv_path.read_text(encoding="utf-8-sig")
self.assertIn("用户名,显示名称,备注,昵称,微信号,类型,地区,国家/地区码,省份,城市,来源,来源场景码", text.splitlines()[0])
self.assertNotIn("头像链接", text.splitlines()[0])
self.assertIn("wxid_friend", text)
self.assertIn("中国大陆·Sichuan·Chengdu", text)
self.assertIn("通过群聊添加", text)
self.assertIn(",14", text)
self.assertNotIn("wxid_local_type_3", text)
finally:
if prev is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev
def test_export_invalid_format_returns_400(self):
from fastapi import FastAPI
from fastapi.testclient import TestClient
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_test"
account_dir = root / "output" / "databases" / account
account_dir.mkdir(parents=True, exist_ok=True)
self._seed_contact_db(account_dir / "contact.db")
self._seed_session_db(account_dir / "session.db")
prev = None
try:
prev = os.environ.get("WECHAT_TOOL_DATA_DIR")
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
import wechat_decrypt_tool.chat_helpers as chat_helpers
import wechat_decrypt_tool.routers.chat_contacts as chat_contacts
importlib.reload(chat_helpers)
importlib.reload(chat_contacts)
app = FastAPI()
app.include_router(chat_contacts.router)
client = TestClient(app)
resp = client.post(
"/api/chat/contacts/export",
json={
"account": account,
"output_dir": str(root / "exports"),
"format": "vcf",
"include_avatar_link": True,
"contact_types": {
"friends": True,
"groups": True,
"officials": True,
},
},
)
self.assertEqual(resp.status_code, 400)
finally:
if prev is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev
def test_missing_contact_db_returns_404(self):
from fastapi import FastAPI
from fastapi.testclient import TestClient
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_test"
account_dir = root / "output" / "databases" / account
account_dir.mkdir(parents=True, exist_ok=True)
# only session.db exists
self._seed_session_db(account_dir / "session.db")
prev = None
try:
prev = os.environ.get("WECHAT_TOOL_DATA_DIR")
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
import wechat_decrypt_tool.chat_helpers as chat_helpers
import wechat_decrypt_tool.routers.chat_contacts as chat_contacts
importlib.reload(chat_helpers)
importlib.reload(chat_contacts)
app = FastAPI()
app.include_router(chat_contacts.router)
client = TestClient(app)
resp = client.get("/api/chat/contacts", params={"account": account})
self.assertEqual(resp.status_code, 404)
finally:
if prev is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev
def test_legacy_schema_without_extra_buffer_is_compatible(self):
from fastapi import FastAPI
from fastapi.testclient import TestClient
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_legacy"
account_dir = root / "output" / "databases" / account
account_dir.mkdir(parents=True, exist_ok=True)
self._seed_contact_db_legacy(account_dir / "contact.db")
self._seed_session_db(account_dir / "session.db")
prev = None
try:
prev = os.environ.get("WECHAT_TOOL_DATA_DIR")
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
import wechat_decrypt_tool.chat_helpers as chat_helpers
import wechat_decrypt_tool.routers.chat_contacts as chat_contacts
importlib.reload(chat_helpers)
importlib.reload(chat_contacts)
app = FastAPI()
app.include_router(chat_contacts.router)
client = TestClient(app)
resp = client.get(
"/api/chat/contacts",
params={
"account": account,
"include_friends": True,
"include_groups": False,
"include_officials": False,
},
)
self.assertEqual(resp.status_code, 200)
payload = resp.json()
self.assertEqual(payload.get("status"), "success")
self.assertEqual(int(payload.get("total", 0)), 1)
contact = payload.get("contacts", [])[0]
self.assertEqual(contact.get("username"), "wxid_legacy_friend")
self.assertEqual(contact.get("country"), "")
self.assertEqual(contact.get("province"), "")
self.assertEqual(contact.get("city"), "")
self.assertEqual(contact.get("region"), "")
self.assertIsNone(contact.get("sourceScene"))
self.assertEqual(contact.get("source"), "")
finally:
if prev is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev
if __name__ == "__main__":
unittest.main()