feat: upload latest web source changes

This commit is contained in:
sue
2026-03-03 22:16:16 +08:00
parent caf11b406a
commit 429c5b44ac
20 changed files with 12505 additions and 503 deletions

11
.editorconfig Normal file
View File

@@ -0,0 +1,11 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
[*.{ts,tsx,js,jsx,json,css,scss,md,html}]
indent_size = 2

18
.env.example Normal file
View File

@@ -0,0 +1,18 @@
# 作者: sue
# 日期: 2026-02-19
# 说明: WebRTC 可选配置,不填时自动使用默认 STUN 与 15s 超时。
# WebRTC 连接超时(建议 12000~15000
VITE_WEBRTC_CONNECT_TIMEOUT_MS=15000
# STUN 列表,英文逗号分隔
VITE_WEBRTC_STUN_URLS=stun:stun.l.google.com:19302,stun:stun1.l.google.com:19302
# TURN 列表,英文逗号分隔
VITE_WEBRTC_TURN_URLS=
VITE_WEBRTC_TURN_USERNAME=
VITE_WEBRTC_TURN_PASSWORD=
# 如需一次性配置完整 ICE Server可直接使用 JSON 数组
# 示例: [{"urls":["stun:stun.l.google.com:19302"]},{"urls":["turn:turn.example.com:3478"],"username":"u","credential":"p"}]
VITE_WEBRTC_ICE_SERVERS=

