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

重构排队设置与变更日志交互,补充 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
struct ScenicQueueHomeStats: Decodable, Equatable {
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
@ -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 {
let list: [ScenicSpotItem]

View File

@ -6,11 +6,56 @@
import Foundation
///
enum ScenicQueueCallStatus: Equatable {
enum ScenicQueueCallStatus: Hashable {
case waiting
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 {
let recordId: Int64

View File

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

View File

@ -47,6 +47,7 @@ final class ScenicQueueSettingsViewModel {
private(set) var presetVoices: [String]
private(set) var settingsTab: ScenicQueueSettingsTab = .basic
private(set) var highlightedInvalidFieldKeys: Set<String> = []
private(set) var blockingInvalidNumericFieldKeys: Set<String> = []
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
@ -56,7 +57,7 @@ final class ScenicQueueSettingsViewModel {
private let appStore: AppStore
private let ttsManager: ScenicQueueTTSManaging
private var punchSpotSettingRequestGeneration = 0
private var ttsLoopAnchorText: String?
private(set) var ttsLoopAnchorText: String?
init(appStore: AppStore = .shared, ttsManager: ScenicQueueTTSManaging = AliyunNLSTTSManager.shared) {
self.appStore = appStore
@ -108,6 +109,30 @@ final class ScenicQueueSettingsViewModel {
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() {
onNavigateLog?()
}
@ -146,6 +171,18 @@ final class ScenicQueueSettingsViewModel {
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 setShootSecond(_ value: Int) { shootSecond = value.clamped(to: Limits.shootSecond); 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() {
Task {
@ -289,7 +341,7 @@ final class ScenicQueueSettingsViewModel {
///
func save(api: ScenicQueueAPIProtocol) async {
highlightedInvalidFieldKeys = []
highlightedInvalidFieldKeys = blockingInvalidNumericFieldKeys
guard let ids = currentScenicAndSpotIdsForSelected() else {
highlightedInvalidFieldKeys = ["punchSpot"]
settingsTab = .basic
@ -297,6 +349,12 @@ final class ScenicQueueSettingsViewModel {
notifyStateChange()
return
}
guard blockingInvalidNumericFieldKeys.isEmpty else {
settingsTab = tabForValidationKeys(blockingInvalidNumericFieldKeys)
onShowMessage?("请修正标红的配置项")
notifyStateChange()
return
}
let validation = buildSaveValidation()
guard validation.messages.isEmpty else {
highlightedInvalidFieldKeys = validation.keys

View File

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

View File

@ -31,6 +31,13 @@ enum ScenicQueueTokens {
static let dialogDangerLine = UIColor(hex: 0xD32F2F)
static let fieldBorderMuted = UIColor(hex: 0xE5E7EB)
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 radiusCard: CGFloat = 12
@ -43,6 +50,9 @@ enum ScenicQueueTokens {
static let listItemGap: CGFloat = 10
static let listHorizontalInset: 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 bannerValueFont = UIFont.systemFont(ofSize: 30, weight: .bold)

View File

@ -14,4 +14,12 @@ extension UIButton {
updatedConfiguration.contentInsets = contentInsets
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 emptyLabel = UILabel()
init(viewModel: ScenicSelectionViewModel = ScenicSelectionViewModel(), homeAPI: HomeAPI = NetworkServices.shared.homeAPI) {
init(viewModel: ScenicSelectionViewModel = ScenicSelectionViewModel(), homeAPI: HomeAPI? = nil) {
self.viewModel = viewModel
self.homeAPI = homeAPI
self.homeAPI = homeAPI ?? NetworkServices.shared.homeAPI
super.init(nibName: nil, bundle: nil)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,9 +24,9 @@ final class MessageDetailViewController: BaseViewController {
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)
self.api = api
self.api = api ?? NetworkServices.shared.messageCenterAPI
super.init(nibName: nil, bundle: nil)
}

View File

@ -14,9 +14,9 @@ final class DepositOrderDetailViewController: BaseViewController, UITableViewDat
private var detail: StoreOrderDetailData?
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.orderAPI = orderAPI
self.orderAPI = orderAPI ?? NetworkServices.shared.orderAPI
super.init(nibName: nil, bundle: nil)
}

View File

@ -14,9 +14,9 @@ final class HistoricalShootingInfoViewController: BaseViewController, UITableVie
private var response: MultiTravelShootHistoryResponse?
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.orderAPI = orderAPI
self.orderAPI = orderAPI ?? NetworkServices.shared.orderAPI
super.init(nibName: nil, bundle: nil)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,12 +6,15 @@
import SnapKit
import UIKit
///
/// Android
final class ScenicQueueSettingChangeLogViewController: BaseViewController {
private let viewModel = ScenicQueueSettingChangeLogViewModel()
private let api: ScenicQueueAPIProtocol
private let tableView = UITableView(frame: .zero, style: .plain)
private let emptyLabel = UILabel()
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
private let footerLabel = UILabel()
private var dataSource: UITableViewDiffableDataSource<Int, ScenicQueueSettingChangeLogItem>!
@MainActor
init(api: ScenicQueueAPIProtocol? = nil) {
@ -20,133 +23,142 @@ final class ScenicQueueSettingChangeLogViewController: BaseViewController {
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.refresh(api: api) }
}
override func setupNavigationBar() {
title = "配置日志"
}
override func setupNavigationBar() { title = "配置日志" }
override func setupUI() {
view.backgroundColor = ScenicQueueTokens.pageBackground
tableView.backgroundColor = .clear
view.backgroundColor = .white
tableView.backgroundColor = .white
tableView.separatorStyle = .none
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 120
tableView.estimatedRowHeight = 132
tableView.delegate = self
tableView.dataSource = self
tableView.register(ScenicQueueLogCell.self, forCellReuseIdentifier: ScenicQueueLogCell.reuseIdentifier)
tableView.register(ScenicQueueLogTimelineCell.self, forCellReuseIdentifier: ScenicQueueLogTimelineCell.reuseIdentifier)
tableView.refreshControl = UIRefreshControl()
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.textColor = AppColor.textTertiary
emptyLabel.textColor = AppColor.textSecondary
emptyLabel.font = ScenicQueueTokens.emptyStateFont
emptyLabel.textAlignment = .center
loadingIndicator.color = ScenicQueueTokens.bannerBlue
footerLabel.textAlignment = .center
footerLabel.textColor = AppColor.textSecondary
footerLabel.font = .systemFont(ofSize: 14)
view.addSubview(tableView)
view.addSubview(emptyLabel)
view.addSubview(loadingIndicator)
}
override func setupConstraints() {
tableView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
emptyLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
}
tableView.snp.makeConstraints { $0.edges.equalTo(view.safeAreaLayoutGuide) }
emptyLabel.snp.makeConstraints { $0.center.equalToSuperview() }
loadingIndicator.snp.makeConstraints { $0.center.equalToSuperview() }
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyState() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } }
viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } }
}
private func applyState() {
tableView.refreshControl?.endRefreshing()
tableView.reloadData()
emptyLabel.isHidden = !viewModel.items.isEmpty
viewModel.initialLoading && viewModel.items.isEmpty ? loadingIndicator.startAnimating() : loadingIndicator.stopAnimating()
emptyLabel.isHidden = viewModel.initialLoading || viewModel.isRefreshing || !viewModel.items.isEmpty
var snapshot = NSDiffableDataSourceSnapshot<Int, ScenicQueueSettingChangeLogItem>()
snapshot.appendSections([0])
snapshot.appendItems(viewModel.items)
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
}
}
@objc private func refreshPulled() {
Task { await viewModel.refresh(api: api) }
private func shouldShowDot(at index: Int) -> Bool {
let estimatedRowHeight: CGFloat = 132
let itemsPerThird = max(1, Int(((view.bounds.height / 3) / estimatedRowHeight).rounded()))
let stride = max(3, min(12, itemsPerThird))
return index % stride == 0
}
@objc private func refreshPulled() { Task { await viewModel.refresh(api: api) } }
}
extension ScenicQueueSettingChangeLogViewController: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
viewModel.items.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
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) {
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) }
}
}
/// cell
final class ScenicQueueLogCell: UITableViewCell {
static let reuseIdentifier = "ScenicQueueLogCell"
///
final class ScenicQueueLogTimelineCell: UITableViewCell {
static let reuseIdentifier = "ScenicQueueLogTimelineCell"
private let rail = UIView()
private let dot = UIView()
private let card = UIView()
private let titleLabel = UILabel()
private let detailLabel = UILabel()
private let timeLabel = UILabel()
private let metaLabel = UILabel()
private let summaryLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
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)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func configure(item: ScenicQueueSettingChangeLogItem) {
titleLabel.text = item.operatorName.isEmpty ? "配置变更" : item.operatorName
detailLabel.text = item.displayText.isEmpty ? item.summary : item.displayText
timeLabel.text = item.createdAt
}
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)
}
///
func configure(item: ScenicQueueSettingChangeLogItem, showDot: Bool) {
metaLabel.text = item.metadataLine
summaryLabel.text = item.cardSummary
dot.isHidden = !showDot
}
}

View File

@ -15,12 +15,13 @@ final class ScenicQueueViewController: BaseViewController {
private let punchSpotLabel = UILabel()
private let statsBanner = ScenicQueueStatsBannerView()
private let contentContainer = UIView()
private let segmentedControl = UISegmentedControl(items: ["当前排队", "过号列表"])
private let tabStrip = ScenicQueueTabStripView(titles: ["当前排队", "过号列表"])
private let tableView = UITableView(frame: .zero, style: .plain)
private let bottomBar = UIView()
private let prepareCallButton = UIButton(type: .system)
private let quickCallButton = UIButton(type: .system)
private let emptyLabel = UILabel()
private var dataSource: UITableViewDiffableDataSource<Int, ScenicQueueListItem>!
private var selectedTab = 0
@ -64,8 +65,8 @@ final class ScenicQueueViewController: BaseViewController {
view.backgroundColor = ScenicQueueTokens.pageBackground
punchSpotRow.backgroundColor = .white
let locationIcon = UIImageView(image: UIImage(systemName: "mappin.and.ellipse"))
locationIcon.tintColor = ScenicQueueTokens.bannerBlue
let locationIcon = UIImageView(image: UIImage(named: "queue_location"))
locationIcon.contentMode = .scaleAspectFit
punchSpotLabel.font = .systemFont(ofSize: 16)
punchSpotLabel.textColor = UIColor.black.withAlphaComponent(0.7)
punchSpotLabel.lineBreakMode = .byTruncatingTail
@ -75,29 +76,21 @@ final class ScenicQueueViewController: BaseViewController {
contentContainer.backgroundColor = .white
contentContainer.layer.cornerRadius = ScenicQueueTokens.radiusContentSheetTop
contentContainer.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
contentContainer.clipsToBounds = true
segmentedControl.selectedSegmentIndex = 0
segmentedControl.selectedSegmentTintColor = .white
segmentedControl.setTitleTextAttributes([
.font: ScenicQueueTokens.tabFont,
.foregroundColor: AppColor.textSecondary,
], for: .normal)
segmentedControl.setTitleTextAttributes([
.font: ScenicQueueTokens.tabSelectedFont,
.foregroundColor: ScenicQueueTokens.bannerBlue,
], for: .selected)
contentContainer.layer.shadowColor = UIColor.black.cgColor
contentContainer.layer.shadowOpacity = 0.08
contentContainer.layer.shadowRadius = 4
contentContainer.layer.shadowOffset = CGSize(width: 0, height: 2)
tableView.backgroundColor = .white
tableView.separatorStyle = .none
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 260
tableView.delegate = self
tableView.dataSource = self
tableView.register(ScenicQueueTicketCell.self, forCellReuseIdentifier: ScenicQueueTicketCell.reuseIdentifier)
tableView.register(ScenicQueueSkippedCell.self, forCellReuseIdentifier: ScenicQueueSkippedCell.reuseIdentifier)
tableView.refreshControl = UIRefreshControl()
tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
configureDataSource()
emptyLabel.textColor = AppColor.textTertiary
emptyLabel.font = ScenicQueueTokens.emptyStateFont
@ -114,7 +107,7 @@ final class ScenicQueueViewController: BaseViewController {
view.addSubview(punchSpotRow)
view.addSubview(statsBanner)
view.addSubview(contentContainer)
contentContainer.addSubview(segmentedControl)
contentContainer.addSubview(tabStrip)
contentContainer.addSubview(tableView)
contentContainer.addSubview(emptyLabel)
view.addSubview(bottomBar)
@ -149,12 +142,13 @@ final class ScenicQueueViewController: BaseViewController {
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(bottomBar.snp.top).offset(-8)
}
segmentedControl.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(12)
make.height.equalTo(40)
tabStrip.snp.makeConstraints { make in
make.top.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(12)
make.height.equalTo(48)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(segmentedControl.snp.bottom).offset(8)
make.top.equalTo(tabStrip.snp.bottom)
make.leading.trailing.bottom.equalToSuperview()
}
emptyLabel.snp.makeConstraints { make in
@ -177,7 +171,7 @@ final class ScenicQueueViewController: BaseViewController {
}
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)
quickCallButton.addTarget(self, action: #selector(quickCallTapped), for: .touchUpInside)
viewModel.onStateChange = { [weak self] in
@ -202,7 +196,7 @@ final class ScenicQueueViewController: BaseViewController {
punchSpotLabel.text = viewModel.selectedPunchSpotLine
statsBanner.configure(summary: viewModel.summary)
tableView.refreshControl?.endRefreshing()
tableView.reloadData()
applySnapshot(animated: true)
let isEmpty = selectedTab == 0 ? viewModel.currentQueue.isEmpty : viewModel.skippedQueue.isEmpty
emptyLabel.text = selectedTab == 0 ? "暂无排队" : "暂无过号记录"
emptyLabel.isHidden = !isEmpty
@ -212,6 +206,41 @@ final class ScenicQueueViewController: BaseViewController {
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) {
var config = UIButton.Configuration.filled()
config.baseBackgroundColor = color
@ -232,42 +261,63 @@ final class ScenicQueueViewController: BaseViewController {
private func presentParametersRequiredDialog() {
guard presentedViewController == nil else { return }
let alert = UIAlertController(title: "排队参数未配置", message: "请先选择打卡点并保存排队设置", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "暂不进入", style: .cancel) { [weak self] _ in
self?.viewModel.exitQueueModule()
})
alert.addAction(UIAlertAction(title: "去设置", style: .default) { [weak self] _ in
self?.navigationController?.pushViewController(ScenicQueueSettingsViewController(), animated: true)
})
present(alert, animated: true)
let dialog = ScenicQueueConfirmationDialogViewController(
configuration: ScenicQueueDialogConfiguration(
title: "请先设置排队参数",
message: "请在排队设置中选择并保存打卡点后,再使用排队管理。",
emphasizedText: nil,
dangerText: nil,
cancelTitle: "暂不使用",
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) {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "确认", style: .default) { _ in confirm() })
present(alert, animated: true)
private func presentConfirm(
title: String,
emphasizedText: String,
dangerText: String? = nil,
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) {
guard !digits.isEmpty else { return }
let alert = UIAlertController(title: "拨打电话", message: digits, preferredStyle: .actionSheet)
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)
present(ScenicQueueDialSheetViewController(digits: digits), animated: true)
}
@objc private func settingsTapped() {
viewModel.openSettings()
}
@objc private func tabChanged() {
selectedTab = segmentedControl.selectedSegmentIndex
tableView.reloadData()
private func selectTab(_ index: Int) {
guard index != selectedTab else { return }
selectedTab = index
UIView.transition(with: tableView, duration: 0.22, options: .transitionCrossDissolve) {
self.applySnapshot(animated: false)
}
applyState()
}
@ -298,30 +348,7 @@ final class ScenicQueueViewController: BaseViewController {
}
}
extension ScenicQueueViewController: UITableViewDataSource, 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
}
extension ScenicQueueViewController: UITableViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height - 120 else { return }
Task {
@ -341,7 +368,9 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate {
func scenicQueueTicketCellDidTapSkip(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
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 }
Task { await self.viewModel.confirmSkipTicket(api: self.api) }
}
@ -350,7 +379,9 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate {
func scenicQueueTicketCellDidTapPrimary(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
if ticket.status == .waiting {
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 }
Task { await self.viewModel.confirmCallTicket(api: self.api) }
}
@ -361,7 +392,9 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate {
func scenicQueueTicketCellDidTapRecall(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
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 }
Task { await self.viewModel.confirmCallTicket(api: self.api) }
}
@ -369,7 +402,9 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate {
func scenicQueueTicketCellDidTapComplete(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
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 }
Task { await self.viewModel.confirmFinishTicket(api: self.api) }
}
@ -377,25 +412,16 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate {
func scenicQueueTicketCellDidTapMark(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
viewModel.onTicketMarkClick(recordId: ticket.recordId, queueNo: ticket.queueNo, uid: ticket.uid)
let alert = UIAlertController(title: "标记用户", message: "\(ticket.queueNo)\n\(ticket.phoneMasked)", preferredStyle: .alert)
alert.addTextField { field in
field.placeholder = "限制排队天数"
field.keyboardType = .numberPad
field.text = "0"
guard let pending = viewModel.pendingMark else { return }
let dialog = ScenicQueueMarkDialogViewController(
pending: pending,
onCancel: { [weak self] in self?.viewModel.dismissMarkDialog() },
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()
})
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)
)
present(dialog, animated: true)
}
}
@ -406,7 +432,9 @@ extension ScenicQueueViewController: ScenicQueueSkippedCellDelegate {
func scenicQueueSkippedCellDidTapRequeue(_ cell: ScenicQueueSkippedCell, ticket: ScenicQueueSkippedTicket) {
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 }
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) {
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)
skip.addTarget(self, action: #selector(skipTapped), for: .touchUpInside)
let primaryTitle: String
if shootingQueueNo == ticket.queueNo {
primaryTitle = "剩余 \(max(0, remainSeconds))"
} 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])
skip.isEnabled = state.skipEnabled
skip.alpha = state.skipEnabled ? 1 : 0.45
let row = UIStackView(arrangedSubviews: [skip])
row.axis = .horizontal
row.spacing = 10
row.distribution = .fillProportionally
skip.snp.makeConstraints { make in
make.height.equalTo(46)
make.width.equalTo(primary).multipliedBy(0.56)
if state.primaryVisible {
let primary = ScenicQueueActionButton(title: state.primaryTitle, background: ScenicQueueTokens.bannerBlue, foreground: .white)
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)
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.snp.makeConstraints { make in make.height.equalTo(40) }
actionsStack.addArrangedSubview(recallButton)
}
if ticket.showCompleteAction {
if state.completeVisible {
completeButton.addTarget(self, action: #selector(completeTapped), for: .touchUpInside)
completeButton.snp.makeConstraints { make in make.height.equalTo(40) }
actionsStack.addArrangedSubview(completeButton)
@ -329,17 +334,27 @@ final class ScenicQueueTicketCell: UITableViewCell {
right.backgroundColor = accent ? AppColor.primaryLight : .clear
right.layer.cornerRadius = 2
right.clipsToBounds = true
if let tap {
right.isUserInteractionEnabled = true
right.addGestureRecognizer(UITapGestureRecognizer(target: self, action: tap))
}
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
make.leading.centerY.equalToSuperview()
make.top.bottom.equalToSuperview()
}
right.snp.makeConstraints { make in
valueContainer.snp.makeConstraints { make in
make.trailing.centerY.equalToSuperview()
make.leading.greaterThanOrEqualTo(left.snp.trailing).offset(12)
}
@ -490,17 +505,27 @@ final class ScenicQueueSkippedCell: UITableViewCell {
right.text = value
right.font = ScenicQueueTokens.phoneMaskedFont
right.textColor = AppColor.textPrimary
if let tap {
right.isUserInteractionEnabled = true
right.addGestureRecognizer(UITapGestureRecognizer(target: self, action: tap))
}
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
make.leading.centerY.equalToSuperview()
make.top.bottom.equalToSuperview()
}
right.snp.makeConstraints { make in
valueContainer.snp.makeConstraints { make in
make.trailing.centerY.equalToSuperview()
make.leading.greaterThanOrEqualTo(left.snp.trailing).offset(12)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,6 +16,8 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let descriptionView = UITextView()
private let descriptionPlaceholderLabel = UILabel()
private let descriptionCountLabel = UILabel()
private let contactField = UITextField()
private let submitButton = UIButton(type: .system)
private var pickerTarget: WildReportPickerTarget = .image
@ -25,12 +27,12 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
init(
homeViewModel: WildPhotographerReportHomeViewModel,
locationProvider: any LocationProviding = LocationProvider.shared,
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI,
uploader: any WildReportAttachmentUploading = NetworkServices.shared.ossUploadService
api: (any WildPhotographerReportServing)? = nil,
uploader: (any WildReportAttachmentUploading)? = nil
) {
self.homeViewModel = homeViewModel
self.api = api
self.uploader = uploader
self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI
self.uploader = uploader ?? NetworkServices.shared.ossUploadService
self.viewModel = WildPhotographerReportSubmitViewModel(
homeViewModel: homeViewModel,
locationProvider: locationProvider
@ -64,6 +66,14 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
descriptionView.textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 34, right: 8)
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.font = .app(.body)
contactField.textColor = AppColor.textPrimary
@ -271,34 +281,28 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
private func makeDescriptionCard() -> UIView {
let card = sectionCard(index: 3, title: "举报说明")
descriptionView.text = viewModel.description
updateDescriptionInputState()
let inputContainer = UIView()
inputContainer.backgroundColor = UIColor(hex: 0xFBFCFE)
inputContainer.layer.cornerRadius = 12
inputContainer.layer.borderWidth = 1
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(placeholder)
inputContainer.addSubview(count)
inputContainer.addSubview(descriptionPlaceholderLabel)
inputContainer.addSubview(descriptionCountLabel)
descriptionView.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.height.equalTo(132)
}
placeholder.snp.makeConstraints { make in
make.top.equalToSuperview().offset(18)
make.leading.equalToSuperview().offset(14)
let textOriginX = descriptionView.textContainerInset.left
+ descriptionView.textContainer.lineFragmentPadding
descriptionPlaceholderLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(descriptionView.textContainerInset.top)
make.leading.equalToSuperview().offset(textOriginX)
make.trailing.lessThanOrEqualToSuperview().inset(14)
}
count.snp.makeConstraints { make in
descriptionCountLabel.snp.makeConstraints { make in
make.trailing.bottom.equalToSuperview().inset(12)
}
card.stack.addArrangedSubview(inputContainer)
@ -376,6 +380,11 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
return card
}
private func updateDescriptionInputState() {
descriptionPlaceholderLabel.isHidden = !viewModel.description.isEmpty
descriptionCountLabel.text = "\(viewModel.description.count)/500"
}
private func makePaymentCard() -> UIView {
let card = sectionCard(index: 6, title: "线下微信、支付宝截图", optional: true)
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) }
@MainActor
@ -654,6 +668,10 @@ extension WildPhotographerReportSubmitViewController: UITextViewDelegate {
func textViewDidChange(_ textView: UITextView) {
viewModel.updateDescription(textView.text)
if textView.markedTextRange == nil, textView.text != viewModel.description {
textView.text = viewModel.description
}
updateDescriptionInputState()
}
func textViewDidEndEditing(_ textView: UITextView) {

View File

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

View File

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

View File

@ -4,6 +4,7 @@
//
import XCTest
import UIKit
@testable import suixinkan
@MainActor
@ -55,6 +56,24 @@ final class ScenicQueueFeatureTests: XCTestCase {
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 {
let settingJSON = """
{
@ -399,6 +418,191 @@ final class ScenicQueueFeatureTests: XCTestCase {
XCTAssertFalse(store.scenicQueue.useOfflineTTS)
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

View File

@ -68,4 +68,19 @@ final class UIButtonConfigurationTests: XCTestCase {
XCTAssertEqual(button.currentTitle, "更新标题")
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, "瘦西湖路")
}
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 {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)