完善景区排队设置页与全局按钮配置迁移。

重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-14 16:21:53 +08:00
parent efb3925257
commit 6336feff27
49 changed files with 1996 additions and 590 deletions

View File

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

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "queue_location.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 3C8.134 3 5 6.134 5 10C5 12.862 6.782 15.623 8.738 17.763C9.698 18.813 10.661 19.671 11.385 20.267C11.619 20.459 11.826 20.623 12 20.757C12.174 20.623 12.381 20.459 12.615 20.267C13.339 19.671 14.302 18.813 15.262 17.763C17.218 15.623 19 12.863 19 10C19 6.134 15.866 3 12 3ZM12 23.214L11.433 22.824L11.403 22.803C11.386 22.791 11.361 22.773 11.329 22.75C11.265 22.705 11.174 22.639 11.059 22.553C10.828 22.381 10.503 22.131 10.115 21.811C9.339 21.173 8.302 20.249 7.262 19.112C5.218 16.876 3 13.637 3 10C3 5.029 7.029 1 12 1C16.971 1 21 5.029 21 10C21 13.637 18.782 16.876 16.738 19.112C15.698 20.25 14.661 21.173 13.885 21.811C13.497 22.131 13.172 22.381 12.941 22.553C12.826 22.639 12.735 22.705 12.671 22.75L12.597 22.803L12 23.214ZM12 8C10.895 8 10 8.895 10 10C10 11.105 10.895 12 12 12C13.105 12 14 11.105 14 10C14 8.895 13.105 8 12 8ZM8 10C8 7.791 9.791 6 12 6C14.209 6 16 7.791 16 10C16 12.209 14.209 14 12 14C9.791 14 8 12.209 8 10Z" fill="#0066FF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -28,6 +28,20 @@ struct ScenicQueueHomeData: Decodable, Equatable {
/// home stats /// home stats
struct ScenicQueueHomeStats: Decodable, Equatable { struct ScenicQueueHomeStats: Decodable, Equatable {
let type: Int let type: Int
/// stats Android DTO type 使 0
init(type: Int = 0) {
self.type = type
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
type = try container.decodeIfPresent(Int.self, forKey: .type) ?? 0
}
private enum CodingKeys: String, CodingKey {
case type
}
} }
/// home /// home
@ -426,6 +440,43 @@ struct ScenicQueueSettingChangeLogItem: Decodable, Equatable, Hashable {
} }
} }
extension ScenicQueueSettingChangeLogItem {
/// Android
var metadataLine: String {
let time: String
if let createdAt, !createdAt.isEmpty {
let input = DateFormatter()
input.locale = Locale(identifier: "en_US_POSIX")
input.timeZone = TimeZone(identifier: "Asia/Shanghai")
let formats = ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy-MM-dd'T'HH:mm:ssXXXXX", "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX"]
let parsedDate = formats.lazy.compactMap { format -> Date? in
input.dateFormat = format
return input.date(from: createdAt)
}.first
if let date = parsedDate {
let output = DateFormatter()
output.locale = Locale(identifier: "en_US_POSIX")
output.timeZone = TimeZone(identifier: "Asia/Shanghai")
output.dateFormat = "yyyy-MM-dd HH:mm"
time = output.string(from: date)
} else {
time = String(createdAt.replacingOccurrences(of: "T", with: " ").prefix(16))
}
} else {
time = ""
}
let name = operatorName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "未知" : operatorName.trimmingCharacters(in: .whitespacesAndNewlines)
let tail = operatorPhoneTail.trimmingCharacters(in: .whitespacesAndNewlines)
return tail.isEmpty ? "\(time) \(name)" : "\(time) \(name) 尾号\(tail)"
}
/// 使 summary退 displayText
var cardSummary: String {
let value = summary.trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? displayText : value
}
}
/// ///
struct ScenicSpotListResponse: Decodable, Equatable { struct ScenicSpotListResponse: Decodable, Equatable {
let list: [ScenicSpotItem] let list: [ScenicSpotItem]

View File

@ -6,11 +6,56 @@
import Foundation import Foundation
/// ///
enum ScenicQueueCallStatus: Equatable { enum ScenicQueueCallStatus: Hashable {
case waiting case waiting
case called case called
} }
///
struct ScenicQueueTicketActionState: Equatable {
let skipEnabled: Bool
let primaryVisible: Bool
let primaryEnabled: Bool
let primaryTitle: String
let recallVisible: Bool
let completeVisible: Bool
/// Android
static func make(
ticket: ScenicQueueTicket,
shootingQueueNo: String?,
remainSeconds: Int,
showStartShootingButton: Bool
) -> ScenicQueueTicketActionState {
let isShooting = shootingQueueNo == ticket.queueNo
if isShooting {
return ScenicQueueTicketActionState(
skipEnabled: false,
primaryVisible: true,
primaryEnabled: false,
primaryTitle: "剩余 \(max(0, remainSeconds))",
recallVisible: false,
completeVisible: ticket.showCompleteAction
)
}
let primaryVisible = ticket.status == .waiting || showStartShootingButton
return ScenicQueueTicketActionState(
skipEnabled: true,
primaryVisible: primaryVisible,
primaryEnabled: true,
primaryTitle: ticket.status == .waiting ? "叫号" : "开始拍摄",
recallVisible: ticket.status == .called,
completeVisible: ticket.showCompleteAction
)
}
}
/// Diffable Data Source
enum ScenicQueueListItem: Hashable {
case current(ScenicQueueTicket)
case skipped(ScenicQueueSkippedTicket)
}
/// ///
struct ScenicQueueTicket: Hashable { struct ScenicQueueTicket: Hashable {
let recordId: Int64 let recordId: Int64

View File

@ -15,6 +15,7 @@ final class ScenicQueueSettingChangeLogViewModel {
private(set) var isRefreshing = false private(set) var isRefreshing = false
private(set) var isLoadingMore = false private(set) var isLoadingMore = false
private(set) var canLoadMore = false private(set) var canLoadMore = false
private(set) var initialLoading = true
var onStateChange: (() -> Void)? var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)? var onShowMessage: ((String) -> Void)?
@ -50,6 +51,7 @@ final class ScenicQueueSettingChangeLogViewModel {
} }
notifyStateChange() notifyStateChange()
defer { defer {
initialLoading = false
if append { isLoadingMore = false } else { isRefreshing = false } if append { isLoadingMore = false } else { isRefreshing = false }
notifyStateChange() notifyStateChange()
} }

View File

@ -47,6 +47,7 @@ final class ScenicQueueSettingsViewModel {
private(set) var presetVoices: [String] private(set) var presetVoices: [String]
private(set) var settingsTab: ScenicQueueSettingsTab = .basic private(set) var settingsTab: ScenicQueueSettingsTab = .basic
private(set) var highlightedInvalidFieldKeys: Set<String> = [] private(set) var highlightedInvalidFieldKeys: Set<String> = []
private(set) var blockingInvalidNumericFieldKeys: Set<String> = []
var onStateChange: (() -> Void)? var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)? var onShowMessage: ((String) -> Void)?
@ -56,7 +57,7 @@ final class ScenicQueueSettingsViewModel {
private let appStore: AppStore private let appStore: AppStore
private let ttsManager: ScenicQueueTTSManaging private let ttsManager: ScenicQueueTTSManaging
private var punchSpotSettingRequestGeneration = 0 private var punchSpotSettingRequestGeneration = 0
private var ttsLoopAnchorText: String? private(set) var ttsLoopAnchorText: String?
init(appStore: AppStore = .shared, ttsManager: ScenicQueueTTSManaging = AliyunNLSTTSManager.shared) { init(appStore: AppStore = .shared, ttsManager: ScenicQueueTTSManaging = AliyunNLSTTSManager.shared) {
self.appStore = appStore self.appStore = appStore
@ -108,6 +109,30 @@ final class ScenicQueueSettingsViewModel {
appStore.scenicQueue.useOfflineTTS appStore.scenicQueue.useOfflineTTS
} }
///
var isCustomTTSLooping: Bool {
ttsManager.customTextLooping
}
/// Android 使
nonisolated static func kilometersDisplay(fromMeters meters: Int) -> String {
let value = Double(max(0, meters)) / 1_000
if value.rounded() == value { return String(format: "%.0f", value) }
let text = String(format: "%.2f", value)
return text.replacingOccurrences(of: #"0+$"#, with: "", options: .regularExpression)
.replacingOccurrences(of: #"\.$"#, with: "", options: .regularExpression)
}
/// nil
nonisolated static func meters(fromKilometersText raw: String) -> Int? {
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard text.range(of: #"^\d{1,4}(\.\d{0,2})?$"#, options: .regularExpression) != nil,
let value = Decimal(string: text, locale: Locale(identifier: "en_US_POSIX")) else { return nil }
let meters = NSDecimalNumber(decimal: value * 1_000).intValue
guard Limits.queueDistance.contains(meters) else { return nil }
return meters
}
func openSettingChangeLog() { func openSettingChangeLog() {
onNavigateLog?() onNavigateLog?()
} }
@ -146,6 +171,18 @@ final class ScenicQueueSettingsViewModel {
notifyStateChange() notifyStateChange()
} }
///
func reportOutOfRangeNumericPending(fieldKey: String, pending: Bool) {
if pending {
blockingInvalidNumericFieldKeys.insert(fieldKey)
highlightedInvalidFieldKeys.insert(fieldKey)
} else {
blockingInvalidNumericFieldKeys.remove(fieldKey)
highlightedInvalidFieldKeys.remove(fieldKey)
}
notifyStateChange()
}
func setShootMinute(_ value: Int) { shootMinute = value.clamped(to: Limits.shootMinute); notifyStateChange() } func setShootMinute(_ value: Int) { shootMinute = value.clamped(to: Limits.shootMinute); notifyStateChange() }
func setShootSecond(_ value: Int) { shootSecond = value.clamped(to: Limits.shootSecond); notifyStateChange() } func setShootSecond(_ value: Int) { shootSecond = value.clamped(to: Limits.shootSecond); notifyStateChange() }
func setFirstAheadCount(_ value: Int) { firstAheadCount = value.clamped(to: Limits.ahead); notifyStateChange() } func setFirstAheadCount(_ value: Int) { firstAheadCount = value.clamped(to: Limits.ahead); notifyStateChange() }
@ -222,6 +259,21 @@ final class ScenicQueueSettingsViewModel {
} }
} }
///
func startPresetVoiceLoop(_ phrase: String) {
let text = String(phrase.trimmingCharacters(in: .whitespacesAndNewlines).prefix(Limits.remarkMaxLen))
guard !text.isEmpty else { return }
Task {
if ttsManager.customTextLooping {
ttsManager.stopCustomTextLoop()
}
let ok = await ttsManager.startCustomTextLoop(text)
ttsLoopAnchorText = ok ? text : nil
if !ok { onShowMessage?("播报失败,请检查网络与语音服务") }
notifyStateChange()
}
}
/// ///
func playBluetoothTestSound() { func playBluetoothTestSound() {
Task { Task {
@ -289,7 +341,7 @@ final class ScenicQueueSettingsViewModel {
/// ///
func save(api: ScenicQueueAPIProtocol) async { func save(api: ScenicQueueAPIProtocol) async {
highlightedInvalidFieldKeys = [] highlightedInvalidFieldKeys = blockingInvalidNumericFieldKeys
guard let ids = currentScenicAndSpotIdsForSelected() else { guard let ids = currentScenicAndSpotIdsForSelected() else {
highlightedInvalidFieldKeys = ["punchSpot"] highlightedInvalidFieldKeys = ["punchSpot"]
settingsTab = .basic settingsTab = .basic
@ -297,6 +349,12 @@ final class ScenicQueueSettingsViewModel {
notifyStateChange() notifyStateChange()
return return
} }
guard blockingInvalidNumericFieldKeys.isEmpty else {
settingsTab = tabForValidationKeys(blockingInvalidNumericFieldKeys)
onShowMessage?("请修正标红的配置项")
notifyStateChange()
return
}
let validation = buildSaveValidation() let validation = buildSaveValidation()
guard validation.messages.isEmpty else { guard validation.messages.isEmpty else {
highlightedInvalidFieldKeys = validation.keys highlightedInvalidFieldKeys = validation.keys

View File

@ -194,13 +194,11 @@ final class WildPhotographerReportSubmitViewModel {
/// 500 /// 500
func updateDescription(_ newValue: String) { func updateDescription(_ newValue: String) {
description = String(newValue.prefix(500)) description = String(newValue.prefix(500))
notifyStateChange()
} }
/// ///
func updateContact(_ value: String) { func updateContact(_ value: String) {
contact = String(value.prefix(80)) contact = String(value.prefix(80))
notifyStateChange()
} }
/// ///

View File

@ -31,6 +31,13 @@ enum ScenicQueueTokens {
static let dialogDangerLine = UIColor(hex: 0xD32F2F) static let dialogDangerLine = UIColor(hex: 0xD32F2F)
static let fieldBorderMuted = UIColor(hex: 0xE5E7EB) static let fieldBorderMuted = UIColor(hex: 0xE5E7EB)
static let settingsMutedBlock = UIColor(hex: 0xF7F8FA) static let settingsMutedBlock = UIColor(hex: 0xF7F8FA)
static let dialogCancelBackground = UIColor(hex: 0xEAF1F8)
static let markDialogCancelBackground = UIColor(hex: 0xF0F7FF)
static let markDialogDivider = UIColor(hex: 0xF0F0F0)
static let markDialogStepperBackground = UIColor(hex: 0xF2F4F7)
static let logCardBackground = UIColor(hex: 0xF7F8FA)
static let timelineLine = UIColor(hex: 0xD8E8FF)
static let timelineDot = UIColor(hex: 0x1677FF)
static let radiusContentSheetTop: CGFloat = 14 static let radiusContentSheetTop: CGFloat = 14
static let radiusCard: CGFloat = 12 static let radiusCard: CGFloat = 12
@ -43,6 +50,9 @@ enum ScenicQueueTokens {
static let listItemGap: CGFloat = 10 static let listItemGap: CGFloat = 10
static let listHorizontalInset: CGFloat = 12 static let listHorizontalInset: CGFloat = 12
static let bottomSaveButtonRadius: CGFloat = 12 static let bottomSaveButtonRadius: CGFloat = 12
static let markDialogRadius: CGFloat = 22
static let markDialogButtonRadius: CGFloat = 12
static let dialogHorizontalInset: CGFloat = 22
static let bannerLabelFont = UIFont.systemFont(ofSize: 12) static let bannerLabelFont = UIFont.systemFont(ofSize: 12)
static let bannerValueFont = UIFont.systemFont(ofSize: 30, weight: .bold) static let bannerValueFont = UIFont.systemFont(ofSize: 30, weight: .bold)

View File

@ -14,4 +14,12 @@ extension UIButton {
updatedConfiguration.contentInsets = contentInsets updatedConfiguration.contentInsets = contentInsets
configuration = updatedConfiguration configuration = updatedConfiguration
} }
/// configuration
func setConfigurationImagePlacement(_ imagePlacement: NSDirectionalRectEdge, padding: CGFloat) {
var updatedConfiguration = configuration ?? .plain()
updatedConfiguration.imagePlacement = imagePlacement
updatedConfiguration.imagePadding = padding
configuration = updatedConfiguration
}
} }

View File

@ -20,9 +20,9 @@ final class ScenicSelectionViewController: BaseViewController, UITableViewDataSo
private let tableView = UITableView(frame: .zero, style: .plain) private let tableView = UITableView(frame: .zero, style: .plain)
private let emptyLabel = UILabel() private let emptyLabel = UILabel()
init(viewModel: ScenicSelectionViewModel = ScenicSelectionViewModel(), homeAPI: HomeAPI = NetworkServices.shared.homeAPI) { init(viewModel: ScenicSelectionViewModel = ScenicSelectionViewModel(), homeAPI: HomeAPI? = nil) {
self.viewModel = viewModel self.viewModel = viewModel
self.homeAPI = homeAPI self.homeAPI = homeAPI ?? NetworkServices.shared.homeAPI
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }

View File

@ -58,8 +58,7 @@ final class HomeWorkStatusCardView: UIView {
let reminderIconConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .semibold) let reminderIconConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
reminderButton.setImage(UIImage(systemName: "bell.fill", withConfiguration: reminderIconConfig), for: .normal) reminderButton.setImage(UIImage(systemName: "bell.fill", withConfiguration: reminderIconConfig), for: .normal)
reminderButton.semanticContentAttribute = .forceLeftToRight reminderButton.semanticContentAttribute = .forceLeftToRight
reminderButton.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 6) reminderButton.setConfigurationImagePlacement(.leading, padding: 6)
reminderButton.titleEdgeInsets = UIEdgeInsets(top: 0, left: 6, bottom: 0, right: -6)
reminderButton.titleLabel?.adjustsFontSizeToFitWidth = true reminderButton.titleLabel?.adjustsFontSizeToFitWidth = true
reminderButton.titleLabel?.minimumScaleFactor = 0.8 reminderButton.titleLabel?.minimumScaleFactor = 0.8
reminderButton.addTarget(self, action: #selector(reminderTapped), for: .touchUpInside) reminderButton.addTarget(self, action: #selector(reminderTapped), for: .touchUpInside)

View File

@ -38,11 +38,11 @@ final class InviteRecordViewController: BaseViewController {
/// ///
init( init(
inviteAPI: InviteServing = NetworkServices.shared.inviteAPI, inviteAPI: InviteServing? = nil,
walletAPI: WalletServing = NetworkServices.shared.walletAPI walletAPI: WalletServing? = nil
) { ) {
self.inviteAPI = inviteAPI self.inviteAPI = inviteAPI ?? NetworkServices.shared.inviteAPI
self.walletAPI = walletAPI self.walletAPI = walletAPI ?? NetworkServices.shared.walletAPI
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }

View File

@ -19,8 +19,8 @@ final class PhotographerInviteViewController: BaseViewController {
private let ruleStack = UIStackView() private let ruleStack = UIStackView()
/// ///
init(inviteAPI: InviteServing = NetworkServices.shared.inviteAPI) { init(inviteAPI: InviteServing? = nil) {
self.inviteAPI = inviteAPI self.inviteAPI = inviteAPI ?? NetworkServices.shared.inviteAPI
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
@ -234,7 +234,7 @@ final class PhotographerInviteViewController: BaseViewController {
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
button.setImage(UIImage(named: "ic_invite_record")?.withRenderingMode(.alwaysTemplate), for: .normal) button.setImage(UIImage(named: "ic_invite_record")?.withRenderingMode(.alwaysTemplate), for: .normal)
button.tintColor = AppColor.primary button.tintColor = AppColor.primary
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -6, bottom: 0, right: 6) button.setConfigurationImagePlacement(.leading, padding: 6)
button.addTarget(self, action: #selector(inviteRecordTapped), for: .touchUpInside) button.addTarget(self, action: #selector(inviteRecordTapped), for: .touchUpInside)
container.addSubview(button) container.addSubview(button)
button.snp.makeConstraints { make in button.snp.makeConstraints { make in
@ -360,8 +360,7 @@ private final class InviteIconButton: UIButton {
setImage(UIImage(named: imageName)?.withRenderingMode(.alwaysTemplate), for: .normal) setImage(UIImage(named: imageName)?.withRenderingMode(.alwaysTemplate), for: .normal)
tintColor = .white tintColor = .white
titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
imageEdgeInsets = UIEdgeInsets(top: 0, left: -3, bottom: 0, right: 3) setConfigurationImagePlacement(.leading, padding: 6)
titleEdgeInsets = UIEdgeInsets(top: 0, left: 3, bottom: 0, right: -3)
} }
@available(*, unavailable) @available(*, unavailable)

View File

@ -207,7 +207,7 @@ final class LiveAlbumListViewController: BaseViewController, UITableViewDelegate
button.titleLabel?.font = .systemFont(ofSize: 13) button.titleLabel?.font = .systemFont(ofSize: 13)
button.setImage(UIImage(systemName: "calendar"), for: .normal) button.setImage(UIImage(systemName: "calendar"), for: .normal)
button.tintColor = AppColor.textPrimary button.tintColor = AppColor.textPrimary
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -4, bottom: 0, right: 4) button.setConfigurationImagePlacement(.leading, padding: 4)
setDateButtonTitle(button, text: title) setDateButtonTitle(button, text: title)
} }

View File

@ -75,7 +75,7 @@ final class LiveActionButton: UIButton {
layer.cornerRadius = 8 layer.cornerRadius = 8
clipsToBounds = true clipsToBounds = true
tintColor = .white tintColor = .white
imageEdgeInsets = UIEdgeInsets(top: 0, left: -4, bottom: 0, right: 4) setConfigurationImagePlacement(.leading, padding: 4)
updateAppearance() updateAppearance()
} }

View File

@ -20,10 +20,10 @@ final class LocationReportHistoryViewController: BaseViewController, UITableView
init( init(
viewModel: LocationReportHistoryViewModel = LocationReportHistoryViewModel(), viewModel: LocationReportHistoryViewModel = LocationReportHistoryViewModel(),
homeAPI: HomeAPI = NetworkServices.shared.homeAPI homeAPI: HomeAPI? = nil
) { ) {
self.viewModel = viewModel self.viewModel = viewModel
self.homeAPI = homeAPI self.homeAPI = homeAPI ?? NetworkServices.shared.homeAPI
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }

View File

@ -24,10 +24,10 @@ final class LocationReportViewController: BaseViewController {
init( init(
viewModel: LocationReportViewModel = LocationReportViewModel(), viewModel: LocationReportViewModel = LocationReportViewModel(),
homeAPI: HomeAPI = NetworkServices.shared.homeAPI homeAPI: HomeAPI? = nil
) { ) {
self.viewModel = viewModel self.viewModel = viewModel
self.homeAPI = homeAPI self.homeAPI = homeAPI ?? NetworkServices.shared.homeAPI
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }

View File

@ -30,10 +30,10 @@ final class MaterialDetailViewController: BaseViewController {
/// ///
init( init(
materialId: Int, materialId: Int,
api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI api: (any MaterialManagementServing)? = nil
) { ) {
viewModel = MaterialDetailViewModel(materialId: materialId) viewModel = MaterialDetailViewModel(materialId: materialId)
self.api = api self.api = api ?? NetworkServices.shared.materialManagementAPI
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }

View File

@ -49,12 +49,12 @@ final class MaterialFormViewController: BaseViewController {
/// ///
init( init(
mode: MaterialFormViewModel.Mode, mode: MaterialFormViewModel.Mode,
api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI, api: (any MaterialManagementServing)? = nil,
uploader: any MaterialOSSUploading = NetworkServices.shared.ossUploadService uploader: (any MaterialOSSUploading)? = nil
) { ) {
viewModel = MaterialFormViewModel(mode: mode) viewModel = MaterialFormViewModel(mode: mode)
self.api = api self.api = api ?? NetworkServices.shared.materialManagementAPI
self.uploader = uploader self.uploader = uploader ?? NetworkServices.shared.ossUploadService
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }

View File

@ -33,10 +33,10 @@ final class MaterialListViewController: BaseViewController {
/// ///
init( init(
viewModel: MaterialListViewModel = MaterialListViewModel(), viewModel: MaterialListViewModel = MaterialListViewModel(),
api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI api: (any MaterialManagementServing)? = nil
) { ) {
self.viewModel = viewModel self.viewModel = viewModel
self.api = api self.api = api ?? NetworkServices.shared.materialManagementAPI
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }

View File

@ -25,10 +25,10 @@ final class MessageCenterViewController: BaseViewController, UITableViewDelegate
/// ///
init( init(
viewModel: MessageCenterViewModel = MessageCenterViewModel(), viewModel: MessageCenterViewModel = MessageCenterViewModel(),
api: any MessageCenterServing = NetworkServices.shared.messageCenterAPI api: (any MessageCenterServing)? = nil
) { ) {
self.viewModel = viewModel self.viewModel = viewModel
self.api = api self.api = api ?? NetworkServices.shared.messageCenterAPI
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }

View File

@ -24,9 +24,9 @@ final class MessageDetailViewController: BaseViewController {
private let deleteButton = UIButton(type: .system) private let deleteButton = UIButton(type: .system)
/// ///
init(message: MessageItem, api: any MessageCenterServing = NetworkServices.shared.messageCenterAPI) { init(message: MessageItem, api: (any MessageCenterServing)? = nil) {
viewModel = MessageDetailViewModel(message: message) viewModel = MessageDetailViewModel(message: message)
self.api = api self.api = api ?? NetworkServices.shared.messageCenterAPI
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }

View File

@ -14,9 +14,9 @@ final class DepositOrderDetailViewController: BaseViewController, UITableViewDat
private var detail: StoreOrderDetailData? private var detail: StoreOrderDetailData?
private let tableView = UITableView(frame: .zero, style: .insetGrouped) private let tableView = UITableView(frame: .zero, style: .insetGrouped)
init(orderNumber: String, orderAPI: OrderAPI = NetworkServices.shared.orderAPI) { init(orderNumber: String, orderAPI: OrderAPI? = nil) {
self.orderNumber = orderNumber self.orderNumber = orderNumber
self.orderAPI = orderAPI self.orderAPI = orderAPI ?? NetworkServices.shared.orderAPI
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }

View File

@ -14,9 +14,9 @@ final class HistoricalShootingInfoViewController: BaseViewController, UITableVie
private var response: MultiTravelShootHistoryResponse? private var response: MultiTravelShootHistoryResponse?
private let tableView = UITableView(frame: .zero, style: .insetGrouped) private let tableView = UITableView(frame: .zero, style: .insetGrouped)
init(orderNumber: String, orderAPI: OrderAPI = NetworkServices.shared.orderAPI) { init(orderNumber: String, orderAPI: OrderAPI? = nil) {
self.orderNumber = orderNumber self.orderNumber = orderNumber
self.orderAPI = orderAPI self.orderAPI = orderAPI ?? NetworkServices.shared.orderAPI
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }

View File

@ -53,10 +53,10 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
init( init(
viewModel: ProfileSpaceSettingsViewModel = ProfileSpaceSettingsViewModel(), viewModel: ProfileSpaceSettingsViewModel = ProfileSpaceSettingsViewModel(),
profileAPI: ProfileAPI = NetworkServices.shared.profileAPI profileAPI: ProfileAPI? = nil
) { ) {
self.viewModel = viewModel self.viewModel = viewModel
self.profileAPI = profileAPI self.profileAPI = profileAPI ?? NetworkServices.shared.profileAPI
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }

View File

@ -26,10 +26,10 @@ final class SampleDetailViewController: SampleBaseViewController {
/// ///
init( init(
sampleId: Int, sampleId: Int,
api: any SampleManagementServing = NetworkServices.shared.sampleManagementAPI api: (any SampleManagementServing)? = nil
) { ) {
viewModel = SampleDetailViewModel(sampleId: sampleId) viewModel = SampleDetailViewModel(sampleId: sampleId)
self.api = api self.api = api ?? NetworkServices.shared.sampleManagementAPI
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }

View File

@ -32,10 +32,10 @@ final class SampleListViewController: SampleBaseViewController {
/// ///
init( init(
viewModel: SampleListViewModel = SampleListViewModel(), viewModel: SampleListViewModel = SampleListViewModel(),
api: any SampleManagementServing = NetworkServices.shared.sampleManagementAPI api: (any SampleManagementServing)? = nil
) { ) {
self.viewModel = viewModel self.viewModel = viewModel
self.api = api self.api = api ?? NetworkServices.shared.sampleManagementAPI
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }

View File

@ -51,12 +51,12 @@ final class UploadSampleViewController: SampleBaseViewController {
/// ///
init( init(
viewModel: UploadSampleViewModel = UploadSampleViewModel(), viewModel: UploadSampleViewModel = UploadSampleViewModel(),
api: any SampleManagementServing = NetworkServices.shared.sampleManagementAPI, api: (any SampleManagementServing)? = nil,
uploader: any SampleOSSUploading = NetworkServices.shared.ossUploadService uploader: (any SampleOSSUploading)? = nil
) { ) {
self.viewModel = viewModel self.viewModel = viewModel
self.api = api self.api = api ?? NetworkServices.shared.sampleManagementAPI
self.uploader = uploader self.uploader = uploader ?? NetworkServices.shared.ossUploadService
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }

View File

@ -41,14 +41,14 @@ final class ScenicApplicationViewController: BaseViewController {
init( init(
viewModel: ScenicApplicationViewModel = ScenicApplicationViewModel(), viewModel: ScenicApplicationViewModel = ScenicApplicationViewModel(),
homeAPI: HomeAPI = NetworkServices.shared.homeAPI, homeAPI: HomeAPI? = nil,
profileAPI: ProfileAPI = NetworkServices.shared.profileAPI, profileAPI: ProfileAPI? = nil,
ossUploadService: OSSUploadService = NetworkServices.shared.ossUploadService ossUploadService: OSSUploadService? = nil
) { ) {
self.viewModel = viewModel self.viewModel = viewModel
self.homeAPI = homeAPI self.homeAPI = homeAPI ?? NetworkServices.shared.homeAPI
self.profileAPI = profileAPI self.profileAPI = profileAPI ?? NetworkServices.shared.profileAPI
self.ossUploadService = ossUploadService self.ossUploadService = ossUploadService ?? NetworkServices.shared.ossUploadService
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }

View File

@ -6,12 +6,15 @@
import SnapKit import SnapKit
import UIKit import UIKit
/// /// Android
final class ScenicQueueSettingChangeLogViewController: BaseViewController { final class ScenicQueueSettingChangeLogViewController: BaseViewController {
private let viewModel = ScenicQueueSettingChangeLogViewModel() private let viewModel = ScenicQueueSettingChangeLogViewModel()
private let api: ScenicQueueAPIProtocol private let api: ScenicQueueAPIProtocol
private let tableView = UITableView(frame: .zero, style: .plain) private let tableView = UITableView(frame: .zero, style: .plain)
private let emptyLabel = UILabel() private let emptyLabel = UILabel()
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
private let footerLabel = UILabel()
private var dataSource: UITableViewDiffableDataSource<Int, ScenicQueueSettingChangeLogItem>!
@MainActor @MainActor
init(api: ScenicQueueAPIProtocol? = nil) { init(api: ScenicQueueAPIProtocol? = nil) {
@ -20,133 +23,142 @@ final class ScenicQueueSettingChangeLogViewController: BaseViewController {
} }
@available(*, unavailable) @available(*, unavailable)
required init?(coder: NSCoder) { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
Task { await viewModel.refresh(api: api) } Task { await viewModel.refresh(api: api) }
} }
override func setupNavigationBar() { override func setupNavigationBar() { title = "配置日志" }
title = "配置日志"
}
override func setupUI() { override func setupUI() {
view.backgroundColor = ScenicQueueTokens.pageBackground view.backgroundColor = .white
tableView.backgroundColor = .clear tableView.backgroundColor = .white
tableView.separatorStyle = .none tableView.separatorStyle = .none
tableView.rowHeight = UITableView.automaticDimension tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 120 tableView.estimatedRowHeight = 132
tableView.delegate = self tableView.delegate = self
tableView.dataSource = self tableView.register(ScenicQueueLogTimelineCell.self, forCellReuseIdentifier: ScenicQueueLogTimelineCell.reuseIdentifier)
tableView.register(ScenicQueueLogCell.self, forCellReuseIdentifier: ScenicQueueLogCell.reuseIdentifier)
tableView.refreshControl = UIRefreshControl() tableView.refreshControl = UIRefreshControl()
tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged) tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
dataSource = UITableViewDiffableDataSource<Int, ScenicQueueSettingChangeLogItem>(tableView: tableView) { [weak self] tableView, indexPath, item in
let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueLogTimelineCell.reuseIdentifier, for: indexPath) as! ScenicQueueLogTimelineCell
cell.configure(item: item, showDot: self?.shouldShowDot(at: indexPath.row) ?? false)
return cell
}
emptyLabel.text = "暂无配置变更记录" emptyLabel.text = "暂无配置变更记录"
emptyLabel.textColor = AppColor.textTertiary emptyLabel.textColor = AppColor.textSecondary
emptyLabel.font = ScenicQueueTokens.emptyStateFont emptyLabel.font = ScenicQueueTokens.emptyStateFont
emptyLabel.textAlignment = .center emptyLabel.textAlignment = .center
loadingIndicator.color = ScenicQueueTokens.bannerBlue
footerLabel.textAlignment = .center
footerLabel.textColor = AppColor.textSecondary
footerLabel.font = .systemFont(ofSize: 14)
view.addSubview(tableView) view.addSubview(tableView)
view.addSubview(emptyLabel) view.addSubview(emptyLabel)
view.addSubview(loadingIndicator)
} }
override func setupConstraints() { override func setupConstraints() {
tableView.snp.makeConstraints { make in tableView.snp.makeConstraints { $0.edges.equalTo(view.safeAreaLayoutGuide) }
make.edges.equalTo(view.safeAreaLayoutGuide) emptyLabel.snp.makeConstraints { $0.center.equalToSuperview() }
} loadingIndicator.snp.makeConstraints { $0.center.equalToSuperview() }
emptyLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
}
} }
override func bindActions() { override func bindActions() {
viewModel.onStateChange = { [weak self] in viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } }
Task { @MainActor in self?.applyState() } viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
} }
private func applyState() { private func applyState() {
tableView.refreshControl?.endRefreshing() tableView.refreshControl?.endRefreshing()
tableView.reloadData() viewModel.initialLoading && viewModel.items.isEmpty ? loadingIndicator.startAnimating() : loadingIndicator.stopAnimating()
emptyLabel.isHidden = !viewModel.items.isEmpty emptyLabel.isHidden = viewModel.initialLoading || viewModel.isRefreshing || !viewModel.items.isEmpty
} var snapshot = NSDiffableDataSourceSnapshot<Int, ScenicQueueSettingChangeLogItem>()
snapshot.appendSections([0])
@objc private func refreshPulled() { snapshot.appendItems(viewModel.items)
Task { await viewModel.refresh(api: api) } dataSource.apply(snapshot, animatingDifferences: true)
if viewModel.items.isEmpty {
tableView.tableFooterView = UIView()
} else {
footerLabel.text = viewModel.isLoadingMore ? "加载中…" : (viewModel.canLoadMore ? "" : "没有更多")
footerLabel.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52)
tableView.tableFooterView = footerLabel
} }
} }
extension ScenicQueueSettingChangeLogViewController: UITableViewDataSource, UITableViewDelegate { private func shouldShowDot(at index: Int) -> Bool {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { let estimatedRowHeight: CGFloat = 132
viewModel.items.count let itemsPerThird = max(1, Int(((view.bounds.height / 3) / estimatedRowHeight).rounded()))
let stride = max(3, min(12, itemsPerThird))
return index % stride == 0
} }
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { @objc private func refreshPulled() { Task { await viewModel.refresh(api: api) } }
let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueLogCell.reuseIdentifier, for: indexPath) as! ScenicQueueLogCell
cell.configure(item: viewModel.items[indexPath.row])
return cell
} }
extension ScenicQueueSettingChangeLogViewController: UITableViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) { func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height - 120 else { return } guard !viewModel.items.isEmpty,
scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height - 180 else { return }
Task { await viewModel.loadMore(api: api) } Task { await viewModel.loadMore(api: api) }
} }
} }
/// cell ///
final class ScenicQueueLogCell: UITableViewCell { final class ScenicQueueLogTimelineCell: UITableViewCell {
static let reuseIdentifier = "ScenicQueueLogCell" static let reuseIdentifier = "ScenicQueueLogTimelineCell"
private let rail = UIView()
private let dot = UIView()
private let card = UIView() private let card = UIView()
private let titleLabel = UILabel() private let metaLabel = UILabel()
private let detailLabel = UILabel() private let summaryLabel = UILabel()
private let timeLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier) super.init(style: style, reuseIdentifier: reuseIdentifier)
setupUI() selectionStyle = .none
backgroundColor = .white
rail.backgroundColor = ScenicQueueTokens.timelineLine
dot.backgroundColor = ScenicQueueTokens.timelineDot
dot.layer.cornerRadius = 4
card.backgroundColor = ScenicQueueTokens.logCardBackground
card.layer.cornerRadius = 12
metaLabel.font = .systemFont(ofSize: 12)
metaLabel.textColor = AppColor.textSecondary
metaLabel.numberOfLines = 3
summaryLabel.font = .systemFont(ofSize: 15, weight: .semibold)
summaryLabel.textColor = AppColor.textPrimary
summaryLabel.numberOfLines = 0
let stack = UIStackView(arrangedSubviews: [metaLabel, summaryLabel])
stack.axis = .vertical
stack.spacing = 8
contentView.addSubview(rail)
contentView.addSubview(dot)
contentView.addSubview(card)
card.addSubview(stack)
rail.snp.makeConstraints { make in
make.centerX.equalTo(contentView.snp.leading).offset(UIScreen.main.bounds.width * 0.10)
make.top.bottom.equalToSuperview()
make.width.equalTo(1.5)
}
dot.snp.makeConstraints { make in make.centerX.equalTo(rail); make.top.equalToSuperview().offset(18); make.size.equalTo(8) }
card.snp.makeConstraints { make in
make.leading.equalTo(rail.snp.trailing).offset(20)
make.trailing.equalToSuperview().offset(-16)
make.top.equalToSuperview().offset(6)
make.bottom.equalToSuperview().offset(-6)
}
stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(15) }
} }
@available(*, unavailable) @available(*, unavailable)
required init?(coder: NSCoder) { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
fatalError("init(coder:) has not been implemented")
}
func configure(item: ScenicQueueSettingChangeLogItem) { ///
titleLabel.text = item.operatorName.isEmpty ? "配置变更" : item.operatorName func configure(item: ScenicQueueSettingChangeLogItem, showDot: Bool) {
detailLabel.text = item.displayText.isEmpty ? item.summary : item.displayText metaLabel.text = item.metadataLine
timeLabel.text = item.createdAt summaryLabel.text = item.cardSummary
} dot.isHidden = !showDot
private func setupUI() {
selectionStyle = .none
backgroundColor = .clear
card.backgroundColor = .white
card.layer.cornerRadius = ScenicQueueTokens.radiusCard
card.layer.borderWidth = 1
card.layer.borderColor = ScenicQueueTokens.cardOutline.cgColor
contentView.addSubview(card)
titleLabel.font = ScenicQueueTokens.settingsSectionTitleFont
titleLabel.textColor = AppColor.textPrimary
detailLabel.font = ScenicQueueTokens.settingsRowTitleFont
detailLabel.textColor = AppColor.textSecondary
detailLabel.numberOfLines = 0
timeLabel.font = ScenicQueueTokens.settingsHintFont
timeLabel.textColor = AppColor.textTertiary
let stack = UIStackView(arrangedSubviews: [titleLabel, detailLabel, timeLabel])
stack.axis = .vertical
stack.spacing = 8
card.addSubview(stack)
card.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 6, left: 16, bottom: 6, right: 16))
}
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(14)
}
} }
} }

View File

@ -15,12 +15,13 @@ final class ScenicQueueViewController: BaseViewController {
private let punchSpotLabel = UILabel() private let punchSpotLabel = UILabel()
private let statsBanner = ScenicQueueStatsBannerView() private let statsBanner = ScenicQueueStatsBannerView()
private let contentContainer = UIView() private let contentContainer = UIView()
private let segmentedControl = UISegmentedControl(items: ["当前排队", "过号列表"]) private let tabStrip = ScenicQueueTabStripView(titles: ["当前排队", "过号列表"])
private let tableView = UITableView(frame: .zero, style: .plain) private let tableView = UITableView(frame: .zero, style: .plain)
private let bottomBar = UIView() private let bottomBar = UIView()
private let prepareCallButton = UIButton(type: .system) private let prepareCallButton = UIButton(type: .system)
private let quickCallButton = UIButton(type: .system) private let quickCallButton = UIButton(type: .system)
private let emptyLabel = UILabel() private let emptyLabel = UILabel()
private var dataSource: UITableViewDiffableDataSource<Int, ScenicQueueListItem>!
private var selectedTab = 0 private var selectedTab = 0
@ -64,8 +65,8 @@ final class ScenicQueueViewController: BaseViewController {
view.backgroundColor = ScenicQueueTokens.pageBackground view.backgroundColor = ScenicQueueTokens.pageBackground
punchSpotRow.backgroundColor = .white punchSpotRow.backgroundColor = .white
let locationIcon = UIImageView(image: UIImage(systemName: "mappin.and.ellipse")) let locationIcon = UIImageView(image: UIImage(named: "queue_location"))
locationIcon.tintColor = ScenicQueueTokens.bannerBlue locationIcon.contentMode = .scaleAspectFit
punchSpotLabel.font = .systemFont(ofSize: 16) punchSpotLabel.font = .systemFont(ofSize: 16)
punchSpotLabel.textColor = UIColor.black.withAlphaComponent(0.7) punchSpotLabel.textColor = UIColor.black.withAlphaComponent(0.7)
punchSpotLabel.lineBreakMode = .byTruncatingTail punchSpotLabel.lineBreakMode = .byTruncatingTail
@ -75,29 +76,21 @@ final class ScenicQueueViewController: BaseViewController {
contentContainer.backgroundColor = .white contentContainer.backgroundColor = .white
contentContainer.layer.cornerRadius = ScenicQueueTokens.radiusContentSheetTop contentContainer.layer.cornerRadius = ScenicQueueTokens.radiusContentSheetTop
contentContainer.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] contentContainer.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
contentContainer.clipsToBounds = true contentContainer.layer.shadowColor = UIColor.black.cgColor
contentContainer.layer.shadowOpacity = 0.08
segmentedControl.selectedSegmentIndex = 0 contentContainer.layer.shadowRadius = 4
segmentedControl.selectedSegmentTintColor = .white contentContainer.layer.shadowOffset = CGSize(width: 0, height: 2)
segmentedControl.setTitleTextAttributes([
.font: ScenicQueueTokens.tabFont,
.foregroundColor: AppColor.textSecondary,
], for: .normal)
segmentedControl.setTitleTextAttributes([
.font: ScenicQueueTokens.tabSelectedFont,
.foregroundColor: ScenicQueueTokens.bannerBlue,
], for: .selected)
tableView.backgroundColor = .white tableView.backgroundColor = .white
tableView.separatorStyle = .none tableView.separatorStyle = .none
tableView.rowHeight = UITableView.automaticDimension tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 260 tableView.estimatedRowHeight = 260
tableView.delegate = self tableView.delegate = self
tableView.dataSource = self
tableView.register(ScenicQueueTicketCell.self, forCellReuseIdentifier: ScenicQueueTicketCell.reuseIdentifier) tableView.register(ScenicQueueTicketCell.self, forCellReuseIdentifier: ScenicQueueTicketCell.reuseIdentifier)
tableView.register(ScenicQueueSkippedCell.self, forCellReuseIdentifier: ScenicQueueSkippedCell.reuseIdentifier) tableView.register(ScenicQueueSkippedCell.self, forCellReuseIdentifier: ScenicQueueSkippedCell.reuseIdentifier)
tableView.refreshControl = UIRefreshControl() tableView.refreshControl = UIRefreshControl()
tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged) tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
configureDataSource()
emptyLabel.textColor = AppColor.textTertiary emptyLabel.textColor = AppColor.textTertiary
emptyLabel.font = ScenicQueueTokens.emptyStateFont emptyLabel.font = ScenicQueueTokens.emptyStateFont
@ -114,7 +107,7 @@ final class ScenicQueueViewController: BaseViewController {
view.addSubview(punchSpotRow) view.addSubview(punchSpotRow)
view.addSubview(statsBanner) view.addSubview(statsBanner)
view.addSubview(contentContainer) view.addSubview(contentContainer)
contentContainer.addSubview(segmentedControl) contentContainer.addSubview(tabStrip)
contentContainer.addSubview(tableView) contentContainer.addSubview(tableView)
contentContainer.addSubview(emptyLabel) contentContainer.addSubview(emptyLabel)
view.addSubview(bottomBar) view.addSubview(bottomBar)
@ -149,12 +142,13 @@ final class ScenicQueueViewController: BaseViewController {
make.leading.trailing.equalToSuperview().inset(16) make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(bottomBar.snp.top).offset(-8) make.bottom.equalTo(bottomBar.snp.top).offset(-8)
} }
segmentedControl.snp.makeConstraints { make in tabStrip.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(12) make.top.equalToSuperview()
make.height.equalTo(40) make.leading.trailing.equalToSuperview().inset(12)
make.height.equalTo(48)
} }
tableView.snp.makeConstraints { make in tableView.snp.makeConstraints { make in
make.top.equalTo(segmentedControl.snp.bottom).offset(8) make.top.equalTo(tabStrip.snp.bottom)
make.leading.trailing.bottom.equalToSuperview() make.leading.trailing.bottom.equalToSuperview()
} }
emptyLabel.snp.makeConstraints { make in emptyLabel.snp.makeConstraints { make in
@ -177,7 +171,7 @@ final class ScenicQueueViewController: BaseViewController {
} }
override func bindActions() { override func bindActions() {
segmentedControl.addTarget(self, action: #selector(tabChanged), for: .valueChanged) tabStrip.onSelect = { [weak self] index in self?.selectTab(index) }
prepareCallButton.addTarget(self, action: #selector(prepareCallTapped), for: .touchUpInside) prepareCallButton.addTarget(self, action: #selector(prepareCallTapped), for: .touchUpInside)
quickCallButton.addTarget(self, action: #selector(quickCallTapped), for: .touchUpInside) quickCallButton.addTarget(self, action: #selector(quickCallTapped), for: .touchUpInside)
viewModel.onStateChange = { [weak self] in viewModel.onStateChange = { [weak self] in
@ -202,7 +196,7 @@ final class ScenicQueueViewController: BaseViewController {
punchSpotLabel.text = viewModel.selectedPunchSpotLine punchSpotLabel.text = viewModel.selectedPunchSpotLine
statsBanner.configure(summary: viewModel.summary) statsBanner.configure(summary: viewModel.summary)
tableView.refreshControl?.endRefreshing() tableView.refreshControl?.endRefreshing()
tableView.reloadData() applySnapshot(animated: true)
let isEmpty = selectedTab == 0 ? viewModel.currentQueue.isEmpty : viewModel.skippedQueue.isEmpty let isEmpty = selectedTab == 0 ? viewModel.currentQueue.isEmpty : viewModel.skippedQueue.isEmpty
emptyLabel.text = selectedTab == 0 ? "暂无排队" : "暂无过号记录" emptyLabel.text = selectedTab == 0 ? "暂无排队" : "暂无过号记录"
emptyLabel.isHidden = !isEmpty emptyLabel.isHidden = !isEmpty
@ -212,6 +206,41 @@ final class ScenicQueueViewController: BaseViewController {
bottomBar.isHidden = !viewModel.queueGatePassed || (!viewModel.prepareCallButtonEnabled && !viewModel.quickCallButtonEnabled) bottomBar.isHidden = !viewModel.queueGatePassed || (!viewModel.prepareCallButtonEnabled && !viewModel.quickCallButtonEnabled)
} }
private func configureDataSource() {
dataSource = UITableViewDiffableDataSource<Int, ScenicQueueListItem>(tableView: tableView) { [weak self] tableView, indexPath, item in
guard let self else { return UITableViewCell() }
switch item {
case .current(let ticket):
let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueTicketCell.reuseIdentifier, for: indexPath) as! ScenicQueueTicketCell
cell.delegate = self
cell.configure(
ticket: ticket,
shootingQueueNo: self.viewModel.shootingTimedQueueNo,
remainSeconds: self.viewModel.shootingRemainSeconds,
showStartShootingButton: self.viewModel.showStartShootingButton
)
return cell
case .skipped(let ticket):
let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueSkippedCell.reuseIdentifier, for: indexPath) as! ScenicQueueSkippedCell
cell.delegate = self
cell.configure(ticket: ticket)
return cell
}
}
}
private func applySnapshot(animated: Bool) {
let previousItems = Set(dataSource.snapshot().itemIdentifiers)
var snapshot = NSDiffableDataSourceSnapshot<Int, ScenicQueueListItem>()
snapshot.appendSections([0])
let items: [ScenicQueueListItem] = selectedTab == 0
? viewModel.currentQueue.map(ScenicQueueListItem.current)
: viewModel.skippedQueue.map(ScenicQueueListItem.skipped)
snapshot.appendItems(items)
snapshot.reconfigureItems(items.filter(previousItems.contains))
dataSource.apply(snapshot, animatingDifferences: animated)
}
private func configureBottomButton(_ button: UIButton, title: String, subtitle: String, image: UIImage?, color: UIColor) { private func configureBottomButton(_ button: UIButton, title: String, subtitle: String, image: UIImage?, color: UIColor) {
var config = UIButton.Configuration.filled() var config = UIButton.Configuration.filled()
config.baseBackgroundColor = color config.baseBackgroundColor = color
@ -232,42 +261,63 @@ final class ScenicQueueViewController: BaseViewController {
private func presentParametersRequiredDialog() { private func presentParametersRequiredDialog() {
guard presentedViewController == nil else { return } guard presentedViewController == nil else { return }
let alert = UIAlertController(title: "排队参数未配置", message: "请先选择打卡点并保存排队设置", preferredStyle: .alert) let dialog = ScenicQueueConfirmationDialogViewController(
alert.addAction(UIAlertAction(title: "暂不进入", style: .cancel) { [weak self] _ in configuration: ScenicQueueDialogConfiguration(
self?.viewModel.exitQueueModule() title: "请先设置排队参数",
}) message: "请在排队设置中选择并保存打卡点后,再使用排队管理。",
alert.addAction(UIAlertAction(title: "去设置", style: .default) { [weak self] _ in emphasizedText: nil,
self?.navigationController?.pushViewController(ScenicQueueSettingsViewController(), animated: true) dangerText: nil,
}) cancelTitle: "暂不使用",
present(alert, animated: true) confirmTitle: "去设置",
buttonRadius: 22,
dismissOnBackdrop: false
),
onCancel: { [weak self] in self?.viewModel.exitQueueModule() },
onConfirm: { [weak self] in self?.navigationController?.pushViewController(ScenicQueueSettingsViewController(), animated: true) }
)
present(dialog, animated: true)
} }
private func presentConfirm(title: String, message: String?, confirm: @escaping () -> Void) { private func presentConfirm(
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) title: String,
alert.addAction(UIAlertAction(title: "取消", style: .cancel)) emphasizedText: String,
alert.addAction(UIAlertAction(title: "确认", style: .default) { _ in confirm() }) dangerText: String? = nil,
present(alert, animated: true) confirmTitle: String = "确认",
cancel: @escaping () -> Void,
confirm: @escaping () -> Void
) {
let dialog = ScenicQueueConfirmationDialogViewController(
configuration: ScenicQueueDialogConfiguration(
title: title,
message: nil,
emphasizedText: emphasizedText,
dangerText: dangerText,
cancelTitle: "取消",
confirmTitle: confirmTitle,
buttonRadius: title == "确认完成拍摄" ? 22 : 8,
dismissOnBackdrop: true
),
onCancel: cancel,
onConfirm: confirm
)
present(dialog, animated: true)
} }
private func openDial(_ digits: String) { private func openDial(_ digits: String) {
guard !digits.isEmpty else { return } guard !digits.isEmpty else { return }
let alert = UIAlertController(title: "拨打电话", message: digits, preferredStyle: .actionSheet) present(ScenicQueueDialSheetViewController(digits: digits), animated: true)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "拨打", style: .default) { _ in
if let url = URL(string: "tel:\(digits)") {
UIApplication.shared.open(url)
}
})
present(alert, animated: true)
} }
@objc private func settingsTapped() { @objc private func settingsTapped() {
viewModel.openSettings() viewModel.openSettings()
} }
@objc private func tabChanged() { private func selectTab(_ index: Int) {
selectedTab = segmentedControl.selectedSegmentIndex guard index != selectedTab else { return }
tableView.reloadData() selectedTab = index
UIView.transition(with: tableView, duration: 0.22, options: .transitionCrossDissolve) {
self.applySnapshot(animated: false)
}
applyState() applyState()
} }
@ -298,30 +348,7 @@ final class ScenicQueueViewController: BaseViewController {
} }
} }
extension ScenicQueueViewController: UITableViewDataSource, UITableViewDelegate { extension ScenicQueueViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
selectedTab == 0 ? viewModel.currentQueue.count : viewModel.skippedQueue.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
if selectedTab == 0 {
let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueTicketCell.reuseIdentifier, for: indexPath) as! ScenicQueueTicketCell
let ticket = viewModel.currentQueue[indexPath.row]
cell.delegate = self
cell.configure(
ticket: ticket,
shootingQueueNo: viewModel.shootingTimedQueueNo,
remainSeconds: viewModel.shootingRemainSeconds,
showStartShootingButton: viewModel.showStartShootingButton
)
return cell
}
let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueSkippedCell.reuseIdentifier, for: indexPath) as! ScenicQueueSkippedCell
cell.delegate = self
cell.configure(ticket: viewModel.skippedQueue[indexPath.row])
return cell
}
func scrollViewDidScroll(_ scrollView: UIScrollView) { func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height - 120 else { return } guard scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height - 120 else { return }
Task { Task {
@ -341,7 +368,9 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate {
func scenicQueueTicketCellDidTapSkip(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) { func scenicQueueTicketCellDidTapSkip(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
viewModel.requestSkipTicket(recordId: ticket.recordId, queueNo: ticket.queueNo) viewModel.requestSkipTicket(recordId: ticket.recordId, queueNo: ticket.queueNo)
presentConfirm(title: "确认过号", message: ticket.queueNo) { [weak self] in presentConfirm(title: "确认过号", emphasizedText: ticket.queueNo, dangerText: "确认该用户未到场并过号", cancel: { [weak self] in
self?.viewModel.dismissSkipDialog()
}) { [weak self] in
guard let self else { return } guard let self else { return }
Task { await self.viewModel.confirmSkipTicket(api: self.api) } Task { await self.viewModel.confirmSkipTicket(api: self.api) }
} }
@ -350,7 +379,9 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate {
func scenicQueueTicketCellDidTapPrimary(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) { func scenicQueueTicketCellDidTapPrimary(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
if ticket.status == .waiting { if ticket.status == .waiting {
viewModel.requestCallConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo) viewModel.requestCallConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo)
presentConfirm(title: "确认叫号", message: ticket.queueNo) { [weak self] in presentConfirm(title: "确认叫号", emphasizedText: "确认叫号【\(ticket.queueNo)", cancel: { [weak self] in
self?.viewModel.dismissCallConfirmDialog()
}) { [weak self] in
guard let self else { return } guard let self else { return }
Task { await self.viewModel.confirmCallTicket(api: self.api) } Task { await self.viewModel.confirmCallTicket(api: self.api) }
} }
@ -361,7 +392,9 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate {
func scenicQueueTicketCellDidTapRecall(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) { func scenicQueueTicketCellDidTapRecall(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
viewModel.requestCallConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo, isRecall: true) viewModel.requestCallConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo, isRecall: true)
presentConfirm(title: "重新叫号", message: ticket.queueNo) { [weak self] in presentConfirm(title: "重新叫号", emphasizedText: "确认叫号【\(ticket.queueNo)", cancel: { [weak self] in
self?.viewModel.dismissCallConfirmDialog()
}) { [weak self] in
guard let self else { return } guard let self else { return }
Task { await self.viewModel.confirmCallTicket(api: self.api) } Task { await self.viewModel.confirmCallTicket(api: self.api) }
} }
@ -369,7 +402,9 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate {
func scenicQueueTicketCellDidTapComplete(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) { func scenicQueueTicketCellDidTapComplete(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
viewModel.requestFinishConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo) viewModel.requestFinishConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo)
presentConfirm(title: "确认完成拍摄", message: ticket.queueNo) { [weak self] in presentConfirm(title: "确认完成拍摄", emphasizedText: ticket.queueNo, cancel: { [weak self] in
self?.viewModel.dismissFinishDialog()
}) { [weak self] in
guard let self else { return } guard let self else { return }
Task { await self.viewModel.confirmFinishTicket(api: self.api) } Task { await self.viewModel.confirmFinishTicket(api: self.api) }
} }
@ -377,25 +412,16 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate {
func scenicQueueTicketCellDidTapMark(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) { func scenicQueueTicketCellDidTapMark(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
viewModel.onTicketMarkClick(recordId: ticket.recordId, queueNo: ticket.queueNo, uid: ticket.uid) viewModel.onTicketMarkClick(recordId: ticket.recordId, queueNo: ticket.queueNo, uid: ticket.uid)
let alert = UIAlertController(title: "标记用户", message: "\(ticket.queueNo)\n\(ticket.phoneMasked)", preferredStyle: .alert) guard let pending = viewModel.pendingMark else { return }
alert.addTextField { field in let dialog = ScenicQueueMarkDialogViewController(
field.placeholder = "限制排队天数" pending: pending,
field.keyboardType = .numberPad onCancel: { [weak self] in self?.viewModel.dismissMarkDialog() },
field.text = "0" onConfirm: { [weak self] marked, days in
guard let self else { return }
Task { await self.viewModel.confirmUserMark(api: self.api, markAsFreelancePhotog: marked, queueBanDays: days) }
} }
alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in )
self?.viewModel.dismissMarkDialog() present(dialog, animated: true)
})
alert.addAction(UIAlertAction(title: "标记打野", style: .default) { [weak self, weak alert] _ in
guard let self else { return }
let days = Int(alert?.textFields?.first?.text ?? "") ?? 0
Task { await self.viewModel.confirmUserMark(api: self.api, markAsFreelancePhotog: true, queueBanDays: days) }
})
alert.addAction(UIAlertAction(title: "取消标记", style: .destructive) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.confirmUserMark(api: self.api, markAsFreelancePhotog: false, queueBanDays: 0) }
})
present(alert, animated: true)
} }
} }
@ -406,7 +432,9 @@ extension ScenicQueueViewController: ScenicQueueSkippedCellDelegate {
func scenicQueueSkippedCellDidTapRequeue(_ cell: ScenicQueueSkippedCell, ticket: ScenicQueueSkippedTicket) { func scenicQueueSkippedCellDidTapRequeue(_ cell: ScenicQueueSkippedCell, ticket: ScenicQueueSkippedTicket) {
viewModel.requestRequeueConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo) viewModel.requestRequeueConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo)
presentConfirm(title: "确认重新排队", message: ticket.queueNo) { [weak self] in presentConfirm(title: "确认重新排队", emphasizedText: ticket.queueNo, dangerText: "确认后将插入当前排队队列", confirmTitle: "确定", cancel: { [weak self] in
self?.viewModel.dismissRequeueDialog()
}) { [weak self] in
guard let self else { return } guard let self else { return }
Task { await self.viewModel.confirmRequeueTicket(api: self.api) } Task { await self.viewModel.confirmRequeueTicket(api: self.api) }
} }

View File

@ -0,0 +1,396 @@
//
// ScenicQueueOverlayViews.swift
// suixinkan
//
import SnapKit
import UIKit
/// Android
final class ScenicQueueTabStripView: UIView {
private let stackView = UIStackView()
private let indicator = UIView()
private let divider = UIView()
private var buttons: [UIButton] = []
///
var onSelect: ((Int) -> Void)?
///
private(set) var selectedIndex = 0
init(titles: [String]) {
super.init(frame: .zero)
backgroundColor = .white
stackView.axis = .horizontal
stackView.distribution = .fillEqually
titles.enumerated().forEach { index, title in
let button = UIButton(type: .system)
button.tag = index
button.setTitle(title, for: .normal)
button.titleLabel?.font = ScenicQueueTokens.tabFont
button.addTarget(self, action: #selector(buttonTapped(_:)), for: .touchUpInside)
buttons.append(button)
stackView.addArrangedSubview(button)
}
indicator.backgroundColor = ScenicQueueTokens.bannerBlue
divider.backgroundColor = ScenicQueueTokens.cardOutline
addSubview(stackView)
addSubview(divider)
addSubview(indicator)
stackView.snp.makeConstraints { $0.edges.equalToSuperview() }
divider.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
make.height.equalTo(1 / UIScreen.main.scale)
}
setSelectedIndex(0, animated: false)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
updateIndicatorFrame()
}
/// 3pt
func setSelectedIndex(_ index: Int, animated: Bool) {
guard buttons.indices.contains(index) else { return }
selectedIndex = index
buttons.enumerated().forEach { item in
item.element.setTitleColor(item.offset == index ? ScenicQueueTokens.bannerBlue : AppColor.textSecondary, for: .normal)
item.element.titleLabel?.font = item.offset == index ? ScenicQueueTokens.tabSelectedFont : ScenicQueueTokens.tabFont
}
let changes = { self.updateIndicatorFrame() }
animated ? UIView.animate(withDuration: 0.22, animations: changes) : changes()
}
private func updateIndicatorFrame() {
guard !buttons.isEmpty, bounds.width > 0 else { return }
let width = bounds.width / CGFloat(buttons.count)
indicator.frame = CGRect(x: width * CGFloat(selectedIndex), y: bounds.height - 3, width: width, height: 3)
}
@objc private func buttonTapped(_ sender: UIButton) {
setSelectedIndex(sender.tag, animated: true)
onSelect?(sender.tag)
}
}
///
struct ScenicQueueDialogConfiguration {
let title: String
let message: String?
let emphasizedText: String?
let dangerText: String?
let cancelTitle: String
let confirmTitle: String
let buttonRadius: CGFloat
let dismissOnBackdrop: Bool
}
/// Android
final class ScenicQueueConfirmationDialogViewController: UIViewController, UIGestureRecognizerDelegate {
private let configuration: ScenicQueueDialogConfiguration
private let onCancel: () -> Void
private let onConfirm: () -> Void
init(configuration: ScenicQueueDialogConfiguration, onCancel: @escaping () -> Void, onConfirm: @escaping () -> Void) {
self.configuration = configuration
self.onCancel = onCancel
self.onConfirm = onConfirm
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
modalTransitionStyle = .crossDissolve
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.black.withAlphaComponent(0.42)
if configuration.dismissOnBackdrop {
let backdropTap = UITapGestureRecognizer(target: self, action: #selector(cancelTapped))
backdropTap.delegate = self
view.addGestureRecognizer(backdropTap)
}
let card = UIView()
card.backgroundColor = .white
card.layer.cornerRadius = ScenicQueueTokens.radiusDialog
card.layer.shadowColor = UIColor.black.cgColor
card.layer.shadowOpacity = 0.18
card.layer.shadowRadius = 8
let titleLabel = UILabel()
titleLabel.text = configuration.title
titleLabel.font = ScenicQueueTokens.dialogTitleFont
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
let contentStack = UIStackView(arrangedSubviews: [titleLabel])
contentStack.axis = .vertical
contentStack.spacing = 10
if let message = configuration.message {
let label = makeLabel(message, font: .systemFont(ofSize: 15), color: AppColor.textSecondary)
contentStack.addArrangedSubview(label)
}
if let danger = configuration.dangerText {
let label = makeLabel(danger, font: .systemFont(ofSize: 13), color: ScenicQueueTokens.dialogDangerLine)
contentStack.addArrangedSubview(label)
}
if let emphasized = configuration.emphasizedText {
let label = makeLabel(emphasized, font: ScenicQueueTokens.dialogTicketFont, color: AppColor.textPrimary)
contentStack.addArrangedSubview(label)
contentStack.setCustomSpacing(20, after: label)
}
let cancel = makeButton(configuration.cancelTitle, background: ScenicQueueTokens.dialogCancelBackground, foreground: ScenicQueueTokens.bannerBlue)
cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
let confirm = makeButton(configuration.confirmTitle, background: ScenicQueueTokens.bannerBlue, foreground: .white)
confirm.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
cancel.layer.cornerRadius = configuration.buttonRadius
confirm.layer.cornerRadius = configuration.buttonRadius
let buttons = UIStackView(arrangedSubviews: [cancel, confirm])
buttons.axis = .horizontal
buttons.distribution = .fillEqually
buttons.spacing = 12
contentStack.addArrangedSubview(buttons)
view.addSubview(card)
card.addSubview(contentStack)
card.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(32)
}
contentStack.snp.makeConstraints { $0.edges.equalToSuperview().inset(22) }
buttons.snp.makeConstraints { $0.height.equalTo(44) }
}
private func makeLabel(_ text: String, font: UIFont, color: UIColor) -> UILabel {
let label = UILabel()
label.text = text
label.font = font
label.textColor = color
label.textAlignment = .center
label.numberOfLines = 0
return label
}
private func makeButton(_ title: String, background: UIColor, foreground: UIColor) -> UIButton {
let button = UIButton(type: .system)
button.setTitle(title, for: .normal)
button.setTitleColor(foreground, for: .normal)
button.titleLabel?.font = ScenicQueueTokens.dialogButtonFont
button.backgroundColor = background
button.clipsToBounds = true
return button
}
@objc private func cancelTapped() {
dismiss(animated: true, completion: onCancel)
}
@objc private func confirmTapped() {
dismiss(animated: true, completion: onConfirm)
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
touch.view === view
}
}
/// Android
final class ScenicQueueDialSheetViewController: UIViewController, UIGestureRecognizerDelegate {
private let digits: String
init(digits: String) {
self.digits = digits
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
modalTransitionStyle = .crossDissolve
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.black.withAlphaComponent(0.36)
let backdropTap = UITapGestureRecognizer(target: self, action: #selector(cancelTapped))
backdropTap.delegate = self
view.addGestureRecognizer(backdropTap)
let panel = UIView()
panel.backgroundColor = .white
panel.layer.cornerRadius = 18
panel.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
let title = UILabel()
title.text = "拨打电话"
title.font = ScenicQueueTokens.dialogTitleFont
title.textAlignment = .center
let number = UILabel()
number.text = digits
number.font = .systemFont(ofSize: 18, weight: .medium)
number.textColor = AppColor.textPrimary
number.textAlignment = .center
let cancel = pillButton("取消", background: ScenicQueueTokens.dialogCancelBackground, foreground: ScenicQueueTokens.bannerBlue)
cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
let dial = pillButton("拨打", background: ScenicQueueTokens.bannerBlue, foreground: .white)
dial.addTarget(self, action: #selector(dialTapped), for: .touchUpInside)
let buttons = UIStackView(arrangedSubviews: [cancel, dial])
buttons.axis = .horizontal
buttons.distribution = .fillEqually
buttons.spacing = 12
view.addSubview(panel)
[title, number, buttons].forEach(panel.addSubview)
panel.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() }
title.snp.makeConstraints { make in make.top.equalToSuperview().offset(18); make.centerX.equalToSuperview() }
number.snp.makeConstraints { make in make.top.equalTo(title.snp.bottom).offset(18); make.leading.trailing.equalToSuperview().inset(16) }
buttons.snp.makeConstraints { make in
make.top.equalTo(number.snp.bottom).offset(18)
make.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(44)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
}
private func pillButton(_ title: String, background: UIColor, foreground: UIColor) -> UIButton {
let button = UIButton(type: .system)
button.setTitle(title, for: .normal)
button.setTitleColor(foreground, for: .normal)
button.titleLabel?.font = ScenicQueueTokens.dialogButtonFont
button.backgroundColor = background
button.layer.cornerRadius = 22
return button
}
@objc private func cancelTapped() { dismiss(animated: true) }
@objc private func dialTapped() {
dismiss(animated: true) {
guard let url = URL(string: "tel:\(self.digits)") else { return }
UIApplication.shared.open(url)
}
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
touch.view === view
}
}
/// Android
final class ScenicQueueMarkDialogViewController: UIViewController {
private let pending: ScenicQueuePendingMark
private let onCancel: () -> Void
private let onConfirm: (Bool, Int) -> Void
private let markSwitch = UISwitch()
private let daysLabel = UILabel()
private var days = 0 { didSet { daysLabel.text = "\(days)" } }
init(pending: ScenicQueuePendingMark, onCancel: @escaping () -> Void, onConfirm: @escaping (Bool, Int) -> Void) {
self.pending = pending
self.onCancel = onCancel
self.onConfirm = onConfirm
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
modalTransitionStyle = .crossDissolve
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.black.withAlphaComponent(0.42)
let card = UIView()
card.backgroundColor = .white
card.layer.cornerRadius = ScenicQueueTokens.markDialogRadius
let title = UILabel()
title.text = "标记用户"
title.font = ScenicQueueTokens.dialogTitleFont
title.textAlignment = .center
let queue = UILabel()
queue.text = pending.queueNo
queue.font = .systemFont(ofSize: 24, weight: .bold)
queue.textColor = ScenicQueueTokens.bannerBlue
let phone = UILabel()
phone.text = pending.phoneMasked
phone.font = ScenicQueueTokens.phoneMaskedFont
phone.textColor = AppColor.textSecondary
let identityTitle = UILabel()
identityTitle.text = "标记为打野摄影师"
identityTitle.font = ScenicQueueTokens.settingsRowTitleFont
markSwitch.isOn = pending.markAsFreelancePhotog == 1
markSwitch.onTintColor = ScenicQueueTokens.bannerBlue
let identityRow = UIStackView(arrangedSubviews: [identityTitle, UIView(), markSwitch])
identityRow.axis = .horizontal
identityRow.alignment = .center
let hint = UILabel()
hint.text = "标记后,该用户扫码时将受排队限制。"
hint.font = ScenicQueueTokens.settingsHintFont
hint.textColor = AppColor.textTertiary
hint.numberOfLines = 0
let daysTitle = UILabel()
daysTitle.text = "限制排队天数"
daysTitle.font = ScenicQueueTokens.settingsRowTitleFont
let minus = stepButton("", action: #selector(minusTapped))
let plus = stepButton("+", action: #selector(plusTapped))
daysLabel.text = "0"
daysLabel.font = .systemFont(ofSize: 16, weight: .medium)
daysLabel.textAlignment = .center
let stepper = UIStackView(arrangedSubviews: [minus, daysLabel, plus])
stepper.axis = .horizontal
stepper.alignment = .center
stepper.distribution = .fillEqually
stepper.backgroundColor = ScenicQueueTokens.markDialogStepperBackground
stepper.layer.cornerRadius = 10
let cancel = actionButton("取消", background: ScenicQueueTokens.markDialogCancelBackground, foreground: ScenicQueueTokens.bannerBlue)
cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
let confirm = actionButton("完成", background: ScenicQueueTokens.bannerBlue, foreground: .white)
confirm.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
let buttons = UIStackView(arrangedSubviews: [cancel, confirm])
buttons.axis = .horizontal
buttons.distribution = .fillEqually
buttons.spacing = 12
let stack = UIStackView(arrangedSubviews: [title, queue, phone, identityRow, hint, daysTitle, stepper, buttons])
stack.axis = .vertical
stack.spacing = 12
view.addSubview(card)
card.addSubview(stack)
card.snp.makeConstraints { make in make.center.equalToSuperview(); make.width.equalToSuperview().multipliedBy(0.88) }
stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(22) }
stepper.snp.makeConstraints { $0.height.equalTo(44) }
buttons.snp.makeConstraints { $0.height.equalTo(44) }
}
private func stepButton(_ title: String, action: Selector) -> UIButton {
let button = UIButton(type: .system)
button.setTitle(title, for: .normal)
button.setTitleColor(ScenicQueueTokens.bannerBlue, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 22, weight: .medium)
button.addTarget(self, action: action, for: .touchUpInside)
return button
}
private func actionButton(_ title: String, background: UIColor, foreground: UIColor) -> UIButton {
let button = UIButton(type: .system)
button.setTitle(title, for: .normal)
button.setTitleColor(foreground, for: .normal)
button.titleLabel?.font = ScenicQueueTokens.dialogButtonFont
button.backgroundColor = background
button.layer.cornerRadius = ScenicQueueTokens.markDialogButtonRadius
return button
}
@objc private func minusTapped() { days = max(0, days - 1) }
@objc private func plusTapped() { days = min(999, days + 1) }
@objc private func cancelTapped() { dismiss(animated: true, completion: onCancel) }
@objc private func confirmTapped() {
let marked = markSwitch.isOn
dismiss(animated: true) { self.onConfirm(marked, marked ? self.days : 0) }
}
}

View File

@ -261,33 +261,38 @@ final class ScenicQueueTicketCell: UITableViewCell {
private func rebuildActions(ticket: ScenicQueueTicket, shootingQueueNo: String?, remainSeconds: Int) { private func rebuildActions(ticket: ScenicQueueTicket, shootingQueueNo: String?, remainSeconds: Int) {
actionsStack.arrangedSubviews.forEach { $0.removeFromSuperview() } actionsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
let state = ScenicQueueTicketActionState.make(
ticket: ticket,
shootingQueueNo: shootingQueueNo,
remainSeconds: remainSeconds,
showStartShootingButton: showStartShootingButton
)
let skip = ScenicQueueActionButton(title: "过号", background: ScenicQueueTokens.buttonSkipBackground, foreground: ScenicQueueTokens.buttonSkipForeground) let skip = ScenicQueueActionButton(title: "过号", background: ScenicQueueTokens.buttonSkipBackground, foreground: ScenicQueueTokens.buttonSkipForeground)
skip.addTarget(self, action: #selector(skipTapped), for: .touchUpInside) skip.addTarget(self, action: #selector(skipTapped), for: .touchUpInside)
let primaryTitle: String skip.isEnabled = state.skipEnabled
if shootingQueueNo == ticket.queueNo { skip.alpha = state.skipEnabled ? 1 : 0.45
primaryTitle = "剩余 \(max(0, remainSeconds))" let row = UIStackView(arrangedSubviews: [skip])
} else {
primaryTitle = ticket.status == .called ? "开始拍摄" : "叫号"
}
let primary = ScenicQueueActionButton(title: primaryTitle, background: ScenicQueueTokens.bannerBlue, foreground: .white)
primary.isEnabled = shootingQueueNo != ticket.queueNo
primary.alpha = primary.isEnabled ? 1 : 0.65
primary.addTarget(self, action: #selector(primaryTapped), for: .touchUpInside)
let row = UIStackView(arrangedSubviews: [skip, primary])
row.axis = .horizontal row.axis = .horizontal
row.spacing = 10 row.spacing = 10
row.distribution = .fillProportionally row.distribution = .fillProportionally
skip.snp.makeConstraints { make in if state.primaryVisible {
make.height.equalTo(46) let primary = ScenicQueueActionButton(title: state.primaryTitle, background: ScenicQueueTokens.bannerBlue, foreground: .white)
make.width.equalTo(primary).multipliedBy(0.56) primary.isEnabled = state.primaryEnabled
primary.alpha = state.primaryEnabled ? 1 : 0.65
primary.addTarget(self, action: #selector(primaryTapped), for: .touchUpInside)
row.addArrangedSubview(primary)
skip.snp.makeConstraints { $0.width.equalTo(primary).multipliedBy(0.56) }
} }
row.snp.makeConstraints { $0.height.equalTo(46) }
actionsStack.addArrangedSubview(row) actionsStack.addArrangedSubview(row)
if ticket.status == .called, shootingQueueNo != ticket.queueNo { recallButton.removeTarget(nil, action: nil, for: .allEvents)
completeButton.removeTarget(nil, action: nil, for: .allEvents)
if state.recallVisible {
recallButton.addTarget(self, action: #selector(recallTapped), for: .touchUpInside) recallButton.addTarget(self, action: #selector(recallTapped), for: .touchUpInside)
recallButton.snp.makeConstraints { make in make.height.equalTo(40) } recallButton.snp.makeConstraints { make in make.height.equalTo(40) }
actionsStack.addArrangedSubview(recallButton) actionsStack.addArrangedSubview(recallButton)
} }
if ticket.showCompleteAction { if state.completeVisible {
completeButton.addTarget(self, action: #selector(completeTapped), for: .touchUpInside) completeButton.addTarget(self, action: #selector(completeTapped), for: .touchUpInside)
completeButton.snp.makeConstraints { make in make.height.equalTo(40) } completeButton.snp.makeConstraints { make in make.height.equalTo(40) }
actionsStack.addArrangedSubview(completeButton) actionsStack.addArrangedSubview(completeButton)
@ -329,17 +334,27 @@ final class ScenicQueueTicketCell: UITableViewCell {
right.backgroundColor = accent ? AppColor.primaryLight : .clear right.backgroundColor = accent ? AppColor.primaryLight : .clear
right.layer.cornerRadius = 2 right.layer.cornerRadius = 2
right.clipsToBounds = true right.clipsToBounds = true
if let tap {
right.isUserInteractionEnabled = true
right.addGestureRecognizer(UITapGestureRecognizer(target: self, action: tap))
}
row.addSubview(left) row.addSubview(left)
row.addSubview(right) let valueContainer = UIStackView(arrangedSubviews: [right])
valueContainer.axis = .horizontal
valueContainer.alignment = .center
valueContainer.spacing = 8
if let tap {
let dial = UIButton(type: .system)
dial.backgroundColor = ScenicQueueTokens.bannerBlue
dial.tintColor = .white
dial.setImage(UIImage(systemName: "phone.fill")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)), for: .normal)
dial.layer.cornerRadius = 16
dial.addTarget(self, action: tap, for: .touchUpInside)
valueContainer.addArrangedSubview(dial)
dial.snp.makeConstraints { $0.size.equalTo(32) }
}
row.addSubview(valueContainer)
left.snp.makeConstraints { make in left.snp.makeConstraints { make in
make.leading.centerY.equalToSuperview() make.leading.centerY.equalToSuperview()
make.top.bottom.equalToSuperview() make.top.bottom.equalToSuperview()
} }
right.snp.makeConstraints { make in valueContainer.snp.makeConstraints { make in
make.trailing.centerY.equalToSuperview() make.trailing.centerY.equalToSuperview()
make.leading.greaterThanOrEqualTo(left.snp.trailing).offset(12) make.leading.greaterThanOrEqualTo(left.snp.trailing).offset(12)
} }
@ -490,17 +505,27 @@ final class ScenicQueueSkippedCell: UITableViewCell {
right.text = value right.text = value
right.font = ScenicQueueTokens.phoneMaskedFont right.font = ScenicQueueTokens.phoneMaskedFont
right.textColor = AppColor.textPrimary right.textColor = AppColor.textPrimary
if let tap {
right.isUserInteractionEnabled = true
right.addGestureRecognizer(UITapGestureRecognizer(target: self, action: tap))
}
row.addSubview(left) row.addSubview(left)
row.addSubview(right) let valueContainer = UIStackView(arrangedSubviews: [right])
valueContainer.axis = .horizontal
valueContainer.alignment = .center
valueContainer.spacing = 8
if let tap {
let dial = UIButton(type: .system)
dial.backgroundColor = ScenicQueueTokens.bannerBlue
dial.tintColor = .white
dial.setImage(UIImage(systemName: "phone.fill")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)), for: .normal)
dial.layer.cornerRadius = 16
dial.addTarget(self, action: tap, for: .touchUpInside)
valueContainer.addArrangedSubview(dial)
dial.snp.makeConstraints { $0.size.equalTo(32) }
}
row.addSubview(valueContainer)
left.snp.makeConstraints { make in left.snp.makeConstraints { make in
make.leading.centerY.equalToSuperview() make.leading.centerY.equalToSuperview()
make.top.bottom.equalToSuperview() make.top.bottom.equalToSuperview()
} }
right.snp.makeConstraints { make in valueContainer.snp.makeConstraints { make in
make.trailing.centerY.equalToSuperview() make.trailing.centerY.equalToSuperview()
make.leading.greaterThanOrEqualTo(left.snp.trailing).offset(12) make.leading.greaterThanOrEqualTo(left.snp.trailing).offset(12)
} }

View File

@ -19,10 +19,10 @@ final class SettingViewController: BaseViewController {
init( init(
viewModel: SettingViewModel = SettingViewModel(), viewModel: SettingViewModel = SettingViewModel(),
settingAPI: SettingAPI = NetworkServices.shared.settingAPI settingAPI: SettingAPI? = nil
) { ) {
self.viewModel = viewModel self.viewModel = viewModel
self.settingAPI = settingAPI self.settingAPI = settingAPI ?? NetworkServices.shared.settingAPI
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }

View File

@ -28,9 +28,9 @@ final class TravelAlbumDetailViewController: BaseViewController {
private let uploadButton = UIButton(type: .system) private let uploadButton = UIButton(type: .system)
private var isDeleteSelectedButtonVisible = false private var isDeleteSelectedButtonVisible = false
init(albumId: Int, api: any TravelAlbumServing = NetworkServices.shared.travelAlbumAPI) { init(albumId: Int, api: (any TravelAlbumServing)? = nil) {
viewModel = TravelAlbumDetailViewModel(albumId: albumId) viewModel = TravelAlbumDetailViewModel(albumId: albumId)
self.api = api self.api = api ?? NetworkServices.shared.travelAlbumAPI
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }

View File

@ -18,8 +18,8 @@ final class TravelAlbumEntryViewController: BaseViewController {
private let emptyView = TravelAlbumEmptyView() private let emptyView = TravelAlbumEmptyView()
private var dataSource: UITableViewDiffableDataSource<Int, TravelAlbum>! private var dataSource: UITableViewDiffableDataSource<Int, TravelAlbum>!
init(api: any TravelAlbumServing = NetworkServices.shared.travelAlbumAPI) { init(api: (any TravelAlbumServing)? = nil) {
self.api = api self.api = api ?? NetworkServices.shared.travelAlbumAPI
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }

View File

@ -36,10 +36,10 @@ final class PointsRedemptionViewController: BaseViewController, UITableViewDeleg
/// ///
init( init(
viewModel: PointsRedemptionViewModel = PointsRedemptionViewModel(), viewModel: PointsRedemptionViewModel = PointsRedemptionViewModel(),
walletAPI: any WalletPageServing = NetworkServices.shared.walletAPI walletAPI: (any WalletPageServing)? = nil
) { ) {
self.viewModel = viewModel self.viewModel = viewModel
self.walletAPI = walletAPI self.walletAPI = walletAPI ?? NetworkServices.shared.walletAPI
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }

View File

@ -41,12 +41,12 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
/// ///
init( init(
viewModel: WalletViewModel = WalletViewModel(), viewModel: WalletViewModel = WalletViewModel(),
walletAPI: any WalletPageServing = NetworkServices.shared.walletAPI, walletAPI: (any WalletPageServing)? = nil,
profileAPI: any WalletProfileServing = NetworkServices.shared.profileAPI profileAPI: (any WalletProfileServing)? = nil
) { ) {
self.viewModel = viewModel self.viewModel = viewModel
self.walletAPI = walletAPI self.walletAPI = walletAPI ?? NetworkServices.shared.walletAPI
self.profileAPI = profileAPI self.profileAPI = profileAPI ?? NetworkServices.shared.profileAPI
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }

View File

@ -34,10 +34,10 @@ final class WithdrawViewController: BaseViewController {
/// ///
init( init(
viewModel: WithdrawViewModel = WithdrawViewModel(), viewModel: WithdrawViewModel = WithdrawViewModel(),
walletAPI: any WalletPageServing = NetworkServices.shared.walletAPI walletAPI: (any WalletPageServing)? = nil
) { ) {
self.viewModel = viewModel self.viewModel = viewModel
self.walletAPI = walletAPI self.walletAPI = walletAPI ?? NetworkServices.shared.walletAPI
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }

View File

@ -15,10 +15,10 @@ final class WildPhotographerReportHomeViewController: BaseViewController {
init( init(
viewModel: WildPhotographerReportHomeViewModel = WildPhotographerReportHomeViewModel(), viewModel: WildPhotographerReportHomeViewModel = WildPhotographerReportHomeViewModel(),
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI api: (any WildPhotographerReportServing)? = nil
) { ) {
self.viewModel = viewModel self.viewModel = viewModel
self.api = api self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }

View File

@ -18,10 +18,10 @@ final class WildPhotographerReportListViewController: BaseViewController {
init( init(
homeViewModel: WildPhotographerReportHomeViewModel, homeViewModel: WildPhotographerReportHomeViewModel,
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI api: (any WildPhotographerReportServing)? = nil
) { ) {
self.viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel) self.viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel)
self.api = api self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
@ -560,11 +560,11 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
init( init(
record: WildReportRecord, record: WildReportRecord,
homeViewModel: WildPhotographerReportHomeViewModel, homeViewModel: WildPhotographerReportHomeViewModel,
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI api: (any WildPhotographerReportServing)? = nil
) { ) {
self.viewModel = WildPhotographerReportDetailViewModel(record: record, homeViewModel: homeViewModel) self.viewModel = WildPhotographerReportDetailViewModel(record: record, homeViewModel: homeViewModel)
self.homeViewModel = homeViewModel self.homeViewModel = homeViewModel
self.api = api self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }

View File

@ -16,6 +16,8 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
private let scrollView = UIScrollView() private let scrollView = UIScrollView()
private let contentStack = UIStackView() private let contentStack = UIStackView()
private let descriptionView = UITextView() private let descriptionView = UITextView()
private let descriptionPlaceholderLabel = UILabel()
private let descriptionCountLabel = UILabel()
private let contactField = UITextField() private let contactField = UITextField()
private let submitButton = UIButton(type: .system) private let submitButton = UIButton(type: .system)
private var pickerTarget: WildReportPickerTarget = .image private var pickerTarget: WildReportPickerTarget = .image
@ -25,12 +27,12 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
init( init(
homeViewModel: WildPhotographerReportHomeViewModel, homeViewModel: WildPhotographerReportHomeViewModel,
locationProvider: any LocationProviding = LocationProvider.shared, locationProvider: any LocationProviding = LocationProvider.shared,
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI, api: (any WildPhotographerReportServing)? = nil,
uploader: any WildReportAttachmentUploading = NetworkServices.shared.ossUploadService uploader: (any WildReportAttachmentUploading)? = nil
) { ) {
self.homeViewModel = homeViewModel self.homeViewModel = homeViewModel
self.api = api self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI
self.uploader = uploader self.uploader = uploader ?? NetworkServices.shared.ossUploadService
self.viewModel = WildPhotographerReportSubmitViewModel( self.viewModel = WildPhotographerReportSubmitViewModel(
homeViewModel: homeViewModel, homeViewModel: homeViewModel,
locationProvider: locationProvider locationProvider: locationProvider
@ -64,6 +66,14 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
descriptionView.textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 34, right: 8) descriptionView.textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 34, right: 8)
descriptionView.delegate = self descriptionView.delegate = self
descriptionPlaceholderLabel.text = "请描述摄影师的位置、特征、行为..."
descriptionPlaceholderLabel.font = .systemFont(ofSize: 15)
descriptionPlaceholderLabel.textColor = UIColor(hex: 0x9CA3AF)
descriptionCountLabel.font = .systemFont(ofSize: 14)
descriptionCountLabel.textColor = UIColor(hex: 0x6B7280)
descriptionCountLabel.textAlignment = .right
contactField.placeholder = "请输入摄影师微信号或手机号(选填)" contactField.placeholder = "请输入摄影师微信号或手机号(选填)"
contactField.font = .app(.body) contactField.font = .app(.body)
contactField.textColor = AppColor.textPrimary contactField.textColor = AppColor.textPrimary
@ -271,34 +281,28 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
private func makeDescriptionCard() -> UIView { private func makeDescriptionCard() -> UIView {
let card = sectionCard(index: 3, title: "举报说明") let card = sectionCard(index: 3, title: "举报说明")
descriptionView.text = viewModel.description descriptionView.text = viewModel.description
updateDescriptionInputState()
let inputContainer = UIView() let inputContainer = UIView()
inputContainer.backgroundColor = UIColor(hex: 0xFBFCFE) inputContainer.backgroundColor = UIColor(hex: 0xFBFCFE)
inputContainer.layer.cornerRadius = 12 inputContainer.layer.cornerRadius = 12
inputContainer.layer.borderWidth = 1 inputContainer.layer.borderWidth = 1
inputContainer.layer.borderColor = UIColor(hex: 0xCDD5E1).cgColor inputContainer.layer.borderColor = UIColor(hex: 0xCDD5E1).cgColor
let placeholder = UILabel()
placeholder.text = "请描述摄影师的位置、特征、行为..."
placeholder.font = .systemFont(ofSize: 15)
placeholder.textColor = UIColor(hex: 0x9CA3AF)
placeholder.isHidden = !viewModel.description.isEmpty
let count = WildReportUI.label("\(viewModel.description.count)/500", font: .systemFont(ofSize: 14), color: UIColor(hex: 0x6B7280))
count.textAlignment = .right
inputContainer.addSubview(descriptionView) inputContainer.addSubview(descriptionView)
inputContainer.addSubview(placeholder) inputContainer.addSubview(descriptionPlaceholderLabel)
inputContainer.addSubview(count) inputContainer.addSubview(descriptionCountLabel)
descriptionView.snp.makeConstraints { make in descriptionView.snp.makeConstraints { make in
make.edges.equalToSuperview() make.edges.equalToSuperview()
make.height.equalTo(132) make.height.equalTo(132)
} }
placeholder.snp.makeConstraints { make in let textOriginX = descriptionView.textContainerInset.left
make.top.equalToSuperview().offset(18) + descriptionView.textContainer.lineFragmentPadding
make.leading.equalToSuperview().offset(14) descriptionPlaceholderLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(descriptionView.textContainerInset.top)
make.leading.equalToSuperview().offset(textOriginX)
make.trailing.lessThanOrEqualToSuperview().inset(14) make.trailing.lessThanOrEqualToSuperview().inset(14)
} }
count.snp.makeConstraints { make in descriptionCountLabel.snp.makeConstraints { make in
make.trailing.bottom.equalToSuperview().inset(12) make.trailing.bottom.equalToSuperview().inset(12)
} }
card.stack.addArrangedSubview(inputContainer) card.stack.addArrangedSubview(inputContainer)
@ -376,6 +380,11 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
return card return card
} }
private func updateDescriptionInputState() {
descriptionPlaceholderLabel.isHidden = !viewModel.description.isEmpty
descriptionCountLabel.text = "\(viewModel.description.count)/500"
}
private func makePaymentCard() -> UIView { private func makePaymentCard() -> UIView {
let card = sectionCard(index: 6, title: "线下微信、支付宝截图", optional: true) let card = sectionCard(index: 6, title: "线下微信、支付宝截图", optional: true)
card.stack.addArrangedSubview(makeEvidenceUploadBlock( card.stack.addArrangedSubview(makeEvidenceUploadBlock(
@ -561,7 +570,12 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
) )
} }
} }
@objc private func contactChanged() { viewModel.updateContact(contactField.text ?? "") } @objc private func contactChanged() {
viewModel.updateContact(contactField.text ?? "")
guard contactField.markedTextRange == nil,
contactField.text != viewModel.contact else { return }
contactField.text = viewModel.contact
}
@objc private func dismissKeyboard() { view.endEditing(true) } @objc private func dismissKeyboard() { view.endEditing(true) }
@MainActor @MainActor
@ -654,6 +668,10 @@ extension WildPhotographerReportSubmitViewController: UITextViewDelegate {
func textViewDidChange(_ textView: UITextView) { func textViewDidChange(_ textView: UITextView) {
viewModel.updateDescription(textView.text) viewModel.updateDescription(textView.text)
if textView.markedTextRange == nil, textView.text != viewModel.description {
textView.text = viewModel.description
}
updateDescriptionInputState()
} }
func textViewDidEndEditing(_ textView: UITextView) { func textViewDidEndEditing(_ textView: UITextView) {

View File

@ -32,13 +32,13 @@ final class WildReportRiskMapViewController: BaseViewController {
private var isShowingRiskMapLoading = false private var isShowingRiskMapLoading = false
init( init(
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI, api: (any WildPhotographerReportServing)? = nil,
locationProvider: any LocationProviding = LocationProvider.shared, locationProvider: any LocationProviding = LocationProvider.shared,
scenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }, scenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId },
scenicNameProvider: @escaping () -> String = { AppStore.shared.session.currentScenicName } scenicNameProvider: @escaping () -> String = { AppStore.shared.session.currentScenicName }
) { ) {
self.viewModel = WildReportRiskMapViewModel() self.viewModel = WildReportRiskMapViewModel()
self.api = api self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI
self.locationProvider = locationProvider self.locationProvider = locationProvider
self.scenicIdProvider = scenicIdProvider self.scenicIdProvider = scenicIdProvider
self.scenicNameProvider = scenicNameProvider self.scenicNameProvider = scenicNameProvider
@ -675,7 +675,7 @@ final class WildReportRiskMapViewController: BaseViewController {
button.tintColor = AppColor.primary button.tintColor = AppColor.primary
button.setTitleColor(AppColor.primary, for: .normal) button.setTitleColor(AppColor.primary, for: .normal)
button.semanticContentAttribute = .forceRightToLeft button.semanticContentAttribute = .forceRightToLeft
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: 4, bottom: 0, right: -4) button.setConfigurationImagePlacement(.trailing, padding: 4)
if let phone { if let phone {
button.addAction(UIAction { [weak self] _ in button.addAction(UIAction { [weak self] _ in
self?.callPhone(phone) self?.callPhone(phone)

View File

@ -28,13 +28,13 @@ final class WildReportSupplementEvidenceViewController: BaseViewController {
init( init(
record: WildReportRecord, record: WildReportRecord,
homeViewModel: WildPhotographerReportHomeViewModel, homeViewModel: WildPhotographerReportHomeViewModel,
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI, api: (any WildPhotographerReportServing)? = nil,
uploader: any WildReportAttachmentUploading = NetworkServices.shared.ossUploadService, uploader: (any WildReportAttachmentUploading)? = nil,
onSupplementSuccess: (() -> Void)? = nil onSupplementSuccess: (() -> Void)? = nil
) { ) {
self.viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel) self.viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel)
self.api = api self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI
self.uploader = uploader self.uploader = uploader ?? NetworkServices.shared.ossUploadService
self.onSupplementSuccess = onSupplementSuccess self.onSupplementSuccess = onSupplementSuccess
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }

View File

@ -4,6 +4,7 @@
// //
import XCTest import XCTest
import UIKit
@testable import suixinkan @testable import suixinkan
@MainActor @MainActor
@ -55,6 +56,24 @@ final class ScenicQueueFeatureTests: XCTestCase {
XCTAssertEqual(ticket.missRequeueText, "过号重排") XCTAssertEqual(ticket.missRequeueText, "过号重排")
} }
func testHomeDecodingAcceptsBackendStatsWithoutNestedType() throws {
let json = """
{
"stats":{"avg_wait_min":0,"queue_count":0},
"list":{"list":[],"page":1,"page_size":10,"total":0},
"type":1
}
""".data(using: .utf8)!
let home = try JSONDecoder().decode(ScenicQueueHomeData.self, from: json)
XCTAssertEqual(home.stats?.type, 0)
XCTAssertEqual(home.list?.list, [])
XCTAssertEqual(home.list?.page, 1)
XCTAssertEqual(home.list?.pageSize, 10)
XCTAssertEqual(home.list?.total, 0)
}
func testSettingDecodingClampsSnapshotAndSocketMessage() throws { func testSettingDecodingClampsSnapshotAndSocketMessage() throws {
let settingJSON = """ let settingJSON = """
{ {
@ -399,6 +418,191 @@ final class ScenicQueueFeatureTests: XCTestCase {
XCTAssertFalse(store.scenicQueue.useOfflineTTS) XCTAssertFalse(store.scenicQueue.useOfflineTTS)
XCTAssertEqual(messages.last, "离线语音资源未安装") XCTAssertEqual(messages.last, "离线语音资源未安装")
} }
func testQueueDistanceKilometerConversionAndInvalidFormats() {
XCTAssertEqual(ScenicQueueSettingsViewModel.kilometersDisplay(fromMeters: 0), "0")
XCTAssertEqual(ScenicQueueSettingsViewModel.kilometersDisplay(fromMeters: 500), "0.5")
XCTAssertEqual(ScenicQueueSettingsViewModel.kilometersDisplay(fromMeters: 1_230), "1.23")
XCTAssertEqual(ScenicQueueSettingsViewModel.kilometersDisplay(fromMeters: 9_999_990), "9999.99")
XCTAssertEqual(ScenicQueueSettingsViewModel.meters(fromKilometersText: "0.50"), 500)
XCTAssertEqual(ScenicQueueSettingsViewModel.meters(fromKilometersText: "9999.99"), 9_999_990)
XCTAssertNil(ScenicQueueSettingsViewModel.meters(fromKilometersText: "1.234"))
XCTAssertNil(ScenicQueueSettingsViewModel.meters(fromKilometersText: "10000"))
XCTAssertNil(ScenicQueueSettingsViewModel.meters(fromKilometersText: "公里"))
}
func testInvalidNumericPendingBlocksSaveAndSelectsMatchingTab() async {
let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.invalidNumeric")!
defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.invalidNumeric")
let store = AppStore(defaults: defaults)
store.session.userId = "u1"
store.session.accountType = .unknown("photog")
store.session.currentScenicId = 11
let api = MockScenicQueueAPI()
let viewModel = ScenicQueueSettingsViewModel(appStore: store, ttsManager: MockScenicQueueTTSManager())
await viewModel.setPunchSpot(ScenicQueuePunchSpotOption(id: "22", label: "东门打卡点"), api: api)
viewModel.reportOutOfRangeNumericPending(fieldKey: ScenicQueueNumericFieldKey.firstNoticeAhead.rawValue, pending: true)
await viewModel.save(api: api)
XCTAssertNil(api.savedSetting)
XCTAssertEqual(viewModel.settingsTab, .notify)
XCTAssertTrue(viewModel.highlightedInvalidFieldKeys.contains(ScenicQueueNumericFieldKey.firstNoticeAhead.rawValue))
}
func testPresetVoicePlayStopAndDeleteState() async {
let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.presetPlayback")!
defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.presetPlayback")
let store = AppStore(defaults: defaults)
store.session.userId = "u1"
store.session.accountType = .unknown("photog")
store.session.currentScenicId = 11
store.scenicQueue.punchSpotId = "22"
store.scenicQueue.punchSpotName = "东门打卡点"
store.scenicQueue.savePresetVoices(["请到拍摄区域"])
let tts = MockScenicQueueTTSManager()
let viewModel = ScenicQueueSettingsViewModel(appStore: store, ttsManager: tts)
viewModel.startPresetVoiceLoop("请到拍摄区域")
for _ in 0..<20 where viewModel.ttsLoopAnchorText == nil {
await Task.yield()
}
XCTAssertTrue(viewModel.isCustomTTSLooping)
XCTAssertEqual(viewModel.ttsLoopAnchorText, "请到拍摄区域")
XCTAssertTrue(tts.events.contains("custom:请到拍摄区域"))
viewModel.deletePresetVoice(at: 0)
XCTAssertTrue(viewModel.presetVoices.isEmpty)
XCTAssertFalse(viewModel.isCustomTTSLooping)
XCTAssertNil(viewModel.ttsLoopAnchorText)
XCTAssertEqual(tts.events.last, "stopCustom")
}
func testTicketActionStateMatchesAndroidCardMatrix() {
let waiting = makeTicket(status: .waiting, showCompleteAction: false)
let waitingState = ScenicQueueTicketActionState.make(ticket: waiting, shootingQueueNo: nil, remainSeconds: 0, showStartShootingButton: true)
XCTAssertTrue(waitingState.skipEnabled)
XCTAssertEqual(waitingState.primaryTitle, "叫号")
XCTAssertTrue(waitingState.primaryVisible)
XCTAssertFalse(waitingState.recallVisible)
XCTAssertFalse(waitingState.completeVisible)
let called = makeTicket(status: .called, showCompleteAction: true)
let hiddenStartState = ScenicQueueTicketActionState.make(ticket: called, shootingQueueNo: nil, remainSeconds: 0, showStartShootingButton: false)
XCTAssertFalse(hiddenStartState.primaryVisible)
XCTAssertTrue(hiddenStartState.recallVisible)
XCTAssertTrue(hiddenStartState.completeVisible)
let shootingState = ScenicQueueTicketActionState.make(ticket: called, shootingQueueNo: called.queueNo, remainSeconds: 17, showStartShootingButton: true)
XCTAssertFalse(shootingState.skipEnabled)
XCTAssertFalse(shootingState.primaryEnabled)
XCTAssertEqual(shootingState.primaryTitle, "剩余 17 秒")
XCTAssertFalse(shootingState.recallVisible)
XCTAssertTrue(shootingState.completeVisible)
}
func testQueueVisualTokensAndChangeLogPresentationContract() {
XCTAssertEqual(ScenicQueueTokens.radiusCard, 12)
XCTAssertEqual(ScenicQueueTokens.radiusDialog, 16)
XCTAssertEqual(ScenicQueueTokens.radiusPrimaryRowButton, 10)
XCTAssertEqual(ScenicQueueTokens.ticketIdFont.pointSize, 20)
XCTAssertEqual(ScenicQueueTokens.dialogTicketFont.pointSize, 28)
XCTAssertEqual(ScenicQueueTokens.listHorizontalInset, 12)
XCTAssertTrue(ScenicQueueTokens.timelineDot.isEqualToRGB(0x1677FF))
XCTAssertTrue(ScenicQueueTokens.tagWaitingForeground.isEqualToRGB(0x10B981))
let distanceField = ScenicQueueNumericFieldView(key: "queueDistanceMeter", unit: "千米", keyboard: .decimalPad)
XCTAssertEqual(distanceField.subviews.compactMap { ($0 as? UILabel)?.text }, ["千米"])
let item = ScenicQueueSettingChangeLogItem(
id: 1,
scenicId: 11,
scenicSpotId: 22,
scenicSpotName: "东门打卡点",
operatorId: nil,
operatorName: " ",
operatorPhoneTail: "8000",
summary: " ",
displayText: "拍摄时长由 5 分钟改为 8 分钟",
createdAt: "2026-07-07T10:20:30+08:00"
)
XCTAssertEqual(item.metadataLine, "2026-07-07 10:20 未知 尾号8000")
XCTAssertEqual(item.cardSummary, "拍摄时长由 5 分钟改为 8 分钟")
}
func testPendingDialogDismissMethodsClearEveryOperationState() async {
let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.pendingDialogs")!
defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.pendingDialogs")
let store = AppStore(defaults: defaults)
store.session.userId = "9"
store.session.accountType = .unknown("photog")
store.session.currentScenicId = 11
store.scenicQueue.punchSpotId = "22"
store.scenicQueue.punchSpotName = "东门打卡点"
let api = MockScenicQueueAPI()
api.homePages[1] = .page(items: [.item(id: 1, queueCode: "A01", isCalled: 0)], total: 1, page: 1)
let viewModel = ScenicQueueViewModel(appStore: store, ttsManager: MockScenicQueueTTSManager())
await viewModel.refreshQueueGate(api: api)
viewModel.requestCallConfirm(recordId: 1, queueNo: "A01")
viewModel.requestSkipTicket(recordId: 1, queueNo: "A01")
viewModel.requestFinishConfirm(recordId: 1, queueNo: "A01")
viewModel.requestRequeueConfirm(recordId: 1, queueNo: "A01")
viewModel.onTicketMarkClick(recordId: 1, queueNo: "A01", uid: 1)
XCTAssertNotNil(viewModel.pendingCall)
XCTAssertNotNil(viewModel.pendingSkip)
XCTAssertNotNil(viewModel.pendingFinish)
XCTAssertNotNil(viewModel.pendingRequeue)
XCTAssertNotNil(viewModel.pendingMark)
viewModel.dismissCallConfirmDialog()
viewModel.dismissSkipDialog()
viewModel.dismissFinishDialog()
viewModel.dismissRequeueDialog()
viewModel.dismissMarkDialog()
XCTAssertNil(viewModel.pendingCall)
XCTAssertNil(viewModel.pendingSkip)
XCTAssertNil(viewModel.pendingFinish)
XCTAssertNil(viewModel.pendingRequeue)
XCTAssertNil(viewModel.pendingMark)
}
private func makeTicket(status: ScenicQueueCallStatus, showCompleteAction: Bool) -> ScenicQueueTicket {
ScenicQueueTicket(
recordId: 1,
queueNo: "A01",
phoneMasked: "138****8000",
dialPhoneDigits: "13800138000",
status: status,
statusText: status == .waiting ? "待叫号" : "已叫号",
waitMinutes: 5,
peopleAhead: 2,
showCompleteAction: showCompleteAction,
queueBanLabel: "",
identityTag: "",
queueTimeDisplay: "07-07 10:20",
queueCountToday: 1,
uid: 8,
markAsPhotog: 0,
markAsFreelancePhotog: 0,
isMissRequeue: false,
missRequeueText: ""
)
}
}
private extension UIColor {
func isEqualToRGB(_ rgb: UInt32) -> Bool {
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return false }
let expectedRed = CGFloat((rgb >> 16) & 0xFF) / 255
let expectedGreen = CGFloat((rgb >> 8) & 0xFF) / 255
let expectedBlue = CGFloat(rgb & 0xFF) / 255
return abs(red - expectedRed) < 0.001 && abs(green - expectedGreen) < 0.001 && abs(blue - expectedBlue) < 0.001 && abs(alpha - 1) < 0.001
}
} }
@MainActor @MainActor

View File

@ -68,4 +68,19 @@ final class UIButtonConfigurationTests: XCTestCase {
XCTAssertEqual(button.currentTitle, "更新标题") XCTAssertEqual(button.currentTitle, "更新标题")
XCTAssertEqual(button.currentTitleColor, .red) XCTAssertEqual(button.currentTitleColor, .red)
} }
func testImagePlacementPreservesExistingConfiguration() {
var configuration = UIButton.Configuration.plain()
configuration.title = "电话"
configuration.contentInsets = NSDirectionalEdgeInsets(top: 1, leading: 2, bottom: 3, trailing: 4)
let button = UIButton(configuration: configuration)
button.setConfigurationImagePlacement(.trailing, padding: 6)
XCTAssertEqual(button.configuration?.title, "电话")
XCTAssertEqual(button.configuration?.contentInsets.leading, 2)
XCTAssertEqual(button.configuration?.contentInsets.trailing, 4)
XCTAssertEqual(button.configuration?.imagePlacement, .trailing)
XCTAssertEqual(button.configuration?.imagePadding, 6)
}
} }

View File

@ -800,6 +800,21 @@ final class WildPhotographerReportTests: XCTestCase {
XCTAssertEqual(viewModel.reportLocation, "瘦西湖路") XCTAssertEqual(viewModel.reportLocation, "瘦西湖路")
} }
func testSubmitTextInputUpdatesWithoutRequestingStructuralRefresh() {
let viewModel = WildPhotographerReportSubmitViewModel(
homeViewModel: WildPhotographerReportHomeViewModel()
)
var stateChangeCount = 0
viewModel.onStateChange = { stateChangeCount += 1 }
viewModel.updateDescription("现场说明")
viewModel.updateContact("wx-test")
XCTAssertEqual(viewModel.description, "现场说明")
XCTAssertEqual(viewModel.contact, "wx-test")
XCTAssertEqual(stateChangeCount, 0)
}
func testSubmitFailsWithoutEvidence() async { func testSubmitFailsWithoutEvidence() async {
let homeViewModel = WildPhotographerReportHomeViewModel() let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel) let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)