Add submit-task flow aligned with Android and extract profile edit page.

Implement task creation with cloud/local media, order linking, and OSS upload; wire home entry and move profile nickname/avatar editing to ProfileEditViewController.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-07 10:12:20 +08:00
parent 715393e78c
commit 3c4ca7eefb
23 changed files with 3186 additions and 170 deletions

View File

@ -185,8 +185,8 @@ final class HomeViewController: BaseViewController {
)
}
cell.cardView.onSubmitTask = { [weak self] in
self?.showToast("功能开发中")
}
self?.navigationController?.pushViewController(TaskAddViewController(), animated: true)
}
cell.cardView.onToggleOnline = { [weak self] in
self?.viewModel.showOnlineStatusSwitchDialog()
}

View File

@ -0,0 +1,195 @@
//
// ProfileEditViewController.swift
// suixinkan
//
import PhotosUI
import SnapKit
import UIKit
///
final class ProfileEditViewController: BaseViewController {
private let viewModel: ProfileEditViewModel
private let profileAPI = NetworkServices.shared.profileAPI
private let ossUploadService = NetworkServices.shared.ossUploadService
private let avatarContainer = UIView()
private let avatarImageView = UIImageView()
private let avatarHintLabel = UILabel()
private let nicknameCard = UIView()
private let nicknameTitleLabel = UILabel()
private let nicknameField = UITextField()
private let saveButton = AppButton(title: "保存", style: .primary)
/// 使
init(nickname: String, avatarURL: String) {
viewModel = ProfileEditViewModel(nickname: nickname, avatarURL: avatarURL)
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() {
view.backgroundColor = AppColor.pageBackgroundSoft
avatarImageView.layer.cornerRadius = 48
avatarImageView.clipsToBounds = true
avatarImageView.contentMode = .scaleAspectFill
avatarImageView.isUserInteractionEnabled = true
avatarHintLabel.text = "点击更换头像"
avatarHintLabel.font = .systemFont(ofSize: 14)
avatarHintLabel.textColor = AppColor.textTertiary
avatarHintLabel.textAlignment = .center
avatarContainer.addSubview(avatarImageView)
avatarContainer.addSubview(avatarHintLabel)
view.addSubview(avatarContainer)
nicknameCard.backgroundColor = .white
nicknameCard.layer.cornerRadius = AppRadius.sm
view.addSubview(nicknameCard)
nicknameTitleLabel.text = "昵称"
nicknameTitleLabel.font = .systemFont(ofSize: 14)
nicknameTitleLabel.textColor = AppColor.textTertiary
nicknameField.font = .systemFont(ofSize: 16)
nicknameField.textColor = AppColor.text333
nicknameField.placeholder = "请输入昵称"
nicknameField.clearButtonMode = .whileEditing
nicknameField.returnKeyType = .done
nicknameCard.addSubview(nicknameTitleLabel)
nicknameCard.addSubview(nicknameField)
view.addSubview(saveButton)
}
override func setupConstraints() {
avatarContainer.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(32)
make.centerX.equalToSuperview()
}
avatarImageView.snp.makeConstraints { make in
make.top.centerX.equalToSuperview()
make.width.height.equalTo(96)
}
avatarHintLabel.snp.makeConstraints { make in
make.top.equalTo(avatarImageView.snp.bottom).offset(12)
make.leading.trailing.bottom.equalToSuperview()
}
nicknameCard.snp.makeConstraints { make in
make.top.equalTo(avatarContainer.snp.bottom).offset(32)
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
}
nicknameTitleLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().inset(16)
}
nicknameField.snp.makeConstraints { make in
make.top.equalTo(nicknameTitleLabel.snp.bottom).offset(8)
make.leading.trailing.bottom.equalToSuperview().inset(16)
make.height.equalTo(44)
}
saveButton.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16)
make.height.equalTo(48)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
avatarImageView.addGestureRecognizer(
UITapGestureRecognizer(target: self, action: #selector(pickAvatar))
)
nicknameField.addTarget(self, action: #selector(nicknameChanged), for: .editingChanged)
saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside)
applyViewModel()
}
private func applyViewModel() {
nicknameField.text = viewModel.editingNickname
if let pendingData = viewModel.pendingAvatarData, let image = UIImage(data: pendingData) {
avatarImageView.image = image
} else {
avatarImageView.loadRemoteImage(urlString: viewModel.displayAvatarURL)
}
saveButton.isEnabled = !viewModel.isSaving
avatarImageView.alpha = viewModel.isSaving ? 0.6 : 1
nicknameField.isEnabled = !viewModel.isSaving
}
@objc private func nicknameChanged() {
viewModel.updateEditingNickname(nicknameField.text ?? "")
}
@objc private func pickAvatar() {
guard !viewModel.isSaving else { return }
var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.filter = .images
configuration.selectionLimit = 1
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self
present(picker, animated: true)
}
@objc private func saveTapped() {
nicknameField.resignFirstResponder()
Task { await saveProfile() }
}
private func saveProfile() async {
let scenicId = AppStore.shared.currentScenicId
guard scenicId > 0 else {
showToast("当前景区信息缺失")
return
}
showLoading()
defer { hideLoading() }
do {
try await viewModel.saveProfile(
api: profileAPI,
uploader: ossUploadService,
scenicId: scenicId
)
showToast("保存成功")
navigationController?.popViewController(animated: true)
} catch {
showToast(error.localizedDescription)
}
}
}
extension ProfileEditViewController: PHPickerViewControllerDelegate {
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
guard let self, let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { return }
Task { @MainActor in
do {
try self.viewModel.prepareAvatarImage(data: data)
} catch {
self.showToast(error.localizedDescription)
}
}
}
}
}

