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,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
}
}