From 81b8960d41f79b752fa706ab232f83a71631b139 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 3 Apr 2026 21:07:44 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E5=8F=8C=E4=BA=BA=E5=B9=B4=E5=BA=A6?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E6=94=AF=E6=8C=81=E5=AF=BC=E5=87=BA=20[Enhan?= =?UTF-8?q?cement]:=20=E5=8F=8C=E4=BA=BA=E5=B9=B4=E5=BA=A6=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E4=B8=8D=E6=94=AF=E6=8C=81=E5=AF=BC=E5=87=BA=20?= =?UTF-8?q?=E4=BD=86=E6=80=BB=E5=B9=B4=E5=BA=A6=E6=8A=A5=E5=91=8A=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20Fixes=20#531?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/AnnualReportWindow.tsx | 37 +-- src/pages/DualReportWindow.scss | 4 +- src/pages/DualReportWindow.tsx | 504 +++++++++++++++++++++++++++++-- src/utils/reportExport.ts | 36 +++ 4 files changed, 525 insertions(+), 56 deletions(-) create mode 100644 src/utils/reportExport.ts diff --git a/src/pages/AnnualReportWindow.tsx b/src/pages/AnnualReportWindow.tsx index ab5fd0d..5b2d510 100644 --- a/src/pages/AnnualReportWindow.tsx +++ b/src/pages/AnnualReportWindow.tsx @@ -8,44 +8,9 @@ import { registerBackgroundTask, updateBackgroundTask } from '../services/backgroundTaskMonitor' +import { drawPatternBackground } from '../utils/reportExport' import './AnnualReportWindow.scss' -// SVG 背景图案 (用于导出) -const PATTERN_LIGHT_SVG = `` - -const PATTERN_DARK_SVG = `` - -// 绘制 SVG 图案背景到 canvas -const drawPatternBackground = async (ctx: CanvasRenderingContext2D, width: number, height: number, bgColor: string, isDark: boolean) => { - // 先填充背景色 - ctx.fillStyle = bgColor - ctx.fillRect(0, 0, width, height) - - // 加载 SVG 图案 - const svgString = isDark ? PATTERN_DARK_SVG : PATTERN_LIGHT_SVG - const blob = new Blob([svgString], { type: 'image/svg+xml' }) - const url = URL.createObjectURL(blob) - - return new Promise((resolve) => { - const img = new window.Image() - img.onload = () => { - // 平铺绘制图案 - const pattern = ctx.createPattern(img, 'repeat') - if (pattern) { - ctx.fillStyle = pattern - ctx.fillRect(0, 0, width, height) - } - URL.revokeObjectURL(url) - resolve() - } - img.onerror = () => { - URL.revokeObjectURL(url) - resolve() - } - img.src = url - }) -} - interface TopContact { username: string displayName: string diff --git a/src/pages/DualReportWindow.scss b/src/pages/DualReportWindow.scss index 44bee66..7eb5ff9 100644 --- a/src/pages/DualReportWindow.scss +++ b/src/pages/DualReportWindow.scss @@ -238,7 +238,7 @@ } .scene-message.sent .scene-avatar { - border-color: color-mix(in srgb, var(--primary) 30%, var(--bg-tertiary, rgba(0, 0, 0, 0.08))); + border-color: rgba(var(--ar-primary-rgb), 0.3); } .dual-stat-grid { @@ -981,4 +981,4 @@ transform: translateY(0); } } -} \ No newline at end of file +} diff --git a/src/pages/DualReportWindow.tsx b/src/pages/DualReportWindow.tsx index 585fb1e..e9f9627 100644 --- a/src/pages/DualReportWindow.tsx +++ b/src/pages/DualReportWindow.tsx @@ -1,6 +1,10 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' +import { Check, Download, Image, SlidersHorizontal, X } from 'lucide-react' +import html2canvas from 'html2canvas' import ReportHeatmap from '../components/ReportHeatmap' import ReportWordCloud from '../components/ReportWordCloud' +import { useThemeStore } from '../stores/themeStore' +import { drawPatternBackground } from '../utils/reportExport' import './AnnualReportWindow.scss' import './DualReportWindow.scss' @@ -66,6 +70,12 @@ interface DualReportData { streak?: { days: number; startDate: string; endDate: string } } +interface SectionInfo { + id: string + name: string + ref: React.RefObject +} + function DualReportWindow() { const [reportData, setReportData] = useState(null) const [isLoading, setIsLoading] = useState(true) @@ -75,6 +85,29 @@ function DualReportWindow() { const [myEmojiUrl, setMyEmojiUrl] = useState(null) const [friendEmojiUrl, setFriendEmojiUrl] = useState(null) const [activeWordCloudTab, setActiveWordCloudTab] = useState<'shared' | 'my' | 'friend'>('shared') + const [isExporting, setIsExporting] = useState(false) + const [exportProgress, setExportProgress] = useState('') + const [showExportModal, setShowExportModal] = useState(false) + const [selectedSections, setSelectedSections] = useState>(new Set()) + const [fabOpen, setFabOpen] = useState(false) + const [exportMode, setExportMode] = useState<'separate' | 'long'>('separate') + + const { themeMode } = useThemeStore() + + const sectionRefs = { + cover: useRef(null), + firstChat: useRef(null), + yearFirstChat: useRef(null), + heatmap: useRef(null), + initiative: useRef(null), + response: useRef(null), + streak: useRef(null), + wordCloud: useRef(null), + stats: useRef(null), + ending: useRef(null) + } + + const containerRef = useRef(null) useEffect(() => { const params = new URLSearchParams(window.location.hash.split('?')[1] || '') @@ -151,6 +184,351 @@ function DualReportWindow() { void loadEmojis() }, [reportData]) + const formatFileYearLabel = (year: number) => (year === 0 ? '历史以来' : String(year)) + + const sanitizeFileNameSegment = (value: string) => { + const sanitized = value.replace(/[\\/:*?"<>|]/g, '_').trim() + return sanitized || '好友' + } + + const getAvailableSections = (): SectionInfo[] => { + if (!reportData) return [] + + const sections: SectionInfo[] = [ + { id: 'cover', name: '封面', ref: sectionRefs.cover }, + { id: 'firstChat', name: '首次聊天', ref: sectionRefs.firstChat } + ] + + if (reportData.yearFirstChat && (!reportData.firstChat || reportData.yearFirstChat.createTime !== reportData.firstChat.createTime)) { + sections.push({ id: 'yearFirstChat', name: '第一段对话', ref: sectionRefs.yearFirstChat }) + } + if (reportData.heatmap) { + sections.push({ id: 'heatmap', name: '作息规律', ref: sectionRefs.heatmap }) + } + if (reportData.initiative) { + sections.push({ id: 'initiative', name: '主动性', ref: sectionRefs.initiative }) + } + if (reportData.response) { + sections.push({ id: 'response', name: '回应速度', ref: sectionRefs.response }) + } + if (reportData.streak) { + sections.push({ id: 'streak', name: '最长连续聊天', ref: sectionRefs.streak }) + } + + sections.push({ id: 'wordCloud', name: '常用语', ref: sectionRefs.wordCloud }) + sections.push({ id: 'stats', name: '年度统计', ref: sectionRefs.stats }) + sections.push({ id: 'ending', name: '尾声', ref: sectionRefs.ending }) + + return sections + } + + const exportSection = async (section: SectionInfo): Promise<{ name: string; data: string } | null> => { + const element = section.ref.current + if (!element) { + return null + } + + const OUTPUT_WIDTH = 1920 + const OUTPUT_HEIGHT = 1080 + let wordCloudInner: HTMLElement | null = null + let wordTags: NodeListOf | null = null + let wordCloudOriginalStyle = '' + const wordTagOriginalStyles: string[] = [] + const originalStyle = element.style.cssText + + 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') + + element.style.minHeight = 'auto' + element.style.padding = '40px 20px' + element.style.background = 'transparent' + element.style.backgroundColor = 'transparent' + element.style.boxShadow = 'none' + + wordCloudInner = element.querySelector('.word-cloud-inner') as HTMLElement | null + wordTags = element.querySelectorAll('.word-tag') as NodeListOf + + if (wordCloudInner) { + wordCloudOriginalStyle = wordCloudInner.style.cssText + wordCloudInner.style.transform = 'none' + } + + wordTags.forEach((tag, index) => { + wordTagOriginalStyles[index] = tag.style.cssText + tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1') + tag.style.animation = 'none' + }) + + await new Promise((resolve) => setTimeout(resolve, 50)) + + const computedStyle = getComputedStyle(document.documentElement) + const bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#F9F8F6' + + const canvas = await html2canvas(element, { + 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() + } + }) + + const outputCanvas = document.createElement('canvas') + outputCanvas.width = OUTPUT_WIDTH + outputCanvas.height = OUTPUT_HEIGHT + const ctx = outputCanvas.getContext('2d') + if (!ctx) { + return null + } + + 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 + let drawHeight: number + let drawX: number + let 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 { + return null + } finally { + element.style.cssText = originalStyle + if (wordCloudInner) { + wordCloudInner.style.cssText = wordCloudOriginalStyle + } + wordTags?.forEach((tag, index) => { + tag.style.cssText = wordTagOriginalStyles[index] + }) + document.body.classList.remove('exporting-snapshot') + document.documentElement.classList.remove('exporting-snapshot') + } + } + + const exportFullReport = async (filterIds?: Set) => { + if (!containerRef.current || !reportData) { + return + } + + setIsExporting(true) + setExportProgress('正在生成长图...') + + let wordCloudInner: HTMLElement | null = null + let wordTags: NodeListOf | null = null + let wordCloudOriginalStyle = '' + const wordTagOriginalStyles: string[] = [] + const container = containerRef.current + const sections = Array.from(container.querySelectorAll('.section')) as HTMLElement[] + const originalStyles = sections.map((section) => section.style.cssText) + + 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') + + sections.forEach((section) => { + section.style.minHeight = 'auto' + section.style.padding = '40px 0' + }) + + if (filterIds) { + getAvailableSections().forEach((section) => { + if (!filterIds.has(section.id) && section.ref.current) { + section.ref.current.style.display = 'none' + } + }) + } + + wordCloudInner = container.querySelector('.word-cloud-inner') as HTMLElement | null + wordTags = container.querySelectorAll('.word-tag') as NodeListOf + + if (wordCloudInner) { + wordCloudOriginalStyle = wordCloudInner.style.cssText + wordCloudInner.style.transform = 'none' + } + + wordTags.forEach((tag, index) => { + wordTagOriginalStyles[index] = tag.style.cssText + tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1') + tag.style.animation = 'none' + }) + + await new Promise((resolve) => setTimeout(resolve, 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() + } + }) + + const outputCanvas = document.createElement('canvas') + outputCanvas.width = canvas.width + outputCanvas.height = canvas.height + const ctx = outputCanvas.getContext('2d') + if (!ctx) { + throw new Error('无法创建导出画布') + } + + const isDark = themeMode === 'dark' + await drawPatternBackground(ctx, canvas.width, canvas.height, bgColor, isDark) + ctx.drawImage(canvas, 0, 0) + + const yearFilePrefix = formatFileYearLabel(reportData.year) + const friendFileSegment = sanitizeFileNameSegment(reportData.friendName || reportData.friendUsername) + const link = document.createElement('a') + link.download = `${yearFilePrefix}双人年度报告_${friendFileSegment}${filterIds ? '_自定义' : ''}.png` + link.href = outputCanvas.toDataURL('image/png') + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } catch (e) { + alert('导出失败: ' + String(e)) + } finally { + sections.forEach((section, index) => { + section.style.cssText = originalStyles[index] + }) + if (wordCloudInner) { + wordCloudInner.style.cssText = wordCloudOriginalStyle + } + wordTags?.forEach((tag, index) => { + tag.style.cssText = wordTagOriginalStyles[index] + }) + document.body.classList.remove('exporting-snapshot') + document.documentElement.classList.remove('exporting-snapshot') + setIsExporting(false) + setExportProgress('') + } + } + + const exportSelectedSections = async () => { + if (!reportData) return + + const sections = getAvailableSections().filter((section) => selectedSections.has(section.id)) + if (sections.length === 0) { + alert('请至少选择一个板块') + return + } + + if (exportMode === 'long') { + setShowExportModal(false) + await exportFullReport(selectedSections) + setSelectedSections(new Set()) + return + } + + setIsExporting(true) + setShowExportModal(false) + + const exportedImages: Array<{ name: string; data: string }> = [] + + for (let index = 0; index < sections.length; index++) { + const section = sections[index] + setExportProgress(`正在导出: ${section.name} (${index + 1}/${sections.length})`) + + const result = await exportSection(section) + if (result) { + exportedImages.push(result) + } + } + + if (exportedImages.length === 0) { + alert('导出失败') + setIsExporting(false) + setExportProgress('') + return + } + + const dirResult = await window.electronAPI.dialog.openDirectory({ + title: '选择导出文件夹', + properties: ['openDirectory', 'createDirectory'] + }) + if (dirResult.canceled || !dirResult.filePaths?.[0]) { + setIsExporting(false) + setExportProgress('') + return + } + + setExportProgress('正在写入文件...') + const yearFilePrefix = formatFileYearLabel(reportData.year) + const friendFileSegment = sanitizeFileNameSegment(reportData.friendName || reportData.friendUsername) + const exportResult = await window.electronAPI.annualReport.exportImages({ + baseDir: dirResult.filePaths[0], + folderName: `${yearFilePrefix}双人年度报告_${friendFileSegment}_分模块`, + images: exportedImages.map((image) => ({ + name: `${yearFilePrefix}双人年度报告_${friendFileSegment}_${image.name}.png`, + dataUrl: image.data + })) + }) + + if (!exportResult.success) { + alert('导出失败: ' + (exportResult.error || '未知错误')) + } + + setIsExporting(false) + setExportProgress('') + setSelectedSections(new Set()) + } + + const toggleSection = (id: string) => { + const next = new Set(selectedSections) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + setSelectedSections(next) + } + + const toggleAll = () => { + const sections = getAvailableSections() + if (selectedSections.size === sections.length) { + setSelectedSections(new Set()) + return + } + setSelectedSections(new Set(sections.map((section) => section.id))) + } + if (isLoading) { return (
@@ -305,7 +683,7 @@ function DualReportWindow() { if (emojiUrl) { return (
- 表情 { + 表情 { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style'); }} /> @@ -356,7 +734,7 @@ function DualReportWindow() { if (avatarUrl) { return (
- {isSentByMe + {isSentByMe
) } @@ -419,9 +797,99 @@ function DualReportWindow() {
+
+ + + + +
+ + {isExporting && ( +
+
+
+
+ +
+

正在导出

+

{exportProgress}

+
+
+ )} + + {showExportModal && ( +
setShowExportModal(false)}> +
e.stopPropagation()}> +
+

{exportMode === 'long' ? '自定义导出长图' : '选择要导出的板块'}

+ +
+
+ {getAvailableSections().map((section) => ( +
toggleSection(section.id)} + > +
+ {selectedSections.has(section.id) && } +
+ {section.name} +
+ ))} +
+
+ + +
+
+
+ )} +
-
-
+
+
WEFLOW · DUAL REPORT

{yearTitle}
双人聊天报告


@@ -433,7 +901,7 @@ function DualReportWindow() {

每一次对话都值得被珍藏

-
+
首次聊天

故事的开始

{firstChat ? ( @@ -457,7 +925,7 @@ function DualReportWindow() {
{yearFirstChat && (!firstChat || yearFirstChat.createTime !== firstChat.createTime) ? ( -
+
第一段对话

{reportData.year === 0 ? '你们的第一段对话' : `${reportData.year}年的第一段对话`} @@ -473,7 +941,7 @@ function DualReportWindow() { ) : null} {reportData.heatmap && ( -
+
聊天习惯

作息规律

{mostActive && ( @@ -486,14 +954,14 @@ function DualReportWindow() { )} {reportData.initiative && ( -
+
主动性

情感的天平

- {reportData.selfAvatarUrl ? me-avatar : '我'} + {reportData.selfAvatarUrl ? me-avatar : '我'}
{reportData.initiative.initiated}次
{initiatedPercent.toFixed(1)}%
@@ -507,7 +975,7 @@ function DualReportWindow() {
- {reportData.friendAvatarUrl ? friend-avatar : reportData.friendName.substring(0, 1)} + {reportData.friendAvatarUrl ? friend-avatar : reportData.friendName.substring(0, 1)}
{reportData.initiative.received}次
{receivedPercent.toFixed(1)}%
@@ -521,7 +989,7 @@ function DualReportWindow() { )} {reportData.response && ( -
+
回应速度

你说,我在

@@ -558,7 +1026,7 @@ function DualReportWindow() { )} {reportData.streak && ( -
+
聊天火花

最长连续聊天

@@ -596,7 +1064,7 @@ function DualReportWindow() {
)} -
+
常用语

{yearTitle}常用语

@@ -640,7 +1108,7 @@ function DualReportWindow() {
-
+
年度统计

{yearTitle}数据概览

@@ -664,7 +1132,7 @@ function DualReportWindow() {
我常用的表情
{myEmojiUrl ? ( - my-emoji { + my-emoji { (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style'); (e.target as HTMLImageElement).style.display = 'none'; }} /> @@ -677,7 +1145,7 @@ function DualReportWindow() {
{reportData.friendName}常用的表情
{friendEmojiUrl ? ( - friend-emoji { + friend-emoji { (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style'); (e.target as HTMLImageElement).style.display = 'none'; }} /> @@ -690,7 +1158,7 @@ function DualReportWindow() {
-
+
尾声

谢谢你一直在

愿我们继续把故事写下去

diff --git a/src/utils/reportExport.ts b/src/utils/reportExport.ts new file mode 100644 index 0000000..224b99e --- /dev/null +++ b/src/utils/reportExport.ts @@ -0,0 +1,36 @@ +const PATTERN_LIGHT_SVG = `` + +const PATTERN_DARK_SVG = `` + +export const drawPatternBackground = async ( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + bgColor: string, + isDark: boolean +) => { + ctx.fillStyle = bgColor + ctx.fillRect(0, 0, width, height) + + const svgString = isDark ? PATTERN_DARK_SVG : PATTERN_LIGHT_SVG + const blob = new Blob([svgString], { type: 'image/svg+xml' }) + const url = URL.createObjectURL(blob) + + return new Promise((resolve) => { + const img = new window.Image() + img.onload = () => { + const pattern = ctx.createPattern(img, 'repeat') + if (pattern) { + ctx.fillStyle = pattern + ctx.fillRect(0, 0, width, height) + } + URL.revokeObjectURL(url) + resolve() + } + img.onerror = () => { + URL.revokeObjectURL(url) + resolve() + } + img.src = url + }) +} From 758de9949b34b344db65545cd08c247c0c0b24ee Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 3 Apr 2026 21:08:05 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=BC=80=E6=9C=BA?= =?UTF-8?q?=E8=87=AA=E5=90=AF=E5=8A=A8=20[Enhancement]:=20=E5=B8=8C?= =?UTF-8?q?=E6=9C=9B=E8=83=BD=E5=A4=9F=E6=94=AF=E6=8C=81=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E5=92=8C=E5=BC=80=E6=9C=BA=E8=87=AA=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=20Fixes=20#516?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.ts | 134 +++++++++++++++++++++++++++++++++++- electron/preload.ts | 2 + electron/services/config.ts | 1 + src/pages/SettingsPage.tsx | 64 +++++++++++++++++ src/services/config.ts | 13 ++++ src/types/electron.d.ts | 8 +++ 6 files changed, 221 insertions(+), 1 deletion(-) diff --git a/electron/main.ts b/electron/main.ts index 352651d..b2a93e5 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -171,6 +171,118 @@ const AUTO_UPDATE_ENABLED = process.env.AUTO_UPDATE_ENABLED === '1' || (process.env.AUTO_UPDATE_ENABLED == null && !process.env.VITE_DEV_SERVER_URL) +const getLaunchAtStartupUnsupportedReason = (): string | null => { + if (process.platform !== 'win32' && process.platform !== 'darwin') { + return '当前平台暂不支持开机自启动' + } + if (!app.isPackaged) { + return '仅安装后的 Windows / macOS 版本支持开机自启动' + } + return null +} + +const isLaunchAtStartupSupported = (): boolean => getLaunchAtStartupUnsupportedReason() == null + +const getStoredLaunchAtStartupPreference = (): boolean | undefined => { + const value = configService?.get('launchAtStartup') + return typeof value === 'boolean' ? value : undefined +} + +const getSystemLaunchAtStartup = (): boolean => { + if (!isLaunchAtStartupSupported()) return false + try { + return app.getLoginItemSettings().openAtLogin === true + } catch (error) { + console.error('[WeFlow] 读取开机自启动状态失败:', error) + return false + } +} + +const buildLaunchAtStartupSettings = (enabled: boolean): Parameters[0] => + process.platform === 'win32' + ? { openAtLogin: enabled, path: process.execPath } + : { openAtLogin: enabled } + +const setSystemLaunchAtStartup = (enabled: boolean): { success: boolean; enabled: boolean; error?: string } => { + try { + app.setLoginItemSettings(buildLaunchAtStartupSettings(enabled)) + const effectiveEnabled = app.getLoginItemSettings().openAtLogin === true + if (effectiveEnabled !== enabled) { + return { + success: false, + enabled: effectiveEnabled, + error: '系统未接受该开机自启动设置' + } + } + return { success: true, enabled: effectiveEnabled } + } catch (error) { + return { + success: false, + enabled: getSystemLaunchAtStartup(), + error: `设置开机自启动失败: ${String((error as Error)?.message || error)}` + } + } +} + +const getLaunchAtStartupStatus = (): { enabled: boolean; supported: boolean; reason?: string } => { + const unsupportedReason = getLaunchAtStartupUnsupportedReason() + if (unsupportedReason) { + return { + enabled: getStoredLaunchAtStartupPreference() === true, + supported: false, + reason: unsupportedReason + } + } + return { + enabled: getSystemLaunchAtStartup(), + supported: true + } +} + +const applyLaunchAtStartupPreference = ( + enabled: boolean +): { success: boolean; enabled: boolean; supported: boolean; reason?: string; error?: string } => { + const unsupportedReason = getLaunchAtStartupUnsupportedReason() + if (unsupportedReason) { + return { + success: false, + enabled: getStoredLaunchAtStartupPreference() === true, + supported: false, + reason: unsupportedReason + } + } + + const result = setSystemLaunchAtStartup(enabled) + configService?.set('launchAtStartup', result.enabled) + return { + ...result, + supported: true + } +} + +const syncLaunchAtStartupPreference = () => { + if (!configService) return + + const unsupportedReason = getLaunchAtStartupUnsupportedReason() + if (unsupportedReason) return + + const storedPreference = getStoredLaunchAtStartupPreference() + const systemEnabled = getSystemLaunchAtStartup() + + if (typeof storedPreference !== 'boolean') { + configService.set('launchAtStartup', systemEnabled) + return + } + + if (storedPreference === systemEnabled) return + + const result = setSystemLaunchAtStartup(storedPreference) + configService.set('launchAtStartup', result.enabled) + if (!result.success && result.error) { + console.error('[WeFlow] 同步开机自启动设置失败:', result.error) + } +} + // 使用白名单过滤 PATH,避免被第三方目录中的旧版 VC++ 运行库劫持。 // 仅保留系统目录(Windows/System32/SysWOW64)和应用自身目录(可执行目录、resources)。 function sanitizePathEnv() { @@ -1250,7 +1362,12 @@ function registerIpcHandlers() { }) ipcMain.handle('config:set', async (_, key: string, value: any) => { - const result = configService?.set(key as any, value) + let result: unknown + if (key === 'launchAtStartup') { + result = applyLaunchAtStartupPreference(value === true) + } else { + result = configService?.set(key as any, value) + } if (key === 'updateChannel') { applyAutoUpdateChannel('settings') } @@ -1259,6 +1376,12 @@ function registerIpcHandlers() { }) ipcMain.handle('config:clear', async () => { + if (isLaunchAtStartupSupported() && getSystemLaunchAtStartup()) { + const result = setSystemLaunchAtStartup(false) + if (!result.success && result.error) { + console.error('[WeFlow] 清空配置时关闭开机自启动失败:', result.error) + } + } configService?.clear() messagePushService.handleConfigCleared() return true @@ -1301,6 +1424,14 @@ function registerIpcHandlers() { return app.getVersion() }) + ipcMain.handle('app:getLaunchAtStartupStatus', async () => { + return getLaunchAtStartupStatus() + }) + + ipcMain.handle('app:setLaunchAtStartup', async (_, enabled: boolean) => { + return applyLaunchAtStartupPreference(enabled === true) + }) + ipcMain.handle('app:checkWayland', async () => { if (process.platform !== 'linux') return false; @@ -2881,6 +3012,7 @@ app.whenReady().then(async () => { updateSplashProgress(5, '正在加载配置...') configService = new ConfigService() applyAutoUpdateChannel('startup') + syncLaunchAtStartupPreference() // 将用户主题配置推送给 Splash 窗口 if (splashWindow && !splashWindow.isDestroyed()) { diff --git a/electron/preload.ts b/electron/preload.ts index 38e722f..db103ef 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -53,6 +53,8 @@ contextBridge.exposeInMainWorld('electronAPI', { app: { getDownloadsPath: () => ipcRenderer.invoke('app:getDownloadsPath'), getVersion: () => ipcRenderer.invoke('app:getVersion'), + getLaunchAtStartupStatus: () => ipcRenderer.invoke('app:getLaunchAtStartupStatus'), + setLaunchAtStartup: (enabled: boolean) => ipcRenderer.invoke('app:setLaunchAtStartup', enabled), checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'), downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'), ignoreUpdate: (version: string) => ipcRenderer.invoke('app:ignoreUpdate', version), diff --git a/electron/services/config.ts b/electron/services/config.ts index 7e3b1e1..3039412 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -27,6 +27,7 @@ interface ConfigSchema { themeId: string language: string logEnabled: boolean + launchAtStartup?: boolean llmModelPath: string whisperModelName: string whisperModelDir: string diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 98fe8b3..808a601 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -138,6 +138,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'>('top-right') const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all') const [notificationFilterList, setNotificationFilterList] = useState([]) + const [launchAtStartup, setLaunchAtStartup] = useState(false) + const [launchAtStartupSupported, setLaunchAtStartupSupported] = useState(isWindows || isMac) + const [launchAtStartupReason, setLaunchAtStartupReason] = useState('') const [windowCloseBehavior, setWindowCloseBehavior] = useState('ask') const [quoteLayout, setQuoteLayout] = useState('quote-top') const [updateChannel, setUpdateChannel] = useState('stable') @@ -162,6 +165,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [isFetchingDbKey, setIsFetchingDbKey] = useState(false) const [isFetchingImageKey, setIsFetchingImageKey] = useState(false) const [isCheckingUpdate, setIsCheckingUpdate] = useState(false) + const [isUpdatingLaunchAtStartup, setIsUpdatingLaunchAtStartup] = useState(false) const [appVersion, setAppVersion] = useState('') const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null) const [showDecryptKey, setShowDecryptKey] = useState(false) @@ -337,6 +341,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const savedNotificationFilterMode = await configService.getNotificationFilterMode() const savedNotificationFilterList = await configService.getNotificationFilterList() const savedMessagePushEnabled = await configService.getMessagePushEnabled() + const savedLaunchAtStartupStatus = await window.electronAPI.app.getLaunchAtStartupStatus() const savedWindowCloseBehavior = await configService.getWindowCloseBehavior() const savedQuoteLayout = await configService.getQuoteLayout() const savedUpdateChannel = await configService.getUpdateChannel() @@ -386,6 +391,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setNotificationFilterMode(savedNotificationFilterMode) setNotificationFilterList(savedNotificationFilterList) setMessagePushEnabled(savedMessagePushEnabled) + setLaunchAtStartup(savedLaunchAtStartupStatus.enabled) + setLaunchAtStartupSupported(savedLaunchAtStartupStatus.supported) + setLaunchAtStartupReason(savedLaunchAtStartupStatus.reason || '') setWindowCloseBehavior(savedWindowCloseBehavior) setQuoteLayout(savedQuoteLayout) if (savedUpdateChannel) { @@ -428,6 +436,29 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { + const handleLaunchAtStartupChange = async (enabled: boolean) => { + if (isUpdatingLaunchAtStartup) return + + try { + setIsUpdatingLaunchAtStartup(true) + const result = await window.electronAPI.app.setLaunchAtStartup(enabled) + setLaunchAtStartup(result.enabled) + setLaunchAtStartupSupported(result.supported) + setLaunchAtStartupReason(result.reason || '') + + if (result.success) { + showMessage(enabled ? '已开启开机自启动' : '已关闭开机自启动', true) + return + } + + showMessage(result.error || result.reason || '设置开机自启动失败', false) + } catch (e: any) { + showMessage(`设置开机自启动失败: ${e?.message || String(e)}`, false) + } finally { + setIsUpdatingLaunchAtStartup(false) + } + } + const refreshWhisperStatus = async (modelDirValue = whisperModelDir) => { try { const result = await window.electronAPI.whisper?.getModelStatus() @@ -1199,6 +1230,39 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
+
+ + + {launchAtStartupSupported + ? '开启后,登录系统时会自动启动 WeFlow。' + : launchAtStartupReason || '当前环境暂不支持开机自启动。'} + +
+ + {isUpdatingLaunchAtStartup + ? '保存中...' + : launchAtStartupSupported + ? (launchAtStartup ? '已开启' : '已关闭') + : '当前不可用'} + + +
+
+ +
+
设置点击关闭按钮后的默认行为;选择“每次询问”时会弹出关闭确认。 diff --git a/src/services/config.ts b/src/services/config.ts index 59e8afa..1f687e7 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -13,6 +13,7 @@ export const CONFIG_KEYS = { LAST_SESSION: 'lastSession', WINDOW_BOUNDS: 'windowBounds', CACHE_PATH: 'cachePath', + LAUNCH_AT_STARTUP: 'launchAtStartup', EXPORT_PATH: 'exportPath', AGREEMENT_ACCEPTED: 'agreementAccepted', @@ -258,6 +259,18 @@ export async function setLogEnabled(enabled: boolean): Promise { await config.set(CONFIG_KEYS.LOG_ENABLED, enabled) } +// 获取开机自启动偏好 +export async function getLaunchAtStartup(): Promise { + const value = await config.get(CONFIG_KEYS.LAUNCH_AT_STARTUP) + if (typeof value === 'boolean') return value + return null +} + +// 设置开机自启动偏好 +export async function setLaunchAtStartup(enabled: boolean): Promise { + await config.set(CONFIG_KEYS.LAUNCH_AT_STARTUP, enabled) +} + // 获取 LLM 模型路径 export async function getLlmModelPath(): Promise { const value = await config.get(CONFIG_KEYS.LLM_MODEL_PATH) diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index c174983..19f33a5 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -56,6 +56,14 @@ export interface ElectronAPI { app: { getDownloadsPath: () => Promise getVersion: () => Promise + getLaunchAtStartupStatus: () => Promise<{ enabled: boolean; supported: boolean; reason?: string }> + setLaunchAtStartup: (enabled: boolean) => Promise<{ + success: boolean + enabled: boolean + supported: boolean + reason?: string + error?: string + }> checkForUpdates: () => Promise<{ hasUpdate: boolean; version?: string; releaseNotes?: string }> downloadAndInstall: () => Promise ignoreUpdate: (version: string) => Promise<{ success: boolean }>