View File

@ -3,7 +3,6 @@
// suixinkan
//
import PhotosUI
import SnapKit
import UIKit
@ -12,7 +11,6 @@ final class ProfileViewController: BaseViewController {
private let viewModel = ProfileViewModel()
private let profileAPI = NetworkServices.shared.profileAPI
private let ossUploadService = NetworkServices.shared.ossUploadService
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
@ -35,7 +33,7 @@ final class ProfileViewController: BaseViewController {
private let logoutButton = UIButton(type: .system)
override func setupNavigationBar() {
title = "个人信息"
title = "我的"
}
override func setupUI() {
@ -87,18 +85,18 @@ final class ProfileViewController: BaseViewController {
withdrawalRow.addTarget(self, action: #selector(withdrawalTapped), for: .touchUpInside)
realNameRow.addTarget(self, action: #selector(realNameTapped), for: .touchUpInside)
nicknameField.addTarget(self, action: #selector(nicknameChanged), for: .editingChanged)
let avatarTap = UITapGestureRecognizer(target: self, action: #selector(pickAvatar))
avatarImageView.isUserInteractionEnabled = true
avatarImageView.addGestureRecognizer(avatarTap)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleAccountDidSwitch),
name: NotificationName.accountDidSwitch,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleProfileDidUpdate),
name: NotificationName.userProfileDidUpdate,
object: nil
)
}
override func viewWillAppear(_ animated: Bool) {
@ -191,19 +189,9 @@ final class ProfileViewController: BaseViewController {
}
private func applyViewModel() {
nicknameField.text = viewModel.isEditingProfile ? viewModel.editingNickname : viewModel.displayNickname
nicknameField.isEnabled = viewModel.isEditingProfile
nicknameField.text = viewModel.displayNickname
uidLabel.text = "UID:\(viewModel.displayUID)"
if let pendingData = viewModel.pendingAvatarData, let image = UIImage(data: pendingData) {
avatarImageView.image = image
} else {
avatarImageView.loadRemoteImage(urlString: viewModel.displayAvatarURL)
}
editButton.setTitle(viewModel.isEditingProfile ? "完成" : "编辑", for: .normal)
editButton.isEnabled = !viewModel.isSaving
avatarImageView.alpha = viewModel.isEditingProfile ? 1 : 1
avatarImageView.loadRemoteImage(urlString: viewModel.displayAvatarURL)
nameRow.configure(value: viewModel.displayRealName)
accountRow.configure(
@ -289,26 +277,17 @@ final class ProfileViewController: BaseViewController {
Task { await reloadProfile(showGlobalLoading: true) }
}
@objc private func nicknameChanged() {
viewModel.updateEditingNickname(nicknameField.text ?? "")
@objc private func handleProfileDidUpdate() {
viewModel.applyLocalProfileUpdate()
applyViewModel()
}
@objc private func editTapped() {
if viewModel.isEditingProfile {
Task { await saveProfile() }
} else {
viewModel.beginEditing()
}
}
@objc private func pickAvatar() {
guard viewModel.isEditingProfile else { return }
var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.filter = .images
configuration.selectionLimit = 1
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self
present(picker, animated: true)
let editVC = ProfileEditViewController(
nickname: viewModel.displayNickname,
avatarURL: viewModel.displayAvatarURL
)
navigationController?.pushViewController(editVC, animated: true)
}
@objc private func accountSwitchTapped() {
@ -382,26 +361,6 @@ final class ProfileViewController: BaseViewController {
}
}
private func saveProfile() async {
let scenicId = AppStore.shared.currentScenicId
guard scenicId > 0 else {
showToast("当前景区信息缺失")
return
}
showLoading()
defer { hideLoading() }
do {
try await viewModel.saveProfile(
api: profileAPI,
uploader: ossUploadService,
scenicId: scenicId
)
showToast("保存成功")
} catch {
showToast(error.localizedDescription)
}
}
private func updatePassword(_ password: String) async {
showLoading()
defer { hideLoading() }
@ -413,20 +372,3 @@ final class ProfileViewController: BaseViewController {
}
}
}
extension ProfileViewController: PHPickerViewControllerDelegate {
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
guard let self, let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { return }
Task { @MainActor in
do {
try self.viewModel.prepareAvatarImage(data: data)
} catch {
self.showToast(error.localizedDescription)
}
}
}
}
}

View File

@ -0,0 +1,323 @@
//
// CloudStoragePickForTaskViewController.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
/// Android `CloudStorageListForTaskScreen`
final class CloudStoragePickForTaskViewController: BaseViewController {
var onConfirmed: (([CloudFile]) -> Void)?
private let viewModel: CloudStoragePickForTaskViewModel
private let taskAPI = NetworkServices.shared.taskAPI
private let searchField = UITextField()
private let pathScrollView = UIScrollView()
private let pathStack = UIStackView()
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Int, Int>!
private let confirmButton = AppButton(title: "确定")
private let bottomBar = UIView()
init(importedFileIDs: Set<Int> = []) {
viewModel = CloudStoragePickForTaskViewModel(importedFileIDs: importedFileIDs)
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() {
searchField.placeholder = "搜索文件"
searchField.borderStyle = .roundedRect
searchField.font = .app(.body)
searchField.returnKeyType = .search
searchField.delegate = self
pathStack.axis = .horizontal
pathStack.spacing = AppSpacing.xs
pathScrollView.showsHorizontalScrollIndicator = false
pathScrollView.addSubview(pathStack)
pathStack.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.height.equalToSuperview()
}
collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
collectionView.backgroundColor = AppColor.pageBackground
collectionView.register(CloudPickCell.self, forCellWithReuseIdentifier: CloudPickCell.reuseIdentifier)
collectionView.delegate = self
dataSource = UICollectionViewDiffableDataSource<Int, Int>(collectionView: collectionView) {
[weak self] collectionView, indexPath, fileID in
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: CloudPickCell.reuseIdentifier,
for: indexPath
) as! CloudPickCell
if let file = self?.viewModel.files.first(where: { $0.id == fileID }) {
cell.apply(
file: file,
isSelected: self?.viewModel.isSelected(fileID) == true,
isImported: self?.viewModel.isImported(fileID) == true
)
}
return cell
}
bottomBar.backgroundColor = .white
view.addSubview(searchField)
view.addSubview(pathScrollView)
view.addSubview(collectionView)
view.addSubview(bottomBar)
bottomBar.addSubview(confirmButton)
}
override func setupConstraints() {
searchField.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(40)
}
pathScrollView.snp.makeConstraints { make in
make.top.equalTo(searchField.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(28)
}
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
confirmButton.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.md)
}
collectionView.snp.makeConstraints { make in
make.top.equalTo(pathScrollView.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top)
}
}
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) }
}
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.refresh(api: taskAPI) }
}
@MainActor
private func applyViewModel() {
rebuildPathButtons()
var snapshot = NSDiffableDataSourceSnapshot<Int, Int>()
snapshot.appendSections([0])
snapshot.appendItems(viewModel.files.map(\.id))
dataSource.apply(snapshot, animatingDifferences: false)
if viewModel.isLoading && viewModel.files.isEmpty {
showLoading()
} else {
hideLoading()
}
}
private func rebuildPathButtons() {
pathStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
for (index, item) in viewModel.pathStack.enumerated() {
let button = UIButton(type: .system)
button.setTitle(item.name, for: .normal)
button.titleLabel?.font = .app(.caption)
button.tag = index
button.addTarget(self, action: #selector(pathTapped(_:)), for: .touchUpInside)
pathStack.addArrangedSubview(button)
if index < viewModel.pathStack.count - 1 {
let arrow = UILabel()
arrow.text = ">"
arrow.font = .app(.caption)
arrow.textColor = AppColor.textTertiary
pathStack.addArrangedSubview(arrow)
}
}
}
@objc private func pathTapped(_ sender: UIButton) {
Task { await viewModel.navigateToPathIndex(sender.tag, api: taskAPI) }
}
@objc private func confirmTapped() {
onConfirmed?(viewModel.selectedFileList)
navigationController?.popViewController(animated: true)
}
private func makeLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { _, environment in
let columns = 3
let spacing: CGFloat = 8
let inset = AppSpacing.md * 2
let availableWidth = environment.container.effectiveContentSize.width - inset
let totalSpacing = spacing * CGFloat(columns - 1)
let itemWidth = max(0, (availableWidth - totalSpacing) / CGFloat(columns))
let itemSize = NSCollectionLayoutSize(
widthDimension: .absolute(itemWidth),
heightDimension: .absolute(itemWidth)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(itemWidth)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: groupSize,
repeatingSubitem: item,
count: columns
)
group.interItemSpacing = .fixed(spacing)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = spacing
section.contentInsets = NSDirectionalEdgeInsets(
top: 0,
leading: AppSpacing.md,
bottom: AppSpacing.md,
trailing: AppSpacing.md
)
return section
}
}
}
extension CloudStoragePickForTaskViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let fileID = dataSource.itemIdentifier(for: indexPath),
let file = viewModel.files.first(where: { $0.id == fileID }) else {
return
}
Task { await viewModel.handleItemTap(file, api: taskAPI) }
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offsetY = scrollView.contentOffset.y
let contentHeight = scrollView.contentSize.height
let frameHeight = scrollView.frame.size.height
if offsetY > contentHeight - frameHeight - 120 {
Task { await viewModel.loadMore(api: taskAPI) }
}
}
}
extension CloudStoragePickForTaskViewController: UITextFieldDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
viewModel.updateSearchText(textField.text ?? "")
textField.resignFirstResponder()
Task { await viewModel.refresh(api: taskAPI) }
return true
}
}
/// cell
private final class CloudPickCell: UICollectionViewCell {
static let reuseIdentifier = "CloudPickCell"
private let imageView = UIImageView()
private let nameLabel = UILabel()
private let badgeView = UIView()
private let folderIconView = UIImageView()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = .white
contentView.layer.cornerRadius = AppRadius.sm
contentView.clipsToBounds = true
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
folderIconView.image = UIImage(systemName: "folder.fill")
folderIconView.tintColor = AppColor.primary
folderIconView.contentMode = .scaleAspectFit
nameLabel.font = .app(.caption)
nameLabel.textColor = AppColor.textPrimary
nameLabel.numberOfLines = 2
nameLabel.textAlignment = .center
badgeView.backgroundColor = AppColor.primary
badgeView.layer.cornerRadius = 10
badgeView.isHidden = true
contentView.addSubview(imageView)
contentView.addSubview(folderIconView)
contentView.addSubview(nameLabel)
contentView.addSubview(badgeView)
imageView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(contentView.snp.width)
}
folderIconView.snp.makeConstraints { make in
make.center.equalTo(imageView)
make.width.height.equalTo(36)
}
nameLabel.snp.makeConstraints { make in
make.top.equalTo(imageView.snp.bottom).offset(AppSpacing.xxs)
make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.xxs)
}
badgeView.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(AppSpacing.xxs)
make.width.height.equalTo(20)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
imageView.kf.cancelDownloadTask()
imageView.image = nil
folderIconView.isHidden = true
badgeView.isHidden = true
}
func apply(file: CloudFile, isSelected: Bool, isImported: Bool) {
nameLabel.text = file.name
contentView.layer.borderWidth = isSelected ? 2 : 0
contentView.layer.borderColor = AppColor.primary.cgColor
badgeView.isHidden = !isSelected
if file.isFolder {
imageView.image = nil
folderIconView.isHidden = false
return
}
folderIconView.isHidden = true
let preview = file.coverUrl.isEmpty ? file.fileUrl : file.coverUrl
if let url = URL(string: preview), !preview.isEmpty {
imageView.kf.setImage(with: url)
} else {
imageView.image = UIImage(systemName: file.isVideo ? "video" : "photo")
imageView.tintColor = AppColor.textTertiary
}
if isImported {
contentView.alpha = 0.5
} else {
contentView.alpha = 1
}
}
}

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)"))
}
}
}
}

