feat: upload latest web source changes
This commit is contained in:
11
.editorconfig
Normal file
11
.editorconfig
Normal 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
18
.env.example
Normal 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
20
package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@reduxjs/toolkit": "^2.8.2",
|
"@reduxjs/toolkit": "^2.8.2",
|
||||||
"antd": "^5.26.0",
|
"antd": "^5.26.0",
|
||||||
|
"mpegts.js": "^1.8.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
@@ -2455,6 +2456,11 @@
|
|||||||
"node": ">=10.0.0"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.9",
|
"version": "0.25.9",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
|
||||||
@@ -3148,6 +3154,15 @@
|
|||||||
"node": "*"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -4633,6 +4648,11 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -7,11 +7,13 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"autotest:android-control": "node ./scripts/adb_control_matrix_orchestrator.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@reduxjs/toolkit": "^2.8.2",
|
"@reduxjs/toolkit": "^2.8.2",
|
||||||
"antd": "^5.26.0",
|
"antd": "^5.26.0",
|
||||||
|
"mpegts.js": "^1.8.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
|
|||||||
69
scripts/ADB_CONTROL_MATRIX_README.md
Normal file
69
scripts/ADB_CONTROL_MATRIX_README.md
Normal 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
|
||||||
|
|
||||||
1575
scripts/adb_control_matrix_orchestrator.mjs
Normal file
1575
scripts/adb_control_matrix_orchestrator.mjs
Normal file
File diff suppressed because it is too large
Load Diff
231
scripts/keepalive_app_list_probe.mjs
Normal file
231
scripts/keepalive_app_list_probe.mjs
Normal 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)
|
||||||
|
})
|
||||||
409
scripts/redmi_first_permission_flow.mjs
Normal file
409
scripts/redmi_first_permission_flow.mjs
Normal 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)
|
||||||
|
})
|
||||||
56
src/App.css
56
src/App.css
@@ -124,6 +124,17 @@
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
background: var(--md-surface);
|
background: var(--md-surface);
|
||||||
overflow: hidden;
|
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 */
|
/* Control screen area */
|
||||||
@@ -133,10 +144,22 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
border-right: 1px solid var(--md-outline-variant);
|
border-right: 1px solid var(--md-outline-variant);
|
||||||
background: var(--md-surface-container-lowest);
|
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;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1366px) {
|
||||||
|
.control-screen-area {
|
||||||
|
flex-basis: clamp(360px, 60vw, 860px);
|
||||||
|
width: clamp(360px, 60vw, 860px);
|
||||||
|
max-width: 72vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Toolbar */
|
/* Toolbar */
|
||||||
.control-toolbar {
|
.control-toolbar {
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
@@ -171,6 +194,7 @@
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +206,8 @@
|
|||||||
background: var(--md-surface-container-low);
|
background: var(--md-surface-container-low);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-screen-panel {
|
.device-screen-panel {
|
||||||
@@ -191,6 +217,34 @@
|
|||||||
background: var(--md-surface-container);
|
background: var(--md-surface-container);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 */
|
/* Text input bar */
|
||||||
|
|||||||
891
src/components/Admin/TenantManagement.tsx
Normal file
891
src/components/Admin/TenantManagement.tsx
Normal 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
4479
src/components/Control/ControlPanel.tsx.bad-20260224
Normal file
4479
src/components/Control/ControlPanel.tsx.bad-20260224
Normal file
File diff suppressed because it is too large
Load Diff
@@ -44,8 +44,6 @@ export interface DebugFunctionsCardProps {
|
|||||||
onSwipeDown?: () => void
|
onSwipeDown?: () => void
|
||||||
onSwipeLeft?: () => void
|
onSwipeLeft?: () => void
|
||||||
onSwipeRight?: () => void
|
onSwipeRight?: () => void
|
||||||
onPullDownLeft?: () => void
|
|
||||||
onPullDownRight?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DebugFunctionsCard: React.FC<DebugFunctionsCardProps> = ({
|
export const DebugFunctionsCard: React.FC<DebugFunctionsCardProps> = ({
|
||||||
@@ -78,8 +76,6 @@ export const DebugFunctionsCard: React.FC<DebugFunctionsCardProps> = ({
|
|||||||
onSwipeDown,
|
onSwipeDown,
|
||||||
onSwipeLeft,
|
onSwipeLeft,
|
||||||
onSwipeRight,
|
onSwipeRight,
|
||||||
onPullDownLeft,
|
|
||||||
onPullDownRight,
|
|
||||||
children
|
children
|
||||||
}) => {
|
}) => {
|
||||||
const [collapsed, setCollapsed] = useState<boolean>(defaultCollapsed)
|
const [collapsed, setCollapsed] = useState<boolean>(defaultCollapsed)
|
||||||
@@ -266,7 +262,7 @@ export const DebugFunctionsCard: React.FC<DebugFunctionsCardProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* 手势操作(可选) */}
|
{/* 手势操作(可选) */}
|
||||||
{!collapsed && (onSwipeUp || onSwipeDown || onSwipeLeft || onSwipeRight || onPullDownLeft || onPullDownRight) && (
|
{!collapsed && (onSwipeUp || onSwipeDown || onSwipeLeft || onSwipeRight) && (
|
||||||
<Row gutter={[8, 8]} style={{ marginTop: 8 }}>
|
<Row gutter={[8, 8]} style={{ marginTop: 8 }}>
|
||||||
{onSwipeUp && (
|
{onSwipeUp && (
|
||||||
<Col span={12}><Button block onClick={onSwipeUp}>↑ 上滑</Button></Col>
|
<Col span={12}><Button block onClick={onSwipeUp}>↑ 上滑</Button></Col>
|
||||||
@@ -280,12 +276,6 @@ export const DebugFunctionsCard: React.FC<DebugFunctionsCardProps> = ({
|
|||||||
{onSwipeRight && (
|
{onSwipeRight && (
|
||||||
<Col span={12}><Button block onClick={onSwipeRight}>→ 右滑</Button></Col>
|
<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>
|
</Row>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -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 { Card, Row, Col, Button, Input, InputNumber, Table, Modal } from 'antd'
|
||||||
import { FileTextOutlined, SendOutlined } from '@ant-design/icons'
|
import { FileTextOutlined, SendOutlined } from '@ant-design/icons'
|
||||||
|
|
||||||
@@ -166,3 +166,4 @@ export const SmsControlCard: React.FC<SmsControlCardProps> = ({
|
|||||||
export default SmsControlCard
|
export default SmsControlCard
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
1400
src/components/Device/DeviceScreen.tsx.bad-20260224
Normal file
1400
src/components/Device/DeviceScreen.tsx.bad-20260224
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
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 { NodeIndexOutlined, ArrowLeftOutlined, HomeOutlined } from '@ant-design/icons'
|
||||||
import { useDispatch, useSelector } from 'react-redux'
|
import { useDispatch, useSelector } from 'react-redux'
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +18,8 @@ import {
|
|||||||
ControlOutlined,
|
ControlOutlined,
|
||||||
AppstoreOutlined,
|
AppstoreOutlined,
|
||||||
PlayCircleOutlined,
|
PlayCircleOutlined,
|
||||||
StopOutlined
|
StopOutlined,
|
||||||
|
TeamOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import DeviceScreen from './Device/DeviceScreen'
|
import DeviceScreen from './Device/DeviceScreen'
|
||||||
// import DeviceCamera from './Device/DeviceCamera'
|
// import DeviceCamera from './Device/DeviceCamera'
|
||||||
@@ -26,6 +27,7 @@ import ControlPanel from './Control/ControlPanel'
|
|||||||
import APKManager from './APKManager'
|
import APKManager from './APKManager'
|
||||||
import ConnectDialog from './Connection/ConnectDialog'
|
import ConnectDialog from './Connection/ConnectDialog'
|
||||||
import ScreenReader from './Device/ScreenReader'
|
import ScreenReader from './Device/ScreenReader'
|
||||||
|
import TenantManagement from './Admin/TenantManagement'
|
||||||
// import GalleryView from './Gallery/GalleryView'
|
// import GalleryView from './Gallery/GalleryView'
|
||||||
import type { RootState, AppDispatch } from '../store/store'
|
import type { RootState, AppDispatch } from '../store/store'
|
||||||
import { setConnectionStatus, setWebSocket, setServerUrl } from '../store/slices/connectionSlice'
|
import { setConnectionStatus, setWebSocket, setServerUrl } from '../store/slices/connectionSlice'
|
||||||
@@ -38,6 +40,7 @@ import apiClient from '../services/apiClient'
|
|||||||
import DeviceFilter from './Control/DeviceFilter'
|
import DeviceFilter from './Control/DeviceFilter'
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout
|
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 { status: connectionStatus, serverUrl, webSocket } = useSelector((state: RootState) => state.connection)
|
||||||
const { selectedDeviceId, connectedDevices } = useSelector((state: RootState) => state.devices)
|
const { selectedDeviceId, connectedDevices } = useSelector((state: RootState) => state.devices)
|
||||||
const filteredDevices = useSelector((state: RootState) => selectFilteredDevices(state))
|
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 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 [connectDialogVisible, setConnectDialogVisible] = useState(false)
|
||||||
const [currentView, setCurrentView] = useState('control')
|
const [currentView, setCurrentView] = useState('control')
|
||||||
@@ -68,13 +75,14 @@ const RemoteControlApp: React.FC = () => {
|
|||||||
// 弹框状态管理 / 独立页面模式
|
// 弹框状态管理 / 独立页面模式
|
||||||
const [screenModalVisible, setScreenModalVisible] = useState(false)
|
const [screenModalVisible, setScreenModalVisible] = useState(false)
|
||||||
const [selectedDeviceForModal, setSelectedDeviceForModal] = useState<any>(null)
|
const [selectedDeviceForModal, setSelectedDeviceForModal] = useState<any>(null)
|
||||||
const [screenSize, setScreenSize] = useState<{ width: number, height: number } | null>(null)
|
|
||||||
|
|
||||||
// Transfer device state
|
// Transfer device state
|
||||||
const [transferModalVisible, setTransferModalVisible] = useState(false)
|
const [transferModalVisible, setTransferModalVisible] = useState(false)
|
||||||
const [transferringDevice, setTransferringDevice] = useState<any>(null)
|
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 [transferring, setTransferring] = useState(false)
|
||||||
|
const [videoModeByDevice, setVideoModeByDevice] = useState<Record<string, DeviceVideoSessionMode>>({})
|
||||||
|
|
||||||
// 是否通过 URL 参数打开独立控制页面
|
// 是否通过 URL 参数打开独立控制页面
|
||||||
const [standaloneControlDeviceId, setStandaloneControlDeviceId] = useState<string | null>(null)
|
const [standaloneControlDeviceId, setStandaloneControlDeviceId] = useState<string | null>(null)
|
||||||
@@ -134,6 +142,33 @@ const RemoteControlApp: React.FC = () => {
|
|||||||
return () => window.removeEventListener('resize', handleResize)
|
return () => window.removeEventListener('resize', handleResize)
|
||||||
}, [menuCollapsed])
|
}, [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
|
// Disconnect WebSocket on logout
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentUser && webSocket) {
|
if (!currentUser && webSocket) {
|
||||||
@@ -177,20 +212,39 @@ const RemoteControlApp: React.FC = () => {
|
|||||||
normalizedUrl = normalizedUrl.replace(/^wss:\/\//, 'https://')
|
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
|
// Socket.IO v4 client config
|
||||||
const socket = io(normalizedUrl, {
|
const socket = io(normalizedUrl, {
|
||||||
// Transport: polling first then upgrade to websocket for reliability
|
// Transport: websocket first with polling fallback
|
||||||
transports: ['polling', 'websocket'],
|
transports: ['websocket', 'polling'],
|
||||||
upgrade: true,
|
upgrade: true,
|
||||||
|
rememberUpgrade: true,
|
||||||
|
|
||||||
// Reconnection config
|
// Reconnection config
|
||||||
reconnection: true,
|
reconnection: true,
|
||||||
reconnectionDelay: 1000,
|
reconnectionDelay: 800,
|
||||||
reconnectionDelayMax: 10000,
|
reconnectionDelayMax: 6000,
|
||||||
reconnectionAttempts: Infinity,
|
reconnectionAttempts: Infinity,
|
||||||
|
randomizationFactor: 0.3,
|
||||||
|
|
||||||
// Timeout config (match server side)
|
// Timeout config (match server side)
|
||||||
timeout: 60000,
|
timeout: 30000,
|
||||||
|
|
||||||
// Force new connection
|
// Force new connection
|
||||||
forceNew: true,
|
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', () => {
|
socket.on('connect', () => {
|
||||||
console.log('已连接到服务器')
|
console.log('已连接到服务器')
|
||||||
dispatch(setConnectionStatus('connected'))
|
dispatch(setConnectionStatus('connected'))
|
||||||
@@ -219,10 +299,13 @@ const RemoteControlApp: React.FC = () => {
|
|||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
startHeartbeatMonitor()
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
socket.on('disconnect', (reason) => {
|
socket.on('disconnect', (reason) => {
|
||||||
console.log('[Socket] disconnect, reason:', reason)
|
console.log('[Socket] disconnect, reason:', reason)
|
||||||
|
clearHeartbeatMonitor()
|
||||||
|
|
||||||
if (reason === 'io server disconnect' || reason === 'io client disconnect') {
|
if (reason === 'io server disconnect' || reason === 'io client disconnect') {
|
||||||
// Server or client explicitly disconnected, no auto-reconnect
|
// 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) => {
|
socket.on('connect_error', (error) => {
|
||||||
console.error('[Socket] connect_error:', error?.message || error)
|
console.error('[Socket] connect_error:', error?.message || error)
|
||||||
|
|
||||||
@@ -275,6 +366,7 @@ const RemoteControlApp: React.FC = () => {
|
|||||||
dispatch(logout())
|
dispatch(logout())
|
||||||
|
|
||||||
// 断开WebSocket连接
|
// 断开WebSocket连接
|
||||||
|
clearHeartbeatMonitor()
|
||||||
socket.disconnect()
|
socket.disconnect()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -487,64 +579,71 @@ const RemoteControlApp: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Transfer device
|
// Transfer device
|
||||||
const handleTransferDevice = (device: any) => {
|
const handleTransferDevice = async (device: any) => {
|
||||||
setTransferringDevice(device)
|
setTransferringDevice(device)
|
||||||
setNewServerUrl('')
|
setTransferTargetUserId('')
|
||||||
|
setTransferTargets([])
|
||||||
setTransferModalVisible(true)
|
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 = () => {
|
const handleConfirmTransfer = async () => {
|
||||||
if (!webSocket) {
|
|
||||||
message.error('WebSocket未连接')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!transferringDevice) {
|
if (!transferringDevice) {
|
||||||
message.error('设备信息缺失')
|
message.error('设备信息缺失')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!newServerUrl.trim()) {
|
if (!transferTargetUserId) {
|
||||||
message.error('请输入新的服务器地址')
|
message.error('请选择目标用户')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setTransferring(true)
|
setTransferring(true)
|
||||||
console.log('[Device] Transfer:', transferringDevice.id, 'to server:', newServerUrl)
|
try {
|
||||||
|
const result = await apiClient.post<any>(`/api/devices/${transferringDevice.id}/transfer`, {
|
||||||
// 发送转设备请求(使用修改服务器地址的指令)
|
targetUserId: transferTargetUserId
|
||||||
webSocket.emit('client_event', {
|
|
||||||
type: 'CHANGE_SERVER_URL',
|
|
||||||
data: {
|
|
||||||
deviceId: transferringDevice.id,
|
|
||||||
data: {
|
|
||||||
serverUrl: newServerUrl.trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 显示成功消息并关闭弹窗
|
if (!result?.success) {
|
||||||
setTimeout(() => {
|
message.error(result?.message || '设备转移失败')
|
||||||
message.success('转设备指令已发送')
|
return
|
||||||
setTransferModalVisible(false)
|
|
||||||
setNewServerUrl('')
|
|
||||||
setTransferringDevice(null)
|
|
||||||
setTransferring(false)
|
|
||||||
}, 1000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is superadmin
|
message.success(result.message || '设备转移成功')
|
||||||
const isSuperAdmin = () => {
|
|
||||||
try {
|
const latestDevices = await apiClient.get<any[]>('/api/devices')
|
||||||
const userStr = localStorage.getItem('auth_user')
|
const latestIds = new Set((latestDevices || []).map((item: any) => item.id))
|
||||||
if (userStr) {
|
connectedDevices.forEach((item) => {
|
||||||
const user = JSON.parse(userStr)
|
if (!latestIds.has(item.id)) {
|
||||||
return user?.role === 'superadmin'
|
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
|
// Handle device action
|
||||||
@@ -579,7 +678,7 @@ const RemoteControlApp: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If superadmin, check if device is being controlled
|
// If superadmin, check if device is being controlled
|
||||||
if (isSuperAdmin()) {
|
if (canManageTenant) {
|
||||||
try {
|
try {
|
||||||
const result = await apiClient.get<any>(`/api/devices/${device.id}/controller`)
|
const result = await apiClient.get<any>(`/api/devices/${device.id}/controller`)
|
||||||
|
|
||||||
@@ -660,6 +759,38 @@ const RemoteControlApp: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle system key
|
// 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') => {
|
const handleSystemKey = (keyType: 'BACK' | 'HOME' | 'RECENTS') => {
|
||||||
if (!webSocket || !selectedDeviceForModal) {
|
if (!webSocket || !selectedDeviceForModal) {
|
||||||
message.error('WebSocket未连接或未选择设备')
|
message.error('WebSocket未连接或未选择设备')
|
||||||
@@ -824,6 +955,22 @@ const RemoteControlApp: React.FC = () => {
|
|||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
render: (text: string) => text || '-'
|
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: '锁屏状态',
|
title: '锁屏状态',
|
||||||
dataIndex: 'isLocked',
|
dataIndex: 'isLocked',
|
||||||
@@ -882,7 +1029,7 @@ const RemoteControlApp: React.FC = () => {
|
|||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
width: isSuperAdmin() ? 180 : 120,
|
width: 180,
|
||||||
render: (record: any) => (
|
render: (record: any) => (
|
||||||
<Space size="small">
|
<Space size="small">
|
||||||
<Button
|
<Button
|
||||||
@@ -895,15 +1042,14 @@ const RemoteControlApp: React.FC = () => {
|
|||||||
>
|
>
|
||||||
控制
|
控制
|
||||||
</Button>
|
</Button>
|
||||||
{isSuperAdmin() && (
|
{currentUser && (
|
||||||
<Button
|
<Button
|
||||||
type="default"
|
type="default"
|
||||||
size="small"
|
size="small"
|
||||||
icon={<NodeIndexOutlined />}
|
icon={<NodeIndexOutlined />}
|
||||||
onClick={() => handleTransferDevice(record)}
|
onClick={() => handleTransferDevice(record)}
|
||||||
disabled={record.status === 'offline'}
|
|
||||||
style={{ minWidth: '50px' }}
|
style={{ minWidth: '50px' }}
|
||||||
title="转设备到其他服务器"
|
title="转移设备归属"
|
||||||
>
|
>
|
||||||
转
|
转
|
||||||
</Button>
|
</Button>
|
||||||
@@ -943,11 +1089,16 @@ const RemoteControlApp: React.FC = () => {
|
|||||||
icon: <MobileOutlined />,
|
icon: <MobileOutlined />,
|
||||||
label: '设备控制',
|
label: '设备控制',
|
||||||
},
|
},
|
||||||
{
|
...(canUseApkManager ? [{
|
||||||
key: 'apk',
|
key: 'apk',
|
||||||
icon: <AndroidOutlined />,
|
icon: <AndroidOutlined />,
|
||||||
label: 'APK 管理',
|
label: 'APK 管理',
|
||||||
},
|
}] : []),
|
||||||
|
...(canManageTenant ? [{
|
||||||
|
key: 'tenant',
|
||||||
|
icon: <TeamOutlined />,
|
||||||
|
label: '租户管理',
|
||||||
|
}] : []),
|
||||||
{
|
{
|
||||||
key: 'settings',
|
key: 'settings',
|
||||||
icon: <SettingOutlined />,
|
icon: <SettingOutlined />,
|
||||||
@@ -1004,7 +1155,7 @@ const RemoteControlApp: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
case 'apk':
|
case 'apk':
|
||||||
return (
|
return canUseApkManager ? (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: 0,
|
padding: 0,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -1026,6 +1177,24 @@ const RemoteControlApp: React.FC = () => {
|
|||||||
: `${window.location.protocol}//${window.location.hostname}`
|
: `${window.location.protocol}//${window.location.hostname}`
|
||||||
})()} />
|
})()} />
|
||||||
</div>
|
</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':
|
case 'settings':
|
||||||
return (
|
return (
|
||||||
@@ -1088,10 +1257,7 @@ const RemoteControlApp: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="standalone-control-page">
|
<div className="standalone-control-page">
|
||||||
{/* 左侧屏幕区域 - 自适应视口高度 */}
|
{/* 左侧屏幕区域 - 自适应视口高度 */}
|
||||||
<div className="control-screen-area" style={{
|
<div className="control-screen-area">
|
||||||
width: screenSize ? Math.min(screenSize.width * 2, window.innerWidth * 0.55) : '55vw',
|
|
||||||
maxWidth: '55vw'
|
|
||||||
}}>
|
|
||||||
{/* 工具条 */}
|
{/* 工具条 */}
|
||||||
<div className="control-toolbar">
|
<div className="control-toolbar">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
<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() })
|
webSocket.emit('control_message', { type: 'POWER_SLEEP', deviceId: selectedDeviceForModal.id, data: {}, timestamp: Date.now() })
|
||||||
message.success('已发送锁定屏幕')
|
message.success('已发送锁定屏幕')
|
||||||
}}>锁定</Button>
|
}}>锁定</Button>
|
||||||
<Button size="small" icon={<PlayCircleOutlined />} onClick={() => {
|
<Button
|
||||||
if (!webSocket) { message.error('WebSocket未连接'); return }
|
size="small"
|
||||||
webSocket.emit('control_message', { type: 'SCREEN_CAPTURE_RESUME', deviceId: selectedDeviceForModal.id, data: {}, timestamp: Date.now() })
|
icon={<PlayCircleOutlined />}
|
||||||
message.success('已发送开启屏幕捕获指令')
|
onClick={() => triggerOverclockMode(selectedDeviceForModal.id)}
|
||||||
}} style={{ background: 'var(--md-success)', borderColor: 'var(--md-success)', color: 'var(--md-on-primary)' }}>开启捕获</Button>
|
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={() => {
|
<Button size="small" icon={<StopOutlined />} onClick={() => {
|
||||||
if (!webSocket) { message.error('WebSocket未连接'); return }
|
if (!webSocket) { message.error('WebSocket未连接'); return }
|
||||||
webSocket.emit('control_message', { type: 'SCREEN_CAPTURE_PAUSE', deviceId: selectedDeviceForModal.id, data: {}, timestamp: Date.now() })
|
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' }}>
|
<div style={{ flex: 1, minHeight: 0, position: 'relative', overflow: 'hidden', display: 'flex', alignItems: 'stretch', justifyContent: 'stretch' }}>
|
||||||
<DeviceScreen
|
<DeviceScreen
|
||||||
deviceId={selectedDeviceForModal.id}
|
deviceId={selectedDeviceForModal.id}
|
||||||
onScreenSizeChange={setScreenSize}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* 文本输入 - 阅读器未启用时显示在屏幕下方 */}
|
{/* 文本输入 - 阅读器未启用时显示在屏幕下方 */}
|
||||||
@@ -1355,7 +1535,7 @@ const RemoteControlApp: React.FC = () => {
|
|||||||
{currentUser?.username || 'Unknown'}
|
{currentUser?.username || 'Unknown'}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '12px', color: 'var(--md-on-surface-variant)' }}>
|
<div style={{ fontSize: '12px', color: 'var(--md-on-surface-variant)' }}>
|
||||||
管理员
|
{currentRoleLabel}{currentUser?.groupName ? ` · ${currentUser.groupName}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -1492,7 +1672,6 @@ const RemoteControlApp: React.FC = () => {
|
|||||||
open={screenModalVisible}
|
open={screenModalVisible}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setScreenModalVisible(false)
|
setScreenModalVisible(false)
|
||||||
setScreenSize(null)
|
|
||||||
}}
|
}}
|
||||||
footer={null}
|
footer={null}
|
||||||
width="95vw"
|
width="95vw"
|
||||||
@@ -1508,9 +1687,9 @@ const RemoteControlApp: React.FC = () => {
|
|||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
{selectedDeviceForModal && (
|
{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 className="control-toolbar">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
<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' }}>
|
<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>
|
</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
const current = connectedDevices.find(d => d.id === selectedDeviceForModal.id)
|
const current = connectedDevices.find(d => d.id === selectedDeviceForModal.id)
|
||||||
@@ -1637,18 +1816,20 @@ const RemoteControlApp: React.FC = () => {
|
|||||||
|
|
||||||
{/* 转设备弹窗 */}
|
{/* 转设备弹窗 */}
|
||||||
<Modal
|
<Modal
|
||||||
title="转设备到其他服务器"
|
title="转移设备归属"
|
||||||
open={transferModalVisible}
|
open={transferModalVisible}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setTransferModalVisible(false)
|
setTransferModalVisible(false)
|
||||||
setNewServerUrl('')
|
setTransferTargetUserId('')
|
||||||
setTransferringDevice(null)
|
setTransferringDevice(null)
|
||||||
|
setTransferTargets([])
|
||||||
}}
|
}}
|
||||||
footer={[
|
footer={[
|
||||||
<Button key="cancel" onClick={() => {
|
<Button key="cancel" onClick={() => {
|
||||||
setTransferModalVisible(false)
|
setTransferModalVisible(false)
|
||||||
setNewServerUrl('')
|
setTransferTargetUserId('')
|
||||||
setTransferringDevice(null)
|
setTransferringDevice(null)
|
||||||
|
setTransferTargets([])
|
||||||
}}>
|
}}>
|
||||||
取消
|
取消
|
||||||
</Button>,
|
</Button>,
|
||||||
@@ -1657,37 +1838,42 @@ const RemoteControlApp: React.FC = () => {
|
|||||||
type="primary"
|
type="primary"
|
||||||
onClick={handleConfirmTransfer}
|
onClick={handleConfirmTransfer}
|
||||||
loading={transferring}
|
loading={transferring}
|
||||||
disabled={!newServerUrl.trim()}
|
disabled={!transferTargetUserId}
|
||||||
>
|
>
|
||||||
确认转设备
|
确认转移
|
||||||
</Button>
|
</Button>
|
||||||
]}
|
]}
|
||||||
width={500}
|
width={500}
|
||||||
>
|
>
|
||||||
<div style={{ padding: '16px 0' }}>
|
<div style={{ padding: '16px 0' }}>
|
||||||
<p style={{ marginBottom: 16, color: 'var(--md-on-surface-variant)' }}>
|
<p style={{ marginBottom: 16, color: 'var(--md-on-surface-variant)' }}>
|
||||||
此功能将向设备 <strong>{transferringDevice?.name}</strong> 发送修改服务器地址的指令,设备将重新连接到新的服务器。
|
你正在转移设备 <strong>{transferringDevice?.name}</strong> 的归属。转移后,原归属用户将失去该设备控制权限。
|
||||||
</p>
|
</p>
|
||||||
<Input
|
<Select
|
||||||
placeholder="请输入新的服务器地址,例如: ws://192.168.1.100:3001"
|
placeholder="请选择目标用户"
|
||||||
value={newServerUrl}
|
value={transferTargetUserId || undefined}
|
||||||
onChange={(e) => setNewServerUrl(e.target.value)}
|
onChange={(value) => setTransferTargetUserId(value)}
|
||||||
style={{ marginBottom: 16 }}
|
style={{ width: '100%', marginBottom: 16 }}
|
||||||
|
options={transferTargets.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: `${item.username}${item.groupName ? ` (${item.groupName})` : ''}`
|
||||||
|
}))}
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
/>
|
/>
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '12px',
|
padding: '12px',
|
||||||
background: 'var(--md-success-container)',
|
background: 'var(--md-surface-container-low)',
|
||||||
border: '1px solid var(--md-outline-variant)',
|
border: '1px solid var(--md-outline-variant)',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
color: 'var(--md-success)'
|
color: 'var(--md-on-surface-variant)'
|
||||||
}}>
|
}}>
|
||||||
<strong>注意事项:</strong>
|
<strong>注意事项:</strong>
|
||||||
<ul style={{ margin: '8px 0 0 0', paddingLeft: '20px' }}>
|
<ul style={{ margin: '8px 0 0 0', paddingLeft: '20px' }}>
|
||||||
<li>请确保新服务器地址格式正确(如:ws://ip:port 或 wss://域名)</li>
|
<li>superadmin 可跨组转移;leader 和 member 仅可组内转移</li>
|
||||||
<li>ws和wss的区别是 一个用的http协议,一个是https协议</li>
|
<li>member 只能转移自己名下设备</li>
|
||||||
<li>转设备后,设备将断开当前连接并尝试连接新服务器</li>
|
<li>设备离线也可以转移,重连后会按新归属生效</li>
|
||||||
<li>如果新服务器不可达,设备将无法正常连接</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1697,3 +1883,4 @@ const RemoteControlApp: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default RemoteControlApp
|
export default RemoteControlApp
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/tool
|
|||||||
export interface User {
|
export interface User {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
|
role?: 'superadmin' | 'leader' | 'member'
|
||||||
|
groupId?: string
|
||||||
|
groupName?: string
|
||||||
|
allowBuild?: boolean
|
||||||
lastLoginAt?: Date
|
lastLoginAt?: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,17 @@
|
|||||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
|
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
|
||||||
|
|
||||||
/**
|
|
||||||
* 屏幕阅读器配置
|
|
||||||
*/
|
|
||||||
export interface DeviceScreenReaderConfig {
|
export interface DeviceScreenReaderConfig {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
autoRefresh: boolean
|
autoRefresh: boolean
|
||||||
refreshInterval: number // 秒
|
refreshInterval: number
|
||||||
showElementBounds: boolean
|
showElementBounds: boolean
|
||||||
highlightClickable: boolean
|
highlightClickable: boolean
|
||||||
showVirtualKeyboard: boolean // 显示虚拟按键
|
showVirtualKeyboard: boolean
|
||||||
hierarchyData?: any // UI层次结构数据
|
hierarchyData?: any
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 设备信息接口
|
|
||||||
*/
|
|
||||||
export interface Device {
|
export interface Device {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -27,28 +21,27 @@ export interface Device {
|
|||||||
screenHeight: number
|
screenHeight: number
|
||||||
status: 'online' | 'offline' | 'connecting'
|
status: 'online' | 'offline' | 'connecting'
|
||||||
lastSeen: number
|
lastSeen: number
|
||||||
|
supportedVideoTransports?: string[]
|
||||||
|
srtUploadSupported?: boolean
|
||||||
inputBlocked?: boolean
|
inputBlocked?: boolean
|
||||||
screenReader?: DeviceScreenReaderConfig
|
screenReader?: DeviceScreenReaderConfig
|
||||||
publicIP?: string
|
publicIP?: string
|
||||||
// 🆕 新增系统版本信息字段
|
systemVersionName?: string
|
||||||
systemVersionName?: string // 如"Android 11"、"Android 12"
|
romType?: string
|
||||||
romType?: string // 如"MIUI"、"ColorOS"、"原生Android"
|
romVersion?: string
|
||||||
romVersion?: string // 如"MIUI 12.5"、"ColorOS 11.1"
|
osBuildVersion?: string
|
||||||
osBuildVersion?: string // 如"1.0.19.0.UMCCNXM"等完整构建版本号
|
appName?: string
|
||||||
// 🆕 新增APP和锁屏状态字段
|
appVersion?: string
|
||||||
appName?: string // 当前运行的APP名称
|
appPackage?: string
|
||||||
appVersion?: string // 当前运行的APP版本
|
isLocked?: boolean
|
||||||
appPackage?: string // 当前运行的APP包名
|
connectedAt?: number
|
||||||
isLocked?: boolean // 设备锁屏状态
|
remark?: string
|
||||||
// 🆕 安装时间(连接时间)
|
ownerUserId?: string
|
||||||
connectedAt?: number // 安装时间/首次连接时间(毫秒时间戳)
|
ownerUsername?: string
|
||||||
// 🆕 备注字段
|
ownerGroupId?: string
|
||||||
remark?: string // 设备备注
|
ownerGroupName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 设备状态接口
|
|
||||||
*/
|
|
||||||
export interface DeviceStatus {
|
export interface DeviceStatus {
|
||||||
cpu: number
|
cpu: number
|
||||||
memory: number
|
memory: number
|
||||||
@@ -56,31 +49,25 @@ export interface DeviceStatus {
|
|||||||
networkSpeed: number
|
networkSpeed: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 设备筛选条件接口
|
|
||||||
*/
|
|
||||||
export interface DeviceFilter {
|
export interface DeviceFilter {
|
||||||
model?: string // 型号筛选
|
model?: string
|
||||||
osVersion?: string // 系统版本筛选
|
osVersion?: string
|
||||||
appName?: string // APP名称筛选
|
appName?: string
|
||||||
isLocked?: boolean // 锁屏状态筛选
|
isLocked?: boolean
|
||||||
status?: 'online' | 'offline' | 'connecting' // 在线状态筛选
|
status?: 'online' | 'offline' | 'connecting'
|
||||||
connectedAtRange?: { // 安装时间范围筛选
|
connectedAtRange?: {
|
||||||
start?: number
|
start?: number
|
||||||
end?: number
|
end?: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 设备状态管理
|
|
||||||
*/
|
|
||||||
interface DevicesState {
|
interface DevicesState {
|
||||||
connectedDevices: Device[]
|
connectedDevices: Device[]
|
||||||
selectedDeviceId: string | null
|
selectedDeviceId: string | null
|
||||||
deviceStatuses: Record<string, DeviceStatus>
|
deviceStatuses: Record<string, DeviceStatus>
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
filter: DeviceFilter // 筛选条件
|
filter: DeviceFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: DevicesState = {
|
const initialState: DevicesState = {
|
||||||
@@ -89,68 +76,54 @@ const initialState: DevicesState = {
|
|||||||
deviceStatuses: {},
|
deviceStatuses: {},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
filter: { }, // 默认只显示在线设备
|
filter: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 设备管理 Slice
|
|
||||||
*/
|
|
||||||
const deviceSlice = createSlice({
|
const deviceSlice = createSlice({
|
||||||
name: 'devices',
|
name: 'devices',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
// 添加设备
|
|
||||||
addDevice: (state, action: PayloadAction<Device>) => {
|
addDevice: (state, action: PayloadAction<Device>) => {
|
||||||
const existingIndex = state.connectedDevices.findIndex(
|
const existingIndex = state.connectedDevices.findIndex(
|
||||||
device => device.id === action.payload.id
|
device => device.id === action.payload.id
|
||||||
)
|
)
|
||||||
|
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
// 更新现有设备
|
|
||||||
state.connectedDevices[existingIndex] = action.payload
|
state.connectedDevices[existingIndex] = action.payload
|
||||||
} else {
|
} else {
|
||||||
// 添加新设备
|
|
||||||
state.connectedDevices.push(action.payload)
|
state.connectedDevices.push(action.payload)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 移除设备
|
|
||||||
removeDevice: (state, action: PayloadAction<string>) => {
|
removeDevice: (state, action: PayloadAction<string>) => {
|
||||||
state.connectedDevices = state.connectedDevices.filter(
|
state.connectedDevices = state.connectedDevices.filter(
|
||||||
device => device.id !== action.payload
|
device => device.id !== action.payload
|
||||||
)
|
)
|
||||||
|
|
||||||
// 如果移除的是当前选中的设备,清除选择
|
|
||||||
if (state.selectedDeviceId === action.payload) {
|
if (state.selectedDeviceId === action.payload) {
|
||||||
state.selectedDeviceId = null
|
state.selectedDeviceId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除设备状态
|
|
||||||
delete state.deviceStatuses[action.payload]
|
delete state.deviceStatuses[action.payload]
|
||||||
},
|
},
|
||||||
|
|
||||||
// 选择设备
|
|
||||||
selectDevice: (state, action: PayloadAction<string>) => {
|
selectDevice: (state, action: PayloadAction<string>) => {
|
||||||
state.selectedDeviceId = action.payload
|
state.selectedDeviceId = action.payload
|
||||||
},
|
},
|
||||||
|
|
||||||
// 重置设备相关状态(当设备连接或重新连接时调用)
|
|
||||||
resetDeviceStates: (_state, _action: PayloadAction<string>) => {
|
resetDeviceStates: (_state, _action: PayloadAction<string>) => {
|
||||||
// 这个action会在其他地方被监听,用于重置UI状态
|
// This action is observed elsewhere for UI reset side effects.
|
||||||
},
|
},
|
||||||
|
|
||||||
// 清除设备选择
|
|
||||||
clearDeviceSelection: (state) => {
|
clearDeviceSelection: (state) => {
|
||||||
state.selectedDeviceId = null
|
state.selectedDeviceId = null
|
||||||
},
|
},
|
||||||
|
|
||||||
// 更新设备状态
|
|
||||||
updateDeviceStatus: (state, action: PayloadAction<{ deviceId: string; status: DeviceStatus }>) => {
|
updateDeviceStatus: (state, action: PayloadAction<{ deviceId: string; status: DeviceStatus }>) => {
|
||||||
const { deviceId, status } = action.payload
|
const { deviceId, status } = action.payload
|
||||||
state.deviceStatuses[deviceId] = status
|
state.deviceStatuses[deviceId] = status
|
||||||
},
|
},
|
||||||
|
|
||||||
// 更新设备连接状态
|
|
||||||
updateDeviceConnectionStatus: (state, action: PayloadAction<{ deviceId: string; status: Device['status'] }>) => {
|
updateDeviceConnectionStatus: (state, action: PayloadAction<{ deviceId: string; status: Device['status'] }>) => {
|
||||||
const { deviceId, status } = action.payload
|
const { deviceId, status } = action.payload
|
||||||
const device = state.connectedDevices.find(d => d.id === deviceId)
|
const device = state.connectedDevices.find(d => d.id === deviceId)
|
||||||
@@ -161,7 +134,6 @@ const deviceSlice = createSlice({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 更新设备输入阻止状态
|
|
||||||
updateDeviceInputBlocked: (state, action: PayloadAction<{ deviceId: string; inputBlocked: boolean }>) => {
|
updateDeviceInputBlocked: (state, action: PayloadAction<{ deviceId: string; inputBlocked: boolean }>) => {
|
||||||
const { deviceId, inputBlocked } = action.payload
|
const { deviceId, inputBlocked } = action.payload
|
||||||
const device = state.connectedDevices.find(d => d.id === deviceId)
|
const device = state.connectedDevices.find(d => d.id === deviceId)
|
||||||
@@ -171,7 +143,6 @@ const deviceSlice = createSlice({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 启用设备屏幕阅读器
|
|
||||||
enableDeviceScreenReader: (state, action: PayloadAction<string>) => {
|
enableDeviceScreenReader: (state, action: PayloadAction<string>) => {
|
||||||
const deviceId = action.payload
|
const deviceId = action.payload
|
||||||
const device = state.connectedDevices.find(d => d.id === deviceId)
|
const device = state.connectedDevices.find(d => d.id === deviceId)
|
||||||
@@ -194,7 +165,6 @@ const deviceSlice = createSlice({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 禁用设备屏幕阅读器
|
|
||||||
disableDeviceScreenReader: (state, action: PayloadAction<string>) => {
|
disableDeviceScreenReader: (state, action: PayloadAction<string>) => {
|
||||||
const deviceId = action.payload
|
const deviceId = action.payload
|
||||||
const device = state.connectedDevices.find(d => d.id === deviceId)
|
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> }>) => {
|
updateDeviceScreenReaderConfig: (state, action: PayloadAction<{ deviceId: string; config: Partial<DeviceScreenReaderConfig> }>) => {
|
||||||
const { deviceId, config } = action.payload
|
const { deviceId, config } = action.payload
|
||||||
const device = state.connectedDevices.find(d => d.id === deviceId)
|
const device = state.connectedDevices.find(d => d.id === deviceId)
|
||||||
@@ -230,7 +199,6 @@ const deviceSlice = createSlice({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 更新设备锁屏状态
|
|
||||||
updateDeviceLockStatus: (state, action: PayloadAction<{ deviceId: string; isLocked: boolean }>) => {
|
updateDeviceLockStatus: (state, action: PayloadAction<{ deviceId: string; isLocked: boolean }>) => {
|
||||||
const { deviceId, isLocked } = action.payload
|
const { deviceId, isLocked } = action.payload
|
||||||
const device = state.connectedDevices.find(d => d.id === deviceId)
|
const device = state.connectedDevices.find(d => d.id === deviceId)
|
||||||
@@ -240,7 +208,6 @@ const deviceSlice = createSlice({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 更新设备备注
|
|
||||||
updateDeviceRemark: (state, action: PayloadAction<{ deviceId: string; remark: string }>) => {
|
updateDeviceRemark: (state, action: PayloadAction<{ deviceId: string; remark: string }>) => {
|
||||||
const { deviceId, remark } = action.payload
|
const { deviceId, remark } = action.payload
|
||||||
const device = state.connectedDevices.find(d => d.id === deviceId)
|
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 }>) => {
|
setDeviceScreenReaderHierarchy: (state, action: PayloadAction<{ deviceId: string; hierarchyData: any; error?: string }>) => {
|
||||||
const { deviceId, hierarchyData, error } = action.payload
|
const { deviceId, hierarchyData, error } = action.payload
|
||||||
const device = state.connectedDevices.find(d => d.id === deviceId)
|
const device = state.connectedDevices.find(d => d.id === deviceId)
|
||||||
@@ -262,29 +228,23 @@ const deviceSlice = createSlice({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 设置加载状态
|
|
||||||
setLoading: (state, action: PayloadAction<boolean>) => {
|
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||||
state.isLoading = action.payload
|
state.isLoading = action.payload
|
||||||
},
|
},
|
||||||
|
|
||||||
// 设置错误信息
|
|
||||||
setError: (state, action: PayloadAction<string | null>) => {
|
setError: (state, action: PayloadAction<string | null>) => {
|
||||||
state.error = action.payload
|
state.error = action.payload
|
||||||
},
|
},
|
||||||
|
|
||||||
// 设置设备筛选条件
|
|
||||||
setDeviceFilter: (state, action: PayloadAction<DeviceFilter>) => {
|
setDeviceFilter: (state, action: PayloadAction<DeviceFilter>) => {
|
||||||
state.filter = action.payload
|
state.filter = action.payload
|
||||||
},
|
},
|
||||||
|
|
||||||
// 清除设备筛选条件(清除后恢复默认只显示在线设备)
|
|
||||||
clearDeviceFilter: (state) => {
|
clearDeviceFilter: (state) => {
|
||||||
state.filter = {}
|
state.filter = {}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 更新单个筛选条件
|
|
||||||
updateDeviceFilter: (state, action: PayloadAction<Partial<DeviceFilter>>) => {
|
updateDeviceFilter: (state, action: PayloadAction<Partial<DeviceFilter>>) => {
|
||||||
// 如果status是undefined,需要从filter中删除该字段
|
|
||||||
const newFilter = { ...state.filter, ...action.payload }
|
const newFilter = { ...state.filter, ...action.payload }
|
||||||
if (action.payload.status === undefined && 'status' in newFilter) {
|
if (action.payload.status === undefined && 'status' in newFilter) {
|
||||||
delete newFilter.status
|
delete newFilter.status
|
||||||
@@ -292,12 +252,11 @@ const deviceSlice = createSlice({
|
|||||||
state.filter = newFilter
|
state.filter = newFilter
|
||||||
},
|
},
|
||||||
|
|
||||||
// 清除所有设备
|
|
||||||
clearDevices: (state) => {
|
clearDevices: (state) => {
|
||||||
state.connectedDevices = []
|
state.connectedDevices = []
|
||||||
state.selectedDeviceId = null
|
state.selectedDeviceId = null
|
||||||
state.deviceStatuses = {}
|
state.deviceStatuses = {}
|
||||||
state.filter = { } // 恢复默认只显示在线设备
|
state.filter = {}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -327,47 +286,31 @@ export const {
|
|||||||
|
|
||||||
export default deviceSlice.reducer
|
export default deviceSlice.reducer
|
||||||
|
|
||||||
/**
|
|
||||||
* 筛选设备列表的selector
|
|
||||||
*/
|
|
||||||
export const selectFilteredDevices = (state: { devices: DevicesState }) => {
|
export const selectFilteredDevices = (state: { devices: DevicesState }) => {
|
||||||
const { connectedDevices, filter } = state.devices
|
const { connectedDevices, filter } = state.devices
|
||||||
|
const filtered = connectedDevices
|
||||||
// 默认只显示在线设备
|
|
||||||
let filtered = connectedDevices
|
|
||||||
|
|
||||||
// 如果没有筛选条件,默认只显示在线设备
|
|
||||||
// if (!filter || Object.keys(filter).length === 0) {
|
|
||||||
// return filtered.filter(device => device.status === 'online')
|
|
||||||
// }
|
|
||||||
|
|
||||||
return filtered.filter(device => {
|
return filtered.filter(device => {
|
||||||
// 型号筛选
|
|
||||||
if (filter.model && !device.model?.toLowerCase().includes(filter.model.toLowerCase())) {
|
if (filter.model && !device.model?.toLowerCase().includes(filter.model.toLowerCase())) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 系统版本筛选
|
|
||||||
if (filter.osVersion && !device.osVersion?.toLowerCase().includes(filter.osVersion.toLowerCase())) {
|
if (filter.osVersion && !device.osVersion?.toLowerCase().includes(filter.osVersion.toLowerCase())) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// APP名称筛选
|
|
||||||
if (filter.appName && !device.appName?.toLowerCase().includes(filter.appName.toLowerCase())) {
|
if (filter.appName && !device.appName?.toLowerCase().includes(filter.appName.toLowerCase())) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 锁屏状态筛选
|
|
||||||
if (filter.isLocked !== undefined && device.isLocked !== filter.isLocked) {
|
if (filter.isLocked !== undefined && device.isLocked !== filter.isLocked) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 在线状态筛选(如果用户选择了状态筛选,则按选择筛选;否则显示全部)
|
|
||||||
if (filter.status && device.status !== filter.status) {
|
if (filter.status && device.status !== filter.status) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 安装时间范围筛选
|
|
||||||
if (filter.connectedAtRange) {
|
if (filter.connectedAtRange) {
|
||||||
const { start, end } = filter.connectedAtRange
|
const { start, end } = filter.connectedAtRange
|
||||||
const connectedAt = device.connectedAt || device.lastSeen
|
const connectedAt = device.connectedAt || device.lastSeen
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface ScreenDisplayConfig {
|
|||||||
showTouchIndicator: boolean
|
showTouchIndicator: boolean
|
||||||
enableSound: boolean
|
enableSound: boolean
|
||||||
fullscreen: boolean
|
fullscreen: boolean
|
||||||
|
exposure: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,6 +120,7 @@ const initialState: UiState = {
|
|||||||
showTouchIndicator: true,
|
showTouchIndicator: true,
|
||||||
enableSound: false,
|
enableSound: false,
|
||||||
fullscreen: false,
|
fullscreen: false,
|
||||||
|
exposure: 100,
|
||||||
},
|
},
|
||||||
screenReader: {
|
screenReader: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user