diff --git a/package.json b/package.json index 1d1213a078..e4b7e646d8 100644 --- a/package.json +++ b/package.json @@ -92,8 +92,10 @@ "node-stream-zip": "^1.15.0", "officeparser": "^4.2.0", "os-proxy-config": "^1.1.2", + "qrcode.react": "^4.2.0", "selection-hook": "^1.0.12", "sharp": "^0.34.3", + "socket.io": "^4.8.1", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 9a3866d480..7704bbaa13 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -364,5 +364,12 @@ export enum IpcChannel { ClaudeCodePlugin_ListInstalled = 'claudeCodePlugin:list-installed', ClaudeCodePlugin_InvalidateCache = 'claudeCodePlugin:invalidate-cache', ClaudeCodePlugin_ReadContent = 'claudeCodePlugin:read-content', - ClaudeCodePlugin_WriteContent = 'claudeCodePlugin:write-content' + ClaudeCodePlugin_WriteContent = 'claudeCodePlugin:write-content', + + // WebSocket + WebSocket_Start = 'webSocket:start', + WebSocket_Stop = 'webSocket:stop', + WebSocket_Status = 'webSocket:status', + WebSocket_SendFile = 'webSocket:send-file', + WebSocket_GetAllCandidates = 'webSocket:get-all-candidates' } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 94f8556d13..08fef11955 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -70,6 +70,7 @@ import { import storeSyncService from './services/StoreSyncService' import { themeService } from './services/ThemeService' import VertexAIService from './services/VertexAIService' +import WebSocketService from './services/WebSocketService' import { setOpenLinkExternal } from './services/WebviewService' import { windowService } from './services/WindowService' import { calculateDirectorySize, getResourcePath } from './utils' @@ -1017,4 +1018,11 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { return { success: false, error } } }) + + // WebSocket + ipcMain.handle(IpcChannel.WebSocket_Start, WebSocketService.start) + ipcMain.handle(IpcChannel.WebSocket_Stop, WebSocketService.stop) + ipcMain.handle(IpcChannel.WebSocket_Status, WebSocketService.getStatus) + ipcMain.handle(IpcChannel.WebSocket_SendFile, WebSocketService.sendFile) + ipcMain.handle(IpcChannel.WebSocket_GetAllCandidates, WebSocketService.getAllCandidates) } diff --git a/src/main/services/WebSocketService.ts b/src/main/services/WebSocketService.ts new file mode 100644 index 0000000000..f1cbe8f426 --- /dev/null +++ b/src/main/services/WebSocketService.ts @@ -0,0 +1,368 @@ +import { loggerService } from '@logger' +import * as fs from 'fs' +import { networkInterfaces } from 'os' +import * as path from 'path' +import { Server, Socket } from 'socket.io' + +import { windowService } from './WindowService' + +const logger = loggerService.withContext('WebSocketService') + +class WebSocketService { + private io: Server | null = null + private isStarted = false + private port = 7017 + private connectedClients = new Set() + + private getLocalIpAddress(): string | undefined { + const interfaces = networkInterfaces() + + // 按优先级排序的网络接口名称模式 + const interfacePriority = [ + // macOS: 以太网/Wi-Fi 优先 + /^en[0-9]+$/, // en0, en1 (以太网/Wi-Fi) + /^(en|eth)[0-9]+$/, // 以太网接口 + /^wlan[0-9]+$/, // 无线接口 + // Windows: 以太网/Wi-Fi 优先 + /^(Ethernet|Wi-Fi|Local Area Connection)/, + /^(Wi-Fi|无线网络连接)/, + // Linux: 以太网/Wi-Fi 优先 + /^(eth|enp|wlp|wlan)[0-9]+/, + // 虚拟化接口(低优先级) + /^bridge[0-9]+$/, // Docker bridge + /^veth[0-9]+$/, // Docker veth + /^docker[0-9]+/, // Docker interfaces + /^br-[0-9a-f]+/, // Docker bridge + /^vmnet[0-9]+$/, // VMware + /^vboxnet[0-9]+$/, // VirtualBox + // VPN 隧道接口(低优先级) + /^utun[0-9]+$/, // macOS VPN + /^tun[0-9]+$/, // Linux/Unix VPN + /^tap[0-9]+$/, // TAP interfaces + /^tailscale[0-9]*$/, // Tailscale VPN + /^wg[0-9]+$/ // WireGuard VPN + ] + + const candidates: Array<{ interface: string; address: string; priority: number }> = [] + + for (const [name, ifaces] of Object.entries(interfaces)) { + for (const iface of ifaces || []) { + if (iface.family === 'IPv4' && !iface.internal) { + // 计算接口优先级 + let priority = 999 // 默认最低优先级 + for (let i = 0; i < interfacePriority.length; i++) { + if (interfacePriority[i].test(name)) { + priority = i + break + } + } + + candidates.push({ + interface: name, + address: iface.address, + priority + }) + } + } + } + + if (candidates.length === 0) { + logger.warn('无法获取局域网 IP,使用默认 IP: 127.0.0.1') + return '127.0.0.1' + } + + // 按优先级排序,选择优先级最高的 + candidates.sort((a, b) => a.priority - b.priority) + const best = candidates[0] + + logger.info(`获取局域网 IP: ${best.address} (interface: ${best.interface})`) + return best.address + } + + public start = async (): Promise<{ success: boolean; port?: number; error?: string }> => { + if (this.isStarted && this.io) { + return { success: true, port: this.port } + } + + try { + this.io = new Server(this.port, { + cors: { + origin: '*', + methods: ['GET', 'POST'] + }, + transports: ['websocket', 'polling'], + allowEIO3: true, + pingTimeout: 60000, + pingInterval: 25000 + }) + + this.io.on('connection', (socket: Socket) => { + this.connectedClients.add(socket.id) + + const mainWindow = windowService.getMainWindow() + if (!mainWindow) { + logger.error('Main window is null, cannot send connection event') + } else { + mainWindow.webContents.send('websocket-client-connected', { + connected: true, + clientId: socket.id + }) + logger.info(`Connection event sent to renderer, total clients: ${this.connectedClients.size}`) + } + + socket.on('message', (data) => { + logger.info('Received message from mobile:', data) + mainWindow?.webContents.send('websocket-message-received', data) + socket.emit('message_received', { success: true }) + }) + + socket.on('disconnect', () => { + logger.info(`Client disconnected: ${socket.id}`) + this.connectedClients.delete(socket.id) + + if (this.connectedClients.size === 0) { + mainWindow?.webContents.send('websocket-client-connected', { + connected: false, + clientId: socket.id + }) + } + }) + }) + + // Engine 层面的事件监听 + this.io.engine.on('connection_error', (err) => { + logger.error('Engine connection error:', err) + }) + + this.io.engine.on('connection', (rawSocket) => { + const remoteAddr = rawSocket.request.connection.remoteAddress + logger.info(`[Engine] Raw connection from: ${remoteAddr}`) + logger.info(`[Engine] Transport: ${rawSocket.transport.name}`) + + rawSocket.on('packet', (packet: { type: string; data?: any }) => { + logger.info( + `[Engine] ← Packet from ${remoteAddr}: type="${packet.type}"`, + packet.data ? { data: packet.data } : {} + ) + }) + + rawSocket.on('packetCreate', (packet: { type: string; data?: any }) => { + logger.info(`[Engine] → Packet to ${remoteAddr}: type="${packet.type}"`) + }) + + rawSocket.on('close', (reason: string) => { + logger.warn(`[Engine] Connection closed from ${remoteAddr}, reason: ${reason}`) + }) + + rawSocket.on('error', (error: Error) => { + logger.error(`[Engine] Connection error from ${remoteAddr}:`, error) + }) + }) + + // Socket.IO 握手失败监听 + this.io.on('connection_error', (err) => { + logger.error('[Socket.IO] Connection error during handshake:', err) + }) + + this.isStarted = true + logger.info(`WebSocket server started on port ${this.port}`) + + return { success: true, port: this.port } + } catch (error) { + logger.error('Failed to start WebSocket server:', error as Error) + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } + } + } + + public stop = async (): Promise<{ success: boolean }> => { + if (!this.isStarted || !this.io) { + return { success: true } + } + + try { + await new Promise((resolve) => { + this.io!.close(() => { + resolve() + }) + }) + + this.io = null + this.isStarted = false + this.connectedClients.clear() + logger.info('WebSocket server stopped') + + return { success: true } + } catch (error) { + logger.error('Failed to stop WebSocket server:', error as Error) + return { success: false } + } + } + + public getStatus = async (): Promise<{ + isRunning: boolean + port?: number + ip?: string + clientConnected: boolean + }> => { + return { + isRunning: this.isStarted, + port: this.isStarted ? this.port : undefined, + ip: this.isStarted ? this.getLocalIpAddress() : undefined, + clientConnected: this.connectedClients.size > 0 + } + } + + public getAllCandidates = async (): Promise< + Array<{ + host: string + interface: string + priority: number + }> + > => { + const interfaces = networkInterfaces() + + // 按优先级排序的网络接口名称模式 + const interfacePriority = [ + // macOS: 以太网/Wi-Fi 优先 + /^en[0-9]+$/, // en0, en1 (以太网/Wi-Fi) + /^(en|eth)[0-9]+$/, // 以太网接口 + /^wlan[0-9]+$/, // 无线接口 + // Windows: 以太网/Wi-Fi 优先 + /^(Ethernet|Wi-Fi|Local Area Connection)/, + /^(Wi-Fi|无线网络连接)/, + // Linux: 以太网/Wi-Fi 优先 + /^(eth|enp|wlp|wlan)[0-9]+/, + // 虚拟化接口(低优先级) + /^bridge[0-9]+$/, // Docker bridge + /^veth[0-9]+$/, // Docker veth + /^docker[0-9]+/, // Docker interfaces + /^br-[0-9a-f]+/, // Docker bridge + /^vmnet[0-9]+$/, // VMware + /^vboxnet[0-9]+$/, // VirtualBox + // VPN 隧道接口(低优先级) + /^utun[0-9]+$/, // macOS VPN + /^tun[0-9]+$/, // Linux/Unix VPN + /^tap[0-9]+$/, // TAP interfaces + /^tailscale[0-9]*$/, // Tailscale VPN + /^wg[0-9]+$/ // WireGuard VPN + ] + + const candidates: Array<{ host: string; interface: string; priority: number }> = [] + + for (const [name, ifaces] of Object.entries(interfaces)) { + for (const iface of ifaces || []) { + if (iface.family === 'IPv4' && !iface.internal) { + // 计算接口优先级 + let priority = 999 // 默认最低优先级 + for (let i = 0; i < interfacePriority.length; i++) { + if (interfacePriority[i].test(name)) { + priority = i + break + } + } + + candidates.push({ + host: iface.address, + interface: name, + priority + }) + + logger.debug(`Found interface: ${name} -> ${iface.address} (priority: ${priority})`) + } + } + } + + // 按优先级排序返回 + candidates.sort((a, b) => a.priority - b.priority) + logger.info( + `Found ${candidates.length} IP candidates: ${candidates.map((c) => `${c.host}(${c.interface})`).join(', ')}` + ) + return candidates + } + + public sendFile = async ( + _: Electron.IpcMainInvokeEvent, + filePath: string + ): Promise<{ success: boolean; error?: string }> => { + if (!this.isStarted || !this.io) { + const errorMsg = 'WebSocket server is not running.' + logger.error(errorMsg) + return { success: false, error: errorMsg } + } + + if (this.connectedClients.size === 0) { + const errorMsg = 'No client connected.' + logger.error(errorMsg) + return { success: false, error: errorMsg } + } + + const mainWindow = windowService.getMainWindow() + + return new Promise((resolve, reject) => { + const stats = fs.statSync(filePath) + const totalSize = stats.size + const filename = path.basename(filePath) + const stream = fs.createReadStream(filePath) + let bytesSent = 0 + const startTime = Date.now() + + logger.info(`Starting file transfer: ${filename} (${this.formatFileSize(totalSize)})`) + + // 向客户端发送文件开始的信号,包含文件名和总大小 + this.io!.emit('zip-file-start', { filename, totalSize }) + + stream.on('data', (chunk) => { + bytesSent += chunk.length + const progress = (bytesSent / totalSize) * 100 + + // 向客户端发送文件块 + this.io!.emit('zip-file-chunk', chunk) + + // 向渲染进程发送进度更新 + mainWindow?.webContents.send('file-send-progress', { progress }) + + // 每10%记录一次进度 + if (Math.floor(progress) % 10 === 0) { + const elapsed = (Date.now() - startTime) / 1000 + const speed = elapsed > 0 ? bytesSent / elapsed : 0 + logger.info(`Transfer progress: ${Math.floor(progress)}% (${this.formatFileSize(speed)}/s)`) + } + }) + + stream.on('end', () => { + const totalTime = (Date.now() - startTime) / 1000 + const avgSpeed = totalTime > 0 ? totalSize / totalTime : 0 + logger.info( + `File transfer completed: ${filename} in ${totalTime.toFixed(1)}s (${this.formatFileSize(avgSpeed)}/s)` + ) + + // 确保发送100%的进度 + mainWindow?.webContents.send('file-send-progress', { progress: 100 }) + // 向客户端发送文件结束的信号 + this.io!.emit('zip-file-end') + resolve({ success: true }) + }) + + stream.on('error', (error) => { + logger.error(`File transfer failed: ${filename}`, error) + reject({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }) + }) + }) + } + + private formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } +} + +export default new WebSocketService() diff --git a/src/preload/index.ts b/src/preload/index.ts index fa39ac6987..30536b8a3f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -550,6 +550,13 @@ const api = { ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_ReadContent, sourcePath), writeContent: (options: WritePluginContentOptions): Promise> => ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_WriteContent, options) + }, + webSocket: { + start: () => ipcRenderer.invoke(IpcChannel.WebSocket_Start), + stop: () => ipcRenderer.invoke(IpcChannel.WebSocket_Stop), + status: () => ipcRenderer.invoke(IpcChannel.WebSocket_Status), + sendFile: (filePath: string) => ipcRenderer.invoke(IpcChannel.WebSocket_SendFile, filePath), + getAllCandidates: () => ipcRenderer.invoke(IpcChannel.WebSocket_GetAllCandidates) } } diff --git a/src/renderer/src/components/Popups/ExportToPhoneLanPopup.tsx b/src/renderer/src/components/Popups/ExportToPhoneLanPopup.tsx new file mode 100644 index 0000000000..d826ddd01d --- /dev/null +++ b/src/renderer/src/components/Popups/ExportToPhoneLanPopup.tsx @@ -0,0 +1,591 @@ +import { Button } from '@heroui/button' +import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@heroui/modal' +import { Progress } from '@heroui/progress' +import { Spinner } from '@heroui/spinner' +import { loggerService } from '@logger' +import { SettingHelpText, SettingRow } from '@renderer/pages/settings' +import { QRCodeSVG } from 'qrcode.react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { TopView } from '../TopView' + +const logger = loggerService.withContext('ExportToPhoneLanPopup') + +interface Props { + resolve: (data: any) => void +} + +type ConnectionPhase = 'initializing' | 'waiting_qr_scan' | 'connecting' | 'connected' | 'disconnected' | 'error' +type TransferPhase = 'idle' | 'preparing' | 'sending' | 'completed' | 'error' + +const LoadingQRCode: React.FC = () => { + const { t } = useTranslation() + return ( +
+ + + {t('settings.data.export_to_phone.lan.generating_qr')} + +
+ ) +} + +const ScanQRCode: React.FC<{ qrCodeValue: string }> = ({ qrCodeValue }) => { + const { t } = useTranslation() + return ( +
+ + + {t('settings.data.export_to_phone.lan.scan_qr')} + +
+ ) +} + +const ConnectingAnimation: React.FC = () => { + const { t } = useTranslation() + return ( +
+
+ + + {t('settings.data.export_to_phone.lan.status.connecting')} + +
+
+ ) +} + +const ConnectedDisplay: React.FC = () => { + const { t } = useTranslation() + return ( +
+
+ 📱 + + {t('settings.data.export_to_phone.lan.connected')} + +
+
+ ) +} + +const ErrorQRCode: React.FC<{ error: string | null }> = ({ error }) => { + const { t } = useTranslation() + return ( +
+ ⚠️ + + {t('settings.data.export_to_phone.lan.connection_failed')} + + {error && {error}} +
+ ) +} + +const PopupContainer: React.FC = ({ resolve }) => { + const [isOpen, setIsOpen] = useState(true) + const [connectionPhase, setConnectionPhase] = useState('initializing') + const [transferPhase, setTransferPhase] = useState('idle') + const [qrCodeValue, setQrCodeValue] = useState('') + const [selectedFolderPath, setSelectedFolderPath] = useState(null) + const [sendProgress, setSendProgress] = useState(0) + const [error, setError] = useState(null) + const [showCloseConfirm, setShowCloseConfirm] = useState(false) + const [autoCloseCountdown, setAutoCloseCountdown] = useState(null) + + const { t } = useTranslation() + + // 派生状态 + const isConnected = connectionPhase === 'connected' + const canSend = isConnected && selectedFolderPath && transferPhase === 'idle' + const isSending = transferPhase === 'preparing' || transferPhase === 'sending' + + // 状态文本映射 + const connectionStatusText = useMemo(() => { + const statusMap = { + initializing: t('settings.data.export_to_phone.lan.status.initializing'), + waiting_qr_scan: t('settings.data.export_to_phone.lan.status.waiting_qr_scan'), + connecting: t('settings.data.export_to_phone.lan.status.connecting'), + connected: t('settings.data.export_to_phone.lan.status.connected'), + disconnected: t('settings.data.export_to_phone.lan.status.disconnected'), + error: t('settings.data.export_to_phone.lan.status.error') + } + return statusMap[connectionPhase] + }, [connectionPhase, t]) + + const transferStatusText = useMemo(() => { + const statusMap = { + idle: '', + preparing: t('settings.data.export_to_phone.lan.status.preparing'), + sending: t('settings.data.export_to_phone.lan.status.sending'), + completed: t('settings.data.export_to_phone.lan.status.completed'), + error: t('settings.data.export_to_phone.lan.status.error') + } + return statusMap[transferPhase] + }, [transferPhase, t]) + + // 状态样式映射 + const connectionStatusStyles = useMemo(() => { + const styleMap = { + initializing: { + bg: 'var(--color-background-mute)', + border: 'var(--color-border-mute)' + }, + waiting_qr_scan: { + bg: 'var(--color-primary-mute)', + border: 'var(--color-primary-soft)' + }, + connecting: { bg: 'var(--color-status-warning)', border: 'var(--color-status-warning)' }, + connected: { + bg: 'var(--color-status-success)', + border: 'var(--color-status-success)' + }, + disconnected: { bg: 'var(--color-error)', border: 'var(--color-error)' }, + error: { bg: 'var(--color-error)', border: 'var(--color-error)' } + } + return styleMap[connectionPhase] + }, [connectionPhase]) + + const initWebSocket = useCallback(async () => { + try { + setConnectionPhase('initializing') + await window.api.webSocket.start() + const { port, ip } = await window.api.webSocket.status() + + if (ip && port) { + const candidates = await window.api.webSocket.getAllCandidates() + const connectionInfo = { + type: 'cherry-studio-app', + candidates, + selectedHost: ip, + port, + timestamp: Date.now() + } + setQrCodeValue(JSON.stringify(connectionInfo)) + setConnectionPhase('waiting_qr_scan') + logger.info(`QR code generated: ${ip}:${port} with ${candidates.length} IP candidates`) + } else { + setError(t('settings.data.export_to_phone.lan.error.no_ip')) + setConnectionPhase('error') + } + } catch (error) { + setError( + `${t('settings.data.export_to_phone.lan.error.init_failed')}: ${error instanceof Error ? error.message : ''}` + ) + setConnectionPhase('error') + logger.error('Failed to initialize WebSocket:', error as Error) + } + }, [t]) + + const handleClientConnected = useCallback((_event: any, data: { connected: boolean }) => { + logger.info(`Client connection status: ${data.connected ? 'connected' : 'disconnected'}`) + if (data.connected) { + setConnectionPhase('connected') + setError(null) + } else { + setConnectionPhase('disconnected') + } + }, []) + + const handleMessageReceived = useCallback((_event: any, data: any) => { + logger.info(`Received message from mobile: ${JSON.stringify(data)}`) + }, []) + + const handleSendProgress = useCallback( + (_event: any, data: { progress: number }) => { + const progress = data.progress + setSendProgress(progress) + + if (transferPhase === 'preparing' && progress > 0) { + setTransferPhase('sending') + } + + if (progress >= 100) { + setTransferPhase('completed') + // 启动 3 秒倒计时自动关闭 + setAutoCloseCountdown(3) + } + }, + [transferPhase] + ) + + const handleSelectZip = useCallback(async () => { + const result = await window.api.file.select() + if (result) { + setSelectedFolderPath(result[0].path) + } + }, []) + + const handleSendZip = useCallback(async () => { + if (!selectedFolderPath) { + setError(t('settings.data.export_to_phone.lan.error.no_file')) + return + } + + setTransferPhase('preparing') + setError(null) + setSendProgress(0) + + try { + logger.info(`Starting file transfer: ${selectedFolderPath}`) + await window.api.webSocket.sendFile(selectedFolderPath) + } catch (error) { + setError( + `${t('settings.data.export_to_phone.lan.error.send_failed')}: ${error instanceof Error ? error.message : ''}` + ) + setTransferPhase('error') + logger.error('Failed to send file:', error as Error) + } + }, [selectedFolderPath, t]) + + // 尝试关闭弹窗 - 如果正在传输则显示确认 + const handleCancel = useCallback(() => { + if (isSending) { + setShowCloseConfirm(true) + } else { + setIsOpen(false) + } + }, [isSending]) + + // 确认强制关闭 + const handleForceClose = useCallback(() => { + logger.info('Force closing popup during transfer') + setIsOpen(false) + }, []) + + // 取消关闭确认 + const handleCancelClose = useCallback(() => { + setShowCloseConfirm(false) + }, []) + + // 清理并关闭 + const handleClose = useCallback(async () => { + try { + // 主动断开 WebSocket 连接 + if (isConnected || connectionPhase !== 'disconnected') { + logger.info('Closing popup, stopping WebSocket') + await window.api.webSocket.stop() + } + } catch (error) { + logger.error('Failed to stop WebSocket on close:', error as Error) + } + resolve({}) + }, [resolve, isConnected, connectionPhase]) + + useEffect(() => { + initWebSocket() + + const removeClientConnectedListener = window.electron.ipcRenderer.on( + 'websocket-client-connected', + handleClientConnected + ) + const removeMessageReceivedListener = window.electron.ipcRenderer.on( + 'websocket-message-received', + handleMessageReceived + ) + const removeSendProgressListener = window.electron.ipcRenderer.on('file-send-progress', handleSendProgress) + + return () => { + removeClientConnectedListener() + removeMessageReceivedListener() + removeSendProgressListener() + window.api.webSocket.stop() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // 自动关闭倒计时 + useEffect(() => { + if (autoCloseCountdown === null) return + + if (autoCloseCountdown <= 0) { + logger.debug('Auto-closing popup after transfer completion') + setIsOpen(false) + return + } + + const timer = setTimeout(() => { + setAutoCloseCountdown(autoCloseCountdown - 1) + }, 1000) + + return () => clearTimeout(timer) + }, [autoCloseCountdown]) + + // 状态指示器组件 + const StatusIndicator = useCallback( + () => ( +
+ {connectionStatusText} +
+ ), + [connectionStatusStyles, connectionStatusText] + ) + + // 二维码显示组件 - 使用显式条件渲染以避免类型不匹配 + const QRCodeDisplay = useCallback(() => { + switch (connectionPhase) { + case 'waiting_qr_scan': + case 'disconnected': + return + case 'initializing': + return + case 'connecting': + return + case 'connected': + return + case 'error': + return + default: + return null + } + }, [connectionPhase, qrCodeValue, error]) + + // 传输进度组件 + const TransferProgress = useCallback(() => { + if (!isSending && transferPhase !== 'completed') return null + + return ( +
+
+
+ + {t('settings.data.export_to_phone.lan.transfer_progress')} + + + {transferPhase === 'completed' ? '✅ ' + t('common.completed') : `${Math.round(sendProgress)}%`} + +
+ + +
+
+ ) + }, [isSending, transferPhase, sendProgress, t]) + + const AutoCloseCountdown = useCallback(() => { + if (transferPhase !== 'completed' || autoCloseCountdown === null || autoCloseCountdown <= 0) return null + + return ( +
+ {t('settings.data.export_to_phone.lan.auto_close_tip', { seconds: autoCloseCountdown })} +
+ ) + }, [transferPhase, autoCloseCountdown, t]) + + // 错误显示组件 + const ErrorDisplay = useCallback(() => { + if (!error || transferPhase !== 'error') return null + + return ( +
+ ❌ {error} +
+ ) + }, [error, transferPhase]) + + return ( + { + if (!open) { + handleCancel() + } + }} + isDismissable={false} + isKeyboardDismissDisabled={false} + placement="center" + onClose={handleClose}> + + {() => ( + <> + {t('settings.data.export_to_phone.lan.title')} + + + + + + +
{t('settings.data.export_to_phone.lan.content')}
+
+ + + + + + +
+ + +
+
+ + + {selectedFolderPath || t('settings.data.export_to_phone.lan.noZipSelected')} + + + + + +
+ + {showCloseConfirm && ( + +
+
+ ⚠️ + + {t('settings.data.export_to_phone.lan.confirm_close_title')} + +
+ + {t('settings.data.export_to_phone.lan.confirm_close_message')} + +
+ + +
+
+
+ )} + + )} +
+
+ ) +} + +const TopViewKey = 'ExportToPhoneLanPopup' + +export default class ExportToPhoneLanPopup { + static topviewId = 0 + static hide() { + TopView.hide(TopViewKey) + } + static show() { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + TopView.hide(TopViewKey) + }} + />, + TopViewKey + ) + }) + } +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 98da54794f..9eaab84076 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1047,6 +1047,7 @@ "clear": "Clear", "close": "Close", "collapse": "Collapse", + "completed": "Completed", "confirm": "Confirm", "copied": "Copied", "copy": "Copy", @@ -3038,6 +3039,46 @@ "title": "Export Menu Settings", "yuque": "Export to Yuque" }, + "export_to_phone": { + "confirm": { + "button": "Select backup file" + }, + "content": "Export some data, including chat logs and settings. Please note that the backup process may take some time. Thank you for your patience.", + "lan": { + "auto_close_tip": "Auto-closing in {{seconds}} seconds...", + "confirm_close_message": "File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", + "confirm_close_title": "Confirm Close", + "connected": "Connected", + "connection_failed": "Connection failed", + "content": "Please ensure your computer and phone are on the same network for LAN transfer. Open the Cherry Studio App to scan this QR code.", + "error": { + "init_failed": "Initialization failed", + "no_file": "No file selected", + "no_ip": "Unable to get IP address", + "send_failed": "Failed to send file" + }, + "force_close": "Force Close", + "generating_qr": "Generating QR code...", + "noZipSelected": "No compressed file selected", + "scan_qr": "Please scan QR code with your phone", + "selectZip": "Select a compressed file", + "sendZip": "Begin data recovery", + "status": { + "completed": "Transfer completed", + "connected": "Connected", + "connecting": "Connecting...", + "disconnected": "Disconnected", + "error": "Connection error", + "initializing": "Initializing connection...", + "preparing": "Preparing transfer...", + "sending": "Transferring {{progress}}%", + "waiting_qr_scan": "Please scan QR code to connect" + }, + "title": "LAN transmission", + "transfer_progress": "Transfer progress" + }, + "title": "Export to phone" + }, "hour_interval_one": "{{count}} hour", "hour_interval_other": "{{count}} hours", "joplin": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index af6a3c1472..c3f486c0b2 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1047,6 +1047,7 @@ "clear": "清除", "close": "关闭", "collapse": "折叠", + "completed": "完成", "confirm": "确认", "copied": "已复制", "copy": "复制", @@ -3038,6 +3039,46 @@ "title": "导出菜单设置", "yuque": "导出到语雀" }, + "export_to_phone": { + "confirm": { + "button": "选择备份文件" + }, + "content": "导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。", + "lan": { + "auto_close_tip": "{{seconds}} 秒后自动关闭...", + "confirm_close_message": "文件正在传输中,关闭将中断传输。确定要强制关闭吗?", + "confirm_close_title": "确认关闭", + "connected": "连接成功", + "connection_failed": "连接失败", + "content": "请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。", + "error": { + "init_failed": "初始化失败", + "no_file": "未选择文件", + "no_ip": "无法获取 IP 地址", + "send_failed": "发送文件失败" + }, + "force_close": "强制关闭", + "generating_qr": "正在生成二维码...", + "noZipSelected": "未选择压缩文件", + "scan_qr": "请使用手机扫码连接", + "selectZip": "选择压缩文件", + "sendZip": "开始恢复数据", + "status": { + "completed": "传输完成", + "connected": "连接成功", + "connecting": "正在连接中...", + "disconnected": "连接已断开", + "error": "连接出错", + "initializing": "正在初始化连接...", + "preparing": "准备传输中...", + "sending": "传输中 {{progress}}%", + "waiting_qr_scan": "请扫描二维码连接" + }, + "title": "局域网传输", + "transfer_progress": "传输进度" + }, + "title": "导出至手机" + }, "hour_interval_one": "{{count}} 小时", "hour_interval_other": "{{count}} 小时", "joplin": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 8bd8ff17e7..58174e6944 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1047,6 +1047,7 @@ "clear": "清除", "close": "關閉", "collapse": "折疊", + "completed": "[to be translated]:Completed", "confirm": "確認", "copied": "已複製", "copy": "複製", @@ -3038,6 +3039,46 @@ "title": "匯出選單設定", "yuque": "匯出到語雀" }, + "export_to_phone": { + "confirm": { + "button": "選擇備份檔案" + }, + "content": "匯出部分數據,包括聊天記錄、設定。請注意,備份過程可能需要一些時間,感謝您的耐心等候。", + "lan": { + "auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...", + "confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", + "confirm_close_title": "[to be translated]:Confirm Close", + "connected": "[to be translated]:Connected", + "connection_failed": "[to be translated]:Connection failed", + "content": "請確保電腦和手機處於同一網路以使用區域網路傳輸。請打開 Cherry Studio App 掃描此 QR 碼。", + "error": { + "init_failed": "[to be translated]:Initialization failed", + "no_file": "[to be translated]:No file selected", + "no_ip": "[to be translated]:Unable to get IP address", + "send_failed": "[to be translated]:Failed to send file" + }, + "force_close": "[to be translated]:Force Close", + "generating_qr": "[to be translated]:Generating QR code...", + "noZipSelected": "未選取壓縮檔案", + "scan_qr": "[to be translated]:Please scan QR code with your phone", + "selectZip": "選擇壓縮檔案", + "sendZip": "開始恢復資料", + "status": { + "completed": "[to be translated]:Transfer completed", + "connected": "[to be translated]:Connected", + "connecting": "[to be translated]:Connecting...", + "disconnected": "[to be translated]:Disconnected", + "error": "[to be translated]:Connection error", + "initializing": "[to be translated]:Initializing connection...", + "preparing": "[to be translated]:Preparing transfer...", + "sending": "[to be translated]:Transferring {{progress}}%", + "waiting_qr_scan": "[to be translated]:Please scan QR code to connect" + }, + "title": "區域網路傳輸", + "transfer_progress": "[to be translated]:Transfer progress" + }, + "title": "匯出手機" + }, "hour_interval_one": "{{count}} 小時", "hour_interval_other": "{{count}} 小時", "joplin": { diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index 28d03c064b..49a68809e0 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -1047,6 +1047,7 @@ "clear": "Löschen", "close": "Schließen", "collapse": "Einklappen", + "completed": "[to be translated]:Completed", "confirm": "Bestätigen", "copied": "Kopiert", "copy": "Kopieren", @@ -3038,6 +3039,46 @@ "title": "Export-Menü-Einstellungen", "yuque": "Nach Yuque exportieren" }, + "export_to_phone": { + "confirm": { + "button": "[to be translated]:Select backup file" + }, + "content": "[to be translated]:Export some data, including chat logs and settings. Please note that the backup process may take some time. Thank you for your patience.", + "lan": { + "auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...", + "confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", + "confirm_close_title": "[to be translated]:Confirm Close", + "connected": "[to be translated]:Connected", + "connection_failed": "[to be translated]:Connection failed", + "content": "[to be translated]:Please ensure your computer and phone are on the same network for LAN transfer. Open the Cherry Studio App to scan this QR code.", + "error": { + "init_failed": "[to be translated]:Initialization failed", + "no_file": "[to be translated]:No file selected", + "no_ip": "[to be translated]:Unable to get IP address", + "send_failed": "[to be translated]:Failed to send file" + }, + "force_close": "[to be translated]:Force Close", + "generating_qr": "[to be translated]:Generating QR code...", + "noZipSelected": "[to be translated]:No compressed file selected", + "scan_qr": "[to be translated]:Please scan QR code with your phone", + "selectZip": "[to be translated]:Select a compressed file", + "sendZip": "[to be translated]:Begin data recovery", + "status": { + "completed": "[to be translated]:Transfer completed", + "connected": "[to be translated]:Connected", + "connecting": "[to be translated]:Connecting...", + "disconnected": "[to be translated]:Disconnected", + "error": "[to be translated]:Connection error", + "initializing": "[to be translated]:Initializing connection...", + "preparing": "[to be translated]:Preparing transfer...", + "sending": "[to be translated]:Transferring {{progress}}%", + "waiting_qr_scan": "[to be translated]:Please scan QR code to connect" + }, + "title": "[to be translated]:LAN transmission", + "transfer_progress": "[to be translated]:Transfer progress" + }, + "title": "[to be translated]:Export to phone" + }, "hour_interval_one": "{{count}} Stunde", "hour_interval_other": "{{count}} Stunden", "joplin": { diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index ddea9edff1..5a38b80511 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -1047,6 +1047,7 @@ "clear": "Καθαρισμός", "close": "Κλείσιμο", "collapse": "Σύμπτυξη", + "completed": "[to be translated]:Completed", "confirm": "Επιβεβαίωση", "copied": "Αντιγράφηκε", "copy": "Αντιγραφή", @@ -3038,6 +3039,46 @@ "title": "Εξαγωγή ρυθμίσεων μενού", "yuque": "Εξαγωγή στο Yuque" }, + "export_to_phone": { + "confirm": { + "button": "[to be translated]:选择备份文件" + }, + "content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。", + "lan": { + "auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...", + "confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", + "confirm_close_title": "[to be translated]:Confirm Close", + "connected": "[to be translated]:Connected", + "connection_failed": "[to be translated]:Connection failed", + "content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。", + "error": { + "init_failed": "[to be translated]:Initialization failed", + "no_file": "[to be translated]:No file selected", + "no_ip": "[to be translated]:Unable to get IP address", + "send_failed": "[to be translated]:Failed to send file" + }, + "force_close": "[to be translated]:Force Close", + "generating_qr": "[to be translated]:Generating QR code...", + "noZipSelected": "[to be translated]:未选择压缩文件", + "scan_qr": "[to be translated]:Please scan QR code with your phone", + "selectZip": "[to be translated]:选择压缩文件", + "sendZip": "[to be translated]:开始恢复数据", + "status": { + "completed": "[to be translated]:Transfer completed", + "connected": "[to be translated]:Connected", + "connecting": "[to be translated]:Connecting...", + "disconnected": "[to be translated]:Disconnected", + "error": "[to be translated]:Connection error", + "initializing": "[to be translated]:Initializing connection...", + "preparing": "[to be translated]:Preparing transfer...", + "sending": "[to be translated]:Transferring {{progress}}%", + "waiting_qr_scan": "[to be translated]:Please scan QR code to connect" + }, + "title": "[to be translated]:局域网传输", + "transfer_progress": "[to be translated]:Transfer progress" + }, + "title": "[to be translated]:导出至手机" + }, "hour_interval_one": "{{count}} ώρα", "hour_interval_other": "{{count}} ώρες", "joplin": { diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 99e18afc31..b04258151d 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -1047,6 +1047,7 @@ "clear": "Limpiar", "close": "Cerrar", "collapse": "Colapsar", + "completed": "[to be translated]:Completed", "confirm": "Confirmar", "copied": "Copiado", "copy": "Copiar", @@ -3038,6 +3039,46 @@ "title": "Exportar configuración del menú", "yuque": "Exportar a Yuque" }, + "export_to_phone": { + "confirm": { + "button": "[to be translated]:选择备份文件" + }, + "content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。", + "lan": { + "auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...", + "confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", + "confirm_close_title": "[to be translated]:Confirm Close", + "connected": "[to be translated]:Connected", + "connection_failed": "[to be translated]:Connection failed", + "content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。", + "error": { + "init_failed": "[to be translated]:Initialization failed", + "no_file": "[to be translated]:No file selected", + "no_ip": "[to be translated]:Unable to get IP address", + "send_failed": "[to be translated]:Failed to send file" + }, + "force_close": "[to be translated]:Force Close", + "generating_qr": "[to be translated]:Generating QR code...", + "noZipSelected": "[to be translated]:未选择压缩文件", + "scan_qr": "[to be translated]:Please scan QR code with your phone", + "selectZip": "[to be translated]:选择压缩文件", + "sendZip": "[to be translated]:开始恢复数据", + "status": { + "completed": "[to be translated]:Transfer completed", + "connected": "[to be translated]:Connected", + "connecting": "[to be translated]:Connecting...", + "disconnected": "[to be translated]:Disconnected", + "error": "[to be translated]:Connection error", + "initializing": "[to be translated]:Initializing connection...", + "preparing": "[to be translated]:Preparing transfer...", + "sending": "[to be translated]:Transferring {{progress}}%", + "waiting_qr_scan": "[to be translated]:Please scan QR code to connect" + }, + "title": "[to be translated]:局域网传输", + "transfer_progress": "[to be translated]:Transfer progress" + }, + "title": "[to be translated]:导出至手机" + }, "hour_interval_one": "{{count}} hora", "hour_interval_other": "{{count}} horas", "joplin": { diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 06c71c60ab..b9c68d9a8e 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -1047,6 +1047,7 @@ "clear": "Effacer", "close": "Fermer", "collapse": "Réduire", + "completed": "[to be translated]:Completed", "confirm": "Confirmer", "copied": "Copié", "copy": "Copier", @@ -3038,6 +3039,46 @@ "title": "Exporter les paramètres du menu", "yuque": "Exporter vers Yuque" }, + "export_to_phone": { + "confirm": { + "button": "[to be translated]:选择备份文件" + }, + "content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。", + "lan": { + "auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...", + "confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", + "confirm_close_title": "[to be translated]:Confirm Close", + "connected": "[to be translated]:Connected", + "connection_failed": "[to be translated]:Connection failed", + "content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。", + "error": { + "init_failed": "[to be translated]:Initialization failed", + "no_file": "[to be translated]:No file selected", + "no_ip": "[to be translated]:Unable to get IP address", + "send_failed": "[to be translated]:Failed to send file" + }, + "force_close": "[to be translated]:Force Close", + "generating_qr": "[to be translated]:Generating QR code...", + "noZipSelected": "[to be translated]:未选择压缩文件", + "scan_qr": "[to be translated]:Please scan QR code with your phone", + "selectZip": "[to be translated]:选择压缩文件", + "sendZip": "[to be translated]:开始恢复数据", + "status": { + "completed": "[to be translated]:Transfer completed", + "connected": "[to be translated]:Connected", + "connecting": "[to be translated]:Connecting...", + "disconnected": "[to be translated]:Disconnected", + "error": "[to be translated]:Connection error", + "initializing": "[to be translated]:Initializing connection...", + "preparing": "[to be translated]:Preparing transfer...", + "sending": "[to be translated]:Transferring {{progress}}%", + "waiting_qr_scan": "[to be translated]:Please scan QR code to connect" + }, + "title": "[to be translated]:局域网传输", + "transfer_progress": "[to be translated]:Transfer progress" + }, + "title": "[to be translated]:导出至手机" + }, "hour_interval_one": "{{count}} heure", "hour_interval_other": "{{count}} heures", "joplin": { diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 6bd095a2bf..15b2fb111b 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -1047,6 +1047,7 @@ "clear": "クリア", "close": "閉じる", "collapse": "折りたたむ", + "completed": "[to be translated]:Completed", "confirm": "確認", "copied": "コピーされました", "copy": "コピー", @@ -3038,6 +3039,46 @@ "title": "エクスポートメニュー設定", "yuque": "語雀にエクスポート" }, + "export_to_phone": { + "confirm": { + "button": "[to be translated]:选择备份文件" + }, + "content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。", + "lan": { + "auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...", + "confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", + "confirm_close_title": "[to be translated]:Confirm Close", + "connected": "[to be translated]:Connected", + "connection_failed": "[to be translated]:Connection failed", + "content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。", + "error": { + "init_failed": "[to be translated]:Initialization failed", + "no_file": "[to be translated]:No file selected", + "no_ip": "[to be translated]:Unable to get IP address", + "send_failed": "[to be translated]:Failed to send file" + }, + "force_close": "[to be translated]:Force Close", + "generating_qr": "[to be translated]:Generating QR code...", + "noZipSelected": "[to be translated]:未选择压缩文件", + "scan_qr": "[to be translated]:Please scan QR code with your phone", + "selectZip": "[to be translated]:选择压缩文件", + "sendZip": "[to be translated]:开始恢复数据", + "status": { + "completed": "[to be translated]:Transfer completed", + "connected": "[to be translated]:Connected", + "connecting": "[to be translated]:Connecting...", + "disconnected": "[to be translated]:Disconnected", + "error": "[to be translated]:Connection error", + "initializing": "[to be translated]:Initializing connection...", + "preparing": "[to be translated]:Preparing transfer...", + "sending": "[to be translated]:Transferring {{progress}}%", + "waiting_qr_scan": "[to be translated]:Please scan QR code to connect" + }, + "title": "[to be translated]:局域网传输", + "transfer_progress": "[to be translated]:Transfer progress" + }, + "title": "[to be translated]:导出至手机" + }, "hour_interval_one": "{{count}} 時間", "hour_interval_other": "{{count}} 時間", "joplin": { diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 3ff8c970be..0253dba3cd 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -1047,6 +1047,7 @@ "clear": "Limpar", "close": "Fechar", "collapse": "Recolher", + "completed": "[to be translated]:Completed", "confirm": "Confirmar", "copied": "Copiado", "copy": "Copiar", @@ -3038,6 +3039,46 @@ "title": "Exportar Configurações do Menu", "yuque": "Exportar para Yuque" }, + "export_to_phone": { + "confirm": { + "button": "[to be translated]:选择备份文件" + }, + "content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。", + "lan": { + "auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...", + "confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", + "confirm_close_title": "[to be translated]:Confirm Close", + "connected": "[to be translated]:Connected", + "connection_failed": "[to be translated]:Connection failed", + "content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。", + "error": { + "init_failed": "[to be translated]:Initialization failed", + "no_file": "[to be translated]:No file selected", + "no_ip": "[to be translated]:Unable to get IP address", + "send_failed": "[to be translated]:Failed to send file" + }, + "force_close": "[to be translated]:Force Close", + "generating_qr": "[to be translated]:Generating QR code...", + "noZipSelected": "[to be translated]:未选择压缩文件", + "scan_qr": "[to be translated]:Please scan QR code with your phone", + "selectZip": "[to be translated]:选择压缩文件", + "sendZip": "[to be translated]:开始恢复数据", + "status": { + "completed": "[to be translated]:Transfer completed", + "connected": "[to be translated]:Connected", + "connecting": "[to be translated]:Connecting...", + "disconnected": "[to be translated]:Disconnected", + "error": "[to be translated]:Connection error", + "initializing": "[to be translated]:Initializing connection...", + "preparing": "[to be translated]:Preparing transfer...", + "sending": "[to be translated]:Transferring {{progress}}%", + "waiting_qr_scan": "[to be translated]:Please scan QR code to connect" + }, + "title": "[to be translated]:局域网传输", + "transfer_progress": "[to be translated]:Transfer progress" + }, + "title": "[to be translated]:导出至手机" + }, "hour_interval_one": "{{count}} hora", "hour_interval_other": "{{count}} horas", "joplin": { diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index f65048d6b2..7e31339a53 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -1047,6 +1047,7 @@ "clear": "Очистить", "close": "Закрыть", "collapse": "Свернуть", + "completed": "[to be translated]:Completed", "confirm": "Подтверждение", "copied": "Скопировано", "copy": "Копировать", @@ -3038,6 +3039,46 @@ "title": "Настройки меню экспорта", "yuque": "Экспорт в Yuque" }, + "export_to_phone": { + "confirm": { + "button": "[to be translated]:选择备份文件" + }, + "content": "[to be translated]:导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。", + "lan": { + "auto_close_tip": "[to be translated]:Auto-closing in {{seconds}} seconds...", + "confirm_close_message": "[to be translated]:File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", + "confirm_close_title": "[to be translated]:Confirm Close", + "connected": "[to be translated]:Connected", + "connection_failed": "[to be translated]:Connection failed", + "content": "[to be translated]:请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。", + "error": { + "init_failed": "[to be translated]:Initialization failed", + "no_file": "[to be translated]:No file selected", + "no_ip": "[to be translated]:Unable to get IP address", + "send_failed": "[to be translated]:Failed to send file" + }, + "force_close": "[to be translated]:Force Close", + "generating_qr": "[to be translated]:Generating QR code...", + "noZipSelected": "[to be translated]:未选择压缩文件", + "scan_qr": "[to be translated]:Please scan QR code with your phone", + "selectZip": "[to be translated]:选择压缩文件", + "sendZip": "[to be translated]:开始恢复数据", + "status": { + "completed": "[to be translated]:Transfer completed", + "connected": "[to be translated]:Connected", + "connecting": "[to be translated]:Connecting...", + "disconnected": "[to be translated]:Disconnected", + "error": "[to be translated]:Connection error", + "initializing": "[to be translated]:Initializing connection...", + "preparing": "[to be translated]:Preparing transfer...", + "sending": "[to be translated]:Transferring {{progress}}%", + "waiting_qr_scan": "[to be translated]:Please scan QR code to connect" + }, + "title": "[to be translated]:局域网传输", + "transfer_progress": "[to be translated]:Transfer progress" + }, + "title": "[to be translated]:导出至手机" + }, "hour_interval_one": "{{count}} час", "hour_interval_other": "{{count}} часов", "joplin": { diff --git a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx index 4b00993f60..aa6078b6b1 100644 --- a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx @@ -3,13 +3,17 @@ import { CloudSyncOutlined, FileSearchOutlined, LoadingOutlined, + WifiOutlined, YuqueOutlined } from '@ant-design/icons' +import { Button } from '@heroui/button' +import { Switch } from '@heroui/switch' import DividerWithText from '@renderer/components/DividerWithText' import { NutstoreIcon } from '@renderer/components/Icons/NutstoreIcons' import { HStack } from '@renderer/components/Layout' import ListItem from '@renderer/components/ListItem' import BackupPopup from '@renderer/components/Popups/BackupPopup' +import ExportToPhoneLanPopup from '@renderer/components/Popups/ExportToPhoneLanPopup' import RestorePopup from '@renderer/components/Popups/RestorePopup' import { useTheme } from '@renderer/context/ThemeProvider' import { useKnowledgeFiles } from '@renderer/hooks/useKnowledgeFiles' @@ -20,7 +24,7 @@ import { setSkipBackupFile as _setSkipBackupFile } from '@renderer/store/setting import { AppInfo } from '@renderer/types' import { formatFileSize } from '@renderer/utils' import { occupiedDirs } from '@shared/config/constant' -import { Button, Progress, Switch, Typography } from 'antd' +import { Progress, Typography } from 'antd' import { FileText, FolderCog, FolderInput, FolderOpen, SaveIcon, Sparkle } from 'lucide-react' import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -290,12 +294,16 @@ const DataSettings: FC = () => {
{ + defaultSelected={shouldCopyData} + onValueChange={(checked) => { shouldCopyData = checked }} - style={{ marginRight: '8px' }} - /> + size="sm"> + + {t('settings.data.app_data.copy_data_option')} + + + {t('settings.data.app_data.copy_data_option')} @@ -605,10 +613,10 @@ const DataSettings: FC = () => { {t('settings.general.backup.title')} - - @@ -616,11 +624,24 @@ const DataSettings: FC = () => { {t('settings.data.backup.skip_file_data_title')} - + {t('settings.data.backup.skip_file_data_help')} + + + {t('settings.data.export_to_phone.title')} + + + + {t('settings.data.data.title')} @@ -635,7 +656,9 @@ const DataSettings: FC = () => { handleOpenPath(appInfo?.appDataPath)} style={{ flexShrink: 0 }} /> - + @@ -648,7 +671,7 @@ const DataSettings: FC = () => { handleOpenPath(appInfo?.logsPath)} style={{ flexShrink: 0 }} /> - @@ -658,7 +681,9 @@ const DataSettings: FC = () => { {t('settings.data.app_knowledge.label')} - + @@ -668,14 +693,16 @@ const DataSettings: FC = () => { {cacheSize && ({cacheSize}MB)} - + {t('settings.general.reset.title')} - diff --git a/yarn.lock b/yarn.lock index 06d44e14dc..fba0420fd4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11073,6 +11073,13 @@ __metadata: languageName: node linkType: hard +"@socket.io/component-emitter@npm:~3.1.0": + version: 3.1.2 + resolution: "@socket.io/component-emitter@npm:3.1.2" + checksum: 10c0/c4242bad66f67e6f7b712733d25b43cbb9e19a595c8701c3ad99cbeb5901555f78b095e24852f862fffb43e96f1d8552e62def885ca82ae1bb05da3668fd87d7 + languageName: node + linkType: hard + "@standard-schema/spec@npm:^1.0.0": version: 1.0.0 resolution: "@standard-schema/spec@npm:1.0.0" @@ -12125,7 +12132,7 @@ __metadata: languageName: node linkType: hard -"@types/cors@npm:^2.8.19": +"@types/cors@npm:^2.8.12, @types/cors@npm:^2.8.19": version: 2.8.19 resolution: "@types/cors@npm:2.8.19" dependencies: @@ -12673,6 +12680,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:>=10.0.0": + version: 24.3.1 + resolution: "@types/node@npm:24.3.1" + dependencies: + undici-types: "npm:~7.10.0" + checksum: 10c0/99b86fc32294fcd61136ca1f771026443a1e370e9f284f75e243b29299dd878e18c193deba1ce29a374932db4e30eb80826e1049b9aad02d36f5c30b94b6f928 + languageName: node + linkType: hard + "@types/node@npm:^18.11.18": version: 18.19.86 resolution: "@types/node@npm:18.19.86" @@ -14108,6 +14124,7 @@ __metadata: pdf-parse: "npm:^1.1.1" playwright: "npm:^1.55.1" proxy-agent: "npm:^6.5.0" + qrcode.react: "npm:^4.2.0" react: "npm:^19.2.0" react-dom: "npm:^19.2.0" react-error-boundary: "npm:^6.0.0" @@ -14139,6 +14156,7 @@ __metadata: selection-hook: "npm:^1.0.12" sharp: "npm:^0.34.3" shiki: "npm:^3.12.0" + socket.io: "npm:^4.8.1" strict-url-sanitise: "npm:^0.0.1" string-width: "npm:^7.2.0" striptags: "npm:^3.2.0" @@ -14214,6 +14232,16 @@ __metadata: languageName: node linkType: hard +"accepts@npm:~1.3.4": + version: 1.3.8 + resolution: "accepts@npm:1.3.8" + dependencies: + mime-types: "npm:~2.1.34" + negotiator: "npm:0.6.3" + checksum: 10c0/3a35c5f5586cfb9a21163ca47a5f77ac34fa8ceb5d17d2fa2c0d81f41cbd7f8c6fa52c77e2c039acc0f4d09e71abdc51144246900f6bef5e3c4b333f77d89362 + languageName: node + linkType: hard + "acorn-jsx@npm:^5.3.2": version: 5.3.2 resolution: "acorn-jsx@npm:5.3.2" @@ -14900,6 +14928,13 @@ __metadata: languageName: node linkType: hard +"base64id@npm:2.0.0, base64id@npm:~2.0.0": + version: 2.0.0 + resolution: "base64id@npm:2.0.0" + checksum: 10c0/6919efd237ed44b9988cbfc33eca6f173a10e810ce50292b271a1a421aac7748ef232a64d1e6032b08f19aae48dce6ee8f66c5ae2c9e5066c82b884861d4d453 + languageName: node + linkType: hard + "basic-ftp@npm:^5.0.2": version: 5.0.5 resolution: "basic-ftp@npm:5.0.5" @@ -16176,7 +16211,7 @@ __metadata: languageName: node linkType: hard -"cookie@npm:^0.7.1": +"cookie@npm:^0.7.1, cookie@npm:~0.7.2": version: 0.7.2 resolution: "cookie@npm:0.7.2" checksum: 10c0/9596e8ccdbf1a3a88ae02cf5ee80c1c50959423e1022e4e60b91dd87c622af1da309253d8abdb258fb5e3eacb4f08e579dc58b4897b8087574eee0fd35dfa5d2 @@ -16206,7 +16241,7 @@ __metadata: languageName: node linkType: hard -"cors@npm:^2.8.5": +"cors@npm:^2.8.5, cors@npm:~2.8.5": version: 2.8.5 resolution: "cors@npm:2.8.5" dependencies: @@ -16869,6 +16904,18 @@ __metadata: languageName: node linkType: hard +"debug@npm:~4.3.1, debug@npm:~4.3.2, debug@npm:~4.3.4": + version: 4.3.7 + resolution: "debug@npm:4.3.7" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/1471db19c3b06d485a622d62f65947a19a23fbd0dd73f7fd3eafb697eec5360cde447fb075919987899b1a2096e85d35d4eb5a4de09a57600ac9cf7e6c8e768b + languageName: node + linkType: hard + "decamelize@npm:1.2.0": version: 1.2.0 resolution: "decamelize@npm:1.2.0" @@ -17796,6 +17843,30 @@ __metadata: languageName: node linkType: hard +"engine.io-parser@npm:~5.2.1": + version: 5.2.3 + resolution: "engine.io-parser@npm:5.2.3" + checksum: 10c0/ed4900d8dbef470ab3839ccf3bfa79ee518ea8277c7f1f2759e8c22a48f64e687ea5e474291394d0c94f84054749fd93f3ef0acb51fa2f5f234cc9d9d8e7c536 + languageName: node + linkType: hard + +"engine.io@npm:~6.6.0": + version: 6.6.4 + resolution: "engine.io@npm:6.6.4" + dependencies: + "@types/cors": "npm:^2.8.12" + "@types/node": "npm:>=10.0.0" + accepts: "npm:~1.3.4" + base64id: "npm:2.0.0" + cookie: "npm:~0.7.2" + cors: "npm:~2.8.5" + debug: "npm:~4.3.1" + engine.io-parser: "npm:~5.2.1" + ws: "npm:~8.17.1" + checksum: 10c0/845761163f8ea7962c049df653b75dafb6b3693ad6f59809d4474751d7b0392cbf3dc2730b8a902ff93677a91fd28711d34ab29efd348a8a4b49c6b0724021ab + languageName: node + linkType: hard + "enhanced-resolve@npm:^5.18.3": version: 5.18.3 resolution: "enhanced-resolve@npm:5.18.3" @@ -23077,7 +23148,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:^2.1.12, mime-types@npm:^2.1.35": +"mime-types@npm:^2.1.12, mime-types@npm:^2.1.35, mime-types@npm:~2.1.34": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: @@ -23541,6 +23612,13 @@ __metadata: languageName: node linkType: hard +"negotiator@npm:0.6.3": + version: 0.6.3 + resolution: "negotiator@npm:0.6.3" + checksum: 10c0/3ec9fd413e7bf071c937ae60d572bc67155262068ed522cf4b3be5edbe6ddf67d095ec03a3a14ebf8fc8e95f8e1d61be4869db0dbb0de696f6b837358bd43fc2 + languageName: node + linkType: hard + "negotiator@npm:^0.6.3": version: 0.6.4 resolution: "negotiator@npm:0.6.4" @@ -25232,6 +25310,15 @@ __metadata: languageName: node linkType: hard +"qrcode.react@npm:^4.2.0": + version: 4.2.0 + resolution: "qrcode.react@npm:4.2.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/68c691d130e5fda2f57cee505ed7aea840e7d02033100687b764601f9595e1116e34c13876628a93e1a5c2b85e4efc27d30b2fda72e2050c02f3e1c4e998d248 + languageName: node + linkType: hard + "qs@npm:^6.14.0": version: 6.14.0 resolution: "qs@npm:6.14.0" @@ -27499,6 +27586,41 @@ __metadata: languageName: node linkType: hard +"socket.io-adapter@npm:~2.5.2": + version: 2.5.5 + resolution: "socket.io-adapter@npm:2.5.5" + dependencies: + debug: "npm:~4.3.4" + ws: "npm:~8.17.1" + checksum: 10c0/04a5a2a9c4399d1b6597c2afc4492ab1e73430cc124ab02b09e948eabf341180b3866e2b61b5084cb899beb68a4db7c328c29bda5efb9207671b5cb0bc6de44e + languageName: node + linkType: hard + +"socket.io-parser@npm:~4.2.4": + version: 4.2.4 + resolution: "socket.io-parser@npm:4.2.4" + dependencies: + "@socket.io/component-emitter": "npm:~3.1.0" + debug: "npm:~4.3.1" + checksum: 10c0/9383b30358fde4a801ea4ec5e6860915c0389a091321f1c1f41506618b5cf7cd685d0a31c587467a0c4ee99ef98c2b99fb87911f9dfb329716c43b587f29ca48 + languageName: node + linkType: hard + +"socket.io@npm:^4.8.1": + version: 4.8.1 + resolution: "socket.io@npm:4.8.1" + dependencies: + accepts: "npm:~1.3.4" + base64id: "npm:~2.0.0" + cors: "npm:~2.8.5" + debug: "npm:~4.3.2" + engine.io: "npm:~6.6.0" + socket.io-adapter: "npm:~2.5.2" + socket.io-parser: "npm:~4.2.4" + checksum: 10c0/acf931a2bb235be96433b71da3d8addc63eeeaa8acabd33dc8d64e12287390a45f1e9f389a73cf7dc336961cd491679741b7a016048325c596835abbcc017ca9 + languageName: node + linkType: hard + "socks-proxy-agent@npm:^7.0.0": version: 7.0.0 resolution: "socks-proxy-agent@npm:7.0.0" @@ -28951,6 +29073,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~7.10.0": + version: 7.10.0 + resolution: "undici-types@npm:7.10.0" + checksum: 10c0/8b00ce50e235fe3cc601307f148b5e8fb427092ee3b23e8118ec0a5d7f68eca8cee468c8fc9f15cbb2cf2a3797945ebceb1cbd9732306a1d00e0a9b6afa0f635 + languageName: node + linkType: hard + "undici@npm:6.21.2": version: 6.21.2 resolution: "undici@npm:6.21.2" @@ -29987,6 +30116,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:~8.17.1": + version: 8.17.1 + resolution: "ws@npm:8.17.1" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/f4a49064afae4500be772abdc2211c8518f39e1c959640457dcee15d4488628620625c783902a52af2dd02f68558da2868fd06e6fd0e67ebcd09e6881b1b5bfe + languageName: node + linkType: hard + "xlsx@https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz": version: 0.20.2 resolution: "xlsx@https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz"