mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-26 15:45:51 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
654eb40740 | ||
|
|
bd3e9a63b7 | ||
|
|
bc9ef140f5 | ||
|
|
f864189407 | ||
|
|
f321c465d5 | ||
|
|
e27270f134 | ||
|
|
bb7730ff2f | ||
|
|
ea29e77a57 | ||
|
|
2ad88df3c0 |
34
.github/workflows/release.yml
vendored
34
.github/workflows/release.yml
vendored
@@ -15,6 +15,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check out git repository
|
- name: Check out git repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -30,7 +32,37 @@ jobs:
|
|||||||
npx tsc
|
npx tsc
|
||||||
npx vite build
|
npx vite build
|
||||||
|
|
||||||
|
- name: Build Changelog
|
||||||
|
id: build_changelog
|
||||||
|
uses: mikepenz/release-changelog-builder-action@v4
|
||||||
|
with:
|
||||||
|
outputFile: "release-notes.md"
|
||||||
|
configurationJson: |
|
||||||
|
{
|
||||||
|
"template": "# v${{ github.ref_name }} 版本发布\n\n{{CHANGELOG}}\n\n---\n> 此更新由系统自动构建",
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"title": "## 新功能",
|
||||||
|
"filter": { "pattern": "^feat:.*", "flags": "i" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "## 修复",
|
||||||
|
"filter": { "pattern": "^fix:.*", "flags": "i" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "## 性能与维护",
|
||||||
|
"filter": { "pattern": "^(chore|docs|perf|refactor):.*", "flags": "i" }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ignore_labels": true,
|
||||||
|
"commitMode": true,
|
||||||
|
"empty_summary": "## 更新详情\n- 常规代码优化与维护"
|
||||||
|
}
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Package and Publish
|
- name: Package and Publish
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: npx electron-builder --publish always
|
run: |
|
||||||
|
npx electron-builder --publish always "-c.releaseInfo.releaseNotesFile=release-notes.md"
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
|
import * as http from 'http'
|
||||||
|
import * as https from 'https'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
|
|
||||||
@@ -298,9 +301,9 @@ class ExportService {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
cleanedMyWxid: string,
|
cleanedMyWxid: string,
|
||||||
dateRange?: { start: number; end: number } | null
|
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 rows: any[] = []
|
||||||
const memberSet = new Map<string, ChatLabMember>()
|
const memberSet = new Map<string, { member: ChatLabMember; avatarUrl?: string }>()
|
||||||
let firstTime: number | null = null
|
let firstTime: number | null = null
|
||||||
let lastTime: number | null = null
|
let lastTime: number | null = null
|
||||||
|
|
||||||
@@ -336,8 +339,11 @@ class ExportService {
|
|||||||
const memberInfo = await this.getContactInfo(actualSender)
|
const memberInfo = await this.getContactInfo(actualSender)
|
||||||
if (!memberSet.has(actualSender)) {
|
if (!memberSet.has(actualSender)) {
|
||||||
memberSet.set(actualSender, {
|
memberSet.set(actualSender, {
|
||||||
platformId: actualSender,
|
member: {
|
||||||
accountName: memberInfo.displayName
|
platformId: actualSender,
|
||||||
|
accountName: memberInfo.displayName
|
||||||
|
},
|
||||||
|
avatarUrl: memberInfo.avatarUrl
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,6 +367,121 @@ class ExportService {
|
|||||||
return { rows, memberSet, firstTime, lastTime }
|
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 格式
|
* 导出单个会话为 ChatLab 格式
|
||||||
*/
|
*/
|
||||||
@@ -399,7 +520,7 @@ class ExportService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const chatLabMessages: ChatLabMessage[] = allMessages.map((msg) => {
|
const chatLabMessages: ChatLabMessage[] = allMessages.map((msg) => {
|
||||||
const memberInfo = collected.memberSet.get(msg.senderUsername) || {
|
const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || {
|
||||||
platformId: msg.senderUsername,
|
platformId: msg.senderUsername,
|
||||||
accountName: 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 = {
|
const chatLabExport: ChatLabExport = {
|
||||||
chatlab: {
|
chatlab: {
|
||||||
version: '0.0.1',
|
version: '0.0.1',
|
||||||
@@ -424,7 +562,7 @@ class ExportService {
|
|||||||
type: isGroup ? 'group' : 'private',
|
type: isGroup ? 'group' : 'private',
|
||||||
...(isGroup && { groupId: sessionId })
|
...(isGroup && { groupId: sessionId })
|
||||||
},
|
},
|
||||||
members: Array.from(collected.memberSet.values()),
|
members,
|
||||||
messages: chatLabMessages
|
messages: chatLabMessages
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,6 +676,29 @@ class ExportService {
|
|||||||
messages: allMessages
|
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')
|
fs.writeFileSync(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8')
|
||||||
|
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.0.0",
|
"version": "1.0.2",
|
||||||
"description": "WeFlow - 微信聊天记录查看工具",
|
"description": "WeFlow - 微信聊天记录查看工具",
|
||||||
"main": "dist-electron/main.js",
|
"main": "dist-electron/main.js",
|
||||||
|
"author": "cc",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build && electron-builder",
|
"build": "tsc && vite build && electron-builder",
|
||||||
@@ -46,6 +47,10 @@
|
|||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.WeFlow.app",
|
"appId": "com.WeFlow.app",
|
||||||
|
"publish": {
|
||||||
|
"provider": "github",
|
||||||
|
"releaseType": "release"
|
||||||
|
},
|
||||||
"productName": "WeFlow",
|
"productName": "WeFlow",
|
||||||
"artifactName": "${productName}-${version}-Setup.${ext}",
|
"artifactName": "${productName}-${version}-Setup.${ext}",
|
||||||
"directories": {
|
"directories": {
|
||||||
@@ -59,6 +64,7 @@
|
|||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
|
"differentialPackage":false,
|
||||||
"allowToChangeInstallationDirectory": true,
|
"allowToChangeInstallationDirectory": true,
|
||||||
"createDesktopShortcut": true,
|
"createDesktopShortcut": true,
|
||||||
"unicode": true,
|
"unicode": true,
|
||||||
|
|||||||
@@ -379,29 +379,21 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.change-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-options {
|
.media-options {
|
||||||
@@ -649,9 +641,245 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.date-picker-modal {
|
||||||
|
background: var(--card-bg);
|
||||||
|
padding: 28px 32px;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
||||||
|
min-width: 420px;
|
||||||
|
max-width: 500px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-select {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
.quick-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.date-display-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(var(--primary-rgb), 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: rgba(var(--primary-rgb), 0.1);
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-value {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-separator {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-container {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
|
||||||
|
.calendar-nav-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-month {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-weekdays {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.calendar-weekday {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-days {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.calendar-day {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.empty {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.empty):hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.in-range {
|
||||||
|
background: rgba(var(--primary-rgb), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.start,
|
||||||
|
&.end {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-picker-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes exportSpin {
|
@keyframes exportSpin {
|
||||||
from { transform: rotate(0deg); }
|
from {
|
||||||
to { transform: rotate(360deg); }
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink } from 'lucide-react'
|
import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, ChevronLeft, ChevronRight, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink } from 'lucide-react'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
import './ExportPage.scss'
|
import './ExportPage.scss'
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ interface ExportOptions {
|
|||||||
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'sql'
|
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'sql'
|
||||||
dateRange: { start: Date; end: Date } | null
|
dateRange: { start: Date; end: Date } | null
|
||||||
useAllTime: boolean
|
useAllTime: boolean
|
||||||
|
exportAvatars: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExportResult {
|
interface ExportResult {
|
||||||
@@ -34,6 +35,9 @@ function ExportPage() {
|
|||||||
const [isExporting, setIsExporting] = useState(false)
|
const [isExporting, setIsExporting] = useState(false)
|
||||||
const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '' })
|
const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '' })
|
||||||
const [exportResult, setExportResult] = useState<ExportResult | null>(null)
|
const [exportResult, setExportResult] = useState<ExportResult | null>(null)
|
||||||
|
const [showDatePicker, setShowDatePicker] = useState(false)
|
||||||
|
const [calendarDate, setCalendarDate] = useState(new Date())
|
||||||
|
const [selectingStart, setSelectingStart] = useState(true)
|
||||||
|
|
||||||
const [options, setOptions] = useState<ExportOptions>({
|
const [options, setOptions] = useState<ExportOptions>({
|
||||||
format: 'chatlab',
|
format: 'chatlab',
|
||||||
@@ -41,7 +45,8 @@ function ExportPage() {
|
|||||||
start: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
start: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||||
end: new Date()
|
end: new Date()
|
||||||
},
|
},
|
||||||
useAllTime: true
|
useAllTime: true,
|
||||||
|
exportAvatars: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
@@ -140,9 +145,11 @@ function ExportPage() {
|
|||||||
const sessionList = Array.from(selectedSessions)
|
const sessionList = Array.from(selectedSessions)
|
||||||
const exportOptions = {
|
const exportOptions = {
|
||||||
format: options.format,
|
format: options.format,
|
||||||
|
exportAvatars: options.exportAvatars,
|
||||||
dateRange: options.useAllTime ? null : options.dateRange ? {
|
dateRange: options.useAllTime ? null : options.dateRange ? {
|
||||||
start: Math.floor(options.dateRange.start.getTime() / 1000),
|
start: Math.floor(options.dateRange.start.getTime() / 1000),
|
||||||
end: Math.floor(options.dateRange.end.getTime() / 1000)
|
// 将结束日期设置为当天的 23:59:59,以包含当天的所有消息
|
||||||
|
end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000)
|
||||||
} : null
|
} : null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +171,54 @@ function ExportPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getDaysInMonth = (date: Date) => {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = date.getMonth()
|
||||||
|
return new Date(year, month + 1, 0).getDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFirstDayOfMonth = (date: Date) => {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = date.getMonth()
|
||||||
|
return new Date(year, month, 1).getDay()
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateCalendar = () => {
|
||||||
|
const daysInMonth = getDaysInMonth(calendarDate)
|
||||||
|
const firstDay = getFirstDayOfMonth(calendarDate)
|
||||||
|
const days: (number | null)[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < firstDay; i++) {
|
||||||
|
days.push(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 1; i <= daysInMonth; i++) {
|
||||||
|
days.push(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDateSelect = (day: number) => {
|
||||||
|
const year = calendarDate.getFullYear()
|
||||||
|
const month = calendarDate.getMonth()
|
||||||
|
const selectedDate = new Date(year, month, day)
|
||||||
|
|
||||||
|
if (selectingStart) {
|
||||||
|
setOptions({
|
||||||
|
...options,
|
||||||
|
dateRange: options.dateRange ? { ...options.dateRange, start: selectedDate } : { start: selectedDate, end: new Date() }
|
||||||
|
})
|
||||||
|
setSelectingStart(false)
|
||||||
|
} else {
|
||||||
|
setOptions({
|
||||||
|
...options,
|
||||||
|
dateRange: options.dateRange ? { ...options.dateRange, end: selectedDate } : { start: new Date(), end: selectedDate }
|
||||||
|
})
|
||||||
|
setSelectingStart(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const formatOptions = [
|
const formatOptions = [
|
||||||
{ value: 'chatlab', label: 'ChatLab', icon: FileCode, desc: '标准格式,支持其他软件导入' },
|
{ value: 'chatlab', label: 'ChatLab', icon: FileCode, desc: '标准格式,支持其他软件导入' },
|
||||||
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', icon: FileCode, desc: '流式格式,适合大量消息' },
|
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', icon: FileCode, desc: '流式格式,适合大量消息' },
|
||||||
@@ -278,17 +333,29 @@ function ExportPage() {
|
|||||||
<span>导出全部时间</span>
|
<span>导出全部时间</span>
|
||||||
</label>
|
</label>
|
||||||
{!options.useAllTime && options.dateRange && (
|
{!options.useAllTime && options.dateRange && (
|
||||||
<div className="date-range">
|
<div className="date-range" onClick={() => setShowDatePicker(true)}>
|
||||||
<Calendar size={16} />
|
<Calendar size={16} />
|
||||||
<span>{formatDate(options.dateRange.start)} - {formatDate(options.dateRange.end)}</span>
|
<span>{formatDate(options.dateRange.start)} - {formatDate(options.dateRange.end)}</span>
|
||||||
<button className="change-btn">
|
<ChevronDown size={14} />
|
||||||
<ChevronDown size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="setting-section">
|
||||||
<h3>导出位置</h3>
|
<h3>导出位置</h3>
|
||||||
<div className="export-path-display">
|
<div className="export-path-display">
|
||||||
@@ -370,6 +437,130 @@ function ExportPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 日期选择弹窗 */}
|
||||||
|
{showDatePicker && (
|
||||||
|
<div className="export-overlay" onClick={() => setShowDatePicker(false)}>
|
||||||
|
<div className="date-picker-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<h3>选择时间范围</h3>
|
||||||
|
<div className="quick-select">
|
||||||
|
<button
|
||||||
|
className="quick-btn"
|
||||||
|
onClick={() => {
|
||||||
|
const end = new Date()
|
||||||
|
const start = new Date(end.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||||
|
setOptions({ ...options, dateRange: { start, end } })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
最近7天
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="quick-btn"
|
||||||
|
onClick={() => {
|
||||||
|
const end = new Date()
|
||||||
|
const start = new Date(end.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||||
|
setOptions({ ...options, dateRange: { start, end } })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
最近30天
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="quick-btn"
|
||||||
|
onClick={() => {
|
||||||
|
const end = new Date()
|
||||||
|
const start = new Date(end.getTime() - 90 * 24 * 60 * 60 * 1000)
|
||||||
|
setOptions({ ...options, dateRange: { start, end } })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
最近90天
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="date-display">
|
||||||
|
<div
|
||||||
|
className={`date-display-item ${selectingStart ? 'active' : ''}`}
|
||||||
|
onClick={() => setSelectingStart(true)}
|
||||||
|
>
|
||||||
|
<span className="date-label">开始日期</span>
|
||||||
|
<span className="date-value">
|
||||||
|
{options.dateRange?.start.toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit'
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="date-separator">至</span>
|
||||||
|
<div
|
||||||
|
className={`date-display-item ${!selectingStart ? 'active' : ''}`}
|
||||||
|
onClick={() => setSelectingStart(false)}
|
||||||
|
>
|
||||||
|
<span className="date-label">结束日期</span>
|
||||||
|
<span className="date-value">
|
||||||
|
{options.dateRange?.end.toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit'
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="calendar-container">
|
||||||
|
<div className="calendar-header">
|
||||||
|
<button
|
||||||
|
className="calendar-nav-btn"
|
||||||
|
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={18} />
|
||||||
|
</button>
|
||||||
|
<span className="calendar-month">
|
||||||
|
{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="calendar-nav-btn"
|
||||||
|
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
|
||||||
|
>
|
||||||
|
<ChevronRight size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="calendar-weekdays">
|
||||||
|
{['日', '一', '二', '三', '四', '五', '六'].map(day => (
|
||||||
|
<div key={day} className="calendar-weekday">{day}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="calendar-days">
|
||||||
|
{generateCalendar().map((day, index) => {
|
||||||
|
if (day === null) {
|
||||||
|
return <div key={`empty-${index}`} className="calendar-day empty" />
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day)
|
||||||
|
const isStart = options.dateRange?.start.toDateString() === currentDate.toDateString()
|
||||||
|
const isEnd = options.dateRange?.end.toDateString() === currentDate.toDateString()
|
||||||
|
const isInRange = options.dateRange && currentDate >= options.dateRange.start && currentDate <= options.dateRange.end
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={day}
|
||||||
|
className={`calendar-day ${isStart ? 'start' : ''} ${isEnd ? 'end' : ''} ${isInRange ? 'in-range' : ''}`}
|
||||||
|
onClick={() => handleDateSelect(day)}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="date-picker-actions">
|
||||||
|
<button className="cancel-btn" onClick={() => setShowDatePicker(false)}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button className="confirm-btn" onClick={() => setShowDatePicker(false)}>
|
||||||
|
确定
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user