添加提交任务流程并抽取资料编辑页

This commit is contained in:
2026-07-07 10:12:20 +08:00
parent af450fb4cc
commit ef1d3b4af5
23 changed files with 3186 additions and 170 deletions

View File

@ -0,0 +1,366 @@
//
// TaskAddViewController.swift
// suixinkan
//
import PhotosUI
import SnapKit
import UIKit
import UniformTypeIdentifiers
/// Android `TaskAddScreen`
final class TaskAddViewController: BaseViewController {
private let viewModel = TaskAddViewModel()
private let taskAPI = NetworkServices.shared.taskAPI
private let ossUploadService = NetworkServices.shared.ossUploadService
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let mediaSection = TaskAddMediaSectionView()
private let prioritySection = TaskPrioritySectionView()
private let orderButton = TaskOrderSelectButton()
private let nameField = TaskBorderTextField(placeholder: "请输入任务标题")
private let detailsField = TaskBorderTextView(placeholder: "请输入任务描述(必填)")
private let saveButton = AppButton(title: "保存")
private let bottomBar = UIView()
private var remarkDialog: TaskFileRemarkDialogView?
private var pickingMediaType: Int = 2
private var detailsPlaceholder = "请输入任务描述(必填)"
override func setupNavigationBar() {
title = "添加任务"
}
override func setupUI() {
contentStack.axis = .vertical
contentStack.spacing = AppSpacing.sm
bottomBar.backgroundColor = .white
view.addSubview(scrollView)
view.addSubview(bottomBar)
scrollView.addSubview(contentStack)
contentStack.addArrangedSubview(mediaSection)
contentStack.addArrangedSubview(prioritySection)
contentStack.addArrangedSubview(orderButton)
contentStack.addArrangedSubview(nameField)
contentStack.addArrangedSubview(detailsField)
bottomBar.addSubview(saveButton)
}
override func setupConstraints() {
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
saveButton.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.md)
}
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.bottom.equalTo(bottomBar.snp.top)
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.md)
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.md * 2)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
mediaSection.onCloudImport = { [weak self] in self?.openCloudPicker() }
mediaSection.onLocalImport = { [weak self] in self?.presentLocalImportSheet() }
mediaSection.onFileTap = { [weak self] file in self?.viewModel.presentRemarkDialog(for: file.id) }
mediaSection.onDeleteFile = { [weak self] file in self?.confirmDelete(file: file) }
prioritySection.onUrgentHourChange = { [weak self] hour in
self?.viewModel.setUrgentHour(hour)
}
prioritySection.onCustomHourTextChange = { [weak self] text in
self?.viewModel.updateCustomUrgentHourText(text)
}
orderButton.addTarget(self, action: #selector(openOrderSelect), for: .touchUpInside)
nameField.textField.addTarget(self, action: #selector(nameChanged), for: .editingChanged)
detailsField.textView.delegate = self
saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if let fileID = viewModel.consumePendingRemarkFileID() {
viewModel.presentRemarkDialog(for: fileID)
}
}
@MainActor
private func applyViewModel() {
mediaSection.apply(files: viewModel.selectedFiles)
prioritySection.apply(urgentHour: viewModel.urgentHour)
orderButton.apply(orderNumber: viewModel.selectedOrderNumber)
if nameField.textField.text != viewModel.taskName {
nameField.textField.text = viewModel.taskName
}
if detailsField.textView.textColor != AppColor.textTertiary,
detailsField.textView.text != viewModel.taskDetails {
detailsField.textView.text = viewModel.taskDetails
}
saveButton.isEnabled = !viewModel.isUploading
if viewModel.showRemarkDialog, let file = viewModel.remarkTargetFile {
showRemarkDialog(file: file)
} else {
hideRemarkDialog()
}
if viewModel.showSuccessDialog {
presentSuccessDialog()
}
}
@objc private func nameChanged() {
viewModel.updateTaskName(nameField.textField.text ?? "")
}
@objc private func openOrderSelect() {
let controller = TaskOrderSelectViewController(initialOrderNumber: viewModel.selectedOrderNumber)
controller.onConfirmed = { [weak self] orderNumber in
self?.viewModel.setSelectedOrderNumber(orderNumber)
}
navigationController?.pushViewController(controller, animated: true)
}
private func openCloudPicker() {
let controller = CloudStoragePickForTaskViewController(
importedFileIDs: viewModel.importedCloudFileIDs
)
controller.onConfirmed = { [weak self] files in
self?.viewModel.addCloudFiles(files)
if let fileID = self?.viewModel.consumePendingRemarkFileID() {
self?.viewModel.presentRemarkDialog(for: fileID)
}
}
navigationController?.pushViewController(controller, animated: true)
}
private func presentLocalImportSheet() {
let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
sheet.addAction(UIAlertAction(title: "图片", style: .default) { [weak self] _ in
self?.presentMediaPicker(isImage: true)
})
sheet.addAction(UIAlertAction(title: "视频", style: .default) { [weak self] _ in
self?.presentMediaPicker(isImage: false)
})
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true)
}
private func presentMediaPicker(isImage: Bool) {
pickingMediaType = isImage ? 2 : 1
var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.filter = isImage ? .images : .videos
configuration.selectionLimit = 50
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self
present(picker, animated: true)
}
private func confirmDelete(file: TaskFileItem) {
let alert = UIAlertController(title: "删除确认", message: "确定要删除此文件吗?", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in
self?.viewModel.removeTaskFile(id: file.id)
})
present(alert, animated: true)
}
@objc private func saveTapped() {
showLoading()
Task {
await viewModel.validateAndSubmit(api: taskAPI)
hideLoading()
}
}
private func presentSuccessDialog() {
guard viewModel.showSuccessDialog else { return }
let alert = UIAlertController(
title: "添加成功",
message: "任务已成功添加。是否继续添加新任务?",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "继续添加", style: .default) { [weak self] _ in
self?.viewModel.handleContinueAdding()
})
alert.addAction(UIAlertAction(title: "返回", style: .cancel) { [weak self] _ in
self?.viewModel.dismissSuccessDialog()
self?.navigationController?.popViewController(animated: true)
})
present(alert, animated: true)
viewModel.dismissSuccessDialog()
}
private func showRemarkDialog(file: TaskFileItem) {
if remarkDialog == nil {
let dialog = TaskFileRemarkDialogView(frame: view.bounds)
dialog.onCancel = { [weak self] in
self?.viewModel.hideRemarkDialog()
}
dialog.onConfirm = { [weak self] in
guard let self, let dialog = self.remarkDialog else { return }
self.viewModel.updateRemarkDialogText(dialog.inputText)
self.viewModel.saveRemarkDialog()
}
remarkDialog = dialog
view.addSubview(dialog)
dialog.snp.makeConstraints { make in make.edges.equalToSuperview() }
}
remarkDialog?.apply(file: file, text: viewModel.remarkDialogText)
}
private func hideRemarkDialog() {
remarkDialog?.removeFromSuperview()
remarkDialog = nil
}
private func uploadPickedResults(_ results: [PHPickerResult], isImage: Bool) {
let scenicId = AppStore.shared.currentScenicId
guard scenicId > 0 else {
showToast("请先选择景区")
return
}
let totalCount = results.count
for (index, result) in results.enumerated() {
let uploadTaskId = UUID().uuidString.replacingOccurrences(of: "-", with: "")
let placeholder = viewModel.addUploadPlaceholder(
fileName: "upload_\(uploadTaskId).\(isImage ? "jpg" : "mp4")",
fileType: isImage ? 2 : 1,
uploadTaskId: uploadTaskId
)
Task {
do {
let payload = try await TaskMediaLoader.load(from: result, isImage: isImage)
let fileURL = try await ossUploadService.uploadTaskFile(
data: payload.data,
fileName: payload.fileName,
fileType: isImage ? 2 : 1,
scenicId: scenicId
) { [weak self] progress in
Task { @MainActor in
self?.viewModel.updateUploadProgress(uploadTaskId: uploadTaskId, progress: progress)
}
}
try await taskAPI.registerUploadedFileURL(scenicId: scenicId, fileURL: fileURL, folderId: 0)
await MainActor.run {
self.viewModel.markUploadSucceeded(
uploadTaskId: uploadTaskId,
fileURL: fileURL,
autoRemarkWhenSingle: totalCount == 1
)
if totalCount == 1, let fileID = self.viewModel.consumePendingRemarkFileID() {
self.viewModel.presentRemarkDialog(for: fileID)
}
}
} catch {
await MainActor.run {
self.viewModel.markUploadFailed(uploadTaskId: uploadTaskId)
self.showToast(error.localizedDescription)
}
}
_ = placeholder
_ = index
}
}
}
}
extension TaskAddViewController: PHPickerViewControllerDelegate {
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
guard !results.isEmpty else { return }
uploadPickedResults(results, isImage: pickingMediaType == 2)
}
}
extension TaskAddViewController: UITextViewDelegate {
func textViewDidBeginEditing(_ textView: UITextView) {
if textView.textColor == AppColor.textTertiary {
textView.text = ""
textView.textColor = AppColor.textPrimary
}
}
func textViewDidEndEditing(_ textView: UITextView) {
if textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
textView.text = detailsPlaceholder
textView.textColor = AppColor.textTertiary
viewModel.updateTaskDetails("")
}
}
func textViewDidChange(_ textView: UITextView) {
guard textView.textColor != AppColor.textTertiary else { return }
viewModel.updateTaskDetails(textView.text)
}
}
/// PHPicker
enum TaskMediaLoader {
struct Payload {
let data: Data
let fileName: String
}
static func load(from result: PHPickerResult, isImage: Bool) async throws -> Payload {
let provider = result.itemProvider
if isImage {
return try await loadImage(from: provider)
}
return try await loadVideo(from: provider)
}
private static func loadImage(from provider: NSItemProvider) async throws -> Payload {
try await withCheckedThrowingContinuation { continuation in
if provider.canLoadObject(ofClass: UIImage.self) {
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
}
continuation.resume(returning: Payload(data: data, fileName: "image_\(UUID().uuidString).jpg"))
}
return
}
continuation.resume(throwing: OSSUploadError.unsupportedFileType)
}
}
private static func loadVideo(from provider: NSItemProvider) async throws -> Payload {
let typeIdentifier = UTType.movie.identifier
return try await withCheckedThrowingContinuation { continuation in
provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in
if let error {
continuation.resume(throwing: error)
return
}
guard let url, let data = try? Data(contentsOf: url) else {
continuation.resume(throwing: OSSUploadError.emptyFile)
return
}
let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension
continuation.resume(returning: Payload(data: data, fileName: "video_\(UUID().uuidString).\(ext)"))
}
}
}
}