View File

@ -0,0 +1,225 @@
//
// TaskOrderSelectViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// Android `OrderSelectScreen`
final class TaskOrderSelectViewController: BaseViewController {
var onConfirmed: ((String?) -> Void)?
private let viewModel: TaskOrderSelectViewModel
private let taskAPI = NetworkServices.shared.taskAPI
private let tableView = UITableView(frame: .zero, style: .plain)
private let confirmButton = AppButton(title: "确定")
private let emptyLabel = UILabel()
private let bottomBar = UIView()
init(initialOrderNumber: String? = nil) {
viewModel = TaskOrderSelectViewModel(initialOrderNumber: initialOrderNumber)
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() {
tableView.backgroundColor = AppColor.pageBackground
tableView.separatorStyle = .none
tableView.register(TaskOrderCell.self, forCellReuseIdentifier: TaskOrderCell.reuseIdentifier)
tableView.dataSource = self
tableView.delegate = self
emptyLabel.text = "无有效订单"
emptyLabel.font = .app(.body)
emptyLabel.textColor = AppColor.textPrimary
emptyLabel.textAlignment = .center
emptyLabel.isHidden = true
bottomBar.backgroundColor = .white
view.addSubview(tableView)
view.addSubview(emptyLabel)
view.addSubview(bottomBar)
bottomBar.addSubview(confirmButton)
}
override func setupConstraints() {
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
confirmButton.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.md)
}
tableView.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.bottom.equalTo(bottomBar.snp.top)
}
emptyLabel.snp.makeConstraints { make in
make.center.equalTo(tableView)
}
}
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) }
}
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.loadOrders(api: taskAPI) }
}
@MainActor
private func applyViewModel() {
emptyLabel.isHidden = viewModel.isLoading || !viewModel.orders.isEmpty
tableView.reloadData()
if viewModel.isLoading {
showLoading()
} else {
hideLoading()
}
}
@objc private func confirmTapped() {
onConfirmed?(viewModel.confirmedOrderNumber())
navigationController?.popViewController(animated: true)
}
}
extension TaskOrderSelectViewController: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
viewModel.orders.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: TaskOrderCell.reuseIdentifier,
for: indexPath
) as! TaskOrderCell
let order = viewModel.orders[indexPath.row]
cell.apply(
order: order,
isSelected: viewModel.selectedOrderNumber == order.orderNumber
)
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
viewModel.toggleSelection(orderNumber: viewModel.orders[indexPath.row].orderNumber)
}
}
/// cell
private final class TaskOrderCell: UITableViewCell {
static let reuseIdentifier = "TaskOrderCell"
private let cardView = UIView()
private let projectRow = TaskInfoRowView(label: "项目名称:")
private let statusRow = TaskInfoRowView(label: "订单状态:")
private let numberRow = TaskInfoRowView(label: "订单编号:")
private let payTimeRow = TaskInfoRowView(label: "付款时间:")
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = .clear
contentView.backgroundColor = .clear
cardView.backgroundColor = .white
cardView.layer.cornerRadius = AppRadius.sm
cardView.layer.borderWidth = 1
cardView.layer.borderColor = AppColor.border.cgColor
contentView.addSubview(cardView)
cardView.addSubview(projectRow)
cardView.addSubview(statusRow)
cardView.addSubview(numberRow)
cardView.addSubview(payTimeRow)
cardView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: AppSpacing.md, bottom: AppSpacing.sm, right: AppSpacing.md))
}
projectRow.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(AppSpacing.md)
}
statusRow.snp.makeConstraints { make in
make.top.equalTo(projectRow.snp.bottom).offset(AppSpacing.xs)
make.leading.trailing.equalTo(projectRow)
}
numberRow.snp.makeConstraints { make in
make.top.equalTo(statusRow.snp.bottom).offset(AppSpacing.xs)
make.leading.trailing.equalTo(projectRow)
}
payTimeRow.snp.makeConstraints { make in
make.top.equalTo(numberRow.snp.bottom).offset(AppSpacing.xs)
make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.md)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(order: AvailableOrder, isSelected: Bool) {
projectRow.apply(value: order.projectName.isEmpty ? "--" : order.projectName)
statusRow.apply(value: order.orderStatusLabel.isEmpty ? "--" : order.orderStatusLabel)
numberRow.apply(value: order.orderNumber)
payTimeRow.apply(value: order.payTime.isEmpty ? "--" : order.payTime)
cardView.layer.borderColor = isSelected ? AppColor.danger.cgColor : AppColor.border.cgColor
cardView.layer.borderWidth = isSelected ? 2 : 1
}
}
///
private final class TaskInfoRowView: UIView {
private let labelView = UILabel()
private let valueView = UILabel()
init(label: String) {
super.init(frame: .zero)
labelView.text = label
labelView.font = .app(.body)
labelView.textColor = AppColor.textSecondary
valueView.font = .app(.body)
valueView.textColor = AppColor.textPrimary
valueView.numberOfLines = 0
addSubview(labelView)
addSubview(valueView)
labelView.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview()
make.width.equalTo(80)
}
valueView.snp.makeConstraints { make in
make.leading.equalTo(labelView.snp.trailing).offset(AppSpacing.xs)
make.trailing.top.bottom.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(value: String) {
valueView.text = value
}
}

View File

@ -0,0 +1,627 @@
//
// TaskAddViews.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
///
final class TaskAddMediaSectionView: UIView {
var onCloudImport: (() -> Void)?
var onLocalImport: (() -> Void)?
var onFileTap: ((TaskFileItem) -> Void)?
var onDeleteFile: ((TaskFileItem) -> Void)?
private var files: [TaskFileItem] = []
private var collectionHeightConstraint: Constraint?
private enum Section { case main }
private enum Item: Hashable { case add; case file(String) }
private lazy var collectionView: UICollectionView = {
let layout = UICollectionViewCompositionalLayout { _, environment in
let columns = 3
let spacing: CGFloat = 8
let inset = AppSpacing.md * 2
let availableWidth = environment.container.effectiveContentSize.width - inset
let totalSpacing = spacing * CGFloat(columns - 1)
let itemWidth = max(0, (availableWidth - totalSpacing) / CGFloat(columns))
let itemHeight = itemWidth / 1.77
let itemSize = NSCollectionLayoutSize(
widthDimension: .absolute(itemWidth),
heightDimension: .absolute(itemHeight)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(itemHeight)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: groupSize,
repeatingSubitem: item,
count: columns
)
group.interItemSpacing = .fixed(spacing)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = spacing
return section
}
let view = UICollectionView(frame: .zero, collectionViewLayout: layout)
view.backgroundColor = .clear
view.isScrollEnabled = false
return view
}()
private lazy var dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) {
[weak self] collectionView, indexPath, item in
switch item {
case .add:
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: TaskAddAddMediaCell.reuseIdentifier,
for: indexPath
) as! TaskAddAddMediaCell
return cell
case .file(let id):
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: TaskAddMediaCell.reuseIdentifier,
for: indexPath
) as! TaskAddMediaCell
if let file = self?.files.first(where: { $0.id == id }) {
cell.apply(file: file)
cell.onDelete = { self?.onDeleteFile?(file) }
}
return cell
}
}
private let titleLabel = UILabel()
private let tipsLabel = UILabel()
private let cloudButton = UIButton(type: .system)
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
layer.cornerRadius = AppRadius.md
titleLabel.text = "添加图片"
titleLabel.font = .app(.bodyMedium)
titleLabel.textColor = AppColor.textPrimary
tipsLabel.text = "(点击图片或视频可添加备注)"
tipsLabel.font = .app(.caption)
tipsLabel.textColor = AppColor.textPrimary
cloudButton.setTitle("云盘导入", for: .normal)
cloudButton.setTitleColor(AppColor.primary, for: .normal)
cloudButton.titleLabel?.font = .app(.caption)
cloudButton.addTarget(self, action: #selector(cloudTapped), for: .touchUpInside)
collectionView.register(TaskAddMediaCell.self, forCellWithReuseIdentifier: TaskAddMediaCell.reuseIdentifier)
collectionView.register(TaskAddAddMediaCell.self, forCellWithReuseIdentifier: TaskAddAddMediaCell.reuseIdentifier)
collectionView.delegate = self
addSubview(titleLabel)
addSubview(tipsLabel)
addSubview(cloudButton)
addSubview(collectionView)
titleLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(AppSpacing.md)
}
tipsLabel.snp.makeConstraints { make in
make.leading.equalTo(titleLabel.snp.trailing).offset(AppSpacing.xxs)
make.centerY.equalTo(titleLabel)
}
cloudButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(AppSpacing.md)
make.centerY.equalTo(titleLabel)
}
collectionView.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.bottom.equalToSuperview().inset(AppSpacing.md)
collectionHeightConstraint = make.height.equalTo(88).constraint
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(files: [TaskFileItem]) {
self.files = files
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.main])
snapshot.appendItems(files.map { Item.file($0.id) } + [.add])
dataSource.apply(snapshot, animatingDifferences: false)
collectionView.layoutIfNeeded()
let height = max(88, collectionView.collectionViewLayout.collectionViewContentSize.height)
collectionHeightConstraint?.update(offset: height)
}
@objc private func cloudTapped() {
onCloudImport?()
}
}
extension TaskAddMediaSectionView: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
switch item {
case .add:
onLocalImport?()
case .file(let id):
guard let file = files.first(where: { $0.id == id }) else { return }
onFileTap?(file)
}
}
}
/// cell
final class TaskAddMediaCell: UICollectionViewCell {
static let reuseIdentifier = "TaskAddMediaCell"
var onDelete: (() -> Void)?
private let imageView = UIImageView()
private let overlayView = UIView()
private let progressLabel = UILabel()
private let deleteButton = UIButton(type: .system)
private let playIconView = UIImageView()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = AppColor.inputBackground
contentView.layer.cornerRadius = AppRadius.sm
contentView.clipsToBounds = true
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
overlayView.isHidden = true
progressLabel.font = .app(.captionMedium)
progressLabel.textColor = .white
progressLabel.textAlignment = .center
deleteButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal)
deleteButton.tintColor = .white
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
playIconView.image = UIImage(systemName: "play.circle.fill")
playIconView.tintColor = .white
playIconView.isHidden = true
contentView.addSubview(imageView)
contentView.addSubview(overlayView)
contentView.addSubview(playIconView)
contentView.addSubview(progressLabel)
contentView.addSubview(deleteButton)
imageView.snp.makeConstraints { make in make.edges.equalToSuperview() }
overlayView.snp.makeConstraints { make in make.edges.equalToSuperview() }
playIconView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.width.height.equalTo(28)
}
progressLabel.snp.makeConstraints { make in make.center.equalToSuperview() }
deleteButton.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(AppSpacing.xxs)
make.width.height.equalTo(22)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
onDelete = nil
imageView.kf.cancelDownloadTask()
imageView.image = nil
}
func apply(file: TaskFileItem) {
let urlString = file.previewURLString
if let url = URL(string: urlString), !urlString.isEmpty {
imageView.kf.setImage(with: url)
} else {
imageView.image = UIImage(systemName: "photo")
imageView.tintColor = AppColor.textTertiary
}
playIconView.isHidden = file.fileType != 1
if file.isUploading {
overlayView.isHidden = false
progressLabel.text = "\(file.uploadProgress)%"
} else {
overlayView.isHidden = true
progressLabel.text = nil
}
}
@objc private func deleteTapped() {
onDelete?()
}
}
/// cell
final class TaskAddAddMediaCell: UICollectionViewCell {
static let reuseIdentifier = "TaskAddAddMediaCell"
private let iconView = UIImageView()
private let titleLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = AppColor.inputBackground
contentView.layer.cornerRadius = AppRadius.sm
iconView.image = UIImage(systemName: "plus")
iconView.tintColor = AppColor.textTabInactive
titleLabel.text = "本地导入"
titleLabel.font = .app(.caption)
titleLabel.textColor = AppColor.textTabInactive
contentView.addSubview(iconView)
contentView.addSubview(titleLabel)
iconView.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalToSuperview().offset(-8)
make.width.height.equalTo(24)
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(iconView.snp.bottom).offset(2)
make.centerX.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
final class TaskPrioritySectionView: UIView {
var onUrgentHourChange: ((Int) -> Void)?
var onCustomHourTextChange: ((String) -> Void)?
private let titleLabel = UILabel()
private let noneButton = UIButton(type: .system)
private let twoHourButton = UIButton(type: .system)
private let customButton = UIButton(type: .system)
private let customField = UITextField()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
layer.cornerRadius = AppRadius.md
titleLabel.text = "优先级"
titleLabel.font = .app(.bodyMedium)
titleLabel.textColor = AppColor.textPrimary
configureOptionButton(noneButton, title: "不加急")
configureOptionButton(twoHourButton, title: "加急(2小时)")
configureOptionButton(customButton, title: "自定义")
noneButton.addTarget(self, action: #selector(noneTapped), for: .touchUpInside)
twoHourButton.addTarget(self, action: #selector(twoHourTapped), for: .touchUpInside)
customButton.addTarget(self, action: #selector(customTapped), for: .touchUpInside)
customField.placeholder = "请输入小时数"
customField.font = .app(.body)
customField.textColor = AppColor.textPrimary
customField.keyboardType = .numberPad
customField.borderStyle = .roundedRect
customField.isHidden = true
customField.addTarget(self, action: #selector(customFieldChanged), for: .editingChanged)
let row = UIStackView(arrangedSubviews: [noneButton, twoHourButton, customButton])
row.axis = .horizontal
row.spacing = AppSpacing.xs
row.distribution = .fillEqually
addSubview(titleLabel)
addSubview(row)
addSubview(customField)
titleLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(AppSpacing.md)
}
row.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(42)
}
customField.snp.makeConstraints { make in
make.top.equalTo(row.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.bottom.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(40)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(urgentHour: Int) {
let isCustom = urgentHour > 0 && urgentHour != 2
styleOption(noneButton, selected: urgentHour == 0)
styleOption(twoHourButton, selected: urgentHour == 2)
styleOption(customButton, selected: isCustom)
customField.isHidden = !isCustom
if isCustom {
customField.text = "\(urgentHour)"
} else {
customField.text = ""
}
}
private func configureOptionButton(_ button: UIButton, title: String) {
button.setTitle(title, for: .normal)
button.titleLabel?.font = .app(.caption)
button.layer.cornerRadius = AppRadius.xs
button.clipsToBounds = true
}
private func styleOption(_ button: UIButton, selected: Bool) {
button.backgroundColor = selected ? AppColor.primary : AppColor.inputBackground
button.setTitleColor(selected ? .white : AppColor.textSecondary, for: .normal)
}
@objc private func noneTapped() { onUrgentHourChange?(0) }
@objc private func twoHourTapped() { onUrgentHourChange?(2) }
@objc private func customTapped() {
onUrgentHourChange?(1)
onCustomHourTextChange?("1")
}
@objc private func customFieldChanged() {
onCustomHourTextChange?(customField.text ?? "")
}
}
///
final class TaskOrderSelectButton: UIControl {
private let titleLabel = UILabel()
private let arrowView = UIImageView(image: UIImage(systemName: "chevron.down"))
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
layer.cornerRadius = AppRadius.sm
layer.borderWidth = 1
layer.borderColor = AppColor.border.cgColor
titleLabel.font = .app(.bodyMedium)
titleLabel.textColor = AppColor.textTabInactive
titleLabel.text = "关联订单(可选)"
arrowView.tintColor = AppColor.textTabInactive
addSubview(titleLabel)
addSubview(arrowView)
titleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(AppSpacing.sm)
make.centerY.equalToSuperview()
}
arrowView.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(AppSpacing.sm)
make.centerY.equalToSuperview()
}
snp.makeConstraints { make in make.height.equalTo(44) }
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(orderNumber: String?) {
if let orderNumber, !orderNumber.isEmpty {
titleLabel.text = orderNumber
titleLabel.textColor = AppColor.textPrimary
} else {
titleLabel.text = "关联订单(可选)"
titleLabel.textColor = AppColor.textTabInactive
}
}
}
///
final class TaskFileRemarkDialogView: UIView {
var onCancel: (() -> Void)?
var onConfirm: (() -> Void)?
private let containerView = UIView()
private let titleLabel = UILabel()
private let previewView = UIImageView()
private let textView = UITextView()
private let countLabel = UILabel()
private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system)
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = AppColor.overlayScrim
containerView.backgroundColor = .white
containerView.layer.cornerRadius = AppRadius.md
titleLabel.text = "备注信息"
titleLabel.font = .app(.title)
previewView.contentMode = .scaleAspectFill
previewView.clipsToBounds = true
previewView.layer.cornerRadius = AppRadius.sm
previewView.backgroundColor = AppColor.inputBackground
textView.font = .app(.body)
textView.layer.borderWidth = 1
textView.layer.borderColor = AppColor.border.cgColor
textView.layer.cornerRadius = AppRadius.sm
textView.delegate = self
countLabel.font = .app(.caption)
countLabel.textColor = AppColor.textTertiary
countLabel.text = "0/50"
cancelButton.setTitle("取消", for: .normal)
cancelButton.setTitleColor(AppColor.textSecondary, for: .normal)
cancelButton.backgroundColor = AppColor.inputBackground
cancelButton.layer.cornerRadius = AppRadius.xs
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.setTitle("确定", for: .normal)
confirmButton.setTitleColor(.white, for: .normal)
confirmButton.backgroundColor = AppColor.primary
confirmButton.layer.cornerRadius = AppRadius.xs
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
addSubview(containerView)
containerView.addSubview(titleLabel)
containerView.addSubview(previewView)
containerView.addSubview(textView)
containerView.addSubview(countLabel)
containerView.addSubview(cancelButton)
containerView.addSubview(confirmButton)
containerView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(AppSpacing.lg)
}
titleLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(AppSpacing.md)
}
previewView.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(160)
}
textView.snp.makeConstraints { make in
make.top.equalTo(previewView.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(80)
}
countLabel.snp.makeConstraints { make in
make.top.equalTo(textView.snp.bottom).offset(AppSpacing.xxs)
make.trailing.equalTo(textView)
}
cancelButton.snp.makeConstraints { make in
make.top.equalTo(countLabel.snp.bottom).offset(AppSpacing.md)
make.leading.bottom.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(36)
make.width.equalTo(72)
}
confirmButton.snp.makeConstraints { make in
make.centerY.equalTo(cancelButton)
make.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(36)
make.width.equalTo(72)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(file: TaskFileItem, text: String) {
textView.text = text
countLabel.text = "\(text.count)/50"
let urlString = file.previewURLString
if let url = URL(string: urlString), !urlString.isEmpty {
previewView.kf.setImage(with: url)
} else {
previewView.image = UIImage(systemName: "photo")
previewView.tintColor = AppColor.textTertiary
}
}
@objc private func cancelTapped() { onCancel?() }
@objc private func confirmTapped() { onConfirm?() }
///
var inputText: String { textView.text ?? "" }
}
extension TaskFileRemarkDialogView: UITextViewDelegate {
func textViewDidChange(_ textView: UITextView) {
if textView.text.count > 50 {
textView.text = String(textView.text.prefix(50))
}
countLabel.text = "\(textView.text.count)/50"
}
}
///
final class TaskBorderTextField: UIView {
let textField = UITextField()
init(placeholder: String) {
super.init(frame: .zero)
backgroundColor = .white
layer.cornerRadius = AppRadius.sm
layer.borderWidth = 1
layer.borderColor = AppColor.border.cgColor
textField.placeholder = placeholder
textField.font = .app(.body)
textField.textColor = AppColor.textPrimary
addSubview(textField)
textField.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 10, left: 12, bottom: 10, right: 12))
}
snp.makeConstraints { make in make.height.equalTo(44) }
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
final class TaskBorderTextView: UIView {
let textView = UITextView()
init(placeholder: String) {
super.init(frame: .zero)
backgroundColor = .white
layer.cornerRadius = AppRadius.sm
layer.borderWidth = 1
layer.borderColor = AppColor.border.cgColor
textView.font = .app(.body)
textView.textColor = AppColor.textPrimary
textView.text = placeholder
textView.textColor = AppColor.textTertiary
addSubview(textView)
textView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.xs)
}
snp.makeConstraints { make in make.height.equalTo(80) }
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}