Implement report supplement evidence flow
This commit is contained in:
@ -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) {
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Kingfisher
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
@ -136,7 +137,7 @@ final class WildPhotographerReportListViewController: BaseViewController {
|
||||
|
||||
private func openDetail(_ record: WildReportRecord) {
|
||||
navigationController?.pushViewController(
|
||||
WildPhotographerReportDetailViewController(record: record, homeViewModel: viewModel.homeViewModel),
|
||||
WildPhotographerReportDetailViewController(record: record, homeViewModel: viewModel.homeViewModel, api: api),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
@ -514,14 +515,20 @@ private final class WildReportListLoadingView: UIView {
|
||||
final class WildPhotographerReportDetailViewController: BaseViewController {
|
||||
private let viewModel: WildPhotographerReportDetailViewModel
|
||||
private let homeViewModel: WildPhotographerReportHomeViewModel
|
||||
private let api: any WildPhotographerReportServing
|
||||
private let scrollView = UIScrollView()
|
||||
private let stack = UIStackView()
|
||||
private let bottomBar = UIView()
|
||||
private let bottomButtonRow = UIStackView()
|
||||
|
||||
init(record: WildReportRecord, homeViewModel: WildPhotographerReportHomeViewModel) {
|
||||
init(
|
||||
record: WildReportRecord,
|
||||
homeViewModel: WildPhotographerReportHomeViewModel,
|
||||
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI
|
||||
) {
|
||||
self.viewModel = WildPhotographerReportDetailViewModel(record: record, homeViewModel: homeViewModel)
|
||||
self.homeViewModel = homeViewModel
|
||||
self.api = api
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@ -593,7 +600,17 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
Task {
|
||||
await viewModel.loadDetailIfNeeded(api: api)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.rebuildContent() }
|
||||
}
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
@ -705,6 +722,10 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
|
||||
card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "举报说明", value: viewModel.descriptionText, multiline: true))
|
||||
card.stack.addArrangedSubview(makeInsetDivider())
|
||||
card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "微信号/手机号", value: viewModel.contactText))
|
||||
if !viewModel.handleRemarkText.isEmpty {
|
||||
card.stack.addArrangedSubview(makeInsetDivider())
|
||||
card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "处理备注", value: viewModel.handleRemarkText, multiline: true))
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
@ -715,6 +736,10 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
|
||||
card.stack.addArrangedSubview(makeReporterMaterialsSection())
|
||||
card.stack.addArrangedSubview(makeDivider())
|
||||
}
|
||||
if !viewModel.paymentScreenshotItems.isEmpty {
|
||||
card.stack.addArrangedSubview(makePaymentScreenshotSection())
|
||||
card.stack.addArrangedSubview(makeDivider())
|
||||
}
|
||||
card.stack.addArrangedSubview(makeLocationSection())
|
||||
return card
|
||||
}
|
||||
@ -819,6 +844,43 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
|
||||
return section
|
||||
}
|
||||
|
||||
private func makePaymentScreenshotSection() -> UIView {
|
||||
let section = UIStackView()
|
||||
section.axis = .vertical
|
||||
section.spacing = 12
|
||||
section.addArrangedSubview(makeSectionHeader(
|
||||
iconName: "creditcard.fill",
|
||||
title: "支付截图",
|
||||
subtitle: viewModel.paymentScreenshotSummary,
|
||||
chevron: true,
|
||||
action: #selector(paymentPreviewTapped)
|
||||
))
|
||||
|
||||
let mediaScroll = UIScrollView()
|
||||
mediaScroll.showsHorizontalScrollIndicator = false
|
||||
let mediaStack = UIStackView()
|
||||
mediaStack.axis = .horizontal
|
||||
mediaStack.spacing = 10
|
||||
mediaScroll.addSubview(mediaStack)
|
||||
mediaStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
make.height.equalTo(mediaScroll.frameLayoutGuide)
|
||||
}
|
||||
viewModel.paymentScreenshotItems.forEach { item in
|
||||
let tile = WildReportDetailMediaThumbnailView(reporterItem: item)
|
||||
tile.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(reporterMediaTapped(_:))))
|
||||
tile.snp.makeConstraints { make in
|
||||
make.size.equalTo(108)
|
||||
}
|
||||
mediaStack.addArrangedSubview(tile)
|
||||
}
|
||||
mediaScroll.snp.makeConstraints { make in
|
||||
make.height.equalTo(108)
|
||||
}
|
||||
section.addArrangedSubview(mediaScroll)
|
||||
return section
|
||||
}
|
||||
|
||||
private func makeCompletionMediaSection(info: WildReportHandlerInfo) -> UIView {
|
||||
let section = UIStackView()
|
||||
section.axis = .vertical
|
||||
@ -996,6 +1058,7 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
|
||||
|
||||
@objc private func shareTapped() { viewModel.shareReport() }
|
||||
@objc private func previewTapped() { viewModel.showMediaPreview(kind: "图片/视频") }
|
||||
@objc private func paymentPreviewTapped() { viewModel.showMediaPreview(kind: "支付截图") }
|
||||
@objc private func locationTapped() { viewModel.showLocationPreview() }
|
||||
|
||||
@objc private func reporterMediaTapped(_ recognizer: UITapGestureRecognizer) {
|
||||
@ -1010,7 +1073,17 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
|
||||
|
||||
@objc private func supplementTapped() {
|
||||
navigationController?.pushViewController(
|
||||
WildReportSupplementEvidenceViewController(reportID: viewModel.record.id, homeViewModel: homeViewModel),
|
||||
WildReportSupplementEvidenceViewController(
|
||||
record: viewModel.record,
|
||||
homeViewModel: homeViewModel,
|
||||
api: api,
|
||||
onSupplementSuccess: { [weak self] in
|
||||
guard let self else { return }
|
||||
Task {
|
||||
await self.viewModel.loadDetailIfNeeded(api: self.api, force: true)
|
||||
}
|
||||
}
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
@ -1107,24 +1180,25 @@ private final class WildReportDetailInfoRowView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 举报详情页材料缩略图,用渐变与系统图标模拟图片、视频和处理凭证。
|
||||
/// 举报详情页材料缩略图,展示远程图片、视频和处理凭证。
|
||||
private final class WildReportDetailMediaThumbnailView: UIView {
|
||||
let previewKindText: String
|
||||
private let gradient = CAGradientLayer()
|
||||
private let remoteImageView = UIImageView()
|
||||
private let iconView = UIImageView()
|
||||
private let footerLabel = PaddingLabel(insets: UIEdgeInsets(top: 3, left: 8, bottom: 3, right: 8))
|
||||
private let playView = UIImageView(image: UIImage(systemName: "play.circle.fill"))
|
||||
|
||||
init(reporterItem: WildReportReporterMediaItem) {
|
||||
switch reporterItem {
|
||||
case .image(let index):
|
||||
previewKindText = "第\(index + 1)张举报图片"
|
||||
case .image(let index, let url, let fileName, let fileTypeText):
|
||||
previewKindText = fileName?.isEmpty == false ? fileName! : "第\(index + 1)张举报图片"
|
||||
super.init(frame: .zero)
|
||||
configure(kind: .image(index: index), footerText: "图片 \(index + 1)")
|
||||
case .video(let index):
|
||||
previewKindText = "第\(index + 1)段举报视频"
|
||||
configure(kind: .image(index: index), footerText: fileTypeText ?? "图片 \(index + 1)", imageURL: url)
|
||||
case .video(let index, _, let fileName, let fileTypeText):
|
||||
previewKindText = fileName?.isEmpty == false ? fileName! : "第\(index + 1)段举报视频"
|
||||
super.init(frame: .zero)
|
||||
configure(kind: .video(index: index, duration: "00:32"), footerText: "视频 \(index + 1)")
|
||||
configure(kind: .video(index: index, duration: fileTypeText ?? "视频"), footerText: fileTypeText ?? "视频 \(index + 1)")
|
||||
}
|
||||
}
|
||||
|
||||
@ -1151,7 +1225,7 @@ private final class WildReportDetailMediaThumbnailView: UIView {
|
||||
gradient.frame = bounds
|
||||
}
|
||||
|
||||
private func configure(kind: Kind, footerText: String) {
|
||||
private func configure(kind: Kind, footerText: String, imageURL: String? = nil) {
|
||||
clipsToBounds = true
|
||||
layer.cornerRadius = 14
|
||||
gradient.colors = kind.gradientColors
|
||||
@ -1159,9 +1233,20 @@ private final class WildReportDetailMediaThumbnailView: UIView {
|
||||
gradient.endPoint = CGPoint(x: 1, y: 1)
|
||||
layer.insertSublayer(gradient, at: 0)
|
||||
|
||||
if let imageURL, let url = URL(string: imageURL) {
|
||||
remoteImageView.contentMode = .scaleAspectFill
|
||||
remoteImageView.clipsToBounds = true
|
||||
remoteImageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo"))
|
||||
addSubview(remoteImageView)
|
||||
remoteImageView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
iconView.image = UIImage(systemName: kind.iconName)
|
||||
iconView.tintColor = UIColor.white.withAlphaComponent(0.92)
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
iconView.isHidden = imageURL != nil
|
||||
addSubview(iconView)
|
||||
|
||||
footerLabel.text = footerText
|
||||
|
||||
@ -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
|
||||
|
||||
@ -3,19 +3,40 @@
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import PhotosUI
|
||||
import QuickLook
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 补充证据页面,允许为已有举报追加说明和 Mock 附件。
|
||||
/// 补充证据页面,允许为已有举报追加说明、图片和视频证据。
|
||||
final class WildReportSupplementEvidenceViewController: BaseViewController {
|
||||
private let viewModel: WildReportSupplementEvidenceViewModel
|
||||
private let scrollView = UIScrollView()
|
||||
private let stack = UIStackView()
|
||||
private let textView = UITextView()
|
||||
private let submitButton = AppButton(title: "提交补充证据")
|
||||
private let api: any WildPhotographerReportServing
|
||||
private let uploader: any WildReportAttachmentUploading
|
||||
private let onSupplementSuccess: (() -> Void)?
|
||||
|
||||
init(reportID: String, homeViewModel: WildPhotographerReportHomeViewModel) {
|
||||
self.viewModel = WildReportSupplementEvidenceViewModel(reportID: reportID, homeViewModel: homeViewModel)
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentStack = UIStackView()
|
||||
private let textView = UITextView()
|
||||
private let placeholderLabel = UILabel()
|
||||
private let countLabel = UILabel()
|
||||
private let submitButton = UIButton(type: .system)
|
||||
|
||||
private var pickerTarget: WildReportPickerTarget = .image
|
||||
private var previewURL: URL?
|
||||
private weak var activeInputView: UIView?
|
||||
|
||||
init(
|
||||
record: WildReportRecord,
|
||||
homeViewModel: WildPhotographerReportHomeViewModel,
|
||||
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI,
|
||||
uploader: any WildReportAttachmentUploading = NetworkServices.shared.ossUploadService,
|
||||
onSupplementSuccess: (() -> Void)? = nil
|
||||
) {
|
||||
self.viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel)
|
||||
self.api = api
|
||||
self.uploader = uploader
|
||||
self.onSupplementSuccess = onSupplementSuccess
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@ -24,105 +45,421 @@ final class WildReportSupplementEvidenceViewController: BaseViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "补充证据"
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = AppColor.pageBackgroundSoft
|
||||
view.backgroundColor = UIColor(hex: 0xF8FAFF)
|
||||
scrollView.keyboardDismissMode = .interactive
|
||||
stack.axis = .vertical
|
||||
stack.spacing = AppSpacing.sm
|
||||
textView.font = .app(.body)
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 14
|
||||
|
||||
textView.font = .systemFont(ofSize: 15)
|
||||
textView.textColor = AppColor.textPrimary
|
||||
textView.backgroundColor = AppColor.inputBackground
|
||||
textView.layer.cornerRadius = AppRadius.md
|
||||
textView.textContainerInset = UIEdgeInsets(top: 12, left: 10, bottom: 12, right: 10)
|
||||
textView.backgroundColor = .clear
|
||||
textView.textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 34, right: 8)
|
||||
textView.delegate = self
|
||||
|
||||
placeholderLabel.text = "请补充新的线索说明..."
|
||||
placeholderLabel.font = .systemFont(ofSize: 15)
|
||||
placeholderLabel.textColor = UIColor(hex: 0x9CA3AF)
|
||||
|
||||
countLabel.font = .systemFont(ofSize: 14)
|
||||
countLabel.textColor = UIColor(hex: 0x6B7280)
|
||||
countLabel.textAlignment = .right
|
||||
|
||||
submitButton.setTitle("提交补充证据", for: .normal)
|
||||
submitButton.titleLabel?.font = .systemFont(ofSize: 18, weight: .bold)
|
||||
submitButton.setTitleColor(.white, for: .normal)
|
||||
submitButton.backgroundColor = AppColor.primary
|
||||
submitButton.layer.cornerRadius = 14
|
||||
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
|
||||
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(stack)
|
||||
scrollView.addSubview(contentStack)
|
||||
view.addSubview(submitButton)
|
||||
|
||||
let dismissTap = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
|
||||
dismissTap.cancelsTouchesInView = false
|
||||
dismissTap.delegate = self
|
||||
scrollView.addGestureRecognizer(dismissTap)
|
||||
registerKeyboardNotifications()
|
||||
rebuildContent()
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
submitButton.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppSpacing.sm)
|
||||
make.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(18)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(12)
|
||||
make.height.equalTo(52)
|
||||
}
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||
make.bottom.equalTo(submitButton.snp.top).offset(-AppSpacing.sm)
|
||||
make.bottom.equalTo(submitButton.snp.top).offset(-12)
|
||||
}
|
||||
stack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: AppSpacing.sm, left: AppSpacing.md, bottom: AppSpacing.lg, right: AppSpacing.md))
|
||||
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.md * 2)
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.edges.equalTo(scrollView.contentLayoutGuide).inset(UIEdgeInsets(top: 16, left: 18, bottom: 28, right: 18))
|
||||
make.width.equalTo(scrollView.frameLayoutGuide).offset(-36)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.rebuildContent() }
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
if self.textView.isFirstResponder {
|
||||
self.updateTextInputState()
|
||||
self.updateSubmitButton()
|
||||
} else {
|
||||
self.rebuildContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
viewModel.onSubmitSuccess = { [weak self] in
|
||||
Task { @MainActor in
|
||||
self?.showToast("补充证据已提交")
|
||||
self?.navigationController?.popViewController(animated: true)
|
||||
guard let self else { return }
|
||||
self.onSupplementSuccess?()
|
||||
self.showToast("补充证据已提交")
|
||||
self.navigationController?.popViewController(animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func rebuildContent() {
|
||||
stack.arrangedSubviews.forEach { view in
|
||||
stack.removeArrangedSubview(view)
|
||||
contentStack.arrangedSubviews.forEach { view in
|
||||
contentStack.removeArrangedSubview(view)
|
||||
view.removeFromSuperview()
|
||||
}
|
||||
let textCard = WildReportCardView(spacing: AppSpacing.sm)
|
||||
textCard.stack.addArrangedSubview(WildReportUI.label("补充说明", font: .app(.title), color: AppColor.textPrimary))
|
||||
textView.text = viewModel.text
|
||||
textCard.stack.addArrangedSubview(textView)
|
||||
textView.snp.makeConstraints { make in make.height.equalTo(150) }
|
||||
let count = WildReportUI.label("\(viewModel.text.count)/500", font: .app(.caption), color: AppColor.textTertiary)
|
||||
count.textAlignment = .right
|
||||
textCard.stack.addArrangedSubview(count)
|
||||
stack.addArrangedSubview(textCard)
|
||||
|
||||
let attachmentCard = WildReportCardView(spacing: AppSpacing.sm)
|
||||
attachmentCard.stack.addArrangedSubview(WildReportUI.label("补充材料", font: .app(.title), color: AppColor.textPrimary))
|
||||
let row = UIStackView()
|
||||
row.axis = .horizontal
|
||||
row.spacing = AppSpacing.sm
|
||||
row.distribution = .fillEqually
|
||||
let imageButton = WildReportUI.iconButton(title: "添加图片", imageName: "photo", style: .secondary)
|
||||
imageButton.addTarget(self, action: #selector(addImageTapped), for: .touchUpInside)
|
||||
let videoButton = WildReportUI.iconButton(title: "添加视频", imageName: "video", style: .secondary)
|
||||
videoButton.addTarget(self, action: #selector(addVideoTapped), for: .touchUpInside)
|
||||
[imageButton, videoButton].forEach { button in
|
||||
button.snp.makeConstraints { make in make.height.equalTo(44) }
|
||||
row.addArrangedSubview(button)
|
||||
}
|
||||
attachmentCard.stack.addArrangedSubview(row)
|
||||
(viewModel.images + viewModel.videos).forEach { attachment in
|
||||
let chip = WildReportAttachmentChipView(attachment: attachment)
|
||||
chip.onRemove = { [weak self] in self?.viewModel.removeAttachment(attachment) }
|
||||
attachmentCard.stack.addArrangedSubview(chip)
|
||||
}
|
||||
stack.addArrangedSubview(attachmentCard)
|
||||
contentStack.addArrangedSubview(makeTextCard())
|
||||
contentStack.addArrangedSubview(makeUploadCard(
|
||||
icon: "photo.fill",
|
||||
title: "上传图片",
|
||||
subtitle: "支持 JPG、PNG 格式,最多 9 张",
|
||||
attachments: viewModel.images,
|
||||
maxCount: 9,
|
||||
kind: .image
|
||||
))
|
||||
contentStack.addArrangedSubview(makeUploadCard(
|
||||
icon: "video.fill",
|
||||
title: "上传视频",
|
||||
subtitle: "支持 MP4 格式,最多 3 段",
|
||||
attachments: viewModel.videos,
|
||||
maxCount: 3,
|
||||
kind: .video
|
||||
))
|
||||
updateSubmitButton()
|
||||
}
|
||||
|
||||
@objc private func addImageTapped() { viewModel.addImage() }
|
||||
@objc private func addVideoTapped() { viewModel.addVideo() }
|
||||
@objc private func submitTapped() { viewModel.submit() }
|
||||
private func makeTextCard() -> UIView {
|
||||
let card = cardView(spacing: 10)
|
||||
card.stack.addArrangedSubview(WildReportUI.label("补充说明", font: .systemFont(ofSize: 17, weight: .bold), color: AppColor.textPrimary))
|
||||
textView.text = viewModel.text
|
||||
|
||||
let inputContainer = UIView()
|
||||
inputContainer.backgroundColor = UIColor(hex: 0xFBFCFE)
|
||||
inputContainer.layer.cornerRadius = 12
|
||||
inputContainer.layer.borderWidth = 1
|
||||
inputContainer.layer.borderColor = UIColor(hex: 0xCDD5E1).cgColor
|
||||
inputContainer.addSubview(textView)
|
||||
inputContainer.addSubview(placeholderLabel)
|
||||
inputContainer.addSubview(countLabel)
|
||||
|
||||
textView.snp.remakeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
make.height.greaterThanOrEqualTo(132)
|
||||
}
|
||||
placeholderLabel.snp.remakeConstraints { make in
|
||||
make.top.equalToSuperview().offset(18)
|
||||
make.leading.equalToSuperview().offset(14)
|
||||
make.trailing.lessThanOrEqualToSuperview().inset(14)
|
||||
}
|
||||
countLabel.snp.remakeConstraints { make in
|
||||
make.trailing.bottom.equalToSuperview().inset(12)
|
||||
}
|
||||
card.stack.addArrangedSubview(inputContainer)
|
||||
updateTextInputState()
|
||||
return card
|
||||
}
|
||||
|
||||
private func makeUploadCard(
|
||||
icon: String,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
attachments: [WildReportAttachment],
|
||||
maxCount: Int,
|
||||
kind: WildReportAttachmentKind
|
||||
) -> UIView {
|
||||
let card = cardView(spacing: 12)
|
||||
let header = UIStackView()
|
||||
header.axis = .horizontal
|
||||
header.alignment = .center
|
||||
header.spacing = 10
|
||||
|
||||
let iconView = UIImageView(image: UIImage(systemName: icon))
|
||||
iconView.tintColor = AppColor.primary
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.size.equalTo(26)
|
||||
}
|
||||
|
||||
let textStack = UIStackView()
|
||||
textStack.axis = .vertical
|
||||
textStack.spacing = 4
|
||||
textStack.addArrangedSubview(WildReportUI.label(title, font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary))
|
||||
textStack.addArrangedSubview(WildReportUI.label(subtitle, font: .systemFont(ofSize: 12), color: AppColor.textSecondary, lines: 0))
|
||||
|
||||
header.addArrangedSubview(iconView)
|
||||
header.addArrangedSubview(textStack)
|
||||
header.addArrangedSubview(UIView())
|
||||
card.stack.addArrangedSubview(header)
|
||||
|
||||
let scroll = UIScrollView()
|
||||
scroll.showsHorizontalScrollIndicator = false
|
||||
let tiles = UIStackView()
|
||||
tiles.axis = .horizontal
|
||||
tiles.spacing = 10
|
||||
scroll.addSubview(tiles)
|
||||
tiles.snp.makeConstraints { make in
|
||||
make.edges.equalTo(scroll.contentLayoutGuide).inset(UIEdgeInsets(top: 2, left: 0, bottom: 2, right: 0))
|
||||
make.height.equalTo(scroll.frameLayoutGuide).offset(-4)
|
||||
}
|
||||
|
||||
attachments.forEach { attachment in
|
||||
tiles.addArrangedSubview(WildReportAttachmentTileView(
|
||||
attachment: attachment,
|
||||
tileSize: 72,
|
||||
onPreview: { [weak self] in self?.previewAttachment(attachment) },
|
||||
onRemove: { [weak self] in self?.viewModel.removeAttachment(attachment) }
|
||||
))
|
||||
}
|
||||
|
||||
if canAddAttachment(kind: kind, currentCount: attachments.count, maxCount: maxCount) {
|
||||
tiles.addArrangedSubview(WildReportAddAttachmentTileView(
|
||||
tileSize: 72,
|
||||
borderColor: AppColor.primary,
|
||||
iconColor: AppColor.primary
|
||||
) { [weak self] in
|
||||
self?.presentMediaPicker(for: WildReportPickerTarget(kind: kind))
|
||||
})
|
||||
}
|
||||
|
||||
scroll.snp.makeConstraints { make in
|
||||
make.height.equalTo(76)
|
||||
}
|
||||
card.stack.addArrangedSubview(scroll)
|
||||
return card
|
||||
}
|
||||
|
||||
private func cardView(spacing: CGFloat) -> WildReportCardView {
|
||||
let card = WildReportCardView(spacing: spacing, inset: 16)
|
||||
card.layer.cornerRadius = 16
|
||||
card.layer.borderColor = AppColor.cardOutline.cgColor
|
||||
return card
|
||||
}
|
||||
|
||||
private func updateTextInputState() {
|
||||
placeholderLabel.isHidden = !viewModel.text.isEmpty
|
||||
countLabel.text = "\(viewModel.text.count)/500"
|
||||
}
|
||||
|
||||
private func updateSubmitButton() {
|
||||
let title = viewModel.isSubmitting && !viewModel.submitProgressText.isEmpty
|
||||
? viewModel.submitProgressText
|
||||
: "提交补充证据"
|
||||
submitButton.setTitle(title, for: .normal)
|
||||
submitButton.isEnabled = !viewModel.isSubmitting
|
||||
submitButton.alpha = viewModel.isSubmitting ? 0.78 : 1
|
||||
}
|
||||
|
||||
private func canAddAttachment(kind: WildReportAttachmentKind, currentCount: Int, maxCount: Int) -> Bool {
|
||||
let ownRemaining = max(0, maxCount - currentCount)
|
||||
let evidenceRemaining = max(0, 9 - viewModel.images.count - viewModel.videos.count)
|
||||
switch kind {
|
||||
case .image:
|
||||
return min(ownRemaining, evidenceRemaining) > 0
|
||||
case .video:
|
||||
return min(ownRemaining, evidenceRemaining) > 0
|
||||
case .payment:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func presentMediaPicker(for target: WildReportPickerTarget) {
|
||||
dismissKeyboard()
|
||||
let remaining = remainingSelectionCount(for: target)
|
||||
guard remaining > 0 else {
|
||||
showToast(target == .video ? "补充视频最多上传3段" : "补充证据最多上传9项")
|
||||
return
|
||||
}
|
||||
pickerTarget = target
|
||||
var configuration = PHPickerConfiguration(photoLibrary: .shared())
|
||||
configuration.selectionLimit = remaining
|
||||
configuration.filter = target == .video ? .videos : .images
|
||||
let picker = PHPickerViewController(configuration: configuration)
|
||||
picker.delegate = self
|
||||
present(picker, animated: true)
|
||||
}
|
||||
|
||||
private func remainingSelectionCount(for target: WildReportPickerTarget) -> Int {
|
||||
switch target {
|
||||
case .image:
|
||||
return max(0, 9 - viewModel.images.count - viewModel.videos.count)
|
||||
case .video:
|
||||
return max(0, min(3 - viewModel.videos.count, 9 - viewModel.images.count - viewModel.videos.count))
|
||||
case .payment:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private func previewAttachment(_ attachment: WildReportAttachment) {
|
||||
guard let url = attachment.localFileURL else {
|
||||
showToast("当前附件暂无本地预览")
|
||||
return
|
||||
}
|
||||
previewURL = url
|
||||
let preview = QLPreviewController()
|
||||
preview.dataSource = self
|
||||
present(preview, animated: true)
|
||||
}
|
||||
|
||||
@objc private func submitTapped() {
|
||||
Task {
|
||||
await viewModel.submit(
|
||||
api: api,
|
||||
uploader: uploader,
|
||||
scenicId: AppStore.shared.currentScenicId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func dismissKeyboard() {
|
||||
view.endEditing(true)
|
||||
}
|
||||
|
||||
private func registerKeyboardNotifications() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(keyboardWillChangeFrame(_:)),
|
||||
name: UIResponder.keyboardWillChangeFrameNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(keyboardWillHide(_:)),
|
||||
name: UIResponder.keyboardWillHideNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func keyboardWillChangeFrame(_ notification: Notification) {
|
||||
guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
|
||||
let keyboardFrameInView = view.convert(keyboardFrame, from: nil)
|
||||
let overlap = max(0, view.bounds.maxY - keyboardFrameInView.minY)
|
||||
updateScrollInsets(bottom: overlap + 12, notification: notification)
|
||||
scrollActiveInputIntoView(animated: true)
|
||||
}
|
||||
|
||||
@objc private func keyboardWillHide(_ notification: Notification) {
|
||||
updateScrollInsets(bottom: 0, notification: notification)
|
||||
}
|
||||
|
||||
private func updateScrollInsets(bottom: CGFloat, notification: Notification) {
|
||||
let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0.25
|
||||
let curveRaw = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt ?? UIView.AnimationOptions.curveEaseInOut.rawValue
|
||||
let options = UIView.AnimationOptions(rawValue: curveRaw << 16)
|
||||
UIView.animate(withDuration: duration, delay: 0, options: options) {
|
||||
self.scrollView.contentInset.bottom = bottom
|
||||
self.scrollView.verticalScrollIndicatorInsets.bottom = bottom
|
||||
}
|
||||
}
|
||||
|
||||
private func scrollActiveInputIntoView(animated: Bool) {
|
||||
guard let activeInputView else { return }
|
||||
view.layoutIfNeeded()
|
||||
let rect = activeInputView.convert(activeInputView.bounds, to: scrollView).insetBy(dx: 0, dy: -24)
|
||||
scrollView.scrollRectToVisible(rect, animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
extension WildReportSupplementEvidenceViewController: PHPickerViewControllerDelegate {
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
picker.dismiss(animated: true)
|
||||
guard !results.isEmpty else { return }
|
||||
let target = pickerTarget
|
||||
Task {
|
||||
do {
|
||||
let attachments = try await WildReportLocalMediaLoader.load(
|
||||
results: results,
|
||||
target: target,
|
||||
titlePrefix: target == .video ? "补充视频" : "补充图片"
|
||||
)
|
||||
await MainActor.run {
|
||||
switch target {
|
||||
case .image:
|
||||
viewModel.addImages(attachments)
|
||||
case .video:
|
||||
viewModel.addVideos(attachments)
|
||||
case .payment:
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run { showToast(error.localizedDescription) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WildReportSupplementEvidenceViewController: QLPreviewControllerDataSource {
|
||||
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
|
||||
previewURL == nil ? 0 : 1
|
||||
}
|
||||
|
||||
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
|
||||
previewURL! as NSURL
|
||||
}
|
||||
}
|
||||
|
||||
extension WildReportSupplementEvidenceViewController: UITextViewDelegate {
|
||||
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||
activeInputView = textView
|
||||
DispatchQueue.main.async { [weak self] in self?.scrollActiveInputIntoView(animated: true) }
|
||||
}
|
||||
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
viewModel.updateText(textView.text)
|
||||
if textView.text != viewModel.text {
|
||||
textView.text = viewModel.text
|
||||
}
|
||||
updateTextInputState()
|
||||
}
|
||||
|
||||
func textViewDidEndEditing(_ textView: UITextView) {
|
||||
if activeInputView === textView {
|
||||
activeInputView = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WildReportSupplementEvidenceViewController: UIGestureRecognizerDelegate {
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||
var touchedView = touch.view
|
||||
while let currentView = touchedView {
|
||||
if currentView is UIControl || currentView is UITextView {
|
||||
return false
|
||||
}
|
||||
touchedView = currentView.superview
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user