mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
feat: 支持头像base64导出
This commit is contained in:
36
.github/workflows/release.yml
vendored
36
.github/workflows/release.yml
vendored
@@ -15,6 +15,8 @@ jobs:
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -30,7 +32,39 @@ jobs:
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
# --- 生成更新日志步骤 ---
|
||||
- name: Build Changelog
|
||||
id: build_changelog
|
||||
uses: mikepenz/release-changelog-builder-action@v4
|
||||
with:
|
||||
outputFile: "release-notes.md"
|
||||
configurationJson: |
|
||||
{
|
||||
"categories": [
|
||||
{
|
||||
"title": "## 🚀 Features",
|
||||
"labels": ["feat", "feature"]
|
||||
},
|
||||
{
|
||||
"title": "## 🐛 Fixes",
|
||||
"labels": ["fix", "bug"]
|
||||
},
|
||||
{
|
||||
"title": "## 🧰 Maintenance",
|
||||
"labels": ["chore", "refactor", "docs", "perf"]
|
||||
}
|
||||
],
|
||||
"template": "# Release Notes\n\n{{CHANGELOG}}"
|
||||
}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# --- 打包并发布步骤 ---
|
||||
- name: Package and Publish
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npx electron-builder --publish always
|
||||
run: >
|
||||
npx electron-builder
|
||||
--publish always
|
||||
-c.releaseInfo.releaseNotesFile=release-notes.md
|
||||
-c.publish.releaseType=release
|
||||
@@ -1,5 +1,8 @@
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as http from 'http'
|
||||
import * as https from 'https'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { ConfigService } from './config'
|
||||
import { wcdbService } from './wcdbService'
|
||||
|
||||
@@ -298,9 +301,9 @@ class ExportService {
|
||||
sessionId: string,
|
||||
cleanedMyWxid: string,
|
||||
dateRange?: { start: number; end: number } | null
|
||||
): Promise<{ rows: any[]; memberSet: Map<string, ChatLabMember>; firstTime: number | null; lastTime: number | null }> {
|
||||
): Promise<{ rows: any[]; memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>; firstTime: number | null; lastTime: number | null }> {
|
||||
const rows: any[] = []
|
||||
const memberSet = new Map<string, ChatLabMember>()
|
||||
const memberSet = new Map<string, { member: ChatLabMember; avatarUrl?: string }>()
|
||||
let firstTime: number | null = null
|
||||
let lastTime: number | null = null
|
||||
|
||||
@@ -336,8 +339,11 @@ class ExportService {
|
||||
const memberInfo = await this.getContactInfo(actualSender)
|
||||
if (!memberSet.has(actualSender)) {
|
||||
memberSet.set(actualSender, {
|
||||
platformId: actualSender,
|
||||
accountName: memberInfo.displayName
|
||||
member: {
|
||||
platformId: actualSender,
|
||||
accountName: memberInfo.displayName
|
||||
},
|
||||
avatarUrl: memberInfo.avatarUrl
|
||||
})
|
||||
}
|
||||
|
||||
@@ -361,6 +367,121 @@ class ExportService {
|
||||
return { rows, memberSet, firstTime, lastTime }
|
||||
}
|
||||
|
||||
private resolveAvatarFile(avatarUrl?: string): { data?: Buffer; sourcePath?: string; sourceUrl?: string; ext: string; mime?: string } | null {
|
||||
if (!avatarUrl) return null
|
||||
if (avatarUrl.startsWith('data:')) {
|
||||
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/i.exec(avatarUrl)
|
||||
if (!match) return null
|
||||
const mime = match[1].toLowerCase()
|
||||
const data = Buffer.from(match[2], 'base64')
|
||||
const ext = mime.includes('png') ? '.png'
|
||||
: mime.includes('gif') ? '.gif'
|
||||
: mime.includes('webp') ? '.webp'
|
||||
: '.jpg'
|
||||
return { data, ext, mime }
|
||||
}
|
||||
if (avatarUrl.startsWith('file://')) {
|
||||
try {
|
||||
const sourcePath = fileURLToPath(avatarUrl)
|
||||
const ext = path.extname(sourcePath) || '.jpg'
|
||||
return { sourcePath, ext }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (avatarUrl.startsWith('http://') || avatarUrl.startsWith('https://')) {
|
||||
const url = new URL(avatarUrl)
|
||||
const ext = path.extname(url.pathname) || '.jpg'
|
||||
return { sourceUrl: avatarUrl, ext }
|
||||
}
|
||||
const sourcePath = avatarUrl
|
||||
const ext = path.extname(sourcePath) || '.jpg'
|
||||
return { sourcePath, ext }
|
||||
}
|
||||
|
||||
private async downloadToBuffer(url: string, remainingRedirects = 2): Promise<{ data: Buffer; mime?: string } | null> {
|
||||
const client = url.startsWith('https:') ? https : http
|
||||
return new Promise((resolve) => {
|
||||
const request = client.get(url, (res) => {
|
||||
const status = res.statusCode || 0
|
||||
if (status >= 300 && status < 400 && res.headers.location && remainingRedirects > 0) {
|
||||
res.resume()
|
||||
const redirectedUrl = new URL(res.headers.location, url).href
|
||||
this.downloadToBuffer(redirectedUrl, remainingRedirects - 1)
|
||||
.then(resolve)
|
||||
return
|
||||
}
|
||||
if (status < 200 || status >= 300) {
|
||||
res.resume()
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
res.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))
|
||||
res.on('end', () => {
|
||||
const data = Buffer.concat(chunks)
|
||||
const mime = typeof res.headers['content-type'] === 'string' ? res.headers['content-type'] : undefined
|
||||
resolve({ data, mime })
|
||||
})
|
||||
})
|
||||
request.on('error', () => resolve(null))
|
||||
request.setTimeout(15000, () => {
|
||||
request.destroy()
|
||||
resolve(null)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private async exportAvatars(
|
||||
members: Array<{ username: string; avatarUrl?: string }>
|
||||
): Promise<Map<string, string>> {
|
||||
const result = new Map<string, string>()
|
||||
if (members.length === 0) return result
|
||||
|
||||
for (const member of members) {
|
||||
const fileInfo = this.resolveAvatarFile(member.avatarUrl)
|
||||
if (!fileInfo) continue
|
||||
try {
|
||||
let data: Buffer | null = null
|
||||
let mime = fileInfo.mime
|
||||
if (fileInfo.data) {
|
||||
data = fileInfo.data
|
||||
} else if (fileInfo.sourcePath && fs.existsSync(fileInfo.sourcePath)) {
|
||||
data = await fs.promises.readFile(fileInfo.sourcePath)
|
||||
} else if (fileInfo.sourceUrl) {
|
||||
const downloaded = await this.downloadToBuffer(fileInfo.sourceUrl)
|
||||
if (downloaded) {
|
||||
data = downloaded.data
|
||||
mime = downloaded.mime || mime
|
||||
}
|
||||
}
|
||||
if (!data) continue
|
||||
const finalMime = mime || this.inferImageMime(fileInfo.ext)
|
||||
const base64 = data.toString('base64')
|
||||
result.set(member.username, `data:${finalMime};base64,${base64}`)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private inferImageMime(ext: string): string {
|
||||
switch (ext.toLowerCase()) {
|
||||
case '.png':
|
||||
return 'image/png'
|
||||
case '.gif':
|
||||
return 'image/gif'
|
||||
case '.webp':
|
||||
return 'image/webp'
|
||||
case '.bmp':
|
||||
return 'image/bmp'
|
||||
default:
|
||||
return 'image/jpeg'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出单个会话为 ChatLab 格式
|
||||
*/
|
||||
@@ -399,7 +520,7 @@ class ExportService {
|
||||
})
|
||||
|
||||
const chatLabMessages: ChatLabMessage[] = allMessages.map((msg) => {
|
||||
const memberInfo = collected.memberSet.get(msg.senderUsername) || {
|
||||
const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || {
|
||||
platformId: msg.senderUsername,
|
||||
accountName: msg.senderUsername
|
||||
}
|
||||
@@ -412,6 +533,23 @@ class ExportService {
|
||||
}
|
||||
})
|
||||
|
||||
const avatarMap = options.exportAvatars
|
||||
? await this.exportAvatars(
|
||||
[
|
||||
...Array.from(collected.memberSet.entries()).map(([username, info]) => ({
|
||||
username,
|
||||
avatarUrl: info.avatarUrl
|
||||
})),
|
||||
{ username: sessionId, avatarUrl: sessionInfo.avatarUrl }
|
||||
]
|
||||
)
|
||||
: new Map<string, string>()
|
||||
|
||||
const members = Array.from(collected.memberSet.values()).map((info) => {
|
||||
const avatar = avatarMap.get(info.member.platformId)
|
||||
return avatar ? { ...info.member, avatar } : info.member
|
||||
})
|
||||
|
||||
const chatLabExport: ChatLabExport = {
|
||||
chatlab: {
|
||||
version: '0.0.1',
|
||||
@@ -424,7 +562,7 @@ class ExportService {
|
||||
type: isGroup ? 'group' : 'private',
|
||||
...(isGroup && { groupId: sessionId })
|
||||
},
|
||||
members: Array.from(collected.memberSet.values()),
|
||||
members,
|
||||
messages: chatLabMessages
|
||||
}
|
||||
|
||||
@@ -538,6 +676,29 @@ class ExportService {
|
||||
messages: allMessages
|
||||
}
|
||||
|
||||
if (options.exportAvatars) {
|
||||
const avatarMap = await this.exportAvatars(
|
||||
[
|
||||
...Array.from(collected.memberSet.entries()).map(([username, info]) => ({
|
||||
username,
|
||||
avatarUrl: info.avatarUrl
|
||||
})),
|
||||
{ username: sessionId, avatarUrl: sessionInfo.avatarUrl }
|
||||
]
|
||||
)
|
||||
const avatars: Record<string, string> = {}
|
||||
for (const [username, relPath] of avatarMap.entries()) {
|
||||
avatars[username] = relPath
|
||||
}
|
||||
if (Object.keys(avatars).length > 0) {
|
||||
detailedExport.session = {
|
||||
...detailedExport.session,
|
||||
avatar: avatars[sessionId]
|
||||
}
|
||||
;(detailedExport as any).avatars = avatars
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8')
|
||||
|
||||
onProgress?.({
|
||||
|
||||
@@ -15,6 +15,7 @@ interface ExportOptions {
|
||||
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'sql'
|
||||
dateRange: { start: Date; end: Date } | null
|
||||
useAllTime: boolean
|
||||
exportAvatars: boolean
|
||||
}
|
||||
|
||||
interface ExportResult {
|
||||
@@ -41,7 +42,8 @@ function ExportPage() {
|
||||
start: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||
end: new Date()
|
||||
},
|
||||
useAllTime: true
|
||||
useAllTime: true,
|
||||
exportAvatars: true
|
||||
})
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
@@ -140,6 +142,7 @@ function ExportPage() {
|
||||
const sessionList = Array.from(selectedSessions)
|
||||
const exportOptions = {
|
||||
format: options.format,
|
||||
exportAvatars: options.exportAvatars,
|
||||
dateRange: options.useAllTime ? null : options.dateRange ? {
|
||||
start: Math.floor(options.dateRange.start.getTime() / 1000),
|
||||
end: Math.floor(options.dateRange.end.getTime() / 1000)
|
||||
@@ -289,6 +292,20 @@ function ExportPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<h3>导出头像</h3>
|
||||
<div className="time-options">
|
||||
<label className="checkbox-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.exportAvatars}
|
||||
onChange={e => setOptions({ ...options, exportAvatars: e.target.checked })}
|
||||
/>
|
||||
<span>导出头像图片</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<h3>导出位置</h3>
|
||||
<div className="export-path-display">
|
||||
|
||||
Reference in New Issue
Block a user