新增运营区域与飞手认证模块,并完善直播推流就绪流程
将运营区域与飞手认证从首页占位页迁移为完整模块,扩展 Live 播放与推流就绪流程,并新增飞手证书 OSS 上传。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -452,8 +452,9 @@
|
|||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
|
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "用于扫码核销订单";
|
INFOPLIST_KEY_NSCameraUsageDescription = "用于扫码核销订单和直播推流预览";
|
||||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "用于景区选择、打卡点选点和位置上报时获取当前位置";
|
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "用于景区选择、打卡点选点和位置上报时获取当前位置";
|
||||||
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "用于直播推流时采集现场声音";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "用于保存收款二维码到相册";
|
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "用于保存收款二维码到相册";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
@ -496,8 +497,9 @@
|
|||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
|
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "用于扫码核销订单";
|
INFOPLIST_KEY_NSCameraUsageDescription = "用于扫码核销订单和直播推流预览";
|
||||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "用于景区选择、打卡点选点和位置上报时获取当前位置";
|
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "用于景区选择、打卡点选点和位置上报时获取当前位置";
|
||||||
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "用于直播推流时采集现场声音";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "用于保存收款二维码到相册";
|
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "用于保存收款二维码到相册";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
|||||||
@ -34,6 +34,8 @@ struct RootView: View {
|
|||||||
@State private var scenicQueueAPI: ScenicQueueAPI
|
@State private var scenicQueueAPI: ScenicQueueAPI
|
||||||
@State private var scenicQueueRuntime = ScenicQueueRuntime()
|
@State private var scenicQueueRuntime = ScenicQueueRuntime()
|
||||||
@State private var liveAPI: LiveAPI
|
@State private var liveAPI: LiveAPI
|
||||||
|
@State private var operatingAreaAPI: OperatingAreaAPI
|
||||||
|
@State private var pilotCertificationAPI: PilotCertificationAPI
|
||||||
@State private var taskAPI: TaskAPI
|
@State private var taskAPI: TaskAPI
|
||||||
@State private var projectAPI: ProjectAPI
|
@State private var projectAPI: ProjectAPI
|
||||||
@State private var scheduleAPI: ScheduleAPI
|
@State private var scheduleAPI: ScheduleAPI
|
||||||
@ -66,6 +68,8 @@ struct RootView: View {
|
|||||||
_messageCenterAPI = State(initialValue: MessageCenterAPI(client: apiClient))
|
_messageCenterAPI = State(initialValue: MessageCenterAPI(client: apiClient))
|
||||||
_scenicQueueAPI = State(initialValue: ScenicQueueAPI(client: apiClient))
|
_scenicQueueAPI = State(initialValue: ScenicQueueAPI(client: apiClient))
|
||||||
_liveAPI = State(initialValue: LiveAPI(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))
|
_taskAPI = State(initialValue: TaskAPI(client: apiClient))
|
||||||
_projectAPI = State(initialValue: ProjectAPI(client: apiClient))
|
_projectAPI = State(initialValue: ProjectAPI(client: apiClient))
|
||||||
_scheduleAPI = State(initialValue: ScheduleAPI(client: apiClient))
|
_scheduleAPI = State(initialValue: ScheduleAPI(client: apiClient))
|
||||||
@ -116,6 +120,8 @@ struct RootView: View {
|
|||||||
.environment(scenicQueueAPI)
|
.environment(scenicQueueAPI)
|
||||||
.environment(scenicQueueRuntime)
|
.environment(scenicQueueRuntime)
|
||||||
.environment(liveAPI)
|
.environment(liveAPI)
|
||||||
|
.environment(operatingAreaAPI)
|
||||||
|
.environment(pilotCertificationAPI)
|
||||||
.environment(taskAPI)
|
.environment(taskAPI)
|
||||||
.environment(projectAPI)
|
.environment(projectAPI)
|
||||||
.environment(scheduleAPI)
|
.environment(scheduleAPI)
|
||||||
|
|||||||
@ -45,6 +45,9 @@ protocol OSSUploadServing {
|
|||||||
|
|
||||||
/// 上传银行卡照片,并返回可访问的 OSS 文件 URL。
|
/// 上传银行卡照片,并返回可访问的 OSS 文件 URL。
|
||||||
func uploadBankCardImage(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
|
||||||
|
|
||||||
|
/// 上传飞手认证证件图片,并返回可访问的 OSS 文件 URL。
|
||||||
|
func uploadPilotCertificateImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -108,6 +111,11 @@ final class OSSUploadService {
|
|||||||
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "bank_card", onProgress: onProgress)
|
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 上传流程。
|
/// 执行通用 OSS 上传流程。
|
||||||
private func uploadFile(
|
private func uploadFile(
|
||||||
data: Data,
|
data: Data,
|
||||||
@ -218,6 +226,8 @@ enum OSSUploadPolicy {
|
|||||||
return "real_name/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
return "real_name/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
case "bank_card":
|
case "bank_card":
|
||||||
return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
|
case "pilot_cert":
|
||||||
|
return "pilot_cert/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
default:
|
default:
|
||||||
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,18 @@ import Foundation
|
|||||||
|
|
||||||
/// 表单校验工具,承接排队设置等页面的通用数字和金额格式校验。
|
/// 表单校验工具,承接排队设置等页面的通用数字和金额格式校验。
|
||||||
enum AppFormValidator {
|
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<Int>) throws -> Int {
|
static func rangedInteger(_ text: String, name: String, range: ClosedRange<Int>) throws -> Int {
|
||||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|||||||
@ -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 的映射,并同步补充单元测试。
|
后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。
|
||||||
|
|||||||
@ -62,6 +62,8 @@ enum HomeRoute: Hashable {
|
|||||||
case queueManagement
|
case queueManagement
|
||||||
case liveManagement
|
case liveManagement
|
||||||
case liveAlbum
|
case liveAlbum
|
||||||
|
case operatingArea
|
||||||
|
case pilotCertification
|
||||||
case modulePlaceholder(uri: String, title: String)
|
case modulePlaceholder(uri: String, title: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -143,6 +143,10 @@ enum HomeMenuRouter {
|
|||||||
return .destination(.liveManagement)
|
return .destination(.liveManagement)
|
||||||
case "live_album":
|
case "live_album":
|
||||||
return .destination(.liveAlbum)
|
return .destination(.liveAlbum)
|
||||||
|
case "operating-area":
|
||||||
|
return .destination(.operatingArea)
|
||||||
|
case "pilot_cert":
|
||||||
|
return .destination(.pilotCertification)
|
||||||
case "store", "more_functions":
|
case "store", "more_functions":
|
||||||
return .destination(.moreFunctions)
|
return .destination(.moreFunctions)
|
||||||
case "fly", "pilot_controller":
|
case "fly", "pilot_controller":
|
||||||
@ -151,10 +155,6 @@ enum HomeMenuRouter {
|
|||||||
title: title.isEmpty ? self.title(for: uri) : title,
|
title: title.isEmpty ? self.title(for: uri) : title,
|
||||||
reason: "DJI/飞控模块已明确不纳入 iOS 迁移和后续开发范围。"
|
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:
|
default:
|
||||||
return .placeholder(uri: uri, title: title.isEmpty ? self.title(for: uri) : title)
|
return .placeholder(uri: uri, title: title.isEmpty ? self.title(for: uri) : title)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -100,6 +100,10 @@ extension HomeRoute {
|
|||||||
LiveManagementView()
|
LiveManagementView()
|
||||||
case .liveAlbum:
|
case .liveAlbum:
|
||||||
LiveAlbumView()
|
LiveAlbumView()
|
||||||
|
case .operatingArea:
|
||||||
|
OperatingAreaView()
|
||||||
|
case .pilotCertification:
|
||||||
|
PilotCertificationView()
|
||||||
case let .modulePlaceholder(uri, title):
|
case let .modulePlaceholder(uri, title):
|
||||||
HomeMigrationModuleView(title: title, uri: uri)
|
HomeMigrationModuleView(title: title, uri: uri)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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/...` 接口。
|
- `LiveAPI`:封装 `/api/app/manual-live/...` 和 `/api/app/view-album/...` 接口。
|
||||||
- `LiveManagementViewModel`:管理直播列表分页、创建、开始/暂停、结束和详情失败清理。
|
- `LiveManagementViewModel`:管理直播列表分页、创建、开始/暂停、结束和详情失败清理。
|
||||||
|
- `LivePlaybackViewModel`:管理直播详情和直播相册视频的系统播放器 URL、播放、暂停和释放状态。
|
||||||
|
- `LivePushReadinessViewModel`:诊断直播推流地址、相机/麦克风权限、网络状态和当前推流 SDK 接入状态,不触发本机采集推流。
|
||||||
- `LiveAlbumViewModel`:管理直播相册日期筛选、分页和删除相册。
|
- `LiveAlbumViewModel`:管理直播相册日期筛选、分页和删除相册。
|
||||||
- `LiveAlbumCreateViewModel`:管理本地图片/视频上传到 OSS 后创建直播相册。
|
- `LiveAlbumCreateViewModel`:管理本地图片/视频上传到 OSS 后创建直播相册。
|
||||||
- `LiveAlbumPreviewViewModel`:进入相册预览时加载相册详情,并支持删除单个素材。
|
- `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 提交给创建相册接口。
|
直播封面创建沿用旧 iOS 的 URL 输入。直播相册素材通过 `PhotosPicker` 选择后,使用 `OSSUploadService.uploadAliveAlbumFile` 上传到 `live_albums/yyyyMMdd/scenicId/...`,再把最终 URL 提交给创建相册接口。
|
||||||
|
|||||||
@ -43,6 +43,11 @@ struct LiveEntity: Decodable, Identifiable, Equatable {
|
|||||||
let title: String
|
let title: String
|
||||||
let coverImg: String
|
let coverImg: String
|
||||||
let pushUrl: String
|
let pushUrl: String
|
||||||
|
let playUrl: String
|
||||||
|
let pullUrl: String
|
||||||
|
let hlsUrl: String
|
||||||
|
let flvUrl: String
|
||||||
|
let liveUrl: String
|
||||||
let startTime: Int64
|
let startTime: Int64
|
||||||
let endTime: Int64
|
let endTime: Int64
|
||||||
let duration: Int64
|
let duration: Int64
|
||||||
@ -57,6 +62,11 @@ struct LiveEntity: Decodable, Identifiable, Equatable {
|
|||||||
case title
|
case title
|
||||||
case coverImg = "cover_img"
|
case coverImg = "cover_img"
|
||||||
case pushUrl = "push_url"
|
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 startTime = "start_time"
|
||||||
case endTime = "end_time"
|
case endTime = "end_time"
|
||||||
case duration
|
case duration
|
||||||
@ -72,6 +82,11 @@ struct LiveEntity: Decodable, Identifiable, Equatable {
|
|||||||
title: String = "",
|
title: String = "",
|
||||||
coverImg: String = "",
|
coverImg: String = "",
|
||||||
pushUrl: String = "",
|
pushUrl: String = "",
|
||||||
|
playUrl: String = "",
|
||||||
|
pullUrl: String = "",
|
||||||
|
hlsUrl: String = "",
|
||||||
|
flvUrl: String = "",
|
||||||
|
liveUrl: String = "",
|
||||||
startTime: Int64 = 0,
|
startTime: Int64 = 0,
|
||||||
endTime: Int64 = 0,
|
endTime: Int64 = 0,
|
||||||
duration: Int64 = 0,
|
duration: Int64 = 0,
|
||||||
@ -85,6 +100,11 @@ struct LiveEntity: Decodable, Identifiable, Equatable {
|
|||||||
self.title = title
|
self.title = title
|
||||||
self.coverImg = coverImg
|
self.coverImg = coverImg
|
||||||
self.pushUrl = pushUrl
|
self.pushUrl = pushUrl
|
||||||
|
self.playUrl = playUrl
|
||||||
|
self.pullUrl = pullUrl
|
||||||
|
self.hlsUrl = hlsUrl
|
||||||
|
self.flvUrl = flvUrl
|
||||||
|
self.liveUrl = liveUrl
|
||||||
self.startTime = startTime
|
self.startTime = startTime
|
||||||
self.endTime = endTime
|
self.endTime = endTime
|
||||||
self.duration = duration
|
self.duration = duration
|
||||||
@ -101,6 +121,11 @@ struct LiveEntity: Decodable, Identifiable, Equatable {
|
|||||||
title = try container.liveDecodeLossyString(forKey: .title)
|
title = try container.liveDecodeLossyString(forKey: .title)
|
||||||
coverImg = try container.liveDecodeLossyString(forKey: .coverImg)
|
coverImg = try container.liveDecodeLossyString(forKey: .coverImg)
|
||||||
pushUrl = try container.liveDecodeLossyString(forKey: .pushUrl)
|
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)
|
startTime = Int64(try container.liveDecodeLossyInt(forKey: .startTime) ?? 0)
|
||||||
endTime = Int64(try container.liveDecodeLossyInt(forKey: .endTime) ?? 0)
|
endTime = Int64(try container.liveDecodeLossyInt(forKey: .endTime) ?? 0)
|
||||||
duration = Int64(try container.liveDecodeLossyInt(forKey: .duration) ?? 0)
|
duration = Int64(try container.liveDecodeLossyInt(forKey: .duration) ?? 0)
|
||||||
@ -114,6 +139,10 @@ struct LiveEntity: Decodable, Identifiable, Equatable {
|
|||||||
var displayStatus: String {
|
var displayStatus: String {
|
||||||
statusLabel.liveNonEmpty ?? "状态\(status)"
|
statusLabel.liveNonEmpty ?? "状态\(status)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var playbackURLCandidates: [String] {
|
||||||
|
[playUrl, pullUrl, hlsUrl, liveUrl, flvUrl]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 创建直播请求实体。
|
/// 创建直播请求实体。
|
||||||
|
|||||||
132
suixinkan/Features/Live/ViewModels/LivePlaybackViewModel.swift
Normal file
132
suixinkan/Features/Live/ViewModels/LivePlaybackViewModel.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@
|
|||||||
// Created by Codex on 2026/6/25.
|
// Created by Codex on 2026/6/25.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import AVKit
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
@ -459,13 +460,17 @@ struct LiveAlbumPreviewView: View {
|
|||||||
private struct LiveAlbumPreviewPage: View {
|
private struct LiveAlbumPreviewPage: View {
|
||||||
let file: LiveAlbumFileItem
|
let file: LiveAlbumFileItem
|
||||||
@Environment(ToastCenter.self) private var toastCenter
|
@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 {
|
var body: some View {
|
||||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||||
if file.isVideo && file.coverImg?.liveTrimmed.isEmpty != false {
|
if file.isVideo {
|
||||||
Image(systemName: "play.circle.fill")
|
videoContent
|
||||||
.font(.system(size: 72, weight: .semibold))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
} else {
|
} else {
|
||||||
RemoteImage(urlString: file.previewURL, contentMode: .fit) {
|
RemoteImage(urlString: file.previewURL, contentMode: .fit) {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@ -487,6 +492,33 @@ private struct LiveAlbumPreviewPage: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.padding(AppMetrics.Spacing.medium)
|
.padding(AppMetrics.Spacing.medium)
|
||||||
.background(Color.black)
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
// Created by Codex on 2026/6/25.
|
// Created by Codex on 2026/6/25.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import AVKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// 直播管理首页,展示直播列表和控制入口。
|
/// 直播管理首页,展示直播列表和控制入口。
|
||||||
@ -354,11 +355,14 @@ struct LiveDetailView: View {
|
|||||||
@Environment(ToastCenter.self) private var toastCenter
|
@Environment(ToastCenter.self) private var toastCenter
|
||||||
@Environment(\.globalLoading) private var globalLoading
|
@Environment(\.globalLoading) private var globalLoading
|
||||||
@State private var viewModel: LiveDetailViewModel
|
@State private var viewModel: LiveDetailViewModel
|
||||||
|
@State private var playbackViewModel: LivePlaybackViewModel
|
||||||
|
@State private var pushReadinessViewModel = LivePushReadinessViewModel()
|
||||||
@State private var copied = false
|
@State private var copied = false
|
||||||
let onChanged: () -> Void
|
let onChanged: () -> Void
|
||||||
|
|
||||||
init(initialDetail: LiveEntity, onChanged: @escaping () -> Void) {
|
init(initialDetail: LiveEntity, onChanged: @escaping () -> Void) {
|
||||||
_viewModel = State(initialValue: LiveDetailViewModel(detail: initialDetail))
|
_viewModel = State(initialValue: LiveDetailViewModel(detail: initialDetail))
|
||||||
|
_playbackViewModel = State(initialValue: LivePlaybackViewModel(live: initialDetail))
|
||||||
self.onChanged = onChanged
|
self.onChanged = onChanged
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -366,10 +370,11 @@ struct LiveDetailView: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||||
coverSection
|
playbackSection
|
||||||
infoSection
|
infoSection
|
||||||
pushURLSection
|
pushURLSection
|
||||||
pushModeSection
|
pushModeSection
|
||||||
|
pushReadinessSection
|
||||||
Spacer(minLength: 80)
|
Spacer(minLength: 80)
|
||||||
}
|
}
|
||||||
.padding(AppMetrics.Spacing.pageHorizontal)
|
.padding(AppMetrics.Spacing.pageHorizontal)
|
||||||
@ -387,11 +392,24 @@ struct LiveDetailView: View {
|
|||||||
.disabled(viewModel.loading || viewModel.actionInFlight)
|
.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) {
|
ZStack(alignment: .bottomLeading) {
|
||||||
|
if let player = playbackViewModel.player, playbackViewModel.playableURL != nil {
|
||||||
|
VideoPlayer(player: player)
|
||||||
|
.background(Color.black)
|
||||||
|
} else {
|
||||||
RemoteImage(urlString: viewModel.detail.coverImg, contentMode: .fill) {
|
RemoteImage(urlString: viewModel.detail.coverImg, contentMode: .fill) {
|
||||||
LinearGradient(colors: [Color(hex: 0x0F274A), AppDesign.primary], startPoint: .topLeading, endPoint: .bottomTrailing)
|
LinearGradient(colors: [Color(hex: 0x0F274A), AppDesign.primary], startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||||
.overlay {
|
.overlay {
|
||||||
@ -400,6 +418,7 @@ struct LiveDetailView: View {
|
|||||||
.foregroundStyle(.white.opacity(0.72))
|
.foregroundStyle(.white.opacity(0.72))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||||
Text(viewModel.detail.displayStatus)
|
Text(viewModel.detail.displayStatus)
|
||||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||||||
@ -411,6 +430,32 @@ struct LiveDetailView: View {
|
|||||||
.font(.system(size: AppMetrics.FontSize.title, weight: .bold))
|
.font(.system(size: AppMetrics.FontSize.title, weight: .bold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.lineLimit(2)
|
.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)
|
.padding(AppMetrics.Spacing.medium)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@ -420,6 +465,16 @@ struct LiveDetailView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
.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 {
|
private var infoSection: some View {
|
||||||
liveDetailCard("直播信息") {
|
liveDetailCard("直播信息") {
|
||||||
LiveInfoRow(title: "标题", value: viewModel.detail.title.liveNonEmpty ?? "--")
|
LiveInfoRow(title: "标题", value: viewModel.detail.title.liveNonEmpty ?? "--")
|
||||||
@ -431,6 +486,9 @@ struct LiveDetailView: View {
|
|||||||
|
|
||||||
private var pushURLSection: some View {
|
private var pushURLSection: some View {
|
||||||
liveDetailCard("推流地址") {
|
liveDetailCard("推流地址") {
|
||||||
|
Text("该地址用于 OBS 或第三方推流工具;当前版本不在 App 内采集摄像头/麦克风并推流。")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.caption))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
Text(viewModel.detail.pushUrl.liveNonEmpty ?? "--")
|
Text(viewModel.detail.pushUrl.liveNonEmpty ?? "--")
|
||||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||||
.textSelection(.enabled)
|
.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 {
|
private var bottomBar: some View {
|
||||||
HStack(spacing: AppMetrics.Spacing.medium) {
|
HStack(spacing: AppMetrics.Spacing.medium) {
|
||||||
Button {
|
Button {
|
||||||
@ -523,6 +615,7 @@ struct LiveDetailView: View {
|
|||||||
await globalLoading.withOptionalLoading(showLoading) {
|
await globalLoading.withOptionalLoading(showLoading) {
|
||||||
await viewModel.refresh(api: liveAPI, showLoading: false)
|
await viewModel.refresh(api: liveAPI, showLoading: false)
|
||||||
}
|
}
|
||||||
|
syncMediaState()
|
||||||
if let message = viewModel.errorMessage {
|
if let message = viewModel.errorMessage {
|
||||||
toastCenter.show(message)
|
toastCenter.show(message)
|
||||||
}
|
}
|
||||||
@ -554,6 +647,21 @@ struct LiveDetailView: View {
|
|||||||
toastCenter.show(error.localizedDescription)
|
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 {
|
private struct LiveInfoRow: View {
|
||||||
|
|||||||
52
suixinkan/Features/OperatingArea/API/OperatingAreaAPI.swift
Normal file
52
suixinkan/Features/OperatingArea/API/OperatingAreaAPI.swift
Normal file
@ -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<OperatingAreaItem>
|
||||||
|
func scenicAdminBusinessArea(scenicId: Int) async throws -> ListPayload<OperatingAreaItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
@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<OperatingAreaItem> {
|
||||||
|
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<OperatingAreaItem> {
|
||||||
|
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 {}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
17
suixinkan/Features/OperatingArea/OperatingArea.md
Normal file
17
suixinkan/Features/OperatingArea/OperatingArea.md
Normal file
@ -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;模拟器或未启用高德时展示坐标摘要兜底。
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
本模块只展示已有区域和围栏,不提供新建、编辑、删除或绘制保存能力。缺少景区/门店、角色不支持、空列表和不可解析围栏都会进入明确空态或阻断提示。
|
||||||
@ -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<OperatingAreaItem>
|
||||||
|
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 = []
|
||||||
|
}
|
||||||
|
}
|
||||||
390
suixinkan/Features/OperatingArea/Views/OperatingAreaViews.swift
Normal file
390
suixinkan/Features/OperatingArea/Views/OperatingAreaViews.swift
Normal file
@ -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
|
||||||
@ -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 {}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
17
suixinkan/Features/PilotCertification/PilotCertification.md
Normal file
17
suixinkan/Features/PilotCertification/PilotCertification.md
Normal file
@ -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、无人机连接、飞行控制或横屏飞控流程。
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<Content: View>: 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
|
||||||
|
}
|
||||||
|
}
|
||||||
38
suixinkanTests/Fixtures/flyer_detail_success.json
Normal file
38
suixinkanTests/Fixtures/flyer_detail_success.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
8
suixinkanTests/Fixtures/operating_area_empty.json
Normal file
8
suixinkanTests/Fixtures/operating_area_empty.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"total": 0,
|
||||||
|
"list": []
|
||||||
|
}
|
||||||
|
}
|
||||||
46
suixinkanTests/Fixtures/operating_area_success.json
Normal file
46
suixinkanTests/Fixtures/operating_area_success.json
Normal file
@ -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": "待审核"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -64,14 +64,8 @@ final class HomeMenuRouterTests: XCTestCase {
|
|||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "queue_management", title: ""), .destination(.queueManagement))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "queue_management", title: ""), .destination(.queueManagement))
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_stream_management", title: ""), .destination(.liveManagement))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_stream_management", title: ""), .destination(.liveManagement))
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_album", title: ""), .destination(.liveAlbum))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_album", title: ""), .destination(.liveAlbum))
|
||||||
}
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "operating-area", title: ""), .destination(.operatingArea))
|
||||||
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "pilot_cert", title: ""), .destination(.pilotCertification))
|
||||||
/// 测试已知但未迁移的首页路由会进入安全占位。
|
|
||||||
func testKnownUnmigratedRoutesResolveToPlaceholders() {
|
|
||||||
XCTAssertEqual(
|
|
||||||
HomeMenuRouter.resolve(uri: "operating-area", title: ""),
|
|
||||||
.destination(.modulePlaceholder(uri: "operating-area", title: "运营区域"))
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试 Tab 路由仍集中在 HomeMenuRouter。
|
/// 测试 Tab 路由仍集中在 HomeMenuRouter。
|
||||||
|
|||||||
@ -192,6 +192,107 @@ final class LiveViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(api.albumDeleteFileRequests.first?.fileIds, [11])
|
XCTAssertEqual(api.albumDeleteFileRequests.first?.fileIds, [11])
|
||||||
XCTAssertEqual(viewModel.files.map(\.id), [12])
|
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
|
@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 { "" }
|
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 {
|
private func live(id: Int, title: String = "直播", status: Int = 1, manualPushMode: Int = 1) -> LiveEntity {
|
||||||
LiveEntity(
|
LiveEntity(
|
||||||
id: id,
|
id: id,
|
||||||
|
|||||||
171
suixinkanTests/OperatingArea/OperatingAreaTests.swift
Normal file
171
suixinkanTests/OperatingArea/OperatingAreaTests.swift
Normal file
@ -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<OperatingAreaItem>.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<OperatingAreaItem>(total: 0, list: [])
|
||||||
|
var error: Error?
|
||||||
|
var storeIds: [Int] = []
|
||||||
|
var scenicIds: [Int] = []
|
||||||
|
|
||||||
|
func storeBusinessArea(storeId: Int) async throws -> ListPayload<OperatingAreaItem> {
|
||||||
|
storeIds.append(storeId)
|
||||||
|
if let error { throw error }
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
func scenicAdminBusinessArea(scenicId: Int) async throws -> ListPayload<OperatingAreaItem> {
|
||||||
|
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) }
|
||||||
|
})
|
||||||
|
}
|
||||||
277
suixinkanTests/PilotCertification/PilotCertificationTests.swift
Normal file
277
suixinkanTests/PilotCertification/PilotCertificationTests.swift
Normal file
@ -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<T>(
|
||||||
|
_ 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -55,4 +55,14 @@ extension OSSUploadServing {
|
|||||||
) async throws -> String {
|
) async throws -> String {
|
||||||
""
|
""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 旧上传替身默认不关心飞手认证入口;飞手认证专用测试会覆盖此方法记录调用。
|
||||||
|
func uploadPilotCertificateImage(
|
||||||
|
data: Data,
|
||||||
|
fileName: String,
|
||||||
|
scenicId: Int,
|
||||||
|
onProgress: @escaping (Int) -> Void
|
||||||
|
) async throws -> String {
|
||||||
|
""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -102,14 +102,14 @@
|
|||||||
| 完成 | 相册预览上传 | `album_trailer` 入口已接真实页面;支持选择相册、本地图片/视频 OSS 上传、上传后入库到相册。 | 不恢复旧工程手填 URL 主流程。 |
|
| 完成 | 相册预览上传 | `album_trailer` 入口已接真实页面;支持选择相册、本地图片/视频 OSS 上传、上传后入库到相册。 | 不恢复旧工程手填 URL 主流程。 |
|
||||||
| 完成 | 素材管理 | 素材列表、关键词/审核状态筛选、分页、详情、上传素材、编辑素材、上下架、删除、标签输入、素材文件 OSS 上传。 | 无。 |
|
| 完成 | 素材管理 | 素材列表、关键词/审核状态筛选、分页、详情、上传素材、编辑素材、上下架、删除、标签输入、素材文件 OSS 上传。 | 无。 |
|
||||||
| 完成 | 样片管理 | 样片列表、关键词/审核状态筛选、分页、详情、上下架、删除、上传样片、关联项目选择、样片文件 OSS 上传。 | 完整项目管理、样片编辑、视频播放器增强后续单独迁移。 |
|
| 完成 | 样片管理 | 样片列表、关键词/审核状态筛选、分页、详情、上下架、删除、上传样片、关联项目选择、样片文件 OSS 上传。 | 完整项目管理、样片编辑、视频播放器增强后续单独迁移。 |
|
||||||
| 完成 | 直播管理/直播相册 | `live_stream_management` 和 `live_album` 已接真实页面;支持直播列表、创建、详情、复制推流地址、开始/暂停/结束、推流模式切换,以及直播相册筛选、上传素材、预览、删除相册和删除单个素材。 | 不接入播放器/推流 SDK,音视频专项后续人工验证。 |
|
| 完成 | 直播管理/直播相册 | `live_stream_management` 和 `live_album` 已接真实页面;支持直播列表、创建、详情、复制外部推流地址、开始/暂停/结束、推流模式切换、直播详情可播放 URL 预览、直播相册视频播放,以及直播相册筛选、上传素材、预览、删除相册和删除单个素材。 | 已按旧 iOS 对齐:不新增真推流 SDK,`push_url` 仅作为 OBS/第三方工具外部推流地址;页面保留推流诊断适配层,真 RTMP/RTC 推流 SDK 仍需后续单独选型和真机联调。 |
|
||||||
| 完成 | 景区申请 | 景区申请表单、地区选择、合作类型、图片选择、OSS 上传、待审核/驳回状态回填。 | 景区结算不属于本模块,后续单独迁移。 |
|
| 完成 | 景区申请 | 景区申请表单、地区选择、合作类型、图片选择、OSS 上传、待审核/驳回状态回填。 | 景区结算不属于本模块,后续单独迁移。 |
|
||||||
| 完成 | 权限申请 | 角色权限申请、景区多选、已有权限禁用、申请状态页、驳回编辑入口。 | 后续如有附件上传入口再补。 |
|
| 完成 | 权限申请 | 角色权限申请、景区多选、已有权限禁用、申请状态页、驳回编辑入口。 | 后续如有附件上传入口再补。 |
|
||||||
| 完成 | 景区结算 | 景区结算申请、金额/备注、多景区提交和结算审核记录已接入。 | 审核记录复用景区申请/权限申请记录,不新增后端未体现的详情审批操作。 |
|
| 完成 | 景区结算 | 景区结算申请、金额/备注、多景区提交和结算审核记录已接入。 | 审核记录复用景区申请/权限申请记录,不新增后端未体现的详情审批操作。 |
|
||||||
| 部分完成 | 运营区域 | 入口已识别,进入占位页。 | 迁移区域列表、地图/范围配置。 |
|
| 完成 | 运营区域 | `operating-area` 已接真实页面;按店铺/景区管理员模式加载围栏,解析 `business_map_area` 并只读展示地图或模拟器坐标摘要。 | 本轮不做区域新增、编辑、删除或绘制保存。 |
|
||||||
| 完成 | 位置上报 | 当前位置、标记点、在线状态、立即上报、提醒设置、历史记录、筛选和分页已接入。 | 本轮不做后台定位、离线队列或推送提醒。 |
|
| 完成 | 位置上报 | 当前位置、标记点、在线状态、立即上报、提醒设置、历史记录、筛选和分页已接入。 | 本轮不做后台定位、离线队列或推送提醒。 |
|
||||||
| 完成 | 注册邀请 | 邀请码、邀请链接、二维码、复制链接、规则展示、邀请记录和奖励记录已接入。 | 邀请二维码不落盘,后续如需分享图片再临时生成。 |
|
| 完成 | 注册邀请 | 邀请码、邀请链接、二维码、复制链接、规则展示、邀请记录和奖励记录已接入。 | 邀请二维码不落盘,后续如需分享图片再临时生成。 |
|
||||||
| 部分完成 | 飞手认证 | 入口已识别,进入占位页。 | 迁移飞手认证页面。 |
|
| 完成 | 飞手认证 | `pilot_cert` 已接真实页面;支持实名状态、认证详情、验证码、证件图 OSS 上传、首次提交和驳回编辑。 | 不包含 DJI/飞控 SDK 或真实飞行控制。 |
|
||||||
| 不迁移 | 飞控/DJI | `fly` / `pilot_controller` 已明确为 iOS 不支持。 | 不再迁移。 |
|
| 不迁移 | 飞控/DJI | `fly` / `pilot_controller` 已明确为 iOS 不支持。 | 不再迁移。 |
|
||||||
|
|
||||||
## 旧工程能力尚未系统迁移
|
## 旧工程能力尚未系统迁移
|
||||||
@ -134,6 +134,5 @@ xcodebuild build -workspace suixinkan.xcworkspace -scheme suixinkan -destination
|
|||||||
|
|
||||||
## 下一批建议迁移顺序
|
## 下一批建议迁移顺序
|
||||||
|
|
||||||
1. 运营区域/飞手认证:仍为首页占位入口,可按旧工程业务优先级拆分。
|
1. 直播真推流 SDK:旧 iOS 未接入真推流 SDK,当前已按旧 iOS 对齐为外部 `push_url` + 推流诊断;如后续需要真 SDK,可在腾讯/TRTC、RTMP SDK 等方案明确后替换 `LivePushAdapter` 实现并补真机联调。
|
||||||
2. 直播播放器/推流专项:需要明确 iOS 播放器/推流 SDK、真机摄像头/麦克风和网络切换策略。
|
2. 订单尾片深层能力:等后端明确独立尾片接口后,再补自动关联订单、审核进度和视频播放器增强。
|
||||||
3. 订单尾片深层能力:等后端明确独立尾片接口后,再补自动关联订单、审核进度和视频播放器增强。
|
|
||||||
|
|||||||
Reference in New Issue
Block a user