完善任务流程、键盘适配与页面交互

This commit is contained in:
2026-07-10 15:56:15 +08:00
parent f88a85a807
commit ab5220e460
189 changed files with 16779 additions and 1038 deletions

View File

@ -1,10 +1,8 @@
//
// AppDelegate.swift
// suixinkan
//
// Created by hanqiu on 2026/7/6.
//
import IQKeyboardManagerSwift
import IQKeyboardToolbarManager
import UIKit
@main
@ -14,6 +12,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
@objc var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
configureKeyboardManager()
UmengBootstrap.configureIfNeeded()
WeChatManager.shared.registerIfNeeded()
if AppStore.shared.privacyAgreementAccepted, !AppStore.shared.token.isEmpty {
@ -36,5 +35,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
}
private func configureKeyboardManager() {
IQKeyboardManager.shared.isEnabled = true
IQKeyboardManager.shared.keyboardDistance = 12
IQKeyboardManager.shared.resignOnTouchOutside = true
IQKeyboardToolbarManager.shared.isEnabled = true
IQKeyboardToolbarManager.shared.toolbarConfiguration.useTextInputViewTintColor = true
IQKeyboardToolbarManager.shared.toolbarConfiguration.previousNextDisplayMode = .alwaysShow
IQKeyboardToolbarManager.shared.playInputClicks = false
}
}

View File

