Implement report supplement evidence flow

This commit is contained in:
2026-07-08 17:23:28 +08:00
parent 290a01e699
commit fd5dc2bbe6
9 changed files with 1906 additions and 421 deletions

View File

@ -3,8 +3,11 @@
// suixinkan
//
import AVFoundation
import PhotosUI
import SnapKit
import UIKit
import UniformTypeIdentifiers
/// UI
enum WildReportUI {
@ -182,6 +185,322 @@ final class WildReportAttachmentChipView: UIView {
}
}
///
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 /
enum WildReportLocalMediaLoader {
static func load(
results: [PHPickerResult],
target: WildReportPickerTarget,
titlePrefix: String? = nil
) async throws -> [WildReportAttachment] {
var attachments: [WildReportAttachment] = []
for result in results {
let attachment = target == .video
? try await loadVideo(from: result.itemProvider, target: target, titlePrefix: titlePrefix)
: try await loadImage(from: result.itemProvider, target: target, titlePrefix: titlePrefix)
attachments.append(attachment)
}
return attachments
}
private static func loadImage(
from provider: NSItemProvider,
target: WildReportPickerTarget,
titlePrefix: String?
) 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: titlePrefix ?? target.titlePrefix,
localFileURL: url,
fileName: fileName
))
} catch {
continuation.resume(throwing: error)
}
}
}
}
private static func loadVideo(
from provider: NSItemProvider,
target: WildReportPickerTarget,
titlePrefix: String?
) 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: titlePrefix ?? 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)
}
}
/// tile
final class WildReportAttachmentTileView: UIControl {
private let gradientLayer = CAGradientLayer()
private let previewAction: () -> Void
private let removeAction: () -> Void
private let tileSize: CGFloat
init(
attachment: WildReportAttachment,
tileSize: CGFloat = 58,
onPreview: @escaping () -> Void,
onRemove: @escaping () -> Void
) {
self.previewAction = onPreview
self.removeAction = onRemove
self.tileSize = tileSize
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(tileSize)
}
}
@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()
}
}
///
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线
final class WildReportAddAttachmentTileView: UIControl {
private let tapAction: () -> Void
private let borderLayer = CAShapeLayer()
private let tileSize: CGFloat
init(
tileSize: CGFloat = 58,
borderColor: UIColor = UIColor(hex: 0xCDD5E1),
iconColor: UIColor = UIColor(hex: 0x8D93A1),
action: @escaping () -> Void
) {
self.tapAction = action
self.tileSize = tileSize
super.init(frame: .zero)
backgroundColor = .white
layer.cornerRadius = 10
borderLayer.strokeColor = borderColor.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 = iconColor
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(tileSize)
}
}
@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 WildReportEmptyView: UIView {
init(text: String) {