修复任务提交与弹窗交互问题

This commit is contained in:
2026-07-10 14:51:34 +08:00
parent df547a16dc
commit f88a85a807
48 changed files with 1065 additions and 254 deletions

View File

@ -4,6 +4,14 @@
"filename" : "icon_cloud_storage_add_folder.png", "filename" : "icon_cloud_storage_add_folder.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info" : { "info" : {

View File

@ -4,6 +4,14 @@
"filename" : "icon_cloud_storage_add_photo.png", "filename" : "icon_cloud_storage_add_photo.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info" : { "info" : {

View File

@ -4,6 +4,14 @@
"filename" : "icon_cloud_storage_add_video.png", "filename" : "icon_cloud_storage_add_video.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info" : { "info" : {

View File

@ -4,6 +4,14 @@
"filename" : "icon_cloud_storage_current_path_arrow.png", "filename" : "icon_cloud_storage_current_path_arrow.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info" : { "info" : {

View File

@ -4,6 +4,14 @@
"filename" : "icon_cloud_storage_file_delete.png", "filename" : "icon_cloud_storage_file_delete.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info" : { "info" : {

View File

@ -4,6 +4,14 @@
"filename" : "icon_cloud_storage_file_details.png", "filename" : "icon_cloud_storage_file_details.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info" : { "info" : {

View File

@ -4,6 +4,14 @@
"filename" : "icon_cloud_storage_file_download.png", "filename" : "icon_cloud_storage_file_download.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info" : { "info" : {

View File

@ -4,6 +4,14 @@
"filename" : "icon_cloud_storage_file_filter.png", "filename" : "icon_cloud_storage_file_filter.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info" : { "info" : {

View File

@ -4,6 +4,14 @@
"filename" : "icon_cloud_storage_file_item_details.png", "filename" : "icon_cloud_storage_file_item_details.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info" : { "info" : {

View File

@ -4,6 +4,14 @@
"filename" : "icon_cloud_storage_file_move.png", "filename" : "icon_cloud_storage_file_move.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info" : { "info" : {

View File

@ -4,6 +4,14 @@
"filename" : "icon_cloud_storage_file_play.png", "filename" : "icon_cloud_storage_file_play.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info" : { "info" : {

View File

@ -4,6 +4,14 @@
"filename" : "icon_cloud_storage_file_search.png", "filename" : "icon_cloud_storage_file_search.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info" : { "info" : {

View File

@ -4,6 +4,14 @@
"filename" : "icon_cloud_storage_file_share.png", "filename" : "icon_cloud_storage_file_share.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info" : { "info" : {

View File

@ -4,6 +4,14 @@
"filename" : "icon_cloud_storage_file_type_folder.png", "filename" : "icon_cloud_storage_file_type_folder.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info" : { "info" : {

View File

@ -4,6 +4,14 @@
"filename" : "icon_cloud_storage_file_type_photo.png", "filename" : "icon_cloud_storage_file_type_photo.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info" : { "info" : {

View File

@ -4,6 +4,14 @@
"filename" : "icon_cloud_storage_file_type_video.png", "filename" : "icon_cloud_storage_file_type_video.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info" : { "info" : {

View File

@ -4,6 +4,14 @@
"filename" : "icon_cloud_storage_folder_type.png", "filename" : "icon_cloud_storage_folder_type.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info" : { "info" : {

View File

@ -4,6 +4,14 @@
"filename" : "icon_cloud_storage_go_home.png", "filename" : "icon_cloud_storage_go_home.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info" : { "info" : {

View File

@ -4,6 +4,14 @@
"filename" : "icon_cloud_storage_list_sort.png", "filename" : "icon_cloud_storage_list_sort.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info" : { "info" : {

View File

@ -4,6 +4,14 @@
"filename" : "icon_cloud_storage_show_type_list.png", "filename" : "icon_cloud_storage_show_type_list.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info" : { "info" : {

View File

@ -4,6 +4,14 @@
"filename" : "icon_cloud_storage_show_type_table.png", "filename" : "icon_cloud_storage_show_type_table.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info" : { "info" : {

View File

@ -4,6 +4,14 @@
"filename" : "icon_cloud_storage_transit.png", "filename" : "icon_cloud_storage_transit.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info" : { "info" : {

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "report_share_cover.jpg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@ -61,6 +61,14 @@ enum MaterialMediaType: Int, CaseIterable, Sendable, Equatable {
case .video: 1 case .video: 1
} }
} }
/// 1 2
var cloudFileType: Int {
switch self {
case .image: 2
case .video: 1
}
}
} }
/// Android `MaterialItemEntity` /// Android `MaterialItemEntity`

View File

@ -354,6 +354,48 @@ final class MaterialFormViewModel {
notifyStateChange() notifyStateChange()
} }
/// 使 URL
func addCloudMediaFiles(_ files: [CloudFile]) {
guard !files.isEmpty else { return }
let remaining = mediaType.maxCount - uploadedMediaList.count
guard remaining > 0 else {
onShowMessage?(mediaType == .video ? "视频只能上传1个" : "图片最多上传9张")
return
}
var seenCloudIDs = importedCloudFileIDs
var accepted: [MaterialUploadedMediaItem] = []
for file in files where accepted.count < remaining {
let fileURL = file.fileUrl.trimmingCharacters(in: .whitespacesAndNewlines)
guard file.type == mediaType.cloudFileType, !fileURL.isEmpty, !seenCloudIDs.contains(file.id) else {
continue
}
seenCloudIDs.insert(file.id)
accepted.append(
MaterialUploadedMediaItem(
id: "cloud_\(file.id)",
data: Data(),
thumbnailData: nil,
previewURL: file.coverUrl.isEmpty ? fileURL : file.coverUrl,
fileName: file.name.isEmpty ? "cloud_file_\(file.id)" : file.name,
size: file.fileSize,
width: 0,
height: 0,
ossUrl: fileURL,
isUploading: false,
uploadProgress: 100
)
)
}
guard !accepted.isEmpty else {
onShowMessage?(mediaType == .video ? "请选择视频素材" : "请选择图片素材")
return
}
uploadedMediaList.append(contentsOf: accepted)
notifyStateChange()
}
/// ///
func setCoverImage(_ item: MaterialUploadedMediaItem, uploader: any MaterialOSSUploading) async { func setCoverImage(_ item: MaterialUploadedMediaItem, uploader: any MaterialOSSUploading) async {
coverImage = item coverImage = item
@ -376,6 +418,16 @@ final class MaterialFormViewModel {
notifyStateChange() notifyStateChange()
} }
/// ID
var importedCloudFileIDs: Set<Int> {
Set(
uploadedMediaList.compactMap { item in
guard item.id.hasPrefix("cloud_") else { return nil }
return Int(item.id.dropFirst("cloud_".count))
}
)
}
/// ///
func submit(api: any MaterialManagementServing) async { func submit(api: any MaterialManagementServing) async {
guard !materialName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { guard !materialName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {

View File

@ -61,20 +61,6 @@ final class TaskAPI {
) )
} }
/// OSS URL
func registerUploadedFileURL(scenicId: Int, fileURL: String, folderId: Int = 0) async throws {
let _: EmptyResponse = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/album/file-upload-url",
queryItems: [
URLQueryItem(name: "scenic_id", value: String(scenicId)),
URLQueryItem(name: "file_url", value: fileURL),
URLQueryItem(name: "folder_id", value: String(folderId)),
]
)
)
}
} }
/// data /// data

View File

@ -177,4 +177,14 @@ struct TaskFileItem: Sendable, Equatable, Hashable, Identifiable {
return uploadFileName ?? "" return uploadFileName ?? ""
} }
} }
///
var sourceBadgeText: String {
switch source {
case .cloud:
return "云盘"
case .upload:
return "本地"
}
}
} }

View File

@ -25,9 +25,13 @@ final class CloudStoragePickForTaskViewModel {
private var totalCount = 0 private var totalCount = 0
private let pageSize = 20 private let pageSize = 20
private let importedFileIDs: Set<Int> private let importedFileIDs: Set<Int>
private let allowedFileType: Int?
init(importedFileIDs: Set<Int> = []) { /// ViewModel
init(importedFileIDs: Set<Int> = [], allowedFileType: Int? = nil) {
self.importedFileIDs = importedFileIDs self.importedFileIDs = importedFileIDs
self.allowedFileType = allowedFileType
self.filterType = allowedFileType ?? 0
} }
/// ID /// ID
@ -94,6 +98,10 @@ final class CloudStoragePickForTaskViewModel {
return return
} }
guard file.isSelectableMedia else { return } guard file.isSelectableMedia else { return }
if let allowedFileType, file.type != allowedFileType {
onShowMessage?(allowedFileType == 1 ? "请选择视频素材" : "请选择图片素材")
return
}
if isImported(file.id) { if isImported(file.id) {
onShowMessage?("已选择") onShowMessage?("已选择")
return return

View File

@ -139,16 +139,6 @@ final class WildPhotographerReportSubmitViewModel {
return "当前定位:\(reportLocation)" return "当前定位:\(reportLocation)"
} }
var locationSubtitleText: String {
if isUpdatingLocation {
return "正在更新定位,请稍候"
}
if locationConfirmed {
return "已获取最新定位,如不准确请点击更新定位"
}
return "如位置不准确,请点击更新定位"
}
init( init(
homeViewModel: WildPhotographerReportHomeViewModel, homeViewModel: WildPhotographerReportHomeViewModel,
locationProvider: any LocationProviding = LocationProvider.shared locationProvider: any LocationProviding = LocationProvider.shared
@ -358,12 +348,13 @@ final class WildPhotographerReportSubmitViewModel {
let address = snapshot.address.isEmpty let address = snapshot.address.isEmpty
? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude) ? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude)
: snapshot.address : snapshot.address
let reportAddress = Self.simplifiedReportLocation(address)
locationSnapshot = HomeLocationSnapshot( locationSnapshot = HomeLocationSnapshot(
latitude: snapshot.latitude, latitude: snapshot.latitude,
longitude: snapshot.longitude, longitude: snapshot.longitude,
address: address address: reportAddress
) )
reportLocation = address reportLocation = reportAddress
locationConfirmed = true locationConfirmed = true
isUpdatingLocation = false isUpdatingLocation = false
notifyStateChange() notifyStateChange()
@ -393,6 +384,40 @@ final class WildPhotographerReportSubmitViewModel {
return types.filter { seen.insert($0.value).inserted } return types.filter { seen.insert($0.value).inserted }
} }
private static func simplifiedReportLocation(_ address: String) -> String {
let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return trimmed }
let detail = WildReportLocationParser.split(trimmed).detail
let simplified = removeAdministrativePrefix(from: detail)
return simplified.isEmpty ? trimmed : simplified
}
private static func removeAdministrativePrefix(from address: String) -> String {
var text = address
removeLeadingRegion(from: &text, matches: [
"北京市", "天津市", "上海市", "重庆市",
"内蒙古自治区", "广西壮族自治区", "西藏自治区", "宁夏回族自治区", "新疆维吾尔自治区"
])
removeLeadingRegion(from: &text, suffixes: [""], maxLength: 8)
removeLeadingRegion(from: &text, suffixes: ["自治州", "地区", "", ""], maxLength: 12)
removeLeadingRegion(from: &text, suffixes: ["自治县", "新区", "", "", "", ""], maxLength: 12)
return text.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func removeLeadingRegion(from text: inout String, matches: [String]) {
guard let match = matches.first(where: { text.hasPrefix($0) }) else { return }
text.removeFirst(match.count)
}
private static func removeLeadingRegion(from text: inout String, suffixes: [String], maxLength: Int) {
guard let match = suffixes.compactMap({ suffix -> String? in
guard let range = text.range(of: suffix) else { return nil }
let prefix = String(text[..<range.upperBound])
return prefix.count <= maxLength ? prefix : nil
}).min(by: { $0.count < $1.count }) else { return }
text.removeFirst(match.count)
}
private var evidenceCount: Int { private var evidenceCount: Int {
images.count + videos.count images.count + videos.count
} }

View File

@ -0,0 +1,105 @@
//
// KeyboardAvoidingDialogView.swift
// suixinkan
//
import SnapKit
import UIKit
///
class KeyboardAvoidingDialogView: UIView {
///
var keyboardAvoidingContentView: UIView?
/// centerY
var keyboardAvoidingCenterYConstraint: Constraint?
///
var keyboardAvoidingMinimumSpacing: CGFloat = AppSpacing.md
private var keyboardObservers: [NSObjectProtocol] = []
override init(frame: CGRect) {
super.init(frame: frame)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
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

@ -630,7 +630,7 @@ final class CooperationAcquirerCell: UITableViewCell {
} }
/// Android `CommissionRateEditDialog` /// Android `CommissionRateEditDialog`
final class CooperationCommissionRateDialogView: UIView { final class CooperationCommissionRateDialogView: KeyboardAvoidingDialogView {
var onCommissionRateChange: ((String) -> Void)? var onCommissionRateChange: ((String) -> Void)?
var onSmsCodeChange: ((String) -> Void)? var onSmsCodeChange: ((String) -> Void)?
@ -654,8 +654,6 @@ final class CooperationCommissionRateDialogView: UIView {
private let buttonStack = UIStackView() private let buttonStack = UIStackView()
private let cancelButton = UIButton(type: .system) private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system) private let confirmButton = UIButton(type: .system)
private var cardCenterYConstraint: Constraint?
private var keyboardObservers: [NSObjectProtocol] = []
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
@ -667,10 +665,6 @@ final class CooperationCommissionRateDialogView: UIView {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
deinit {
unregisterKeyboardObservers()
}
func apply(commissionRate: String, smsCode: String, phone: String, countdown: Int) { func apply(commissionRate: String, smsCode: String, phone: String, countdown: Int) {
rateField.text = commissionRate rateField.text = commissionRate
smsCodeField.text = smsCode smsCodeField.text = smsCode
@ -690,7 +684,6 @@ final class CooperationCommissionRateDialogView: UIView {
alpha = 0 alpha = 0
parent.addSubview(self) parent.addSubview(self)
layoutIfNeeded() layoutIfNeeded()
registerKeyboardObservers()
rateField.focus() rateField.focus()
UIView.animate(withDuration: 0.2) { UIView.animate(withDuration: 0.2) {
self.alpha = 1 self.alpha = 1
@ -699,7 +692,6 @@ final class CooperationCommissionRateDialogView: UIView {
func dismiss() { func dismiss() {
endEditing(true) endEditing(true)
unregisterKeyboardObservers()
UIView.animate(withDuration: 0.2, animations: { UIView.animate(withDuration: 0.2, animations: {
self.alpha = 0 self.alpha = 0
}, completion: { _ in }, completion: { _ in
@ -708,6 +700,8 @@ final class CooperationCommissionRateDialogView: UIView {
} }
private func setupUI() { private func setupUI() {
keyboardAvoidingContentView = cardView
dimView.backgroundColor = AppColor.overlayScrim dimView.backgroundColor = AppColor.overlayScrim
dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped))) dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped)))
@ -780,7 +774,7 @@ final class CooperationCommissionRateDialogView: UIView {
} }
cardView.snp.makeConstraints { make in cardView.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
cardCenterYConstraint = make.centerY.equalToSuperview().constraint keyboardAvoidingCenterYConstraint = make.centerY.equalToSuperview().constraint
} }
titleLabel.snp.makeConstraints { make in titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.top.leading.trailing.equalToSuperview().inset(AppSpacing.md)
@ -815,67 +809,6 @@ final class CooperationCommissionRateDialogView: UIView {
return row return row
} }
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)
let offset = keyboardAvoidanceOffset(from: userInfo)
cardCenterYConstraint?.update(offset: offset)
UIView.animate(withDuration: duration, delay: 0, options: options) {
self.layoutIfNeeded()
}
}
private func keyboardAvoidanceOffset(from userInfo: [AnyHashable: Any]) -> CGFloat {
guard
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 cardHeight = cardView.bounds.height
let baseMinY = bounds.midY - cardHeight / 2
let baseMaxY = bounds.midY + cardHeight / 2
let desiredBottom = keyboardFrame.minY - AppSpacing.md
let neededShift = max(0, baseMaxY - desiredBottom)
let maxShift = max(0, baseMinY - AppSpacing.md)
return -min(neededShift, maxShift)
}
@objc private func sendCodeTapped() { @objc private func sendCodeTapped() {
onSendSmsCode?() onSendSmsCode?()
} }

View File

@ -267,10 +267,26 @@ final class MaterialFormViewController: BaseViewController {
sheet.addAction(UIAlertAction(title: "从相册选择", style: .default) { [weak self] _ in sheet.addAction(UIAlertAction(title: "从相册选择", style: .default) { [weak self] _ in
self?.presentPhotoPicker() self?.presentPhotoPicker()
}) })
if target == .material {
sheet.addAction(UIAlertAction(title: "从云盘导入", style: .default) { [weak self] _ in
self?.openCloudPicker()
})
}
sheet.addAction(UIAlertAction(title: "取消", style: .cancel)) sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true) present(sheet, animated: true)
} }
private func openCloudPicker() {
let controller = CloudStoragePickForTaskViewController(
importedFileIDs: viewModel.importedCloudFileIDs,
allowedFileType: viewModel.mediaType.cloudFileType
)
controller.onConfirmed = { [weak self] files in
self?.viewModel.addCloudMediaFiles(files)
}
navigationController?.pushViewController(controller, animated: true)
}
private func presentPhotoPicker() { private func presentPhotoPicker() {
var configuration = PHPickerConfiguration(photoLibrary: .shared()) var configuration = PHPickerConfiguration(photoLibrary: .shared())
if pickTarget == .cover { if pickTarget == .cover {

View File

@ -220,7 +220,18 @@ private extension String {
} }
/// 退 Android `OrderRefundDialog` / `DepositOrderRefundDialog` /// 退 Android `OrderRefundDialog` / `DepositOrderRefundDialog`
final class OrderRefundDialogView: UIView, UITextViewDelegate { final class OrderRefundDialogView: KeyboardAvoidingDialogView, UITextViewDelegate {
/// 退
private enum Layout {
static let sectionGap: CGFloat = AppSpacing.lg
static let formGap: CGFloat = AppSpacing.md
static let controlHeight: CGFloat = AppSpacing.minTouchTarget
static let amountGroupHeight: CGFloat = 67
static let remarkHeight: CGFloat = 88
static let actionTop: CGFloat = 14
static let bottomInset: CGFloat = AppSpacing.md
}
/// 退/ /// 退/
struct Configuration { struct Configuration {
@ -266,8 +277,6 @@ final class OrderRefundDialogView: UIView, UITextViewDelegate {
private let configuration: Configuration private let configuration: Configuration
private var selectedMode: OrderRefundMode private var selectedMode: OrderRefundMode
private var keyboardObservers: [NSObjectProtocol] = []
private var cardCenterYConstraint: Constraint?
private let dimView = UIView() private let dimView = UIView()
private let cardView = UIView() private let cardView = UIView()
@ -304,16 +313,11 @@ final class OrderRefundDialogView: UIView, UITextViewDelegate {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
deinit {
unregisterKeyboardObservers()
}
func show(in parent: UIView) { func show(in parent: UIView) {
frame = parent.bounds frame = parent.bounds
autoresizingMask = [.flexibleWidth, .flexibleHeight] autoresizingMask = [.flexibleWidth, .flexibleHeight]
alpha = 0 alpha = 0
parent.addSubview(self) parent.addSubview(self)
registerKeyboardObservers()
UIView.animate(withDuration: 0.2) { UIView.animate(withDuration: 0.2) {
self.alpha = 1 self.alpha = 1
} }
@ -321,7 +325,6 @@ final class OrderRefundDialogView: UIView, UITextViewDelegate {
func dismiss() { func dismiss() {
endEditing(true) endEditing(true)
unregisterKeyboardObservers()
UIView.animate(withDuration: 0.2, animations: { UIView.animate(withDuration: 0.2, animations: {
self.alpha = 0 self.alpha = 0
}, completion: { _ in }, completion: { _ in
@ -330,6 +333,9 @@ final class OrderRefundDialogView: UIView, UITextViewDelegate {
} }
private func setupUI() { private func setupUI() {
keyboardAvoidingContentView = cardView
keyboardAvoidingMinimumSpacing = AppSpacing.sm
dimView.backgroundColor = UIColor.black.withAlphaComponent(0.2) dimView.backgroundColor = UIColor.black.withAlphaComponent(0.2)
dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped))) dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped)))
@ -422,7 +428,7 @@ final class OrderRefundDialogView: UIView, UITextViewDelegate {
} }
cardView.snp.makeConstraints { make in cardView.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
cardCenterYConstraint = make.centerY.equalToSuperview().constraint keyboardAvoidingCenterYConstraint = make.centerY.equalToSuperview().constraint
} }
titleLabel.snp.makeConstraints { make in titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(AppSpacing.sm) make.top.equalToSuperview().offset(AppSpacing.sm)
@ -442,18 +448,18 @@ final class OrderRefundDialogView: UIView, UITextViewDelegate {
make.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
} }
refundModeTitleLabel.snp.makeConstraints { make in refundModeTitleLabel.snp.makeConstraints { make in
make.top.equalTo(orderNumberLabel.snp.bottom).offset(AppSpacing.xl) make.top.equalTo(orderNumberLabel.snp.bottom).offset(Layout.sectionGap)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
} }
modeButtonStack.snp.makeConstraints { make in modeButtonStack.snp.makeConstraints { make in
make.top.equalTo(refundModeTitleLabel.snp.bottom).offset(AppSpacing.xs) make.top.equalTo(refundModeTitleLabel.snp.bottom).offset(AppSpacing.xs)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(46) make.height.equalTo(Layout.controlHeight)
} }
amountGroupView.snp.makeConstraints { make in amountGroupView.snp.makeConstraints { make in
make.top.equalTo(modeButtonStack.snp.bottom).offset(AppSpacing.xl) make.top.equalTo(modeButtonStack.snp.bottom).offset(Layout.formGap)
make.leading.trailing.equalToSuperview() make.leading.trailing.equalToSuperview()
make.height.equalTo(73) make.height.equalTo(Layout.amountGroupHeight)
} }
amountTitleLabel.snp.makeConstraints { make in amountTitleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(UIEdgeInsets(top: 0, left: AppSpacing.md, bottom: 0, right: AppSpacing.md)) make.top.leading.trailing.equalToSuperview().inset(UIEdgeInsets(top: 0, left: AppSpacing.md, bottom: 0, right: AppSpacing.md))
@ -461,20 +467,20 @@ final class OrderRefundDialogView: UIView, UITextViewDelegate {
amountContainerView.snp.makeConstraints { make in amountContainerView.snp.makeConstraints { make in
make.top.equalTo(amountTitleLabel.snp.bottom).offset(AppSpacing.xs) make.top.equalTo(amountTitleLabel.snp.bottom).offset(AppSpacing.xs)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(46) make.height.equalTo(Layout.controlHeight)
} }
amountField.snp.makeConstraints { make in amountField.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(AppSpacing.sm) make.leading.trailing.equalToSuperview().inset(AppSpacing.sm)
make.centerY.equalToSuperview() make.centerY.equalToSuperview()
} }
remarkTitleLabel.snp.makeConstraints { make in remarkTitleLabel.snp.makeConstraints { make in
make.top.equalTo(amountGroupView.snp.bottom).offset(AppSpacing.xl) make.top.equalTo(amountGroupView.snp.bottom).offset(Layout.formGap)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
} }
remarkContainerView.snp.makeConstraints { make in remarkContainerView.snp.makeConstraints { make in
make.top.equalTo(remarkTitleLabel.snp.bottom).offset(AppSpacing.xs) make.top.equalTo(remarkTitleLabel.snp.bottom).offset(AppSpacing.xs)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(104) make.height.equalTo(Layout.remarkHeight)
} }
remarkTextView.snp.makeConstraints { make in remarkTextView.snp.makeConstraints { make in
make.edges.equalToSuperview() make.edges.equalToSuperview()
@ -484,9 +490,9 @@ final class OrderRefundDialogView: UIView, UITextViewDelegate {
make.leading.trailing.equalToSuperview().inset(AppSpacing.sm) make.leading.trailing.equalToSuperview().inset(AppSpacing.sm)
} }
actionRow.snp.makeConstraints { make in actionRow.snp.makeConstraints { make in
make.top.equalTo(remarkContainerView.snp.bottom).offset(20) make.top.equalTo(remarkContainerView.snp.bottom).offset(Layout.actionTop)
make.trailing.equalToSuperview().inset(AppSpacing.md) make.trailing.equalToSuperview().inset(AppSpacing.md)
make.bottom.equalToSuperview().inset(AppSpacing.lg) make.bottom.equalToSuperview().inset(Layout.bottomInset)
} }
[cancelButton, confirmButton].forEach { button in [cancelButton, confirmButton].forEach { button in
button.snp.makeConstraints { make in button.snp.makeConstraints { make in
@ -521,10 +527,10 @@ final class OrderRefundDialogView: UIView, UITextViewDelegate {
let amountHidden = selectedMode == .full let amountHidden = selectedMode == .full
amountGroupView.isHidden = amountHidden amountGroupView.isHidden = amountHidden
amountGroupView.snp.updateConstraints { make in amountGroupView.snp.updateConstraints { make in
make.height.equalTo(amountHidden ? 0 : 73) make.height.equalTo(amountHidden ? 0 : Layout.amountGroupHeight)
} }
remarkTitleLabel.snp.remakeConstraints { make in remarkTitleLabel.snp.remakeConstraints { make in
make.top.equalTo((amountHidden ? modeButtonStack : amountGroupView).snp.bottom).offset(AppSpacing.xl) make.top.equalTo((amountHidden ? modeButtonStack : amountGroupView).snp.bottom).offset(Layout.formGap)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
} }
@ -544,60 +550,6 @@ final class OrderRefundDialogView: UIView, UITextViewDelegate {
button.setTitleColor(selected ? .white : UIColor(hex: 0x4B5563), for: .normal) button.setTitleColor(selected ? .white : UIColor(hex: 0x4B5563), for: .normal)
} }
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)
let offset = keyboardAvoidanceOffset(from: userInfo)
cardCenterYConstraint?.update(offset: offset)
UIView.animate(withDuration: duration, delay: 0, options: options) {
self.layoutIfNeeded()
}
}
private func keyboardAvoidanceOffset(from userInfo: [AnyHashable: Any]) -> CGFloat {
guard
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 desiredBottom = keyboardFrame.minY - AppSpacing.sm
let overlap = cardView.frame.maxY - desiredBottom
return overlap > 0 ? -overlap : 0
}
func textViewDidChange(_ textView: UITextView) { func textViewDidChange(_ textView: UITextView) {
if textView.text.count > 255 { if textView.text.count > 255 {
textView.text = String(textView.text.prefix(255)) textView.text = String(textView.text.prefix(255))

View File

@ -17,6 +17,7 @@ final class OrderSourceEntryView: UIControl {
private let personRow = UIStackView() private let personRow = UIStackView()
private let personNameLabel = UILabel() private let personNameLabel = UILabel()
private let personPhoneLabel = UILabel() private let personPhoneLabel = UILabel()
private let personSpacerView = UIView()
private let divider = UIView() private let divider = UIView()
private let leadContentView = OrderSourceLeadContentView() private let leadContentView = OrderSourceLeadContentView()
private var currentImages: [String] = [] private var currentImages: [String] = []
@ -83,10 +84,19 @@ final class OrderSourceEntryView: UIControl {
personRow.spacing = 10 personRow.spacing = 10
personNameLabel.font = .systemFont(ofSize: 13, weight: .medium) personNameLabel.font = .systemFont(ofSize: 13, weight: .medium)
personNameLabel.textColor = AppColor.text333 personNameLabel.textColor = AppColor.text333
personNameLabel.lineBreakMode = .byTruncatingTail
personNameLabel.setContentHuggingPriority(.required, for: .horizontal)
personNameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
personPhoneLabel.font = .systemFont(ofSize: 13) personPhoneLabel.font = .systemFont(ofSize: 13)
personPhoneLabel.textColor = AppColor.text666 personPhoneLabel.textColor = AppColor.text666
personPhoneLabel.lineBreakMode = .byTruncatingTail
personPhoneLabel.setContentHuggingPriority(.required, for: .horizontal)
personPhoneLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
personSpacerView.setContentHuggingPriority(.defaultLow, for: .horizontal)
personSpacerView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
personRow.addArrangedSubview(personNameLabel) personRow.addArrangedSubview(personNameLabel)
personRow.addArrangedSubview(personPhoneLabel) personRow.addArrangedSubview(personPhoneLabel)
personRow.addArrangedSubview(personSpacerView)
divider.backgroundColor = OrderTokens.sourceDivider divider.backgroundColor = OrderTokens.sourceDivider

View File

@ -15,6 +15,7 @@ final class OrderSourceLeadContentView: UIView {
private let phoneRow = UIStackView() private let phoneRow = UIStackView()
private let phoneTitleLabel = UILabel() private let phoneTitleLabel = UILabel()
private let phoneValueLabel = UILabel() private let phoneValueLabel = UILabel()
private let phoneSpacerView = UIView()
private let remarkLabel = UILabel() private let remarkLabel = UILabel()
private let imageRow = UIStackView() private let imageRow = UIStackView()
private let emptyImageLabel = UILabel() private let emptyImageLabel = UILabel()
@ -91,12 +92,20 @@ final class OrderSourceLeadContentView: UIView {
phoneTitleLabel.text = "客户手机号" phoneTitleLabel.text = "客户手机号"
phoneTitleLabel.font = .systemFont(ofSize: 12) phoneTitleLabel.font = .systemFont(ofSize: 12)
phoneTitleLabel.textColor = AppColor.text666 phoneTitleLabel.textColor = AppColor.text666
phoneTitleLabel.setContentHuggingPriority(.required, for: .horizontal)
phoneTitleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
phoneValueLabel.font = .systemFont(ofSize: 13, weight: .medium) phoneValueLabel.font = .systemFont(ofSize: 13, weight: .medium)
phoneValueLabel.lineBreakMode = .byTruncatingTail
phoneValueLabel.setContentHuggingPriority(.required, for: .horizontal)
phoneValueLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
phoneValueLabel.addGestureRecognizer( phoneValueLabel.addGestureRecognizer(
UITapGestureRecognizer(target: self, action: #selector(phoneTapped)) UITapGestureRecognizer(target: self, action: #selector(phoneTapped))
) )
phoneSpacerView.setContentHuggingPriority(.defaultLow, for: .horizontal)
phoneSpacerView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
phoneRow.addArrangedSubview(phoneTitleLabel) phoneRow.addArrangedSubview(phoneTitleLabel)
phoneRow.addArrangedSubview(phoneValueLabel) phoneRow.addArrangedSubview(phoneValueLabel)
phoneRow.addArrangedSubview(phoneSpacerView)
remarkLabel.textColor = AppColor.text333 remarkLabel.textColor = AppColor.text333
remarkLabel.lineBreakMode = .byTruncatingTail remarkLabel.lineBreakMode = .byTruncatingTail

View File

@ -7,7 +7,7 @@ import SnapKit
import UIKit import UIKit
/// Android `SetAmountDialog` /// Android `SetAmountDialog`
final class PaymentSetAmountDialogView: UIView { final class PaymentSetAmountDialogView: KeyboardAvoidingDialogView {
var onConfirm: (() -> Void)? var onConfirm: (() -> Void)?
var onCancel: (() -> Void)? var onCancel: (() -> Void)?
@ -52,6 +52,8 @@ final class PaymentSetAmountDialogView: UIView {
autoresizingMask = [.flexibleWidth, .flexibleHeight] autoresizingMask = [.flexibleWidth, .flexibleHeight]
alpha = 0 alpha = 0
parent.addSubview(self) parent.addSubview(self)
layoutIfNeeded()
amountField.becomeFirstResponder()
UIView.animate(withDuration: 0.2) { UIView.animate(withDuration: 0.2) {
self.alpha = 1 self.alpha = 1
} }
@ -67,6 +69,8 @@ final class PaymentSetAmountDialogView: UIView {
} }
private func setupUI() { private func setupUI() {
keyboardAvoidingContentView = cardView
dimView.backgroundColor = AppColor.overlayScrim dimView.backgroundColor = AppColor.overlayScrim
dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped))) dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped)))
@ -146,7 +150,7 @@ final class PaymentSetAmountDialogView: UIView {
} }
cardView.snp.makeConstraints { make in cardView.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.centerY.equalToSuperview() keyboardAvoidingCenterYConstraint = make.centerY.equalToSuperview().constraint
} }
titleLabel.snp.makeConstraints { make in titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.top.leading.trailing.equalToSuperview().inset(AppSpacing.md)

View File

@ -17,6 +17,11 @@ final class StatisticsPeriodButton: UIButton {
didSet { updateAppearance() } didSet { updateAppearance() }
} }
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = bounds.height / 2
}
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
setupUI() setupUI()
@ -29,7 +34,7 @@ final class StatisticsPeriodButton: UIButton {
private func setupUI() { private func setupUI() {
titleLabel?.font = .app(.bodyMedium) titleLabel?.font = .app(.bodyMedium)
layer.cornerRadius = AppRadius.pill layer.cornerCurve = .continuous
clipsToBounds = true clipsToBounds = true
contentEdgeInsets = UIEdgeInsets(top: 6, left: 20, bottom: 6, right: 20) contentEdgeInsets = UIEdgeInsets(top: 6, left: 20, bottom: 6, right: 20)
updateAppearance() updateAppearance()

View File

@ -23,8 +23,9 @@ final class CloudStoragePickForTaskViewController: BaseViewController {
private let confirmButton = AppButton(title: "确定") private let confirmButton = AppButton(title: "确定")
private let bottomBar = UIView() private let bottomBar = UIView()
init(importedFileIDs: Set<Int> = []) { ///
viewModel = CloudStoragePickForTaskViewModel(importedFileIDs: importedFileIDs) init(importedFileIDs: Set<Int> = [], allowedFileType: Int? = nil) {
viewModel = CloudStoragePickForTaskViewModel(importedFileIDs: importedFileIDs, allowedFileType: allowedFileType)
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
@ -293,6 +294,7 @@ private final class CloudPickCell: UICollectionViewCell {
imageView.image = nil imageView.image = nil
folderIconView.isHidden = true folderIconView.isHidden = true
badgeView.isHidden = true badgeView.isHidden = true
contentView.alpha = 1
} }
func apply(file: CloudFile, isSelected: Bool, isImported: Bool) { func apply(file: CloudFile, isSelected: Bool, isImported: Bool) {
@ -302,6 +304,7 @@ private final class CloudPickCell: UICollectionViewCell {
badgeView.isHidden = !isSelected badgeView.isHidden = !isSelected
if file.isFolder { if file.isFolder {
contentView.alpha = 1
imageView.image = nil imageView.image = nil
folderIconView.isHidden = false folderIconView.isHidden = false
return return

View File

@ -16,9 +16,11 @@ final class TaskAddViewController: BaseViewController {
private let ossUploadService = NetworkServices.shared.ossUploadService private let ossUploadService = NetworkServices.shared.ossUploadService
private let scrollView = UIScrollView() private let scrollView = UIScrollView()
private let formCardView = UIView()
private let contentStack = UIStackView() private let contentStack = UIStackView()
private let mediaSection = TaskAddMediaSectionView() private let mediaSection = TaskAddMediaSectionView()
private let prioritySection = TaskPrioritySectionView() private let prioritySection = TaskPrioritySectionView()
private let orderTitleLabel = UILabel()
private let orderButton = TaskOrderSelectButton() private let orderButton = TaskOrderSelectButton()
private let nameField = TaskBorderTextField(placeholder: "请输入任务标题") private let nameField = TaskBorderTextField(placeholder: "请输入任务标题")
private let detailsField = TaskBorderTextView(placeholder: "请输入任务描述(必填)") private let detailsField = TaskBorderTextView(placeholder: "请输入任务描述(必填)")
@ -26,6 +28,7 @@ final class TaskAddViewController: BaseViewController {
private let bottomBar = UIView() private let bottomBar = UIView()
private var remarkDialog: TaskFileRemarkDialogView? private var remarkDialog: TaskFileRemarkDialogView?
private var uploadTypeSheet: TaskUploadTypeSheetView?
private var pickingMediaType: Int = 2 private var pickingMediaType: Int = 2
private var detailsPlaceholder = "请输入任务描述(必填)" private var detailsPlaceholder = "请输入任务描述(必填)"
@ -34,37 +37,56 @@ final class TaskAddViewController: BaseViewController {
} }
override func setupUI() { override func setupUI() {
view.backgroundColor = AppColor.pageBackground
scrollView.backgroundColor = AppColor.pageBackground
scrollView.keyboardDismissMode = .interactive
formCardView.backgroundColor = .white
formCardView.layer.cornerRadius = AppRadius.lg
contentStack.axis = .vertical contentStack.axis = .vertical
contentStack.spacing = AppSpacing.sm contentStack.spacing = AppSpacing.md
orderTitleLabel.text = "关联订单(可选)"
orderTitleLabel.font = .app(.bodyMedium)
orderTitleLabel.textColor = AppColor.textPrimary
bottomBar.backgroundColor = .white bottomBar.backgroundColor = .white
view.addSubview(scrollView) view.addSubview(scrollView)
view.addSubview(bottomBar) view.addSubview(bottomBar)
scrollView.addSubview(contentStack) scrollView.addSubview(formCardView)
formCardView.addSubview(contentStack)
contentStack.addArrangedSubview(mediaSection) contentStack.addArrangedSubview(mediaSection)
contentStack.addArrangedSubview(prioritySection) contentStack.addArrangedSubview(prioritySection)
contentStack.addArrangedSubview(orderTitleLabel)
contentStack.addArrangedSubview(orderButton) contentStack.addArrangedSubview(orderButton)
contentStack.addArrangedSubview(nameField) contentStack.addArrangedSubview(nameField)
contentStack.addArrangedSubview(detailsField) contentStack.addArrangedSubview(detailsField)
contentStack.setCustomSpacing(AppSpacing.xxs, after: orderTitleLabel)
bottomBar.addSubview(saveButton) bottomBar.addSubview(saveButton)
} }
override func setupConstraints() { override func setupConstraints() {
bottomBar.snp.makeConstraints { make in bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview() make.leading.trailing.bottom.equalToSuperview()
make.height.equalTo(80)
} }
saveButton.snp.makeConstraints { make in saveButton.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.md) make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.centerY.equalToSuperview()
} }
scrollView.snp.makeConstraints { make in scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.bottom.equalTo(bottomBar.snp.top) make.bottom.equalTo(bottomBar.snp.top)
} }
contentStack.snp.makeConstraints { make in formCardView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.md) make.edges.equalToSuperview().inset(AppSpacing.md)
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.md * 2) make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.md * 2)
} }
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.md)
}
} }
override func bindActions() { override func bindActions() {
@ -91,6 +113,7 @@ final class TaskAddViewController: BaseViewController {
nameField.textField.addTarget(self, action: #selector(nameChanged), for: .editingChanged) nameField.textField.addTarget(self, action: #selector(nameChanged), for: .editingChanged)
detailsField.textView.delegate = self detailsField.textView.delegate = self
saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside) saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside)
applyViewModel()
} }
override func viewDidAppear(_ animated: Bool) { override func viewDidAppear(_ animated: Bool) {
@ -108,11 +131,15 @@ final class TaskAddViewController: BaseViewController {
if nameField.textField.text != viewModel.taskName { if nameField.textField.text != viewModel.taskName {
nameField.textField.text = viewModel.taskName nameField.textField.text = viewModel.taskName
} }
if detailsField.textView.textColor != AppColor.textTertiary, if viewModel.taskDetails.isEmpty, !detailsField.textView.isFirstResponder {
detailsField.textView.text != viewModel.taskDetails { detailsField.textView.text = detailsPlaceholder
detailsField.textView.textColor = AppColor.textTertiary
} else if detailsField.textView.text != viewModel.taskDetails {
detailsField.textView.text = viewModel.taskDetails detailsField.textView.text = viewModel.taskDetails
detailsField.textView.textColor = AppColor.textPrimary
} }
saveButton.isEnabled = !viewModel.isUploading saveButton.isEnabled = true
saveButton.alpha = viewModel.isUploading ? 0.6 : 1
if viewModel.showRemarkDialog, let file = viewModel.remarkTargetFile { if viewModel.showRemarkDialog, let file = viewModel.remarkTargetFile {
showRemarkDialog(file: file) showRemarkDialog(file: file)
@ -151,15 +178,30 @@ final class TaskAddViewController: BaseViewController {
} }
private func presentLocalImportSheet() { private func presentLocalImportSheet() {
let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) guard uploadTypeSheet == nil else { return }
sheet.addAction(UIAlertAction(title: "图片", style: .default) { [weak self] _ in let sheet = TaskUploadTypeSheetView(frame: view.bounds)
sheet.onChooseImage = { [weak self] in
self?.hideUploadTypeSheet()
self?.presentMediaPicker(isImage: true) self?.presentMediaPicker(isImage: true)
}) }
sheet.addAction(UIAlertAction(title: "视频", style: .default) { [weak self] _ in sheet.onChooseVideo = { [weak self] in
self?.hideUploadTypeSheet()
self?.presentMediaPicker(isImage: false) self?.presentMediaPicker(isImage: false)
}) }
sheet.addAction(UIAlertAction(title: "取消", style: .cancel)) sheet.onCancel = { [weak self] in
present(sheet, animated: true) self?.hideUploadTypeSheet()
}
uploadTypeSheet = sheet
view.addSubview(sheet)
sheet.snp.makeConstraints { make in make.edges.equalToSuperview() }
sheet.show()
}
private func hideUploadTypeSheet() {
uploadTypeSheet?.dismiss { [weak self] in
self?.uploadTypeSheet?.removeFromSuperview()
self?.uploadTypeSheet = nil
}
} }
private func presentMediaPicker(isImage: Bool) { private func presentMediaPicker(isImage: Bool) {
@ -257,7 +299,6 @@ final class TaskAddViewController: BaseViewController {
self?.viewModel.updateUploadProgress(uploadTaskId: uploadTaskId, progress: progress) self?.viewModel.updateUploadProgress(uploadTaskId: uploadTaskId, progress: progress)
} }
} }
try await taskAPI.registerUploadedFileURL(scenicId: scenicId, fileURL: fileURL, folderId: 0)
await MainActor.run { await MainActor.run {
self.viewModel.markUploadSucceeded( self.viewModel.markUploadSucceeded(
uploadTaskId: uploadTaskId, uploadTaskId: uploadTaskId,

View File

@ -17,16 +17,17 @@ final class TaskAddMediaSectionView: UIView {
private var files: [TaskFileItem] = [] private var files: [TaskFileItem] = []
private var collectionHeightConstraint: Constraint? private var collectionHeightConstraint: Constraint?
private let gridColumns = 3
private let gridSpacing: CGFloat = 8
private enum Section { case main } private enum Section { case main }
private enum Item: Hashable { case add; case file(String) } private enum Item: Hashable { case add; case file(String) }
private lazy var collectionView: UICollectionView = { private lazy var collectionView: UICollectionView = {
let layout = UICollectionViewCompositionalLayout { _, environment in let layout = UICollectionViewCompositionalLayout { [weak self] _, environment in
let columns = 3 let columns = self?.gridColumns ?? 3
let spacing: CGFloat = 8 let spacing = self?.gridSpacing ?? 8
let inset = AppSpacing.md * 2 let availableWidth = environment.container.effectiveContentSize.width
let availableWidth = environment.container.effectiveContentSize.width - inset
let totalSpacing = spacing * CGFloat(columns - 1) let totalSpacing = spacing * CGFloat(columns - 1)
let itemWidth = max(0, (availableWidth - totalSpacing) / CGFloat(columns)) let itemWidth = max(0, (availableWidth - totalSpacing) / CGFloat(columns))
let itemHeight = itemWidth / 1.77 let itemHeight = itemWidth / 1.77
@ -83,8 +84,7 @@ final class TaskAddMediaSectionView: UIView {
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
backgroundColor = .white backgroundColor = .clear
layer.cornerRadius = AppRadius.md
titleLabel.text = "添加图片" titleLabel.text = "添加图片"
titleLabel.font = .app(.bodyMedium) titleLabel.font = .app(.bodyMedium)
@ -93,11 +93,13 @@ final class TaskAddMediaSectionView: UIView {
tipsLabel.text = "(点击图片或视频可添加备注)" tipsLabel.text = "(点击图片或视频可添加备注)"
tipsLabel.font = .app(.caption) tipsLabel.font = .app(.caption)
tipsLabel.textColor = AppColor.textPrimary tipsLabel.textColor = AppColor.textPrimary
tipsLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
cloudButton.setTitle("云盘导入", for: .normal) cloudButton.setTitle("云盘导入", for: .normal)
cloudButton.setTitleColor(AppColor.primary, for: .normal) cloudButton.setTitleColor(AppColor.primary, for: .normal)
cloudButton.titleLabel?.font = .app(.caption) cloudButton.titleLabel?.font = .app(.caption)
cloudButton.addTarget(self, action: #selector(cloudTapped), for: .touchUpInside) cloudButton.addTarget(self, action: #selector(cloudTapped), for: .touchUpInside)
cloudButton.setContentCompressionResistancePriority(.required, for: .horizontal)
collectionView.register(TaskAddMediaCell.self, forCellWithReuseIdentifier: TaskAddMediaCell.reuseIdentifier) collectionView.register(TaskAddMediaCell.self, forCellWithReuseIdentifier: TaskAddMediaCell.reuseIdentifier)
collectionView.register(TaskAddAddMediaCell.self, forCellWithReuseIdentifier: TaskAddAddMediaCell.reuseIdentifier) collectionView.register(TaskAddAddMediaCell.self, forCellWithReuseIdentifier: TaskAddAddMediaCell.reuseIdentifier)
@ -109,20 +111,20 @@ final class TaskAddMediaSectionView: UIView {
addSubview(collectionView) addSubview(collectionView)
titleLabel.snp.makeConstraints { make in titleLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(AppSpacing.md) make.top.leading.equalToSuperview()
} }
tipsLabel.snp.makeConstraints { make in tipsLabel.snp.makeConstraints { make in
make.leading.equalTo(titleLabel.snp.trailing).offset(AppSpacing.xxs) make.leading.equalTo(titleLabel.snp.trailing).offset(AppSpacing.xxs)
make.centerY.equalTo(titleLabel) make.centerY.equalTo(titleLabel)
make.trailing.lessThanOrEqualTo(cloudButton.snp.leading).offset(-AppSpacing.xs)
} }
cloudButton.snp.makeConstraints { make in cloudButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(AppSpacing.md) make.trailing.equalToSuperview()
make.centerY.equalTo(titleLabel) make.centerY.equalTo(titleLabel)
} }
collectionView.snp.makeConstraints { make in collectionView.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm) make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.leading.trailing.bottom.equalToSuperview()
make.bottom.equalToSuperview().inset(AppSpacing.md)
collectionHeightConstraint = make.height.equalTo(88).constraint collectionHeightConstraint = make.height.equalTo(88).constraint
} }
} }
@ -138,9 +140,22 @@ final class TaskAddMediaSectionView: UIView {
snapshot.appendSections([.main]) snapshot.appendSections([.main])
snapshot.appendItems(files.map { Item.file($0.id) } + [.add]) snapshot.appendItems(files.map { Item.file($0.id) } + [.add])
dataSource.apply(snapshot, animatingDifferences: false) dataSource.apply(snapshot, animatingDifferences: false)
collectionView.layoutIfNeeded() updateCollectionHeight(itemCount: files.count + 1)
let height = max(88, collectionView.collectionViewLayout.collectionViewContentSize.height) }
collectionHeightConstraint?.update(offset: height)
override func layoutSubviews() {
super.layoutSubviews()
updateCollectionHeight(itemCount: files.count + 1)
}
private func updateCollectionHeight(itemCount: Int) {
let availableWidth = collectionView.bounds.width
guard availableWidth > 0 else { return }
let itemWidth = max(0, (availableWidth - gridSpacing * CGFloat(gridColumns - 1)) / CGFloat(gridColumns))
let itemHeight = itemWidth / 1.77
let rows = max(1, Int(ceil(Double(itemCount) / Double(gridColumns))))
let height = itemHeight * CGFloat(rows) + gridSpacing * CGFloat(rows - 1)
collectionHeightConstraint?.update(offset: max(88, height))
} }
@objc private func cloudTapped() { @objc private func cloudTapped() {
@ -161,6 +176,54 @@ extension TaskAddMediaSectionView: UICollectionViewDelegate {
} }
} }
/// Android cell
final class TaskCircularProgressView: UIView {
private let trackLayer = CAShapeLayer()
private let progressLayer = CAShapeLayer()
override init(frame: CGRect) {
super.init(frame: frame)
isHidden = true
layer.addSublayer(trackLayer)
layer.addSublayer(progressLayer)
trackLayer.fillColor = UIColor.clear.cgColor
trackLayer.strokeColor = UIColor.white.withAlphaComponent(0.35).cgColor
trackLayer.lineWidth = 3
progressLayer.fillColor = UIColor.clear.cgColor
progressLayer.strokeColor = UIColor.white.cgColor
progressLayer.lineCap = .round
progressLayer.lineWidth = 3
progressLayer.strokeEnd = 0
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
let radius = min(bounds.width, bounds.height) / 2 - progressLayer.lineWidth / 2
let path = UIBezierPath(
arcCenter: CGPoint(x: bounds.midX, y: bounds.midY),
radius: radius,
startAngle: -.pi / 2,
endAngle: .pi * 1.5,
clockwise: true
)
trackLayer.frame = bounds
progressLayer.frame = bounds
trackLayer.path = path.cgPath
progressLayer.path = path.cgPath
}
/// 0...1
func setProgress(_ progress: CGFloat) {
progressLayer.strokeEnd = min(1, max(0, progress))
}
}
/// cell /// cell
final class TaskAddMediaCell: UICollectionViewCell { final class TaskAddMediaCell: UICollectionViewCell {
@ -170,9 +233,11 @@ final class TaskAddMediaCell: UICollectionViewCell {
private let imageView = UIImageView() private let imageView = UIImageView()
private let overlayView = UIView() private let overlayView = UIView()
private let progressRingView = TaskCircularProgressView()
private let progressLabel = UILabel() private let progressLabel = UILabel()
private let deleteButton = UIButton(type: .system) private let deleteButton = UIButton(type: .system)
private let playIconView = UIImageView() private let playIconView = UIImageView()
private let sourceBadgeLabel = UILabel()
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
@ -183,7 +248,7 @@ final class TaskAddMediaCell: UICollectionViewCell {
imageView.contentMode = .scaleAspectFill imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true imageView.clipsToBounds = true
overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.35) overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.2)
overlayView.isHidden = true overlayView.isHidden = true
progressLabel.font = .app(.captionMedium) progressLabel.font = .app(.captionMedium)
@ -198,22 +263,45 @@ final class TaskAddMediaCell: UICollectionViewCell {
playIconView.tintColor = .white playIconView.tintColor = .white
playIconView.isHidden = true playIconView.isHidden = true
sourceBadgeLabel.font = .app(.caption)
sourceBadgeLabel.textColor = .white
sourceBadgeLabel.textAlignment = .center
sourceBadgeLabel.backgroundColor = UIColor.black.withAlphaComponent(0.5)
sourceBadgeLabel.layer.cornerRadius = AppRadius.xs
sourceBadgeLabel.clipsToBounds = true
sourceBadgeLabel.isHidden = true
contentView.addSubview(imageView) contentView.addSubview(imageView)
contentView.addSubview(overlayView) contentView.addSubview(overlayView)
contentView.addSubview(sourceBadgeLabel)
contentView.addSubview(playIconView) contentView.addSubview(playIconView)
contentView.addSubview(progressRingView)
contentView.addSubview(progressLabel) contentView.addSubview(progressLabel)
contentView.addSubview(deleteButton) contentView.addSubview(deleteButton)
imageView.snp.makeConstraints { make in make.edges.equalToSuperview() } imageView.snp.makeConstraints { make in make.edges.equalToSuperview() }
overlayView.snp.makeConstraints { make in make.edges.equalToSuperview() } overlayView.snp.makeConstraints { make in make.edges.equalToSuperview() }
sourceBadgeLabel.snp.makeConstraints { make in
make.leading.bottom.equalToSuperview().inset(AppSpacing.xxs)
make.height.equalTo(18)
make.width.greaterThanOrEqualTo(34)
}
playIconView.snp.makeConstraints { make in playIconView.snp.makeConstraints { make in
make.center.equalToSuperview() make.center.equalToSuperview()
make.width.height.equalTo(24)
}
progressRingView.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalToSuperview().offset(-10)
make.width.height.equalTo(28) make.width.height.equalTo(28)
} }
progressLabel.snp.makeConstraints { make in make.center.equalToSuperview() } progressLabel.snp.makeConstraints { make in
make.top.equalTo(progressRingView.snp.bottom).offset(2)
make.centerX.equalToSuperview()
}
deleteButton.snp.makeConstraints { make in deleteButton.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(AppSpacing.xxs) make.top.trailing.equalToSuperview().inset(AppSpacing.xxs)
make.width.height.equalTo(22) make.width.height.equalTo(20)
} }
} }
@ -227,6 +315,7 @@ final class TaskAddMediaCell: UICollectionViewCell {
onDelete = nil onDelete = nil
imageView.kf.cancelDownloadTask() imageView.kf.cancelDownloadTask()
imageView.image = nil imageView.image = nil
progressRingView.setProgress(0)
} }
func apply(file: TaskFileItem) { func apply(file: TaskFileItem) {
@ -237,12 +326,18 @@ final class TaskAddMediaCell: UICollectionViewCell {
imageView.image = UIImage(systemName: "photo") imageView.image = UIImage(systemName: "photo")
imageView.tintColor = AppColor.textTertiary imageView.tintColor = AppColor.textTertiary
} }
playIconView.isHidden = file.fileType != 1 playIconView.isHidden = file.fileType != 1 || file.isUploading
deleteButton.isHidden = file.isUploading
sourceBadgeLabel.isHidden = file.isUploading
sourceBadgeLabel.text = file.sourceBadgeText
if file.isUploading { if file.isUploading {
overlayView.isHidden = false overlayView.isHidden = false
progressRingView.isHidden = false
progressRingView.setProgress(CGFloat(file.uploadProgress) / 100)
progressLabel.text = "\(file.uploadProgress)%" progressLabel.text = "\(file.uploadProgress)%"
} else { } else {
overlayView.isHidden = true overlayView.isHidden = true
progressRingView.isHidden = true
progressLabel.text = nil progressLabel.text = nil
} }
} }
@ -302,11 +397,14 @@ final class TaskPrioritySectionView: UIView {
private let twoHourButton = UIButton(type: .system) private let twoHourButton = UIButton(type: .system)
private let customButton = UIButton(type: .system) private let customButton = UIButton(type: .system)
private let customField = UITextField() private let customField = UITextField()
private let customConfirmButton = UIButton(type: .system)
private let optionRow = UIStackView()
private let customRow = UIStackView()
private let stackView = UIStackView()
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
backgroundColor = .white backgroundColor = .clear
layer.cornerRadius = AppRadius.md
titleLabel.text = "优先级" titleLabel.text = "优先级"
titleLabel.font = .app(.bodyMedium) titleLabel.font = .app(.bodyMedium)
@ -324,32 +422,54 @@ final class TaskPrioritySectionView: UIView {
customField.font = .app(.body) customField.font = .app(.body)
customField.textColor = AppColor.textPrimary customField.textColor = AppColor.textPrimary
customField.keyboardType = .numberPad customField.keyboardType = .numberPad
customField.borderStyle = .roundedRect customField.backgroundColor = .white
customField.isHidden = true customField.layer.cornerRadius = AppRadius.sm
customField.layer.borderWidth = 1
customField.layer.borderColor = AppColor.border.cgColor
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) customField.addTarget(self, action: #selector(customFieldChanged), for: .editingChanged)
let row = UIStackView(arrangedSubviews: [noneButton, twoHourButton, customButton]) customConfirmButton.setTitle("确定", for: .normal)
row.axis = .horizontal customConfirmButton.setTitleColor(.white, for: .normal)
row.spacing = AppSpacing.xs customConfirmButton.titleLabel?.font = .app(.bodyMedium)
row.distribution = .fillEqually customConfirmButton.backgroundColor = AppColor.primary
customConfirmButton.layer.cornerRadius = AppRadius.xs
customConfirmButton.addTarget(self, action: #selector(customConfirmTapped), for: .touchUpInside)
addSubview(titleLabel) optionRow.addArrangedSubview(noneButton)
addSubview(row) optionRow.addArrangedSubview(twoHourButton)
addSubview(customField) optionRow.addArrangedSubview(customButton)
optionRow.axis = .horizontal
optionRow.spacing = AppSpacing.xs
optionRow.distribution = .fillEqually
titleLabel.snp.makeConstraints { make in customRow.addArrangedSubview(customField)
make.top.leading.equalToSuperview().offset(AppSpacing.md) customRow.addArrangedSubview(customConfirmButton)
customRow.axis = .horizontal
customRow.spacing = AppSpacing.xs
customRow.alignment = .fill
customRow.isHidden = true
stackView.addArrangedSubview(titleLabel)
stackView.addArrangedSubview(optionRow)
stackView.addArrangedSubview(customRow)
stackView.axis = .vertical
stackView.spacing = AppSpacing.xs
addSubview(stackView)
stackView.snp.makeConstraints { make in
make.edges.equalToSuperview()
} }
row.snp.makeConstraints { make in optionRow.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(42) make.height.equalTo(42)
} }
customField.snp.makeConstraints { make in customField.snp.makeConstraints { make in
make.top.equalTo(row.snp.bottom).offset(AppSpacing.sm) make.height.equalTo(46)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md) }
make.bottom.equalToSuperview().inset(AppSpacing.md) customConfirmButton.snp.makeConstraints { make in
make.height.equalTo(40) make.width.equalTo(72)
} }
} }
@ -363,7 +483,7 @@ final class TaskPrioritySectionView: UIView {
styleOption(noneButton, selected: urgentHour == 0) styleOption(noneButton, selected: urgentHour == 0)
styleOption(twoHourButton, selected: urgentHour == 2) styleOption(twoHourButton, selected: urgentHour == 2)
styleOption(customButton, selected: isCustom) styleOption(customButton, selected: isCustom)
customField.isHidden = !isCustom customRow.isHidden = !isCustom
if isCustom { if isCustom {
customField.text = "\(urgentHour)" customField.text = "\(urgentHour)"
} else { } else {
@ -392,6 +512,9 @@ final class TaskPrioritySectionView: UIView {
@objc private func customFieldChanged() { @objc private func customFieldChanged() {
onCustomHourTextChange?(customField.text ?? "") onCustomHourTextChange?(customField.text ?? "")
} }
@objc private func customConfirmTapped() {
onCustomHourTextChange?(customField.text ?? "")
}
} }
/// ///
@ -443,7 +566,7 @@ final class TaskOrderSelectButton: UIControl {
} }
/// ///
final class TaskFileRemarkDialogView: UIView { final class TaskFileRemarkDialogView: KeyboardAvoidingDialogView {
var onCancel: (() -> Void)? var onCancel: (() -> Void)?
var onConfirm: (() -> Void)? var onConfirm: (() -> Void)?
@ -451,31 +574,46 @@ final class TaskFileRemarkDialogView: UIView {
private let containerView = UIView() private let containerView = UIView()
private let titleLabel = UILabel() private let titleLabel = UILabel()
private let previewView = UIImageView() private let previewView = UIImageView()
private let remarkTitleLabel = UILabel()
private let textView = UITextView() private let textView = UITextView()
private let placeholderLabel = UILabel()
private let countLabel = UILabel() private let countLabel = UILabel()
private let cancelButton = UIButton(type: .system) private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system) private let confirmButton = UIButton(type: .system)
private let buttonRow = UIStackView()
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
backgroundColor = AppColor.overlayScrim backgroundColor = AppColor.overlayScrim
keyboardAvoidingContentView = containerView
containerView.backgroundColor = .white containerView.backgroundColor = .white
containerView.layer.cornerRadius = AppRadius.md containerView.layer.cornerRadius = AppRadius.lg
titleLabel.text = "备注信息" titleLabel.text = "请填写剪辑备注"
titleLabel.font = .app(.title) titleLabel.font = .app(.title)
titleLabel.textColor = AppColor.textPrimary
previewView.contentMode = .scaleAspectFill previewView.contentMode = .scaleAspectFill
previewView.clipsToBounds = true previewView.clipsToBounds = true
previewView.layer.cornerRadius = AppRadius.sm previewView.layer.cornerRadius = AppRadius.lg
previewView.backgroundColor = AppColor.inputBackground previewView.backgroundColor = AppColor.inputBackground
remarkTitleLabel.text = "备注信息"
remarkTitleLabel.font = .app(.caption)
remarkTitleLabel.textColor = AppColor.textPrimary
textView.font = .app(.body) textView.font = .app(.body)
textView.textColor = AppColor.textPrimary
textView.layer.borderWidth = 1 textView.layer.borderWidth = 1
textView.layer.borderColor = AppColor.border.cgColor textView.layer.borderColor = AppColor.border.cgColor
textView.layer.cornerRadius = AppRadius.sm textView.layer.cornerRadius = AppRadius.sm
textView.delegate = self textView.delegate = self
textView.textContainerInset = UIEdgeInsets(top: AppSpacing.sm, left: AppSpacing.xs, bottom: AppSpacing.sm, right: AppSpacing.xs)
placeholderLabel.text = "请输入备注最多50字"
placeholderLabel.font = .app(.body)
placeholderLabel.textColor = AppColor.textTertiary
countLabel.font = .app(.caption) countLabel.font = .app(.caption)
countLabel.textColor = AppColor.textTertiary countLabel.textColor = AppColor.textTertiary
@ -493,16 +631,25 @@ final class TaskFileRemarkDialogView: UIView {
confirmButton.layer.cornerRadius = AppRadius.xs confirmButton.layer.cornerRadius = AppRadius.xs
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
buttonRow.axis = .horizontal
buttonRow.spacing = AppSpacing.xs
buttonRow.alignment = .center
buttonRow.distribution = .fill
buttonRow.addArrangedSubview(cancelButton)
buttonRow.addArrangedSubview(confirmButton)
addSubview(containerView) addSubview(containerView)
containerView.addSubview(titleLabel) containerView.addSubview(titleLabel)
containerView.addSubview(previewView) containerView.addSubview(previewView)
containerView.addSubview(textView) containerView.addSubview(remarkTitleLabel)
containerView.addSubview(countLabel) containerView.addSubview(countLabel)
containerView.addSubview(cancelButton) containerView.addSubview(textView)
containerView.addSubview(confirmButton) textView.addSubview(placeholderLabel)
containerView.addSubview(buttonRow)
containerView.snp.makeConstraints { make in containerView.snp.makeConstraints { make in
make.center.equalToSuperview() make.centerX.equalToSuperview()
keyboardAvoidingCenterYConstraint = make.centerY.equalToSuperview().constraint
make.leading.trailing.equalToSuperview().inset(AppSpacing.lg) make.leading.trailing.equalToSuperview().inset(AppSpacing.lg)
} }
titleLabel.snp.makeConstraints { make in titleLabel.snp.makeConstraints { make in
@ -511,26 +658,35 @@ final class TaskFileRemarkDialogView: UIView {
previewView.snp.makeConstraints { make in previewView.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm) make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(160) make.height.equalTo(previewView.snp.width)
}
remarkTitleLabel.snp.makeConstraints { make in
make.top.equalTo(previewView.snp.bottom).offset(AppSpacing.md)
make.leading.equalToSuperview().offset(AppSpacing.md)
}
countLabel.snp.makeConstraints { make in
make.centerY.equalTo(remarkTitleLabel)
make.trailing.equalToSuperview().inset(AppSpacing.md)
} }
textView.snp.makeConstraints { make in textView.snp.makeConstraints { make in
make.top.equalTo(previewView.snp.bottom).offset(AppSpacing.sm) make.top.equalTo(remarkTitleLabel.snp.bottom).offset(AppSpacing.xs)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(80) make.height.equalTo(80)
} }
countLabel.snp.makeConstraints { make in placeholderLabel.snp.makeConstraints { make in
make.top.equalTo(textView.snp.bottom).offset(AppSpacing.xxs) make.top.equalToSuperview().offset(AppSpacing.sm)
make.trailing.equalTo(textView) make.leading.equalToSuperview().offset(AppSpacing.sm + 3)
make.trailing.lessThanOrEqualToSuperview().inset(AppSpacing.sm)
}
buttonRow.snp.makeConstraints { make in
make.top.equalTo(textView.snp.bottom).offset(AppSpacing.md)
make.trailing.bottom.equalToSuperview().inset(AppSpacing.md)
} }
cancelButton.snp.makeConstraints { make in 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.height.equalTo(36)
make.width.equalTo(72) make.width.equalTo(72)
} }
confirmButton.snp.makeConstraints { make in confirmButton.snp.makeConstraints { make in
make.centerY.equalTo(cancelButton)
make.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(36) make.height.equalTo(36)
make.width.equalTo(72) make.width.equalTo(72)
} }
@ -544,6 +700,7 @@ final class TaskFileRemarkDialogView: UIView {
func apply(file: TaskFileItem, text: String) { func apply(file: TaskFileItem, text: String) {
textView.text = text textView.text = text
countLabel.text = "\(text.count)/50" countLabel.text = "\(text.count)/50"
placeholderLabel.isHidden = !text.isEmpty
let urlString = file.previewURLString let urlString = file.previewURLString
if let url = URL(string: urlString), !urlString.isEmpty { if let url = URL(string: urlString), !urlString.isEmpty {
previewView.kf.setImage(with: url) previewView.kf.setImage(with: url)
@ -565,6 +722,7 @@ extension TaskFileRemarkDialogView: UITextViewDelegate {
if textView.text.count > 50 { if textView.text.count > 50 {
textView.text = String(textView.text.prefix(50)) textView.text = String(textView.text.prefix(50))
} }
placeholderLabel.isHidden = !textView.text.isEmpty
countLabel.text = "\(textView.text.count)/50" countLabel.text = "\(textView.text.count)/50"
} }
} }
@ -625,3 +783,177 @@ final class TaskBorderTextView: UIView {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
} }
/// Android `AddFileBottomModalForTask`
final class TaskUploadTypeSheetView: UIView {
var onChooseImage: (() -> Void)?
var onChooseVideo: (() -> Void)?
var onCancel: (() -> Void)?
private let dimmingView = UIView()
private let panelView = UIView()
private let titleLabel = UILabel()
private let imageItem = TaskUploadTypeItemView(
title: "上传图片",
image: UIImage(named: "icon_cloud_storage_add_photo") ?? UIImage(systemName: "photo")
)
private let videoItem = TaskUploadTypeItemView(
title: "上传视频",
image: UIImage(named: "icon_cloud_storage_add_video") ?? UIImage(systemName: "video")
)
private let dividerView = UIView()
private let cancelButton = UIButton(type: .system)
private var panelBottomConstraint: Constraint?
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
dimmingView.backgroundColor = AppColor.overlayScrim
dimmingView.alpha = 0
let tap = UITapGestureRecognizer(target: self, action: #selector(cancelTapped))
dimmingView.addGestureRecognizer(tap)
panelView.backgroundColor = .white
panelView.layer.cornerRadius = AppRadius.sheetTop
panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
panelView.clipsToBounds = true
titleLabel.text = "选择上传类型"
titleLabel.font = .app(.bodyMedium)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
imageItem.addTarget(self, action: #selector(imageTapped), for: .touchUpInside)
videoItem.addTarget(self, action: #selector(videoTapped), for: .touchUpInside)
dividerView.backgroundColor = AppColor.border
cancelButton.setTitle("取消", for: .normal)
cancelButton.titleLabel?.font = .app(.bodyMedium)
cancelButton.setTitleColor(UIColor(hex: 0x4B5563), for: .normal)
cancelButton.backgroundColor = AppColor.inputBackground
cancelButton.layer.cornerRadius = AppRadius.lg
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
addSubview(dimmingView)
addSubview(panelView)
panelView.addSubview(titleLabel)
panelView.addSubview(imageItem)
panelView.addSubview(videoItem)
panelView.addSubview(dividerView)
panelView.addSubview(cancelButton)
dimmingView.snp.makeConstraints { make in make.edges.equalToSuperview() }
panelView.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
panelBottomConstraint = make.bottom.equalToSuperview().offset(260).constraint
}
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(AppSpacing.md)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
}
imageItem.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.md)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(58)
}
videoItem.snp.makeConstraints { make in
make.top.equalTo(imageItem.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalTo(imageItem)
make.height.equalTo(imageItem)
}
dividerView.snp.makeConstraints { make in
make.top.equalTo(videoItem.snp.bottom).offset(AppSpacing.md)
make.leading.trailing.equalToSuperview()
make.height.equalTo(0.5)
}
cancelButton.snp.makeConstraints { make in
make.top.equalTo(dividerView.snp.bottom).offset(AppSpacing.md)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(48)
make.bottom.equalTo(safeAreaLayoutGuide).inset(AppSpacing.md)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func show() {
layoutIfNeeded()
panelBottomConstraint?.update(offset: 0)
UIView.animate(withDuration: 0.22, delay: 0, options: [.curveEaseOut]) {
self.dimmingView.alpha = 1
self.layoutIfNeeded()
}
}
///
func dismiss(completion: (() -> Void)? = nil) {
panelBottomConstraint?.update(offset: panelView.bounds.height)
UIView.animate(withDuration: 0.18, delay: 0, options: [.curveEaseIn]) {
self.dimmingView.alpha = 0
self.layoutIfNeeded()
} completion: { _ in
completion?()
}
}
@objc private func imageTapped() {
onChooseImage?()
}
@objc private func videoTapped() {
onChooseVideo?()
}
@objc private func cancelTapped() {
onCancel?()
}
}
///
final class TaskUploadTypeItemView: UIControl {
private let iconView = UIImageView()
private let titleLabel = UILabel()
init(title: String, image: UIImage?) {
super.init(frame: .zero)
backgroundColor = .white
layer.cornerRadius = AppRadius.sm
layer.borderWidth = 1
layer.borderColor = AppColor.inputBackground.cgColor
iconView.image = image
iconView.contentMode = .scaleAspectFit
iconView.tintColor = AppColor.primary
titleLabel.text = title
titleLabel.font = .app(.bodyMedium)
titleLabel.textColor = .black
addSubview(iconView)
addSubview(titleLabel)
iconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(AppSpacing.md)
make.centerY.equalToSuperview()
make.width.height.equalTo(32)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing).offset(AppSpacing.sm)
make.centerY.equalToSuperview()
make.trailing.lessThanOrEqualToSuperview().inset(AppSpacing.md)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -156,7 +156,7 @@ final class WildPhotographerReportListViewController: BaseViewController {
await MainActor.run { await MainActor.run {
self.hideLoading() self.hideLoading()
guard let payload else { return } guard let payload else { return }
WeChatShareService.shareMiniProgram(payload) { [weak self] result in WeChatShareService.shareMiniProgram(payload, previewImage: WildReportShareCover.image) { [weak self] result in
self?.showWildReportShareResult(result) self?.showWildReportShareResult(result)
} }
} }
@ -181,6 +181,13 @@ extension WildPhotographerReportListViewController: UIScrollViewDelegate {
} }
} }
///
private enum WildReportShareCover {
static var image: UIImage? {
UIImage(named: "report_share_cover")
}
}
/// 使 /// 使
private final class WildReportFilterTabBarView: UIView { private final class WildReportFilterTabBarView: UIView {
var onSelect: ((WildReportListFilter) -> Void)? var onSelect: ((WildReportListFilter) -> Void)?
@ -1228,7 +1235,7 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
await MainActor.run { await MainActor.run {
self.hideLoading() self.hideLoading()
guard let payload else { return } guard let payload else { return }
WeChatShareService.shareMiniProgram(payload) { [weak self] result in WeChatShareService.shareMiniProgram(payload, previewImage: WildReportShareCover.image) { [weak self] result in
self?.showWildReportShareResult(result) self?.showWildReportShareResult(result)
} }
} }

View File

@ -330,9 +330,7 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
let title = WildReportUI.label(viewModel.locationTitleText, font: .systemFont(ofSize: 15, weight: .semibold), color: AppColor.textPrimary, lines: 2) let title = WildReportUI.label(viewModel.locationTitleText, font: .systemFont(ofSize: 15, weight: .semibold), color: AppColor.textPrimary, lines: 2)
title.adjustsFontSizeToFitWidth = true title.adjustsFontSizeToFitWidth = true
title.minimumScaleFactor = 0.78 title.minimumScaleFactor = 0.78
let subtitle = WildReportUI.label(viewModel.locationSubtitleText, font: .systemFont(ofSize: 12), color: AppColor.textSecondary, lines: 0)
textStack.addArrangedSubview(title) textStack.addArrangedSubview(title)
textStack.addArrangedSubview(subtitle)
let button = UIButton(type: .system) let button = UIButton(type: .system)
button.setTitle(viewModel.isUpdatingLocation ? "定位中" : "更新定位", for: .normal) button.setTitle(viewModel.isUpdatingLocation ? "定位中" : "更新定位", for: .normal)

View File

@ -73,6 +73,25 @@ final class CloudStoragePickForTaskViewModelTests: XCTestCase {
XCTAssertEqual(Set(viewModel.selectedFileList.map(\.id)), Set([1, 2])) XCTAssertEqual(Set(viewModel.selectedFileList.map(\.id)), Set([1, 2]))
} }
func testAllowedFileTypeRejectsMismatchedMedia() async throws {
let listJSON = try makeListJSON(files: [
makeFileJSON(id: 1, name: "视频", type: 1),
makeFileJSON(id: 2, name: "图片", type: 2),
])
let api = makeAPI(responses: [listJSON])
let viewModel = CloudStoragePickForTaskViewModel(allowedFileType: 2)
var message: String?
viewModel.onShowMessage = { message = $0 }
await viewModel.refresh(api: api)
await viewModel.handleItemTap(viewModel.files[0], api: api)
await viewModel.handleItemTap(viewModel.files[1], api: api)
XCTAssertEqual(message, "请选择图片素材")
XCTAssertFalse(viewModel.isSelected(1))
XCTAssertTrue(viewModel.isSelected(2))
}
private func makeAPI(responses: [Data]) -> TaskAPI { private func makeAPI(responses: [Data]) -> TaskAPI {
TaskAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: responses))) TaskAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: responses)))
} }

View File

@ -118,6 +118,45 @@ final class MaterialManagementViewModelTests: XCTestCase {
XCTAssertNil(api.lastUploadRequest?.id) XCTAssertNil(api.lastUploadRequest?.id)
} }
func testCloudMediaImportBuildsMaterialRequestWithoutUploadingAgain() async {
let api = MockMaterialAPI()
api.spotResponse = ScenicSpotListResponse(list: [ScenicSpotItem(id: 9, name: "打卡点")], total: 1)
let viewModel = MaterialFormViewModel(mode: .create, currentScenicIdProvider: { 3 })
var didSucceed = false
viewModel.onSubmitSuccess = { didSucceed = true }
await viewModel.loadInitial(api: api)
viewModel.updateMaterialName("云盘素材")
viewModel.selectScenicSpot(ScenicSpotItem(id: 9, name: "打卡点"))
viewModel.addCloudMediaFiles([
CloudFile(id: 31, fileUrl: "https://cdn/cloud.jpg", coverUrl: "https://cdn/cover-thumb.jpg", name: "cloud.jpg", type: 2, fileSize: 88),
])
await viewModel.setCoverImage(makeUploadItem(fileName: "cover.jpg"), uploader: MockMaterialUploader(urls: ["https://cdn/cover.jpg"]))
await viewModel.submit(api: api)
XCTAssertTrue(didSucceed)
XCTAssertEqual(viewModel.importedCloudFileIDs, Set([31]))
XCTAssertEqual(api.lastUploadRequest?.mediaList.first?.originalName, "cloud.jpg")
XCTAssertEqual(api.lastUploadRequest?.mediaList.first?.ossUrl, "https://cdn/cloud.jpg")
XCTAssertEqual(api.lastUploadRequest?.mediaList.first?.size, 88)
}
func testCloudMediaImportFiltersByCurrentMaterialTypeAndDeduplicates() {
let viewModel = MaterialFormViewModel(mode: .create, currentScenicIdProvider: { 3 })
var messages: [String] = []
viewModel.onShowMessage = { messages.append($0) }
viewModel.addCloudMediaFiles([
CloudFile(id: 1, fileUrl: "https://cdn/video.mp4", name: "video.mp4", type: 1),
CloudFile(id: 2, fileUrl: "https://cdn/image.jpg", name: "image.jpg", type: 2),
CloudFile(id: 2, fileUrl: "https://cdn/image.jpg", name: "image.jpg", type: 2),
])
XCTAssertEqual(viewModel.uploadedMediaList.map(\.ossUrl), ["https://cdn/image.jpg"])
XCTAssertEqual(viewModel.importedCloudFileIDs, Set([2]))
XCTAssertTrue(messages.isEmpty)
}
func testUploadDialogStaysHiddenAfterCompletionProgressCallback() async { func testUploadDialogStaysHiddenAfterCompletionProgressCallback() async {
let viewModel = MaterialFormViewModel(mode: .create, currentScenicIdProvider: { 3 }) let viewModel = MaterialFormViewModel(mode: .create, currentScenicIdProvider: { 3 })
let uploader = MockMaterialUploader(urls: ["https://cdn/a.jpg"]) let uploader = MockMaterialUploader(urls: ["https://cdn/a.jpg"])

View File

@ -30,6 +30,32 @@ final class TaskAddViewModelTests: XCTestCase {
XCTAssertEqual(message, "请填写任务名称!") XCTAssertEqual(message, "请填写任务名称!")
} }
func testTextInputsAreTrimmedToAndroidLimits() {
let viewModel = TaskAddViewModel(appStore: appStore)
viewModel.updateTaskName(String(repeating: "", count: 25))
viewModel.updateTaskDetails(String(repeating: "", count: 210))
viewModel.addCloudFiles([CloudFile(id: 1, name: "A", type: 2)])
viewModel.presentRemarkDialog(for: viewModel.selectedFiles[0].id)
viewModel.updateRemarkDialogText(String(repeating: "", count: 60))
XCTAssertEqual(viewModel.taskName.count, 20)
XCTAssertEqual(viewModel.taskDetails.count, 200)
XCTAssertEqual(viewModel.remarkDialogText.count, 50)
}
func testUploadingStateBlocksSubmitButKeepsButtonActionUseful() async {
let viewModel = TaskAddViewModel(appStore: appStore)
viewModel.updateTaskName("任务")
viewModel.addUploadPlaceholder(fileName: "a.jpg", fileType: 2, uploadTaskId: "task1")
var message: String?
viewModel.onShowMessage = { message = $0 }
await viewModel.validateAndSubmit(api: TaskAPI(client: makeClient()))
XCTAssertEqual(message, "文件正在上传中,请稍后添加!")
}
func testBuildAddTaskRequestSplitsCloudAndUploadFiles() { func testBuildAddTaskRequestSplitsCloudAndUploadFiles() {
let viewModel = TaskAddViewModel(appStore: appStore) let viewModel = TaskAddViewModel(appStore: appStore)
viewModel.updateTaskName("任务A") viewModel.updateTaskName("任务A")
@ -50,6 +76,14 @@ final class TaskAddViewModelTests: XCTestCase {
XCTAssertEqual(request.uploadFile[0].fileUrl, "https://cdn/a.jpg") XCTAssertEqual(request.uploadFile[0].fileUrl, "https://cdn/a.jpg")
} }
func testTaskFileSourceBadgeTextMatchesAndroidLabels() {
let cloudItem = TaskFileItem(source: .cloud, cloudFile: CloudFile(id: 1, name: "云文件", type: 2))
let uploadItem = TaskFileItem(source: .upload, uploadFileName: "a.jpg", uploadFileType: 2)
XCTAssertEqual(cloudItem.sourceBadgeText, "云盘")
XCTAssertEqual(uploadItem.sourceBadgeText, "本地")
}
func testAddCloudFilesDedupesAndAutoRemarkSingleFile() { func testAddCloudFilesDedupesAndAutoRemarkSingleFile() {
let viewModel = TaskAddViewModel(appStore: appStore) let viewModel = TaskAddViewModel(appStore: appStore)
viewModel.addCloudFiles([CloudFile(id: 1, name: "A", type: 2)]) viewModel.addCloudFiles([CloudFile(id: 1, name: "A", type: 2)])
@ -74,8 +108,12 @@ final class TaskAddViewModelTests: XCTestCase {
viewModel.resetAllState() viewModel.resetAllState()
XCTAssertEqual(viewModel.taskName, "") XCTAssertEqual(viewModel.taskName, "")
XCTAssertEqual(viewModel.taskDetails, "")
XCTAssertNil(viewModel.selectedOrderNumber) XCTAssertNil(viewModel.selectedOrderNumber)
XCTAssertEqual(viewModel.urgentHour, 0)
XCTAssertTrue(viewModel.selectedFiles.isEmpty) XCTAssertTrue(viewModel.selectedFiles.isEmpty)
XCTAssertFalse(viewModel.isUploading)
XCTAssertFalse(viewModel.showSuccessDialog)
} }
private func makeClient() -> APIClient { private func makeClient() -> APIClient {

View File

@ -701,6 +701,20 @@ final class WildPhotographerReportTests: XCTestCase {
XCTAssertEqual(messages.last, URLError(.badServerResponse).localizedDescription) XCTAssertEqual(messages.last, URLError(.badServerResponse).localizedDescription)
} }
func testSubmitLocationOmitsProvinceCityDistrict() {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
viewModel.applyResolvedLocation(HomeLocationSnapshot(
latitude: 32.40,
longitude: 119.41,
address: "江苏省扬州市邗江区瘦西湖路"
))
XCTAssertEqual(viewModel.locationTitleText, "当前定位:瘦西湖路")
XCTAssertEqual(viewModel.reportLocation, "瘦西湖路")
}
func testSubmitFailsWithoutEvidence() async { func testSubmitFailsWithoutEvidence() async {
let homeViewModel = WildPhotographerReportHomeViewModel() let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel) let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
@ -766,7 +780,7 @@ final class WildPhotographerReportTests: XCTestCase {
viewModel.addImages([try makeAttachment(kind: .image, fileName: "scene.jpg")]) viewModel.addImages([try makeAttachment(kind: .image, fileName: "scene.jpg")])
viewModel.addPaymentImages([try makeAttachment(kind: .payment, fileName: "payment.jpg")]) viewModel.addPaymentImages([try makeAttachment(kind: .payment, fileName: "payment.jpg")])
viewModel.updateDescription("测试说明") viewModel.updateDescription("测试说明")
viewModel.applyResolvedLocation(HomeLocationSnapshot(latitude: 30.12, longitude: 120.34, address: "测试景区 · 测试点")) viewModel.applyResolvedLocation(HomeLocationSnapshot(latitude: 30.12, longitude: 120.34, address: "江苏省扬州市邗江区瘦西湖路"))
await viewModel.submitReport(api: api, uploader: uploader, scenicId: 9, scenicName: "测试景区") await viewModel.submitReport(api: api, uploader: uploader, scenicId: 9, scenicName: "测试景区")
@ -779,10 +793,13 @@ final class WildPhotographerReportTests: XCTestCase {
XCTAssertEqual(api.submitRequests.first?.reportType, 2) XCTAssertEqual(api.submitRequests.first?.reportType, 2)
XCTAssertEqual(api.submitRequests.first?.lat, 30.12) XCTAssertEqual(api.submitRequests.first?.lat, 30.12)
XCTAssertEqual(api.submitRequests.first?.lng, 120.34) XCTAssertEqual(api.submitRequests.first?.lng, 120.34)
XCTAssertEqual(api.submitRequests.first?.locationName, "测试景区")
XCTAssertEqual(api.submitRequests.first?.locationAddress, "瘦西湖路")
XCTAssertEqual(api.submitRequests.first?.evidences.count, 1) XCTAssertEqual(api.submitRequests.first?.evidences.count, 1)
XCTAssertEqual(api.submitRequests.first?.evidences.first?.fileType, 1) XCTAssertEqual(api.submitRequests.first?.evidences.first?.fileType, 1)
XCTAssertEqual(api.submitRequests.first?.paymentScreenshots.count, 1) XCTAssertEqual(api.submitRequests.first?.paymentScreenshots.count, 1)
XCTAssertNil(api.submitRequests.first?.storeUserId) XCTAssertNil(api.submitRequests.first?.storeUserId)
XCTAssertEqual(viewModel.submitResult?.record.location, "瘦西湖路")
} }
func testPaymentScreenshotsDoNotReplaceRequiredEvidence() async throws { func testPaymentScreenshotsDoNotReplaceRequiredEvidence() async throws {