@ -1,8 +1,17 @@
{
"images" : [
{
"filename" : "report_share_cover.jpg",
"idiom" : "universal"
"filename" : "report_share_cover.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@ -100,11 +100,12 @@ struct TaskFileItem: Sendable, Equatable, Hashable, Identifiable {
let id: String
let source: TaskFileSource
let cloudFile: CloudFile?
let uploadFileName: String?
var uploadFileName: String?
let uploadFileType: Int
let uploadTaskId: String?
var uploadFileUrl: String?
var uploadCoverUrl: String?
var localPreviewData: Data?
var remark: String
var isUploading: Bool
var uploadProgress: Int
@ -117,6 +118,7 @@ struct TaskFileItem: Sendable, Equatable, Hashable, Identifiable {
uploadFileName: String? = nil,
uploadFileType: Int = 0,
uploadCoverUrl: String? = nil,
localPreviewData: Data? = nil,
remark: String = "",
uploadTaskId: String? = nil,
isUploading: Bool = false,
@ -137,6 +139,7 @@ struct TaskFileItem: Sendable, Equatable, Hashable, Identifiable {
self.uploadFileName = uploadFileName
self.uploadFileType = uploadFileType
self.uploadCoverUrl = uploadCoverUrl
self.localPreviewData = localPreviewData
self.remark = remark
self.uploadTaskId = uploadTaskId
self.isUploading = isUploading

View File

@ -57,11 +57,7 @@ final class TaskAddViewModel {
///
func updateCustomUrgentHourText(_ text: String) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard let hour = Int(trimmed), hour > 0 else {
urgentHour = 1
notifyStateChange()
return
}
guard let hour = Int(trimmed), hour > 0 else { return }
urgentHour = hour
notifyStateChange()
}
@ -117,13 +113,38 @@ final class TaskAddViewModel {
notifyStateChange()
}
///
func prepareUpload(uploadTaskId: String, fileName: String, localPreviewData: Data?) {
selectedFiles = selectedFiles.map { item in
guard item.uploadTaskId == uploadTaskId else { return item }
var updated = item
updated.uploadFileName = fileName
updated.localPreviewData = localPreviewData
return updated
}
notifyStateChange()
}
/// URL
func markUploadSucceeded(uploadTaskId: String, fileURL: String, coverURL: String? = nil, autoRemarkWhenSingle: Bool) {
func markUploadSucceeded(
uploadTaskId: String,
fileURL: String,
fileName: String? = nil,
coverURL: String? = nil,
localPreviewData: Data? = nil,
autoRemarkWhenSingle: Bool
) {
selectedFiles = selectedFiles.map { item in
guard item.uploadTaskId == uploadTaskId else { return item }
var updated = item
updated.uploadFileUrl = fileURL
if let fileName, !fileName.isEmpty {
updated.uploadFileName = fileName
}
updated.uploadCoverUrl = coverURL
if let localPreviewData {
updated.localPreviewData = localPreviewData
}
updated.isUploading = false
updated.uploadProgress = 100
return updated

View File

@ -1168,9 +1168,27 @@ final class WildReportRiskMapViewModel {
)
}
/// 线
func shareSelectedClue() {
_ = selectedClue
/// 线
func shareSelectedClue(api: any WildPhotographerReportServing) async -> WeChatMiniProgramSharePayload? {
guard let marker = selectedMarker,
marker.kind == .wildPhotographer || marker.kind == .processingClue,
let reportID = reportID(fromMarkerID: marker.id)
else {
errorMessage = "请选择可分享的举报线索"
notifyStateChange()
return nil
}
do {
let shareData = try await api.reportShare(id: String(reportID))
return shareData.payload
} catch is CancellationError {
return nil
} catch {
errorMessage = error.localizedDescription
notifyStateChange()
return nil
}
}
///
@ -1371,6 +1389,12 @@ final class WildReportRiskMapViewModel {
)
}
private func reportID(fromMarkerID markerID: String) -> Int? {
let parts = markerID.split(separator: "-", maxSplits: 1).map(String.init)
guard parts.count == 2, let id = Int(parts[1]), id >= 1 else { return nil }
return id
}
private func mergeDetail(_ detail: WildReportMarkerDetailResponse, intoMarkerID markerID: String) {
guard let index = markers.firstIndex(where: { $0.id == markerID }),
case .activeClue(let oldClue) = markers[index].detail

View File

@ -6,7 +6,7 @@
import SnapKit
import UIKit
///
/// `IQKeyboardManager`
class KeyboardAvoidingDialogView: UIView {
///
@ -18,8 +18,6 @@ class KeyboardAvoidingDialogView: UIView {
///
var keyboardAvoidingMinimumSpacing: CGFloat = AppSpacing.md
private var keyboardObservers: [NSObjectProtocol] = []
override init(frame: CGRect) {
super.init(frame: frame)
}
@ -29,77 +27,4 @@ class KeyboardAvoidingDialogView: UIView {
fatalError("init(coder:) has not been implemented")
}
deinit {
unregisterKeyboardObservers()
}
override func didMoveToSuperview() {
super.didMoveToSuperview()
if superview == nil {
unregisterKeyboardObservers()
} else {
registerKeyboardObservers()
}
}
private func registerKeyboardObservers() {
unregisterKeyboardObservers()
let center = NotificationCenter.default
keyboardObservers = [
center.addObserver(
forName: UIResponder.keyboardWillChangeFrameNotification,
object: nil,
queue: .main
) { [weak self] notification in
self?.handleKeyboardNotification(notification)
},
center.addObserver(
forName: UIResponder.keyboardWillHideNotification,
object: nil,
queue: .main
) { [weak self] notification in
self?.handleKeyboardNotification(notification)
}
]
}
private func unregisterKeyboardObservers() {
keyboardObservers.forEach(NotificationCenter.default.removeObserver)
keyboardObservers.removeAll()
}
private func handleKeyboardNotification(_ notification: Notification) {
layoutIfNeeded()
guard let userInfo = notification.userInfo else { return }
let duration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25
let curveRaw = (userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue
?? UInt(UIView.AnimationCurve.easeInOut.rawValue)
let options = UIView.AnimationOptions(rawValue: curveRaw << 16)
keyboardAvoidingCenterYConstraint?.update(offset: keyboardAvoidanceOffset(from: userInfo))
UIView.animate(withDuration: duration, delay: 0, options: options) {
self.layoutIfNeeded()
}
}
private func keyboardAvoidanceOffset(from userInfo: [AnyHashable: Any]) -> CGFloat {
guard
let contentView = keyboardAvoidingContentView,
let keyboardFrameValue = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue
else {
return 0
}
let keyboardFrame = convert(keyboardFrameValue.cgRectValue, from: nil)
guard keyboardFrame.minY < bounds.height else { return 0 }
let contentHeight = contentView.bounds.height
let baseMinY = bounds.midY - contentHeight / 2
let baseMaxY = bounds.midY + contentHeight / 2
let desiredBottom = keyboardFrame.minY - keyboardAvoidingMinimumSpacing
let neededShift = max(0, baseMaxY - desiredBottom)
let maxShift = max(0, baseMinY - keyboardAvoidingMinimumSpacing)
return -min(neededShift, maxShift)
}
}

View File

@ -65,7 +65,8 @@ final class AllFunctionsViewController: BaseViewController {
collectionView.backgroundColor = .clear
collectionView.alwaysBounceVertical = true
collectionView.allowsSelection = false
collectionView.allowsSelection = true
collectionView.delegate = self
collectionView.contentInset = UIEdgeInsets(
top: AppSpacing.xs,
left: 0,
@ -131,20 +132,12 @@ final class AllFunctionsViewController: BaseViewController {
private func makeDataSource() -> UICollectionViewDiffableDataSource<AllFunctionSection, AllFunctionListItem> {
let dataSource = UICollectionViewDiffableDataSource<AllFunctionSection, AllFunctionListItem>(
collectionView: collectionView
) { [weak self] collectionView, indexPath, item in
) { collectionView, indexPath, item in
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: AllFunctionMenuCell.reuseIdentifier,
for: indexPath
) as! AllFunctionMenuCell
cell.apply(menu: item.menu, actionStyle: item.actionStyle)
cell.onActionTap = {
switch item.section {
case .common:
self?.viewModel.removeFromCommon(item.menu)
case .more:
self?.viewModel.addToCommon(item.menu)
}
}
return cell
}
@ -214,6 +207,21 @@ final class AllFunctionsViewController: BaseViewController {
}
}
extension AllFunctionsViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
collectionView.deselectItem(at: indexPath, animated: true)
switch item.section {
case .common:
viewModel.removeFromCommon(item.menu)
case .more:
viewModel.addToCommon(item.menu)
}
}
}
///
private final class AllFunctionSectionHeaderView: UICollectionReusableView {

View File

@ -6,24 +6,29 @@
import SnapKit
import UIKit
///
///
final class AllFunctionMenuCell: UICollectionViewCell {
static let reuseIdentifier = "AllFunctionMenuCell"
///
enum ActionStyle {
case none
case add
case remove
}
var onActionTap: (() -> Void)?
private let cardView = UIView()
private let contentStackView = UIStackView()
private let iconView = UIImageView()
private let titleLabel = UILabel()
private let actionButton = UIButton(type: .system)
private let actionImageView = UIImageView()
override var isHighlighted: Bool {
didSet {
cardView.alpha = isHighlighted ? 0.72 : 1
}
}
override init(frame: CGRect) {
super.init(frame: frame)
@ -35,26 +40,29 @@ final class AllFunctionMenuCell: UICollectionViewCell {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
onActionTap = nil
}
///
func apply(menu: HomeMenuItem, actionStyle: ActionStyle) {
iconView.image = UIImage(systemName: menu.iconName)
titleLabel.text = menu.title
accessibilityLabel = menu.title
switch actionStyle {
case .none:
actionButton.isHidden = true
actionImageView.isHidden = true
accessibilityTraits = []
accessibilityHint = nil
case .add:
actionButton.isHidden = false
actionButton.setImage(UIImage(systemName: "plus.circle.fill"), for: .normal)
actionButton.tintColor = AppColor.primary
actionImageView.isHidden = false
actionImageView.image = UIImage(systemName: "plus.circle.fill")
actionImageView.tintColor = AppColor.primary
accessibilityTraits = .button
accessibilityHint = "添加到常用应用"
case .remove:
actionButton.isHidden = false
actionButton.setImage(UIImage(systemName: "minus.circle.fill"), for: .normal)
actionButton.tintColor = AppColor.danger
actionImageView.isHidden = false
actionImageView.image = UIImage(systemName: "minus.circle.fill")
actionImageView.tintColor = AppColor.danger
accessibilityTraits = .button
accessibilityHint = "移出常用应用"
}
}
@ -78,11 +86,13 @@ final class AllFunctionMenuCell: UICollectionViewCell {
titleLabel.textAlignment = .center
titleLabel.numberOfLines = 2
actionButton.addTarget(self, action: #selector(actionTapped), for: .touchUpInside)
isAccessibilityElement = true
actionImageView.contentMode = .scaleAspectFit
actionImageView.isUserInteractionEnabled = false
contentView.addSubview(cardView)
cardView.addSubview(contentStackView)
cardView.addSubview(actionButton)
cardView.addSubview(actionImageView)
contentStackView.addArrangedSubview(iconView)
contentStackView.addArrangedSubview(titleLabel)
@ -96,13 +106,9 @@ final class AllFunctionMenuCell: UICollectionViewCell {
iconView.snp.makeConstraints { make in
make.width.height.equalTo(26)
}
actionButton.snp.makeConstraints { make in
actionImageView.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(AppSpacing.xxs)
make.width.height.equalTo(20)
}
}
@objc private func actionTapped() {
onActionTap?()
}
}

View File

@ -13,12 +13,14 @@ final class LocationReportMapControlStack: UIView {
var onZoomIn: (() -> Void)?
var onZoomOut: (() -> Void)?
var onRelocate: (() -> Void)?
private weak var relocateButton: UIButton?
override init(frame: CGRect) {
super.init(frame: frame)
let zoomIn = makeButton(symbol: "plus", action: #selector(zoomInTapped))
let zoomOut = makeButton(symbol: "minus", action: #selector(zoomOutTapped))
let relocate = makeButton(symbol: "location.fill", action: #selector(relocateTapped))
relocateButton = relocate
let zoomStack = UIStackView(arrangedSubviews: [zoomIn, zoomOut])
zoomStack.axis = .vertical
@ -44,6 +46,12 @@ final class LocationReportMapControlStack: UIView {
}
}
///
func setRelocateEnabled(_ isEnabled: Bool) {
relocateButton?.isEnabled = isEnabled
relocateButton?.alpha = isEnabled ? 1 : 0.46
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")

View File

@ -18,10 +18,16 @@ final class CloudStoragePickForTaskViewController: BaseViewController {
private let searchField = UITextField()
private let pathScrollView = UIScrollView()
private let pathStack = UIStackView()
private let filterBar = UIStackView()
private let sortButton = UIButton(type: .system)
private let typeButton = UIButton(type: .system)
private let displayModeButton = UIButton(type: .system)
private let refreshControl = UIRefreshControl()
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Int, Int>!
private let confirmButton = AppButton(title: "确定")
private let bottomBar = UIView()
private var isGridMode = true
///
init(importedFileIDs: Set<Int> = [], allowedFileType: Int? = nil) {
@ -35,7 +41,7 @@ final class CloudStoragePickForTaskViewController: BaseViewController {
}
override func setupNavigationBar() {
title = "云盘导入"
title = "相册云盘"
}
override func setupUI() {
@ -54,10 +60,26 @@ final class CloudStoragePickForTaskViewController: BaseViewController {
make.height.equalToSuperview()
}
configureMenuButton(sortButton, imageName: "arrow.up.arrow.down")
configureMenuButton(typeButton, imageName: "line.3.horizontal.decrease")
displayModeButton.tintColor = AppColor.textSecondary
displayModeButton.backgroundColor = AppColor.inputBackground
displayModeButton.layer.cornerRadius = AppRadius.xs
displayModeButton.addTarget(self, action: #selector(displayModeTapped), for: .touchUpInside)
filterBar.axis = .horizontal
filterBar.spacing = AppSpacing.sm
filterBar.distribution = .fill
filterBar.addArrangedSubview(sortButton)
filterBar.addArrangedSubview(typeButton)
filterBar.addArrangedSubview(displayModeButton)
collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
collectionView.backgroundColor = AppColor.pageBackground
collectionView.register(CloudPickCell.self, forCellWithReuseIdentifier: CloudPickCell.reuseIdentifier)
collectionView.delegate = self
collectionView.refreshControl = refreshControl
refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged)
dataSource = UICollectionViewDiffableDataSource<Int, Int>(collectionView: collectionView) {
[weak self] collectionView, indexPath, fileID in
@ -69,7 +91,8 @@ final class CloudStoragePickForTaskViewController: BaseViewController {
cell.apply(
file: file,
isSelected: self?.viewModel.isSelected(fileID) == true,
isImported: self?.viewModel.isImported(fileID) == true
isImported: self?.viewModel.isImported(fileID) == true,
isGridMode: self?.isGridMode == true
)
}
return cell
@ -78,6 +101,7 @@ final class CloudStoragePickForTaskViewController: BaseViewController {
bottomBar.backgroundColor = .white
view.addSubview(searchField)
view.addSubview(pathScrollView)
view.addSubview(filterBar)
view.addSubview(collectionView)
view.addSubview(bottomBar)
bottomBar.addSubview(confirmButton)
@ -94,6 +118,13 @@ final class CloudStoragePickForTaskViewController: BaseViewController {
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(28)
}
filterBar.snp.makeConstraints { make in
make.top.equalTo(pathScrollView.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(36)
}
sortButton.snp.makeConstraints { make in make.width.equalTo(typeButton) }
displayModeButton.snp.makeConstraints { make in make.width.equalTo(36) }
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
@ -101,7 +132,7 @@ final class CloudStoragePickForTaskViewController: BaseViewController {
make.edges.equalToSuperview().inset(AppSpacing.md)
}
collectionView.snp.makeConstraints { make in
make.top.equalTo(pathScrollView.snp.bottom).offset(AppSpacing.sm)
make.top.equalTo(filterBar.snp.bottom).offset(AppSpacing.md)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top)
}
@ -125,15 +156,24 @@ final class CloudStoragePickForTaskViewController: BaseViewController {
@MainActor
private func applyViewModel() {
rebuildPathButtons()
rebuildFilterMenus()
var snapshot = NSDiffableDataSourceSnapshot<Int, Int>()
snapshot.appendSections([0])
snapshot.appendItems(viewModel.files.map(\.id))
let existingItems = Set(dataSource.snapshot().itemIdentifiers)
let itemsToReconfigure = snapshot.itemIdentifiers.filter(existingItems.contains)
snapshot.reconfigureItems(itemsToReconfigure)
dataSource.apply(snapshot, animatingDifferences: false)
let selectedCount = viewModel.selectedFileList.count
confirmButton.setTitle(selectedCount > 0 ? "确认选择(\(selectedCount))" : "确认选择", for: .normal)
if viewModel.isLoading && viewModel.files.isEmpty {
showLoading()
} else {
hideLoading()
}
if !viewModel.isRefreshing {
refreshControl.endRefreshing()
}
}
private func rebuildPathButtons() {
@ -160,26 +200,81 @@ final class CloudStoragePickForTaskViewController: BaseViewController {
}
@objc private func confirmTapped() {
guard !viewModel.selectedFileList.isEmpty else {
showToast("请至少选择一个文件")
return
}
onConfirmed?(viewModel.selectedFileList)
navigationController?.popViewController(animated: true)
}
@objc private func refreshTriggered() {
Task { await viewModel.refresh(api: taskAPI) }
}
@objc private func displayModeTapped() {
isGridMode.toggle()
collectionView.setCollectionViewLayout(makeLayout(), animated: true)
applyViewModel()
}
private func configureMenuButton(_ button: UIButton, imageName: String) {
button.tintColor = AppColor.textSecondary
button.backgroundColor = AppColor.inputBackground
button.layer.cornerRadius = AppRadius.xs
button.contentHorizontalAlignment = .leading
button.titleLabel?.font = .app(.body)
button.setTitleColor(AppColor.textPrimary, for: .normal)
button.setImage(UIImage(systemName: imageName), for: .normal)
button.semanticContentAttribute = .forceRightToLeft
button.showsMenuAsPrimaryAction = true
}
private func rebuildFilterMenus() {
let sortItems = [(1, "创建时间顺序"), (2, "创建时间倒序")]
sortButton.setTitle(sortItems.first(where: { $0.0 == viewModel.sortType })?.1 ?? "排序", for: .normal)
sortButton.menu = UIMenu(children: sortItems.map { value, title in
UIAction(title: title, state: value == viewModel.sortType ? .on : .off) { [weak self] _ in
guard let self else { return }
self.viewModel.updateSortType(value)
Task { await self.viewModel.refresh(api: self.taskAPI) }
}
})
let typeItems = [(0, "全部"), (2, "图片"), (1, "视频"), (99, "文件夹")]
typeButton.setTitle(typeItems.first(where: { $0.0 == viewModel.filterType })?.1 ?? "全部", for: .normal)
typeButton.menu = UIMenu(children: typeItems.map { value, title in
UIAction(title: title, state: value == viewModel.filterType ? .on : .off) { [weak self] _ in
guard let self else { return }
self.viewModel.updateFilterType(value)
Task { await self.viewModel.refresh(api: self.taskAPI) }
}
})
displayModeButton.setImage(
UIImage(systemName: isGridMode ? "list.bullet" : "square.grid.2x2"),
for: .normal
)
displayModeButton.accessibilityLabel = isGridMode ? "列表显示" : "宫格显示"
}
private func makeLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { _, environment in
let columns = 3
let isGridMode = isGridMode
return UICollectionViewCompositionalLayout { _, environment in
let columns = isGridMode ? 2 : 1
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 = isGridMode ? itemWidth + 38 : 72
let itemSize = NSCollectionLayoutSize(
widthDimension: .absolute(itemWidth),
heightDimension: .absolute(itemWidth)
heightDimension: .absolute(itemHeight)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(itemWidth)
heightDimension: .absolute(itemHeight)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: groupSize,
@ -297,12 +392,37 @@ private final class CloudPickCell: UICollectionViewCell {
contentView.alpha = 1
}
func apply(file: CloudFile, isSelected: Bool, isImported: Bool) {
func apply(file: CloudFile, isSelected: Bool, isImported: Bool, isGridMode: Bool) {
nameLabel.text = file.name
contentView.layer.borderWidth = isSelected ? 2 : 0
contentView.layer.borderColor = AppColor.primary.cgColor
badgeView.isHidden = !isSelected
if isGridMode {
imageView.snp.remakeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(imageView.snp.width)
}
nameLabel.textAlignment = .center
nameLabel.numberOfLines = 2
nameLabel.snp.remakeConstraints { make in
make.top.equalTo(imageView.snp.bottom).offset(AppSpacing.xxs)
make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.xxs)
}
} else {
imageView.snp.remakeConstraints { make in
make.top.bottom.leading.equalToSuperview()
make.width.equalTo(imageView.snp.height)
}
nameLabel.textAlignment = .left
nameLabel.numberOfLines = 2
nameLabel.snp.remakeConstraints { make in
make.leading.equalTo(imageView.snp.trailing).offset(AppSpacing.sm)
make.trailing.equalToSuperview().inset(AppSpacing.sm)
make.centerY.equalToSuperview()
}
}
if file.isFolder {
contentView.alpha = 1
imageView.image = nil

View File

@ -3,6 +3,7 @@
// suixinkan
//
import AVFoundation
import PhotosUI
import SnapKit
import UIKit
@ -108,6 +109,9 @@ final class TaskAddViewController: BaseViewController {
prioritySection.onCustomHourTextChange = { [weak self] text in
self?.viewModel.updateCustomUrgentHourText(text)
}
prioritySection.onShowMessage = { [weak self] message in
self?.showToast(message)
}
orderButton.addTarget(self, action: #selector(openOrderSelect), for: .touchUpInside)
nameField.textField.addTarget(self, action: #selector(nameChanged), for: .editingChanged)
@ -181,12 +185,14 @@ final class TaskAddViewController: BaseViewController {
guard uploadTypeSheet == nil else { return }
let sheet = TaskUploadTypeSheetView(frame: view.bounds)
sheet.onChooseImage = { [weak self] in
self?.hideUploadTypeSheet()
self?.presentMediaPicker(isImage: true)
self?.hideUploadTypeSheet {
self?.presentLocalSourcePicker(isImage: true)
}
}
sheet.onChooseVideo = { [weak self] in
self?.hideUploadTypeSheet()
self?.presentMediaPicker(isImage: false)
self?.hideUploadTypeSheet {
self?.presentLocalSourcePicker(isImage: false)
}
}
sheet.onCancel = { [weak self] in
self?.hideUploadTypeSheet()
@ -197,13 +203,29 @@ final class TaskAddViewController: BaseViewController {
sheet.show()
}
private func hideUploadTypeSheet() {
private func hideUploadTypeSheet(completion: (() -> Void)? = nil) {
uploadTypeSheet?.dismiss { [weak self] in
self?.uploadTypeSheet?.removeFromSuperview()
self?.uploadTypeSheet = nil
completion?()
}
}
private func presentLocalSourcePicker(isImage: Bool) {
pickingMediaType = isImage ? 2 : 1
let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
if UIImagePickerController.isSourceTypeAvailable(.camera) {
sheet.addAction(UIAlertAction(title: isImage ? "拍照" : "拍摄视频", style: .default) { [weak self] _ in
self?.presentCamera(isImage: isImage)
})
}
sheet.addAction(UIAlertAction(title: "从相册选择", style: .default) { [weak self] _ in
self?.presentMediaPicker(isImage: isImage)
})
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true)
}
private func presentMediaPicker(isImage: Bool) {
pickingMediaType = isImage ? 2 : 1
var configuration = PHPickerConfiguration(photoLibrary: .shared())
@ -214,6 +236,18 @@ final class TaskAddViewController: BaseViewController {
present(picker, animated: true)
}
private func presentCamera(isImage: Bool) {
let picker = UIImagePickerController()
picker.sourceType = .camera
picker.delegate = self
picker.mediaTypes = [isImage ? UTType.image.identifier : UTType.movie.identifier]
picker.cameraCaptureMode = isImage ? .photo : .video
if !isImage {
picker.videoQuality = .typeHigh
}
present(picker, animated: true)
}
private func confirmDelete(file: TaskFileItem) {
let alert = UIAlertController(title: "删除确认", message: "确定要删除此文件吗?", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
@ -279,47 +313,97 @@ final class TaskAddViewController: BaseViewController {
return
}
let totalCount = results.count
for (index, result) in results.enumerated() {
for result in results {
let uploadTaskId = UUID().uuidString.replacingOccurrences(of: "-", with: "")
let placeholder = viewModel.addUploadPlaceholder(
fileName: "upload_\(uploadTaskId).\(isImage ? "jpg" : "mp4")",
viewModel.addUploadPlaceholder(
fileName: TaskMediaLoader.suggestedFileName(
from: result.itemProvider,
fallback: "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,
await uploadPayload(
payload,
fileType: isImage ? 2 : 1,
uploadTaskId: uploadTaskId,
totalCount: totalCount,
scenicId: scenicId
) { [weak self] progress in
Task { @MainActor in
self?.viewModel.updateUploadProgress(uploadTaskId: uploadTaskId, progress: progress)
}
}
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
}
}
}
private func uploadCapturedPayload(_ payload: TaskMediaLoader.Payload, fileType: Int) {
let scenicId = AppStore.shared.currentScenicId
guard scenicId > 0 else {
showToast("请先选择景区")
return
}
let uploadTaskId = UUID().uuidString.replacingOccurrences(of: "-", with: "")
viewModel.addUploadPlaceholder(
fileName: payload.fileName,
fileType: fileType,
uploadTaskId: uploadTaskId
)
Task {
await uploadPayload(
payload,
fileType: fileType,
uploadTaskId: uploadTaskId,
totalCount: 1,
scenicId: scenicId
)
}
}
private func uploadPayload(
_ payload: TaskMediaLoader.Payload,
fileType: Int,
uploadTaskId: String,
totalCount: Int,
scenicId: Int
) async {
viewModel.prepareUpload(
uploadTaskId: uploadTaskId,
fileName: payload.fileName,
localPreviewData: payload.previewData
)
do {
let fileURL = try await ossUploadService.uploadTaskFile(
data: payload.data,
fileName: payload.fileName,
fileType: fileType,
scenicId: scenicId
) { [weak self] progress in
Task { @MainActor in
self?.viewModel.updateUploadProgress(uploadTaskId: uploadTaskId, progress: progress)
}
}
viewModel.markUploadSucceeded(
uploadTaskId: uploadTaskId,
fileURL: fileURL,
fileName: payload.fileName,
localPreviewData: payload.previewData,
autoRemarkWhenSingle: totalCount == 1
)
if totalCount == 1, let fileID = viewModel.consumePendingRemarkFileID() {
viewModel.presentRemarkDialog(for: fileID)
}
} catch {
viewModel.markUploadFailed(uploadTaskId: uploadTaskId)
showToast(error.localizedDescription)
}
}
}
extension TaskAddViewController: PHPickerViewControllerDelegate {
@ -330,6 +414,32 @@ extension TaskAddViewController: PHPickerViewControllerDelegate {
}
}
extension TaskAddViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
}
func imagePickerController(
_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
) {
picker.dismiss(animated: true)
if pickingMediaType == 1,
let url = info[.mediaURL] as? URL,
let payload = try? TaskMediaLoader.videoPayload(from: url) {
uploadCapturedPayload(payload, fileType: 1)
return
}
if let image = info[.originalImage] as? UIImage,
let payload = TaskMediaLoader.imagePayload(
from: image,
fileName: "image_\(Int(Date().timeIntervalSince1970)).jpg"
) {
uploadCapturedPayload(payload, fileType: 2)
}
}
}
extension TaskAddViewController: UITextViewDelegate {
func textViewDidBeginEditing(_ textView: UITextView) {
if textView.textColor == AppColor.textTertiary {
@ -354,9 +464,24 @@ extension TaskAddViewController: UITextViewDelegate {
/// PHPicker
enum TaskMediaLoader {
///
struct Payload {
let data: Data
let fileName: String
let previewData: Data?
}
/// 使
static func suggestedFileName(from provider: NSItemProvider, fallback: String) -> String {
guard let suggestedName = provider.suggestedName?.trimmingCharacters(in: .whitespacesAndNewlines),
!suggestedName.isEmpty else {
return fallback
}
if !URL(fileURLWithPath: suggestedName).pathExtension.isEmpty {
return suggestedName
}
let fallbackExtension = URL(fileURLWithPath: fallback).pathExtension
return fallbackExtension.isEmpty ? suggestedName : "\(suggestedName).\(fallbackExtension)"
}
static func load(from result: PHPickerResult, isImage: Bool) async throws -> Payload {
@ -369,21 +494,24 @@ enum TaskMediaLoader {
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"))
let identifier = provider.registeredTypeIdentifiers.first {
UTType($0)?.conforms(to: .image) == true
} ?? UTType.image.identifier
provider.loadFileRepresentation(forTypeIdentifier: identifier) { url, error in
if let error {
continuation.resume(throwing: error)
return
}
return
guard let url, let data = try? Data(contentsOf: url), !data.isEmpty else {
continuation.resume(throwing: OSSUploadError.emptyFile)
return
}
let ext = url.pathExtension.isEmpty ? "jpg" : url.pathExtension
let fallback = "image_\(UUID().uuidString).\(ext)"
let fileName = suggestedFileName(from: provider, fallback: fallback)
let previewData = UIImage(data: data).flatMap(makePreviewData(from:))
continuation.resume(returning: Payload(data: data, fileName: fileName, previewData: previewData))
}
continuation.resume(throwing: OSSUploadError.unsupportedFileType)
}
}
@ -400,8 +528,49 @@ enum TaskMediaLoader {
return
}
let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension
continuation.resume(returning: Payload(data: data, fileName: "video_\(UUID().uuidString).\(ext)"))
let fallback = "video_\(UUID().uuidString).\(ext)"
let fileName = suggestedFileName(from: provider, fallback: fallback)
continuation.resume(returning: Payload(
data: data,
fileName: fileName,
previewData: makeVideoPreviewData(from: url)
))
}
}
}
///
static func imagePayload(from image: UIImage, fileName: String) -> Payload? {
guard let data = image.jpegData(compressionQuality: 0.9) else { return nil }
return Payload(data: data, fileName: fileName, previewData: makePreviewData(from: image))
}
///
static func videoPayload(from url: URL) throws -> Payload {
let data = try Data(contentsOf: url)
guard !data.isEmpty else { throw OSSUploadError.emptyFile }
let ext = url.pathExtension.isEmpty ? "mov" : url.pathExtension
let fileName = url.lastPathComponent.isEmpty
? "video_\(UUID().uuidString).\(ext)"
: url.lastPathComponent
return Payload(data: data, fileName: fileName, previewData: makeVideoPreviewData(from: url))
}
private static func makePreviewData(from image: UIImage) -> Data? {
let maxLength: CGFloat = 600
let scale = min(1, maxLength / max(image.size.width, image.size.height))
let size = CGSize(width: image.size.width * scale, height: image.size.height * scale)
let renderer = UIGraphicsImageRenderer(size: size)
let preview = renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: size))
}
return preview.jpegData(compressionQuality: 0.72)
}
private static func makeVideoPreviewData(from url: URL) -> Data? {
let generator = AVAssetImageGenerator(asset: AVAsset(url: url))
generator.appliesPreferredTrackTransform = true
guard let image = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return nil }
return makePreviewData(from: UIImage(cgImage: image))
}
}

View File

@ -3,6 +3,7 @@
// suixinkan
//
import AVFoundation
import Kingfisher
import SnapKit
import UIKit
@ -139,6 +140,9 @@ final class TaskAddMediaSectionView: UIView {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.main])
snapshot.appendItems(files.map { Item.file($0.id) } + [.add])
let existingItems = Set(dataSource.snapshot().itemIdentifiers)
let itemsToReconfigure = snapshot.itemIdentifiers.filter(existingItems.contains)
snapshot.reconfigureItems(itemsToReconfigure)
dataSource.apply(snapshot, animatingDifferences: false)
updateCollectionHeight(itemCount: files.count + 1)
}
@ -320,7 +324,10 @@ final class TaskAddMediaCell: UICollectionViewCell {
func apply(file: TaskFileItem) {
let urlString = file.previewURLString
if let url = URL(string: urlString), !urlString.isEmpty {
if let localPreviewData = file.localPreviewData,
let localImage = UIImage(data: localPreviewData) {
imageView.image = localImage
} else if let url = URL(string: urlString), !urlString.isEmpty {
imageView.kf.setImage(with: url)
} else {
imageView.image = UIImage(systemName: "photo")
@ -391,6 +398,7 @@ final class TaskPrioritySectionView: UIView {
var onUrgentHourChange: ((Int) -> Void)?
var onCustomHourTextChange: ((String) -> Void)?
var onShowMessage: ((String) -> Void)?
private let titleLabel = UILabel()
private let noneButton = UIButton(type: .system)
@ -401,6 +409,8 @@ final class TaskPrioritySectionView: UIView {
private let optionRow = UIStackView()
private let customRow = UIStackView()
private let stackView = UIStackView()
private var customHourText = ""
private var lastUrgentHour = 0
override init(frame: CGRect) {
super.init(frame: frame)
@ -426,6 +436,7 @@ final class TaskPrioritySectionView: UIView {
customField.layer.cornerRadius = AppRadius.sm
customField.layer.borderWidth = 1
customField.layer.borderColor = AppColor.border.cgColor
customField.delegate = self
customField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: AppSpacing.sm, height: 1))
customField.leftViewMode = .always
customField.addTarget(self, action: #selector(customFieldChanged), for: .editingChanged)
@ -480,15 +491,21 @@ final class TaskPrioritySectionView: UIView {
func apply(urgentHour: Int) {
let isCustom = urgentHour > 0 && urgentHour != 2
let wasCustom = lastUrgentHour > 0 && lastUrgentHour != 2
styleOption(noneButton, selected: urgentHour == 0)
styleOption(twoHourButton, selected: urgentHour == 2)
styleOption(customButton, selected: isCustom)
customRow.isHidden = !isCustom
if isCustom {
customField.text = "\(urgentHour)"
if !wasCustom || urgentHour != lastUrgentHour {
customHourText = "\(urgentHour)"
}
customField.text = customHourText
} else {
customHourText = ""
customField.text = ""
}
lastUrgentHour = urgentHour
}
private func configureOptionButton(_ button: UIButton, title: String) {
@ -506,14 +523,31 @@ final class TaskPrioritySectionView: UIView {
@objc private func noneTapped() { onUrgentHourChange?(0) }
@objc private func twoHourTapped() { onUrgentHourChange?(2) }
@objc private func customTapped() {
guard lastUrgentHour <= 0 || lastUrgentHour == 2 else { return }
onUrgentHourChange?(1)
onCustomHourTextChange?("1")
}
@objc private func customFieldChanged() {
onCustomHourTextChange?(customField.text ?? "")
customHourText = customField.text ?? ""
onCustomHourTextChange?(customHourText)
}
@objc private func customConfirmTapped() {
onCustomHourTextChange?(customField.text ?? "")
guard let hour = Int(customHourText), hour > 0 else {
onShowMessage?("请输入有效的正整数")
return
}
onCustomHourTextChange?(customHourText)
onShowMessage?("加急\(hour)小时")
}
}
extension TaskPrioritySectionView: UITextFieldDelegate {
func textField(
_ textField: UITextField,
shouldChangeCharactersIn range: NSRange,
replacementString string: String
) -> Bool {
string.isEmpty || string.allSatisfy(\.isNumber)
}
}
@ -573,7 +607,9 @@ final class TaskFileRemarkDialogView: KeyboardAvoidingDialogView {
private let containerView = UIView()
private let titleLabel = UILabel()
private let previewContainerView = UIView()
private let previewView = UIImageView()
private let videoView = TaskRemarkVideoView()
private let remarkTitleLabel = UILabel()
private let textView = UITextView()
private let placeholderLabel = UILabel()
@ -599,6 +635,10 @@ final class TaskFileRemarkDialogView: KeyboardAvoidingDialogView {
previewView.layer.cornerRadius = AppRadius.lg
previewView.backgroundColor = AppColor.inputBackground
previewContainerView.backgroundColor = AppColor.inputBackground
previewContainerView.layer.cornerRadius = AppRadius.lg
previewContainerView.clipsToBounds = true
remarkTitleLabel.text = "备注信息"
remarkTitleLabel.font = .app(.caption)
remarkTitleLabel.textColor = AppColor.textPrimary
@ -640,7 +680,9 @@ final class TaskFileRemarkDialogView: KeyboardAvoidingDialogView {
addSubview(containerView)
containerView.addSubview(titleLabel)
containerView.addSubview(previewView)
containerView.addSubview(previewContainerView)
previewContainerView.addSubview(previewView)
previewContainerView.addSubview(videoView)
containerView.addSubview(remarkTitleLabel)
containerView.addSubview(countLabel)
containerView.addSubview(textView)
@ -655,13 +697,15 @@ final class TaskFileRemarkDialogView: KeyboardAvoidingDialogView {
titleLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(AppSpacing.md)
}
previewView.snp.makeConstraints { make in
previewContainerView.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(previewView.snp.width)
make.height.equalTo(previewContainerView.snp.width)
}
previewView.snp.makeConstraints { make in make.edges.equalToSuperview() }
videoView.snp.makeConstraints { make in make.edges.equalToSuperview() }
remarkTitleLabel.snp.makeConstraints { make in
make.top.equalTo(previewView.snp.bottom).offset(AppSpacing.md)
make.top.equalTo(previewContainerView.snp.bottom).offset(AppSpacing.md)
make.leading.equalToSuperview().offset(AppSpacing.md)
}
countLabel.snp.makeConstraints { make in
@ -701,8 +745,23 @@ final class TaskFileRemarkDialogView: KeyboardAvoidingDialogView {
textView.text = text
countLabel.text = "\(text.count)/50"
placeholderLabel.isHidden = !text.isEmpty
if file.fileType == 1,
let url = URL(string: file.uploadFileUrl ?? file.cloudFile?.fileUrl ?? ""),
!url.absoluteString.isEmpty {
previewView.isHidden = true
videoView.isHidden = false
videoView.configure(url: url)
return
}
videoView.stop()
videoView.isHidden = true
previewView.isHidden = false
let urlString = file.previewURLString
if let url = URL(string: urlString), !urlString.isEmpty {
if let localPreviewData = file.localPreviewData,
let localImage = UIImage(data: localPreviewData) {
previewView.image = localImage
} else if let url = URL(string: urlString), !urlString.isEmpty {
previewView.kf.setImage(with: url)
} else {
previewView.image = UIImage(systemName: "photo")
@ -710,13 +769,82 @@ final class TaskFileRemarkDialogView: KeyboardAvoidingDialogView {
}
}
@objc private func cancelTapped() { onCancel?() }
@objc private func confirmTapped() { onConfirm?() }
@objc private func cancelTapped() {
videoView.stop()
onCancel?()
}
@objc private func confirmTapped() {
videoView.stop()
onConfirm?()
}
///
var inputText: String { textView.text ?? "" }
}
///
final class TaskRemarkVideoView: UIView {
private let playerLayer = AVPlayerLayer()
private let playButton = UIButton(type: .system)
private var player: AVPlayer?
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .black
layer.addSublayer(playerLayer)
playButton.setImage(UIImage(systemName: "play.circle.fill"), for: .normal)
playButton.tintColor = .white
playButton.addTarget(self, action: #selector(togglePlayback), for: .touchUpInside)
addSubview(playButton)
playButton.snp.makeConstraints { make in
make.center.equalToSuperview()
make.width.height.equalTo(52)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
playerLayer.frame = bounds
}
///
func configure(url: URL) {
stop()
let player = AVPlayer(url: url)
self.player = player
playerLayer.player = player
playerLayer.videoGravity = .resizeAspect
playButton.isHidden = false
}
///
func stop() {
player?.pause()
player = nil
playerLayer.player = nil
playButton.isHidden = false
}
@objc private func togglePlayback() {
guard let player else { return }
if player.timeControlStatus == .playing {
player.pause()
playButton.isHidden = false
} else {
player.play()
playButton.isHidden = true
}
}
}
extension TaskFileRemarkDialogView: UITextViewDelegate {
func textViewDidChange(_ textView: UITextView) {
if textView.text.count > 50 {

View File

@ -21,7 +21,7 @@ final class WildReportRiskMapViewController: BaseViewController {
private let mapView = MKMapView()
private let scenicCapsule = UIView()
private let scenicNameLabel = UILabel()
private let locateButton = UIButton(type: .system)
private let mapControls = LocationReportMapControlStack()
private let legendBar = UIStackView()
private let detailContainerView = UIView()
private let scrollView = UIScrollView()
@ -88,7 +88,7 @@ final class WildReportRiskMapViewController: BaseViewController {
mapContainerView.addSubview(mapView)
configureScenicCapsule()
configureLocateButton()
configureMapControls()
configureLegendBar()
configureDetailArea()
refreshContent(animated: false)
@ -108,9 +108,8 @@ final class WildReportRiskMapViewController: BaseViewController {
make.top.leading.equalToSuperview().inset(12)
make.height.equalTo(34)
}
locateButton.snp.makeConstraints { make in
mapControls.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(12)
make.size.equalTo(44)
}
detailContainerView.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(180)
@ -154,18 +153,21 @@ final class WildReportRiskMapViewController: BaseViewController {
}
}
private func configureLocateButton() {
locateButton.backgroundColor = UIColor.white.withAlphaComponent(0.96)
locateButton.tintColor = AppColor.primary
locateButton.setImage(UIImage(systemName: "location.fill"), for: .normal)
locateButton.layer.cornerRadius = 22
locateButton.layer.shadowColor = UIColor.black.cgColor
locateButton.layer.shadowOpacity = 0.1
locateButton.layer.shadowRadius = 8
locateButton.layer.shadowOffset = CGSize(width: 0, height: 4)
locateButton.accessibilityLabel = "回到我的位置"
locateButton.addTarget(self, action: #selector(locateButtonTapped), for: .touchUpInside)
mapContainerView.addSubview(locateButton)
private func configureMapControls() {
mapControls.layer.shadowColor = UIColor.black.cgColor
mapControls.layer.shadowOpacity = 0.1
mapControls.layer.shadowRadius = 8
mapControls.layer.shadowOffset = CGSize(width: 0, height: 4)
mapControls.onZoomIn = { [weak self] in
self?.zoomMap(by: 0.5)
}
mapControls.onZoomOut = { [weak self] in
self?.zoomMap(by: 2)
}
mapControls.onRelocate = { [weak self] in
self?.focusCurrentLocation()
}
mapContainerView.addSubview(mapControls)
}
private func configureLegendBar() {
@ -226,8 +228,7 @@ final class WildReportRiskMapViewController: BaseViewController {
@MainActor
private func updateLocateButtonState() {
let hasCurrentLocation = viewModel.markers.contains { $0.kind == .selfLocation }
locateButton.isEnabled = hasCurrentLocation
locateButton.alpha = hasCurrentLocation ? 1 : 0.46
mapControls.setRelocateEnabled(hasCurrentLocation)
}
private func syncMapAnnotations() {
@ -325,6 +326,10 @@ final class WildReportRiskMapViewController: BaseViewController {
}
private func makeRadarHeader(clue: WildReportRadarActiveClue) -> UIView {
let container = UIStackView()
container.axis = .vertical
container.spacing = 6
let row = UIStackView()
row.axis = .horizontal
row.alignment = .top
@ -348,21 +353,18 @@ final class WildReportRiskMapViewController: BaseViewController {
let textStack = UIStackView()
textStack.axis = .vertical
textStack.spacing = 6
var reportMetaText: String?
if isReportClue(clue) {
let title = WildReportUI.label("线索 ID\(clue.id)", font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary, lines: 2)
textStack.addArrangedSubview(title)
let metaRow = UIStackView()
metaRow.axis = .horizontal
metaRow.spacing = 12
var metaParts: [String] = []
if let distance = clue.distanceText ?? clue.distance {
metaRow.addArrangedSubview(WildReportUI.label("距离:距离您\(distance)", font: .systemFont(ofSize: 12), color: AppColor.textSecondary))
metaParts.append("距离:距离您\(distance)")
}
if let updatedAt = clue.updatedAt {
metaRow.addArrangedSubview(WildReportUI.label("时间:\(updatedAt)", font: .systemFont(ofSize: 12), color: AppColor.textSecondary))
}
if !metaRow.arrangedSubviews.isEmpty {
textStack.addArrangedSubview(metaRow)
metaParts.append("时间:\(updatedAt)")
}
reportMetaText = metaParts.isEmpty ? nil : metaParts.joined(separator: " ")
} else {
let title = WildReportUI.label(clue.displayTitle, font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary, lines: 2)
textStack.addArrangedSubview(title)
@ -380,7 +382,24 @@ final class WildReportRiskMapViewController: BaseViewController {
row.addArrangedSubview(badge)
textStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
badge.setContentHuggingPriority(.required, for: .horizontal)
return row
container.addArrangedSubview(row)
if let reportMetaText {
let metaRow = UIStackView()
metaRow.axis = .horizontal
metaRow.spacing = 0
let spacer = UIView()
spacer.snp.makeConstraints { make in
make.width.equalTo(56)
}
let metaLabel = WildReportUI.label(reportMetaText, font: .systemFont(ofSize: 12), color: AppColor.textSecondary)
metaLabel.textAlignment = .right
metaLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
metaRow.addArrangedSubview(spacer)
metaRow.addArrangedSubview(metaLabel)
container.addArrangedSubview(metaRow)
}
return container
}
private func addPhotographerProfile(for clue: WildReportRadarActiveClue, to stack: UIStackView) {
@ -888,22 +907,134 @@ final class WildReportRiskMapViewController: BaseViewController {
return
}
guard let target = viewModel.navigateToSelectedMarker() else { return }
let item = MKMapItem(placemark: MKPlacemark(coordinate: target.coordinate))
item.name = target.title
item.openInMaps(launchOptions: [
MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDriving
])
presentNavigationOptions(for: target)
}
@objc private func shareTapped() {
viewModel.shareSelectedClue()
showLoading()
Task { [weak self] in
guard let self else { return }
let payload = await self.viewModel.shareSelectedClue(api: self.api)
await MainActor.run {
self.hideLoading()
guard let payload else { return }
WeChatShareService.shareMiniProgram(payload, previewImage: UIImage(named: "report_share_cover")) { [weak self] result in
self?.showShareResult(result)
}
}
}
}
@objc private func locateButtonTapped() {
private func presentNavigationOptions(for target: WildReportMapMarker) {
let options = availableNavigationOptions(for: target)
let alert = UIAlertController(title: "选择导航软件", message: target.title, preferredStyle: .actionSheet)
options.forEach { option in
alert.addAction(UIAlertAction(title: option.title, style: .default) { [weak self] _ in
self?.openNavigation(option)
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
if let popover = alert.popoverPresentationController {
popover.sourceView = view
popover.sourceRect = CGRect(x: view.bounds.midX, y: view.bounds.maxY - 80, width: 1, height: 1)
}
present(alert, animated: true)
}
private func availableNavigationOptions(for target: WildReportMapMarker) -> [WildReportNavigationOption] {
[
WildReportNavigationOption(title: "Apple 地图", url: navigationURL(for: .apple, target: target)),
WildReportNavigationOption(title: "高德地图", url: navigationURL(for: .amap, target: target)),
WildReportNavigationOption(title: "百度地图", url: navigationURL(for: .baidu, target: target)),
WildReportNavigationOption(title: "腾讯地图", url: navigationURL(for: .tencent, target: target)),
WildReportNavigationOption(title: "Google Maps", url: navigationURL(for: .google, target: target))
].compactMap { option in
guard option.url != nil else { return nil }
return option
}
}
private func openNavigation(_ option: WildReportNavigationOption) {
guard let url = option.url else { return }
UIApplication.shared.open(url) { [weak self] success in
guard !success else { return }
Task { @MainActor in
self?.showToast("未安装\(option.title)或无法打开")
}
}
}
private func navigationURL(for app: WildReportNavigationApp, target: WildReportMapMarker) -> URL? {
let latitude = target.coordinate.latitude
let longitude = target.coordinate.longitude
let name = encodedNavigationText(target.title)
switch app {
case .apple:
return URL(string: "http://maps.apple.com/?daddr=\(latitude),\(longitude)&dirflg=d&q=\(name)")
case .amap:
return URL(string: "iosamap://path?sourceApplication=suixinkan&dlat=\(latitude)&dlon=\(longitude)&dname=\(name)&dev=0&t=0")
case .baidu:
return URL(string: "baidumap://map/direction?destination=latlng:\(latitude),\(longitude)|name:\(name)&mode=driving&coord_type=wgs84")
case .tencent:
return URL(string: "qqmap://map/routeplan?type=drive&tocoord=\(latitude),\(longitude)&to=\(name)&referer=suixinkan")
case .google:
return URL(string: "comgooglemaps://?daddr=\(latitude),\(longitude)&directionsmode=driving")
}
}
private func encodedNavigationText(_ text: String) -> String {
var allowed = CharacterSet.urlQueryAllowed
allowed.remove(charactersIn: "&=?")
return text.addingPercentEncoding(withAllowedCharacters: allowed) ?? text
}
@MainActor
private func showShareResult(_ result: WeChatShareResult) {
switch result {
case .success:
showToast("分享成功")
case .cancelled:
showToast("已取消分享")
case .notInstalled:
showToast("请先安装微信")
case .unsupported:
showToast("当前微信版本不支持分享")
case .invalidPayload(let message):
showToast(message)
case .sendFailed:
showToast("微信分享发送失败")
case .unknown:
showToast("微信分享失败")
}
}
private func zoomMap(by multiplier: CLLocationDegrees) {
let visibleRegion = mapView.region
let span = MKCoordinateSpan(
latitudeDelta: min(max(visibleRegion.span.latitudeDelta * multiplier, 0.001), 180),
longitudeDelta: min(max(visibleRegion.span.longitudeDelta * multiplier, 0.001), 180)
)
mapView.setRegion(MKCoordinateRegion(center: visibleRegion.center, span: span), animated: true)
}
private func focusCurrentLocation() {
viewModel.focusCurrentLocation()
}
}
private struct WildReportNavigationOption {
let title: String
let url: URL?
}
private enum WildReportNavigationApp {
case apple
case amap
case baidu
case tencent
case google
}
extension WildReportRiskMapViewController: MKMapViewDelegate {
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
guard let annotation = annotation as? WildReportMapAnnotation else { return nil }
@ -1130,6 +1261,8 @@ private final class WildReportRiskLegendView: UIView {
///
private final class WildReportRiskStatusBadge: UILabel {
private let contentInsets = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10)
init(text: String, color: UIColor) {
super.init(frame: .zero)
self.text = text
@ -1142,13 +1275,26 @@ private final class WildReportRiskStatusBadge: UILabel {
numberOfLines = 1
adjustsFontSizeToFitWidth = true
minimumScaleFactor = 0.8
setContentHuggingPriority(.required, for: .horizontal)
setContentCompressionResistancePriority(.required, for: .horizontal)
snp.makeConstraints { make in
make.height.equalTo(24)
make.width.greaterThanOrEqualTo(64)
make.width.lessThanOrEqualTo(96)
make.width.greaterThanOrEqualTo(52)
}
}
override var intrinsicContentSize: CGSize {
let size = super.intrinsicContentSize
return CGSize(
width: size.width + contentInsets.left + contentInsets.right,
height: max(24, size.height + contentInsets.top + contentInsets.bottom)
)
}
override func drawText(in rect: CGRect) {
super.drawText(in: rect.inset(by: contentInsets))
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")