feat(ios): add online device list and pairing code redemption

- Add API methods for fetching online devices and redeeming pairing codes
- Add OnlineDevice model and corresponding ViewModel state management
- Add UI sections for online device list and pairing code input in SetupPanel
- Ensure access token is verified before connecting to signaling server
This commit is contained in:
2026-08-03 21:42:31 +08:00
parent da0a3bf59d
commit 1bd0318d35
3 changed files with 179 additions and 7 deletions

View File

@@ -45,12 +45,32 @@ struct ApiClient {
return try await get("/api/client/turn-credentials", accessToken: accessToken) return try await get("/api/client/turn-credentials", accessToken: accessToken)
} }
/// 线线
/// Flutter `devices_online`GET /api/client/devices/online
static func onlineDevices(accessToken: String) async throws -> [String: Any] {
return try await get("/api/client/devices/online", accessToken: accessToken)
}
///
/// Flutter `redeemPairingCode`POST /api/client/pairing/redeem
static func redeemPairingCode(accessToken: String, code: String) async throws -> [String: Any] {
let body: [String: Any] = ["code": code]
return try await post("/api/client/pairing/redeem", body: body, accessToken: accessToken)
}
// MARK: - Private // MARK: - Private
private static func post(_ path: String, body: [String: Any]) async throws -> [String: Any] { private static func post(_ path: String, body: [String: Any]) async throws -> [String: Any] {
return try await post(path, body: body, accessToken: nil)
}
private static func post(_ path: String, body: [String: Any], accessToken: String?) async throws -> [String: Any] {
var req = URLRequest(url: URL(string: baseURL + path)!) var req = URLRequest(url: URL(string: baseURL + path)!)
req.httpMethod = "POST" req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let accessToken, !accessToken.isEmpty {
req.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
}
req.httpBody = try JSONSerialization.data(withJSONObject: body) req.httpBody = try JSONSerialization.data(withJSONObject: body)
return try await perform(req) return try await perform(req)
} }

View File

