v1.1.0: better-sqlite3→sql.js迁移 + 6项UI/代码修复

迁移:
- better-sqlite3(原生C++) → sql.js(WASM纯JS), 兼容Node 24
- database-service.ts: PreparedStatement包装器保持API兼容
- main.ts: initialize()改为async/await
- 移除@electron/rebuild和@types/better-sqlite3依赖
- 新增.npmrc阿里云镜像加速

UI/代码修复(任务1-6):
1. TerminalView: 清理111行调试日志(645→534行)
2. 设计系统: cyan/slate→accent-blue/dark-*颜色统一(FileRow/TransferItem/Breadcrumb/ContextMenu)
3. StatusBar: 重写接入实时连接状态,三区布局
4. SessionDialog: 私钥浏览按钮添加onClick处理
5. WelcomeView: 真实session数据替换硬编码假数据
6. SFTPView: emoji→Lucide图标(FolderOpen/Upload/Download/Trash2)

构建验证: tsc零错误 + vite build成功(Node 24.14.0)
This commit is contained in:
OpsXShell Dev committed 2026-04-18 15:40:19 +08:00
1 parent c5243df040
commit 53274e2291
14 files changed
+1781 -2138

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
registry=https://registry.npmmirror.com
electron_mirror=https://npmmirror.com/mirrors/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
+2 -2
View File
@@ -203,7 +203,7 @@ function createApplicationMenu(): void {
// ============== App Lifecycle ============== // ============== App Lifecycle ==============
app.whenReady().then(() => { app.whenReady().then(async () => {
// Initialize encryption key first // Initialize encryption key first
keyService.getMasterKey() keyService.getMasterKey()
logger.info('[App] Encryption key initialized') logger.info('[App] Encryption key initialized')
@@ -212,7 +212,7 @@ app.whenReady().then(() => {
sshService = new SSHService() sshService = new SSHService()
zmodemService = new ZmodemService() zmodemService = new ZmodemService()
dbService = new DatabaseService() dbService = new DatabaseService()
dbService.initialize() await dbService.initialize()
// Register IPC handlers // Register IPC handlers
registerAllIPCHandlers({ registerAllIPCHandlers({
+98 -26
View File
@@ -1,4 +1,4 @@
import Database from 'better-sqlite3' import initSqlJs, { Database as SqlJsDatabase } from 'sql.js'
import path from 'path' import path from 'path'
import { app } from 'electron' import { app } from 'electron'
import fs from 'fs' import fs from 'fs'
@@ -8,30 +8,85 @@ import { createLogger } from '../utils/logger'
const logger = createLogger('DatabaseService') const logger = createLogger('DatabaseService')
// sql.js statement wrapper to match better-sqlite3 API
interface StatementResult {
id: number
[key: string]: any
}
class PreparedStatement {
private db: SqlJsDatabase
private sql: string
constructor(db: SqlJsDatabase, sql: string) {
this.db = db
this.sql = sql
}
run(...params: any[]): StatementResult {
this.db.run(this.sql, params as any)
return { id: 0 } as StatementResult
}
get(...params: any[]): any {
// 使用 exec 模式获取单行
const stmt = this.db.prepare(this.sql)
stmt.bind(params as any)
let result: any = null
if (stmt.step()) {
result = stmt.getAsObject()
}
stmt.free()
return result
}
all(...params: any[]): any[] {
const stmt = this.db.prepare(this.sql)
stmt.bind(params as any)
const results: any[] = []
while (stmt.step()) {
results.push(stmt.getAsObject())
}
stmt.free()
return results
}
}
export class DatabaseService { export class DatabaseService {
private db: Database.Database | null = null private db: SqlJsDatabase | null = null
private dbPath: string private dbPath: string
private masterKey: string | null = null private masterKey: string | null = null
private initialized = false
constructor() { constructor() {
// 使用应用数据目录存储数据库
const userDataPath = app.getPath('userData') const userDataPath = app.getPath('userData')
this.dbPath = path.join(userDataPath, 'opsxshell.db') this.dbPath = path.join(userDataPath, 'opsxshell.db')
} }
initialize(): void { async initialize(): Promise<void> {
try { try {
// 确保目录存在
const dbDir = path.dirname(this.dbPath) const dbDir = path.dirname(this.dbPath)
if (!fs.existsSync(dbDir)) { if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true }) fs.mkdirSync(dbDir, { recursive: true })
} }
// 获取主密钥
this.masterKey = keyService.getMasterKey() this.masterKey = keyService.getMasterKey()
this.db = new Database(this.dbPath) // 初始化 sql.js (WASM, 纯 JS 无需原生编译)
const SQL = await initSqlJs()
// 如果已有数据库文件,加载它
if (fs.existsSync(this.dbPath)) {
const buffer = fs.readFileSync(this.dbPath)
this.db = new SQL.Database(buffer)
} else {
this.db = new SQL.Database()
}
this.createTables() this.createTables()
this.save()
this.initialized = true
logger.info('Database initialized at:', this.dbPath) logger.info('Database initialized at:', this.dbPath)
} catch (error) { } catch (error) {
logger.error('Failed to initialize database:', error) logger.error('Failed to initialize database:', error)
@@ -42,8 +97,7 @@ export class DatabaseService {
private createTables(): void { private createTables(): void {
if (!this.db) return if (!this.db) return
// 会话表 this.db.run(`
this.db.exec(`
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -63,13 +117,12 @@ export class DatabaseService {
// 迁移:如果 password 列不存在,添加它 // 迁移:如果 password 列不存在,添加它
try { try {
this.db.exec(`ALTER TABLE sessions ADD COLUMN password TEXT`) this.db.run(`ALTER TABLE sessions ADD COLUMN password TEXT`)
} catch (e) { } catch (e) {
// 列可能已存在,忽略错误 // 列可能已存在,忽略错误
} }
// 连接历史表 this.db.run(`
this.db.exec(`
CREATE TABLE IF NOT EXISTS connection_history ( CREATE TABLE IF NOT EXISTS connection_history (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
session_id TEXT REFERENCES sessions(id), session_id TEXT REFERENCES sessions(id),
@@ -80,8 +133,7 @@ export class DatabaseService {
) )
`) `)
// 设置表 this.db.run(`
this.db.exec(`
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT, value TEXT,
@@ -89,8 +141,7 @@ export class DatabaseService {
) )
`) `)
// 密钥表 this.db.run(`
this.db.exec(`
CREATE TABLE IF NOT EXISTS keys ( CREATE TABLE IF NOT EXISTS keys (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -102,8 +153,7 @@ export class DatabaseService {
) )
`) `)
// 端口转发规则表 this.db.run(`
this.db.exec(`
CREATE TABLE IF NOT EXISTS port_forwards ( CREATE TABLE IF NOT EXISTS port_forwards (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
session_id TEXT REFERENCES sessions(id), session_id TEXT REFERENCES sessions(id),
@@ -117,6 +167,23 @@ export class DatabaseService {
`) `)
} }
// 持久化到磁盘
private save(): void {
if (!this.db) return
try {
const data = this.db.export()
const buffer = Buffer.from(data)
fs.writeFileSync(this.dbPath, buffer)
} catch (error) {
logger.error('Failed to save database:', error)
}
}
private prepare(sql: string): PreparedStatement {
if (!this.db) throw new Error('Database not initialized')
return new PreparedStatement(this.db, sql)
}
// 加密字段 // 加密字段
private encryptField(value: string | null | undefined): string | null { private encryptField(value: string | null | undefined): string | null {
if (!value) return null if (!value) return null
@@ -147,7 +214,7 @@ export class DatabaseService {
logger.info('Creating session with password:', session.password ? `len=${session.password.length}` : 'null/undefined') logger.info('Creating session with password:', session.password ? `len=${session.password.length}` : 'null/undefined')
const stmt = this.db.prepare(` const stmt = this.prepare(`
INSERT INTO sessions (id, name, host, port, username, auth_type, password, private_key_path, passphrase_encrypted, group_name, tags) INSERT INTO sessions (id, name, host, port, username, auth_type, password, private_key_path, passphrase_encrypted, group_name, tags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`) `)
@@ -164,19 +231,20 @@ export class DatabaseService {
session.username, session.username,
session.authType, session.authType,
encryptedPassword, encryptedPassword,
session.privateKey || null, // 私钥路径不加密 session.privateKey || null,
this.encryptField(session.passphrase), this.encryptField(session.passphrase),
session.group || 'default', session.group || 'default',
session.tags ? JSON.stringify(session.tags) : null session.tags ? JSON.stringify(session.tags) : null
) )
this.save()
return this.getSessionById(id) return this.getSessionById(id)
} }
getAllSessions(): any[] { getAllSessions(): any[] {
if (!this.db) throw new Error('Database not initialized') if (!this.db) throw new Error('Database not initialized')
const stmt = this.db.prepare('SELECT * FROM sessions ORDER BY created_at DESC') const stmt = this.prepare('SELECT * FROM sessions ORDER BY created_at DESC')
const sessions = stmt.all() const sessions = stmt.all()
return sessions.map((s: any) => ({ return sessions.map((s: any) => ({
@@ -197,7 +265,7 @@ export class DatabaseService {
getSessionById(id: string): any { getSessionById(id: string): any {
if (!this.db) throw new Error('Database not initialized') if (!this.db) throw new Error('Database not initialized')
const stmt = this.db.prepare('SELECT * FROM sessions WHERE id = ?') const stmt = this.prepare('SELECT * FROM sessions WHERE id = ?')
const session = stmt.get(id) as any const session = stmt.get(id) as any
if (session) { if (session) {
@@ -264,26 +332,28 @@ export class DatabaseService {
fields.push('updated_at = CURRENT_TIMESTAMP') fields.push('updated_at = CURRENT_TIMESTAMP')
values.push(id) values.push(id)
const stmt = this.db.prepare(` const stmt = this.prepare(`
UPDATE sessions SET ${fields.join(', ')} WHERE id = ? UPDATE sessions SET ${fields.join(', ')} WHERE id = ?
`) `)
stmt.run(...values) stmt.run(...values)
this.save()
return this.getSessionById(id) return this.getSessionById(id)
} }
deleteSession(id: string): void { deleteSession(id: string): void {
if (!this.db) throw new Error('Database not initialized') if (!this.db) throw new Error('Database not initialized')
const stmt = this.db.prepare('DELETE FROM sessions WHERE id = ?') const stmt = this.prepare('DELETE FROM sessions WHERE id = ?')
stmt.run(id) stmt.run(id)
this.save()
} }
// 设置操作 // 设置操作
getSetting(key: string): any { getSetting(key: string): any {
if (!this.db) throw new Error('Database not initialized') if (!this.db) throw new Error('Database not initialized')
const stmt = this.db.prepare('SELECT value FROM settings WHERE key = ?') const stmt = this.prepare('SELECT value FROM settings WHERE key = ?')
const row: any = stmt.get(key) const row: any = stmt.get(key)
return row ? JSON.parse(row.value) : null return row ? JSON.parse(row.value) : null
} }
@@ -291,7 +361,7 @@ export class DatabaseService {
setSetting(key: string, value: any): void { setSetting(key: string, value: any): void {
if (!this.db) throw new Error('Database not initialized') if (!this.db) throw new Error('Database not initialized')
const stmt = this.db.prepare(` const stmt = this.prepare(`
INSERT INTO settings (key, value, updated_at) INSERT INTO settings (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET ON CONFLICT(key) DO UPDATE SET
@@ -299,10 +369,12 @@ export class DatabaseService {
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
`) `)
stmt.run(key, JSON.stringify(value)) stmt.run(key, JSON.stringify(value))
this.save()
} }
close(): void { close(): void {
if (this.db) { if (this.db) {
this.save()
this.db.close() this.db.close()
this.db = null this.db = null
} }
+1443 -1933
View File
File diff suppressed because it is too large. Load diff
+2 -3
View File
@@ -22,25 +22,24 @@
"dependencies": { "dependencies": {
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"better-sqlite3": "^9.4.0",
"lucide-react": "^0.294.0", "lucide-react": "^0.294.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.21.0", "react-router-dom": "^6.21.0",
"socksv5": "^0.0.6", "socksv5": "^0.0.6",
"sql.js": "^1.11.0",
"ssh2": "^1.15.0", "ssh2": "^1.15.0",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"zmodem-ts": "^1.0.8", "zmodem-ts": "^1.0.8",
"zustand": "^4.4.7" "zustand": "^4.4.7"
}, },
"devDependencies": { "devDependencies": {
"@electron/rebuild": "^4.0.3",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/better-sqlite3": "^7.6.8",
"@types/react": "^18.2.43", "@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17", "@types/react-dom": "^18.2.17",
"@types/sql.js": "^1.4.11",
"@types/ssh2": "^1.11.18", "@types/ssh2": "^1.11.18",
"@types/uuid": "^9.0.7", "@types/uuid": "^9.0.7",
"@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/eslint-plugin": "^6.14.0",
+6 -5
View File
@@ -5,6 +5,7 @@
*/ */
import { useState, useCallback } from 'react' import { useState, useCallback } from 'react'
import { FolderOpen, Upload, Download, Trash2 } from 'lucide-react'
import { useSFTP } from '../hooks' import { useSFTP } from '../hooks'
import { FileListPanel } from './sftp/FileListPanel' import { FileListPanel } from './sftp/FileListPanel'
import { TransferPanel } from './sftp/TransferPanel' import { TransferPanel } from './sftp/TransferPanel'
@@ -93,12 +94,12 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
e.preventDefault() e.preventDefault()
const items: MenuItem[] = [ const items: MenuItem[] = [
{ {
icon: '📂', icon: <FolderOpen className="w-4 h-4" />,
label: '打开', label: '打开',
action: () => file.type === 'directory' && navigateLocal(file.path), action: () => file.type === 'directory' && navigateLocal(file.path),
}, },
{ {
icon: '📤', icon: <Upload className="w-4 h-4" />,
label: '上传', label: '上传',
action: () => {}, action: () => {},
}, },
@@ -111,12 +112,12 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
e.preventDefault() e.preventDefault()
const items: MenuItem[] = [ const items: MenuItem[] = [
{ {
icon: '📂', icon: <FolderOpen className="w-4 h-4" />,
label: '打开', label: '打开',
action: () => file.type === 'directory' && navigateRemote(file.path), action: () => file.type === 'directory' && navigateRemote(file.path),
}, },
{ {
icon: '📥', icon: <Download className="w-4 h-4" />,
label: '下载', label: '下载',
action: () => { action: () => {
setRemoteSelected(new Set([file.name])) setRemoteSelected(new Set([file.name]))
@@ -124,7 +125,7 @@ export default function SFTPView({ tabId, isActive: _isActive = true }: SFTPView
}, },
}, },
{ {
icon: '🗑️', icon: <Trash2 className="w-4 h-4" />,
label: '删除', label: '删除',
danger: true, danger: true,
action: () => {}, action: () => {},
+15
View File
@@ -265,6 +265,21 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
<button <button
type="button" type="button"
className="px-4 py-2 bg-dark-700 hover:bg-dark-600 rounded-lg text-gray-300 transition-colors" className="px-4 py-2 bg-dark-700 hover:bg-dark-600 rounded-lg text-gray-300 transition-colors"
onClick={async () => {
const result = await window.electronAPI?.showOpenDialog({
title: '选择私钥文件',
defaultPath: '~/.ssh',
filters: [
{ name: '所有文件', extensions: ['*'] },
{ name: 'PEM 密钥', extensions: ['pem'] },
{ name: 'SSH 密钥', extensions: ['rsa', 'ed25519', 'pem'] },
],
})
if (result?.canceled) return
if (result?.filePaths?.[0]) {
setFormData({ ...formData, privateKey: result.filePaths[0] })
}
}}
> >
浏览 浏览
</button> </button>
+156 -20
View File
@@ -1,19 +1,137 @@
import { Wifi, HardDrive, Clock } from 'lucide-react' import { Wifi, WifiOff, AlertCircle, Loader2, HardDrive, Clock } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState, useMemo } from 'react'
import { useTabStore, type ConnectionStatus } from '../stores/tabStore'
import { useConnectionStore, type ConnectionState } from '../stores/connection'
// ============== Status Display Config ==============
interface StatusDisplay {
icon: React.ReactNode
label: string
colorClass: string
animateClass: string
}
const STATUS_CONFIG: Record<string, StatusDisplay> = {
connected: {
icon: <Wifi className="w-3 h-3" />,
label: '已连接',
colorClass: 'text-green-400',
animateClass: '',
},
connecting: {
icon: <Loader2 className="w-3 h-3 animate-spin" />,
label: '连接中',
colorClass: 'text-yellow-400',
animateClass: 'animate-pulse',
},
background: {
icon: <Wifi className="w-3 h-3" />,
label: '后台',
colorClass: 'text-green-400',
animateClass: '',
},
suspended: {
icon: <WifiOff className="w-3 h-3" />,
label: '已挂起',
colorClass: 'text-yellow-400',
animateClass: '',
},
reconnecting: {
icon: <Loader2 className="w-3 h-3 animate-spin" />,
label: '重连中',
colorClass: 'text-yellow-400',
animateClass: 'animate-pulse',
},
error: {
icon: <AlertCircle className="w-3 h-3" />,
label: '连接错误',
colorClass: 'text-red-400',
animateClass: '',
},
disconnected: {
icon: <WifiOff className="w-3 h-3" />,
label: '未连接',
colorClass: 'text-gray-400',
animateClass: '',
},
}
// ============== Helper: format bytes ==============
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const k = 1024
const i = Math.floor(Math.log(bytes) / Math.log(k))
const value = bytes / Math.pow(k, i)
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
}
// ============== Helper: map tab connectionStatus to ConnectionState ==============
function mapTabStatusToConnectionState(status?: ConnectionStatus): ConnectionState | undefined {
if (!status) return undefined
// tabStore's ConnectionStatus: 'disconnected' | 'connecting' | 'connected' | 'error'
// maps directly to ConnectionState values
return status as ConnectionState
}
// ============== Component ==============
export default function StatusBar() { export default function StatusBar() {
const [version, setVersion] = useState('1.0.0') const [version, setVersion] = useState('1.0.0')
const [currentTime, setCurrentTime] = useState(new Date()) const [currentTime, setCurrentTime] = useState(new Date())
// Tab store
const activeTabId = useTabStore((s) => s.activeTabId)
const tabs = useTabStore((s) => s.tabs)
// Connection store (pool)
const connections = useConnectionStore().config // trigger re-renders via store subscription
const poolStore = useConnectionStore()
// Derive the active tab
const activeTab = useMemo(
() => tabs.find((t) => t.id === activeTabId),
[tabs, activeTabId]
)
// Determine the connection state and info for the active tab
const { connectionState, hostLabel, stats } = useMemo(() => {
if (!activeTab || activeTab.type === 'welcome' || activeTab.type === 'settings') {
return { connectionState: undefined, hostLabel: undefined, stats: undefined }
}
// 1. Try to get connection from pool via connectionInstance
const instance = activeTab.connectionInstance
if (instance?.connectionId) {
const conn = poolStore.getConnection(instance.connectionId)
if (conn) {
return {
connectionState: conn.state,
hostLabel: `${conn.config.username}@${conn.config.host}`,
stats: conn.stats,
}
}
}
// 2. Fall back to tab's own connectionStatus
const state = mapTabStatusToConnectionState(activeTab.connectionStatus)
const config = activeTab.connectionConfig
const label = config ? `${config.username}@${config.host}` : undefined
return { connectionState: state, hostLabel: label, stats: undefined }
}, [activeTab, poolStore, connections]) // eslint-disable-line react-hooks/exhaustive-deps
// Clock timer + version
useEffect(() => { useEffect(() => {
// Get app version
if (window.electronAPI) { if (window.electronAPI) {
window.electronAPI.getVersion().then((v) => { window.electronAPI.getVersion().then((v: string) => {
setVersion(v) setVersion(v)
}) })
} }
// Update time every second
const timer = setInterval(() => { const timer = setInterval(() => {
setCurrentTime(new Date()) setCurrentTime(new Date())
}, 1000) }, 1000)
@@ -29,29 +147,47 @@ export default function StatusBar() {
}) })
} }
return ( // Resolve status display
<div className="h-7 bg-accent-blue flex items-center px-3 text-xs text-white"> const statusKey = connectionState ?? 'disconnected'
<div className="flex items-center gap-4"> const statusCfg = STATUS_CONFIG[statusKey] ?? STATUS_CONFIG.disconnected
<div className="flex items-center gap-1">
<Wifi className="w-3 h-3" />
<span>就绪</span>
</div>
<div className="flex items-center gap-1"> // Format transfer info if available
<HardDrive className="w-3 h-3" /> const transferLabel = stats && stats.bytesTransferred > 0
<span>本地</span> ? `↑↓ ${formatBytes(stats.bytesTransferred)}`
</div> : null
return (
<div className="h-7 bg-dark-800 border-t border-dark-700 flex items-center px-3 text-xs text-white select-none">
{/* Left: Connection status indicator + host */}
<div className={`flex items-center gap-1.5 ${statusCfg.colorClass} ${statusCfg.animateClass}`}>
{statusCfg.icon}
<span>{statusCfg.label}</span>
{hostLabel && (
<>
<span className="text-dark-500 mx-0.5">·</span>
<span className="text-gray-300">{hostLabel}</span>
</>
)}
</div> </div>
<div className="flex-1"></div> {/* Center: Transfer stats */}
{transferLabel && (
<div className="flex items-center gap-1 ml-4 text-gray-400">
<HardDrive className="w-3 h-3" />
<span>{transferLabel}</span>
</div>
)}
{/* Spacer */}
<div className="flex-1" />
{/* Right: Clock + version */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1 text-gray-400">
<Clock className="w-3 h-3" /> <Clock className="w-3 h-3" />
<span>{formatTime(currentTime)}</span> <span>{formatTime(currentTime)}</span>
</div> </div>
<span className="text-dark-500">OpsXShell v{version}</span>
<span className="text-blue-200">OpsXShell v{version}</span>
</div> </div>
</div> </div>
) )
+3 -114
View File
@@ -12,51 +12,7 @@ import type { TerminalViewProps } from './terminal/types'
const logger = createLogger('TerminalView') const logger = createLogger('TerminalView')
// 调试用 - 大量日志
const DEBUG = true
function debug(...args: any[]) {
if (DEBUG) {
console.log('[TerminalView]', ...args)
}
}
// 彩色高亮日志
function logHeader(msg: string) {
console.log(
`%c${msg}`,
'background: #41b883; color: #fff; padding: 4px 8px; border-radius: 4px; font-weight: bold;'
)
}
function logInfo(label: string, value: any) {
console.log(
`%c${label}:%c ${value}`,
'background: #35495e; color: #fff; padding: 2px 6px; border-radius: 3px;',
'color: #41b883; font-weight: bold;'
)
}
function logSuccess(msg: string) {
console.log(
`%c✓ ${msg}`,
'color: #41b883; font-weight: bold;'
)
}
function logError(msg: string) {
console.log(
`%c✗ ${msg}`,
'color: #ff4757; font-weight: bold;'
)
}
// 使用 logError 避免 TS 错误
if (false) logError('')
function logWarning(msg: string) {
console.log(
`%c⚠ ${msg}`,
'color: #ffa502; font-weight: bold;'
)
}
export default function TerminalView({ tabId, isActive = true }: TerminalViewProps) { export default function TerminalView({ tabId, isActive = true }: TerminalViewProps) {
debug('>>> RENDER tabId:', tabId, 'isActive:', isActive)
const terminalRef = useRef<HTMLDivElement>(null) const terminalRef = useRef<HTMLDivElement>(null)
const xtermRef = useRef<Terminal | null>(null) const xtermRef = useRef<Terminal | null>(null)
const fitAddonRef = useRef<FitAddon | null>(null) const fitAddonRef = useRef<FitAddon | null>(null)
@@ -86,13 +42,10 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
// 初始化 xterm // 初始化 xterm
useEffect(() => { useEffect(() => {
debug('>>> useEffect[init] START - terminalRef.current:', !!terminalRef.current, 'xtermRef.current:', !!xtermRef.current)
// 修复:只检查 terminalRef,不检查 xtermRef // 修复:只检查 terminalRef,不检查 xtermRef
// 因为在严格模式下,组件会 unmount/remount,xtermRef 可能还存在 // 因为在严格模式下,组件会 unmount/remount,xtermRef 可能还存在
// 但监听器需要重新注册 // 但监听器需要重新注册
if (!terminalRef.current) { if (!terminalRef.current) {
debug('>>> useEffect[init] EARLY RETURN - terminalRef is null')
return return
} }
@@ -107,8 +60,6 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
// 如果 xterm 不存在,创建它 // 如果 xterm 不存在,创建它
if (!xtermRef.current) { if (!xtermRef.current) {
debug('>>> useEffect[init] Creating Terminal...')
const term = new Terminal({ const term = new Terminal({
cursorBlink: true, cursorBlink: true,
cursorStyle: 'block', cursorStyle: 'block',
@@ -144,11 +95,9 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
term.open(terminalRef.current) term.open(terminalRef.current)
fitAddon.fit() fitAddon.fit()
debug('>>> Terminal opened, cols:', term.cols, 'rows:', term.rows)
xtermRef.current = term xtermRef.current = term
fitAddonRef.current = fitAddon fitAddonRef.current = fitAddon
debug('>>> xtermRef set:', !!xtermRef.current)
// 处理终端输入 // 处理终端输入
term.onData((data) => { term.onData((data) => {
@@ -160,8 +109,6 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
window.electronAPI.sshShellWrite(shellIdRef.current, data) window.electronAPI.sshShellWrite(shellIdRef.current, data)
} }
}) })
} else {
debug('>>> useEffect[init] xterm already exists, skipping creation')
} }
window.addEventListener('resize', handleResize) window.addEventListener('resize', handleResize)
@@ -182,18 +129,8 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
handleShellDataRef.current = (_event: unknown, _connId: string, shellId: string, data: Buffer) => { handleShellDataRef.current = (_event: unknown, _connId: string, shellId: string, data: Buffer) => {
const text = data.toString('utf-8') const text = data.toString('utf-8')
// ===== 大量调试日志 =====
logHeader('📝 SHELL DATA RECEIVED')
logInfo('shellId (received)', shellId)
logInfo('shellId (expected)', shellIdRef.current)
logInfo('xterm exists', !!xtermRef.current)
logInfo('data length', data.length)
logInfo('MATCH', shellId === shellIdRef.current)
console.log(' Data preview:', text.substring(0, 200).replace(/\n/g, '\\n'))
// 使用 ref 获取最新的 shellId,避免闭包问题 // 使用 ref 获取最新的 shellId,避免闭包问题
if (shellId === shellIdRef.current && xtermRef.current) { if (shellId === shellIdRef.current && xtermRef.current) {
logSuccess('WRITING TO XTERM')
// 处理数据,高亮 IP 地址(Last login 或 from IP 行) // 处理数据,高亮 IP 地址(Last login 或 from IP 行)
let processedData = data let processedData = data
@@ -206,7 +143,6 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
// 写入 xterm // 写入 xterm
xtermRef.current.write(processedData) xtermRef.current.write(processedData)
console.log('>>> DATA WRITTEN TO XTERM <<<')
// 更新传输统计 // 更新传输统计
const connId = connectionIdRef.current const connId = connectionIdRef.current
@@ -216,30 +152,16 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
conn.stats.bytesTransferred += data.length conn.stats.bytesTransferred += data.length
} }
} }
} else {
logWarning('SKIPPED: shellId mismatch or xterm not ready')
logInfo('Expected', shellIdRef.current)
logInfo('Received', shellId)
logInfo('xterm exists', !!xtermRef.current)
} }
} }
// 只注册一次监听器(防止严格模式下重复注册) // 只注册一次监听器(防止严格模式下重复注册)
if (!listenerRegisteredRef.current) { if (!listenerRegisteredRef.current) {
debug('>>> Registering onShellData listener')
const handler = (event: any, connectionId: string, shellId: string, data: Buffer) => { const handler = (event: any, connectionId: string, shellId: string, data: Buffer) => {
console.log('=== IPC onShellData RAW received ===')
console.log(' connectionId:', connectionId)
console.log(' shellId:', shellId)
console.log(' data type:', typeof data)
console.log(' data length:', data?.length)
handleShellDataRef.current?.(event, connectionId, shellId, data) handleShellDataRef.current?.(event, connectionId, shellId, data)
} }
window.electronAPI?.onShellData(handler) window.electronAPI?.onShellData(handler)
listenerRegisteredRef.current = true listenerRegisteredRef.current = true
debug('>>> onShellData listener registered')
} else {
debug('>>> onShellData listener already registered, skipping')
} }
return () => { return () => {
@@ -285,23 +207,14 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
// 连接管理 // 连接管理
const connect = useCallback(async () => { const connect = useCallback(async () => {
logHeader('CONNECT() CALLED')
logInfo('tabId', tabId)
logInfo('isConnectingRef', isConnectingRef.current)
logInfo('hasConnectedRef', hasConnectedRef.current)
logInfo('xtermRef', !!xtermRef.current)
if (isConnectingRef.current) { if (isConnectingRef.current) {
console.log('>>> CONNECT: Already connecting, skipping <<<')
logger.debug('[Terminal] Connection already in progress, skipping') logger.debug('[Terminal] Connection already in progress, skipping')
return return
} }
isConnectingRef.current = true isConnectingRef.current = true
console.log('>>> isConnectingRef set to true <<<')
// 直接使用 getTabById,避免 useCallback 循环问题 // 直接使用 getTabById,避免 useCallback 循环问题
const currentTab = getTabById(tabId) const currentTab = getTabById(tabId)
console.log(' currentTab:', currentTab ? 'found' : 'NOT FOUND')
if (!currentTab) { if (!currentTab) {
logger.debug('[Terminal] Tab not found:', tabId) logger.debug('[Terminal] Tab not found:', tabId)
isConnectingRef.current = false isConnectingRef.current = false
@@ -309,7 +222,6 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
} }
logger.debug('[Terminal] Starting connection for tab:', tabId, 'sessionId:', currentTab.sessionId) logger.debug('[Terminal] Starting connection for tab:', tabId, 'sessionId:', currentTab.sessionId)
console.log('>>> Starting connection for tab:', tabId, 'sessionId:', currentTab.sessionId)
// 检查是否已有连接 // 检查是否已有连接
if (connectionIdRef.current) { if (connectionIdRef.current) {
@@ -364,12 +276,9 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
if (!config) { if (!config) {
throw new Error('无可用连接信息') throw new Error('无可用连接信息')
} }
logSuccess(`Config ready: ${config.host}:${config.port} (${config.username})`)
// 创建连接 // 创建连接
console.log('>>> Calling createConnection...')
const result = await getConnectionStore().createConnection(tabId, config) const result = await getConnectionStore().createConnection(tabId, config)
console.log('>>> createConnection result:', result)
if (!result.success || !result.connectionId) { if (!result.success || !result.connectionId) {
throw new Error(result.error || '连接失败') throw new Error(result.error || '连接失败')
@@ -377,25 +286,20 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
const connectionId = result.connectionId const connectionId = result.connectionId
connectionIdRef.current = connectionId connectionIdRef.current = connectionId
logSuccess(`connectionId set: ${connectionId}`)
// 创建 Shell - 先生成前端 shellId,确保数据不会丢失 // 创建 Shell - 先生成前端 shellId,确保数据不会丢失
const pendingShellId = `shell-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` const pendingShellId = `shell-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
logger.debug('[Terminal] Pre-assigning shellId:', pendingShellId) logger.debug('[Terminal] Pre-assigning shellId:', pendingShellId)
shellIdRef.current = pendingShellId shellIdRef.current = pendingShellId
logSuccess(`shellIdRef set to: ${pendingShellId}`)
logger.debug('[Terminal] Creating shell for connection:', connectionId, 'with shellId:', pendingShellId) logger.debug('[Terminal] Creating shell for connection:', connectionId, 'with shellId:', pendingShellId)
console.log('>>> Calling sshCreateShell...')
const shellResult = await window.electronAPI?.sshCreateShell(connectionId, { const shellResult = await window.electronAPI?.sshCreateShell(connectionId, {
cols: xtermRef.current?.cols || 80, cols: xtermRef.current?.cols || 80,
rows: xtermRef.current?.rows || 24, rows: xtermRef.current?.rows || 24,
}, pendingShellId) }, pendingShellId)
console.log('>>> sshCreateShell result:', shellResult)
if (!shellResult?.success || !shellResult.shellId) { if (!shellResult?.success || !shellResult.shellId) {
logger.error('[Terminal] Shell creation failed:', shellResult?.error) logger.error('[Terminal] Shell creation failed:', shellResult?.error)
console.error('>>> Shell creation failed:', shellResult?.error)
shellIdRef.current = null shellIdRef.current = null
await getConnectionStore().disconnectConnection(connectionId, 'shell creation failed') await getConnectionStore().disconnectConnection(connectionId, 'shell creation failed')
throw new Error(shellResult?.error || '创建终端失败') throw new Error(shellResult?.error || '创建终端失败')
@@ -404,7 +308,6 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
// shellId 应该和 pendingShellId 相同(由后端确认) // shellId 应该和 pendingShellId 相同(由后端确认)
const confirmedShellId = shellResult.shellId const confirmedShellId = shellResult.shellId
logger.debug('[Terminal] Shell confirmed with ID:', confirmedShellId) logger.debug('[Terminal] Shell confirmed with ID:', confirmedShellId)
logSuccess(`Shell confirmed: ${confirmedShellId}`)
// shellIdRef 已经是 confirmedShellId(等于 pendingShellId) // shellIdRef 已经是 confirmedShellId(等于 pendingShellId)
getConnectionStore().addShell(connectionId, confirmedShellId) getConnectionStore().addShell(connectionId, confirmedShellId)
@@ -496,23 +399,15 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
// 初始连接 // 初始连接
useEffect(() => { useEffect(() => {
logHeader('INITIAL CONNECT useEffect')
logInfo('hasConnectedRef', hasConnectedRef.current)
logInfo('isActive', isActive)
if (!hasConnectedRef.current && isActive) { if (!hasConnectedRef.current && isActive) {
// 检查是否有快照 // 检查是否有快照
const hasSnapshot = snapshotStore.hasSnapshot(tabId) const hasSnapshot = snapshotStore.hasSnapshot(tabId)
logInfo('hasSnapshot', hasSnapshot)
if (hasSnapshot && isActive) { if (hasSnapshot && isActive) {
// 先恢复快照 // 先恢复快照
restoreSnapshot() restoreSnapshot()
} }
logSuccess('Calling connect() from initial useEffect')
connect() connect()
} else {
logWarning('SKIPPED: hasConnectedRef=true or isActive=false')
} }
}, []) // 只在挂载时执行 }, []) // 只在挂载时执行
@@ -564,18 +459,12 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
// 组件卸载时清理监听器 // 组件卸载时清理监听器
useEffect(() => { useEffect(() => {
logHeader('📺 TerminalView MOUNT')
logInfo('tabId', tabId)
return () => { return () => {
logHeader('📺 TerminalView UNMOUNT')
logInfo('tabId', tabId)
// 重置连接状态,防止严格模式下 remount 时状态混乱 // 重置连接状态,防止严格模式下 remount 时状态混乱
isConnectingRef.current = false isConnectingRef.current = false
listenerRegisteredRef.current = false // 重置监听器注册标记 listenerRegisteredRef.current = false // 重置监听器注册标记
logSuccess('Reset isConnectingRef and listenerRegisteredRef to false')
// 组件真正卸载时移除所有监听器 // 组件真正卸载时移除所有监听器
window.electronAPI?.removeAllListeners('ssh:shellData') window.electronAPI?.removeAllListeners('ssh:shellData')
logSuccess('Removed all ssh:shellData listeners')
} }
}, []) }, [])
@@ -598,7 +487,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
</div> </div>
<button <button
onClick={resumeConnection} onClick={resumeConnection}
className="px-4 py-2 bg-cyan-600 hover:bg-cyan-500 text-white rounded transition-colors mr-2" className="px-4 py-2 bg-accent-blue hover:bg-blue-500 text-white rounded transition-colors mr-2"
> >
恢复连接 恢复连接
</button> </button>
@@ -620,7 +509,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
<div className="text-gray-400 text-sm mb-4">{statusMessage}</div> <div className="text-gray-400 text-sm mb-4">{statusMessage}</div>
<button <button
onClick={handleReconnect} onClick={handleReconnect}
className="px-4 py-2 bg-cyan-600 hover:bg-cyan-500 text-white rounded transition-colors" className="px-4 py-2 bg-accent-blue hover:bg-blue-500 text-white rounded transition-colors"
> >
重新连接 重新连接
</button> </button>
@@ -631,7 +520,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
{/* 连接中状态 */} {/* 连接中状态 */}
{connectionState === 'connecting' && ( {connectionState === 'connecting' && (
<div className="absolute inset-0 bg-dark-900/50 flex items-center justify-center pointer-events-none z-10"> <div className="absolute inset-0 bg-dark-900/50 flex items-center justify-center pointer-events-none z-10">
<div className="text-cyan-400 animate-pulse">连接中...</div> <div className="text-accent-blue animate-pulse">连接中...</div>
</div> </div>
)} )}
+40 -22
View File
@@ -1,4 +1,5 @@
import { Terminal, FolderSync, ArrowRightLeft, Globe, Settings, Plus, Zap } from 'lucide-react' import { Terminal, FolderSync, ArrowRightLeft, Globe, Settings, Plus, Zap, Inbox } from 'lucide-react'
import { useSessionStore } from '../stores'
import { useTabStore } from '../stores/tabStore' import { useTabStore } from '../stores/tabStore'
interface WelcomeViewProps { interface WelcomeViewProps {
@@ -8,12 +9,18 @@ interface WelcomeViewProps {
export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeViewProps) { export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeViewProps) {
const { addTab } = useTabStore() const { addTab } = useTabStore()
const sessions = useSessionStore((s) => s.sessions)
const recentConnections = [ // 取最近 5 个会话作为最近连接
{ name: '本地开发服务器', host: '192.168.1.100', lastConnected: '2小时前' }, const recentSessions = sessions.slice(-5).reverse()
{ name: '生产环境', host: '10.0.0.1', lastConnected: '1天前' },
{ name: '测试服务器', host: '172.16.0.50', lastConnected: '3天前' }, const handleSessionClick = (session: typeof sessions[number]) => {
] addTab({
title: session.name || session.host,
type: 'terminal',
sessionId: session.id,
})
}
return ( return (
<div className="h-full flex flex-col items-center justify-center bg-dark-900 p-8"> <div className="h-full flex flex-col items-center justify-center bg-dark-900 p-8">
@@ -93,23 +100,34 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
{/* Recent connections */} {/* Recent connections */}
<div className="w-full max-w-2xl"> <div className="w-full max-w-2xl">
<h3 className="text-sm font-medium text-gray-400 mb-4">最近连接</h3> <h3 className="text-sm font-medium text-gray-400 mb-4">最近连接</h3>
<div className="space-y-2"> {recentSessions.length === 0 ? (
{recentConnections.map((conn, index) => ( <div className="flex flex-col items-center justify-center py-10 text-gray-500">
<button <Inbox className="w-10 h-10 mb-3 text-gray-600" />
key={index} <p className="text-sm">暂无会话记录</p>
className="w-full flex items-center justify-between p-4 bg-dark-800 hover:bg-dark-700 rounded-lg border border-dark-700 transition-colors" <p className="text-xs mt-1">点击上方「新建会话」创建第一个连接</p>
> </div>
<div className="flex items-center gap-3"> ) : (
<Globe className="w-5 h-5 text-accent-blue" /> <div className="space-y-2">
<div className="text-left"> {recentSessions.map((session) => (
<div className="font-medium text-gray-200">{conn.name}</div> <button
<div className="text-sm text-gray-500">{conn.host}</div> key={session.id}
onClick={() => handleSessionClick(session)}
className="w-full flex items-center justify-between p-4 bg-dark-800 hover:bg-dark-700 rounded-lg border border-dark-700 hover:border-accent-blue/50 transition-colors"
>
<div className="flex items-center gap-3">
<Globe className="w-5 h-5 text-accent-blue" />
<div className="text-left">
<div className="font-medium text-gray-200">{session.name}</div>
<div className="text-sm text-gray-500">
{session.username}@{session.host}{session.port !== 22 ? `:${session.port}` : ''}
</div>
</div>
</div> </div>
</div> <span className="text-xs text-gray-500">--</span>
<span className="text-xs text-gray-500">{conn.lastConnected}</span> </button>
</button> ))}
))} </div>
</div> )}
</div> </div>
{/* Shortcuts hint */} {/* Shortcuts hint */}
+4 -4
View File
@@ -17,23 +17,23 @@ export function Breadcrumb({ path, onNavigate, onHome }: BreadcrumbProps) {
<div className="flex items-center gap-1 text-sm min-w-0"> <div className="flex items-center gap-1 text-sm min-w-0">
<button <button
onClick={onHome} onClick={onHome}
className="p-1.5 hover:bg-slate-700 rounded transition-colors flex-shrink-0" className="p-1.5 hover:bg-dark-700 rounded transition-colors flex-shrink-0"
title="主目录" title="主目录"
> >
<Home className="w-4 h-4 text-gray-400" /> <Home className="w-4 h-4 text-gray-400" />
</button> </button>
<button <button
onClick={() => onNavigate('/')} onClick={() => onNavigate('/')}
className="px-1.5 py-0.5 hover:bg-slate-700 rounded text-gray-400 hover:text-slate-200 transition-colors" className="px-1.5 py-0.5 hover:bg-dark-700 rounded text-gray-400 hover:text-dark-200 transition-colors"
> >
/ /
</button> </button>
{parts.map((part, index) => ( {parts.map((part, index) => (
<span key={index} className="flex items-center min-w-0"> <span key={index} className="flex items-center min-w-0">
<ChevronRight className="w-3 h-3 text-slate-600 mx-0.5 flex-shrink-0" /> <ChevronRight className="w-3 h-3 text-dark-600 mx-0.5 flex-shrink-0" />
<button <button
onClick={() => onNavigate('/' + parts.slice(0, index + 1).join('/'))} onClick={() => onNavigate('/' + parts.slice(0, index + 1).join('/'))}
className="px-1.5 py-0.5 hover:bg-slate-700 rounded text-gray-200 hover:text-white transition-colors truncate max-w-[120px]" className="px-1.5 py-0.5 hover:bg-dark-700 rounded text-gray-200 hover:text-white transition-colors truncate max-w-[120px]"
> >
{part} {part}
</button> </button>
+2 -2
View File
@@ -41,7 +41,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
return ( return (
<div <div
ref={menuRef} ref={menuRef}
className="fixed z-50 bg-dark-700 border border-slate-700 rounded-lg shadow-xl py-1 min-w-[180px]" className="fixed z-50 bg-dark-700 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[180px]"
style={{ left: Math.min(x, window.innerWidth - 200), top: Math.min(y, window.innerHeight - 300) }} style={{ left: Math.min(x, window.innerWidth - 200), top: Math.min(y, window.innerHeight - 300) }}
> >
{items.map((item, index) => ( {items.map((item, index) => (
@@ -52,7 +52,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
onClose() onClose()
}} }}
className={`w-full flex items-center gap-3 px-4 py-2 text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-4 py-2 text-sm transition-colors ${
item.danger ? 'text-red-400 hover:bg-red-500/20' : 'text-gray-200 hover:bg-slate-700 hover:text-white' item.danger ? 'text-red-400 hover:bg-red-500/20' : 'text-gray-200 hover:bg-dark-700 hover:text-white'
}`} }`}
> >
{item.icon} {item.icon}
+2 -2
View File
@@ -24,12 +24,12 @@ export function FileRow({ file, isSelected, onSelect, onOpen, onContextMenu, onD
draggable={file.type === 'file'} draggable={file.type === 'file'}
onDragStart={onDragStart} onDragStart={onDragStart}
className={`h-10 flex items-center px-4 cursor-pointer border-b border-dark-700/50 transition-colors group ${ className={`h-10 flex items-center px-4 cursor-pointer border-b border-dark-700/50 transition-colors group ${
isSelected ? 'bg-cyan-500/20 hover:bg-cyan-500/25' : 'hover:bg-dark-700/50' isSelected ? 'bg-accent-blue/20 hover:bg-accent-blue/25' : 'hover:bg-dark-700/50'
}`} }`}
> >
<div className="flex-1 flex items-center gap-2 min-w-0"> <div className="flex-1 flex items-center gap-2 min-w-0">
{file.type === 'directory' ? ( {file.type === 'directory' ? (
<Folder className="w-4 h-4 text-sky-400 flex-shrink-0" /> <Folder className="w-4 h-4 text-accent-blue flex-shrink-0" />
) : file.type === 'symlink' ? ( ) : file.type === 'symlink' ? (
<Folder className="w-4 h-4 text-yellow-400 flex-shrink-0" /> <Folder className="w-4 h-4 text-yellow-400 flex-shrink-0" />
) : ( ) : (
+5 -5
View File
@@ -17,7 +17,7 @@ export function TransferItem({ task, onCancel, onRemove }: TransferItemProps) {
return ( return (
<div className="flex items-center gap-3 px-3 py-2 bg-dark-700/50 rounded-lg group"> <div className="flex items-center gap-3 px-3 py-2 bg-dark-700/50 rounded-lg group">
<div className={`flex-shrink-0 ${task.type === 'download' ? 'text-cyan-400' : 'text-green-400'}`}> <div className={`flex-shrink-0 ${task.type === 'download' ? 'text-accent-blue' : 'text-green-400'}`}>
{task.type === 'download' ? <Download className="w-4 h-4" /> : <Upload className="w-4 h-4" />} {task.type === 'download' ? <Download className="w-4 h-4" /> : <Upload className="w-4 h-4" />}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -25,8 +25,8 @@ export function TransferItem({ task, onCancel, onRemove }: TransferItemProps) {
<div className="flex items-center gap-2 mt-0.5"> <div className="flex items-center gap-2 mt-0.5">
{isActive && ( {isActive && (
<> <>
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden max-w-[120px]"> <div className="flex-1 h-1.5 bg-dark-700 rounded-full overflow-hidden max-w-[120px]">
<div className="h-full bg-cyan-400 transition-all duration-300" style={{ width: `${task.progress}%` }} /> <div className="h-full bg-accent-blue transition-all duration-300" style={{ width: `${task.progress}%` }} />
</div> </div>
<span className="text-xs text-gray-500">{task.progress}%</span> <span className="text-xs text-gray-500">{task.progress}%</span>
{task.speed !== undefined && <span className="text-xs text-gray-500">{formatSpeed(task.speed)}</span>} {task.speed !== undefined && <span className="text-xs text-gray-500">{formatSpeed(task.speed)}</span>}
@@ -62,7 +62,7 @@ export function TransferItem({ task, onCancel, onRemove }: TransferItemProps) {
{task.status === 'transferring' && ( {task.status === 'transferring' && (
<button <button
onClick={onCancel} onClick={onCancel}
className="p-1.5 hover:bg-slate-700 rounded text-gray-400 hover:text-red-400" className="p-1.5 hover:bg-dark-700 rounded text-gray-400 hover:text-red-400"
title="取消" title="取消"
> >
<X className="w-3.5 h-3.5" /> <X className="w-3.5 h-3.5" />
@@ -71,7 +71,7 @@ export function TransferItem({ task, onCancel, onRemove }: TransferItemProps) {
{(task.status === 'completed' || task.status === 'failed' || task.status === 'canceled') && ( {(task.status === 'completed' || task.status === 'failed' || task.status === 'canceled') && (
<button <button
onClick={onRemove} onClick={onRemove}
className="p-1.5 hover:bg-slate-700 rounded text-gray-400 hover:text-gray-200" className="p-1.5 hover:bg-dark-700 rounded text-gray-400 hover:text-gray-200"
title="移除" title="移除"
> >
<X className="w-3.5 h-3.5" /> <X className="w-3.5 h-3.5" />