新增运营区域与飞手认证模块,并完善直播推流就绪流程
将运营区域与飞手认证从首页占位页迁移为完整模块,扩展 Live 播放与推流就绪流程,并新增飞手证书 OSS 上传。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user