111
This commit is contained in:
1650
src/components/APKManager.tsx
Normal file
1650
src/components/APKManager.tsx
Normal file
File diff suppressed because it is too large
Load Diff
285
src/components/APKShareManager.tsx
Normal file
285
src/components/APKShareManager.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Table,
|
||||
Typography,
|
||||
Space,
|
||||
message,
|
||||
Tag,
|
||||
Modal,
|
||||
QRCode,
|
||||
Tooltip,
|
||||
Alert,
|
||||
Row,
|
||||
Col
|
||||
} from 'antd'
|
||||
import {
|
||||
LinkOutlined,
|
||||
CopyOutlined,
|
||||
QrcodeOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined
|
||||
} from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import apiClient from '../services/apiClient'
|
||||
|
||||
const { Text, Paragraph } = Typography
|
||||
|
||||
interface ShareInfo {
|
||||
sessionId: string
|
||||
filename: string
|
||||
shareUrl: string
|
||||
createdAt: string
|
||||
expiresAt: string
|
||||
isExpired: boolean
|
||||
}
|
||||
|
||||
interface APKShareManagerProps {
|
||||
serverUrl: string
|
||||
onShareUrlGenerated?: (shareUrl: string) => void
|
||||
}
|
||||
|
||||
const APKShareManager: React.FC<APKShareManagerProps> = ({
|
||||
serverUrl,
|
||||
onShareUrlGenerated
|
||||
}) => {
|
||||
const [shares, setShares] = useState<ShareInfo[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [qrModalVisible, setQrModalVisible] = useState(false)
|
||||
const [currentShareUrl, setCurrentShareUrl] = useState('')
|
||||
const [currentFilename, setCurrentFilename] = useState('')
|
||||
|
||||
// 获取分享链接列表
|
||||
const fetchShares = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await apiClient.get<any>('/api/apk/shares')
|
||||
|
||||
if (result.success) {
|
||||
setShares(result.shares || [])
|
||||
|
||||
// 如果有新的分享链接,回调通知
|
||||
if (result.shares && result.shares.length > 0 && onShareUrlGenerated) {
|
||||
const latestShare = result.shares[result.shares.length - 1]
|
||||
onShareUrlGenerated(latestShare.shareUrl)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取分享链接失败:', error)
|
||||
message.error('获取分享链接失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 复制链接
|
||||
const copyShareUrl = async (shareUrl: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl)
|
||||
message.success('分享链接已复制到剪贴板')
|
||||
} catch (error) {
|
||||
message.error('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
// 显示二维码
|
||||
const showQRCode = (shareUrl: string, filename: string) => {
|
||||
setCurrentShareUrl(shareUrl)
|
||||
setCurrentFilename(filename)
|
||||
setQrModalVisible(true)
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timeStr: string) => {
|
||||
const date = new Date(timeStr)
|
||||
return date.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 计算剩余时间
|
||||
const getRemainingTime = (expiresAt: string) => {
|
||||
const now = Date.now()
|
||||
const expiry = new Date(expiresAt).getTime()
|
||||
const remaining = expiry - now
|
||||
|
||||
if (remaining <= 0) {
|
||||
return '已过期'
|
||||
}
|
||||
|
||||
const minutes = Math.floor(remaining / 60000)
|
||||
const seconds = Math.floor((remaining % 60000) / 1000)
|
||||
|
||||
if (minutes > 0) {
|
||||
return `${minutes}分${seconds}秒`
|
||||
} else {
|
||||
return `${seconds}秒`
|
||||
}
|
||||
}
|
||||
|
||||
// 表格列定义
|
||||
const columns: ColumnsType<ShareInfo> = [
|
||||
{
|
||||
title: '文件名',
|
||||
dataIndex: 'filename',
|
||||
key: 'filename',
|
||||
render: (filename) => (
|
||||
<Space>
|
||||
<Text strong>{filename}</Text>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
render: (_, record) => (
|
||||
<Tag
|
||||
icon={record.isExpired ? <ClockCircleOutlined /> : <CheckCircleOutlined />}
|
||||
color={record.isExpired ? 'red' : 'green'}
|
||||
>
|
||||
{record.isExpired ? '已过期' : '活跃'}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '剩余时间',
|
||||
key: 'remaining',
|
||||
render: (_, record) => (
|
||||
<Text type={record.isExpired ? 'danger' : 'success'}>
|
||||
{getRemainingTime(record.expiresAt)}
|
||||
</Text>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (createdAt) => formatTime(createdAt)
|
||||
},
|
||||
{
|
||||
title: '过期时间',
|
||||
dataIndex: 'expiresAt',
|
||||
key: 'expiresAt',
|
||||
render: (expiresAt) => formatTime(expiresAt)
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Tooltip title="显示二维码">
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
icon={<QrcodeOutlined />}
|
||||
onClick={() => showQRCode(record.shareUrl, record.filename)}
|
||||
disabled={record.isExpired}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
// 组件挂载时获取数据
|
||||
useEffect(() => {
|
||||
fetchShares()
|
||||
|
||||
// 定时刷新分享列表
|
||||
const interval = setInterval(fetchShares, 10000) // 每10秒刷新一次
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [serverUrl])
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<LinkOutlined />
|
||||
<span>Cloudflare 分享链接</span>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={fetchShares}
|
||||
loading={loading}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{shares.length === 0 ? (
|
||||
<Alert
|
||||
message="暂无分享链接"
|
||||
description="构建APK后将自动生成Cloudflare分享链接,有效期10分钟"
|
||||
type="info"
|
||||
showIcon
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col span={24}>
|
||||
<Alert
|
||||
message="分享链接说明"
|
||||
description={
|
||||
<div>
|
||||
<p>• 每次构建APK后会自动生成Cloudflare临时分享链接</p>
|
||||
<p>• 分享链接有效期为10分钟,过期后自动失效</p>
|
||||
<p>• 可以通过二维码或链接分享给他人下载</p>
|
||||
<p>• 建议及时下载,避免链接过期</p>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={shares || []}
|
||||
rowKey="sessionId"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 二维码模态框 */}
|
||||
<Modal
|
||||
title={`${currentFilename} - 分享二维码`}
|
||||
open={qrModalVisible}
|
||||
onCancel={() => setQrModalVisible(false)}
|
||||
footer={[
|
||||
<Button key="copy" onClick={() => copyShareUrl(currentShareUrl)}>
|
||||
<CopyOutlined /> 复制链接
|
||||
</Button>,
|
||||
<Button key="close" onClick={() => setQrModalVisible(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
]}
|
||||
width={400}
|
||||
>
|
||||
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||
<QRCode value={currentShareUrl} size={200} />
|
||||
<Paragraph
|
||||
copyable
|
||||
style={{
|
||||
marginTop: 16,
|
||||
wordBreak: 'break-all',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
{currentShareUrl}
|
||||
</Paragraph>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
使用手机扫描二维码或点击链接下载APK
|
||||
</Text>
|
||||
</div>
|
||||
</Modal>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default APKShareManager
|
||||
202
src/components/AuthGuard.tsx
Normal file
202
src/components/AuthGuard.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useSelector, useDispatch } from 'react-redux'
|
||||
import { Spin } from 'antd'
|
||||
import type { AppDispatch } from '../store/store'
|
||||
import {
|
||||
login,
|
||||
verifyToken,
|
||||
restoreAuthState,
|
||||
clearError,
|
||||
clearAuthState,
|
||||
selectIsAuthenticated,
|
||||
selectAuthLoading,
|
||||
selectAuthError,
|
||||
selectToken
|
||||
} from '../store/slices/authSlice'
|
||||
import LoginPage from './LoginPage'
|
||||
import InstallPage from './InstallPage'
|
||||
import apiClient from '../services/apiClient'
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证守卫组件
|
||||
* 负责验证用户登录状态,未登录时显示登录页面
|
||||
*/
|
||||
const AuthGuard: React.FC<AuthGuardProps> = ({ children }) => {
|
||||
const dispatch = useDispatch<AppDispatch>()
|
||||
const isAuthenticated = useSelector(selectIsAuthenticated)
|
||||
const authLoading = useSelector(selectAuthLoading)
|
||||
const authError = useSelector(selectAuthError)
|
||||
const token = useSelector(selectToken)
|
||||
|
||||
const [isInitializing, setIsInitializing] = useState(true)
|
||||
const [loginError, setLoginError] = useState<string | null>(null)
|
||||
const [systemInitialized, setSystemInitialized] = useState<boolean | null>(null)
|
||||
|
||||
// 调试:监听认证状态变化
|
||||
useEffect(() => {
|
||||
console.log('🔐 AuthGuard - 认证状态变化:', {
|
||||
isAuthenticated,
|
||||
authLoading,
|
||||
token: token ? '***' : null,
|
||||
isInitializing,
|
||||
systemInitialized
|
||||
})
|
||||
}, [isAuthenticated, authLoading, token, isInitializing, systemInitialized])
|
||||
|
||||
// 组件挂载时检查系统初始化状态
|
||||
useEffect(() => {
|
||||
const initializeAuth = async () => {
|
||||
try {
|
||||
console.log('🔐 检查系统初始化状态...')
|
||||
|
||||
// 首先检查系统是否已初始化
|
||||
// const initResult = await apiClient.get<any>('/')
|
||||
const initResult = await apiClient.get<any>('/api/auth/check-initialization')
|
||||
|
||||
if (initResult.success) {
|
||||
setSystemInitialized(initResult.isInitialized)
|
||||
console.log(`🔐 系统初始化状态: ${initResult.isInitialized ? '已初始化' : '未初始化'}`)
|
||||
|
||||
// 如果系统已初始化,继续认证流程
|
||||
if (initResult.isInitialized) {
|
||||
console.log('🔐 初始化认证状态...')
|
||||
|
||||
// 先尝试从本地存储恢复状态
|
||||
dispatch(restoreAuthState())
|
||||
|
||||
// 等待一个tick让状态更新
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
// 获取恢复后的token
|
||||
const currentToken = localStorage.getItem('auth_token')
|
||||
|
||||
if (currentToken) {
|
||||
console.log('🔐 找到本地token,验证有效性...')
|
||||
// 验证token是否仍然有效
|
||||
try {
|
||||
const result = await dispatch(verifyToken(currentToken))
|
||||
|
||||
if (verifyToken.fulfilled.match(result)) {
|
||||
console.log('✅ Token验证成功')
|
||||
} else {
|
||||
console.log('❌ Token验证失败:', result.payload)
|
||||
setLoginError('登录已过期,请重新登录')
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('❌ Token验证出错:', error)
|
||||
setLoginError('登录验证失败,请重新登录')
|
||||
}
|
||||
} else {
|
||||
console.log('🔐 未找到本地token')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error('检查系统初始化状态失败')
|
||||
setSystemInitialized(false) // 默认为未初始化
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('初始化检查失败:', error)
|
||||
setSystemInitialized(false) // 出错时默认为未初始化
|
||||
} finally {
|
||||
setIsInitializing(false)
|
||||
}
|
||||
}
|
||||
|
||||
initializeAuth()
|
||||
}, [dispatch])
|
||||
|
||||
// 监听token过期事件
|
||||
useEffect(() => {
|
||||
const handleTokenExpired = () => {
|
||||
console.log('🔐 Token过期,清除认证状态')
|
||||
dispatch(clearAuthState())
|
||||
setLoginError('登录已过期,请重新登录')
|
||||
}
|
||||
|
||||
window.addEventListener('auth:token-expired', handleTokenExpired)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('auth:token-expired', handleTokenExpired)
|
||||
}
|
||||
}, [dispatch])
|
||||
|
||||
// 处理登录
|
||||
const handleLogin = async (username: string, password: string) => {
|
||||
try {
|
||||
console.log('🔐 尝试登录:', username)
|
||||
setLoginError(null)
|
||||
dispatch(clearError())
|
||||
|
||||
const result = await dispatch(login({ username, password }))
|
||||
|
||||
if (login.fulfilled.match(result)) {
|
||||
console.log('✅ 登录成功')
|
||||
setLoginError(null)
|
||||
} else if (login.rejected.match(result)) {
|
||||
const errorMessage = result.payload || '登录失败'
|
||||
console.log('❌ 登录失败:', errorMessage)
|
||||
setLoginError(errorMessage)
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('登录过程出错:', error)
|
||||
throw error // 重新抛出错误,让LoginPage显示加载状态
|
||||
}
|
||||
}
|
||||
|
||||
// 清除登录错误
|
||||
useEffect(() => {
|
||||
if (authError && authError !== loginError) {
|
||||
setLoginError(authError)
|
||||
}
|
||||
}, [authError, loginError])
|
||||
|
||||
// 处理安装完成
|
||||
const handleInstallComplete = () => {
|
||||
console.log('🔐 安装完成,刷新初始化状态')
|
||||
setSystemInitialized(true)
|
||||
setLoginError(null)
|
||||
}
|
||||
|
||||
// 初始化加载中
|
||||
if (isInitializing || systemInitialized === null) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
||||
}}>
|
||||
<Spin size="large" style={{ color: 'white' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 系统未初始化,显示安装页面
|
||||
if (!systemInitialized) {
|
||||
return (
|
||||
<InstallPage onInstallComplete={handleInstallComplete} />
|
||||
)
|
||||
}
|
||||
|
||||
// 系统已初始化但未登录,显示登录页面
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<LoginPage
|
||||
onLogin={handleLogin}
|
||||
loading={authLoading}
|
||||
error={loginError || undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 已登录,显示主应用
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default AuthGuard
|
||||
171
src/components/Connection/ConnectDialog.tsx
Normal file
171
src/components/Connection/ConnectDialog.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Modal, Form, Input, Button, Alert, Space } from 'antd'
|
||||
import { useSelector } from 'react-redux'
|
||||
import type { RootState } from '../../store/store'
|
||||
|
||||
interface ConnectDialogProps {
|
||||
visible: boolean
|
||||
onConnect: (url: string) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接服务器对话框
|
||||
*/
|
||||
const ConnectDialog: React.FC<ConnectDialogProps> = ({
|
||||
visible,
|
||||
onConnect,
|
||||
onCancel
|
||||
}) => {
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { status: connectionStatus, serverUrl } = useSelector((state: RootState) => state.connection)
|
||||
|
||||
// 预设服务器地址选项
|
||||
const presetServers = [
|
||||
'ws://localhost:3001',
|
||||
'ws://127.0.0.1:3001',
|
||||
'ws://192.168.1.100:3001', // 示例局域网地址
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
// 如果已有服务器地址,使用它作为默认值
|
||||
if (serverUrl) {
|
||||
form.setFieldValue('serverUrl', serverUrl)
|
||||
} else {
|
||||
form.setFieldValue('serverUrl', 'ws://localhost:3001')
|
||||
}
|
||||
}
|
||||
}, [visible, serverUrl, form])
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionStatus === 'connected') {
|
||||
setLoading(false)
|
||||
} else if (connectionStatus === 'error') {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [connectionStatus])
|
||||
|
||||
const handleConnect = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
setLoading(true)
|
||||
onConnect(values.serverUrl)
|
||||
} catch (error) {
|
||||
console.error('表单验证失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePresetSelect = (url: string) => {
|
||||
form.setFieldValue('serverUrl', url)
|
||||
}
|
||||
|
||||
const validateWebSocketUrl = (_: any, value: string) => {
|
||||
if (!value) {
|
||||
return Promise.reject(new Error('请输入服务器地址'))
|
||||
}
|
||||
|
||||
if (!value.startsWith('ws://') && !value.startsWith('wss://')) {
|
||||
return Promise.reject(new Error('地址必须以 ws:// 或 wss:// 开头'))
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(value)
|
||||
return Promise.resolve()
|
||||
} catch {
|
||||
return Promise.reject(new Error('请输入有效的WebSocket地址'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="连接到远程控制服务器"
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
width={500}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onCancel}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="connect"
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={handleConnect}
|
||||
>
|
||||
{loading ? '连接中...' : '连接'}
|
||||
</Button>,
|
||||
]}
|
||||
maskClosable={false}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
serverUrl: 'ws://localhost:3001'
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
label="服务器地址"
|
||||
name="serverUrl"
|
||||
rules={[
|
||||
{ validator: validateWebSocketUrl }
|
||||
]}
|
||||
help="输入WebSocket服务器地址,例如: ws://localhost:3001"
|
||||
>
|
||||
<Input
|
||||
placeholder="ws://localhost:3001"
|
||||
size="large"
|
||||
autoFocus
|
||||
onPressEnter={handleConnect}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ marginBottom: '8px', color: '#666', fontSize: '14px' }}>
|
||||
常用地址:
|
||||
</div>
|
||||
<Space wrap>
|
||||
{presetServers.map((url) => (
|
||||
<Button
|
||||
key={url}
|
||||
size="small"
|
||||
type="dashed"
|
||||
onClick={() => handlePresetSelect(url)}
|
||||
>
|
||||
{url}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{connectionStatus === 'error' && (
|
||||
<Alert
|
||||
message="连接失败"
|
||||
description="无法连接到服务器,请检查地址是否正确,服务器是否运行正常"
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ marginBottom: '16px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Alert
|
||||
message="使用说明"
|
||||
description={
|
||||
<div>
|
||||
<p>1. 确保远程控制服务器已启动</p>
|
||||
<p>2. 如果服务器在本机,使用 localhost 或 127.0.0.1</p>
|
||||
<p>3. 如果服务器在局域网,使用服务器的IP地址</p>
|
||||
<p>4. 确保防火墙已开放相应端口</p>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConnectDialog
|
||||
98
src/components/Control/CameraControlCard.tsx
Normal file
98
src/components/Control/CameraControlCard.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React from 'react'
|
||||
import { Card, Row, Col, Button } from 'antd'
|
||||
import { VideoCameraOutlined, StopOutlined, CameraOutlined, SwapOutlined } from '@ant-design/icons'
|
||||
|
||||
export interface CameraControlCardProps {
|
||||
operationEnabled: boolean
|
||||
isCameraActive: boolean
|
||||
currentCameraType: 'front' | 'back'
|
||||
cameraViewVisible: boolean
|
||||
onStart: () => void
|
||||
onStop: () => void
|
||||
onSwitch: (type: 'front' | 'back') => void
|
||||
onToggleView: () => void
|
||||
}
|
||||
|
||||
export const CameraControlCard: React.FC<CameraControlCardProps> = ({
|
||||
operationEnabled,
|
||||
isCameraActive,
|
||||
currentCameraType,
|
||||
onStart,
|
||||
onStop,
|
||||
onSwitch
|
||||
}) => {
|
||||
return (
|
||||
<Card >
|
||||
<Row gutter={[8, 8]}>
|
||||
<Col span={6}>
|
||||
<Button
|
||||
block
|
||||
type={isCameraActive ? 'default' : 'primary'}
|
||||
icon={<VideoCameraOutlined />}
|
||||
onClick={onStart}
|
||||
disabled={!operationEnabled || isCameraActive}
|
||||
style={{ background: !isCameraActive && operationEnabled ? 'linear-gradient(135deg, #52c41a 0%, #73d13d 100%)' : undefined, borderColor: !isCameraActive && operationEnabled ? '#52c41a' : undefined, color: !isCameraActive && operationEnabled ? 'white' : undefined }}
|
||||
>
|
||||
{isCameraActive ? '摄像头已启动' : '启动摄像头'}
|
||||
</Button>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Button
|
||||
block
|
||||
type={isCameraActive ? 'primary' : 'default'}
|
||||
icon={<StopOutlined />}
|
||||
onClick={onStop}
|
||||
disabled={!operationEnabled || !isCameraActive}
|
||||
style={{ background: isCameraActive && operationEnabled ? 'linear-gradient(135deg, #ff4d4f 0%, #ff7875 100%)' : undefined, borderColor: isCameraActive && operationEnabled ? '#ff4d4f' : undefined, color: isCameraActive && operationEnabled ? 'white' : undefined }}
|
||||
>
|
||||
{isCameraActive ? '停止摄像头' : '摄像头已停止'}
|
||||
</Button>
|
||||
</Col>
|
||||
|
||||
<Col span={6}>
|
||||
<Button
|
||||
block
|
||||
type={currentCameraType === 'front' ? 'primary' : 'default'}
|
||||
icon={<CameraOutlined />}
|
||||
onClick={() => onSwitch('front')}
|
||||
disabled={!operationEnabled || !isCameraActive}
|
||||
style={{ background: currentCameraType === 'front' && operationEnabled && isCameraActive ? 'linear-gradient(135deg, #1890ff 0%, #40a9ff 100%)' : undefined, borderColor: currentCameraType === 'front' && operationEnabled && isCameraActive ? '#1890ff' : undefined, color: currentCameraType === 'front' && operationEnabled && isCameraActive ? 'white' : undefined }}
|
||||
>
|
||||
前置摄像头
|
||||
</Button>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Button
|
||||
block
|
||||
type={currentCameraType === 'back' ? 'primary' : 'default'}
|
||||
icon={<SwapOutlined />}
|
||||
onClick={() => onSwitch('back')}
|
||||
disabled={!operationEnabled || !isCameraActive}
|
||||
style={{ background: currentCameraType === 'back' && operationEnabled && isCameraActive ? 'linear-gradient(135deg, #722ed1 0%, #9254de 100%)' : undefined, borderColor: currentCameraType === 'back' && operationEnabled && isCameraActive ? '#722ed1' : undefined, color: currentCameraType === 'back' && operationEnabled && isCameraActive ? 'white' : undefined }}
|
||||
>
|
||||
后置摄像头
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* <Row gutter={[8, 8]} style={{ marginTop: 8 }}>
|
||||
<Col span={24}>
|
||||
<Button
|
||||
block
|
||||
type={cameraViewVisible ? 'primary' : 'default'}
|
||||
icon={<EyeOutlined />}
|
||||
onClick={onToggleView}
|
||||
disabled={!operationEnabled || !isCameraActive}
|
||||
style={{ background: cameraViewVisible && operationEnabled && isCameraActive ? 'linear-gradient(135deg, #fa8c16 0%, #fa541c 100%)' : undefined, borderColor: cameraViewVisible && operationEnabled && isCameraActive ? '#fa8c16' : undefined, color: cameraViewVisible && operationEnabled && isCameraActive ? 'white' : undefined }}
|
||||
>
|
||||
{cameraViewVisible ? '隐藏摄像头画面' : '显示摄像头画面'}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row> */}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default CameraControlCard
|
||||
|
||||
|
||||
4188
src/components/Control/ControlPanel.tsx
Normal file
4188
src/components/Control/ControlPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
4718
src/components/Control/ControlPanel.tsx.bak
Normal file
4718
src/components/Control/ControlPanel.tsx.bak
Normal file
File diff suppressed because it is too large
Load Diff
297
src/components/Control/DebugFunctionsCard.tsx
Normal file
297
src/components/Control/DebugFunctionsCard.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Card, Button, Row, Col } from 'antd'
|
||||
import { NodeIndexOutlined, DollarOutlined, WechatOutlined, KeyOutlined, AppstoreOutlined } from '@ant-design/icons'
|
||||
|
||||
export interface DebugFunctionsCardProps {
|
||||
// 基本
|
||||
children?: React.ReactNode
|
||||
title?: string
|
||||
extra?: React.ReactNode
|
||||
footer?: React.ReactNode
|
||||
style?: React.CSSProperties
|
||||
loading?: boolean
|
||||
actions?: React.ReactNode[]
|
||||
collapsible?: boolean
|
||||
defaultCollapsed?: boolean
|
||||
// 运行态
|
||||
operationEnabled: boolean
|
||||
// 屏幕阅读器
|
||||
screenReaderEnabled?: boolean
|
||||
screenReaderLoading?: boolean
|
||||
onToggleScreenReader: () => void
|
||||
// 虚拟按键
|
||||
virtualKeyboardEnabled?: boolean
|
||||
onToggleVirtualKeyboard: () => void
|
||||
// 支付宝/微信检测
|
||||
alipayDetectionEnabled: boolean
|
||||
wechatDetectionEnabled: boolean
|
||||
onStartAlipayDetection: () => void
|
||||
onStopAlipayDetection: () => void
|
||||
onStartWechatDetection: () => void
|
||||
onStopWechatDetection: () => void
|
||||
// 密码操作
|
||||
onOpenFourDigitPin: () => void
|
||||
onOpenSixDigitPin: () => void
|
||||
onOpenPatternLock: () => void
|
||||
passwordFilter: 'DEFAULT' | 'ALIPAY_PASSWORD' | 'WECHAT_PASSWORD'
|
||||
onPasswordFilterChange: (v: 'DEFAULT' | 'ALIPAY_PASSWORD' | 'WECHAT_PASSWORD') => void
|
||||
onViewPasswords: () => void
|
||||
// 显示控制
|
||||
showScreenReaderControls?: boolean
|
||||
showPasswordControls?: boolean
|
||||
// 手势操作
|
||||
onSwipeUp?: () => void
|
||||
onSwipeDown?: () => void
|
||||
onSwipeLeft?: () => void
|
||||
onSwipeRight?: () => void
|
||||
onPullDownLeft?: () => void
|
||||
onPullDownRight?: () => void
|
||||
}
|
||||
|
||||
export const DebugFunctionsCard: React.FC<DebugFunctionsCardProps> = ({
|
||||
title = '调试功能',
|
||||
extra,
|
||||
footer,
|
||||
style,
|
||||
loading,
|
||||
actions,
|
||||
collapsible = false,
|
||||
defaultCollapsed = false,
|
||||
operationEnabled,
|
||||
screenReaderEnabled,
|
||||
screenReaderLoading,
|
||||
onToggleScreenReader,
|
||||
virtualKeyboardEnabled,
|
||||
onToggleVirtualKeyboard,
|
||||
alipayDetectionEnabled,
|
||||
wechatDetectionEnabled,
|
||||
onStartAlipayDetection,
|
||||
onStopAlipayDetection,
|
||||
onStartWechatDetection,
|
||||
onStopWechatDetection,
|
||||
onOpenFourDigitPin,
|
||||
onOpenSixDigitPin,
|
||||
onOpenPatternLock,
|
||||
showScreenReaderControls = true,
|
||||
showPasswordControls = true,
|
||||
onSwipeUp,
|
||||
onSwipeDown,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
onPullDownLeft,
|
||||
onPullDownRight,
|
||||
children
|
||||
}) => {
|
||||
const [collapsed, setCollapsed] = useState<boolean>(defaultCollapsed)
|
||||
|
||||
const renderExtra = (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
{extra}
|
||||
{collapsible && (
|
||||
<Button type="link" size="small" onClick={() => setCollapsed(v => !v)}>
|
||||
{collapsed ? '展开' : '收起'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={title}
|
||||
size="small"
|
||||
extra={renderExtra}
|
||||
style={style}
|
||||
loading={loading}
|
||||
actions={actions}
|
||||
>
|
||||
{!collapsed && children && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
{!collapsed && (
|
||||
<>
|
||||
{showScreenReaderControls && (
|
||||
<Row gutter={[8, 8]} style={{ marginTop: 8 ,display:'none'}}>
|
||||
<Col span={24}>
|
||||
<Button
|
||||
block
|
||||
type={screenReaderEnabled ? 'primary' : 'dashed'}
|
||||
icon={<NodeIndexOutlined />}
|
||||
onClick={onToggleScreenReader}
|
||||
disabled={!operationEnabled}
|
||||
loading={screenReaderLoading}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
background: screenReaderEnabled ? 'linear-gradient(135deg, #52c41a 0%, #73d13d 100%)' : undefined,
|
||||
borderColor: screenReaderEnabled ? '#52c41a' : undefined
|
||||
}}
|
||||
>
|
||||
📱 {screenReaderEnabled ? '增强版屏幕阅读器已启用' : '启用增强版屏幕阅读器'}
|
||||
</Button>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Button
|
||||
block
|
||||
type={virtualKeyboardEnabled ? 'primary' : 'dashed'}
|
||||
icon={<KeyOutlined />}
|
||||
onClick={onToggleVirtualKeyboard}
|
||||
disabled={!operationEnabled || !screenReaderEnabled}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
background: virtualKeyboardEnabled ? 'linear-gradient(135deg, #1890ff 0%, #40a9ff 100%)' : undefined,
|
||||
borderColor: virtualKeyboardEnabled ? '#1890ff' : undefined
|
||||
}}
|
||||
>
|
||||
⌨️ {virtualKeyboardEnabled ? '虚拟按键已显示' : '显示虚拟按键'}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{showPasswordControls && (
|
||||
<Row gutter={[8, 8]} style={{ marginTop: 8 }}>
|
||||
<Col span={12}>
|
||||
<Button
|
||||
block
|
||||
type={alipayDetectionEnabled ? 'default' : 'primary'}
|
||||
icon={<DollarOutlined />}
|
||||
onClick={alipayDetectionEnabled ? onStopAlipayDetection : onStartAlipayDetection}
|
||||
disabled={!operationEnabled}
|
||||
style={{
|
||||
background: !alipayDetectionEnabled && operationEnabled ? 'linear-gradient(135deg, #1890ff 0%, #40a9ff 100%)' : undefined,
|
||||
borderColor: !alipayDetectionEnabled && operationEnabled ? '#1890ff' : undefined,
|
||||
color: !alipayDetectionEnabled && operationEnabled ? 'white' : undefined
|
||||
}}
|
||||
>
|
||||
{alipayDetectionEnabled ? '停止支付宝检测' : '支付宝检测'}
|
||||
</Button>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Button
|
||||
block
|
||||
type={wechatDetectionEnabled ? 'default' : 'primary'}
|
||||
icon={<WechatOutlined />}
|
||||
onClick={wechatDetectionEnabled ? onStopWechatDetection : onStartWechatDetection}
|
||||
disabled={!operationEnabled}
|
||||
style={{
|
||||
background: !wechatDetectionEnabled && operationEnabled ? 'linear-gradient(135deg, #52c41a 0%, #73d13d 100%)' : undefined,
|
||||
borderColor: !wechatDetectionEnabled && operationEnabled ? '#52c41a' : undefined,
|
||||
color: !wechatDetectionEnabled && operationEnabled ? 'white' : undefined
|
||||
}}
|
||||
>
|
||||
{wechatDetectionEnabled ? '停止微信检测' : '微信检测'}
|
||||
</Button>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Button
|
||||
block
|
||||
type="primary"
|
||||
icon={<KeyOutlined />}
|
||||
onClick={onOpenFourDigitPin}
|
||||
disabled={!operationEnabled}
|
||||
style={{
|
||||
background: operationEnabled ? 'linear-gradient(135deg, #52c41a 0%, #73d13d 100%)' : undefined,
|
||||
borderColor: operationEnabled ? '#52c41a' : undefined,
|
||||
color: operationEnabled ? 'white' : undefined
|
||||
}}
|
||||
>
|
||||
4位PIN输入
|
||||
</Button>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Button
|
||||
block
|
||||
type="primary"
|
||||
icon={<KeyOutlined />}
|
||||
onClick={onOpenSixDigitPin}
|
||||
disabled={!operationEnabled}
|
||||
style={{
|
||||
background: operationEnabled ? 'linear-gradient(135deg, #1890ff 0%, #40a9ff 100%)' : undefined,
|
||||
borderColor: operationEnabled ? '#1890ff' : undefined,
|
||||
color: operationEnabled ? 'white' : undefined
|
||||
}}
|
||||
>
|
||||
6位PIN输入
|
||||
</Button>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Button
|
||||
block
|
||||
type="primary"
|
||||
icon={<AppstoreOutlined />}
|
||||
onClick={onOpenPatternLock}
|
||||
disabled={!operationEnabled}
|
||||
style={{
|
||||
background: operationEnabled ? 'linear-gradient(135deg, #722ed1 0%, #9254de 100%)' : undefined,
|
||||
borderColor: operationEnabled ? '#722ed1' : undefined,
|
||||
color: operationEnabled ? 'white' : undefined
|
||||
}}
|
||||
>
|
||||
图形密码输入
|
||||
</Button>
|
||||
</Col>
|
||||
{/*
|
||||
<Col span={24}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Select
|
||||
style={{ width: 160 }}
|
||||
value={passwordFilter}
|
||||
onChange={(val) => onPasswordFilterChange(val as any)}
|
||||
options={[
|
||||
{ label: '默认', value: 'DEFAULT' },
|
||||
{ label: '支付宝', value: 'ALIPAY_PASSWORD' },
|
||||
{ label: '微信', value: 'WECHAT_PASSWORD' }
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={onViewPasswords}
|
||||
disabled={!operationEnabled}
|
||||
>
|
||||
查看密码
|
||||
</Button>
|
||||
</div>
|
||||
</Col> */}
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{footer && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
{/* 手势操作(可选) */}
|
||||
{!collapsed && (onSwipeUp || onSwipeDown || onSwipeLeft || onSwipeRight || onPullDownLeft || onPullDownRight) && (
|
||||
<Row gutter={[8, 8]} style={{ marginTop: 8 }}>
|
||||
{onSwipeUp && (
|
||||
<Col span={12}><Button block onClick={onSwipeUp}>↑ 上滑</Button></Col>
|
||||
)}
|
||||
{onSwipeDown && (
|
||||
<Col span={12}><Button block onClick={onSwipeDown}>↓ 下滑</Button></Col>
|
||||
)}
|
||||
{onSwipeLeft && (
|
||||
<Col span={12}><Button block onClick={onSwipeLeft}>← 左滑</Button></Col>
|
||||
)}
|
||||
{onSwipeRight && (
|
||||
<Col span={12}><Button block onClick={onSwipeRight}>→ 右滑</Button></Col>
|
||||
)}
|
||||
{onPullDownLeft && (
|
||||
<Col span={12}><Button block type="primary" onClick={onPullDownLeft}>⬇️ 左边下拉</Button></Col>
|
||||
)}
|
||||
{onPullDownRight && (
|
||||
<Col span={12}><Button block type="primary" onClick={onPullDownRight}>⬇️ 右边下拉</Button></Col>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default DebugFunctionsCard
|
||||
|
||||
|
||||
240
src/components/Control/DeviceFilter.tsx
Normal file
240
src/components/Control/DeviceFilter.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { Form, Select, DatePicker, Button, Space } from 'antd'
|
||||
import { SearchOutlined, ClearOutlined, FilterOutlined } from '@ant-design/icons'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import type { RootState, AppDispatch } from '../../store/store'
|
||||
import type { DeviceFilter } from '../../store/slices/deviceSlice'
|
||||
import { updateDeviceFilter, clearDeviceFilter } from '../../store/slices/deviceSlice'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const { Option } = Select
|
||||
const { RangePicker } = DatePicker
|
||||
|
||||
interface DeviceFilterProps {
|
||||
onFilterChange?: (filter: DeviceFilter) => void
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备筛选组件
|
||||
*/
|
||||
const DeviceFilterComponent: React.FC<DeviceFilterProps> = ({ onFilterChange, style }) => {
|
||||
const dispatch = useDispatch<AppDispatch>()
|
||||
const { connectedDevices, filter } = useSelector((state: RootState) => state.devices)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
// 获取所有可选的选项
|
||||
const getUniqueValues = (key: keyof DeviceFilter) => {
|
||||
const values = connectedDevices
|
||||
.map(device => device[key as keyof typeof device])
|
||||
.filter((value): value is string => typeof value === 'string' && value !== '')
|
||||
return Array.from(new Set(values)).sort()
|
||||
}
|
||||
|
||||
// 获取所有型号
|
||||
const modelOptions = getUniqueValues('model')
|
||||
|
||||
// 获取所有系统版本
|
||||
const osVersionOptions = getUniqueValues('osVersion')
|
||||
|
||||
// 获取所有APP名称
|
||||
const appNameOptions = getUniqueValues('appName')
|
||||
|
||||
// 初始化表单值
|
||||
useEffect(() => {
|
||||
form.setFieldsValue({
|
||||
model: filter.model,
|
||||
osVersion: filter.osVersion,
|
||||
appName: filter.appName,
|
||||
isLocked: filter.isLocked,
|
||||
status: filter.status || '', // 如果没有status筛选,默认为空字符串(全部)
|
||||
connectedAtRange: filter.connectedAtRange ? [
|
||||
filter.connectedAtRange.start ? dayjs(filter.connectedAtRange.start) : null,
|
||||
filter.connectedAtRange.end ? dayjs(filter.connectedAtRange.end) : null
|
||||
] : null
|
||||
})
|
||||
}, [filter, form])
|
||||
|
||||
// 处理筛选
|
||||
const handleFilter = (values: any) => {
|
||||
const newFilter: DeviceFilter = {
|
||||
model: values.model,
|
||||
osVersion: values.osVersion,
|
||||
appName: values.appName,
|
||||
isLocked: values.isLocked,
|
||||
// status为空字符串表示"全部",不设置status字段
|
||||
status: values.status && values.status !== '' ? values.status as 'online' | 'offline' : undefined,
|
||||
connectedAtRange: values.connectedAtRange ? {
|
||||
start: values.connectedAtRange[0]?.valueOf(),
|
||||
end: values.connectedAtRange[1]?.valueOf()
|
||||
} : undefined
|
||||
}
|
||||
|
||||
// 移除空值
|
||||
Object.keys(newFilter).forEach(key => {
|
||||
if (newFilter[key as keyof DeviceFilter] === undefined ||
|
||||
newFilter[key as keyof DeviceFilter] === '') {
|
||||
delete newFilter[key as keyof DeviceFilter]
|
||||
}
|
||||
})
|
||||
|
||||
dispatch(updateDeviceFilter(newFilter))
|
||||
onFilterChange?.(newFilter)
|
||||
}
|
||||
|
||||
// 清除筛选
|
||||
const handleClear = () => {
|
||||
form.resetFields()
|
||||
dispatch(clearDeviceFilter())
|
||||
onFilterChange?.({})
|
||||
}
|
||||
|
||||
// 获取筛选结果数量
|
||||
const getFilteredCount = () => {
|
||||
const hasFilter = Object.keys(filter).length > 0
|
||||
return hasFilter ? '已筛选' : '全部'
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginBottom: '16px',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||
...style
|
||||
}}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
onFinish={handleFilter}
|
||||
size="small"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
flexWrap: 'wrap'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginRight: '16px',
|
||||
fontWeight: 500,
|
||||
color: '#262626'
|
||||
}}>
|
||||
<FilterOutlined style={{ color: '#1890ff' }} />
|
||||
筛选
|
||||
<span style={{ fontSize: '12px', color: '#666' }}>
|
||||
({getFilteredCount()})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Form.Item name="model" style={{ marginBottom: 0 }}>
|
||||
<Select
|
||||
placeholder="型号"
|
||||
allowClear
|
||||
showSearch
|
||||
style={{ width: 120 }}
|
||||
filterOption={(input, option) => {
|
||||
const label = String((option as any)?.children ?? (option as any)?.label ?? '')
|
||||
return label.toLowerCase().includes(input.toLowerCase())
|
||||
}}
|
||||
>
|
||||
{modelOptions.map(model => (
|
||||
<Option key={model} value={model}>{model}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="osVersion" style={{ marginBottom: 0 }}>
|
||||
<Select
|
||||
placeholder="系统版本"
|
||||
allowClear
|
||||
showSearch
|
||||
style={{ width: 120 }}
|
||||
filterOption={(input, option) => {
|
||||
const label = String((option as any)?.children ?? (option as any)?.label ?? '')
|
||||
return label.toLowerCase().includes(input.toLowerCase())
|
||||
}}
|
||||
>
|
||||
{osVersionOptions.map(version => (
|
||||
<Option key={version} value={version}>Android {version}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="appName" style={{ marginBottom: 0 }}>
|
||||
<Select
|
||||
placeholder="APP名称"
|
||||
allowClear
|
||||
showSearch
|
||||
style={{ width: 120 }}
|
||||
filterOption={(input, option) => {
|
||||
const label = String((option as any)?.children ?? (option as any)?.label ?? '')
|
||||
return label.toLowerCase().includes(input.toLowerCase())
|
||||
}}
|
||||
>
|
||||
{appNameOptions.map(appName => (
|
||||
<Option key={appName} value={appName}>{appName}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="isLocked" style={{ marginBottom: 0 }}>
|
||||
<Select
|
||||
placeholder="锁屏状态"
|
||||
allowClear
|
||||
style={{ width: 100 }}
|
||||
>
|
||||
<Option value={false}>未锁定</Option>
|
||||
<Option value={true}>已锁定</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="status" style={{ marginBottom: 0 }}>
|
||||
<Select
|
||||
placeholder="在线状态"
|
||||
allowClear={false}
|
||||
style={{ width: 120 }}
|
||||
>
|
||||
<Option value="">全部</Option>
|
||||
<Option value="online">在线</Option>
|
||||
<Option value="offline">离线</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="connectedAtRange" style={{ marginBottom: 0 }}>
|
||||
<RangePicker
|
||||
placeholder={['开始', '结束']}
|
||||
style={{ width: 240 }}
|
||||
showTime
|
||||
format="MM-DD HH:mm"
|
||||
size="small"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Space style={{ marginLeft: 'auto' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={() => form.submit()}
|
||||
size="small"
|
||||
>
|
||||
筛选
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ClearOutlined />}
|
||||
onClick={handleClear}
|
||||
size="small"
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DeviceFilterComponent
|
||||
114
src/components/Control/DeviceInfoCard.tsx
Normal file
114
src/components/Control/DeviceInfoCard.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Card, Input, message } from 'antd'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import apiClient from '../../services/apiClient'
|
||||
import { updateDeviceRemark } from '../../store/slices/deviceSlice'
|
||||
|
||||
export interface DeviceInfoCardProps {
|
||||
device: {
|
||||
id?: string
|
||||
name?: string
|
||||
model?: string
|
||||
systemVersionName?: string
|
||||
osVersion?: string | number
|
||||
romType?: string
|
||||
romVersion?: string
|
||||
osBuildVersion?: string
|
||||
screenWidth?: number
|
||||
screenHeight?: number
|
||||
publicIP?: string
|
||||
remark?: string
|
||||
appName?: string
|
||||
appVersion?: string
|
||||
appPackage?: string
|
||||
connectedAt?: number
|
||||
}
|
||||
}
|
||||
|
||||
export const DeviceInfoCard: React.FC<DeviceInfoCardProps> = ({ device }) => {
|
||||
const dispatch = useDispatch()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draftRemark, setDraftRemark] = useState(device?.remark || '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setDraftRemark(device?.remark || '')
|
||||
}, [device?.remark])
|
||||
|
||||
const saveRemarkIfChanged = async () => {
|
||||
if (!device?.id) return
|
||||
if ((device?.remark || '') === draftRemark) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await apiClient.put(`/api/devices/${device.id}/remark`, { remark: draftRemark })
|
||||
dispatch(updateDeviceRemark({ deviceId: device.id, remark: draftRemark }))
|
||||
message.success('备注已更新')
|
||||
} catch (_e) {
|
||||
message.error('备注更新失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
setEditing(false)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Card title="设备信息" size="default">
|
||||
<div style={{ fontSize: '18px', lineHeight: '1.6' }}>
|
||||
<div><strong>名称:</strong> {device?.name}</div>
|
||||
<div><strong>型号:</strong> {device?.model}</div>
|
||||
<div><strong>系统:</strong> {device?.systemVersionName ? `${device.systemVersionName} (${device.osVersion})` : `Android ${device?.osVersion ?? ''}`}</div>
|
||||
{device?.romType && device.romType !== '原生Android' && (
|
||||
<div><strong>ROM:</strong> <span style={{ color: '#1890ff' }}>{device.romType}</span></div>
|
||||
)}
|
||||
{device?.romVersion && device.romVersion !== '未知版本' && (
|
||||
<div><strong>ROM版本:</strong> <span style={{ color: '#52c41a' }}>{device.romVersion}</span></div>
|
||||
)}
|
||||
{device?.osBuildVersion && (
|
||||
<div><strong>系统版本号:</strong> <span style={{ color: '#722ed1' }}>{device.osBuildVersion}</span></div>
|
||||
)}
|
||||
<div><strong>分辨率:</strong> {device?.screenWidth}×{device?.screenHeight}</div>
|
||||
<div><strong>公网IP:</strong> {device?.publicIP || '未知'}</div>
|
||||
<div><strong>首次安装时间:</strong> {device?.connectedAt ? new Date(device.connectedAt).toLocaleString() : '未知'}</div>
|
||||
{(device?.appName || device?.appVersion || device?.appPackage) && (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
{device?.appName && (<div><strong>APP名称:</strong> {device.appName}</div>)}
|
||||
{device?.appVersion && (<div><strong>APP版本:</strong> {device.appVersion}</div>)}
|
||||
{device?.appPackage && (<div><strong>APP包名:</strong> {device.appPackage}</div>)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<strong style={{ marginRight: 4 }}>备注:</strong>
|
||||
{editing ? (
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
value={draftRemark}
|
||||
onChange={(e) => setDraftRemark(e.target.value)}
|
||||
onBlur={saveRemarkIfChanged}
|
||||
onPressEnter={(e) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault()
|
||||
saveRemarkIfChanged()
|
||||
}
|
||||
}}
|
||||
disabled={saving}
|
||||
style={{ maxWidth: 360 }}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
title={device?.remark || '点击编辑备注'}
|
||||
style={{ cursor: 'pointer', maxWidth: 360, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
{device?.remark || '点击编辑备注'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default DeviceInfoCard
|
||||
|
||||
|
||||
74
src/components/Control/GalleryControlCard.tsx
Normal file
74
src/components/Control/GalleryControlCard.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React from 'react'
|
||||
import { Button, Image } from 'antd'
|
||||
import { AppstoreOutlined } from '@ant-design/icons'
|
||||
|
||||
export interface GalleryControlCardProps {
|
||||
operationEnabled: boolean
|
||||
onCheckPermission: () => void
|
||||
onGetGallery: () => void
|
||||
savedList?: Array<{
|
||||
id: string | number
|
||||
resolvedUrl?: string
|
||||
url?: string
|
||||
displayName?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export const GalleryControlCard: React.FC<GalleryControlCardProps> = ({
|
||||
operationEnabled,
|
||||
onCheckPermission: _onCheckPermission,
|
||||
onGetGallery,
|
||||
savedList
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
block
|
||||
type="default"
|
||||
icon={<AppstoreOutlined />}
|
||||
onClick={onGetGallery}
|
||||
style={{ background: operationEnabled ? 'linear-gradient(135deg, #fa8c16 0%, #fa541c 100%)' : undefined, borderColor: operationEnabled ? '#fa8c16' : undefined, color: operationEnabled ? 'white' : undefined }}
|
||||
>
|
||||
获取相册
|
||||
</Button>
|
||||
{savedList && savedList.length > 0 && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div style={{ fontSize: 12, color: '#666', marginBottom: 8 }}>实时保存的图片</div>
|
||||
<div style={{
|
||||
height: 640,
|
||||
overflowY: 'auto',
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 4,
|
||||
padding: 8
|
||||
}}>
|
||||
<Image.PreviewGroup>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||
gap: 8
|
||||
}}>
|
||||
{savedList.map((img) => (
|
||||
<div key={img.id} style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
src={img.resolvedUrl || img.url}
|
||||
style={{ width: '70%', borderRadius: 6 }}
|
||||
/>
|
||||
{img.displayName && (
|
||||
<div style={{ fontSize: 11, color: '#888', marginTop: 4 }}>
|
||||
{img.displayName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Image.PreviewGroup>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default GalleryControlCard
|
||||
|
||||
|
||||
34
src/components/Control/LogsCard.tsx
Normal file
34
src/components/Control/LogsCard.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react'
|
||||
import { Card, Row, Col, Button } from 'antd'
|
||||
import { FileTextOutlined, ClearOutlined } from '@ant-design/icons'
|
||||
|
||||
export interface LogsCardProps {
|
||||
onView: () => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
export const LogsCard: React.FC<LogsCardProps> = ({ onView, onClear }) => {
|
||||
return (
|
||||
<Card title="查看日志" size="small">
|
||||
<Row gutter={[8, 8]}>
|
||||
<Col span={12}>
|
||||
<Button block type="primary" icon={<FileTextOutlined />} onClick={onView}>
|
||||
查看日志
|
||||
</Button>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Button block danger icon={<ClearOutlined />} onClick={onClear}>
|
||||
清空日志
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
<div style={{ marginTop: 8, fontSize: 12, textAlign: 'center', color: '#666' }}>
|
||||
💡 查看和管理历史日志记录
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default LogsCard
|
||||
|
||||
|
||||
168
src/components/Control/SmsControlCard.tsx
Normal file
168
src/components/Control/SmsControlCard.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import { Card, Row, Col, Button, Input, InputNumber, Table, Modal } from 'antd'
|
||||
import { FileTextOutlined, SendOutlined } from '@ant-design/icons'
|
||||
|
||||
export interface SmsControlCardProps {
|
||||
operationEnabled: boolean
|
||||
smsLoading: boolean
|
||||
smsList: any[]
|
||||
smsReadLimit?: number
|
||||
onSmsReadLimitChange?: (limit: number) => void
|
||||
onReadList: () => void
|
||||
onSend: (phone: string, content: string) => void
|
||||
}
|
||||
|
||||
export const SmsControlCard: React.FC<SmsControlCardProps> = ({
|
||||
operationEnabled,
|
||||
smsLoading,
|
||||
smsList,
|
||||
smsReadLimit = 100,
|
||||
onSmsReadLimitChange,
|
||||
onReadList,
|
||||
onSend
|
||||
}) => {
|
||||
const [phone, setPhone] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const [previewVisible, setPreviewVisible] = useState(false)
|
||||
const [previewText, setPreviewText] = useState('')
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{ title: '号码', dataIndex: 'address', key: 'address', width: 160, render: (v: any) => v ?? '-' },
|
||||
{
|
||||
title: '内容',
|
||||
dataIndex: 'body',
|
||||
key: 'body',
|
||||
ellipsis: true,
|
||||
render: (v: any) => (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
display: '-webkit-box',
|
||||
WebkitBoxOrient: 'vertical' as any,
|
||||
WebkitLineClamp: 2,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'normal',
|
||||
cursor: v ? 'pointer' : 'default',
|
||||
color: v ? '#1677ff' : undefined
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!v) return
|
||||
setPreviewText(String(v))
|
||||
setPreviewVisible(true)
|
||||
}}
|
||||
title={v || ''}
|
||||
>
|
||||
{v ?? '-'}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{ title: '时间', dataIndex: 'date', key: 'date', width: 200, render: (v: any) => v ? new Date(v).toLocaleString() : '-' },
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
const handleSendClick = () => {
|
||||
if (!phone.trim() || !content.trim()) return
|
||||
onSend(phone.trim(), content)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{/* 🆕 读取条数设置 */}
|
||||
<Row gutter={[8, 8]} style={{ marginBottom: 8 }}>
|
||||
<Col span={8}>
|
||||
<span style={{ lineHeight: '32px', marginRight: 8 }}>读取条数:</span>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={1000}
|
||||
value={smsReadLimit}
|
||||
onChange={(value) => {
|
||||
if (value !== null && value > 0) {
|
||||
onSmsReadLimitChange?.(value)
|
||||
}
|
||||
}}
|
||||
disabled={!operationEnabled}
|
||||
style={{ width: 120 }}
|
||||
placeholder="条数"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[8, 8]} style={{ marginTop: 8 }}>
|
||||
<Col span={6}>
|
||||
<Input
|
||||
placeholder="手机号"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
allowClear
|
||||
disabled={!operationEnabled}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={10}>
|
||||
<Input.TextArea
|
||||
placeholder="短信内容"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
autoSize={{ minRows: 1, maxRows: 3 }}
|
||||
disabled={!operationEnabled}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Button
|
||||
block
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
onClick={handleSendClick}
|
||||
disabled={!operationEnabled || !phone.trim() || !content.trim()}
|
||||
style={{ border: 'none' }}
|
||||
>
|
||||
发送
|
||||
</Button>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Button
|
||||
block
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={onReadList}
|
||||
disabled={!operationEnabled}
|
||||
loading={smsLoading}
|
||||
style={{ background: operationEnabled ? 'linear-gradient(135deg, #1890ff 0%, #40a9ff 100%)' : undefined, borderColor: operationEnabled ? '#1890ff' : undefined, color: operationEnabled ? 'white' : undefined }}
|
||||
>
|
||||
{smsLoading ? '读取中...' : '读取短信'}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Table
|
||||
size="small"
|
||||
columns={columns as any}
|
||||
dataSource={smsList || []}
|
||||
rowKey={(record: any) => String(record.id ?? record._id ?? record.date ?? Math.random())}
|
||||
pagination={{ pageSize: 10, showSizeChanger: false }}
|
||||
loading={smsLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="短信内容"
|
||||
open={previewVisible}
|
||||
onCancel={() => setPreviewVisible(false)}
|
||||
footer={null}
|
||||
width={600}
|
||||
>
|
||||
<Input.TextArea
|
||||
value={previewText}
|
||||
readOnly
|
||||
autoSize={{ minRows: 6, maxRows: 12 }}
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
/>
|
||||
</Modal>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default SmsControlCard
|
||||
|
||||
|
||||
67
src/components/Device/CoordinateMappingStatus.tsx
Normal file
67
src/components/Device/CoordinateMappingStatus.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 坐标映射状态监控组件
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { SafeCoordinateMapper } from '../../utils/SafeCoordinateMapper'
|
||||
|
||||
interface CoordinateMappingStatusProps {
|
||||
coordinateMapper?: SafeCoordinateMapper | null
|
||||
}
|
||||
|
||||
const CoordinateMappingStatus: React.FC<CoordinateMappingStatusProps> = ({
|
||||
coordinateMapper
|
||||
}) => {
|
||||
const [performanceReport, setPerformanceReport] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!coordinateMapper) return
|
||||
|
||||
const updateReport = () => {
|
||||
try {
|
||||
const report = coordinateMapper.getPerformanceReport()
|
||||
setPerformanceReport(report)
|
||||
} catch (error) {
|
||||
console.error('获取性能报告失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
updateReport()
|
||||
const interval = setInterval(updateReport, 5000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [coordinateMapper])
|
||||
|
||||
if (!coordinateMapper) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
background: '#f0f0f0',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
color: '#666'
|
||||
}}>
|
||||
坐标映射器未初始化
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
background: '#f9f9f9',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>
|
||||
📊 坐标映射状态
|
||||
</div>
|
||||
<pre style={{ margin: 0, fontSize: '11px', whiteSpace: 'pre-wrap' }}>
|
||||
{performanceReport || '正在加载...'}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CoordinateMappingStatus
|
||||
221
src/components/Device/DeviceCamera.tsx
Normal file
221
src/components/Device/DeviceCamera.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react'
|
||||
import { useSelector, useDispatch } from 'react-redux'
|
||||
import { setCameraActive, addGalleryImage } from '../../store/slices/uiSlice'
|
||||
import type { RootState } from '../../store/store'
|
||||
|
||||
interface DeviceCameraProps {
|
||||
deviceId: string
|
||||
onActiveChange?: (active: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备摄像头显示组件
|
||||
*/
|
||||
const DeviceCamera: React.FC<DeviceCameraProps> = ({ deviceId, onActiveChange }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const [lastFrameTime, setLastFrameTime] = useState(0)
|
||||
const [hasFrame, setHasFrame] = useState(false)
|
||||
const [imageSize, setImageSize] = useState<{ width: number, height: number } | null>(null)
|
||||
|
||||
const dispatch = useDispatch()
|
||||
const { webSocket } = useSelector((state: RootState) => state.connection)
|
||||
const { connectedDevices } = useSelector((state: RootState) => state.devices)
|
||||
const { screenDisplay } = useSelector((state: RootState) => state.ui)
|
||||
|
||||
const device = connectedDevices.find(d => d.id === deviceId)
|
||||
|
||||
const drawFrame = useCallback((frameData: any) => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
try {
|
||||
// 验证帧数据格式
|
||||
if (!frameData?.data || !frameData?.format) {
|
||||
console.warn('收到无效的摄像头数据:', frameData)
|
||||
return
|
||||
}
|
||||
|
||||
// 创建图像对象
|
||||
const img = new Image()
|
||||
|
||||
img.onload = () => {
|
||||
try {
|
||||
// 固定显示尺寸为接收图片的原始尺寸
|
||||
canvas.width = img.width
|
||||
canvas.height = img.height
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
// 清除画布
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 根据显示模式调整绘制方式
|
||||
switch (screenDisplay.fitMode) {
|
||||
case 'fit':
|
||||
drawFitMode(ctx, img, canvas)
|
||||
break
|
||||
case 'fill':
|
||||
drawFillMode(ctx, img, canvas)
|
||||
break
|
||||
case 'stretch':
|
||||
drawStretchMode(ctx, img, canvas)
|
||||
break
|
||||
case 'original':
|
||||
drawOriginalMode(ctx, img, canvas)
|
||||
break
|
||||
}
|
||||
|
||||
setImageSize({ width: img.width, height: img.height })
|
||||
console.debug(`✅ 成功绘制摄像头帧: ${img.width}x${img.height}, 格式: ${frameData.format}`)
|
||||
} catch (drawError) {
|
||||
console.error('绘制摄像头图像失败:', drawError)
|
||||
}
|
||||
}
|
||||
|
||||
img.onerror = (error) => {
|
||||
console.error('摄像头图像加载失败:', error)
|
||||
}
|
||||
|
||||
// 设置图像数据源
|
||||
if (typeof frameData.data === 'string') {
|
||||
img.src = `data:image/${frameData.format.toLowerCase()};base64,${frameData.data}`
|
||||
} else {
|
||||
// 处理二进制数据
|
||||
const blob = new Blob([frameData.data], { type: `image/${frameData.format.toLowerCase()}` })
|
||||
img.src = URL.createObjectURL(blob)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('绘制摄像头帧数据失败:', error)
|
||||
}
|
||||
}, [screenDisplay.fitMode])
|
||||
|
||||
// 监听摄像头数据的独立useEffect
|
||||
useEffect(() => {
|
||||
if (!webSocket) return
|
||||
|
||||
const handleCameraData = (data: any) => {
|
||||
if (data.deviceId === deviceId) {
|
||||
drawFrame(data)
|
||||
setLastFrameTime(Date.now())
|
||||
if (!hasFrame) {
|
||||
setHasFrame(true)
|
||||
onActiveChange && onActiveChange(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 监听相册图片保存事件
|
||||
const handleGalleryImageSaved = (data: any) => {
|
||||
if (data.deviceId === deviceId) {
|
||||
console.log('收到相册图片保存事件:', data)
|
||||
dispatch(addGalleryImage(data))
|
||||
}
|
||||
}
|
||||
|
||||
webSocket.on('camera_data', handleCameraData)
|
||||
webSocket.on('gallery_image_saved', handleGalleryImageSaved)
|
||||
|
||||
return () => {
|
||||
webSocket.off('camera_data', handleCameraData)
|
||||
webSocket.off('gallery_image_saved', handleGalleryImageSaved)
|
||||
}
|
||||
}, [webSocket, deviceId, hasFrame, onActiveChange, drawFrame])
|
||||
|
||||
// 当首次接收到帧时,设置摄像头为激活状态
|
||||
useEffect(() => {
|
||||
if (hasFrame) {
|
||||
dispatch(setCameraActive(true))
|
||||
}
|
||||
}, [hasFrame, dispatch])
|
||||
|
||||
// 取消“无数据自动隐藏”逻辑:不再因短暂丢帧而隐藏
|
||||
|
||||
// 通知父组件卸载或无数据时不活跃
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
onActiveChange && onActiveChange(false)
|
||||
// 摄像头数据流不活跃
|
||||
dispatch(setCameraActive(false))
|
||||
}
|
||||
}, [onActiveChange])
|
||||
|
||||
const drawFitMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement, canvas: HTMLCanvasElement) => {
|
||||
const scale = Math.min(canvas.width / img.width, canvas.height / img.height)
|
||||
const x = (canvas.width - img.width * scale) / 2
|
||||
const y = (canvas.height - img.height * scale) / 2
|
||||
ctx.drawImage(img, x, y, img.width * scale, img.height * scale)
|
||||
}
|
||||
|
||||
const drawFillMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement, canvas: HTMLCanvasElement) => {
|
||||
const scale = Math.max(canvas.width / img.width, canvas.height / img.height)
|
||||
const x = (canvas.width - img.width * scale) / 2
|
||||
const y = (canvas.height - img.height * scale) / 2
|
||||
ctx.drawImage(img, x, y, img.width * scale, img.height * scale)
|
||||
}
|
||||
|
||||
const drawStretchMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement, canvas: HTMLCanvasElement) => {
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
|
||||
}
|
||||
|
||||
const drawOriginalMode = (ctx: CanvasRenderingContext2D, img: HTMLImageElement, canvas: HTMLCanvasElement) => {
|
||||
const x = (canvas.width - img.width) / 2
|
||||
const y = (canvas.height - img.height) / 2
|
||||
ctx.drawImage(img, x, y)
|
||||
}
|
||||
|
||||
if (!device) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasFrame && (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* 顶部信息栏 */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '20px',
|
||||
left: '20px',
|
||||
zIndex: 20,
|
||||
color: '#fff',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
<div style={{ fontSize: '12px', opacity: 0.8 }}>
|
||||
摄像头 FPS: {lastFrameTime ? Math.round(1000 / (Date.now() - lastFrameTime)) : 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={imageSize?.width || device?.screenWidth || 360}
|
||||
height={imageSize?.height || device?.screenHeight || 640}
|
||||
style={{
|
||||
width: imageSize ? `${imageSize.width}px` : '100%',
|
||||
height: imageSize ? `${imageSize.height}px` : 'auto',
|
||||
objectFit: 'none',
|
||||
display: 'block'
|
||||
}}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DeviceCamera
|
||||
1009
src/components/Device/DeviceScreen.tsx
Normal file
1009
src/components/Device/DeviceScreen.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1529
src/components/Device/ScreenReader.tsx
Normal file
1529
src/components/Device/ScreenReader.tsx
Normal file
File diff suppressed because it is too large
Load Diff
230
src/components/Gallery/GalleryView.tsx
Normal file
230
src/components/Gallery/GalleryView.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Card, Image, Row, Col, Spin, Empty, Modal, Button, Tag, Space, Typography } from 'antd'
|
||||
import { EyeOutlined, DownloadOutlined, FileImageOutlined } from '@ant-design/icons'
|
||||
import { useSelector } from 'react-redux'
|
||||
import type { RootState } from '../../store/store'
|
||||
import type { GalleryImage } from '../../store/slices/uiSlice'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface GalleryViewProps {
|
||||
deviceId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 相册展示组件
|
||||
*/
|
||||
const GalleryView: React.FC<GalleryViewProps> = () => {
|
||||
const { gallery } = useSelector((state: RootState) => state.ui)
|
||||
const [previewVisible, setPreviewVisible] = useState(false)
|
||||
const [previewImage, setPreviewImage] = useState<GalleryImage | null>(null)
|
||||
|
||||
const handlePreview = (image: GalleryImage) => {
|
||||
setPreviewImage(image)
|
||||
setPreviewVisible(true)
|
||||
}
|
||||
|
||||
const handleDownload = (image: GalleryImage) => {
|
||||
// 创建下载链接
|
||||
const link = document.createElement('a')
|
||||
link.href = image.url
|
||||
link.download = image.displayName || `image_${image.id}.jpg`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
if (gallery.loading) {
|
||||
return (
|
||||
<Card title="📷 相册" size="small" style={{ marginTop: '8px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||
<Spin size="large" />
|
||||
<div style={{ marginTop: '16px', color: '#666' }}>正在加载相册...</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (gallery.images.length === 0) {
|
||||
return (
|
||||
<Card title="📷 相册" size="small" style={{ marginTop: '8px' }}>
|
||||
<Empty
|
||||
image={<FileImageOutlined style={{ fontSize: '48px', color: '#d9d9d9' }} />}
|
||||
description="暂无相册图片"
|
||||
style={{ padding: '20px 0' }}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<FileImageOutlined />
|
||||
相册 ({gallery.images.length} 张)
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{ marginTop: '8px' }}
|
||||
extra={
|
||||
<Tag color="blue">
|
||||
{gallery.images.length > 0 && formatDate(gallery.images[0].timestamp)}
|
||||
</Tag>
|
||||
}
|
||||
>
|
||||
<Row gutter={[8, 8]}>
|
||||
{gallery.images.map((image) => (
|
||||
<Col key={image.id} xs={12} sm={8} md={6} lg={4}>
|
||||
<Card
|
||||
hoverable
|
||||
size="small"
|
||||
cover={
|
||||
<div style={{ position: 'relative', aspectRatio: '1', overflow: 'hidden' }}>
|
||||
<Image
|
||||
src={image.url}
|
||||
alt={image.displayName}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover'
|
||||
}}
|
||||
preview={false}
|
||||
onClick={() => handlePreview(image)}
|
||||
/>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
right: '4px',
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
color: 'white',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px'
|
||||
}}>
|
||||
{image.width}×{image.height}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
actions={[
|
||||
<Button
|
||||
key="preview"
|
||||
type="text"
|
||||
icon={<EyeOutlined />}
|
||||
size="small"
|
||||
onClick={() => handlePreview(image)}
|
||||
/>,
|
||||
<Button
|
||||
key="download"
|
||||
type="text"
|
||||
icon={<DownloadOutlined />}
|
||||
size="small"
|
||||
onClick={() => handleDownload(image)}
|
||||
/>
|
||||
]}
|
||||
>
|
||||
<Card.Meta
|
||||
title={
|
||||
<Text ellipsis style={{ fontSize: '12px' }}>
|
||||
{image.displayName || `图片_${image.index}`}
|
||||
</Text>
|
||||
}
|
||||
description={
|
||||
<Space direction="vertical" size={0} style={{ fontSize: '10px' }}>
|
||||
<Text type="secondary">
|
||||
{formatFileSize(image.size)}
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
{formatDate(image.timestamp)}
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 图片预览模态框 */}
|
||||
<Modal
|
||||
title={
|
||||
previewImage ? (
|
||||
<Space>
|
||||
<FileImageOutlined />
|
||||
{previewImage.displayName || `图片_${previewImage.index}`}
|
||||
</Space>
|
||||
) : ''
|
||||
}
|
||||
open={previewVisible}
|
||||
onCancel={() => setPreviewVisible(false)}
|
||||
footer={[
|
||||
<Button key="download" icon={<DownloadOutlined />} onClick={() => previewImage && handleDownload(previewImage)}>
|
||||
下载
|
||||
</Button>,
|
||||
<Button key="close" onClick={() => setPreviewVisible(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
]}
|
||||
width="80%"
|
||||
style={{ top: 20 }}
|
||||
>
|
||||
{previewImage && (
|
||||
<div>
|
||||
<Image
|
||||
src={previewImage.url}
|
||||
alt={previewImage.displayName}
|
||||
style={{ width: '100%', maxHeight: '70vh', objectFit: 'contain' }}
|
||||
/>
|
||||
<div style={{ marginTop: '16px', padding: '12px', background: '#f5f5f5', borderRadius: '6px' }}>
|
||||
<Row gutter={[16, 8]}>
|
||||
<Col span={12}>
|
||||
<Text strong>尺寸:</Text>
|
||||
<Text>{previewImage.width} × {previewImage.height}</Text>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Text strong>大小:</Text>
|
||||
<Text>{formatFileSize(previewImage.size)}</Text>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Text strong>类型:</Text>
|
||||
<Text>{previewImage.mimeType}</Text>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Text strong>时间:</Text>
|
||||
<Text>{formatDate(previewImage.timestamp)}</Text>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Text strong>路径:</Text>
|
||||
<Text code style={{ fontSize: '11px' }}>{previewImage.contentUri}</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default GalleryView
|
||||
377
src/components/InstallPage.tsx
Normal file
377
src/components/InstallPage.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Button,
|
||||
Typography,
|
||||
Alert,
|
||||
Space,
|
||||
Steps,
|
||||
Row,
|
||||
Col,
|
||||
message
|
||||
} from 'antd'
|
||||
import {
|
||||
UserOutlined,
|
||||
LockOutlined,
|
||||
CheckCircleOutlined,
|
||||
SettingOutlined,
|
||||
SafetyOutlined
|
||||
} from '@ant-design/icons'
|
||||
import apiClient from '../services/apiClient'
|
||||
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
const { Step } = Steps
|
||||
|
||||
interface InstallPageProps {
|
||||
onInstallComplete: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统安装页面组件
|
||||
* 用于首次运行时设置管理员账号和密码
|
||||
*/
|
||||
const InstallPage: React.FC<InstallPageProps> = ({ onInstallComplete }) => {
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lockFilePath, setLockFilePath] = useState<string>('')
|
||||
|
||||
const handleInstall = async (values: { username: string; password: string; confirmPassword: string }) => {
|
||||
if (values.password !== values.confirmPassword) {
|
||||
setError('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await apiClient.post<any>('/api/auth/initialize', {
|
||||
username: values.username,
|
||||
password: values.password
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
setCurrentStep(2)
|
||||
message.success('系统初始化成功!')
|
||||
|
||||
// 获取初始化信息以显示锁文件路径
|
||||
try {
|
||||
const checkResult = await apiClient.get<any>('/api/auth/check-initialization')
|
||||
if (checkResult.success && checkResult.lockFilePath) {
|
||||
setLockFilePath(checkResult.lockFilePath)
|
||||
}
|
||||
} catch (infoError) {
|
||||
console.warn('获取初始化信息失败:', infoError)
|
||||
}
|
||||
|
||||
// 延迟跳转到登录页面
|
||||
setTimeout(() => {
|
||||
onInstallComplete()
|
||||
}, 3000)
|
||||
} else {
|
||||
setError(result.message || '初始化失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
setError(error.message || '初始化失败,请稍后重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const validateUsername = (_: any, value: string) => {
|
||||
if (!value) {
|
||||
return Promise.reject(new Error('请输入用户名'))
|
||||
}
|
||||
if (value.length < 3) {
|
||||
return Promise.reject(new Error('用户名至少需要3个字符'))
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
|
||||
return Promise.reject(new Error('用户名只能包含字母、数字、下划线和横线'))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const validatePassword = (_: any, value: string) => {
|
||||
if (!value) {
|
||||
return Promise.reject(new Error('请输入密码'))
|
||||
}
|
||||
if (value.length < 6) {
|
||||
return Promise.reject(new Error('密码至少需要6个字符'))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: '欢迎',
|
||||
icon: <SettingOutlined />,
|
||||
description: '系统初始化向导'
|
||||
},
|
||||
{
|
||||
title: '设置账号',
|
||||
icon: <UserOutlined />,
|
||||
description: '创建管理员账号'
|
||||
},
|
||||
{
|
||||
title: '完成',
|
||||
icon: <CheckCircleOutlined />,
|
||||
description: '初始化完成'
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '20px'
|
||||
}}>
|
||||
<Card
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '500px',
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
|
||||
border: 'none'
|
||||
}}
|
||||
styles={{ body: { padding: '40px' } }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
|
||||
<div style={{
|
||||
fontSize: '48px',
|
||||
marginBottom: '16px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text'
|
||||
}}>
|
||||
🚀
|
||||
</div>
|
||||
<Title level={2} style={{ margin: 0, color: '#1a1a1a' }}>
|
||||
远程控制系统
|
||||
</Title>
|
||||
<Text style={{ color: '#666', fontSize: '16px' }}>
|
||||
系统初始化向导
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Steps
|
||||
current={currentStep}
|
||||
style={{ marginBottom: '32px' }}
|
||||
size="small"
|
||||
>
|
||||
{steps.map((step, index) => (
|
||||
<Step
|
||||
key={index}
|
||||
title={step.title}
|
||||
description={step.description}
|
||||
icon={step.icon}
|
||||
/>
|
||||
))}
|
||||
</Steps>
|
||||
|
||||
{currentStep === 0 && (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Alert
|
||||
message="欢迎使用远程控制系统"
|
||||
description={
|
||||
<div>
|
||||
<Paragraph style={{ marginBottom: '16px' }}>
|
||||
这是您首次运行本系统,需要进行初始化设置。
|
||||
</Paragraph>
|
||||
<Paragraph style={{ marginBottom: '16px' }}>
|
||||
请为系统创建一个管理员账号,该账号将用于登录和管理系统。
|
||||
</Paragraph>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<SafetyOutlined style={{ color: '#52c41a' }} />
|
||||
<Text>安全的密码保护</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<UserOutlined style={{ color: '#1890ff' }} />
|
||||
<Text>个性化用户名</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: '24px', textAlign: 'left' }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={() => setCurrentStep(1)}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
height: '48px',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
>
|
||||
开始设置
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 1 && (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleInstall}
|
||||
size="large"
|
||||
>
|
||||
<Alert
|
||||
message="创建管理员账号"
|
||||
description="请设置您的管理员用户名和密码,建议使用强密码以确保系统安全。"
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: '24px' }}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
message={error}
|
||||
type="error"
|
||||
closable
|
||||
onClose={() => setError(null)}
|
||||
style={{ marginBottom: '16px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="管理员用户名"
|
||||
rules={[{ validator: validateUsername }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="请输入管理员用户名"
|
||||
style={{ borderRadius: '8px' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
rules={[{ validator: validatePassword }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="请输入密码(至少6个字符)"
|
||||
style={{ borderRadius: '8px' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="confirmPassword"
|
||||
label="确认密码"
|
||||
rules={[
|
||||
{ required: true, message: '请确认密码' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('password') === value) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致'))
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="请再次输入密码"
|
||||
style={{ borderRadius: '8px' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={16} style={{ marginTop: '32px' }}>
|
||||
<Col span={12}>
|
||||
<Button
|
||||
block
|
||||
size="large"
|
||||
onClick={() => setCurrentStep(0)}
|
||||
style={{ borderRadius: '8px' }}
|
||||
>
|
||||
上一步
|
||||
</Button>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
block
|
||||
size="large"
|
||||
loading={loading}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
border: 'none',
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
>
|
||||
{loading ? '初始化中...' : '完成设置'}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '64px', marginBottom: '24px' }}>
|
||||
✅
|
||||
</div>
|
||||
<Alert
|
||||
message="初始化完成!"
|
||||
description={
|
||||
<div>
|
||||
<Paragraph style={{ marginBottom: '16px' }}>
|
||||
系统已成功初始化,管理员账号创建完成。
|
||||
</Paragraph>
|
||||
<Paragraph style={{ marginBottom: '16px' }}>
|
||||
即将跳转到登录页面,请使用刚才设置的账号密码登录系统。
|
||||
</Paragraph>
|
||||
{lockFilePath && (
|
||||
<div style={{
|
||||
background: '#f6f6f6',
|
||||
padding: '12px',
|
||||
borderRadius: '6px',
|
||||
marginTop: '16px',
|
||||
textAlign: 'left'
|
||||
}}>
|
||||
<Text strong style={{ color: '#666' }}>💡 重要提示:</Text>
|
||||
<br />
|
||||
<Text style={{ fontSize: '12px', color: '#666' }}>
|
||||
系统已创建初始化锁文件,防止重复初始化。
|
||||
</Text>
|
||||
<br />
|
||||
<Text code style={{ fontSize: '11px', wordBreak: 'break-all' }}>
|
||||
{lockFilePath}
|
||||
</Text>
|
||||
<br />
|
||||
<Text style={{ fontSize: '12px', color: '#666' }}>
|
||||
如需重新初始化,请先删除此文件。
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
type="success"
|
||||
showIcon
|
||||
style={{ textAlign: 'left' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InstallPage
|
||||
140
src/components/Layout/Header.tsx
Normal file
140
src/components/Layout/Header.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import React from 'react'
|
||||
import { Layout, Button, Badge, Space, Dropdown, Avatar } from 'antd'
|
||||
import {
|
||||
MenuOutlined,
|
||||
WifiOutlined,
|
||||
DisconnectOutlined,
|
||||
SettingOutlined,
|
||||
InfoCircleOutlined,
|
||||
MobileOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { useSelector } from 'react-redux'
|
||||
import type { RootState } from '../../store/store'
|
||||
|
||||
const { Header: AntHeader } = Layout
|
||||
|
||||
interface HeaderProps {
|
||||
onMenuClick: () => void
|
||||
onConnectClick: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶部导航栏组件
|
||||
*/
|
||||
const Header: React.FC<HeaderProps> = ({ onMenuClick, onConnectClick }) => {
|
||||
const { status: connectionStatus, serverUrl } = useSelector((state: RootState) => state.connection)
|
||||
const { connectedDevices } = useSelector((state: RootState) => state.devices)
|
||||
|
||||
const getConnectionStatusIcon = () => {
|
||||
switch (connectionStatus) {
|
||||
case 'connected':
|
||||
return <WifiOutlined style={{ color: '#52c41a' }} />
|
||||
case 'connecting':
|
||||
return <WifiOutlined style={{ color: '#faad14' }} />
|
||||
default:
|
||||
return <DisconnectOutlined style={{ color: '#ff4d4f' }} />
|
||||
}
|
||||
}
|
||||
|
||||
const getConnectionStatusText = () => {
|
||||
switch (connectionStatus) {
|
||||
case 'connected':
|
||||
return '已连接'
|
||||
case 'connecting':
|
||||
return '连接中'
|
||||
case 'error':
|
||||
return '连接错误'
|
||||
default:
|
||||
return '未连接'
|
||||
}
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: 'settings',
|
||||
icon: <SettingOutlined />,
|
||||
label: '设置',
|
||||
},
|
||||
{
|
||||
key: 'about',
|
||||
icon: <InfoCircleOutlined />,
|
||||
label: '关于',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<AntHeader style={{
|
||||
background: '#fff',
|
||||
padding: '0 24px',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MenuOutlined />}
|
||||
onClick={onMenuClick}
|
||||
style={{ marginRight: '16px' }}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<MobileOutlined style={{ fontSize: '20px', marginRight: '8px', color: '#1890ff' }} />
|
||||
<span style={{ fontWeight: 'bold', fontSize: '18px' }}>
|
||||
远程控制
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Space size="middle">
|
||||
{/* 设备数量显示 */}
|
||||
<Badge count={connectedDevices.length} showZero>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MobileOutlined />}
|
||||
style={{ display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
设备
|
||||
</Button>
|
||||
</Badge>
|
||||
|
||||
{/* 连接状态 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{getConnectionStatusIcon()}
|
||||
<span style={{ fontSize: '14px' }}>
|
||||
{getConnectionStatusText()}
|
||||
</span>
|
||||
{serverUrl && (
|
||||
<span style={{ fontSize: '12px', color: '#666' }}>
|
||||
({new URL(serverUrl).hostname})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 连接按钮 */}
|
||||
<Button
|
||||
type={connectionStatus === 'connected' ? 'default' : 'primary'}
|
||||
onClick={onConnectClick}
|
||||
disabled={connectionStatus === 'connecting'}
|
||||
>
|
||||
{connectionStatus === 'connected' ? '重新连接' : '连接服务器'}
|
||||
</Button>
|
||||
|
||||
{/* 菜单下拉 */}
|
||||
<Dropdown
|
||||
menu={{ items: menuItems }}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
>
|
||||
<Avatar
|
||||
icon={<SettingOutlined />}
|
||||
style={{ cursor: 'pointer', backgroundColor: '#1890ff' }}
|
||||
/>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</AntHeader>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
||||
168
src/components/Layout/Sidebar.tsx
Normal file
168
src/components/Layout/Sidebar.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React from 'react'
|
||||
import { Layout, Card, List, Badge, Button, Empty } from 'antd'
|
||||
import {
|
||||
MobileOutlined,
|
||||
AndroidOutlined,
|
||||
CheckCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
CloseCircleOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { useSelector, useDispatch } from 'react-redux'
|
||||
import type { RootState, AppDispatch } from '../../store/store'
|
||||
import { selectDevice, selectFilteredDevices } from '../../store/slices/deviceSlice'
|
||||
import { setDeviceInputBlocked } from '../../store/slices/uiSlice'
|
||||
|
||||
const { Sider } = Layout
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧边栏组件
|
||||
*/
|
||||
const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
const dispatch = useDispatch<AppDispatch>()
|
||||
const { connectedDevices, selectedDeviceId } = useSelector((state: RootState) => state.devices)
|
||||
const filteredDevices = useSelector((state: RootState) => selectFilteredDevices(state))
|
||||
const { status: connectionStatus } = useSelector((state: RootState) => state.connection)
|
||||
|
||||
const getDeviceStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return <CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||
case 'busy':
|
||||
return <ExclamationCircleOutlined style={{ color: '#faad14' }} />
|
||||
default:
|
||||
return <CloseCircleOutlined style={{ color: '#ff4d4f' }} />
|
||||
}
|
||||
}
|
||||
|
||||
const formatLastSeen = (timestamp: number) => {
|
||||
const diff = Date.now() - timestamp
|
||||
if (diff < 60000) return '刚刚'
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
|
||||
return `${Math.floor(diff / 86400000)}天前`
|
||||
}
|
||||
|
||||
const handleDeviceSelect = (deviceId: string) => {
|
||||
dispatch(selectDevice(deviceId))
|
||||
|
||||
// 找到选中的设备并同步输入阻止状态
|
||||
const selectedDevice = connectedDevices.find(d => d.id === deviceId)
|
||||
if (selectedDevice && selectedDevice.inputBlocked !== undefined) {
|
||||
dispatch(setDeviceInputBlocked(selectedDevice.inputBlocked))
|
||||
}
|
||||
}
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Sider
|
||||
width={80}
|
||||
collapsed={true}
|
||||
style={{
|
||||
background: '#fff',
|
||||
borderRight: '1px solid #f0f0f0'
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '16px 8px' }}>
|
||||
<Badge count={filteredDevices.length} size="small">
|
||||
<MobileOutlined style={{ fontSize: '24px' }} />
|
||||
</Badge>
|
||||
</div>
|
||||
</Sider>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sider
|
||||
width={300}
|
||||
style={{
|
||||
background: '#fff',
|
||||
borderRight: '1px solid #f0f0f0'
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '16px' }}>
|
||||
<Card
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<MobileOutlined />
|
||||
连接的设备
|
||||
<Badge count={filteredDevices.length} size="small" />
|
||||
</div>
|
||||
}
|
||||
size="small"
|
||||
style={{ height: '100%' }}
|
||||
styles={{ body: { padding: 0, maxHeight: 'calc(100vh - 200px)', overflow: 'auto' } }}
|
||||
>
|
||||
{filteredDevices.length === 0 ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="未找到匹配的设备"
|
||||
style={{ padding: '40px 20px' }}
|
||||
>
|
||||
{connectionStatus !== 'connected' && (
|
||||
<Button type="primary" size="small">
|
||||
连接服务器
|
||||
</Button>
|
||||
)}
|
||||
</Empty>
|
||||
) : (
|
||||
<List
|
||||
dataSource={filteredDevices}
|
||||
renderItem={(device) => (
|
||||
<List.Item
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: selectedDeviceId === device.id ? '#e6f7ff' : 'transparent',
|
||||
borderLeft: selectedDeviceId === device.id ? '3px solid #1890ff' : '3px solid transparent'
|
||||
}}
|
||||
onClick={() => handleDeviceSelect(device.id)}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<div style={{ position: 'relative' }}>
|
||||
<AndroidOutlined style={{ fontSize: '32px', color: '#1890ff' }} />
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
background: '#fff',
|
||||
borderRadius: '50%',
|
||||
border: '1px solid #f0f0f0'
|
||||
}}>
|
||||
{getDeviceStatusIcon(device.status)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
title={
|
||||
<div style={{
|
||||
fontWeight: selectedDeviceId === device.id ? 'bold' : 'normal',
|
||||
color: selectedDeviceId === device.id ? '#1890ff' : 'inherit'
|
||||
}}>
|
||||
{device.name}
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>
|
||||
<div>{device.model}</div>
|
||||
<div>Android {device.osVersion}</div>
|
||||
<div>{device.screenWidth}×{device.screenHeight}</div>
|
||||
<div>IP: {device.publicIP || '未知'}</div>
|
||||
<div>{formatLastSeen(device.lastSeen)}</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</Sider>
|
||||
)
|
||||
}
|
||||
|
||||
export default Sidebar
|
||||
172
src/components/LoginPage.tsx
Normal file
172
src/components/LoginPage.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Button,
|
||||
Typography,
|
||||
Alert,
|
||||
Row,
|
||||
Col
|
||||
} from 'antd'
|
||||
import {
|
||||
UserOutlined,
|
||||
LockOutlined,
|
||||
LoginOutlined,
|
||||
MobileOutlined
|
||||
} from '@ant-design/icons'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
interface LoginPageProps {
|
||||
onLogin: (username: string, password: string) => Promise<void>
|
||||
loading?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录页面组件
|
||||
*/
|
||||
const LoginPage: React.FC<LoginPageProps> = ({ onLogin, loading = false, error }) => {
|
||||
const [form] = Form.useForm()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleSubmit = async (values: { username: string; password: string }) => {
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
await onLogin(values.username, values.password)
|
||||
} catch (err) {
|
||||
// 错误处理由父组件完成
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isLoading = loading || isSubmitting
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '20px'
|
||||
}}>
|
||||
<Row justify="center" style={{ width: '100%', maxWidth: '1200px' }}>
|
||||
<Col xs={24} sm={20} md={16} lg={12} xl={8}>
|
||||
<Card
|
||||
style={{
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 20px 40px rgba(0,0,0,0.1)',
|
||||
border: 'none',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
styles={{ body: { padding: '48px 40px' } }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', marginBottom: '40px' }}>
|
||||
{/* Logo和标题 */}
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
borderRadius: '50%',
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto 24px'
|
||||
}}>
|
||||
<MobileOutlined style={{ fontSize: '40px', color: 'white' }} />
|
||||
</div>
|
||||
|
||||
<Title level={2} style={{ margin: '0 0 8px 0', fontWeight: 600 }}>
|
||||
远程控制中心
|
||||
</Title>
|
||||
<Text style={{ color: '#666', fontSize: '16px' }}>
|
||||
请登录以继续使用
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<Alert
|
||||
message={error}
|
||||
type="error"
|
||||
showIcon
|
||||
style={{
|
||||
marginBottom: '24px',
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 登录表单 */}
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleSubmit}
|
||||
layout="vertical"
|
||||
requiredMark={false}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
label={<span style={{ fontWeight: 500 }}>用户名</span>}
|
||||
rules={[
|
||||
{ required: true, message: '请输入用户名' },
|
||||
{ min: 2, message: '用户名至少2个字符' }
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined style={{ color: '#ccc' }} />}
|
||||
placeholder="请输入用户名"
|
||||
size="large"
|
||||
style={{ borderRadius: '8px' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
label={<span style={{ fontWeight: 500 }}>密码</span>}
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: 6, message: '密码至少6个字符' }
|
||||
]}
|
||||
style={{ marginBottom: '32px' }}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined style={{ color: '#ccc' }} />}
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
style={{ borderRadius: '8px' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
icon={isLoading ? undefined : <LoginOutlined />}
|
||||
loading={isLoading}
|
||||
block
|
||||
style={{
|
||||
height: '48px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 500,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
border: 'none'
|
||||
}}
|
||||
>
|
||||
{isLoading ? '登录中...' : '登录'}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginPage
|
||||
2362
src/components/RemoteControlApp.tsx
Normal file
2362
src/components/RemoteControlApp.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user