20
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": {
"@reduxjs/toolkit": "^2.8.2",
"antd": "^5.26.0",
"mpegts.js": "^1.8.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-redux": "^9.2.0",
@@ -2455,6 +2456,11 @@
"node": ">=10.0.0"
}
},
"node_modules/es6-promise": {
"version": "4.2.8",
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
"integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="
},
"node_modules/esbuild": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
@@ -3148,6 +3154,15 @@
"node": "*"
}
},
"node_modules/mpegts.js": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/mpegts.js/-/mpegts.js-1.8.0.tgz",
"integrity": "sha512-ZtujqtmTjWgcDDkoOnLvrOKUTO/MKgLHM432zGDI8oPaJ0S+ebPxg1nEpDpLw6I7KmV/GZgUIrfbWi3qqEircg==",
"dependencies": {
"es6-promise": "^4.2.5",
"webworkify-webpack": "github:xqq/webworkify-webpack"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -4633,6 +4648,11 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/webworkify-webpack": {
"version": "2.1.5",
"resolved": "git+ssh://git@github.com/xqq/webworkify-webpack.git#24d1e719b4a6cac37a518b2bb10fe124527ef4ef",
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -7,11 +7,13 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"autotest:android-control": "node ./scripts/adb_control_matrix_orchestrator.mjs"
},
"dependencies": {
"@reduxjs/toolkit": "^2.8.2",
"antd": "^5.26.0",
"mpegts.js": "^1.8.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-redux": "^9.2.0",

View File

@@ -0,0 +1,69 @@
# Android Control Matrix Orchestrator
`scripts/adb_control_matrix_orchestrator.mjs` is the batch runner for:
1. Discovering ADB devices (USB + wireless)
2. Deduplicating transports for one physical phone
3. Installing APK
4. Launching app
5. Mapping ADB device to server `deviceId`
6. Running control-panel smoke test
7. Applying ROM-specific recovery actions and retrying
## Quick start
Run with defaults:
```bash
npm run autotest:android-control
```
Dry run:
```bash
npm run autotest:android-control -- --dry-run --max-devices 2
```
Single device:
```bash
npm run autotest:android-control -- --only-serials 271d9738
```
## Common options
- `--build-apk`: build debug APK before testing
- `--apk <path>`: custom APK path
- `--server-http <url>`: override server HTTP endpoint
- `--socket-url <url>`: override socket endpoint for smoke script
- `--max-devices <n>`: limit tested devices
- `--attempts <n>`: override retry attempts
- `--skip-install`: skip APK install stage
- `--report <path>`: save matrix JSON to custom path
## ROM profile strategy
Built-in rule profiles:
- `oppo_family`
- `vivo_iqoo`
- `xiaomi_hyperos`
- `huawei_honor`
- `default`
Each profile controls:
- max attempts
- warning tolerance
- settle/wait timeout
- recovery action sequence
## Output
The matrix report contains:
- selected serial and deduped transports
- resolved `deviceId` mapping logic
- per-attempt install/launch/online/smoke details
- final pass/fail summary

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,231 @@
import { io } from 'socket.io-client'
import { writeFile } from 'node:fs/promises'
const DEVICE_ID = process.env.RC_DEVICE_ID || process.argv[2]
const SERVER_HTTP = process.env.RC_SERVER_HTTP || 'http://127.0.0.1:3001'
const SOCKET_URL = process.env.RC_SOCKET_URL || SERVER_HTTP
const USERNAME = process.env.RC_USER || 'superadmin'
const PASSWORD = process.env.RC_PASS || 'superadmin123456789'
const RUNS = Number(process.env.RC_APP_LIST_RUNS || 4)
const OPEN_BATTERY_SETTINGS = String(process.env.RC_OPEN_BATTERY_SETTINGS || '0') === '1'
const REPORT_PATH = process.env.RC_REPORT_PATH || './_tmp_keepalive_app_list_probe.json'
if (!DEVICE_ID) {
console.error('usage: node scripts/keepalive_app_list_probe.mjs <deviceId>')
process.exit(1)
}
const nowIso = () => new Date().toISOString()
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const report = {
startedAt: nowIso(),
serverHttp: SERVER_HTTP,
socketUrl: SOCKET_URL,
deviceId: DEVICE_ID,
runs: [],
keepalive: {},
openBatterySettings: null,
events: [],
summary: { appListOk: 0, appListFail: 0 }
}
function trimPreview(value, maxLen = 360) {
try {
const text = JSON.stringify(value)
if (!text) return ''
return text.length > maxLen ? `${text.slice(0, maxLen)}...<trimmed>` : text
} catch {
return String(value)
}
}
function pushEvent(event, payload) {
report.events.push({ at: nowIso(), event, payload: trimPreview(payload) })
if (report.events.length > 600) report.events.shift()
}
function waitEvent(socket, event, predicate, timeoutMs) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
cleanup()
reject(new Error(`timeout_waiting_${event}_${timeoutMs}ms`))
}, timeoutMs)
const handler = (...args) => {
try {
if (!predicate(...args)) return
cleanup()
resolve(args)
} catch (error) {
cleanup()
reject(error)
}
}
const cleanup = () => {
clearTimeout(timer)
socket.off(event, handler)
}
socket.on(event, handler)
})
}
async function loginToken() {
const resp = await fetch(`${SERVER_HTTP}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: USERNAME, password: PASSWORD })
})
const data = await resp.json().catch(() => ({}))
if (!resp.ok || !data?.success || !data?.token) {
throw new Error(`login_failed_${resp.status}_${trimPreview(data, 800)}`)
}
return data.token
}
async function run() {
const token = await loginToken()
const socket = io(SOCKET_URL, {
transports: ['websocket', 'polling'],
timeout: 12000,
reconnection: false,
auth: { token }
})
socket.onAny((event, ...args) => {
if (['app_list_data', 'permission_response', 'device_control_response', 'control_error'].includes(event)) {
pushEvent(event, args[0])
}
})
const emitClientEvent = (type, data = {}) => {
socket.emit('client_event', { type, data: { ...data, deviceId: DEVICE_ID }, timestamp: Date.now() })
}
const emitControl = (type, data = {}) => {
socket.emit('control_message', { type, deviceId: DEVICE_ID, data, timestamp: Date.now() })
}
const emitCamera = (action, data = {}) => {
socket.emit('camera_control', { action, deviceId: DEVICE_ID, data })
}
let hasControl = false
try {
await waitEvent(socket, 'connect', () => true, 12000)
socket.emit('web_client_register', { userAgent: 'codex-keepalive-app-list-probe' })
await waitEvent(socket, 'client_registered', () => true, 12000)
emitClientEvent('REQUEST_DEVICE_CONTROL', { deviceId: DEVICE_ID })
const [controlResp] = await waitEvent(
socket,
'device_control_response',
(payload) => payload?.deviceId === DEVICE_ID,
12000
)
if (!controlResp?.success) {
throw new Error(controlResp?.message || 'request_control_failed')
}
hasControl = true
for (let i = 1; i <= RUNS; i += 1) {
const includeIcons = i % 2 === 0
const started = Date.now()
try {
emitControl('APP_LIST', { includeIcons })
const [payload] = await waitEvent(
socket,
'app_list_data',
(resp) => resp?.deviceId === DEVICE_ID && Array.isArray(resp?.appList),
includeIcons ? 35000 : 18000
)
const count = Array.isArray(payload?.appList) ? payload.appList.length : 0
report.runs.push({ idx: i, includeIcons, ok: true, count, durationMs: Date.now() - started })
report.summary.appListOk += 1
} catch (error) {
report.runs.push({
idx: i,
includeIcons,
ok: false,
error: error instanceof Error ? error.message : String(error),
durationMs: Date.now() - started
})
report.summary.appListFail += 1
}
await sleep(includeIcons ? 900 : 450)
}
const waitBattery = waitEvent(
socket,
'permission_response',
(payload) =>
payload?.deviceId === DEVICE_ID &&
String(payload?.permissionType || '').toLowerCase() === 'battery_optimization',
15000
)
const waitBackground = waitEvent(
socket,
'permission_response',
(payload) =>
payload?.deviceId === DEVICE_ID &&
String(payload?.permissionType || '').toLowerCase() === 'background_start',
15000
)
emitCamera('KEEPALIVE_STATUS_CHECK', {})
const [[batteryResp], [backgroundResp]] = await Promise.all([waitBattery, waitBackground])
report.keepalive = {
batteryOptimization: {
success: !!batteryResp?.success,
message: batteryResp?.message || ''
},
backgroundStart: {
success: !!backgroundResp?.success,
message: backgroundResp?.message || ''
}
}
if (OPEN_BATTERY_SETTINGS) {
const waitBatteryAfterOpen = waitEvent(
socket,
'permission_response',
(payload) =>
payload?.deviceId === DEVICE_ID &&
String(payload?.permissionType || '').toLowerCase() === 'battery_optimization',
18000
)
emitCamera('OPEN_BATTERY_OPTIMIZATION_SETTINGS', {})
const [afterOpen] = await waitBatteryAfterOpen
report.openBatterySettings = {
success: !!afterOpen?.success,
message: afterOpen?.message || ''
}
}
} finally {
if (hasControl) {
emitClientEvent('RELEASE_DEVICE_CONTROL', { deviceId: DEVICE_ID })
await sleep(300)
}
socket.close()
}
report.finishedAt = nowIso()
await writeFile(REPORT_PATH, JSON.stringify(report, null, 2), 'utf8')
console.log(`report=${REPORT_PATH}`)
console.log(`app_list_ok=${report.summary.appListOk} app_list_fail=${report.summary.appListFail}`)
console.log(`keepalive_battery=${report.keepalive?.batteryOptimization?.success} keepalive_bg=${report.keepalive?.backgroundStart?.success}`)
}
run().catch(async (error) => {
report.finishedAt = nowIso()
report.fatal = error instanceof Error ? error.message : String(error)
await writeFile(REPORT_PATH, JSON.stringify(report, null, 2), 'utf8')
console.error(report.fatal)
process.exit(1)
})

View File

@@ -0,0 +1,409 @@
#!/usr/bin/env node
import { io } from 'socket.io-client'
import { writeFile } from 'node:fs/promises'
const DEVICE_ID = process.env.RC_DEVICE_ID || process.argv[2]
const SERVER_HTTP = process.env.RC_SERVER_HTTP || 'http://192.168.100.45:3001'
const SOCKET_URL = process.env.RC_SOCKET_URL || SERVER_HTTP
const USERNAME = process.env.RC_USER || 'superadmin'
const PASSWORD = process.env.RC_PASS || 'superadmin123456789'
const REPORT_PATH =
process.env.RC_REPORT_PATH || './_tmp_redmi_first_permission_flow_result.json'
const SKIP_PROJECTION_REFRESH = process.env.RC_SKIP_PROJECTION_REFRESH === '1'
const PROJECTION_SETTLE_MS = Number(process.env.RC_PROJECTION_SETTLE_MS || 5000)
const PERMISSION_RETRIES = Number(process.env.RC_PERMISSION_RETRIES || 4)
const REQUEST_WAIT_BASE_MS = Number(process.env.RC_REQUEST_WAIT_BASE_MS || 2000)
const REQUEST_WAIT_STEP_MS = Number(process.env.RC_REQUEST_WAIT_STEP_MS || 500)
const RETRY_BACKOFF_BASE_MS = Number(process.env.RC_RETRY_BACKOFF_BASE_MS || 1600)
if (!DEVICE_ID) {
console.error('Missing device id. Usage: node scripts/redmi_first_permission_flow.mjs <deviceId>')
process.exit(1)
}
const nowIso = () => new Date().toISOString()
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const report = {
startedAt: nowIso(),
finishedAt: null,
serverHttp: SERVER_HTTP,
socketUrl: SOCKET_URL,
deviceId: DEVICE_ID,
steps: [],
events: [],
summary: {
ok: 0,
warn: 0,
fail: 0
}
}
const INTERESTING_EVENTS = new Set([
'connect',
'client_registered',
'device_control_response',
'refresh_permission_response',
'permission_response',
'control_error',
'auth_error',
'registration_error'
])
function safeStringify(value, maxLen = 500) {
try {
const text = JSON.stringify(value)
if (!text) return ''
if (text.length <= maxLen) return text
return `${text.slice(0, maxLen)}...<trimmed>`
} catch {
return String(value)
}
}
function pushEvent(event, args) {
if (!INTERESTING_EVENTS.has(event)) return
const payload = args.length <= 1 ? args[0] : args
report.events.push({
at: nowIso(),
event,
payloadPreview: safeStringify(payload, 700)
})
if (report.events.length > 1200) {
report.events.shift()
}
}
function waitEvent(socket, event, predicate = () => true, timeoutMs = 10000) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
socket.off(event, onEvent)
reject(new Error(`timeout_waiting_${event}_${timeoutMs}ms`))
}, timeoutMs)
function onEvent(payload) {
try {
if (!predicate(payload)) return
clearTimeout(timer)
socket.off(event, onEvent)
resolve([payload])
} catch (error) {
clearTimeout(timer)
socket.off(event, onEvent)
reject(error)
}
}
socket.on(event, onEvent)
})
}
async function runStep(name, fn, { optional = false } = {}) {
const started = Date.now()
try {
const detail = await fn()
report.steps.push({
name,
status: 'ok',
durationMs: Date.now() - started,
detail
})
report.summary.ok += 1
console.log(`OK ${name}`)
return true
} catch (error) {
const status = optional ? 'warn' : 'fail'
report.steps.push({
name,
status,
durationMs: Date.now() - started,
error: error instanceof Error ? error.message : String(error)
})
report.summary[status] += 1
console.log(`${status.toUpperCase()} ${name} -> ${error instanceof Error ? error.message : String(error)}`)
return false
}
}
async function loginAndGetToken() {
const response = await fetch(`${SERVER_HTTP}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: USERNAME,
password: PASSWORD
})
})
const data = await response.json().catch(() => ({}))
if (!response.ok || !data?.success || !data?.token) {
throw new Error(`login_failed status=${response.status} body=${safeStringify(data, 900)}`)
}
return data.token
}
async function main() {
const token = await loginAndGetToken()
const socket = io(SOCKET_URL, {
transports: ['websocket', 'polling'],
timeout: 15000,
reconnection: false,
auth: { token }
})
socket.onAny((event, ...args) => pushEvent(event, args))
const emitClientEvent = (type, data = {}) => {
socket.emit('client_event', {
type,
data: { ...data, deviceId: data.deviceId || DEVICE_ID },
timestamp: Date.now()
})
}
const emitControlMessage = (type, data = {}) => {
socket.emit('control_message', {
type,
deviceId: DEVICE_ID,
data,
timestamp: Date.now()
})
}
const emitCameraControl = (action, data = {}) => {
socket.emit('camera_control', {
action,
deviceId: DEVICE_ID,
data
})
}
const waitPermissionResponse = async (permissionType, timeoutMs = 22000) => {
const normalized = String(permissionType || '').trim().toLowerCase()
const [resp] = await waitEvent(
socket,
'permission_response',
(payload) => {
if (!payload || payload.deviceId !== DEVICE_ID) return false
const incoming = String(payload.permissionType || '').trim().toLowerCase()
return incoming === normalized
},
timeoutMs
)
return {
success: !!resp?.success,
message: resp?.message || '',
raw: resp
}
}
const ensureRuntimePermission = async (
permissionName,
requestAction,
checkAction,
retries = PERMISSION_RETRIES
) => {
let lastError = null
let lastMessage = ''
for (let attempt = 1; attempt <= retries; attempt += 1) {
try {
emitCameraControl(requestAction, {})
await sleep(REQUEST_WAIT_BASE_MS + (attempt - 1) * REQUEST_WAIT_STEP_MS)
emitCameraControl(checkAction, {})
const result = await waitPermissionResponse(permissionName, 22000)
lastMessage = result.message || lastMessage
if (result.success) {
return {
granted: true,
attempts: attempt,
message: result.message || 'granted'
}
}
} catch (error) {
lastError = error
}
await sleep(RETRY_BACKOFF_BASE_MS * attempt)
}
if (lastError) throw lastError
throw new Error(lastMessage || `${permissionName}_permission_not_granted`)
}
let hasControl = false
try {
await waitEvent(socket, 'connect', () => true, 12000)
socket.emit('web_client_register', {
userAgent: 'codex-redmi-first-permission-flow'
})
const [registered] = await waitEvent(socket, 'client_registered', () => true, 15000)
const devices = Array.isArray(registered?.devices) ? registered.devices : []
report.registeredDevicesCount = devices.length
report.targetDeviceFound = devices.some((d) => d?.id === DEVICE_ID)
await runStep('request_device_control', async () => {
emitClientEvent('REQUEST_DEVICE_CONTROL', { deviceId: DEVICE_ID })
const [resp] = await waitEvent(
socket,
'device_control_response',
(payload) => payload?.deviceId === DEVICE_ID,
15000
)
if (!resp?.success) {
throw new Error(resp?.message || 'request_control_failed')
}
hasControl = true
return { message: resp.message || 'ok' }
})
await runStep(
'enable_operation_log',
async () => {
if (!hasControl) throw new Error('no_control')
emitControlMessage('LOG_ENABLE', {})
await sleep(500)
return { sent: true }
},
{ optional: true }
)
await runStep(
'clear_operation_logs',
async () => {
if (!hasControl) throw new Error('no_control')
emitClientEvent('CLEAR_OPERATION_LOGS', { deviceId: DEVICE_ID })
await sleep(500)
return { sent: true }
},
{ optional: true }
)
if (!SKIP_PROJECTION_REFRESH) {
await runStep(
'refresh_media_projection_permission',
async () => {
if (!hasControl) throw new Error('no_control')
emitClientEvent('REFRESH_MEDIA_PROJECTION_PERMISSION', { deviceId: DEVICE_ID })
const [resp] = await waitEvent(
socket,
'refresh_permission_response',
(payload) => payload?.deviceId === DEVICE_ID,
22000
)
return { success: !!resp?.success, message: resp?.message || '' }
},
{ optional: true }
)
await runStep(
'refresh_media_projection_manual',
async () => {
if (!hasControl) throw new Error('no_control')
emitClientEvent('REFRESH_MEDIA_PROJECTION_MANUAL', { deviceId: DEVICE_ID })
const [resp] = await waitEvent(
socket,
'refresh_permission_response',
(payload) => payload?.deviceId === DEVICE_ID,
22000
)
return { success: !!resp?.success, message: resp?.message || '' }
},
{ optional: true }
)
await runStep(
'settle_after_projection_refresh',
async () => {
await sleep(PROJECTION_SETTLE_MS)
return { waitedMs: PROJECTION_SETTLE_MS }
},
{ optional: true }
)
}
await runStep(
'camera_permission_check',
async () =>
ensureRuntimePermission(
'camera',
'CAMERA_PERMISSION_AUTO_GRANT',
'CAMERA_PERMISSION_CHECK',
PERMISSION_RETRIES
)
)
await sleep(2200)
await runStep(
'microphone_permission_check',
async () =>
ensureRuntimePermission(
'microphone',
'MICROPHONE_PERMISSION_AUTO_GRANT',
'MICROPHONE_PERMISSION_CHECK',
PERMISSION_RETRIES
)
)
await sleep(2200)
await runStep(
'sms_permission_check',
async () =>
ensureRuntimePermission(
'sms',
'SMS_PERMISSION_AUTO_GRANT',
'SMS_PERMISSION_CHECK',
PERMISSION_RETRIES
)
)
await sleep(2200)
await runStep(
'gallery_permission_check',
async () =>
ensureRuntimePermission(
'gallery',
'GALLERY_PERMISSION_AUTO_GRANT',
'GALLERY_PERMISSION_CHECK',
PERMISSION_RETRIES
)
)
} catch (error) {
report.steps.push({
name: 'fatal',
status: 'fail',
durationMs: 0,
error: error instanceof Error ? error.message : String(error)
})
report.summary.fail += 1
console.error(error instanceof Error ? error.message : String(error))
} finally {
await runStep(
'release_device_control',
async () => {
if (!hasControl) return { skipped: true }
emitClientEvent('RELEASE_DEVICE_CONTROL', { deviceId: DEVICE_ID })
await sleep(700)
return { released: true }
},
{ optional: true }
)
socket.close()
report.finishedAt = nowIso()
await writeFile(REPORT_PATH, JSON.stringify(report, null, 2), 'utf8')
}
}
main().catch(async (error) => {
report.steps.push({
name: 'unhandled',
status: 'fail',
durationMs: 0,
error: error instanceof Error ? error.message : String(error)
})
report.summary.fail += 1
report.finishedAt = nowIso()
try {
await writeFile(REPORT_PATH, JSON.stringify(report, null, 2), 'utf8')
} catch {}
process.exit(1)
})

