Implement report supplement evidence flow
This commit is contained in:
@ -3,12 +3,10 @@
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import PhotosUI
|
||||
import QuickLook
|
||||
import SnapKit
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// 提交举报页面,提供类型、证据、说明、定位和联系方式表单。
|
||||
final class WildPhotographerReportSubmitViewController: BaseViewController {
|
||||
@ -686,137 +684,6 @@ extension WildPhotographerReportSubmitViewController: UIGestureRecognizerDelegat
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交页本地媒体选择目标。
|
||||
private enum WildReportPickerTarget: Equatable {
|
||||
case image
|
||||
case video
|
||||
case payment
|
||||
|
||||
init(kind: WildReportAttachmentKind) {
|
||||
switch kind {
|
||||
case .image: self = .image
|
||||
case .video: self = .video
|
||||
case .payment: self = .payment
|
||||
}
|
||||
}
|
||||
|
||||
var attachmentKind: WildReportAttachmentKind {
|
||||
switch self {
|
||||
case .image: return .image
|
||||
case .video: return .video
|
||||
case .payment: return .payment
|
||||
}
|
||||
}
|
||||
|
||||
var titlePrefix: String {
|
||||
switch self {
|
||||
case .image: return "现场图片"
|
||||
case .video: return "现场视频"
|
||||
case .payment: return "支付截图"
|
||||
}
|
||||
}
|
||||
|
||||
var limitMessage: String {
|
||||
switch self {
|
||||
case .image: return "现场证据最多上传9项"
|
||||
case .video: return "现场视频最多上传3段"
|
||||
case .payment: return "支付截图最多上传3张"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交页 PHPicker 结果加载器,将图片/视频保存为可预览和待上传的本地文件。
|
||||
private enum WildReportLocalMediaLoader {
|
||||
static func load(results: [PHPickerResult], target: WildReportPickerTarget) async throws -> [WildReportAttachment] {
|
||||
var attachments: [WildReportAttachment] = []
|
||||
for result in results {
|
||||
let attachment = target == .video
|
||||
? try await loadVideo(from: result.itemProvider, target: target)
|
||||
: try await loadImage(from: result.itemProvider, target: target)
|
||||
attachments.append(attachment)
|
||||
}
|
||||
return attachments
|
||||
}
|
||||
|
||||
private static func loadImage(from provider: NSItemProvider, target: WildReportPickerTarget) async throws -> WildReportAttachment {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
guard provider.canLoadObject(ofClass: UIImage.self) else {
|
||||
continuation.resume(throwing: OSSUploadError.unsupportedFileType)
|
||||
return
|
||||
}
|
||||
provider.loadObject(ofClass: UIImage.self) { object, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
guard let image = object as? UIImage,
|
||||
let data = image.jpegData(compressionQuality: 0.9) else {
|
||||
continuation.resume(throwing: OSSUploadError.emptyFile)
|
||||
return
|
||||
}
|
||||
do {
|
||||
let fileName = "report_image_\(UUID().uuidString).jpg"
|
||||
let url = try write(data: data, fileName: fileName)
|
||||
continuation.resume(returning: WildReportAttachment(
|
||||
kind: target.attachmentKind,
|
||||
title: target.titlePrefix,
|
||||
localFileURL: url,
|
||||
fileName: fileName
|
||||
))
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadVideo(from provider: NSItemProvider, target: WildReportPickerTarget) async throws -> WildReportAttachment {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
guard let url else {
|
||||
continuation.resume(throwing: OSSUploadError.emptyFile)
|
||||
return
|
||||
}
|
||||
do {
|
||||
let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension
|
||||
let fileName = "report_video_\(UUID().uuidString).\(ext)"
|
||||
let targetURL = try temporaryFileURL(fileName: fileName)
|
||||
if FileManager.default.fileExists(atPath: targetURL.path) {
|
||||
try FileManager.default.removeItem(at: targetURL)
|
||||
}
|
||||
try FileManager.default.copyItem(at: url, to: targetURL)
|
||||
continuation.resume(returning: WildReportAttachment(
|
||||
kind: target.attachmentKind,
|
||||
title: target.titlePrefix,
|
||||
localFileURL: targetURL,
|
||||
fileName: fileName
|
||||
))
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func write(data: Data, fileName: String) throws -> URL {
|
||||
let url = try temporaryFileURL(fileName: fileName)
|
||||
try data.write(to: url, options: .atomic)
|
||||
return url
|
||||
}
|
||||
|
||||
private static func temporaryFileURL(fileName: String) throws -> URL {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("wild_report_media", isDirectory: true)
|
||||
if !FileManager.default.fileExists(atPath: directory.path) {
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
}
|
||||
return directory.appendingPathComponent(fileName)
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交页步骤标题,复刻参考页左侧蓝色竖条与选填标识。
|
||||
private final class WildReportSectionTitleView: UIView {
|
||||
init(index: Int, title: String, optional: Bool) {
|
||||
@ -899,169 +766,6 @@ private final class WildReportTypeOptionButton: UIControl {
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交页附件缩略 tile,优先展示本地图片或视频封面。
|
||||
private final class WildReportAttachmentTileView: UIControl {
|
||||
private let gradientLayer = CAGradientLayer()
|
||||
private let previewAction: () -> Void
|
||||
private let removeAction: () -> Void
|
||||
|
||||
init(
|
||||
attachment: WildReportAttachment,
|
||||
onPreview: @escaping () -> Void,
|
||||
onRemove: @escaping () -> Void
|
||||
) {
|
||||
self.previewAction = onPreview
|
||||
self.removeAction = onRemove
|
||||
super.init(frame: .zero)
|
||||
layer.cornerRadius = 10
|
||||
clipsToBounds = false
|
||||
|
||||
gradientLayer.cornerRadius = 10
|
||||
let colors: [UIColor] = attachment.kind == .video
|
||||
? [UIColor(hex: 0xB7D8FF), UIColor(hex: 0x4C8FEF)]
|
||||
: [UIColor(hex: 0xCFE6FF), UIColor(hex: 0x7EB4FF)]
|
||||
gradientLayer.colors = colors.map(\.cgColor)
|
||||
gradientLayer.startPoint = CGPoint(x: 0, y: 0)
|
||||
gradientLayer.endPoint = CGPoint(x: 1, y: 1)
|
||||
layer.insertSublayer(gradientLayer, at: 0)
|
||||
|
||||
let thumbnailView = UIImageView()
|
||||
thumbnailView.image = WildReportAttachmentThumbnailFactory.thumbnail(for: attachment)
|
||||
thumbnailView.contentMode = .scaleAspectFill
|
||||
thumbnailView.clipsToBounds = true
|
||||
thumbnailView.layer.cornerRadius = 10
|
||||
thumbnailView.isUserInteractionEnabled = false
|
||||
thumbnailView.isHidden = thumbnailView.image == nil
|
||||
addSubview(thumbnailView)
|
||||
thumbnailView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
|
||||
let icon = UIImageView(image: UIImage(systemName: attachment.kind == .video ? "play.circle.fill" : "mountain.2.fill"))
|
||||
icon.tintColor = UIColor.white.withAlphaComponent(0.95)
|
||||
icon.contentMode = .scaleAspectFit
|
||||
icon.isHidden = attachment.kind != .video && thumbnailView.image != nil
|
||||
addSubview(icon)
|
||||
icon.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(attachment.kind == .video ? 30 : 26)
|
||||
}
|
||||
|
||||
let removeButton = UIButton(type: .system)
|
||||
removeButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal)
|
||||
removeButton.setPreferredSymbolConfiguration(UIImage.SymbolConfiguration(pointSize: 17, weight: .semibold), forImageIn: .normal)
|
||||
removeButton.tintColor = UIColor(hex: 0x8D93A1)
|
||||
removeButton.backgroundColor = .white
|
||||
removeButton.layer.cornerRadius = 14
|
||||
removeButton.layer.shadowColor = UIColor.black.cgColor
|
||||
removeButton.layer.shadowOpacity = 0.10
|
||||
removeButton.layer.shadowRadius = 4
|
||||
removeButton.layer.shadowOffset = CGSize(width: 0, height: 2)
|
||||
removeButton.addTarget(self, action: #selector(removeTapped), for: .touchUpInside)
|
||||
addSubview(removeButton)
|
||||
removeButton.snp.makeConstraints { make in
|
||||
make.top.trailing.equalToSuperview().inset(-2)
|
||||
make.size.equalTo(34)
|
||||
}
|
||||
|
||||
addTarget(self, action: #selector(previewTapped), for: .touchUpInside)
|
||||
snp.makeConstraints { make in
|
||||
make.size.equalTo(58)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
gradientLayer.frame = bounds
|
||||
}
|
||||
|
||||
@objc private func previewTapped() {
|
||||
previewAction()
|
||||
}
|
||||
|
||||
@objc private func removeTapped() {
|
||||
removeAction()
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交页附件封面生成器,负责从本地图片或视频文件提取缩略图。
|
||||
private enum WildReportAttachmentThumbnailFactory {
|
||||
static func thumbnail(for attachment: WildReportAttachment) -> UIImage? {
|
||||
guard let url = attachment.localFileURL else { return nil }
|
||||
switch attachment.kind {
|
||||
case .image, .payment:
|
||||
return UIImage(contentsOfFile: url.path)
|
||||
case .video:
|
||||
return videoThumbnail(url: url)
|
||||
}
|
||||
}
|
||||
|
||||
private static func videoThumbnail(url: URL) -> UIImage? {
|
||||
let asset = AVURLAsset(url: url)
|
||||
let generator = AVAssetImageGenerator(asset: asset)
|
||||
generator.appliesPreferredTrackTransform = true
|
||||
generator.maximumSize = CGSize(width: 160, height: 160)
|
||||
do {
|
||||
let cgImage = try generator.copyCGImage(at: CMTime(seconds: 0.1, preferredTimescale: 600), actualTime: nil)
|
||||
return UIImage(cgImage: cgImage)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交页添加附件 tile,白底虚线加号。
|
||||
private final class WildReportAddAttachmentTileView: UIControl {
|
||||
private let tapAction: () -> Void
|
||||
private let borderLayer = CAShapeLayer()
|
||||
|
||||
init(action: @escaping () -> Void) {
|
||||
self.tapAction = action
|
||||
super.init(frame: .zero)
|
||||
backgroundColor = .white
|
||||
layer.cornerRadius = 10
|
||||
|
||||
borderLayer.strokeColor = UIColor(hex: 0xCDD5E1).cgColor
|
||||
borderLayer.fillColor = UIColor.clear.cgColor
|
||||
borderLayer.lineWidth = 1
|
||||
borderLayer.lineDashPattern = [5, 4]
|
||||
layer.addSublayer(borderLayer)
|
||||
|
||||
let icon = UIImageView(image: UIImage(systemName: "plus"))
|
||||
icon.tintColor = UIColor(hex: 0x8D93A1)
|
||||
icon.contentMode = .scaleAspectFit
|
||||
addSubview(icon)
|
||||
icon.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(30)
|
||||
}
|
||||
|
||||
addTarget(self, action: #selector(tapped), for: .touchUpInside)
|
||||
snp.makeConstraints { make in
|
||||
make.size.equalTo(58)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
borderLayer.path = UIBezierPath(roundedRect: bounds, cornerRadius: 10).cgPath
|
||||
}
|
||||
|
||||
@objc private func tapped() {
|
||||
tapAction()
|
||||
}
|
||||
}
|
||||
|
||||
/// 举报成功页面,展示编号、上传摘要和处理进度。
|
||||
final class WildPhotographerReportSuccessViewController: BaseViewController {
|
||||
private let viewModel: WildPhotographerReportSuccessViewModel
|
||||
|
||||
Reference in New Issue
Block a user