Add OperatingArea and PilotCertification modules with live push readiness.
Migrate operating area and pilot certification from home placeholders, extend Live with playback and push readiness flows, and add pilot certificate OSS upload. 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,18 +392,32 @@ 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) {
|
||||||
RemoteImage(urlString: viewModel.detail.coverImg, contentMode: .fill) {
|
if let player = playbackViewModel.player, playbackViewModel.playableURL != nil {
|
||||||
LinearGradient(colors: [Color(hex: 0x0F274A), AppDesign.primary], startPoint: .topLeading, endPoint: .bottomTrailing)
|
VideoPlayer(player: player)
|
||||||
.overlay {
|
.background(Color.black)
|
||||||
Image(systemName: "dot.radiowaves.left.and.right")
|
} else {
|
||||||
.font(.system(size: 42, weight: .semibold))
|
RemoteImage(urlString: viewModel.detail.coverImg, contentMode: .fill) {
|
||||||
.foregroundStyle(.white.opacity(0.72))
|
LinearGradient(colors: [Color(hex: 0x0F274A), AppDesign.primary], startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||||
}
|
.overlay {
|
||||||
|
Image(systemName: "dot.radiowaves.left.and.right")
|
||||||
|
.font(.system(size: 42, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.72))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||||
Text(viewModel.detail.displayStatus)
|
Text(viewModel.detail.displayStatus)
|
||||||
@ -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