This commit is contained in:
wdvipa
2026-02-09 16:33:52 +08:00
parent 52c6322a24
commit 28040495c8
63 changed files with 26743 additions and 301 deletions

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,240 @@
import React, { useEffect } from 'react'
import { Form, Select, DatePicker, Button, Space } from 'antd'
import { SearchOutlined, ClearOutlined, FilterOutlined } from '@ant-design/icons'
import { useDispatch, useSelector } from 'react-redux'
import type { RootState, AppDispatch } from '../../store/store'
import type { DeviceFilter } from '../../store/slices/deviceSlice'
import { updateDeviceFilter, clearDeviceFilter } from '../../store/slices/deviceSlice'
import dayjs from 'dayjs'
const { Option } = Select
const { RangePicker } = DatePicker
interface DeviceFilterProps {
onFilterChange?: (filter: DeviceFilter) => void
style?: React.CSSProperties
}
/**
* 设备筛选组件
*/
const DeviceFilterComponent: React.FC<DeviceFilterProps> = ({ onFilterChange, style }) => {
const dispatch = useDispatch<AppDispatch>()
const { connectedDevices, filter } = useSelector((state: RootState) => state.devices)
const [form] = Form.useForm()
// 获取所有可选的选项
const getUniqueValues = (key: keyof DeviceFilter) => {
const values = connectedDevices
.map(device => device[key as keyof typeof device])
.filter((value): value is string => typeof value === 'string' && value !== '')
return Array.from(new Set(values)).sort()
}
// 获取所有型号
const modelOptions = getUniqueValues('model')
// 获取所有系统版本
const osVersionOptions = getUniqueValues('osVersion')
// 获取所有APP名称
const appNameOptions = getUniqueValues('appName')
// 初始化表单值
useEffect(() => {
form.setFieldsValue({
model: filter.model,
osVersion: filter.osVersion,
appName: filter.appName,
isLocked: filter.isLocked,
status: filter.status || '', // 如果没有status筛选默认为空字符串全部
connectedAtRange: filter.connectedAtRange ? [
filter.connectedAtRange.start ? dayjs(filter.connectedAtRange.start) : null,
filter.connectedAtRange.end ? dayjs(filter.connectedAtRange.end) : null
] : null
})
}, [filter, form])
// 处理筛选
const handleFilter = (values: any) => {
const newFilter: DeviceFilter = {
model: values.model,
osVersion: values.osVersion,
appName: values.appName,
isLocked: values.isLocked,
// status为空字符串表示"全部"不设置status字段
status: values.status && values.status !== '' ? values.status as 'online' | 'offline' : undefined,
connectedAtRange: values.connectedAtRange ? {
start: values.connectedAtRange[0]?.valueOf(),
end: values.connectedAtRange[1]?.valueOf()
} : undefined
}
// 移除空值
Object.keys(newFilter).forEach(key => {
if (newFilter[key as keyof DeviceFilter] === undefined ||
newFilter[key as keyof DeviceFilter] === '') {
delete newFilter[key as keyof DeviceFilter]
}
})
dispatch(updateDeviceFilter(newFilter))
onFilterChange?.(newFilter)
}
// 清除筛选
const handleClear = () => {
form.resetFields()
dispatch(clearDeviceFilter())
onFilterChange?.({})
}
// 获取筛选结果数量
const getFilteredCount = () => {
const hasFilter = Object.keys(filter).length > 0
return hasFilter ? '已筛选' : '全部'
}
return (
<div style={{
background: 'white',
borderRadius: '8px',
padding: '16px',
marginBottom: '16px',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
...style
}}>
<Form
form={form}
layout="inline"
onFinish={handleFilter}
size="small"
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
flexWrap: 'wrap'
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginRight: '16px',
fontWeight: 500,
color: '#262626'
}}>
<FilterOutlined style={{ color: '#1890ff' }} />
<span style={{ fontSize: '12px', color: '#666' }}>
({getFilteredCount()})
</span>
</div>
<Form.Item name="model" style={{ marginBottom: 0 }}>
<Select
placeholder="型号"
allowClear
showSearch
style={{ width: 120 }}
filterOption={(input, option) => {
const label = String((option as any)?.children ?? (option as any)?.label ?? '')
return label.toLowerCase().includes(input.toLowerCase())
}}
>
{modelOptions.map(model => (
<Option key={model} value={model}>{model}</Option>
))}
</Select>
</Form.Item>
<Form.Item name="osVersion" style={{ marginBottom: 0 }}>
<Select
placeholder="系统版本"
allowClear
showSearch
style={{ width: 120 }}
filterOption={(input, option) => {
const label = String((option as any)?.children ?? (option as any)?.label ?? '')
return label.toLowerCase().includes(input.toLowerCase())
}}
>
{osVersionOptions.map(version => (
<Option key={version} value={version}>Android {version}</Option>
))}
</Select>
</Form.Item>
<Form.Item name="appName" style={{ marginBottom: 0 }}>
<Select
placeholder="APP名称"
allowClear
showSearch
style={{ width: 120 }}
filterOption={(input, option) => {
const label = String((option as any)?.children ?? (option as any)?.label ?? '')
return label.toLowerCase().includes(input.toLowerCase())
}}
>
{appNameOptions.map(appName => (
<Option key={appName} value={appName}>{appName}</Option>
))}
</Select>
</Form.Item>
<Form.Item name="isLocked" style={{ marginBottom: 0 }}>
<Select
placeholder="锁屏状态"
allowClear
style={{ width: 100 }}
>
<Option value={false}></Option>
<Option value={true}></Option>
</Select>
</Form.Item>
<Form.Item name="status" style={{ marginBottom: 0 }}>
<Select
placeholder="在线状态"
allowClear={false}
style={{ width: 120 }}
>
<Option value=""></Option>
<Option value="online">线</Option>
<Option value="offline">线</Option>
</Select>
</Form.Item>
<Form.Item name="connectedAtRange" style={{ marginBottom: 0 }}>
<RangePicker
placeholder={['开始', '结束']}
style={{ width: 240 }}
showTime
format="MM-DD HH:mm"
size="small"
/>
</Form.Item>
<Space style={{ marginLeft: 'auto' }}>
<Button
type="primary"
icon={<SearchOutlined />}
onClick={() => form.submit()}
size="small"
>
</Button>
<Button
icon={<ClearOutlined />}
onClick={handleClear}
size="small"
>
</Button>
</Space>
</Form>
</div>
)
}
export default DeviceFilterComponent

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