Initial commit
This commit is contained in:
0
src/modules/.gitkeep
Normal file
0
src/modules/.gitkeep
Normal file
9
src/modules/home/HomePage.tsx
Normal file
9
src/modules/home/HomePage.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
export const HomePage = () => {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<h1 className="text-3xl font-light text-gray-600 dark:text-gray-300">
|
||||
开始今天的工作吧!
|
||||
</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
src/modules/home/index.tsx
Normal file
11
src/modules/home/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Home } from 'lucide-react'
|
||||
import { HomePage } from './HomePage'
|
||||
import { HOME_MODULE } from '@shared/modules/home'
|
||||
import { createFrontendModule } from '@/lib/module-registry'
|
||||
|
||||
export default createFrontendModule(HOME_MODULE, {
|
||||
icon: Home,
|
||||
component: HomePage,
|
||||
})
|
||||
|
||||
export { HomePage }
|
||||
18
src/modules/index.ts
Normal file
18
src/modules/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { registry, FrontendModuleConfig } from '@/lib/module-registry'
|
||||
|
||||
const moduleFiles = import.meta.glob('./*/index.tsx', { eager: true })
|
||||
|
||||
Object.entries(moduleFiles).forEach(([path, module]) => {
|
||||
const mod = module as { default?: FrontendModuleConfig; module?: FrontendModuleConfig }
|
||||
const config = mod.default || mod.module
|
||||
if (config) {
|
||||
registry.register(config)
|
||||
}
|
||||
})
|
||||
|
||||
export const modules = Object.entries(moduleFiles)
|
||||
.map(([path, module]) => {
|
||||
const mod = module as { default?: FrontendModuleConfig; module?: FrontendModuleConfig }
|
||||
return mod.default || mod.module
|
||||
})
|
||||
.filter(Boolean)
|
||||
365
src/modules/pydemos/PyDemosPage.tsx
Normal file
365
src/modules/pydemos/PyDemosPage.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Play, ChevronLeft, ChevronRight, Plus, Loader2 } from 'lucide-react'
|
||||
import { fetchPyDemos, createPyDemoWithFiles, deletePyDemo, renamePyDemo, type PyDemoMonth } from './api'
|
||||
import { ContextMenu } from '@/components/common/ContextMenu'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { DialogContent } from '@/components/common/DialogContent'
|
||||
import { PythonIcon } from '@/components/icons/PythonIcon'
|
||||
import { CreatePyDemoDialog } from '@/components/dialogs/CreatePyDemoDialog'
|
||||
|
||||
const formatDate = (dateStr: string): string => {
|
||||
const date = new Date(dateStr)
|
||||
const month = date.getMonth() + 1
|
||||
const day = date.getDate()
|
||||
return `${month}月${day}日`
|
||||
}
|
||||
|
||||
export const PyDemosPage: React.FC = () => {
|
||||
const [months, setMonths] = useState<PyDemoMonth[]>([])
|
||||
const [currentYear, setCurrentYear] = useState(new Date().getFullYear())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const now = new Date()
|
||||
const isCurrentYear = currentYear === now.getFullYear()
|
||||
const currentMonth = now.getMonth() + 1
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
demo: { name: string; path: string } | null
|
||||
}>({
|
||||
isOpen: false,
|
||||
position: { x: 0, y: 0 },
|
||||
demo: null
|
||||
})
|
||||
|
||||
const [createDialog, setCreateDialog] = useState(false)
|
||||
|
||||
const [renameDialog, setRenameDialog] = useState<{
|
||||
isOpen: boolean
|
||||
oldPath: string
|
||||
oldName: string
|
||||
}>({ isOpen: false, oldPath: '', oldName: '' })
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const [renameError, setRenameError] = useState<string | null>(null)
|
||||
|
||||
const [deleteDialog, setDeleteDialog] = useState<{
|
||||
isOpen: boolean
|
||||
demo: { name: string; path: string } | null
|
||||
}>({ isOpen: false, demo: null })
|
||||
const [deleteInput, setDeleteInput] = useState('')
|
||||
|
||||
const [backgroundContextMenu, setBackgroundContextMenu] = useState<{
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
}>({ isOpen: false, position: { x: 0, y: 0 } })
|
||||
|
||||
const loadDemos = useCallback(async (year: number) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await fetchPyDemos(year)
|
||||
setMonths(data.months)
|
||||
} catch (err) {
|
||||
console.error('Failed to load pydemos:', err)
|
||||
setError('加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadDemos(currentYear)
|
||||
}, [currentYear, loadDemos])
|
||||
|
||||
const handlePrevYear = () => {
|
||||
setCurrentYear(currentYear - 1)
|
||||
setBackgroundContextMenu(prev => ({ ...prev, isOpen: false }))
|
||||
}
|
||||
|
||||
const handleNextYear = () => {
|
||||
setCurrentYear(currentYear + 1)
|
||||
setBackgroundContextMenu(prev => ({ ...prev, isOpen: false }))
|
||||
}
|
||||
|
||||
const handleDemoClick = (demo: { name: string; path: string }) => {
|
||||
console.log('Open demo:', demo.path, demo.name)
|
||||
}
|
||||
|
||||
const handleDemoContextMenu = (e: React.MouseEvent, demo: { name: string; path: string }) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setContextMenu({
|
||||
isOpen: true,
|
||||
position: { x: e.clientX, y: e.clientY },
|
||||
demo
|
||||
})
|
||||
}
|
||||
|
||||
const handleCloseContextMenu = () => {
|
||||
setContextMenu(prev => ({ ...prev, isOpen: false }))
|
||||
}
|
||||
|
||||
const handleBackgroundContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setBackgroundContextMenu({
|
||||
isOpen: true,
|
||||
position: { x: e.clientX, y: e.clientY }
|
||||
})
|
||||
}
|
||||
|
||||
const handleOpenCreateDialog = () => {
|
||||
setCreateDialog(true)
|
||||
handleCloseContextMenu()
|
||||
setBackgroundContextMenu(prev => ({ ...prev, isOpen: false }))
|
||||
}
|
||||
|
||||
const handleCreateDemo = async (name: string, files: { file: File; relativePath: string }[]) => {
|
||||
await createPyDemoWithFiles(name, currentYear, currentMonth, files)
|
||||
loadDemos(currentYear)
|
||||
}
|
||||
|
||||
const handleOpenRenameDialog = () => {
|
||||
if (!contextMenu.demo) return
|
||||
setRenameValue(contextMenu.demo.name)
|
||||
setRenameError(null)
|
||||
setRenameDialog({
|
||||
isOpen: true,
|
||||
oldPath: contextMenu.demo.path,
|
||||
oldName: contextMenu.demo.name
|
||||
})
|
||||
handleCloseContextMenu()
|
||||
}
|
||||
|
||||
const handleRenameDemo = async () => {
|
||||
const newName = renameValue.trim()
|
||||
if (!newName) {
|
||||
setRenameError('请输入名称')
|
||||
return
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_\-\u4e00-\u9fa5]+$/.test(newName)) {
|
||||
setRenameError('名称只能包含字母、数字、下划线、中划线和中文')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await renamePyDemo(renameDialog.oldPath, newName)
|
||||
setRenameDialog({ isOpen: false, oldPath: '', oldName: '' })
|
||||
loadDemos(currentYear)
|
||||
} catch (err) {
|
||||
console.error('Failed to rename demo:', err)
|
||||
setRenameError('重命名失败,可能已存在同名Demo')
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenDeleteDialog = () => {
|
||||
if (!contextMenu.demo) return
|
||||
setDeleteInput('')
|
||||
setDeleteDialog({
|
||||
isOpen: true,
|
||||
demo: contextMenu.demo
|
||||
})
|
||||
handleCloseContextMenu()
|
||||
}
|
||||
|
||||
const handleDeleteDemo = async () => {
|
||||
if (!deleteDialog.demo) return
|
||||
if (deleteInput !== 'DELETE') return
|
||||
try {
|
||||
await deletePyDemo(deleteDialog.demo.path)
|
||||
setDeleteDialog({ isOpen: false, demo: null })
|
||||
loadDemos(currentYear)
|
||||
} catch (err) {
|
||||
console.error('Failed to delete demo:', err)
|
||||
alert('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const contextMenuItems = [
|
||||
{ label: '重命名', onClick: handleOpenRenameDialog },
|
||||
{ label: '删除', onClick: handleOpenDeleteDialog }
|
||||
]
|
||||
|
||||
const backgroundContextMenuItems = isCurrentYear
|
||||
? [{ label: '新建 Demo', onClick: handleOpenCreateDialog }]
|
||||
: []
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full w-full overflow-y-auto"
|
||||
onContextMenu={handleBackgroundContextMenu}
|
||||
>
|
||||
<div className="max-w-4xl mx-auto w-full p-6 min-h-full">
|
||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-gray-200 mb-8 flex items-center gap-2">
|
||||
<Play className="w-8 h-8" />
|
||||
Python Demo
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{isCurrentYear && (
|
||||
<button
|
||||
onClick={handleOpenCreateDialog}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors text-sm text-gray-600 dark:text-gray-300"
|
||||
title="新建 Demo"
|
||||
>
|
||||
<Plus size={16} />
|
||||
新建
|
||||
</button>
|
||||
)}
|
||||
<div className={`flex items-center gap-2 ${!isCurrentYear ? 'ml-auto' : ''}`}>
|
||||
<button
|
||||
onClick={handlePrevYear}
|
||||
className="p-1.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<span className="text-base font-medium text-gray-700 dark:text-gray-300 min-w-[80px] text-center">
|
||||
{currentYear}年
|
||||
</span>
|
||||
<button
|
||||
onClick={handleNextYear}
|
||||
className="p-1.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 size={32} className="animate-spin text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-4 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && months.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<Play size={48} className="mx-auto mb-4 opacity-30" />
|
||||
<p>暂无 Python Demo</p>
|
||||
{isCurrentYear && (
|
||||
<p className="text-sm mt-2">点击右上角 + 按钮或右键空白处创建</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && months.length > 0 && (
|
||||
<div className="space-y-8">
|
||||
{months.map((monthData) => (
|
||||
<div key={monthData.month}>
|
||||
<h2 className="text-lg font-semibold text-gray-700 dark:text-gray-300 mb-4 flex items-center gap-2">
|
||||
<span className="w-8 h-px bg-gray-300 dark:bg-gray-600" />
|
||||
{monthData.month}月
|
||||
<span className="flex-1 h-px bg-gray-300 dark:bg-gray-600" />
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{monthData.demos.map((demo) => (
|
||||
<div
|
||||
key={demo.path}
|
||||
className="bg-white/60 dark:bg-gray-900/60 backdrop-blur-md rounded-xl border border-gray-200/50 dark:border-gray-700/60 shadow-sm p-4 cursor-pointer hover:shadow-md transition-all"
|
||||
onClick={() => handleDemoClick(demo)}
|
||||
onContextMenu={(e) => handleDemoContextMenu(e, demo)}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<PythonIcon size={18} className="text-gray-500 dark:text-gray-400 shrink-0" />
|
||||
<span className="font-medium text-gray-800 dark:text-gray-200 truncate">
|
||||
{demo.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{formatDate(demo.created)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{demo.fileCount} 个文件
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreatePyDemoDialog
|
||||
isOpen={createDialog}
|
||||
onClose={() => setCreateDialog(false)}
|
||||
onConfirm={handleCreateDemo}
|
||||
year={currentYear}
|
||||
month={currentMonth}
|
||||
/>
|
||||
|
||||
<Modal isOpen={renameDialog.isOpen} onClose={() => setRenameDialog(prev => ({ ...prev, isOpen: false }))}>
|
||||
<DialogContent
|
||||
title="重命名 Demo"
|
||||
onCancel={() => setRenameDialog(prev => ({ ...prev, isOpen: false }))}
|
||||
confirmText="确定"
|
||||
onConfirm={handleRenameDemo}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-gray-500 focus:border-transparent dark:bg-gray-700 dark:text-gray-100 ${renameError ? 'border-red-400' : 'border-gray-300 dark:border-gray-600'}`}
|
||||
placeholder="新名称"
|
||||
value={renameValue}
|
||||
onChange={(e) => {
|
||||
setRenameValue(e.target.value)
|
||||
setRenameError(null)
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
{renameError && (
|
||||
<div className="mt-2 text-sm text-red-600">{renameError}</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Modal>
|
||||
|
||||
<Modal isOpen={deleteDialog.isOpen} onClose={() => setDeleteDialog(prev => ({ ...prev, isOpen: false }))}>
|
||||
<DialogContent
|
||||
title="删除 Python Demo"
|
||||
onCancel={() => setDeleteDialog(prev => ({ ...prev, isOpen: false }))}
|
||||
confirmText="删除"
|
||||
onConfirm={handleDeleteDemo}
|
||||
isConfirmDisabled={deleteInput !== 'DELETE'}
|
||||
confirmButtonVariant="danger"
|
||||
>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||
确定要删除 "{deleteDialog.demo?.name}" 吗?此操作将删除文件夹中的所有文件,且不可撤销。
|
||||
</p>
|
||||
<div className="mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
请输入 <span className="font-mono font-bold text-red-600 dark:text-red-400">DELETE</span> 以确认删除:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={deleteInput}
|
||||
onChange={(e) => setDeleteInput(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-gray-500 focus:border-gray-500 sm:text-sm dark:bg-gray-700 dark:text-gray-100"
|
||||
placeholder="DELETE"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
<ContextMenu
|
||||
isOpen={contextMenu.isOpen}
|
||||
position={contextMenu.position}
|
||||
items={contextMenuItems}
|
||||
onClose={handleCloseContextMenu}
|
||||
/>
|
||||
|
||||
<ContextMenu
|
||||
isOpen={backgroundContextMenu.isOpen}
|
||||
position={backgroundContextMenu.position}
|
||||
items={backgroundContextMenuItems}
|
||||
onClose={() => setBackgroundContextMenu(prev => ({ ...prev, isOpen: false }))}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
57
src/modules/pydemos/api.ts
Normal file
57
src/modules/pydemos/api.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { getModuleApi } from '@/lib/module-registry'
|
||||
import type { PyDemosEndpoints } from '@shared/modules/pydemos'
|
||||
import type { PyDemoMonth } from '@shared/types/pydemos'
|
||||
|
||||
const getApi = () => {
|
||||
const api = getModuleApi<PyDemosEndpoints>('pydemos')
|
||||
if (!api) {
|
||||
throw new Error('pydemos module API not initialized')
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
export const fetchPyDemos = async (year: number): Promise<{ months: PyDemoMonth[] }> => {
|
||||
return (await getApi().get<{ months: PyDemoMonth[] }>('list', {
|
||||
queryParams: { year },
|
||||
}))!
|
||||
}
|
||||
|
||||
export const createPyDemo = async (name: string, year: number, month: number): Promise<{ path: string }> => {
|
||||
return (await getApi().post<{ path: string }>('create', { name, year, month }))!
|
||||
}
|
||||
|
||||
export interface SelectedFile {
|
||||
file: File
|
||||
relativePath: string
|
||||
}
|
||||
|
||||
export const createPyDemoWithFiles = async (
|
||||
name: string,
|
||||
year: number,
|
||||
month: number,
|
||||
files: SelectedFile[]
|
||||
): Promise<{ path: string; fileCount: number }> => {
|
||||
const formData = new FormData()
|
||||
formData.append('name', name)
|
||||
formData.append('year', year.toString())
|
||||
formData.append('month', month.toString())
|
||||
|
||||
const folderStructure: Record<string, string> = {}
|
||||
for (const { file, relativePath } of files) {
|
||||
formData.append('files', file)
|
||||
folderStructure[relativePath] = relativePath
|
||||
}
|
||||
formData.append('folderStructure', JSON.stringify(folderStructure))
|
||||
|
||||
return (await getApi().postFormData<{ path: string; fileCount: number }>('create', formData))!
|
||||
}
|
||||
|
||||
export const deletePyDemo = async (path: string): Promise<void> => {
|
||||
await getApi().delete<null>('delete', { path })
|
||||
}
|
||||
|
||||
export const renamePyDemo = async (oldPath: string, newName: string): Promise<{ newPath: string }> => {
|
||||
return (await getApi().post<{ newPath: string }>('rename', { oldPath, newName }))!
|
||||
}
|
||||
|
||||
export type { PyDemoMonth }
|
||||
13
src/modules/pydemos/index.tsx
Normal file
13
src/modules/pydemos/index.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Play } from 'lucide-react'
|
||||
import { PyDemosPage } from './PyDemosPage'
|
||||
import { PYDEMOS_MODULE } from '@shared/modules/pydemos'
|
||||
import { createFrontendModule } from '@/lib/module-registry'
|
||||
|
||||
export default createFrontendModule(PYDEMOS_MODULE, {
|
||||
icon: Play,
|
||||
component: PyDemosPage,
|
||||
})
|
||||
|
||||
export { PyDemosPage } from './PyDemosPage'
|
||||
export { fetchPyDemos, createPyDemo, createPyDemoWithFiles, deletePyDemo, renamePyDemo } from './api'
|
||||
export type { PyDemoMonth } from './api'
|
||||
283
src/modules/recycle-bin/RecycleBinPage.tsx
Normal file
283
src/modules/recycle-bin/RecycleBinPage.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import React, { useEffect, useState, useRef } from 'react'
|
||||
import { Trash2, FileText, Folder, Loader2 } from 'lucide-react'
|
||||
import { useWallpaper } from '@/stores'
|
||||
import { fetchRecycleBin, restoreItem, permanentlyDeleteItem, emptyRecycleBin } from './api'
|
||||
import type { RecycleBinGroupDTO, RecycleBinItemDTO } from '@shared/types'
|
||||
import { ContextMenu } from '@/components/common/ContextMenu'
|
||||
import { DeleteConfirmDialog } from '@/components/dialogs/DeleteConfirmDialog'
|
||||
|
||||
type NotebookEvent = {
|
||||
event: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string): string => {
|
||||
if (dateStr.length !== 8) return dateStr
|
||||
const year = dateStr.substring(0, 4)
|
||||
const month = dateStr.substring(4, 6)
|
||||
const day = dateStr.substring(6, 8)
|
||||
return `${year}年${month}月${day}日`
|
||||
}
|
||||
|
||||
export const RecycleBinPage: React.FC = () => {
|
||||
const { opacity } = useWallpaper()
|
||||
const [groups, setGroups] = useState<RecycleBinGroupDTO[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
item: RecycleBinItemDTO | null
|
||||
}>({
|
||||
isOpen: false,
|
||||
position: { x: 0, y: 0 },
|
||||
item: null
|
||||
})
|
||||
const [deleteDialog, setDeleteDialog] = useState<{
|
||||
isOpen: boolean
|
||||
item: RecycleBinItemDTO | null
|
||||
}>({
|
||||
isOpen: false,
|
||||
item: null
|
||||
})
|
||||
const [emptyDialog, setEmptyDialog] = useState(false)
|
||||
|
||||
const loadRecycleBinRef = useRef<(showLoading?: boolean) => Promise<void>>()
|
||||
|
||||
const loadRecycleBin = async (showLoading = true) => {
|
||||
if (showLoading) {
|
||||
setLoading(true)
|
||||
}
|
||||
setError(null)
|
||||
try {
|
||||
const data = await fetchRecycleBin()
|
||||
setGroups(data.groups)
|
||||
} catch (err) {
|
||||
console.error('Failed to load recycle bin:', err)
|
||||
setError('加载回收站失败')
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadRecycleBinRef.current = loadRecycleBin
|
||||
|
||||
useEffect(() => {
|
||||
loadRecycleBin()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const eventSource = new EventSource('/api/events')
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data) as NotebookEvent
|
||||
if (
|
||||
data.event === 'change' ||
|
||||
data.event === 'add' ||
|
||||
data.event === 'unlink' ||
|
||||
data.event === 'addDir' ||
|
||||
data.event === 'unlinkDir'
|
||||
) {
|
||||
if (data.path && data.path.startsWith('RB/')) {
|
||||
loadRecycleBinRef.current?.(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
eventSource.close()
|
||||
}
|
||||
|
||||
return () => {
|
||||
eventSource.close()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, item: RecycleBinItemDTO) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setContextMenu({
|
||||
isOpen: true,
|
||||
position: { x: e.clientX, y: e.clientY },
|
||||
item
|
||||
})
|
||||
}
|
||||
|
||||
const handleCloseContextMenu = () => {
|
||||
setContextMenu(prev => ({ ...prev, isOpen: false }))
|
||||
}
|
||||
|
||||
const handleRestore = async () => {
|
||||
if (!contextMenu.item) return
|
||||
try {
|
||||
await restoreItem(contextMenu.item.path, contextMenu.item.type)
|
||||
handleCloseContextMenu()
|
||||
loadRecycleBin(false)
|
||||
} catch (err) {
|
||||
console.error('Failed to restore:', err)
|
||||
alert('恢复失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePermanentDeleteClick = () => {
|
||||
if (!contextMenu.item) return
|
||||
handleCloseContextMenu()
|
||||
setDeleteDialog({
|
||||
isOpen: true,
|
||||
item: contextMenu.item
|
||||
})
|
||||
}
|
||||
|
||||
const handlePermanentDeleteConfirm = async () => {
|
||||
if (!deleteDialog.item) return
|
||||
try {
|
||||
await permanentlyDeleteItem(deleteDialog.item.path, deleteDialog.item.type)
|
||||
setDeleteDialog({ isOpen: false, item: null })
|
||||
loadRecycleBin(false)
|
||||
} catch (err) {
|
||||
console.error('Failed to delete:', err)
|
||||
alert('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteDialogCancel = () => {
|
||||
setDeleteDialog({ isOpen: false, item: null })
|
||||
}
|
||||
|
||||
const handleEmptyClick = () => {
|
||||
setEmptyDialog(true)
|
||||
}
|
||||
|
||||
const handleEmptyConfirm = async () => {
|
||||
try {
|
||||
await emptyRecycleBin()
|
||||
setEmptyDialog(false)
|
||||
loadRecycleBin(false)
|
||||
} catch (err) {
|
||||
console.error('Failed to empty recycle bin:', err)
|
||||
alert('清空回收站失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleEmptyDialogCancel = () => {
|
||||
setEmptyDialog(false)
|
||||
}
|
||||
|
||||
const contextMenuItems = [
|
||||
{ label: '恢复', onClick: handleRestore },
|
||||
{ label: '彻底删除', onClick: handlePermanentDeleteClick }
|
||||
]
|
||||
|
||||
const deleteItem = deleteDialog.item
|
||||
const deleteExpectedText = deleteItem?.type === 'dir' ? 'DESTROY FOLDER' : 'DESTROY FILE'
|
||||
const deleteTitle = deleteItem?.type === 'dir' ? '彻底删除文件夹' : '彻底删除文件'
|
||||
const deleteMessage = deleteItem?.type === 'dir'
|
||||
? `确定要彻底删除文件夹 "${deleteItem?.originalName}" 吗?此操作不可撤销,文件夹中的所有内容都将被永久删除。`
|
||||
: `确定要彻底删除文件 "${deleteItem?.originalName}" 吗?此操作不可撤销,文件将被永久删除。`
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto w-full p-6">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Trash2 size={28} className="text-gray-600 dark:text-gray-300" />
|
||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-gray-200">回收站</h1>
|
||||
</div>
|
||||
{groups.length > 0 && (
|
||||
<button
|
||||
onClick={handleEmptyClick}
|
||||
className="px-4 py-2 text-sm bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 size={32} className="animate-spin text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-4 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && groups.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<Trash2 size={48} className="mx-auto mb-4 opacity-30" />
|
||||
<p>回收站是空的</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && groups.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
{groups.map((group) => (
|
||||
<div
|
||||
key={group.date}
|
||||
className="bg-white/60 dark:bg-gray-900/60 backdrop-blur-md rounded-xl border border-gray-200/50 dark:border-gray-700/60 shadow-sm overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="px-4 py-3 border-b border-gray-200/50 dark:border-gray-700/60 font-medium text-gray-700 dark:text-gray-200"
|
||||
style={{
|
||||
backgroundColor: `rgba(var(--app-content-bg-rgb), ${opacity * 0.8})`
|
||||
}}
|
||||
>
|
||||
{formatDate(group.date)}
|
||||
</div>
|
||||
<div className="divide-y divide-gray-200/50 dark:divide-gray-700/60">
|
||||
{group.items.map((item) => (
|
||||
<div
|
||||
key={item.path}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-gray-100/50 dark:hover:bg-gray-800/50 transition-colors cursor-pointer"
|
||||
onContextMenu={(e) => handleContextMenu(e, item)}
|
||||
>
|
||||
{item.type === 'dir' ? (
|
||||
<Folder size={20} className="text-gray-500 dark:text-gray-400 shrink-0" />
|
||||
) : (
|
||||
<FileText size={20} className="text-gray-500 dark:text-gray-400 shrink-0" />
|
||||
)}
|
||||
<span className="text-gray-700 dark:text-gray-200 truncate">
|
||||
{item.originalName}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ContextMenu
|
||||
isOpen={contextMenu.isOpen}
|
||||
position={contextMenu.position}
|
||||
items={contextMenuItems}
|
||||
onClose={handleCloseContextMenu}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
isOpen={deleteDialog.isOpen}
|
||||
title={deleteTitle}
|
||||
message={deleteMessage}
|
||||
expectedText={deleteExpectedText}
|
||||
confirmText="彻底删除"
|
||||
onConfirm={handlePermanentDeleteConfirm}
|
||||
onCancel={handleDeleteDialogCancel}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
isOpen={emptyDialog}
|
||||
title="清空回收站"
|
||||
message="确定要清空回收站吗?此操作将永久删除回收站中的所有文件和文件夹,且不可撤销。"
|
||||
expectedText="DESTROY EVERYTHING"
|
||||
confirmText="清空"
|
||||
buttonVariant="primary"
|
||||
onConfirm={handleEmptyConfirm}
|
||||
onCancel={handleEmptyDialogCancel}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
src/modules/recycle-bin/api.ts
Normal file
27
src/modules/recycle-bin/api.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { getModuleApi } from '@/lib/module-registry'
|
||||
import type { RecycleBinEndpoints } from '@shared/modules/recycle-bin'
|
||||
import type { RecycleBinGroupDTO } from '@shared/types/recycle-bin'
|
||||
|
||||
const getApi = () => {
|
||||
const api = getModuleApi<RecycleBinEndpoints>('recycle-bin')
|
||||
if (!api) {
|
||||
throw new Error('recycle-bin module API not initialized')
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
export const fetchRecycleBin = async (): Promise<{ groups: RecycleBinGroupDTO[] }> => {
|
||||
return (await getApi().get<{ groups: RecycleBinGroupDTO[] }>('list'))!
|
||||
}
|
||||
|
||||
export const restoreItem = async (path: string, type: 'file' | 'dir'): Promise<void> => {
|
||||
await getApi().post<null>('restore', { path, type })
|
||||
}
|
||||
|
||||
export const permanentlyDeleteItem = async (path: string, type: 'file' | 'dir'): Promise<void> => {
|
||||
await getApi().delete<null>('permanent', { path, type })
|
||||
}
|
||||
|
||||
export const emptyRecycleBin = async (): Promise<void> => {
|
||||
await getApi().delete<null>('empty')
|
||||
}
|
||||
12
src/modules/recycle-bin/index.tsx
Normal file
12
src/modules/recycle-bin/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { RecycleBinPage } from './RecycleBinPage'
|
||||
import { RECYCLE_BIN_MODULE } from '@shared/modules/recycle-bin'
|
||||
import { createFrontendModule } from '@/lib/module-registry'
|
||||
|
||||
export default createFrontendModule(RECYCLE_BIN_MODULE, {
|
||||
icon: Trash2,
|
||||
component: RecycleBinPage,
|
||||
})
|
||||
|
||||
export { RecycleBinPage } from './RecycleBinPage'
|
||||
export { fetchRecycleBin, restoreItem, permanentlyDeleteItem, emptyRecycleBin } from './api'
|
||||
602
src/modules/remote/RemotePage.tsx
Normal file
602
src/modules/remote/RemotePage.tsx
Normal file
@@ -0,0 +1,602 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Monitor, GitBranch, Settings, Plus, Trash2, GripVertical, Folder } from 'lucide-react'
|
||||
import { ConfigDialog } from './components/ConfigDialog'
|
||||
import { DeleteConfirmDialog } from '@/components/dialogs/DeleteConfirmDialog'
|
||||
import type { DeviceInfo, RemoteConfig, RemoteDevice } from './types'
|
||||
import { useTabContentContext, useScreenshot } from '@/stores'
|
||||
import { getRemoteConfig, saveRemoteConfig, getDeviceData } from './api'
|
||||
import type { FileItem } from '@/lib/api'
|
||||
|
||||
const defaultConfig: RemoteConfig = {
|
||||
devices: [],
|
||||
}
|
||||
|
||||
const createRemoteDesktopFileItem = (url: string, title: string, deviceName?: string): FileItem => {
|
||||
const id = deviceName ? `remote-${deviceName}` : 'remote-desktop'
|
||||
return {
|
||||
name: title,
|
||||
path: `remote-desktop://${id}?url=${encodeURIComponent(url)}&device=${encodeURIComponent(deviceName || '')}`,
|
||||
type: 'file' as const,
|
||||
size: 0,
|
||||
modified: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
const createRemoteGitFileItem = (url: string, title: string, deviceName?: string): FileItem => {
|
||||
const id = deviceName ? `remote-git-${deviceName}` : 'remote-git'
|
||||
return {
|
||||
name: title,
|
||||
path: `remote-git://${id}?url=${encodeURIComponent(url)}&device=${encodeURIComponent(deviceName || '')}`,
|
||||
type: 'file' as const,
|
||||
size: 0,
|
||||
modified: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
const createEmptyDevice = (): RemoteDevice => ({
|
||||
id: Date.now().toString(),
|
||||
deviceName: '',
|
||||
serverHost: '',
|
||||
desktopPort: 3000,
|
||||
gitPort: 3001,
|
||||
})
|
||||
|
||||
export const RemotePage: React.FC = () => {
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null)
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
||||
const [showConfig, setShowConfig] = useState(false)
|
||||
const [config, setConfig] = useState<RemoteConfig>(defaultConfig)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editingDevice, setEditingDevice] = useState<RemoteDevice | null>(null)
|
||||
const [deviceLastConnected, setDeviceLastConnected] = useState<Record<string, string>>({})
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [deletingDeviceId, setDeletingDeviceId] = useState<string | null>(null)
|
||||
|
||||
// 自定义拖拽状态
|
||||
const dragRef = useRef<{
|
||||
isDragging: boolean
|
||||
startY: number
|
||||
startIndex: number
|
||||
currentIndex: number
|
||||
listRect: DOMRect | null
|
||||
itemRects: DOMRect[]
|
||||
}>({
|
||||
isDragging: false,
|
||||
startY: 0,
|
||||
startIndex: -1,
|
||||
currentIndex: -1,
|
||||
listRect: null,
|
||||
itemRects: [],
|
||||
})
|
||||
const listContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { selectFile } = useTabContentContext()
|
||||
const { screenshot, lastConnected, loadScreenshots } = useScreenshot()
|
||||
|
||||
const selectedConfig = config.devices.find(d => d.id === selectedDeviceId) || null
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
const savedConfig = await getRemoteConfig()
|
||||
const configWithDevices = savedConfig.devices ? savedConfig : { devices: [] }
|
||||
setConfig(configWithDevices)
|
||||
if (configWithDevices.devices.length > 0 && !selectedDeviceId) {
|
||||
setSelectedDeviceId(configWithDevices.devices[0].id)
|
||||
}
|
||||
// 加载所有设备的最后连接时间
|
||||
const newData: Record<string, string> = {}
|
||||
for (const device of configWithDevices.devices) {
|
||||
if (device.deviceName) {
|
||||
try {
|
||||
const data = await getDeviceData(device.deviceName)
|
||||
if (data?.lastConnected) {
|
||||
newData[device.deviceName] = data.lastConnected
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load device data:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
setDeviceLastConnected(newData)
|
||||
} catch (err) {
|
||||
console.error('Failed to load remote config:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
init()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedConfig?.deviceName) {
|
||||
loadScreenshots(selectedConfig.deviceName)
|
||||
}
|
||||
}, [selectedDeviceId, selectedConfig?.deviceName, loadScreenshots])
|
||||
|
||||
// 当全局 lastConnected 更新时,同步更新 deviceLastConnected
|
||||
useEffect(() => {
|
||||
if (selectedConfig?.deviceName) {
|
||||
setDeviceLastConnected(prev => ({
|
||||
...prev,
|
||||
[selectedConfig.deviceName]: lastConnected
|
||||
}))
|
||||
}
|
||||
}, [lastConnected, selectedConfig?.deviceName])
|
||||
|
||||
// 当设备列表变化时,加载新增设备的最后连接时间
|
||||
useEffect(() => {
|
||||
const loadNewDevices = async () => {
|
||||
for (const device of config.devices) {
|
||||
if (device.deviceName && !deviceLastConnected[device.deviceName]) {
|
||||
try {
|
||||
const data = await getDeviceData(device.deviceName)
|
||||
if (data?.lastConnected) {
|
||||
setDeviceLastConnected(prev => ({
|
||||
...prev,
|
||||
[device.deviceName]: data.lastConnected
|
||||
}))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load device data:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!loading) {
|
||||
loadNewDevices()
|
||||
}
|
||||
}, [config.devices, loading, deviceLastConnected])
|
||||
|
||||
const devices: DeviceInfo[] = (config.devices || []).map(device => ({
|
||||
id: device.id,
|
||||
name: device.deviceName,
|
||||
status: device.serverHost ? 'online' as const : 'offline' as const,
|
||||
lastConnected: deviceLastConnected[device.deviceName] || '-',
|
||||
}))
|
||||
|
||||
const selectedDevice = devices.find(device => device.id === selectedDeviceId) || null
|
||||
|
||||
const existingDeviceNames = config.devices.map(d => d.deviceName)
|
||||
|
||||
const handleOpenRemoteDesktop = () => {
|
||||
if (!selectedConfig?.serverHost) {
|
||||
setShowConfig(true)
|
||||
return
|
||||
}
|
||||
const url = `http://${selectedConfig.serverHost}:${selectedConfig.desktopPort}`
|
||||
const deviceName = selectedConfig.deviceName ? ` - ${selectedConfig.deviceName}` : ''
|
||||
const fileItem = createRemoteDesktopFileItem(url, `远程桌面${deviceName}`, selectedConfig.deviceName)
|
||||
selectFile(fileItem)
|
||||
}
|
||||
|
||||
const handleOpenGitRepo = () => {
|
||||
if (!selectedConfig?.serverHost) {
|
||||
setShowConfig(true)
|
||||
return
|
||||
}
|
||||
const url = `http://${selectedConfig.serverHost}:${selectedConfig.gitPort}`
|
||||
const deviceName = selectedConfig.deviceName ? ` - ${selectedConfig.deviceName}` : ''
|
||||
const fileItem = createRemoteGitFileItem(url, `远程 Git 仓库${deviceName}`, selectedConfig.deviceName)
|
||||
selectFile(fileItem)
|
||||
}
|
||||
|
||||
const handleOpenFileTransfer = () => {
|
||||
if (!selectedConfig?.serverHost) {
|
||||
setShowConfig(true)
|
||||
return
|
||||
}
|
||||
const url = `http://${selectedConfig.serverHost}:${selectedConfig.desktopPort}/files`
|
||||
const deviceName = selectedConfig.deviceName ? ` - ${selectedConfig.deviceName}` : ''
|
||||
const fileItem = createRemoteDesktopFileItem(url, `文件传输${deviceName}`, selectedConfig.deviceName)
|
||||
selectFile(fileItem)
|
||||
}
|
||||
|
||||
const handleOpenConfig = () => {
|
||||
if (selectedConfig) {
|
||||
setEditingDevice(selectedConfig)
|
||||
} else {
|
||||
setEditingDevice(createEmptyDevice())
|
||||
}
|
||||
setShowConfig(true)
|
||||
}
|
||||
|
||||
const handleCloseConfig = () => {
|
||||
setShowConfig(false)
|
||||
setEditingDevice(null)
|
||||
}
|
||||
|
||||
const handleSaveConfig = useCallback(async (device: RemoteDevice) => {
|
||||
const existingIndex = config.devices.findIndex(d => d.id === device.id)
|
||||
let newDevices: RemoteDevice[]
|
||||
if (existingIndex >= 0) {
|
||||
newDevices = [...config.devices]
|
||||
newDevices[existingIndex] = device
|
||||
} else {
|
||||
newDevices = [...config.devices, device]
|
||||
}
|
||||
const newConfig = { devices: newDevices }
|
||||
try {
|
||||
await saveRemoteConfig(newConfig)
|
||||
setConfig(newConfig)
|
||||
setSelectedDeviceId(device.id)
|
||||
} catch (err) {
|
||||
console.error('Failed to save config:', err)
|
||||
}
|
||||
setShowConfig(false)
|
||||
setEditingDevice(null)
|
||||
}, [config.devices])
|
||||
|
||||
const handleAddDevice = () => {
|
||||
setEditingDevice(createEmptyDevice())
|
||||
setShowConfig(true)
|
||||
}
|
||||
|
||||
const handleDeleteDevice = (deviceId: string) => {
|
||||
setDeletingDeviceId(deviceId)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deletingDeviceId) return
|
||||
const newDevices = config.devices.filter(d => d.id !== deletingDeviceId)
|
||||
const newConfig = { devices: newDevices }
|
||||
try {
|
||||
await saveRemoteConfig(newConfig)
|
||||
setConfig(newConfig)
|
||||
if (selectedDeviceId === deletingDeviceId) {
|
||||
setSelectedDeviceId(newDevices.length > 0 ? newDevices[0].id : null)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete device:', err)
|
||||
}
|
||||
setDeleteDialogOpen(false)
|
||||
setDeletingDeviceId(null)
|
||||
}
|
||||
|
||||
const handleDeleteCancel = () => {
|
||||
setDeleteDialogOpen(false)
|
||||
setDeletingDeviceId(null)
|
||||
}
|
||||
|
||||
// 自定义拖拽处理 - 元素跟随鼠标移动 + 实时重排
|
||||
const handleMouseDown = (e: React.MouseEvent, index: number) => {
|
||||
// 只允许左键拖拽
|
||||
if (e.button !== 0) return
|
||||
|
||||
// 阻止默认选择行为
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const listContainer = listContainerRef.current
|
||||
if (!listContainer) return
|
||||
|
||||
const listRect = listContainer.getBoundingClientRect()
|
||||
|
||||
// 获取被拖动元素的引用
|
||||
const listContent = listContainer.querySelector('[data-drag-list]')
|
||||
const items = listContent?.querySelectorAll('[data-drag-item]') as NodeListOf<HTMLElement> | undefined
|
||||
const draggedElement = items?.[index]
|
||||
|
||||
dragRef.current = {
|
||||
isDragging: true,
|
||||
startY: e.clientY,
|
||||
startIndex: index,
|
||||
currentIndex: index,
|
||||
listRect,
|
||||
itemRects: [],
|
||||
}
|
||||
|
||||
setDragIndex(index)
|
||||
setDragOverIndex(index)
|
||||
|
||||
// 直接操作DOM实现流畅拖动
|
||||
if (draggedElement) {
|
||||
draggedElement.style.position = 'relative'
|
||||
draggedElement.style.zIndex = '100'
|
||||
draggedElement.style.transition = 'none'
|
||||
}
|
||||
|
||||
// 添加全局鼠标事件
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const drag = dragRef.current
|
||||
if (!drag.isDragging || !drag.listRect) return
|
||||
|
||||
const { listRect, startIndex } = drag
|
||||
|
||||
// 检测鼠标是否在列表区域内
|
||||
const isInListArea =
|
||||
e.clientX >= listRect.left &&
|
||||
e.clientX <= listRect.right &&
|
||||
e.clientY >= listRect.top &&
|
||||
e.clientY <= listRect.bottom
|
||||
|
||||
// 如果鼠标移出列表区域,取消拖拽
|
||||
if (!isInListArea) {
|
||||
handleMouseUp(e)
|
||||
return
|
||||
}
|
||||
|
||||
// 直接修改DOM实现流畅移动
|
||||
const listContent = listContainerRef.current?.querySelector('[data-drag-list]')
|
||||
const items = listContent?.querySelectorAll('[data-drag-item]') as NodeListOf<HTMLElement> | undefined
|
||||
const draggedElement = items?.[drag.startIndex]
|
||||
|
||||
// 计算被拖动元素的偏移
|
||||
const deltaY = e.clientY - drag.startY
|
||||
|
||||
if (draggedElement) {
|
||||
draggedElement.style.transform = `translateY(${deltaY}px)`
|
||||
}
|
||||
|
||||
const mouseY = e.clientY
|
||||
|
||||
// 简单计算:基于鼠标在列表中的相对位置,加24px偏移使落点在缝隙中间
|
||||
const relativeY = mouseY - listRect.top - 8
|
||||
let newIndex = Math.floor(relativeY / 48) - 1
|
||||
newIndex = Math.max(0, Math.min(newIndex, devices.length - 1))
|
||||
|
||||
// 限制newIndex范围
|
||||
if (newIndex < 0) newIndex = 0
|
||||
if (newIndex >= devices.length) newIndex = devices.length - 1
|
||||
|
||||
drag.currentIndex = newIndex
|
||||
setDragOverIndex(newIndex)
|
||||
|
||||
// 直接操作其他元素DOM实现重排(去掉transition)
|
||||
if (items) {
|
||||
const itemHeight = 48
|
||||
items.forEach((item, i) => {
|
||||
if (i === drag.startIndex) return // 跳过被拖动的元素
|
||||
|
||||
let transform = ''
|
||||
if (drag.startIndex < newIndex) {
|
||||
// 从上面拖到下面:原位置到目标位置之间的元素向上移动
|
||||
if (i > drag.startIndex && i <= newIndex) {
|
||||
transform = `translateY(-${itemHeight}px)`
|
||||
}
|
||||
} else if (drag.startIndex > newIndex) {
|
||||
// 从下面拖到上面:目标位置到原位置之间的元素向下移动
|
||||
if (i >= newIndex && i < drag.startIndex) {
|
||||
transform = `translateY(${itemHeight}px)`
|
||||
}
|
||||
}
|
||||
item.style.transform = transform
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = async (e: MouseEvent) => {
|
||||
const drag = dragRef.current
|
||||
if (!drag.isDragging) return
|
||||
|
||||
drag.isDragging = false
|
||||
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
|
||||
// 清理所有元素的样式
|
||||
const listContent = listContainerRef.current?.querySelector('[data-drag-list]')
|
||||
const items = listContent?.querySelectorAll('[data-drag-item]') as NodeListOf<HTMLElement> | undefined
|
||||
items?.forEach(item => {
|
||||
item.style.transform = ''
|
||||
item.style.position = ''
|
||||
item.style.zIndex = ''
|
||||
item.style.transition = ''
|
||||
})
|
||||
const draggedElement = items?.[drag.startIndex]
|
||||
if (draggedElement) {
|
||||
draggedElement.style.transform = ''
|
||||
draggedElement.style.position = ''
|
||||
draggedElement.style.zIndex = ''
|
||||
}
|
||||
|
||||
const { startIndex, currentIndex } = drag
|
||||
|
||||
setDragIndex(null)
|
||||
setDragOverIndex(null)
|
||||
|
||||
// 如果位置发生变化,保存新顺序
|
||||
if (startIndex !== currentIndex && currentIndex >= 0) {
|
||||
const newDevices = [...config.devices]
|
||||
const [movedItem] = newDevices.splice(startIndex, 1)
|
||||
newDevices.splice(currentIndex, 0, movedItem)
|
||||
|
||||
const newConfig = { devices: newDevices }
|
||||
|
||||
try {
|
||||
await saveRemoteConfig(newConfig)
|
||||
setConfig(newConfig)
|
||||
} catch (err) {
|
||||
console.error('Failed to save reordered config:', err)
|
||||
}
|
||||
}
|
||||
|
||||
dragRef.current = {
|
||||
isDragging: false,
|
||||
startY: 0,
|
||||
startIndex: -1,
|
||||
currentIndex: -1,
|
||||
listRect: null,
|
||||
itemRects: [],
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-gray-500 dark:text-gray-400">加载中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="pl-12 pt-6 pb-4 shrink-0">
|
||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-gray-200 flex items-center gap-2 whitespace-nowrap">
|
||||
<Monitor className="w-8 h-8 shrink-0" />
|
||||
远程
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex px-0 -ml-4">
|
||||
<div className="absolute left-60 top-[88px] bottom-16 w-px bg-gray-300/40 dark:bg-gray-500/50" />
|
||||
<div
|
||||
ref={listContainerRef}
|
||||
className="w-56 shrink-0 pr-4 overflow-hidden select-none"
|
||||
>
|
||||
<button
|
||||
onClick={handleAddDevice}
|
||||
className="w-full flex items-center gap-2 px-3 py-3 rounded-lg cursor-pointer transition-colors hover:bg-gray-100/50 dark:hover:bg-gray-700/40 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
|
||||
>
|
||||
<Plus size={16} className="shrink-0" />
|
||||
<span className="text-base truncate">添加设备</span>
|
||||
</button>
|
||||
<div data-drag-list className="space-y-2 w-full relative mt-2">
|
||||
{devices.map((device, index) => {
|
||||
const isSelected = selectedDeviceId === device.id
|
||||
const isDragging = dragIndex === index
|
||||
|
||||
return (
|
||||
<div
|
||||
key={device.id}
|
||||
data-drag-item
|
||||
className={`group flex items-center gap-2 px-3 py-3 rounded-lg transition-all w-full max-w-full ${
|
||||
isSelected
|
||||
? 'bg-gray-100/50 dark:bg-gray-700/40'
|
||||
: 'hover:bg-gray-100/30 dark:hover:bg-gray-700/20'
|
||||
} ${isDragging ? 'opacity-90' : ''}`}
|
||||
onClick={() => dragIndex === null && setSelectedDeviceId(device.id)}
|
||||
>
|
||||
<span
|
||||
onMouseDown={(e) => handleMouseDown(e, index)}
|
||||
className="cursor-grab active:cursor-grabbing p-0.5 hover:bg-gray-200/50 dark:hover:bg-gray-600/50 rounded"
|
||||
>
|
||||
<GripVertical size={14} className="text-gray-400 shrink-0" />
|
||||
</span>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${device.status === 'online' ? 'bg-green-500' : 'bg-gray-400'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-base text-gray-700 dark:text-gray-300 truncate flex-1">
|
||||
{device.name || '未命名设备'}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteDevice(device.id)
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-red-100 dark:hover:bg-red-900/30 rounded transition-all"
|
||||
>
|
||||
<Trash2 size={14} className="text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{devices.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">
|
||||
暂无设备,点击上方添加
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 pl-8 -mt-10 min-w-[400px] overflow-auto pb-6">
|
||||
{selectedDevice && selectedConfig ? (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center justify-between py-0 px-4 shrink-0 border-b border-gray-300/40 dark:border-gray-500/50">
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-gray-200 whitespace-nowrap">
|
||||
{selectedDevice.name || '未命名设备'}
|
||||
</h2>
|
||||
<div className="flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${selectedDevice.status === 'online' ? 'bg-green-500' : 'bg-gray-400'
|
||||
}`}
|
||||
/>
|
||||
{selectedDevice.status === 'online' ? '在线' : '离线'}
|
||||
</span>
|
||||
<span>最后连接:{selectedDevice.lastConnected}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleOpenConfig}
|
||||
className="p-2 rounded-lg transition-colors hover:bg-gray-100/50 dark:hover:bg-gray-700/40 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
title="配置"
|
||||
>
|
||||
<Settings size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 mb-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-[2fr_1fr] gap-8">
|
||||
<button
|
||||
onClick={handleOpenRemoteDesktop}
|
||||
className="rounded-lg overflow-hidden border border-gray-300/40 dark:border-gray-500/50 bg-gray-100/30 dark:bg-gray-800/30 hover:opacity-80 transition-opacity cursor-pointer"
|
||||
>
|
||||
<img
|
||||
src={screenshot || '/background.png'}
|
||||
alt="远程桌面"
|
||||
className="w-full aspect-video object-cover"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div className="grid grid-rows-2 gap-6">
|
||||
<button
|
||||
onClick={handleOpenGitRepo}
|
||||
className="flex items-center justify-center gap-2 text-2xl bg-gray-100/50 dark:bg-gray-700/40 hover:bg-gray-200/60 dark:hover:bg-gray-600/50 rounded-2xl shadow-md hover:shadow-xl transition-all font-medium text-gray-700 dark:text-gray-200 whitespace-nowrap"
|
||||
>
|
||||
<GitBranch size={24} />
|
||||
Git 仓库
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleOpenFileTransfer}
|
||||
className="flex items-center justify-center gap-2 text-2xl bg-gray-100/50 dark:bg-gray-700/40 hover:bg-gray-200/60 dark:hover:bg-gray-600/50 rounded-2xl shadow-md hover:shadow-xl transition-all font-medium text-gray-700 dark:text-gray-200 whitespace-nowrap"
|
||||
>
|
||||
<Folder size={24} />
|
||||
文件传输
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
请选择设备
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editingDevice && (
|
||||
<ConfigDialog
|
||||
isOpen={showConfig}
|
||||
onClose={handleCloseConfig}
|
||||
config={editingDevice}
|
||||
onSave={handleSaveConfig}
|
||||
existingDeviceNames={existingDeviceNames.filter(name => name !== editingDevice.deviceName)}
|
||||
isEdit={!!config.devices.find(d => d.id === editingDevice.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deletingDeviceId && (
|
||||
<DeleteConfirmDialog
|
||||
isOpen={deleteDialogOpen}
|
||||
title="删除设备"
|
||||
message={`确定要删除设备 "${config.devices.find(d => d.id === deletingDeviceId)?.deviceName || '未命名设备'}" 吗?`}
|
||||
expectedText="DELETE DEVICE"
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={handleDeleteCancel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
src/modules/remote/RemoteTabPage.tsx
Normal file
18
src/modules/remote/RemoteTabPage.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
import { RemoteWebView } from './components/RemoteWebView'
|
||||
|
||||
interface RemoteTabPageProps {
|
||||
url: string
|
||||
title: string
|
||||
deviceName?: string
|
||||
}
|
||||
|
||||
export const RemoteTabPage: React.FC<RemoteTabPageProps> = ({ url, title, deviceName = '' }) => {
|
||||
const isDesktop = title.includes('远程桌面')
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0">
|
||||
<RemoteWebView url={url} title={title} isDesktop={isDesktop} deviceName={deviceName} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
39
src/modules/remote/api.ts
Normal file
39
src/modules/remote/api.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { getModuleApi } from '@/lib/module-registry'
|
||||
import { type RemoteEndpoints } from '@shared/modules/remote'
|
||||
import type { RemoteConfig } from './types'
|
||||
|
||||
const getApi = () => {
|
||||
const api = getModuleApi<RemoteEndpoints>('remote')
|
||||
if (!api) {
|
||||
throw new Error('remote module API not initialized')
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
export const getRemoteConfig = async (): Promise<RemoteConfig> => {
|
||||
return (await getApi().get<RemoteConfig>('getConfig'))!
|
||||
}
|
||||
|
||||
export const saveRemoteConfig = async (config: RemoteConfig): Promise<void> => {
|
||||
await getApi().post<null>('saveConfig', config)
|
||||
}
|
||||
|
||||
export const getScreenshot = async (deviceName?: string): Promise<string> => {
|
||||
const params = deviceName ? { device: deviceName } : undefined
|
||||
const result = await getApi().get<string>('getScreenshot', params ? { queryParams: params } : undefined)
|
||||
return result || ''
|
||||
}
|
||||
|
||||
export const saveScreenshot = async (dataUrl: string, deviceName?: string): Promise<void> => {
|
||||
await getApi().post<null>('saveScreenshot', { dataUrl, deviceName })
|
||||
}
|
||||
|
||||
export const getDeviceData = async (deviceName?: string): Promise<{ lastConnected?: string } | null> => {
|
||||
const params = deviceName ? { device: deviceName } : undefined
|
||||
const result = await getApi().get<{ lastConnected?: string } | null>('getData', params ? { queryParams: params } : undefined)
|
||||
return result
|
||||
}
|
||||
|
||||
export const saveDeviceData = async (deviceName: string, lastConnected: string): Promise<void> => {
|
||||
await getApi().post<null>('saveData', { deviceName, lastConnected })
|
||||
}
|
||||
179
src/modules/remote/components/ConfigDialog.tsx
Normal file
179
src/modules/remote/components/ConfigDialog.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { RemoteDevice } from '../types'
|
||||
|
||||
interface ConfigDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
config: RemoteDevice
|
||||
onSave: (device: RemoteDevice) => void
|
||||
existingDeviceNames: string[]
|
||||
isEdit?: boolean
|
||||
}
|
||||
|
||||
export const ConfigDialog: React.FC<ConfigDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
config,
|
||||
onSave,
|
||||
existingDeviceNames,
|
||||
isEdit = false,
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<RemoteDevice>(config)
|
||||
const [nameError, setNameError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setFormData(config)
|
||||
setNameError('')
|
||||
}, [config])
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
|
||||
const handleChange = (field: keyof RemoteDevice, value: string | number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}))
|
||||
if (field === 'deviceName') {
|
||||
setNameError('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
const trimmedName = formData.deviceName.trim()
|
||||
if (!trimmedName) {
|
||||
setNameError('请输入设备名称')
|
||||
return
|
||||
}
|
||||
const isDuplicate = existingDeviceNames.some(
|
||||
(name) => name.toLowerCase() === trimmedName.toLowerCase() &&
|
||||
(!isEdit || name.toLowerCase() !== config.deviceName.toLowerCase())
|
||||
)
|
||||
if (isDuplicate) {
|
||||
setNameError('设备名称已存在')
|
||||
return
|
||||
}
|
||||
onSave({ ...formData, deviceName: trimmedName })
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
if (typeof document === 'undefined') return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in duration-200"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl transform transition-all animate-in zoom-in-95 duration-200 w-full max-w-md"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
远程配置
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
设备名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.deviceName}
|
||||
onChange={(e) => handleChange('deviceName', e.target.value)}
|
||||
placeholder="例如: 开发服务器"
|
||||
className={`w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 border rounded-lg text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-900 dark:focus:ring-gray-100 focus:border-transparent transition-colors ${nameError ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}`}
|
||||
/>
|
||||
{nameError && (
|
||||
<p className="mt-1 text-sm text-red-500">{nameError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
服务器地址
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.serverHost}
|
||||
onChange={(e) => handleChange('serverHost', e.target.value)}
|
||||
placeholder="例如: 192.168.1.100"
|
||||
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-900 dark:focus:ring-gray-100 focus:border-transparent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
远程桌面端口
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.desktopPort}
|
||||
onChange={(e) => handleChange('desktopPort', parseInt(e.target.value) || 3000)}
|
||||
placeholder="默认: 3000"
|
||||
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-900 dark:focus:ring-gray-100 focus:border-transparent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
远程 Git 端口
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.gitPort}
|
||||
onChange={(e) => handleChange('gitPort', parseInt(e.target.value) || 3001)}
|
||||
placeholder="默认: 3001"
|
||||
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-900 dark:focus:ring-gray-100 focus:border-transparent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
514
src/modules/remote/components/RemoteWebView.tsx
Normal file
514
src/modules/remote/components/RemoteWebView.tsx
Normal file
@@ -0,0 +1,514 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react'
|
||||
import { useWallpaper, useScreenshot } from '@/stores'
|
||||
|
||||
interface RemoteWebViewProps {
|
||||
url: string
|
||||
title: string
|
||||
isDesktop?: boolean
|
||||
deviceName?: string
|
||||
}
|
||||
|
||||
const CAPTURE_INTERVAL = 60000
|
||||
|
||||
// WebSocket 消息类型
|
||||
const MessageTypes = {
|
||||
CLIPBOARD_GET: 'clipboardGet',
|
||||
CLIPBOARD_SET: 'clipboardSet',
|
||||
CLIPBOARD_DATA: 'clipboardData',
|
||||
CLIPBOARD_RESULT: 'clipboardResult',
|
||||
CLIPBOARD_TOO_LARGE: 'clipboardTooLarge'
|
||||
}
|
||||
|
||||
export const RemoteWebView: React.FC<RemoteWebViewProps> = ({ url, title, isDesktop = false, deviceName = '' }) => {
|
||||
const { opacity } = useWallpaper()
|
||||
const { updateScreenshot } = useScreenshot()
|
||||
const webviewRef = useRef<HTMLWebViewElement & {
|
||||
capturePage: () => Promise<{ resize: (opts: { width: number; height: number; quality: string }) => { toDataURL: () => string } }>
|
||||
}>(null)
|
||||
const deviceNameRef = useRef(deviceName)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [canGoBack, setCanGoBack] = useState(false)
|
||||
const [canGoForward, setCanGoForward] = useState(false)
|
||||
const captureIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const isDomReadyRef = useRef(false)
|
||||
|
||||
// 剪贴板相关状态 - 用 ref 避免闭包问题
|
||||
const clipboardWsRef = useRef<WebSocket | null>(null)
|
||||
const [clipboardStatus, setClipboardStatus] = useState<string>('')
|
||||
|
||||
// 生成 WebSocket URL(不需要密码,WebSocket 允许未认证连接)
|
||||
const getWsUrl = useCallback(() => {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
const wsProtocol = urlObj.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${wsProtocol}//${urlObj.host}/ws`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [url])
|
||||
|
||||
// 初始化剪贴板 WebSocket 连接
|
||||
const initClipboardWs = useCallback(() => {
|
||||
const wsUrl = getWsUrl()
|
||||
if (!wsUrl) return null
|
||||
|
||||
const ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[RemoteWebView] Clipboard WebSocket connected')
|
||||
setClipboardStatus('已连接')
|
||||
}
|
||||
|
||||
ws.onclose = (e) => {
|
||||
console.log('[RemoteWebView] Clipboard WebSocket closed:', e.code, e.reason)
|
||||
clipboardWsRef.current = null
|
||||
setClipboardStatus('')
|
||||
}
|
||||
|
||||
ws.onerror = (e) => {
|
||||
console.error('[RemoteWebView] Clipboard WebSocket error:', e)
|
||||
setClipboardStatus('连接错误')
|
||||
}
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
|
||||
if (msg.type === MessageTypes.CLIPBOARD_DATA) {
|
||||
if (msg.contentType === 'text' && msg.data) {
|
||||
try {
|
||||
const result = await window.electronAPI?.clipboardWriteText(msg.data)
|
||||
if (result?.success) {
|
||||
console.log('[RemoteWebView] Clipboard synced from remote')
|
||||
setClipboardStatus('已同步到本地')
|
||||
setTimeout(() => setClipboardStatus(''), 2000)
|
||||
} else {
|
||||
throw new Error(result?.error || 'write failed')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[RemoteWebView] Failed to write local clipboard:', err)
|
||||
setClipboardStatus('写入本地失败')
|
||||
}
|
||||
}
|
||||
} else if (msg.type === MessageTypes.CLIPBOARD_RESULT) {
|
||||
if (msg.success) {
|
||||
console.log('[RemoteWebView] Clipboard synced to remote')
|
||||
setClipboardStatus('已同步到远程')
|
||||
setTimeout(() => setClipboardStatus(''), 2000)
|
||||
} else {
|
||||
setClipboardStatus('同步到远程失败')
|
||||
}
|
||||
} else if (msg.type === MessageTypes.CLIPBOARD_TOO_LARGE) {
|
||||
console.warn('[RemoteWebView] Remote clipboard too large:', msg.size)
|
||||
setClipboardStatus('内容过大')
|
||||
}
|
||||
} catch (err) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
|
||||
return ws
|
||||
}, [getWsUrl])
|
||||
|
||||
// 同步剪贴板到远程
|
||||
const syncToRemote = useCallback(async () => {
|
||||
console.log('[RemoteWebView] syncToRemote called')
|
||||
|
||||
// 先关闭旧连接
|
||||
if (clipboardWsRef.current) {
|
||||
clipboardWsRef.current.close()
|
||||
clipboardWsRef.current = null
|
||||
}
|
||||
|
||||
const ws = initClipboardWs()
|
||||
if (!ws) {
|
||||
console.log('[RemoteWebView] initClipboardWs returned null')
|
||||
setClipboardStatus('连接失败')
|
||||
return
|
||||
}
|
||||
clipboardWsRef.current = ws
|
||||
|
||||
// 等待连接打开
|
||||
await new Promise<void>((resolve) => {
|
||||
const checkOpen = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
clearInterval(checkOpen)
|
||||
resolve()
|
||||
}
|
||||
}, 50)
|
||||
// 超时 3 秒
|
||||
setTimeout(() => {
|
||||
clearInterval(checkOpen)
|
||||
resolve()
|
||||
}, 3000)
|
||||
})
|
||||
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
console.log('[RemoteWebView] WebSocket not open')
|
||||
setClipboardStatus('连接失败')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI?.clipboardReadText()
|
||||
if (!result?.success) {
|
||||
console.log('[RemoteWebView] clipboard empty or read failed:', result?.error)
|
||||
setClipboardStatus(result?.error || '剪贴板为空')
|
||||
return
|
||||
}
|
||||
const text = result.text
|
||||
if (!text) {
|
||||
console.log('[RemoteWebView] clipboard empty')
|
||||
setClipboardStatus('剪贴板为空')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[RemoteWebView] sending to remote:', text.substring(0, 50))
|
||||
ws.send(JSON.stringify({
|
||||
type: MessageTypes.CLIPBOARD_SET,
|
||||
contentType: 'text',
|
||||
data: text
|
||||
}))
|
||||
setClipboardStatus('已发送到远程')
|
||||
setTimeout(() => setClipboardStatus(''), 2000)
|
||||
} catch (err) {
|
||||
console.error('[RemoteWebView] Failed to read local clipboard:', err)
|
||||
setClipboardStatus('读取本地失败')
|
||||
}
|
||||
}, [initClipboardWs])
|
||||
|
||||
// 从远程获取剪贴板
|
||||
const syncFromRemote = useCallback(async () => {
|
||||
console.log('[RemoteWebView] syncFromRemote called')
|
||||
|
||||
// 先关闭旧连接
|
||||
if (clipboardWsRef.current) {
|
||||
clipboardWsRef.current.close()
|
||||
clipboardWsRef.current = null
|
||||
}
|
||||
|
||||
const ws = initClipboardWs()
|
||||
if (!ws) {
|
||||
console.log('[RemoteWebView] initClipboardWs returned null')
|
||||
setClipboardStatus('连接失败')
|
||||
return
|
||||
}
|
||||
clipboardWsRef.current = ws
|
||||
|
||||
// 等待连接打开
|
||||
await new Promise<void>((resolve) => {
|
||||
const checkOpen = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
clearInterval(checkOpen)
|
||||
resolve()
|
||||
}
|
||||
}, 50)
|
||||
// 超时 3 秒
|
||||
setTimeout(() => {
|
||||
clearInterval(checkOpen)
|
||||
resolve()
|
||||
}, 3000)
|
||||
})
|
||||
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
console.log('[RemoteWebView] WebSocket not open')
|
||||
setClipboardStatus('连接失败')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[RemoteWebView] requesting from remote')
|
||||
ws.send(JSON.stringify({
|
||||
type: MessageTypes.CLIPBOARD_GET
|
||||
}))
|
||||
}, [initClipboardWs])
|
||||
|
||||
// 保持 WebSocket 长连接,用于接收远程推送的剪贴板
|
||||
useEffect(() => {
|
||||
if (!isDesktop) return
|
||||
|
||||
const wsUrl = getWsUrl()
|
||||
if (!wsUrl) return
|
||||
|
||||
const ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[RemoteWebView] Clipboard WebSocket connected for receiving')
|
||||
setClipboardStatus('已连接')
|
||||
}
|
||||
|
||||
ws.onclose = (e) => {
|
||||
console.log('[RemoteWebView] Clipboard WebSocket closed:', e.code)
|
||||
setClipboardStatus('')
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
setClipboardStatus('连接错误')
|
||||
}
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
|
||||
if (msg.type === MessageTypes.CLIPBOARD_DATA) {
|
||||
if (msg.contentType === 'text' && msg.data) {
|
||||
try {
|
||||
const result = await window.electronAPI?.clipboardWriteText(msg.data)
|
||||
if (result?.success) {
|
||||
console.log('[RemoteWebView] Clipboard synced from remote (auto)')
|
||||
setClipboardStatus('已同步到本地')
|
||||
setTimeout(() => setClipboardStatus(''), 2000)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[RemoteWebView] Failed to write local clipboard:', err)
|
||||
setClipboardStatus('写入本地失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
clipboardWsRef.current = ws
|
||||
|
||||
return () => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.close()
|
||||
}
|
||||
}
|
||||
}, [isDesktop])
|
||||
|
||||
// 清理 WebSocket
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (clipboardWsRef.current) {
|
||||
clipboardWsRef.current.close()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 保持 deviceName 的最新引用
|
||||
useEffect(() => {
|
||||
deviceNameRef.current = deviceName
|
||||
}, [deviceName])
|
||||
|
||||
const captureScreenshot = useCallback(async () => {
|
||||
if (!isDesktop || !deviceName) return
|
||||
const webview = webviewRef.current
|
||||
if (!webview || !isDomReadyRef.current) return
|
||||
|
||||
try {
|
||||
const image = await webview.capturePage()
|
||||
if (!image) {
|
||||
console.warn('[RemoteWebView] capturePage returned empty image')
|
||||
return
|
||||
}
|
||||
const resizedImage = image.resize({ width: 640, height: 360, quality: 'good' })
|
||||
const dataUrl = resizedImage.toDataURL()
|
||||
if (!dataUrl || dataUrl.length === 0) {
|
||||
console.warn('[RemoteWebView] dataUrl is empty')
|
||||
return
|
||||
}
|
||||
await updateScreenshot(dataUrl, deviceName)
|
||||
} catch (err) {
|
||||
console.error('[RemoteWebView] Failed to capture screenshot:', err)
|
||||
}
|
||||
}, [isDesktop, deviceName, updateScreenshot])
|
||||
|
||||
const startCaptureInterval = useCallback(() => {
|
||||
if (!isDesktop) return
|
||||
if (captureIntervalRef.current) {
|
||||
clearInterval(captureIntervalRef.current)
|
||||
}
|
||||
captureIntervalRef.current = setInterval(captureScreenshot, CAPTURE_INTERVAL)
|
||||
}, [captureScreenshot, isDesktop])
|
||||
|
||||
const stopCaptureInterval = useCallback(() => {
|
||||
if (captureIntervalRef.current) {
|
||||
clearInterval(captureIntervalRef.current)
|
||||
captureIntervalRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const updateNavigationState = useCallback(() => {
|
||||
const webview = webviewRef.current
|
||||
if (webview) {
|
||||
setCanGoBack(webview.canGoBack())
|
||||
setCanGoForward(webview.canGoForward())
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const webview = webviewRef.current
|
||||
if (!webview) return
|
||||
|
||||
const handleDidStartLoading = () => setLoading(true)
|
||||
const handleDidStopLoading = () => setLoading(false)
|
||||
const handleDidNavigate = () => {
|
||||
updateNavigationState()
|
||||
}
|
||||
|
||||
const handleClose = async () => {
|
||||
const currentDeviceName = deviceNameRef.current
|
||||
if (isDesktop && isDomReadyRef.current && currentDeviceName) {
|
||||
stopCaptureInterval()
|
||||
try {
|
||||
const image = await webview.capturePage()
|
||||
if (!image) {
|
||||
console.warn('[RemoteWebView] capturePage returned empty image on close event')
|
||||
return
|
||||
}
|
||||
const resizedImage = image.resize({ width: 640, height: 360, quality: 'good' })
|
||||
const dataUrl = resizedImage.toDataURL()
|
||||
if (!dataUrl || dataUrl.length === 0) {
|
||||
console.warn('[RemoteWebView] dataUrl is empty on close event')
|
||||
return
|
||||
}
|
||||
console.log('[RemoteWebView] Saving screenshot on close event for device:', currentDeviceName)
|
||||
await updateScreenshot(dataUrl, currentDeviceName)
|
||||
} catch (err) {
|
||||
console.error('[RemoteWebView] Failed to capture screenshot on close event:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
webview.addEventListener('did-start-loading', handleDidStartLoading)
|
||||
webview.addEventListener('did-stop-loading', handleDidStopLoading)
|
||||
webview.addEventListener('did-navigate', handleDidNavigate)
|
||||
webview.addEventListener('did-navigate-in-page', handleDidNavigate)
|
||||
webview.addEventListener('close', handleClose)
|
||||
|
||||
return () => {
|
||||
webview.removeEventListener('did-start-loading', handleDidStartLoading)
|
||||
webview.removeEventListener('did-stop-loading', handleDidStopLoading)
|
||||
webview.removeEventListener('did-navigate', handleDidNavigate)
|
||||
webview.removeEventListener('did-navigate-in-page', handleDidNavigate)
|
||||
webview.removeEventListener('close', handleClose)
|
||||
}
|
||||
}, [updateNavigationState, updateScreenshot, isDesktop])
|
||||
|
||||
useEffect(() => {
|
||||
const webview = webviewRef.current
|
||||
if (!webview) return
|
||||
|
||||
const handleDomReady = () => {
|
||||
isDomReadyRef.current = true
|
||||
setTimeout(captureScreenshot, 2000)
|
||||
startCaptureInterval()
|
||||
}
|
||||
|
||||
webview.addEventListener('dom-ready', handleDomReady)
|
||||
|
||||
return () => {
|
||||
webview.removeEventListener('dom-ready', handleDomReady)
|
||||
}
|
||||
}, [captureScreenshot, startCaptureInterval])
|
||||
|
||||
// 组件卸载时保存截图
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// 组件卸载时尝试保存截图
|
||||
const currentDeviceName = deviceNameRef.current
|
||||
if (isDesktop && isDomReadyRef.current && currentDeviceName) {
|
||||
const webview = webviewRef.current
|
||||
if (webview) {
|
||||
try {
|
||||
// 同步尝试捕获并保存
|
||||
const captureAndSave = async () => {
|
||||
try {
|
||||
const image = await webview.capturePage()
|
||||
if (!image) {
|
||||
console.warn('[RemoteWebView] capturePage returned empty image on unmount')
|
||||
return
|
||||
}
|
||||
const resizedImage = image.resize({ width: 640, height: 360, quality: 'good' })
|
||||
const dataUrl = resizedImage.toDataURL()
|
||||
if (!dataUrl || dataUrl.length === 0) {
|
||||
console.warn('[RemoteWebView] dataUrl is empty on unmount')
|
||||
return
|
||||
}
|
||||
console.log('[RemoteWebView] Saving screenshot on unmount for device:', currentDeviceName)
|
||||
await updateScreenshot(dataUrl, currentDeviceName)
|
||||
} catch (err) {
|
||||
console.error('[RemoteWebView] Failed to capture screenshot on unmount:', err)
|
||||
}
|
||||
}
|
||||
captureAndSave()
|
||||
} catch (err) {
|
||||
console.warn('[RemoteWebView] Sync capture failed on unmount:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isDesktop, updateScreenshot])
|
||||
|
||||
// 自动同步剪贴板到远程(当本地剪贴板变化时)
|
||||
const syncToRemoteWithText = useCallback(async (text: string) => {
|
||||
if (!text) return
|
||||
|
||||
const wsUrl = getWsUrl()
|
||||
if (!wsUrl) return
|
||||
|
||||
const ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({
|
||||
type: MessageTypes.CLIPBOARD_SET,
|
||||
contentType: 'text',
|
||||
data: text
|
||||
}))
|
||||
setClipboardStatus('已自动同步到远程')
|
||||
setTimeout(() => setClipboardStatus(''), 2000)
|
||||
setTimeout(() => ws.close(), 1000)
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close()
|
||||
}
|
||||
}, [getWsUrl])
|
||||
|
||||
// 监听全局快捷键和自动同步事件
|
||||
useEffect(() => {
|
||||
if (!isDesktop) return
|
||||
|
||||
const cleanupToRemote = window.electronAPI?.onRemoteClipboardSyncToRemote(() => {
|
||||
syncToRemote()
|
||||
})
|
||||
|
||||
const cleanupFromRemote = window.electronAPI?.onRemoteClipboardSyncFromRemote(() => {
|
||||
syncFromRemote()
|
||||
})
|
||||
|
||||
const cleanupAutoSync = window.electronAPI?.onRemoteClipboardAutoSync((text) => {
|
||||
console.log('[RemoteWebView] Auto clipboard sync triggered')
|
||||
syncToRemoteWithText(text)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cleanupToRemote?.()
|
||||
cleanupFromRemote?.()
|
||||
cleanupAutoSync?.()
|
||||
}
|
||||
}, [isDesktop, syncFromRemote, syncToRemote, syncToRemoteWithText])
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0" style={{ backgroundColor: `rgba(var(--app-content-bg-rgb), ${opacity})` }}>
|
||||
<webview
|
||||
ref={webviewRef}
|
||||
src={url}
|
||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||
{...{ allowpopups: true, webpreferences: 'contextIsolation=no' } as React.HTMLAttributes<HTMLWebViewElement>}
|
||||
/>
|
||||
{/* 剪贴板同步状态提示 */}
|
||||
{isDesktop && (
|
||||
<div className="absolute bottom-4 right-4 flex flex-col gap-2 z-10">
|
||||
{clipboardStatus && (
|
||||
<div className="px-3 py-1.5 bg-gray-800/90 text-white text-xs rounded-full whitespace-nowrap">
|
||||
{clipboardStatus}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import React from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { LocalFilePanel } from './LocalFilePanel'
|
||||
import { RemoteFilePanel } from './RemoteFilePanel'
|
||||
import { TransferQueue } from './TransferQueue'
|
||||
import type { FileItem } from '@/lib/api'
|
||||
import { type RemoteFileItem, uploadFileToRemote, downloadFileFromRemote } from '../../api'
|
||||
import type { TransferItem } from '../../types'
|
||||
|
||||
interface FileTransferPanelProps {
|
||||
serverHost: string
|
||||
port: number
|
||||
password?: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const FileTransferPanel: React.FC<FileTransferPanelProps> = ({
|
||||
serverHost,
|
||||
port,
|
||||
password,
|
||||
onClose,
|
||||
}) => {
|
||||
const [localSelected, setLocalSelected] = React.useState<FileItem | null>(null)
|
||||
const [remoteSelected, setRemoteSelected] = React.useState<RemoteFileItem | null>(null)
|
||||
const [transfers, setTransfers] = React.useState<TransferItem[]>([])
|
||||
const [transferring, setTransferring] = React.useState(false)
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!localSelected || !localSelected.path) return
|
||||
|
||||
setTransferring(true)
|
||||
const transferId = Date.now().toString()
|
||||
const newTransfer: TransferItem = {
|
||||
id: transferId,
|
||||
name: localSelected.name,
|
||||
type: 'upload',
|
||||
size: localSelected.size,
|
||||
progress: 0,
|
||||
status: 'transferring',
|
||||
}
|
||||
setTransfers((prev) => [...prev, newTransfer])
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/files/content?path=${encodeURIComponent(localSelected.path)}`)
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], localSelected.name, { type: blob.type })
|
||||
|
||||
await uploadFileToRemote(serverHost, port, file, '', password, (progress) => {
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => (t.id === transferId ? { ...t, progress } : t))
|
||||
)
|
||||
})
|
||||
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === transferId ? { ...t, progress: 100, status: 'completed' } : t
|
||||
)
|
||||
)
|
||||
setLocalSelected(null)
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err)
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === transferId
|
||||
? { ...t, status: 'error', error: err instanceof Error ? err.message : '上传失败' }
|
||||
: t
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
setTransferring(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!remoteSelected) return
|
||||
|
||||
setTransferring(true)
|
||||
const transferId = Date.now().toString()
|
||||
const newTransfer: TransferItem = {
|
||||
id: transferId,
|
||||
name: remoteSelected.name,
|
||||
type: 'download',
|
||||
size: remoteSelected.size,
|
||||
progress: 0,
|
||||
status: 'transferring',
|
||||
}
|
||||
setTransfers((prev) => [...prev, newTransfer])
|
||||
|
||||
try {
|
||||
await downloadFileFromRemote(serverHost, port, remoteSelected.name, '', password, (progress) => {
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => (t.id === transferId ? { ...t, progress } : t))
|
||||
)
|
||||
})
|
||||
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === transferId ? { ...t, progress: 100, status: 'completed' } : t
|
||||
)
|
||||
)
|
||||
setRemoteSelected(null)
|
||||
} catch (err) {
|
||||
console.error('Download failed:', err)
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === transferId
|
||||
? { ...t, status: 'error', error: err instanceof Error ? err.message : '下载失败' }
|
||||
: t
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
setTransferring(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearTransfers = () => {
|
||||
setTransfers((prev) => prev.filter((t) => t.status === 'transferring'))
|
||||
}
|
||||
|
||||
const handleRemoveTransfer = (id: string) => {
|
||||
setTransfers((prev) => prev.filter((t) => t.id !== id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-[900px] h-[600px] max-w-[95vw] max-h-[90vh] bg-white dark:bg-gray-900 rounded-xl shadow-2xl flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
||||
文件传输
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex gap-4 p-4 overflow-hidden">
|
||||
<div className="flex-1 min-w-0">
|
||||
<LocalFilePanel
|
||||
selectedFile={localSelected}
|
||||
onSelect={setLocalSelected}
|
||||
onUpload={handleUpload}
|
||||
disabled={transferring}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<RemoteFilePanel
|
||||
serverHost={serverHost}
|
||||
port={port}
|
||||
password={password}
|
||||
selectedFile={remoteSelected}
|
||||
onSelect={setRemoteSelected}
|
||||
onDownload={handleDownload}
|
||||
disabled={transferring}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TransferQueue
|
||||
transfers={transfers}
|
||||
onClear={handleClearTransfers}
|
||||
onRemove={handleRemoveTransfer}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
154
src/modules/remote/components/file-transfer/LocalFilePanel.tsx
Normal file
154
src/modules/remote/components/file-transfer/LocalFilePanel.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React from 'react'
|
||||
import { Folder, FileText, ArrowUp, ChevronRight, ChevronLeft, RefreshCw, HardDrive } from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
import type { FileItem } from '@/lib/api'
|
||||
import { fetchSystemFiles } from '@/lib/api'
|
||||
|
||||
interface LocalFilePanelProps {
|
||||
selectedFile: FileItem | null
|
||||
onSelect: (file: FileItem | null) => void
|
||||
onUpload: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const LocalFilePanel: React.FC<LocalFilePanelProps> = ({
|
||||
selectedFile,
|
||||
onSelect,
|
||||
onUpload,
|
||||
disabled,
|
||||
}) => {
|
||||
const [currentPath, setCurrentPath] = React.useState('')
|
||||
const [files, setFiles] = React.useState<FileItem[]>([])
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [pathHistory, setPathHistory] = React.useState<string[]>([''])
|
||||
|
||||
const loadFiles = React.useCallback(async (systemPath: string) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const items = await fetchSystemFiles(systemPath)
|
||||
setFiles(items)
|
||||
} catch (err) {
|
||||
console.error('Failed to load local files:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
loadFiles(currentPath)
|
||||
}, [currentPath, loadFiles])
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (pathHistory.length > 1) {
|
||||
const newHistory = [...pathHistory]
|
||||
newHistory.pop()
|
||||
const prevPath = newHistory[newHistory.length - 1]
|
||||
setPathHistory(newHistory)
|
||||
setCurrentPath(prevPath)
|
||||
onSelect(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoInto = (folder: FileItem) => {
|
||||
setPathHistory([...pathHistory, folder.path])
|
||||
setCurrentPath(folder.path)
|
||||
onSelect(null)
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadFiles(currentPath)
|
||||
}
|
||||
|
||||
const getDisplayPath = () => {
|
||||
if (!currentPath) return '本地文件'
|
||||
if (currentPath === '' || currentPath === '/') return '根目录'
|
||||
const parts = currentPath.split(/[/\\]/)
|
||||
return parts[parts.length - 1] || currentPath
|
||||
}
|
||||
|
||||
const getFullPathDisplay = () => {
|
||||
if (!currentPath) return ''
|
||||
return currentPath
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleGoBack}
|
||||
disabled={pathHistory.length <= 1}
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="返回上级"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate max-w-[120px]">
|
||||
{getDisplayPath()}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw size={14} className={clsx(loading && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
加载中...
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
空文件夹
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.path}
|
||||
onClick={() => onSelect(file.type === 'dir' ? null : file)}
|
||||
onDoubleClick={() => file.type === 'dir' && handleGoInto(file)}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors',
|
||||
selectedFile?.path === file.path
|
||||
? 'bg-blue-100 dark:bg-blue-900/30'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700/50'
|
||||
)}
|
||||
>
|
||||
{file.type === 'dir' ? (
|
||||
<Folder size={16} className="text-yellow-500 flex-shrink-0" />
|
||||
) : (
|
||||
<FileText size={16} className="text-gray-400 flex-shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 truncate text-sm text-gray-700 dark:text-gray-300">
|
||||
{file.name}
|
||||
</span>
|
||||
{selectedFile?.path === file.path && (
|
||||
<div className="w-4 h-4 rounded-full bg-blue-500 flex items-center justify-center">
|
||||
<span className="text-white text-xs">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||
<button
|
||||
onClick={onUpload}
|
||||
disabled={!selectedFile || disabled}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 disabled:bg-gray-300 dark:disabled:bg-gray-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
上传
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
155
src/modules/remote/components/file-transfer/RemoteFilePanel.tsx
Normal file
155
src/modules/remote/components/file-transfer/RemoteFilePanel.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React from 'react'
|
||||
import { Folder, FileText, ArrowDown, ChevronRight, ChevronLeft, RefreshCw } from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
import { fetchRemoteFiles, type RemoteFileItem } from '../../api'
|
||||
|
||||
interface RemoteFilePanelProps {
|
||||
serverHost: string
|
||||
port: number
|
||||
password?: string
|
||||
selectedFile: RemoteFileItem | null
|
||||
onSelect: (file: RemoteFileItem | null) => void
|
||||
onDownload: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const RemoteFilePanel: React.FC<RemoteFilePanelProps> = ({
|
||||
serverHost,
|
||||
port,
|
||||
password,
|
||||
selectedFile,
|
||||
onSelect,
|
||||
onDownload,
|
||||
disabled,
|
||||
}) => {
|
||||
const [currentPath, setCurrentPath] = React.useState('')
|
||||
const [files, setFiles] = React.useState<RemoteFileItem[]>([])
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [pathHistory, setPathHistory] = React.useState<string[]>([''])
|
||||
|
||||
const loadFiles = React.useCallback(async (path: string) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const items = await fetchRemoteFiles(serverHost, port, path, password)
|
||||
setFiles(items)
|
||||
} catch (err) {
|
||||
console.error('Failed to load remote files:', err)
|
||||
setFiles([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [serverHost, port, password])
|
||||
|
||||
React.useEffect(() => {
|
||||
loadFiles(currentPath)
|
||||
}, [currentPath, loadFiles])
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (pathHistory.length > 1) {
|
||||
const newHistory = [...pathHistory]
|
||||
newHistory.pop()
|
||||
const prevPath = newHistory[newHistory.length - 1]
|
||||
setPathHistory(newHistory)
|
||||
setCurrentPath(prevPath)
|
||||
onSelect(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoInto = (folder: RemoteFileItem) => {
|
||||
const newPath = currentPath ? `${currentPath}/${folder.name}` : folder.name
|
||||
setPathHistory([...pathHistory, newPath])
|
||||
setCurrentPath(newPath)
|
||||
onSelect(null)
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadFiles(currentPath)
|
||||
}
|
||||
|
||||
const getDisplayPath = () => {
|
||||
if (!currentPath) return '远程文件'
|
||||
const parts = currentPath.split('/')
|
||||
return parts[parts.length - 1] || currentPath
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleGoBack}
|
||||
disabled={pathHistory.length <= 1}
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="返回上级"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate max-w-[120px]">
|
||||
{getDisplayPath()}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw size={14} className={clsx(loading && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
加载中...
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
空文件夹
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.path}
|
||||
onClick={() => onSelect(file.type === 'dir' ? null : file)}
|
||||
onDoubleClick={() => file.type === 'dir' && handleGoInto(file)}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors',
|
||||
selectedFile?.path === file.path
|
||||
? 'bg-blue-100 dark:bg-blue-900/30'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700/50'
|
||||
)}
|
||||
>
|
||||
{file.type === 'dir' ? (
|
||||
<Folder size={16} className="text-yellow-500 flex-shrink-0" />
|
||||
) : (
|
||||
<FileText size={16} className="text-gray-400 flex-shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 truncate text-sm text-gray-700 dark:text-gray-300">
|
||||
{file.name}
|
||||
</span>
|
||||
{selectedFile?.path === file.path && (
|
||||
<div className="w-4 h-4 rounded-full bg-blue-500 flex items-center justify-center">
|
||||
<span className="text-white text-xs">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||
<button
|
||||
onClick={onDownload}
|
||||
disabled={!selectedFile || disabled}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-green-500 hover:bg-green-600 disabled:bg-gray-300 dark:disabled:bg-gray-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowDown size={16} />
|
||||
下载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from 'react'
|
||||
import { X, ArrowUp, ArrowDown, CheckCircle, XCircle, Loader } from 'lucide-react'
|
||||
import type { TransferItem } from '../../types'
|
||||
|
||||
interface TransferQueueProps {
|
||||
transfers: TransferItem[]
|
||||
onClear: () => void
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
export const TransferQueue: React.FC<TransferQueueProps> = ({ transfers, onClear, onRemove }) => {
|
||||
if (transfers.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-3 bg-gray-50 dark:bg-gray-800/50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
传输队列 ({transfers.length})
|
||||
</span>
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-32 overflow-y-auto">
|
||||
{transfers.map((transfer) => (
|
||||
<div
|
||||
key={transfer.id}
|
||||
className="flex items-center gap-2 text-sm bg-white dark:bg-gray-700 rounded px-2 py-1"
|
||||
>
|
||||
{transfer.type === 'upload' ? (
|
||||
<ArrowUp size={14} className="text-blue-500" />
|
||||
) : (
|
||||
<ArrowDown size={14} className="text-green-500" />
|
||||
)}
|
||||
<span className="flex-1 truncate max-w-[120px]">{transfer.name}</span>
|
||||
<div className="flex-1 h-2 bg-gray-200 dark:bg-gray-600 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all ${
|
||||
transfer.status === 'error'
|
||||
? 'bg-red-500'
|
||||
: transfer.status === 'completed'
|
||||
? 'bg-green-500'
|
||||
: 'bg-blue-500'
|
||||
}`}
|
||||
style={{ width: `${transfer.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 w-12">
|
||||
{transfer.status === 'completed'
|
||||
? '完成'
|
||||
: transfer.status === 'error'
|
||||
? '失败'
|
||||
: `${transfer.progress}%`}
|
||||
</span>
|
||||
{transfer.status === 'transferring' && <Loader size={14} className="animate-spin text-blue-500" />}
|
||||
{transfer.status === 'completed' && <CheckCircle size={14} className="text-green-500" />}
|
||||
{transfer.status === 'error' && <XCircle size={14} className="text-red-500" />}
|
||||
<button
|
||||
onClick={() => onRemove(transfer.id)}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
src/modules/remote/index.tsx
Normal file
11
src/modules/remote/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Monitor } from 'lucide-react'
|
||||
import { RemotePage } from './RemotePage'
|
||||
import { REMOTE_MODULE } from '@shared/modules/remote'
|
||||
import { createFrontendModule } from '@/lib/module-registry'
|
||||
|
||||
export default createFrontendModule(REMOTE_MODULE, {
|
||||
icon: Monitor,
|
||||
component: RemotePage,
|
||||
})
|
||||
|
||||
export { RemotePage }
|
||||
21
src/modules/remote/types.ts
Normal file
21
src/modules/remote/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type DeviceStatus = 'online' | 'offline'
|
||||
|
||||
export interface DeviceInfo {
|
||||
id: string
|
||||
name: string
|
||||
status: DeviceStatus
|
||||
lastConnected: string
|
||||
screenshotUrl?: string
|
||||
}
|
||||
|
||||
export interface RemoteDevice {
|
||||
id: string
|
||||
deviceName: string
|
||||
serverHost: string
|
||||
desktopPort: number
|
||||
gitPort: number
|
||||
}
|
||||
|
||||
export interface RemoteConfig {
|
||||
devices: RemoteDevice[]
|
||||
}
|
||||
151
src/modules/search/SearchPage.tsx
Normal file
151
src/modules/search/SearchPage.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { Search, File as FileIcon, Loader2, X } from 'lucide-react'
|
||||
import { searchFiles } from '@/lib/api'
|
||||
import type { FileItem } from '@/lib/api'
|
||||
import { useTabContentContext } from '@/stores'
|
||||
|
||||
export const SearchPage: React.FC = () => {
|
||||
const { selectFile } = useTabContentContext()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [keywords, setKeywords] = useState<string[]>([])
|
||||
const [results, setResults] = useState<FileItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [searched, setSearched] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const term = inputValue.trim()
|
||||
if (term) {
|
||||
if (!keywords.includes(term)) {
|
||||
setKeywords([...keywords, term])
|
||||
}
|
||||
setInputValue('')
|
||||
} else if (keywords.length > 0) {
|
||||
executeSearch(keywords)
|
||||
}
|
||||
} else if (e.key === 'Backspace' && !inputValue && keywords.length > 0) {
|
||||
const lastKeyword = keywords[keywords.length - 1]
|
||||
setKeywords(keywords.slice(0, -1))
|
||||
setInputValue(lastKeyword)
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const removeKeyword = (termToRemove: string) => {
|
||||
setKeywords(keywords.filter(k => k !== termToRemove))
|
||||
}
|
||||
|
||||
const executeSearch = async (terms: string[]) => {
|
||||
if (terms.length === 0) return
|
||||
|
||||
setLoading(true)
|
||||
setSearched(true)
|
||||
try {
|
||||
const items = await searchFiles(terms)
|
||||
setResults(items)
|
||||
} catch (error) {
|
||||
console.error('搜索失败:', error)
|
||||
setResults([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearchClick = (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault()
|
||||
|
||||
const term = inputValue.trim()
|
||||
const termsToSearch = [...keywords]
|
||||
|
||||
if (term && !keywords.includes(term)) {
|
||||
termsToSearch.push(term)
|
||||
setKeywords(termsToSearch)
|
||||
setInputValue('')
|
||||
}
|
||||
|
||||
if (termsToSearch.length > 0) {
|
||||
executeSearch(termsToSearch)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto w-full p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-gray-200 mb-8 flex items-center gap-2">
|
||||
<Search className="w-8 h-8" />
|
||||
搜索
|
||||
</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="relative">
|
||||
<div className="w-full min-h-[50px] px-4 py-2 rounded-xl bg-white/60 dark:bg-gray-900/60 backdrop-blur-md border border-gray-200/50 dark:border-gray-700/60 focus-within:ring-2 focus-within:ring-gray-400 dark:focus-within:ring-gray-500 flex flex-wrap items-center gap-2 transition-shadow">
|
||||
<Search className="text-gray-400 mr-2 shrink-0" size={20} />
|
||||
|
||||
{keywords.map((term) => (
|
||||
<div key={term} className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 px-2 py-1 rounded-md text-sm border border-gray-200 dark:border-gray-700">
|
||||
<span>{term}</span>
|
||||
<button
|
||||
onClick={() => removeKeyword(term)}
|
||||
className="hover:bg-gray-200 dark:hover:bg-gray-700 rounded-full p-0.5 transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={keywords.length === 0 ? "输入关键词,按回车确认..." : ""}
|
||||
className="flex-1 min-w-[120px] bg-transparent border-none focus:outline-none text-lg text-gray-800 dark:text-gray-200 placeholder-gray-400 h-8"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleSearchClick}
|
||||
disabled={loading || (keywords.length === 0 && !inputValue.trim())}
|
||||
className="ml-auto px-4 py-1.5 bg-gray-800 dark:bg-gray-200 text-white dark:text-gray-900 rounded-lg hover:bg-gray-700 dark:hover:bg-gray-300 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shrink-0 font-medium"
|
||||
>
|
||||
{loading ? <Loader2 className="animate-spin" size={20} /> : '搜索'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{searched && results.length === 0 && !loading && (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 py-8">
|
||||
未找到相关文档
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.map((file) => (
|
||||
<div
|
||||
key={file.path}
|
||||
onClick={() => selectFile(file)}
|
||||
className="flex items-center gap-3 p-4 bg-white/60 dark:bg-gray-900/60 backdrop-blur-md rounded-xl border border-gray-200/50 dark:border-gray-700/60 hover:bg-white/80 dark:hover:bg-gray-800/80 cursor-pointer transition-colors group"
|
||||
>
|
||||
<div className="p-2 bg-gray-100 dark:bg-gray-800 rounded-lg text-gray-600 dark:text-gray-400">
|
||||
<FileIcon size={20} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-800 dark:text-gray-200 truncate group-hover:text-black dark:group-hover:text-white transition-colors">
|
||||
{file.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 truncate">
|
||||
{file.path}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
src/modules/search/index.tsx
Normal file
11
src/modules/search/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Search } from 'lucide-react'
|
||||
import { SearchPage } from './SearchPage'
|
||||
import { SEARCH_MODULE } from '@shared/modules/search'
|
||||
import { createFrontendModule } from '@/lib/module-registry'
|
||||
|
||||
export default createFrontendModule(SEARCH_MODULE, {
|
||||
icon: Search,
|
||||
component: SearchPage,
|
||||
})
|
||||
|
||||
export { SearchPage }
|
||||
165
src/modules/settings/SettingsPage.tsx
Normal file
165
src/modules/settings/SettingsPage.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Upload, Image as ImageIcon, Loader2 } from 'lucide-react'
|
||||
import { useWallpaper, useMarkdownDisplay, useTheme } from '@/stores'
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [bgVersion, setBgVersion] = useState(Date.now())
|
||||
const { opacity, setOpacity } = useWallpaper()
|
||||
const { fontSize, setFontSize } = useMarkdownDisplay()
|
||||
const { isDark, toggleTheme } = useTheme()
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setUploading(true)
|
||||
try {
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = async () => {
|
||||
const base64String = reader.result as string
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/upload/wallpaper', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ image: base64String }),
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('上传失败')
|
||||
|
||||
setBgVersion(Date.now())
|
||||
window.dispatchEvent(new Event('wallpaper-changed'))
|
||||
} catch (error) {
|
||||
console.error('上传错误:', error)
|
||||
alert('上传失败,请重试')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
} catch (error) {
|
||||
console.error('文件读取错误:', error)
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto w-full p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-gray-200 mb-8">设置</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white/60 dark:bg-gray-900/60 backdrop-blur-md rounded-xl p-6 border border-gray-200/50 dark:border-gray-700/60 shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-6 text-gray-800 dark:text-gray-200">
|
||||
<ImageIcon size={20} />
|
||||
<h2 className="text-lg font-semibold">外观设置</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="font-medium text-gray-700 dark:text-gray-200">夜间模式</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isDark}
|
||||
onClick={toggleTheme}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${isDark ? 'bg-gray-800' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white transition-transform ${isDark ? 'translate-x-5' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-6">
|
||||
<div className="w-48 aspect-video bg-gray-100 dark:bg-gray-800 rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 shadow-inner relative group">
|
||||
<img
|
||||
src={`/background.png?t=${bgVersion}`}
|
||||
alt="当前壁纸"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-3">
|
||||
<h3 className="font-medium text-gray-700 dark:text-gray-200">背景壁纸</h3>
|
||||
|
||||
<div className="pt-2">
|
||||
<label className={`
|
||||
inline-flex items-center gap-2 px-4 py-2 bg-gray-800 dark:bg-gray-700 text-white rounded-lg
|
||||
hover:bg-gray-900 dark:hover:bg-gray-600 cursor-pointer transition-colors shadow-sm
|
||||
${uploading ? 'opacity-75 cursor-not-allowed' : ''}
|
||||
`}>
|
||||
{uploading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<Upload size={18} />
|
||||
)}
|
||||
<span>{uploading ? '正在上传...' : '更换壁纸'}</span>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-3">
|
||||
<h3 className="font-medium text-gray-700 dark:text-gray-200">壁纸透明度</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={1 - opacity}
|
||||
onChange={(e) => setOpacity(1 - parseFloat(e.target.value))}
|
||||
className="flex-1 h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer"
|
||||
style={{
|
||||
background: `linear-gradient(to right, ${isDark ? '#9ca3af' : '#4b5563'} 0%, ${isDark ? '#9ca3af' : '#4b5563'} ${(1 - opacity) * 100}%, ${isDark ? '#374151' : '#e5e7eb'} ${(1 - opacity) * 100}%, ${isDark ? '#374151' : '#e5e7eb'} 100%)`
|
||||
}}
|
||||
/>
|
||||
<div className="w-16 text-center font-medium text-gray-700 dark:text-gray-200">
|
||||
{Math.round((1 - opacity) * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-3">
|
||||
<h3 className="font-medium text-gray-700 dark:text-gray-200">Markdown 字体大小</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min="12"
|
||||
max="24"
|
||||
step="1"
|
||||
value={fontSize}
|
||||
onChange={(e) => setFontSize(Number.parseInt(e.target.value, 10))}
|
||||
className="flex-1 h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer"
|
||||
style={{
|
||||
background: `linear-gradient(to right, ${isDark ? '#9ca3af' : '#4b5563'} 0%, ${isDark ? '#9ca3af' : '#4b5563'} ${((fontSize - 12) / (24 - 12)) * 100}%, ${isDark ? '#374151' : '#e5e7eb'} ${((fontSize - 12) / (24 - 12)) * 100}%, ${isDark ? '#374151' : '#e5e7eb'} 100%)`
|
||||
}}
|
||||
/>
|
||||
<div className="w-16 text-center font-medium text-gray-700 dark:text-gray-200">
|
||||
{fontSize}px
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/60 dark:bg-gray-900/60 backdrop-blur-md rounded-xl p-6 border border-gray-200/50 dark:border-gray-700/60 shadow-sm opacity-50">
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">其他设置</h2>
|
||||
<p className="text-gray-500 text-sm">更多功能正在开发中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
src/modules/settings/index.tsx
Normal file
11
src/modules/settings/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Settings } from 'lucide-react'
|
||||
import { SettingsPage } from './SettingsPage'
|
||||
import { SETTINGS_MODULE } from '@shared/modules/settings'
|
||||
import { createFrontendModule } from '@/lib/module-registry'
|
||||
|
||||
export default createFrontendModule(SETTINGS_MODULE, {
|
||||
icon: Settings,
|
||||
component: SettingsPage,
|
||||
})
|
||||
|
||||
export { SettingsPage }
|
||||
562
src/modules/time-tracking/TimeTrackingPage.tsx
Normal file
562
src/modules/time-tracking/TimeTrackingPage.tsx
Normal file
@@ -0,0 +1,562 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Clock, FileText, CheckSquare, Settings, Search, Trash2, BookOpen, Monitor, Code, GitBranch } from 'lucide-react'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Area, AreaChart } from 'recharts'
|
||||
import { getDayTimeData, getMonthTimeData, getYearTimeData } from './api'
|
||||
import { WEEK_DAYS } from '@shared/constants'
|
||||
import type { DayTimeData, MonthTimeData, YearTimeData, TabType, TabSummary, DaySummary } from '@shared/types'
|
||||
import { formatDurationShort, getWeekStart } from '@shared/utils/date'
|
||||
|
||||
type ViewMode = 'day' | 'week' | 'month' | 'year'
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
return formatDurationShort(seconds * 1000)
|
||||
}
|
||||
|
||||
const formatDateDisplay = (dateStr: string): string => {
|
||||
const [year, month, day] = dateStr.split('-')
|
||||
const date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day))
|
||||
const weekDay = WEEK_DAYS[date.getDay()]
|
||||
return `${parseInt(month)}月${parseInt(day)}日 ${weekDay}`
|
||||
}
|
||||
|
||||
const getTabIcon = (tabType: TabType): React.ReactNode => {
|
||||
switch (tabType) {
|
||||
case 'markdown':
|
||||
return <FileText size={16} />
|
||||
case 'todo':
|
||||
return <CheckSquare size={16} />
|
||||
case 'settings':
|
||||
return <Settings size={16} />
|
||||
case 'search':
|
||||
return <Search size={16} />
|
||||
case 'recycle-bin':
|
||||
return <Trash2 size={16} />
|
||||
case 'weread':
|
||||
return <BookOpen size={16} />
|
||||
case 'remote':
|
||||
return <Monitor size={16} />
|
||||
case 'remote-desktop':
|
||||
return <Monitor size={16} />
|
||||
case 'remote-git':
|
||||
return <GitBranch size={16} />
|
||||
case 'pydemos':
|
||||
return <Code size={16} />
|
||||
default:
|
||||
return <FileText size={16} />
|
||||
}
|
||||
}
|
||||
|
||||
const getTabColor = (tabType: TabType): string => {
|
||||
switch (tabType) {
|
||||
case 'markdown':
|
||||
return 'text-blue-500'
|
||||
case 'todo':
|
||||
return 'text-green-500'
|
||||
case 'settings':
|
||||
return 'text-gray-500'
|
||||
case 'search':
|
||||
return 'text-purple-500'
|
||||
case 'recycle-bin':
|
||||
return 'text-red-500'
|
||||
case 'weread':
|
||||
return 'text-orange-500'
|
||||
case 'time-tracking':
|
||||
return 'text-cyan-500'
|
||||
case 'remote':
|
||||
return 'text-indigo-500'
|
||||
case 'remote-desktop':
|
||||
return 'text-indigo-500'
|
||||
case 'remote-git':
|
||||
return 'text-orange-500'
|
||||
case 'pydemos':
|
||||
return 'text-yellow-500'
|
||||
default:
|
||||
return 'text-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
const getTabDisplayName = (tabType: TabType, fileName: string): string => {
|
||||
if (tabType === 'remote') {
|
||||
if (fileName === 'remote-tab') {
|
||||
return '远程'
|
||||
}
|
||||
if (fileName.startsWith('remote-git-')) {
|
||||
return '远程Git'
|
||||
}
|
||||
if (fileName.startsWith('remote-')) {
|
||||
return '远程桌面'
|
||||
}
|
||||
return '远程'
|
||||
}
|
||||
if (tabType === 'remote-desktop') {
|
||||
if (fileName.startsWith('remote-git-')) {
|
||||
return '远程Git'
|
||||
}
|
||||
const match = fileName.match(/device=([^&]+)/)
|
||||
const deviceName = match ? decodeURIComponent(match[1]) : ''
|
||||
if (deviceName) {
|
||||
return `远程桌面-${deviceName}`
|
||||
}
|
||||
return '远程桌面'
|
||||
}
|
||||
if (tabType === 'remote-git') {
|
||||
const match = fileName.match(/device=([^&]+)/)
|
||||
const deviceName = match ? decodeURIComponent(match[1]) : ''
|
||||
if (deviceName) {
|
||||
return `远程Git-${deviceName}`
|
||||
}
|
||||
return '远程Git'
|
||||
}
|
||||
return fileName
|
||||
}
|
||||
|
||||
interface HourlyData {
|
||||
hour: string
|
||||
duration: number
|
||||
}
|
||||
|
||||
const getHourlyUsageData = (sessions: Array<{ startTime: string; endTime?: string; duration: number; tabRecords?: Array<{ focusedPeriods: Array<{ start: string; end: string }> }> }>): HourlyData[] => {
|
||||
const hourlyMap: Record<number, number> = {}
|
||||
|
||||
for (let i = 0; i < 24; i++) {
|
||||
hourlyMap[i] = 0
|
||||
}
|
||||
|
||||
sessions.forEach(session => {
|
||||
if (session.tabRecords) {
|
||||
session.tabRecords.forEach(record => {
|
||||
record.focusedPeriods.forEach(period => {
|
||||
const [startH] = period.start.split(':').map(Number)
|
||||
const [endH, endM, endS] = period.end.split(':').map(Number)
|
||||
|
||||
const startHour = startH
|
||||
const endHour = endH
|
||||
|
||||
if (startHour === endHour) {
|
||||
const [_, startM, startS] = period.start.split(':').map(Number)
|
||||
const startSeconds = startM * 60 + startS
|
||||
const endSeconds = endM * 60 + endS
|
||||
hourlyMap[startHour] += (endSeconds - startSeconds)
|
||||
} else {
|
||||
for (let h = startHour; h <= endHour; h++) {
|
||||
if (h === startHour) {
|
||||
const [_, startM, startS] = period.start.split(':').map(Number)
|
||||
hourlyMap[h] += (60 - startM) * 60 - startS
|
||||
} else if (h === endHour) {
|
||||
hourlyMap[h] += endM * 60 + endS
|
||||
} else {
|
||||
hourlyMap[h] += 3600
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
} else {
|
||||
const startTime = new Date(session.startTime)
|
||||
const endTime = session.endTime ? new Date(session.endTime) : new Date()
|
||||
const startHour = startTime.getHours()
|
||||
const endHour = endTime.getHours()
|
||||
|
||||
for (let h = startHour; h <= endHour && h < 24; h++) {
|
||||
if (h === startHour && h === endHour) {
|
||||
const duration = Math.min(session.duration, 3600)
|
||||
hourlyMap[h] += duration
|
||||
} else if (h === startHour) {
|
||||
const minutes = 60 - startTime.getMinutes()
|
||||
hourlyMap[h] += minutes * 60
|
||||
} else if (h === endHour) {
|
||||
hourlyMap[h] += endTime.getMinutes() * 60 + endTime.getSeconds()
|
||||
} else {
|
||||
hourlyMap[h] += 3600
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return Object.entries(hourlyMap)
|
||||
.map(([hour, duration]) => ({
|
||||
hour: `${hour.padStart(2, '0')}:00`,
|
||||
duration
|
||||
}))
|
||||
.sort((a, b) => parseInt(a.hour) - parseInt(b.hour))
|
||||
}
|
||||
|
||||
export const TimeTrackingPage: React.FC = () => {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('day')
|
||||
const [currentDate, setCurrentDate] = useState(new Date())
|
||||
const [dayData, setDayData] = useState<DayTimeData | null>(null)
|
||||
const [monthData, setMonthData] = useState<MonthTimeData | null>(null)
|
||||
const [yearData, setYearData] = useState<YearTimeData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const year = currentDate.getFullYear()
|
||||
const month = currentDate.getMonth() + 1
|
||||
const day = currentDate.getDate()
|
||||
|
||||
if (viewMode === 'day') {
|
||||
const dateStr = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`
|
||||
const data = await getDayTimeData(dateStr)
|
||||
setDayData(data)
|
||||
} else if (viewMode === 'month') {
|
||||
const data = await getMonthTimeData(`${year}-${month.toString().padStart(2, '0')}`)
|
||||
setMonthData(data)
|
||||
} else if (viewMode === 'year') {
|
||||
const data = await getYearTimeData(year)
|
||||
setYearData(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load time data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [viewMode, currentDate])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
const handlePrev = () => {
|
||||
const newDate = new Date(currentDate)
|
||||
if (viewMode === 'day') {
|
||||
newDate.setDate(newDate.getDate() - 1)
|
||||
} else if (viewMode === 'month') {
|
||||
newDate.setMonth(newDate.getMonth() - 1)
|
||||
} else if (viewMode === 'year') {
|
||||
newDate.setFullYear(newDate.getFullYear() - 1)
|
||||
}
|
||||
setCurrentDate(newDate)
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
const newDate = new Date(currentDate)
|
||||
if (viewMode === 'day') {
|
||||
newDate.setDate(newDate.getDate() + 1)
|
||||
} else if (viewMode === 'month') {
|
||||
newDate.setMonth(newDate.getMonth() + 1)
|
||||
} else if (viewMode === 'year') {
|
||||
newDate.setFullYear(newDate.getFullYear() + 1)
|
||||
}
|
||||
setCurrentDate(newDate)
|
||||
}
|
||||
|
||||
const getTitle = (): string => {
|
||||
const year = currentDate.getFullYear()
|
||||
const month = currentDate.getMonth() + 1
|
||||
const day = currentDate.getDate()
|
||||
|
||||
if (viewMode === 'day') {
|
||||
return `${year}年${month}月${day}日`
|
||||
} else if (viewMode === 'month') {
|
||||
return `${year}年${month}月`
|
||||
} else if (viewMode === 'year') {
|
||||
return `${year}年`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const renderDayView = () => {
|
||||
if (!dayData) return null
|
||||
|
||||
const tabSummaries: [string, TabSummary][] = Object.entries(dayData.tabSummary)
|
||||
.sort((a, b) => b[1].totalDuration - a[1].totalDuration)
|
||||
|
||||
const hourlyData = getHourlyUsageData(dayData.sessions)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">今日概览</h3>
|
||||
<div className="flex justify-center">
|
||||
<div className="text-center p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg min-w-[200px]">
|
||||
<div className="text-3xl font-bold text-gray-800 dark:text-gray-200">
|
||||
{formatDuration(dayData.totalDuration)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">总使用时长</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dayData.totalDuration > 0 && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">使用时段分布</h3>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={hourlyData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorDuration" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.2} />
|
||||
<XAxis
|
||||
dataKey="hour"
|
||||
tick={{ fontSize: 12, fill: '#9ca3af' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#374151', opacity: 0.3 }}
|
||||
interval={0}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={50}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: '#9ca3af' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#374151', opacity: 0.3 }}
|
||||
tickFormatter={(value) => `${Math.floor(value / 60)}m`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1f2937',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
color: '#f3f4f6'
|
||||
}}
|
||||
labelStyle={{ color: '#9ca3af' }}
|
||||
formatter={(value: number) => [formatDuration(value), '使用时长']}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="duration"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorDuration)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tabSummaries.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">标签页使用统计</h3>
|
||||
<div className="space-y-3">
|
||||
{tabSummaries.map(([key, summary]) => {
|
||||
const percentage = dayData.totalDuration > 0
|
||||
? Math.round((summary.totalDuration / dayData.totalDuration) * 100)
|
||||
: 0
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-3">
|
||||
<div className={getTabColor(summary.tabType)}>
|
||||
{getTabIcon(summary.tabType)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-800 dark:text-gray-200 truncate">
|
||||
{getTabDisplayName(summary.tabType, summary.fileName)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 ml-2 shrink-0">
|
||||
{formatDuration(summary.totalDuration)} ({percentage}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all duration-300"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dayData.sessions.length === 0 && (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 py-12">
|
||||
暂无使用记录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderMonthView = () => {
|
||||
if (!monthData) return null
|
||||
|
||||
const days: [string, DaySummary][] = Object.entries(monthData.days)
|
||||
.sort((a, b) => parseInt(a[0]) - parseInt(b[0]))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">本月概览</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
||||
{formatDuration(monthData.monthlyTotal)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">总时长</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
||||
{monthData.activeDays}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">活跃天数</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
||||
{formatDuration(monthData.averageDaily)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">日均时长</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{days.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">每日统计</h3>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{days.map(([day, summary]) => {
|
||||
const year = currentDate.getFullYear()
|
||||
const month = currentDate.getMonth() + 1
|
||||
const dateStr = `${year}-${month.toString().padStart(2, '0')}-${day}`
|
||||
return (
|
||||
<div key={day} className="flex items-center justify-between py-2 border-b border-gray-100 dark:border-gray-700 last:border-0">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{formatDateDisplay(dateStr)}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
{formatDuration(summary.totalDuration)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{days.length === 0 && (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 py-12">
|
||||
暂无使用记录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderYearView = () => {
|
||||
if (!yearData) return null
|
||||
|
||||
const months: [string, { totalDuration: number; activeDays: number }][] = Object.entries(yearData.months)
|
||||
.sort((a, b) => parseInt(a[0]) - parseInt(b[0]))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">本年概览</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
||||
{formatDuration(yearData.yearlyTotal)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">总时长</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
||||
{yearData.totalActiveDays}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">活跃天数</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
||||
{formatDuration(yearData.averageDaily)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">日均时长</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{months.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">每月统计</h3>
|
||||
<div className="space-y-2">
|
||||
{months.map(([month, summary]) => (
|
||||
<div key={month} className="flex items-center justify-between py-2 border-b border-gray-100 dark:border-gray-700 last:border-0">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{currentDate.getFullYear()}年{parseInt(month)}月
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{summary.activeDays} 天
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
{formatDuration(summary.totalDuration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{months.length === 0 && (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 py-12">
|
||||
暂无使用记录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto w-full p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-gray-200 mb-8 flex items-center gap-2">
|
||||
<Clock className="w-8 h-8" />
|
||||
时间统计
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
{(['day', 'month', 'year'] as ViewMode[]).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setViewMode(mode)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${viewMode === mode
|
||||
? 'bg-gray-800 dark:bg-gray-200 text-white dark:text-gray-800'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{mode === 'day' ? '日' : mode === 'month' ? '月' : '年'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
className="p-1.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<span className="text-base font-medium text-gray-700 dark:text-gray-300 min-w-[100px] text-center">
|
||||
{getTitle()}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
className="p-1.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 py-8">
|
||||
加载中...
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-2xl mx-auto pb-8">
|
||||
{viewMode === 'day' && renderDayView()}
|
||||
{viewMode === 'month' && renderMonthView()}
|
||||
{viewMode === 'year' && renderYearView()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
src/modules/time-tracking/api.ts
Normal file
65
src/modules/time-tracking/api.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { getModuleApi } from '@/lib/module-registry'
|
||||
import { type TimeTrackingEndpoints } from '@shared/modules/time-tracking'
|
||||
import type { DayTimeData, MonthTimeData, YearTimeData, TimeStats, CurrentTimerState, TimeTrackingEvent } from '@shared/types/time'
|
||||
|
||||
export interface DayTimeDataWithStats extends DayTimeData {
|
||||
stats: {
|
||||
sessionsCount: number
|
||||
averageSessionDuration: number
|
||||
longestSession: number
|
||||
topTabs: Array<{ fileName: string; duration: number }>
|
||||
}
|
||||
}
|
||||
|
||||
export interface WeekTimeData {
|
||||
days: DayTimeData[]
|
||||
totalDuration: number
|
||||
activeDays: number
|
||||
averageDaily: number
|
||||
}
|
||||
|
||||
const getApi = () => {
|
||||
const api = getModuleApi<TimeTrackingEndpoints>('time-tracking')
|
||||
if (!api) {
|
||||
throw new Error('time-tracking module API not initialized')
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
export const getCurrentTimerState = async (): Promise<CurrentTimerState> => {
|
||||
return (await getApi().get<CurrentTimerState>('current'))!
|
||||
}
|
||||
|
||||
export const sendTimeEvent = async (event: TimeTrackingEvent): Promise<void> => {
|
||||
await getApi().post<null>('event', event)
|
||||
}
|
||||
|
||||
export const getDayTimeData = async (date: string): Promise<DayTimeDataWithStats> => {
|
||||
return (await getApi().get<DayTimeDataWithStats>('day', {
|
||||
pathParams: { date },
|
||||
}))!
|
||||
}
|
||||
|
||||
export const getWeekTimeData = async (startDate: string): Promise<WeekTimeData> => {
|
||||
return (await getApi().get<WeekTimeData>('week', {
|
||||
pathParams: { startDate },
|
||||
}))!
|
||||
}
|
||||
|
||||
export const getMonthTimeData = async (yearMonth: string): Promise<MonthTimeData> => {
|
||||
return (await getApi().get<MonthTimeData>('month', {
|
||||
pathParams: { yearMonth },
|
||||
}))!
|
||||
}
|
||||
|
||||
export const getYearTimeData = async (year: number): Promise<YearTimeData> => {
|
||||
return (await getApi().get<YearTimeData>('year', {
|
||||
pathParams: { year },
|
||||
}))!
|
||||
}
|
||||
|
||||
export const getTimeStats = async (year?: number, month?: number): Promise<TimeStats> => {
|
||||
return (await getApi().get<TimeStats>('stats', {
|
||||
queryParams: { year, month },
|
||||
}))!
|
||||
}
|
||||
164
src/modules/time-tracking/context/TimeTrackerContext.tsx
Normal file
164
src/modules/time-tracking/context/TimeTrackerContext.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React, { createContext, useContext, useEffect, useCallback, useRef, type ReactNode } from 'react'
|
||||
import { sendTimeEvent, getCurrentTimerState } from '../api'
|
||||
import { getTabTypeFromPath, getFileNameFromPath } from '@shared/utils/tabType'
|
||||
import type { TabType, TimeTrackingEvent, CurrentTimerState } from '@shared/types/time'
|
||||
|
||||
interface TimeTrackerContextType {
|
||||
isRunning: boolean
|
||||
isPaused: boolean
|
||||
todayDuration: number
|
||||
currentTab: { tabId: string; fileName: string; tabType: TabType } | null
|
||||
trackTabSwitch: (tabInfo: { tabId: string; filePath: string | null }) => void
|
||||
trackWindowFocus: () => void
|
||||
trackWindowBlur: () => void
|
||||
trackAppQuit: () => void
|
||||
}
|
||||
|
||||
const TimeTrackerContext = createContext<TimeTrackerContextType | undefined>(undefined)
|
||||
|
||||
export const useTimeTracker = () => {
|
||||
const context = useContext(TimeTrackerContext)
|
||||
if (!context) {
|
||||
throw new Error('useTimeTracker must be used within a TimeTrackerProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export const TimeTrackerProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [state, setState] = React.useState<CurrentTimerState>({
|
||||
isRunning: false,
|
||||
isPaused: false,
|
||||
currentSession: null,
|
||||
todayDuration: 0
|
||||
})
|
||||
|
||||
const heartbeatIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const lastTabRef = useRef<{ tabId: string; filePath: string | null } | null>(null)
|
||||
|
||||
const sendEvent = useCallback(async (event: TimeTrackingEvent) => {
|
||||
try {
|
||||
await sendTimeEvent(event)
|
||||
} catch (error) {
|
||||
console.error('Failed to send time event:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const trackTabSwitch = useCallback((tabInfo: { tabId: string; filePath: string | null }) => {
|
||||
const event: TimeTrackingEvent = {
|
||||
type: 'tab-switch',
|
||||
timestamp: new Date().toISOString(),
|
||||
tabInfo: {
|
||||
tabId: tabInfo.tabId,
|
||||
filePath: tabInfo.filePath,
|
||||
fileName: getFileNameFromPath(tabInfo.filePath),
|
||||
tabType: getTabTypeFromPath(tabInfo.filePath)
|
||||
}
|
||||
}
|
||||
sendEvent(event)
|
||||
lastTabRef.current = tabInfo
|
||||
}, [sendEvent])
|
||||
|
||||
const trackWindowFocus = useCallback(() => {
|
||||
const event: TimeTrackingEvent = {
|
||||
type: 'window-focus',
|
||||
timestamp: new Date().toISOString(),
|
||||
tabInfo: lastTabRef.current ? {
|
||||
tabId: lastTabRef.current.tabId,
|
||||
filePath: lastTabRef.current.filePath,
|
||||
fileName: getFileNameFromPath(lastTabRef.current.filePath),
|
||||
tabType: getTabTypeFromPath(lastTabRef.current.filePath)
|
||||
} : undefined
|
||||
}
|
||||
sendEvent(event)
|
||||
}, [sendEvent])
|
||||
|
||||
const trackWindowBlur = useCallback(() => {
|
||||
const event: TimeTrackingEvent = {
|
||||
type: 'window-blur',
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
sendEvent(event)
|
||||
}, [sendEvent])
|
||||
|
||||
const trackAppQuit = useCallback(() => {
|
||||
const event: TimeTrackingEvent = {
|
||||
type: 'app-quit',
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
sendEvent(event)
|
||||
}, [sendEvent])
|
||||
|
||||
useEffect(() => {
|
||||
const initTracker = async () => {
|
||||
try {
|
||||
const currentState = await getCurrentTimerState()
|
||||
setState(currentState)
|
||||
} catch (error) {
|
||||
console.error('Failed to get current timer state:', error)
|
||||
}
|
||||
}
|
||||
|
||||
initTracker()
|
||||
|
||||
trackWindowFocus()
|
||||
|
||||
heartbeatIntervalRef.current = setInterval(() => {
|
||||
sendEvent({
|
||||
type: 'heartbeat',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}, 60000)
|
||||
|
||||
return () => {
|
||||
if (heartbeatIntervalRef.current) {
|
||||
clearInterval(heartbeatIntervalRef.current)
|
||||
}
|
||||
trackAppQuit()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
trackAppQuit()
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}
|
||||
}, [trackAppQuit])
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
trackWindowBlur()
|
||||
} else {
|
||||
trackWindowFocus()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
}, [trackWindowFocus, trackWindowBlur])
|
||||
|
||||
const value: TimeTrackerContextType = {
|
||||
isRunning: state.isRunning,
|
||||
isPaused: state.isPaused,
|
||||
todayDuration: state.todayDuration,
|
||||
currentTab: state.currentSession?.currentTab || null,
|
||||
trackTabSwitch,
|
||||
trackWindowFocus,
|
||||
trackWindowBlur,
|
||||
trackAppQuit
|
||||
}
|
||||
|
||||
return (
|
||||
<TimeTrackerContext.Provider value={value}>
|
||||
{children}
|
||||
</TimeTrackerContext.Provider>
|
||||
)
|
||||
}
|
||||
24
src/modules/time-tracking/index.tsx
Normal file
24
src/modules/time-tracking/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Clock } from 'lucide-react'
|
||||
import { TimeTrackingPage } from './TimeTrackingPage'
|
||||
import { TIME_TRACKING_MODULE } from '@shared/modules/time-tracking'
|
||||
import { createFrontendModule } from '@/lib/module-registry'
|
||||
|
||||
export default createFrontendModule(TIME_TRACKING_MODULE, {
|
||||
icon: Clock,
|
||||
component: TimeTrackingPage,
|
||||
})
|
||||
|
||||
export { TimeTrackingPage } from './TimeTrackingPage'
|
||||
export {
|
||||
getCurrentTimerState,
|
||||
sendTimeEvent,
|
||||
getDayTimeData,
|
||||
getWeekTimeData,
|
||||
getMonthTimeData,
|
||||
getYearTimeData,
|
||||
getTimeStats,
|
||||
} from './api'
|
||||
export type { ViewMode, HourlyData, TimeTrackingPageState } from './types'
|
||||
export type { DayTimeDataWithStats, WeekTimeData } from './api'
|
||||
export { WEEK_DAYS } from '@shared/constants'
|
||||
export { TimeTrackerProvider, useTimeTracker } from './context/TimeTrackerContext'
|
||||
15
src/modules/time-tracking/types.ts
Normal file
15
src/modules/time-tracking/types.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export type ViewMode = 'day' | 'week' | 'month' | 'year'
|
||||
|
||||
export interface HourlyData {
|
||||
hour: string
|
||||
duration: number
|
||||
}
|
||||
|
||||
export interface TimeTrackingPageState {
|
||||
viewMode: ViewMode
|
||||
currentDate: Date
|
||||
dayData: import('@shared/types').DayTimeData | null
|
||||
monthData: import('@shared/types').MonthTimeData | null
|
||||
yearData: import('@shared/types').YearTimeData | null
|
||||
loading: boolean
|
||||
}
|
||||
349
src/modules/todo/TodoPage.tsx
Normal file
349
src/modules/todo/TodoPage.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Plus, Trash2, ChevronLeft, ChevronRight, ListTodo } from 'lucide-react'
|
||||
import { getTodoData, addTodoItem, toggleTodoItem, updateTodoItem, deleteTodoItem } from './api'
|
||||
import { WEEK_DAYS } from '@shared/constants'
|
||||
import { getTodayDate, getTomorrowDate } from '@shared/utils/date'
|
||||
import type { DayTodo } from './types'
|
||||
|
||||
const formatDateDisplay = (dateStr: string): string => {
|
||||
const [year, month, day] = dateStr.split('-')
|
||||
const date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day))
|
||||
const weekDay = WEEK_DAYS[date.getDay()]
|
||||
return `${parseInt(month)}月${parseInt(day)}日 ${weekDay}`
|
||||
}
|
||||
|
||||
const isToday = (dateStr: string): boolean => {
|
||||
return dateStr === getTodayDate()
|
||||
}
|
||||
|
||||
const isTomorrow = (dateStr: string): boolean => {
|
||||
return dateStr === getTomorrowDate()
|
||||
}
|
||||
|
||||
const isEditable = (dateStr: string): boolean => {
|
||||
const today = getTodayDate()
|
||||
return dateStr >= today
|
||||
}
|
||||
|
||||
export const TodoPage: React.FC = () => {
|
||||
const [dayTodos, setDayTodos] = useState<DayTodo[]>([])
|
||||
const [currentYear, setCurrentYear] = useState(new Date().getFullYear())
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date().getMonth() + 1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editingItem, setEditingItem] = useState<{ date: string; index: number } | null>(null)
|
||||
const [editContent, setEditContent] = useState('')
|
||||
const [addingDate, setAddingDate] = useState<string | null>(null)
|
||||
const [newTodoContent, setNewTodoContent] = useState('')
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const loadTodoData = useCallback(async (year: number, month: number, isInitial: boolean = false) => {
|
||||
if (isInitial) {
|
||||
setLoading(true)
|
||||
}
|
||||
try {
|
||||
const data = await getTodoData(year, month)
|
||||
setDayTodos(data.dayTodos)
|
||||
} catch (error) {
|
||||
console.error('Failed to load TODO:', error)
|
||||
setDayTodos([])
|
||||
} finally {
|
||||
if (isInitial) {
|
||||
setLoading(false)
|
||||
setIsInitialLoad(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadTodoData(currentYear, currentMonth, isInitialLoad)
|
||||
}, [currentYear, currentMonth, loadTodoData, isInitialLoad])
|
||||
|
||||
useEffect(() => {
|
||||
if (addingDate && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [addingDate])
|
||||
|
||||
const handlePrevMonth = () => {
|
||||
if (currentMonth === 1) {
|
||||
setCurrentMonth(12)
|
||||
setCurrentYear(currentYear - 1)
|
||||
} else {
|
||||
setCurrentMonth(currentMonth - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNextMonth = () => {
|
||||
if (currentMonth === 12) {
|
||||
setCurrentMonth(1)
|
||||
setCurrentYear(currentYear + 1)
|
||||
} else {
|
||||
setCurrentMonth(currentMonth + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartAdd = (date: string) => {
|
||||
setAddingDate(date)
|
||||
setNewTodoContent('')
|
||||
}
|
||||
|
||||
const handleCancelAdd = () => {
|
||||
setAddingDate(null)
|
||||
setNewTodoContent('')
|
||||
}
|
||||
|
||||
const handleAddTodo = async (date: string, continueAdding: boolean = false) => {
|
||||
const content = newTodoContent.trim()
|
||||
if (!content) {
|
||||
setAddingDate(null)
|
||||
setNewTodoContent('')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await addTodoItem(currentYear, currentMonth, date, content)
|
||||
setDayTodos(data.dayTodos)
|
||||
if (continueAdding) {
|
||||
setNewTodoContent('')
|
||||
} else {
|
||||
setAddingDate(null)
|
||||
setNewTodoContent('')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to add TODO:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleTodo = async (date: string, itemIndex: number, completed: boolean) => {
|
||||
try {
|
||||
const data = await toggleTodoItem(currentYear, currentMonth, date, itemIndex, completed)
|
||||
setDayTodos(data.dayTodos)
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle TODO:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartEdit = (date: string, index: number, content: string) => {
|
||||
setEditingItem({ date, index })
|
||||
setEditContent(content)
|
||||
setAddingDate(null)
|
||||
}
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editingItem) return
|
||||
const { date, index } = editingItem
|
||||
const newContent = editContent.trim()
|
||||
if (!newContent) return
|
||||
|
||||
try {
|
||||
const data = await updateTodoItem(currentYear, currentMonth, date, index, newContent)
|
||||
setDayTodos(data.dayTodos)
|
||||
} catch (error) {
|
||||
console.error('Failed to update TODO:', error)
|
||||
} finally {
|
||||
setEditingItem(null)
|
||||
setEditContent('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingItem(null)
|
||||
setEditContent('')
|
||||
}
|
||||
|
||||
const handleDeleteTodo = async (date: string, itemIndex: number) => {
|
||||
try {
|
||||
const data = await deleteTodoItem(currentYear, currentMonth, date, itemIndex)
|
||||
setDayTodos(data.dayTodos)
|
||||
} catch (error) {
|
||||
console.error('Failed to delete TODO:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent, date: string) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleAddTodo(date, true)
|
||||
} else if (e.key === 'Escape') {
|
||||
handleCancelAdd()
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSaveEdit()
|
||||
} else if (e.key === 'Escape') {
|
||||
handleCancelEdit()
|
||||
}
|
||||
}
|
||||
|
||||
const todayDate = getTodayDate()
|
||||
const tomorrowDate = getTomorrowDate()
|
||||
|
||||
const now = new Date()
|
||||
const isCurrentMonth = currentYear === now.getFullYear() && currentMonth === now.getMonth() + 1
|
||||
|
||||
const allDates = [...new Set([
|
||||
...dayTodos.map(d => d.date),
|
||||
...(isCurrentMonth ? [todayDate, tomorrowDate] : [])
|
||||
])].sort()
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto w-full p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-gray-200 mb-8 flex items-center gap-2">
|
||||
<ListTodo className="w-8 h-8" />
|
||||
TODO
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handlePrevMonth}
|
||||
className="p-1.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<span className="text-base font-medium text-gray-700 dark:text-gray-300 min-w-[80px] text-center">
|
||||
{currentYear}年{currentMonth}月
|
||||
</span>
|
||||
<button
|
||||
onClick={handleNextMonth}
|
||||
className="p-1.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && isInitialLoad ? (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 py-12">
|
||||
加载中...
|
||||
</div>
|
||||
) : allDates.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<ListTodo size={48} className="mx-auto mb-4 opacity-30" />
|
||||
<p>暂无计划</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 pb-8">
|
||||
{allDates.map((date) => {
|
||||
const dayTodo = dayTodos.find(d => d.date === date)
|
||||
const isTodayDate = isToday(date)
|
||||
const isTomorrowDate = isTomorrow(date)
|
||||
const canEdit = isEditable(date)
|
||||
|
||||
return (
|
||||
<div key={date} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className={`text-lg font-semibold ${isTodayDate
|
||||
? 'text-gray-900 dark:text-gray-100'
|
||||
: isTomorrowDate
|
||||
? 'text-gray-700 dark:text-gray-300'
|
||||
: 'text-gray-500 dark:text-gray-500'
|
||||
}`}>
|
||||
{formatDateDisplay(date)}
|
||||
</h3>
|
||||
{isTodayDate && (
|
||||
<span className="px-2 py-0.5 text-xs bg-gray-800 dark:bg-gray-200 text-white dark:text-gray-800 rounded-full">
|
||||
今天
|
||||
</span>
|
||||
)}
|
||||
{isTomorrowDate && (
|
||||
<span className="px-2 py-0.5 text-xs bg-gray-400 dark:bg-gray-600 text-white rounded-full">
|
||||
明天
|
||||
</span>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => handleStartAdd(date)}
|
||||
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
title="添加任务"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pl-2 space-y-1">
|
||||
{dayTodo?.items.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-3 group h-7"
|
||||
>
|
||||
<button
|
||||
onClick={() => canEdit && handleToggleTodo(date, index, !item.completed)}
|
||||
disabled={!canEdit}
|
||||
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors ${item.completed
|
||||
? 'bg-gray-600 dark:bg-gray-400 border-gray-600 dark:border-gray-400 text-white'
|
||||
: 'border-gray-400 dark:border-gray-500'
|
||||
} ${canEdit ? 'hover:border-gray-600 dark:hover:border-gray-400 cursor-pointer' : 'cursor-default'}`}
|
||||
>
|
||||
{item.completed && (
|
||||
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{editingItem?.date === date && editingItem?.index === index ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
onBlur={handleSaveEdit}
|
||||
autoFocus
|
||||
className="flex-1 px-2 py-0.5 bg-transparent border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-gray-400 text-gray-800 dark:text-gray-200"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onDoubleClick={() => canEdit && handleStartEdit(date, index, item.content)}
|
||||
className={`flex-1 ${canEdit ? 'cursor-pointer' : 'cursor-default'
|
||||
} ${item.completed
|
||||
? 'text-gray-400 dark:text-gray-500 line-through'
|
||||
: 'text-gray-700 dark:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{item.content}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => handleDeleteTodo(date, index)}
|
||||
className="p-1 opacity-0 group-hover:opacity-100 hover:bg-red-100 dark:hover:bg-red-900/30 rounded transition-all text-red-500"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{canEdit && addingDate === date && (
|
||||
<div className="flex items-center gap-3 h-7">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-gray-400 dark:border-gray-500 shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={newTodoContent}
|
||||
onChange={(e) => setNewTodoContent(e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, date)}
|
||||
onBlur={() => {
|
||||
handleAddTodo(date, false)
|
||||
}}
|
||||
placeholder="输入任务内容..."
|
||||
className="flex-1 px-2 py-0.5 bg-transparent border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-gray-400 text-gray-800 dark:text-gray-200 placeholder-gray-400"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
src/modules/todo/api.ts
Normal file
44
src/modules/todo/api.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { getModuleApi } from '@/lib/module-registry'
|
||||
import { type TodoEndpoints } from '@shared/modules/todo'
|
||||
import type { DayTodo } from './types'
|
||||
|
||||
export interface TodoData {
|
||||
dayTodos: DayTodo[]
|
||||
year: number
|
||||
month: number
|
||||
}
|
||||
|
||||
const getApi = () => {
|
||||
const api = getModuleApi<TodoEndpoints>('todo')
|
||||
if (!api) {
|
||||
console.error('[TODO API] getModuleApi returned undefined! Available modules:', (window as any).__debug_modules)
|
||||
throw new Error('TODO module API not initialized')
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
export const getTodoData = async (year?: number, month?: number): Promise<TodoData> => {
|
||||
return (await getApi().get<TodoData>('list', {
|
||||
queryParams: { year, month },
|
||||
}))!
|
||||
}
|
||||
|
||||
export const addTodoItem = async (year: number, month: number, date: string, content: string): Promise<{ dayTodos: DayTodo[] }> => {
|
||||
return (await getApi().post<{ dayTodos: DayTodo[] }>('add', { year, month, date, content }))!
|
||||
}
|
||||
|
||||
export const toggleTodoItem = async (year: number, month: number, date: string, itemIndex: number, completed: boolean): Promise<{ dayTodos: DayTodo[] }> => {
|
||||
return (await getApi().post<{ dayTodos: DayTodo[] }>('toggle', { year, month, date, itemIndex, completed }))!
|
||||
}
|
||||
|
||||
export const updateTodoItem = async (year: number, month: number, date: string, itemIndex: number, content: string): Promise<{ dayTodos: DayTodo[] }> => {
|
||||
return (await getApi().post<{ dayTodos: DayTodo[] }>('update', { year, month, date, itemIndex, content }))!
|
||||
}
|
||||
|
||||
export const deleteTodoItem = async (year: number, month: number, date: string, itemIndex: number): Promise<{ dayTodos: DayTodo[] }> => {
|
||||
return (await getApi().delete<{ dayTodos: DayTodo[] }>('delete', { year, month, date, itemIndex }))!
|
||||
}
|
||||
|
||||
export const saveTodo = async (year: number, month: number, dayTodos: DayTodo[]): Promise<void> => {
|
||||
await getApi().post<null>('save', { year, month, dayTodos })
|
||||
}
|
||||
14
src/modules/todo/index.tsx
Normal file
14
src/modules/todo/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ListTodo } from 'lucide-react'
|
||||
import { TodoPage } from './TodoPage'
|
||||
import { TODO_MODULE } from '@shared/modules/todo'
|
||||
import { createFrontendModule } from '@/lib/module-registry'
|
||||
|
||||
export default createFrontendModule(TODO_MODULE, {
|
||||
icon: ListTodo,
|
||||
component: TodoPage,
|
||||
})
|
||||
|
||||
export { TodoPage } from './TodoPage'
|
||||
export { getTodoData, addTodoItem, toggleTodoItem, updateTodoItem, deleteTodoItem, saveTodo } from './api'
|
||||
export type { TodoItem, DayTodo, TodoPageState } from './types'
|
||||
export { WEEK_DAYS } from '@shared/constants'
|
||||
15
src/modules/todo/types.ts
Normal file
15
src/modules/todo/types.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { DayTodo } from '@shared/types/todo'
|
||||
|
||||
export type { TodoItem, DayTodo } from '@shared/types/todo'
|
||||
|
||||
export interface TodoPageState {
|
||||
dayTodos: DayTodo[]
|
||||
currentYear: number
|
||||
currentMonth: number
|
||||
loading: boolean
|
||||
editingItem: { date: string; index: number } | null
|
||||
editContent: string
|
||||
addingDate: string | null
|
||||
newTodoContent: string
|
||||
isInitialLoad: boolean
|
||||
}
|
||||
215
src/modules/weread/WeReadPage.tsx
Normal file
215
src/modules/weread/WeReadPage.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { useState } from 'react'
|
||||
import { BookOpen, Loader2, RefreshCw, ArrowLeft, ArrowRight, Bug, FileText } from 'lucide-react'
|
||||
import { useWebviewControl } from './hooks/useWebviewControl'
|
||||
import { useDebugInfo } from './hooks/useDebugInfo'
|
||||
import { useCreateNote } from './hooks/useCreateNote'
|
||||
import { DebugDialog } from './components/DebugDialog'
|
||||
import { FULL_EXTRACT_SCRIPT } from './utils/scripts'
|
||||
import type { BookInfo, PageInfo, TocInfo } from './types'
|
||||
|
||||
export const WeReadPage: React.FC = () => {
|
||||
const {
|
||||
webviewRef,
|
||||
loading,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
handleRefresh,
|
||||
handleGoBack,
|
||||
handleGoForward
|
||||
} = useWebviewControl()
|
||||
|
||||
const {
|
||||
showDebug,
|
||||
debugInfo,
|
||||
debugLoading,
|
||||
handleDebug,
|
||||
handleCloseDebug
|
||||
} = useDebugInfo(webviewRef as React.RefObject<HTMLWebViewElement>)
|
||||
|
||||
const { createNoteLoading, createNote } = useCreateNote()
|
||||
const [createNoteStatus, setCreateNoteStatus] = useState<string | null>(null)
|
||||
|
||||
const handleCreateNote = async () => {
|
||||
const webview = webviewRef.current
|
||||
if (!webview) return
|
||||
|
||||
setCreateNoteStatus(null)
|
||||
|
||||
try {
|
||||
const pageInfo = await webview.executeJavaScript(FULL_EXTRACT_SCRIPT) as PageInfo
|
||||
|
||||
let tocList: TocInfo[] | null = null
|
||||
if (pageInfo.toc) {
|
||||
try {
|
||||
tocList = JSON.parse(pageInfo.toc) as TocInfo[]
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
const bookInfo: BookInfo = {
|
||||
bookTitle: pageInfo.bookTitle,
|
||||
author: pageInfo.author,
|
||||
chapterTitle: pageInfo.chapterTitle,
|
||||
selectedText: pageInfo.selectedText,
|
||||
readingTime: pageInfo.readingTime,
|
||||
rating: pageInfo.rating,
|
||||
bookStatus: pageInfo.bookStatus,
|
||||
bookCover: pageInfo.bookCover,
|
||||
userAvatar: pageInfo.userAvatar,
|
||||
currentPage: pageInfo.currentPage,
|
||||
toc: tocList
|
||||
}
|
||||
|
||||
const result = await createNote(bookInfo)
|
||||
|
||||
if (result.success) {
|
||||
setCreateNoteStatus(`笔记已创建: ${result.path}`)
|
||||
} else {
|
||||
setCreateNoteStatus(`创建失败: ${result.error}`)
|
||||
}
|
||||
} catch (err) {
|
||||
setCreateNoteStatus(`创建失败: ${String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
<WeReadToolbar
|
||||
loading={loading}
|
||||
debugLoading={debugLoading}
|
||||
createNoteLoading={createNoteLoading}
|
||||
canGoBack={canGoBack}
|
||||
canGoForward={canGoForward}
|
||||
onRefresh={handleRefresh}
|
||||
onGoBack={handleGoBack}
|
||||
onGoForward={handleGoForward}
|
||||
onDebug={handleDebug}
|
||||
onCreateNote={handleCreateNote}
|
||||
createNoteStatus={createNoteStatus}
|
||||
/>
|
||||
|
||||
<div className="flex-1 relative min-h-0">
|
||||
<webview
|
||||
ref={webviewRef}
|
||||
src="https://weread.qq.com/"
|
||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||
allowpopups={true}
|
||||
webpreferences="contextIsolation=no"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DebugDialog
|
||||
isOpen={showDebug}
|
||||
debugInfo={debugInfo}
|
||||
onClose={handleCloseDebug}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ToolbarProps {
|
||||
loading: boolean
|
||||
debugLoading: boolean
|
||||
createNoteLoading: boolean
|
||||
canGoBack: boolean
|
||||
canGoForward: boolean
|
||||
onRefresh: () => void
|
||||
onGoBack: () => void
|
||||
onGoForward: () => void
|
||||
onDebug: () => void
|
||||
onCreateNote: () => void
|
||||
createNoteStatus: string | null
|
||||
}
|
||||
|
||||
const WeReadToolbar: React.FC<ToolbarProps> = ({
|
||||
loading,
|
||||
debugLoading,
|
||||
createNoteLoading,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
onRefresh,
|
||||
onGoBack,
|
||||
onGoForward,
|
||||
onDebug,
|
||||
onCreateNote,
|
||||
createNoteStatus
|
||||
}) => (
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-200 dark:border-gray-700/60 bg-white/60 dark:bg-gray-900/60 backdrop-blur-md shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
<h1 className="text-lg font-medium text-gray-800 dark:text-gray-200">微信读书</h1>
|
||||
{createNoteStatus && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">
|
||||
{createNoteStatus}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ToolbarButton
|
||||
onClick={onGoBack}
|
||||
disabled={!canGoBack}
|
||||
title="后退"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={onGoForward}
|
||||
disabled={!canGoForward}
|
||||
title="前进"
|
||||
>
|
||||
<ArrowRight size={18} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
title="刷新"
|
||||
>
|
||||
{loading ? <Loader2 className="animate-spin" size={18} /> : <RefreshCw size={18} />}
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={onDebug}
|
||||
disabled={debugLoading}
|
||||
title="调试信息"
|
||||
>
|
||||
{debugLoading ? <Loader2 className="animate-spin" size={18} /> : <Bug size={18} />}
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={onCreateNote}
|
||||
disabled={createNoteLoading}
|
||||
title="创建笔记"
|
||||
primary
|
||||
>
|
||||
{createNoteLoading ? <Loader2 className="animate-spin" size={18} /> : <FileText size={18} />}
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
onClick: () => void
|
||||
disabled: boolean
|
||||
title: string
|
||||
primary?: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const ToolbarButton: React.FC<ToolbarButtonProps> = ({
|
||||
onClick,
|
||||
disabled,
|
||||
title,
|
||||
primary,
|
||||
children
|
||||
}) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`p-2 rounded-md transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
|
||||
primary
|
||||
? 'bg-blue-500 hover:bg-blue-600 text-white'
|
||||
: 'hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
}`}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
132
src/modules/weread/components/DebugDialog.tsx
Normal file
132
src/modules/weread/components/DebugDialog.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React from 'react'
|
||||
import type { DebugInfo } from '../types'
|
||||
|
||||
interface DebugDialogProps {
|
||||
isOpen: boolean
|
||||
debugInfo: DebugInfo | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const DebugDialog: React.FC<DebugDialogProps> = ({
|
||||
isOpen,
|
||||
debugInfo,
|
||||
onClose
|
||||
}) => {
|
||||
if (!isOpen || !debugInfo) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-3xl w-full mx-4 max-h-[85vh] overflow-hidden"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-medium text-gray-800 dark:text-gray-200">调试信息</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors text-gray-500"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 overflow-y-auto max-h-[calc(85vh-60px)]">
|
||||
{debugInfo.error ? (
|
||||
<div className="text-red-500">{debugInfo.error}</div>
|
||||
) : (
|
||||
<div className="space-y-4 text-sm">
|
||||
<InfoGrid debugInfo={debugInfo} />
|
||||
<ImagePreview debugInfo={debugInfo} />
|
||||
<TocInfo toc={debugInfo.toc} />
|
||||
<UrlInfo url={debugInfo.url} />
|
||||
<DomInfo domInfo={debugInfo.domInfo} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const InfoGrid: React.FC<{ debugInfo: DebugInfo }> = ({ debugInfo }) => (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoItem label="书名" value={debugInfo.bookTitle} />
|
||||
<InfoItem label="作者" value={debugInfo.author} />
|
||||
<InfoItem label="章节标题" value={debugInfo.chapterTitle} />
|
||||
<InfoItem label="书籍状态" value={debugInfo.bookStatus} />
|
||||
<InfoItem label="阅读时间" value={debugInfo.readingTime} />
|
||||
<InfoItem label="评分" value={debugInfo.rating} />
|
||||
<InfoItem label="当前页面" value={debugInfo.currentPage} isSmall />
|
||||
<InfoItem label="选中文字" value={debugInfo.selectedText} />
|
||||
</div>
|
||||
)
|
||||
|
||||
const InfoItem: React.FC<{
|
||||
label: string
|
||||
value: string | null
|
||||
isSmall?: boolean
|
||||
}> = ({ label, value, isSmall }) => (
|
||||
<div>
|
||||
<div className="font-medium text-gray-700 dark:text-gray-300 mb-1">{label}</div>
|
||||
<div className={`bg-gray-100 dark:bg-gray-900 p-2 rounded text-gray-800 dark:text-gray-200 ${isSmall ? 'text-xs' : ''}`}>
|
||||
{value || '(未检测到)'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const ImagePreview: React.FC<{ debugInfo: DebugInfo }> = ({ debugInfo }) => {
|
||||
if (!debugInfo.bookCover && !debugInfo.userAvatar) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="font-medium text-gray-700 dark:text-gray-300 mb-2">图片</div>
|
||||
<div className="flex gap-4">
|
||||
{debugInfo.bookCover && (
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 mb-1">书籍封面</div>
|
||||
<img src={debugInfo.bookCover} alt="cover" className="w-16 h-20 object-cover rounded" />
|
||||
</div>
|
||||
)}
|
||||
{debugInfo.userAvatar && (
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 mb-1">用户头像</div>
|
||||
<img src={debugInfo.userAvatar} alt="avatar" className="w-12 h-12 rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TocInfo: React.FC<{ toc: string | null }> = ({ toc }) => {
|
||||
if (!toc) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="font-medium text-gray-700 dark:text-gray-300 mb-1">目录信息</div>
|
||||
<pre className="bg-gray-100 dark:bg-gray-900 p-2 rounded text-gray-800 dark:text-gray-200 overflow-x-auto text-xs max-h-48">
|
||||
{toc}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const UrlInfo: React.FC<{ url: string }> = ({ url }) => (
|
||||
<div>
|
||||
<div className="font-medium text-gray-700 dark:text-gray-300 mb-1">当前 URL</div>
|
||||
<div className="bg-gray-100 dark:bg-gray-900 p-2 rounded text-gray-800 dark:text-gray-200 break-all text-xs">
|
||||
{url || '(空)'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const DomInfo: React.FC<{ domInfo: string | null }> = ({ domInfo }) => (
|
||||
<div>
|
||||
<div className="font-medium text-gray-700 dark:text-gray-300 mb-1">DOM 结构信息</div>
|
||||
<pre className="bg-gray-100 dark:bg-gray-900 p-2 rounded text-gray-800 dark:text-gray-200 overflow-x-auto text-xs max-h-96">
|
||||
{domInfo}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
122
src/modules/weread/hooks/useCreateNote.ts
Normal file
122
src/modules/weread/hooks/useCreateNote.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { createDirectory, createFile, saveFileContent } from '@/lib/api'
|
||||
import type { BookInfo, TocInfo, TocItem } from '../types'
|
||||
|
||||
export interface CreateNoteResult {
|
||||
success: boolean
|
||||
path?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface CreateNoteControl {
|
||||
createNoteLoading: boolean
|
||||
createNote: (bookInfo: BookInfo) => Promise<CreateNoteResult>
|
||||
}
|
||||
|
||||
function sanitizeFileName(name: string): string {
|
||||
return name.replace(/[<>:"/\\|?*]/g, '_').trim()
|
||||
}
|
||||
|
||||
function generateTocMarkdown(tocList: TocInfo[] | null): string {
|
||||
if (!tocList || tocList.length === 0) return ''
|
||||
|
||||
const lines: string[] = []
|
||||
|
||||
for (const toc of tocList) {
|
||||
if (toc.type === 'catalog' && toc.items && toc.items.length > 0) {
|
||||
lines.push('## 目录')
|
||||
lines.push('')
|
||||
|
||||
for (const item of toc.items) {
|
||||
const indent = ' '.repeat(item.level - 1)
|
||||
const bullet = item.level === 1 ? '- ' : ' - '
|
||||
const marker = item.isCurrent ? '✓ ' : ''
|
||||
lines.push(`${indent}${bullet}${marker}${item.text}`)
|
||||
}
|
||||
|
||||
lines.push('')
|
||||
} else if (toc.type === 'h3') {
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function generateNoteContent(bookInfo: BookInfo): string {
|
||||
const lines: string[] = []
|
||||
|
||||
lines.push(`# ${bookInfo.bookTitle || '未知书名'}`)
|
||||
lines.push('')
|
||||
|
||||
if (bookInfo.author) {
|
||||
lines.push(`**作者**: ${bookInfo.author}`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
if (bookInfo.chapterTitle) {
|
||||
lines.push(`**当前章节**: ${bookInfo.chapterTitle}`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
if (bookInfo.rating) {
|
||||
lines.push(`**评分**: ${bookInfo.rating}`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
if (bookInfo.readingTime) {
|
||||
lines.push(`**阅读时间**: ${bookInfo.readingTime}`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
lines.push('---')
|
||||
lines.push('')
|
||||
|
||||
const tocMarkdown = generateTocMarkdown(bookInfo.toc)
|
||||
if (tocMarkdown) {
|
||||
lines.push(tocMarkdown)
|
||||
}
|
||||
|
||||
lines.push('## 读书笔记')
|
||||
lines.push('')
|
||||
lines.push('<!-- 在这里记录你的读书笔记 -->')
|
||||
lines.push('')
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function useCreateNote(): CreateNoteControl {
|
||||
const [createNoteLoading, setCreateNoteLoading] = useState(false)
|
||||
|
||||
const createNote = useCallback(async (bookInfo: BookInfo): Promise<CreateNoteResult> => {
|
||||
if (!bookInfo.bookTitle) {
|
||||
return { success: false, error: '无法获取书名' }
|
||||
}
|
||||
|
||||
setCreateNoteLoading(true)
|
||||
|
||||
try {
|
||||
const folderName = sanitizeFileName(bookInfo.bookTitle)
|
||||
const folderPath = `/${folderName}`
|
||||
const fileName = `${folderName}.md`
|
||||
const filePath = `${folderPath}/${fileName}`
|
||||
|
||||
await createDirectory(folderPath)
|
||||
|
||||
await createFile(filePath)
|
||||
|
||||
const content = generateNoteContent(bookInfo)
|
||||
await saveFileContent(filePath, content)
|
||||
|
||||
return { success: true, path: filePath }
|
||||
} catch (err) {
|
||||
return { success: false, error: String(err) }
|
||||
} finally {
|
||||
setCreateNoteLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
createNoteLoading,
|
||||
createNote
|
||||
}
|
||||
}
|
||||
85
src/modules/weread/hooks/useDebugInfo.ts
Normal file
85
src/modules/weread/hooks/useDebugInfo.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import type { DebugInfo, PageInfo } from '../types'
|
||||
import { FULL_EXTRACT_SCRIPT } from '../utils/scripts'
|
||||
|
||||
export interface DebugInfoControl {
|
||||
showDebug: boolean
|
||||
debugInfo: DebugInfo | null
|
||||
debugLoading: boolean
|
||||
handleDebug: () => Promise<void>
|
||||
handleCloseDebug: () => void
|
||||
}
|
||||
|
||||
export function useDebugInfo(
|
||||
webviewRef: React.RefObject<HTMLWebViewElement>
|
||||
): DebugInfoControl {
|
||||
const [showDebug, setShowDebug] = useState(false)
|
||||
const [debugInfo, setDebugInfo] = useState<DebugInfo | null>(null)
|
||||
const [debugLoading, setDebugLoading] = useState(false)
|
||||
|
||||
const handleDebug = useCallback(async () => {
|
||||
const webview = webviewRef.current
|
||||
if (!webview) return
|
||||
|
||||
setDebugLoading(true)
|
||||
setDebugInfo(null)
|
||||
|
||||
try {
|
||||
const title = webview.getTitle()
|
||||
const url = webview.getURL()
|
||||
|
||||
const pageInfo = await webview.executeJavaScript(FULL_EXTRACT_SCRIPT) as PageInfo
|
||||
|
||||
setDebugInfo({
|
||||
title,
|
||||
url,
|
||||
bookTitle: pageInfo.bookTitle,
|
||||
author: pageInfo.author,
|
||||
chapterTitle: pageInfo.chapterTitle,
|
||||
selectedText: pageInfo.selectedText,
|
||||
readingTime: pageInfo.readingTime,
|
||||
rating: pageInfo.rating,
|
||||
bookStatus: pageInfo.bookStatus,
|
||||
bookCover: pageInfo.bookCover,
|
||||
userAvatar: pageInfo.userAvatar,
|
||||
currentPage: pageInfo.currentPage,
|
||||
toc: pageInfo.toc,
|
||||
domInfo: pageInfo.domInfo,
|
||||
error: null
|
||||
})
|
||||
} catch (err) {
|
||||
setDebugInfo({
|
||||
title: '',
|
||||
url: '',
|
||||
bookTitle: null,
|
||||
author: null,
|
||||
chapterTitle: null,
|
||||
selectedText: null,
|
||||
readingTime: null,
|
||||
rating: null,
|
||||
bookStatus: null,
|
||||
bookCover: null,
|
||||
userAvatar: null,
|
||||
currentPage: null,
|
||||
toc: null,
|
||||
domInfo: null,
|
||||
error: String(err)
|
||||
})
|
||||
} finally {
|
||||
setDebugLoading(false)
|
||||
setShowDebug(true)
|
||||
}
|
||||
}, [webviewRef])
|
||||
|
||||
const handleCloseDebug = useCallback(() => {
|
||||
setShowDebug(false)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
showDebug,
|
||||
debugInfo,
|
||||
debugLoading,
|
||||
handleDebug,
|
||||
handleCloseDebug
|
||||
}
|
||||
}
|
||||
80
src/modules/weread/hooks/useWebviewControl.ts
Normal file
80
src/modules/weread/hooks/useWebviewControl.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useRef, useEffect, useState, useCallback } from 'react'
|
||||
|
||||
export interface WebviewControl {
|
||||
webviewRef: React.RefObject<HTMLWebViewElement>
|
||||
loading: boolean
|
||||
canGoBack: boolean
|
||||
canGoForward: boolean
|
||||
handleRefresh: () => void
|
||||
handleGoBack: () => void
|
||||
handleGoForward: () => void
|
||||
}
|
||||
|
||||
export function useWebviewControl(): WebviewControl {
|
||||
const webviewRef = useRef<HTMLWebViewElement>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [canGoBack, setCanGoBack] = useState(false)
|
||||
const [canGoForward, setCanGoForward] = useState(false)
|
||||
|
||||
const updateNavigationState = useCallback(() => {
|
||||
const webview = webviewRef.current
|
||||
if (webview) {
|
||||
setCanGoBack(webview.canGoBack())
|
||||
setCanGoForward(webview.canGoForward())
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const webview = webviewRef.current
|
||||
if (!webview) return
|
||||
|
||||
const handleDidStartLoading = () => setLoading(true)
|
||||
const handleDidStopLoading = () => setLoading(false)
|
||||
const handleDidNavigate = () => {
|
||||
updateNavigationState()
|
||||
}
|
||||
|
||||
webview.addEventListener('did-start-loading', handleDidStartLoading)
|
||||
webview.addEventListener('did-stop-loading', handleDidStopLoading)
|
||||
webview.addEventListener('did-navigate', handleDidNavigate)
|
||||
webview.addEventListener('did-navigate-in-page', handleDidNavigate)
|
||||
|
||||
return () => {
|
||||
webview.removeEventListener('did-start-loading', handleDidStartLoading)
|
||||
webview.removeEventListener('did-stop-loading', handleDidStopLoading)
|
||||
webview.removeEventListener('did-navigate', handleDidNavigate)
|
||||
webview.removeEventListener('did-navigate-in-page', handleDidNavigate)
|
||||
}
|
||||
}, [updateNavigationState])
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
const webview = webviewRef.current
|
||||
if (webview) {
|
||||
webview.reload()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleGoBack = useCallback(() => {
|
||||
const webview = webviewRef.current
|
||||
if (webview && webview.canGoBack()) {
|
||||
webview.goBack()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleGoForward = useCallback(() => {
|
||||
const webview = webviewRef.current
|
||||
if (webview && webview.canGoForward()) {
|
||||
webview.goForward()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
webviewRef,
|
||||
loading,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
handleRefresh,
|
||||
handleGoBack,
|
||||
handleGoForward
|
||||
}
|
||||
}
|
||||
11
src/modules/weread/index.tsx
Normal file
11
src/modules/weread/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { BookOpen } from 'lucide-react'
|
||||
import { WeReadPage } from './WeReadPage'
|
||||
import { WEREAD_MODULE } from '@shared/modules/weread'
|
||||
import { createFrontendModule } from '@/lib/module-registry'
|
||||
|
||||
export default createFrontendModule(WEREAD_MODULE, {
|
||||
icon: BookOpen,
|
||||
component: WeReadPage,
|
||||
})
|
||||
|
||||
export { WeReadPage }
|
||||
102
src/modules/weread/types.ts
Normal file
102
src/modules/weread/types.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
export interface BookInfo {
|
||||
bookTitle: string | null
|
||||
author: string | null
|
||||
chapterTitle: string | null
|
||||
selectedText: string | null
|
||||
readingTime: string | null
|
||||
rating: string | null
|
||||
bookStatus: string | null
|
||||
bookCover: string | null
|
||||
userAvatar: string | null
|
||||
currentPage: string | null
|
||||
toc: TocInfo[] | null
|
||||
}
|
||||
|
||||
export interface TocItem {
|
||||
text: string
|
||||
level: number
|
||||
isCurrent: boolean
|
||||
className?: string
|
||||
type?: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
export interface TocInfo {
|
||||
type: string
|
||||
itemCount: number
|
||||
items: TocItem[]
|
||||
}
|
||||
|
||||
export interface DomInfo {
|
||||
titleTag: string
|
||||
url: string
|
||||
bodyClasses: string
|
||||
elementCounts: Record<string, number>
|
||||
topBar: {
|
||||
homeText: string | null
|
||||
titleText: string | null
|
||||
titleLinkText: string | null
|
||||
titleLinkHref: string | null
|
||||
addToShelfText: string | null
|
||||
}
|
||||
chapterContent: {
|
||||
className: string
|
||||
h2Text: string | null
|
||||
h3Count: number
|
||||
pCount: number
|
||||
} | null
|
||||
pageInfo: {
|
||||
leftPage: boolean
|
||||
rightPage: boolean
|
||||
showPage: string | null
|
||||
}
|
||||
footer: {
|
||||
endingTitle: string | null
|
||||
endingTime: string | null
|
||||
markFinishText: string | null
|
||||
}
|
||||
rating: {
|
||||
title: string | null
|
||||
hasRating: boolean
|
||||
}
|
||||
images: Array<{
|
||||
src: string
|
||||
alt: string
|
||||
className: string
|
||||
}>
|
||||
links: Array<{
|
||||
href: string
|
||||
text: string
|
||||
className: string
|
||||
}>
|
||||
allClasses: string[]
|
||||
keywordElements: Record<string, Array<{
|
||||
className: string
|
||||
text: string
|
||||
}>>
|
||||
scroll: {
|
||||
x: number
|
||||
y: number
|
||||
maxScrollY: number
|
||||
documentHeight: number
|
||||
}
|
||||
viewport: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
localStorage?: Record<string, string>
|
||||
localStorageError?: string
|
||||
}
|
||||
|
||||
export interface PageInfo extends Omit<BookInfo, 'toc'> {
|
||||
toc: string | null
|
||||
domInfo: string | null
|
||||
}
|
||||
|
||||
export interface DebugInfo extends Omit<BookInfo, 'toc'> {
|
||||
title: string
|
||||
url: string
|
||||
toc: string | null
|
||||
domInfo: string | null
|
||||
error: string | null
|
||||
}
|
||||
344
src/modules/weread/utils/scripts.ts
Normal file
344
src/modules/weread/utils/scripts.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
const EXTRACT_TOC_SCRIPT = `
|
||||
function extractToc() {
|
||||
var tocList = [];
|
||||
var tocStructured = [];
|
||||
|
||||
var catalog = document.querySelector('.readerCatalog');
|
||||
if (catalog) {
|
||||
var catalogItems = catalog.querySelectorAll('.readerCatalog_list_item');
|
||||
|
||||
for (var i = 0; i < catalogItems.length; i++) {
|
||||
var item = catalogItems[i];
|
||||
var itemText = (item.textContent || '').replace(/\\s+/g, ' ').trim();
|
||||
|
||||
var level = inferTocLevel(itemText);
|
||||
var isCurrent = item.className.indexOf('selected') > -1 || item.className.indexOf('current') > -1;
|
||||
|
||||
if (itemText && itemText.length > 0 && itemText.length < 50) {
|
||||
tocStructured.push({
|
||||
text: itemText,
|
||||
level: level,
|
||||
isCurrent: isCurrent,
|
||||
className: item.className
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var seen = {};
|
||||
var uniqueToc = [];
|
||||
for (var i = 0; i < tocStructured.length; i++) {
|
||||
var t = tocStructured[i].text;
|
||||
if (!seen[t]) {
|
||||
seen[t] = true;
|
||||
uniqueToc.push(tocStructured[i]);
|
||||
}
|
||||
}
|
||||
|
||||
tocList.push({
|
||||
type: 'catalog',
|
||||
itemCount: uniqueToc.length,
|
||||
items: uniqueToc.slice(0, 50)
|
||||
});
|
||||
}
|
||||
|
||||
if (tocList.length === 0) {
|
||||
var navMenu = document.querySelector('.menu_container.js_reader_navBarMenu');
|
||||
if (navMenu) {
|
||||
var menuLinks = navMenu.querySelectorAll('a');
|
||||
for (var i = 0; i < menuLinks.length; i++) {
|
||||
var linkText = (menuLinks[i].textContent || '').replace(/\\s+/g, ' ').trim();
|
||||
if (linkText && linkText.length > 0 && linkText.length < 50) {
|
||||
tocList.push({
|
||||
type: 'menuLink',
|
||||
text: linkText,
|
||||
href: menuLinks[i].href
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var h3Texts = {};
|
||||
var h3Els = document.querySelectorAll('.readerChapterContent h3');
|
||||
for (var i = 0; i < h3Els.length; i++) {
|
||||
var h3Text = (h3Els[i].textContent || '').replace(/\\s+/g, ' ').trim();
|
||||
if (h3Text && !h3Texts[h3Text]) {
|
||||
h3Texts[h3Text] = true;
|
||||
tocList.push({
|
||||
type: 'h3',
|
||||
text: h3Text,
|
||||
level: 3
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(tocList, null, 2);
|
||||
}
|
||||
`
|
||||
|
||||
const INFER_TOC_LEVEL_SCRIPT = `
|
||||
function inferTocLevel(text) {
|
||||
var level = 2;
|
||||
|
||||
if (text.match(/^第[一二三四五六七八九十]+[部分篇卷]/)) {
|
||||
level = 1;
|
||||
}
|
||||
else if (text.match(/^(扉页|版权信息|序[一二三四五六七八九十]?|引子|前言|后记|附录)/)) {
|
||||
level = 1;
|
||||
}
|
||||
else if (text.match(/^第[0-9]+[部分篇卷]/)) {
|
||||
level = 1;
|
||||
}
|
||||
else if (text.match(/^第[一二三四五六七八九十百千]+章/)) {
|
||||
level = 1;
|
||||
}
|
||||
else if (text.match(/^第[0-9]+章/)) {
|
||||
level = 1;
|
||||
}
|
||||
else if (text.match(/^[0-9]+\\.[0-9]+/)) {
|
||||
level = 3;
|
||||
}
|
||||
else if (text.match(/^[((][一二三四五六七八九十]+[))]/)) {
|
||||
level = 3;
|
||||
}
|
||||
|
||||
return level;
|
||||
}
|
||||
`
|
||||
|
||||
const EXTRACT_DOM_INFO_SCRIPT = `
|
||||
function extractDomInfo() {
|
||||
var domData = {};
|
||||
|
||||
domData.titleTag = document.title;
|
||||
domData.url = window.location.href;
|
||||
domData.bodyClasses = document.body.className;
|
||||
|
||||
domData.elementCounts = {
|
||||
h1: document.getElementsByTagName('h1').length,
|
||||
h2: document.getElementsByTagName('h2').length,
|
||||
h3: document.getElementsByTagName('h3').length,
|
||||
h4: document.getElementsByTagName('h4').length,
|
||||
h5: document.getElementsByTagName('h5').length,
|
||||
p: document.getElementsByTagName('p').length,
|
||||
div: document.getElementsByTagName('div').length,
|
||||
span: document.getElementsByTagName('span').length,
|
||||
a: document.getElementsByTagName('a').length,
|
||||
img: document.getElementsByTagName('img').length,
|
||||
canvas: document.getElementsByTagName('canvas').length,
|
||||
svg: document.getElementsByTagName('svg').length,
|
||||
button: document.getElementsByTagName('button').length,
|
||||
input: document.getElementsByTagName('input').length,
|
||||
textarea: document.getElementsByTagName('textarea').length,
|
||||
ul: document.getElementsByTagName('ul').length,
|
||||
li: document.getElementsByTagName('li').length
|
||||
};
|
||||
|
||||
var getText = function(selector) {
|
||||
var el = document.querySelector(selector);
|
||||
return el ? el.textContent.replace(/\\s+/g, ' ').trim() : null;
|
||||
};
|
||||
|
||||
var getAttr = function(selector, attr) {
|
||||
var el = document.querySelector(selector);
|
||||
return el ? el.getAttribute(attr) : null;
|
||||
};
|
||||
|
||||
domData.topBar = {
|
||||
homeText: getText('.readerTopBar_home'),
|
||||
titleText: getText('.readerTopBar_title'),
|
||||
titleLinkText: getText('.readerTopBar_title_link'),
|
||||
titleLinkHref: getAttr('.readerTopBar_title_link', 'href'),
|
||||
addToShelfText: getText('.readerTopBar_addToShelf')
|
||||
};
|
||||
|
||||
var chapterContent = document.querySelector('.readerChapterContent');
|
||||
if (chapterContent) {
|
||||
domData.chapterContent = {
|
||||
className: chapterContent.className,
|
||||
h2Text: getText('.readerChapterContent h2'),
|
||||
h3Count: chapterContent.querySelectorAll('h3').length,
|
||||
pCount: chapterContent.querySelectorAll('p').length
|
||||
};
|
||||
}
|
||||
|
||||
domData.pageInfo = {
|
||||
leftPage: document.querySelector('.page_left') ? true : false,
|
||||
rightPage: document.querySelector('.page_right') ? true : false,
|
||||
showPage: document.querySelector('.page_show') ? document.querySelector('.page_show').className : null
|
||||
};
|
||||
|
||||
domData.footer = {
|
||||
endingTitle: getText('.readerFooter_ending_title'),
|
||||
endingTime: getText('.readerFooter_ending_time'),
|
||||
markFinishText: getText('.readerFooter_mark_finish')
|
||||
};
|
||||
|
||||
domData.rating = {
|
||||
title: getText('.wr_flyleaf_module_rating_title'),
|
||||
hasRating: document.querySelector('.wr_flyleaf_module_rating') ? true : false
|
||||
};
|
||||
|
||||
var imgList = [];
|
||||
var imgs = document.getElementsByTagName('img');
|
||||
for (var i = 0; i < imgs.length && i < 15; i++) {
|
||||
var img = imgs[i];
|
||||
imgList.push({
|
||||
src: img.src,
|
||||
alt: img.alt,
|
||||
className: img.className
|
||||
});
|
||||
}
|
||||
domData.images = imgList;
|
||||
|
||||
var linkList = [];
|
||||
var links = document.getElementsByTagName('a');
|
||||
for (var i = 0; i < links.length && i < 10; i++) {
|
||||
var link = links[i];
|
||||
if (link.href && link.textContent) {
|
||||
linkList.push({
|
||||
href: link.href,
|
||||
text: link.textContent.replace(/\\s+/g, ' ').trim().substring(0, 50),
|
||||
className: link.className
|
||||
});
|
||||
}
|
||||
}
|
||||
domData.links = linkList;
|
||||
|
||||
var allClasses = [];
|
||||
var allEl = document.querySelectorAll('[class]');
|
||||
for (var i = 0; i < allEl.length && allClasses.length < 100; i++) {
|
||||
var cn = allEl[i].className;
|
||||
if (cn && typeof cn === 'string' && allClasses.indexOf(cn) === -1) {
|
||||
allClasses.push(cn);
|
||||
}
|
||||
}
|
||||
domData.allClasses = allClasses;
|
||||
|
||||
var keywordElements = {};
|
||||
var keywords = ['reader', 'chapter', 'book', 'progress', 'title', 'content', 'footer', 'nav', 'menu', 'button'];
|
||||
for (var k = 0; k < keywords.length; k++) {
|
||||
var kw = keywords[k];
|
||||
var kwEls = document.querySelectorAll('[class*="' + kw + '"]');
|
||||
var kwList = [];
|
||||
for (var i = 0; i < kwEls.length && i < 5; i++) {
|
||||
kwList.push({
|
||||
className: kwEls[i].className,
|
||||
text: (kwEls[i].textContent || '').replace(/\\s+/g, ' ').trim().substring(0, 100)
|
||||
});
|
||||
}
|
||||
keywordElements[kw] = kwList;
|
||||
}
|
||||
domData.keywordElements = keywordElements;
|
||||
|
||||
domData.scroll = {
|
||||
x: window.scrollX,
|
||||
y: window.scrollY,
|
||||
maxScrollY: document.body.scrollHeight - window.innerHeight,
|
||||
documentHeight: document.body.scrollHeight
|
||||
};
|
||||
domData.viewport = {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight
|
||||
};
|
||||
|
||||
try {
|
||||
var readingData = {};
|
||||
for (var i = 0; i < localStorage.length; i++) {
|
||||
var key = localStorage.key(i);
|
||||
if (key && (key.indexOf('read') > -1 || key.indexOf('book') > -1 || key.indexOf('chapter') > -1)) {
|
||||
readingData[key] = localStorage.getItem(key).substring(0, 200);
|
||||
}
|
||||
}
|
||||
domData.localStorage = readingData;
|
||||
} catch(e) {
|
||||
domData.localStorageError = e.message;
|
||||
}
|
||||
|
||||
return JSON.stringify(domData, null, 2);
|
||||
}
|
||||
`
|
||||
|
||||
export const FULL_EXTRACT_SCRIPT = `
|
||||
(function() {
|
||||
${INFER_TOC_LEVEL_SCRIPT}
|
||||
${EXTRACT_TOC_SCRIPT}
|
||||
${EXTRACT_DOM_INFO_SCRIPT}
|
||||
|
||||
var result = {
|
||||
bookTitle: null,
|
||||
author: null,
|
||||
chapterTitle: null,
|
||||
selectedText: null,
|
||||
readingTime: null,
|
||||
rating: null,
|
||||
bookStatus: null,
|
||||
bookCover: null,
|
||||
userAvatar: null,
|
||||
currentPage: null,
|
||||
toc: null,
|
||||
domInfo: null
|
||||
};
|
||||
|
||||
var titleTag = document.title;
|
||||
if (titleTag && titleTag.indexOf('微信读书') > -1) {
|
||||
var parts = titleTag.replace(' - 微信读书', '').split(' - ');
|
||||
if (parts.length === 3) {
|
||||
result.bookTitle = parts[0];
|
||||
result.chapterTitle = parts[1];
|
||||
result.author = parts[2];
|
||||
} else if (parts.length === 2) {
|
||||
result.bookTitle = parts[0];
|
||||
result.author = parts[1];
|
||||
} else if (parts.length === 1) {
|
||||
result.bookTitle = parts[0];
|
||||
}
|
||||
}
|
||||
|
||||
var sel = window.getSelection();
|
||||
if (sel) {
|
||||
var selText = sel.toString();
|
||||
if (selText) {
|
||||
result.selectedText = selText;
|
||||
}
|
||||
}
|
||||
|
||||
var timeEl = document.querySelector('.readerFooter_ending_time');
|
||||
if (timeEl) {
|
||||
result.readingTime = timeEl.textContent.replace(/\\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
var ratingEl = document.querySelector('.wr_flyleaf_module_rating_title');
|
||||
if (ratingEl) {
|
||||
result.rating = ratingEl.textContent.replace(/\\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
var statusEl = document.querySelector('.readerFooter_ending_title');
|
||||
if (statusEl) {
|
||||
result.bookStatus = statusEl.textContent.replace(/\\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
var pageEl = document.querySelector('.page_show');
|
||||
if (pageEl) {
|
||||
result.currentPage = pageEl.className;
|
||||
}
|
||||
|
||||
var imgs = document.getElementsByTagName('img');
|
||||
for (var i = 0; i < imgs.length; i++) {
|
||||
var src = imgs[i].src;
|
||||
if (src) {
|
||||
if (src.indexOf('cover') > -1 && !result.bookCover) {
|
||||
result.bookCover = src;
|
||||
}
|
||||
if (src.indexOf('avatar') > -1 && !result.userAvatar) {
|
||||
result.userAvatar = src;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.toc = extractToc();
|
||||
result.domInfo = extractDomInfo();
|
||||
|
||||
return result;
|
||||
})()
|
||||
`
|
||||
Reference in New Issue
Block a user