(rebase) avoid dist and binary

This commit is contained in:
2026-02-14 10:01:14 +08:00
commit e3dd38d1a5
46 changed files with 21117 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
public/

28
eslint.config.js Normal file
View File

@@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hi远程控制 - Web客户端</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

34
package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "web-client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@reduxjs/toolkit": "^2.8.2",
"antd": "^5.26.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-redux": "^9.2.0",
"socket.io-client": "^4.8.1"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"terser": "^5.43.1",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5"
}
}

335
src/App.css Normal file
View File

@@ -0,0 +1,335 @@
#root {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
/* 主布局自适应 */
.app-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Header 响应式 */
.app-header {
padding: 0 24px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
flex-shrink: 0;
height: 56px;
line-height: 56px;
z-index: 10;
}
@media (max-width: 768px) {
.app-header {
padding: 0 12px;
height: 48px;
line-height: 48px;
}
}
/* 侧边栏 */
.app-sider {
background: #fff !important;
border-right: 1px solid #f0f0f0;
box-shadow: 2px 0 8px rgba(0,0,0,0.04);
}
.app-sider .ant-menu {
border-right: 0;
background: transparent;
}
/* 内容区域 */
.app-content {
margin: 0;
padding: 0;
overflow: hidden;
width: 100%;
display: flex;
flex-direction: column;
flex: 1;
}
/* 设备列表页 */
.device-list-page {
padding: 20px;
background: #f0f2f5;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
@media (max-width: 768px) {
.device-list-page {
padding: 12px;
}
}
.device-list-card {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* 筛选栏响应式 */
.device-filter-bar {
background: white;
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
border: 1px solid #f0f0f0;
}
.device-filter-bar .ant-form-inline {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
@media (max-width: 1200px) {
.device-filter-bar .ant-form-inline .ant-form-item {
margin-bottom: 8px;
}
}
/* 空状态 */
.device-empty-state {
text-align: center;
padding: 60px 20px;
color: #8c8c8c;
}
/* 独立控制页面 - 全屏自适应 */
.standalone-control-page {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: row;
background: #f0f2f5;
overflow: hidden;
}
/* 控制页左侧屏幕区域 */
.control-screen-area {
display: flex;
flex-direction: column;
height: 100%;
border-right: 1px solid #e8e8e8;
background: #fff;
flex-shrink: 0;
overflow: hidden;
}
/* 工具栏 */
.control-toolbar {
padding: 6px 12px;
border-bottom: 1px solid #f0f0f0;
background: #fafafa;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
min-height: 40px;
}
.control-toolbar .ant-btn {
font-size: 12px;
}
@media (max-width: 1200px) {
.control-toolbar {
padding: 4px 8px;
}
.control-toolbar .ant-btn {
font-size: 11px;
padding: 0 6px;
}
}
/* 屏幕+阅读器水平布局 */
.screen-reader-row {
display: flex;
flex-direction: row;
flex: 1;
min-height: 0;
overflow: hidden;
}
.screen-reader-panel {
width: 50%;
border-right: 1px solid #e8e8e8;
position: relative;
overflow: hidden;
background: #fafafa;
display: flex;
flex-direction: column;
}
.device-screen-panel {
width: 50%;
position: relative;
overflow: hidden;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
/* 文本输入区域 */
.text-input-bar {
height: 44px;
border-top: 1px solid #f0f0f0;
background: #fff;
padding: 6px 12px;
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.text-input-bar input {
flex: 1;
height: 30px;
padding: 0 10px;
border: 1px solid #d9d9d9;
border-radius: 6px;
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.text-input-bar input:focus {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24,144,255,0.1);
}
.text-input-bar button {
height: 30px;
padding: 0 14px;
border: none;
border-radius: 6px;
background: #1890ff;
color: #fff;
cursor: pointer;
font-size: 13px;
transition: background 0.2s;
}
.text-input-bar button:hover:not(:disabled) {
background: #40a9ff;
}
.text-input-bar button:disabled {
background: #f5f5f5;
color: #bfbfbf;
cursor: not-allowed;
}
/* 系统按键区域 */
.system-keys-bar {
padding: 8px 12px;
border-top: 1px solid #f0f0f0;
background: #fff;
flex-shrink: 0;
display: flex;
justify-content: center;
gap: 10px;
}
.system-keys-bar .ant-btn {
min-width: 72px;
height: 34px;
border-radius: 8px;
font-size: 13px;
}
/* 右侧控制面板 */
.control-panel-area {
flex: 1;
display: flex;
flex-direction: column;
background: #fff;
min-width: 0;
overflow: hidden;
}
.control-panel-header {
padding: 10px 16px;
border-bottom: 1px solid #f0f0f0;
background: #fafafa;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
font-size: 14px;
}
.control-panel-body {
flex: 1;
overflow: auto;
padding: 0;
}
/* 底部状态栏 */
.screen-reader-status-bar {
padding: 3px 12px;
border-top: 1px solid #f0f0f0;
background: #fafafa;
font-size: 11px;
color: #8c8c8c;
text-align: center;
flex-shrink: 0;
}
/* 移动端遮罩 */
.mobile-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0,0,0,0.45);
z-index: 999;
animation: fadeIn 0.2s ease;
}
/* 移动端侧边栏 */
@media (max-width: 768px) {
.ant-layout-sider {
position: fixed !important;
height: 100vh !important;
z-index: 1000 !important;
}
.ant-layout-header {
padding: 0 12px !important;
}
}
/* 表格自适应 */
.ant-table-wrapper {
overflow-x: auto;
}
.ant-table {
font-size: 13px;
}

56
src/App.tsx Normal file
View File

@@ -0,0 +1,56 @@
// React 17+ 使用新的JSX转换无需显式导入React
import { Provider } from 'react-redux'
import { ConfigProvider, App as AntdApp } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import { store } from './store/store'
import RemoteControlApp from './components/RemoteControlApp'
import AuthGuard from './components/AuthGuard'
import './App.css'
/**
* 主应用组件
*/
function App() {
return (
<Provider store={store}>
<ConfigProvider
locale={zhCN}
theme={{
token: {
colorPrimary: '#667eea',
borderRadius: 8,
colorBgContainer: '#ffffff',
colorBgLayout: '#f0f2f5',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
},
components: {
Table: {
headerBg: '#fafafa',
headerColor: '#595959',
rowHoverBg: '#f0f5ff',
borderRadius: 8,
},
Card: {
borderRadiusLG: 12,
},
Button: {
borderRadius: 6,
},
Menu: {
itemBorderRadius: 8,
itemMarginInline: 8,
},
},
}}
>
<AntdApp>
<AuthGuard>
<RemoteControlApp />
</AuthGuard>
</AntdApp>
</ConfigProvider>
</Provider>
)
}
export default App

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because it is too large Load Diff

View 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

View 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

View 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

View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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
}}
>
4PIN输入
</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
}}
>
6PIN输入
</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

View File

@@ -0,0 +1,233 @@
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 className="device-filter-bar" style={style}>
<Form
form={form}
layout="inline"
onFinish={handleFilter}
size="small"
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
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

View 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

View 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

View 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

View 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

View 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

View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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

View 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

View 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

View 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

View 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

File diff suppressed because it is too large Load Diff

78
src/index.css Normal file
View File