@@ -41,6 +41,14 @@ struct ResolutionOption: Identifiable, Equatable {
] ]
} }
/// 线 Flutter BindingDevice.fromOnlineJson
struct OnlineDevice: Identifiable, Equatable {
let deviceId: String
let nickname: String
var id: String { deviceId }
}
/// ViewModelWebRTC UI /// ViewModelWebRTC UI
/// 线@Published SwiftUI /// 线@Published SwiftUI
final class ControllerViewModel: NSObject, ObservableObject { final class ControllerViewModel: NSObject, ObservableObject {
@@ -67,6 +75,17 @@ final class ControllerViewModel: NSObject, ObservableObject {
@Published var showAuthSheet: Bool = false @Published var showAuthSheet: Bool = false
@Published var alertMessage: String? @Published var alertMessage: String?
// MARK: -
/// 线GET /api/client/devices/online
@Published var onlineDevices: [OnlineDevice] = []
/// 线
@Published var isLoadingOnlineDevices: Bool = false
///
@Published var pairingCode: String = ""
///
@Published var isRedeemingPairing: Bool = false
/// ///
@Published var isRecording: Bool = false @Published var isRecording: Bool = false
/// ///
@@ -156,10 +175,12 @@ final class ControllerViewModel: NSObject, ObservableObject {
alertMessage = "请先在右上角登录账号" alertMessage = "请先在右上角登录账号"
return return
} }
loadOnlineDevices()
showAuthSheet = true showAuthSheet = true
} }
/// accessToken /// accessToken Flutter connectSignaling verify/refresh
/// 4001
func connect(authType: AuthType, authValue: String) { func connect(authType: AuthType, authValue: String) {
showAuthSheet = false showAuthSheet = false
pendingAuthType = authType pendingAuthType = authType
@@ -167,13 +188,26 @@ final class ControllerViewModel: NSObject, ObservableObject {
isConnecting = true isConnecting = true
statusText = "正在连接信令服务器..." statusText = "正在连接信令服务器..."
Task {
do {
let token = try await ensureAccessToken()
accessToken = token
await MainActor.run { [weak self] in
guard let self else { return }
let signaling = SignalingClient( let signaling = SignalingClient(
serverUrl: serverUrl.trimmingCharacters(in: .whitespaces), serverUrl: self.serverUrl.trimmingCharacters(in: .whitespaces),
token: accessToken) token: token)
signaling.delegate = self signaling.delegate = self
signalingClient = signaling self.signalingClient = signaling
signaling.connect() signaling.connect()
} }
} catch {
await MainActor.run { [weak self] in
self?.failConnection((error as? ApiError)?.message ?? "令牌失效,请重新登录")
}
}
}
}
func disconnect() { func disconnect() {
stopStatsTimer() stopStatsTimer()
@@ -433,6 +467,67 @@ final class ControllerViewModel: NSObject, ObservableObject {
} }
} }
/// 线GET /api/client/devices/online线
/// Flutter RemoteController.loadOnlineDevices / BindingDevice.fromOnlineJson
func loadOnlineDevices() {
guard let at = accessToken else { return }
isLoadingOnlineDevices = true
Task {
do {
let data = try await ApiClient.onlineDevices(accessToken: at)
let list = (data["devices"] as? [[String: Any]]) ?? []
let devices: [OnlineDevice] = list.compactMap { entry in
guard let id = entry["deviceId"] as? String, !id.isEmpty else { return nil }
let nickname = (entry["nickname"] as? String) ?? id
return OnlineDevice(deviceId: id, nickname: nickname)
}
await MainActor.run { [weak self] in
guard let self else { return }
self.onlineDevices = devices
self.isLoadingOnlineDevices = false
}
} catch {
await MainActor.run { [weak self] in
self?.isLoadingOnlineDevices = false
}
}
}
}
/// POST /api/client/pairing/redeem
/// Flutter RemoteController.redeemPairingCode
func redeemPairingCode() {
let code = pairingCode.trimmingCharacters(in: .whitespacesAndNewlines)
guard !code.isEmpty else {
alertMessage = "请输入配对码"
return
}
guard let at = accessToken else {
alertMessage = "请先登录账号"
return
}
isRedeemingPairing = true
Task {
do {
let data = try await ApiClient.redeemPairingCode(accessToken: at, code: code)
let deviceId = (data["deviceId"] as? String) ?? ""
let nickname = (data["nickname"] as? String) ?? deviceId
await MainActor.run { [weak self] in
guard let self else { return }
self.isRedeemingPairing = false
self.pairingCode = ""
self.alertMessage = "绑定成功:\(nickname)\(deviceId)"
self.loadOnlineDevices()
}
} catch {
await MainActor.run { [weak self] in
self?.isRedeemingPairing = false
self?.alertMessage = "配对失败:\((error as? ApiError)?.message ?? error.localizedDescription)"
}
}
}
}
/// TURN ICE WebRTCClient /// TURN ICE WebRTCClient
private func loadTurnCredentials() async { private func loadTurnCredentials() async {
guard let at = accessToken else { return } guard let at = accessToken else { return }

View File

@@ -26,6 +26,63 @@ struct SetupPanelView: View {
TextField("目标设备 ID", text: $viewModel.targetDeviceId) TextField("目标设备 ID", text: $viewModel.targetDeviceId)
.autocapitalization(.allCharacters) .autocapitalization(.allCharacters)
.disableAutocorrection(true) .disableAutocorrection(true)
Button {
viewModel.loadOnlineDevices()
} label: {
HStack {
Text("刷新在线设备")
Spacer()
if viewModel.isLoadingOnlineDevices {
ProgressView()
}
}
}
}
if !viewModel.onlineDevices.isEmpty {
Section("在线设备(点击填入目标 ID") {
ForEach(viewModel.onlineDevices) { device in
Button {
viewModel.targetDeviceId = device.deviceId
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(device.nickname)
Text(device.deviceId)
.font(.system(.caption, design: .monospaced))
.foregroundColor(.secondary)
}
Spacer()
if viewModel.targetDeviceId == device.deviceId {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
}
}
}
}
Section("配对码兑换(建立新绑定)") {
HStack {
TextField("输入被控端显示的配对码", text: $viewModel.pairingCode)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button {
viewModel.redeemPairingCode()
} label: {
if viewModel.isRedeemingPairing {
ProgressView()
} else {
Text("兑换")
}
}
.disabled(viewModel.isRedeemingPairing || viewModel.pairingCode.trimmingCharacters(in: .whitespaces).isEmpty)
}
Text("在被控端生成配对码后填入此处即可建立绑定,绑定成功后会出现在上方在线设备列表中。")
.font(.caption)
.foregroundColor(.secondary)
} }
Section { Section {