mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-19 14:20:51 +08:00
新增 SidebarRail 组件并统一主导航入口 引入 chatAccounts/chatRealtime/privacy 三个 Pinia store 复用全局状态 聊天/联系人/朋友圈页面去重侧栏逻辑,app 根布局统一承载标题栏与内容区
449 lines
17 KiB
Vue
449 lines
17 KiB
Vue
<template>
|
||
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
|
||
<div class="flex-1 flex flex-col min-h-0" style="background-color: #EDEDED">
|
||
<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>
|
||
import { storeToRefs } from 'pinia'
|
||
import { useChatAccountsStore } from '~/stores/chatAccounts'
|
||
import { usePrivacyStore } from '~/stores/privacy'
|
||
|
||
useHead({ title: '联系人 - 微信数据分析助手' })
|
||
|
||
const api = useApi()
|
||
|
||
const chatAccounts = useChatAccountsStore()
|
||
const { accounts: availableAccounts, selectedAccount } = storeToRefs(chatAccounts)
|
||
|
||
const privacyStore = usePrivacyStore()
|
||
const { privacyMode } = storeToRefs(privacyStore)
|
||
|
||
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 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 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 () => {
|
||
await chatAccounts.ensureLoaded({ force: true })
|
||
}
|
||
|
||
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 () => {
|
||
privacyStore.init()
|
||
await loadAccounts()
|
||
await loadContacts()
|
||
})
|
||
</script>
|