From a04168cf302044ba950b55456f345bbbc749d159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Thu, 25 Jun 2026 18:15:59 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=BF=90=E8=90=A5=E5=8C=BA?= =?UTF-8?q?=E5=9F=9F=E4=B8=8E=E9=A3=9E=E6=89=8B=E8=AE=A4=E8=AF=81=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=EF=BC=8C=E5=B9=B6=E5=AE=8C=E5=96=84=E7=9B=B4=E6=92=AD?= =?UTF-8?q?=E6=8E=A8=E6=B5=81=E5=B0=B1=E7=BB=AA=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将运营区域与飞手认证从首页占位页迁移为完整模块,扩展 Live 播放与推流就绪流程,并新增飞手证书 OSS 上传。 Co-authored-by: Cursor --- suixinkan.xcodeproj/project.pbxproj | 6 +- suixinkan/App/RootView.swift | 6 + suixinkan/Core/Upload/OSSUploadService.swift | 10 + .../Core/Validation/AppFormValidator.swift | 12 + suixinkan/Features/Home/Home.md | 2 +- .../Features/Home/Models/HomeMenuItem.swift | 2 + .../Home/Routing/HomeMenuRouter.swift | 8 +- .../Home/Views/HomeSupportViews.swift | 4 + suixinkan/Features/Live/Live.md | 12 +- .../Features/Live/Models/LiveModels.swift | 29 ++ .../ViewModels/LivePlaybackViewModel.swift | 132 ++++++ .../LivePushReadinessViewModel.swift | 290 ++++++++++++ .../Features/Live/Views/LiveAlbumViews.swift | 40 +- .../Live/Views/LiveManagementViews.swift | 128 +++++- .../OperatingArea/API/OperatingAreaAPI.swift | 52 +++ .../Models/OperatingAreaModels.swift | 374 ++++++++++++++++ .../Features/OperatingArea/OperatingArea.md | 17 + .../ViewModels/OperatingAreaViewModel.swift | 154 +++++++ .../Views/OperatingAreaViews.swift | 390 ++++++++++++++++ .../API/PilotCertificationAPI.swift | 58 +++ .../Models/PilotCertificationModels.swift | 310 +++++++++++++ .../PilotCertification/PilotCertification.md | 17 + .../PilotCertificationViewModel.swift | 282 ++++++++++++ .../Views/PilotCertificationViews.swift | 422 ++++++++++++++++++ .../Fixtures/flyer_detail_success.json | 38 ++ .../Fixtures/operating_area_empty.json | 8 + .../Fixtures/operating_area_success.json | 46 ++ suixinkanTests/HomeMenuRouterTests.swift | 10 +- suixinkanTests/Live/LiveViewModelTests.swift | 164 +++++++ .../OperatingArea/OperatingAreaTests.swift | 171 +++++++ .../PilotCertificationTests.swift | 277 ++++++++++++ suixinkanTests/TestFixture.swift | 10 + 功能同步Checklist.md | 11 +- 33 files changed, 3455 insertions(+), 37 deletions(-) create mode 100644 suixinkan/Features/Live/ViewModels/LivePlaybackViewModel.swift create mode 100644 suixinkan/Features/Live/ViewModels/LivePushReadinessViewModel.swift create mode 100644 suixinkan/Features/OperatingArea/API/OperatingAreaAPI.swift create mode 100644 suixinkan/Features/OperatingArea/Models/OperatingAreaModels.swift create mode 100644 suixinkan/Features/OperatingArea/OperatingArea.md create mode 100644 suixinkan/Features/OperatingArea/ViewModels/OperatingAreaViewModel.swift create mode 100644 suixinkan/Features/OperatingArea/Views/OperatingAreaViews.swift create mode 100644 suixinkan/Features/PilotCertification/API/PilotCertificationAPI.swift create mode 100644 suixinkan/Features/PilotCertification/Models/PilotCertificationModels.swift create mode 100644 suixinkan/Features/PilotCertification/PilotCertification.md create mode 100644 suixinkan/Features/PilotCertification/ViewModels/PilotCertificationViewModel.swift create mode 100644 suixinkan/Features/PilotCertification/Views/PilotCertificationViews.swift create mode 100644 suixinkanTests/Fixtures/flyer_detail_success.json create mode 100644 suixinkanTests/Fixtures/operating_area_empty.json create mode 100644 suixinkanTests/Fixtures/operating_area_success.json create mode 100644 suixinkanTests/OperatingArea/OperatingAreaTests.swift create mode 100644 suixinkanTests/PilotCertification/PilotCertificationTests.swift diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index 0b44a30..92ad43b 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -452,8 +452,9 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_NSCameraUsageDescription = "用于扫码核销订单"; + INFOPLIST_KEY_NSCameraUsageDescription = "用于扫码核销订单和直播推流预览"; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "用于景区选择、打卡点选点和位置上报时获取当前位置"; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "用于直播推流时采集现场声音"; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "用于保存收款二维码到相册"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -496,8 +497,9 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_NSCameraUsageDescription = "用于扫码核销订单"; + INFOPLIST_KEY_NSCameraUsageDescription = "用于扫码核销订单和直播推流预览"; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "用于景区选择、打卡点选点和位置上报时获取当前位置"; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "用于直播推流时采集现场声音"; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "用于保存收款二维码到相册"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; diff --git a/suixinkan/App/RootView.swift b/suixinkan/App/RootView.swift index f8c617a..e46d6c2 100644 --- a/suixinkan/App/RootView.swift +++ b/suixinkan/App/RootView.swift @@ -34,6 +34,8 @@ struct RootView: View { @State private var scenicQueueAPI: ScenicQueueAPI @State private var scenicQueueRuntime = ScenicQueueRuntime() @State private var liveAPI: LiveAPI + @State private var operatingAreaAPI: OperatingAreaAPI + @State private var pilotCertificationAPI: PilotCertificationAPI @State private var taskAPI: TaskAPI @State private var projectAPI: ProjectAPI @State private var scheduleAPI: ScheduleAPI @@ -66,6 +68,8 @@ struct RootView: View { _messageCenterAPI = State(initialValue: MessageCenterAPI(client: apiClient)) _scenicQueueAPI = State(initialValue: ScenicQueueAPI(client: apiClient)) _liveAPI = State(initialValue: LiveAPI(client: apiClient)) + _operatingAreaAPI = State(initialValue: OperatingAreaAPI(client: apiClient)) + _pilotCertificationAPI = State(initialValue: PilotCertificationAPI(client: apiClient)) _taskAPI = State(initialValue: TaskAPI(client: apiClient)) _projectAPI = State(initialValue: ProjectAPI(client: apiClient)) _scheduleAPI = State(initialValue: ScheduleAPI(client: apiClient)) @@ -116,6 +120,8 @@ struct RootView: View { .environment(scenicQueueAPI) .environment(scenicQueueRuntime) .environment(liveAPI) + .environment(operatingAreaAPI) + .environment(pilotCertificationAPI) .environment(taskAPI) .environment(projectAPI) .environment(scheduleAPI) diff --git a/suixinkan/Core/Upload/OSSUploadService.swift b/suixinkan/Core/Upload/OSSUploadService.swift index 64eb7b1..ab31e30 100644 --- a/suixinkan/Core/Upload/OSSUploadService.swift +++ b/suixinkan/Core/Upload/OSSUploadService.swift @@ -45,6 +45,9 @@ protocol OSSUploadServing { /// 上传银行卡照片,并返回可访问的 OSS 文件 URL。 func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String + + /// 上传飞手认证证件图片,并返回可访问的 OSS 文件 URL。 + func uploadPilotCertificateImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String } @MainActor @@ -108,6 +111,11 @@ final class OSSUploadService { try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "bank_card", onProgress: onProgress) } + /// 上传飞手认证证件图片,并返回可访问的 OSS 文件 URL。 + func uploadPilotCertificateImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "pilot_cert", onProgress: onProgress) + } + /// 执行通用 OSS 上传流程。 private func uploadFile( data: Data, @@ -218,6 +226,8 @@ enum OSSUploadPolicy { return "real_name/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" case "bank_card": return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + case "pilot_cert": + return "pilot_cert/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" default: return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" } diff --git a/suixinkan/Core/Validation/AppFormValidator.swift b/suixinkan/Core/Validation/AppFormValidator.swift index a5c557f..35c301a 100644 --- a/suixinkan/Core/Validation/AppFormValidator.swift +++ b/suixinkan/Core/Validation/AppFormValidator.swift @@ -9,6 +9,18 @@ import Foundation /// 表单校验工具,承接排队设置等页面的通用数字和金额格式校验。 enum AppFormValidator { + /// 规范化大陆手机号,去除所有空白字符。 + static func normalizedPhoneNumber(_ value: String) -> String { + value.filter { !$0.isWhitespace } + } + + /// 校验大陆 11 位手机号基础号段。 + static func isValidMainlandPhoneNumber(_ value: String) -> Bool { + let phone = normalizedPhoneNumber(value) + guard phone.count == 11, phone.first == "1" else { return false } + return phone.allSatisfy(\.isNumber) + } + /// 校验整数文本在指定范围内。 static func rangedInteger(_ text: String, name: String, range: ClosedRange) throws -> Int { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/suixinkan/Features/Home/Home.md b/suixinkan/Features/Home/Home.md index b156267..25086c1 100644 --- a/suixinkan/Features/Home/Home.md +++ b/suixinkan/Features/Home/Home.md @@ -33,6 +33,6 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作 ## 后续迁移 -目前 `payment_collection`、`payment_qr`、`payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`scenicselection`、`permission_apply`、`permission_apply_status`、`scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management`、`task_management_editor`、`task_create` 已由 `Features/Tasks` 接管,`cloud_management`、`cloud_storage_transit`、`asset_management`、`material_upload`、`album_list`、`album_trailer`、`sample_management`、`sample_upload` 已由 `Features/Assets` 接管,`checkin_points` 已由 `Features/PunchPoint` 接管,`location_report` 和 `location_report_history` 已由 `Features/LocationReport` 接管,`pm`、`project_edit` 已由 `Features/Projects` 的摄影师项目管理接管,`pm_manager` 已由店铺项目管理接管,`schedule_management` 已由 `Features/Schedule` 接管,`registration_invitation`、`photographer_invite`、`invite_record` 已由 `Features/Invite` 接管,`deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 已由 `Features/Orders` 的押金订单页接管,`withdrawal_audit` 已由 `Features/WithdrawalAudit` 接管,`scenic_settlement` 和 `scenic_settlement_review` 已由 `Features/ScenicSettlement` 接管,`message_center` 已由 `Features/MessageCenter` 接管,`/scenic-queue` 和 `queue_management` 已由 `Features/QueueManagement` 接管,`live_stream_management` 和 `live_album` 已由 `Features/Live` 接管。 +目前 `payment_collection`、`payment_qr`、`payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`scenicselection`、`permission_apply`、`permission_apply_status`、`scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management`、`task_management_editor`、`task_create` 已由 `Features/Tasks` 接管,`cloud_management`、`cloud_storage_transit`、`asset_management`、`material_upload`、`album_list`、`album_trailer`、`sample_management`、`sample_upload` 已由 `Features/Assets` 接管,`checkin_points` 已由 `Features/PunchPoint` 接管,`location_report` 和 `location_report_history` 已由 `Features/LocationReport` 接管,`pm`、`project_edit` 已由 `Features/Projects` 的摄影师项目管理接管,`pm_manager` 已由店铺项目管理接管,`schedule_management` 已由 `Features/Schedule` 接管,`registration_invitation`、`photographer_invite`、`invite_record` 已由 `Features/Invite` 接管,`deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 已由 `Features/Orders` 的押金订单页接管,`withdrawal_audit` 已由 `Features/WithdrawalAudit` 接管,`scenic_settlement` 和 `scenic_settlement_review` 已由 `Features/ScenicSettlement` 接管,`message_center` 已由 `Features/MessageCenter` 接管,`/scenic-queue` 和 `queue_management` 已由 `Features/QueueManagement` 接管,`live_stream_management` 和 `live_album` 已由 `Features/Live` 接管,`operating-area` 已由 `Features/OperatingArea` 接管,`pilot_cert` 已由 `Features/PilotCertification` 接管。 后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。 diff --git a/suixinkan/Features/Home/Models/HomeMenuItem.swift b/suixinkan/Features/Home/Models/HomeMenuItem.swift index ac17b84..ed8fcb9 100644 --- a/suixinkan/Features/Home/Models/HomeMenuItem.swift +++ b/suixinkan/Features/Home/Models/HomeMenuItem.swift @@ -62,6 +62,8 @@ enum HomeRoute: Hashable { case queueManagement case liveManagement case liveAlbum + case operatingArea + case pilotCertification case modulePlaceholder(uri: String, title: String) } diff --git a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift index 6a06bb5..1b4f9e6 100644 --- a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift +++ b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift @@ -143,6 +143,10 @@ enum HomeMenuRouter { return .destination(.liveManagement) case "live_album": return .destination(.liveAlbum) + case "operating-area": + return .destination(.operatingArea) + case "pilot_cert": + return .destination(.pilotCertification) case "store", "more_functions": return .destination(.moreFunctions) case "fly", "pilot_controller": @@ -151,10 +155,6 @@ enum HomeMenuRouter { title: title.isEmpty ? self.title(for: uri) : title, reason: "DJI/飞控模块已明确不纳入 iOS 迁移和后续开发范围。" ) - case "operating-area", - "pilot_cert": - let resolvedTitle = title.isEmpty ? self.title(for: uri) : title - return .destination(.modulePlaceholder(uri: uri, title: resolvedTitle)) default: return .placeholder(uri: uri, title: title.isEmpty ? self.title(for: uri) : title) } diff --git a/suixinkan/Features/Home/Views/HomeSupportViews.swift b/suixinkan/Features/Home/Views/HomeSupportViews.swift index 049c58d..7f7c942 100644 --- a/suixinkan/Features/Home/Views/HomeSupportViews.swift +++ b/suixinkan/Features/Home/Views/HomeSupportViews.swift @@ -100,6 +100,10 @@ extension HomeRoute { LiveManagementView() case .liveAlbum: LiveAlbumView() + case .operatingArea: + OperatingAreaView() + case .pilotCertification: + PilotCertificationView() case let .modulePlaceholder(uri, title): HomeMigrationModuleView(title: title, uri: uri) } diff --git a/suixinkan/Features/Live/Live.md b/suixinkan/Features/Live/Live.md index cd7b94a..6939f61 100644 --- a/suixinkan/Features/Live/Live.md +++ b/suixinkan/Features/Live/Live.md @@ -2,18 +2,26 @@ ## 模块职责 -`Features/Live` 承接首页 `live_stream_management` 和 `live_album` 权限入口,负责手动直播管理和直播相册素材管理。 +`Features/Live` 承接首页 `live_stream_management` 和 `live_album` 权限入口,负责手动直播管理和直播相册素材管理。旧 iOS 工程未接入真推流 SDK,本模块按旧 iOS 对齐,不新增腾讯 TRTC、RTMP 或其他推流 SDK 依赖。 ## 代码结构 - `LiveAPI`:封装 `/api/app/manual-live/...` 和 `/api/app/view-album/...` 接口。 - `LiveManagementViewModel`:管理直播列表分页、创建、开始/暂停、结束和详情失败清理。 +- `LivePlaybackViewModel`:管理直播详情和直播相册视频的系统播放器 URL、播放、暂停和释放状态。 +- `LivePushReadinessViewModel`:诊断直播推流地址、相机/麦克风权限、网络状态和当前推流 SDK 接入状态,不触发本机采集推流。 - `LiveAlbumViewModel`:管理直播相册日期筛选、分页和删除相册。 - `LiveAlbumCreateViewModel`:管理本地图片/视频上传到 OSS 后创建直播相册。 - `LiveAlbumPreviewViewModel`:进入相册预览时加载相册详情,并支持删除单个素材。 ## 业务边界 -本模块不接入播放器或推流 SDK。直播详情展示推流地址、复制和控制能力;视频素材预览优先展示接口返回的封面,缺少封面时展示视频占位,并提供打开或复制原始 URL。 +直播详情会从 `play_url`、`pull_url`、`hls_url`、`live_url`、`flv_url` 中筛选系统播放器可处理的 http/https 播放地址;`push_url` 是给 OBS 或第三方工具使用的外部推流地址,不作为播放地址使用。只有 RTMP 推流地址时,页面展示封面、提示和复制入口。 + +直播相册视频预览使用系统 `VideoPlayer` 播放可支持的视频地址;图片仍使用 `RemoteImage` 展示。视频地址不可播放时,页面保留打开和复制原始 URL 的操作。 + +推流专项按旧 iOS 对齐,只保留诊断层和适配协议,默认 `UnsupportedLivePushAdapter` 明确提示未接入真推流 SDK;不引入腾讯/TRTC、Agora、HaishinKit、Zego 或 Android `youfun_control` 的飞控/抓娃娃直播接口。直播详情的“开始/暂停/结束”和“推流模式”只调用 `manual-live` 业务接口,不启动本机摄像头、麦克风或后台推流。 + +后续若要接入真推流 SDK,可参考 Android `youfun_control` 中腾讯 TRTC 的实现路线,但需要单独确认后端房间参数、SDK 版本、真机权限、推流源和人工联调范围。 直播封面创建沿用旧 iOS 的 URL 输入。直播相册素材通过 `PhotosPicker` 选择后,使用 `OSSUploadService.uploadAliveAlbumFile` 上传到 `live_albums/yyyyMMdd/scenicId/...`,再把最终 URL 提交给创建相册接口。 diff --git a/suixinkan/Features/Live/Models/LiveModels.swift b/suixinkan/Features/Live/Models/LiveModels.swift index 7baaa95..867c15c 100644 --- a/suixinkan/Features/Live/Models/LiveModels.swift +++ b/suixinkan/Features/Live/Models/LiveModels.swift @@ -43,6 +43,11 @@ struct LiveEntity: Decodable, Identifiable, Equatable { let title: String let coverImg: String let pushUrl: String + let playUrl: String + let pullUrl: String + let hlsUrl: String + let flvUrl: String + let liveUrl: String let startTime: Int64 let endTime: Int64 let duration: Int64 @@ -57,6 +62,11 @@ struct LiveEntity: Decodable, Identifiable, Equatable { case title case coverImg = "cover_img" case pushUrl = "push_url" + case playUrl = "play_url" + case pullUrl = "pull_url" + case hlsUrl = "hls_url" + case flvUrl = "flv_url" + case liveUrl = "live_url" case startTime = "start_time" case endTime = "end_time" case duration @@ -72,6 +82,11 @@ struct LiveEntity: Decodable, Identifiable, Equatable { title: String = "", coverImg: String = "", pushUrl: String = "", + playUrl: String = "", + pullUrl: String = "", + hlsUrl: String = "", + flvUrl: String = "", + liveUrl: String = "", startTime: Int64 = 0, endTime: Int64 = 0, duration: Int64 = 0, @@ -85,6 +100,11 @@ struct LiveEntity: Decodable, Identifiable, Equatable { self.title = title self.coverImg = coverImg self.pushUrl = pushUrl + self.playUrl = playUrl + self.pullUrl = pullUrl + self.hlsUrl = hlsUrl + self.flvUrl = flvUrl + self.liveUrl = liveUrl self.startTime = startTime self.endTime = endTime self.duration = duration @@ -101,6 +121,11 @@ struct LiveEntity: Decodable, Identifiable, Equatable { title = try container.liveDecodeLossyString(forKey: .title) coverImg = try container.liveDecodeLossyString(forKey: .coverImg) pushUrl = try container.liveDecodeLossyString(forKey: .pushUrl) + playUrl = try container.liveDecodeLossyString(forKey: .playUrl) + pullUrl = try container.liveDecodeLossyString(forKey: .pullUrl) + hlsUrl = try container.liveDecodeLossyString(forKey: .hlsUrl) + flvUrl = try container.liveDecodeLossyString(forKey: .flvUrl) + liveUrl = try container.liveDecodeLossyString(forKey: .liveUrl) startTime = Int64(try container.liveDecodeLossyInt(forKey: .startTime) ?? 0) endTime = Int64(try container.liveDecodeLossyInt(forKey: .endTime) ?? 0) duration = Int64(try container.liveDecodeLossyInt(forKey: .duration) ?? 0) @@ -114,6 +139,10 @@ struct LiveEntity: Decodable, Identifiable, Equatable { var displayStatus: String { statusLabel.liveNonEmpty ?? "状态\(status)" } + + var playbackURLCandidates: [String] { + [playUrl, pullUrl, hlsUrl, liveUrl, flvUrl] + } } /// 创建直播请求实体。 diff --git a/suixinkan/Features/Live/ViewModels/LivePlaybackViewModel.swift b/suixinkan/Features/Live/ViewModels/LivePlaybackViewModel.swift new file mode 100644 index 0000000..2f0f8ee --- /dev/null +++ b/suixinkan/Features/Live/ViewModels/LivePlaybackViewModel.swift @@ -0,0 +1,132 @@ +// +// LivePlaybackViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import AVFoundation +import Foundation +import Observation + +/// 直播播放地址解析器,避免把 RTMP 推流地址误当作播放地址。 +enum LivePlaybackURLResolver { + static func playableURL(from live: LiveEntity) -> URL? { + playableURL(from: live.playbackURLCandidates) + } + + static func playableURL(from candidates: [String]) -> URL? { + for candidate in candidates { + if let url = playableURL(from: candidate) { + return url + } + } + return nil + } + + static func playableURL(from rawValue: String) -> URL? { + let value = rawValue.liveTrimmed + guard !value.isEmpty, let url = URL(string: value) else { return nil } + guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return nil } + if url.pathExtension.lowercased() == "flv" { + return nil + } + return url + } +} + +/// 播放器状态。 +enum LivePlaybackState: Equatable { + case empty + case ready(URL) + case playing(URL) + case failed(String) +} + +@MainActor +@Observable +/// 直播播放器 ViewModel,管理系统播放器的 URL、播放和释放状态。 +final class LivePlaybackViewModel { + var state: LivePlaybackState = .empty + var errorMessage: String? + + @ObservationIgnored private(set) var player: AVPlayer? + + var playableURL: URL? { + switch state { + case .ready(let url), .playing(let url): + url + case .empty, .failed: + nil + } + } + + var isPlaying: Bool { + if case .playing = state { return true } + return false + } + + init(live: LiveEntity? = nil, urlString: String? = nil) { + if let live { + load(live: live) + } else if let urlString { + load(urlString: urlString) + } + } + + func load(live: LiveEntity) { + load(url: LivePlaybackURLResolver.playableURL(from: live)) + } + + func load(urlString: String) { + load(url: LivePlaybackURLResolver.playableURL(from: urlString)) + } + + func play() { + guard let url = playableURL else { return } + if player == nil { + player = AVPlayer(url: url) + } + player?.play() + state = .playing(url) + } + + func pause() { + guard let url = playableURL else { return } + player?.pause() + state = .ready(url) + } + + func reload() { + guard let url = playableURL else { return } + releasePlayer() + player = AVPlayer(url: url) + state = .ready(url) + } + + func release() { + releasePlayer() + if let url = playableURL { + state = .ready(url) + } else { + state = .empty + } + } + + private func load(url: URL?) { + releasePlayer() + guard let url else { + errorMessage = "暂无可播放地址" + state = .empty + return + } + errorMessage = nil + player = AVPlayer(url: url) + state = .ready(url) + } + + private func releasePlayer() { + player?.pause() + player = nil + } +} diff --git a/suixinkan/Features/Live/ViewModels/LivePushReadinessViewModel.swift b/suixinkan/Features/Live/ViewModels/LivePushReadinessViewModel.swift new file mode 100644 index 0000000..1268d95 --- /dev/null +++ b/suixinkan/Features/Live/ViewModels/LivePushReadinessViewModel.swift @@ -0,0 +1,290 @@ +// +// LivePushReadinessViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import AVFoundation +import Foundation +import Network +import Observation + +/// 直播推流权限状态。 +enum LivePushPermissionState: Equatable { + case unknown + case granted + case denied + + var displayText: String { + switch self { + case .unknown: "未检查" + case .granted: "已授权" + case .denied: "未授权" + } + } +} + +/// 直播推流网络状态。 +enum LivePushNetworkState: Equatable { + case unknown + case unavailable + case wifi + case cellular + case other + + var displayText: String { + switch self { + case .unknown: "检测中" + case .unavailable: "网络不可用" + case .wifi: "Wi-Fi" + case .cellular: "蜂窝网络" + case .other: "其他网络" + } + } +} + +/// 推流 SDK 适配协议。后续接入真实 RTMP/RTC SDK 时替换默认实现。 +protocol LivePushAdapter { + var name: String { get } + var isAvailable: Bool { get } + func prepare(pushURL: URL) async throws + func start() async throws + func stop() async throws + func dispose() async +} + +/// 权限提供者协议,隔离 AVFoundation 便于测试。 +protocol LivePermissionProviding { + func cameraPermission() async -> LivePushPermissionState + func microphonePermission() async -> LivePushPermissionState +} + +/// 网络监听协议,隔离 NWPathMonitor 便于测试。 +protocol LiveNetworkMonitoring: AnyObject { + var currentState: LivePushNetworkState { get } + func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void) + func stop() +} + +enum LivePushReadinessError: LocalizedError, Equatable { + case missingPushURL + case invalidPushURL + case permissionDenied + case networkUnavailable + case sdkUnavailable + + var errorDescription: String? { + switch self { + case .missingPushURL: + "暂无推流地址" + case .invalidPushURL: + "推流地址格式无效" + case .permissionDenied: + "请先开启相机和麦克风权限" + case .networkUnavailable: + "当前网络不可用" + case .sdkUnavailable: + "当前版本未接入真推流 SDK" + } + } +} + +struct UnsupportedLivePushAdapter: LivePushAdapter { + let name = "未接入推流 SDK" + let isAvailable = false + + func prepare(pushURL: URL) async throws { + throw LivePushReadinessError.sdkUnavailable + } + + func start() async throws { + throw LivePushReadinessError.sdkUnavailable + } + + func stop() async throws {} + + func dispose() async {} +} + +struct SystemLivePermissionProvider: LivePermissionProviding { + func cameraPermission() async -> LivePushPermissionState { + await permission(for: .video) + } + + func microphonePermission() async -> LivePushPermissionState { + await permission(for: .audio) + } + + private func permission(for mediaType: AVMediaType) async -> LivePushPermissionState { + switch AVCaptureDevice.authorizationStatus(for: mediaType) { + case .authorized: + return .granted + case .notDetermined: + let granted = await AVCaptureDevice.requestAccess(for: mediaType) + return granted ? .granted : .denied + case .denied, .restricted: + return .denied + @unknown default: + return .denied + } + } +} + +final class SystemLiveNetworkMonitor: LiveNetworkMonitoring { + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "com.suixinkan.live.network") + private(set) var currentState: LivePushNetworkState = .unknown + + func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void) { + monitor.pathUpdateHandler = { [weak self] path in + let state = Self.state(from: path) + self?.currentState = state + onChange(state) + } + monitor.start(queue: queue) + } + + func stop() { + monitor.cancel() + } + + private static func state(from path: NWPath) -> LivePushNetworkState { + guard path.status == .satisfied else { return .unavailable } + if path.usesInterfaceType(.wifi) { return .wifi } + if path.usesInterfaceType(.cellular) { return .cellular } + return .other + } +} + +@MainActor +@Observable +/// 推流准备 ViewModel,检查权限、网络和默认 SDK 可用性。 +final class LivePushReadinessViewModel { + var cameraPermission: LivePushPermissionState = .unknown + var microphonePermission: LivePushPermissionState = .unknown + var networkState: LivePushNetworkState = .unknown + var prepared = false + var running = false + var errorMessage: String? + + let adapterName: String + let sdkStatusText: String + + @ObservationIgnored private let permissionProvider: any LivePermissionProviding + @ObservationIgnored private let networkMonitor: any LiveNetworkMonitoring + @ObservationIgnored private let adapter: any LivePushAdapter + @ObservationIgnored private var pushURL: URL? + + init( + permissionProvider: any LivePermissionProviding = SystemLivePermissionProvider(), + networkMonitor: any LiveNetworkMonitoring = SystemLiveNetworkMonitor(), + adapter: any LivePushAdapter = UnsupportedLivePushAdapter() + ) { + self.permissionProvider = permissionProvider + self.networkMonitor = networkMonitor + self.adapter = adapter + self.adapterName = adapter.name + self.sdkStatusText = adapter.isAvailable ? "已接入" : "未接入真推流 SDK" + self.networkState = networkMonitor.currentState + } + + func configure(pushURL rawValue: String) { + let value = rawValue.liveTrimmed + guard !value.isEmpty else { + pushURL = nil + errorMessage = LivePushReadinessError.missingPushURL.localizedDescription + return + } + guard let url = URL(string: value), url.scheme?.isEmpty == false else { + pushURL = nil + errorMessage = LivePushReadinessError.invalidPushURL.localizedDescription + return + } + pushURL = url + errorMessage = nil + } + + func startMonitoring() { + networkState = networkMonitor.currentState + networkMonitor.start { [weak self] state in + Task { @MainActor in + self?.networkState = state + } + } + } + + func stopMonitoring() { + networkMonitor.stop() + } + + func refreshPermissions() async { + async let camera = permissionProvider.cameraPermission() + async let microphone = permissionProvider.microphonePermission() + cameraPermission = await camera + microphonePermission = await microphone + } + + func runDiagnostics() throws { + do { + try validateReadiness() + } catch { + errorMessage = error.localizedDescription + throw error + } + guard adapter.isAvailable else { + errorMessage = LivePushReadinessError.sdkUnavailable.localizedDescription + throw LivePushReadinessError.sdkUnavailable + } + errorMessage = nil + } + + func prepare() async throws { + try validateReadiness() + guard let pushURL else { throw LivePushReadinessError.missingPushURL } + do { + try await adapter.prepare(pushURL: pushURL) + prepared = true + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + throw error + } + } + + func startPush() async throws { + try validateReadiness() + guard adapter.isAvailable else { + errorMessage = LivePushReadinessError.sdkUnavailable.localizedDescription + throw LivePushReadinessError.sdkUnavailable + } + try await adapter.start() + running = true + prepared = true + errorMessage = nil + } + + func stopPush() async { + try? await adapter.stop() + running = false + } + + func dispose() async { + stopMonitoring() + await adapter.dispose() + running = false + prepared = false + } + + private func validateReadiness() throws { + guard pushURL != nil else { + throw LivePushReadinessError.missingPushURL + } + guard cameraPermission != .denied, microphonePermission != .denied else { + throw LivePushReadinessError.permissionDenied + } + guard networkState != .unavailable else { + throw LivePushReadinessError.networkUnavailable + } + } +} diff --git a/suixinkan/Features/Live/Views/LiveAlbumViews.swift b/suixinkan/Features/Live/Views/LiveAlbumViews.swift index b9250bb..065bef3 100644 --- a/suixinkan/Features/Live/Views/LiveAlbumViews.swift +++ b/suixinkan/Features/Live/Views/LiveAlbumViews.swift @@ -5,6 +5,7 @@ // Created by Codex on 2026/6/25. // +import AVKit import PhotosUI import SwiftUI import UniformTypeIdentifiers @@ -459,13 +460,17 @@ struct LiveAlbumPreviewView: View { private struct LiveAlbumPreviewPage: View { let file: LiveAlbumFileItem @Environment(ToastCenter.self) private var toastCenter + @State private var playbackViewModel: LivePlaybackViewModel + + init(file: LiveAlbumFileItem) { + self.file = file + _playbackViewModel = State(initialValue: LivePlaybackViewModel(urlString: file.url)) + } var body: some View { VStack(spacing: AppMetrics.Spacing.medium) { - if file.isVideo && file.coverImg?.liveTrimmed.isEmpty != false { - Image(systemName: "play.circle.fill") - .font(.system(size: 72, weight: .semibold)) - .foregroundStyle(.white) + if file.isVideo { + videoContent } else { RemoteImage(urlString: file.previewURL, contentMode: .fit) { ProgressView() @@ -487,6 +492,33 @@ private struct LiveAlbumPreviewPage: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(AppMetrics.Spacing.medium) .background(Color.black) + .onDisappear { + playbackViewModel.release() + } + } + + @ViewBuilder + private var videoContent: some View { + if let player = playbackViewModel.player, playbackViewModel.playableURL != nil { + VideoPlayer(player: player) + .aspectRatio(16.0 / 9.0, contentMode: .fit) + .frame(maxWidth: .infinity) + .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } else if file.coverImg?.liveTrimmed.isEmpty == false { + RemoteImage(urlString: file.previewURL, contentMode: .fit) { + ProgressView() + .tint(.white) + } + } else { + VStack(spacing: AppMetrics.Spacing.small) { + Image(systemName: "play.slash.fill") + .font(.system(size: 62, weight: .semibold)) + .foregroundStyle(.white) + Text("当前视频地址暂不可播放") + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(.white) + } + } } } diff --git a/suixinkan/Features/Live/Views/LiveManagementViews.swift b/suixinkan/Features/Live/Views/LiveManagementViews.swift index 8a17adc..f0bb900 100644 --- a/suixinkan/Features/Live/Views/LiveManagementViews.swift +++ b/suixinkan/Features/Live/Views/LiveManagementViews.swift @@ -5,6 +5,7 @@ // Created by Codex on 2026/6/25. // +import AVKit import SwiftUI /// 直播管理首页,展示直播列表和控制入口。 @@ -354,11 +355,14 @@ struct LiveDetailView: View { @Environment(ToastCenter.self) private var toastCenter @Environment(\.globalLoading) private var globalLoading @State private var viewModel: LiveDetailViewModel + @State private var playbackViewModel: LivePlaybackViewModel + @State private var pushReadinessViewModel = LivePushReadinessViewModel() @State private var copied = false let onChanged: () -> Void init(initialDetail: LiveEntity, onChanged: @escaping () -> Void) { _viewModel = State(initialValue: LiveDetailViewModel(detail: initialDetail)) + _playbackViewModel = State(initialValue: LivePlaybackViewModel(live: initialDetail)) self.onChanged = onChanged } @@ -366,10 +370,11 @@ struct LiveDetailView: View { VStack(spacing: 0) { ScrollView { VStack(spacing: AppMetrics.Spacing.medium) { - coverSection + playbackSection infoSection pushURLSection pushModeSection + pushReadinessSection Spacer(minLength: 80) } .padding(AppMetrics.Spacing.pageHorizontal) @@ -387,18 +392,32 @@ struct LiveDetailView: View { .disabled(viewModel.loading || viewModel.actionInFlight) } } - .task { await refresh(showLoading: false) } + .task { + pushReadinessViewModel.configure(pushURL: viewModel.detail.pushUrl) + pushReadinessViewModel.startMonitoring() + await pushReadinessViewModel.refreshPermissions() + await refresh(showLoading: false) + } + .onDisappear { + playbackViewModel.release() + Task { await pushReadinessViewModel.dispose() } + } } - private var coverSection: some View { + private var playbackSection: some View { ZStack(alignment: .bottomLeading) { - RemoteImage(urlString: viewModel.detail.coverImg, contentMode: .fill) { - LinearGradient(colors: [Color(hex: 0x0F274A), AppDesign.primary], startPoint: .topLeading, endPoint: .bottomTrailing) - .overlay { - Image(systemName: "dot.radiowaves.left.and.right") - .font(.system(size: 42, weight: .semibold)) - .foregroundStyle(.white.opacity(0.72)) - } + if let player = playbackViewModel.player, playbackViewModel.playableURL != nil { + VideoPlayer(player: player) + .background(Color.black) + } else { + RemoteImage(urlString: viewModel.detail.coverImg, contentMode: .fill) { + LinearGradient(colors: [Color(hex: 0x0F274A), AppDesign.primary], startPoint: .topLeading, endPoint: .bottomTrailing) + .overlay { + Image(systemName: "dot.radiowaves.left.and.right") + .font(.system(size: 42, weight: .semibold)) + .foregroundStyle(.white.opacity(0.72)) + } + } } VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { Text(viewModel.detail.displayStatus) @@ -411,6 +430,32 @@ struct LiveDetailView: View { .font(.system(size: AppMetrics.FontSize.title, weight: .bold)) .foregroundStyle(.white) .lineLimit(2) + Text(playbackHint) + .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) + .foregroundStyle(.white.opacity(0.86)) + .lineLimit(2) + HStack(spacing: AppMetrics.Spacing.small) { + if playbackViewModel.playableURL != nil { + Button { + playbackViewModel.isPlaying ? playbackViewModel.pause() : playbackViewModel.play() + } label: { + Label(playbackViewModel.isPlaying ? "暂停" : "播放", systemImage: playbackViewModel.isPlaying ? "pause.fill" : "play.fill") + } + Button { + playbackViewModel.reload() + } label: { + Label("重载", systemImage: "arrow.clockwise") + } + } + if let url = playbackViewModel.playableURL { + Link(destination: url) { + Label("打开", systemImage: "safari") + } + } + } + .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) + .buttonStyle(.borderedProminent) + .tint(.white.opacity(0.22)) } .padding(AppMetrics.Spacing.medium) .frame(maxWidth: .infinity, alignment: .leading) @@ -420,6 +465,16 @@ struct LiveDetailView: View { .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) } + private var playbackHint: String { + if playbackViewModel.playableURL != nil { + return "已检测到可播放地址" + } + if viewModel.detail.pushUrl.liveTrimmed.lowercased().hasPrefix("rtmp://") { + return "当前仅有 RTMP 推流地址,系统播放器不可直接播放" + } + return "暂无可播放直播地址" + } + private var infoSection: some View { liveDetailCard("直播信息") { LiveInfoRow(title: "标题", value: viewModel.detail.title.liveNonEmpty ?? "--") @@ -431,6 +486,9 @@ struct LiveDetailView: View { private var pushURLSection: some View { liveDetailCard("推流地址") { + Text("该地址用于 OBS 或第三方推流工具;当前版本不在 App 内采集摄像头/麦克风并推流。") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) Text(viewModel.detail.pushUrl.liveNonEmpty ?? "--") .font(.system(size: AppMetrics.FontSize.subheadline)) .textSelection(.enabled) @@ -458,6 +516,40 @@ struct LiveDetailView: View { } } + private var pushReadinessSection: some View { + liveDetailCard("推流诊断") { + LiveInfoRow(title: "相机权限", value: pushReadinessViewModel.cameraPermission.displayText) + LiveInfoRow(title: "麦克风权限", value: pushReadinessViewModel.microphonePermission.displayText) + LiveInfoRow(title: "网络状态", value: pushReadinessViewModel.networkState.displayText) + LiveInfoRow(title: "推流 SDK", value: pushReadinessViewModel.adapterName) + LiveInfoRow(title: "SDK 状态", value: pushReadinessViewModel.sdkStatusText) + Text("此处仅做权限、网络和 SDK 接入状态诊断;开始、暂停、结束仍只控制 manual-live 业务状态。") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + if let message = pushReadinessViewModel.errorMessage { + Text(message) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(Color(hex: 0xEF4444)) + } + HStack(spacing: AppMetrics.Spacing.small) { + Button { + Task { await pushReadinessViewModel.refreshPermissions() } + } label: { + Label("重新检查", systemImage: "checkmark.shield") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + Button { + diagnosePush() + } label: { + Label("检查 SDK", systemImage: "antenna.radiowaves.left.and.right") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + } + } + } + private var bottomBar: some View { HStack(spacing: AppMetrics.Spacing.medium) { Button { @@ -523,6 +615,7 @@ struct LiveDetailView: View { await globalLoading.withOptionalLoading(showLoading) { await viewModel.refresh(api: liveAPI, showLoading: false) } + syncMediaState() if let message = viewModel.errorMessage { toastCenter.show(message) } @@ -554,6 +647,21 @@ struct LiveDetailView: View { toastCenter.show(error.localizedDescription) } } + + private func diagnosePush() { + pushReadinessViewModel.configure(pushURL: viewModel.detail.pushUrl) + do { + try pushReadinessViewModel.runDiagnostics() + toastCenter.show("推流诊断通过") + } catch { + toastCenter.show(error.localizedDescription) + } + } + + private func syncMediaState() { + playbackViewModel.load(live: viewModel.detail) + pushReadinessViewModel.configure(pushURL: viewModel.detail.pushUrl) + } } private struct LiveInfoRow: View { diff --git a/suixinkan/Features/OperatingArea/API/OperatingAreaAPI.swift b/suixinkan/Features/OperatingArea/API/OperatingAreaAPI.swift new file mode 100644 index 0000000..690feb7 --- /dev/null +++ b/suixinkan/Features/OperatingArea/API/OperatingAreaAPI.swift @@ -0,0 +1,52 @@ +// +// OperatingAreaAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation +import Observation + +/// 运营区域服务协议,定义店铺和景区管理员两类围栏数据接口。 +@MainActor +protocol OperatingAreaServing { + func storeBusinessArea(storeId: Int) async throws -> ListPayload + func scenicAdminBusinessArea(scenicId: Int) async throws -> ListPayload +} + +@MainActor +@Observable +/// 运营区域 API,封装运营区域只读围栏接口。 +final class OperatingAreaAPI { + @ObservationIgnored private let client: APIClient + + /// 初始化运营区域 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取当前店铺的运营区域围栏。 + func storeBusinessArea(storeId: Int) async throws -> ListPayload { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/store/business-area", + queryItems: [URLQueryItem(name: "store_id", value: String(storeId))] + ) + ) + } + + /// 获取景区管理员视角下的运营区域围栏。 + func scenicAdminBusinessArea(scenicId: Int) async throws -> ListPayload { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/scenic-admin/business-area", + queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))] + ) + ) + } +} + +extension OperatingAreaAPI: OperatingAreaServing {} diff --git a/suixinkan/Features/OperatingArea/Models/OperatingAreaModels.swift b/suixinkan/Features/OperatingArea/Models/OperatingAreaModels.swift new file mode 100644 index 0000000..6ed6754 --- /dev/null +++ b/suixinkan/Features/OperatingArea/Models/OperatingAreaModels.swift @@ -0,0 +1,374 @@ +// +// OperatingAreaModels.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +/// 运营区域入口模式,区分店铺管理员与景区管理员两条接口链路。 +enum OperatingAreaEntryMode: Equatable { + case storeAdmin(storeId: Int) + case scenicAdmin(scenicId: Int) + + var title: String { + switch self { + case .storeAdmin: + "店铺运营区域" + case .scenicAdmin: + "景区运营区域" + } + } +} + +/// 运营区域接口单项,表示一个门店或区域围栏。 +struct OperatingAreaItem: Decodable, Equatable, Identifiable { + let id: Int + let name: String + let businessMapArea: OperatingMapArea? + let statusText: String + let typeText: String + let auditStatusText: String + + enum CodingKeys: String, CodingKey { + case id + case name + case businessMapArea = "business_map_area" + case statusText = "status_text" + case typeText = "type_text" + case auditStatusText = "audit_status_text" + } + + init( + id: Int = 0, + name: String = "", + businessMapArea: OperatingMapArea? = nil, + statusText: String = "", + typeText: String = "", + auditStatusText: String = "" + ) { + self.id = id + self.name = name + self.businessMapArea = businessMapArea + self.statusText = statusText + self.typeText = typeText + self.auditStatusText = auditStatusText + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.operatingDecodeLossyInt(forKey: .id) ?? 0 + name = try container.operatingDecodeLossyString(forKey: .name) + businessMapArea = try container.decodeIfPresent(OperatingMapArea.self, forKey: .businessMapArea) + statusText = try container.operatingDecodeLossyString(forKey: .statusText) + typeText = try container.operatingDecodeLossyString(forKey: .typeText) + auditStatusText = try container.operatingDecodeLossyString(forKey: .auditStatusText) + } +} + +/// 运营区域原始 JSON 值,用于保留并解析后端不稳定的 `business_map_area`。 +enum OperatingMapArea: Decodable, Equatable { + case null + case bool(Bool) + case number(Double) + case string(String) + case array([OperatingMapArea]) + case object([String: OperatingMapArea]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([OperatingMapArea].self) { + self = .array(value) + } else { + let keyed = try decoder.container(keyedBy: OperatingDynamicCodingKey.self) + var object: [String: OperatingMapArea] = [:] + for key in keyed.allKeys { + object[key.stringValue] = try keyed.decode(OperatingMapArea.self, forKey: key) + } + self = .object(object) + } + } + + /// 将 JSON 字符串解析为运营区域 JSON 值。 + static func parseString(_ text: String) -> OperatingMapArea? { + guard let data = text.data(using: .utf8), + let raw = try? JSONSerialization.jsonObject(with: data) + else { return nil } + return fromJSONObject(raw) + } + + private static func fromJSONObject(_ value: Any) -> OperatingMapArea { + switch value { + case is NSNull: + return .null + case let value as Bool: + return .bool(value) + case let value as NSNumber: + return .number(value.doubleValue) + case let value as String: + return .string(value) + case let value as [Any]: + return .array(value.map(fromJSONObject)) + case let value as [String: Any]: + return .object(value.mapValues(fromJSONObject)) + default: + return .null + } + } +} + +/// 运营区域地理坐标点。 +struct OperatingGeoPoint: Equatable, Hashable { + let latitude: Double + let longitude: Double +} + +/// 运营区域围栏环,用于地图绘制和列表展示。 +struct OperatingFenceRing: Identifiable, Equatable { + let id: String + let itemId: Int + let regionName: String + let points: [OperatingGeoPoint] + let isCurrentStore: Bool + + init(itemId: Int, regionName: String, points: [OperatingGeoPoint], isCurrentStore: Bool, ringIndex: Int) { + self.id = "\(itemId)-\(ringIndex)" + self.itemId = itemId + self.regionName = regionName + self.points = points + self.isCurrentStore = isCurrentStore + } +} + +/// 运营区域阻断原因,用于缺少上下文或数据不可展示时给出稳定提示。 +enum OperatingAreaBlockReason: Equatable { + case missingStore + case missingScenic + case unsupportedRole + case emptyAreaList + case noParsableFenceData + case backendMessage(String) + + var message: String { + switch self { + case .missingStore: + "请先选择店铺" + case .missingScenic: + "请先选择景区" + case .unsupportedRole: + "当前账号暂不支持运营区域" + case .emptyAreaList: + "当前景区无区域数据" + case .noParsableFenceData: + "有区域记录,但未解析出有效围栏,请检查坐标数据" + case .backendMessage(let message): + message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "运营区域加载失败" : message + } + } +} + +/// 运营区域围栏解析器,兼容 GeoJSON、数组、字符串 JSON 和常见路径字段。 +enum OperatingAreaParser { + /// 将后端 `business_map_area` 解析为若干闭合多边形环。 + static func parseToRings(_ area: OperatingMapArea?) -> [[OperatingGeoPoint]] { + guard let area else { return [] } + return parseElement(area) + } + + private static func parseElement(_ value: OperatingMapArea) -> [[OperatingGeoPoint]] { + switch value { + case .null, .bool, .number: + return [] + case .string(let text): + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let parsed = OperatingMapArea.parseString(trimmed) else { return [] } + return parseElement(parsed) + case .array(let array): + return parseArrayRoot(array) + case .object(let object): + return parseObject(object) + } + } + + private static func parseObject(_ object: [String: OperatingMapArea]) -> [[OperatingGeoPoint]] { + let type: String? + if case let .string(rawType)? = object["type"] { + type = rawType.lowercased() + } else { + type = nil + } + + switch type { + case "polygon": + guard case let .array(coordinates)? = object["coordinates"], + case let .array(outer)? = coordinates.first + else { return [] } + return parseRing(outer).map { [$0] } ?? [] + case "multipolygon": + guard case let .array(polygons)? = object["coordinates"] else { return [] } + return polygons.compactMap { polygon -> [OperatingGeoPoint]? in + guard case let .array(rings) = polygon, + case let .array(firstRing)? = rings.first + else { return nil } + return parseRing(firstRing) + } + default: + for key in ["path", "paths", "points", "coordinates"] { + if let nested = object[key] { + return parseElement(nested) + } + } + return [] + } + } + + private static func parseArrayRoot(_ array: [OperatingMapArea]) -> [[OperatingGeoPoint]] { + guard let first = array.first else { return [] } + switch first { + case .object: + return parseObjectRing(array).map { [$0] } ?? [] + case .array(let nested): + guard let sample = nested.first else { return [] } + switch sample { + case .array(let pointCandidate): + if pointCandidate.count >= 2, pointCandidate[0].numberValue != nil, pointCandidate[1].numberValue != nil { + return parseRing(nested).map { [$0] } ?? [] + } + return array.compactMap { element -> [OperatingGeoPoint]? in + guard case let .array(segment) = element else { return nil } + return parseRing(segment) + } + case .object: + return parseObjectRing(nested).map { [$0] } ?? [] + default: + return parseRing(nested).map { [$0] } ?? [] + } + default: + return [] + } + } + + private static func parseObjectRing(_ array: [OperatingMapArea]) -> [OperatingGeoPoint]? { + let points = array.compactMap { element -> OperatingGeoPoint? in + guard case let .object(object) = element else { return nil } + return pointFromObject(object) + } + return points.count >= 3 ? points : nil + } + + private static func parseRing(_ array: [OperatingMapArea]) -> [OperatingGeoPoint]? { + let points = array.compactMap { element -> OperatingGeoPoint? in + switch element { + case .array(let pair): + guard pair.count >= 2, + let a = pair[0].numberValue, + let b = pair[1].numberValue + else { return nil } + return normalizePair(a, b) + case .object(let object): + return pointFromObject(object) + default: + return nil + } + } + return points.count >= 3 ? points : nil + } + + private static func pointFromObject(_ object: [String: OperatingMapArea]) -> OperatingGeoPoint? { + let lat = object["lat"]?.numberValue ?? object["latitude"]?.numberValue + let lng = object["lng"]?.numberValue ?? object["lon"]?.numberValue ?? object["longitude"]?.numberValue + guard let lat, let lng else { return nil } + return OperatingGeoPoint(latitude: lat, longitude: lng) + } + + private static func normalizePair(_ a: Double, _ b: Double) -> OperatingGeoPoint { + if looksLikeLngLatPair(lng: a, lat: b) { + return OperatingGeoPoint(latitude: b, longitude: a) + } + if looksLikeLatLngPair(lat: a, lng: b) { + return OperatingGeoPoint(latitude: a, longitude: b) + } + if abs(a) > 90 { + return OperatingGeoPoint(latitude: b, longitude: a) + } + return OperatingGeoPoint(latitude: a, longitude: b) + } + + private static func looksLikeLngLatPair(lng: Double, lat: Double) -> Bool { + (-90.0...90.0).contains(lat) && abs(lng) <= 180.0 && abs(lng) > abs(lat) + } + + private static func looksLikeLatLngPair(lat: Double, lng: Double) -> Bool { + (-90.0...90.0).contains(lat) && abs(lng) <= 180.0 && abs(lat) <= abs(lng) + } +} + +private struct OperatingDynamicCodingKey: CodingKey { + let stringValue: String + let intValue: Int? + + init?(stringValue: String) { + self.stringValue = stringValue + intValue = nil + } + + init?(intValue: Int) { + stringValue = String(intValue) + self.intValue = intValue + } +} + +private extension OperatingMapArea { + var numberValue: Double? { + switch self { + case .number(let value): + return value + case .string(let text): + return Double(text.trimmingCharacters(in: .whitespacesAndNewlines)) + default: + return nil + } + } +} + +private extension KeyedDecodingContainer { + func operatingDecodeLossyString(forKey key: Key) throws -> String { + if let value = try? decodeIfPresent(String.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Bool.self, forKey: key) { + return value ? "true" : "false" + } + return "" + } + + func operatingDecodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let intValue = Int(text) { return intValue } + if let doubleValue = Double(text) { return Int(doubleValue) } + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + return nil + } +} diff --git a/suixinkan/Features/OperatingArea/OperatingArea.md b/suixinkan/Features/OperatingArea/OperatingArea.md new file mode 100644 index 0000000..c2f82ba --- /dev/null +++ b/suixinkan/Features/OperatingArea/OperatingArea.md @@ -0,0 +1,17 @@ +# OperatingArea 模块业务逻辑 + +## 模块职责 + +OperatingArea 负责首页 `operating-area` 入口的运营区域展示。模块按当前角色和业务上下文进入店铺管理员或景区管理员模式,读取后端围栏数据并只读展示。 + +## 核心流程 + +- `OperatingAreaView` 从环境读取 `AccountContext`、`PermissionContext` 和 `OperatingAreaAPI`。 +- `OperatingAreaViewModel` 根据角色名称和当前景区/门店解析入口模式。 +- 店铺管理员调用 `/api/app/store/business-area`,景区管理员调用 `/api/app/scenic-admin/business-area`。 +- `OperatingAreaParser` 解析 `business_map_area`,兼容 GeoJSON、坐标数组和字符串 JSON。 +- 真机构建启用高德地图时绘制 polygon;模拟器或未启用高德时展示坐标摘要兜底。 + +## 边界 + +本模块只展示已有区域和围栏,不提供新建、编辑、删除或绘制保存能力。缺少景区/门店、角色不支持、空列表和不可解析围栏都会进入明确空态或阻断提示。 diff --git a/suixinkan/Features/OperatingArea/ViewModels/OperatingAreaViewModel.swift b/suixinkan/Features/OperatingArea/ViewModels/OperatingAreaViewModel.swift new file mode 100644 index 0000000..bd2d524 --- /dev/null +++ b/suixinkan/Features/OperatingArea/ViewModels/OperatingAreaViewModel.swift @@ -0,0 +1,154 @@ +// +// OperatingAreaViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation +import Observation + +@MainActor +@Observable +/// 运营区域 ViewModel,负责角色分流、围栏加载、解析和错误状态管理。 +final class OperatingAreaViewModel { + private(set) var mode: OperatingAreaEntryMode? + private(set) var loading = false + private(set) var items: [OperatingAreaItem] = [] + private(set) var fenceRings: [OperatingFenceRing] = [] + private(set) var blockReason: OperatingAreaBlockReason? + private(set) var errorMessage: String? + + /// 按当前账号和角色刷新运营区域。 + func reload( + api: any OperatingAreaServing, + accountContext: AccountContext, + permissionContext: PermissionContext + ) async { + guard !loading else { return } + guard let nextMode = resolveMode(accountContext: accountContext, permissionContext: permissionContext) else { + return + } + + mode = nextMode + loading = true + errorMessage = nil + blockReason = nil + defer { loading = false } + + do { + let response: ListPayload + switch nextMode { + case .storeAdmin(let storeId): + response = try await api.storeBusinessArea(storeId: storeId) + case .scenicAdmin(let scenicId): + response = try await api.scenicAdminBusinessArea(scenicId: scenicId) + } + apply(items: response.list, mode: nextMode) + } catch { + resetLoadedData() + let message = error.localizedDescription + errorMessage = message + blockReason = .backendMessage(message) + } + } + + /// 根据当前上下文解析运营区域入口模式。 + @discardableResult + func resolveMode(accountContext: AccountContext, permissionContext: PermissionContext) -> OperatingAreaEntryMode? { + let roleName = permissionContext.currentRole?.name.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let lowercaseRoleName = roleName.lowercased() + let isStoreRole = roleName.contains("店铺") || roleName.contains("门店") || lowercaseRoleName.contains("store") + let isScenicRole = roleName.contains("景区") || lowercaseRoleName.contains("scenic") + + if isStoreRole { + guard let storeId = accountContext.currentStore?.id, storeId > 0 else { + setBlocked(.missingStore) + return nil + } + mode = .storeAdmin(storeId: storeId) + blockReason = nil + return mode + } + + if isScenicRole { + guard let scenicId = accountContext.currentScenic?.id, scenicId > 0 else { + setBlocked(.missingScenic) + return nil + } + mode = .scenicAdmin(scenicId: scenicId) + blockReason = nil + return mode + } + + if let storeId = accountContext.currentStore?.id, storeId > 0 { + mode = .storeAdmin(storeId: storeId) + blockReason = nil + return mode + } + + if let scenicId = accountContext.currentScenic?.id, scenicId > 0 { + mode = .scenicAdmin(scenicId: scenicId) + blockReason = nil + return mode + } + + setBlocked(.unsupportedRole) + return nil + } + + /// 根据状态生成统计展示。 + var summary: (areaCount: Int, fenceCount: Int, currentStoreFenceCount: Int) { + ( + areaCount: items.count, + fenceCount: fenceRings.count, + currentStoreFenceCount: fenceRings.filter(\.isCurrentStore).count + ) + } + + /// 当前页面标题。 + var title: String { + mode?.title ?? "运营区域" + } + + private func apply(items: [OperatingAreaItem], mode: OperatingAreaEntryMode) { + self.items = items + if items.isEmpty { + fenceRings = [] + blockReason = .emptyAreaList + return + } + + let currentStoreId: Int? = { + guard case .storeAdmin(let storeId) = mode else { return nil } + return storeId + }() + + let parsed = items.flatMap { item -> [OperatingFenceRing] in + OperatingAreaParser.parseToRings(item.businessMapArea).enumerated().compactMap { index, points in + guard points.count >= 3 else { return nil } + return OperatingFenceRing( + itemId: item.id, + regionName: item.name, + points: points, + isCurrentStore: currentStoreId == item.id, + ringIndex: index + ) + } + } + fenceRings = parsed + blockReason = parsed.isEmpty ? .noParsableFenceData : nil + } + + private func setBlocked(_ reason: OperatingAreaBlockReason) { + resetLoadedData() + blockReason = reason + errorMessage = nil + loading = false + } + + private func resetLoadedData() { + items = [] + fenceRings = [] + } +} diff --git a/suixinkan/Features/OperatingArea/Views/OperatingAreaViews.swift b/suixinkan/Features/OperatingArea/Views/OperatingAreaViews.swift new file mode 100644 index 0000000..76d09ef --- /dev/null +++ b/suixinkan/Features/OperatingArea/Views/OperatingAreaViews.swift @@ -0,0 +1,390 @@ +// +// OperatingAreaViews.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import CoreLocation +import SwiftUI + +#if AMAP_ENABLED +import MAMapKit +#endif + +/// 运营区域首页,按当前角色展示店铺或景区运营围栏。 +struct OperatingAreaView: View { + @Environment(AccountContext.self) private var accountContext + @Environment(PermissionContext.self) private var permissionContext + @Environment(OperatingAreaAPI.self) private var operatingAreaAPI + @Environment(ToastCenter.self) private var toastCenter + @Environment(\.globalLoading) private var globalLoading + + @State private var viewModel = OperatingAreaViewModel() + + var body: some View { + ScrollView { + VStack(spacing: AppMetrics.Spacing.medium) { + summarySection + mapSection + areaListSection + } + .padding(AppMetrics.Spacing.pageHorizontal) + .padding(.vertical, AppMetrics.Spacing.medium) + } + .background(Color(hex: 0xF5F7FA)) + .navigationTitle("运营区域") + .navigationBarTitleDisplayMode(.inline) + .refreshable { await reload(showLoading: false) } + .task(id: taskID) { + await reload(showLoading: viewModel.items.isEmpty) + } + } + + private var taskID: String { + "\(permissionContext.currentRole?.id ?? 0)-\(accountContext.currentScenic?.id ?? 0)-\(accountContext.currentStore?.id ?? 0)" + } + + private var summarySection: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(viewModel.title) + .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + Text(scopeText) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } + Spacer() + Image(systemName: "map.fill") + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + .frame(width: 42, height: 42) + .background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 8)) + } + + HStack(spacing: AppMetrics.Spacing.small) { + OperatingAreaSummaryCard(title: "区域记录", value: "\(viewModel.summary.areaCount)", tint: AppDesign.primary) + OperatingAreaSummaryCard(title: "有效围栏", value: "\(viewModel.summary.fenceCount)", tint: AppDesign.success) + OperatingAreaSummaryCard(title: "当前门店", value: "\(viewModel.summary.currentStoreFenceCount)", tint: AppDesign.warning) + } + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: 8)) + } + + private var mapSection: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + HStack { + Text("围栏地图") + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + Spacer() + Text(mapLegendText) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } + + if let reason = viewModel.blockReason { + ContentUnavailableView(reason.message, systemImage: "mappin.slash") + .frame(maxWidth: .infinity, minHeight: 260) + .background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8)) + .overlay(alignment: .bottom) { + if shouldShowRetry(for: reason) { + Button("重新加载") { + Task { await reload(showLoading: true) } + } + .buttonStyle(.borderedProminent) + .padding(.bottom, AppMetrics.Spacing.medium) + } + } + } else { + OperatingAreaMapView(rings: viewModel.fenceRings) + .frame(height: 320) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: 8)) + } + + private var areaListSection: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + Text("区域列表") + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + + if viewModel.items.isEmpty { + Text(viewModel.blockReason?.message ?? "暂无运营区域") + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + .frame(maxWidth: .infinity, minHeight: 88) + .background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8)) + } else { + ForEach(viewModel.items) { item in + let ringCount = viewModel.fenceRings.filter { $0.itemId == item.id }.count + OperatingAreaItemRow(item: item, ringCount: ringCount, isCurrentStore: isCurrentStore(item)) + } + } + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: 8)) + } + + private var scopeText: String { + switch viewModel.mode { + case .storeAdmin: + return accountContext.currentStore?.name ?? "当前店铺" + case .scenicAdmin: + return accountContext.currentScenic?.name ?? "当前景区" + case nil: + return accountContext.currentScenic?.name ?? accountContext.currentStore?.name ?? "未选择业务范围" + } + } + + private var mapLegendText: String { + switch viewModel.mode { + case .storeAdmin: + "红色为当前门店" + case .scenicAdmin: + "景区全部围栏" + case nil: + "只读展示" + } + } + + private func reload(showLoading: Bool) async { + await globalLoading.withOptionalLoading(showLoading) { + await viewModel.reload(api: operatingAreaAPI, accountContext: accountContext, permissionContext: permissionContext) + } + if let message = viewModel.errorMessage { + toastCenter.show(message) + } + } + + private func isCurrentStore(_ item: OperatingAreaItem) -> Bool { + guard case .storeAdmin(let storeId)? = viewModel.mode else { return false } + return item.id == storeId + } + + private func shouldShowRetry(for reason: OperatingAreaBlockReason) -> Bool { + if case .backendMessage = reason { + return true + } + return false + } +} + +private struct OperatingAreaSummaryCard: View { + let title: String + let value: String + let tint: Color + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + Text(value) + .font(.system(size: AppMetrics.FontSize.title3, weight: .bold)) + .foregroundStyle(tint) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(AppMetrics.Spacing.small) + .background(tint.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) + } +} + +private struct OperatingAreaItemRow: View { + let item: OperatingAreaItem + let ringCount: Int + let isCurrentStore: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(item.name.isEmpty ? "未命名区域" : item.name) + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + Text("有效围栏 \(ringCount) 个") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } + Spacer() + if isCurrentStore { + Text("当前门店") + .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) + .foregroundStyle(Color(hex: 0xDC2626)) + .padding(.horizontal, 8) + .frame(height: 24) + .background(Color(hex: 0xDC2626).opacity(0.12), in: Capsule()) + } + } + HStack(spacing: 8) { + if !item.statusText.isEmpty { + OperatingAreaTag(text: item.statusText, color: AppDesign.success) + } + if !item.typeText.isEmpty { + OperatingAreaTag(text: item.typeText, color: AppDesign.primary) + } + if !item.auditStatusText.isEmpty { + OperatingAreaTag(text: item.auditStatusText, color: AppDesign.warning) + } + } + } + .padding(AppMetrics.Spacing.small) + .background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8)) + } +} + +private struct OperatingAreaTag: View { + let text: String + let color: Color + + var body: some View { + Text(text) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(color) + .padding(.horizontal, 8) + .frame(height: 22) + .background(color.opacity(0.12), in: Capsule()) + } +} + +private struct OperatingAreaMapView: View { + let rings: [OperatingFenceRing] + + var body: some View { + #if AMAP_ENABLED + OperatingAreaAMapRepresentable(rings: rings) + #else + OperatingAreaMapFallback(rings: rings) + #endif + } +} + +private struct OperatingAreaMapFallback: View { + let rings: [OperatingFenceRing] + + var body: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + Label("模拟器未启用高德地图,以下为围栏坐标摘要", systemImage: "map") + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) + .foregroundStyle(AppDesign.textSecondary) + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(rings) { ring in + VStack(alignment: .leading, spacing: 4) { + Text(ring.regionName.isEmpty ? "未命名区域" : ring.regionName) + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(ring.isCurrentStore ? Color(hex: 0xDC2626) : AppDesign.textPrimary) + Text(ring.points.prefix(4).map { String(format: "%.6f, %.6f", $0.latitude, $0.longitude) }.joined(separator: " ")) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + .lineLimit(2) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(.white, in: RoundedRectangle(cornerRadius: 8)) + } + } + } + } + .padding(AppMetrics.Spacing.medium) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color(hex: 0xEEF2F7)) + } +} + +#if AMAP_ENABLED +private struct OperatingAreaAMapRepresentable: UIViewRepresentable { + let rings: [OperatingFenceRing] + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIView(context: Context) -> MAMapView { + let mapView = MAMapView(frame: .zero) + mapView.delegate = context.coordinator + mapView.showsCompass = false + mapView.showsScale = false + mapView.isRotateEnabled = false + return mapView + } + + func updateUIView(_ mapView: MAMapView, context: Context) { + context.coordinator.ringsByOverlay.removeAll() + mapView.removeOverlays(mapView.overlays) + mapView.removeAnnotations(mapView.annotations) + + var allCoordinates: [CLLocationCoordinate2D] = [] + for ring in rings { + var coordinates = ring.points.map { + CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) + } + guard coordinates.count >= 3 else { continue } + let polygon = MAPolygon(coordinates: &coordinates, count: UInt(coordinates.count)) + context.coordinator.ringsByOverlay[ObjectIdentifier(polygon)] = ring + mapView.add(polygon) + allCoordinates.append(contentsOf: coordinates) + + if let center = ring.centerCoordinate { + let annotation = MAPointAnnotation() + annotation.coordinate = center + annotation.title = ring.regionName + mapView.addAnnotation(annotation) + } + } + + if let region = Self.region(for: allCoordinates) { + mapView.setRegion(region, animated: true) + } + } + + static func dismantleUIView(_ uiView: MAMapView, coordinator: Coordinator) { + uiView.delegate = nil + } + + private static func region(for coordinates: [CLLocationCoordinate2D]) -> MACoordinateRegion? { + guard !coordinates.isEmpty else { return nil } + let minLat = coordinates.map(\.latitude).min() ?? 0 + let maxLat = coordinates.map(\.latitude).max() ?? 0 + let minLng = coordinates.map(\.longitude).min() ?? 0 + let maxLng = coordinates.map(\.longitude).max() ?? 0 + let center = CLLocationCoordinate2D(latitude: (minLat + maxLat) / 2, longitude: (minLng + maxLng) / 2) + let span = MACoordinateSpan( + latitudeDelta: max((maxLat - minLat) * 1.4, 0.01), + longitudeDelta: max((maxLng - minLng) * 1.4, 0.01) + ) + return MACoordinateRegion(center: center, span: span) + } + + final class Coordinator: NSObject, MAMapViewDelegate { + var ringsByOverlay: [ObjectIdentifier: OperatingFenceRing] = [:] + + func mapView(_ mapView: MAMapView!, rendererFor overlay: MAOverlay!) -> MAOverlayRenderer! { + guard let polygon = overlay as? MAPolygon else { return nil } + let ring = ringsByOverlay[ObjectIdentifier(polygon)] + let renderer = MAPolygonRenderer(polygon: polygon) + let color = ring?.isCurrentStore == true ? UIColor.systemRed : UIColor.systemBlue + renderer?.strokeColor = color + renderer?.fillColor = color.withAlphaComponent(0.18) + renderer?.lineWidth = 3 + return renderer + } + } +} + +private extension OperatingFenceRing { + var centerCoordinate: CLLocationCoordinate2D? { + guard !points.isEmpty else { return nil } + let latitude = points.reduce(0) { $0 + $1.latitude } / Double(points.count) + let longitude = points.reduce(0) { $0 + $1.longitude } / Double(points.count) + return CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } +} +#endif diff --git a/suixinkan/Features/PilotCertification/API/PilotCertificationAPI.swift b/suixinkan/Features/PilotCertification/API/PilotCertificationAPI.swift new file mode 100644 index 0000000..67e14da --- /dev/null +++ b/suixinkan/Features/PilotCertification/API/PilotCertificationAPI.swift @@ -0,0 +1,58 @@ +// +// PilotCertificationAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation +import Observation + +/// 飞手认证服务协议,定义认证详情、短信、提交和编辑接口。 +@MainActor +protocol PilotCertificationServing { + func flyerDetail() async throws -> FlyerDetailResponse + func flyerSendCode(phone: String) async throws + func flyerApply(_ request: FlyerApplyRequest) async throws + func flyerEdit(_ request: FlyerEditRequest) async throws +} + +@MainActor +@Observable +/// 飞手认证 API,封装 `/api/app/flyer` 认证相关接口。 +final class PilotCertificationAPI { + @ObservationIgnored private let client: APIClient + + /// 初始化飞手认证 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取飞手认证详情。 + func flyerDetail() async throws -> FlyerDetailResponse { + try await client.send(APIRequest(method: .get, path: "/api/app/flyer/detail")) + } + + /// 发送飞手认证短信验证码。 + func flyerSendCode(phone: String) async throws { + let _: EmptyPayload = try await client.send( + APIRequest(method: .post, path: "/api/app/flyer/send-code", body: FlyerSendCodeRequest(phone: phone)) + ) + } + + /// 首次提交飞手认证申请。 + func flyerApply(_ request: FlyerApplyRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest(method: .post, path: "/api/app/flyer/apply", body: request) + ) + } + + /// 驳回后编辑并重新提交飞手认证申请。 + func flyerEdit(_ request: FlyerEditRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest(method: .post, path: "/api/app/flyer/edit", body: request) + ) + } +} + +extension PilotCertificationAPI: PilotCertificationServing {} diff --git a/suixinkan/Features/PilotCertification/Models/PilotCertificationModels.swift b/suixinkan/Features/PilotCertification/Models/PilotCertificationModels.swift new file mode 100644 index 0000000..f7ae4ca --- /dev/null +++ b/suixinkan/Features/PilotCertification/Models/PilotCertificationModels.swift @@ -0,0 +1,310 @@ +// +// PilotCertificationModels.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +/// 飞手证件类型。 +enum PilotCertType: Int, CaseIterable, Identifiable { + case idCard = 1 + case caac = 2 + + var id: Int { rawValue } + + var title: String { + switch self { + case .idCard: + "身份证" + case .caac: + "民用无人机驾驶员执照" + } + } +} + +/// 飞手认证详情响应。 +struct FlyerDetailResponse: Decodable, Equatable { + let id: Int + let name: String + let accountId: String + let realnameStatus: Int + let status: Int + let statusName: String + let submitTime: String + let updatedAt: String + let auditPerson: String + let auditTime: String + let auditNote: String + let certificateType: Int + let certificateNo: String + let certificateStartDate: String + let certificateEndDate: String + let certificateImage: String + let droneModel: String + let droneSn: String + let contactPhone: String + let realnameStatusText: String + let certificationLogs: [FlyerCertificationLogItem] + + enum CodingKeys: String, CodingKey { + case id + case name = "flyer_nickname" + case accountId = "account_id" + case realnameStatus = "realname_status" + case status + case statusName = "status_text" + case submitTime = "created_at" + case updatedAt = "updated_at" + case auditPerson = "reviewer" + case auditTime = "review_time" + case auditNote = "reject_reason" + case certificateType = "certificate_type" + case certificateNo = "certificate_no" + case certificateStartDate = "certificate_start_date" + case certificateEndDate = "certificate_end_date" + case certificateImage = "certificate_image" + case droneModel = "drone_model" + case droneSn = "drone_sn" + case contactPhone = "contact_phone" + case realnameStatusText = "realname_status_text" + case certificationLogs = "flyers_certification_logs" + } + + init( + id: Int = 0, + name: String = "", + accountId: String = "", + realnameStatus: Int = 2, + status: Int = 0, + statusName: String = "", + submitTime: String = "", + updatedAt: String = "", + auditPerson: String = "", + auditTime: String = "", + auditNote: String = "", + certificateType: Int = 2, + certificateNo: String = "", + certificateStartDate: String = "", + certificateEndDate: String = "", + certificateImage: String = "", + droneModel: String = "", + droneSn: String = "", + contactPhone: String = "", + realnameStatusText: String = "", + certificationLogs: [FlyerCertificationLogItem] = [] + ) { + self.id = id + self.name = name + self.accountId = accountId + self.realnameStatus = realnameStatus + self.status = status + self.statusName = statusName + self.submitTime = submitTime + self.updatedAt = updatedAt + self.auditPerson = auditPerson + self.auditTime = auditTime + self.auditNote = auditNote + self.certificateType = certificateType + self.certificateNo = certificateNo + self.certificateStartDate = certificateStartDate + self.certificateEndDate = certificateEndDate + self.certificateImage = certificateImage + self.droneModel = droneModel + self.droneSn = droneSn + self.contactPhone = contactPhone + self.realnameStatusText = realnameStatusText + self.certificationLogs = certificationLogs + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.pilotDecodeLossyInt(forKey: .id) ?? 0 + name = try container.pilotDecodeLossyString(forKey: .name) + accountId = try container.pilotDecodeLossyString(forKey: .accountId) + realnameStatus = try container.pilotDecodeLossyInt(forKey: .realnameStatus) ?? 2 + status = try container.pilotDecodeLossyInt(forKey: .status) ?? 0 + statusName = try container.pilotDecodeLossyString(forKey: .statusName) + submitTime = try container.pilotDecodeLossyString(forKey: .submitTime) + updatedAt = try container.pilotDecodeLossyString(forKey: .updatedAt) + auditPerson = try container.pilotDecodeLossyString(forKey: .auditPerson) + auditTime = try container.pilotDecodeLossyString(forKey: .auditTime) + auditNote = try container.pilotDecodeLossyString(forKey: .auditNote) + certificateType = try container.pilotDecodeLossyInt(forKey: .certificateType) ?? 2 + certificateNo = try container.pilotDecodeLossyString(forKey: .certificateNo) + certificateStartDate = try container.pilotDecodeLossyString(forKey: .certificateStartDate) + certificateEndDate = try container.pilotDecodeLossyString(forKey: .certificateEndDate) + certificateImage = try container.pilotDecodeLossyString(forKey: .certificateImage) + droneModel = try container.pilotDecodeLossyString(forKey: .droneModel) + droneSn = try container.pilotDecodeLossyString(forKey: .droneSn) + contactPhone = try container.pilotDecodeLossyString(forKey: .contactPhone) + realnameStatusText = try container.pilotDecodeLossyString(forKey: .realnameStatusText) + certificationLogs = try container.decodeIfPresent([FlyerCertificationLogItem].self, forKey: .certificationLogs) ?? [] + } +} + +/// 飞手认证审核日志项。 +struct FlyerCertificationLogItem: Decodable, Identifiable, Equatable { + let id: Int + let flyerId: Int + let operatorName: String + let action: Int + let actionText: String + let rejectReason: String + let remark: String + let createdAt: String + + enum CodingKeys: String, CodingKey { + case id + case flyerId = "flyer_id" + case operatorName = "operator" + case action + case actionText = "action_text" + case rejectReason = "reject_reason" + case remark + case createdAt = "created_at" + } + + init( + id: Int = 0, + flyerId: Int = 0, + operatorName: String = "", + action: Int = 0, + actionText: String = "", + rejectReason: String = "", + remark: String = "", + createdAt: String = "" + ) { + self.id = id + self.flyerId = flyerId + self.operatorName = operatorName + self.action = action + self.actionText = actionText + self.rejectReason = rejectReason + self.remark = remark + self.createdAt = createdAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.pilotDecodeLossyInt(forKey: .id) ?? 0 + flyerId = try container.pilotDecodeLossyInt(forKey: .flyerId) ?? 0 + operatorName = try container.pilotDecodeLossyString(forKey: .operatorName) + action = try container.pilotDecodeLossyInt(forKey: .action) ?? 0 + actionText = try container.pilotDecodeLossyString(forKey: .actionText) + rejectReason = try container.pilotDecodeLossyString(forKey: .rejectReason) + remark = try container.pilotDecodeLossyString(forKey: .remark) + createdAt = try container.pilotDecodeLossyString(forKey: .createdAt) + } +} + +/// 飞手认证短信验证码请求。 +struct FlyerSendCodeRequest: Encodable, Equatable { + let phone: String +} + +/// 飞手首次认证申请请求。 +struct FlyerApplyRequest: Encodable, Equatable { + let name: String + let realnameStatus: Int + let certificateType: Int + let certificateNo: String + let certificateStartDate: String + let certificateEndDate: String + let certificateImage: String + let droneModel: String + let droneSn: String + let contactPhone: String + let code: String + + enum CodingKeys: String, CodingKey { + case name + case realnameStatus = "realname_status" + case certificateType = "certificate_type" + case certificateNo = "certificate_no" + case certificateStartDate = "certificate_start_date" + case certificateEndDate = "certificate_end_date" + case certificateImage = "certificate_image" + case droneModel = "drone_model" + case droneSn = "drone_sn" + case contactPhone = "contact_phone" + case code + } +} + +/// 飞手认证驳回后编辑请求。 +struct FlyerEditRequest: Encodable, Equatable { + let id: Int + let name: String + let realnameStatus: Int + let certificateType: Int + let certificateNo: String + let certificateStartDate: String + let certificateEndDate: String + let certificateImage: String + let droneModel: String + let droneSn: String + let contactPhone: String + let code: String + + enum CodingKeys: String, CodingKey { + case id + case name + case realnameStatus = "realname_status" + case certificateType = "certificate_type" + case certificateNo = "certificate_no" + case certificateStartDate = "certificate_start_date" + case certificateEndDate = "certificate_end_date" + case certificateImage = "certificate_image" + case droneModel = "drone_model" + case droneSn = "drone_sn" + case contactPhone = "contact_phone" + case code + } +} + +/// 飞手认证校验错误。 +enum PilotCertificationValidationError: LocalizedError, Equatable { + case message(String) + + var errorDescription: String? { + switch self { + case .message(let message): + message + } + } +} + +private extension KeyedDecodingContainer { + func pilotDecodeLossyString(forKey key: Key) throws -> String { + if let value = try? decodeIfPresent(String.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Bool.self, forKey: key) { + return value ? "true" : "false" + } + return "" + } + + func pilotDecodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let intValue = Int(text) { return intValue } + if let doubleValue = Double(text) { return Int(doubleValue) } + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + return nil + } +} diff --git a/suixinkan/Features/PilotCertification/PilotCertification.md b/suixinkan/Features/PilotCertification/PilotCertification.md new file mode 100644 index 0000000..14e764e --- /dev/null +++ b/suixinkan/Features/PilotCertification/PilotCertification.md @@ -0,0 +1,17 @@ +# PilotCertification 模块业务逻辑 + +## 模块职责 + +PilotCertification 负责首页 `pilot_cert` 入口的飞手认证申请。模块展示实名状态、飞手证件、无人机信息、联系方式和审核记录,并支持未提交或驳回状态下重新提交。 + +## 核心流程 + +- `PilotCertificationView` 从环境读取 `ProfileAPI`、`PilotCertificationAPI`、`OSSUploadService` 和当前景区。 +- `PilotCertificationViewModel` 并行加载实名认证状态和飞手认证详情,单通道失败不清空另一通道数据。 +- 用户选择证件图片后先保存在本地状态,提交时通过 OSS 上传并写入 `certificate_image`。 +- 未提交状态调用 `/api/app/flyer/apply`,驳回且有飞手记录 ID 时调用 `/api/app/flyer/edit`。 +- 验证码通过 `/api/app/flyer/send-code` 发送,成功后进入 60 秒本地倒计时。 + +## 边界 + +审核通过后页面只读。本模块不包含 DJI/飞控 SDK、无人机连接、飞行控制或横屏飞控流程。 diff --git a/suixinkan/Features/PilotCertification/ViewModels/PilotCertificationViewModel.swift b/suixinkan/Features/PilotCertification/ViewModels/PilotCertificationViewModel.swift new file mode 100644 index 0000000..17471d9 --- /dev/null +++ b/suixinkan/Features/PilotCertification/ViewModels/PilotCertificationViewModel.swift @@ -0,0 +1,282 @@ +// +// PilotCertificationViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation +import Observation + +@MainActor +/// 飞手认证实名认证读取协议,便于 ViewModel 单测替换。 +protocol PilotRealNameServing { + func realNameInfo() async throws -> RealNameInfoResponse +} + +extension ProfileAPI: PilotRealNameServing {} + +@MainActor +@Observable +/// 飞手认证 ViewModel,管理审核状态、表单、验证码、证件图上传和提交。 +final class PilotCertificationViewModel { + private(set) var flyer: FlyerDetailResponse? + private(set) var realNameInfo: RealNameInfo? + var name = "" + var certType: PilotCertType = .caac + var certNo = "" + var certImageUrl = "" + var startDate = Date() + var endDate = Calendar.current.date(byAdding: .year, value: 1, to: Date()) ?? Date() + var droneModel = "" + var droneSerialNo = "" + var phone = "" + var verifyCode = "" + private(set) var pendingCertificateImageData: Data? + private(set) var pendingCertificateFileName: String? + private(set) var uploadProgress: Int? + private(set) var loading = false + private(set) var sendingCode = false + private(set) var submitting = false + private(set) var countdown = 0 + var statusMessage: String? + + /// 加载实名状态和飞手认证详情,单通道失败不清空另一通道数据。 + func load(api: any PilotCertificationServing, realNameAPI: any PilotRealNameServing) async { + guard !loading else { return } + loading = true + defer { loading = false } + + async let realNameTask = realNameAPI.realNameInfo() + async let flyerTask = api.flyerDetail() + var messages: [String] = [] + + do { + let response = try await realNameTask + realNameInfo = response.realNameInfo + } catch { + messages.append(error.localizedDescription) + } + + do { + apply(try await flyerTask) + } catch { + messages.append(error.localizedDescription) + } + + statusMessage = messages.isEmpty ? nil : messages.joined(separator: "\n") + } + + /// 选择待上传证件图。 + func prepareCertificateImage(data: Data, fileName: String) { + pendingCertificateImageData = data + pendingCertificateFileName = fileName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "pilot_cert_\(Int(Date().timeIntervalSince1970)).jpg" + : fileName + certImageUrl = "" + uploadProgress = nil + } + + /// 发送验证码,成功后进入 60 秒倒计时。 + func sendCode(api: any PilotCertificationServing) async throws { + guard canSendCode else { + throw PilotCertificationValidationError.message("请输入有效的手机号码") + } + sendingCode = true + defer { sendingCode = false } + + try await api.flyerSendCode(phone: AppFormValidator.normalizedPhoneNumber(phone)) + countdown = 60 + statusMessage = "验证码已发送" + } + + /// 倒计时递减,由页面定时器驱动。 + func tickCountdown() { + if countdown > 0 { + countdown -= 1 + } + } + + /// 提交飞手认证,必要时先上传证件图。 + func submit(api: any PilotCertificationServing, uploader: any OSSUploadServing, scenicId: Int?) async throws { + if let validationMessage { + throw PilotCertificationValidationError.message(validationMessage) + } + + submitting = true + defer { submitting = false } + + try await uploadPendingCertificateImage(uploader: uploader, scenicId: scenicId) + if auditStatus == 3, flyerId > 0 { + try await api.flyerEdit(makeEditRequest()) + statusMessage = "修改已提交" + } else { + try await api.flyerApply(makeApplyRequest()) + statusMessage = "认证申请已提交" + } + await load(api: api, realNameAPI: PilotEmptyRealNameServing(info: realNameInfo)) + } + + /// 审核状态编码。 + var auditStatus: Int { + flyer?.status ?? 0 + } + + /// 飞手认证记录 ID。 + var flyerId: Int { + flyer?.id ?? 0 + } + + /// 审核通过后表单只读。 + var isReadOnly: Bool { + auditStatus == 2 + } + + /// 实名是否已通过。 + var isRealNameVerified: Bool { + realNameInfo?.verified == true || flyer?.realnameStatus == 2 + } + + /// 是否允许发送验证码。 + var canSendCode: Bool { + !sendingCode && countdown == 0 && (auditStatus == 0 || auditStatus == 3) && AppFormValidator.isValidMainlandPhoneNumber(phone) + } + + /// 当前表单校验错误。 + var validationMessage: String? { + if isReadOnly { return "认证已通过,无需重复提交" } + if auditStatus != 0 && auditStatus != 3 { return "认证审核中,请等待审核结果" } + if name.trimmedForPilot.isEmpty { return "请输入飞手昵称" } + if certNo.trimmedForPilot.isEmpty { return "请输入证件号码" } + if certImageUrl.trimmedForPilot.isEmpty && pendingCertificateImageData == nil { return "请选择证件图片" } + if endDate < startDate { return "截至日期不能早于起始日期" } + if droneModel.trimmedForPilot.isEmpty { return "请输入无人机型号" } + if droneSerialNo.trimmedForPilot.isEmpty { return "请输入无人机序列号" } + if phone.trimmedForPilot.isEmpty { return "请输入手机号码" } + if !AppFormValidator.isValidMainlandPhoneNumber(phone) { return "请输入有效的手机号码" } + if verifyCode.trimmedForPilot.isEmpty { return "请输入验证码" } + return nil + } + + /// 审核状态展示文本。 + var auditStatusText: String { + flyer?.statusName.trimmedForPilot.nonEmptyForPilot ?? Self.statusName(for: auditStatus) + } + + /// 最近审核日志。 + var latestAuditLog: FlyerCertificationLogItem? { + flyer?.certificationLogs.last { $0.action == 2 || $0.action == 3 } + } + + private func apply(_ flyer: FlyerDetailResponse) { + self.flyer = flyer + name = flyer.name + certType = PilotCertType(rawValue: flyer.certificateType) ?? .caac + certNo = flyer.certificateNo + certImageUrl = flyer.certificateImage + startDate = Self.date(from: flyer.certificateStartDate) ?? startDate + endDate = Self.date(from: flyer.certificateEndDate) ?? endDate + droneModel = flyer.droneModel + droneSerialNo = flyer.droneSn + phone = flyer.contactPhone + pendingCertificateImageData = nil + pendingCertificateFileName = nil + uploadProgress = nil + } + + private func uploadPendingCertificateImage(uploader: any OSSUploadServing, scenicId: Int?) async throws { + guard let data = pendingCertificateImageData else { return } + guard let scenicId, scenicId > 0 else { + throw PilotCertificationValidationError.message("请先选择景区") + } + uploadProgress = 1 + defer { uploadProgress = nil } + certImageUrl = try await uploader.uploadPilotCertificateImage( + data: data, + fileName: pendingCertificateFileName ?? "pilot_cert_\(Int(Date().timeIntervalSince1970)).jpg", + scenicId: scenicId + ) { [weak self] progress in + Task { @MainActor in + self?.uploadProgress = progress + } + } + pendingCertificateImageData = nil + pendingCertificateFileName = nil + } + + private func makeApplyRequest() -> FlyerApplyRequest { + FlyerApplyRequest( + name: name.trimmedForPilot, + realnameStatus: isRealNameVerified ? 1 : 2, + certificateType: certType.rawValue, + certificateNo: certNo.trimmedForPilot, + certificateStartDate: Self.dateFormatter.string(from: startDate), + certificateEndDate: Self.dateFormatter.string(from: endDate), + certificateImage: certImageUrl.trimmedForPilot, + droneModel: droneModel.trimmedForPilot, + droneSn: droneSerialNo.trimmedForPilot, + contactPhone: AppFormValidator.normalizedPhoneNumber(phone), + code: verifyCode.trimmedForPilot + ) + } + + private func makeEditRequest() -> FlyerEditRequest { + let apply = makeApplyRequest() + return FlyerEditRequest( + id: flyerId, + name: apply.name, + realnameStatus: apply.realnameStatus, + certificateType: apply.certificateType, + certificateNo: apply.certificateNo, + certificateStartDate: apply.certificateStartDate, + certificateEndDate: apply.certificateEndDate, + certificateImage: apply.certificateImage, + droneModel: apply.droneModel, + droneSn: apply.droneSn, + contactPhone: apply.contactPhone, + code: apply.code + ) + } + + private static func statusName(for status: Int) -> String { + switch status { + case 1: "审核中" + case 2: "已通过" + case 3: "审核失败" + default: "未提交" + } + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + private static func date(from value: String) -> Date? { + let text = String(value.prefix(10)) + guard !text.isEmpty else { return nil } + return dateFormatter.date(from: text) + } +} + +private struct PilotEmptyRealNameServing: PilotRealNameServing { + let info: RealNameInfo? + + func realNameInfo() async throws -> RealNameInfoResponse { + RealNameInfoResponse(realNameInfo: info) + } +} + +private extension String { + var trimmedForPilot: String { + trimmingCharacters(in: .whitespacesAndNewlines) + } + + var nonEmptyForPilot: String? { + let value = trimmedForPilot + return value.isEmpty ? nil : value + } +} diff --git a/suixinkan/Features/PilotCertification/Views/PilotCertificationViews.swift b/suixinkan/Features/PilotCertification/Views/PilotCertificationViews.swift new file mode 100644 index 0000000..d5a724b --- /dev/null +++ b/suixinkan/Features/PilotCertification/Views/PilotCertificationViews.swift @@ -0,0 +1,422 @@ +// +// PilotCertificationViews.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Combine +import PhotosUI +import SwiftUI +import UIKit + +/// 飞手认证页面,展示认证审核状态并支持提交/驳回后编辑。 +struct PilotCertificationView: View { + @Environment(AccountContext.self) private var accountContext + @Environment(ProfileAPI.self) private var profileAPI + @Environment(PilotCertificationAPI.self) private var pilotAPI + @Environment(OSSUploadService.self) private var ossUploadService + @Environment(ToastCenter.self) private var toastCenter + @Environment(\.globalLoading) private var globalLoading + + @State private var viewModel = PilotCertificationViewModel() + @State private var selectedImageItem: PhotosPickerItem? + private let countdownTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + var body: some View { + ScrollView { + VStack(spacing: AppMetrics.Spacing.medium) { + if viewModel.auditStatus != 0 { + auditStatusCard + } + realNameCard + certificateCard + droneCard + contactCard + if let message = viewModel.statusMessage, !message.isEmpty { + messageBanner(message) + } + } + .padding(AppMetrics.Spacing.pageHorizontal) + .padding(.vertical, AppMetrics.Spacing.medium) + .padding(.bottom, viewModel.isReadOnly ? 0 : 88) + } + .background(Color(hex: 0xF5F7FA)) + .navigationTitle("飞手认证") + .navigationBarTitleDisplayMode(.inline) + .safeAreaInset(edge: .bottom) { + if !viewModel.isReadOnly { + submitBar + } + } + .task { + await reload(showLoading: true) + } + .onReceive(countdownTimer) { _ in + viewModel.tickCountdown() + } + .onChange(of: selectedImageItem) { _, item in + guard let item else { return } + Task { await loadImage(item) } + } + } + + private var auditStatusCard: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + HStack { + Text(viewModel.auditStatusText) + .font(.system(size: AppMetrics.FontSize.title3, weight: .bold)) + .foregroundStyle(auditColor) + Spacer() + Image(systemName: auditIcon) + .foregroundStyle(auditColor) + } + PilotInfoRow(title: "提交时间", value: viewModel.flyer?.submitTime.nonEmptyPilotView ?? "--") + PilotInfoRow(title: "审核时间", value: viewModel.latestAuditLog?.createdAt.nonEmptyPilotView ?? viewModel.flyer?.auditTime.nonEmptyPilotView ?? "--") + PilotInfoRow(title: "审核人", value: viewModel.latestAuditLog?.operatorName.nonEmptyPilotView ?? viewModel.flyer?.auditPerson.nonEmptyPilotView ?? "--") + PilotInfoRow(title: "审核备注", value: viewModel.latestAuditLog?.rejectReason.nonEmptyPilotView ?? viewModel.flyer?.auditNote.nonEmptyPilotView ?? "--") + } + .padding(AppMetrics.Spacing.medium) + .background(auditColor.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(auditColor.opacity(0.35), lineWidth: 1)) + } + + private var realNameCard: some View { + VStack(spacing: AppMetrics.Spacing.medium) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("实名状态") + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + Text(viewModel.isRealNameVerified ? "已完成实名认证" : "飞手认证前建议先完成实名认证") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } + Spacer() + Text(viewModel.isRealNameVerified ? "已认证" : "未认证") + .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .frame(height: 28) + .background(viewModel.isRealNameVerified ? AppDesign.success : AppDesign.warning, in: Capsule()) + } + if !viewModel.isRealNameVerified { + NavigationLink { + RealNameAuthView() + } label: { + Label("去认证", systemImage: "person.text.rectangle") + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .frame(maxWidth: .infinity) + .frame(height: 44) + } + .buttonStyle(.borderedProminent) + } + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: 8)) + } + + private var certificateCard: some View { + PilotSectionCard(title: "飞手证件信息") { + PilotTextField(title: "飞手昵称", text: $viewModel.name, placeholder: "请输入飞手昵称", disabled: viewModel.isReadOnly) + VStack(alignment: .leading, spacing: 8) { + Text("证件类型") + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) + .foregroundStyle(AppDesign.textPrimary) + Picker("证件类型", selection: $viewModel.certType) { + ForEach(PilotCertType.allCases) { type in + Text(type.title).tag(type) + } + } + .pickerStyle(.menu) + .disabled(viewModel.isReadOnly) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .frame(height: 48) + .background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8)) + } + PilotTextField(title: "证件号码", text: $viewModel.certNo, placeholder: "请输入证件号码", disabled: viewModel.isReadOnly) + certificateImagePicker + HStack(spacing: AppMetrics.Spacing.small) { + PilotDateField(title: "起始日期", date: $viewModel.startDate, disabled: viewModel.isReadOnly) + PilotDateField(title: "截至日期", date: $viewModel.endDate, disabled: viewModel.isReadOnly) + } + } + } + + private var certificateImagePicker: some View { + VStack(alignment: .leading, spacing: 8) { + Text("证件图片") + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) + .foregroundStyle(AppDesign.textPrimary) + ZStack { + if let data = viewModel.pendingCertificateImageData, + let image = UIImage(data: data) { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else if !viewModel.certImageUrl.isEmpty { + RemoteImage(urlString: viewModel.certImageUrl, contentMode: .fill) { + certificatePlaceholder("证件图片加载中") + } + } else { + certificatePlaceholder("点击选择证件图片") + } + } + .frame(maxWidth: .infinity) + .frame(height: 150) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color(hex: 0xE2E8F0), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))) + + if !viewModel.isReadOnly { + PhotosPicker(selection: $selectedImageItem, matching: .images) { + Label(viewModel.pendingCertificateImageData == nil && viewModel.certImageUrl.isEmpty ? "选择图片" : "更换图片", systemImage: "photo") + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .frame(maxWidth: .infinity) + .frame(height: 42) + } + .buttonStyle(.bordered) + } + + if let progress = viewModel.uploadProgress { + ProgressView(value: Double(progress), total: 100) + } + } + } + + private var droneCard: some View { + PilotSectionCard(title: "绑定无人机信息") { + PilotTextField(title: "无人机型号", text: $viewModel.droneModel, placeholder: "请输入无人机型号", disabled: viewModel.isReadOnly) + PilotTextField(title: "无人机序列号", text: $viewModel.droneSerialNo, placeholder: "请输入无人机序列号", disabled: viewModel.isReadOnly) + } + } + + private var contactCard: some View { + PilotSectionCard(title: "联系信息") { + HStack(alignment: .bottom, spacing: AppMetrics.Spacing.small) { + PilotTextField(title: "手机号码", text: $viewModel.phone, placeholder: "请输入手机号", keyboard: .phonePad, disabled: viewModel.isReadOnly) + if !viewModel.isReadOnly { + Button { + Task { await sendCode() } + } label: { + Text(codeButtonText) + .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) + .frame(width: 96, height: 48) + } + .buttonStyle(.bordered) + .disabled(!viewModel.canSendCode) + } + } + if !viewModel.isReadOnly { + PilotTextField(title: "验证码", text: $viewModel.verifyCode, placeholder: "请输入验证码", keyboard: .numberPad, disabled: false) + } + } + } + + private var submitBar: some View { + VStack(spacing: 0) { + Divider() + Button { + Task { await submit() } + } label: { + HStack(spacing: 8) { + if viewModel.submitting { + ProgressView() + .tint(.white) + } + Text(viewModel.auditStatus == 3 ? "重新提交" : "提交认证") + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + } + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: AppMetrics.ControlSize.primaryButtonHeight) + .background(viewModel.validationMessage == nil ? AppDesign.primary : Color(hex: 0xC9CED6), in: RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + .disabled(viewModel.validationMessage != nil || viewModel.submitting) + .padding(AppMetrics.Spacing.medium) + .background(.white) + } + } + + private var auditColor: Color { + switch viewModel.auditStatus { + case 2: + AppDesign.success + case 3: + Color(hex: 0xDC2626) + case 1: + AppDesign.warning + default: + AppDesign.textSecondary + } + } + + private var auditIcon: String { + switch viewModel.auditStatus { + case 2: + "checkmark.seal.fill" + case 3: + "xmark.seal.fill" + case 1: + "clock.badge.checkmark.fill" + default: + "paperplane.fill" + } + } + + private var codeButtonText: String { + if viewModel.countdown > 0 { return "\(viewModel.countdown)s" } + return viewModel.sendingCode ? "发送中" : "获取验证码" + } + + private func certificatePlaceholder(_ text: String) -> some View { + VStack(spacing: 8) { + Image(systemName: "photo.badge.plus") + .font(.system(size: 34, weight: .semibold)) + Text(text) + .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) + } + .foregroundStyle(AppDesign.primary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AppDesign.primarySoft) + } + + private func messageBanner(_ text: String) -> some View { + HStack(spacing: 8) { + Image(systemName: text.contains("已") ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + Text(text) + .font(.system(size: AppMetrics.FontSize.caption)) + .fixedSize(horizontal: false, vertical: true) + } + .foregroundStyle(text.contains("已") ? AppDesign.success : AppDesign.warning) + .padding(AppMetrics.Spacing.small) + .frame(maxWidth: .infinity, alignment: .leading) + .background((text.contains("已") ? AppDesign.success : AppDesign.warning).opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) + } + + private func reload(showLoading: Bool) async { + await globalLoading.withOptionalLoading(showLoading) { + await viewModel.load(api: pilotAPI, realNameAPI: profileAPI) + } + if let message = viewModel.statusMessage, !message.isEmpty { + toastCenter.show(message) + } + } + + private func loadImage(_ item: PhotosPickerItem) async { + do { + if let data = try await item.loadTransferable(type: Data.self) { + viewModel.prepareCertificateImage(data: data, fileName: "pilot_cert_\(Int(Date().timeIntervalSince1970)).jpg") + } + } catch { + toastCenter.show(error.localizedDescription) + } + selectedImageItem = nil + } + + private func sendCode() async { + do { + try await viewModel.sendCode(api: pilotAPI) + } catch { + toastCenter.show(error.localizedDescription) + } + } + + private func submit() async { + do { + try await globalLoading.withLoading { + try await viewModel.submit(api: pilotAPI, uploader: ossUploadService, scenicId: accountContext.currentScenic?.id) + } + toastCenter.show(viewModel.statusMessage ?? "提交成功") + } catch { + toastCenter.show(error.localizedDescription) + } + } +} + +private struct PilotSectionCard: View { + let title: String + @ViewBuilder let content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) { + Text(title) + .font(.system(size: AppMetrics.FontSize.body, weight: .bold)) + .foregroundStyle(AppDesign.textPrimary) + content() + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: 8)) + } +} + +private struct PilotTextField: View { + let title: String + @Binding var text: String + let placeholder: String + var keyboard: UIKeyboardType = .default + let disabled: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) + .foregroundStyle(AppDesign.textPrimary) + TextField(placeholder, text: $text) + .keyboardType(keyboard) + .disabled(disabled) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .appInputFieldStyle(cornerRadius: 8, minHeight: 48) + } + } +} + +private struct PilotDateField: View { + let title: String + @Binding var date: Date + let disabled: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) + .foregroundStyle(AppDesign.textPrimary) + DatePicker(title, selection: $date, displayedComponents: .date) + .labelsHidden() + .disabled(disabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .frame(height: 48) + .background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8)) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct PilotInfoRow: View { + let title: String + let value: String + + var body: some View { + HStack(alignment: .top, spacing: AppMetrics.Spacing.small) { + Text(title) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + .frame(width: 72, alignment: .leading) + Text(value) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + .multilineTextAlignment(.leading) + } + } +} + +private extension String { + var nonEmptyPilotView: String? { + let value = trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/suixinkanTests/Fixtures/flyer_detail_success.json b/suixinkanTests/Fixtures/flyer_detail_success.json new file mode 100644 index 0000000..c293a02 --- /dev/null +++ b/suixinkanTests/Fixtures/flyer_detail_success.json @@ -0,0 +1,38 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "id": "801", + "flyer_nickname": "航拍摄影师", + "account_id": "42", + "realname_status": "2", + "status": "1", + "status_text": "审核中", + "created_at": "2026-05-23 04:11:00", + "updated_at": "2026-05-23 04:12:00", + "reviewer": "审核员", + "review_time": "2026-05-23 04:15:00", + "reject_reason": "", + "certificate_type": "2", + "certificate_no": "UAS-2026-001", + "certificate_start_date": "2026-01-01", + "certificate_end_date": "2028-01-01", + "certificate_image": "https://cdn.example.com/flyer/cert.jpg", + "drone_model": "DJI Mini", + "drone_sn": "SN20260523001", + "contact_phone": "13800000000", + "realname_status_text": "已实名", + "flyers_certification_logs": [ + { + "id": "9001", + "flyer_id": "801", + "operator": "审核员", + "action": "2", + "action_text": "通过", + "reject_reason": "", + "remark": "资料齐全", + "created_at": "2026-05-23 04:15:00" + } + ] + } +} diff --git a/suixinkanTests/Fixtures/operating_area_empty.json b/suixinkanTests/Fixtures/operating_area_empty.json new file mode 100644 index 0000000..2f7b44f --- /dev/null +++ b/suixinkanTests/Fixtures/operating_area_empty.json @@ -0,0 +1,8 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "total": 0, + "list": [] + } +} diff --git a/suixinkanTests/Fixtures/operating_area_success.json b/suixinkanTests/Fixtures/operating_area_success.json new file mode 100644 index 0000000..80af1a9 --- /dev/null +++ b/suixinkanTests/Fixtures/operating_area_success.json @@ -0,0 +1,46 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "total": 4, + "list": [ + { + "id": "101", + "name": "东门门店", + "business_map_area": [[[118.781, 32.041], [118.782, 32.041], [118.782, 32.042], [118.781, 32.041]]], + "status_text": "启用", + "type_text": "门店", + "audit_status_text": "通过" + }, + { + "id": 102, + "name": "西门门店", + "business_map_area": "{\"type\":\"Polygon\",\"coordinates\":[[[118.791,32.051],[118.792,32.051],[118.792,32.052],[118.791,32.051]]]}", + "status_text": "启用", + "type_text": "门店", + "audit_status_text": "通过" + }, + { + "id": 103, + "name": "北区", + "business_map_area": { + "type": "MultiPolygon", + "coordinates": [ + [[[118.801, 32.061], [118.802, 32.061], [118.802, 32.062], [118.801, 32.061]]] + ] + }, + "status_text": "启用", + "type_text": "区域", + "audit_status_text": "通过" + }, + { + "id": 104, + "name": "无效区域", + "business_map_area": [[118.811, 32.071], [118.812, 32.071]], + "status_text": "停用", + "type_text": "区域", + "audit_status_text": "待审核" + } + ] + } +} diff --git a/suixinkanTests/HomeMenuRouterTests.swift b/suixinkanTests/HomeMenuRouterTests.swift index b7d6703..5a29b9a 100644 --- a/suixinkanTests/HomeMenuRouterTests.swift +++ b/suixinkanTests/HomeMenuRouterTests.swift @@ -64,14 +64,8 @@ final class HomeMenuRouterTests: XCTestCase { XCTAssertEqual(HomeMenuRouter.resolve(uri: "queue_management", title: ""), .destination(.queueManagement)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_stream_management", title: ""), .destination(.liveManagement)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_album", title: ""), .destination(.liveAlbum)) - } - - /// 测试已知但未迁移的首页路由会进入安全占位。 - func testKnownUnmigratedRoutesResolveToPlaceholders() { - XCTAssertEqual( - HomeMenuRouter.resolve(uri: "operating-area", title: ""), - .destination(.modulePlaceholder(uri: "operating-area", title: "运营区域")) - ) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "operating-area", title: ""), .destination(.operatingArea)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "pilot_cert", title: ""), .destination(.pilotCertification)) } /// 测试 Tab 路由仍集中在 HomeMenuRouter。 diff --git a/suixinkanTests/Live/LiveViewModelTests.swift b/suixinkanTests/Live/LiveViewModelTests.swift index f03135a..58518a4 100644 --- a/suixinkanTests/Live/LiveViewModelTests.swift +++ b/suixinkanTests/Live/LiveViewModelTests.swift @@ -192,6 +192,107 @@ final class LiveViewModelTests: XCTestCase { XCTAssertEqual(api.albumDeleteFileRequests.first?.fileIds, [11]) XCTAssertEqual(viewModel.files.map(\.id), [12]) } + + func testLiveEntityDecodesPlaybackURLsAndResolverExcludesRTMP() throws { + let json = """ + { + "id": "1", + "title": "播放测试", + "cover_img": "https://cdn.example.com/c.jpg", + "push_url": "rtmp://push.example.com/live/1", + "play_url": "rtmp://pull.example.com/live/1", + "pull_url": "https://cdn.example.com/live/1.flv", + "hls_url": "https://cdn.example.com/live/1.m3u8", + "live_url": "https://cdn.example.com/live/1.mp4" + } + """.data(using: .utf8)! + + let live = try JSONDecoder().decode(LiveEntity.self, from: json) + + XCTAssertEqual(live.playUrl, "rtmp://pull.example.com/live/1") + XCTAssertEqual(live.pullUrl, "https://cdn.example.com/live/1.flv") + XCTAssertEqual(live.hlsUrl, "https://cdn.example.com/live/1.m3u8") + XCTAssertNil(LivePlaybackURLResolver.playableURL(from: live.pushUrl)) + XCTAssertNil(LivePlaybackURLResolver.playableURL(from: live.playUrl)) + XCTAssertNil(LivePlaybackURLResolver.playableURL(from: live.pullUrl)) + XCTAssertEqual(LivePlaybackURLResolver.playableURL(from: live)?.absoluteString, "https://cdn.example.com/live/1.m3u8") + } + + func testLivePlaybackViewModelStateTransitions() { + let viewModel = LivePlaybackViewModel(urlString: "https://cdn.example.com/live/1.m3u8") + + XCTAssertEqual(viewModel.playableURL?.absoluteString, "https://cdn.example.com/live/1.m3u8") + XCTAssertFalse(viewModel.isPlaying) + + viewModel.play() + XCTAssertTrue(viewModel.isPlaying) + XCTAssertNotNil(viewModel.player) + + viewModel.pause() + XCTAssertFalse(viewModel.isPlaying) + + viewModel.release() + XCTAssertNil(viewModel.player) + XCTAssertEqual(viewModel.playableURL?.absoluteString, "https://cdn.example.com/live/1.m3u8") + + viewModel.load(urlString: "rtmp://push.example.com/live/1") + XCTAssertNil(viewModel.playableURL) + XCTAssertEqual(viewModel.errorMessage, "暂无可播放地址") + } + + func testLivePushReadinessPermissionsNetworkAndUnsupportedAdapter() async { + let permissions = LivePermissionMock(camera: .granted, microphone: .granted) + let network = LiveNetworkMock(state: .wifi) + let adapter = LivePushAdapterMock(available: false) + let viewModel = LivePushReadinessViewModel(permissionProvider: permissions, networkMonitor: network, adapter: adapter) + + viewModel.configure(pushURL: "rtmp://push.example.com/live/1") + viewModel.startMonitoring() + await viewModel.refreshPermissions() + + XCTAssertEqual(viewModel.cameraPermission, .granted) + XCTAssertEqual(viewModel.microphonePermission, .granted) + XCTAssertEqual(viewModel.networkState, .wifi) + XCTAssertEqual(viewModel.sdkStatusText, "未接入真推流 SDK") + + await XCTAssertThrowsErrorAsync(try await viewModel.prepare()) + XCTAssertEqual(viewModel.errorMessage, LivePushReadinessError.sdkUnavailable.localizedDescription) + } + + func testLivePushReadinessDiagnosticsDoNotPrepareUnsupportedAdapter() async { + let permissions = LivePermissionMock(camera: .granted, microphone: .granted) + let network = LiveNetworkMock(state: .wifi) + let adapter = LivePushAdapterMock(available: false) + let viewModel = LivePushReadinessViewModel(permissionProvider: permissions, networkMonitor: network, adapter: adapter) + + viewModel.configure(pushURL: "rtmp://push.example.com/live/1") + viewModel.startMonitoring() + await viewModel.refreshPermissions() + + XCTAssertThrowsError(try viewModel.runDiagnostics()) + XCTAssertFalse(adapter.prepareCalled) + XCTAssertFalse(adapter.startCalled) + XCTAssertEqual(viewModel.errorMessage, LivePushReadinessError.sdkUnavailable.localizedDescription) + } + + func testLivePushReadinessBlocksDeniedPermissionAndUnavailableNetwork() async { + let permissions = LivePermissionMock(camera: .denied, microphone: .granted) + let network = LiveNetworkMock(state: .unavailable) + let adapter = LivePushAdapterMock(available: true) + let viewModel = LivePushReadinessViewModel(permissionProvider: permissions, networkMonitor: network, adapter: adapter) + + viewModel.configure(pushURL: "rtmp://push.example.com/live/1") + viewModel.startMonitoring() + await viewModel.refreshPermissions() + + await XCTAssertThrowsErrorAsync(try await viewModel.prepare()) + XCTAssertFalse(adapter.prepareCalled) + + let missingURLViewModel = LivePushReadinessViewModel(permissionProvider: permissions, networkMonitor: network, adapter: adapter) + missingURLViewModel.configure(pushURL: "") + await XCTAssertThrowsErrorAsync(try await missingURLViewModel.prepare()) + XCTAssertEqual(missingURLViewModel.errorMessage, LivePushReadinessError.missingPushURL.localizedDescription) + } } @MainActor @@ -294,6 +395,69 @@ private final class LiveUploadMock: OSSUploadServing { func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } } +private struct LivePermissionMock: LivePermissionProviding { + let camera: LivePushPermissionState + let microphone: LivePushPermissionState + + func cameraPermission() async -> LivePushPermissionState { + camera + } + + func microphonePermission() async -> LivePushPermissionState { + microphone + } +} + +private final class LiveNetworkMock: LiveNetworkMonitoring { + private(set) var currentState: LivePushNetworkState + private var onChange: (@Sendable (LivePushNetworkState) -> Void)? + + init(state: LivePushNetworkState) { + currentState = state + } + + func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void) { + self.onChange = onChange + onChange(currentState) + } + + func stop() { + onChange = nil + } +} + +private final class LivePushAdapterMock: LivePushAdapter { + let name = "Mock SDK" + let isAvailable: Bool + var prepareCalled = false + var startCalled = false + var stopCalled = false + + init(available: Bool) { + isAvailable = available + } + + func prepare(pushURL: URL) async throws { + prepareCalled = true + if !isAvailable { + throw LivePushReadinessError.sdkUnavailable + } + } + + func start() async throws { + startCalled = true + if !isAvailable { + throw LivePushReadinessError.sdkUnavailable + } + } + + func stop() async throws { + stopCalled = true + } + + func dispose() async {} +} + private func live(id: Int, title: String = "直播", status: Int = 1, manualPushMode: Int = 1) -> LiveEntity { LiveEntity( id: id, diff --git a/suixinkanTests/OperatingArea/OperatingAreaTests.swift b/suixinkanTests/OperatingArea/OperatingAreaTests.swift new file mode 100644 index 0000000..00389d2 --- /dev/null +++ b/suixinkanTests/OperatingArea/OperatingAreaTests.swift @@ -0,0 +1,171 @@ +// +// OperatingAreaTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/25. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 运营区域测试,覆盖接口编码、角色分流和围栏解析。 +final class OperatingAreaTests: XCTestCase { + func testAPIsUseExpectedQueriesAndDecodeFixture() async throws { + let session = OperatingRecordingURLSession(data: try TestFixture.data(named: "operating_area_success")) + let api = OperatingAreaAPI(client: APIClient(session: session)) + + let storeResponse = try await api.storeBusinessArea(storeId: 101) + XCTAssertEqual(storeResponse.total, 4) + XCTAssertEqual(storeResponse.list.first?.id, 101) + var request = try XCTUnwrap(session.requests.last) + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.url?.path, "/api/app/store/business-area") + XCTAssertEqual(queryItems(from: request)["store_id"], "101") + + _ = try await api.scenicAdminBusinessArea(scenicId: 88) + request = try XCTUnwrap(session.requests.last) + XCTAssertEqual(request.url?.path, "/api/app/scenic-admin/business-area") + XCTAssertEqual(queryItems(from: request)["scenic_id"], "88") + } + + func testViewModelUsesStoreAndScenicModes() async { + let api = OperatingMock() + api.response = ListPayload(total: 1, list: [area(id: 101)]) + + let storeViewModel = OperatingAreaViewModel() + await storeViewModel.reload(api: api, accountContext: accountContext(storeId: 101), permissionContext: permissionContext(roleName: "店铺管理员")) + XCTAssertEqual(api.storeIds, [101]) + XCTAssertEqual(storeViewModel.fenceRings.first?.isCurrentStore, true) + + let scenicViewModel = OperatingAreaViewModel() + await scenicViewModel.reload(api: api, accountContext: accountContext(scenicId: 88, storeId: nil), permissionContext: permissionContext(roleName: "景区管理员")) + XCTAssertEqual(api.scenicIds, [88]) + XCTAssertEqual(scenicViewModel.mode, .scenicAdmin(scenicId: 88)) + } + + func testViewModelBlocksMissingContextAndFailureClearsData() async { + let api = OperatingMock() + let viewModel = OperatingAreaViewModel() + + await viewModel.reload(api: api, accountContext: accountContext(scenicId: nil, storeId: nil), permissionContext: permissionContext(roleName: "店铺管理员")) + XCTAssertEqual(viewModel.blockReason, .missingStore) + XCTAssertTrue(api.storeIds.isEmpty) + + api.response = ListPayload(total: 1, list: [area(id: 101)]) + await viewModel.reload(api: api, accountContext: accountContext(storeId: 101), permissionContext: permissionContext(roleName: "店铺管理员")) + XCTAssertFalse(viewModel.fenceRings.isEmpty) + + api.error = OperatingTestError.sample + await viewModel.reload(api: api, accountContext: accountContext(storeId: 101), permissionContext: permissionContext(roleName: "店铺管理员")) + XCTAssertTrue(viewModel.items.isEmpty) + XCTAssertTrue(viewModel.fenceRings.isEmpty) + XCTAssertEqual(viewModel.blockReason, .backendMessage("测试错误")) + } + + func testParserSupportsGeoJSONStringArraysAndFiltersInvalidRings() throws { + let response = try TestFixture.payload(ListPayload.self, named: "operating_area_success") + let rings = response.list.flatMap { OperatingAreaParser.parseToRings($0.businessMapArea) } + + XCTAssertEqual(rings.count, 3) + XCTAssertEqual(rings.first?.first, OperatingGeoPoint(latitude: 32.041, longitude: 118.781)) + XCTAssertEqual(rings[1].first, OperatingGeoPoint(latitude: 32.051, longitude: 118.791)) + XCTAssertEqual(rings[2].first, OperatingGeoPoint(latitude: 32.061, longitude: 118.801)) + } + + func testEmptyAndUnparsableAreaStates() async { + let api = OperatingMock() + let viewModel = OperatingAreaViewModel() + + api.response = ListPayload(total: 0, list: []) + await viewModel.reload(api: api, accountContext: accountContext(storeId: 101), permissionContext: permissionContext(roleName: "店铺管理员")) + XCTAssertEqual(viewModel.blockReason, .emptyAreaList) + + api.response = ListPayload(total: 1, list: [OperatingAreaItem(id: 1, name: "bad", businessMapArea: .array([.array([.number(1), .number(2)])]))]) + await viewModel.reload(api: api, accountContext: accountContext(storeId: 101), permissionContext: permissionContext(roleName: "店铺管理员")) + XCTAssertEqual(viewModel.blockReason, .noParsableFenceData) + } +} + +@MainActor +private final class OperatingMock: OperatingAreaServing { + var response = ListPayload(total: 0, list: []) + var error: Error? + var storeIds: [Int] = [] + var scenicIds: [Int] = [] + + func storeBusinessArea(storeId: Int) async throws -> ListPayload { + storeIds.append(storeId) + if let error { throw error } + return response + } + + func scenicAdminBusinessArea(scenicId: Int) async throws -> ListPayload { + scenicIds.append(scenicId) + if let error { throw error } + return response + } +} + +private final class OperatingRecordingURLSession: URLSessionProtocol { + private let data: Data + private(set) var requests: [URLRequest] = [] + + init(data: Data) { + self.data = data + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } +} + +private enum OperatingTestError: LocalizedError { + case sample + + var errorDescription: String? { "测试错误" } +} + +private func area(id: Int) -> OperatingAreaItem { + OperatingAreaItem( + id: id, + name: "区域\(id)", + businessMapArea: .array([ + .array([ + .array([.number(118.781), .number(32.041)]), + .array([.number(118.782), .number(32.041)]), + .array([.number(118.782), .number(32.042)]) + ]) + ]), + statusText: "启用", + typeText: "门店", + auditStatusText: "通过" + ) +} + +@MainActor +private func accountContext(scenicId: Int? = 88, storeId: Int? = 101) -> AccountContext { + let context = AccountContext() + let scenics = scenicId.map { [BusinessScope(id: $0, name: "测试景区", kind: .scenic)] } ?? [] + let stores = storeId.map { [BusinessScope(id: $0, name: "测试门店", kind: .store, parentScenicId: scenicId)] } ?? [] + context.replaceScopes(scenic: scenics, stores: stores, currentScenicId: scenicId, currentStoreId: storeId) + return context +} + +@MainActor +private func permissionContext(roleName: String) -> PermissionContext { + let context = PermissionContext() + let role = RoleInfo(id: 1, name: roleName, permission: [PermissionItem(id: 1, name: "运营区域", uri: "operating-area")]) + context.replaceRolePermissions([RolePermissionResponse(role: role, scenic: [])], currentRoleId: 1) + return context +} + +private func queryItems(from request: URLRequest) -> [String: String] { + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { return [:] } + return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + }) +} diff --git a/suixinkanTests/PilotCertification/PilotCertificationTests.swift b/suixinkanTests/PilotCertification/PilotCertificationTests.swift new file mode 100644 index 0000000..a090a51 --- /dev/null +++ b/suixinkanTests/PilotCertification/PilotCertificationTests.swift @@ -0,0 +1,277 @@ +// +// PilotCertificationTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/25. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 飞手认证测试,覆盖接口编码、加载、验证码、校验和提交流程。 +final class PilotCertificationTests: XCTestCase { + func testAPIsUseExpectedPathsAndBodies() async throws { + let session = PilotRecordingURLSession(responses: [ + try TestFixture.data(named: "flyer_detail_success"), + try TestFixture.data(named: "empty_success"), + try TestFixture.data(named: "empty_success"), + try TestFixture.data(named: "empty_success") + ]) + let api = PilotCertificationAPI(client: APIClient(session: session)) + + let detail = try await api.flyerDetail() + XCTAssertEqual(detail.id, 801) + XCTAssertEqual(detail.certificateNo, "UAS-2026-001") + var request = try XCTUnwrap(session.requests.last) + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.url?.path, "/api/app/flyer/detail") + + try await api.flyerSendCode(phone: "13800000000") + request = try XCTUnwrap(session.requests.last) + XCTAssertEqual(request.url?.path, "/api/app/flyer/send-code") + XCTAssertEqual(try pilotJSONBody(from: request)["phone"] as? String, "13800000000") + + try await api.flyerApply(applyRequest()) + request = try XCTUnwrap(session.requests.last) + XCTAssertEqual(request.url?.path, "/api/app/flyer/apply") + var json = try pilotJSONBody(from: request) + XCTAssertEqual(json["name"] as? String, "航拍摄影师") + XCTAssertEqual(json["certificate_no"] as? String, "UAS-2026-001") + XCTAssertEqual(json["drone_sn"] as? String, "SN20260523001") + + try await api.flyerEdit(editRequest()) + request = try XCTUnwrap(session.requests.last) + XCTAssertEqual(request.url?.path, "/api/app/flyer/edit") + json = try pilotJSONBody(from: request) + XCTAssertEqual(json["id"] as? Int, 801) + XCTAssertEqual(json["contact_phone"] as? String, "13800000001") + } + + func testViewModelLoadSendCodeAndCountdown() async throws { + let api = PilotMock() + api.detailResponses = [try TestFixture.payload(FlyerDetailResponse.self, named: "flyer_detail_success")] + let realName = PilotRealNameMock(response: try TestFixture.payload(RealNameInfoResponse.self, named: "real_name_info_success")) + let viewModel = PilotCertificationViewModel() + + await viewModel.load(api: api, realNameAPI: realName) + + XCTAssertEqual(viewModel.name, "航拍摄影师") + XCTAssertEqual(viewModel.certType, .caac) + XCTAssertEqual(viewModel.droneSerialNo, "SN20260523001") + XCTAssertTrue(viewModel.isRealNameVerified) + XCTAssertEqual(viewModel.latestAuditLog?.operatorName, "审核员") + + let codeViewModel = PilotCertificationViewModel() + fillValidForm(codeViewModel) + try await codeViewModel.sendCode(api: api) + XCTAssertEqual(api.sendCodePhones, ["13800000000"]) + XCTAssertEqual(codeViewModel.countdown, 60) + codeViewModel.tickCountdown() + XCTAssertEqual(codeViewModel.countdown, 59) + + api.sendCodeError = PilotTestError.sample + codeViewModel.phone = "13800000001" + while codeViewModel.countdown > 0 { codeViewModel.tickCountdown() } + await XCTAssertThrowsErrorAsync(try await codeViewModel.sendCode(api: api)) + XCTAssertEqual(codeViewModel.countdown, 0) + } + + func testValidationCoversRequiredFieldsPhoneImageAndDates() { + let viewModel = PilotCertificationViewModel() + + XCTAssertEqual(viewModel.validationMessage, "请输入飞手昵称") + fillValidForm(viewModel) + viewModel.phone = "123" + XCTAssertEqual(viewModel.validationMessage, "请输入有效的手机号码") + + fillValidForm(viewModel) + viewModel.certImageUrl = "" + XCTAssertEqual(viewModel.validationMessage, "请选择证件图片") + + fillValidForm(viewModel) + viewModel.endDate = Date(timeInterval: -86_400, since: viewModel.startDate) + XCTAssertEqual(viewModel.validationMessage, "截至日期不能早于起始日期") + } + + func testSubmitUploadsAndAppliesThenClearsPendingImage() async throws { + let api = PilotMock() + let uploader = PilotUploadMock() + let viewModel = PilotCertificationViewModel() + fillValidForm(viewModel) + viewModel.certImageUrl = "" + viewModel.prepareCertificateImage(data: Data([1, 2, 3]), fileName: "cert.jpg") + uploader.url = "https://cdn.example.com/pilot/cert.jpg" + + try await viewModel.submit(api: api, uploader: uploader, scenicId: 88) + + XCTAssertEqual(uploader.uploads.first?.fileName, "cert.jpg") + XCTAssertEqual(uploader.uploads.first?.scenicId, 88) + XCTAssertEqual(api.applyRequests.first?.certificateImage, "https://cdn.example.com/pilot/cert.jpg") + XCTAssertNil(viewModel.pendingCertificateImageData) + } + + func testRejectedSubmitUsesEditAndFailureKeepsForm() async throws { + let api = PilotMock() + api.detailResponses = [ + FlyerDetailResponse(id: 801, name: "旧资料", status: 3, certificateImage: "https://cdn.example.com/old.jpg") + ] + let viewModel = PilotCertificationViewModel() + await viewModel.load(api: api, realNameAPI: PilotRealNameMock()) + fillValidForm(viewModel) + + try await viewModel.submit(api: api, uploader: PilotUploadMock(), scenicId: 88) + XCTAssertEqual(api.editRequests.first?.id, 801) + + let failingUploader = PilotUploadMock() + failingUploader.error = PilotTestError.sample + viewModel.certImageUrl = "" + viewModel.prepareCertificateImage(data: Data([9]), fileName: "new.jpg") + await XCTAssertThrowsErrorAsync(try await viewModel.submit(api: api, uploader: failingUploader, scenicId: 88)) + XCTAssertEqual(viewModel.pendingCertificateFileName, "new.jpg") + } +} + +@MainActor +private final class PilotMock: PilotCertificationServing { + var detailResponses: [FlyerDetailResponse] = [] + var sendCodePhones: [String] = [] + var applyRequests: [FlyerApplyRequest] = [] + var editRequests: [FlyerEditRequest] = [] + var sendCodeError: Error? + + func flyerDetail() async throws -> FlyerDetailResponse { + detailResponses.isEmpty ? FlyerDetailResponse() : detailResponses.removeFirst() + } + + func flyerSendCode(phone: String) async throws { + if let sendCodeError { throw sendCodeError } + sendCodePhones.append(phone) + } + + func flyerApply(_ request: FlyerApplyRequest) async throws { + applyRequests.append(request) + } + + func flyerEdit(_ request: FlyerEditRequest) async throws { + editRequests.append(request) + } +} + +private struct PilotRealNameMock: PilotRealNameServing { + var response = RealNameInfoResponse(realNameInfo: nil) + + func realNameInfo() async throws -> RealNameInfoResponse { + response + } +} + +@MainActor +private final class PilotUploadMock: OSSUploadServing { + var url = "https://cdn.example.com/pilot/cert.jpg" + var error: Error? + var uploads: [(fileName: String, scenicId: Int)] = [] + + func uploadPilotCertificateImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + if let error { throw error } + uploads.append((fileName, scenicId)) + onProgress(100) + return url + } + + func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadAliveAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } +} + +private final class PilotRecordingURLSession: URLSessionProtocol { + private var responses: [Data] + private(set) var requests: [URLRequest] = [] + + init(responses: [Data]) { + self.responses = responses + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = responses.isEmpty ? try TestFixture.data(named: "empty_success") : responses.removeFirst() + return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } +} + +private enum PilotTestError: LocalizedError { + case sample + + var errorDescription: String? { "测试错误" } +} + +private func fillValidForm(_ viewModel: PilotCertificationViewModel) { + viewModel.name = "航拍摄影师" + viewModel.certType = .caac + viewModel.certNo = "UAS-2026-001" + viewModel.certImageUrl = "https://cdn.example.com/flyer/cert.jpg" + viewModel.startDate = Date(timeIntervalSince1970: 1_767_225_600) + viewModel.endDate = Date(timeIntervalSince1970: 1_830_384_000) + viewModel.droneModel = "DJI Mini" + viewModel.droneSerialNo = "SN20260523001" + viewModel.phone = "13800000000" + viewModel.verifyCode = "123456" +} + +private func applyRequest() -> FlyerApplyRequest { + FlyerApplyRequest( + name: "航拍摄影师", + realnameStatus: 1, + certificateType: 2, + certificateNo: "UAS-2026-001", + certificateStartDate: "2026-01-01", + certificateEndDate: "2028-01-01", + certificateImage: "https://cdn.example.com/flyer/cert.jpg", + droneModel: "DJI Mini", + droneSn: "SN20260523001", + contactPhone: "13800000000", + code: "123456" + ) +} + +private func editRequest() -> FlyerEditRequest { + FlyerEditRequest( + id: 801, + name: "航拍摄影师", + realnameStatus: 1, + certificateType: 2, + certificateNo: "UAS-2026-001", + certificateStartDate: "2026-01-01", + certificateEndDate: "2028-01-01", + certificateImage: "https://cdn.example.com/flyer/cert.jpg", + droneModel: "DJI Mini", + droneSn: "SN20260523002", + contactPhone: "13800000001", + code: "654321" + ) +} + +private func pilotJSONBody(from request: URLRequest) throws -> [String: Any] { + let body = try XCTUnwrap(request.httpBody) + return try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) +} + +private func XCTAssertThrowsErrorAsync( + _ expression: @autoclosure () async throws -> T, + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + _ = try await expression() + XCTFail("Expected async expression to throw", file: file, line: line) + } catch { + // Expected path. + } +} diff --git a/suixinkanTests/TestFixture.swift b/suixinkanTests/TestFixture.swift index 64264d9..677f9c5 100644 --- a/suixinkanTests/TestFixture.swift +++ b/suixinkanTests/TestFixture.swift @@ -55,4 +55,14 @@ extension OSSUploadServing { ) async throws -> String { "" } + + /// 旧上传替身默认不关心飞手认证入口;飞手认证专用测试会覆盖此方法记录调用。 + func uploadPilotCertificateImage( + data: Data, + fileName: String, + scenicId: Int, + onProgress: @escaping (Int) -> Void + ) async throws -> String { + "" + } } diff --git a/功能同步Checklist.md b/功能同步Checklist.md index a99a78e..ac21e7a 100644 --- a/功能同步Checklist.md +++ b/功能同步Checklist.md @@ -102,14 +102,14 @@ | 完成 | 相册预览上传 | `album_trailer` 入口已接真实页面;支持选择相册、本地图片/视频 OSS 上传、上传后入库到相册。 | 不恢复旧工程手填 URL 主流程。 | | 完成 | 素材管理 | 素材列表、关键词/审核状态筛选、分页、详情、上传素材、编辑素材、上下架、删除、标签输入、素材文件 OSS 上传。 | 无。 | | 完成 | 样片管理 | 样片列表、关键词/审核状态筛选、分页、详情、上下架、删除、上传样片、关联项目选择、样片文件 OSS 上传。 | 完整项目管理、样片编辑、视频播放器增强后续单独迁移。 | -| 完成 | 直播管理/直播相册 | `live_stream_management` 和 `live_album` 已接真实页面;支持直播列表、创建、详情、复制推流地址、开始/暂停/结束、推流模式切换,以及直播相册筛选、上传素材、预览、删除相册和删除单个素材。 | 不接入播放器/推流 SDK,音视频专项后续人工验证。 | +| 完成 | 直播管理/直播相册 | `live_stream_management` 和 `live_album` 已接真实页面;支持直播列表、创建、详情、复制外部推流地址、开始/暂停/结束、推流模式切换、直播详情可播放 URL 预览、直播相册视频播放,以及直播相册筛选、上传素材、预览、删除相册和删除单个素材。 | 已按旧 iOS 对齐:不新增真推流 SDK,`push_url` 仅作为 OBS/第三方工具外部推流地址;页面保留推流诊断适配层,真 RTMP/RTC 推流 SDK 仍需后续单独选型和真机联调。 | | 完成 | 景区申请 | 景区申请表单、地区选择、合作类型、图片选择、OSS 上传、待审核/驳回状态回填。 | 景区结算不属于本模块,后续单独迁移。 | | 完成 | 权限申请 | 角色权限申请、景区多选、已有权限禁用、申请状态页、驳回编辑入口。 | 后续如有附件上传入口再补。 | | 完成 | 景区结算 | 景区结算申请、金额/备注、多景区提交和结算审核记录已接入。 | 审核记录复用景区申请/权限申请记录,不新增后端未体现的详情审批操作。 | -| 部分完成 | 运营区域 | 入口已识别,进入占位页。 | 迁移区域列表、地图/范围配置。 | +| 完成 | 运营区域 | `operating-area` 已接真实页面;按店铺/景区管理员模式加载围栏,解析 `business_map_area` 并只读展示地图或模拟器坐标摘要。 | 本轮不做区域新增、编辑、删除或绘制保存。 | | 完成 | 位置上报 | 当前位置、标记点、在线状态、立即上报、提醒设置、历史记录、筛选和分页已接入。 | 本轮不做后台定位、离线队列或推送提醒。 | | 完成 | 注册邀请 | 邀请码、邀请链接、二维码、复制链接、规则展示、邀请记录和奖励记录已接入。 | 邀请二维码不落盘,后续如需分享图片再临时生成。 | -| 部分完成 | 飞手认证 | 入口已识别,进入占位页。 | 迁移飞手认证页面。 | +| 完成 | 飞手认证 | `pilot_cert` 已接真实页面;支持实名状态、认证详情、验证码、证件图 OSS 上传、首次提交和驳回编辑。 | 不包含 DJI/飞控 SDK 或真实飞行控制。 | | 不迁移 | 飞控/DJI | `fly` / `pilot_controller` 已明确为 iOS 不支持。 | 不再迁移。 | ## 旧工程能力尚未系统迁移 @@ -134,6 +134,5 @@ xcodebuild build -workspace suixinkan.xcworkspace -scheme suixinkan -destination ## 下一批建议迁移顺序 -1. 运营区域/飞手认证:仍为首页占位入口,可按旧工程业务优先级拆分。 -2. 直播播放器/推流专项:需要明确 iOS 播放器/推流 SDK、真机摄像头/麦克风和网络切换策略。 -3. 订单尾片深层能力:等后端明确独立尾片接口后,再补自动关联订单、审核进度和视频播放器增强。 +1. 直播真推流 SDK:旧 iOS 未接入真推流 SDK,当前已按旧 iOS 对齐为外部 `push_url` + 推流诊断;如后续需要真 SDK,可在腾讯/TRTC、RTMP SDK 等方案明确后替换 `LivePushAdapter` 实现并补真机联调。 +2. 订单尾片深层能力:等后端明确独立尾片接口后,再补自动关联订单、审核进度和视频播放器增强。