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:
@ -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