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