@@ -0,0 +1,78 @@
* {
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow-x: hidden;
}
:root {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
line-height: 1.5;
font-weight: 400;
color: #1f1f1f;
background-color: #f0f2f5;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
a {
font-weight: 500;
color: #1890ff;
text-decoration: none;
}
a:hover {
color: #40a9ff;
}
/* 滚动条美化 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #d9d9d9;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #bfbfbf;
}
/* Ant Design 表格行悬停效果增强 */
.ant-table-tbody > tr:hover > td {
background: #e6f7ff !important;
}
/* 动画 */
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideInLeft {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

226
src/services/apiClient.ts Normal file
View File

@@ -0,0 +1,226 @@
/**
* API客户端服务
* 提供统一的HTTP请求接口自动处理认证token
*/
class ApiClient {
private baseURL: string
constructor() {
// 判断hostname是否为IP地址
const isIPAddress = this.isIPAddress(window.location.hostname)
if (isIPAddress) {
// 如果是IP地址添加3001端口
this.baseURL = `${window.location.protocol}//${window.location.hostname}:3001`
} else {
// 如果是域名,直接使用域名(通常域名会通过反向代理处理端口)
this.baseURL = `${window.location.protocol}//${window.location.hostname}`
}
console.log('API BaseURL:', this.baseURL)
}
/**
* 判断字符串是否为IP地址
*/
private isIPAddress(hostname: string): boolean {
// IPv4地址正则表达式
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/
// IPv6地址正则表达式简化版
const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/
return ipv4Regex.test(hostname) || ipv6Regex.test(hostname)
}
/**
* 获取认证token
*/
private getAuthToken(): string | null {
return localStorage.getItem('auth_token')
}
/**
* 创建请求headers
*/
private createHeaders(customHeaders: Record<string, string> = {}): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...customHeaders
}
const token = this.getAuthToken()
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
return headers
}
/**
* 处理响应
*/
private async handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
// 如果是认证失败清除本地token
if (response.status === 401) {
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_user')
// 可以触发全局的登录状态重置事件
window.dispatchEvent(new CustomEvent('auth:token-expired'))
}
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`)
}
return response.json()
}
/**
* GET请求
*/
async get<T>(endpoint: string, customHeaders?: Record<string, string>): Promise<T> {
try {
const response = await fetch(`${this.baseURL}${endpoint}`, {
method: 'GET',
headers: this.createHeaders(customHeaders),
})
return this.handleResponse<T>(response)
} catch (error: any) {
// 捕获网络错误(如 Failed to fetch
if (error?.name === 'TypeError' && error?.message?.includes('fetch')) {
throw new Error(`网络连接失败: 无法连接到服务器 ${this.baseURL}。请检查服务器是否正常运行。`)
}
throw error
}
}
/**
* POST请求
*/
async post<T>(
endpoint: string,
data?: any,
customHeaders?: Record<string, string>
): Promise<T> {
try {
const headers = this.createHeaders(customHeaders)
let body: string | FormData | undefined
// 如果data是FormData不设置Content-Type让浏览器自动设置
if (data instanceof FormData) {
body = data
delete headers['Content-Type']
} else if (data !== undefined) {
body = JSON.stringify(data)
}
const response = await fetch(`${this.baseURL}${endpoint}`, {
method: 'POST',
headers,
body,
})
return this.handleResponse<T>(response)
} catch (error: any) {
// 捕获网络错误(如 Failed to fetch
if (error?.name === 'TypeError' && error?.message?.includes('fetch')) {
throw new Error(`网络连接失败: 无法连接到服务器 ${this.baseURL}。请检查服务器是否正常运行。`)
}
throw error
}
}
/**
* PUT请求
*/
async put<T>(
endpoint: string,
data?: any,
customHeaders?: Record<string, string>
): Promise<T> {
const response = await fetch(`${this.baseURL}${endpoint}`, {
method: 'PUT',
headers: this.createHeaders(customHeaders),
body: data ? JSON.stringify(data) : undefined,
})
return this.handleResponse<T>(response)
}
/**
* DELETE请求
*/
async delete<T>(endpoint: string, customHeaders?: Record<string, string>): Promise<T> {
try {
const response = await fetch(`${this.baseURL}${endpoint}`, {
method: 'DELETE',
headers: this.createHeaders(customHeaders),
})
return this.handleResponse<T>(response)
} catch (error: any) {
// 捕获网络错误(如 Failed to fetch
if (error?.name === 'TypeError' && error?.message?.includes('fetch')) {
throw new Error(`网络连接失败: 无法连接到服务器 ${this.baseURL}。请检查服务器是否正常运行。`)
}
throw error
}
}
/**
* 上传文件的POST请求
*/
async postFormData<T>(
endpoint: string,
formData: FormData,
customHeaders?: Record<string, string>
): Promise<T> {
return this.post<T>(endpoint, formData, customHeaders)
}
/**
* 下载文件
*/
async downloadFile(endpoint: string, filename?: string): Promise<void> {
const response = await fetch(`${this.baseURL}${endpoint}`, {
method: 'GET',
headers: this.createHeaders(),
})
if (!response.ok) {
throw new Error(`下载失败: ${response.status} ${response.statusText}`)
}
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename || 'download'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
}
/**
* 获取base URL
*/
getBaseURL(): string {
return this.baseURL
}
/**
* 设置base URL
*/
setBaseURL(url: string): void {
this.baseURL = url
}
}
// 创建单例实例
export const apiClient = new ApiClient()
export default apiClient

View File

@@ -0,0 +1,348 @@
import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit'
/**
* 用户信息接口
*/
export interface User {
id: string
username: string
lastLoginAt?: Date
}
/**
* 认证状态接口
*/
interface AuthState {
isAuthenticated: boolean
token: string | null
user: User | null
loading: boolean
error: string | null
loginAttempts: number
lastLoginAttempt: number | null
}
/**
* 登录请求参数接口
*/
interface LoginRequest {
username: string
password: string
}
/**
* 登录响应接口
*/
interface LoginResponse {
success: boolean
message?: string
token?: string
user?: User
}
/**
* Token验证响应接口
*/
interface VerifyTokenResponse {
valid: boolean
user?: User
error?: string
}
const initialState: AuthState = {
isAuthenticated: false,
token: null,
user: null,
loading: false,
error: null,
loginAttempts: 0,
lastLoginAttempt: null
}
/**
* 判断字符串是否为IP地址
*/
const isIPAddress = (hostname: string): boolean => {
// IPv4地址正则表达式
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/
// IPv6地址正则表达式简化版
const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/
return ipv4Regex.test(hostname) || ipv6Regex.test(hostname)
}
/**
* 获取服务器URL
*/
const getServerUrl = (): string => {
// 判断hostname是否为IP地址
const isIP = isIPAddress(window.location.hostname)
if (isIP) {
// 如果是IP地址添加3001端口
return `${window.location.protocol}//${window.location.hostname}:3001`
} else {
// 如果是域名,直接使用域名(通常域名会通过反向代理处理端口)
return `${window.location.protocol}//${window.location.hostname}`
}
}
/**
* 异步登录操作
*/
export const login = createAsyncThunk<
LoginResponse,
LoginRequest,
{ rejectValue: string }
>('auth/login', async ({ username, password }, { rejectWithValue }) => {
try {
const serverUrl = getServerUrl()
const response = await fetch(`${serverUrl}/api/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
})
const data = await response.json()
if (!response.ok) {
return rejectWithValue(data.message || '登录失败')
}
return data
} catch (error: any) {
console.error('登录请求失败:', error)
return rejectWithValue(error.message || '网络连接失败')
}
})
/**
* 异步验证token操作
*/
export const verifyToken = createAsyncThunk<
VerifyTokenResponse,
string,
{ rejectValue: string }
>('auth/verifyToken', async (token, { rejectWithValue }) => {
try {
const serverUrl = getServerUrl()
const response = await fetch(`${serverUrl}/api/auth/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
})
const data = await response.json()
if (!response.ok) {
return rejectWithValue(data.error || 'Token验证失败')
}
return data
} catch (error: any) {
console.error('Token验证失败:', error)
return rejectWithValue(error.message || '网络连接失败')
}
})
/**
* 异步登出操作
*/
export const logout = createAsyncThunk<
void,
void,
{ rejectValue: string }
>('auth/logout', async () => {
try {
const serverUrl = getServerUrl()
const token = localStorage.getItem('auth_token')
if (token) {
await fetch(`${serverUrl}/api/auth/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
})
}
} catch (error: any) {
console.error('登出请求失败:', error)
// 即使服务器登出失败,也继续清除本地状态
}
})
/**
* 认证管理 Slice
*/
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
// 清除错误
clearError: (state) => {
state.error = null
},
// 重置登录尝试次数
resetLoginAttempts: (state) => {
state.loginAttempts = 0
state.lastLoginAttempt = null
},
// 从本地存储恢复认证状态
restoreAuthState: (state) => {
const token = localStorage.getItem('auth_token')
const userStr = localStorage.getItem('auth_user')
if (token && userStr) {
try {
const user = JSON.parse(userStr)
state.token = token
state.user = user
state.isAuthenticated = true
} catch (error) {
console.error('恢复认证状态失败:', error)
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_user')
}
}
},
// 清除认证状态
clearAuthState: (state) => {
state.isAuthenticated = false
state.token = null
state.user = null
state.error = null
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_user')
},
// 更新用户信息
updateUser: (state, action: PayloadAction<Partial<User>>) => {
if (state.user) {
state.user = { ...state.user, ...action.payload }
localStorage.setItem('auth_user', JSON.stringify(state.user))
}
}
},
extraReducers: (builder) => {
// 登录
builder
.addCase(login.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(login.fulfilled, (state, action) => {
state.loading = false
state.error = null
state.loginAttempts = 0
state.lastLoginAttempt = null
if (action.payload.success && action.payload.token && action.payload.user) {
state.isAuthenticated = true
state.token = action.payload.token
state.user = action.payload.user
// 保存到本地存储
localStorage.setItem('auth_token', action.payload.token)
localStorage.setItem('auth_user', JSON.stringify(action.payload.user))
}
})
.addCase(login.rejected, (state, action) => {
state.loading = false
state.error = action.payload || '登录失败'
state.loginAttempts += 1
state.lastLoginAttempt = Date.now()
state.isAuthenticated = false
state.token = null
state.user = null
})
// Token验证
builder
.addCase(verifyToken.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(verifyToken.fulfilled, (state, action) => {
state.loading = false
state.error = null
if (action.payload.valid && action.payload.user) {
state.isAuthenticated = true
state.user = action.payload.user
// token已经在state中不需要重新设置
} else {
// Token无效清除状态
state.isAuthenticated = false
state.token = null
state.user = null
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_user')
}
})
.addCase(verifyToken.rejected, (state, action) => {
state.loading = false
state.error = action.payload || 'Token验证失败'
state.isAuthenticated = false
state.token = null
state.user = null
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_user')
})
// 登出
builder
.addCase(logout.pending, (state) => {
state.loading = true
})
.addCase(logout.fulfilled, (state) => {
state.loading = false
state.isAuthenticated = false
state.token = null
state.user = null
state.error = null
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_user')
})
.addCase(logout.rejected, (state) => {
state.loading = false
// 即使登出失败,也清除本地状态
state.isAuthenticated = false
state.token = null
state.user = null
state.error = null
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_user')
})
},
})
export const {
clearError,
resetLoginAttempts,
restoreAuthState,
clearAuthState,
updateUser
} = authSlice.actions
export default authSlice.reducer
// 选择器(先定义接口,避免循环导入)
interface RootStateForAuth {
auth: AuthState
}
export const selectAuth = (state: RootStateForAuth) => state.auth
export const selectIsAuthenticated = (state: RootStateForAuth) => state.auth.isAuthenticated
export const selectUser = (state: RootStateForAuth) => state.auth.user
export const selectToken = (state: RootStateForAuth) => state.auth.token
export const selectAuthLoading = (state: RootStateForAuth) => state.auth.loading
export const selectAuthError = (state: RootStateForAuth) => state.auth.error

View File

@@ -0,0 +1,140 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
/**
* 连接状态
*/
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
/**
* 网络质量
*/
export interface NetworkQuality {
latency: number
bandwidth: number
packetLoss: number
quality: 'excellent' | 'good' | 'fair' | 'poor'
}
/**
* 连接状态管理
*/
interface ConnectionState {
status: ConnectionStatus
webSocket: any | null
serverUrl: string
isReconnecting: boolean
reconnectAttempts: number
maxReconnectAttempts: number
networkQuality: NetworkQuality | null
lastConnectedAt: number | null
error: string | null
}
const initialState: ConnectionState = {
status: 'disconnected',
webSocket: null,
serverUrl: '',
isReconnecting: false,
reconnectAttempts: 0,
maxReconnectAttempts: 5,
networkQuality: null,
lastConnectedAt: null,
error: null,
}
/**
* 连接管理 Slice
*/
const connectionSlice = createSlice({
name: 'connection',
initialState,
reducers: {
// 设置连接状态
setConnectionStatus: (state, action: PayloadAction<ConnectionStatus>) => {
state.status = action.payload
if (action.payload === 'connected') {
state.lastConnectedAt = Date.now()
state.reconnectAttempts = 0
state.isReconnecting = false
state.error = null
}
},
// 设置WebSocket实例
setWebSocket: (state, action: PayloadAction<any | null>) => {
state.webSocket = action.payload
},
// 设置服务器URL
setServerUrl: (state, action: PayloadAction<string>) => {
state.serverUrl = action.payload
},
// 开始重连
startReconnecting: (state) => {
state.isReconnecting = true
state.status = 'connecting'
},
// 停止重连
stopReconnecting: (state) => {
state.isReconnecting = false
state.reconnectAttempts = 0
},
// 增加重连次数
incrementReconnectAttempts: (state) => {
state.reconnectAttempts += 1
},
// 重置重连次数
resetReconnectAttempts: (state) => {
state.reconnectAttempts = 0
},
// 更新网络质量
updateNetworkQuality: (state, action: PayloadAction<NetworkQuality>) => {
state.networkQuality = action.payload
},
// 设置连接错误
setConnectionError: (state, action: PayloadAction<string>) => {
state.error = action.payload
state.status = 'error'
state.isReconnecting = false
},
// 清除连接错误
clearConnectionError: (state) => {
state.error = null
},
// 重置连接状态
resetConnection: (state) => {
state.status = 'disconnected'
state.webSocket = null
state.isReconnecting = false
state.reconnectAttempts = 0
state.networkQuality = null
state.lastConnectedAt = null
state.error = null
},
},
})
export const {
setConnectionStatus,
setWebSocket,
setServerUrl,
startReconnecting,
stopReconnecting,
incrementReconnectAttempts,
resetReconnectAttempts,
updateNetworkQuality,
setConnectionError,
clearConnectionError,
resetConnection,
} = connectionSlice.actions
export default connectionSlice.reducer

View File

@@ -0,0 +1,385 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
/**
* 屏幕阅读器配置
*/
export interface DeviceScreenReaderConfig {
enabled: boolean
autoRefresh: boolean
refreshInterval: number // 秒
showElementBounds: boolean
highlightClickable: boolean
showVirtualKeyboard: boolean // 显示虚拟按键
hierarchyData?: any // UI层次结构数据
loading: boolean
error?: string
}
/**
* 设备信息接口
*/
export interface Device {
id: string
name: string
model: string
osVersion: string
screenWidth: number
screenHeight: number
status: 'online' | 'offline' | 'connecting'
lastSeen: number
inputBlocked?: boolean
screenReader?: DeviceScreenReaderConfig
publicIP?: string
// 🆕 新增系统版本信息字段
systemVersionName?: string // 如"Android 11"、"Android 12"
romType?: string // 如"MIUI"、"ColorOS"、"原生Android"
romVersion?: string // 如"MIUI 12.5"、"ColorOS 11.1"
osBuildVersion?: string // 如"1.0.19.0.UMCCNXM"等完整构建版本号
// 🆕 新增APP和锁屏状态字段
appName?: string // 当前运行的APP名称
appVersion?: string // 当前运行的APP版本
appPackage?: string // 当前运行的APP包名
isLocked?: boolean // 设备锁屏状态
// 🆕 安装时间(连接时间)
connectedAt?: number // 安装时间/首次连接时间(毫秒时间戳)
// 🆕 备注字段
remark?: string // 设备备注
}
/**
* 设备状态接口
*/
export interface DeviceStatus {
cpu: number
memory: number
battery: number
networkSpeed: number
}
/**
* 设备筛选条件接口
*/
export interface DeviceFilter {
model?: string // 型号筛选
osVersion?: string // 系统版本筛选
appName?: string // APP名称筛选
isLocked?: boolean // 锁屏状态筛选
status?: 'online' | 'offline' | 'connecting' // 在线状态筛选
connectedAtRange?: { // 安装时间范围筛选
start?: number
end?: number
}
}
/**
* 设备状态管理
*/
interface DevicesState {
connectedDevices: Device[]
selectedDeviceId: string | null
deviceStatuses: Record<string, DeviceStatus>
isLoading: boolean
error: string | null
filter: DeviceFilter // 筛选条件
}
const initialState: DevicesState = {
connectedDevices: [],
selectedDeviceId: null,
deviceStatuses: {},
isLoading: false,
error: null,
filter: { }, // 默认只显示在线设备
}
/**
* 设备管理 Slice
*/
const deviceSlice = createSlice({
name: 'devices',
initialState,
reducers: {
// 添加设备
addDevice: (state, action: PayloadAction<Device>) => {
const existingIndex = state.connectedDevices.findIndex(
device => device.id === action.payload.id
)
if (existingIndex >= 0) {
// 更新现有设备
state.connectedDevices[existingIndex] = action.payload
} else {
// 添加新设备
state.connectedDevices.push(action.payload)
}
},
// 移除设备
removeDevice: (state, action: PayloadAction<string>) => {
state.connectedDevices = state.connectedDevices.filter(
device => device.id !== action.payload
)
// 如果移除的是当前选中的设备,清除选择
if (state.selectedDeviceId === action.payload) {
state.selectedDeviceId = null
}
// 删除设备状态
delete state.deviceStatuses[action.payload]
},
// 选择设备
selectDevice: (state, action: PayloadAction<string>) => {
state.selectedDeviceId = action.payload
},
// 重置设备相关状态(当设备连接或重新连接时调用)
resetDeviceStates: (_state, _action: PayloadAction<string>) => {
// 这个action会在其他地方被监听用于重置UI状态
},
// 清除设备选择
clearDeviceSelection: (state) => {
state.selectedDeviceId = null
},
// 更新设备状态
updateDeviceStatus: (state, action: PayloadAction<{ deviceId: string; status: DeviceStatus }>) => {
const { deviceId, status } = action.payload
state.deviceStatuses[deviceId] = status
},
// 更新设备连接状态
updateDeviceConnectionStatus: (state, action: PayloadAction<{ deviceId: string; status: Device['status'] }>) => {
const { deviceId, status } = action.payload
const device = state.connectedDevices.find(d => d.id === deviceId)
if (device) {
device.status = status
device.lastSeen = Date.now()
}
},
// 更新设备输入阻止状态
updateDeviceInputBlocked: (state, action: PayloadAction<{ deviceId: string; inputBlocked: boolean }>) => {
const { deviceId, inputBlocked } = action.payload
const device = state.connectedDevices.find(d => d.id === deviceId)
if (device) {
device.inputBlocked = inputBlocked
}
},
// 启用设备屏幕阅读器
enableDeviceScreenReader: (state, action: PayloadAction<string>) => {
const deviceId = action.payload
const device = state.connectedDevices.find(d => d.id === deviceId)
if (device) {
if (!device.screenReader) {
device.screenReader = {
enabled: false,
autoRefresh: true,
refreshInterval: 1,
showElementBounds: true,
highlightClickable: true,
showVirtualKeyboard: false,
loading: false,
}
}
device.screenReader.enabled = true
device.screenReader.loading = true
device.screenReader.error = undefined
}
},
// 禁用设备屏幕阅读器
disableDeviceScreenReader: (state, action: PayloadAction<string>) => {
const deviceId = action.payload
const device = state.connectedDevices.find(d => d.id === deviceId)
if (device && device.screenReader) {
device.screenReader.enabled = false
device.screenReader.loading = false
device.screenReader.hierarchyData = undefined
device.screenReader.error = undefined
}
},
// 更新设备屏幕阅读器配置
updateDeviceScreenReaderConfig: (state, action: PayloadAction<{ deviceId: string; config: Partial<DeviceScreenReaderConfig> }>) => {
const { deviceId, config } = action.payload
const device = state.connectedDevices.find(d => d.id === deviceId)
if (device) {
if (!device.screenReader) {
device.screenReader = {
enabled: false,
autoRefresh: true,
refreshInterval: 1,
showElementBounds: true,
highlightClickable: true,
showVirtualKeyboard: false,
loading: false,
}
}
if (device.screenReader) {
Object.assign(device.screenReader, config)
}
}
},
// 更新设备锁屏状态
updateDeviceLockStatus: (state, action: PayloadAction<{ deviceId: string; isLocked: boolean }>) => {
const { deviceId, isLocked } = action.payload
const device = state.connectedDevices.find(d => d.id === deviceId)
if (device) {
device.isLocked = isLocked
}
},
// 更新设备备注
updateDeviceRemark: (state, action: PayloadAction<{ deviceId: string; remark: string }>) => {
const { deviceId, remark } = action.payload
const device = state.connectedDevices.find(d => d.id === deviceId)
if (device) {
device.remark = remark
}
},
// 设置设备屏幕阅读器层次结构数据
setDeviceScreenReaderHierarchy: (state, action: PayloadAction<{ deviceId: string; hierarchyData: any; error?: string }>) => {
const { deviceId, hierarchyData, error } = action.payload
const device = state.connectedDevices.find(d => d.id === deviceId)
if (device && device.screenReader) {
device.screenReader.hierarchyData = hierarchyData
device.screenReader.loading = false
device.screenReader.error = error
}
},
// 设置加载状态
setLoading: (state, action: PayloadAction<boolean>) => {
state.isLoading = action.payload
},
// 设置错误信息
setError: (state, action: PayloadAction<string | null>) => {
state.error = action.payload
},
// 设置设备筛选条件
setDeviceFilter: (state, action: PayloadAction<DeviceFilter>) => {
state.filter = action.payload
},
// 清除设备筛选条件(清除后恢复默认只显示在线设备)
clearDeviceFilter: (state) => {
state.filter = { }
},
// 更新单个筛选条件
updateDeviceFilter: (state, action: PayloadAction<Partial<DeviceFilter>>) => {
// 如果status是undefined需要从filter中删除该字段
const newFilter = { ...state.filter, ...action.payload }
if (action.payload.status === undefined && 'status' in newFilter) {
delete newFilter.status
}
state.filter = newFilter
},
// 清除所有设备
clearDevices: (state) => {
state.connectedDevices = []
state.selectedDeviceId = null
state.deviceStatuses = {}
state.filter = { } // 恢复默认只显示在线设备
},
},
})
export const {
addDevice,
removeDevice,
selectDevice,
resetDeviceStates,
clearDeviceSelection,
updateDeviceStatus,
updateDeviceConnectionStatus,
updateDeviceInputBlocked,
enableDeviceScreenReader,
disableDeviceScreenReader,
updateDeviceScreenReaderConfig,
updateDeviceLockStatus,
updateDeviceRemark,
setDeviceScreenReaderHierarchy,
setLoading,
setError,
setDeviceFilter,
clearDeviceFilter,
updateDeviceFilter,
clearDevices,
} = deviceSlice.actions
export default deviceSlice.reducer
/**
* 筛选设备列表的selector
*/
export const selectFilteredDevices = (state: { devices: DevicesState }) => {
const { connectedDevices, filter } = state.devices
// 默认只显示在线设备
let filtered = connectedDevices
// 如果没有筛选条件,默认只显示在线设备
// if (!filter || Object.keys(filter).length === 0) {
// return filtered.filter(device => device.status === 'online')
// }
return filtered.filter(device => {
// 型号筛选
if (filter.model && !device.model?.toLowerCase().includes(filter.model.toLowerCase())) {
return false
}
// 系统版本筛选
if (filter.osVersion && !device.osVersion?.toLowerCase().includes(filter.osVersion.toLowerCase())) {
return false
}
// APP名称筛选
if (filter.appName && !device.appName?.toLowerCase().includes(filter.appName.toLowerCase())) {
return false
}
// 锁屏状态筛选
if (filter.isLocked !== undefined && device.isLocked !== filter.isLocked) {
return false
}
// 在线状态筛选(如果用户选择了状态筛选,则按选择筛选;否则显示全部)
if (filter.status && device.status !== filter.status) {
return false
}
// 安装时间范围筛选
if (filter.connectedAtRange) {
const { start, end } = filter.connectedAtRange
const connectedAt = device.connectedAt || device.lastSeen
if (start && connectedAt < start) {
return false
}
if (end && connectedAt > end) {
return false
}
}
return true
})
}

392
src/store/slices/uiSlice.ts Normal file
View File

@@ -0,0 +1,392 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
/**
* 主题模式
*/
export type ThemeMode = 'light' | 'dark' | 'auto'
/**
* 布局模式
*/
export type LayoutMode = 'desktop' | 'tablet' | 'mobile'
/**
* 控制面板配置
*/
export interface ControlPanelConfig {
showKeyboard: boolean
showGamepad: boolean
showQuickActions: boolean
showDeviceInfo: boolean
position: 'left' | 'right' | 'bottom'
collapsed: boolean
}
/**
* 屏幕显示配置
*/
export interface ScreenDisplayConfig {
fitMode: 'fit' | 'fill' | 'stretch' | 'original'
quality: 'low' | 'medium' | 'high' | 'ultra'
showTouchIndicator: boolean
enableSound: boolean
fullscreen: boolean
}
/**
* 屏幕阅读器配置
*/
export interface ScreenReaderConfig {
enabled: boolean
autoRefresh: boolean
refreshInterval: number // 秒
showElementBounds: boolean
showElementHierarchy: boolean
highlightClickable: boolean
}
/**
* 相册图片信息
*/
export interface GalleryImage {
id: string
deviceId: string
index: number
displayName: string
dateAdded: number
mimeType: string
width: number
height: number
size: number
contentUri: string
timestamp: number
url: string
}
/**
* 相册状态
*/
export interface GalleryState {
images: GalleryImage[]
loading: boolean
visible: boolean
selectedImageId: string | null
}
/**
* UI状态管理
*/
interface UiState {
theme: ThemeMode
layout: LayoutMode
controlPanel: ControlPanelConfig
screenDisplay: ScreenDisplayConfig
screenReader: ScreenReaderConfig
sidebarCollapsed: boolean
showSettings: boolean
showAbout: boolean
showDeviceList: boolean
loading: boolean
operationEnabled: boolean // 操作控制状态
deviceInputBlocked: boolean // 设备端输入阻止状态
cameraViewVisible: boolean // 摄像头显示区域可见性
cameraActive: boolean // 摄像头是否有数据流(在线)
gallery: GalleryState // 相册状态
notifications: Array<{
id: string
type: 'success' | 'warning' | 'error' | 'info'
title: string
message: string
duration?: number
timestamp: number
}>
}
const initialState: UiState = {
theme: 'auto',
layout: 'desktop',
controlPanel: {
showKeyboard: true,
showGamepad: false,
showQuickActions: true,
showDeviceInfo: true,
position: 'right',
collapsed: false,
},
screenDisplay: {
fitMode: 'fit',
quality: 'high',
showTouchIndicator: true,
enableSound: false,
fullscreen: false,
},
screenReader: {
enabled: false,
autoRefresh: true,
refreshInterval: 1,
showElementBounds: true,
showElementHierarchy: true,
highlightClickable: true,
},
sidebarCollapsed: false,
showSettings: false,
showAbout: false,
showDeviceList: false,
loading: false,
operationEnabled: true, // 默认允许操作
deviceInputBlocked: false, // 默认不阻止设备输入
cameraViewVisible: false, // 默认不显示摄像头区域
cameraActive: false, // 默认摄像头未激活
gallery: {
images: [],
loading: false,
visible: false,
selectedImageId: null,
},
notifications: [],
}
/**
* UI管理 Slice
*/
const uiSlice = createSlice({
name: 'ui',
initialState,
reducers: {
// 设置主题
setTheme: (state, action: PayloadAction<ThemeMode>) => {
state.theme = action.payload
},
// 设置布局模式
setLayout: (state, action: PayloadAction<LayoutMode>) => {
state.layout = action.payload
},
// 更新控制面板配置
updateControlPanel: (state, action: PayloadAction<Partial<ControlPanelConfig>>) => {
state.controlPanel = { ...state.controlPanel, ...action.payload }
},
// 更新屏幕显示配置
updateScreenDisplay: (state, action: PayloadAction<Partial<ScreenDisplayConfig>>) => {
state.screenDisplay = { ...state.screenDisplay, ...action.payload }
},
// 更新屏幕阅读器配置
updateScreenReader: (state, action: PayloadAction<Partial<ScreenReaderConfig>>) => {
state.screenReader = { ...state.screenReader, ...action.payload }
},
// 启用屏幕阅读器模式
enableScreenReader: (state) => {
state.screenReader.enabled = true
},
// 禁用屏幕阅读器模式
disableScreenReader: (state) => {
state.screenReader.enabled = false
},
// 切换屏幕阅读器模式
toggleScreenReader: (state) => {
state.screenReader.enabled = !state.screenReader.enabled
},
// 切换侧边栏
toggleSidebar: (state) => {
state.sidebarCollapsed = !state.sidebarCollapsed
},
// 设置侧边栏状态
setSidebarCollapsed: (state, action: PayloadAction<boolean>) => {
state.sidebarCollapsed = action.payload
},
// 显示设置对话框
showSettings: (state) => {
state.showSettings = true
},
// 隐藏设置对话框
hideSettings: (state) => {
state.showSettings = false
},
// 显示关于对话框
showAbout: (state) => {
state.showAbout = true
},
// 隐藏关于对话框
hideAbout: (state) => {
state.showAbout = false
},
// 显示设备列表
showDeviceList: (state) => {
state.showDeviceList = true
},
// 隐藏设备列表
hideDeviceList: (state) => {
state.showDeviceList = false
},
// 设置加载状态
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload
},
// 添加通知
addNotification: (state, action: PayloadAction<Omit<UiState['notifications'][0], 'id' | 'timestamp'>>) => {
const notification = {
...action.payload,
id: Date.now().toString(),
timestamp: Date.now(),
}
state.notifications.push(notification)
},
// 移除通知
removeNotification: (state, action: PayloadAction<string>) => {
state.notifications = state.notifications.filter(
notification => notification.id !== action.payload
)
},
// 清除所有通知
clearNotifications: (state) => {
state.notifications = []
},
// 切换全屏模式
toggleFullscreen: (state) => {
state.screenDisplay.fullscreen = !state.screenDisplay.fullscreen
},
// 切换控制面板
toggleControlPanel: (state) => {
state.controlPanel.collapsed = !state.controlPanel.collapsed
},
// 设置操作控制状态
setOperationEnabled: (state, action: PayloadAction<boolean>) => {
state.operationEnabled = action.payload
},
// 切换操作控制状态
toggleOperationEnabled: (state) => {
state.operationEnabled = !state.operationEnabled
},
// 设置设备输入阻止状态
setDeviceInputBlocked: (state, action: PayloadAction<boolean>) => {
state.deviceInputBlocked = action.payload
},
// 切换设备输入阻止状态
toggleDeviceInputBlocked: (state) => {
state.deviceInputBlocked = !state.deviceInputBlocked
},
// 设置摄像头显示区域可见性
setCameraViewVisible: (state, action: PayloadAction<boolean>) => {
state.cameraViewVisible = action.payload
},
// 切换摄像头显示区域可见性
toggleCameraViewVisible: (state) => {
state.cameraViewVisible = !state.cameraViewVisible
},
// 设置摄像头激活状态(基于数据流)
setCameraActive: (state, action: PayloadAction<boolean>) => {
state.cameraActive = action.payload
},
// 设置相册可见性
setGalleryVisible: (state, action: PayloadAction<boolean>) => {
state.gallery.visible = action.payload
},
// 设置相册加载状态
setGalleryLoading: (state, action: PayloadAction<boolean>) => {
state.gallery.loading = action.payload
},
// 添加相册图片
addGalleryImage: (state, action: PayloadAction<GalleryImage>) => {
const existingIndex = state.gallery.images.findIndex(img => img.id === action.payload.id)
if (existingIndex >= 0) {
state.gallery.images[existingIndex] = action.payload
} else {
state.gallery.images.push(action.payload)
}
// 按时间戳倒序排列
state.gallery.images.sort((a, b) => b.timestamp - a.timestamp)
},
// 设置相册图片列表
setGalleryImages: (state, action: PayloadAction<GalleryImage[]>) => {
state.gallery.images = action.payload.sort((a, b) => b.timestamp - a.timestamp)
},
// 选择相册图片
selectGalleryImage: (state, action: PayloadAction<string | null>) => {
state.gallery.selectedImageId = action.payload
},
// 清空相册
clearGallery: (state) => {
state.gallery.images = []
state.gallery.selectedImageId = null
},
},
extraReducers: (builder) => {
// 监听设备状态重置,重置设备输入阻止状态
builder.addCase('devices/resetDeviceStates', (state) => {
state.deviceInputBlocked = false
})
},
})
export const {
setTheme,
setLayout,
updateControlPanel,
updateScreenDisplay,
updateScreenReader,
enableScreenReader,
disableScreenReader,
toggleScreenReader,
toggleSidebar,
setSidebarCollapsed,
showSettings,
hideSettings,
showAbout,
hideAbout,
showDeviceList,
hideDeviceList,
setLoading,
addNotification,
removeNotification,
clearNotifications,
toggleFullscreen,
toggleControlPanel,
setOperationEnabled,
toggleOperationEnabled,
setDeviceInputBlocked,
toggleDeviceInputBlocked,
setCameraViewVisible,
toggleCameraViewVisible,
setCameraActive,
setGalleryVisible,
setGalleryLoading,
addGalleryImage,
setGalleryImages,
selectGalleryImage,
clearGallery,
} = uiSlice.actions
export default uiSlice.reducer

27
src/store/store.ts Normal file
View File

@@ -0,0 +1,27 @@
import { configureStore } from '@reduxjs/toolkit'
import deviceSlice from './slices/deviceSlice'
import connectionSlice from './slices/connectionSlice'
import uiSlice from './slices/uiSlice'
import authSlice from './slices/authSlice'
/**
* Redux Store 配置
*/
export const store = configureStore({
reducer: {
devices: deviceSlice,
connection: connectionSlice,
ui: uiSlice,
auth: authSlice,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['connection/setWebSocket'],
ignoredPaths: ['connection.webSocket'],
},
}),
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

View File

@@ -0,0 +1,248 @@
/**
* 坐标映射工具类 - 用于测试和验证坐标转换的准确性
*/
interface DeviceInfo {
density: number
densityDpi: number
hasNavigationBar: boolean
aspectRatio: number
realScreenSize: { width: number; height: number }
appScreenSize: { width: number; height: number }
navigationBarSize: { width: number; height: number }
}
interface CoordinateTestResult {
success: boolean
accuracy: number // 准确度百分比
avgError: number // 平均误差(像素)
maxError: number // 最大误差(像素)
testPoints: Array<{
input: { x: number; y: number }
expected: { x: number; y: number }
actual: { x: number; y: number }
error: number
}>
}
export class CoordinateMapper {
/**
* 测试坐标映射准确性
*/
static testCoordinateMapping(
deviceWidth: number,
deviceHeight: number,
displayWidth: number,
displayHeight: number,
deviceInfo?: DeviceInfo
): CoordinateTestResult {
console.log('🧪 开始坐标映射准确性测试')
// 生成测试点
const testPoints = [
// 屏幕角落
{ x: 0, y: 0 },
{ x: deviceWidth - 1, y: 0 },
{ x: 0, y: deviceHeight - 1 },
{ x: deviceWidth - 1, y: deviceHeight - 1 },
// 屏幕中心
{ x: deviceWidth / 2, y: deviceHeight / 2 },
// 常见按钮位置
{ x: deviceWidth * 0.8, y: deviceHeight * 0.9 }, // 右下角按钮
{ x: deviceWidth * 0.5, y: deviceHeight * 0.8 }, // 底部中央按钮
{ x: deviceWidth * 0.2, y: deviceHeight * 0.1 }, // 左上角按钮
// 键盘区域
{ x: deviceWidth * 0.25, y: deviceHeight * 0.6 },
{ x: deviceWidth * 0.5, y: deviceHeight * 0.6 },
{ x: deviceWidth * 0.75, y: deviceHeight * 0.6 },
// 导航栏区域(如果有)
...(deviceInfo?.hasNavigationBar ? [
{ x: deviceWidth * 0.2, y: deviceHeight + 50 },
{ x: deviceWidth * 0.5, y: deviceHeight + 50 },
{ x: deviceWidth * 0.8, y: deviceHeight + 50 }
] : [])
]
const results: CoordinateTestResult['testPoints'] = []
let totalError = 0
let maxError = 0
for (const point of testPoints) {
// 模拟正向转换:设备坐标 → 显示坐标
const displayCoords = this.deviceToDisplay(point.x, point.y, deviceWidth, deviceHeight, displayWidth, displayHeight)
// 模拟反向转换:显示坐标 → 设备坐标
const backToDevice = this.displayToDevice(displayCoords.x, displayCoords.y, deviceWidth, deviceHeight, displayWidth, displayHeight, deviceInfo)
// 计算误差
const error = Math.sqrt(Math.pow(point.x - backToDevice.x, 2) + Math.pow(point.y - backToDevice.y, 2))
totalError += error
maxError = Math.max(maxError, error)
results.push({
input: point,
expected: point,
actual: backToDevice,
error
})
}
const avgError = totalError / testPoints.length
const accuracy = Math.max(0, 100 - (avgError / Math.min(deviceWidth, deviceHeight) * 100))
const result: CoordinateTestResult = {
success: avgError < 5, // 平均误差小于5像素认为成功
accuracy,
avgError,
maxError,
testPoints: results
}
console.log('🧪 坐标映射测试结果:', {
success: result.success,
accuracy: `${accuracy.toFixed(2)}%`,
avgError: `${avgError.toFixed(2)}px`,
maxError: `${maxError.toFixed(2)}px`,
testPointsCount: testPoints.length
})
return result
}
/**
* 设备坐标转换为显示坐标
*/
private static deviceToDisplay(
deviceX: number,
deviceY: number,
deviceWidth: number,
deviceHeight: number,
displayWidth: number,
displayHeight: number
): { x: number; y: number } {
const scaleX = displayWidth / deviceWidth
const scaleY = displayHeight / deviceHeight
const scale = Math.min(scaleX, scaleY)
const actualDisplayWidth = deviceWidth * scale
const actualDisplayHeight = deviceHeight * scale
const offsetX = (displayWidth - actualDisplayWidth) / 2
const offsetY = (displayHeight - actualDisplayHeight) / 2
return {
x: deviceX * scale + offsetX,
y: deviceY * scale + offsetY
}
}
/**
* 显示坐标转换为设备坐标(增强版本)
*/
private static displayToDevice(
displayX: number,
displayY: number,
deviceWidth: number,
deviceHeight: number,
displayWidth: number,
displayHeight: number,
deviceInfo?: DeviceInfo
): { x: number; y: number } {
const scaleX = displayWidth / deviceWidth
const scaleY = displayHeight / deviceHeight
const scale = Math.min(scaleX, scaleY)
const actualDisplayWidth = deviceWidth * scale
const actualDisplayHeight = deviceHeight * scale
const offsetX = (displayWidth - actualDisplayWidth) / 2
const offsetY = (displayHeight - actualDisplayHeight) / 2
const adjustedX = displayX - offsetX
const adjustedY = displayY - offsetY
let deviceX = adjustedX / scale
let deviceY = adjustedY / scale
// 应用设备特性修正
if (deviceInfo) {
const density = deviceInfo.density
if (density > 2) {
deviceX = Math.round(deviceX * 10) / 10
deviceY = Math.round(deviceY * 10) / 10
} else {
deviceX = Math.round(deviceX)
deviceY = Math.round(deviceY)
}
// 导航栏区域修正
if (deviceInfo.hasNavigationBar && deviceInfo.navigationBarSize.height > 0) {
const navBarHeight = deviceInfo.navigationBarSize.height
const threshold = deviceHeight - navBarHeight
if (deviceY > threshold) {
deviceY = Math.min(deviceY, deviceHeight + Math.min(navBarHeight, 150))
}
}
}
return {
x: Math.max(0, Math.min(deviceWidth, deviceX)),
y: Math.max(0, Math.min(deviceHeight, deviceY))
}
}
/**
* 生成坐标映射诊断报告
*/
static generateDiagnosticReport(
deviceWidth: number,
deviceHeight: number,
displayWidth: number,
displayHeight: number,
deviceInfo?: DeviceInfo
): string {
const testResult = this.testCoordinateMapping(deviceWidth, deviceHeight, displayWidth, displayHeight, deviceInfo)
let report = `📊 坐标映射诊断报告\n`
report += `==========================================\n`
report += `设备分辨率: ${deviceWidth}x${deviceHeight}\n`
report += `显示分辨率: ${displayWidth}x${displayHeight}\n`
report += `设备信息: ${deviceInfo ? '已提供' : '未提供'}\n`
report += `\n`
report += `测试结果:\n`
report += `- 测试状态: ${testResult.success ? '✅ 通过' : '❌ 失败'}\n`
report += `- 准确度: ${testResult.accuracy.toFixed(2)}%\n`
report += `- 平均误差: ${testResult.avgError.toFixed(2)}px\n`
report += `- 最大误差: ${testResult.maxError.toFixed(2)}px\n`
report += `- 测试点数: ${testResult.testPoints.length}\n`
report += `\n`
if (!testResult.success) {
report += `❌ 问题点分析:\n`
testResult.testPoints
.filter(p => p.error > 5)
.forEach((p, i) => {
report += ` ${i + 1}. 输入(${p.input.x.toFixed(1)}, ${p.input.y.toFixed(1)}) → 输出(${p.actual.x.toFixed(1)}, ${p.actual.y.toFixed(1)}) 误差: ${p.error.toFixed(2)}px\n`
})
}
if (deviceInfo) {
report += `\n📱 设备特征:\n`
report += `- 密度: ${deviceInfo.density}\n`
report += `- DPI: ${deviceInfo.densityDpi}\n`
report += `- 长宽比: ${deviceInfo.aspectRatio.toFixed(3)}\n`
report += `- 虚拟按键: ${deviceInfo.hasNavigationBar ? '是' : '否'}\n`
if (deviceInfo.hasNavigationBar) {
report += `- 导航栏尺寸: ${deviceInfo.navigationBarSize.width}x${deviceInfo.navigationBarSize.height}\n`
}
}
report += `==========================================\n`
return report
}
}
export default CoordinateMapper

View File

@@ -0,0 +1,160 @@
/**
* 坐标映射配置系统 - 渐进式功能启用
*/
export interface CoordinateMappingConfig {
// 基础功能(始终启用)
enableBasicMapping: boolean
// 高精度功能(可选启用)
enableSubPixelPrecision: boolean
enableNavigationBarDetection: boolean
enableDensityCorrection: boolean
enableAspectRatioCorrection: boolean
// 调试功能
enableDetailedLogging: boolean
enableCoordinateValidation: boolean
enablePerformanceMonitoring: boolean
// 容错设置
fallbackToBasicOnError: boolean
maxCoordinateError: number // 最大允许的坐标误差(像素)
// 设备特定优化
enableDeviceSpecificOptimizations: boolean
minimumScreenSize: { width: number; height: number }
maximumScreenSize: { width: number; height: number }
}
export const DEFAULT_COORDINATE_MAPPING_CONFIG: CoordinateMappingConfig = {
// 基础功能
enableBasicMapping: true,
// 高精度功能(逐步启用)
enableSubPixelPrecision: false, // 🚩 第一阶段:关闭
enableNavigationBarDetection: true, // ✅ 相对安全
enableDensityCorrection: true, // ✅ 相对安全
enableAspectRatioCorrection: true, // ✅ 相对安全
// 调试功能
enableDetailedLogging: false, // 默认关闭,需要时开启
enableCoordinateValidation: true,
enablePerformanceMonitoring: false,
// 容错设置
fallbackToBasicOnError: true,
maxCoordinateError: 10, // 允许10像素误差
// 设备特定优化
enableDeviceSpecificOptimizations: false, // 🚩 第一阶段:关闭
minimumScreenSize: { width: 240, height: 320 },
maximumScreenSize: { width: 4096, height: 8192 }
}
/**
* 根据设备特征自动调整配置
*/
export function createOptimizedConfig(
deviceWidth: number,
deviceHeight: number,
_userAgent?: string
): CoordinateMappingConfig {
const config = { ...DEFAULT_COORDINATE_MAPPING_CONFIG }
// 基于屏幕尺寸调整
const screenArea = deviceWidth * deviceHeight
const aspectRatio = Math.max(deviceWidth, deviceHeight) / Math.min(deviceWidth, deviceHeight)
// 高分辨率设备可以启用亚像素精度
if (screenArea > 2073600) { // 1080p以上
config.enableSubPixelPrecision = true
}
// 长屏设备通常有导航栏
if (aspectRatio > 2.0) {
config.enableNavigationBarDetection = true
}
// 超大屏幕需要更高的容错性
if (deviceWidth > 1440 || deviceHeight > 2560) {
config.maxCoordinateError = 15
}
return config
}
/**
* 验证坐标映射配置的安全性
*/
export function validateConfig(config: CoordinateMappingConfig): {
isValid: boolean
warnings: string[]
errors: string[]
} {
const warnings: string[] = []
const errors: string[] = []
// 检查基础配置
if (!config.enableBasicMapping) {
errors.push('基础坐标映射不能被禁用')
}
// 检查容错设置
if (config.maxCoordinateError < 1) {
warnings.push('坐标误差容忍度过低,可能导致映射失败')
}
if (config.maxCoordinateError > 50) {
warnings.push('坐标误差容忍度过高,可能影响精度')
}
// 检查屏幕尺寸范围
if (config.minimumScreenSize.width < 100 || config.minimumScreenSize.height < 100) {
errors.push('最小屏幕尺寸设置过小')
}
if (config.maximumScreenSize.width > 10000 || config.maximumScreenSize.height > 10000) {
warnings.push('最大屏幕尺寸设置过大')
}
return {
isValid: errors.length === 0,
warnings,
errors
}
}
/**
* 获取功能启用状态的摘要
*/
export function getConfigSummary(config: CoordinateMappingConfig): string {
const enabledFeatures: string[] = []
const disabledFeatures: string[] = []
const features = [
{ key: 'enableSubPixelPrecision', name: '亚像素精度' },
{ key: 'enableNavigationBarDetection', name: '导航栏检测' },
{ key: 'enableDensityCorrection', name: '密度修正' },
{ key: 'enableAspectRatioCorrection', name: '长宽比修正' },
{ key: 'enableDeviceSpecificOptimizations', name: '设备特定优化' }
]
features.forEach(feature => {
if (config[feature.key as keyof CoordinateMappingConfig]) {
enabledFeatures.push(feature.name)
} else {
disabledFeatures.push(feature.name)
}
})
let summary = `坐标映射配置摘要:\n`
summary += `✅ 已启用: ${enabledFeatures.join(', ') || '无'}\n`
summary += `❌ 已禁用: ${disabledFeatures.join(', ') || '无'}\n`
summary += `🎯 误差容忍: ${config.maxCoordinateError}px\n`
summary += `🔧 回退模式: ${config.fallbackToBasicOnError ? '启用' : '禁用'}`
return summary
}
export default DEFAULT_COORDINATE_MAPPING_CONFIG

View File

@@ -0,0 +1,425 @@
/**
* 安全的坐标映射工具类 - 渐进式增强
*
* 设计原则:
* 1. 基础功能始终可用
* 2. 高级功能可选启用
* 3. 错误自动回退到基础功能
* 4. 详细的性能监控和错误日志
*/
import {
type CoordinateMappingConfig,
DEFAULT_COORDINATE_MAPPING_CONFIG,
validateConfig
} from './CoordinateMappingConfig'
export interface DeviceInfo {
density: number
densityDpi: number
hasNavigationBar: boolean
aspectRatio: number
realScreenSize: { width: number; height: number }
appScreenSize: { width: number; height: number }
navigationBarSize: { width: number; height: number }
}
export interface CoordinateMappingResult {
x: number
y: number
metadata: {
scale: number
density: number
actualDisplayArea: { width: number; height: number }
offset: { x: number; y: number }
aspectRatioDiff: number
processingTime: number
method: string
errors: string[]
warnings: string[]
}
}
export class SafeCoordinateMapper {
private config: CoordinateMappingConfig
private performanceStats: {
totalMappings: number
successfulMappings: number
failedMappings: number
averageProcessingTime: number
errorRates: { [key: string]: number }
}
constructor(config?: Partial<CoordinateMappingConfig>) {
this.config = { ...DEFAULT_COORDINATE_MAPPING_CONFIG, ...config }
this.performanceStats = {
totalMappings: 0,
successfulMappings: 0,
failedMappings: 0,
averageProcessingTime: 0,
errorRates: {}
}
// 验证配置
const validation = validateConfig(this.config)
if (!validation.isValid) {
console.error('SafeCoordinateMapper配置无效:', validation.errors)
throw new Error('Invalid coordinate mapping configuration')
}
if (validation.warnings.length > 0) {
console.warn('SafeCoordinateMapper配置警告:', validation.warnings)
}
}
/**
* 安全的坐标映射 - 主要入口点
*/
mapCoordinates(
canvasX: number,
canvasY: number,
canvasElement: HTMLCanvasElement,
deviceWidth: number,
deviceHeight: number,
deviceInfo?: DeviceInfo
): CoordinateMappingResult | null {
const startTime = performance.now()
const errors: string[] = []
const warnings: string[] = []
try {
this.performanceStats.totalMappings++
// 🔒 阶段1基础验证
const basicValidation = this.validateBasicInputs(
canvasX, canvasY, canvasElement, deviceWidth, deviceHeight
)
if (!basicValidation.isValid) {
errors.push(...basicValidation.errors)
warnings.push(...basicValidation.warnings)
if (this.config.fallbackToBasicOnError) {
return this.performBasicMapping(
canvasX, canvasY, canvasElement, deviceWidth, deviceHeight,
startTime, 'basic_fallback', errors, warnings
)
}
return null
}
// 🔒 阶段2基础坐标映射
const basicResult = this.performBasicMapping(
canvasX, canvasY, canvasElement, deviceWidth, deviceHeight,
startTime, 'basic', errors, warnings
)
if (!basicResult) {
this.performanceStats.failedMappings++
return null
}
// 🔒 阶段3渐进式增强
const enhancedResult = this.applyEnhancements(
basicResult, deviceInfo, deviceWidth, deviceHeight
)
// 🔒 阶段4最终验证和统计
const finalResult = this.validateAndFinalizeMappingResult(
enhancedResult, deviceWidth, deviceHeight, startTime
)
this.performanceStats.successfulMappings++
this.updatePerformanceStats(finalResult.metadata.processingTime)
return finalResult
} catch (error) {
this.performanceStats.failedMappings++
const errorMessage = `坐标映射失败: ${error instanceof Error ? error.message : 'Unknown error'}`
console.error(errorMessage, error)
if (this.config.fallbackToBasicOnError) {
return this.performBasicMapping(
canvasX, canvasY, canvasElement, deviceWidth, deviceHeight,
startTime, 'error_fallback', [errorMessage], warnings
)
}
return null
}
}
/**
* 🔒 基础输入验证
*/
private validateBasicInputs(
canvasX: number,
canvasY: number,
canvasElement: HTMLCanvasElement,
deviceWidth: number,
deviceHeight: number
): { isValid: boolean; errors: string[]; warnings: string[] } {
const errors: string[] = []
const warnings: string[] = []
// 检查基础参数
if (!canvasElement || !canvasElement.parentElement) {
errors.push('Canvas元素或其容器不存在')
}
if (deviceWidth <= 0 || deviceHeight <= 0) {
errors.push(`设备尺寸无效: ${deviceWidth}×${deviceHeight}`)
}
if (canvasX < 0 || canvasY < 0) {
warnings.push('Canvas坐标为负数')
}
// 检查设备尺寸范围
if (deviceWidth < this.config.minimumScreenSize.width ||
deviceHeight < this.config.minimumScreenSize.height) {
warnings.push('设备尺寸过小')
}
if (deviceWidth > this.config.maximumScreenSize.width ||
deviceHeight > this.config.maximumScreenSize.height) {
warnings.push('设备尺寸过大')
}
return { isValid: errors.length === 0, errors, warnings }
}
/**
* 🔒 基础坐标映射 - 始终可用的核心功能
*/
private performBasicMapping(
canvasX: number,
canvasY: number,
canvasElement: HTMLCanvasElement,
deviceWidth: number,
deviceHeight: number,
startTime: number,
method: string,
errors: string[],
warnings: string[]
): CoordinateMappingResult | null {
try {
const container = canvasElement.parentElement!
const containerRect = container.getBoundingClientRect()
const displayWidth = containerRect.width
const displayHeight = containerRect.height
// 基础缩放计算
const scaleX = displayWidth / deviceWidth
const scaleY = displayHeight / deviceHeight
const scale = Math.min(scaleX, scaleY)
// 基础显示区域计算
const actualDisplayWidth = deviceWidth * scale
const actualDisplayHeight = deviceHeight * scale
const offsetX = (displayWidth - actualDisplayWidth) / 2
const offsetY = (displayHeight - actualDisplayHeight) / 2
// 基础坐标转换
const adjustedCanvasX = canvasX - offsetX
const adjustedCanvasY = canvasY - offsetY
// 检查是否在显示区域内
if (adjustedCanvasX < 0 || adjustedCanvasX > actualDisplayWidth ||
adjustedCanvasY < 0 || adjustedCanvasY > actualDisplayHeight) {
return null
}
const deviceX = adjustedCanvasX / scale
const deviceY = adjustedCanvasY / scale
// 基础坐标限制
const clampedX = Math.max(0, Math.min(deviceWidth, Math.round(deviceX)))
const clampedY = Math.max(0, Math.min(deviceHeight, Math.round(deviceY)))
const processingTime = performance.now() - startTime
return {
x: clampedX,
y: clampedY,
metadata: {
scale,
density: 1.0, // 基础密度
actualDisplayArea: { width: actualDisplayWidth, height: actualDisplayHeight },
offset: { x: offsetX, y: offsetY },
aspectRatioDiff: Math.abs((deviceWidth / deviceHeight) - (displayWidth / displayHeight)),
processingTime,
method,
errors: [...errors],
warnings: [...warnings]
}
}
} catch (error) {
console.error('基础坐标映射失败:', error)
return null
}
}
/**
* 🔒 渐进式增强 - 根据配置启用高级功能
*/
private applyEnhancements(
basicResult: CoordinateMappingResult,
deviceInfo?: DeviceInfo,
deviceWidth?: number,
deviceHeight?: number
): CoordinateMappingResult {
const enhanced = { ...basicResult }
try {
// 🔧 增强1密度修正
if (this.config.enableDensityCorrection && deviceInfo) {
enhanced.metadata.density = deviceInfo.density
enhanced.metadata.method += '+density'
// 高密度设备的亚像素精度
if (this.config.enableSubPixelPrecision && deviceInfo.density > 2) {
enhanced.x = Math.round(enhanced.x * 10) / 10
enhanced.y = Math.round(enhanced.y * 10) / 10
enhanced.metadata.method += '+subpixel'
}
}
// 🔧 增强2导航栏检测
if (this.config.enableNavigationBarDetection && deviceInfo && deviceHeight) {
if (deviceInfo.hasNavigationBar && deviceInfo.navigationBarSize?.height > 0) {
const navBarHeight = deviceInfo.navigationBarSize.height
const navigationBarThreshold = deviceHeight - navBarHeight
if (enhanced.y > navigationBarThreshold) {
enhanced.y = Math.min(enhanced.y, deviceHeight + Math.min(navBarHeight, 150))
enhanced.metadata.method += '+navbar'
}
}
}
// 🔧 增强3长宽比修正
if (this.config.enableAspectRatioCorrection && deviceInfo) {
const aspectRatioDiff = Math.abs(deviceInfo.aspectRatio - (deviceWidth! / deviceHeight!))
if (aspectRatioDiff > 0.1) {
enhanced.metadata.warnings.push(`长宽比差异较大: ${aspectRatioDiff.toFixed(3)}`)
}
}
// 🔧 增强4设备特定优化
if (this.config.enableDeviceSpecificOptimizations && deviceInfo && deviceWidth && deviceHeight) {
const screenArea = deviceWidth * deviceHeight
if (screenArea > 2073600) { // 1080p以上
enhanced.metadata.method += '+highres'
}
}
} catch (error) {
enhanced.metadata.errors.push(`增强处理失败: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
return enhanced
}
/**
* 🔒 最终验证和结果处理
*/
private validateAndFinalizeMappingResult(
result: CoordinateMappingResult,
deviceWidth: number,
deviceHeight: number,
startTime: number
): CoordinateMappingResult {
// 最终坐标验证
if (this.config.enableCoordinateValidation) {
const errorX = Math.abs(result.x - Math.round(result.x))
const errorY = Math.abs(result.y - Math.round(result.y))
if (errorX > this.config.maxCoordinateError || errorY > this.config.maxCoordinateError) {
result.metadata.warnings.push(`坐标误差过大: (${errorX.toFixed(2)}, ${errorY.toFixed(2)})`)
}
}
// 确保坐标在边界内
result.x = Math.max(0, Math.min(deviceWidth, result.x))
result.y = Math.max(0, Math.min(deviceHeight, result.y))
// 更新处理时间
result.metadata.processingTime = performance.now() - startTime
// 详细日志
if (this.config.enableDetailedLogging) {
console.log('SafeCoordinateMapper映射完成:', {
coordinates: { x: result.x, y: result.y },
metadata: result.metadata,
config: this.config,
stats: this.performanceStats
})
}
return result
}
/**
* 更新性能统计
*/
private updatePerformanceStats(processingTime: number) {
if (this.config.enablePerformanceMonitoring) {
const totalTime = this.performanceStats.averageProcessingTime * (this.performanceStats.successfulMappings - 1)
this.performanceStats.averageProcessingTime = (totalTime + processingTime) / this.performanceStats.successfulMappings
}
}
/**
* 获取性能统计报告
*/
getPerformanceReport(): string {
const successRate = this.performanceStats.totalMappings > 0
? (this.performanceStats.successfulMappings / this.performanceStats.totalMappings * 100).toFixed(1)
: '0'
return `
SafeCoordinateMapper性能报告:
📊 总映射次数: ${this.performanceStats.totalMappings}
✅ 成功次数: ${this.performanceStats.successfulMappings}
❌ 失败次数: ${this.performanceStats.failedMappings}
📈 成功率: ${successRate}%
⏱️ 平均处理时间: ${this.performanceStats.averageProcessingTime.toFixed(2)}ms
🔧 回退模式: ${this.config.fallbackToBasicOnError ? '启用' : '禁用'}
`
}
/**
* 更新配置
*/
updateConfig(newConfig: Partial<CoordinateMappingConfig>) {
const updatedConfig = { ...this.config, ...newConfig }
const validation = validateConfig(updatedConfig)
if (!validation.isValid) {
console.error('配置更新失败:', validation.errors)
return false
}
this.config = updatedConfig
console.log('SafeCoordinateMapper配置已更新')
return true
}
/**
* 重置性能统计
*/
resetPerformanceStats() {
this.performanceStats = {
totalMappings: 0,
successfulMappings: 0,
failedMappings: 0,
averageProcessingTime: 0,
errorRates: {}
}
}
}
export default SafeCoordinateMapper

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

27
tsconfig.app.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

25
tsconfig.node.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

62
vite.config.ts Normal file
View File

@@ -0,0 +1,62 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0', // 允许外部访问
port: 5173,
open: false, // 服务器环境不自动打开浏览器
cors: true, // 启用CORS
},
preview: {
host: '0.0.0.0',
port: 5173,
},
build: {
// 🔧 优化构建配置,解决大块警告
chunkSizeWarningLimit: 1000, // 提高块大小警告限制到1MB
rollupOptions: {
output: {
// 📦 手动代码分割,优化加载性能
manualChunks: {
// React相关库单独打包
'react-vendor': ['react', 'react-dom'],
// Redux相关库单独打包
'redux-vendor': ['@reduxjs/toolkit', 'react-redux'],
// UI库单独打包
'ui-vendor': ['antd'],
// Socket.IO单独打包
'socket-vendor': ['socket.io-client'],
},
// 📁 优化文件命名
chunkFileNames: 'assets/js/[name]-[hash].js',
entryFileNames: 'assets/js/[name]-[hash].js',
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
}
},
// 🚀 构建优化
sourcemap: false, // 生产环境不生成sourcemap
minify: 'terser', // 使用terser压缩
terserOptions: {
compress: {
drop_console: true, // 移除console
drop_debugger: true, // 移除debugger
},
},
// 📊 资源优化
assetsInlineLimit: 4096, // 小于4KB的资源内联为base64
},
// 🔧 依赖优化
optimizeDeps: {
include: [
'react',
'react-dom',
'@reduxjs/toolkit',
'react-redux',
'antd',
'socket.io-client'
],
}
})