33 files changed
+3076
-2636
No files matched your search
@@ -105,9 +105,13 @@ jobs:
|
||||
- name: Package macOS arm64 dev artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
|
||||
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
|
||||
npx electron-builder --mac dmg zip --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}'
|
||||
if ! npx electron-builder --mac dmg zip --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}'; then
|
||||
echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only."
|
||||
npx electron-builder --mac zip --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}'
|
||||
fi
|
||||
|
||||
- name: Upload macOS arm64 assets to fixed release
|
||||
env:
|
||||
@@ -314,6 +318,9 @@ jobs:
|
||||
WINDOWS_ASSET="$(pick_asset "dev-x64-Setup[.]exe$")"
|
||||
WINDOWS_ARM64_ASSET="$(pick_asset "dev-arm64-Setup[.]exe$")"
|
||||
MAC_ASSET="$(pick_asset "dev-arm64[.]dmg$")"
|
||||
if [ -z "$MAC_ASSET" ]; then
|
||||
MAC_ASSET="$(pick_asset "dev-arm64[.]zip$")"
|
||||
fi
|
||||
LINUX_TAR_ASSET="$(pick_asset "dev-linux[.]tar[.]gz$")"
|
||||
LINUX_APPIMAGE_ASSET="$(pick_asset "dev-linux[.]AppImage$")"
|
||||
|
||||
|
||||
@@ -134,9 +134,13 @@ jobs:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
|
||||
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
|
||||
npx electron-builder --mac dmg zip --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}'
|
||||
if ! npx electron-builder --mac dmg zip --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}'; then
|
||||
echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only."
|
||||
npx electron-builder --mac zip --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}'
|
||||
fi
|
||||
|
||||
- name: Upload macOS arm64 assets to fixed preview release
|
||||
env:
|
||||
@@ -359,6 +363,9 @@ jobs:
|
||||
fi
|
||||
WINDOWS_ARM64_ASSET="$(pick_asset "arm64.*[.]exe$")"
|
||||
MAC_ASSET="$(pick_asset "[.]dmg$")"
|
||||
if [ -z "$MAC_ASSET" ]; then
|
||||
MAC_ASSET="$(pick_asset "[.]zip$")"
|
||||
fi
|
||||
LINUX_TAR_ASSET="$(pick_asset "[.]tar[.]gz$")"
|
||||
LINUX_APPIMAGE_ASSET="$(pick_asset "[.]AppImage$")"
|
||||
|
||||
|
||||
@@ -49,9 +49,13 @@ jobs:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
|
||||
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
|
||||
npx electron-builder --mac dmg zip --arm64 --publish always
|
||||
if ! npx electron-builder --mac dmg zip --arm64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}'; then
|
||||
echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only."
|
||||
npx electron-builder --mac zip --arm64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}'
|
||||
fi
|
||||
|
||||
- name: Inject minimumVersion into latest yml
|
||||
env:
|
||||
@@ -114,7 +118,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npx electron-builder --linux --publish always
|
||||
npx electron-builder --linux --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}'
|
||||
|
||||
- name: Inject minimumVersion into latest yml
|
||||
env:
|
||||
@@ -167,7 +171,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npx electron-builder --win nsis --x64 --publish always '--config.artifactName=${productName}-${version}-x64-Setup.${ext}'
|
||||
npx electron-builder --win nsis --x64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' '--config.artifactName=${productName}-${version}-x64-Setup.${ext}'
|
||||
|
||||
- name: Inject minimumVersion into latest yml
|
||||
env:
|
||||
@@ -220,7 +224,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npx electron-builder --win nsis --arm64 --publish always '--config.publish.channel=latest-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}'
|
||||
npx electron-builder --win nsis --arm64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' '--config.publish.channel=latest-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}'
|
||||
|
||||
- name: Inject minimumVersion into latest yml
|
||||
env:
|
||||
@@ -274,6 +278,9 @@ jobs:
|
||||
fi
|
||||
WINDOWS_ARM64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')"
|
||||
MAC_ASSET="$(pick_asset "\\.dmg$")"
|
||||
if [ -z "$MAC_ASSET" ]; then
|
||||
MAC_ASSET="$(pick_asset "arm64\\.zip$")"
|
||||
fi
|
||||
LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")"
|
||||
LINUX_APPIMAGE_ASSET="$(pick_asset "\\.AppImage$")"
|
||||
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
name: Security Scan
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *' # 每天 UTC 02:00
|
||||
workflow_dispatch: # 手动触发
|
||||
pull_request: # PR 时触发
|
||||
branches: [ main, dev ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
security-scan:
|
||||
name: Security Scan (${{ matrix.branch }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
branch:
|
||||
- main
|
||||
|
||||
steps:
|
||||
- name: Checkout ${{ matrix.branch }}
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ matrix.branch }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm' # 使用 npm 缓存加速
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
# 1. npm audit - 检查依赖漏洞
|
||||
- name: Dependency vulnerability audit
|
||||
run: npm audit --audit-level=moderate
|
||||
continue-on-error: true
|
||||
|
||||
# 2. CodeQL 静态分析
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: javascript, typescript
|
||||
queries: security-and-quality
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: '/language:javascript-typescript/branch:${{ matrix.branch }}'
|
||||
|
||||
# 3. 密钥/敏感信息扫描
|
||||
- name: Secret scanning with Gitleaks
|
||||
uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
continue-on-error: true
|
||||
|
||||
# 动态获取所有分支并扫描
|
||||
scan-all-branches:
|
||||
name: Scan additional branches
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Run npm audit on all branches
|
||||
run: |
|
||||
git branch -r | grep -v HEAD | sed 's|origin/||' | tr -d ' ' | while read branch; do
|
||||
echo "===== Auditing branch: $branch ====="
|
||||
git checkout "$branch" 2>/dev/null || continue
|
||||
# 尝试安装并审计
|
||||
npm ci --ignore-scripts --silent 2>/dev/null || npm install --ignore-scripts --silent 2>/dev/null || true
|
||||
npm audit --audit-level=moderate 2>/dev/null || true
|
||||
done
|
||||
continue-on-error: true
|
||||
@@ -3555,6 +3555,24 @@ function registerIpcHandlers() {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('annualReport:captureCurrentWindow', async (event) => {
|
||||
try {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win || win.isDestroyed()) {
|
||||
return { success: false, error: '窗口不可用' }
|
||||
}
|
||||
|
||||
const image = await win.webContents.capturePage()
|
||||
return {
|
||||
success: true,
|
||||
dataUrl: image.toDataURL(),
|
||||
size: image.getSize()
|
||||
}
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
})
|
||||
|
||||
// 密钥获取
|
||||
ipcMain.handle('key:autoGetDbKey', async (event) => {
|
||||
return keyService.autoGetDbKey(180_000, (message: string, level: number) => {
|
||||
|
||||
@@ -412,6 +412,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
generateReport: (year: number) => ipcRenderer.invoke('annualReport:generateReport', year),
|
||||
exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) =>
|
||||
ipcRenderer.invoke('annualReport:exportImages', payload),
|
||||
captureCurrentWindow: () => ipcRenderer.invoke('annualReport:captureCurrentWindow'),
|
||||
onAvailableYearsProgress: (callback: (payload: {
|
||||
taskId: string
|
||||
years?: number[]
|
||||
|
||||
@@ -59,6 +59,8 @@ export interface AnnualReportData {
|
||||
initiatedChats: number
|
||||
receivedChats: number
|
||||
initiativeRate: number
|
||||
topInitiatedFriend?: string
|
||||
topInitiatedCount?: number
|
||||
} | null
|
||||
responseSpeed: {
|
||||
avgResponseTime: number
|
||||
@@ -1190,7 +1192,9 @@ class AnnualReportService {
|
||||
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||
} | undefined
|
||||
|
||||
const snsStats = await wcdbService.getSnsAnnualStats(actualStartTime, actualEndTime)
|
||||
const snsBeginTime = isAllTime ? 0 : actualStartTime
|
||||
const snsEndTime = isAllTime ? Math.floor(Date.now() / 1000) : actualEndTime
|
||||
const snsStats = await wcdbService.getSnsAnnualStats(snsBeginTime, snsEndTime)
|
||||
|
||||
if (snsStats.success && snsStats.data) {
|
||||
const d = snsStats.data
|
||||
@@ -1217,6 +1221,20 @@ class AnnualReportService {
|
||||
}
|
||||
}
|
||||
|
||||
// ALL YEARS 兼容:部分底层实现 begin/end 为 0 时会返回 0,兜底使用导出统计总数。
|
||||
if (isAllTime && (!snsStatsResult || Number(snsStatsResult.totalPosts || 0) <= 0)) {
|
||||
const snsExportStats = await wcdbService.getSnsExportStats(cleanedWxid || rawWxid)
|
||||
if (snsExportStats.success && snsExportStats.data) {
|
||||
const fallbackTotalPosts = Math.max(0, Number(snsExportStats.data.totalPosts || 0))
|
||||
snsStatsResult = {
|
||||
totalPosts: fallbackTotalPosts,
|
||||
typeCounts: snsStatsResult?.typeCounts,
|
||||
topLikers: snsStatsResult?.topLikers || [],
|
||||
topLiked: snsStatsResult?.topLiked || []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.reportProgress('整理联系人信息...', 85, onProgress)
|
||||
|
||||
const contactIds = Array.from(contactStats.keys())
|
||||
@@ -1346,16 +1364,27 @@ class AnnualReportService {
|
||||
let socialInitiative: AnnualReportData['socialInitiative'] = null
|
||||
let totalInitiated = 0
|
||||
let totalReceived = 0
|
||||
for (const stats of conversationStarts.values()) {
|
||||
let topInitiatedSessionId = ''
|
||||
let topInitiatedCount = 0
|
||||
for (const [sessionId, stats] of conversationStarts.entries()) {
|
||||
totalInitiated += stats.initiated
|
||||
totalReceived += stats.received
|
||||
if (stats.initiated > topInitiatedCount) {
|
||||
topInitiatedCount = stats.initiated
|
||||
topInitiatedSessionId = sessionId
|
||||
}
|
||||
}
|
||||
const totalConversations = totalInitiated + totalReceived
|
||||
if (totalConversations > 0) {
|
||||
const topInitiatedInfo = topInitiatedSessionId ? contactInfoMap.get(topInitiatedSessionId) : null
|
||||
socialInitiative = {
|
||||
initiatedChats: totalInitiated,
|
||||
receivedChats: totalReceived,
|
||||
initiativeRate: Math.round((totalInitiated / totalConversations) * 1000) / 10
|
||||
initiativeRate: Math.round((totalInitiated / totalConversations) * 1000) / 10,
|
||||
topInitiatedFriend: topInitiatedCount > 0
|
||||
? (topInitiatedInfo?.displayName || topInitiatedSessionId)
|
||||
: undefined,
|
||||
topInitiatedCount: topInitiatedCount > 0 ? topInitiatedCount : undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,8 @@ interface ConfigSchema {
|
||||
aiInsightSilenceDays: number
|
||||
aiInsightAllowContext: boolean
|
||||
aiInsightAllowSocialContext: boolean
|
||||
aiInsightFilterMode: 'whitelist' | 'blacklist'
|
||||
aiInsightFilterList: string[]
|
||||
aiInsightWhitelistEnabled: boolean
|
||||
aiInsightWhitelist: string[]
|
||||
/** 活跃分析冷却时间(分钟),0 表示无冷却 */
|
||||
@@ -202,6 +204,8 @@ export class ConfigService {
|
||||
aiInsightSilenceDays: 3,
|
||||
aiInsightAllowContext: false,
|
||||
aiInsightAllowSocialContext: false,
|
||||
aiInsightFilterMode: 'whitelist',
|
||||
aiInsightFilterList: [],
|
||||
aiInsightWhitelistEnabled: false,
|
||||
aiInsightWhitelist: [],
|
||||
aiInsightCooldownMinutes: 120,
|
||||
|
||||
+515
-135
@@ -79,6 +79,9 @@ const MESSAGE_TYPE_MAP: Record<number, number> = {
|
||||
34: 2, // 语音 -> VOICE
|
||||
43: 3, // 视频 -> VIDEO
|
||||
49: 7, // 链接/文件 -> LINK (需要进一步判断)
|
||||
34359738417: 7, // 文件消息变体 -> LINK
|
||||
103079215153: 7, // 文件消息变体 -> LINK
|
||||
25769803825: 7, // 文件消息变体 -> LINK
|
||||
47: 5, // 表情包 -> EMOJI
|
||||
48: 8, // 位置 -> LOCATION
|
||||
42: 27, // 名片 -> CONTACT
|
||||
@@ -86,9 +89,13 @@ const MESSAGE_TYPE_MAP: Record<number, number> = {
|
||||
10000: 80, // 系统消息 -> SYSTEM
|
||||
}
|
||||
|
||||
// 与 chatService 的资源消息识别保持一致,覆盖桌面微信里的多种文件消息 localType。
|
||||
const FILE_APP_LOCAL_TYPES = [49, 34359738417, 103079215153, 25769803825] as const
|
||||
const FILE_APP_LOCAL_TYPE_SET = new Set<number>(FILE_APP_LOCAL_TYPES)
|
||||
|
||||
export interface ExportOptions {
|
||||
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
||||
contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji'
|
||||
contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file'
|
||||
dateRange?: { start: number; end: number } | null
|
||||
senderUsername?: string
|
||||
fileNameSuffix?: string
|
||||
@@ -137,11 +144,19 @@ interface ExportDisplayProfile {
|
||||
}
|
||||
|
||||
type MessageCollectMode = 'full' | 'text-fast' | 'media-fast'
|
||||
type MediaContentType = 'voice' | 'image' | 'video' | 'emoji'
|
||||
type MediaContentType = 'voice' | 'image' | 'video' | 'emoji' | 'file'
|
||||
interface FileExportCandidate {
|
||||
sourcePath: string
|
||||
matchedBy: 'md5' | 'name'
|
||||
yearMonth?: string
|
||||
preferredMonth?: boolean
|
||||
mtimeMs: number
|
||||
searchOrder: number
|
||||
}
|
||||
interface FileAttachmentSearchRoot {
|
||||
accountDir: string
|
||||
msgFileRoot?: string
|
||||
fileStorageRoot?: string
|
||||
}
|
||||
|
||||
export interface ExportProgress {
|
||||
@@ -501,6 +516,13 @@ class ExportService {
|
||||
.trim()
|
||||
}
|
||||
|
||||
private resolveFileAttachmentExtensionDir(msg: any, fileName: string): string {
|
||||
const rawExt = String(msg?.fileExt || '').trim() || path.extname(String(fileName || ''))
|
||||
const normalizedExt = rawExt.replace(/^\.+/, '').trim().toLowerCase()
|
||||
const safeExt = this.sanitizeExportFileNamePart(normalizedExt).replace(/\s+/g, '_')
|
||||
return safeExt || 'no-extension'
|
||||
}
|
||||
|
||||
private normalizeFileNamingMode(value: unknown): 'classic' | 'date-range' {
|
||||
return String(value || '').trim().toLowerCase() === 'date-range' ? 'date-range' : 'classic'
|
||||
}
|
||||
@@ -947,7 +969,7 @@ class ExportService {
|
||||
|
||||
private getMediaContentType(options: ExportOptions): MediaContentType | null {
|
||||
const value = options.contentType
|
||||
if (value === 'voice' || value === 'image' || value === 'video' || value === 'emoji') {
|
||||
if (value === 'voice' || value === 'image' || value === 'video' || value === 'emoji' || value === 'file') {
|
||||
return value
|
||||
}
|
||||
return null
|
||||
@@ -963,15 +985,117 @@ class ExportService {
|
||||
if (mediaContentType === 'image') return new Set([3])
|
||||
if (mediaContentType === 'video') return new Set([43])
|
||||
if (mediaContentType === 'emoji') return new Set([47])
|
||||
if (mediaContentType === 'file') return new Set(FILE_APP_LOCAL_TYPES)
|
||||
|
||||
const selected = new Set<number>()
|
||||
if (options.exportImages) selected.add(3)
|
||||
if (options.exportVoices) selected.add(34)
|
||||
if (options.exportVideos) selected.add(43)
|
||||
if (options.exportFiles) selected.add(49)
|
||||
if (options.exportFiles) {
|
||||
for (const fileType of FILE_APP_LOCAL_TYPES) {
|
||||
selected.add(fileType)
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
private isFileAppLocalType(localType: number): boolean {
|
||||
return FILE_APP_LOCAL_TYPE_SET.has(localType)
|
||||
}
|
||||
|
||||
private isFileOnlyMediaFilter(targetMediaTypes: Set<number> | null): boolean {
|
||||
return Boolean(
|
||||
targetMediaTypes &&
|
||||
targetMediaTypes.size === FILE_APP_LOCAL_TYPES.length &&
|
||||
FILE_APP_LOCAL_TYPES.every((fileType) => targetMediaTypes.has(fileType))
|
||||
)
|
||||
}
|
||||
|
||||
private getFileAppMessageHints(message: Record<string, any> | null | undefined): {
|
||||
xmlType?: string
|
||||
fileName?: string
|
||||
fileSize?: number
|
||||
fileExt?: string
|
||||
fileMd5?: string
|
||||
} {
|
||||
const xmlType = String(message?.xmlType ?? message?.xml_type ?? '').trim() || undefined
|
||||
const fileName = String(message?.fileName ?? message?.file_name ?? '').trim() || undefined
|
||||
const fileExt = String(message?.fileExt ?? message?.file_ext ?? '').trim() || undefined
|
||||
const fileSizeRaw = Number(message?.fileSize ?? message?.file_size ?? message?.total_len ?? message?.totalLen ?? message?.totallen ?? 0)
|
||||
const fileSize = Number.isFinite(fileSizeRaw) && fileSizeRaw > 0 ? Math.floor(fileSizeRaw) : undefined
|
||||
const fileMd5Raw = String(message?.fileMd5 ?? message?.file_md5 ?? '').trim()
|
||||
const fileMd5 = /^[a-f0-9]{32}$/i.test(fileMd5Raw) ? fileMd5Raw.toLowerCase() : undefined
|
||||
return { xmlType, fileName, fileSize, fileExt, fileMd5 }
|
||||
}
|
||||
|
||||
private hasFileAppMessageHints(message: Record<string, any> | null | undefined): boolean {
|
||||
const hints = this.getFileAppMessageHints(message)
|
||||
if (hints.xmlType) return hints.xmlType === '6'
|
||||
return Boolean(hints.fileName || hints.fileExt || hints.fileMd5 || hints.fileSize)
|
||||
}
|
||||
|
||||
private isFileAppMessage(msg: {
|
||||
localType?: unknown
|
||||
xmlType?: unknown
|
||||
xml_type?: unknown
|
||||
content?: unknown
|
||||
fileName?: unknown
|
||||
file_name?: unknown
|
||||
fileSize?: unknown
|
||||
file_size?: unknown
|
||||
fileExt?: unknown
|
||||
file_ext?: unknown
|
||||
fileMd5?: unknown
|
||||
file_md5?: unknown
|
||||
}): boolean {
|
||||
const { xmlType, fileName, fileExt, fileMd5, fileSize } = this.getFileAppMessageHints(msg as Record<string, any>)
|
||||
if (xmlType) return xmlType === '6'
|
||||
if (fileName || fileExt || fileMd5 || fileSize) return true
|
||||
|
||||
const normalized = this.normalizeAppMessageContent(String(msg?.content || ''))
|
||||
if (!normalized || (!normalized.includes('<appmsg') && !normalized.includes('<msg>'))) {
|
||||
return false
|
||||
}
|
||||
return this.extractAppMessageType(normalized) === '6'
|
||||
}
|
||||
|
||||
private extractFileAppMessageMeta(content: string): {
|
||||
xmlType?: string
|
||||
fileName?: string
|
||||
fileSize?: number
|
||||
fileExt?: string
|
||||
fileMd5?: string
|
||||
} | null {
|
||||
const normalized = this.normalizeAppMessageContent(content || '')
|
||||
if (!normalized || (!normalized.includes('<appmsg') && !normalized.includes('<msg>'))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const xmlType = this.extractAppMessageType(normalized)
|
||||
if (!xmlType) return null
|
||||
|
||||
const rawFileName = this.extractXmlValue(normalized, 'filename') || this.extractXmlValue(normalized, 'title')
|
||||
const rawFileExt = this.extractXmlValue(normalized, 'fileext')
|
||||
const rawFileSize =
|
||||
this.extractXmlValue(normalized, 'totallen') ||
|
||||
this.extractXmlValue(normalized, 'datasize') ||
|
||||
this.extractXmlValue(normalized, 'filesize')
|
||||
const rawFileMd5 =
|
||||
this.extractXmlValue(normalized, 'md5') ||
|
||||
this.extractXmlAttribute(normalized, 'appattach', 'md5') ||
|
||||
this.extractLooseHexMd5(normalized)
|
||||
const fileSize = Number.parseInt(rawFileSize, 10)
|
||||
const fileMd5 = String(rawFileMd5 || '').trim()
|
||||
|
||||
return {
|
||||
xmlType,
|
||||
fileName: this.decodeHtmlEntities(rawFileName).trim() || undefined,
|
||||
fileSize: Number.isFinite(fileSize) && fileSize > 0 ? fileSize : undefined,
|
||||
fileExt: this.decodeHtmlEntities(rawFileExt).trim() || undefined,
|
||||
fileMd5: /^[a-f0-9]{32}$/i.test(fileMd5) ? fileMd5.toLowerCase() : undefined
|
||||
}
|
||||
}
|
||||
|
||||
private resolveCollectMode(options: ExportOptions): MessageCollectMode {
|
||||
if (this.isMediaContentBatchExport(options)) {
|
||||
return 'media-fast'
|
||||
@@ -1020,12 +1144,17 @@ class ExportService {
|
||||
return true
|
||||
}
|
||||
|
||||
private shouldDecodeMessageContentInMediaMode(localType: number, targetMediaTypes: Set<number> | null): boolean {
|
||||
if (!targetMediaTypes || !targetMediaTypes.has(localType)) return false
|
||||
private shouldDecodeMessageContentInMediaMode(
|
||||
localType: number,
|
||||
targetMediaTypes: Set<number> | null,
|
||||
options?: { allowFileProbe?: boolean }
|
||||
): boolean {
|
||||
const allowFileProbe = options?.allowFileProbe === true
|
||||
if (!targetMediaTypes || (!targetMediaTypes.has(localType) && !allowFileProbe)) return false
|
||||
// 语音导出仅需要 localId 读取音频数据,不依赖 XML 内容
|
||||
if (localType === 34) return false
|
||||
// 图片/视频/表情可能需要从 XML 提取 md5/datName/cdnUrl
|
||||
if (localType === 3 || localType === 43 || localType === 47) return true
|
||||
// 图片/视频/表情/文件可能需要从 XML 提取 md5/datName/附件信息
|
||||
if (localType === 3 || localType === 43 || localType === 47 || this.isFileAppLocalType(localType) || allowFileProbe) return true
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -3628,7 +3757,7 @@ class ExportService {
|
||||
)
|
||||
}
|
||||
|
||||
if ((localType === 49 || localType === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') {
|
||||
if (options.exportFiles && this.isFileAppMessage(msg)) {
|
||||
return this.exportFileAttachment(
|
||||
msg,
|
||||
mediaRootDir,
|
||||
@@ -4183,33 +4312,104 @@ class ExportService {
|
||||
return this.normalizeVideoFileToken(this.extractVideoMd5(content || ''))
|
||||
}
|
||||
|
||||
private resolveFileAttachmentRoots(): string[] {
|
||||
private isFileAttachmentAccountDir(dirPath: string): boolean {
|
||||
if (!dirPath) return false
|
||||
return fs.existsSync(path.join(dirPath, 'db_storage')) ||
|
||||
fs.existsSync(path.join(dirPath, 'msg', 'file')) ||
|
||||
fs.existsSync(path.join(dirPath, 'FileStorage', 'File')) ||
|
||||
fs.existsSync(path.join(dirPath, 'FileStorage', 'Image')) ||
|
||||
fs.existsSync(path.join(dirPath, 'FileStorage', 'Image2'))
|
||||
}
|
||||
|
||||
private resolveAccountDirForFileExport(basePath: string, wxid: string): string | null {
|
||||
const cleanedWxid = this.cleanAccountDirName(wxid)
|
||||
if (!basePath || !cleanedWxid) return null
|
||||
|
||||
const normalized = path.resolve(basePath.replace(/[\\/]+$/, ''))
|
||||
const parentDir = path.dirname(normalized)
|
||||
const dbStorageParent = path.basename(normalized).toLowerCase() === 'db_storage'
|
||||
? path.dirname(normalized)
|
||||
: ''
|
||||
const fileInsideDbStorageParent = path.basename(parentDir).toLowerCase() === 'db_storage'
|
||||
? path.dirname(parentDir)
|
||||
: ''
|
||||
const candidateBases = Array.from(new Set([
|
||||
normalized,
|
||||
parentDir,
|
||||
path.join(normalized, 'WeChat Files'),
|
||||
path.join(parentDir, 'WeChat Files'),
|
||||
dbStorageParent,
|
||||
fileInsideDbStorageParent
|
||||
].filter(Boolean)))
|
||||
|
||||
const lowerWxid = cleanedWxid.toLowerCase()
|
||||
const tryResolveBase = (candidateBase: string): string | null => {
|
||||
if (!candidateBase || !fs.existsSync(candidateBase)) return null
|
||||
if (this.isFileAttachmentAccountDir(candidateBase)) return candidateBase
|
||||
|
||||
const direct = path.join(candidateBase, cleanedWxid)
|
||||
if (this.isFileAttachmentAccountDir(direct)) return direct
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(candidateBase, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const lowerEntry = entry.name.toLowerCase()
|
||||
if (lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)) {
|
||||
const entryPath = path.join(candidateBase, entry.name)
|
||||
if (this.isFileAttachmentAccountDir(entryPath)) {
|
||||
return entryPath
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
for (const candidateBase of candidateBases) {
|
||||
const resolved = tryResolveBase(candidateBase)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private resolveFileAttachmentSearchRoots(): FileAttachmentSearchRoot[] {
|
||||
const dbPath = String(this.configService.get('dbPath') || '').trim()
|
||||
const rawWxid = String(this.configService.get('myWxid') || '').trim()
|
||||
const cleanedWxid = this.cleanAccountDirName(rawWxid)
|
||||
if (!dbPath) return []
|
||||
|
||||
const normalized = dbPath.replace(/[\\/]+$/, '')
|
||||
const roots = new Set<string>()
|
||||
const tryAddRoot = (candidate: string) => {
|
||||
const fileRoot = path.join(candidate, 'msg', 'file')
|
||||
if (fs.existsSync(fileRoot)) {
|
||||
roots.add(fileRoot)
|
||||
const normalized = path.resolve(dbPath.replace(/[\\/]+$/, ''))
|
||||
const accountDirs = new Set<string>()
|
||||
const maybeAddAccountDir = (candidate: string | null | undefined) => {
|
||||
if (!candidate) return
|
||||
const resolved = path.resolve(candidate)
|
||||
if (this.isFileAttachmentAccountDir(resolved)) {
|
||||
accountDirs.add(resolved)
|
||||
}
|
||||
}
|
||||
|
||||
tryAddRoot(normalized)
|
||||
if (rawWxid) tryAddRoot(path.join(normalized, rawWxid))
|
||||
if (cleanedWxid && cleanedWxid !== rawWxid) tryAddRoot(path.join(normalized, cleanedWxid))
|
||||
maybeAddAccountDir(normalized)
|
||||
maybeAddAccountDir(path.dirname(normalized))
|
||||
|
||||
const dbStoragePath =
|
||||
this.resolveDbStoragePathForExport(normalized, cleanedWxid) ||
|
||||
this.resolveDbStoragePathForExport(normalized, rawWxid)
|
||||
if (dbStoragePath) {
|
||||
tryAddRoot(path.dirname(dbStoragePath))
|
||||
const wxidCandidates = Array.from(new Set([cleanedWxid, rawWxid].filter(Boolean)))
|
||||
for (const wxid of wxidCandidates) {
|
||||
maybeAddAccountDir(this.resolveAccountDirForFileExport(normalized, wxid))
|
||||
}
|
||||
|
||||
return Array.from(roots)
|
||||
return Array.from(accountDirs).map((accountDir) => {
|
||||
const msgFileRoot = path.join(accountDir, 'msg', 'file')
|
||||
const fileStorageRoot = path.join(accountDir, 'FileStorage', 'File')
|
||||
return {
|
||||
accountDir,
|
||||
msgFileRoot: fs.existsSync(msgFileRoot) ? msgFileRoot : undefined,
|
||||
fileStorageRoot: fs.existsSync(fileStorageRoot) ? fileStorageRoot : undefined
|
||||
}
|
||||
}).filter((root) => Boolean(root.msgFileRoot || root.fileStorageRoot))
|
||||
}
|
||||
|
||||
private buildPreferredFileYearMonths(createTime?: unknown): string[] {
|
||||
@@ -4241,52 +4441,147 @@ class ExportService {
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveFileAttachmentCandidates(msg: any): Promise<FileExportCandidate[]> {
|
||||
const fileName = String(msg?.fileName || '').trim()
|
||||
if (!fileName) return []
|
||||
private collectFileStorageCandidatesByName(rootDir: string, fileName: string, maxDepth = 3): string[] {
|
||||
const normalizedName = String(fileName || '').trim().toLowerCase()
|
||||
if (!rootDir || !normalizedName) return []
|
||||
|
||||
const roots = this.resolveFileAttachmentRoots()
|
||||
if (roots.length === 0) return []
|
||||
const matches: string[] = []
|
||||
const stack: Array<{ dir: string; depth: number }> = [{ dir: rootDir, depth: 0 }]
|
||||
|
||||
const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase()
|
||||
const preferredMonths = this.buildPreferredFileYearMonths(msg?.createTime)
|
||||
const candidates: FileExportCandidate[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const root of roots) {
|
||||
let monthDirs: string[] = []
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()!
|
||||
let entries: fs.Dirent[]
|
||||
try {
|
||||
monthDirs = fs.readdirSync(root)
|
||||
.filter(entry => /^\d{4}-\d{2}$/.test(entry) && fs.existsSync(path.join(root, entry)))
|
||||
.sort()
|
||||
entries = fs.readdirSync(current.dir, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const orderedMonths = Array.from(new Set([
|
||||
...preferredMonths,
|
||||
...monthDirs.slice().reverse()
|
||||
]))
|
||||
|
||||
for (const month of orderedMonths) {
|
||||
const sourcePath = path.join(root, month, fileName)
|
||||
if (!fs.existsSync(sourcePath)) continue
|
||||
const resolvedPath = path.resolve(sourcePath)
|
||||
if (seen.has(resolvedPath)) continue
|
||||
seen.add(resolvedPath)
|
||||
|
||||
if (normalizedMd5) {
|
||||
const ok = await this.verifyFileHash(resolvedPath, normalizedMd5)
|
||||
if (ok) {
|
||||
candidates.unshift({ sourcePath: resolvedPath, matchedBy: 'md5', yearMonth: month })
|
||||
continue
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(current.dir, entry.name)
|
||||
if (entry.isFile() && entry.name.toLowerCase() === normalizedName) {
|
||||
matches.push(entryPath)
|
||||
continue
|
||||
}
|
||||
if (entry.isDirectory() && current.depth < maxDepth) {
|
||||
stack.push({ dir: entryPath, depth: current.depth + 1 })
|
||||
}
|
||||
|
||||
candidates.push({ sourcePath: resolvedPath, matchedBy: 'name', yearMonth: month })
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
private getFileAttachmentLogContext(msg: any): Record<string, unknown> {
|
||||
return {
|
||||
localId: msg?.localId,
|
||||
createTime: msg?.createTime,
|
||||
localType: msg?.localType,
|
||||
xmlType: msg?.xmlType,
|
||||
fileName: msg?.fileName,
|
||||
fileMd5: msg?.fileMd5
|
||||
}
|
||||
}
|
||||
|
||||
private logFileAttachmentEvent(
|
||||
level: 'warn' | 'error',
|
||||
action: string,
|
||||
msg: any,
|
||||
extra: Record<string, unknown> = {}
|
||||
): void {
|
||||
const logger = level === 'error' ? console.error : console.warn
|
||||
logger(`[Export][File] ${action}`, {
|
||||
...this.getFileAttachmentLogContext(msg),
|
||||
...extra
|
||||
})
|
||||
}
|
||||
|
||||
private recordFileAttachmentMiss(msg: any, action: string, extra: Record<string, unknown> = {}): void {
|
||||
this.logFileAttachmentEvent('warn', action, msg, extra)
|
||||
this.noteMediaTelemetry({ cacheMissFiles: 1 })
|
||||
}
|
||||
|
||||
private async resolveFileAttachmentCandidates(msg: any): Promise<FileExportCandidate[]> {
|
||||
const fileName = String(msg?.fileName || '').trim()
|
||||
if (!fileName) return []
|
||||
|
||||
const roots = this.resolveFileAttachmentSearchRoots()
|
||||
if (roots.length === 0) return []
|
||||
|
||||
const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase()
|
||||
const preferredMonths = new Set(this.buildPreferredFileYearMonths(msg?.createTime))
|
||||
const candidates: FileExportCandidate[] = []
|
||||
const seen = new Set<string>()
|
||||
let searchOrder = 0
|
||||
|
||||
const appendCandidate = async (sourcePath: string, yearMonth?: string) => {
|
||||
if (!sourcePath || !fs.existsSync(sourcePath)) return
|
||||
|
||||
const resolvedPath = path.resolve(sourcePath)
|
||||
if (seen.has(resolvedPath)) return
|
||||
|
||||
let stat: fs.Stats
|
||||
try {
|
||||
stat = await fs.promises.stat(resolvedPath)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!stat.isFile()) return
|
||||
|
||||
seen.add(resolvedPath)
|
||||
const matchedBy = normalizedMd5 && await this.verifyFileHash(resolvedPath, normalizedMd5) ? 'md5' : 'name'
|
||||
candidates.push({
|
||||
sourcePath: resolvedPath,
|
||||
matchedBy,
|
||||
yearMonth,
|
||||
preferredMonth: Boolean(yearMonth && preferredMonths.has(yearMonth)),
|
||||
mtimeMs: Number.isFinite(stat.mtimeMs) ? stat.mtimeMs : 0,
|
||||
searchOrder: searchOrder++
|
||||
})
|
||||
}
|
||||
|
||||
for (const root of roots) {
|
||||
if (root.msgFileRoot) {
|
||||
for (const month of preferredMonths) {
|
||||
await appendCandidate(path.join(root.msgFileRoot, month, fileName), month)
|
||||
}
|
||||
|
||||
let monthDirs: string[] = []
|
||||
try {
|
||||
monthDirs = fs.readdirSync(root.msgFileRoot, { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory() && /^\d{4}-\d{2}$/.test(entry.name) && !preferredMonths.has(entry.name))
|
||||
.map(entry => entry.name)
|
||||
.sort()
|
||||
} catch {
|
||||
monthDirs = []
|
||||
}
|
||||
|
||||
for (const month of monthDirs) {
|
||||
await appendCandidate(path.join(root.msgFileRoot, month, fileName), month)
|
||||
}
|
||||
await appendCandidate(path.join(root.msgFileRoot, fileName))
|
||||
}
|
||||
|
||||
if (root.fileStorageRoot) {
|
||||
for (const candidatePath of this.collectFileStorageCandidatesByName(root.fileStorageRoot, fileName, 3)) {
|
||||
await appendCandidate(candidatePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort((left, right) => {
|
||||
if (left.matchedBy !== right.matchedBy) {
|
||||
return left.matchedBy === 'md5' ? -1 : 1
|
||||
}
|
||||
if (left.preferredMonth !== right.preferredMonth) {
|
||||
return left.preferredMonth ? -1 : 1
|
||||
}
|
||||
if (left.mtimeMs !== right.mtimeMs) {
|
||||
return right.mtimeMs - left.mtimeMs
|
||||
}
|
||||
return left.searchOrder - right.searchOrder
|
||||
})
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
@@ -4301,14 +4596,20 @@ class ExportService {
|
||||
const fileNameRaw = String(msg?.fileName || '').trim()
|
||||
if (!fileNameRaw) return null
|
||||
|
||||
const filesDir = path.join(mediaRootDir, mediaRelativePrefix, 'files')
|
||||
if (!dirCache?.has(filesDir)) {
|
||||
await fs.promises.mkdir(filesDir, { recursive: true })
|
||||
dirCache?.add(filesDir)
|
||||
const fileExtDir = this.resolveFileAttachmentExtensionDir(msg, fileNameRaw)
|
||||
const fileDir = path.join(mediaRootDir, mediaRelativePrefix, 'file', fileExtDir)
|
||||
if (!dirCache?.has(fileDir)) {
|
||||
await fs.promises.mkdir(fileDir, { recursive: true })
|
||||
dirCache?.add(fileDir)
|
||||
}
|
||||
|
||||
const candidates = await this.resolveFileAttachmentCandidates(msg)
|
||||
if (candidates.length === 0) return null
|
||||
if (candidates.length === 0) {
|
||||
this.recordFileAttachmentMiss(msg, '附件候选未命中', {
|
||||
searchRoots: this.resolveFileAttachmentSearchRoots().map(root => root.accountDir)
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const maxBytes = Number.isFinite(maxFileSizeMb)
|
||||
? Math.max(0, Math.floor(Number(maxFileSizeMb) * 1024 * 1024))
|
||||
@@ -4316,28 +4617,54 @@ class ExportService {
|
||||
|
||||
const selected = candidates[0]
|
||||
const stat = await fs.promises.stat(selected.sourcePath)
|
||||
if (!stat.isFile()) return null
|
||||
if (maxBytes > 0 && stat.size > maxBytes) return null
|
||||
if (!stat.isFile()) {
|
||||
this.recordFileAttachmentMiss(msg, '附件候选不是普通文件', {
|
||||
sourcePath: selected.sourcePath
|
||||
})
|
||||
return null
|
||||
}
|
||||
if (maxBytes > 0 && stat.size > maxBytes) {
|
||||
this.recordFileAttachmentMiss(msg, '附件超过大小限制', {
|
||||
sourcePath: selected.sourcePath,
|
||||
size: stat.size,
|
||||
maxBytes
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase()
|
||||
if (normalizedMd5 && selected.matchedBy !== 'md5') {
|
||||
const verified = await this.verifyFileHash(selected.sourcePath, normalizedMd5)
|
||||
if (!verified) return null
|
||||
this.recordFileAttachmentMiss(msg, '附件哈希校验失败', {
|
||||
sourcePath: selected.sourcePath,
|
||||
expectedMd5: normalizedMd5
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const safeBaseName = path.basename(fileNameRaw).replace(/[\\/:*?"<>|]/g, '_') || 'file'
|
||||
const messageId = String(msg?.localId || Date.now())
|
||||
const destFileName = `${messageId}_${safeBaseName}`
|
||||
const destPath = path.join(filesDir, destFileName)
|
||||
const destPath = path.join(fileDir, destFileName)
|
||||
const copied = await this.copyFileOptimized(selected.sourcePath, destPath)
|
||||
if (!copied.success) return null
|
||||
if (!copied.success) {
|
||||
this.recordFileAttachmentMiss(msg, '附件复制失败', {
|
||||
sourcePath: selected.sourcePath,
|
||||
destPath,
|
||||
code: copied.code
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: stat.size })
|
||||
return {
|
||||
relativePath: path.posix.join(mediaRelativePrefix, 'files', destFileName),
|
||||
relativePath: path.posix.join(mediaRelativePrefix, 'file', fileExtDir, destFileName),
|
||||
kind: 'file'
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
this.logFileAttachmentEvent('error', '附件导出异常', msg, {
|
||||
error: error instanceof Error ? error.message : String(error || 'unknown')
|
||||
})
|
||||
this.noteMediaTelemetry({ cacheMissFiles: 1 })
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -4420,6 +4747,38 @@ class ExportService {
|
||||
return { exportMediaEnabled, mediaRootDir: outputDir, mediaRelativePrefix }
|
||||
}
|
||||
|
||||
private collectMediaMessagesForExport(messages: any[], options: ExportOptions): any[] {
|
||||
if (!this.isMediaExportEnabled(options)) return []
|
||||
|
||||
return messages.filter((msg) => {
|
||||
const localType = Number(msg?.localType || 0)
|
||||
return (localType === 3 && options.exportImages) ||
|
||||
(localType === 47 && options.exportEmojis) ||
|
||||
(localType === 43 && options.exportVideos) ||
|
||||
(localType === 34 && options.exportVoices) ||
|
||||
(options.exportFiles === true && this.isFileAppMessage(msg))
|
||||
})
|
||||
}
|
||||
|
||||
private getMediaDoneFilesCount(): number {
|
||||
return this.mediaExportTelemetry?.doneFiles ?? 0
|
||||
}
|
||||
|
||||
private buildFileOnlyExportFailure(
|
||||
options: ExportOptions,
|
||||
mediaMessages: any[],
|
||||
beforeDoneFiles: number
|
||||
): { success: boolean; error?: string } | null {
|
||||
if (options.contentType !== 'file') return null
|
||||
if (!mediaMessages.some(msg => this.isFileAppMessage(msg))) return null
|
||||
if (this.getMediaDoneFilesCount() > beforeDoneFiles) return null
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: '检测到文件消息,但未找到可导出的源文件,请检查数据库路径或文件存储目录配置'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
*/
|
||||
@@ -4485,6 +4844,7 @@ class ExportService {
|
||||
const mediaTypeFilter = collectMode === 'media-fast' && targetMediaTypes && targetMediaTypes.size > 0
|
||||
? targetMediaTypes
|
||||
: null
|
||||
const fileOnlyMediaFilter = this.isFileOnlyMediaFilter(mediaTypeFilter)
|
||||
|
||||
// 修复时间范围:0 表示不限制,而不是时间戳 0
|
||||
const beginTime = dateRange?.start || 0
|
||||
@@ -4545,12 +4905,14 @@ class ExportService {
|
||||
const localType = this.getIntFromRow(row, [
|
||||
'local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'
|
||||
], 1)
|
||||
if (mediaTypeFilter && !mediaTypeFilter.has(localType)) {
|
||||
const rowFileHints = this.getFileAppMessageHints(row)
|
||||
const allowFileProbe = fileOnlyMediaFilter && this.hasFileAppMessageHints(row)
|
||||
if (mediaTypeFilter && !mediaTypeFilter.has(localType) && !allowFileProbe) {
|
||||
continue
|
||||
}
|
||||
const shouldDecodeContent = collectMode === 'full'
|
||||
|| (collectMode === 'text-fast' && this.shouldDecodeMessageContentInFastMode(localType))
|
||||
|| (collectMode === 'media-fast' && this.shouldDecodeMessageContentInMediaMode(localType, mediaTypeFilter))
|
||||
|| (collectMode === 'media-fast' && this.shouldDecodeMessageContentInMediaMode(localType, mediaTypeFilter, { allowFileProbe }))
|
||||
const content = shouldDecodeContent
|
||||
? this.decodeMessageContent(row.message_content, row.compress_content)
|
||||
: ''
|
||||
@@ -4619,6 +4981,11 @@ class ExportService {
|
||||
let locationLabel: string | undefined
|
||||
let chatRecordList: any[] | undefined
|
||||
let emojiCaption: string | undefined
|
||||
let xmlType: string | undefined
|
||||
let fileName: string | undefined
|
||||
let fileSize: number | undefined
|
||||
let fileExt: string | undefined
|
||||
let fileMd5: string | undefined
|
||||
|
||||
if (localType === 48 && content) {
|
||||
const locationMeta = this.extractLocationMeta(content, localType)
|
||||
@@ -4649,6 +5016,22 @@ class ExportService {
|
||||
imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined
|
||||
imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined
|
||||
videoMd5 = this.extractVideoFileNameFromRow(row, content)
|
||||
xmlType = rowFileHints.xmlType
|
||||
fileName = rowFileHints.fileName
|
||||
fileExt = rowFileHints.fileExt
|
||||
fileSize = rowFileHints.fileSize
|
||||
fileMd5 = rowFileHints.fileMd5
|
||||
|
||||
if (content && (this.isFileAppLocalType(localType) || allowFileProbe || this.hasFileAppMessageHints({ xmlType, fileName, fileSize, fileExt, fileMd5 }))) {
|
||||
const fileMeta = this.extractFileAppMessageMeta(content)
|
||||
if (fileMeta) {
|
||||
xmlType = fileMeta.xmlType || xmlType
|
||||
fileName = fileMeta.fileName || fileName
|
||||
fileSize = fileMeta.fileSize || fileSize
|
||||
fileExt = fileMeta.fileExt || fileExt
|
||||
fileMd5 = fileMeta.fileMd5 || fileMd5
|
||||
}
|
||||
}
|
||||
|
||||
if (localType === 3 && content) {
|
||||
// 图片消息
|
||||
@@ -4667,6 +5050,10 @@ class ExportService {
|
||||
}
|
||||
}
|
||||
|
||||
if (fileOnlyMediaFilter && !this.isFileAppMessage({ localType, xmlType, content, fileName, fileExt, fileMd5, fileSize })) {
|
||||
continue
|
||||
}
|
||||
|
||||
rows.push({
|
||||
localId,
|
||||
serverId,
|
||||
@@ -4682,6 +5069,11 @@ class ExportService {
|
||||
emojiMd5,
|
||||
emojiCaption,
|
||||
videoMd5,
|
||||
xmlType,
|
||||
fileName,
|
||||
fileSize,
|
||||
fileExt,
|
||||
fileMd5,
|
||||
locationLat,
|
||||
locationLng,
|
||||
locationPoiname,
|
||||
@@ -4746,7 +5138,12 @@ class ExportService {
|
||||
targetMediaTypes: Set<number>,
|
||||
control?: ExportTaskControl
|
||||
): Promise<void> {
|
||||
const fileOnlyMediaFilter = this.isFileOnlyMediaFilter(targetMediaTypes)
|
||||
const needsBackfill = rows.filter((msg) => {
|
||||
const isFileCandidate = this.isFileAppLocalType(Number(msg.localType || 0)) || (fileOnlyMediaFilter && this.hasFileAppMessageHints(msg))
|
||||
if (isFileCandidate) {
|
||||
return !msg.xmlType || !msg.fileName || !msg.fileMd5 || !msg.fileSize || !msg.fileExt
|
||||
}
|
||||
if (!targetMediaTypes.has(msg.localType)) return false
|
||||
if (msg.localType === 3) return !msg.imageMd5 && !msg.imageDatName
|
||||
if (msg.localType === 47) return !msg.emojiMd5
|
||||
@@ -4803,6 +5200,24 @@ class ExportService {
|
||||
if (msg.localType === 43) {
|
||||
const videoMd5 = this.extractVideoFileNameFromRow(row, content)
|
||||
if (videoMd5) msg.videoMd5 = videoMd5
|
||||
return
|
||||
}
|
||||
|
||||
if (this.isFileAppLocalType(Number(msg.localType || 0)) || this.hasFileAppMessageHints(msg)) {
|
||||
const rowFileHints = this.getFileAppMessageHints(row)
|
||||
const fileMeta = this.extractFileAppMessageMeta(content)
|
||||
const mergedFileMeta = {
|
||||
xmlType: fileMeta?.xmlType || rowFileHints.xmlType,
|
||||
fileName: fileMeta?.fileName || rowFileHints.fileName,
|
||||
fileSize: fileMeta?.fileSize || rowFileHints.fileSize,
|
||||
fileExt: fileMeta?.fileExt || rowFileHints.fileExt,
|
||||
fileMd5: fileMeta?.fileMd5 || rowFileHints.fileMd5
|
||||
}
|
||||
if (mergedFileMeta.xmlType) msg.xmlType = mergedFileMeta.xmlType
|
||||
if (mergedFileMeta.fileName) msg.fileName = mergedFileMeta.fileName
|
||||
if (mergedFileMeta.fileSize) msg.fileSize = mergedFileMeta.fileSize
|
||||
if (mergedFileMeta.fileExt) msg.fileExt = mergedFileMeta.fileExt
|
||||
if (mergedFileMeta.fileMd5) msg.fileMd5 = mergedFileMeta.fileMd5
|
||||
}
|
||||
} catch (error) {
|
||||
// 详情补取失败时保持降级导出(占位符),避免中断整批任务。
|
||||
@@ -5329,19 +5744,11 @@ class ExportService {
|
||||
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||
|
||||
// ========== 阶段1:并行导出媒体文件 ==========
|
||||
const mediaMessages = exportMediaEnabled
|
||||
? allMessages.filter(msg => {
|
||||
const t = msg.localType
|
||||
return (t === 3 && options.exportImages) || // 图片
|
||||
(t === 47 && options.exportEmojis) || // 表情
|
||||
(t === 43 && options.exportVideos) || // 视频
|
||||
(t === 34 && options.exportVoices) || // 语音文件
|
||||
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
|
||||
})
|
||||
: []
|
||||
const mediaMessages = this.collectMediaMessagesForExport(allMessages, options)
|
||||
|
||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||
const mediaDirCache = new Set<string>()
|
||||
const beforeMediaDoneFiles = this.getMediaDoneFilesCount()
|
||||
|
||||
if (mediaMessages.length > 0) {
|
||||
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
|
||||
@@ -5400,6 +5807,8 @@ class ExportService {
|
||||
}
|
||||
})
|
||||
}
|
||||
const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
|
||||
if (fileOnlyExportFailure) return fileOnlyExportFailure
|
||||
|
||||
// ========== 阶段2:并行语音转文字 ==========
|
||||
const voiceTranscriptMap = new Map<string, string>()
|
||||
@@ -5840,19 +6249,11 @@ class ExportService {
|
||||
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||
|
||||
// ========== 阶段1:并行导出媒体文件 ==========
|
||||
const mediaMessages = exportMediaEnabled
|
||||
? collected.rows.filter(msg => {
|
||||
const t = msg.localType
|
||||
return (t === 3 && options.exportImages) ||
|
||||
(t === 47 && options.exportEmojis) ||
|
||||
(t === 43 && options.exportVideos) ||
|
||||
(t === 34 && options.exportVoices) ||
|
||||
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
|
||||
})
|
||||
: []
|
||||
const mediaMessages = this.collectMediaMessagesForExport(collected.rows, options)
|
||||
|
||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||
const mediaDirCache = new Set<string>()
|
||||
const beforeMediaDoneFiles = this.getMediaDoneFilesCount()
|
||||
|
||||
if (mediaMessages.length > 0) {
|
||||
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
|
||||
@@ -5910,6 +6311,8 @@ class ExportService {
|
||||
}
|
||||
})
|
||||
}
|
||||
const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
|
||||
if (fileOnlyExportFailure) return fileOnlyExportFailure
|
||||
|
||||
// ========== 阶段2:并行语音转文字 ==========
|
||||
const voiceTranscriptMap = new Map<string, string>()
|
||||
@@ -6711,19 +7114,11 @@ class ExportService {
|
||||
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||
|
||||
// ========== 并行预处理:媒体文件 ==========
|
||||
const mediaMessages = exportMediaEnabled
|
||||
? sortedMessages.filter(msg => {
|
||||
const t = msg.localType
|
||||
return (t === 3 && options.exportImages) ||
|
||||
(t === 47 && options.exportEmojis) ||
|
||||
(t === 43 && options.exportVideos) ||
|
||||
(t === 34 && options.exportVoices) ||
|
||||
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
|
||||
})
|
||||
: []
|
||||
const mediaMessages = this.collectMediaMessagesForExport(sortedMessages, options)
|
||||
|
||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||
const mediaDirCache = new Set<string>()
|
||||
const beforeMediaDoneFiles = this.getMediaDoneFilesCount()
|
||||
|
||||
if (mediaMessages.length > 0) {
|
||||
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
|
||||
@@ -6781,6 +7176,8 @@ class ExportService {
|
||||
}
|
||||
})
|
||||
}
|
||||
const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
|
||||
if (fileOnlyExportFailure) return fileOnlyExportFailure
|
||||
|
||||
// ========== 并行预处理:语音转文字 ==========
|
||||
const voiceTranscriptMap = new Map<string, string>()
|
||||
@@ -7461,19 +7858,11 @@ class ExportService {
|
||||
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
|
||||
|
||||
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||
const mediaMessages = exportMediaEnabled
|
||||
? sortedMessages.filter(msg => {
|
||||
const t = msg.localType
|
||||
return (t === 3 && options.exportImages) ||
|
||||
(t === 47 && options.exportEmojis) ||
|
||||
(t === 43 && options.exportVideos) ||
|
||||
(t === 34 && options.exportVoices) ||
|
||||
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
|
||||
})
|
||||
: []
|
||||
const mediaMessages = this.collectMediaMessagesForExport(sortedMessages, options)
|
||||
|
||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||
const mediaDirCache = new Set<string>()
|
||||
const beforeMediaDoneFiles = this.getMediaDoneFilesCount()
|
||||
|
||||
if (mediaMessages.length > 0) {
|
||||
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
|
||||
@@ -7531,6 +7920,8 @@ class ExportService {
|
||||
}
|
||||
})
|
||||
}
|
||||
const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
|
||||
if (fileOnlyExportFailure) return fileOnlyExportFailure
|
||||
|
||||
const voiceTranscriptMap = new Map<string, string>()
|
||||
|
||||
@@ -7840,19 +8231,11 @@ class ExportService {
|
||||
}
|
||||
|
||||
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||
const mediaMessages = exportMediaEnabled
|
||||
? sortedMessages.filter(msg => {
|
||||
const t = msg.localType
|
||||
return (t === 3 && options.exportImages) ||
|
||||
(t === 47 && options.exportEmojis) ||
|
||||
(t === 43 && options.exportVideos) ||
|
||||
(t === 34 && options.exportVoices) ||
|
||||
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
|
||||
})
|
||||
: []
|
||||
const mediaMessages = this.collectMediaMessagesForExport(sortedMessages, options)
|
||||
|
||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||
const mediaDirCache = new Set<string>()
|
||||
const beforeMediaDoneFiles = this.getMediaDoneFilesCount()
|
||||
|
||||
if (mediaMessages.length > 0) {
|
||||
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
|
||||
@@ -7910,6 +8293,8 @@ class ExportService {
|
||||
}
|
||||
})
|
||||
}
|
||||
const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
|
||||
if (fileOnlyExportFailure) return fileOnlyExportFailure
|
||||
|
||||
const voiceTranscriptMap = new Map<string, string>()
|
||||
|
||||
@@ -8263,18 +8648,11 @@ class ExportService {
|
||||
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
|
||||
|
||||
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||
const mediaMessages = exportMediaEnabled
|
||||
? sortedMessages.filter(msg => {
|
||||
const t = msg.localType
|
||||
return (t === 3 && options.exportImages) ||
|
||||
(t === 47 && options.exportEmojis) ||
|
||||
(t === 34 && options.exportVoices) ||
|
||||
(t === 43 && options.exportVideos)
|
||||
})
|
||||
: []
|
||||
const mediaMessages = this.collectMediaMessagesForExport(sortedMessages, options)
|
||||
|
||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||
const mediaDirCache = new Set<string>()
|
||||
const beforeMediaDoneFiles = this.getMediaDoneFilesCount()
|
||||
|
||||
if (mediaMessages.length > 0) {
|
||||
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
|
||||
@@ -8333,6 +8711,8 @@ class ExportService {
|
||||
}
|
||||
})
|
||||
}
|
||||
const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
|
||||
if (fileOnlyExportFailure) return fileOnlyExportFailure
|
||||
|
||||
const useVoiceTranscript = options.exportVoiceAsText === true
|
||||
const voiceMessages = useVoiceTranscript
|
||||
@@ -9051,7 +9431,7 @@ class ExportService {
|
||||
: options
|
||||
|
||||
const exportMediaEnabled = effectiveOptions.exportMedia === true &&
|
||||
Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis)
|
||||
Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis || effectiveOptions.exportFiles)
|
||||
attachMediaTelemetry = exportMediaEnabled
|
||||
if (exportMediaEnabled) {
|
||||
this.triggerMediaFileCacheCleanup()
|
||||
|
||||
@@ -104,8 +104,6 @@ export class ImageDecryptService {
|
||||
const timestamp = new Date().toISOString()
|
||||
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||
const logLine = `[${timestamp}] [ImageDecrypt] ${message}${metaStr}\n`
|
||||
|
||||
// 只写入文件,不输出到控制台
|
||||
this.writeLog(logLine)
|
||||
}
|
||||
|
||||
@@ -115,11 +113,7 @@ export class ImageDecryptService {
|
||||
const errorStr = error ? ` Error: ${String(error)}` : ''
|
||||
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||
const logLine = `[${timestamp}] [ImageDecrypt] ERROR: ${message}${errorStr}${metaStr}\n`
|
||||
|
||||
// 同时输出到控制台
|
||||
console.error(message, error, meta)
|
||||
|
||||
// 写入日志文件
|
||||
this.writeLog(logLine)
|
||||
}
|
||||
|
||||
@@ -143,7 +137,7 @@ export class ImageDecryptService {
|
||||
}
|
||||
for (const key of cacheKeys) {
|
||||
const cached = this.resolvedCache.get(key)
|
||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||
if (cached && existsSync(cached) && this.isUsableImageCacheFile(cached)) {
|
||||
const upgraded = !this.isHdPath(cached)
|
||||
? await this.tryPromoteThumbnailCache(payload, key, cached)
|
||||
: null
|
||||
@@ -161,7 +155,7 @@ export class ImageDecryptService {
|
||||
this.emitCacheResolved(payload, key, this.resolveEmitPath(finalPath, payload.preferFilePath))
|
||||
return { success: true, localPath, hasUpdate }
|
||||
}
|
||||
if (cached && !this.isImageFile(cached)) {
|
||||
if (cached && !this.isUsableImageCacheFile(cached)) {
|
||||
this.resolvedCache.delete(key)
|
||||
}
|
||||
}
|
||||
@@ -219,7 +213,7 @@ export class ImageDecryptService {
|
||||
if (payload.force) {
|
||||
for (const key of cacheKeys) {
|
||||
const cached = this.resolvedCache.get(key)
|
||||
if (cached && existsSync(cached) && this.isImageFile(cached) && this.isHdPath(cached)) {
|
||||
if (cached && existsSync(cached) && this.isUsableImageCacheFile(cached) && this.isHdPath(cached)) {
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, cached)
|
||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
|
||||
@@ -227,7 +221,7 @@ export class ImageDecryptService {
|
||||
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
|
||||
return { success: true, localPath }
|
||||
}
|
||||
if (cached && !this.isImageFile(cached)) {
|
||||
if (cached && !this.isUsableImageCacheFile(cached)) {
|
||||
this.resolvedCache.delete(key)
|
||||
}
|
||||
}
|
||||
@@ -236,7 +230,7 @@ export class ImageDecryptService {
|
||||
|
||||
if (!payload.force) {
|
||||
const cached = this.resolvedCache.get(cacheKey)
|
||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||
if (cached && existsSync(cached) && this.isUsableImageCacheFile(cached)) {
|
||||
const upgraded = !this.isHdPath(cached)
|
||||
? await this.tryPromoteThumbnailCache(payload, cacheKey, cached)
|
||||
: null
|
||||
@@ -246,7 +240,7 @@ export class ImageDecryptService {
|
||||
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
|
||||
return { success: true, localPath }
|
||||
}
|
||||
if (cached && !this.isImageFile(cached)) {
|
||||
if (cached && !this.isUsableImageCacheFile(cached)) {
|
||||
this.resolvedCache.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
@@ -1404,7 +1398,8 @@ export class ImageDecryptService {
|
||||
private findCachedOutputByDatPath(datPath: string, sessionId?: string, preferHd = false): string | null {
|
||||
const candidates = this.buildCacheOutputCandidatesFromDat(datPath, sessionId, preferHd)
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate
|
||||
if (!existsSync(candidate)) continue
|
||||
if (this.isUsableImageCacheFile(candidate)) return candidate
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1630,6 +1625,73 @@ export class ImageDecryptService {
|
||||
return ext === '.gif' || ext === '.png' || ext === '.jpg' || ext === '.jpeg' || ext === '.webp'
|
||||
}
|
||||
|
||||
private isUsableImageCacheFile(filePath: string): boolean {
|
||||
if (!this.isImageFile(filePath)) return false
|
||||
if (!existsSync(filePath)) return false
|
||||
if (this.isLikelyCorruptedDecodedImage(filePath)) {
|
||||
this.logInfo('[ImageDecrypt] 跳过疑似损坏缓存文件', { filePath })
|
||||
void rm(filePath, { force: true }).catch(() => { })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private isLikelyCorruptedDecodedImage(filePath: string): boolean {
|
||||
try {
|
||||
const ext = extname(filePath).toLowerCase()
|
||||
if (ext !== '.jpg' && ext !== '.jpeg') return false
|
||||
const data = readFileSync(filePath)
|
||||
return this.isLikelyCorruptedJpegBuffer(data)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private isLikelyCorruptedJpegBuffer(data: Buffer): boolean {
|
||||
if (data.length < 4096) return false
|
||||
let zeroCount = 0
|
||||
for (let i = 0; i < data.length; i += 1) {
|
||||
if (data[i] === 0x00) zeroCount += 1
|
||||
}
|
||||
const zeroRatio = zeroCount / data.length
|
||||
if (zeroRatio >= 0.985) return true
|
||||
|
||||
const hasLavcTag = data.length >= 24 && data.subarray(0, 24).includes(Buffer.from('Lavc'))
|
||||
if (!hasLavcTag) return false
|
||||
|
||||
// JPEG 扫描段若几乎全是 0,通常表示解码失败但被编码器强行输出。
|
||||
let sosPos = -1
|
||||
for (let i = 2; i < data.length - 1; i += 1) {
|
||||
if (data[i] === 0xff && data[i + 1] === 0xda) {
|
||||
sosPos = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if (sosPos < 0 || sosPos + 4 >= data.length) return zeroRatio >= 0.95
|
||||
|
||||
const sosLength = (data[sosPos + 2] << 8) | data[sosPos + 3]
|
||||
const scanStart = sosPos + 2 + sosLength
|
||||
if (scanStart >= data.length - 2) return zeroRatio >= 0.95
|
||||
|
||||
let eoiPos = -1
|
||||
for (let i = data.length - 2; i >= scanStart; i -= 1) {
|
||||
if (data[i] === 0xff && data[i + 1] === 0xd9) {
|
||||
eoiPos = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if (eoiPos < 0 || eoiPos <= scanStart) return zeroRatio >= 0.95
|
||||
|
||||
const scanData = data.subarray(scanStart, eoiPos)
|
||||
if (scanData.length < 1024) return zeroRatio >= 0.95
|
||||
let scanZeroCount = 0
|
||||
for (let i = 0; i < scanData.length; i += 1) {
|
||||
if (scanData[i] === 0x00) scanZeroCount += 1
|
||||
}
|
||||
const scanZeroRatio = scanZeroCount / scanData.length
|
||||
return scanZeroRatio >= 0.985
|
||||
}
|
||||
|
||||
/**
|
||||
* 解包 wxgf 格式
|
||||
* wxgf 是微信的图片格式,内部使用 HEVC 编码
|
||||
@@ -1653,41 +1715,96 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
|
||||
// 提取 HEVC NALU 裸流
|
||||
const hevcData = this.extractHevcNalu(buffer)
|
||||
// 优先用提取的 NALU 裸流,提取失败则跳过 wxgf 头部直接用原始数据
|
||||
const feedData = (hevcData && hevcData.length >= 100) ? hevcData : buffer.subarray(4)
|
||||
const hevcCandidates = this.buildWxgfHevcCandidates(buffer)
|
||||
this.logInfo('unwrapWxgf: 准备 ffmpeg 转换', {
|
||||
naluExtracted: !!(hevcData && hevcData.length >= 100),
|
||||
feedSize: feedData.length
|
||||
candidateCount: hevcCandidates.length,
|
||||
candidates: hevcCandidates.map((item) => `${item.name}:${item.data.length}`)
|
||||
})
|
||||
|
||||
// 尝试用 ffmpeg 转换
|
||||
try {
|
||||
const jpgData = await this.convertHevcToJpg(feedData)
|
||||
if (jpgData && jpgData.length > 0) {
|
||||
for (const candidate of hevcCandidates) {
|
||||
try {
|
||||
const jpgData = await this.convertHevcToJpg(candidate.data)
|
||||
if (!jpgData || jpgData.length === 0) continue
|
||||
return { data: jpgData, isWxgf: false }
|
||||
} catch (e) {
|
||||
this.logError('unwrapWxgf: 候选流转换失败', e, { candidate: candidate.name })
|
||||
}
|
||||
} catch (e) {
|
||||
this.logError('unwrapWxgf: ffmpeg 转换失败', e)
|
||||
}
|
||||
|
||||
return { data: feedData, isWxgf: true }
|
||||
const fallback = hevcCandidates[0]?.data || buffer.subarray(4)
|
||||
return { data: fallback, isWxgf: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 wxgf 数据中提取 HEVC NALU 裸流
|
||||
*/
|
||||
private extractHevcNalu(buffer: Buffer): Buffer | null {
|
||||
private buildWxgfHevcCandidates(buffer: Buffer): Array<{ name: string; data: Buffer }> {
|
||||
const units = this.extractHevcNaluUnits(buffer)
|
||||
const candidates: Array<{ name: string; data: Buffer }> = []
|
||||
|
||||
const addCandidate = (name: string, data: Buffer | null | undefined): void => {
|
||||
if (!data || data.length < 100) return
|
||||
if (candidates.some((item) => item.data.equals(data))) return
|
||||
candidates.push({ name, data })
|
||||
}
|
||||
|
||||
// 1) 优先尝试按 VPS(32) 分组后的候选流
|
||||
const vpsStarts: number[] = []
|
||||
for (let i = 0; i < units.length; i += 1) {
|
||||
const unit = units[i]
|
||||
if (!unit || unit.length < 2) continue
|
||||
const type = (unit[0] >> 1) & 0x3f
|
||||
if (type === 32) vpsStarts.push(i)
|
||||
}
|
||||
const groups: Array<{ index: number; data: Buffer; size: number }> = []
|
||||
for (let i = 0; i < vpsStarts.length; i += 1) {
|
||||
const start = vpsStarts[i]
|
||||
const end = i + 1 < vpsStarts.length ? vpsStarts[i + 1] : units.length
|
||||
const groupUnits = units.slice(start, end)
|
||||
if (groupUnits.length === 0) continue
|
||||
let hasVcl = false
|
||||
for (const unit of groupUnits) {
|
||||
if (!unit || unit.length < 2) continue
|
||||
const type = (unit[0] >> 1) & 0x3f
|
||||
if (type === 19 || type === 20 || type === 1) {
|
||||
hasVcl = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!hasVcl) continue
|
||||
const merged = this.mergeHevcNaluUnits(groupUnits)
|
||||
groups.push({ index: i, data: merged, size: merged.length })
|
||||
}
|
||||
groups.sort((a, b) => b.size - a.size)
|
||||
for (const group of groups) {
|
||||
addCandidate(`group_${group.index}`, group.data)
|
||||
}
|
||||
|
||||
// 2) 全量扫描提取流
|
||||
addCandidate('scan_all_nalus', this.mergeHevcNaluUnits(units))
|
||||
|
||||
// 3) 兜底:直接跳过 wxgf 头喂 ffmpeg
|
||||
addCandidate('raw_skip4', buffer.subarray(4))
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
private mergeHevcNaluUnits(units: Buffer[]): Buffer {
|
||||
if (!Array.isArray(units) || units.length === 0) return Buffer.alloc(0)
|
||||
const merged: Buffer[] = []
|
||||
for (const unit of units) {
|
||||
if (!unit || unit.length < 2) continue
|
||||
merged.push(Buffer.from([0x00, 0x00, 0x00, 0x01]))
|
||||
merged.push(unit)
|
||||
}
|
||||
return Buffer.concat(merged)
|
||||
}
|
||||
|
||||
private extractHevcNaluUnits(buffer: Buffer): Buffer[] {
|
||||
const starts: number[] = []
|
||||
let i = 4
|
||||
|
||||
while (i < buffer.length - 3) {
|
||||
const hasPrefix4 = buffer[i] === 0x00 && buffer[i + 1] === 0x00 &&
|
||||
buffer[i + 2] === 0x00 && buffer[i + 3] === 0x01
|
||||
const hasPrefix3 = buffer[i] === 0x00 && buffer[i + 1] === 0x00 &&
|
||||
buffer[i + 2] === 0x01
|
||||
|
||||
if (hasPrefix4 || hasPrefix3) {
|
||||
starts.push(i)
|
||||
i += hasPrefix4 ? 4 : 3
|
||||
@@ -1695,10 +1812,11 @@ export class ImageDecryptService {
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
if (starts.length === 0) return []
|
||||
|
||||
if (starts.length === 0) return null
|
||||
|
||||
const nalUnits: Buffer[] = []
|
||||
const units: Buffer[] = []
|
||||
let keptUnits = 0
|
||||
let droppedUnits = 0
|
||||
for (let index = 0; index < starts.length; index += 1) {
|
||||
const start = starts[index]
|
||||
const end = index + 1 < starts.length ? starts[index + 1] : buffer.length
|
||||
@@ -1707,12 +1825,29 @@ export class ImageDecryptService {
|
||||
const prefixLength = hasPrefix4 ? 4 : 3
|
||||
const payloadStart = start + prefixLength
|
||||
if (payloadStart >= end) continue
|
||||
nalUnits.push(Buffer.from([0x00, 0x00, 0x00, 0x01]))
|
||||
nalUnits.push(buffer.subarray(payloadStart, end))
|
||||
const payload = buffer.subarray(payloadStart, end)
|
||||
if (payload.length < 2) {
|
||||
droppedUnits += 1
|
||||
continue
|
||||
}
|
||||
if ((payload[0] & 0x80) !== 0) {
|
||||
droppedUnits += 1
|
||||
continue
|
||||
}
|
||||
units.push(payload)
|
||||
keptUnits += 1
|
||||
}
|
||||
return units
|
||||
}
|
||||
|
||||
if (nalUnits.length === 0) return null
|
||||
return Buffer.concat(nalUnits)
|
||||
/**
|
||||
* 从 wxgf 数据中提取 HEVC NALU 裸流
|
||||
*/
|
||||
private extractHevcNalu(buffer: Buffer): Buffer | null {
|
||||
const units = this.extractHevcNaluUnits(buffer)
|
||||
if (units.length === 0) return null
|
||||
const merged = this.mergeHevcNaluUnits(units)
|
||||
return merged.length > 0 ? merged : null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1747,18 +1882,26 @@ export class ImageDecryptService {
|
||||
await writeFile(tmpInput, hevcData)
|
||||
|
||||
// 依次尝试: 1) -f hevc 裸流 2) 不指定格式让 ffmpeg 自动检测
|
||||
const attempts: { label: string; inputArgs: string[] }[] = [
|
||||
{ label: 'hevc raw', inputArgs: ['-f', 'hevc', '-i', tmpInput] },
|
||||
{ label: 'h265 raw', inputArgs: ['-f', 'h265', '-i', tmpInput] },
|
||||
{ label: 'auto detect', inputArgs: ['-i', tmpInput] },
|
||||
const attempts: { label: string; inputArgs: string[]; outputArgs?: string[] }[] = [
|
||||
{ label: 'hevc raw frame0', inputArgs: ['-f', 'hevc', '-i', tmpInput] },
|
||||
{ label: 'hevc raw frame1', inputArgs: ['-f', 'hevc', '-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,1)'] },
|
||||
{ label: 'hevc raw frame5', inputArgs: ['-f', 'hevc', '-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,5)'] },
|
||||
{ label: 'h265 raw frame0', inputArgs: ['-f', 'h265', '-i', tmpInput] },
|
||||
{ label: 'h265 raw frame1', inputArgs: ['-f', 'h265', '-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,1)'] },
|
||||
{ label: 'h265 raw frame5', inputArgs: ['-f', 'h265', '-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,5)'] },
|
||||
{ label: 'auto detect frame0', inputArgs: ['-i', tmpInput] },
|
||||
{ label: 'auto detect frame1', inputArgs: ['-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,1)'] },
|
||||
{ label: 'auto detect frame5', inputArgs: ['-i', tmpInput], outputArgs: ['-vf', 'select=eq(n\\,5)'] },
|
||||
]
|
||||
|
||||
for (const attempt of attempts) {
|
||||
// 清理上一轮的输出
|
||||
try { if (existsSync(tmpOutput)) require('fs').unlinkSync(tmpOutput) } catch {}
|
||||
|
||||
const result = await this.runFfmpegConvert(ffmpeg, attempt.inputArgs, tmpOutput, attempt.label)
|
||||
if (result) return result
|
||||
const result = await this.runFfmpegConvert(ffmpeg, attempt.inputArgs, tmpOutput, attempt.label, attempt.outputArgs)
|
||||
if (!result) continue
|
||||
if (this.isLikelyCorruptedJpegBuffer(result)) continue
|
||||
return result
|
||||
}
|
||||
|
||||
return null
|
||||
@@ -1771,7 +1914,13 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
|
||||
private runFfmpegConvert(ffmpeg: string, inputArgs: string[], tmpOutput: string, label: string): Promise<Buffer | null> {
|
||||
private runFfmpegConvert(
|
||||
ffmpeg: string,
|
||||
inputArgs: string[],
|
||||
tmpOutput: string,
|
||||
label: string,
|
||||
outputArgs?: string[]
|
||||
): Promise<Buffer | null> {
|
||||
return new Promise((resolve) => {
|
||||
const { spawn } = require('child_process')
|
||||
const errChunks: Buffer[] = []
|
||||
@@ -1780,6 +1929,7 @@ export class ImageDecryptService {
|
||||
'-hide_banner', '-loglevel', 'error',
|
||||
'-y',
|
||||
...inputArgs,
|
||||
...(outputArgs || []),
|
||||
'-vframes', '1', '-q:v', '2', '-f', 'image2', tmpOutput
|
||||
]
|
||||
this.logInfo(`ffmpeg 尝试 [${label}]`, { args: args.join(' ') })
|
||||
|
||||
@@ -50,6 +50,8 @@ const INSIGHT_CONFIG_KEYS = new Set([
|
||||
'aiModelApiKey',
|
||||
'aiModelApiModel',
|
||||
'aiModelApiMaxTokens',
|
||||
'aiInsightFilterMode',
|
||||
'aiInsightFilterList',
|
||||
'aiInsightAllowSocialContext',
|
||||
'aiInsightSocialContextCount',
|
||||
'aiInsightWeiboCookie',
|
||||
@@ -73,6 +75,8 @@ interface SharedAiModelConfig {
|
||||
maxTokens: number
|
||||
}
|
||||
|
||||
type InsightFilterMode = 'whitelist' | 'blacklist'
|
||||
|
||||
// ─── 日志 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type InsightLogLevel = 'INFO' | 'WARN' | 'ERROR'
|
||||
@@ -196,6 +200,11 @@ function normalizeApiMaxTokens(value: unknown): number {
|
||||
return Math.min(API_MAX_TOKENS_MAX, Math.max(API_MAX_TOKENS_MIN, Math.floor(numeric)))
|
||||
}
|
||||
|
||||
function normalizeSessionIdList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 OpenAI 兼容 API(非流式),返回模型第一条消息内容。
|
||||
* 使用 Node 原生 https/http 模块,无需任何第三方 SDK。
|
||||
@@ -495,7 +504,7 @@ class InsightService {
|
||||
return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder') && this.isSessionAllowed(id)
|
||||
})
|
||||
if (!session) {
|
||||
return { success: false, message: '未找到任何私聊会话(若已启用白名单,请检查是否有勾选的私聊)' }
|
||||
return { success: false, message: '未找到任何可触发的私聊会话(请检查黑白名单模式与选择列表)' }
|
||||
}
|
||||
const sessionId = session.username?.trim() || ''
|
||||
const displayName = session.displayName || sessionId
|
||||
@@ -747,14 +756,23 @@ ${topMentionText}
|
||||
|
||||
/**
|
||||
* 判断某个会话是否允许触发见解。
|
||||
* 若白名单未启用,则所有私聊会话均允许;
|
||||
* 若白名单已启用,则只有在白名单中的会话才允许。
|
||||
* white/black 模式二选一:
|
||||
* - whitelist:仅名单内允许
|
||||
* - blacklist:名单内屏蔽,其他允许
|
||||
*/
|
||||
private getInsightFilterConfig(): { mode: InsightFilterMode; list: string[] } {
|
||||
const modeRaw = String(this.config.get('aiInsightFilterMode') || '').trim().toLowerCase()
|
||||
const mode: InsightFilterMode = modeRaw === 'blacklist' ? 'blacklist' : 'whitelist'
|
||||
const list = normalizeSessionIdList(this.config.get('aiInsightFilterList'))
|
||||
return { mode, list }
|
||||
}
|
||||
|
||||
private isSessionAllowed(sessionId: string): boolean {
|
||||
const whitelistEnabled = this.config.get('aiInsightWhitelistEnabled') as boolean
|
||||
if (!whitelistEnabled) return true
|
||||
const whitelist = (this.config.get('aiInsightWhitelist') as string[]) || []
|
||||
return whitelist.includes(sessionId)
|
||||
const normalizedSessionId = String(sessionId || '').trim()
|
||||
if (!normalizedSessionId) return false
|
||||
const { mode, list } = this.getInsightFilterConfig()
|
||||
if (mode === 'whitelist') return list.includes(normalizedSessionId)
|
||||
return !list.includes(normalizedSessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -966,8 +984,8 @@ ${topMentionText}
|
||||
* 1. 会话有真正的新消息(lastTimestamp 比上次见到的更新)
|
||||
* 2. 该会话距上次活跃分析已超过冷却期
|
||||
*
|
||||
* 白名单启用时:直接使用白名单里的 sessionId,完全跳过 getSessions()。
|
||||
* 白名单未启用时:从缓存拉取全量会话后过滤私聊。
|
||||
* whitelist 模式:直接使用名单里的 sessionId,完全跳过 getSessions()。
|
||||
* blacklist 模式:从缓存拉取会话后过滤名单。
|
||||
*/
|
||||
private async analyzeRecentActivity(): Promise<void> {
|
||||
if (!this.isEnabled()) return
|
||||
@@ -978,12 +996,11 @@ ${topMentionText}
|
||||
const now = Date.now()
|
||||
const cooldownMinutes = (this.config.get('aiInsightCooldownMinutes') as number) ?? 120
|
||||
const cooldownMs = cooldownMinutes * 60 * 1000
|
||||
const whitelistEnabled = this.config.get('aiInsightWhitelistEnabled') as boolean
|
||||
const whitelist = (this.config.get('aiInsightWhitelist') as string[]) || []
|
||||
const { mode: filterMode, list: filterList } = this.getInsightFilterConfig()
|
||||
|
||||
// 白名单启用且有勾选项时,直接用白名单 sessionId,无需查数据库全量会话列表。
|
||||
// whitelist 模式且有勾选项时,直接用名单 sessionId,无需查数据库全量会话列表。
|
||||
// 通过拉取该会话最新 1 条消息时间戳判断是否真正有新消息,开销极低。
|
||||
if (whitelistEnabled && whitelist.length > 0) {
|
||||
if (filterMode === 'whitelist' && filterList.length > 0) {
|
||||
// 确保数据库已连接(首次时连接,之后复用)
|
||||
if (!this.dbConnected) {
|
||||
const connectResult = await chatService.connect()
|
||||
@@ -991,8 +1008,8 @@ ${topMentionText}
|
||||
this.dbConnected = true
|
||||
}
|
||||
|
||||
for (const sessionId of whitelist) {
|
||||
if (!sessionId || sessionId.endsWith('@chatroom')) continue
|
||||
for (const sessionId of filterList) {
|
||||
if (!sessionId || sessionId.toLowerCase().includes('placeholder')) continue
|
||||
|
||||
// 冷却期检查(先过滤,减少不必要的 DB 查询)
|
||||
if (cooldownMs > 0) {
|
||||
@@ -1029,16 +1046,22 @@ ${topMentionText}
|
||||
return
|
||||
}
|
||||
|
||||
// 白名单未启用:需要拉取全量会话列表,从中过滤私聊
|
||||
if (filterMode === 'whitelist' && filterList.length === 0) {
|
||||
insightLog('INFO', '白名单模式且名单为空,跳过活跃分析')
|
||||
return
|
||||
}
|
||||
|
||||
// blacklist 模式:拉取会话缓存后按过滤规则筛选
|
||||
const sessions = await this.getSessionsCached()
|
||||
if (sessions.length === 0) return
|
||||
|
||||
const privateSessions = sessions.filter((s) => {
|
||||
const candidateSessions = sessions.filter((s) => {
|
||||
const id = s.username?.trim() || ''
|
||||
return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder')
|
||||
if (!id || id.toLowerCase().includes('placeholder')) return false
|
||||
return this.isSessionAllowed(id)
|
||||
})
|
||||
|
||||
for (const session of privateSessions.slice(0, 10)) {
|
||||
for (const session of candidateSessions.slice(0, 10)) {
|
||||
const sessionId = session.username?.trim() || ''
|
||||
if (!sessionId) continue
|
||||
|
||||
|
||||
@@ -25,9 +25,7 @@ export class WcdbService {
|
||||
private logEnabled = false
|
||||
private monitorListener: ((type: string, json: string) => void) | null = null
|
||||
|
||||
constructor() {
|
||||
this.initWorker()
|
||||
}
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* 初始化 Worker 线程
|
||||
|
||||
+3
-3
@@ -13,13 +13,13 @@
|
||||
},
|
||||
"//": "二改不应改变此处的作者与应用信息",
|
||||
"scripts": {
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs",
|
||||
"rebuild": "electron-rebuild",
|
||||
"dev": "vite",
|
||||
"dev": "node scripts/prepare-electron-runtime.cjs && vite",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc && vite build && electron-builder",
|
||||
"preview": "vite preview",
|
||||
"electron:dev": "vite --mode electron",
|
||||
"electron:dev": "node scripts/prepare-electron-runtime.cjs && vite --mode electron",
|
||||
"electron:build": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,57 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const runtimeNames = [
|
||||
'msvcp140.dll',
|
||||
'msvcp140_1.dll',
|
||||
'vcruntime140.dll',
|
||||
'vcruntime140_1.dll',
|
||||
];
|
||||
|
||||
function copyIfDifferent(sourcePath, targetPath) {
|
||||
const source = fs.statSync(sourcePath);
|
||||
const targetExists = fs.existsSync(targetPath);
|
||||
|
||||
if (targetExists) {
|
||||
const target = fs.statSync(targetPath);
|
||||
if (target.size === source.size && target.mtimeMs >= source.mtimeMs) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
fs.copyFileSync(sourcePath, targetPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (process.platform !== 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
const sourceDir = path.join(projectRoot, 'resources', 'runtime', 'win32');
|
||||
const targetDir = path.join(projectRoot, 'node_modules', 'electron', 'dist');
|
||||
|
||||
if (!fs.existsSync(sourceDir) || !fs.existsSync(targetDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let copiedCount = 0;
|
||||
|
||||
for (const name of runtimeNames) {
|
||||
const sourcePath = path.join(sourceDir, name);
|
||||
const targetPath = path.join(targetDir, name);
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
continue;
|
||||
}
|
||||
if (copyIfDifferent(sourcePath, targetPath)) {
|
||||
copiedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (copiedCount > 0) {
|
||||
console.log(`[prepare-electron-runtime] synced ${copiedCount} runtime DLL(s) to ${targetDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
+9
-3
@@ -80,6 +80,7 @@ function App() {
|
||||
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/') || location.pathname.startsWith('/chat-history-inline/')
|
||||
const isStandaloneChatWindow = location.pathname === '/chat-window'
|
||||
const isNotificationWindow = location.pathname === '/notification-window'
|
||||
const isAnnualReportWindow = location.pathname === '/annual-report/view'
|
||||
const isSettingsRoute = location.pathname === '/settings'
|
||||
const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null
|
||||
const routeLocation = isSettingsRoute
|
||||
@@ -127,7 +128,7 @@ function App() {
|
||||
const body = document.body
|
||||
const appRoot = document.getElementById('app')
|
||||
|
||||
if (isOnboardingWindow || isNotificationWindow) {
|
||||
if (isOnboardingWindow || isNotificationWindow || isAnnualReportWindow) {
|
||||
root.style.background = 'transparent'
|
||||
body.style.background = 'transparent'
|
||||
body.style.overflow = 'hidden'
|
||||
@@ -144,7 +145,7 @@ function App() {
|
||||
appRoot.style.overflow = ''
|
||||
}
|
||||
}
|
||||
}, [isOnboardingWindow])
|
||||
}, [isOnboardingWindow, isNotificationWindow, isAnnualReportWindow])
|
||||
|
||||
// 应用主题
|
||||
useEffect(() => {
|
||||
@@ -165,7 +166,7 @@ function App() {
|
||||
}
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow])
|
||||
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow, isAnnualReportWindow])
|
||||
|
||||
// 读取已保存的主题设置
|
||||
useEffect(() => {
|
||||
@@ -511,6 +512,11 @@ function App() {
|
||||
return <NotificationWindow />
|
||||
}
|
||||
|
||||
// 独立年度报告全屏窗口
|
||||
if (isAnnualReportWindow) {
|
||||
return <AnnualReportWindow />
|
||||
}
|
||||
|
||||
// 主窗口 - 完整布局
|
||||
const handleCloseSettings = () => {
|
||||
const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
.annual-report-page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -8,6 +9,11 @@
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
.annual-report-page.report-route-transitioning > :not(.report-launch-overlay) {
|
||||
animation: report-page-exit 420ms cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
color: var(--primary);
|
||||
margin-bottom: 16px;
|
||||
@@ -199,6 +205,11 @@
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary-light);
|
||||
@@ -251,6 +262,10 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.is-pending {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
background: var(--card-bg);
|
||||
color: var(--text-primary);
|
||||
@@ -259,6 +274,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
.report-launch-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in srgb, var(--bg-primary) 78%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
animation: report-launch-overlay-in 420ms ease-out both;
|
||||
}
|
||||
|
||||
.launch-core {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
color: var(--text-primary);
|
||||
animation: report-launch-core-in 420ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
}
|
||||
|
||||
.launch-title {
|
||||
margin: 4px 0 0;
|
||||
font-size: 18px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.launch-subtitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@@ -271,3 +320,36 @@
|
||||
@keyframes dot-ellipsis {
|
||||
to { width: 1.4em; }
|
||||
}
|
||||
|
||||
@keyframes report-page-exit {
|
||||
from {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
filter: blur(8px);
|
||||
transform: scale(0.985);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes report-launch-overlay-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes report-launch-core-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(18px) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Calendar, Loader2, Sparkles, Users } from 'lucide-react'
|
||||
import {
|
||||
@@ -25,6 +25,8 @@ type YearsLoadPayload = {
|
||||
nativeTimedOut?: boolean
|
||||
}
|
||||
|
||||
const REPORT_LAUNCH_DELAY_MS = 420
|
||||
|
||||
const formatLoadElapsed = (ms: number) => {
|
||||
const totalSeconds = Math.max(0, ms) / 1000
|
||||
if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`
|
||||
@@ -50,7 +52,10 @@ function AnnualReportPage() {
|
||||
const [hasSwitchedStrategy, setHasSwitchedStrategy] = useState(false)
|
||||
const [nativeTimedOut, setNativeTimedOut] = useState(false)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [isRouteTransitioning, setIsRouteTransitioning] = useState(false)
|
||||
const [launchingYearLabel, setLaunchingYearLabel] = useState('')
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const launchTimerRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
@@ -186,21 +191,37 @@ function AnnualReportPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleGenerateReport = async () => {
|
||||
if (selectedYear === null) return
|
||||
setIsGenerating(true)
|
||||
try {
|
||||
const yearParam = selectedYear === 'all' ? 0 : selectedYear
|
||||
navigate(`/annual-report/view?year=${yearParam}`)
|
||||
} catch (e) {
|
||||
console.error('生成报告失败:', e)
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (launchTimerRef.current !== null) {
|
||||
window.clearTimeout(launchTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleGenerateReport = () => {
|
||||
if (selectedYear === null || isRouteTransitioning) return
|
||||
const yearParam = selectedYear === 'all' ? 0 : selectedYear
|
||||
const yearLabel = selectedYear === 'all' ? '全部时间' : `${selectedYear}年`
|
||||
setIsGenerating(true)
|
||||
setIsRouteTransitioning(true)
|
||||
setLaunchingYearLabel(yearLabel)
|
||||
if (launchTimerRef.current !== null) {
|
||||
window.clearTimeout(launchTimerRef.current)
|
||||
}
|
||||
launchTimerRef.current = window.setTimeout(() => {
|
||||
try {
|
||||
navigate(`/annual-report/view?year=${yearParam}`)
|
||||
} catch (e) {
|
||||
console.error('生成报告失败:', e)
|
||||
setIsGenerating(false)
|
||||
setIsRouteTransitioning(false)
|
||||
}
|
||||
}, REPORT_LAUNCH_DELAY_MS)
|
||||
}
|
||||
|
||||
const handleGenerateDualReport = () => {
|
||||
if (selectedPairYear === null) return
|
||||
if (selectedPairYear === null || isRouteTransitioning) return
|
||||
const yearParam = selectedPairYear === 'all' ? 0 : selectedPairYear
|
||||
navigate(`/dual-report?year=${yearParam}`)
|
||||
}
|
||||
@@ -251,7 +272,7 @@ function AnnualReportPage() {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="annual-report-page">
|
||||
<div className={`annual-report-page ${isRouteTransitioning ? 'report-route-transitioning' : ''}`}>
|
||||
<Sparkles size={32} className="header-icon" />
|
||||
<h1 className="page-title">年度报告</h1>
|
||||
<p className="page-desc">选择年份,回顾你在微信里的点点滴滴</p>
|
||||
@@ -270,8 +291,11 @@ function AnnualReportPage() {
|
||||
{yearOptions.map(option => (
|
||||
<div
|
||||
key={option}
|
||||
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedYear(option)}
|
||||
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''} ${isRouteTransitioning ? 'disabled' : ''}`}
|
||||
onClick={() => {
|
||||
if (isRouteTransitioning) return
|
||||
setSelectedYear(option)
|
||||
}}
|
||||
>
|
||||
<span className="year-number">{option === 'all' ? '全部' : option}</span>
|
||||
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
|
||||
@@ -281,14 +305,14 @@ function AnnualReportPage() {
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="generate-btn"
|
||||
className={`generate-btn ${isRouteTransitioning ? 'is-pending' : ''}`}
|
||||
onClick={handleGenerateReport}
|
||||
disabled={!selectedYear || isGenerating}
|
||||
disabled={!selectedYear || isGenerating || isRouteTransitioning}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 size={20} className="spin" />
|
||||
<span>正在生成...</span>
|
||||
<span>{isRouteTransitioning ? '正在进入报告...' : '正在生成...'}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -316,8 +340,11 @@ function AnnualReportPage() {
|
||||
{yearOptions.map(option => (
|
||||
<div
|
||||
key={`pair-${option}`}
|
||||
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedPairYear === option ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedPairYear(option)}
|
||||
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedPairYear === option ? 'selected' : ''} ${isRouteTransitioning ? 'disabled' : ''}`}
|
||||
onClick={() => {
|
||||
if (isRouteTransitioning) return
|
||||
setSelectedPairYear(option)
|
||||
}}
|
||||
>
|
||||
<span className="year-number">{option === 'all' ? '全部' : option}</span>
|
||||
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
|
||||
@@ -327,9 +354,9 @@ function AnnualReportPage() {
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="generate-btn secondary"
|
||||
className={`generate-btn secondary ${isRouteTransitioning ? 'is-pending' : ''}`}
|
||||
onClick={handleGenerateDualReport}
|
||||
disabled={!selectedPairYear}
|
||||
disabled={!selectedPairYear || isRouteTransitioning}
|
||||
>
|
||||
<Users size={20} />
|
||||
<span>选择好友并生成报告</span>
|
||||
@@ -337,6 +364,16 @@ function AnnualReportPage() {
|
||||
<p className="section-hint">从聊天排行中选择好友生成双人报告</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{isRouteTransitioning && (
|
||||
<div className="report-launch-overlay" role="status" aria-live="polite">
|
||||
<div className="launch-core">
|
||||
<Loader2 size={30} className="spin" />
|
||||
<p className="launch-title">正在进入{launchingYearLabel}年度报告</p>
|
||||
<p className="launch-subtitle">正在整理你的聊天记忆...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+1010
-1341
@@ -1,163 +1,987 @@
|
||||
@font-face {
|
||||
font-family: 'InterLocal';
|
||||
src: url('../../resources/fonts/annual-report/Inter-Var.ttf') format('truetype');
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'PlayfairDisplayLocal';
|
||||
src: url('../../resources/fonts/annual-report/PlayfairDisplay-Var.ttf') format('truetype');
|
||||
font-style: normal;
|
||||
font-weight: 400 900;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'CormorantGaramondLocal';
|
||||
src: url('../../resources/fonts/annual-report/CormorantGaramond-Var.ttf') format('truetype');
|
||||
font-style: normal;
|
||||
font-weight: 300 700;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'NotoSerifSCLocal';
|
||||
src: url('../../resources/fonts/annual-report/NotoSerifSC-Var.ttf') format('truetype');
|
||||
font-style: normal;
|
||||
font-weight: 200 900;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'SpaceMonoLocal';
|
||||
src: url('../../resources/fonts/annual-report/SpaceMono-Regular.ttf') format('truetype');
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'SpaceMonoLocal';
|
||||
src: url('../../resources/fonts/annual-report/SpaceMono-Bold.ttf') format('truetype');
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
.annual-report-window {
|
||||
// 使用全局主题变量,带回退值
|
||||
--ar-primary: var(--primary, #07C160);
|
||||
--ar-primary-rgb: var(--primary-rgb, 7, 193, 96);
|
||||
--ar-accent: var(--accent, #F2AA00);
|
||||
--ar-accent-rgb: 242, 170, 0;
|
||||
--ar-text-main: var(--text-primary, #222222);
|
||||
--ar-text-sub: var(--text-secondary, #555555);
|
||||
--ar-bg-color: var(--bg-primary, #F9F8F6);
|
||||
--ar-card-bg: var(--bg-secondary, rgba(255, 255, 255, 0.5));
|
||||
--ar-card-bg-hover: var(--bg-tertiary, rgba(255, 255, 255, 0.8));
|
||||
--ar-rank-bg: var(--bg-secondary, #f0f0f0);
|
||||
--ar-rank-color: var(--text-secondary, #666);
|
||||
--c-bg: #050505;
|
||||
--c-bg-deep: #090909;
|
||||
--c-text: #F2F2F0;
|
||||
--c-text-bright: #FAFAF8;
|
||||
--c-text-soft: rgba(250, 250, 248, 0.84);
|
||||
--c-text-muted: rgba(250, 250, 248, 0.58);
|
||||
--c-text-faint: rgba(250, 250, 248, 0.42);
|
||||
--c-gold: #B8945A;
|
||||
--c-gold-strong: #C8AA72;
|
||||
--c-gold-rgb: 184, 148, 90;
|
||||
--c-paper: #ECE8DF;
|
||||
--c-paper-ink: #1A1710;
|
||||
--c-paper-muted: #6E6757;
|
||||
/* 顶级平滑缓动曲线 */
|
||||
--ease-epic: cubic-bezier(0.76, 0, 0.24, 1);
|
||||
--ease-out: cubic-bezier(0.25, 1, 0.5, 1);
|
||||
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background: var(--chat-pattern);
|
||||
background-color: var(--ar-bg-color);
|
||||
// overflow-y: auto; // Moved to .report-scroll-view
|
||||
overflow: hidden; // Contain everything
|
||||
position: relative;
|
||||
-webkit-app-region: no-drag; // 确保主容器不可拖动
|
||||
|
||||
// 隐藏滚动条
|
||||
/* scrollbar-width: none; */
|
||||
// Moved
|
||||
/* -ms-overflow-style: none; */
|
||||
}
|
||||
|
||||
.report-scroll-view {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
z-index: 1;
|
||||
|
||||
// 隐藏滚动条
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// 背景装饰圆点 - 毛玻璃效果
|
||||
.bg-decoration {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
background-color: var(--c-bg);
|
||||
color: var(--c-text);
|
||||
font-family: 'InterLocal', 'NotoSerifSCLocal', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.deco-circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: rgba(var(--ar-primary-rgb), 0.03);
|
||||
backdrop-filter: blur(40px);
|
||||
-webkit-backdrop-filter: blur(40px);
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
&.c1 {
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
top: -80px;
|
||||
right: -60px;
|
||||
animation: float1 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&.c2 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
bottom: 15%;
|
||||
left: -70px;
|
||||
animation: float2 25s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&.c3 {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
top: 45%;
|
||||
right: -40px;
|
||||
animation: float3 18s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&.c4 {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
top: 25%;
|
||||
left: 8%;
|
||||
animation: float1 22s ease-in-out infinite reverse;
|
||||
}
|
||||
|
||||
&.c5 {
|
||||
width: 60px;
|
||||
overscroll-behavior: none;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
position: relative;
|
||||
-webkit-app-region: no-drag; // 默认不可拖拽,给特定区域开放
|
||||
|
||||
// 顶部拖动控制条与关闭按钮
|
||||
.top-controls {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
bottom: 25%;
|
||||
right: 12%;
|
||||
animation: float2 15s ease-in-out infinite reverse;
|
||||
}
|
||||
}
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
z-index: 10000;
|
||||
-webkit-app-region: drag;
|
||||
|
||||
@keyframes float1 {
|
||||
.close-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: rgba(var(--c-gold-rgb), 0.14);
|
||||
border: 1px solid rgba(var(--c-gold-rgb), 0.34);
|
||||
color: var(--c-text-soft);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
-webkit-app-region: no-drag;
|
||||
transition: all 0.3s var(--ease-out);
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
&:hover {
|
||||
background: rgba(var(--c-gold-rgb), 0.24);
|
||||
color: var(--c-text-bright);
|
||||
box-shadow: 0 0 12px rgba(var(--c-gold-rgb), 0.24);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(-15px, 15px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float2 {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
&[data-scene="10"] .top-controls .close-btn {
|
||||
background: rgba(26, 20, 9, 0.08);
|
||||
border-color: rgba(75, 58, 27, 0.28);
|
||||
color: rgba(38, 29, 12, 0.72);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(12px, -12px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float3 {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
&[data-scene="10"] .top-controls .close-btn:hover {
|
||||
background: rgba(30, 23, 10, 0.16);
|
||||
color: #171006;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(-8px, -15px);
|
||||
}
|
||||
}
|
||||
|
||||
.annual-report-window {
|
||||
|
||||
// 所有子元素默认不可拖动
|
||||
* {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
// 背景渐变灯光
|
||||
&::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
.p0-bg-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle 500px at 0% 0%, rgba(7, 193, 96, 0.06), transparent),
|
||||
radial-gradient(circle 500px at 100% 0%, rgba(242, 170, 0, 0.05), transparent),
|
||||
radial-gradient(circle 500px at 0% 100%, rgba(242, 170, 0, 0.05), transparent),
|
||||
radial-gradient(circle 500px at 100% 100%, rgba(7, 193, 96, 0.06), transparent);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 1.1s var(--ease-out);
|
||||
}
|
||||
|
||||
&[data-scene="0"] .p0-bg-layer {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&[data-scene="1"] .p0-bg-layer {
|
||||
opacity: 0.16;
|
||||
}
|
||||
|
||||
.p0-particle-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.p0-center-glow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at center, rgba(var(--c-gold-rgb), 0.12) 0%, rgba(var(--c-gold-rgb), 0.03) 36%, rgba(255, 255, 255, 0.02) 50%, transparent 72%);
|
||||
}
|
||||
|
||||
/* 细微的电影噪点 */
|
||||
.film-grain {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
opacity: 0.018;
|
||||
pointer-events: none;
|
||||
mix-blend-mode: overlay;
|
||||
background: url('data:image/svg+xml;utf8,%3Csvg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"%3E%3Cfilter id="noiseFilter"%3E%3CfeTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="3" stitchTiles="stitch"/%3E%3C/filter%3E%3Crect width="100%25" height="100%25" filter="url(%23noiseFilter)"/%3E%3C/svg%3E');
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
标志性组件的演化
|
||||
========================================= */
|
||||
#memory-core {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: all 1.5s var(--ease-epic);
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
background: var(--c-gold-strong);
|
||||
}
|
||||
|
||||
/* S0: 年份下方引线(保留后续场景形变) */
|
||||
&[data-scene="0"] #memory-core {
|
||||
top: 84vh;
|
||||
left: 50vw;
|
||||
width: clamp(120px, 16vw, 220px);
|
||||
height: 1px;
|
||||
border-radius: 999px;
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 12px rgba(var(--c-gold-rgb), 0.48);
|
||||
filter: blur(0px);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
&[data-scene="0"] #memory-core {
|
||||
top: 81vh;
|
||||
width: clamp(96px, 22vw, 180px);
|
||||
}
|
||||
}
|
||||
|
||||
/* S1: 深海地平线底光 */
|
||||
&[data-scene="1"] #memory-core {
|
||||
top: 100vh;
|
||||
left: 50vw;
|
||||
width: 200vw;
|
||||
height: 60vh;
|
||||
border-radius: 50%;
|
||||
opacity: 0.15;
|
||||
box-shadow: none;
|
||||
filter: blur(80px);
|
||||
}
|
||||
|
||||
/* S2: 凌晨微光 */
|
||||
&[data-scene="2"] #memory-core {
|
||||
top: 45vh;
|
||||
left: 50vw;
|
||||
width: 300px;
|
||||
height: 150px;
|
||||
border-radius: 50%;
|
||||
opacity: 0.08;
|
||||
box-shadow: none;
|
||||
filter: blur(40px);
|
||||
}
|
||||
|
||||
/* S3: 竖直时间引线 (内容线段,可被 S4 过渡形变) */
|
||||
&[data-scene="3"] #memory-core {
|
||||
top: var(--s3-line-top, 48vh);
|
||||
left: var(--s3-line-left, calc(50vw - min(36vw, 440px) + 12px));
|
||||
width: 1px;
|
||||
height: var(--s3-line-height, clamp(240px, 34vh, 320px));
|
||||
border-radius: 1px;
|
||||
background: var(--c-gold);
|
||||
opacity: 0.55;
|
||||
box-shadow: 0 0 12px rgba(var(--c-gold-rgb), 0.38);
|
||||
filter: blur(0px);
|
||||
}
|
||||
|
||||
/* S4: 内容横线 (由 S3 竖线平滑形变过来) */
|
||||
&[data-scene="4"] #memory-core {
|
||||
top: 55vh;
|
||||
left: 50vw;
|
||||
width: 80vw;
|
||||
height: 1px;
|
||||
border-radius: 0;
|
||||
background: rgba(var(--c-gold-rgb), 0.88);
|
||||
opacity: 0.35;
|
||||
box-shadow: 0 0 10px rgba(var(--c-gold-rgb), 0.28);
|
||||
filter: blur(0px);
|
||||
}
|
||||
|
||||
/* S5: MUTUAL RESONANCE (底部弥散的主氛围光源) */
|
||||
&[data-scene="5"] #memory-core {
|
||||
top: 100vh;
|
||||
left: 50vw;
|
||||
width: 150vw;
|
||||
height: 80vh;
|
||||
border-radius: 50%;
|
||||
opacity: 0.04;
|
||||
box-shadow: none;
|
||||
filter: blur(80px);
|
||||
}
|
||||
|
||||
/* S6: SOCIAL KINETICS (大字背后的脉冲环境背光) */
|
||||
&[data-scene="6"] #memory-core {
|
||||
top: 40vh;
|
||||
left: 30vw;
|
||||
width: 80vw;
|
||||
height: 80vh;
|
||||
border-radius: 50%;
|
||||
opacity: 0.03;
|
||||
box-shadow: none;
|
||||
filter: blur(100px);
|
||||
animation: corePulse 3s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes corePulse {
|
||||
0% { transform: translate(-50%, -50%) scale(0.9); opacity: 0.02; }
|
||||
100% { transform: translate(-50%, -50%) scale(1.1); opacity: 0.05; }
|
||||
}
|
||||
|
||||
/* S7: THE SPARK (顶部的倾斜透射光束感) */
|
||||
&[data-scene="7"] #memory-core {
|
||||
top: 0vh;
|
||||
left: 20vw;
|
||||
width: 120vw;
|
||||
height: 100vh;
|
||||
border-radius: 50%;
|
||||
opacity: 0.05;
|
||||
box-shadow: none;
|
||||
filter: blur(90px);
|
||||
transform: translate(-50%, -50%) rotate(-15deg);
|
||||
}
|
||||
|
||||
/* S8: FADING SIGNALS (迷雾) */
|
||||
&[data-scene="8"] #memory-core {
|
||||
top: 50vh;
|
||||
left: 50vw;
|
||||
width: 80vw;
|
||||
height: 80vh;
|
||||
border-radius: 50%;
|
||||
opacity: 0.05;
|
||||
box-shadow: none;
|
||||
filter: blur(80px);
|
||||
}
|
||||
|
||||
/* S9: LEXICON (大气) */
|
||||
&[data-scene="9"] #memory-core {
|
||||
top: -20vh;
|
||||
left: -20vw;
|
||||
width: 150vw;
|
||||
height: 150vw;
|
||||
border-radius: 50%;
|
||||
opacity: 0.08;
|
||||
box-shadow: none;
|
||||
filter: blur(60px);
|
||||
}
|
||||
|
||||
/* S10: EXTRACTION */
|
||||
&[data-scene="10"] #memory-core {
|
||||
top: 50vh;
|
||||
left: 50vw;
|
||||
width: 250vmax;
|
||||
height: 250vmax;
|
||||
border-radius: 50%;
|
||||
background: var(--c-paper); /* Explodes into warm bright color smoothly */
|
||||
opacity: 1;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
filter: blur(0px);
|
||||
transition: all 1s cubic-bezier(0.8, 0, 0.1, 1);
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
场景控制系统 & 遮罩出场动画
|
||||
========================================= */
|
||||
.scene {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.scene.active {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.serif {
|
||||
font-family: 'NotoSerifSCLocal', 'CormorantGaramondLocal', serif;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'SpaceMonoLocal', 'NotoSerifSCLocal', monospace;
|
||||
}
|
||||
|
||||
.num-display {
|
||||
font-family: 'InterLocal', -apple-system, sans-serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* 通用文案样式放大 */
|
||||
.en-tag {
|
||||
position: absolute;
|
||||
top: 6vh;
|
||||
left: 4vw;
|
||||
font-size: clamp(0.9rem, 1.05vw, 1.05rem);
|
||||
color: var(--c-text-soft);
|
||||
letter-spacing: 0.28em;
|
||||
font-weight: 500;
|
||||
text-rendering: optimizeLegibility;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.desc-text {
|
||||
font-size: 1.3rem;
|
||||
line-height: 1.8;
|
||||
color: var(--c-text);
|
||||
margin-top: 35vh;
|
||||
}
|
||||
|
||||
/* Mask Reveal 动画 */
|
||||
.reveal-wrap {
|
||||
overflow: hidden;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.reveal-inner {
|
||||
transform: translateY(110%);
|
||||
transition: transform 1.2s var(--ease-epic), opacity 1.2s var(--ease-epic);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.scene.active .reveal-inner {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.scene.prev .reveal-inner {
|
||||
transform: translateY(-50%);
|
||||
opacity: 0;
|
||||
transition: all 0.6s ease;
|
||||
}
|
||||
|
||||
.scene.next .reveal-inner {
|
||||
transform: translateY(50%);
|
||||
opacity: 0;
|
||||
transition: all 0.6s ease;
|
||||
}
|
||||
|
||||
&.exporting-scenes .top-controls,
|
||||
&.exporting-scenes .pagination,
|
||||
&.exporting-scenes .swipe-hint {
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
.delay-1 {
|
||||
transition-delay: 0.1s;
|
||||
}
|
||||
|
||||
.delay-2 {
|
||||
transition-delay: 0.2s;
|
||||
}
|
||||
|
||||
.delay-3 {
|
||||
transition-delay: 0.3s;
|
||||
}
|
||||
|
||||
/* 场景排版 */
|
||||
#scene-0 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#scene-0 .scene0-cn-tag {
|
||||
letter-spacing: 0.22em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#scene-0 .title-year {
|
||||
font-family: 'PlayfairDisplayLocal', 'CormorantGaramondLocal', serif;
|
||||
font-size: clamp(6.8rem, 21vw, 18rem);
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.04em;
|
||||
margin-top: 10vh;
|
||||
text-shadow: 0 18px 45px rgba(0, 0, 0, 0.45);
|
||||
max-width: 90vw;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
#scene-0 .title-year--numeric {
|
||||
font-size: clamp(6.8rem, 21vw, 18rem);
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
#scene-0 .title-year--text {
|
||||
font-size: clamp(4.8rem, 14vw, 10rem);
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
#scene-0 .title-year--text-long {
|
||||
font-size: clamp(3.8rem, 10.5vw, 7.5rem);
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.12;
|
||||
}
|
||||
|
||||
#scene-0 .title-year-wrap {
|
||||
padding: clamp(6px, 0.8vh, 14px) 0;
|
||||
}
|
||||
|
||||
#scene-0 .p0-desc {
|
||||
margin-top: clamp(9vh, 11vh, 13vh);
|
||||
}
|
||||
|
||||
#scene-0 .p0-desc-inner {
|
||||
font-size: clamp(1rem, 1.35vw, 1.2rem);
|
||||
line-height: 2;
|
||||
color: var(--c-text-soft);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
#scene-1 .title-data {
|
||||
font-size: clamp(5.5rem, 16vw, 13rem);
|
||||
font-family: 'InterLocal';
|
||||
font-weight: 300;
|
||||
letter-spacing: -0.05em;
|
||||
line-height: 1;
|
||||
margin-bottom: 4vh;
|
||||
}
|
||||
|
||||
#scene-2 {
|
||||
padding: 0 10vw;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#scene-2 .title-time {
|
||||
font-size: clamp(4.5rem, 12vw, 9rem);
|
||||
line-height: 1;
|
||||
margin-top: 10vh;
|
||||
}
|
||||
|
||||
#scene-2 .desc-text {
|
||||
margin-top: 2vh;
|
||||
}
|
||||
|
||||
#scene-3 {
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 0 8vw;
|
||||
}
|
||||
|
||||
#scene-3 .en-tag {
|
||||
left: 4vw;
|
||||
top: 6vh;
|
||||
}
|
||||
|
||||
#scene-3 .s3-layout {
|
||||
position: absolute;
|
||||
top: 20vh;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: min(880px, 72vw);
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: clamp(4vh, 5vh, 7vh);
|
||||
padding-left: clamp(52px, 7vw, 108px);
|
||||
}
|
||||
|
||||
#scene-3 .s3-subtitle-wrap {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#scene-3 .s3-subtitle {
|
||||
font-size: clamp(1rem, 1.25vw, 1.15rem);
|
||||
color: var(--c-text-muted);
|
||||
letter-spacing: 0.05em;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
#scene-3 .contact-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: clamp(3.2vh, 4vh, 5.5vh);
|
||||
margin-top: 0;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
#scene-3 .s3-row-wrap {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#scene-3 .c-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
align-items: end;
|
||||
column-gap: clamp(36px, 8vw, 140px);
|
||||
width: 100%;
|
||||
min-height: clamp(58px, 8vh, 88px);
|
||||
}
|
||||
|
||||
#scene-3 .c-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#scene-3 .c-name {
|
||||
font-size: clamp(2rem, 4.3vw, 3.2rem);
|
||||
line-height: 1;
|
||||
letter-spacing: 0.03em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: min(56vw, 460px);
|
||||
}
|
||||
|
||||
#scene-3 .c-sub {
|
||||
font-size: 0.68rem;
|
||||
color: var(--c-text-muted);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
#scene-3 .c-count {
|
||||
font-size: clamp(1.4rem, 2.2vw, 2rem);
|
||||
font-family: 'SpaceMonoLocal';
|
||||
line-height: 1;
|
||||
text-align: right;
|
||||
min-width: 7ch;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
#scene-3 .s3-layout {
|
||||
width: min(820px, 76vw);
|
||||
padding-left: clamp(44px, 6vw, 92px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
#scene-3 .s3-layout {
|
||||
top: 18.5vh;
|
||||
width: min(760px, 86vw);
|
||||
padding-left: clamp(30px, 5vw, 60px);
|
||||
}
|
||||
|
||||
#scene-3 .c-name {
|
||||
font-size: clamp(1.65rem, 5.2vw, 2.4rem);
|
||||
}
|
||||
|
||||
#scene-3 .c-count {
|
||||
font-size: clamp(1.2rem, 2.8vw, 1.75rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
#scene-3 .s3-layout {
|
||||
top: 17.5vh;
|
||||
gap: clamp(3vh, 4.5vh, 5vh);
|
||||
width: 90vw;
|
||||
padding-left: 26px;
|
||||
}
|
||||
|
||||
#scene-3 .contact-list {
|
||||
gap: clamp(2.8vh, 3.4vh, 4vh);
|
||||
}
|
||||
|
||||
#scene-3 .c-item {
|
||||
column-gap: 24px;
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
#scene-3 .c-sub {
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
}
|
||||
|
||||
#scene-8 {
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
padding: 0 6vw;
|
||||
}
|
||||
|
||||
#scene-8 .s8-layout {
|
||||
position: absolute;
|
||||
top: 18vh;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: min(1240px, 86vw);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.92fr) minmax(0, 1.08fr);
|
||||
column-gap: clamp(34px, 4.8vw, 84px);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
#scene-8 .s8-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: clamp(2.5vh, 3.2vh, 4vh);
|
||||
padding-top: clamp(8vh, 9vh, 11vh);
|
||||
}
|
||||
|
||||
#scene-8 .s8-name-wrap,
|
||||
#scene-8 .s8-summary-wrap,
|
||||
#scene-8 .s8-quote-wrap,
|
||||
#scene-8 .s8-letter-wrap {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#scene-8 .s8-name {
|
||||
font-size: clamp(3.2rem, 7.4vw, 5.6rem);
|
||||
color: rgba(var(--c-gold-rgb), 0.88);
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
#scene-8 .s8-summary {
|
||||
max-width: 34ch;
|
||||
font-size: clamp(1.06rem, 1.35vw, 1.35rem);
|
||||
color: var(--c-text-soft);
|
||||
line-height: 1.95;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
#scene-8 .s8-summary-count {
|
||||
margin: 0 8px;
|
||||
font-size: clamp(1.35rem, 2vw, 1.75rem);
|
||||
color: var(--c-gold-strong);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#scene-8 .s8-quote {
|
||||
max-width: 32ch;
|
||||
font-size: clamp(0.98rem, 1.12vw, 1.1rem);
|
||||
color: var(--c-text-muted);
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
#scene-8 .s8-letter-wrap {
|
||||
margin-top: clamp(3vh, 4vh, 5.5vh);
|
||||
}
|
||||
|
||||
#scene-8 .s8-letter {
|
||||
position: relative;
|
||||
padding: clamp(24px, 3.2vh, 38px) clamp(20px, 2.6vw, 34px) clamp(24px, 3.2vh, 38px) clamp(30px, 3.2vw, 44px);
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(var(--c-gold-rgb), 0.34);
|
||||
background: linear-gradient(135deg, rgba(var(--c-gold-rgb), 0.16), rgba(var(--c-gold-rgb), 0.04));
|
||||
font-size: clamp(0.95rem, 1.05vw, 1.08rem);
|
||||
line-height: 2;
|
||||
color: var(--c-text-soft);
|
||||
text-align: left;
|
||||
text-shadow: 0 4px 16px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
#scene-8 .s8-letter::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 14px;
|
||||
width: 2px;
|
||||
height: calc(100% - 40px);
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(to bottom, rgba(var(--c-gold-rgb), 0.7), rgba(var(--c-gold-rgb), 0.08));
|
||||
}
|
||||
|
||||
#scene-8 .s8-empty-wrap {
|
||||
display: block;
|
||||
width: min(760px, 78vw);
|
||||
margin-top: 24vh;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#scene-8 .s8-empty-text {
|
||||
color: var(--c-text);
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
#scene-8 .s8-layout {
|
||||
width: min(1120px, 88vw);
|
||||
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
|
||||
column-gap: clamp(28px, 4vw, 56px);
|
||||
}
|
||||
|
||||
#scene-8 .s8-left {
|
||||
padding-top: clamp(6vh, 8vh, 9vh);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
#scene-8 .s8-layout {
|
||||
top: 16vh;
|
||||
width: min(900px, 90vw);
|
||||
grid-template-columns: 1fr;
|
||||
row-gap: clamp(3vh, 3.5vh, 4.5vh);
|
||||
}
|
||||
|
||||
#scene-8 .s8-left {
|
||||
padding-top: 0;
|
||||
gap: clamp(1.6vh, 2.2vh, 2.8vh);
|
||||
}
|
||||
|
||||
#scene-8 .s8-name {
|
||||
font-size: clamp(2.4rem, 8.4vw, 4.2rem);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
#scene-8 .s8-summary,
|
||||
#scene-8 .s8-quote {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
#scene-8 .s8-letter-wrap {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
#scene-8 .s8-letter {
|
||||
font-size: clamp(0.9rem, 1.9vw, 1rem);
|
||||
line-height: 1.95;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
#scene-8 .s8-layout {
|
||||
top: 14.5vh;
|
||||
width: 92vw;
|
||||
row-gap: clamp(2.2vh, 3vh, 3.8vh);
|
||||
}
|
||||
|
||||
#scene-8 .s8-name {
|
||||
font-size: clamp(2rem, 10vw, 3rem);
|
||||
}
|
||||
|
||||
#scene-8 .s8-summary {
|
||||
font-size: clamp(0.92rem, 3.9vw, 1rem);
|
||||
line-height: 1.85;
|
||||
}
|
||||
|
||||
#scene-8 .s8-summary-count {
|
||||
margin: 0 6px;
|
||||
font-size: clamp(1.1rem, 4.8vw, 1.35rem);
|
||||
}
|
||||
|
||||
#scene-8 .s8-quote {
|
||||
font-size: clamp(0.86rem, 3.5vw, 0.95rem);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
#scene-8 .s8-letter {
|
||||
border-radius: 14px;
|
||||
padding: 16px 16px 16px 24px;
|
||||
font-size: clamp(0.82rem, 3.4vw, 0.9rem);
|
||||
line-height: 1.82;
|
||||
}
|
||||
|
||||
#scene-8 .s8-letter::before {
|
||||
top: 16px;
|
||||
left: 11px;
|
||||
height: calc(100% - 32px);
|
||||
}
|
||||
|
||||
#scene-8 .s8-empty-wrap {
|
||||
width: 88vw;
|
||||
margin-top: 23vh;
|
||||
}
|
||||
|
||||
#scene-8 .s8-empty-text {
|
||||
font-size: 1rem;
|
||||
line-height: 1.9;
|
||||
}
|
||||
}
|
||||
|
||||
/* S4 宇宙 (彻底修复穿模 BUG) */
|
||||
#scene-4 {
|
||||
color: var(--c-text-bright);
|
||||
}
|
||||
|
||||
#scene-4 .en-tag {
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.word-burst {
|
||||
position: absolute;
|
||||
font-family: 'NotoSerifSCLocal';
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transform: translate(-50%, -50%) scale(0.8);
|
||||
opacity: 0;
|
||||
transition: all 1s cubic-bezier(0.175, 0.885, 0.32, 1.2);
|
||||
}
|
||||
|
||||
/* 仅在 S9 显影 */
|
||||
&[data-scene="9"] .word-burst {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
opacity: var(--target-op, 1);
|
||||
}
|
||||
|
||||
.float-el {
|
||||
display: inline-block;
|
||||
animation: floatWord 4s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes floatWord {
|
||||
0% {
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(8px);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-wrap {
|
||||
position: absolute;
|
||||
bottom: 8vh;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transition: opacity 1s 0.8s;
|
||||
}
|
||||
|
||||
&[data-scene="10"] .btn-wrap {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem 3rem;
|
||||
color: var(--c-gold-strong);
|
||||
background: #111111;
|
||||
border-radius: 100px;
|
||||
cursor: pointer;
|
||||
transition: all 0.4s var(--ease-out);
|
||||
margin-top: 3vh;
|
||||
font-family: 'SpaceMonoLocal';
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.15em;
|
||||
border: 1px solid rgba(var(--c-gold-rgb), 0.36);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: scale(1.05);
|
||||
background: #1d1d1d;
|
||||
box-shadow: 0 10px 24px rgba(var(--c-gold-rgb), 0.24);
|
||||
}
|
||||
|
||||
/* 导航系统 */
|
||||
.pagination {
|
||||
position: absolute;
|
||||
right: 4vw;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
z-index: 100;
|
||||
mix-blend-mode: difference;
|
||||
}
|
||||
|
||||
.dot-nav {
|
||||
width: 3px;
|
||||
height: 12px;
|
||||
background: rgba(var(--c-gold-rgb), 0.22);
|
||||
transition: all 0.4s var(--ease-out);
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.dot-nav:hover {
|
||||
background: rgba(var(--c-gold-rgb), 0.56);
|
||||
}
|
||||
|
||||
.dot-nav.active {
|
||||
background: var(--c-gold-strong);
|
||||
height: 32px;
|
||||
box-shadow: 0 0 10px rgba(var(--c-gold-rgb), 0.52);
|
||||
}
|
||||
|
||||
.swipe-hint {
|
||||
position: absolute;
|
||||
bottom: 5vh;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-family: 'SpaceMonoLocal';
|
||||
font-size: clamp(0.74rem, 0.9vw, 0.9rem);
|
||||
letter-spacing: 0.28em;
|
||||
color: var(--c-text-muted);
|
||||
font-weight: 500;
|
||||
text-rendering: geometricPrecision;
|
||||
z-index: 100;
|
||||
opacity: 0;
|
||||
transition: opacity 0.8s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&[data-scene="0"] .swipe-hint {
|
||||
opacity: 0.6;
|
||||
animation: hintPulse 2s infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes hintPulse {
|
||||
0% {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%) translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载状态
|
||||
&.loading,
|
||||
&.error {
|
||||
display: flex;
|
||||
@@ -165,7 +989,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
color: var(--ar-text-sub);
|
||||
color: var(--c-text-muted);
|
||||
|
||||
p {
|
||||
font-size: 16px;
|
||||
@@ -173,10 +997,13 @@
|
||||
}
|
||||
|
||||
&.loading {
|
||||
animation: loadingPageEnter 0.46s var(--ease-out) both;
|
||||
|
||||
.loading-ring {
|
||||
position: relative;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
animation: loadingRingEnter 0.52s var(--ease-epic) both;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
@@ -186,13 +1013,13 @@
|
||||
|
||||
.ring-bg {
|
||||
fill: none;
|
||||
stroke: rgba(0, 0, 0, 0.08);
|
||||
stroke: rgba(var(--c-gold-rgb), 0.2);
|
||||
stroke-width: 6;
|
||||
}
|
||||
|
||||
.ring-progress {
|
||||
fill: none;
|
||||
stroke: var(--ar-primary);
|
||||
stroke: var(--c-gold-strong);
|
||||
stroke-width: 6;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 264;
|
||||
@@ -206,1216 +1033,58 @@
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 36px;
|
||||
font-weight: 600;
|
||||
color: var(--ar-primary);
|
||||
color: var(--c-text-bright);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-stage {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--ar-text-main);
|
||||
color: var(--c-gold-strong);
|
||||
margin-top: 24px;
|
||||
animation: loadingTextEnter 0.52s var(--ease-out) both;
|
||||
animation-delay: 0.06s;
|
||||
}
|
||||
|
||||
.loading-hint {
|
||||
font-size: 14px;
|
||||
color: var(--ar-text-sub);
|
||||
color: var(--c-text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.report-container {
|
||||
width: 80%;
|
||||
margin: 0 auto;
|
||||
padding: 32px 5% 60px;
|
||||
padding-top: 48px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.exporting-snapshot *::selection {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.exporting-snapshot * {
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
.exporting-snapshot {
|
||||
|
||||
.hero-title,
|
||||
.label-text,
|
||||
.hero-desc,
|
||||
.stat-num,
|
||||
.stat-unit,
|
||||
.hl,
|
||||
.gold {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.deco-circle {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
min-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
.label-text {
|
||||
font-size: 12px;
|
||||
letter-spacing: 3px;
|
||||
text-transform: uppercase;
|
||||
color: var(--ar-text-sub);
|
||||
margin-bottom: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: clamp(28px, 5vw, 44px);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 16px;
|
||||
color: var(--ar-text-main);
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
color: var(--ar-text-sub);
|
||||
max-width: 500px;
|
||||
|
||||
&.active-time {
|
||||
font-size: 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.big-stat {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.stat-num {
|
||||
font-size: clamp(40px, 8vw, 64px);
|
||||
font-weight: 700;
|
||||
color: var(--ar-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-unit {
|
||||
font-size: 18px;
|
||||
color: var(--ar-text-sub);
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 50px;
|
||||
height: 3px;
|
||||
background: var(--ar-accent);
|
||||
margin: 24px 0;
|
||||
border: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.hl {
|
||||
color: var(--ar-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gold {
|
||||
color: var(--ar-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// 头像组件
|
||||
.avatar {
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&.sm {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&.md {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&.lg {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
font-size: 20px;
|
||||
border: 3px solid #fff;
|
||||
box-shadow: 0 6px 20px rgba(7, 193, 96, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
// 月度好友环形布局
|
||||
.monthly-orbit {
|
||||
--radius: 180px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
height: 500px;
|
||||
margin: 30px auto 0;
|
||||
}
|
||||
|
||||
.monthly-center {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
z-index: 2;
|
||||
|
||||
.avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.monthly-item {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 90px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
transform: translate(-50%, -50%) rotate(calc(var(--i) * 30deg)) translateY(calc(-1 * var(--radius))) rotate(calc(var(--i) * -30deg));
|
||||
z-index: 1;
|
||||
|
||||
.avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.month-label {
|
||||
font-size: 11px;
|
||||
color: var(--ar-text-sub);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.month-name {
|
||||
font-size: 11px;
|
||||
color: var(--ar-text-sub);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// 热力图
|
||||
.heatmap-wrapper {
|
||||
margin-top: 24px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heatmap-header {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr;
|
||||
gap: 3px;
|
||||
margin-bottom: 6px;
|
||||
color: var(--ar-text-sub);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.time-labels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(24, 1fr);
|
||||
gap: 3px;
|
||||
|
||||
span {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.heatmap {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.heatmap-week-col {
|
||||
display: grid;
|
||||
grid-template-rows: repeat(7, 1fr);
|
||||
gap: 3px;
|
||||
font-size: 10px;
|
||||
color: var(--ar-text-sub);
|
||||
}
|
||||
|
||||
.week-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.heatmap-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(24, 1fr);
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.h-cell {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 2px;
|
||||
min-height: 10px;
|
||||
transition: transform 0.15s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.3);
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 好友列表
|
||||
.friend-list {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.friend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
background: var(--ar-card-bg);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 10px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--ar-card-bg-hover);
|
||||
}
|
||||
|
||||
.rank {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: var(--ar-rank-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ar-rank-color);
|
||||
flex-shrink: 0;
|
||||
|
||||
&.top {
|
||||
background: linear-gradient(135deg, #ffd700, #ffb800);
|
||||
color: #fff;
|
||||
animation: loadingTextEnter 0.52s var(--ease-out) both;
|
||||
animation-delay: 0.12s;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 12px;
|
||||
color: var(--ar-text-sub);
|
||||
}
|
||||
|
||||
.percent {
|
||||
font-size: 13px;
|
||||
color: var(--ar-primary);
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 领奖台布局
|
||||
.podium {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 40px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.podium-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
|
||||
.avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border: 3px solid var(--ar-card-bg);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&.first {
|
||||
.avatar {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-color: #ffd700;
|
||||
}
|
||||
|
||||
.crown {
|
||||
font-size: 28px;
|
||||
margin-bottom: -8px;
|
||||
animation: crownBounce 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.podium-stand {
|
||||
height: 100px;
|
||||
background: linear-gradient(180deg, #ffd700, #ffb800);
|
||||
}
|
||||
}
|
||||
|
||||
&.second {
|
||||
.podium-stand {
|
||||
height: 70px;
|
||||
background: linear-gradient(180deg, #e0e0e0, #c0c0c0);
|
||||
}
|
||||
}
|
||||
|
||||
&.third {
|
||||
.podium-stand {
|
||||
height: 50px;
|
||||
background: linear-gradient(180deg, #cd9b6a, #b87333);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.podium-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ar-text-main);
|
||||
max-width: 90px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.podium-count {
|
||||
font-size: 11px;
|
||||
color: var(--ar-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.podium-stand {
|
||||
width: 90px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.podium-rank {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
@keyframes crownBounce {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
// 第4-5名列表
|
||||
.runner-up-list {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.runner-up-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: var(--ar-card-bg);
|
||||
border-radius: 10px;
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
.runner-up-rank {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--ar-rank-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--ar-rank-color);
|
||||
}
|
||||
|
||||
.runner-up-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--ar-text-main);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.runner-up-count {
|
||||
font-size: 12px;
|
||||
color: var(--ar-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 结尾
|
||||
.ending {
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ending-year {
|
||||
font-size: 100px;
|
||||
font-weight: 700;
|
||||
color: var(--ar-primary);
|
||||
opacity: 0.1;
|
||||
margin-top: 30px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ending-brand {
|
||||
font-size: 14px;
|
||||
letter-spacing: 4px;
|
||||
color: var(--ar-text-sub);
|
||||
margin-top: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// 双向奔赴 - 新样式
|
||||
.mutual-visual {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin: 40px 0 24px;
|
||||
}
|
||||
|
||||
.mutual-side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
&.friend {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.mutual-arrow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.arrow-count {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--ar-primary);
|
||||
}
|
||||
|
||||
.arrow-line {
|
||||
font-size: 20px;
|
||||
color: var(--ar-text-sub);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&.reverse {
|
||||
.arrow-count {
|
||||
color: var(--ar-accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mutual-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 20px;
|
||||
|
||||
.mutual-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.mutual-ratio {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--ar-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.mutual-name-tag {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--ar-text-main);
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
// 常用语列表
|
||||
.phrase-list {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.phrase-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
background: var(--ar-card-bg);
|
||||
border-radius: 12px;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.phrase-text {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--ar-text-main);
|
||||
}
|
||||
|
||||
.phrase-count {
|
||||
font-size: 14px;
|
||||
color: var(--ar-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// 加载动画
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 顶部拖动区域
|
||||
.drag-region {
|
||||
position: absolute; // Changed from fixed
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0; // Changed from right: 138px (since it's now inside the window container)
|
||||
height: 32px;
|
||||
-webkit-app-region: drag !important;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
// 浮动操作按钮
|
||||
.fab-container {
|
||||
position: fixed;
|
||||
bottom: 64px;
|
||||
right: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
z-index: 99;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.fab-main {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--ar-primary);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(7, 193, 96, 0.4);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
order: 99;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 24px rgba(7, 193, 96, 0.5);
|
||||
}
|
||||
|
||||
.fab-container.open & {
|
||||
transform: rotate(45deg);
|
||||
background: var(--ar-text-sub);
|
||||
}
|
||||
}
|
||||
|
||||
.fab-item {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--ar-card-bg);
|
||||
color: var(--ar-text-main);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
opacity: 0;
|
||||
transform: scale(0.5) translateY(20px);
|
||||
pointer-events: none;
|
||||
|
||||
.fab-container.open & {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
pointer-events: auto;
|
||||
|
||||
&:nth-child(1) {
|
||||
transition-delay: 0.05s;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
transition-delay: 0.1s;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--ar-primary);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出遮罩和弹窗
|
||||
.export-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出进度弹窗
|
||||
.export-progress-modal {
|
||||
background: var(--bg-primary, #fff);
|
||||
padding: 40px 48px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
animation: scaleIn 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.export-spinner {
|
||||
position: relative;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.spinner-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 3px solid rgba(0, 0, 0, 0.08);
|
||||
border-top-color: var(--ar-primary);
|
||||
border-radius: 50%;
|
||||
animation: spinRing 1s linear infinite;
|
||||
}
|
||||
|
||||
.spinner-icon {
|
||||
color: var(--ar-primary);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spinRing {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.export-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--ar-text-main);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.export-status {
|
||||
font-size: 14px;
|
||||
color: var(--ar-text-sub);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.export-modal {
|
||||
background: var(--bg-primary, #fff);
|
||||
padding: 0;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
||||
min-width: 280px;
|
||||
color: var(--ar-text-main);
|
||||
overflow: hidden;
|
||||
|
||||
p {
|
||||
margin-top: 12px;
|
||||
color: var(--ar-text-sub);
|
||||
}
|
||||
|
||||
&.section-selector {
|
||||
width: 420px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--ar-text-sub);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
padding: 20px 24px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
position: relative;
|
||||
padding: 16px 12px;
|
||||
border-radius: 10px;
|
||||
background: var(--ar-card-bg);
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--ar-card-bg-hover);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: var(--ar-primary);
|
||||
background: rgba(7, 193, 96, 0.08);
|
||||
}
|
||||
|
||||
.card-check {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--ar-primary);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
&.selected .card-check {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
|
||||
.select-all-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
color: var(--ar-text-sub);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
background: var(--ar-primary);
|
||||
border: none;
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 词云样式
|
||||
.word-cloud-wrapper {
|
||||
margin: 24px auto 0;
|
||||
padding: 0;
|
||||
max-width: 520px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
--cloud-scale: clamp(0.72, 80vw / 520, 1);
|
||||
}
|
||||
|
||||
.word-cloud-inner {
|
||||
position: relative;
|
||||
width: 520px;
|
||||
height: 520px;
|
||||
margin: 0;
|
||||
border-radius: 50%;
|
||||
transform: scale(var(--cloud-scale));
|
||||
transform-origin: center;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -6%;
|
||||
background:
|
||||
radial-gradient(circle at 35% 45%, rgba(7, 193, 96, 0.12), transparent 55%),
|
||||
radial-gradient(circle at 65% 50%, rgba(242, 170, 0, 0.1), transparent 58%),
|
||||
radial-gradient(circle at 50% 65%, rgba(0, 0, 0, 0.04), transparent 60%);
|
||||
filter: blur(18px);
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.word-tag {
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
transition: transform 0.2s ease, color 0.2s ease;
|
||||
cursor: default;
|
||||
color: var(--ar-text-main);
|
||||
font-weight: 600;
|
||||
opacity: 0;
|
||||
animation: wordPopIn 0.55s ease forwards;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
transform: translate(-50%, -50%) scale(0.8);
|
||||
|
||||
&:hover {
|
||||
transform: translate(-50%, -50%) scale(1.08);
|
||||
color: var(--ar-primary);
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wordPopIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.6);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: var(--final-opacity, 1);
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.word-cloud-note {
|
||||
margin-top: 24px;
|
||||
font-size: 14px !important;
|
||||
color: var(--ar-text-sub) !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// 曾经的好朋友 视觉效果
|
||||
.lost-friend-visual {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 32px;
|
||||
margin: 64px auto 48px;
|
||||
position: relative;
|
||||
max-width: 480px;
|
||||
|
||||
.avatar-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
z-index: 2;
|
||||
|
||||
.avatar-label {
|
||||
font-size: 13px;
|
||||
color: var(--ar-text-sub);
|
||||
font-weight: 500;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&.sender {
|
||||
animation: fadeInRight 1s ease-out backwards;
|
||||
}
|
||||
|
||||
&.receiver {
|
||||
animation: fadeInLeft 1s ease-out backwards;
|
||||
}
|
||||
}
|
||||
|
||||
.fading-line {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
min-width: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.line-path {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(to right,
|
||||
var(--ar-primary) 0%,
|
||||
rgba(var(--ar-primary-rgb), 0.4) 50%,
|
||||
rgba(var(--ar-primary-rgb), 0.05) 100%);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.line-glow {
|
||||
position: absolute;
|
||||
inset: -4px 0;
|
||||
background: linear-gradient(to right,
|
||||
rgba(var(--ar-primary-rgb), 0.2) 0%,
|
||||
transparent 100%);
|
||||
filter: blur(8px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.flow-particle {
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
background: linear-gradient(to right, transparent, var(--ar-primary), transparent);
|
||||
border-radius: 2px;
|
||||
@keyframes loadingPageEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
animation: flowAcross 4s infinite linear;
|
||||
filter: blur(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loadingRingEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.94);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loadingTextEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hero-desc.fading {
|
||||
opacity: 0.7;
|
||||
font-style: italic;
|
||||
font-size: 16px;
|
||||
margin-top: 32px;
|
||||
line-height: 1.8;
|
||||
letter-spacing: 0.05em;
|
||||
animation: fadeIn 1.5s ease-out 0.5s backwards;
|
||||
}
|
||||
|
||||
@keyframes flowAcross {
|
||||
0% {
|
||||
left: -20%;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
10% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
90% {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
100% {
|
||||
left: 120%;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
+822
-855
@@ -1,14 +1,12 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Loader2, Download, Image, Check, X, SlidersHorizontal } from 'lucide-react'
|
||||
import html2canvas from 'html2canvas'
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import {
|
||||
finishBackgroundTask,
|
||||
isBackgroundTaskCancelRequested,
|
||||
registerBackgroundTask,
|
||||
updateBackgroundTask
|
||||
} from '../services/backgroundTaskMonitor'
|
||||
import { drawPatternBackground } from '../utils/reportExport'
|
||||
import './AnnualReportWindow.scss'
|
||||
|
||||
interface TopContact {
|
||||
@@ -39,7 +37,13 @@ interface AnnualReportData {
|
||||
midnightKing: { displayName: string; count: number; percentage: number } | null
|
||||
selfAvatarUrl?: string
|
||||
mutualFriend?: { displayName: string; avatarUrl?: string; sentCount: number; receivedCount: number; ratio: number } | null
|
||||
socialInitiative?: { initiatedChats: number; receivedChats: number; initiativeRate: number } | null
|
||||
socialInitiative?: {
|
||||
initiatedChats: number
|
||||
receivedChats: number
|
||||
initiativeRate: number
|
||||
topInitiatedFriend?: string
|
||||
topInitiatedCount?: number
|
||||
} | null
|
||||
responseSpeed?: { avgResponseTime: number; fastestFriend: string; fastestTime: number } | null
|
||||
topPhrases?: { phrase: string; count: number }[]
|
||||
snsStats?: {
|
||||
@@ -58,67 +62,71 @@ interface AnnualReportData {
|
||||
} | null
|
||||
}
|
||||
|
||||
interface SectionInfo {
|
||||
id: string
|
||||
name: string
|
||||
ref: React.RefObject<HTMLElement | null>
|
||||
const DecodeText = ({
|
||||
value,
|
||||
active
|
||||
}: {
|
||||
value: string | number
|
||||
active: boolean
|
||||
}) => {
|
||||
const strVal = String(value)
|
||||
const [display, setDisplay] = useState(strVal)
|
||||
const decodedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
setDisplay(strVal)
|
||||
}, [strVal])
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
decodedRef.current = false
|
||||
return
|
||||
}
|
||||
if (decodedRef.current) return
|
||||
decodedRef.current = true
|
||||
|
||||
const chars = '018X-/#*'
|
||||
let iter = 0
|
||||
const inv = setInterval(() => {
|
||||
setDisplay(strVal.split('').map((c, i) => {
|
||||
if (c === ',' || c === ' ' || c === ':') return c
|
||||
if (i < iter) return strVal[i]
|
||||
return chars[Math.floor(Math.random() * chars.length)]
|
||||
}).join(''))
|
||||
|
||||
if (iter >= strVal.length) {
|
||||
clearInterval(inv)
|
||||
setDisplay(strVal)
|
||||
}
|
||||
iter += 1 / 3
|
||||
}, 35)
|
||||
|
||||
return () => clearInterval(inv)
|
||||
}, [active, strVal])
|
||||
|
||||
return <>{display.length > 0 ? display : value}</>
|
||||
}
|
||||
|
||||
// 头像组件
|
||||
const Avatar = ({ url, name, size = 'md' }: { url?: string; name: string; size?: 'sm' | 'md' | 'lg' }) => {
|
||||
const [imgError, setImgError] = useState(false)
|
||||
const initial = name?.[0] || '友'
|
||||
|
||||
return (
|
||||
<div className={`avatar ${size}`}>
|
||||
{url && !imgError ? (
|
||||
<img src={url} alt="" onError={() => setImgError(true)} crossOrigin="anonymous" />
|
||||
) : (
|
||||
<span>{initial}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
import Heatmap from '../components/ReportHeatmap'
|
||||
import WordCloud from '../components/ReportWordCloud'
|
||||
|
||||
function AnnualReportWindow() {
|
||||
const navigate = useNavigate()
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const [reportData, setReportData] = useState<AnnualReportData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [exportProgress, setExportProgress] = useState('')
|
||||
const [showExportModal, setShowExportModal] = useState(false)
|
||||
const [selectedSections, setSelectedSections] = useState<Set<string>>(new Set())
|
||||
const [fabOpen, setFabOpen] = useState(false)
|
||||
const [loadingProgress, setLoadingProgress] = useState(0)
|
||||
const [loadingStage, setLoadingStage] = useState('正在初始化...')
|
||||
const [exportMode, setExportMode] = useState<'separate' | 'long'>('separate')
|
||||
|
||||
const { currentTheme, themeMode } = useThemeStore()
|
||||
|
||||
// Section refs
|
||||
const sectionRefs = {
|
||||
cover: useRef<HTMLElement>(null),
|
||||
overview: useRef<HTMLElement>(null),
|
||||
bestFriend: useRef<HTMLElement>(null),
|
||||
monthlyFriends: useRef<HTMLElement>(null),
|
||||
mutualFriend: useRef<HTMLElement>(null),
|
||||
socialInitiative: useRef<HTMLElement>(null),
|
||||
peakDay: useRef<HTMLElement>(null),
|
||||
streak: useRef<HTMLElement>(null),
|
||||
heatmap: useRef<HTMLElement>(null),
|
||||
midnightKing: useRef<HTMLElement>(null),
|
||||
responseSpeed: useRef<HTMLElement>(null),
|
||||
topPhrases: useRef<HTMLElement>(null),
|
||||
ranking: useRef<HTMLElement>(null),
|
||||
sns: useRef<HTMLElement>(null),
|
||||
lostFriend: useRef<HTMLElement>(null),
|
||||
ending: useRef<HTMLElement>(null),
|
||||
}
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const TOTAL_SCENES = 11
|
||||
const [currentScene, setCurrentScene] = useState(0)
|
||||
const [isAnimating, setIsAnimating] = useState(false)
|
||||
const p0CanvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const s3LayoutRef = useRef<HTMLDivElement | null>(null)
|
||||
const s3ListRef = useRef<HTMLDivElement | null>(null)
|
||||
const [s3LineVars, setS3LineVars] = useState<React.CSSProperties>({})
|
||||
|
||||
// 提取长图逻辑变量
|
||||
const [buttonText, setButtonText] = useState('EXTRACT RECORD')
|
||||
const [isExtracting, setIsExtracting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
@@ -132,7 +140,7 @@ function AnnualReportWindow() {
|
||||
const taskId = registerBackgroundTask({
|
||||
sourcePage: 'annualReport',
|
||||
title: '年度报告生成',
|
||||
detail: `正在生成 ${formatYearLabel(year)} 年度报告`,
|
||||
detail: `正在生成 ${year === 0 ? '历史以来' : year + '年'} 年度报告`,
|
||||
progressText: '初始化',
|
||||
cancelable: true
|
||||
})
|
||||
@@ -188,401 +196,311 @@ function AnnualReportWindow() {
|
||||
}
|
||||
}
|
||||
|
||||
const formatNumber = (num: number) => num.toLocaleString()
|
||||
// Handle Scroll and touch events
|
||||
const goToScene = useCallback((index: number) => {
|
||||
if (isAnimating || index === currentScene || index < 0 || index >= TOTAL_SCENES) return
|
||||
|
||||
const getMostActiveTime = (data: number[][]) => {
|
||||
let maxHour = 0, maxWeekday = 0, maxVal = 0
|
||||
data.forEach((row, w) => {
|
||||
row.forEach((val, h) => {
|
||||
if (val > maxVal) { maxVal = val; maxHour = h; maxWeekday = w }
|
||||
})
|
||||
setIsAnimating(true)
|
||||
setCurrentScene(index)
|
||||
|
||||
setTimeout(() => {
|
||||
setIsAnimating(false)
|
||||
}, 1500)
|
||||
}, [currentScene, isAnimating, TOTAL_SCENES])
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || error || !reportData) return
|
||||
|
||||
let touchStartY = 0
|
||||
let lastWheelTime = 0
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
const now = Date.now()
|
||||
if (now - lastWheelTime < 1000) return // Throttle wheel events
|
||||
|
||||
if (Math.abs(e.deltaY) > 30) {
|
||||
lastWheelTime = now
|
||||
goToScene(e.deltaY > 0 ? currentScene + 1 : currentScene - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
touchStartY = e.touches[0].clientY
|
||||
}
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
e.preventDefault() // prevent native scroll
|
||||
}
|
||||
|
||||
const handleTouchEnd = (e: TouchEvent) => {
|
||||
const deltaY = touchStartY - e.changedTouches[0].clientY
|
||||
if (deltaY > 40) goToScene(currentScene + 1)
|
||||
else if (deltaY < -40) goToScene(currentScene - 1)
|
||||
}
|
||||
|
||||
window.addEventListener('wheel', handleWheel, { passive: false })
|
||||
window.addEventListener('touchstart', handleTouchStart, { passive: false })
|
||||
window.addEventListener('touchmove', handleTouchMove, { passive: false })
|
||||
window.addEventListener('touchend', handleTouchEnd)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('wheel', handleWheel)
|
||||
window.removeEventListener('touchstart', handleTouchStart)
|
||||
window.removeEventListener('touchmove', handleTouchMove)
|
||||
window.removeEventListener('touchend', handleTouchEnd)
|
||||
}
|
||||
}, [currentScene, isLoading, error, reportData, goToScene])
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || error || !reportData || currentScene !== 0) return
|
||||
|
||||
const canvas = p0CanvasRef.current
|
||||
const ctx = canvas?.getContext('2d')
|
||||
if (!canvas || !ctx) return
|
||||
|
||||
let rafId = 0
|
||||
let particles: Array<{
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
size: number
|
||||
alpha: number
|
||||
}> = []
|
||||
|
||||
const buildParticle = () => ({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height,
|
||||
vx: (Math.random() - 0.5) * 0.3,
|
||||
vy: (Math.random() - 0.5) * 0.3,
|
||||
size: Math.random() * 1.5 + 0.5,
|
||||
alpha: Math.random() * 0.5 + 0.1
|
||||
})
|
||||
const weekdayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
return { weekday: weekdayNames[maxWeekday], hour: maxHour }
|
||||
}
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
if (seconds < 60) return `${seconds}秒`
|
||||
if (seconds < 3600) return `${Math.round(seconds / 60)}分钟`
|
||||
return `${Math.round(seconds / 3600)}小时`
|
||||
}
|
||||
|
||||
const formatYearLabel = (value: number, withSuffix: boolean = true) => {
|
||||
if (value === 0) return '历史以来'
|
||||
return withSuffix ? `${value}年` : `${value}`
|
||||
}
|
||||
|
||||
// 获取可用的板块列表
|
||||
const getAvailableSections = (): SectionInfo[] => {
|
||||
if (!reportData) return []
|
||||
const sections: SectionInfo[] = [
|
||||
{ id: 'cover', name: '封面', ref: sectionRefs.cover },
|
||||
{ id: 'overview', name: '年度概览', ref: sectionRefs.overview },
|
||||
]
|
||||
if (reportData.coreFriends[0]) {
|
||||
sections.push({ id: 'bestFriend', name: '年度挚友', ref: sectionRefs.bestFriend })
|
||||
}
|
||||
sections.push({ id: 'monthlyFriends', name: '月度好友', ref: sectionRefs.monthlyFriends })
|
||||
if (reportData.mutualFriend) {
|
||||
sections.push({ id: 'mutualFriend', name: '双向奔赴', ref: sectionRefs.mutualFriend })
|
||||
}
|
||||
if (reportData.socialInitiative) {
|
||||
sections.push({ id: 'socialInitiative', name: '社交主动性', ref: sectionRefs.socialInitiative })
|
||||
}
|
||||
if (reportData.peakDay) {
|
||||
sections.push({ id: 'peakDay', name: '巅峰时刻', ref: sectionRefs.peakDay })
|
||||
}
|
||||
if (reportData.longestStreak) {
|
||||
sections.push({ id: 'streak', name: '聊天火花', ref: sectionRefs.streak })
|
||||
}
|
||||
sections.push({ id: 'heatmap', name: '作息规律', ref: sectionRefs.heatmap })
|
||||
if (reportData.midnightKing) {
|
||||
sections.push({ id: 'midnightKing', name: '深夜好友', ref: sectionRefs.midnightKing })
|
||||
}
|
||||
if (reportData.responseSpeed) {
|
||||
sections.push({ id: 'responseSpeed', name: '回应速度', ref: sectionRefs.responseSpeed })
|
||||
}
|
||||
if (reportData.lostFriend) {
|
||||
sections.push({ id: 'lostFriend', name: '曾经的好朋友', ref: sectionRefs.lostFriend })
|
||||
}
|
||||
if (reportData.topPhrases && reportData.topPhrases.length > 0) {
|
||||
sections.push({ id: 'topPhrases', name: '年度常用语', ref: sectionRefs.topPhrases })
|
||||
}
|
||||
sections.push({ id: 'ranking', name: '好友排行', ref: sectionRefs.ranking })
|
||||
if (reportData.snsStats && reportData.snsStats.totalPosts > 0) {
|
||||
sections.push({ id: 'sns', name: '朋友圈', ref: sectionRefs.sns })
|
||||
}
|
||||
sections.push({ id: 'ending', name: '尾声', ref: sectionRefs.ending })
|
||||
return sections
|
||||
}
|
||||
|
||||
// 导出单个板块 - 统一 16:9 尺寸
|
||||
const exportSection = async (section: SectionInfo): Promise<{ name: string; data: string } | null> => {
|
||||
const element = section.ref.current
|
||||
if (!element) {
|
||||
return null
|
||||
const initParticles = () => {
|
||||
const count = Math.max(36, Math.floor((canvas.width * canvas.height) / 15000))
|
||||
particles = Array.from({ length: count }, () => buildParticle())
|
||||
}
|
||||
|
||||
// 固定输出尺寸 1920x1080 (16:9)
|
||||
const OUTPUT_WIDTH = 1920
|
||||
const OUTPUT_HEIGHT = 1080
|
||||
|
||||
try {
|
||||
const selection = window.getSelection()
|
||||
if (selection && selection.rangeCount > 0) selection.removeAllRanges()
|
||||
const activeEl = document.activeElement as HTMLElement | null
|
||||
activeEl?.blur?.()
|
||||
document.body.classList.add('exporting-snapshot')
|
||||
document.documentElement.classList.add('exporting-snapshot')
|
||||
|
||||
const originalStyle = element.style.cssText
|
||||
element.style.minHeight = 'auto'
|
||||
element.style.padding = '40px 20px'
|
||||
element.style.background = 'transparent'
|
||||
element.style.backgroundColor = 'transparent'
|
||||
element.style.boxShadow = 'none'
|
||||
|
||||
// 修复词云
|
||||
const wordCloudInner = element.querySelector('.word-cloud-inner') as HTMLElement
|
||||
const wordTags = element.querySelectorAll('.word-tag') as NodeListOf<HTMLElement>
|
||||
let wordCloudOriginalStyle = ''
|
||||
const wordTagOriginalStyles: string[] = []
|
||||
|
||||
if (wordCloudInner) {
|
||||
wordCloudOriginalStyle = wordCloudInner.style.cssText
|
||||
wordCloudInner.style.transform = 'none'
|
||||
}
|
||||
|
||||
wordTags.forEach((tag, i) => {
|
||||
wordTagOriginalStyles[i] = tag.style.cssText
|
||||
tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1')
|
||||
tag.style.animation = 'none'
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#F9F8F6'
|
||||
|
||||
const canvas = await html2canvas(element, {
|
||||
backgroundColor: 'transparent', // 透明背景,让 SVG 图案显示
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
logging: false,
|
||||
onclone: (clonedDoc) => {
|
||||
clonedDoc.body.classList.add('exporting-snapshot')
|
||||
clonedDoc.documentElement.classList.add('exporting-snapshot')
|
||||
clonedDoc.getSelection?.()?.removeAllRanges()
|
||||
},
|
||||
})
|
||||
|
||||
// 恢复样式
|
||||
element.style.cssText = originalStyle
|
||||
if (wordCloudInner) {
|
||||
wordCloudInner.style.cssText = wordCloudOriginalStyle
|
||||
}
|
||||
wordTags.forEach((tag, i) => {
|
||||
tag.style.cssText = wordTagOriginalStyles[i]
|
||||
})
|
||||
document.body.classList.remove('exporting-snapshot')
|
||||
document.documentElement.classList.remove('exporting-snapshot')
|
||||
|
||||
// 创建固定 16:9 尺寸的画布
|
||||
const outputCanvas = document.createElement('canvas')
|
||||
outputCanvas.width = OUTPUT_WIDTH
|
||||
outputCanvas.height = OUTPUT_HEIGHT
|
||||
const ctx = outputCanvas.getContext('2d')!
|
||||
|
||||
// 绘制带 SVG 图案的背景
|
||||
const isDark = themeMode === 'dark'
|
||||
await drawPatternBackground(ctx, OUTPUT_WIDTH, OUTPUT_HEIGHT, bgColor, isDark)
|
||||
|
||||
// 边距 (留出更多空白)
|
||||
const PADDING = 80
|
||||
const contentWidth = OUTPUT_WIDTH - PADDING * 2
|
||||
const contentHeight = OUTPUT_HEIGHT - PADDING * 2
|
||||
|
||||
// 计算缩放和居中位置
|
||||
const srcRatio = canvas.width / canvas.height
|
||||
const dstRatio = contentWidth / contentHeight
|
||||
let drawWidth: number, drawHeight: number, drawX: number, drawY: number
|
||||
|
||||
if (srcRatio > dstRatio) {
|
||||
// 源图更宽,以宽度为准
|
||||
drawWidth = contentWidth
|
||||
drawHeight = contentWidth / srcRatio
|
||||
drawX = PADDING
|
||||
drawY = PADDING + (contentHeight - drawHeight) / 2
|
||||
} else {
|
||||
// 源图更高,以高度为准
|
||||
drawHeight = contentHeight
|
||||
drawWidth = contentHeight * srcRatio
|
||||
drawX = PADDING + (contentWidth - drawWidth) / 2
|
||||
drawY = PADDING
|
||||
}
|
||||
|
||||
ctx.drawImage(canvas, drawX, drawY, drawWidth, drawHeight)
|
||||
|
||||
return { name: section.name, data: outputCanvas.toDataURL('image/png') }
|
||||
} catch (e) {
|
||||
document.body.classList.remove('exporting-snapshot')
|
||||
return null
|
||||
const resizeCanvas = () => {
|
||||
canvas.width = window.innerWidth
|
||||
canvas.height = window.innerHeight
|
||||
initParticles()
|
||||
}
|
||||
}
|
||||
|
||||
// 导出整个报告为长图
|
||||
const exportFullReport = async (filterIds?: Set<string>) => {
|
||||
if (!containerRef.current) {
|
||||
return
|
||||
}
|
||||
setIsExporting(true)
|
||||
setExportProgress('正在生成长图...')
|
||||
const animate = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
try {
|
||||
const selection = window.getSelection()
|
||||
if (selection && selection.rangeCount > 0) selection.removeAllRanges()
|
||||
const activeEl = document.activeElement as HTMLElement | null
|
||||
activeEl?.blur?.()
|
||||
document.body.classList.add('exporting-snapshot')
|
||||
document.documentElement.classList.add('exporting-snapshot')
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
const p = particles[i]
|
||||
p.x += p.vx
|
||||
p.y += p.vy
|
||||
|
||||
const container = containerRef.current
|
||||
const sections = container.querySelectorAll('.section')
|
||||
const originalStyles: string[] = []
|
||||
if (p.x < 0 || p.x > canvas.width) p.vx *= -1
|
||||
if (p.y < 0 || p.y > canvas.height) p.vy *= -1
|
||||
|
||||
sections.forEach((section, i) => {
|
||||
const el = section as HTMLElement
|
||||
originalStyles[i] = el.style.cssText
|
||||
el.style.minHeight = 'auto'
|
||||
el.style.padding = '40px 0'
|
||||
})
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2)
|
||||
const particleAlpha = p.alpha * (i % 4 === 0 ? 0.95 : 0.72)
|
||||
ctx.fillStyle = i % 4 === 0
|
||||
? `rgba(184, 148, 90, ${particleAlpha})`
|
||||
: `rgba(255, 255, 255, ${particleAlpha})`
|
||||
ctx.fill()
|
||||
|
||||
// 如果有筛选,隐藏未选中的板块
|
||||
if (filterIds) {
|
||||
const available = getAvailableSections()
|
||||
available.forEach(s => {
|
||||
if (!filterIds.has(s.id) && s.ref.current) {
|
||||
s.ref.current.style.display = 'none'
|
||||
for (let j = i + 1; j < particles.length; j++) {
|
||||
const q = particles[j]
|
||||
const dx = p.x - q.x
|
||||
const dy = p.y - q.y
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
if (distance < 150) {
|
||||
const lineAlpha = (1 - distance / 150) * 0.15
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(p.x, p.y)
|
||||
ctx.lineTo(q.x, q.y)
|
||||
ctx.strokeStyle = i % 3 === 0
|
||||
? `rgba(184, 148, 90, ${lineAlpha * 0.8})`
|
||||
: `rgba(255, 255, 255, ${lineAlpha * 0.72})`
|
||||
ctx.lineWidth = 0.5
|
||||
ctx.stroke()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 修复词云导出问题
|
||||
const wordCloudInner = container.querySelector('.word-cloud-inner') as HTMLElement
|
||||
const wordTags = container.querySelectorAll('.word-tag') as NodeListOf<HTMLElement>
|
||||
let wordCloudOriginalStyle = ''
|
||||
const wordTagOriginalStyles: string[] = []
|
||||
|
||||
if (wordCloudInner) {
|
||||
wordCloudOriginalStyle = wordCloudInner.style.cssText
|
||||
wordCloudInner.style.transform = 'none'
|
||||
}
|
||||
|
||||
wordTags.forEach((tag, i) => {
|
||||
wordTagOriginalStyles[i] = tag.style.cssText
|
||||
tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1')
|
||||
tag.style.animation = 'none'
|
||||
})
|
||||
|
||||
// 等待样式生效
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
|
||||
// 获取计算后的背景色
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#F9F8F6'
|
||||
|
||||
const canvas = await html2canvas(container, {
|
||||
backgroundColor: 'transparent', // 透明背景
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
logging: false,
|
||||
onclone: (clonedDoc) => {
|
||||
clonedDoc.body.classList.add('exporting-snapshot')
|
||||
clonedDoc.documentElement.classList.add('exporting-snapshot')
|
||||
clonedDoc.getSelection?.()?.removeAllRanges()
|
||||
},
|
||||
})
|
||||
|
||||
// 恢复原始样式
|
||||
sections.forEach((section, i) => {
|
||||
const el = section as HTMLElement
|
||||
el.style.cssText = originalStyles[i]
|
||||
})
|
||||
|
||||
if (wordCloudInner) {
|
||||
wordCloudInner.style.cssText = wordCloudOriginalStyle
|
||||
}
|
||||
|
||||
wordTags.forEach((tag, i) => {
|
||||
tag.style.cssText = wordTagOriginalStyles[i]
|
||||
})
|
||||
document.body.classList.remove('exporting-snapshot')
|
||||
document.documentElement.classList.remove('exporting-snapshot')
|
||||
|
||||
// 创建带 SVG 图案背景的输出画布
|
||||
const outputCanvas = document.createElement('canvas')
|
||||
outputCanvas.width = canvas.width
|
||||
outputCanvas.height = canvas.height
|
||||
const ctx = outputCanvas.getContext('2d')!
|
||||
|
||||
// 绘制 SVG 图案背景
|
||||
const isDark = themeMode === 'dark'
|
||||
await drawPatternBackground(ctx, canvas.width, canvas.height, bgColor, isDark)
|
||||
|
||||
// 绘制内容
|
||||
ctx.drawImage(canvas, 0, 0)
|
||||
|
||||
const dataUrl = outputCanvas.toDataURL('image/png')
|
||||
const link = document.createElement('a')
|
||||
const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : ''
|
||||
link.download = `${yearFilePrefix}年度报告${filterIds ? '_自定义' : ''}.png`
|
||||
link.href = dataUrl
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
} catch (e) {
|
||||
alert('导出失败: ' + String(e))
|
||||
} finally {
|
||||
document.body.classList.remove('exporting-snapshot')
|
||||
document.documentElement.classList.remove('exporting-snapshot')
|
||||
setIsExporting(false)
|
||||
setExportProgress('')
|
||||
rafId = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
resizeCanvas()
|
||||
window.addEventListener('resize', resizeCanvas)
|
||||
animate()
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resizeCanvas)
|
||||
cancelAnimationFrame(rafId)
|
||||
}
|
||||
}, [isLoading, error, reportData, currentScene])
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || error || !reportData) return
|
||||
|
||||
let rafId = 0
|
||||
|
||||
const updateS3Line = () => {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const root = document.querySelector('.annual-report-window') as HTMLElement | null
|
||||
const layout = s3LayoutRef.current
|
||||
const list = s3ListRef.current
|
||||
if (!root || !layout || !list) return
|
||||
|
||||
const rootRect = root.getBoundingClientRect()
|
||||
const layoutRect = layout.getBoundingClientRect()
|
||||
const listRect = list.getBoundingClientRect()
|
||||
if (listRect.height <= 0 || layoutRect.width <= 0) return
|
||||
|
||||
const leftOffset = Math.max(8, Math.min(16, layoutRect.width * 0.018))
|
||||
const lineLeft = layoutRect.left - rootRect.left + leftOffset
|
||||
const lineCenterTop = listRect.top - rootRect.top + listRect.height / 2
|
||||
|
||||
setS3LineVars({
|
||||
['--s3-line-left' as '--s3-line-left']: `${lineLeft}px`,
|
||||
['--s3-line-top' as '--s3-line-top']: `${lineCenterTop}px`,
|
||||
['--s3-line-height' as '--s3-line-height']: `${listRect.height}px`
|
||||
} as React.CSSProperties)
|
||||
})
|
||||
}
|
||||
|
||||
updateS3Line()
|
||||
window.addEventListener('resize', updateS3Line)
|
||||
|
||||
const resizeObserver = typeof ResizeObserver !== 'undefined'
|
||||
? new ResizeObserver(() => updateS3Line())
|
||||
: null
|
||||
|
||||
if (resizeObserver) {
|
||||
if (s3LayoutRef.current) resizeObserver.observe(s3LayoutRef.current)
|
||||
if (s3ListRef.current) resizeObserver.observe(s3ListRef.current)
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId)
|
||||
window.removeEventListener('resize', updateS3Line)
|
||||
resizeObserver?.disconnect()
|
||||
}
|
||||
}, [isLoading, error, reportData, currentScene])
|
||||
|
||||
const getSceneClass = (index: number) => {
|
||||
if (index === currentScene) return 'scene active'
|
||||
if (index < currentScene) return 'scene prev'
|
||||
return 'scene next'
|
||||
}
|
||||
|
||||
// 导出选中的板块
|
||||
const exportSelectedSections = async () => {
|
||||
const sections = getAvailableSections().filter(s => selectedSections.has(s.id))
|
||||
if (sections.length === 0) {
|
||||
alert('请至少选择一个板块')
|
||||
return
|
||||
const handleClose = () => {
|
||||
navigate('/home')
|
||||
}
|
||||
|
||||
const formatFileYearLabel = (year: number) => (year === 0 ? '历史以来' : String(year))
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
const waitForNextPaint = () => new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => resolve())
|
||||
})
|
||||
})
|
||||
const captureSceneDataUrl = async (): Promise<string> => {
|
||||
const captureFn = window.electronAPI.annualReport.captureCurrentWindow
|
||||
if (typeof captureFn !== 'function') {
|
||||
throw new Error('当前版本未启用原生截图接口,请重启应用后重试')
|
||||
}
|
||||
|
||||
if (exportMode === 'long') {
|
||||
setShowExportModal(false)
|
||||
await exportFullReport(selectedSections)
|
||||
setSelectedSections(new Set())
|
||||
return
|
||||
const captureResult = await captureFn()
|
||||
if (!captureResult.success || !captureResult.dataUrl) {
|
||||
throw new Error(captureResult.error || '原生截图失败')
|
||||
}
|
||||
return captureResult.dataUrl
|
||||
}
|
||||
|
||||
setIsExporting(true)
|
||||
setShowExportModal(false)
|
||||
|
||||
const exportedImages: { name: string; data: string }[] = []
|
||||
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
const section = sections[i]
|
||||
setExportProgress(`正在导出: ${section.name} (${i + 1}/${sections.length})`)
|
||||
|
||||
const result = await exportSection(section)
|
||||
if (result) {
|
||||
exportedImages.push(result)
|
||||
}
|
||||
}
|
||||
|
||||
if (exportedImages.length === 0) {
|
||||
alert('导出失败')
|
||||
setIsExporting(false)
|
||||
setExportProgress('')
|
||||
return
|
||||
}
|
||||
const handleExtract = async () => {
|
||||
if (isExtracting || !reportData || !containerRef.current) return
|
||||
|
||||
const dirResult = await window.electronAPI.dialog.openDirectory({
|
||||
title: '选择导出文件夹',
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
})
|
||||
if (dirResult.canceled || !dirResult.filePaths?.[0]) {
|
||||
setIsExporting(false)
|
||||
setExportProgress('')
|
||||
return
|
||||
}
|
||||
if (dirResult.canceled || !dirResult.filePaths?.[0]) return
|
||||
|
||||
setExportProgress('正在写入文件...')
|
||||
const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : ''
|
||||
const exportResult = await window.electronAPI.annualReport.exportImages({
|
||||
baseDir: dirResult.filePaths[0],
|
||||
folderName: `${yearFilePrefix}年度报告_分模块`,
|
||||
images: exportedImages.map((img) => ({
|
||||
name: `${yearFilePrefix}年度报告_${img.name}.png`,
|
||||
dataUrl: img.data
|
||||
}))
|
||||
})
|
||||
const root = containerRef.current
|
||||
const previousScene = currentScene
|
||||
const sceneNames = [
|
||||
'THE_ARCHIVE',
|
||||
'VOLUME',
|
||||
'NOCTURNE',
|
||||
'GRAVITY_CENTERS',
|
||||
'TIME_WAVEFORM',
|
||||
'MUTUAL_RESONANCE',
|
||||
'SOCIAL_KINETICS',
|
||||
'THE_SPARK',
|
||||
'FADING_SIGNALS',
|
||||
'LEXICON',
|
||||
'EXTRACTION'
|
||||
]
|
||||
|
||||
if (!exportResult.success) {
|
||||
alert('导出失败: ' + (exportResult.error || '未知错误'))
|
||||
}
|
||||
setIsExtracting(true)
|
||||
setButtonText('EXTRACTING...')
|
||||
|
||||
setIsExporting(false)
|
||||
setExportProgress('')
|
||||
setSelectedSections(new Set())
|
||||
}
|
||||
try {
|
||||
const images: Array<{ name: string; dataUrl: string }> = []
|
||||
root.classList.add('exporting-scenes')
|
||||
await waitForNextPaint()
|
||||
await wait(120)
|
||||
// 预检:强制验证主进程已注册原生截图 handler,确保导出链路不是旧逻辑。
|
||||
await captureSceneDataUrl()
|
||||
|
||||
// 切换板块选择
|
||||
const toggleSection = (id: string) => {
|
||||
const newSet = new Set(selectedSections)
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id)
|
||||
} else {
|
||||
newSet.add(id)
|
||||
}
|
||||
setSelectedSections(newSet)
|
||||
}
|
||||
for (let i = 0; i < TOTAL_SCENES; i++) {
|
||||
setCurrentScene(i)
|
||||
setButtonText(`EXTRACTING ${i + 1}/${TOTAL_SCENES}`)
|
||||
await waitForNextPaint()
|
||||
await wait(1700)
|
||||
|
||||
// 全选/取消全选
|
||||
const toggleAll = () => {
|
||||
const sections = getAvailableSections()
|
||||
if (selectedSections.size === sections.length) {
|
||||
setSelectedSections(new Set())
|
||||
} else {
|
||||
setSelectedSections(new Set(sections.map(s => s.id)))
|
||||
images.push({
|
||||
name: `P${String(i).padStart(2, '0')}_${sceneNames[i] || `SCENE_${i}`}.png`,
|
||||
dataUrl: await captureSceneDataUrl()
|
||||
})
|
||||
}
|
||||
|
||||
const yearFilePrefix = formatFileYearLabel(reportData.year)
|
||||
const exportResult = await window.electronAPI.annualReport.exportImages({
|
||||
baseDir: dirResult.filePaths[0],
|
||||
folderName: `${yearFilePrefix}年度报告_分页面`,
|
||||
images
|
||||
})
|
||||
|
||||
if (!exportResult.success) {
|
||||
throw new Error(exportResult.error || '导出失败')
|
||||
}
|
||||
|
||||
setButtonText('SAVED TO DEVICE')
|
||||
} catch (e) {
|
||||
alert(`导出失败: ${String(e)}`)
|
||||
setButtonText('EXTRACT RECORD')
|
||||
} finally {
|
||||
root.classList.remove('exporting-scenes')
|
||||
setCurrentScene(previousScene)
|
||||
await wait(80)
|
||||
|
||||
setTimeout(() => {
|
||||
setButtonText('EXTRACT RECORD')
|
||||
setIsExtracting(false)
|
||||
}, 2200)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="annual-report-window loading">
|
||||
<div className="top-controls">
|
||||
<button className="close-btn" onClick={handleClose}><X size={16} /></button>
|
||||
</div>
|
||||
<div className="loading-ring">
|
||||
<svg viewBox="0 0 100 100">
|
||||
<circle className="ring-bg" cx="50" cy="50" r="42" />
|
||||
@@ -600,467 +518,516 @@ function AnnualReportWindow() {
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (error || !reportData) {
|
||||
return (
|
||||
<div className="annual-report-window error">
|
||||
<p>生成报告失败: {error}</p>
|
||||
<div className="top-controls">
|
||||
<button className="close-btn" onClick={handleClose}><X size={16} /></button>
|
||||
</div>
|
||||
<p>{error ? `生成报告失败: ${error}` : '暂无数据'}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!reportData) {
|
||||
return (
|
||||
<div className="annual-report-window error">
|
||||
<p>暂无数据</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { year, totalMessages, totalFriends, coreFriends, monthlyTopFriends, peakDay, longestStreak, activityHeatmap, midnightKing, selfAvatarUrl, mutualFriend, socialInitiative, responseSpeed, topPhrases, lostFriend } = reportData
|
||||
const topFriend = coreFriends[0]
|
||||
const mostActive = getMostActiveTime(activityHeatmap.data)
|
||||
const socialStoryName = topFriend?.displayName || '好友'
|
||||
const yearTitle = formatYearLabel(year, true)
|
||||
const yearTitleShort = formatYearLabel(year, false)
|
||||
const monthlyTitle = year === 0 ? '历史以来月度好友' : `${year}年月度好友`
|
||||
const phrasesTitle = year === 0 ? '你在历史以来的常用语' : `你在${year}年的年度常用语`
|
||||
const yearTitle = reportData.year === 0 ? '历史以来' : String(reportData.year)
|
||||
const finalYearLabel = reportData.year === 0 ? 'ALL YEARS' : String(reportData.year)
|
||||
const compactYearTitle = yearTitle.replace(/\s+/g, '')
|
||||
const isNumericYearTitle = /^\d+$/.test(compactYearTitle)
|
||||
const yearTitleVariantClass = isNumericYearTitle
|
||||
? 'title-year--numeric'
|
||||
: compactYearTitle.length >= 5
|
||||
? 'title-year--text-long'
|
||||
: 'title-year--text'
|
||||
const topFriends = reportData.coreFriends.slice(0, 3)
|
||||
const endingPostCount = reportData.snsStats?.totalPosts ?? 0
|
||||
const endingReceivedChats = reportData.socialInitiative?.receivedChats ?? 0
|
||||
const endingTopPhrase = reportData.topPhrases?.[0]?.phrase || ''
|
||||
const COLOR = {
|
||||
accentGold: 'var(--c-gold-strong)',
|
||||
accentSoft: 'rgba(var(--c-gold-rgb), 0.58)',
|
||||
accentMuted: 'rgba(var(--c-gold-rgb), 0.48)',
|
||||
textStrong: 'var(--c-text-bright)',
|
||||
textSoft: 'var(--c-text-soft)',
|
||||
textMuted: 'var(--c-text-muted)',
|
||||
textFaint: 'var(--c-text-faint)',
|
||||
paperInk: 'var(--c-paper-ink)',
|
||||
paperMuted: 'var(--c-paper-muted)'
|
||||
} as const
|
||||
|
||||
return (
|
||||
<div className="annual-report-window">
|
||||
<div className="drag-region" />
|
||||
|
||||
{/* 背景装饰 */}
|
||||
<div className="bg-decoration">
|
||||
<div className="deco-circle c1" />
|
||||
<div className="deco-circle c2" />
|
||||
<div className="deco-circle c3" />
|
||||
<div className="deco-circle c4" />
|
||||
<div className="deco-circle c5" />
|
||||
<div className="annual-report-window" data-scene={currentScene} style={s3LineVars} ref={containerRef}>
|
||||
<div className="top-controls">
|
||||
<button className="close-btn" title="关闭页面" onClick={handleClose}><X size={16} /></button>
|
||||
</div>
|
||||
|
||||
{/* 浮动操作按钮 */}
|
||||
<div className={`fab-container ${fabOpen ? 'open' : ''}`}>
|
||||
<button className="fab-item" onClick={() => { setFabOpen(false); setExportMode('separate'); setShowExportModal(true) }} title="分模块导出">
|
||||
<Image size={18} />
|
||||
</button>
|
||||
<button className="fab-item" onClick={() => { setFabOpen(false); setExportMode('long'); setShowExportModal(true) }} title="自定义导出长图">
|
||||
<SlidersHorizontal size={18} />
|
||||
</button>
|
||||
<button className="fab-item" onClick={() => { setFabOpen(false); exportFullReport() }} title="导出长图">
|
||||
<Download size={18} />
|
||||
</button>
|
||||
<button className="fab-main" onClick={() => setFabOpen(!fabOpen)}>
|
||||
{fabOpen ? <X size={22} /> : <Download size={22} />}
|
||||
</button>
|
||||
<div className="p0-bg-layer">
|
||||
<canvas ref={p0CanvasRef} className="p0-particle-canvas" />
|
||||
<div className="p0-center-glow" />
|
||||
</div>
|
||||
|
||||
<div className="film-grain"></div>
|
||||
|
||||
<div id="memory-core"></div>
|
||||
|
||||
<div className="pagination">
|
||||
{Array.from({ length: TOTAL_SCENES }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`dot-nav ${currentScene === i ? 'active' : ''}`}
|
||||
onClick={() => goToScene(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="swipe-hint">向下滑动以继续</div>
|
||||
|
||||
{/* S0: THE ARCHIVE */}
|
||||
<div className={getSceneClass(0)} id="scene-0">
|
||||
<div className="reveal-wrap en-tag">
|
||||
<div className="reveal-inner serif scene0-cn-tag">一切的起点</div>
|
||||
</div>
|
||||
<div className="reveal-wrap title-year-wrap">
|
||||
<div className={`reveal-inner serif title-year ${yearTitleVariantClass} delay-1`}>{yearTitle}</div>
|
||||
</div>
|
||||
<div className="reveal-wrap desc-text p0-desc">
|
||||
<div className="reveal-inner serif delay-2 p0-desc-inner">那些被岁月悄悄掩埋的对话<br/>原来都在这里,等待一个春天。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 导出进度 */}
|
||||
{isExporting && (
|
||||
<div className="export-overlay">
|
||||
<div className="export-progress-modal">
|
||||
<div className="export-spinner">
|
||||
<div className="spinner-ring"></div>
|
||||
<Download size={24} className="spinner-icon" />
|
||||
</div>
|
||||
<p className="export-title">正在导出</p>
|
||||
<p className="export-status">{exportProgress}</p>
|
||||
{/* S1: VOLUME */}
|
||||
<div className={getSceneClass(1)} id="scene-1">
|
||||
<div className="reveal-wrap en-tag">
|
||||
<div className="reveal-inner serif scene0-cn-tag">消息报告</div>
|
||||
</div>
|
||||
<div className="reveal-wrap">
|
||||
<div className="reveal-inner title-data delay-1 num-display">
|
||||
<DecodeText value={reportData.totalMessages.toLocaleString()} active={currentScene === 1} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="reveal-wrap desc-text">
|
||||
<div className="reveal-inner serif delay-2">
|
||||
这一年,你说出了 <strong className="num-display" style={{ color: COLOR.accentGold }}>{reportData.totalMessages.toLocaleString()}</strong> 句话。<br/>无数个日夜的碎碎念,都是为了在茫茫人海中,刻下彼此来过的痕迹。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模块选择弹窗 */}
|
||||
{showExportModal && (
|
||||
<div className="export-overlay" onClick={() => setShowExportModal(false)}>
|
||||
<div className="export-modal section-selector" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>{exportMode === 'long' ? '自定义导出长图' : '选择要导出的板块'}</h3>
|
||||
<button className="close-btn" onClick={() => setShowExportModal(false)}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="section-grid">
|
||||
{getAvailableSections().map(section => (
|
||||
<div
|
||||
key={section.id}
|
||||
className={`section-card ${selectedSections.has(section.id) ? 'selected' : ''}`}
|
||||
onClick={() => toggleSection(section.id)}
|
||||
>
|
||||
<div className="card-check">
|
||||
{selectedSections.has(section.id) && <Check size={14} />}
|
||||
{/* S2: NOCTURNE */}
|
||||
<div className={getSceneClass(2)} id="scene-2">
|
||||
<div className="reveal-wrap en-tag">
|
||||
<div className="reveal-inner serif scene0-cn-tag">深夜</div>
|
||||
</div>
|
||||
<div className="reveal-wrap">
|
||||
<div className="reveal-inner serif title-time delay-1">
|
||||
{reportData.midnightKing ? reportData.midnightKing.displayName : '00:00'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="reveal-wrap">
|
||||
<br/>
|
||||
<div className="reveal-inner serif scene0-cn-tag delay-1" style={{ fontSize: '1rem', color: 'var(--c-text-muted)', margin: '1vh 0' }}>
|
||||
在深夜陪你聊天最多的人
|
||||
</div>
|
||||
</div>
|
||||
<div className="reveal-wrap desc-text">
|
||||
<div className="reveal-inner serif delay-2">
|
||||
梦境之外,你与{reportData.midnightKing ? reportData.midnightKing.displayName : '00:00'}共同醒着度过了许多个夜晚<br/>
|
||||
“曾有<strong className="num-display" style={{ color: COLOR.accentGold, margin: '0 10px', fontSize: '1.5rem' }}>
|
||||
<DecodeText value={(reportData.midnightKing?.count || 0).toLocaleString()} active={currentScene === 2} />
|
||||
</strong>条消息在那些无人知晓的夜里,代替星光照亮了彼此”
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* S3: GRAVITY CENTERS */}
|
||||
<div className={getSceneClass(3)} id="scene-3">
|
||||
<div className="reveal-wrap en-tag">
|
||||
<div className="reveal-inner serif scene0-cn-tag">聊天排行</div>
|
||||
</div>
|
||||
|
||||
<div className="s3-layout" ref={s3LayoutRef}>
|
||||
<div className="reveal-wrap s3-subtitle-wrap">
|
||||
<div className="reveal-inner serif delay-1 s3-subtitle">漫长的岁月里,是他们,让你的时间有了实实在在的重量。</div>
|
||||
</div>
|
||||
|
||||
<div className="contact-list" ref={s3ListRef}>
|
||||
{topFriends.map((f, i) => (
|
||||
<div className="reveal-wrap s3-row-wrap" key={f.username}>
|
||||
<div className={`reveal-inner c-item delay-${i + 1}`}>
|
||||
<div className="c-info">
|
||||
<div className="serif c-name" style={{ color: i === 0 ? COLOR.accentGold : i === 1 ? COLOR.textStrong : COLOR.textMuted }}>
|
||||
{f.displayName}
|
||||
</div>
|
||||
<div className="mono c-sub num-display">TOP {i + 1}</div>
|
||||
</div>
|
||||
<div className="c-count num-display" style={{ color: i === 0 ? COLOR.accentGold : COLOR.textSoft }}>
|
||||
{f.messageCount.toLocaleString()}
|
||||
</div>
|
||||
<span>{section.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{topFriends.length === 0 && (
|
||||
<div className="reveal-wrap s3-row-wrap">
|
||||
<div className="reveal-inner c-item delay-1">
|
||||
<div className="c-info">
|
||||
<div className="serif c-name" style={{ color: COLOR.textSoft }}>暂无记录</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* S4: TIME WAVEFORM (Audio/Heartbeat timeline visual) */}
|
||||
<div className={getSceneClass(4)} id="scene-4">
|
||||
<div className="reveal-wrap en-tag" style={{ zIndex: 10 }}>
|
||||
<div className="reveal-inner serif scene0-cn-tag">时间的长河</div>
|
||||
</div>
|
||||
<div className="reveal-wrap desc-text" style={{ position: 'absolute', top: '15vh', left: '50vw', transform: 'translateX(-50%)', textAlign: 'center', zIndex: 10, marginTop: 0, width: '100%' }}>
|
||||
<div className="reveal-inner serif delay-1" style={{ color: COLOR.textMuted, fontSize: '1.2rem', letterSpacing: '0.1em' }}>十二个月的更迭,就像走过了一万个冬天<br />时间在变,但好在总有人陪在身边。</div>
|
||||
</div>
|
||||
|
||||
{reportData.monthlyTopFriends.length > 0 ? (
|
||||
<div style={{ position: 'absolute', top: '55vh', left: '10vw', width: '80vw', height: '1px', background: 'transparent' }}>
|
||||
{reportData.monthlyTopFriends.map((m, i) => {
|
||||
const leftPos = (i / 11) * 100; // 0% to 100%
|
||||
const isTop = i % 2 === 0; // Alternate up and down to prevent crowding
|
||||
const isRightSide = i >= 6; // Center-focus alignment logic
|
||||
|
||||
// Pseudo-random organic height variation for audio-wave feel (from 8vh to 18vh)
|
||||
const heightVariation = 12 + (Math.sin(i * 1.5) * 6);
|
||||
|
||||
const alignStyle = isRightSide ? { right: '10px', alignItems: 'flex-end', textAlign: 'right' as const } : { left: '10px', alignItems: 'flex-start', textAlign: 'left' as const };
|
||||
|
||||
return (
|
||||
<div key={m.month} className="reveal-wrap float-el" style={{ position: 'absolute', left: `${leftPos}%`, top: 0, width: '1px', height: '1px', overflow: 'visible', animationDelay: `${-(i%4)*0.5}s` }}>
|
||||
|
||||
{/* The connecting thread (gradient fades away from center line) */}
|
||||
<div className={`reveal-inner delay-${(i % 5) + 1}`} style={{
|
||||
position: 'absolute',
|
||||
left: '-0px',
|
||||
top: isTop ? `-${heightVariation}vh` : '0px',
|
||||
width: '1px',
|
||||
height: `${heightVariation}vh`,
|
||||
background: isTop
|
||||
? 'linear-gradient(to top, rgba(184,148,90,0.34), transparent)'
|
||||
: 'linear-gradient(to bottom, rgba(184,148,90,0.34), transparent)'
|
||||
}} />
|
||||
|
||||
{/* Center Glowing Dot */}
|
||||
<div className={`reveal-inner delay-${(i % 5) + 1}`} style={{ position: 'absolute', left: '-2.5px', top: '-2.5px', width: '6px', height: '6px', borderRadius: '50%', background: 'rgba(184,148,90,0.72)', boxShadow: '0 0 10px rgba(184,148,90,0.34)' }} />
|
||||
|
||||
{/* Text Payload */}
|
||||
<div className={`reveal-inner delay-${(i % 5) + 1}`} style={{
|
||||
position: 'absolute',
|
||||
...alignStyle,
|
||||
top: isTop ? `-${heightVariation + 2}vh` : `${heightVariation}vh`,
|
||||
transform: 'translateY(-50%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '20vw' // ample space to avoid wrapping
|
||||
}}>
|
||||
<div className="mono num-display" style={{ fontSize: '0.9rem', color: COLOR.textFaint, marginBottom: '4px', letterSpacing: '0.1em' }}>
|
||||
{m.month.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="serif" style={{ fontSize: 'clamp(1rem, 2vw, 1.4rem)', color: COLOR.textStrong, letterSpacing: '0.05em' }}>
|
||||
{m.displayName}
|
||||
</div>
|
||||
<div className="mono num-display" style={{ fontSize: '0.65rem', color: COLOR.textMuted, marginTop: '4px', letterSpacing: '0.1em' }}>
|
||||
{m.messageCount.toLocaleString()} M
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="reveal-wrap desc-text" style={{ position: 'absolute', top: '50vh', left: '50vw', transform: 'translate(-50%, -50%)' }}>
|
||||
<div className="reveal-inner serif delay-1" style={{ color: COLOR.textSoft }}>暂无记忆声纹</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* S5: MUTUAL RESONANCE (Mutual friend) */}
|
||||
<div className={getSceneClass(5)} id="scene-5">
|
||||
<div className="reveal-wrap en-tag">
|
||||
<div className="reveal-inner serif scene0-cn-tag">回应的艺术</div>
|
||||
</div>
|
||||
{reportData.mutualFriend ? (
|
||||
<>
|
||||
<div className="reveal-wrap desc-text" style={{ position: 'absolute', top: '20vh' }}>
|
||||
<div className="reveal-inner serif delay-1" style={{ fontSize: 'clamp(3rem, 7vw, 4rem)', color: COLOR.accentGold, letterSpacing: '0.05em' }}>
|
||||
{reportData.mutualFriend.displayName}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="select-all-btn" onClick={toggleAll}>
|
||||
{selectedSections.size === getAvailableSections().length ? '取消全选' : '全选'}
|
||||
</button>
|
||||
<button
|
||||
className="confirm-btn"
|
||||
onClick={exportSelectedSections}
|
||||
disabled={selectedSections.size === 0}
|
||||
>
|
||||
{exportMode === 'long' ? '生成长图' : '导出'} {selectedSections.size > 0 ? `(${selectedSections.size})` : ''}
|
||||
</button>
|
||||
|
||||
<div className="reveal-wrap" style={{ position: 'absolute', top: '42vh', left: '15vw' }}>
|
||||
<div className="reveal-inner serif scene0-cn-tag delay-2" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.2em' }}>发出</div>
|
||||
<div className="reveal-inner num-display delay-2" style={{ fontSize: 'clamp(2rem, 5vw, 3.5rem)', color: COLOR.accentGold, marginTop: '10px' }}><DecodeText value={reportData.mutualFriend.sentCount.toLocaleString()} active={currentScene === 5} /></div>
|
||||
</div>
|
||||
<div className="reveal-wrap" style={{ position: 'absolute', top: '42vh', right: '15vw', textAlign: 'right' }}>
|
||||
<div className="reveal-inner serif scene0-cn-tag delay-2" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.2em' }}>收到</div>
|
||||
<div className="reveal-inner num-display delay-2" style={{ fontSize: 'clamp(2rem, 5vw, 3.5rem)', color: COLOR.accentGold, marginTop: '10px' }}><DecodeText value={reportData.mutualFriend.receivedCount.toLocaleString()} active={currentScene === 5} /></div>
|
||||
</div>
|
||||
|
||||
<div className="reveal-wrap desc-text" style={{ position: 'absolute', bottom: '20vh' }}>
|
||||
<div className="reveal-inner serif delay-3">
|
||||
你们之间收发的消息高达 <strong className="num-display" style={{ color: COLOR.accentGold, fontSize: '1.5rem' }}>{reportData.mutualFriend.ratio}</strong> 的平衡率
|
||||
<br/>
|
||||
<span style={{ fontSize: '1rem', color: COLOR.textMuted, marginTop: '15px', display: 'block' }}>“你抛出的每一句话,都落在了对方的心里。<br/>所谓重逢,就是我走向你的时候,你也在走向我。”</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="reveal-wrap desc-text" style={{ marginTop: '25vh' }}><div className="reveal-inner serif delay-1">今年似乎独自咽下了很多话。<br/>请相信,分别和孤独总会迎来终结,你终会遇到那个懂你的TA。</div></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* S6: SOCIAL KINETICS */}
|
||||
<div className={getSceneClass(6)} id="scene-6">
|
||||
<div className="reveal-wrap en-tag">
|
||||
<div className="reveal-inner serif scene0-cn-tag">我的风格</div>
|
||||
</div>
|
||||
{reportData.socialInitiative || reportData.responseSpeed ? (
|
||||
<div style={{ position: 'absolute', top: '0', left: '0', width: '100%', height: '100%' }}>
|
||||
{reportData.socialInitiative && (
|
||||
<div className="reveal-wrap" style={{ position: 'absolute', top: '28vh', left: '15vw', width: '38vw', textAlign: 'left' }}>
|
||||
<div className="reveal-inner serif scene0-cn-tag delay-1" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.2em' }}>我的主动性</div>
|
||||
<div className="reveal-inner num-display delay-2" style={{ fontSize: 'clamp(4.5rem, 8vw, 7rem)', color: COLOR.accentGold, lineHeight: '1', margin: '2vh 0' }}>
|
||||
{reportData.socialInitiative.initiativeRate}%
|
||||
</div>
|
||||
<div className="reveal-inner serif delay-3" style={{ fontSize: '1.2rem', color: COLOR.textSoft, lineHeight: '1.8' }}>
|
||||
<div style={{ fontSize: '1.3rem', color: COLOR.textStrong, marginBottom: '0.6vh' }}>
|
||||
你的聊天开场大多由你发起。
|
||||
</div>
|
||||
{reportData.socialInitiative.topInitiatedFriend && (reportData.socialInitiative.topInitiatedCount || 0) > 0 ? (
|
||||
<div style={{ marginBottom: '0.6vh' }}>
|
||||
其中<strong style={{ color: COLOR.accentGold }}>{reportData.socialInitiative.topInitiatedFriend}</strong>是你最常联系的人,
|
||||
有<strong className="num-display" style={{ color: COLOR.accentGold, fontSize: '1.2rem', margin: '0 4px' }}>{(reportData.socialInitiative.topInitiatedCount || 0).toLocaleString()}</strong>次,是你先忍不住敲响了对方的门
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginBottom: '0.6vh' }}>
|
||||
你主动发起了<strong className="num-display" style={{ color: COLOR.accentGold, fontSize: '1.2rem', margin: '0 4px' }}>{reportData.socialInitiative.initiatedChats.toLocaleString()}</strong>次联络。
|
||||
</div>
|
||||
)}
|
||||
<span style={{ fontSize: '0.9rem', color: COLOR.textMuted }}>想见一个人的心,总是走在时间的前面。</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{reportData.responseSpeed && (
|
||||
<div className="reveal-wrap" style={{ position: 'absolute', bottom: '22vh', right: '15vw', width: '38vw', textAlign: 'right' }}>
|
||||
<div className="reveal-inner serif scene0-cn-tag delay-4" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.3em' }}>回应速度</div>
|
||||
<div className="reveal-inner num-display delay-5" style={{ fontSize: 'clamp(3.5rem, 6vw, 5rem)', color: COLOR.accentSoft, lineHeight: '1', margin: '2vh 0' }}>
|
||||
<DecodeText value={reportData.responseSpeed.fastestTime} active={currentScene === 6} />S
|
||||
</div>
|
||||
<div className="reveal-inner serif delay-6" style={{ fontSize: '1.2rem', color: COLOR.textSoft, lineHeight: '1.8' }}>
|
||||
<strong style={{ color: COLOR.accentGold }}>{reportData.responseSpeed.fastestFriend}</strong> 回你的消息总是很快。<br/>
|
||||
<span style={{ fontSize: '0.9rem', color: COLOR.textMuted }}>这世上最让人安心的默契,莫过于一句 "我在"。</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="reveal-wrap desc-text" style={{ marginTop: '25vh' }}><div className="reveal-inner serif delay-1">暂无数据。</div></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* S7: THE SPARK */}
|
||||
<div className={getSceneClass(7)} id="scene-7">
|
||||
<div className="reveal-wrap en-tag">
|
||||
<div className="reveal-inner serif scene0-cn-tag">聊天火花</div>
|
||||
</div>
|
||||
|
||||
{reportData.longestStreak ? (
|
||||
<div className="reveal-wrap" style={{ position: 'absolute', top: '35vh', left: '15vw', textAlign: 'left' }}>
|
||||
<div className="reveal-inner serif scene0-cn-tag delay-1" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.3em', marginBottom: '2vh' }}>最长连续聊天</div>
|
||||
<div className="reveal-inner serif delay-2" style={{ fontSize: 'clamp(3rem, 6vw, 5rem)', color: COLOR.accentGold, letterSpacing: '0.02em' }}>
|
||||
{reportData.longestStreak.friendName}
|
||||
</div>
|
||||
<div className="reveal-inner serif delay-3" style={{ fontSize: '1.2rem', color: COLOR.textSoft, marginTop: '2vh' }}>
|
||||
你们曾连续 <strong className="num-display" style={{ color: COLOR.accentGold, fontSize: '1.8rem' }}><DecodeText value={reportData.longestStreak.days} active={currentScene === 7} /></strong> 天,聊到忘记了时间,<br/>那些舍不得说再见的日夜,连成了最漫长的春天。
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{reportData.peakDay ? (
|
||||
<div className="reveal-wrap" style={{ position: 'absolute', bottom: '30vh', right: '15vw', textAlign: 'right' }}>
|
||||
<div className="reveal-inner serif scene0-cn-tag delay-4" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.3em', marginBottom: '2vh' }}>最热烈的一天</div>
|
||||
<div className="reveal-inner num-display delay-5" style={{ fontSize: 'clamp(2.5rem, 5vw, 4rem)', color: COLOR.accentGold, letterSpacing: '0.02em' }}>
|
||||
{reportData.peakDay.date}
|
||||
</div>
|
||||
<div className="reveal-inner serif delay-6" style={{ fontSize: '1.2rem', color: COLOR.textSoft, marginTop: '2vh' }}>
|
||||
“这一天,你们留下了 <strong className="num-display" style={{ color: COLOR.accentGold, fontSize: '1.8rem' }}>{reportData.peakDay.messageCount}</strong> 句话。<br/>好像要把积攒了很久的想念,一天全都说完。”
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!reportData.longestStreak && !reportData.peakDay && (
|
||||
<div className="reveal-wrap desc-text" style={{ marginTop: '25vh' }}><div className="reveal-inner serif delay-1">没有激起过火花。</div></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* S8: FADING SIGNALS */}
|
||||
<div className={getSceneClass(8)} id="scene-8">
|
||||
<div className="reveal-wrap en-tag">
|
||||
<div className="reveal-inner serif scene0-cn-tag">曾经的好友</div>
|
||||
</div>
|
||||
{reportData.lostFriend ? (
|
||||
<div className="s8-layout">
|
||||
<div className="s8-left">
|
||||
<div className="reveal-wrap s8-name-wrap">
|
||||
<div className="reveal-inner serif delay-1 s8-name">
|
||||
{reportData.lostFriend.displayName}
|
||||
</div>
|
||||
</div>
|
||||
<div className="reveal-wrap s8-summary-wrap">
|
||||
<div className="reveal-inner serif delay-2 s8-summary">
|
||||
后来,你们的交集停留在{reportData.lostFriend.periodDesc}这短短的
|
||||
<span className="num-display s8-summary-count">
|
||||
<DecodeText value={reportData.lostFriend.lateCount.toLocaleString()} active={currentScene === 8} />
|
||||
</span>
|
||||
句话里。
|
||||
</div>
|
||||
</div>
|
||||
<div className="reveal-wrap s8-quote-wrap">
|
||||
<div className="reveal-inner serif delay-3 s8-quote">
|
||||
“我一直相信我们能够再次相见,相信分别的日子总会迎来终结。”
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="reveal-wrap s8-letter-wrap">
|
||||
<div className="reveal-inner serif delay-4 s8-letter">
|
||||
所有的离散,或许都只是一场漫长的越冬。飞鸟要越过一万座雪山,才能带来春天的第一行回信;树木要褪去一万次枯叶,才能记住风的形状。如果时间注定要把我们推向不同的象限,那就在记忆的最深处建一座灯塔。哪怕要熬过几千个无法见面的黄昏,也要相信,总有一次日出的晨光,是为了照亮我们重逢的归途。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="reveal-wrap desc-text s8-empty-wrap">
|
||||
<div className="reveal-inner serif delay-1 s8-empty-text">
|
||||
缘分温柔地眷顾着你。<br/>
|
||||
这一年,所有重要的人都在,没有一次无疾而终的告别。<br/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* S9: LEXICON & ARCHIVE */}
|
||||
<div className={getSceneClass(9)} id="scene-9">
|
||||
<div className="reveal-wrap en-tag">
|
||||
<div className="reveal-inner serif scene0-cn-tag">我的词云</div>
|
||||
</div>
|
||||
|
||||
{reportData.topPhrases && reportData.topPhrases.slice(0, 12).map((phrase, i) => {
|
||||
// 12 precisely tuned absolute coordinates for the ultimate organic scatter without overlapping
|
||||
const demoStyles = [
|
||||
{ left: '25vw', top: '25vh', fontSize: 'clamp(3rem, 7vw, 5rem)', color: 'rgba(250,250,248,0.96)', delay: '0.1s', floatDelay: '0s', targetOp: 0.96 },
|
||||
{ left: '72vw', top: '30vh', fontSize: 'clamp(2rem, 5vw, 4rem)', color: 'rgba(250,250,248,0.78)', delay: '0.2s', floatDelay: '-1s', targetOp: 0.78 },
|
||||
{ left: '15vw', top: '55vh', fontSize: 'clamp(2.5rem, 6vw, 4.5rem)', color: 'rgba(200,170,120,0.72)', delay: '0.3s', floatDelay: '-2.5s', targetOp: 0.72 },
|
||||
{ left: '78vw', top: '60vh', fontSize: 'clamp(1.5rem, 3.5vw, 3rem)', color: 'rgba(250,250,248,0.62)', delay: '0.4s', floatDelay: '-1.5s', targetOp: 0.62 },
|
||||
{ left: '45vw', top: '75vh', fontSize: 'clamp(1.2rem, 3vw, 2.5rem)', color: 'rgba(200,170,120,0.58)', delay: '0.5s', floatDelay: '-3s', targetOp: 0.58 },
|
||||
{ left: '55vw', top: '15vh', fontSize: 'clamp(1.5rem, 3vw, 2.5rem)', color: 'rgba(250,250,248,0.52)', delay: '0.6s', floatDelay: '-0.5s', targetOp: 0.52 },
|
||||
{ left: '12vw', top: '80vh', fontSize: 'clamp(1rem, 2vw, 1.8rem)', color: 'rgba(250,250,248,0.42)', delay: '0.7s', floatDelay: '-1.2s', targetOp: 0.42 },
|
||||
{ left: '35vw', top: '45vh', fontSize: 'clamp(2.2rem, 5vw, 4rem)', color: 'rgba(250,250,248,0.82)', delay: '0.8s', floatDelay: '-0.8s', targetOp: 0.82 },
|
||||
{ left: '85vw', top: '82vh', fontSize: 'clamp(0.9rem, 1.5vw, 1.5rem)', color: 'rgba(200,170,120,0.34)', delay: '0.9s', floatDelay: '-2.1s', targetOp: 0.34 },
|
||||
{ left: '60vw', top: '50vh', fontSize: 'clamp(1.8rem, 4vw, 3.5rem)', color: 'rgba(250,250,248,0.64)', delay: '1s', floatDelay: '-0.3s', targetOp: 0.64 },
|
||||
{ left: '45vw', top: '35vh', fontSize: 'clamp(1rem, 2vw, 1.8rem)', color: 'rgba(250,250,248,0.38)', delay: '1.1s', floatDelay: '-1.8s', targetOp: 0.38 },
|
||||
{ left: '30vw', top: '65vh', fontSize: 'clamp(1.4rem, 2.5vw, 2.2rem)', color: 'rgba(200,170,120,0.46)', delay: '1.2s', floatDelay: '-2.7s', targetOp: 0.46 },
|
||||
];
|
||||
const st = demoStyles[i];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={phrase.phrase + i}
|
||||
className="word-burst"
|
||||
style={{
|
||||
left: st.left,
|
||||
top: st.top,
|
||||
fontSize: st.fontSize,
|
||||
color: st.color,
|
||||
transitionDelay: st.delay,
|
||||
'--target-op': st.targetOp
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<span className="float-el" style={{ animationDelay: st.floatDelay }}>{phrase.phrase}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{(!reportData.topPhrases || reportData.topPhrases.length === 0) && (
|
||||
<div className="reveal-wrap desc-text" style={{ marginTop: '25vh' }}><div className="reveal-inner serif delay-1">词汇量太少,无法形成星云。</div></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* S10: EXTRACTION (白色反色结束页 / Data Receipt) */}
|
||||
<div className={getSceneClass(10)} id="scene-10" style={{ color: COLOR.paperInk }}>
|
||||
<div className="reveal-wrap en-tag" style={{ zIndex: 20 }}>
|
||||
<div className="reveal-inner serif scene0-cn-tag" style={{ color: COLOR.paperMuted }}>旅程的终点</div>
|
||||
</div>
|
||||
|
||||
{/* The Final Summary Receipt / Dashboard */}
|
||||
<div className="reveal-wrap" style={{ position: 'absolute', top: '45vh', left: '50vw', transform: 'translate(-50%, -50%)', width: '60vw', textAlign: 'center', zIndex: 20 }}>
|
||||
<div className="reveal-inner delay-1" style={{ display: 'flex', flexDirection: 'column', gap: '3vh' }}>
|
||||
<div className="mono num-display" style={{ fontSize: 'clamp(3rem, 6vw, 5rem)', color: COLOR.paperInk, fontWeight: 800, letterSpacing: '-0.02em', lineHeight: 1 }}>
|
||||
{finalYearLabel}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: '0.8rem', color: COLOR.paperMuted, letterSpacing: '0.4em' }}>
|
||||
TRANSMISSION COMPLETE
|
||||
</div>
|
||||
|
||||
{/* Core Stats Row */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-around', marginTop: '6vh', borderTop: '1px solid rgba(110, 89, 46, 0.35)', borderBottom: '1px solid rgba(110, 89, 46, 0.35)', padding: '4vh 0' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<div className="serif scene0-cn-tag" style={{ fontSize: '0.75rem', color: COLOR.paperMuted, letterSpacing: '0.1em', marginBottom: '1vh' }}>朋友圈发帖</div>
|
||||
<div className="num-display" style={{ fontSize: '2.5rem', color: COLOR.accentMuted, fontWeight: 600 }}>{endingPostCount.toLocaleString()}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<div className="serif scene0-cn-tag" style={{ fontSize: '0.75rem', color: COLOR.paperMuted, letterSpacing: '0.1em', marginBottom: '1vh' }}>被动开场</div>
|
||||
<div className="num-display" style={{ fontSize: '2.5rem', color: COLOR.accentMuted, fontWeight: 600 }}>{endingReceivedChats.toLocaleString()}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<div className="serif scene0-cn-tag" style={{ fontSize: '0.75rem', color: COLOR.paperMuted, letterSpacing: '0.1em', marginBottom: '1vh' }}>你最爱说</div>
|
||||
<div className="num-display" style={{ fontSize: '2.5rem', color: COLOR.accentMuted, fontWeight: 600 }}>“{endingTopPhrase}”</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="serif" style={{ fontSize: '1.2rem', color: 'rgba(34, 28, 16, 0.82)', marginTop: '4vh', letterSpacing: '0.05em' }}>
|
||||
“故事的最后,我们把这一切悄悄还给岁月<br/>只要这些文字还在,所有的离别,就都只是一场短暂的缺席。”
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="report-scroll-view">
|
||||
<div className="report-container" ref={containerRef}>
|
||||
|
||||
{/* 封面 */}
|
||||
<section className="section" ref={sectionRefs.cover}>
|
||||
<div className="label-text">WEFLOW · ANNUAL REPORT</div>
|
||||
<h1 className="hero-title">{yearTitle}<br />微信聊天报告</h1>
|
||||
<hr className="divider" />
|
||||
<p className="hero-desc">每一条消息背后<br />都藏着一段独特的故事</p>
|
||||
</section>
|
||||
|
||||
{/* 年度概览 */}
|
||||
<section className="section" ref={sectionRefs.overview}>
|
||||
<div className="label-text">年度概览</div>
|
||||
<h2 className="hero-title">你和你的朋友们<br />互相发过</h2>
|
||||
<div className="big-stat">
|
||||
<span className="stat-num">{formatNumber(totalMessages)}</span>
|
||||
<span className="stat-unit">条消息</span>
|
||||
<div className="btn-wrap" style={{ zIndex: 20, bottom: '8vh' }}>
|
||||
<div className="serif reveal-wrap" style={{ marginBottom: '20px' }}>
|
||||
<div
|
||||
className="reveal-inner delay-2"
|
||||
style={{
|
||||
fontSize: 'clamp(0.9rem, 1.15vw, 1.02rem)',
|
||||
color: COLOR.paperMuted,
|
||||
lineHeight: 1.95,
|
||||
letterSpacing: '0.03em',
|
||||
maxWidth: 'min(980px, 78vw)',
|
||||
textAlign: 'center',
|
||||
fontWeight: 500
|
||||
}}
|
||||
>
|
||||
数据数得清一万句落笔的寒暄,却度量不出一个默契的眼神。<br/>在这片由数字构建的大海里,热烈的回应未必是感情的全部轮廓。<br/>真正的爱与羁绊,从来都不在跳动的屏幕里,而在无法被量化的现实。
|
||||
</div>
|
||||
<p className="hero-desc">
|
||||
在这段时光里,你与 <span className="hl">{formatNumber(totalFriends)}</span> 位好友交换过喜怒哀乐。
|
||||
<br />每一个对话,都是一段故事的开始。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 年度挚友 */}
|
||||
{topFriend && (
|
||||
<section className="section" ref={sectionRefs.bestFriend}>
|
||||
<div className="label-text">年度挚友</div>
|
||||
<h2 className="hero-title">{topFriend.displayName}</h2>
|
||||
<div className="big-stat">
|
||||
<span className="stat-num">{formatNumber(topFriend.messageCount)}</span>
|
||||
<span className="stat-unit">条消息</span>
|
||||
</div>
|
||||
<p className="hero-desc">
|
||||
你发出 <span className="hl">{formatNumber(topFriend.sentCount)}</span> 条 ·
|
||||
TA发来 <span className="hl">{formatNumber(topFriend.receivedCount)}</span> 条
|
||||
</p>
|
||||
<br />
|
||||
<p className="hero-desc">
|
||||
在一起,就可以
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 月度好友 */}
|
||||
<section className="section" ref={sectionRefs.monthlyFriends}>
|
||||
<div className="label-text">月度好友</div>
|
||||
<h2 className="hero-title">{monthlyTitle}</h2>
|
||||
<p className="hero-desc">根据12个月的聊天习惯</p>
|
||||
<div className="monthly-orbit">
|
||||
{monthlyTopFriends.map((m, i) => (
|
||||
<div key={m.month} className="monthly-item" style={{ '--i': i } as React.CSSProperties}>
|
||||
<div className="month-label">{m.month}月</div>
|
||||
<Avatar url={m.avatarUrl} name={m.displayName} size="sm" />
|
||||
<div className="month-name">{m.displayName}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="monthly-center">
|
||||
<Avatar url={selfAvatarUrl} name="我" size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="hero-desc">你只管说<br />我一直在</p>
|
||||
</section>
|
||||
|
||||
{/* 双向奔赴 */}
|
||||
{mutualFriend && (
|
||||
<section className="section" ref={sectionRefs.mutualFriend}>
|
||||
<div className="label-text">双向奔赴</div>
|
||||
<h2 className="hero-title">默契与平衡</h2>
|
||||
<div className="mutual-visual">
|
||||
<div className="mutual-side you">
|
||||
<Avatar url={selfAvatarUrl} name="我" size="lg" />
|
||||
<div className="mutual-arrow">
|
||||
<span className="arrow-count">{formatNumber(mutualFriend.sentCount)}</span>
|
||||
<div className="arrow-line">→</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mutual-center">
|
||||
<div className="mutual-icon">🤝</div>
|
||||
<div className="mutual-ratio">{mutualFriend.ratio}</div>
|
||||
</div>
|
||||
<div className="mutual-side friend">
|
||||
<div className="mutual-arrow reverse">
|
||||
<span className="arrow-count">{formatNumber(mutualFriend.receivedCount)}</span>
|
||||
<div className="arrow-line">←</div>
|
||||
</div>
|
||||
<Avatar url={mutualFriend.avatarUrl} name={mutualFriend.displayName} size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mutual-name-tag">{mutualFriend.displayName}</div>
|
||||
<p className="hero-desc">
|
||||
你们的互动比例接近 <span className="hl">{mutualFriend.ratio}</span>。
|
||||
<br />你来我往,势均力敌。
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 社交主动性 */}
|
||||
{socialInitiative && (
|
||||
<section className="section" ref={sectionRefs.socialInitiative}>
|
||||
<div className="label-text">社交主动性</div>
|
||||
<h2 className="hero-title">主动才有故事</h2>
|
||||
<div className="big-stat">
|
||||
<span className="stat-num">{socialInitiative.initiativeRate}%</span>
|
||||
<span className="stat-unit">的对话由你发起</span>
|
||||
</div>
|
||||
<p className="hero-desc">
|
||||
面对 <span className="hl">{socialStoryName}</span> 的时候,你总是那个先开口的人。
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 巅峰时刻 */}
|
||||
{peakDay && (
|
||||
<section className="section" ref={sectionRefs.peakDay}>
|
||||
<div className="label-text">巅峰时刻</div>
|
||||
<h2 className="hero-title">{peakDay.date}</h2>
|
||||
<p className="hero-desc">一天里你一共发了</p>
|
||||
<div className="big-stat">
|
||||
<span className="stat-num">{formatNumber(peakDay.messageCount)}</span>
|
||||
<span className="stat-unit">条消息</span>
|
||||
</div>
|
||||
<p className="hero-desc">
|
||||
在这个快节奏的世界,有人正陪在你身边听你慢慢地讲
|
||||
<br />那天,你和 <span className="hl">{peakDay.topFriend || '好友'}</span> 的 {formatNumber(peakDay.topFriendCount || 0)} 条消息见证着这一切
|
||||
<br />有些话,只想对你说
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 聊天火花 */}
|
||||
{longestStreak && (
|
||||
<section className="section" ref={sectionRefs.streak}>
|
||||
<div className="label-text">持之以恒</div>
|
||||
<h2 className="hero-title">聊天火花</h2>
|
||||
<p className="hero-desc">与 <span className="hl">{longestStreak.friendName}</span> 持续了</p>
|
||||
<div className="big-stat">
|
||||
<span className="stat-num">{longestStreak.days}</span>
|
||||
<span className="stat-unit">天</span>
|
||||
</div>
|
||||
<p className="hero-desc">
|
||||
从 {longestStreak.startDate} 到 {longestStreak.endDate}
|
||||
</p>
|
||||
<p className="hero-desc">陪伴,是最长情的告白</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 作息规律 */}
|
||||
<section className="section" ref={sectionRefs.heatmap}>
|
||||
<div className="label-text">作息规律</div>
|
||||
<h2 className="hero-title">时间的痕迹</h2>
|
||||
<p className="hero-desc active-time">
|
||||
在 <span className="hl">{mostActive.weekday} {String(mostActive.hour).padStart(2, '0')}:00</span> 最活跃
|
||||
</p>
|
||||
<Heatmap data={activityHeatmap.data} />
|
||||
</section>
|
||||
|
||||
{/* 深夜好友 */}
|
||||
{midnightKing && (
|
||||
<section className="section" ref={sectionRefs.midnightKing}>
|
||||
<div className="label-text">深夜好友</div>
|
||||
<h2 className="hero-title">月光下的你</h2>
|
||||
<p className="hero-desc">在这一年你留下了</p>
|
||||
<div className="big-stat">
|
||||
<span className="stat-num">{midnightKing.count}</span>
|
||||
<span className="stat-unit">条深夜的消息</span>
|
||||
</div>
|
||||
<p className="hero-desc">
|
||||
其中 <span className="hl">{midnightKing.displayName}</span> 常常在深夜中陪着你胡思乱想。
|
||||
<br />你和Ta的对话占你深夜期间聊天的 <span className="gold">{midnightKing.percentage}%</span>。
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 回应速度 */}
|
||||
{responseSpeed && (
|
||||
<section className="section" ref={sectionRefs.responseSpeed}>
|
||||
<div className="label-text">回应速度</div>
|
||||
<h2 className="hero-title">念念不忘,必有回响</h2>
|
||||
<div className="big-stat">
|
||||
<span className="stat-num">{formatTime(responseSpeed.avgResponseTime)}</span>
|
||||
<span className="stat-unit">是你的平均回复时间</span>
|
||||
</div>
|
||||
<p className="hero-desc">
|
||||
你回复 <span className="hl">{responseSpeed.fastestFriend}</span> 最快
|
||||
<br />平均只需 <span className="gold">{formatTime(responseSpeed.fastestTime)}</span>
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 曾经的好朋友 */}
|
||||
{lostFriend && (
|
||||
<section className="section" ref={sectionRefs.lostFriend}>
|
||||
<div className="label-text">曾经的好朋友</div>
|
||||
<h2 className="hero-title">{lostFriend.displayName}</h2>
|
||||
<div className="big-stat">
|
||||
<span className="stat-num">{formatNumber(lostFriend.earlyCount)}</span>
|
||||
<span className="stat-unit">条消息</span>
|
||||
</div>
|
||||
<p className="hero-desc">
|
||||
在 <span className="hl">{lostFriend.periodDesc}</span>
|
||||
<br />你们曾有聊不完的话题
|
||||
</p>
|
||||
<div className="lost-friend-visual">
|
||||
<div className="avatar-group sender">
|
||||
<Avatar url={lostFriend.avatarUrl} name={lostFriend.displayName} size="lg" />
|
||||
<span className="avatar-label">TA</span>
|
||||
</div>
|
||||
<div className="fading-line">
|
||||
<div className="line-path" />
|
||||
<div className="line-glow" />
|
||||
<div className="flow-particle" />
|
||||
</div>
|
||||
<div className="avatar-group receiver">
|
||||
<Avatar url={selfAvatarUrl} name="我" size="lg" />
|
||||
<span className="avatar-label">我</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="hero-desc fading">
|
||||
人类发明后悔
|
||||
<br />来证明拥有的珍贵
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 年度常用语 - 词云 */}
|
||||
{topPhrases && topPhrases.length > 0 && (
|
||||
<section className="section" ref={sectionRefs.topPhrases}>
|
||||
<div className="label-text">年度常用语</div>
|
||||
<h2 className="hero-title">{phrasesTitle}</h2>
|
||||
<p className="hero-desc">
|
||||
这一年,你说得最多的是:
|
||||
<br />
|
||||
<span className="hl" style={{ fontSize: '20px' }}>
|
||||
{topPhrases.slice(0, 3).map(p => p.phrase).join('、')}
|
||||
</span>
|
||||
</p>
|
||||
<WordCloud words={topPhrases} />
|
||||
<p className="hero-desc word-cloud-note">颜色越深代表出现频率越高</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 朋友圈 */}
|
||||
{reportData.snsStats && reportData.snsStats.totalPosts > 0 && (
|
||||
<section className="section" ref={sectionRefs.sns}>
|
||||
<div className="label-text">朋友圈</div>
|
||||
<h2 className="hero-title">记录生活时刻</h2>
|
||||
<p className="hero-desc">
|
||||
这一年,你发布了
|
||||
</p>
|
||||
<div className="big-stat">
|
||||
<span className="stat-num">{reportData.snsStats.totalPosts}</span>
|
||||
<span className="stat-unit">条朋友圈</span>
|
||||
</div>
|
||||
|
||||
<div className="sns-stats-container" style={{ display: 'flex', gap: '60px', marginTop: '40px', justifyContent: 'center' }}>
|
||||
{reportData.snsStats.topLikers.length > 0 && (
|
||||
<div className="sns-sub-stat" style={{ textAlign: 'left' }}>
|
||||
<h3 className="sub-title" style={{ fontSize: '18px', marginBottom: '16px', opacity: 0.8, borderBottom: '1px solid currentColor', paddingBottom: '8px' }}>更关心你的Ta</h3>
|
||||
<div className="mini-ranking">
|
||||
{reportData.snsStats.topLikers.slice(0, 3).map((u, i) => (
|
||||
<div key={i} className="mini-rank-item" style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '14px' }}>
|
||||
<Avatar url={u.avatarUrl} name={u.displayName} size="sm" />
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span className="name" style={{ fontSize: '15px', fontWeight: 500, maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{u.displayName}</span>
|
||||
</div>
|
||||
<span className="count hl" style={{ fontSize: '14px', marginLeft: 'auto' }}>{u.count}赞</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reportData.snsStats.topLiked.length > 0 && (
|
||||
<div className="sns-sub-stat" style={{ textAlign: 'left' }}>
|
||||
<h3 className="sub-title" style={{ fontSize: '18px', marginBottom: '16px', opacity: 0.8, borderBottom: '1px solid currentColor', paddingBottom: '8px' }}>你最关心的Ta</h3>
|
||||
<div className="mini-ranking">
|
||||
{reportData.snsStats.topLiked.slice(0, 3).map((u, i) => (
|
||||
<div key={i} className="mini-rank-item" style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '14px' }}>
|
||||
<Avatar url={u.avatarUrl} name={u.displayName} size="sm" />
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span className="name" style={{ fontSize: '15px', fontWeight: 500, maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{u.displayName}</span>
|
||||
</div>
|
||||
<span className="count hl" style={{ fontSize: '14px', marginLeft: 'auto' }}>{u.count}赞</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 好友排行 */}
|
||||
<section className="section" ref={sectionRefs.ranking}>
|
||||
<div className="label-text">好友排行</div>
|
||||
<h2 className="hero-title">聊得最多的人</h2>
|
||||
|
||||
{/* 领奖台 - 前三名 */}
|
||||
<div className="podium">
|
||||
{/* 第二名 - 左边 */}
|
||||
{coreFriends[1] && (
|
||||
<div className="podium-item second">
|
||||
<Avatar url={coreFriends[1].avatarUrl} name={coreFriends[1].displayName} size="lg" />
|
||||
<div className="podium-name">{coreFriends[1].displayName}</div>
|
||||
<div className="podium-count">{formatNumber(coreFriends[1].messageCount)} 条</div>
|
||||
<div className="podium-stand">
|
||||
<span className="podium-rank">2</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 第一名 - 中间最高 */}
|
||||
{coreFriends[0] && (
|
||||
<div className="podium-item first">
|
||||
<div className="crown">👑</div>
|
||||
<Avatar url={coreFriends[0].avatarUrl} name={coreFriends[0].displayName} size="lg" />
|
||||
<div className="podium-name">{coreFriends[0].displayName}</div>
|
||||
<div className="podium-count">{formatNumber(coreFriends[0].messageCount)} 条</div>
|
||||
<div className="podium-stand">
|
||||
<span className="podium-rank">1</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 第三名 - 右边 */}
|
||||
{coreFriends[2] && (
|
||||
<div className="podium-item third">
|
||||
<Avatar url={coreFriends[2].avatarUrl} name={coreFriends[2].displayName} size="lg" />
|
||||
<div className="podium-name">{coreFriends[2].displayName}</div>
|
||||
<div className="podium-count">{formatNumber(coreFriends[2].messageCount)} 条</div>
|
||||
<div className="podium-stand">
|
||||
<span className="podium-rank">3</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 结尾 */}
|
||||
<section className="section ending" ref={sectionRefs.ending}>
|
||||
<h2 className="hero-title">尾声</h2>
|
||||
<p className="hero-desc">
|
||||
我们总是在向前走
|
||||
<br />却很少有机会回头看看
|
||||
<br />如果这份报告让你有所触动,不妨把它分享给你在意的人
|
||||
<br />愿新的一年,
|
||||
<br />所有期待,皆有回声。
|
||||
</p>
|
||||
<div className="ending-year">{yearTitleShort}</div>
|
||||
<div className="ending-brand">WEFLOW</div>
|
||||
</section>
|
||||
</div>
|
||||
<div className="reveal-wrap">
|
||||
<button
|
||||
className="btn num-display reveal-inner delay-3"
|
||||
onClick={handleExtract}
|
||||
disabled={isExtracting}
|
||||
style={{
|
||||
background: isExtracting ? '#CDC4B0' : (buttonText === 'SAVED TO DEVICE' ? '#1A140A' : '#101010'),
|
||||
color: 'var(--c-gold-strong)',
|
||||
fontSize: '0.85rem',
|
||||
border: '1px solid rgba(var(--c-gold-rgb), 0.45)',
|
||||
minWidth: '200px'
|
||||
}}
|
||||
>
|
||||
{buttonText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1898,6 +1898,9 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
const mediaCacheMetricLabel = mediaCacheTotal > 0
|
||||
? `缓存命中 ${mediaCacheHitFiles}/${mediaCacheTotal}`
|
||||
: ''
|
||||
const mediaMissMetricLabel = mediaCacheMissFiles > 0
|
||||
? `未导出 ${mediaCacheMissFiles} 个文件/媒体`
|
||||
: ''
|
||||
const mediaDedupMetricLabel = mediaDedupReuseFiles > 0
|
||||
? `复用 ${mediaDedupReuseFiles}`
|
||||
: ''
|
||||
@@ -1958,6 +1961,7 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
{phaseMetricLabel ? ` · ${phaseMetricLabel}` : ''}
|
||||
{mediaLiveMetricLabel ? ` · ${mediaLiveMetricLabel}` : ''}
|
||||
{mediaCacheMetricLabel ? ` · ${mediaCacheMetricLabel}` : ''}
|
||||
{mediaMissMetricLabel ? ` · ${mediaMissMetricLabel}` : ''}
|
||||
{mediaDedupMetricLabel ? ` · ${mediaDedupMetricLabel}` : ''}
|
||||
{task.status === 'running' && currentSessionRatio !== null
|
||||
? `(当前会话 ${Math.round(currentSessionRatio * 100)}%)`
|
||||
|
||||
+151
-98
@@ -75,6 +75,7 @@ interface WxidOption {
|
||||
type SessionFilterType = configService.MessagePushSessionType
|
||||
type SessionFilterTypeValue = 'all' | SessionFilterType
|
||||
type SessionFilterMode = 'all' | 'whitelist' | 'blacklist'
|
||||
type InsightSessionFilterTypeValue = 'all' | 'private' | 'group' | 'official'
|
||||
|
||||
interface SessionFilterOption {
|
||||
username: string
|
||||
@@ -91,6 +92,13 @@ const sessionFilterTypeOptions: Array<{ value: SessionFilterTypeValue; label: st
|
||||
{ value: 'other', label: '其他/非好友' }
|
||||
]
|
||||
|
||||
const insightFilterTypeOptions: Array<{ value: InsightSessionFilterTypeValue; label: string }> = [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'private', label: '私聊' },
|
||||
{ value: 'group', label: '群聊' },
|
||||
{ value: 'official', label: '订阅号/服务号' }
|
||||
]
|
||||
|
||||
interface SettingsPageProps {
|
||||
onClose?: () => void
|
||||
}
|
||||
@@ -194,6 +202,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
|
||||
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
|
||||
const [closeBehaviorDropdownOpen, setCloseBehaviorDropdownOpen] = useState(false)
|
||||
const [insightFilterModeDropdownOpen, setInsightFilterModeDropdownOpen] = useState(false)
|
||||
|
||||
const [wordCloudExcludeWords, setWordCloudExcludeWords] = useState<string[]>([])
|
||||
const [excludeWordsInput, setExcludeWordsInput] = useState('')
|
||||
@@ -275,8 +284,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const [showInsightApiKey, setShowInsightApiKey] = useState(false)
|
||||
const [isTriggeringInsightTest, setIsTriggeringInsightTest] = useState(false)
|
||||
const [insightTriggerResult, setInsightTriggerResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||
const [aiInsightWhitelistEnabled, setAiInsightWhitelistEnabled] = useState(false)
|
||||
const [aiInsightWhitelist, setAiInsightWhitelist] = useState<Set<string>>(new Set())
|
||||
const [aiInsightFilterMode, setAiInsightFilterMode] = useState<configService.AiInsightFilterMode>('whitelist')
|
||||
const [aiInsightFilterList, setAiInsightFilterList] = useState<Set<string>>(new Set())
|
||||
const [insightFilterType, setInsightFilterType] = useState<InsightSessionFilterTypeValue>('all')
|
||||
const [insightWhitelistSearch, setInsightWhitelistSearch] = useState('')
|
||||
const [aiInsightCooldownMinutes, setAiInsightCooldownMinutes] = useState(120)
|
||||
const [aiInsightScanIntervalHours, setAiInsightScanIntervalHours] = useState(4)
|
||||
@@ -397,15 +407,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
setPositionDropdownOpen(false)
|
||||
setCloseBehaviorDropdownOpen(false)
|
||||
setMessagePushFilterDropdownOpen(false)
|
||||
setInsightFilterModeDropdownOpen(false)
|
||||
}
|
||||
}
|
||||
if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen || messagePushFilterDropdownOpen) {
|
||||
if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen || messagePushFilterDropdownOpen || insightFilterModeDropdownOpen) {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
}, [closeBehaviorDropdownOpen, filterModeDropdownOpen, messagePushFilterDropdownOpen, positionDropdownOpen])
|
||||
}, [closeBehaviorDropdownOpen, filterModeDropdownOpen, insightFilterModeDropdownOpen, messagePushFilterDropdownOpen, positionDropdownOpen])
|
||||
|
||||
|
||||
const loadConfig = async () => {
|
||||
@@ -531,8 +542,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const savedAiModelApiMaxTokens = await configService.getAiModelApiMaxTokens()
|
||||
const savedAiInsightSilenceDays = await configService.getAiInsightSilenceDays()
|
||||
const savedAiInsightAllowContext = await configService.getAiInsightAllowContext()
|
||||
const savedAiInsightWhitelistEnabled = await configService.getAiInsightWhitelistEnabled()
|
||||
const savedAiInsightWhitelist = await configService.getAiInsightWhitelist()
|
||||
const savedAiInsightFilterMode = await configService.getAiInsightFilterMode()
|
||||
const savedAiInsightFilterList = await configService.getAiInsightFilterList()
|
||||
const savedAiInsightCooldownMinutes = await configService.getAiInsightCooldownMinutes()
|
||||
const savedAiInsightScanIntervalHours = await configService.getAiInsightScanIntervalHours()
|
||||
const savedAiInsightContextCount = await configService.getAiInsightContextCount()
|
||||
@@ -555,8 +566,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
setAiModelApiMaxTokens(savedAiModelApiMaxTokens)
|
||||
setAiInsightSilenceDays(savedAiInsightSilenceDays)
|
||||
setAiInsightAllowContext(savedAiInsightAllowContext)
|
||||
setAiInsightWhitelistEnabled(savedAiInsightWhitelistEnabled)
|
||||
setAiInsightWhitelist(new Set(savedAiInsightWhitelist))
|
||||
setAiInsightFilterMode(savedAiInsightFilterMode)
|
||||
setAiInsightFilterList(new Set(savedAiInsightFilterList))
|
||||
setAiInsightCooldownMinutes(savedAiInsightCooldownMinutes)
|
||||
setAiInsightScanIntervalHours(savedAiInsightScanIntervalHours)
|
||||
setAiInsightContextCount(savedAiInsightContextCount)
|
||||
@@ -3390,98 +3401,129 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
{/* 对话白名单 */}
|
||||
{/* 对话过滤名单 */}
|
||||
{(() => {
|
||||
const sortedSessions = [...chatSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0))
|
||||
const selectableSessions = sessionFilterOptions.filter((session) =>
|
||||
session.type === 'private' || session.type === 'group' || session.type === 'official'
|
||||
)
|
||||
const keyword = insightWhitelistSearch.trim().toLowerCase()
|
||||
const filteredSessions = sortedSessions.filter((s) => {
|
||||
const id = s.username?.trim() || ''
|
||||
if (!id || id.endsWith('@chatroom') || id.toLowerCase().includes('placeholder')) return false
|
||||
const filteredSessions = selectableSessions.filter((session) => {
|
||||
if (insightFilterType !== 'all' && session.type !== insightFilterType) return false
|
||||
const id = session.username?.trim() || ''
|
||||
if (!id || id.toLowerCase().includes('placeholder')) return false
|
||||
if (!keyword) return true
|
||||
return (
|
||||
String(s.displayName || '').toLowerCase().includes(keyword) ||
|
||||
String(session.displayName || '').toLowerCase().includes(keyword) ||
|
||||
id.toLowerCase().includes(keyword)
|
||||
)
|
||||
})
|
||||
const filteredIds = filteredSessions.map((s) => s.username)
|
||||
const selectedCount = aiInsightWhitelist.size
|
||||
const selectedInFilteredCount = filteredIds.filter((id) => aiInsightWhitelist.has(id)).length
|
||||
const filteredIds = filteredSessions.map((session) => session.username)
|
||||
const selectedCount = aiInsightFilterList.size
|
||||
const selectedInFilteredCount = filteredIds.filter((id) => aiInsightFilterList.has(id)).length
|
||||
const allFilteredSelected = filteredIds.length > 0 && selectedInFilteredCount === filteredIds.length
|
||||
|
||||
const toggleSession = (id: string) => {
|
||||
setAiInsightWhitelist((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
const saveFilterList = async (next: Set<string>) => {
|
||||
await configService.setAiInsightFilterList(Array.from(next))
|
||||
}
|
||||
|
||||
const saveWhitelist = async (next: Set<string>) => {
|
||||
await configService.setAiInsightWhitelist(Array.from(next))
|
||||
const saveFilterMode = async (mode: configService.AiInsightFilterMode) => {
|
||||
setAiInsightFilterMode(mode)
|
||||
setInsightFilterModeDropdownOpen(false)
|
||||
await configService.setAiInsightFilterMode(mode)
|
||||
showMessage(mode === 'whitelist' ? '已切换为白名单模式' : '已切换为黑名单模式', true)
|
||||
}
|
||||
|
||||
const selectAllFiltered = () => {
|
||||
setAiInsightWhitelist((prev) => {
|
||||
setAiInsightFilterList((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const id of filteredIds) next.add(id)
|
||||
void saveWhitelist(next)
|
||||
void saveFilterList(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const clearSelection = () => {
|
||||
const next = new Set<string>()
|
||||
setAiInsightWhitelist(next)
|
||||
void saveWhitelist(next)
|
||||
setAiInsightFilterList(next)
|
||||
void saveFilterList(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="anti-revoke-tab insight-social-tab">
|
||||
<div className="anti-revoke-hero">
|
||||
<div className="anti-revoke-hero-main">
|
||||
<h3>对话白名单</h3>
|
||||
<h3>对话黑白名单</h3>
|
||||
<p>
|
||||
开启后,AI 见解仅对勾选的私聊对话生效,未勾选的对话将被完全忽略。关闭时对所有私聊均生效。中间可填写微博 UID。
|
||||
白名单模式下仅对已选会话触发见解;黑名单模式下会跳过已选会话。默认白名单且不选择任何会话。支持私聊、群聊、订阅号/服务号分类筛选后批量选择。
|
||||
</p>
|
||||
</div>
|
||||
<div className="anti-revoke-metrics">
|
||||
<div className="anti-revoke-metric is-total">
|
||||
<span className="label">私聊总数</span>
|
||||
<span className="value">{filteredIds.length + (keyword ? 0 : 0)}</span>
|
||||
<span className="label">可选会话总数</span>
|
||||
<span className="value">{selectableSessions.length}</span>
|
||||
</div>
|
||||
<div className="anti-revoke-metric is-installed">
|
||||
<span className="label">已选中</span>
|
||||
<span className="label">已加入名单</span>
|
||||
<span className="value">{selectedCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="log-toggle-line" style={{ marginBottom: 12 }}>
|
||||
<span className="log-status" style={{ fontWeight: 600 }}>
|
||||
{aiInsightWhitelistEnabled ? '白名单已启用(仅对勾选对话生效)' : '白名单未启用(对所有私聊生效)'}
|
||||
</span>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={aiInsightWhitelistEnabled}
|
||||
onChange={async (e) => {
|
||||
const val = e.target.checked
|
||||
setAiInsightWhitelistEnabled(val)
|
||||
await configService.setAiInsightWhitelistEnabled(val)
|
||||
}}
|
||||
/>
|
||||
<span className="switch-slider" />
|
||||
</label>
|
||||
<div className="form-group" style={{ marginBottom: 12 }}>
|
||||
<div className="log-toggle-line">
|
||||
<span className="log-status" style={{ fontWeight: 600 }}>
|
||||
{aiInsightFilterMode === 'whitelist'
|
||||
? '白名单模式(仅对名单内会话生效)'
|
||||
: '黑名单模式(名单内会话将被忽略)'}
|
||||
</span>
|
||||
<div className="custom-select" style={{ minWidth: 210 }}>
|
||||
<div
|
||||
className={`custom-select-trigger ${insightFilterModeDropdownOpen ? 'open' : ''}`}
|
||||
onClick={() => setInsightFilterModeDropdownOpen(!insightFilterModeDropdownOpen)}
|
||||
>
|
||||
<span className="custom-select-value">
|
||||
{aiInsightFilterMode === 'whitelist' ? '白名单模式' : '黑名单模式'}
|
||||
</span>
|
||||
<ChevronDown size={14} className={`custom-select-arrow ${insightFilterModeDropdownOpen ? 'rotate' : ''}`} />
|
||||
</div>
|
||||
<div className={`custom-select-dropdown ${insightFilterModeDropdownOpen ? 'open' : ''}`}>
|
||||
{[
|
||||
{ value: 'whitelist', label: '白名单模式' },
|
||||
{ value: 'blacklist', label: '黑名单模式' }
|
||||
].map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={`custom-select-option ${aiInsightFilterMode === option.value ? 'selected' : ''}`}
|
||||
onClick={() => { void saveFilterMode(option.value as configService.AiInsightFilterMode) }}
|
||||
>
|
||||
{option.label}
|
||||
{aiInsightFilterMode === option.value && <Check size={14} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="anti-revoke-control-card">
|
||||
<div className="push-filter-type-tabs" style={{ marginBottom: 10 }}>
|
||||
{insightFilterTypeOptions.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`push-filter-type-tab ${insightFilterType === option.value ? 'active' : ''}`}
|
||||
onClick={() => setInsightFilterType(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="anti-revoke-toolbar">
|
||||
<div className="filter-search-box anti-revoke-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索私聊对话..."
|
||||
placeholder="搜索对话..."
|
||||
value={insightWhitelistSearch}
|
||||
onChange={(e) => setInsightWhitelistSearch(e.target.value)}
|
||||
/>
|
||||
@@ -3517,7 +3559,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<div className="anti-revoke-list">
|
||||
{filteredSessions.length === 0 ? (
|
||||
<div className="anti-revoke-empty">
|
||||
{insightWhitelistSearch ? '没有匹配的对话' : '暂无私聊对话'}
|
||||
{insightWhitelistSearch || insightFilterType !== 'all' ? '没有匹配的对话' : '暂无可选对话'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -3527,7 +3569,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<span>状态</span>
|
||||
</div>
|
||||
{filteredSessions.map((session) => {
|
||||
const isSelected = aiInsightWhitelist.has(session.username)
|
||||
const isSelected = aiInsightFilterList.has(session.username)
|
||||
const weiboBinding = aiInsightWeiboBindings[session.username]
|
||||
const weiboDraftValue = getWeiboBindingDraftValue(session.username)
|
||||
const isBindingLoading = weiboBindingLoadingSessionId === session.username
|
||||
@@ -3543,11 +3585,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={async () => {
|
||||
setAiInsightWhitelist((prev) => {
|
||||
setAiInsightFilterList((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(session.username)) next.delete(session.username)
|
||||
else next.add(session.username)
|
||||
void configService.setAiInsightWhitelist(Array.from(next))
|
||||
void configService.setAiInsightFilterList(Array.from(next))
|
||||
return next
|
||||
})
|
||||
}}
|
||||
@@ -3563,54 +3605,65 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
/>
|
||||
<div className="anti-revoke-row-text">
|
||||
<span className="name">{session.displayName || session.username}</span>
|
||||
<span className="desc">{getSessionFilterTypeLabel(session.type)}</span>
|
||||
</div>
|
||||
</label>
|
||||
<div className="insight-social-binding-cell">
|
||||
<div className="insight-social-binding-input-wrap">
|
||||
<span className="binding-platform-chip">微博</span>
|
||||
<input
|
||||
type="text"
|
||||
className="insight-social-binding-input"
|
||||
value={weiboDraftValue}
|
||||
placeholder="填写数字 UID"
|
||||
onChange={(e) => updateWeiboBindingDraft(session.username, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="insight-social-binding-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => void handleSaveWeiboBinding(session.username, session.displayName || session.username)}
|
||||
disabled={isBindingLoading || !weiboDraftValue.trim()}
|
||||
>
|
||||
{isBindingLoading ? '绑定中...' : (weiboBinding ? '更新' : '绑定')}
|
||||
</button>
|
||||
{weiboBinding && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => void handleClearWeiboBinding(session.username)}
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="insight-social-binding-feedback">
|
||||
{weiboBindingError ? (
|
||||
<span className="binding-feedback error">{weiboBindingError}</span>
|
||||
) : weiboBinding?.screenName ? (
|
||||
<span className="binding-feedback">@{weiboBinding.screenName}</span>
|
||||
) : weiboBinding?.uid ? (
|
||||
<span className="binding-feedback">已绑定 UID:{weiboBinding.uid}</span>
|
||||
) : (
|
||||
<span className="binding-feedback muted">仅支持手动填写数字 UID</span>
|
||||
)}
|
||||
</div>
|
||||
{session.type === 'private' ? (
|
||||
<>
|
||||
<div className="insight-social-binding-input-wrap">
|
||||
<span className="binding-platform-chip">微博</span>
|
||||
<input
|
||||
type="text"
|
||||
className="insight-social-binding-input"
|
||||
value={weiboDraftValue}
|
||||
placeholder="填写数字 UID"
|
||||
onChange={(e) => updateWeiboBindingDraft(session.username, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="insight-social-binding-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => void handleSaveWeiboBinding(session.username, session.displayName || session.username)}
|
||||
disabled={isBindingLoading || !weiboDraftValue.trim()}
|
||||
>
|
||||
{isBindingLoading ? '绑定中...' : (weiboBinding ? '更新' : '绑定')}
|
||||
</button>
|
||||
{weiboBinding && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => void handleClearWeiboBinding(session.username)}
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="insight-social-binding-feedback">
|
||||
{weiboBindingError ? (
|
||||
<span className="binding-feedback error">{weiboBindingError}</span>
|
||||
) : weiboBinding?.screenName ? (
|
||||
<span className="binding-feedback">@{weiboBinding.screenName}</span>
|
||||
) : weiboBinding?.uid ? (
|
||||
<span className="binding-feedback">已绑定 UID:{weiboBinding.uid}</span>
|
||||
) : (
|
||||
<span className="binding-feedback muted">仅支持手动填写数字 UID</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="insight-social-binding-feedback">
|
||||
<span className="binding-feedback muted">仅私聊支持微博绑定</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="anti-revoke-row-status">
|
||||
<span className={`status-badge ${isSelected ? 'installed' : 'not-installed'}`}>
|
||||
<i className="status-dot" aria-hidden="true" />
|
||||
{isSelected ? '已加入' : '未加入'}
|
||||
{isSelected
|
||||
? (aiInsightFilterMode === 'whitelist' ? '已允许' : '已屏蔽')
|
||||
: (aiInsightFilterMode === 'whitelist' ? '未允许' : '允许')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3631,7 +3684,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<div className="api-docs">
|
||||
<div className="api-item">
|
||||
<p className="api-desc" style={{ lineHeight: 1.7 }}>
|
||||
<strong>触发方式一:活跃会话分析</strong> — 每当微信数据库变化(即你收到新消息)时,经过 500ms 防抖后,对最近活跃的私聊会话进行分析。<br />
|
||||
<strong>触发方式一:活跃会话分析</strong> — 每当微信数据库变化(即你收到新消息)时,经过 500ms 防抖后,对符合黑白名单规则的活跃会话进行分析。<br />
|
||||
<strong>触发方式二:沉默扫描</strong> — 每 4 小时独立扫描一次,对超过阈值天数无消息的联系人发出提醒。<br />
|
||||
<strong>时间观念</strong> — 每次调用时,AI 会收到今天已向该联系人和全局发出过多少次见解,由 AI 自行决定是否需要克制。<br />
|
||||
<strong>隐私</strong> — 所有分析请求均直接从你的电脑发往你填写的 API 地址,不经过任何 WeFlow 服务器。
|
||||
|
||||
+35
-6
@@ -97,6 +97,8 @@ export const CONFIG_KEYS = {
|
||||
AI_INSIGHT_SILENCE_DAYS: 'aiInsightSilenceDays',
|
||||
AI_INSIGHT_ALLOW_CONTEXT: 'aiInsightAllowContext',
|
||||
AI_INSIGHT_ALLOW_SOCIAL_CONTEXT: 'aiInsightAllowSocialContext',
|
||||
AI_INSIGHT_FILTER_MODE: 'aiInsightFilterMode',
|
||||
AI_INSIGHT_FILTER_LIST: 'aiInsightFilterList',
|
||||
AI_INSIGHT_WHITELIST_ENABLED: 'aiInsightWhitelistEnabled',
|
||||
AI_INSIGHT_WHITELIST: 'aiInsightWhitelist',
|
||||
AI_INSIGHT_COOLDOWN_MINUTES: 'aiInsightCooldownMinutes',
|
||||
@@ -1917,22 +1919,49 @@ export async function setAiInsightAllowSocialContext(allow: boolean): Promise<vo
|
||||
await config.set(CONFIG_KEYS.AI_INSIGHT_ALLOW_SOCIAL_CONTEXT, allow)
|
||||
}
|
||||
|
||||
export type AiInsightFilterMode = 'whitelist' | 'blacklist'
|
||||
|
||||
const normalizeAiInsightFilterList = (value: unknown): string[] => {
|
||||
if (!Array.isArray(value)) return []
|
||||
return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
|
||||
}
|
||||
|
||||
export async function getAiInsightFilterMode(): Promise<AiInsightFilterMode> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_FILTER_MODE)
|
||||
if (value === 'blacklist') return 'blacklist'
|
||||
if (value === 'whitelist') return 'whitelist'
|
||||
return 'whitelist'
|
||||
}
|
||||
|
||||
export async function setAiInsightFilterMode(mode: AiInsightFilterMode): Promise<void> {
|
||||
const normalizedMode: AiInsightFilterMode = mode === 'blacklist' ? 'blacklist' : 'whitelist'
|
||||
await config.set(CONFIG_KEYS.AI_INSIGHT_FILTER_MODE, normalizedMode)
|
||||
}
|
||||
|
||||
export async function getAiInsightFilterList(): Promise<string[]> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_FILTER_LIST)
|
||||
return normalizeAiInsightFilterList(value)
|
||||
}
|
||||
|
||||
export async function setAiInsightFilterList(list: string[]): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AI_INSIGHT_FILTER_LIST, normalizeAiInsightFilterList(list))
|
||||
}
|
||||
|
||||
// 兼容旧字段命名:内部已映射到新的黑白名单模式
|
||||
export async function getAiInsightWhitelistEnabled(): Promise<boolean> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_WHITELIST_ENABLED)
|
||||
return value === true
|
||||
return (await getAiInsightFilterMode()) === 'whitelist'
|
||||
}
|
||||
|
||||
export async function setAiInsightWhitelistEnabled(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AI_INSIGHT_WHITELIST_ENABLED, enabled)
|
||||
await setAiInsightFilterMode(enabled ? 'whitelist' : 'blacklist')
|
||||
}
|
||||
|
||||
export async function getAiInsightWhitelist(): Promise<string[]> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_WHITELIST)
|
||||
return Array.isArray(value) ? (value as string[]) : []
|
||||
return getAiInsightFilterList()
|
||||
}
|
||||
|
||||
export async function setAiInsightWhitelist(list: string[]): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AI_INSIGHT_WHITELIST, list)
|
||||
await setAiInsightFilterList(list)
|
||||
}
|
||||
|
||||
export async function getAiInsightCooldownMinutes(): Promise<number> {
|
||||
|
||||
Vendored
+8
@@ -849,6 +849,8 @@ export interface ElectronAPI {
|
||||
initiatedChats: number
|
||||
receivedChats: number
|
||||
initiativeRate: number
|
||||
topInitiatedFriend?: string
|
||||
topInitiatedCount?: number
|
||||
} | null
|
||||
responseSpeed: {
|
||||
avgResponseTime: number
|
||||
@@ -881,6 +883,12 @@ export interface ElectronAPI {
|
||||
dir?: string
|
||||
error?: string
|
||||
}>
|
||||
captureCurrentWindow: () => Promise<{
|
||||
success: boolean
|
||||
dataUrl?: string
|
||||
size?: { width: number; height: number }
|
||||
error?: string
|
||||
}>
|
||||
onAvailableYearsProgress: (callback: (payload: {
|
||||
taskId: string
|
||||
years?: number[]
|
||||
|
||||
Reference in new issue
Block a user