mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-19 06:30:10 +08:00
feat: restore data to mobile App (#10108)
* feat: restore data to App
* fix: i18n check
* fix: lint
* Change WebSocket service port to 11451
- Update default port from 3000 to 11451 for WebSocket connections
- Maintain existing service structure and client connection handling
* Add local IP address to WebSocket server configuration
- Set server path using local IP address for improved network accessibility
- Maintain existing CORS policy with wildcard origin
- Keep backward compatibility with current connection handling
* Remove local IP path and enforce WebSocket transport
- Replace dynamic local IP path with static WebSocket transport configuration
- Maintain CORS policy with wildcard origin for cross-origin connections
- Ensure reliable WebSocket-only communication by disabling fallback transports
* Add detailed logging to WebSocket connection flow
- Enhance WebSocketService with verbose connection logging including transport type and client count
- Add comprehensive logging in ExportToPhoneLanPopup for WebSocket initialization and status tracking
- Improve error handling with null checks for main window before sending events
* Add engine-level WebSocket connection monitoring
- Add initial_headers event listener to log connection attempts with URL and headers
- Add engine connection event to log established connections with remote addresses
- Add startup logs for server binding and allowed transports
* chore: change to use 7017 port
* Improve local IP address selection with interface priority system
- Implement network interface priority ranking to prefer Ethernet/Wi-Fi over virtual/VPN interfaces
- Add detailed logging for interface discovery and selection process
- Remove websocket-only transport restriction for broader client compatibility
- Clean up unused parameter in initial_headers event handler
* Add VPN interface patterns for Tailscale and WireGuard
- Include Tailscale VPN interfaces in network interface filtering
- Add WireGuard VPN interfaces to low-priority network candidates
- Maintain existing VPN tunnel interface patterns for compatibility
* Add network interface prioritization for QR code generation
- Implement `getAllCandidates()` method to scan and prioritize network interfaces by type (Ethernet/Wi-Fi over VPN/virtual interfaces)
- Update QR code payload to include all candidate IPs with priority rankings instead of single host
- Add comprehensive interface pattern matching for macOS, Windows, and Linux systems
* Add WebSocket getAllCandidates IPC channel
- Add new WebSocket_GetAllCandidates enum value to IpcChannel
- Register getAllCandidates handler in main process IPC
- Expose getAllCandidates method in preload script API
* Add WebSocket connection logging and temporary test button
- Add URL and method logging to WebSocket engine connection events
- Implement Socket.IO connect and connect_error event handlers with logging
- Add temporary test button to force connection status for debugging
* Clean up WebSocket logging and remove debug code
- Remove verbose debug logs from WebSocket service and connection handling
- Consolidate connection logging into single informative messages
- Remove temporary test button and force connection functionality from UI
- Add missing "sending" translation key for export button loading state
* Enhance file transfer with progress tracking and improved UI
- Add transfer speed monitoring and formatted file size display in WebSocket service
- Implement detailed connection and transfer state management in UI component
- Improve visual feedback with status indicators, progress bars, and error handling
* Enhance WebSocket service and LAN export UI with improved logging and user experience
- Add detailed WebSocket server configuration with transports, CORS, and timeout settings
- Implement comprehensive connection logging at both Socket.IO and Engine.IO levels
- Refactor export popup with modular components, status indicators, and i18n support
* 移除 WebSocket 连接时的冗余日志记录
* Remove dot indicator from connection status component
- Simplify status style map by removing unused dot color properties
- Delete dot indicator element from connection status display
- Maintain existing border and background color styling for status states
* Refactor ExportToPhoneLanPopup with dedicated UI components and improved UX
- Extract QR code display states into separate components (LoadingQRCode, ScanQRCode, ConnectingAnimation, ConnectedDisplay, ErrorQRCode)
- Add confirmation dialog when attempting to close during active file transfer
- Improve WebSocket cleanup and modal dismissal behavior with proper connection handling
* Remove close button hiding during QR code generation
- Eliminate `hideCloseButton={isSending}` prop to keep close button visible
- Maintain consistent modal behavior throughout export process
- Prevent user confusion by ensuring close option remains available
* auto close
* Extract auto-close countdown into separate component
- Move auto-close countdown logic from TransferProgress to dedicated AutoCloseCountdown component
- Update styling to use paddingTop instead of marginTop for better spacing
- Clean up TransferProgress dependencies by removing autoCloseCountdown
* 添加局域网传输相关的翻译文本,包括自动关闭提示和确认关闭消息
---------
Co-authored-by: suyao <sy20010504@gmail.com>
This commit is contained in:
parent
b6dcf2f5fa
commit
2a06c606e1
@ -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",
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
368
src/main/services/WebSocketService.ts
Normal file
368
src/main/services/WebSocketService.ts
Normal file
@ -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<string>()
|
||||
|
||||
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<void>((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()
|
||||
@ -550,6 +550,13 @@ const api = {
|
||||
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_ReadContent, sourcePath),
|
||||
writeContent: (options: WritePluginContentOptions): Promise<PluginResult<void>> =>
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
591
src/renderer/src/components/Popups/ExportToPhoneLanPopup.tsx
Normal file
591
src/renderer/src/components/Popups/ExportToPhoneLanPopup.tsx
Normal file
@ -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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
|
||||
<Spinner />
|
||||
<span style={{ fontSize: '14px', color: 'var(--color-text-2)' }}>
|
||||
{t('settings.data.export_to_phone.lan.generating_qr')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ScanQRCode: React.FC<{ qrCodeValue: string }> = ({ qrCodeValue }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
|
||||
<QRCodeSVG
|
||||
marginSize={2}
|
||||
value={qrCodeValue}
|
||||
level="Q"
|
||||
size={160}
|
||||
imageSettings={{
|
||||
src: '/src/assets/images/logo.png',
|
||||
width: 40,
|
||||
height: 40,
|
||||
excavate: true
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: '12px', color: 'var(--color-text-2)' }}>
|
||||
{t('settings.data.export_to_phone.lan.scan_qr')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ConnectingAnimation: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '160px',
|
||||
height: '160px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '2px dashed var(--color-status-warning)',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: 'var(--color-status-warning)'
|
||||
}}>
|
||||
<Spinner size="lg" color="warning" />
|
||||
<span style={{ fontSize: '14px', color: 'var(--color-text)', marginTop: '12px' }}>
|
||||
{t('settings.data.export_to_phone.lan.status.connecting')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ConnectedDisplay: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '160px',
|
||||
height: '160px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '2px dashed var(--color-status-success)',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: 'var(--color-status-success)'
|
||||
}}>
|
||||
<span style={{ fontSize: '48px' }}>📱</span>
|
||||
<span style={{ fontSize: '14px', color: 'var(--color-text)', marginTop: '8px' }}>
|
||||
{t('settings.data.export_to_phone.lan.connected')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ErrorQRCode: React.FC<{ error: string | null }> = ({ error }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '20px',
|
||||
border: `1px solid var(--color-error)`,
|
||||
borderRadius: '8px',
|
||||
backgroundColor: 'var(--color-error)'
|
||||
}}>
|
||||
<span style={{ fontSize: '48px' }}>⚠️</span>
|
||||
<span style={{ fontSize: '14px', color: 'var(--color-text)' }}>
|
||||
{t('settings.data.export_to_phone.lan.connection_failed')}
|
||||
</span>
|
||||
{error && <span style={{ fontSize: '12px', color: 'var(--color-text-2)' }}>{error}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
const [isOpen, setIsOpen] = useState(true)
|
||||
const [connectionPhase, setConnectionPhase] = useState<ConnectionPhase>('initializing')
|
||||
const [transferPhase, setTransferPhase] = useState<TransferPhase>('idle')
|
||||
const [qrCodeValue, setQrCodeValue] = useState('')
|
||||
const [selectedFolderPath, setSelectedFolderPath] = useState<string | null>(null)
|
||||
const [sendProgress, setSendProgress] = useState(0)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showCloseConfirm, setShowCloseConfirm] = useState(false)
|
||||
const [autoCloseCountdown, setAutoCloseCountdown] = useState<number | null>(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(
|
||||
() => (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: connectionStatusStyles.bg,
|
||||
border: `1px solid ${connectionStatusStyles.border}`
|
||||
}}>
|
||||
<span style={{ fontSize: '14px', fontWeight: '500', color: 'var(--color-text)' }}>{connectionStatusText}</span>
|
||||
</div>
|
||||
),
|
||||
[connectionStatusStyles, connectionStatusText]
|
||||
)
|
||||
|
||||
// 二维码显示组件 - 使用显式条件渲染以避免类型不匹配
|
||||
const QRCodeDisplay = useCallback(() => {
|
||||
switch (connectionPhase) {
|
||||
case 'waiting_qr_scan':
|
||||
case 'disconnected':
|
||||
return <ScanQRCode qrCodeValue={qrCodeValue} />
|
||||
case 'initializing':
|
||||
return <LoadingQRCode />
|
||||
case 'connecting':
|
||||
return <ConnectingAnimation />
|
||||
case 'connected':
|
||||
return <ConnectedDisplay />
|
||||
case 'error':
|
||||
return <ErrorQRCode error={error} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}, [connectionPhase, qrCodeValue, error])
|
||||
|
||||
// 传输进度组件
|
||||
const TransferProgress = useCallback(() => {
|
||||
if (!isSending && transferPhase !== 'completed') return null
|
||||
|
||||
return (
|
||||
<div style={{ paddingTop: '8px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
padding: '12px',
|
||||
border: `1px solid var(--color-border)`,
|
||||
borderRadius: '8px',
|
||||
backgroundColor: 'var(--color-background-mute)'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
<span style={{ color: 'var(--color-text)' }}>
|
||||
{t('settings.data.export_to_phone.lan.transfer_progress')}
|
||||
</span>
|
||||
<span
|
||||
style={{ color: transferPhase === 'completed' ? 'var(--color-status-success)' : 'var(--color-primary)' }}>
|
||||
{transferPhase === 'completed' ? '✅ ' + t('common.completed') : `${Math.round(sendProgress)}%`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Progress
|
||||
value={Math.round(sendProgress)}
|
||||
size="md"
|
||||
color={transferPhase === 'completed' ? 'success' : 'primary'}
|
||||
showValueLabel={false}
|
||||
aria-label="Send progress"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}, [isSending, transferPhase, sendProgress, t])
|
||||
|
||||
const AutoCloseCountdown = useCallback(() => {
|
||||
if (transferPhase !== 'completed' || autoCloseCountdown === null || autoCloseCountdown <= 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--color-text-2)',
|
||||
textAlign: 'center',
|
||||
paddingTop: '4px'
|
||||
}}>
|
||||
{t('settings.data.export_to_phone.lan.auto_close_tip', { seconds: autoCloseCountdown })}
|
||||
</div>
|
||||
)
|
||||
}, [transferPhase, autoCloseCountdown, t])
|
||||
|
||||
// 错误显示组件
|
||||
const ErrorDisplay = useCallback(() => {
|
||||
if (!error || transferPhase !== 'error') return null
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '12px',
|
||||
border: `1px solid var(--color-error)`,
|
||||
borderRadius: '8px',
|
||||
backgroundColor: 'var(--color-error)',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<span style={{ fontSize: '14px', color: 'var(--color-text)' }}>❌ {error}</span>
|
||||
</div>
|
||||
)
|
||||
}, [error, transferPhase])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleCancel()
|
||||
}
|
||||
}}
|
||||
isDismissable={false}
|
||||
isKeyboardDismissDisabled={false}
|
||||
placement="center"
|
||||
onClose={handleClose}>
|
||||
<ModalContent>
|
||||
{() => (
|
||||
<>
|
||||
<ModalHeader>{t('settings.data.export_to_phone.lan.title')}</ModalHeader>
|
||||
<ModalBody>
|
||||
<SettingRow>
|
||||
<StatusIndicator />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow>
|
||||
<div>{t('settings.data.export_to_phone.lan.content')}</div>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow style={{ display: 'flex', justifyContent: 'center', minHeight: '180px' }}>
|
||||
<QRCodeDisplay />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: 10, justifyContent: 'center', width: '100%' }}>
|
||||
<Button color="default" variant="flat" onPress={handleSelectZip} isDisabled={isSending}>
|
||||
{t('settings.data.export_to_phone.lan.selectZip')}
|
||||
</Button>
|
||||
<Button color="primary" onPress={handleSendZip} isDisabled={!canSend} isLoading={isSending}>
|
||||
{transferStatusText || t('settings.data.export_to_phone.lan.sendZip')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<SettingHelpText
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{selectedFolderPath || t('settings.data.export_to_phone.lan.noZipSelected')}
|
||||
</SettingHelpText>
|
||||
|
||||
<TransferProgress />
|
||||
<AutoCloseCountdown />
|
||||
<ErrorDisplay />
|
||||
</ModalBody>
|
||||
|
||||
{showCloseConfirm && (
|
||||
<ModalFooter>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
gap: '12px',
|
||||
padding: '8px',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: 'var(--color-status-warning)',
|
||||
border: '1px solid var(--color-status-warning)'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '20px' }}>⚠️</span>
|
||||
<span style={{ fontSize: '14px', color: 'var(--color-text)', fontWeight: '500' }}>
|
||||
{t('settings.data.export_to_phone.lan.confirm_close_title')}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: '13px', color: 'var(--color-text-2)', marginLeft: '28px' }}>
|
||||
{t('settings.data.export_to_phone.lan.confirm_close_message')}
|
||||
</span>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '4px' }}>
|
||||
<Button size="sm" color="default" variant="flat" onPress={handleCancelClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" color="danger" onPress={handleForceClose}>
|
||||
{t('settings.data.export_to_phone.lan.force_close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const TopViewKey = 'ExportToPhoneLanPopup'
|
||||
|
||||
export default class ExportToPhoneLanPopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
static show() {
|
||||
return new Promise<any>((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
resolve={(v) => {
|
||||
resolve(v)
|
||||
TopView.hide(TopViewKey)
|
||||
}}
|
||||
/>,
|
||||
TopViewKey
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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 = () => {
|
||||
<div>
|
||||
<MigrationPathRow style={{ marginTop: '20px', flexDirection: 'row', alignItems: 'center' }}>
|
||||
<Switch
|
||||
defaultChecked={shouldCopyData}
|
||||
onChange={(checked) => {
|
||||
defaultSelected={shouldCopyData}
|
||||
onValueChange={(checked) => {
|
||||
shouldCopyData = checked
|
||||
}}
|
||||
style={{ marginRight: '8px' }}
|
||||
/>
|
||||
size="sm">
|
||||
<span style={{ fontWeight: 'normal', fontSize: '14px' }}>
|
||||
{t('settings.data.app_data.copy_data_option')}
|
||||
</span>
|
||||
</Switch>
|
||||
|
||||
<MigrationPathLabel style={{ fontWeight: 'normal', fontSize: '14px' }}>
|
||||
{t('settings.data.app_data.copy_data_option')}
|
||||
</MigrationPathLabel>
|
||||
@ -605,10 +613,10 @@ const DataSettings: FC = () => {
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
|
||||
<HStack gap="5px" justifyContent="space-between">
|
||||
<Button onClick={BackupPopup.show} icon={<SaveIcon size={14} />}>
|
||||
<Button variant="ghost" size="sm" onPress={BackupPopup.show} startContent={<SaveIcon size={14} />}>
|
||||
{t('settings.general.backup.button')}
|
||||
</Button>
|
||||
<Button onClick={RestorePopup.show} icon={<FolderOpen size={14} />}>
|
||||
<Button variant="ghost" size="sm" onPress={RestorePopup.show} startContent={<FolderOpen size={14} />}>
|
||||
{t('settings.general.restore.button')}
|
||||
</Button>
|
||||
</HStack>
|
||||
@ -616,11 +624,24 @@ const DataSettings: FC = () => {
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.backup.skip_file_data_title')}</SettingRowTitle>
|
||||
<Switch checked={skipBackupFile} onChange={onSkipBackupFilesChange} />
|
||||
<Switch isSelected={skipBackupFile} onValueChange={onSkipBackupFilesChange} size="sm" />
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>{t('settings.data.backup.skip_file_data_help')}</SettingHelpText>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.export_to_phone.title')}</SettingRowTitle>
|
||||
<HStack gap="5px" justifyContent="space-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onPress={ExportToPhoneLanPopup.show}
|
||||
startContent={<WifiOutlined />}>
|
||||
{t('settings.data.export_to_phone.lan.title')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>{t('settings.data.data.title')}</SettingTitle>
|
||||
@ -635,7 +656,9 @@ const DataSettings: FC = () => {
|
||||
</PathText>
|
||||
<StyledIcon onClick={() => handleOpenPath(appInfo?.appDataPath)} style={{ flexShrink: 0 }} />
|
||||
<HStack gap="5px" style={{ marginLeft: '8px' }}>
|
||||
<Button onClick={handleSelectAppDataPath}>{t('settings.data.app_data.select')}</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleSelectAppDataPath}>
|
||||
{t('settings.data.app_data.select')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</PathRow>
|
||||
</SettingRow>
|
||||
@ -648,7 +671,7 @@ const DataSettings: FC = () => {
|
||||
</PathText>
|
||||
<StyledIcon onClick={() => handleOpenPath(appInfo?.logsPath)} style={{ flexShrink: 0 }} />
|
||||
<HStack gap="5px" style={{ marginLeft: '8px' }}>
|
||||
<Button onClick={() => handleOpenPath(appInfo?.logsPath)}>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleOpenPath(appInfo?.logsPath)}>
|
||||
{t('settings.data.app_logs.button')}
|
||||
</Button>
|
||||
</HStack>
|
||||
@ -658,7 +681,9 @@ const DataSettings: FC = () => {
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.app_knowledge.label')}</SettingRowTitle>
|
||||
<HStack alignItems="center" gap="5px">
|
||||
<Button onClick={handleRemoveAllFiles}>{t('settings.data.app_knowledge.button.delete')}</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleRemoveAllFiles}>
|
||||
{t('settings.data.app_knowledge.button.delete')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
@ -668,14 +693,16 @@ const DataSettings: FC = () => {
|
||||
{cacheSize && <CacheText>({cacheSize}MB)</CacheText>}
|
||||
</SettingRowTitle>
|
||||
<HStack gap="5px">
|
||||
<Button onClick={handleClearCache}>{t('settings.data.clear_cache.button')}</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleClearCache}>
|
||||
{t('settings.data.clear_cache.button')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.general.reset.title')}</SettingRowTitle>
|
||||
<HStack gap="5px">
|
||||
<Button onClick={reset} danger>
|
||||
<Button variant="ghost" size="sm" onPress={reset} color="danger">
|
||||
{t('settings.general.reset.title')}
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
152
yarn.lock
152
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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user