完善景区排队设置页与全局按钮配置迁移。
重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
6
suixinkan/Assets.xcassets/ScenicQueue/Contents.json
Normal file
6
suixinkan/Assets.xcassets/ScenicQueue/Contents.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
15
suixinkan/Assets.xcassets/ScenicQueue/queue_location.imageset/Contents.json
vendored
Normal file
15
suixinkan/Assets.xcassets/ScenicQueue/queue_location.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "queue_location.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true
|
||||
}
|
||||
}
|
||||
3
suixinkan/Assets.xcassets/ScenicQueue/queue_location.imageset/queue_location.svg
vendored
Normal file
3
suixinkan/Assets.xcassets/ScenicQueue/queue_location.imageset/queue_location.svg
vendored
Normal 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 |
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
/// 添加本地现场图片。
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
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)
|
||||
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) }
|
||||
}
|
||||
)
|
||||
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) }
|
||||
}
|
||||
|
||||
396
suixinkan/UI/ScenicQueue/Views/ScenicQueueOverlayViews.swift
Normal file
396
suixinkan/UI/ScenicQueue/Views/ScenicQueueOverlayViews.swift
Normal 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) }
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user