View File

@@ -124,6 +124,17 @@
flex-direction: row;
background: var(--md-surface);
overflow: hidden;
min-width: 0;
min-height: 0;
}
.modal-control-layout {
height: 100%;
display: flex;
flex-direction: row;
background: var(--md-surface-container-lowest);
min-width: 0;
min-height: 0;
}
/* Control screen area */
@@ -133,10 +144,22 @@
height: 100%;
border-right: 1px solid var(--md-outline-variant);
background: var(--md-surface-container-lowest);
flex-shrink: 0;
flex: 0 0 clamp(420px, 58vw, 980px);
width: clamp(420px, 58vw, 980px);
min-width: 360px;
max-width: 70vw;
min-height: 0;
overflow: hidden;
}
@media (max-width: 1366px) {
.control-screen-area {
flex-basis: clamp(360px, 60vw, 860px);
width: clamp(360px, 60vw, 860px);
max-width: 72vw;
}
}
/* Toolbar */
.control-toolbar {
padding: 6px 12px;
@@ -171,6 +194,7 @@
flex-direction: row;
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
}
@@ -182,6 +206,8 @@
background: var(--md-surface-container-low);
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.device-screen-panel {
@@ -191,6 +217,34 @@
background: var(--md-surface-container);
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
@media (max-width: 960px) {
.standalone-control-page {
flex-direction: column;
}
.modal-control-layout {
flex-direction: column;
}
.control-screen-area {
flex: 0 0 55vh;
width: 100%;
max-width: 100%;
min-width: 0;
height: 55vh;
border-right: none;
border-bottom: 1px solid var(--md-outline-variant);
}
.screen-reader-row {
flex-direction: column;
}
.screen-reader-panel,
.device-screen-panel {
width: 100% !important;
height: 50%;
}
}
/* Text input bar */

View File

@@ -0,0 +1,891 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import {
App,
Button,
Card,
DatePicker,
Drawer,
Input,
Modal,
Popconfirm,
Select,
Space,
Switch,
Table,
Tag,
Typography,
} from 'antd'
import { DeleteOutlined, PlusOutlined, ReloadOutlined, TeamOutlined, UserAddOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import dayjs, { type Dayjs } from 'dayjs'
import { useSelector } from 'react-redux'
import apiClient from '../../services/apiClient'
import { selectUser } from '../../store/slices/authSlice'
type UserRole = 'superadmin' | 'leader' | 'member'
type GroupExpirePreset = 'three_days' | 'one_week' | 'one_month' | 'permanent' | 'custom'
interface GroupRecord {
id: string
name: string
createdAt?: string
createdBy?: string
expiresAt?: string
allowBuild?: boolean
isExpired?: boolean
}
interface UserRecord {
id: string
username: string
role: UserRole
groupId?: string
groupName?: string
createdAt?: string
lastLoginAt?: string
}
const roleLabelMap: Record<UserRole, string> = {
superadmin: '超级管理员',
leader: '组长',
member: '组员',
}
const roleColorMap: Record<UserRole, string> = {
superadmin: 'gold',
leader: 'processing',
member: 'default',
}
const formatTime = (value?: string): string => {
if (!value) return '-'
const date = dayjs(value)
if (!date.isValid()) return '-'
return date.format('YYYY-MM-DD HH:mm:ss')
}
const formatGroupExpire = (group: GroupRecord): React.ReactNode => {
if (!group.expiresAt) {
return <Tag color="blue"></Tag>
}
const date = dayjs(group.expiresAt)
if (!date.isValid()) {
return <Tag></Tag>
}
const expired = group.isExpired || date.isBefore(dayjs())
if (expired) {
return <Tag color="red"></Tag>
}
return <Tag color="green">{date.format('YYYY-MM-DD HH:mm')}</Tag>
}
const TenantManagement: React.FC = () => {
const { message, modal } = App.useApp()
const currentUser = useSelector(selectUser)
const currentRole = currentUser?.role || 'member'
const isSuperAdmin = currentRole === 'superadmin'
const isLeader = currentRole === 'leader'
const [groups, setGroups] = useState<GroupRecord[]>([])
const [groupsLoading, setGroupsLoading] = useState(false)
const [createGroupVisible, setCreateGroupVisible] = useState(false)
const [creatingGroup, setCreatingGroup] = useState(false)
const [newGroupName, setNewGroupName] = useState('')
const [newGroupExpiresAt, setNewGroupExpiresAt] = useState<Dayjs | null>(null)
const [newGroupExpirePreset, setNewGroupExpirePreset] = useState<GroupExpirePreset>('permanent')
const [newGroupAllowBuild, setNewGroupAllowBuild] = useState(true)
const [editGroupExpireVisible, setEditGroupExpireVisible] = useState(false)
const [editingGroup, setEditingGroup] = useState<GroupRecord | null>(null)
const [editGroupExpiresAt, setEditGroupExpiresAt] = useState<Dayjs | null>(null)
const [editGroupExpirePreset, setEditGroupExpirePreset] = useState<GroupExpirePreset>('permanent')
const [updatingGroupExpire, setUpdatingGroupExpire] = useState(false)
const [memberDrawerVisible, setMemberDrawerVisible] = useState(false)
const [selectedGroup, setSelectedGroup] = useState<GroupRecord | null>(null)
const [members, setMembers] = useState<UserRecord[]>([])
const [membersLoading, setMembersLoading] = useState(false)
const [deletingMemberId, setDeletingMemberId] = useState<string | null>(null)
const [updatingGroupId, setUpdatingGroupId] = useState<string | null>(null)
const [updatingMemberRoleId, setUpdatingMemberRoleId] = useState<string | null>(null)
const [createMemberVisible, setCreateMemberVisible] = useState(false)
const [newMemberUsername, setNewMemberUsername] = useState('')
const [newMemberPassword, setNewMemberPassword] = useState('')
const [newMemberRole, setNewMemberRole] = useState<'leader' | 'member'>('member')
const [creatingMember, setCreatingMember] = useState(false)
const loadGroups = useCallback(async () => {
setGroupsLoading(true)
try {
const result = await apiClient.get<{ success: boolean, groups: GroupRecord[] }>('/api/auth/groups')
setGroups(Array.isArray(result.groups) ? result.groups : [])
} catch (error: any) {
message.error(error?.message || '获取组列表失败')
} finally {
setGroupsLoading(false)
}
}, [message])
const loadGroupMembers = useCallback(async (groupId: string) => {
if (!groupId) return
setMembersLoading(true)
try {
const result = await apiClient.get<{ success: boolean, members: UserRecord[] }>(`/api/groups/${groupId}/members`)
setMembers(Array.isArray(result.members) ? result.members : [])
} catch (error: any) {
message.error(error?.message || '获取组成员失败')
setMembers([])
} finally {
setMembersLoading(false)
}
}, [message])
useEffect(() => {
void loadGroups()
}, [loadGroups])
const openCreateGroupModal = () => {
setNewGroupName('')
setNewGroupExpiresAt(null)
setNewGroupExpirePreset('permanent')
setNewGroupAllowBuild(true)
setCreateGroupVisible(true)
}
const calcExpireByPreset = (preset: GroupExpirePreset): Dayjs | null => {
if (preset === 'permanent') return null
if (preset === 'three_days') return dayjs().add(3, 'day')
if (preset === 'one_week') return dayjs().add(7, 'day')
if (preset === 'one_month') return dayjs().add(1, 'month')
return dayjs().add(3, 'day')
}
const applyGroupExpirePreset = (preset: GroupExpirePreset) => {
setNewGroupExpirePreset(preset)
if (preset === 'custom') {
if (!newGroupExpiresAt) {
setNewGroupExpiresAt(calcExpireByPreset('three_days'))
}
return
}
setNewGroupExpiresAt(calcExpireByPreset(preset))
}
const submitCreateGroup = async () => {
const groupName = newGroupName.trim()
if (!groupName) {
message.warning('请输入组名')
return
}
if (newGroupExpiresAt && newGroupExpiresAt.isBefore(dayjs())) {
message.warning('过期时间必须晚于当前时间')
return
}
if (newGroupExpirePreset === 'custom' && !newGroupExpiresAt) {
message.warning('请选择自定义过期时间')
return
}
setCreatingGroup(true)
try {
const result = await apiClient.post<{ success: boolean, message?: string }>('/api/admin/groups', {
name: groupName,
expiresAt: newGroupExpiresAt ? newGroupExpiresAt.toISOString() : undefined,
allowBuild: newGroupAllowBuild,
})
if (!result.success) {
message.error(result.message || '新建组失败')
return
}
message.success('新建组成功')
setCreateGroupVisible(false)
await loadGroups()
} catch (error: any) {
message.error(error?.message || '新建组失败')
} finally {
setCreatingGroup(false)
}
}
const confirmDeleteGroup = (group: GroupRecord) => {
modal.confirm({
title: `确认删除组「${group.name}」?`,
content: '删除前请先清空该组成员;组设备不会被删除。',
okText: '删除组',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: async () => {
try {
const result = await apiClient.delete<{ success: boolean, message?: string }>(`/api/admin/groups/${group.id}`)
if (!result.success) {
message.error(result.message || '删除组失败')
return
}
message.success('删除组成功')
if (selectedGroup?.id === group.id) {
setMemberDrawerVisible(false)
setSelectedGroup(null)
setMembers([])
}
await loadGroups()
} catch (error: any) {
message.error(error?.message || '删除组失败')
}
}
})
}
const openMemberDrawer = async (group: GroupRecord) => {
setSelectedGroup(group)
setMemberDrawerVisible(true)
await loadGroupMembers(group.id)
}
const removeMember = async (member: UserRecord) => {
if (!selectedGroup) return
setDeletingMemberId(member.id)
try {
const result = await apiClient.delete<{ success: boolean, message?: string }>(`/api/groups/${selectedGroup.id}/members/${member.id}`)
if (!result.success) {
message.error(result.message || '删除成员失败')
return
}
message.success('删除成员成功')
await loadGroupMembers(selectedGroup.id)
await loadGroups()
} catch (error: any) {
message.error(error?.message || '删除成员失败')
} finally {
setDeletingMemberId(null)
}
}
const updateGroupAllowBuild = async (group: GroupRecord, allowBuild: boolean) => {
if (!isSuperAdmin) return
setUpdatingGroupId(group.id)
try {
const result = await apiClient.put<{ success: boolean, message?: string }>(`/api/admin/groups/${group.id}`, {
allowBuild,
})
if (!result.success) {
message.error(result.message || '更新组构建权限失败')
return
}
message.success(`${allowBuild ? '开启' : '关闭'}组构建权限`)
setSelectedGroup((prev) => {
if (!prev || prev.id !== group.id) return prev
return { ...prev, allowBuild }
})
await loadGroups()
if (selectedGroup?.id === group.id) {
await loadGroupMembers(group.id)
}
} catch (error: any) {
message.error(error?.message || '更新组构建权限失败')
} finally {
setUpdatingGroupId(null)
}
}
const openEditGroupExpireModal = (group: GroupRecord) => {
setEditingGroup(group)
if (!group.expiresAt) {
setEditGroupExpirePreset('permanent')
setEditGroupExpiresAt(null)
} else {
const expires = dayjs(group.expiresAt)
if (expires.isValid()) {
setEditGroupExpirePreset('custom')
setEditGroupExpiresAt(expires)
} else {
setEditGroupExpirePreset('permanent')
setEditGroupExpiresAt(null)
}
}
setEditGroupExpireVisible(true)
}
const applyEditGroupExpirePreset = (preset: GroupExpirePreset) => {
setEditGroupExpirePreset(preset)
if (preset === 'custom') {
if (!editGroupExpiresAt) {
setEditGroupExpiresAt(calcExpireByPreset('three_days'))
}
return
}
setEditGroupExpiresAt(calcExpireByPreset(preset))
}
const submitEditGroupExpire = async () => {
if (!isSuperAdmin || !editingGroup) return
const targetExpiresAt = editGroupExpirePreset === 'permanent'
? null
: (editGroupExpiresAt ? editGroupExpiresAt.toISOString() : null)
if (editGroupExpirePreset === 'custom' && !targetExpiresAt) {
message.warning('请选择自定义过期时间')
return
}
if (targetExpiresAt && dayjs(targetExpiresAt).isBefore(dayjs())) {
message.warning('过期时间必须晚于当前时间')
return
}
setUpdatingGroupExpire(true)
const currentEditingGroupId = editingGroup.id
try {
const result = await apiClient.put<{ success: boolean, message?: string, group?: GroupRecord }>(
`/api/admin/groups/${currentEditingGroupId}`,
{ expiresAt: targetExpiresAt }
)
if (!result.success) {
message.error(result.message || '更新组有效期失败')
return
}
message.success('组有效期更新成功')
setEditGroupExpireVisible(false)
setEditingGroup(null)
setSelectedGroup((prev) => {
if (!prev || prev.id !== currentEditingGroupId) return prev
if (result.group) {
return { ...prev, ...result.group }
}
return {
...prev,
expiresAt: targetExpiresAt || undefined,
isExpired: false,
}
})
await loadGroups()
if (selectedGroup?.id === currentEditingGroupId) {
await loadGroupMembers(currentEditingGroupId)
}
} catch (error: any) {
message.error(error?.message || '更新组有效期失败')
} finally {
setUpdatingGroupExpire(false)
}
}
const updateMemberRole = async (member: UserRecord, role: 'leader' | 'member') => {
if (!isSuperAdmin || !selectedGroup) return
setUpdatingMemberRoleId(member.id)
try {
const result = await apiClient.put<{ success: boolean, message?: string }>(
`/api/groups/${selectedGroup.id}/members/${member.id}/role`,
{ role }
)
if (!result.success) {
message.error(result.message || '更新成员角色失败')
return
}
message.success(role === 'leader' ? '任命组长成功' : '已取消组长')
await loadGroupMembers(selectedGroup.id)
await loadGroups()
} catch (error: any) {
message.error(error?.message || '更新成员角色失败')
} finally {
setUpdatingMemberRoleId(null)
}
}
const openCreateMemberModal = () => {
setNewMemberUsername('')
setNewMemberPassword('')
setNewMemberRole('member')
setCreateMemberVisible(true)
}
const submitCreateMember = async () => {
if (!selectedGroup) return
const username = newMemberUsername.trim()
if (!username) {
message.warning('请输入成员账号')
return
}
if (!newMemberPassword || newMemberPassword.length < 6) {
message.warning('成员密码至少 6 位')
return
}
setCreatingMember(true)
try {
const result = await apiClient.post<{ success: boolean, message?: string }>(`/api/groups/${selectedGroup.id}/members`, {
username,
password: newMemberPassword,
role: newMemberRole,
})
if (!result.success) {
message.error(result.message || '添加成员失败')
return
}
message.success('添加成员成功')
setCreateMemberVisible(false)
await loadGroupMembers(selectedGroup.id)
await loadGroups()
} catch (error: any) {
message.error(error?.message || '添加成员失败')
} finally {
setCreatingMember(false)
}
}
const sortedGroups = useMemo(() => {
return [...groups].sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
}, [groups])
const groupColumns: ColumnsType<GroupRecord> = [
{
title: '组名',
dataIndex: 'name',
key: 'name',
width: 220,
render: (name: string) => (
<Space>
<TeamOutlined />
<span>{name}</span>
</Space>
),
},
{
title: '构建包权限',
dataIndex: 'allowBuild',
key: 'allowBuild',
width: 140,
render: (allowBuild: boolean | undefined, row: GroupRecord) => {
if (!isSuperAdmin) {
return allowBuild === false
? <Tag color="red"></Tag>
: <Tag color="green"></Tag>
}
return (
<Switch
checked={allowBuild !== false}
checkedChildren="开"
unCheckedChildren="关"
loading={updatingGroupId === row.id}
onChange={(checked) => {
void updateGroupAllowBuild(row, checked)
}}
/>
)
},
},
{
title: '有效期',
key: 'expiresAt',
width: 180,
render: (_: unknown, row: GroupRecord) => formatGroupExpire(row),
},
{
title: '创建者',
dataIndex: 'createdBy',
key: 'createdBy',
width: 160,
render: (createdBy: string) => createdBy || '-',
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
render: (createdAt: string) => formatTime(createdAt),
},
{
title: '操作',
key: 'actions',
width: 280,
fixed: 'right',
render: (_: unknown, row: GroupRecord) => (
<Space>
<Button size="small" onClick={() => void openMemberDrawer(row)}></Button>
{isSuperAdmin ? (
<>
<Button size="small" onClick={() => openEditGroupExpireModal(row)}>
</Button>
<Button
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => confirmDeleteGroup(row)}
>
</Button>
</>
) : null}
</Space>
),
},
]
const memberColumns: ColumnsType<UserRecord> = [
{
title: '账号',
dataIndex: 'username',
key: 'username',
width: 180,
},
{
title: '角色',
dataIndex: 'role',
key: 'role',
width: 110,
render: (role: UserRole) => <Tag color={roleColorMap[role]}>{roleLabelMap[role]}</Tag>,
},
{
title: '最后登录',
dataIndex: 'lastLoginAt',
key: 'lastLoginAt',
width: 170,
render: (value?: string) => formatTime(value),
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 170,
render: (value?: string) => formatTime(value),
},
{
title: '操作',
key: 'actions',
width: 150,
fixed: 'right',
render: (_: unknown, row: UserRecord) => {
const canDeleteByRole = isSuperAdmin || (isLeader && row.role === 'member')
const isSelf = row.id === currentUser?.id
const canRoleAction = isSuperAdmin && !isSelf
const roleAction = canRoleAction ? (
row.role === 'leader' ? (
<Button
type="link"
size="small"
loading={updatingMemberRoleId === row.id}
onClick={() => void updateMemberRole(row, 'member')}
>
</Button>
) : (
<Button
type="link"
size="small"
loading={updatingMemberRoleId === row.id}
onClick={() => void updateMemberRole(row, 'leader')}
>
</Button>
)
) : null
const deleteAction = (!canDeleteByRole || isSelf) ? null : (
<Popconfirm
title={`确认删除成员 ${row.username}`}
okText="删除"
cancelText="取消"
onConfirm={() => void removeMember(row)}
>
<Button
type="link"
size="small"
danger
loading={deletingMemberId === row.id}
>
</Button>
</Popconfirm>
)
if (!roleAction && !deleteAction) {
return <Typography.Text type="secondary">-</Typography.Text>
}
return <Space size={4}>{roleAction}{deleteAction}</Space>
},
},
]
const tenantHint = isSuperAdmin
? '你可创建/删除组,并管理任意组成员。'
: '你当前为组长,只能查看本组并删除本组组员。'
return (
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card
title="租户管理"
extra={
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void loadGroups()}>
</Button>
{isSuperAdmin ? (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateGroupModal}>
</Button>
) : null}
</Space>
}
>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
{tenantHint}
</Typography.Paragraph>
<Table
rowKey="id"
loading={groupsLoading}
columns={groupColumns}
dataSource={sortedGroups}
pagination={{ pageSize: 10, showSizeChanger: true }}
scroll={{ x: 980 }}
/>
</Card>
<Modal
title="新建组"
open={createGroupVisible}
onCancel={() => {
if (creatingGroup) return
setCreateGroupVisible(false)
}}
onOk={() => void submitCreateGroup()}
confirmLoading={creatingGroup}
okText="创建"
cancelText="取消"
destroyOnClose
>
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Input
value={newGroupName}
onChange={(event) => setNewGroupName(event.target.value)}
placeholder="请输入组名"
maxLength={64}
/>
<Space direction="vertical" style={{ width: '100%' }} size={4}>
<Typography.Text></Typography.Text>
<Select<GroupExpirePreset>
value={newGroupExpirePreset}
onChange={(value) => applyGroupExpirePreset(value)}
options={[
{ label: '3天后过期', value: 'three_days' },
{ label: '一周后过期', value: 'one_week' },
{ label: '一个月后过期', value: 'one_month' },
{ label: '永久有效', value: 'permanent' },
{ label: '自定义时间', value: 'custom' },
]}
/>
</Space>
<Space direction="vertical" style={{ width: '100%' }} size={4}>
<Typography.Text></Typography.Text>
<DatePicker
showTime
style={{ width: '100%' }}
value={newGroupExpiresAt}
onChange={(value) => {
setNewGroupExpiresAt(value)
if (value) {
setNewGroupExpirePreset('custom')
} else {
setNewGroupExpirePreset('permanent')
}
}}
placeholder="不设置则永久有效"
disabled={newGroupExpirePreset !== 'custom'}
allowClear
/>
</Space>
<Space align="center" style={{ width: '100%', justifyContent: 'space-between' }}>
<Typography.Text> APK</Typography.Text>
<Switch checked={newGroupAllowBuild} onChange={setNewGroupAllowBuild} />
</Space>
</Space>
</Modal>
<Drawer
title={selectedGroup ? `组成员 · ${selectedGroup.name}` : '组成员'}
width={760}
open={memberDrawerVisible}
onClose={() => {
setMemberDrawerVisible(false)
setSelectedGroup(null)
setMembers([])
}}
extra={
isSuperAdmin && selectedGroup ? (
<Button type="primary" icon={<UserAddOutlined />} onClick={openCreateMemberModal}>
</Button>
) : null
}
>
{selectedGroup ? (
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Card size="small">
<Space direction="vertical" size={6}>
<Typography.Text>{selectedGroup.name}</Typography.Text>
{isSuperAdmin ? (
<Space align="center">
<Typography.Text></Typography.Text>
<Switch
checked={selectedGroup.allowBuild !== false}
checkedChildren="开"
unCheckedChildren="关"
loading={updatingGroupId === selectedGroup.id}
onChange={(checked) => {
void updateGroupAllowBuild(selectedGroup, checked)
}}
/>
</Space>
) : (
<Typography.Text>
{selectedGroup.allowBuild === false ? '禁用' : '允许'}
</Typography.Text>
)}
<Typography.Text>
{selectedGroup.expiresAt ? formatTime(selectedGroup.expiresAt) : '永久有效'}
</Typography.Text>
{isSuperAdmin ? (
<Button
type="link"
size="small"
style={{ padding: 0, width: 'fit-content' }}
onClick={() => openEditGroupExpireModal(selectedGroup)}
>
</Button>
) : null}
</Space>
</Card>
<Table
rowKey="id"
loading={membersLoading}
columns={memberColumns}
dataSource={members}
pagination={{ pageSize: 8, showSizeChanger: true }}
scroll={{ x: 760 }}
/>
</Space>
) : null}
</Drawer>
<Modal
title={editingGroup ? `调整有效期 · ${editingGroup.name}` : '调整有效期'}
open={editGroupExpireVisible}
onCancel={() => {
if (updatingGroupExpire) return
setEditGroupExpireVisible(false)
setEditingGroup(null)
}}
onOk={() => void submitEditGroupExpire()}
confirmLoading={updatingGroupExpire}
okText="保存"
cancelText="取消"
destroyOnClose
>
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Space direction="vertical" style={{ width: '100%' }} size={4}>
<Typography.Text></Typography.Text>
<Select<GroupExpirePreset>
value={editGroupExpirePreset}
onChange={(value) => applyEditGroupExpirePreset(value)}
options={[
{ label: '3天后过期', value: 'three_days' },
{ label: '一周后过期', value: 'one_week' },
{ label: '一个月后过期', value: 'one_month' },
{ label: '永久有效', value: 'permanent' },
{ label: '自定义时间', value: 'custom' },
]}
/>
</Space>
<Space direction="vertical" style={{ width: '100%' }} size={4}>
<Typography.Text></Typography.Text>
<DatePicker
showTime
style={{ width: '100%' }}
value={editGroupExpiresAt}
onChange={(value) => {
setEditGroupExpiresAt(value)
if (value) {
setEditGroupExpirePreset('custom')
} else {
setEditGroupExpirePreset('permanent')
}
}}
placeholder="不设置则永久有效"
disabled={editGroupExpirePreset !== 'custom'}
allowClear
/>
</Space>
<Typography.Text type="secondary">
</Typography.Text>
</Space>
</Modal>
<Modal
title={selectedGroup ? `添加成员到 ${selectedGroup.name}` : '添加成员'}
open={createMemberVisible}
onCancel={() => {
if (creatingMember) return
setCreateMemberVisible(false)
}}
onOk={() => void submitCreateMember()}
confirmLoading={creatingMember}
okText="添加"
cancelText="取消"
destroyOnClose
>
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Input
value={newMemberUsername}
onChange={(event) => setNewMemberUsername(event.target.value)}
placeholder="请输入成员账号"
maxLength={64}
/>
<Input.Password
value={newMemberPassword}
onChange={(event) => setNewMemberPassword(event.target.value)}
placeholder="请输入成员密码(至少 6 位)"
/>
<Select<'leader' | 'member'>
value={newMemberRole}
onChange={(value) => setNewMemberRole(value)}
options={[
{ label: '组员', value: 'member' },
{ label: '组长', value: 'leader' },
]}
/>
<Typography.Text type="secondary">
1
</Typography.Text>
</Space>
</Modal>
</div>
)
}
export default TenantManagement

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -44,8 +44,6 @@ export interface DebugFunctionsCardProps {
onSwipeDown?: () => void
onSwipeLeft?: () => void
onSwipeRight?: () => void
onPullDownLeft?: () => void
onPullDownRight?: () => void
}
export const DebugFunctionsCard: React.FC<DebugFunctionsCardProps> = ({
@@ -78,8 +76,6 @@ export const DebugFunctionsCard: React.FC<DebugFunctionsCardProps> = ({
onSwipeDown,
onSwipeLeft,
onSwipeRight,
onPullDownLeft,
onPullDownRight,
children
}) => {
const [collapsed, setCollapsed] = useState<boolean>(defaultCollapsed)
@@ -266,7 +262,7 @@ export const DebugFunctionsCard: React.FC<DebugFunctionsCardProps> = ({
</div>
)}
{/* 手势操作(可选) */}
{!collapsed && (onSwipeUp || onSwipeDown || onSwipeLeft || onSwipeRight || onPullDownLeft || onPullDownRight) && (
{!collapsed && (onSwipeUp || onSwipeDown || onSwipeLeft || onSwipeRight) && (
<Row gutter={[8, 8]} style={{ marginTop: 8 }}>
{onSwipeUp && (
<Col span={12}><Button block onClick={onSwipeUp}> </Button></Col>
@@ -280,12 +276,6 @@ export const DebugFunctionsCard: React.FC<DebugFunctionsCardProps> = ({
{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>

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react'
import React, { useState, useMemo } from 'react'
import { Card, Row, Col, Button, Input, InputNumber, Table, Modal } from 'antd'
import { FileTextOutlined, SendOutlined } from '@ant-design/icons'
@@ -166,3 +166,4 @@ export const SmsControlCard: React.FC<SmsControlCardProps> = ({
export default SmsControlCard

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'
import { Layout, Menu, Button, Popconfirm, App, Dropdown, Table, Modal, Space, Tag, Badge, Input } from 'antd'
import { Layout, Menu, Button, Popconfirm, App, Dropdown, Table, Modal, Space, Tag, Badge, Input, Select } from 'antd'
import { NodeIndexOutlined, ArrowLeftOutlined, HomeOutlined } from '@ant-design/icons'
import { useDispatch, useSelector } from 'react-redux'
import {
@@ -18,7 +18,8 @@ import {
ControlOutlined,
AppstoreOutlined,
PlayCircleOutlined,
StopOutlined
StopOutlined,
TeamOutlined
} from '@ant-design/icons'
import DeviceScreen from './Device/DeviceScreen'
// import DeviceCamera from './Device/DeviceCamera'
@@ -26,6 +27,7 @@ import ControlPanel from './Control/ControlPanel'
import APKManager from './APKManager'
import ConnectDialog from './Connection/ConnectDialog'
import ScreenReader from './Device/ScreenReader'
import TenantManagement from './Admin/TenantManagement'
// import GalleryView from './Gallery/GalleryView'
import type { RootState, AppDispatch } from '../store/store'
import { setConnectionStatus, setWebSocket, setServerUrl } from '../store/slices/connectionSlice'
@@ -38,6 +40,7 @@ import apiClient from '../services/apiClient'
import DeviceFilter from './Control/DeviceFilter'
const { Header, Sider, Content } = Layout
type DeviceVideoSessionMode = 'ws_default' | 'webrtc_boost' | 'fallback_ws'
/**
* 远程控制主应用组件
@@ -48,8 +51,12 @@ const RemoteControlApp: React.FC = () => {
const { status: connectionStatus, serverUrl, webSocket } = useSelector((state: RootState) => state.connection)
const { selectedDeviceId, connectedDevices } = useSelector((state: RootState) => state.devices)
const filteredDevices = useSelector((state: RootState) => selectFilteredDevices(state))
const { cameraViewVisible, operationEnabled } = useSelector((state: RootState) => state.ui)
const { operationEnabled } = useSelector((state: RootState) => state.ui)
const currentUser = useSelector(selectUser)
const currentRole = currentUser?.role || 'member'
const canManageTenant = currentRole === 'superadmin'
const canUseApkManager = currentRole === 'superadmin' || currentUser?.allowBuild !== false
const currentRoleLabel = currentRole === 'superadmin' ? '超级管理员' : currentRole === 'leader' ? '组长' : '组员'
const [connectDialogVisible, setConnectDialogVisible] = useState(false)
const [currentView, setCurrentView] = useState('control')
@@ -68,13 +75,14 @@ const RemoteControlApp: React.FC = () => {
// 弹框状态管理 / 独立页面模式
const [screenModalVisible, setScreenModalVisible] = useState(false)
const [selectedDeviceForModal, setSelectedDeviceForModal] = useState<any>(null)
const [screenSize, setScreenSize] = useState<{ width: number, height: number } | null>(null)
// Transfer device state
const [transferModalVisible, setTransferModalVisible] = useState(false)
const [transferringDevice, setTransferringDevice] = useState<any>(null)
const [newServerUrl, setNewServerUrl] = useState('')
const [transferTargets, setTransferTargets] = useState<Array<any>>([])
const [transferTargetUserId, setTransferTargetUserId] = useState('')
const [transferring, setTransferring] = useState(false)
const [videoModeByDevice, setVideoModeByDevice] = useState<Record<string, DeviceVideoSessionMode>>({})
// 是否通过 URL 参数打开独立控制页面
const [standaloneControlDeviceId, setStandaloneControlDeviceId] = useState<string | null>(null)
@@ -134,6 +142,33 @@ const RemoteControlApp: React.FC = () => {
return () => window.removeEventListener('resize', handleResize)
}, [menuCollapsed])
useEffect(() => {
const handleVideoModeChanged = (event: Event) => {
const customEvent = event as CustomEvent<{ deviceId?: string, mode?: DeviceVideoSessionMode }>
const deviceId = customEvent.detail?.deviceId
const mode = customEvent.detail?.mode
if (!deviceId || !mode) return
setVideoModeByDevice((prev) => ({ ...prev, [deviceId]: mode }))
}
window.addEventListener('remote-control:video-mode-changed', handleVideoModeChanged as EventListener)
return () => {
window.removeEventListener('remote-control:video-mode-changed', handleVideoModeChanged as EventListener)
}
}, [])
useEffect(() => {
if (currentView === 'tenant' && !canManageTenant) {
setCurrentView('control')
}
}, [currentView, canManageTenant])
useEffect(() => {
if (currentView === 'apk' && !canUseApkManager) {
setCurrentView('control')
}
}, [currentView, canUseApkManager])
// Disconnect WebSocket on logout
useEffect(() => {
if (!currentUser && webSocket) {
@@ -177,20 +212,39 @@ const RemoteControlApp: React.FC = () => {
normalizedUrl = normalizedUrl.replace(/^wss:\/\//, 'https://')
}
const HEARTBEAT_INTERVAL_MS = 8000
const HEARTBEAT_TIMEOUT_MS = 26000
let heartbeatTimer: ReturnType<typeof window.setInterval> | null = null
let lastHeartbeatAckTime = Date.now()
let heartbeatSeq = 0
const clearHeartbeatMonitor = () => {
if (heartbeatTimer) {
window.clearInterval(heartbeatTimer)
heartbeatTimer = null
}
}
const markHeartbeatAck = () => {
lastHeartbeatAckTime = Date.now()
}
// Socket.IO v4 client config
const socket = io(normalizedUrl, {
// Transport: polling first then upgrade to websocket for reliability
transports: ['polling', 'websocket'],
// Transport: websocket first with polling fallback
transports: ['websocket', 'polling'],
upgrade: true,
rememberUpgrade: true,
// Reconnection config
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 10000,
reconnectionDelay: 800,
reconnectionDelayMax: 6000,
reconnectionAttempts: Infinity,
randomizationFactor: 0.3,
// Timeout config (match server side)
timeout: 60000,
timeout: 30000,
// Force new connection
forceNew: true,
@@ -202,6 +256,32 @@ const RemoteControlApp: React.FC = () => {
}
})
const startHeartbeatMonitor = () => {
clearHeartbeatMonitor()
markHeartbeatAck()
heartbeatTimer = window.setInterval(() => {
if (!socket.connected) return
const now = Date.now()
heartbeatSeq += 1
socket.emit('heartbeat', {
timestamp: now,
seq: heartbeatSeq,
source: 'web_client'
})
if (now - lastHeartbeatAckTime > HEARTBEAT_TIMEOUT_MS) {
console.warn('[Socket] heartbeat timeout, force reconnect')
dispatch(setConnectionStatus('connecting'))
markHeartbeatAck()
socket.disconnect()
socket.connect()
}
}, HEARTBEAT_INTERVAL_MS)
}
socket.on('connect', () => {
console.log('已连接到服务器')
dispatch(setConnectionStatus('connected'))
@@ -219,10 +299,13 @@ const RemoteControlApp: React.FC = () => {
timestamp: Date.now()
})
startHeartbeatMonitor()
})
socket.on('disconnect', (reason) => {
console.log('[Socket] disconnect, reason:', reason)
clearHeartbeatMonitor()
if (reason === 'io server disconnect' || reason === 'io client disconnect') {
// Server or client explicitly disconnected, no auto-reconnect
@@ -239,6 +322,14 @@ const RemoteControlApp: React.FC = () => {
}
})
socket.on('heartbeat_ack', () => {
markHeartbeatAck()
})
socket.on('CONNECTION_TEST_RESPONSE', () => {
markHeartbeatAck()
})
socket.on('connect_error', (error) => {
console.error('[Socket] connect_error:', error?.message || error)
@@ -275,6 +366,7 @@ const RemoteControlApp: React.FC = () => {
dispatch(logout())
// 断开WebSocket连接
clearHeartbeatMonitor()
socket.disconnect()
})
@@ -487,64 +579,71 @@ const RemoteControlApp: React.FC = () => {
}
// Transfer device
const handleTransferDevice = (device: any) => {
const handleTransferDevice = async (device: any) => {
setTransferringDevice(device)
setNewServerUrl('')
setTransferTargetUserId('')
setTransferTargets([])
setTransferModalVisible(true)
try {
const result = await apiClient.get<any>('/api/auth/transfer-targets')
if (result?.success) {
const users = Array.isArray(result.users) ? result.users : []
setTransferTargets(users)
} else {
message.error(result?.message || '获取可转移成员失败')
}
} catch (error: any) {
console.error('获取可转移成员失败:', error)
message.error(error?.message || '获取可转移成员失败')
}
}
const handleConfirmTransfer = () => {
if (!webSocket) {
message.error('WebSocket未连接')
return
}
const handleConfirmTransfer = async () => {
if (!transferringDevice) {
message.error('设备信息缺失')
return
}
if (!newServerUrl.trim()) {
message.error('请输入新的服务器地址')
if (!transferTargetUserId) {
message.error('请选择目标用户')
return
}
setTransferring(true)
console.log('[Device] Transfer:', transferringDevice.id, 'to server:', newServerUrl)
// 发送转设备请求(使用修改服务器地址的指令)
webSocket.emit('client_event', {
type: 'CHANGE_SERVER_URL',
data: {
deviceId: transferringDevice.id,
data: {
serverUrl: newServerUrl.trim()
}
}
try {
const result = await apiClient.post<any>(`/api/devices/${transferringDevice.id}/transfer`, {
targetUserId: transferTargetUserId
})
// 显示成功消息并关闭弹窗
setTimeout(() => {
message.success('转设备指令已发送')
setTransferModalVisible(false)
setNewServerUrl('')
setTransferringDevice(null)
setTransferring(false)
}, 1000)
if (!result?.success) {
message.error(result?.message || '设备转移失败')
return
}
// Check if user is superadmin
const isSuperAdmin = () => {
try {
const userStr = localStorage.getItem('auth_user')
if (userStr) {
const user = JSON.parse(userStr)
return user?.role === 'superadmin'
message.success(result.message || '设备转移成功')
const latestDevices = await apiClient.get<any[]>('/api/devices')
const latestIds = new Set((latestDevices || []).map((item: any) => item.id))
connectedDevices.forEach((item) => {
if (!latestIds.has(item.id)) {
dispatch(removeDevice(item.id))
}
} catch (error) {
console.error('获取用户角色失败:', error)
})
;(latestDevices || []).forEach((device: any) => {
dispatch(addDevice(device))
})
setTransferModalVisible(false)
setTransferTargetUserId('')
setTransferringDevice(null)
setTransferTargets([])
} catch (error: any) {
console.error('设备转移失败:', error)
message.error(error?.message || '设备转移失败')
} finally {
setTransferring(false)
}
return false
}
// Handle device action
@@ -579,7 +678,7 @@ const RemoteControlApp: React.FC = () => {
}
// If superadmin, check if device is being controlled
if (isSuperAdmin()) {
if (canManageTenant) {
try {
const result = await apiClient.get<any>(`/api/devices/${device.id}/controller`)
@@ -660,6 +759,38 @@ const RemoteControlApp: React.FC = () => {
}
// Handle system key
const getDeviceVideoMode = (deviceId?: string | null): DeviceVideoSessionMode => {
if (!deviceId) return 'ws_default'
return videoModeByDevice[deviceId] || 'ws_default'
}
const triggerOverclockMode = (deviceId: string) => {
if (!webSocket) {
message.error('WebSocket未连接')
return
}
const currentMode = getDeviceVideoMode(deviceId)
if (currentMode === 'webrtc_boost') {
window.dispatchEvent(new CustomEvent('remote-control:ws-default-mode', {
detail: { deviceId }
}))
message.success('已切回默认模式')
return
}
webSocket.emit('control_message', {
type: 'SCREEN_CAPTURE_RESUME',
deviceId,
data: {},
timestamp: Date.now()
})
window.dispatchEvent(new CustomEvent('remote-control:overclock-mode', {
detail: { deviceId }
}))
message.success('已发送超频模式指令')
}
const handleSystemKey = (keyType: 'BACK' | 'HOME' | 'RECENTS') => {
if (!webSocket || !selectedDeviceForModal) {
message.error('WebSocket未连接或未选择设备')
@@ -824,6 +955,22 @@ const RemoteControlApp: React.FC = () => {
ellipsis: true,
render: (text: string) => text || '-'
},
...(currentRole === 'superadmin' ? [
{
title: '归属用户',
key: 'ownerUsername',
width: 160,
ellipsis: true,
render: (_: unknown, record: any) => record.ownerUsername || record.ownerUserId || '-',
},
{
title: '归属组',
key: 'ownerGroupName',
width: 160,
ellipsis: true,
render: (_: unknown, record: any) => record.ownerGroupName || record.ownerGroupId || '-',
},
] : []),
{
title: '锁屏状态',
dataIndex: 'isLocked',
@@ -882,7 +1029,7 @@ const RemoteControlApp: React.FC = () => {
{
title: '操作',
key: 'actions',
width: isSuperAdmin() ? 180 : 120,
width: 180,
render: (record: any) => (
<Space size="small">
<Button
@@ -895,15 +1042,14 @@ const RemoteControlApp: React.FC = () => {
>
</Button>
{isSuperAdmin() && (
{currentUser && (
<Button
type="default"
size="small"
icon={<NodeIndexOutlined />}
onClick={() => handleTransferDevice(record)}
disabled={record.status === 'offline'}
style={{ minWidth: '50px' }}
title="转设备到其他服务器"
title="转设备归属"
>
</Button>
@@ -943,11 +1089,16 @@ const RemoteControlApp: React.FC = () => {
icon: <MobileOutlined />,
label: '设备控制',
},
{
...(canUseApkManager ? [{
key: 'apk',
icon: <AndroidOutlined />,
label: 'APK 管理',
},
}] : []),
...(canManageTenant ? [{
key: 'tenant',
icon: <TeamOutlined />,
label: '租户管理',
}] : []),
{
key: 'settings',
icon: <SettingOutlined />,
@@ -1004,7 +1155,7 @@ const RemoteControlApp: React.FC = () => {
</div>
)
case 'apk':
return (
return canUseApkManager ? (
<div style={{
padding: 0,
flex: 1,
@@ -1026,6 +1177,24 @@ const RemoteControlApp: React.FC = () => {
: `${window.location.protocol}//${window.location.hostname}`
})()} />
</div>
) : null
case 'tenant':
return canManageTenant ? (
<TenantManagement />
) : (
<div style={{
padding: '24px',
flex: 1,
background: 'var(--md-surface-container-lowest)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
gap: '12px'
}}>
<TeamOutlined style={{ fontSize: '56px', color: 'var(--md-outline-variant)' }} />
<div style={{ fontSize: '16px', color: 'var(--md-on-surface-variant)' }}></div>
</div>
)
case 'settings':
return (
@@ -1088,10 +1257,7 @@ const RemoteControlApp: React.FC = () => {
return (
<div className="standalone-control-page">
{/* 左侧屏幕区域 - 自适应视口高度 */}
<div className="control-screen-area" style={{
width: screenSize ? Math.min(screenSize.width * 2, window.innerWidth * 0.55) : '55vw',
maxWidth: '55vw'
}}>
<div className="control-screen-area">
{/* 工具条 */}
<div className="control-toolbar">
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
@@ -1163,11 +1329,26 @@ const RemoteControlApp: React.FC = () => {
webSocket.emit('control_message', { type: 'POWER_SLEEP', deviceId: selectedDeviceForModal.id, data: {}, timestamp: Date.now() })
message.success('已发送锁定屏幕')
}}></Button>
<Button size="small" icon={<PlayCircleOutlined />} onClick={() => {
if (!webSocket) { message.error('WebSocket未连接'); return }
webSocket.emit('control_message', { type: 'SCREEN_CAPTURE_RESUME', deviceId: selectedDeviceForModal.id, data: {}, timestamp: Date.now() })
message.success('已发送开启屏幕捕获指令')
}} style={{ background: 'var(--md-success)', borderColor: 'var(--md-success)', color: 'var(--md-on-primary)' }}></Button>
<Button
size="small"
icon={<PlayCircleOutlined />}
onClick={() => triggerOverclockMode(selectedDeviceForModal.id)}
style={{
background: getDeviceVideoMode(selectedDeviceForModal?.id) === 'webrtc_boost'
? 'var(--md-primary)'
: 'var(--md-success)',
borderColor: getDeviceVideoMode(selectedDeviceForModal?.id) === 'webrtc_boost'
? 'var(--md-primary)'
: 'var(--md-success)',
color: 'var(--md-on-primary)'
}}
>
{getDeviceVideoMode(selectedDeviceForModal?.id) === 'webrtc_boost'
? '退出超频'
: getDeviceVideoMode(selectedDeviceForModal?.id) === 'fallback_ws'
? '重试超频'
: '超频模式'}
</Button>
<Button size="small" icon={<StopOutlined />} onClick={() => {
if (!webSocket) { message.error('WebSocket未连接'); return }
webSocket.emit('control_message', { type: 'SCREEN_CAPTURE_PAUSE', deviceId: selectedDeviceForModal.id, data: {}, timestamp: Date.now() })
@@ -1217,7 +1398,6 @@ const RemoteControlApp: React.FC = () => {
<div style={{ flex: 1, minHeight: 0, position: 'relative', overflow: 'hidden', display: 'flex', alignItems: 'stretch', justifyContent: 'stretch' }}>
<DeviceScreen
deviceId={selectedDeviceForModal.id}
onScreenSizeChange={setScreenSize}
/>
</div>
{/* 文本输入 - 阅读器未启用时显示在屏幕下方 */}
@@ -1355,7 +1535,7 @@ const RemoteControlApp: React.FC = () => {
{currentUser?.username || 'Unknown'}
</div>
<div style={{ fontSize: '12px', color: 'var(--md-on-surface-variant)' }}>
{currentRoleLabel}{currentUser?.groupName ? ` · ${currentUser.groupName}` : ''}
</div>
</div>
),
@@ -1492,7 +1672,6 @@ const RemoteControlApp: React.FC = () => {
open={screenModalVisible}
onCancel={() => {
setScreenModalVisible(false)
setScreenSize(null)
}}
footer={null}
width="95vw"
@@ -1508,9 +1687,9 @@ const RemoteControlApp: React.FC = () => {
destroyOnHidden
>
{selectedDeviceForModal && (
<div style={{ height: '100%', display: 'flex', flexDirection: 'row', background: 'var(--md-surface-container-lowest)' }}>
<div className="modal-control-layout">
{/* 左侧屏幕区域 */}
<div className="control-screen-area" style={{ width: '55%', maxWidth: '55%' }}>
<div className="control-screen-area">
{/* 工具条 */}
<div className="control-toolbar">
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
@@ -1589,7 +1768,7 @@ const RemoteControlApp: React.FC = () => {
})()
}}>
<div style={{ flex: 1, minHeight: 0, position: 'relative', overflow: 'hidden', display: 'flex', alignItems: 'stretch', justifyContent: 'stretch' }}>
<DeviceScreen deviceId={selectedDeviceForModal.id} onScreenSizeChange={setScreenSize} />
<DeviceScreen deviceId={selectedDeviceForModal.id} />
</div>
{(() => {
const current = connectedDevices.find(d => d.id === selectedDeviceForModal.id)
@@ -1637,18 +1816,20 @@ const RemoteControlApp: React.FC = () => {
{/* 转设备弹窗 */}
<Modal
title="转设备到其他服务器"
title="转设备归属"
open={transferModalVisible}
onCancel={() => {
setTransferModalVisible(false)
setNewServerUrl('')
setTransferTargetUserId('')
setTransferringDevice(null)
setTransferTargets([])
}}
footer={[
<Button key="cancel" onClick={() => {
setTransferModalVisible(false)
setNewServerUrl('')
setTransferTargetUserId('')
setTransferringDevice(null)
setTransferTargets([])
}}>
</Button>,
@@ -1657,37 +1838,42 @@ const RemoteControlApp: React.FC = () => {
type="primary"
onClick={handleConfirmTransfer}
loading={transferring}
disabled={!newServerUrl.trim()}
disabled={!transferTargetUserId}
>
</Button>
]}
width={500}
>
<div style={{ padding: '16px 0' }}>
<p style={{ marginBottom: 16, color: 'var(--md-on-surface-variant)' }}>
<strong>{transferringDevice?.name}</strong>
<strong>{transferringDevice?.name}</strong>
</p>
<Input
placeholder="请输入新的服务器地址,例如: ws://192.168.1.100:3001"
value={newServerUrl}
onChange={(e) => setNewServerUrl(e.target.value)}
style={{ marginBottom: 16 }}
<Select
placeholder="请选择目标用户"
value={transferTargetUserId || undefined}
onChange={(value) => setTransferTargetUserId(value)}
style={{ width: '100%', marginBottom: 16 }}
options={transferTargets.map((item) => ({
value: item.id,
label: `${item.username}${item.groupName ? ` (${item.groupName})` : ''}`
}))}
showSearch
optionFilterProp="label"
/>
<div style={{
padding: '12px',
background: 'var(--md-success-container)',
background: 'var(--md-surface-container-low)',
border: '1px solid var(--md-outline-variant)',
borderRadius: '6px',
fontSize: '12px',
color: 'var(--md-success)'
color: 'var(--md-on-surface-variant)'
}}>
<strong></strong>
<ul style={{ margin: '8px 0 0 0', paddingLeft: '20px' }}>
<li>ws://ip:port 或 wss://域名)</li>
<li>ws和wss的区别是 http协议https协议</li>
<li></li>
<li></li>
<li>superadmin leader member </li>
<li>member </li>
<li>线</li>
</ul>
</div>
</div>
@@ -1697,3 +1883,4 @@ const RemoteControlApp: React.FC = () => {
}
export default RemoteControlApp

View File

@@ -6,6 +6,10 @@ import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/tool
export interface User {
id: string
username: string
role?: 'superadmin' | 'leader' | 'member'
groupId?: string
groupName?: string
allowBuild?: boolean
lastLoginAt?: Date
}

View File

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

View File

@@ -31,6 +31,7 @@ export interface ScreenDisplayConfig {
showTouchIndicator: boolean
enableSound: boolean
fullscreen: boolean
exposure: number
}
/**
@@ -119,6 +120,7 @@ const initialState: UiState = {
showTouchIndicator: true,
enableSound: false,
fullscreen: false,
exposure: 100,
},
screenReader: {
enabled: false,