重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
339 lines
13 KiB
Swift
339 lines
13 KiB
Swift
//
|
|
// ScenicApplicationViewController.swift
|
|
// suixinkan
|
|
//
|
|
|
|
import PhotosUI
|
|
import SnapKit
|
|
import UIKit
|
|
|
|
/// 景区申请页,对齐 Android `ScenicApplicationScreen`。
|
|
final class ScenicApplicationViewController: BaseViewController {
|
|
|
|
private let viewModel: ScenicApplicationViewModel
|
|
private let homeAPI: HomeAPI
|
|
private let profileAPI: ProfileAPI
|
|
private let ossUploadService: OSSUploadService
|
|
|
|
private let scrollView = UIScrollView()
|
|
private let outerStack = UIStackView()
|
|
private let paddedStack = UIStackView()
|
|
private let auditCard = ScenicApplicationAuditStatusView()
|
|
private let warmReminder = ScenicApplicationWarmReminderView()
|
|
private let formCard = UIView()
|
|
private let formStack = UIStackView()
|
|
|
|
private let scenicNameField = ScenicApplicationFieldStyle.makeTextField(placeholder: "请输入景区名称")
|
|
private let scenicNameSection = ScenicApplicationFormFieldView(title: "景区名称")
|
|
private let imageSection = ScenicApplicationFormFieldView(title: "景区图片 (已添加 0/20)")
|
|
private let imageUploadView = ScenicApplicationImageUploadView()
|
|
private let provinceSelector = ScenicApplicationLocationSelector()
|
|
private let citySelector = ScenicApplicationLocationSelector()
|
|
private let cooperationStack = UIStackView()
|
|
private var cooperationOptions: [ScenicApplicationCooperationOptionView] = []
|
|
private let remarksView = ScenicApplicationPlaceholderTextView(placeholder: "请输入备注信息")
|
|
private let remarksSection = ScenicApplicationFormFieldView(title: "备注信息 (0/50)")
|
|
|
|
private let bottomBar = UIView()
|
|
private let agreementView = ScenicApplicationAgreementView()
|
|
private let submitButton = ScenicApplicationSubmitButton()
|
|
private let uploadProgressView = ScenicApplicationUploadProgressView()
|
|
|
|
init(
|
|
viewModel: ScenicApplicationViewModel = ScenicApplicationViewModel(),
|
|
homeAPI: HomeAPI? = nil,
|
|
profileAPI: ProfileAPI? = nil,
|
|
ossUploadService: OSSUploadService? = nil
|
|
) {
|
|
self.viewModel = viewModel
|
|
self.homeAPI = homeAPI ?? NetworkServices.shared.homeAPI
|
|
self.profileAPI = profileAPI ?? NetworkServices.shared.profileAPI
|
|
self.ossUploadService = ossUploadService ?? NetworkServices.shared.ossUploadService
|
|
super.init(nibName: nil, bundle: nil)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func setupNavigationBar() {
|
|
title = "景区申请"
|
|
}
|
|
|
|
override func setupUI() {
|
|
outerStack.axis = .vertical
|
|
outerStack.spacing = 0
|
|
|
|
paddedStack.axis = .vertical
|
|
paddedStack.spacing = 12
|
|
|
|
formCard.backgroundColor = .white
|
|
formCard.layer.cornerRadius = 8
|
|
formStack.axis = .vertical
|
|
formStack.spacing = 20
|
|
formCard.addSubview(formStack)
|
|
formStack.snp.makeConstraints { make in
|
|
make.edges.equalToSuperview().inset(16)
|
|
}
|
|
|
|
scenicNameSection.setContent(scenicNameField)
|
|
imageSection.setContent(imageUploadView)
|
|
|
|
let locationRow = UIStackView(arrangedSubviews: [provinceSelector, citySelector])
|
|
locationRow.axis = .horizontal
|
|
locationRow.spacing = 12
|
|
locationRow.distribution = .fillEqually
|
|
let locationSection = ScenicApplicationFormFieldView(title: "景区位置")
|
|
locationSection.setContent(locationRow)
|
|
|
|
cooperationStack.axis = .vertical
|
|
cooperationStack.spacing = 8
|
|
ScenicCooperationType.allCases.forEach { type in
|
|
let option = ScenicApplicationCooperationOptionView(title: type.displayName)
|
|
option.tag = type.rawValue
|
|
option.addTarget(self, action: #selector(cooperationTypeTapped(_:)), for: .touchUpInside)
|
|
cooperationOptions.append(option)
|
|
cooperationStack.addArrangedSubview(option)
|
|
}
|
|
let cooperationSection = ScenicApplicationFormFieldView(title: "合作类型")
|
|
cooperationSection.setContent(cooperationStack)
|
|
|
|
remarksView.text = ""
|
|
remarksSection.setContent(remarksView)
|
|
|
|
formStack.addArrangedSubview(scenicNameSection)
|
|
formStack.addArrangedSubview(imageSection)
|
|
formStack.addArrangedSubview(locationSection)
|
|
formStack.addArrangedSubview(cooperationSection)
|
|
formStack.addArrangedSubview(remarksSection)
|
|
|
|
paddedStack.addArrangedSubview(warmReminder)
|
|
paddedStack.addArrangedSubview(formCard)
|
|
|
|
outerStack.addArrangedSubview(auditCard)
|
|
outerStack.addArrangedSubview(paddedStack)
|
|
auditCard.isHidden = true
|
|
|
|
scrollView.addSubview(outerStack)
|
|
view.addSubview(scrollView)
|
|
view.addSubview(bottomBar)
|
|
view.addSubview(uploadProgressView)
|
|
|
|
bottomBar.backgroundColor = .white
|
|
let bottomStack = UIStackView(arrangedSubviews: [agreementView, submitButton])
|
|
bottomStack.axis = .vertical
|
|
bottomStack.spacing = 4
|
|
bottomBar.addSubview(bottomStack)
|
|
bottomStack.snp.makeConstraints { make in
|
|
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 16, bottom: 18, right: 16))
|
|
}
|
|
|
|
uploadProgressView.isHidden = true
|
|
uploadProgressView.snp.makeConstraints { make in
|
|
make.edges.equalToSuperview()
|
|
}
|
|
}
|
|
|
|
override func setupConstraints() {
|
|
scrollView.snp.makeConstraints { make in
|
|
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
|
make.bottom.equalTo(bottomBar.snp.top)
|
|
}
|
|
outerStack.snp.makeConstraints { make in
|
|
make.edges.equalToSuperview()
|
|
make.width.equalTo(scrollView)
|
|
}
|
|
paddedStack.snp.makeConstraints { make in
|
|
make.leading.trailing.equalToSuperview().inset(16)
|
|
}
|
|
paddedStack.layoutMargins = UIEdgeInsets(top: 12, left: 0, bottom: 16, right: 0)
|
|
paddedStack.isLayoutMarginsRelativeArrangement = true
|
|
bottomBar.snp.makeConstraints { make in
|
|
make.leading.trailing.equalToSuperview()
|
|
make.bottom.equalTo(view.safeAreaLayoutGuide)
|
|
}
|
|
}
|
|
|
|
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) }
|
|
}
|
|
viewModel.onSubmitSuccess = { [weak self] in
|
|
Task { @MainActor in self?.navigationController?.popViewController(animated: true) }
|
|
}
|
|
|
|
scenicNameField.addTarget(self, action: #selector(scenicNameChanged), for: .editingChanged)
|
|
remarksView.onTextChange = { [weak self] text in
|
|
self?.viewModel.onRemarksChange(text)
|
|
}
|
|
provinceSelector.addTarget(self, action: #selector(provinceTapped), for: .touchUpInside)
|
|
citySelector.addTarget(self, action: #selector(cityTapped), for: .touchUpInside)
|
|
agreementView.onToggle = { [weak self] in self?.viewModel.toggleAgreement() }
|
|
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
|
|
|
|
imageUploadView.onAdd = { [weak self] in self?.pickImages() }
|
|
imageUploadView.onDelete = { [weak self] index in self?.viewModel.deleteImage(at: index) }
|
|
imageUploadView.onRetry = { [weak self] index in
|
|
guard let self else { return }
|
|
self.viewModel.retryUpload(at: index)
|
|
Task { await self.startUploadIfNeeded() }
|
|
}
|
|
|
|
Task { await loadInitial() }
|
|
applyViewModel()
|
|
}
|
|
|
|
private func loadInitial() async {
|
|
showLoading()
|
|
defer { hideLoading() }
|
|
await viewModel.loadInitial(profileAPI: profileAPI, homeAPI: homeAPI)
|
|
}
|
|
|
|
private func applyViewModel() {
|
|
if viewModel.shouldShowAuditCard, let pending = viewModel.pendingApplication {
|
|
auditCard.apply(pending: pending)
|
|
auditCard.isHidden = false
|
|
} else {
|
|
auditCard.isHidden = true
|
|
}
|
|
|
|
let editable = viewModel.isEditable
|
|
let readOnly = viewModel.isReadOnly
|
|
|
|
scenicNameField.text = viewModel.scenicName
|
|
scenicNameField.isEnabled = editable
|
|
scenicNameField.isUserInteractionEnabled = editable && !readOnly
|
|
|
|
imageSection.updateTitle("景区图片 (已添加 \(viewModel.selectedImages.count)/20)")
|
|
imageUploadView.apply(images: viewModel.selectedImages, isReadOnly: readOnly)
|
|
|
|
provinceSelector.setValue(viewModel.selectedProvince, placeholder: "省份")
|
|
citySelector.setValue(viewModel.selectedCity, placeholder: "城市")
|
|
provinceSelector.isEnabled = editable
|
|
citySelector.isEnabled = editable
|
|
|
|
cooperationOptions.forEach { option in
|
|
let type = ScenicCooperationType(rawValue: option.tag) ?? .photographer
|
|
option.isSelected = type == viewModel.selectedCooperationType
|
|
option.isEnabled = editable
|
|
}
|
|
|
|
if remarksView.text != viewModel.remarks {
|
|
remarksView.text = viewModel.remarks
|
|
}
|
|
remarksView.isEditable = editable && !readOnly
|
|
remarksSection.updateTitle("备注信息 (\(viewModel.remarks.count)/50)")
|
|
|
|
agreementView.isHidden = !editable
|
|
agreementView.setChecked(viewModel.isAgreed)
|
|
|
|
submitButton.apply(
|
|
canSubmit: viewModel.canSubmit,
|
|
isSubmitting: viewModel.isSubmitting,
|
|
isReadOnly: readOnly
|
|
)
|
|
|
|
uploadProgressView.apply(state: viewModel.uploadDialogState)
|
|
}
|
|
|
|
@objc private func scenicNameChanged() {
|
|
viewModel.onScenicNameChange(scenicNameField.text ?? "")
|
|
}
|
|
|
|
@objc private func cooperationTypeTapped(_ sender: ScenicApplicationCooperationOptionView) {
|
|
guard viewModel.isEditable else { return }
|
|
guard let type = ScenicCooperationType(rawValue: sender.tag) else { return }
|
|
viewModel.onCooperationTypeSelected(type)
|
|
}
|
|
|
|
@objc private func provinceTapped() {
|
|
guard viewModel.isEditable else { return }
|
|
presentAreaPicker(title: "选择省份", names: viewModel.provinceList.map(\.name)) { [weak self] name in
|
|
self?.viewModel.onProvinceSelected(name)
|
|
}
|
|
}
|
|
|
|
@objc private func cityTapped() {
|
|
guard viewModel.isEditable else { return }
|
|
guard !viewModel.selectedProvince.isEmpty else {
|
|
showToast("请先选择省份")
|
|
return
|
|
}
|
|
presentAreaPicker(title: "选择城市", names: viewModel.cityList.map(\.name)) { [weak self] name in
|
|
self?.viewModel.onCitySelected(name)
|
|
}
|
|
}
|
|
|
|
private func presentAreaPicker(title: String, names: [String], onSelect: @escaping (String) -> Void) {
|
|
let alert = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet)
|
|
names.forEach { name in
|
|
alert.addAction(UIAlertAction(title: name, style: .default) { _ in onSelect(name) })
|
|
}
|
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
|
present(alert, animated: true)
|
|
}
|
|
|
|
@objc private func submitTapped() {
|
|
Task {
|
|
showLoading()
|
|
defer { hideLoading() }
|
|
await viewModel.submit(homeAPI: homeAPI)
|
|
}
|
|
}
|
|
|
|
private func pickImages() {
|
|
guard viewModel.isEditable, !viewModel.isReadOnly else { return }
|
|
let remaining = 20 - viewModel.selectedImages.count
|
|
guard remaining > 0 else {
|
|
showToast("最多只能选择20张图片")
|
|
return
|
|
}
|
|
|
|
var configuration = PHPickerConfiguration(photoLibrary: .shared())
|
|
configuration.filter = .images
|
|
configuration.selectionLimit = remaining
|
|
let picker = PHPickerViewController(configuration: configuration)
|
|
picker.delegate = self
|
|
present(picker, animated: true)
|
|
}
|
|
|
|
private func startUploadIfNeeded() async {
|
|
let scenicId = AppStore.shared.session.currentScenicId
|
|
await viewModel.processPendingUploads(uploader: ossUploadService, scenicId: scenicId)
|
|
}
|
|
}
|
|
|
|
extension ScenicApplicationViewController: PHPickerViewControllerDelegate {
|
|
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
|
picker.dismiss(animated: true)
|
|
guard !results.isEmpty else { return }
|
|
|
|
var loadedItems = [ScenicApplicationImageItem?](repeating: nil, count: results.count)
|
|
let group = DispatchGroup()
|
|
|
|
for (index, result) in results.enumerated() {
|
|
let provider = result.itemProvider
|
|
guard provider.canLoadObject(ofClass: UIImage.self) else { continue }
|
|
group.enter()
|
|
provider.loadObject(ofClass: UIImage.self) { object, _ in
|
|
defer { group.leave() }
|
|
guard let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { return }
|
|
let fileName = "scenic_apply_\(Int(Date().timeIntervalSince1970))_\(index).jpg"
|
|
loadedItems[index] = ScenicApplicationImageItem.fromLocal(data: data, fileName: fileName)
|
|
}
|
|
}
|
|
|
|
group.notify(queue: .main) { [weak self] in
|
|
guard let self else { return }
|
|
let items = loadedItems.compactMap { $0 }
|
|
guard !items.isEmpty else { return }
|
|
self.viewModel.addLocalImages(items)
|
|
Task { await self.startUploadIfNeeded() }
|
|
}
|
|
}
|
|
}
|