完善景区排队设置页与全局按钮配置迁移。
重构排队设置与变更日志交互,补充 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 节点。
|
/// 排队 home 接口 stats 节点。
|
||||||
struct ScenicQueueHomeStats: Decodable, Equatable {
|
struct ScenicQueueHomeStats: Decodable, Equatable {
|
||||||
let type: Int
|
let type: Int
|
||||||
|
|
||||||
|
/// 创建 stats 节点;Android DTO 在服务端未返回 type 时默认使用 0。
|
||||||
|
init(type: Int = 0) {
|
||||||
|
self.type = type
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
type = try container.decodeIfPresent(Int.self, forKey: .type) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case type
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 排队 home 接口分页列表节点。
|
/// 排队 home 接口分页列表节点。
|
||||||
@ -426,6 +440,43 @@ struct ScenicQueueSettingChangeLogItem: Decodable, Equatable, Hashable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension ScenicQueueSettingChangeLogItem {
|
||||||
|
/// Android 日志卡片的单行元信息:时间、操作人和手机号尾号。
|
||||||
|
var metadataLine: String {
|
||||||
|
let time: String
|
||||||
|
if let createdAt, !createdAt.isEmpty {
|
||||||
|
let input = DateFormatter()
|
||||||
|
input.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
input.timeZone = TimeZone(identifier: "Asia/Shanghai")
|
||||||
|
let formats = ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy-MM-dd'T'HH:mm:ssXXXXX", "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX"]
|
||||||
|
let parsedDate = formats.lazy.compactMap { format -> Date? in
|
||||||
|
input.dateFormat = format
|
||||||
|
return input.date(from: createdAt)
|
||||||
|
}.first
|
||||||
|
if let date = parsedDate {
|
||||||
|
let output = DateFormatter()
|
||||||
|
output.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
output.timeZone = TimeZone(identifier: "Asia/Shanghai")
|
||||||
|
output.dateFormat = "yyyy-MM-dd HH:mm"
|
||||||
|
time = output.string(from: date)
|
||||||
|
} else {
|
||||||
|
time = String(createdAt.replacingOccurrences(of: "T", with: " ").prefix(16))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
time = "—"
|
||||||
|
}
|
||||||
|
let name = operatorName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "未知" : operatorName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let tail = operatorPhoneTail.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return tail.isEmpty ? "\(time) \(name)" : "\(time) \(name) 尾号\(tail)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 日志卡片主摘要,优先使用 summary,空时回退 displayText。
|
||||||
|
var cardSummary: String {
|
||||||
|
let value = summary.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return value.isEmpty ? displayText : value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 打卡点列表响应。
|
/// 打卡点列表响应。
|
||||||
struct ScenicSpotListResponse: Decodable, Equatable {
|
struct ScenicSpotListResponse: Decodable, Equatable {
|
||||||
let list: [ScenicSpotItem]
|
let list: [ScenicSpotItem]
|
||||||
|
|||||||
@ -6,11 +6,56 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// 当前排队列表中的叫号状态。
|
/// 当前排队列表中的叫号状态。
|
||||||
enum ScenicQueueCallStatus: Equatable {
|
enum ScenicQueueCallStatus: Hashable {
|
||||||
case waiting
|
case waiting
|
||||||
case called
|
case called
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 当前排队卡片在指定拍摄会话与设置下的操作展示状态。
|
||||||
|
struct ScenicQueueTicketActionState: Equatable {
|
||||||
|
let skipEnabled: Bool
|
||||||
|
let primaryVisible: Bool
|
||||||
|
let primaryEnabled: Bool
|
||||||
|
let primaryTitle: String
|
||||||
|
let recallVisible: Bool
|
||||||
|
let completeVisible: Bool
|
||||||
|
|
||||||
|
/// 按 Android 排队卡片规则生成操作状态。
|
||||||
|
static func make(
|
||||||
|
ticket: ScenicQueueTicket,
|
||||||
|
shootingQueueNo: String?,
|
||||||
|
remainSeconds: Int,
|
||||||
|
showStartShootingButton: Bool
|
||||||
|
) -> ScenicQueueTicketActionState {
|
||||||
|
let isShooting = shootingQueueNo == ticket.queueNo
|
||||||
|
if isShooting {
|
||||||
|
return ScenicQueueTicketActionState(
|
||||||
|
skipEnabled: false,
|
||||||
|
primaryVisible: true,
|
||||||
|
primaryEnabled: false,
|
||||||
|
primaryTitle: "剩余 \(max(0, remainSeconds)) 秒",
|
||||||
|
recallVisible: false,
|
||||||
|
completeVisible: ticket.showCompleteAction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let primaryVisible = ticket.status == .waiting || showStartShootingButton
|
||||||
|
return ScenicQueueTicketActionState(
|
||||||
|
skipEnabled: true,
|
||||||
|
primaryVisible: primaryVisible,
|
||||||
|
primaryEnabled: true,
|
||||||
|
primaryTitle: ticket.status == .waiting ? "叫号" : "开始拍摄",
|
||||||
|
recallVisible: ticket.status == .called,
|
||||||
|
completeVisible: ticket.showCompleteAction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 排队首页 Diffable Data Source 的稳定列表项。
|
||||||
|
enum ScenicQueueListItem: Hashable {
|
||||||
|
case current(ScenicQueueTicket)
|
||||||
|
case skipped(ScenicQueueSkippedTicket)
|
||||||
|
}
|
||||||
|
|
||||||
/// 当前排队列表展示实体。
|
/// 当前排队列表展示实体。
|
||||||
struct ScenicQueueTicket: Hashable {
|
struct ScenicQueueTicket: Hashable {
|
||||||
let recordId: Int64
|
let recordId: Int64
|
||||||
|
|||||||
@ -15,6 +15,7 @@ final class ScenicQueueSettingChangeLogViewModel {
|
|||||||
private(set) var isRefreshing = false
|
private(set) var isRefreshing = false
|
||||||
private(set) var isLoadingMore = false
|
private(set) var isLoadingMore = false
|
||||||
private(set) var canLoadMore = false
|
private(set) var canLoadMore = false
|
||||||
|
private(set) var initialLoading = true
|
||||||
|
|
||||||
var onStateChange: (() -> Void)?
|
var onStateChange: (() -> Void)?
|
||||||
var onShowMessage: ((String) -> Void)?
|
var onShowMessage: ((String) -> Void)?
|
||||||
@ -50,6 +51,7 @@ final class ScenicQueueSettingChangeLogViewModel {
|
|||||||
}
|
}
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
defer {
|
defer {
|
||||||
|
initialLoading = false
|
||||||
if append { isLoadingMore = false } else { isRefreshing = false }
|
if append { isLoadingMore = false } else { isRefreshing = false }
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,6 +47,7 @@ final class ScenicQueueSettingsViewModel {
|
|||||||
private(set) var presetVoices: [String]
|
private(set) var presetVoices: [String]
|
||||||
private(set) var settingsTab: ScenicQueueSettingsTab = .basic
|
private(set) var settingsTab: ScenicQueueSettingsTab = .basic
|
||||||
private(set) var highlightedInvalidFieldKeys: Set<String> = []
|
private(set) var highlightedInvalidFieldKeys: Set<String> = []
|
||||||
|
private(set) var blockingInvalidNumericFieldKeys: Set<String> = []
|
||||||
|
|
||||||
var onStateChange: (() -> Void)?
|
var onStateChange: (() -> Void)?
|
||||||
var onShowMessage: ((String) -> Void)?
|
var onShowMessage: ((String) -> Void)?
|
||||||
@ -56,7 +57,7 @@ final class ScenicQueueSettingsViewModel {
|
|||||||
private let appStore: AppStore
|
private let appStore: AppStore
|
||||||
private let ttsManager: ScenicQueueTTSManaging
|
private let ttsManager: ScenicQueueTTSManaging
|
||||||
private var punchSpotSettingRequestGeneration = 0
|
private var punchSpotSettingRequestGeneration = 0
|
||||||
private var ttsLoopAnchorText: String?
|
private(set) var ttsLoopAnchorText: String?
|
||||||
|
|
||||||
init(appStore: AppStore = .shared, ttsManager: ScenicQueueTTSManaging = AliyunNLSTTSManager.shared) {
|
init(appStore: AppStore = .shared, ttsManager: ScenicQueueTTSManaging = AliyunNLSTTSManager.shared) {
|
||||||
self.appStore = appStore
|
self.appStore = appStore
|
||||||
@ -108,6 +109,30 @@ final class ScenicQueueSettingsViewModel {
|
|||||||
appStore.scenicQueue.useOfflineTTS
|
appStore.scenicQueue.useOfflineTTS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 自定义文案或预设文案是否正在循环播报。
|
||||||
|
var isCustomTTSLooping: Bool {
|
||||||
|
ttsManager.customTextLooping
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将米格式化为 Android 设置页使用的千米文本,最多保留两位小数。
|
||||||
|
nonisolated static func kilometersDisplay(fromMeters meters: Int) -> String {
|
||||||
|
let value = Double(max(0, meters)) / 1_000
|
||||||
|
if value.rounded() == value { return String(format: "%.0f", value) }
|
||||||
|
let text = String(format: "%.2f", value)
|
||||||
|
return text.replacingOccurrences(of: #"0+$"#, with: "", options: .regularExpression)
|
||||||
|
.replacingOccurrences(of: #"\.$"#, with: "", options: .regularExpression)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将最多两位小数的千米文本转换为米;非法格式或越界时返回 nil。
|
||||||
|
nonisolated static func meters(fromKilometersText raw: String) -> Int? {
|
||||||
|
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard text.range(of: #"^\d{1,4}(\.\d{0,2})?$"#, options: .regularExpression) != nil,
|
||||||
|
let value = Decimal(string: text, locale: Locale(identifier: "en_US_POSIX")) else { return nil }
|
||||||
|
let meters = NSDecimalNumber(decimal: value * 1_000).intValue
|
||||||
|
guard Limits.queueDistance.contains(meters) else { return nil }
|
||||||
|
return meters
|
||||||
|
}
|
||||||
|
|
||||||
func openSettingChangeLog() {
|
func openSettingChangeLog() {
|
||||||
onNavigateLog?()
|
onNavigateLog?()
|
||||||
}
|
}
|
||||||
@ -146,6 +171,18 @@ final class ScenicQueueSettingsViewModel {
|
|||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 记录数字输入框是否存在尚未修正的越界原始值,并同步红框状态。
|
||||||
|
func reportOutOfRangeNumericPending(fieldKey: String, pending: Bool) {
|
||||||
|
if pending {
|
||||||
|
blockingInvalidNumericFieldKeys.insert(fieldKey)
|
||||||
|
highlightedInvalidFieldKeys.insert(fieldKey)
|
||||||
|
} else {
|
||||||
|
blockingInvalidNumericFieldKeys.remove(fieldKey)
|
||||||
|
highlightedInvalidFieldKeys.remove(fieldKey)
|
||||||
|
}
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
func setShootMinute(_ value: Int) { shootMinute = value.clamped(to: Limits.shootMinute); notifyStateChange() }
|
func setShootMinute(_ value: Int) { shootMinute = value.clamped(to: Limits.shootMinute); notifyStateChange() }
|
||||||
func setShootSecond(_ value: Int) { shootSecond = value.clamped(to: Limits.shootSecond); notifyStateChange() }
|
func setShootSecond(_ value: Int) { shootSecond = value.clamped(to: Limits.shootSecond); notifyStateChange() }
|
||||||
func setFirstAheadCount(_ value: Int) { firstAheadCount = value.clamped(to: Limits.ahead); notifyStateChange() }
|
func setFirstAheadCount(_ value: Int) { firstAheadCount = value.clamped(to: Limits.ahead); notifyStateChange() }
|
||||||
@ -222,6 +259,21 @@ final class ScenicQueueSettingsViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 循环播放指定预设文案;若已有文案播放则先停止再切换。
|
||||||
|
func startPresetVoiceLoop(_ phrase: String) {
|
||||||
|
let text = String(phrase.trimmingCharacters(in: .whitespacesAndNewlines).prefix(Limits.remarkMaxLen))
|
||||||
|
guard !text.isEmpty else { return }
|
||||||
|
Task {
|
||||||
|
if ttsManager.customTextLooping {
|
||||||
|
ttsManager.stopCustomTextLoop()
|
||||||
|
}
|
||||||
|
let ok = await ttsManager.startCustomTextLoop(text)
|
||||||
|
ttsLoopAnchorText = ok ? text : nil
|
||||||
|
if !ok { onShowMessage?("播报失败,请检查网络与语音服务") }
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 播放蓝牙测试音。
|
/// 播放蓝牙测试音。
|
||||||
func playBluetoothTestSound() {
|
func playBluetoothTestSound() {
|
||||||
Task {
|
Task {
|
||||||
@ -289,7 +341,7 @@ final class ScenicQueueSettingsViewModel {
|
|||||||
|
|
||||||
/// 校验并保存设置。
|
/// 校验并保存设置。
|
||||||
func save(api: ScenicQueueAPIProtocol) async {
|
func save(api: ScenicQueueAPIProtocol) async {
|
||||||
highlightedInvalidFieldKeys = []
|
highlightedInvalidFieldKeys = blockingInvalidNumericFieldKeys
|
||||||
guard let ids = currentScenicAndSpotIdsForSelected() else {
|
guard let ids = currentScenicAndSpotIdsForSelected() else {
|
||||||
highlightedInvalidFieldKeys = ["punchSpot"]
|
highlightedInvalidFieldKeys = ["punchSpot"]
|
||||||
settingsTab = .basic
|
settingsTab = .basic
|
||||||
@ -297,6 +349,12 @@ final class ScenicQueueSettingsViewModel {
|
|||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
guard blockingInvalidNumericFieldKeys.isEmpty else {
|
||||||
|
settingsTab = tabForValidationKeys(blockingInvalidNumericFieldKeys)
|
||||||
|
onShowMessage?("请修正标红的配置项")
|
||||||
|
notifyStateChange()
|
||||||
|
return
|
||||||
|
}
|
||||||
let validation = buildSaveValidation()
|
let validation = buildSaveValidation()
|
||||||
guard validation.messages.isEmpty else {
|
guard validation.messages.isEmpty else {
|
||||||
highlightedInvalidFieldKeys = validation.keys
|
highlightedInvalidFieldKeys = validation.keys
|
||||||
|
|||||||
@ -194,13 +194,11 @@ final class WildPhotographerReportSubmitViewModel {
|
|||||||
/// 限制举报说明最多 500 字。
|
/// 限制举报说明最多 500 字。
|
||||||
func updateDescription(_ newValue: String) {
|
func updateDescription(_ newValue: String) {
|
||||||
description = String(newValue.prefix(500))
|
description = String(newValue.prefix(500))
|
||||||
notifyStateChange()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新联系方式。
|
/// 更新联系方式。
|
||||||
func updateContact(_ value: String) {
|
func updateContact(_ value: String) {
|
||||||
contact = String(value.prefix(80))
|
contact = String(value.prefix(80))
|
||||||
notifyStateChange()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加本地现场图片。
|
/// 添加本地现场图片。
|
||||||
|
|||||||
@ -31,6 +31,13 @@ enum ScenicQueueTokens {
|
|||||||
static let dialogDangerLine = UIColor(hex: 0xD32F2F)
|
static let dialogDangerLine = UIColor(hex: 0xD32F2F)
|
||||||
static let fieldBorderMuted = UIColor(hex: 0xE5E7EB)
|
static let fieldBorderMuted = UIColor(hex: 0xE5E7EB)
|
||||||
static let settingsMutedBlock = UIColor(hex: 0xF7F8FA)
|
static let settingsMutedBlock = UIColor(hex: 0xF7F8FA)
|
||||||
|
static let dialogCancelBackground = UIColor(hex: 0xEAF1F8)
|
||||||
|
static let markDialogCancelBackground = UIColor(hex: 0xF0F7FF)
|
||||||
|
static let markDialogDivider = UIColor(hex: 0xF0F0F0)
|
||||||
|
static let markDialogStepperBackground = UIColor(hex: 0xF2F4F7)
|
||||||
|
static let logCardBackground = UIColor(hex: 0xF7F8FA)
|
||||||
|
static let timelineLine = UIColor(hex: 0xD8E8FF)
|
||||||
|
static let timelineDot = UIColor(hex: 0x1677FF)
|
||||||
|
|
||||||
static let radiusContentSheetTop: CGFloat = 14
|
static let radiusContentSheetTop: CGFloat = 14
|
||||||
static let radiusCard: CGFloat = 12
|
static let radiusCard: CGFloat = 12
|
||||||
@ -43,6 +50,9 @@ enum ScenicQueueTokens {
|
|||||||
static let listItemGap: CGFloat = 10
|
static let listItemGap: CGFloat = 10
|
||||||
static let listHorizontalInset: CGFloat = 12
|
static let listHorizontalInset: CGFloat = 12
|
||||||
static let bottomSaveButtonRadius: CGFloat = 12
|
static let bottomSaveButtonRadius: CGFloat = 12
|
||||||
|
static let markDialogRadius: CGFloat = 22
|
||||||
|
static let markDialogButtonRadius: CGFloat = 12
|
||||||
|
static let dialogHorizontalInset: CGFloat = 22
|
||||||
|
|
||||||
static let bannerLabelFont = UIFont.systemFont(ofSize: 12)
|
static let bannerLabelFont = UIFont.systemFont(ofSize: 12)
|
||||||
static let bannerValueFont = UIFont.systemFont(ofSize: 30, weight: .bold)
|
static let bannerValueFont = UIFont.systemFont(ofSize: 30, weight: .bold)
|
||||||
|
|||||||
@ -14,4 +14,12 @@ extension UIButton {
|
|||||||
updatedConfiguration.contentInsets = contentInsets
|
updatedConfiguration.contentInsets = contentInsets
|
||||||
configuration = updatedConfiguration
|
configuration = updatedConfiguration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 设置图片相对标题的位置和间距,并保留按钮已有的 configuration 内容。
|
||||||
|
func setConfigurationImagePlacement(_ imagePlacement: NSDirectionalRectEdge, padding: CGFloat) {
|
||||||
|
var updatedConfiguration = configuration ?? .plain()
|
||||||
|
updatedConfiguration.imagePlacement = imagePlacement
|
||||||
|
updatedConfiguration.imagePadding = padding
|
||||||
|
configuration = updatedConfiguration
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,9 +20,9 @@ final class ScenicSelectionViewController: BaseViewController, UITableViewDataSo
|
|||||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||||
private let emptyLabel = UILabel()
|
private let emptyLabel = UILabel()
|
||||||
|
|
||||||
init(viewModel: ScenicSelectionViewModel = ScenicSelectionViewModel(), homeAPI: HomeAPI = NetworkServices.shared.homeAPI) {
|
init(viewModel: ScenicSelectionViewModel = ScenicSelectionViewModel(), homeAPI: HomeAPI? = nil) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
self.homeAPI = homeAPI
|
self.homeAPI = homeAPI ?? NetworkServices.shared.homeAPI
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -58,8 +58,7 @@ final class HomeWorkStatusCardView: UIView {
|
|||||||
let reminderIconConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
|
let reminderIconConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
|
||||||
reminderButton.setImage(UIImage(systemName: "bell.fill", withConfiguration: reminderIconConfig), for: .normal)
|
reminderButton.setImage(UIImage(systemName: "bell.fill", withConfiguration: reminderIconConfig), for: .normal)
|
||||||
reminderButton.semanticContentAttribute = .forceLeftToRight
|
reminderButton.semanticContentAttribute = .forceLeftToRight
|
||||||
reminderButton.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 6)
|
reminderButton.setConfigurationImagePlacement(.leading, padding: 6)
|
||||||
reminderButton.titleEdgeInsets = UIEdgeInsets(top: 0, left: 6, bottom: 0, right: -6)
|
|
||||||
reminderButton.titleLabel?.adjustsFontSizeToFitWidth = true
|
reminderButton.titleLabel?.adjustsFontSizeToFitWidth = true
|
||||||
reminderButton.titleLabel?.minimumScaleFactor = 0.8
|
reminderButton.titleLabel?.minimumScaleFactor = 0.8
|
||||||
reminderButton.addTarget(self, action: #selector(reminderTapped), for: .touchUpInside)
|
reminderButton.addTarget(self, action: #selector(reminderTapped), for: .touchUpInside)
|
||||||
|
|||||||
@ -38,11 +38,11 @@ final class InviteRecordViewController: BaseViewController {
|
|||||||
|
|
||||||
/// 初始化邀请记录页面。
|
/// 初始化邀请记录页面。
|
||||||
init(
|
init(
|
||||||
inviteAPI: InviteServing = NetworkServices.shared.inviteAPI,
|
inviteAPI: InviteServing? = nil,
|
||||||
walletAPI: WalletServing = NetworkServices.shared.walletAPI
|
walletAPI: WalletServing? = nil
|
||||||
) {
|
) {
|
||||||
self.inviteAPI = inviteAPI
|
self.inviteAPI = inviteAPI ?? NetworkServices.shared.inviteAPI
|
||||||
self.walletAPI = walletAPI
|
self.walletAPI = walletAPI ?? NetworkServices.shared.walletAPI
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,8 +19,8 @@ final class PhotographerInviteViewController: BaseViewController {
|
|||||||
private let ruleStack = UIStackView()
|
private let ruleStack = UIStackView()
|
||||||
|
|
||||||
/// 初始化摄影师邀请页面。
|
/// 初始化摄影师邀请页面。
|
||||||
init(inviteAPI: InviteServing = NetworkServices.shared.inviteAPI) {
|
init(inviteAPI: InviteServing? = nil) {
|
||||||
self.inviteAPI = inviteAPI
|
self.inviteAPI = inviteAPI ?? NetworkServices.shared.inviteAPI
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -234,7 +234,7 @@ final class PhotographerInviteViewController: BaseViewController {
|
|||||||
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||||||
button.setImage(UIImage(named: "ic_invite_record")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
button.setImage(UIImage(named: "ic_invite_record")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||||
button.tintColor = AppColor.primary
|
button.tintColor = AppColor.primary
|
||||||
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -6, bottom: 0, right: 6)
|
button.setConfigurationImagePlacement(.leading, padding: 6)
|
||||||
button.addTarget(self, action: #selector(inviteRecordTapped), for: .touchUpInside)
|
button.addTarget(self, action: #selector(inviteRecordTapped), for: .touchUpInside)
|
||||||
container.addSubview(button)
|
container.addSubview(button)
|
||||||
button.snp.makeConstraints { make in
|
button.snp.makeConstraints { make in
|
||||||
@ -360,8 +360,7 @@ private final class InviteIconButton: UIButton {
|
|||||||
setImage(UIImage(named: imageName)?.withRenderingMode(.alwaysTemplate), for: .normal)
|
setImage(UIImage(named: imageName)?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||||
tintColor = .white
|
tintColor = .white
|
||||||
titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
|
titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
imageEdgeInsets = UIEdgeInsets(top: 0, left: -3, bottom: 0, right: 3)
|
setConfigurationImagePlacement(.leading, padding: 6)
|
||||||
titleEdgeInsets = UIEdgeInsets(top: 0, left: 3, bottom: 0, right: -3)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
|
|||||||
@ -207,7 +207,7 @@ final class LiveAlbumListViewController: BaseViewController, UITableViewDelegate
|
|||||||
button.titleLabel?.font = .systemFont(ofSize: 13)
|
button.titleLabel?.font = .systemFont(ofSize: 13)
|
||||||
button.setImage(UIImage(systemName: "calendar"), for: .normal)
|
button.setImage(UIImage(systemName: "calendar"), for: .normal)
|
||||||
button.tintColor = AppColor.textPrimary
|
button.tintColor = AppColor.textPrimary
|
||||||
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -4, bottom: 0, right: 4)
|
button.setConfigurationImagePlacement(.leading, padding: 4)
|
||||||
setDateButtonTitle(button, text: title)
|
setDateButtonTitle(button, text: title)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -75,7 +75,7 @@ final class LiveActionButton: UIButton {
|
|||||||
layer.cornerRadius = 8
|
layer.cornerRadius = 8
|
||||||
clipsToBounds = true
|
clipsToBounds = true
|
||||||
tintColor = .white
|
tintColor = .white
|
||||||
imageEdgeInsets = UIEdgeInsets(top: 0, left: -4, bottom: 0, right: 4)
|
setConfigurationImagePlacement(.leading, padding: 4)
|
||||||
updateAppearance()
|
updateAppearance()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,10 +20,10 @@ final class LocationReportHistoryViewController: BaseViewController, UITableView
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
viewModel: LocationReportHistoryViewModel = LocationReportHistoryViewModel(),
|
viewModel: LocationReportHistoryViewModel = LocationReportHistoryViewModel(),
|
||||||
homeAPI: HomeAPI = NetworkServices.shared.homeAPI
|
homeAPI: HomeAPI? = nil
|
||||||
) {
|
) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
self.homeAPI = homeAPI
|
self.homeAPI = homeAPI ?? NetworkServices.shared.homeAPI
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -24,10 +24,10 @@ final class LocationReportViewController: BaseViewController {
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
viewModel: LocationReportViewModel = LocationReportViewModel(),
|
viewModel: LocationReportViewModel = LocationReportViewModel(),
|
||||||
homeAPI: HomeAPI = NetworkServices.shared.homeAPI
|
homeAPI: HomeAPI? = nil
|
||||||
) {
|
) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
self.homeAPI = homeAPI
|
self.homeAPI = homeAPI ?? NetworkServices.shared.homeAPI
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -30,10 +30,10 @@ final class MaterialDetailViewController: BaseViewController {
|
|||||||
/// 初始化素材详情页。
|
/// 初始化素材详情页。
|
||||||
init(
|
init(
|
||||||
materialId: Int,
|
materialId: Int,
|
||||||
api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI
|
api: (any MaterialManagementServing)? = nil
|
||||||
) {
|
) {
|
||||||
viewModel = MaterialDetailViewModel(materialId: materialId)
|
viewModel = MaterialDetailViewModel(materialId: materialId)
|
||||||
self.api = api
|
self.api = api ?? NetworkServices.shared.materialManagementAPI
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -49,12 +49,12 @@ final class MaterialFormViewController: BaseViewController {
|
|||||||
/// 初始化素材表单页。
|
/// 初始化素材表单页。
|
||||||
init(
|
init(
|
||||||
mode: MaterialFormViewModel.Mode,
|
mode: MaterialFormViewModel.Mode,
|
||||||
api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI,
|
api: (any MaterialManagementServing)? = nil,
|
||||||
uploader: any MaterialOSSUploading = NetworkServices.shared.ossUploadService
|
uploader: (any MaterialOSSUploading)? = nil
|
||||||
) {
|
) {
|
||||||
viewModel = MaterialFormViewModel(mode: mode)
|
viewModel = MaterialFormViewModel(mode: mode)
|
||||||
self.api = api
|
self.api = api ?? NetworkServices.shared.materialManagementAPI
|
||||||
self.uploader = uploader
|
self.uploader = uploader ?? NetworkServices.shared.ossUploadService
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -33,10 +33,10 @@ final class MaterialListViewController: BaseViewController {
|
|||||||
/// 初始化素材管理列表页。
|
/// 初始化素材管理列表页。
|
||||||
init(
|
init(
|
||||||
viewModel: MaterialListViewModel = MaterialListViewModel(),
|
viewModel: MaterialListViewModel = MaterialListViewModel(),
|
||||||
api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI
|
api: (any MaterialManagementServing)? = nil
|
||||||
) {
|
) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
self.api = api
|
self.api = api ?? NetworkServices.shared.materialManagementAPI
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -25,10 +25,10 @@ final class MessageCenterViewController: BaseViewController, UITableViewDelegate
|
|||||||
/// 初始化消息中心列表页。
|
/// 初始化消息中心列表页。
|
||||||
init(
|
init(
|
||||||
viewModel: MessageCenterViewModel = MessageCenterViewModel(),
|
viewModel: MessageCenterViewModel = MessageCenterViewModel(),
|
||||||
api: any MessageCenterServing = NetworkServices.shared.messageCenterAPI
|
api: (any MessageCenterServing)? = nil
|
||||||
) {
|
) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
self.api = api
|
self.api = api ?? NetworkServices.shared.messageCenterAPI
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -24,9 +24,9 @@ final class MessageDetailViewController: BaseViewController {
|
|||||||
private let deleteButton = UIButton(type: .system)
|
private let deleteButton = UIButton(type: .system)
|
||||||
|
|
||||||
/// 初始化消息详情页。
|
/// 初始化消息详情页。
|
||||||
init(message: MessageItem, api: any MessageCenterServing = NetworkServices.shared.messageCenterAPI) {
|
init(message: MessageItem, api: (any MessageCenterServing)? = nil) {
|
||||||
viewModel = MessageDetailViewModel(message: message)
|
viewModel = MessageDetailViewModel(message: message)
|
||||||
self.api = api
|
self.api = api ?? NetworkServices.shared.messageCenterAPI
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -14,9 +14,9 @@ final class DepositOrderDetailViewController: BaseViewController, UITableViewDat
|
|||||||
private var detail: StoreOrderDetailData?
|
private var detail: StoreOrderDetailData?
|
||||||
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
||||||
|
|
||||||
init(orderNumber: String, orderAPI: OrderAPI = NetworkServices.shared.orderAPI) {
|
init(orderNumber: String, orderAPI: OrderAPI? = nil) {
|
||||||
self.orderNumber = orderNumber
|
self.orderNumber = orderNumber
|
||||||
self.orderAPI = orderAPI
|
self.orderAPI = orderAPI ?? NetworkServices.shared.orderAPI
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -14,9 +14,9 @@ final class HistoricalShootingInfoViewController: BaseViewController, UITableVie
|
|||||||
private var response: MultiTravelShootHistoryResponse?
|
private var response: MultiTravelShootHistoryResponse?
|
||||||
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
||||||
|
|
||||||
init(orderNumber: String, orderAPI: OrderAPI = NetworkServices.shared.orderAPI) {
|
init(orderNumber: String, orderAPI: OrderAPI? = nil) {
|
||||||
self.orderNumber = orderNumber
|
self.orderNumber = orderNumber
|
||||||
self.orderAPI = orderAPI
|
self.orderAPI = orderAPI ?? NetworkServices.shared.orderAPI
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -53,10 +53,10 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
viewModel: ProfileSpaceSettingsViewModel = ProfileSpaceSettingsViewModel(),
|
viewModel: ProfileSpaceSettingsViewModel = ProfileSpaceSettingsViewModel(),
|
||||||
profileAPI: ProfileAPI = NetworkServices.shared.profileAPI
|
profileAPI: ProfileAPI? = nil
|
||||||
) {
|
) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
self.profileAPI = profileAPI
|
self.profileAPI = profileAPI ?? NetworkServices.shared.profileAPI
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -26,10 +26,10 @@ final class SampleDetailViewController: SampleBaseViewController {
|
|||||||
/// 初始化样片详情页。
|
/// 初始化样片详情页。
|
||||||
init(
|
init(
|
||||||
sampleId: Int,
|
sampleId: Int,
|
||||||
api: any SampleManagementServing = NetworkServices.shared.sampleManagementAPI
|
api: (any SampleManagementServing)? = nil
|
||||||
) {
|
) {
|
||||||
viewModel = SampleDetailViewModel(sampleId: sampleId)
|
viewModel = SampleDetailViewModel(sampleId: sampleId)
|
||||||
self.api = api
|
self.api = api ?? NetworkServices.shared.sampleManagementAPI
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -32,10 +32,10 @@ final class SampleListViewController: SampleBaseViewController {
|
|||||||
/// 初始化样片列表页。
|
/// 初始化样片列表页。
|
||||||
init(
|
init(
|
||||||
viewModel: SampleListViewModel = SampleListViewModel(),
|
viewModel: SampleListViewModel = SampleListViewModel(),
|
||||||
api: any SampleManagementServing = NetworkServices.shared.sampleManagementAPI
|
api: (any SampleManagementServing)? = nil
|
||||||
) {
|
) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
self.api = api
|
self.api = api ?? NetworkServices.shared.sampleManagementAPI
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -51,12 +51,12 @@ final class UploadSampleViewController: SampleBaseViewController {
|
|||||||
/// 初始化样片上传页。
|
/// 初始化样片上传页。
|
||||||
init(
|
init(
|
||||||
viewModel: UploadSampleViewModel = UploadSampleViewModel(),
|
viewModel: UploadSampleViewModel = UploadSampleViewModel(),
|
||||||
api: any SampleManagementServing = NetworkServices.shared.sampleManagementAPI,
|
api: (any SampleManagementServing)? = nil,
|
||||||
uploader: any SampleOSSUploading = NetworkServices.shared.ossUploadService
|
uploader: (any SampleOSSUploading)? = nil
|
||||||
) {
|
) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
self.api = api
|
self.api = api ?? NetworkServices.shared.sampleManagementAPI
|
||||||
self.uploader = uploader
|
self.uploader = uploader ?? NetworkServices.shared.ossUploadService
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -41,14 +41,14 @@ final class ScenicApplicationViewController: BaseViewController {
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
viewModel: ScenicApplicationViewModel = ScenicApplicationViewModel(),
|
viewModel: ScenicApplicationViewModel = ScenicApplicationViewModel(),
|
||||||
homeAPI: HomeAPI = NetworkServices.shared.homeAPI,
|
homeAPI: HomeAPI? = nil,
|
||||||
profileAPI: ProfileAPI = NetworkServices.shared.profileAPI,
|
profileAPI: ProfileAPI? = nil,
|
||||||
ossUploadService: OSSUploadService = NetworkServices.shared.ossUploadService
|
ossUploadService: OSSUploadService? = nil
|
||||||
) {
|
) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
self.homeAPI = homeAPI
|
self.homeAPI = homeAPI ?? NetworkServices.shared.homeAPI
|
||||||
self.profileAPI = profileAPI
|
self.profileAPI = profileAPI ?? NetworkServices.shared.profileAPI
|
||||||
self.ossUploadService = ossUploadService
|
self.ossUploadService = ossUploadService ?? NetworkServices.shared.ossUploadService
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,12 +6,15 @@
|
|||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 排队设置配置日志页。
|
/// 排队配置日志页,按 Android 时间轴样式展示变更历史。
|
||||||
final class ScenicQueueSettingChangeLogViewController: BaseViewController {
|
final class ScenicQueueSettingChangeLogViewController: BaseViewController {
|
||||||
private let viewModel = ScenicQueueSettingChangeLogViewModel()
|
private let viewModel = ScenicQueueSettingChangeLogViewModel()
|
||||||
private let api: ScenicQueueAPIProtocol
|
private let api: ScenicQueueAPIProtocol
|
||||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||||
private let emptyLabel = UILabel()
|
private let emptyLabel = UILabel()
|
||||||
|
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
|
||||||
|
private let footerLabel = UILabel()
|
||||||
|
private var dataSource: UITableViewDiffableDataSource<Int, ScenicQueueSettingChangeLogItem>!
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
init(api: ScenicQueueAPIProtocol? = nil) {
|
init(api: ScenicQueueAPIProtocol? = nil) {
|
||||||
@ -20,133 +23,142 @@ final class ScenicQueueSettingChangeLogViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
Task { await viewModel.refresh(api: api) }
|
Task { await viewModel.refresh(api: api) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupNavigationBar() {
|
override func setupNavigationBar() { title = "配置日志" }
|
||||||
title = "配置日志"
|
|
||||||
}
|
|
||||||
|
|
||||||
override func setupUI() {
|
override func setupUI() {
|
||||||
view.backgroundColor = ScenicQueueTokens.pageBackground
|
view.backgroundColor = .white
|
||||||
tableView.backgroundColor = .clear
|
tableView.backgroundColor = .white
|
||||||
tableView.separatorStyle = .none
|
tableView.separatorStyle = .none
|
||||||
tableView.rowHeight = UITableView.automaticDimension
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
tableView.estimatedRowHeight = 120
|
tableView.estimatedRowHeight = 132
|
||||||
tableView.delegate = self
|
tableView.delegate = self
|
||||||
tableView.dataSource = self
|
tableView.register(ScenicQueueLogTimelineCell.self, forCellReuseIdentifier: ScenicQueueLogTimelineCell.reuseIdentifier)
|
||||||
tableView.register(ScenicQueueLogCell.self, forCellReuseIdentifier: ScenicQueueLogCell.reuseIdentifier)
|
|
||||||
tableView.refreshControl = UIRefreshControl()
|
tableView.refreshControl = UIRefreshControl()
|
||||||
tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||||||
|
dataSource = UITableViewDiffableDataSource<Int, ScenicQueueSettingChangeLogItem>(tableView: tableView) { [weak self] tableView, indexPath, item in
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueLogTimelineCell.reuseIdentifier, for: indexPath) as! ScenicQueueLogTimelineCell
|
||||||
|
cell.configure(item: item, showDot: self?.shouldShowDot(at: indexPath.row) ?? false)
|
||||||
|
return cell
|
||||||
|
}
|
||||||
emptyLabel.text = "暂无配置变更记录"
|
emptyLabel.text = "暂无配置变更记录"
|
||||||
emptyLabel.textColor = AppColor.textTertiary
|
emptyLabel.textColor = AppColor.textSecondary
|
||||||
emptyLabel.font = ScenicQueueTokens.emptyStateFont
|
emptyLabel.font = ScenicQueueTokens.emptyStateFont
|
||||||
emptyLabel.textAlignment = .center
|
emptyLabel.textAlignment = .center
|
||||||
|
loadingIndicator.color = ScenicQueueTokens.bannerBlue
|
||||||
|
footerLabel.textAlignment = .center
|
||||||
|
footerLabel.textColor = AppColor.textSecondary
|
||||||
|
footerLabel.font = .systemFont(ofSize: 14)
|
||||||
view.addSubview(tableView)
|
view.addSubview(tableView)
|
||||||
view.addSubview(emptyLabel)
|
view.addSubview(emptyLabel)
|
||||||
|
view.addSubview(loadingIndicator)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupConstraints() {
|
override func setupConstraints() {
|
||||||
tableView.snp.makeConstraints { make in
|
tableView.snp.makeConstraints { $0.edges.equalTo(view.safeAreaLayoutGuide) }
|
||||||
make.edges.equalTo(view.safeAreaLayoutGuide)
|
emptyLabel.snp.makeConstraints { $0.center.equalToSuperview() }
|
||||||
}
|
loadingIndicator.snp.makeConstraints { $0.center.equalToSuperview() }
|
||||||
emptyLabel.snp.makeConstraints { make in
|
|
||||||
make.center.equalToSuperview()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func bindActions() {
|
override func bindActions() {
|
||||||
viewModel.onStateChange = { [weak self] in
|
viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } }
|
||||||
Task { @MainActor in self?.applyState() }
|
viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } }
|
||||||
}
|
|
||||||
viewModel.onShowMessage = { [weak self] message in
|
|
||||||
Task { @MainActor in self?.showToast(message) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyState() {
|
private func applyState() {
|
||||||
tableView.refreshControl?.endRefreshing()
|
tableView.refreshControl?.endRefreshing()
|
||||||
tableView.reloadData()
|
viewModel.initialLoading && viewModel.items.isEmpty ? loadingIndicator.startAnimating() : loadingIndicator.stopAnimating()
|
||||||
emptyLabel.isHidden = !viewModel.items.isEmpty
|
emptyLabel.isHidden = viewModel.initialLoading || viewModel.isRefreshing || !viewModel.items.isEmpty
|
||||||
}
|
var snapshot = NSDiffableDataSourceSnapshot<Int, ScenicQueueSettingChangeLogItem>()
|
||||||
|
snapshot.appendSections([0])
|
||||||
@objc private func refreshPulled() {
|
snapshot.appendItems(viewModel.items)
|
||||||
Task { await viewModel.refresh(api: api) }
|
dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
|
if viewModel.items.isEmpty {
|
||||||
|
tableView.tableFooterView = UIView()
|
||||||
|
} else {
|
||||||
|
footerLabel.text = viewModel.isLoadingMore ? "加载中…" : (viewModel.canLoadMore ? "" : "没有更多")
|
||||||
|
footerLabel.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52)
|
||||||
|
tableView.tableFooterView = footerLabel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ScenicQueueSettingChangeLogViewController: UITableViewDataSource, UITableViewDelegate {
|
private func shouldShowDot(at index: Int) -> Bool {
|
||||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
let estimatedRowHeight: CGFloat = 132
|
||||||
viewModel.items.count
|
let itemsPerThird = max(1, Int(((view.bounds.height / 3) / estimatedRowHeight).rounded()))
|
||||||
|
let stride = max(3, min(12, itemsPerThird))
|
||||||
|
return index % stride == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
@objc private func refreshPulled() { Task { await viewModel.refresh(api: api) } }
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueLogCell.reuseIdentifier, for: indexPath) as! ScenicQueueLogCell
|
|
||||||
cell.configure(item: viewModel.items[indexPath.row])
|
|
||||||
return cell
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension ScenicQueueSettingChangeLogViewController: UITableViewDelegate {
|
||||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
guard scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height - 120 else { return }
|
guard !viewModel.items.isEmpty,
|
||||||
|
scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height - 180 else { return }
|
||||||
Task { await viewModel.loadMore(api: api) }
|
Task { await viewModel.loadMore(api: api) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 配置日志 cell。
|
/// 配置日志的时间轴卡片单元格。
|
||||||
final class ScenicQueueLogCell: UITableViewCell {
|
final class ScenicQueueLogTimelineCell: UITableViewCell {
|
||||||
static let reuseIdentifier = "ScenicQueueLogCell"
|
static let reuseIdentifier = "ScenicQueueLogTimelineCell"
|
||||||
|
private let rail = UIView()
|
||||||
|
private let dot = UIView()
|
||||||
private let card = UIView()
|
private let card = UIView()
|
||||||
private let titleLabel = UILabel()
|
private let metaLabel = UILabel()
|
||||||
private let detailLabel = UILabel()
|
private let summaryLabel = UILabel()
|
||||||
private let timeLabel = UILabel()
|
|
||||||
|
|
||||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
setupUI()
|
selectionStyle = .none
|
||||||
|
backgroundColor = .white
|
||||||
|
rail.backgroundColor = ScenicQueueTokens.timelineLine
|
||||||
|
dot.backgroundColor = ScenicQueueTokens.timelineDot
|
||||||
|
dot.layer.cornerRadius = 4
|
||||||
|
card.backgroundColor = ScenicQueueTokens.logCardBackground
|
||||||
|
card.layer.cornerRadius = 12
|
||||||
|
metaLabel.font = .systemFont(ofSize: 12)
|
||||||
|
metaLabel.textColor = AppColor.textSecondary
|
||||||
|
metaLabel.numberOfLines = 3
|
||||||
|
summaryLabel.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||||
|
summaryLabel.textColor = AppColor.textPrimary
|
||||||
|
summaryLabel.numberOfLines = 0
|
||||||
|
let stack = UIStackView(arrangedSubviews: [metaLabel, summaryLabel])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 8
|
||||||
|
contentView.addSubview(rail)
|
||||||
|
contentView.addSubview(dot)
|
||||||
|
contentView.addSubview(card)
|
||||||
|
card.addSubview(stack)
|
||||||
|
rail.snp.makeConstraints { make in
|
||||||
|
make.centerX.equalTo(contentView.snp.leading).offset(UIScreen.main.bounds.width * 0.10)
|
||||||
|
make.top.bottom.equalToSuperview()
|
||||||
|
make.width.equalTo(1.5)
|
||||||
|
}
|
||||||
|
dot.snp.makeConstraints { make in make.centerX.equalTo(rail); make.top.equalToSuperview().offset(18); make.size.equalTo(8) }
|
||||||
|
card.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(rail.snp.trailing).offset(20)
|
||||||
|
make.trailing.equalToSuperview().offset(-16)
|
||||||
|
make.top.equalToSuperview().offset(6)
|
||||||
|
make.bottom.equalToSuperview().offset(-6)
|
||||||
|
}
|
||||||
|
stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(15) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
func configure(item: ScenicQueueSettingChangeLogItem) {
|
/// 绑定日志内容与时间轴圆点显隐。
|
||||||
titleLabel.text = item.operatorName.isEmpty ? "配置变更" : item.operatorName
|
func configure(item: ScenicQueueSettingChangeLogItem, showDot: Bool) {
|
||||||
detailLabel.text = item.displayText.isEmpty ? item.summary : item.displayText
|
metaLabel.text = item.metadataLine
|
||||||
timeLabel.text = item.createdAt
|
summaryLabel.text = item.cardSummary
|
||||||
}
|
dot.isHidden = !showDot
|
||||||
|
|
||||||
private func setupUI() {
|
|
||||||
selectionStyle = .none
|
|
||||||
backgroundColor = .clear
|
|
||||||
card.backgroundColor = .white
|
|
||||||
card.layer.cornerRadius = ScenicQueueTokens.radiusCard
|
|
||||||
card.layer.borderWidth = 1
|
|
||||||
card.layer.borderColor = ScenicQueueTokens.cardOutline.cgColor
|
|
||||||
contentView.addSubview(card)
|
|
||||||
titleLabel.font = ScenicQueueTokens.settingsSectionTitleFont
|
|
||||||
titleLabel.textColor = AppColor.textPrimary
|
|
||||||
detailLabel.font = ScenicQueueTokens.settingsRowTitleFont
|
|
||||||
detailLabel.textColor = AppColor.textSecondary
|
|
||||||
detailLabel.numberOfLines = 0
|
|
||||||
timeLabel.font = ScenicQueueTokens.settingsHintFont
|
|
||||||
timeLabel.textColor = AppColor.textTertiary
|
|
||||||
let stack = UIStackView(arrangedSubviews: [titleLabel, detailLabel, timeLabel])
|
|
||||||
stack.axis = .vertical
|
|
||||||
stack.spacing = 8
|
|
||||||
card.addSubview(stack)
|
|
||||||
card.snp.makeConstraints { make in
|
|
||||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 6, left: 16, bottom: 6, right: 16))
|
|
||||||
}
|
|
||||||
stack.snp.makeConstraints { make in
|
|
||||||
make.edges.equalToSuperview().inset(14)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -15,12 +15,13 @@ final class ScenicQueueViewController: BaseViewController {
|
|||||||
private let punchSpotLabel = UILabel()
|
private let punchSpotLabel = UILabel()
|
||||||
private let statsBanner = ScenicQueueStatsBannerView()
|
private let statsBanner = ScenicQueueStatsBannerView()
|
||||||
private let contentContainer = UIView()
|
private let contentContainer = UIView()
|
||||||
private let segmentedControl = UISegmentedControl(items: ["当前排队", "过号列表"])
|
private let tabStrip = ScenicQueueTabStripView(titles: ["当前排队", "过号列表"])
|
||||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||||
private let bottomBar = UIView()
|
private let bottomBar = UIView()
|
||||||
private let prepareCallButton = UIButton(type: .system)
|
private let prepareCallButton = UIButton(type: .system)
|
||||||
private let quickCallButton = UIButton(type: .system)
|
private let quickCallButton = UIButton(type: .system)
|
||||||
private let emptyLabel = UILabel()
|
private let emptyLabel = UILabel()
|
||||||
|
private var dataSource: UITableViewDiffableDataSource<Int, ScenicQueueListItem>!
|
||||||
|
|
||||||
private var selectedTab = 0
|
private var selectedTab = 0
|
||||||
|
|
||||||
@ -64,8 +65,8 @@ final class ScenicQueueViewController: BaseViewController {
|
|||||||
view.backgroundColor = ScenicQueueTokens.pageBackground
|
view.backgroundColor = ScenicQueueTokens.pageBackground
|
||||||
|
|
||||||
punchSpotRow.backgroundColor = .white
|
punchSpotRow.backgroundColor = .white
|
||||||
let locationIcon = UIImageView(image: UIImage(systemName: "mappin.and.ellipse"))
|
let locationIcon = UIImageView(image: UIImage(named: "queue_location"))
|
||||||
locationIcon.tintColor = ScenicQueueTokens.bannerBlue
|
locationIcon.contentMode = .scaleAspectFit
|
||||||
punchSpotLabel.font = .systemFont(ofSize: 16)
|
punchSpotLabel.font = .systemFont(ofSize: 16)
|
||||||
punchSpotLabel.textColor = UIColor.black.withAlphaComponent(0.7)
|
punchSpotLabel.textColor = UIColor.black.withAlphaComponent(0.7)
|
||||||
punchSpotLabel.lineBreakMode = .byTruncatingTail
|
punchSpotLabel.lineBreakMode = .byTruncatingTail
|
||||||
@ -75,29 +76,21 @@ final class ScenicQueueViewController: BaseViewController {
|
|||||||
contentContainer.backgroundColor = .white
|
contentContainer.backgroundColor = .white
|
||||||
contentContainer.layer.cornerRadius = ScenicQueueTokens.radiusContentSheetTop
|
contentContainer.layer.cornerRadius = ScenicQueueTokens.radiusContentSheetTop
|
||||||
contentContainer.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
contentContainer.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||||
contentContainer.clipsToBounds = true
|
contentContainer.layer.shadowColor = UIColor.black.cgColor
|
||||||
|
contentContainer.layer.shadowOpacity = 0.08
|
||||||
segmentedControl.selectedSegmentIndex = 0
|
contentContainer.layer.shadowRadius = 4
|
||||||
segmentedControl.selectedSegmentTintColor = .white
|
contentContainer.layer.shadowOffset = CGSize(width: 0, height: 2)
|
||||||
segmentedControl.setTitleTextAttributes([
|
|
||||||
.font: ScenicQueueTokens.tabFont,
|
|
||||||
.foregroundColor: AppColor.textSecondary,
|
|
||||||
], for: .normal)
|
|
||||||
segmentedControl.setTitleTextAttributes([
|
|
||||||
.font: ScenicQueueTokens.tabSelectedFont,
|
|
||||||
.foregroundColor: ScenicQueueTokens.bannerBlue,
|
|
||||||
], for: .selected)
|
|
||||||
|
|
||||||
tableView.backgroundColor = .white
|
tableView.backgroundColor = .white
|
||||||
tableView.separatorStyle = .none
|
tableView.separatorStyle = .none
|
||||||
tableView.rowHeight = UITableView.automaticDimension
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
tableView.estimatedRowHeight = 260
|
tableView.estimatedRowHeight = 260
|
||||||
tableView.delegate = self
|
tableView.delegate = self
|
||||||
tableView.dataSource = self
|
|
||||||
tableView.register(ScenicQueueTicketCell.self, forCellReuseIdentifier: ScenicQueueTicketCell.reuseIdentifier)
|
tableView.register(ScenicQueueTicketCell.self, forCellReuseIdentifier: ScenicQueueTicketCell.reuseIdentifier)
|
||||||
tableView.register(ScenicQueueSkippedCell.self, forCellReuseIdentifier: ScenicQueueSkippedCell.reuseIdentifier)
|
tableView.register(ScenicQueueSkippedCell.self, forCellReuseIdentifier: ScenicQueueSkippedCell.reuseIdentifier)
|
||||||
tableView.refreshControl = UIRefreshControl()
|
tableView.refreshControl = UIRefreshControl()
|
||||||
tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||||||
|
configureDataSource()
|
||||||
|
|
||||||
emptyLabel.textColor = AppColor.textTertiary
|
emptyLabel.textColor = AppColor.textTertiary
|
||||||
emptyLabel.font = ScenicQueueTokens.emptyStateFont
|
emptyLabel.font = ScenicQueueTokens.emptyStateFont
|
||||||
@ -114,7 +107,7 @@ final class ScenicQueueViewController: BaseViewController {
|
|||||||
view.addSubview(punchSpotRow)
|
view.addSubview(punchSpotRow)
|
||||||
view.addSubview(statsBanner)
|
view.addSubview(statsBanner)
|
||||||
view.addSubview(contentContainer)
|
view.addSubview(contentContainer)
|
||||||
contentContainer.addSubview(segmentedControl)
|
contentContainer.addSubview(tabStrip)
|
||||||
contentContainer.addSubview(tableView)
|
contentContainer.addSubview(tableView)
|
||||||
contentContainer.addSubview(emptyLabel)
|
contentContainer.addSubview(emptyLabel)
|
||||||
view.addSubview(bottomBar)
|
view.addSubview(bottomBar)
|
||||||
@ -149,12 +142,13 @@ final class ScenicQueueViewController: BaseViewController {
|
|||||||
make.leading.trailing.equalToSuperview().inset(16)
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
make.bottom.equalTo(bottomBar.snp.top).offset(-8)
|
make.bottom.equalTo(bottomBar.snp.top).offset(-8)
|
||||||
}
|
}
|
||||||
segmentedControl.snp.makeConstraints { make in
|
tabStrip.snp.makeConstraints { make in
|
||||||
make.top.leading.trailing.equalToSuperview().inset(12)
|
make.top.equalToSuperview()
|
||||||
make.height.equalTo(40)
|
make.leading.trailing.equalToSuperview().inset(12)
|
||||||
|
make.height.equalTo(48)
|
||||||
}
|
}
|
||||||
tableView.snp.makeConstraints { make in
|
tableView.snp.makeConstraints { make in
|
||||||
make.top.equalTo(segmentedControl.snp.bottom).offset(8)
|
make.top.equalTo(tabStrip.snp.bottom)
|
||||||
make.leading.trailing.bottom.equalToSuperview()
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
}
|
}
|
||||||
emptyLabel.snp.makeConstraints { make in
|
emptyLabel.snp.makeConstraints { make in
|
||||||
@ -177,7 +171,7 @@ final class ScenicQueueViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func bindActions() {
|
override func bindActions() {
|
||||||
segmentedControl.addTarget(self, action: #selector(tabChanged), for: .valueChanged)
|
tabStrip.onSelect = { [weak self] index in self?.selectTab(index) }
|
||||||
prepareCallButton.addTarget(self, action: #selector(prepareCallTapped), for: .touchUpInside)
|
prepareCallButton.addTarget(self, action: #selector(prepareCallTapped), for: .touchUpInside)
|
||||||
quickCallButton.addTarget(self, action: #selector(quickCallTapped), for: .touchUpInside)
|
quickCallButton.addTarget(self, action: #selector(quickCallTapped), for: .touchUpInside)
|
||||||
viewModel.onStateChange = { [weak self] in
|
viewModel.onStateChange = { [weak self] in
|
||||||
@ -202,7 +196,7 @@ final class ScenicQueueViewController: BaseViewController {
|
|||||||
punchSpotLabel.text = viewModel.selectedPunchSpotLine
|
punchSpotLabel.text = viewModel.selectedPunchSpotLine
|
||||||
statsBanner.configure(summary: viewModel.summary)
|
statsBanner.configure(summary: viewModel.summary)
|
||||||
tableView.refreshControl?.endRefreshing()
|
tableView.refreshControl?.endRefreshing()
|
||||||
tableView.reloadData()
|
applySnapshot(animated: true)
|
||||||
let isEmpty = selectedTab == 0 ? viewModel.currentQueue.isEmpty : viewModel.skippedQueue.isEmpty
|
let isEmpty = selectedTab == 0 ? viewModel.currentQueue.isEmpty : viewModel.skippedQueue.isEmpty
|
||||||
emptyLabel.text = selectedTab == 0 ? "暂无排队" : "暂无过号记录"
|
emptyLabel.text = selectedTab == 0 ? "暂无排队" : "暂无过号记录"
|
||||||
emptyLabel.isHidden = !isEmpty
|
emptyLabel.isHidden = !isEmpty
|
||||||
@ -212,6 +206,41 @@ final class ScenicQueueViewController: BaseViewController {
|
|||||||
bottomBar.isHidden = !viewModel.queueGatePassed || (!viewModel.prepareCallButtonEnabled && !viewModel.quickCallButtonEnabled)
|
bottomBar.isHidden = !viewModel.queueGatePassed || (!viewModel.prepareCallButtonEnabled && !viewModel.quickCallButtonEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func configureDataSource() {
|
||||||
|
dataSource = UITableViewDiffableDataSource<Int, ScenicQueueListItem>(tableView: tableView) { [weak self] tableView, indexPath, item in
|
||||||
|
guard let self else { return UITableViewCell() }
|
||||||
|
switch item {
|
||||||
|
case .current(let ticket):
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueTicketCell.reuseIdentifier, for: indexPath) as! ScenicQueueTicketCell
|
||||||
|
cell.delegate = self
|
||||||
|
cell.configure(
|
||||||
|
ticket: ticket,
|
||||||
|
shootingQueueNo: self.viewModel.shootingTimedQueueNo,
|
||||||
|
remainSeconds: self.viewModel.shootingRemainSeconds,
|
||||||
|
showStartShootingButton: self.viewModel.showStartShootingButton
|
||||||
|
)
|
||||||
|
return cell
|
||||||
|
case .skipped(let ticket):
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueSkippedCell.reuseIdentifier, for: indexPath) as! ScenicQueueSkippedCell
|
||||||
|
cell.delegate = self
|
||||||
|
cell.configure(ticket: ticket)
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applySnapshot(animated: Bool) {
|
||||||
|
let previousItems = Set(dataSource.snapshot().itemIdentifiers)
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<Int, ScenicQueueListItem>()
|
||||||
|
snapshot.appendSections([0])
|
||||||
|
let items: [ScenicQueueListItem] = selectedTab == 0
|
||||||
|
? viewModel.currentQueue.map(ScenicQueueListItem.current)
|
||||||
|
: viewModel.skippedQueue.map(ScenicQueueListItem.skipped)
|
||||||
|
snapshot.appendItems(items)
|
||||||
|
snapshot.reconfigureItems(items.filter(previousItems.contains))
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: animated)
|
||||||
|
}
|
||||||
|
|
||||||
private func configureBottomButton(_ button: UIButton, title: String, subtitle: String, image: UIImage?, color: UIColor) {
|
private func configureBottomButton(_ button: UIButton, title: String, subtitle: String, image: UIImage?, color: UIColor) {
|
||||||
var config = UIButton.Configuration.filled()
|
var config = UIButton.Configuration.filled()
|
||||||
config.baseBackgroundColor = color
|
config.baseBackgroundColor = color
|
||||||
@ -232,42 +261,63 @@ final class ScenicQueueViewController: BaseViewController {
|
|||||||
|
|
||||||
private func presentParametersRequiredDialog() {
|
private func presentParametersRequiredDialog() {
|
||||||
guard presentedViewController == nil else { return }
|
guard presentedViewController == nil else { return }
|
||||||
let alert = UIAlertController(title: "排队参数未配置", message: "请先选择打卡点并保存排队设置", preferredStyle: .alert)
|
let dialog = ScenicQueueConfirmationDialogViewController(
|
||||||
alert.addAction(UIAlertAction(title: "暂不进入", style: .cancel) { [weak self] _ in
|
configuration: ScenicQueueDialogConfiguration(
|
||||||
self?.viewModel.exitQueueModule()
|
title: "请先设置排队参数",
|
||||||
})
|
message: "请在排队设置中选择并保存打卡点后,再使用排队管理。",
|
||||||
alert.addAction(UIAlertAction(title: "去设置", style: .default) { [weak self] _ in
|
emphasizedText: nil,
|
||||||
self?.navigationController?.pushViewController(ScenicQueueSettingsViewController(), animated: true)
|
dangerText: nil,
|
||||||
})
|
cancelTitle: "暂不使用",
|
||||||
present(alert, animated: true)
|
confirmTitle: "去设置",
|
||||||
|
buttonRadius: 22,
|
||||||
|
dismissOnBackdrop: false
|
||||||
|
),
|
||||||
|
onCancel: { [weak self] in self?.viewModel.exitQueueModule() },
|
||||||
|
onConfirm: { [weak self] in self?.navigationController?.pushViewController(ScenicQueueSettingsViewController(), animated: true) }
|
||||||
|
)
|
||||||
|
present(dialog, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presentConfirm(title: String, message: String?, confirm: @escaping () -> Void) {
|
private func presentConfirm(
|
||||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
title: String,
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
emphasizedText: String,
|
||||||
alert.addAction(UIAlertAction(title: "确认", style: .default) { _ in confirm() })
|
dangerText: String? = nil,
|
||||||
present(alert, animated: true)
|
confirmTitle: String = "确认",
|
||||||
|
cancel: @escaping () -> Void,
|
||||||
|
confirm: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
let dialog = ScenicQueueConfirmationDialogViewController(
|
||||||
|
configuration: ScenicQueueDialogConfiguration(
|
||||||
|
title: title,
|
||||||
|
message: nil,
|
||||||
|
emphasizedText: emphasizedText,
|
||||||
|
dangerText: dangerText,
|
||||||
|
cancelTitle: "取消",
|
||||||
|
confirmTitle: confirmTitle,
|
||||||
|
buttonRadius: title == "确认完成拍摄" ? 22 : 8,
|
||||||
|
dismissOnBackdrop: true
|
||||||
|
),
|
||||||
|
onCancel: cancel,
|
||||||
|
onConfirm: confirm
|
||||||
|
)
|
||||||
|
present(dialog, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openDial(_ digits: String) {
|
private func openDial(_ digits: String) {
|
||||||
guard !digits.isEmpty else { return }
|
guard !digits.isEmpty else { return }
|
||||||
let alert = UIAlertController(title: "拨打电话", message: digits, preferredStyle: .actionSheet)
|
present(ScenicQueueDialSheetViewController(digits: digits), animated: true)
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
|
||||||
alert.addAction(UIAlertAction(title: "拨打", style: .default) { _ in
|
|
||||||
if let url = URL(string: "tel:\(digits)") {
|
|
||||||
UIApplication.shared.open(url)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
present(alert, animated: true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func settingsTapped() {
|
@objc private func settingsTapped() {
|
||||||
viewModel.openSettings()
|
viewModel.openSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func tabChanged() {
|
private func selectTab(_ index: Int) {
|
||||||
selectedTab = segmentedControl.selectedSegmentIndex
|
guard index != selectedTab else { return }
|
||||||
tableView.reloadData()
|
selectedTab = index
|
||||||
|
UIView.transition(with: tableView, duration: 0.22, options: .transitionCrossDissolve) {
|
||||||
|
self.applySnapshot(animated: false)
|
||||||
|
}
|
||||||
applyState()
|
applyState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -298,30 +348,7 @@ final class ScenicQueueViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ScenicQueueViewController: UITableViewDataSource, UITableViewDelegate {
|
extension ScenicQueueViewController: UITableViewDelegate {
|
||||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
|
||||||
selectedTab == 0 ? viewModel.currentQueue.count : viewModel.skippedQueue.count
|
|
||||||
}
|
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
|
||||||
if selectedTab == 0 {
|
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueTicketCell.reuseIdentifier, for: indexPath) as! ScenicQueueTicketCell
|
|
||||||
let ticket = viewModel.currentQueue[indexPath.row]
|
|
||||||
cell.delegate = self
|
|
||||||
cell.configure(
|
|
||||||
ticket: ticket,
|
|
||||||
shootingQueueNo: viewModel.shootingTimedQueueNo,
|
|
||||||
remainSeconds: viewModel.shootingRemainSeconds,
|
|
||||||
showStartShootingButton: viewModel.showStartShootingButton
|
|
||||||
)
|
|
||||||
return cell
|
|
||||||
}
|
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueSkippedCell.reuseIdentifier, for: indexPath) as! ScenicQueueSkippedCell
|
|
||||||
cell.delegate = self
|
|
||||||
cell.configure(ticket: viewModel.skippedQueue[indexPath.row])
|
|
||||||
return cell
|
|
||||||
}
|
|
||||||
|
|
||||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
guard scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height - 120 else { return }
|
guard scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height - 120 else { return }
|
||||||
Task {
|
Task {
|
||||||
@ -341,7 +368,9 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate {
|
|||||||
|
|
||||||
func scenicQueueTicketCellDidTapSkip(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
|
func scenicQueueTicketCellDidTapSkip(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
|
||||||
viewModel.requestSkipTicket(recordId: ticket.recordId, queueNo: ticket.queueNo)
|
viewModel.requestSkipTicket(recordId: ticket.recordId, queueNo: ticket.queueNo)
|
||||||
presentConfirm(title: "确认过号", message: ticket.queueNo) { [weak self] in
|
presentConfirm(title: "确认过号", emphasizedText: ticket.queueNo, dangerText: "确认该用户未到场并过号", cancel: { [weak self] in
|
||||||
|
self?.viewModel.dismissSkipDialog()
|
||||||
|
}) { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
Task { await self.viewModel.confirmSkipTicket(api: self.api) }
|
Task { await self.viewModel.confirmSkipTicket(api: self.api) }
|
||||||
}
|
}
|
||||||
@ -350,7 +379,9 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate {
|
|||||||
func scenicQueueTicketCellDidTapPrimary(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
|
func scenicQueueTicketCellDidTapPrimary(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
|
||||||
if ticket.status == .waiting {
|
if ticket.status == .waiting {
|
||||||
viewModel.requestCallConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo)
|
viewModel.requestCallConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo)
|
||||||
presentConfirm(title: "确认叫号", message: ticket.queueNo) { [weak self] in
|
presentConfirm(title: "确认叫号", emphasizedText: "确认叫号【\(ticket.queueNo)】", cancel: { [weak self] in
|
||||||
|
self?.viewModel.dismissCallConfirmDialog()
|
||||||
|
}) { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
Task { await self.viewModel.confirmCallTicket(api: self.api) }
|
Task { await self.viewModel.confirmCallTicket(api: self.api) }
|
||||||
}
|
}
|
||||||
@ -361,7 +392,9 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate {
|
|||||||
|
|
||||||
func scenicQueueTicketCellDidTapRecall(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
|
func scenicQueueTicketCellDidTapRecall(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
|
||||||
viewModel.requestCallConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo, isRecall: true)
|
viewModel.requestCallConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo, isRecall: true)
|
||||||
presentConfirm(title: "重新叫号", message: ticket.queueNo) { [weak self] in
|
presentConfirm(title: "重新叫号", emphasizedText: "确认叫号【\(ticket.queueNo)】", cancel: { [weak self] in
|
||||||
|
self?.viewModel.dismissCallConfirmDialog()
|
||||||
|
}) { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
Task { await self.viewModel.confirmCallTicket(api: self.api) }
|
Task { await self.viewModel.confirmCallTicket(api: self.api) }
|
||||||
}
|
}
|
||||||
@ -369,7 +402,9 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate {
|
|||||||
|
|
||||||
func scenicQueueTicketCellDidTapComplete(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
|
func scenicQueueTicketCellDidTapComplete(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
|
||||||
viewModel.requestFinishConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo)
|
viewModel.requestFinishConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo)
|
||||||
presentConfirm(title: "确认完成拍摄", message: ticket.queueNo) { [weak self] in
|
presentConfirm(title: "确认完成拍摄", emphasizedText: ticket.queueNo, cancel: { [weak self] in
|
||||||
|
self?.viewModel.dismissFinishDialog()
|
||||||
|
}) { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
Task { await self.viewModel.confirmFinishTicket(api: self.api) }
|
Task { await self.viewModel.confirmFinishTicket(api: self.api) }
|
||||||
}
|
}
|
||||||
@ -377,25 +412,16 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate {
|
|||||||
|
|
||||||
func scenicQueueTicketCellDidTapMark(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
|
func scenicQueueTicketCellDidTapMark(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
|
||||||
viewModel.onTicketMarkClick(recordId: ticket.recordId, queueNo: ticket.queueNo, uid: ticket.uid)
|
viewModel.onTicketMarkClick(recordId: ticket.recordId, queueNo: ticket.queueNo, uid: ticket.uid)
|
||||||
let alert = UIAlertController(title: "标记用户", message: "\(ticket.queueNo)\n\(ticket.phoneMasked)", preferredStyle: .alert)
|
guard let pending = viewModel.pendingMark else { return }
|
||||||
alert.addTextField { field in
|
let dialog = ScenicQueueMarkDialogViewController(
|
||||||
field.placeholder = "限制排队天数"
|
pending: pending,
|
||||||
field.keyboardType = .numberPad
|
onCancel: { [weak self] in self?.viewModel.dismissMarkDialog() },
|
||||||
field.text = "0"
|
onConfirm: { [weak self] marked, days in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { await self.viewModel.confirmUserMark(api: self.api, markAsFreelancePhotog: marked, queueBanDays: days) }
|
||||||
}
|
}
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in
|
)
|
||||||
self?.viewModel.dismissMarkDialog()
|
present(dialog, animated: true)
|
||||||
})
|
|
||||||
alert.addAction(UIAlertAction(title: "标记打野", style: .default) { [weak self, weak alert] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
let days = Int(alert?.textFields?.first?.text ?? "") ?? 0
|
|
||||||
Task { await self.viewModel.confirmUserMark(api: self.api, markAsFreelancePhotog: true, queueBanDays: days) }
|
|
||||||
})
|
|
||||||
alert.addAction(UIAlertAction(title: "取消标记", style: .destructive) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
Task { await self.viewModel.confirmUserMark(api: self.api, markAsFreelancePhotog: false, queueBanDays: 0) }
|
|
||||||
})
|
|
||||||
present(alert, animated: true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -406,7 +432,9 @@ extension ScenicQueueViewController: ScenicQueueSkippedCellDelegate {
|
|||||||
|
|
||||||
func scenicQueueSkippedCellDidTapRequeue(_ cell: ScenicQueueSkippedCell, ticket: ScenicQueueSkippedTicket) {
|
func scenicQueueSkippedCellDidTapRequeue(_ cell: ScenicQueueSkippedCell, ticket: ScenicQueueSkippedTicket) {
|
||||||
viewModel.requestRequeueConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo)
|
viewModel.requestRequeueConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo)
|
||||||
presentConfirm(title: "确认重新排队", message: ticket.queueNo) { [weak self] in
|
presentConfirm(title: "确认重新排队", emphasizedText: ticket.queueNo, dangerText: "确认后将插入当前排队队列", confirmTitle: "确定", cancel: { [weak self] in
|
||||||
|
self?.viewModel.dismissRequeueDialog()
|
||||||
|
}) { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
Task { await self.viewModel.confirmRequeueTicket(api: self.api) }
|
Task { await self.viewModel.confirmRequeueTicket(api: self.api) }
|
||||||
}
|
}
|
||||||
|
|||||||
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) {
|
private func rebuildActions(ticket: ScenicQueueTicket, shootingQueueNo: String?, remainSeconds: Int) {
|
||||||
actionsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
actionsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
|
let state = ScenicQueueTicketActionState.make(
|
||||||
|
ticket: ticket,
|
||||||
|
shootingQueueNo: shootingQueueNo,
|
||||||
|
remainSeconds: remainSeconds,
|
||||||
|
showStartShootingButton: showStartShootingButton
|
||||||
|
)
|
||||||
let skip = ScenicQueueActionButton(title: "过号", background: ScenicQueueTokens.buttonSkipBackground, foreground: ScenicQueueTokens.buttonSkipForeground)
|
let skip = ScenicQueueActionButton(title: "过号", background: ScenicQueueTokens.buttonSkipBackground, foreground: ScenicQueueTokens.buttonSkipForeground)
|
||||||
skip.addTarget(self, action: #selector(skipTapped), for: .touchUpInside)
|
skip.addTarget(self, action: #selector(skipTapped), for: .touchUpInside)
|
||||||
let primaryTitle: String
|
skip.isEnabled = state.skipEnabled
|
||||||
if shootingQueueNo == ticket.queueNo {
|
skip.alpha = state.skipEnabled ? 1 : 0.45
|
||||||
primaryTitle = "剩余 \(max(0, remainSeconds)) 秒"
|
let row = UIStackView(arrangedSubviews: [skip])
|
||||||
} else {
|
|
||||||
primaryTitle = ticket.status == .called ? "开始拍摄" : "叫号"
|
|
||||||
}
|
|
||||||
let primary = ScenicQueueActionButton(title: primaryTitle, background: ScenicQueueTokens.bannerBlue, foreground: .white)
|
|
||||||
primary.isEnabled = shootingQueueNo != ticket.queueNo
|
|
||||||
primary.alpha = primary.isEnabled ? 1 : 0.65
|
|
||||||
primary.addTarget(self, action: #selector(primaryTapped), for: .touchUpInside)
|
|
||||||
let row = UIStackView(arrangedSubviews: [skip, primary])
|
|
||||||
row.axis = .horizontal
|
row.axis = .horizontal
|
||||||
row.spacing = 10
|
row.spacing = 10
|
||||||
row.distribution = .fillProportionally
|
row.distribution = .fillProportionally
|
||||||
skip.snp.makeConstraints { make in
|
if state.primaryVisible {
|
||||||
make.height.equalTo(46)
|
let primary = ScenicQueueActionButton(title: state.primaryTitle, background: ScenicQueueTokens.bannerBlue, foreground: .white)
|
||||||
make.width.equalTo(primary).multipliedBy(0.56)
|
primary.isEnabled = state.primaryEnabled
|
||||||
|
primary.alpha = state.primaryEnabled ? 1 : 0.65
|
||||||
|
primary.addTarget(self, action: #selector(primaryTapped), for: .touchUpInside)
|
||||||
|
row.addArrangedSubview(primary)
|
||||||
|
skip.snp.makeConstraints { $0.width.equalTo(primary).multipliedBy(0.56) }
|
||||||
}
|
}
|
||||||
|
row.snp.makeConstraints { $0.height.equalTo(46) }
|
||||||
actionsStack.addArrangedSubview(row)
|
actionsStack.addArrangedSubview(row)
|
||||||
if ticket.status == .called, shootingQueueNo != ticket.queueNo {
|
recallButton.removeTarget(nil, action: nil, for: .allEvents)
|
||||||
|
completeButton.removeTarget(nil, action: nil, for: .allEvents)
|
||||||
|
if state.recallVisible {
|
||||||
recallButton.addTarget(self, action: #selector(recallTapped), for: .touchUpInside)
|
recallButton.addTarget(self, action: #selector(recallTapped), for: .touchUpInside)
|
||||||
recallButton.snp.makeConstraints { make in make.height.equalTo(40) }
|
recallButton.snp.makeConstraints { make in make.height.equalTo(40) }
|
||||||
actionsStack.addArrangedSubview(recallButton)
|
actionsStack.addArrangedSubview(recallButton)
|
||||||
}
|
}
|
||||||
if ticket.showCompleteAction {
|
if state.completeVisible {
|
||||||
completeButton.addTarget(self, action: #selector(completeTapped), for: .touchUpInside)
|
completeButton.addTarget(self, action: #selector(completeTapped), for: .touchUpInside)
|
||||||
completeButton.snp.makeConstraints { make in make.height.equalTo(40) }
|
completeButton.snp.makeConstraints { make in make.height.equalTo(40) }
|
||||||
actionsStack.addArrangedSubview(completeButton)
|
actionsStack.addArrangedSubview(completeButton)
|
||||||
@ -329,17 +334,27 @@ final class ScenicQueueTicketCell: UITableViewCell {
|
|||||||
right.backgroundColor = accent ? AppColor.primaryLight : .clear
|
right.backgroundColor = accent ? AppColor.primaryLight : .clear
|
||||||
right.layer.cornerRadius = 2
|
right.layer.cornerRadius = 2
|
||||||
right.clipsToBounds = true
|
right.clipsToBounds = true
|
||||||
if let tap {
|
|
||||||
right.isUserInteractionEnabled = true
|
|
||||||
right.addGestureRecognizer(UITapGestureRecognizer(target: self, action: tap))
|
|
||||||
}
|
|
||||||
row.addSubview(left)
|
row.addSubview(left)
|
||||||
row.addSubview(right)
|
let valueContainer = UIStackView(arrangedSubviews: [right])
|
||||||
|
valueContainer.axis = .horizontal
|
||||||
|
valueContainer.alignment = .center
|
||||||
|
valueContainer.spacing = 8
|
||||||
|
if let tap {
|
||||||
|
let dial = UIButton(type: .system)
|
||||||
|
dial.backgroundColor = ScenicQueueTokens.bannerBlue
|
||||||
|
dial.tintColor = .white
|
||||||
|
dial.setImage(UIImage(systemName: "phone.fill")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)), for: .normal)
|
||||||
|
dial.layer.cornerRadius = 16
|
||||||
|
dial.addTarget(self, action: tap, for: .touchUpInside)
|
||||||
|
valueContainer.addArrangedSubview(dial)
|
||||||
|
dial.snp.makeConstraints { $0.size.equalTo(32) }
|
||||||
|
}
|
||||||
|
row.addSubview(valueContainer)
|
||||||
left.snp.makeConstraints { make in
|
left.snp.makeConstraints { make in
|
||||||
make.leading.centerY.equalToSuperview()
|
make.leading.centerY.equalToSuperview()
|
||||||
make.top.bottom.equalToSuperview()
|
make.top.bottom.equalToSuperview()
|
||||||
}
|
}
|
||||||
right.snp.makeConstraints { make in
|
valueContainer.snp.makeConstraints { make in
|
||||||
make.trailing.centerY.equalToSuperview()
|
make.trailing.centerY.equalToSuperview()
|
||||||
make.leading.greaterThanOrEqualTo(left.snp.trailing).offset(12)
|
make.leading.greaterThanOrEqualTo(left.snp.trailing).offset(12)
|
||||||
}
|
}
|
||||||
@ -490,17 +505,27 @@ final class ScenicQueueSkippedCell: UITableViewCell {
|
|||||||
right.text = value
|
right.text = value
|
||||||
right.font = ScenicQueueTokens.phoneMaskedFont
|
right.font = ScenicQueueTokens.phoneMaskedFont
|
||||||
right.textColor = AppColor.textPrimary
|
right.textColor = AppColor.textPrimary
|
||||||
if let tap {
|
|
||||||
right.isUserInteractionEnabled = true
|
|
||||||
right.addGestureRecognizer(UITapGestureRecognizer(target: self, action: tap))
|
|
||||||
}
|
|
||||||
row.addSubview(left)
|
row.addSubview(left)
|
||||||
row.addSubview(right)
|
let valueContainer = UIStackView(arrangedSubviews: [right])
|
||||||
|
valueContainer.axis = .horizontal
|
||||||
|
valueContainer.alignment = .center
|
||||||
|
valueContainer.spacing = 8
|
||||||
|
if let tap {
|
||||||
|
let dial = UIButton(type: .system)
|
||||||
|
dial.backgroundColor = ScenicQueueTokens.bannerBlue
|
||||||
|
dial.tintColor = .white
|
||||||
|
dial.setImage(UIImage(systemName: "phone.fill")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)), for: .normal)
|
||||||
|
dial.layer.cornerRadius = 16
|
||||||
|
dial.addTarget(self, action: tap, for: .touchUpInside)
|
||||||
|
valueContainer.addArrangedSubview(dial)
|
||||||
|
dial.snp.makeConstraints { $0.size.equalTo(32) }
|
||||||
|
}
|
||||||
|
row.addSubview(valueContainer)
|
||||||
left.snp.makeConstraints { make in
|
left.snp.makeConstraints { make in
|
||||||
make.leading.centerY.equalToSuperview()
|
make.leading.centerY.equalToSuperview()
|
||||||
make.top.bottom.equalToSuperview()
|
make.top.bottom.equalToSuperview()
|
||||||
}
|
}
|
||||||
right.snp.makeConstraints { make in
|
valueContainer.snp.makeConstraints { make in
|
||||||
make.trailing.centerY.equalToSuperview()
|
make.trailing.centerY.equalToSuperview()
|
||||||
make.leading.greaterThanOrEqualTo(left.snp.trailing).offset(12)
|
make.leading.greaterThanOrEqualTo(left.snp.trailing).offset(12)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,10 +19,10 @@ final class SettingViewController: BaseViewController {
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
viewModel: SettingViewModel = SettingViewModel(),
|
viewModel: SettingViewModel = SettingViewModel(),
|
||||||
settingAPI: SettingAPI = NetworkServices.shared.settingAPI
|
settingAPI: SettingAPI? = nil
|
||||||
) {
|
) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
self.settingAPI = settingAPI
|
self.settingAPI = settingAPI ?? NetworkServices.shared.settingAPI
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,9 +28,9 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
private let uploadButton = UIButton(type: .system)
|
private let uploadButton = UIButton(type: .system)
|
||||||
private var isDeleteSelectedButtonVisible = false
|
private var isDeleteSelectedButtonVisible = false
|
||||||
|
|
||||||
init(albumId: Int, api: any TravelAlbumServing = NetworkServices.shared.travelAlbumAPI) {
|
init(albumId: Int, api: (any TravelAlbumServing)? = nil) {
|
||||||
viewModel = TravelAlbumDetailViewModel(albumId: albumId)
|
viewModel = TravelAlbumDetailViewModel(albumId: albumId)
|
||||||
self.api = api
|
self.api = api ?? NetworkServices.shared.travelAlbumAPI
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,8 +18,8 @@ final class TravelAlbumEntryViewController: BaseViewController {
|
|||||||
private let emptyView = TravelAlbumEmptyView()
|
private let emptyView = TravelAlbumEmptyView()
|
||||||
private var dataSource: UITableViewDiffableDataSource<Int, TravelAlbum>!
|
private var dataSource: UITableViewDiffableDataSource<Int, TravelAlbum>!
|
||||||
|
|
||||||
init(api: any TravelAlbumServing = NetworkServices.shared.travelAlbumAPI) {
|
init(api: (any TravelAlbumServing)? = nil) {
|
||||||
self.api = api
|
self.api = api ?? NetworkServices.shared.travelAlbumAPI
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -36,10 +36,10 @@ final class PointsRedemptionViewController: BaseViewController, UITableViewDeleg
|
|||||||
/// 初始化积分兑现页面。
|
/// 初始化积分兑现页面。
|
||||||
init(
|
init(
|
||||||
viewModel: PointsRedemptionViewModel = PointsRedemptionViewModel(),
|
viewModel: PointsRedemptionViewModel = PointsRedemptionViewModel(),
|
||||||
walletAPI: any WalletPageServing = NetworkServices.shared.walletAPI
|
walletAPI: (any WalletPageServing)? = nil
|
||||||
) {
|
) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
self.walletAPI = walletAPI
|
self.walletAPI = walletAPI ?? NetworkServices.shared.walletAPI
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -41,12 +41,12 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
|
|||||||
/// 初始化钱包页面。
|
/// 初始化钱包页面。
|
||||||
init(
|
init(
|
||||||
viewModel: WalletViewModel = WalletViewModel(),
|
viewModel: WalletViewModel = WalletViewModel(),
|
||||||
walletAPI: any WalletPageServing = NetworkServices.shared.walletAPI,
|
walletAPI: (any WalletPageServing)? = nil,
|
||||||
profileAPI: any WalletProfileServing = NetworkServices.shared.profileAPI
|
profileAPI: (any WalletProfileServing)? = nil
|
||||||
) {
|
) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
self.walletAPI = walletAPI
|
self.walletAPI = walletAPI ?? NetworkServices.shared.walletAPI
|
||||||
self.profileAPI = profileAPI
|
self.profileAPI = profileAPI ?? NetworkServices.shared.profileAPI
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -34,10 +34,10 @@ final class WithdrawViewController: BaseViewController {
|
|||||||
/// 初始化提现申请页面。
|
/// 初始化提现申请页面。
|
||||||
init(
|
init(
|
||||||
viewModel: WithdrawViewModel = WithdrawViewModel(),
|
viewModel: WithdrawViewModel = WithdrawViewModel(),
|
||||||
walletAPI: any WalletPageServing = NetworkServices.shared.walletAPI
|
walletAPI: (any WalletPageServing)? = nil
|
||||||
) {
|
) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
self.walletAPI = walletAPI
|
self.walletAPI = walletAPI ?? NetworkServices.shared.walletAPI
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -15,10 +15,10 @@ final class WildPhotographerReportHomeViewController: BaseViewController {
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
viewModel: WildPhotographerReportHomeViewModel = WildPhotographerReportHomeViewModel(),
|
viewModel: WildPhotographerReportHomeViewModel = WildPhotographerReportHomeViewModel(),
|
||||||
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI
|
api: (any WildPhotographerReportServing)? = nil
|
||||||
) {
|
) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
self.api = api
|
self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,10 +18,10 @@ final class WildPhotographerReportListViewController: BaseViewController {
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
homeViewModel: WildPhotographerReportHomeViewModel,
|
homeViewModel: WildPhotographerReportHomeViewModel,
|
||||||
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI
|
api: (any WildPhotographerReportServing)? = nil
|
||||||
) {
|
) {
|
||||||
self.viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel)
|
self.viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel)
|
||||||
self.api = api
|
self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -560,11 +560,11 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
|
|||||||
init(
|
init(
|
||||||
record: WildReportRecord,
|
record: WildReportRecord,
|
||||||
homeViewModel: WildPhotographerReportHomeViewModel,
|
homeViewModel: WildPhotographerReportHomeViewModel,
|
||||||
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI
|
api: (any WildPhotographerReportServing)? = nil
|
||||||
) {
|
) {
|
||||||
self.viewModel = WildPhotographerReportDetailViewModel(record: record, homeViewModel: homeViewModel)
|
self.viewModel = WildPhotographerReportDetailViewModel(record: record, homeViewModel: homeViewModel)
|
||||||
self.homeViewModel = homeViewModel
|
self.homeViewModel = homeViewModel
|
||||||
self.api = api
|
self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,8 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
|
|||||||
private let scrollView = UIScrollView()
|
private let scrollView = UIScrollView()
|
||||||
private let contentStack = UIStackView()
|
private let contentStack = UIStackView()
|
||||||
private let descriptionView = UITextView()
|
private let descriptionView = UITextView()
|
||||||
|
private let descriptionPlaceholderLabel = UILabel()
|
||||||
|
private let descriptionCountLabel = UILabel()
|
||||||
private let contactField = UITextField()
|
private let contactField = UITextField()
|
||||||
private let submitButton = UIButton(type: .system)
|
private let submitButton = UIButton(type: .system)
|
||||||
private var pickerTarget: WildReportPickerTarget = .image
|
private var pickerTarget: WildReportPickerTarget = .image
|
||||||
@ -25,12 +27,12 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
|
|||||||
init(
|
init(
|
||||||
homeViewModel: WildPhotographerReportHomeViewModel,
|
homeViewModel: WildPhotographerReportHomeViewModel,
|
||||||
locationProvider: any LocationProviding = LocationProvider.shared,
|
locationProvider: any LocationProviding = LocationProvider.shared,
|
||||||
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI,
|
api: (any WildPhotographerReportServing)? = nil,
|
||||||
uploader: any WildReportAttachmentUploading = NetworkServices.shared.ossUploadService
|
uploader: (any WildReportAttachmentUploading)? = nil
|
||||||
) {
|
) {
|
||||||
self.homeViewModel = homeViewModel
|
self.homeViewModel = homeViewModel
|
||||||
self.api = api
|
self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI
|
||||||
self.uploader = uploader
|
self.uploader = uploader ?? NetworkServices.shared.ossUploadService
|
||||||
self.viewModel = WildPhotographerReportSubmitViewModel(
|
self.viewModel = WildPhotographerReportSubmitViewModel(
|
||||||
homeViewModel: homeViewModel,
|
homeViewModel: homeViewModel,
|
||||||
locationProvider: locationProvider
|
locationProvider: locationProvider
|
||||||
@ -64,6 +66,14 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
|
|||||||
descriptionView.textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 34, right: 8)
|
descriptionView.textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 34, right: 8)
|
||||||
descriptionView.delegate = self
|
descriptionView.delegate = self
|
||||||
|
|
||||||
|
descriptionPlaceholderLabel.text = "请描述摄影师的位置、特征、行为..."
|
||||||
|
descriptionPlaceholderLabel.font = .systemFont(ofSize: 15)
|
||||||
|
descriptionPlaceholderLabel.textColor = UIColor(hex: 0x9CA3AF)
|
||||||
|
|
||||||
|
descriptionCountLabel.font = .systemFont(ofSize: 14)
|
||||||
|
descriptionCountLabel.textColor = UIColor(hex: 0x6B7280)
|
||||||
|
descriptionCountLabel.textAlignment = .right
|
||||||
|
|
||||||
contactField.placeholder = "请输入摄影师微信号或手机号(选填)"
|
contactField.placeholder = "请输入摄影师微信号或手机号(选填)"
|
||||||
contactField.font = .app(.body)
|
contactField.font = .app(.body)
|
||||||
contactField.textColor = AppColor.textPrimary
|
contactField.textColor = AppColor.textPrimary
|
||||||
@ -271,34 +281,28 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
|
|||||||
private func makeDescriptionCard() -> UIView {
|
private func makeDescriptionCard() -> UIView {
|
||||||
let card = sectionCard(index: 3, title: "举报说明")
|
let card = sectionCard(index: 3, title: "举报说明")
|
||||||
descriptionView.text = viewModel.description
|
descriptionView.text = viewModel.description
|
||||||
|
updateDescriptionInputState()
|
||||||
let inputContainer = UIView()
|
let inputContainer = UIView()
|
||||||
inputContainer.backgroundColor = UIColor(hex: 0xFBFCFE)
|
inputContainer.backgroundColor = UIColor(hex: 0xFBFCFE)
|
||||||
inputContainer.layer.cornerRadius = 12
|
inputContainer.layer.cornerRadius = 12
|
||||||
inputContainer.layer.borderWidth = 1
|
inputContainer.layer.borderWidth = 1
|
||||||
inputContainer.layer.borderColor = UIColor(hex: 0xCDD5E1).cgColor
|
inputContainer.layer.borderColor = UIColor(hex: 0xCDD5E1).cgColor
|
||||||
|
|
||||||
let placeholder = UILabel()
|
|
||||||
placeholder.text = "请描述摄影师的位置、特征、行为..."
|
|
||||||
placeholder.font = .systemFont(ofSize: 15)
|
|
||||||
placeholder.textColor = UIColor(hex: 0x9CA3AF)
|
|
||||||
placeholder.isHidden = !viewModel.description.isEmpty
|
|
||||||
|
|
||||||
let count = WildReportUI.label("\(viewModel.description.count)/500", font: .systemFont(ofSize: 14), color: UIColor(hex: 0x6B7280))
|
|
||||||
count.textAlignment = .right
|
|
||||||
|
|
||||||
inputContainer.addSubview(descriptionView)
|
inputContainer.addSubview(descriptionView)
|
||||||
inputContainer.addSubview(placeholder)
|
inputContainer.addSubview(descriptionPlaceholderLabel)
|
||||||
inputContainer.addSubview(count)
|
inputContainer.addSubview(descriptionCountLabel)
|
||||||
descriptionView.snp.makeConstraints { make in
|
descriptionView.snp.makeConstraints { make in
|
||||||
make.edges.equalToSuperview()
|
make.edges.equalToSuperview()
|
||||||
make.height.equalTo(132)
|
make.height.equalTo(132)
|
||||||
}
|
}
|
||||||
placeholder.snp.makeConstraints { make in
|
let textOriginX = descriptionView.textContainerInset.left
|
||||||
make.top.equalToSuperview().offset(18)
|
+ descriptionView.textContainer.lineFragmentPadding
|
||||||
make.leading.equalToSuperview().offset(14)
|
descriptionPlaceholderLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(descriptionView.textContainerInset.top)
|
||||||
|
make.leading.equalToSuperview().offset(textOriginX)
|
||||||
make.trailing.lessThanOrEqualToSuperview().inset(14)
|
make.trailing.lessThanOrEqualToSuperview().inset(14)
|
||||||
}
|
}
|
||||||
count.snp.makeConstraints { make in
|
descriptionCountLabel.snp.makeConstraints { make in
|
||||||
make.trailing.bottom.equalToSuperview().inset(12)
|
make.trailing.bottom.equalToSuperview().inset(12)
|
||||||
}
|
}
|
||||||
card.stack.addArrangedSubview(inputContainer)
|
card.stack.addArrangedSubview(inputContainer)
|
||||||
@ -376,6 +380,11 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
|
|||||||
return card
|
return card
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func updateDescriptionInputState() {
|
||||||
|
descriptionPlaceholderLabel.isHidden = !viewModel.description.isEmpty
|
||||||
|
descriptionCountLabel.text = "\(viewModel.description.count)/500"
|
||||||
|
}
|
||||||
|
|
||||||
private func makePaymentCard() -> UIView {
|
private func makePaymentCard() -> UIView {
|
||||||
let card = sectionCard(index: 6, title: "线下微信、支付宝截图", optional: true)
|
let card = sectionCard(index: 6, title: "线下微信、支付宝截图", optional: true)
|
||||||
card.stack.addArrangedSubview(makeEvidenceUploadBlock(
|
card.stack.addArrangedSubview(makeEvidenceUploadBlock(
|
||||||
@ -561,7 +570,12 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@objc private func contactChanged() { viewModel.updateContact(contactField.text ?? "") }
|
@objc private func contactChanged() {
|
||||||
|
viewModel.updateContact(contactField.text ?? "")
|
||||||
|
guard contactField.markedTextRange == nil,
|
||||||
|
contactField.text != viewModel.contact else { return }
|
||||||
|
contactField.text = viewModel.contact
|
||||||
|
}
|
||||||
@objc private func dismissKeyboard() { view.endEditing(true) }
|
@objc private func dismissKeyboard() { view.endEditing(true) }
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -654,6 +668,10 @@ extension WildPhotographerReportSubmitViewController: UITextViewDelegate {
|
|||||||
|
|
||||||
func textViewDidChange(_ textView: UITextView) {
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
viewModel.updateDescription(textView.text)
|
viewModel.updateDescription(textView.text)
|
||||||
|
if textView.markedTextRange == nil, textView.text != viewModel.description {
|
||||||
|
textView.text = viewModel.description
|
||||||
|
}
|
||||||
|
updateDescriptionInputState()
|
||||||
}
|
}
|
||||||
|
|
||||||
func textViewDidEndEditing(_ textView: UITextView) {
|
func textViewDidEndEditing(_ textView: UITextView) {
|
||||||
|
|||||||
@ -32,13 +32,13 @@ final class WildReportRiskMapViewController: BaseViewController {
|
|||||||
private var isShowingRiskMapLoading = false
|
private var isShowingRiskMapLoading = false
|
||||||
|
|
||||||
init(
|
init(
|
||||||
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI,
|
api: (any WildPhotographerReportServing)? = nil,
|
||||||
locationProvider: any LocationProviding = LocationProvider.shared,
|
locationProvider: any LocationProviding = LocationProvider.shared,
|
||||||
scenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId },
|
scenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId },
|
||||||
scenicNameProvider: @escaping () -> String = { AppStore.shared.session.currentScenicName }
|
scenicNameProvider: @escaping () -> String = { AppStore.shared.session.currentScenicName }
|
||||||
) {
|
) {
|
||||||
self.viewModel = WildReportRiskMapViewModel()
|
self.viewModel = WildReportRiskMapViewModel()
|
||||||
self.api = api
|
self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI
|
||||||
self.locationProvider = locationProvider
|
self.locationProvider = locationProvider
|
||||||
self.scenicIdProvider = scenicIdProvider
|
self.scenicIdProvider = scenicIdProvider
|
||||||
self.scenicNameProvider = scenicNameProvider
|
self.scenicNameProvider = scenicNameProvider
|
||||||
@ -675,7 +675,7 @@ final class WildReportRiskMapViewController: BaseViewController {
|
|||||||
button.tintColor = AppColor.primary
|
button.tintColor = AppColor.primary
|
||||||
button.setTitleColor(AppColor.primary, for: .normal)
|
button.setTitleColor(AppColor.primary, for: .normal)
|
||||||
button.semanticContentAttribute = .forceRightToLeft
|
button.semanticContentAttribute = .forceRightToLeft
|
||||||
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: 4, bottom: 0, right: -4)
|
button.setConfigurationImagePlacement(.trailing, padding: 4)
|
||||||
if let phone {
|
if let phone {
|
||||||
button.addAction(UIAction { [weak self] _ in
|
button.addAction(UIAction { [weak self] _ in
|
||||||
self?.callPhone(phone)
|
self?.callPhone(phone)
|
||||||
|
|||||||
@ -28,13 +28,13 @@ final class WildReportSupplementEvidenceViewController: BaseViewController {
|
|||||||
init(
|
init(
|
||||||
record: WildReportRecord,
|
record: WildReportRecord,
|
||||||
homeViewModel: WildPhotographerReportHomeViewModel,
|
homeViewModel: WildPhotographerReportHomeViewModel,
|
||||||
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI,
|
api: (any WildPhotographerReportServing)? = nil,
|
||||||
uploader: any WildReportAttachmentUploading = NetworkServices.shared.ossUploadService,
|
uploader: (any WildReportAttachmentUploading)? = nil,
|
||||||
onSupplementSuccess: (() -> Void)? = nil
|
onSupplementSuccess: (() -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
self.viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel)
|
self.viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel)
|
||||||
self.api = api
|
self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI
|
||||||
self.uploader = uploader
|
self.uploader = uploader ?? NetworkServices.shared.ossUploadService
|
||||||
self.onSupplementSuccess = onSupplementSuccess
|
self.onSupplementSuccess = onSupplementSuccess
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import XCTest
|
import XCTest
|
||||||
|
import UIKit
|
||||||
@testable import suixinkan
|
@testable import suixinkan
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -55,6 +56,24 @@ final class ScenicQueueFeatureTests: XCTestCase {
|
|||||||
XCTAssertEqual(ticket.missRequeueText, "过号重排")
|
XCTAssertEqual(ticket.missRequeueText, "过号重排")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testHomeDecodingAcceptsBackendStatsWithoutNestedType() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"stats":{"avg_wait_min":0,"queue_count":0},
|
||||||
|
"list":{"list":[],"page":1,"page_size":10,"total":0},
|
||||||
|
"type":1
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let home = try JSONDecoder().decode(ScenicQueueHomeData.self, from: json)
|
||||||
|
|
||||||
|
XCTAssertEqual(home.stats?.type, 0)
|
||||||
|
XCTAssertEqual(home.list?.list, [])
|
||||||
|
XCTAssertEqual(home.list?.page, 1)
|
||||||
|
XCTAssertEqual(home.list?.pageSize, 10)
|
||||||
|
XCTAssertEqual(home.list?.total, 0)
|
||||||
|
}
|
||||||
|
|
||||||
func testSettingDecodingClampsSnapshotAndSocketMessage() throws {
|
func testSettingDecodingClampsSnapshotAndSocketMessage() throws {
|
||||||
let settingJSON = """
|
let settingJSON = """
|
||||||
{
|
{
|
||||||
@ -399,6 +418,191 @@ final class ScenicQueueFeatureTests: XCTestCase {
|
|||||||
XCTAssertFalse(store.scenicQueue.useOfflineTTS)
|
XCTAssertFalse(store.scenicQueue.useOfflineTTS)
|
||||||
XCTAssertEqual(messages.last, "离线语音资源未安装")
|
XCTAssertEqual(messages.last, "离线语音资源未安装")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testQueueDistanceKilometerConversionAndInvalidFormats() {
|
||||||
|
XCTAssertEqual(ScenicQueueSettingsViewModel.kilometersDisplay(fromMeters: 0), "0")
|
||||||
|
XCTAssertEqual(ScenicQueueSettingsViewModel.kilometersDisplay(fromMeters: 500), "0.5")
|
||||||
|
XCTAssertEqual(ScenicQueueSettingsViewModel.kilometersDisplay(fromMeters: 1_230), "1.23")
|
||||||
|
XCTAssertEqual(ScenicQueueSettingsViewModel.kilometersDisplay(fromMeters: 9_999_990), "9999.99")
|
||||||
|
XCTAssertEqual(ScenicQueueSettingsViewModel.meters(fromKilometersText: "0.50"), 500)
|
||||||
|
XCTAssertEqual(ScenicQueueSettingsViewModel.meters(fromKilometersText: "9999.99"), 9_999_990)
|
||||||
|
XCTAssertNil(ScenicQueueSettingsViewModel.meters(fromKilometersText: "1.234"))
|
||||||
|
XCTAssertNil(ScenicQueueSettingsViewModel.meters(fromKilometersText: "10000"))
|
||||||
|
XCTAssertNil(ScenicQueueSettingsViewModel.meters(fromKilometersText: "公里"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInvalidNumericPendingBlocksSaveAndSelectsMatchingTab() async {
|
||||||
|
let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.invalidNumeric")!
|
||||||
|
defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.invalidNumeric")
|
||||||
|
let store = AppStore(defaults: defaults)
|
||||||
|
store.session.userId = "u1"
|
||||||
|
store.session.accountType = .unknown("photog")
|
||||||
|
store.session.currentScenicId = 11
|
||||||
|
let api = MockScenicQueueAPI()
|
||||||
|
let viewModel = ScenicQueueSettingsViewModel(appStore: store, ttsManager: MockScenicQueueTTSManager())
|
||||||
|
await viewModel.setPunchSpot(ScenicQueuePunchSpotOption(id: "22", label: "东门打卡点"), api: api)
|
||||||
|
|
||||||
|
viewModel.reportOutOfRangeNumericPending(fieldKey: ScenicQueueNumericFieldKey.firstNoticeAhead.rawValue, pending: true)
|
||||||
|
await viewModel.save(api: api)
|
||||||
|
|
||||||
|
XCTAssertNil(api.savedSetting)
|
||||||
|
XCTAssertEqual(viewModel.settingsTab, .notify)
|
||||||
|
XCTAssertTrue(viewModel.highlightedInvalidFieldKeys.contains(ScenicQueueNumericFieldKey.firstNoticeAhead.rawValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPresetVoicePlayStopAndDeleteState() async {
|
||||||
|
let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.presetPlayback")!
|
||||||
|
defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.presetPlayback")
|
||||||
|
let store = AppStore(defaults: defaults)
|
||||||
|
store.session.userId = "u1"
|
||||||
|
store.session.accountType = .unknown("photog")
|
||||||
|
store.session.currentScenicId = 11
|
||||||
|
store.scenicQueue.punchSpotId = "22"
|
||||||
|
store.scenicQueue.punchSpotName = "东门打卡点"
|
||||||
|
store.scenicQueue.savePresetVoices(["请到拍摄区域"])
|
||||||
|
let tts = MockScenicQueueTTSManager()
|
||||||
|
let viewModel = ScenicQueueSettingsViewModel(appStore: store, ttsManager: tts)
|
||||||
|
|
||||||
|
viewModel.startPresetVoiceLoop("请到拍摄区域")
|
||||||
|
for _ in 0..<20 where viewModel.ttsLoopAnchorText == nil {
|
||||||
|
await Task.yield()
|
||||||
|
}
|
||||||
|
XCTAssertTrue(viewModel.isCustomTTSLooping)
|
||||||
|
XCTAssertEqual(viewModel.ttsLoopAnchorText, "请到拍摄区域")
|
||||||
|
XCTAssertTrue(tts.events.contains("custom:请到拍摄区域"))
|
||||||
|
|
||||||
|
viewModel.deletePresetVoice(at: 0)
|
||||||
|
XCTAssertTrue(viewModel.presetVoices.isEmpty)
|
||||||
|
XCTAssertFalse(viewModel.isCustomTTSLooping)
|
||||||
|
XCTAssertNil(viewModel.ttsLoopAnchorText)
|
||||||
|
XCTAssertEqual(tts.events.last, "stopCustom")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTicketActionStateMatchesAndroidCardMatrix() {
|
||||||
|
let waiting = makeTicket(status: .waiting, showCompleteAction: false)
|
||||||
|
let waitingState = ScenicQueueTicketActionState.make(ticket: waiting, shootingQueueNo: nil, remainSeconds: 0, showStartShootingButton: true)
|
||||||
|
XCTAssertTrue(waitingState.skipEnabled)
|
||||||
|
XCTAssertEqual(waitingState.primaryTitle, "叫号")
|
||||||
|
XCTAssertTrue(waitingState.primaryVisible)
|
||||||
|
XCTAssertFalse(waitingState.recallVisible)
|
||||||
|
XCTAssertFalse(waitingState.completeVisible)
|
||||||
|
|
||||||
|
let called = makeTicket(status: .called, showCompleteAction: true)
|
||||||
|
let hiddenStartState = ScenicQueueTicketActionState.make(ticket: called, shootingQueueNo: nil, remainSeconds: 0, showStartShootingButton: false)
|
||||||
|
XCTAssertFalse(hiddenStartState.primaryVisible)
|
||||||
|
XCTAssertTrue(hiddenStartState.recallVisible)
|
||||||
|
XCTAssertTrue(hiddenStartState.completeVisible)
|
||||||
|
|
||||||
|
let shootingState = ScenicQueueTicketActionState.make(ticket: called, shootingQueueNo: called.queueNo, remainSeconds: 17, showStartShootingButton: true)
|
||||||
|
XCTAssertFalse(shootingState.skipEnabled)
|
||||||
|
XCTAssertFalse(shootingState.primaryEnabled)
|
||||||
|
XCTAssertEqual(shootingState.primaryTitle, "剩余 17 秒")
|
||||||
|
XCTAssertFalse(shootingState.recallVisible)
|
||||||
|
XCTAssertTrue(shootingState.completeVisible)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testQueueVisualTokensAndChangeLogPresentationContract() {
|
||||||
|
XCTAssertEqual(ScenicQueueTokens.radiusCard, 12)
|
||||||
|
XCTAssertEqual(ScenicQueueTokens.radiusDialog, 16)
|
||||||
|
XCTAssertEqual(ScenicQueueTokens.radiusPrimaryRowButton, 10)
|
||||||
|
XCTAssertEqual(ScenicQueueTokens.ticketIdFont.pointSize, 20)
|
||||||
|
XCTAssertEqual(ScenicQueueTokens.dialogTicketFont.pointSize, 28)
|
||||||
|
XCTAssertEqual(ScenicQueueTokens.listHorizontalInset, 12)
|
||||||
|
XCTAssertTrue(ScenicQueueTokens.timelineDot.isEqualToRGB(0x1677FF))
|
||||||
|
XCTAssertTrue(ScenicQueueTokens.tagWaitingForeground.isEqualToRGB(0x10B981))
|
||||||
|
|
||||||
|
let distanceField = ScenicQueueNumericFieldView(key: "queueDistanceMeter", unit: "千米", keyboard: .decimalPad)
|
||||||
|
XCTAssertEqual(distanceField.subviews.compactMap { ($0 as? UILabel)?.text }, ["千米"])
|
||||||
|
|
||||||
|
let item = ScenicQueueSettingChangeLogItem(
|
||||||
|
id: 1,
|
||||||
|
scenicId: 11,
|
||||||
|
scenicSpotId: 22,
|
||||||
|
scenicSpotName: "东门打卡点",
|
||||||
|
operatorId: nil,
|
||||||
|
operatorName: " ",
|
||||||
|
operatorPhoneTail: "8000",
|
||||||
|
summary: " ",
|
||||||
|
displayText: "拍摄时长由 5 分钟改为 8 分钟",
|
||||||
|
createdAt: "2026-07-07T10:20:30+08:00"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(item.metadataLine, "2026-07-07 10:20 未知 尾号8000")
|
||||||
|
XCTAssertEqual(item.cardSummary, "拍摄时长由 5 分钟改为 8 分钟")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPendingDialogDismissMethodsClearEveryOperationState() async {
|
||||||
|
let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.pendingDialogs")!
|
||||||
|
defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.pendingDialogs")
|
||||||
|
let store = AppStore(defaults: defaults)
|
||||||
|
store.session.userId = "9"
|
||||||
|
store.session.accountType = .unknown("photog")
|
||||||
|
store.session.currentScenicId = 11
|
||||||
|
store.scenicQueue.punchSpotId = "22"
|
||||||
|
store.scenicQueue.punchSpotName = "东门打卡点"
|
||||||
|
let api = MockScenicQueueAPI()
|
||||||
|
api.homePages[1] = .page(items: [.item(id: 1, queueCode: "A01", isCalled: 0)], total: 1, page: 1)
|
||||||
|
let viewModel = ScenicQueueViewModel(appStore: store, ttsManager: MockScenicQueueTTSManager())
|
||||||
|
await viewModel.refreshQueueGate(api: api)
|
||||||
|
|
||||||
|
viewModel.requestCallConfirm(recordId: 1, queueNo: "A01")
|
||||||
|
viewModel.requestSkipTicket(recordId: 1, queueNo: "A01")
|
||||||
|
viewModel.requestFinishConfirm(recordId: 1, queueNo: "A01")
|
||||||
|
viewModel.requestRequeueConfirm(recordId: 1, queueNo: "A01")
|
||||||
|
viewModel.onTicketMarkClick(recordId: 1, queueNo: "A01", uid: 1)
|
||||||
|
XCTAssertNotNil(viewModel.pendingCall)
|
||||||
|
XCTAssertNotNil(viewModel.pendingSkip)
|
||||||
|
XCTAssertNotNil(viewModel.pendingFinish)
|
||||||
|
XCTAssertNotNil(viewModel.pendingRequeue)
|
||||||
|
XCTAssertNotNil(viewModel.pendingMark)
|
||||||
|
|
||||||
|
viewModel.dismissCallConfirmDialog()
|
||||||
|
viewModel.dismissSkipDialog()
|
||||||
|
viewModel.dismissFinishDialog()
|
||||||
|
viewModel.dismissRequeueDialog()
|
||||||
|
viewModel.dismissMarkDialog()
|
||||||
|
XCTAssertNil(viewModel.pendingCall)
|
||||||
|
XCTAssertNil(viewModel.pendingSkip)
|
||||||
|
XCTAssertNil(viewModel.pendingFinish)
|
||||||
|
XCTAssertNil(viewModel.pendingRequeue)
|
||||||
|
XCTAssertNil(viewModel.pendingMark)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeTicket(status: ScenicQueueCallStatus, showCompleteAction: Bool) -> ScenicQueueTicket {
|
||||||
|
ScenicQueueTicket(
|
||||||
|
recordId: 1,
|
||||||
|
queueNo: "A01",
|
||||||
|
phoneMasked: "138****8000",
|
||||||
|
dialPhoneDigits: "13800138000",
|
||||||
|
status: status,
|
||||||
|
statusText: status == .waiting ? "待叫号" : "已叫号",
|
||||||
|
waitMinutes: 5,
|
||||||
|
peopleAhead: 2,
|
||||||
|
showCompleteAction: showCompleteAction,
|
||||||
|
queueBanLabel: "",
|
||||||
|
identityTag: "",
|
||||||
|
queueTimeDisplay: "07-07 10:20",
|
||||||
|
queueCountToday: 1,
|
||||||
|
uid: 8,
|
||||||
|
markAsPhotog: 0,
|
||||||
|
markAsFreelancePhotog: 0,
|
||||||
|
isMissRequeue: false,
|
||||||
|
missRequeueText: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension UIColor {
|
||||||
|
func isEqualToRGB(_ rgb: UInt32) -> Bool {
|
||||||
|
var red: CGFloat = 0
|
||||||
|
var green: CGFloat = 0
|
||||||
|
var blue: CGFloat = 0
|
||||||
|
var alpha: CGFloat = 0
|
||||||
|
guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return false }
|
||||||
|
let expectedRed = CGFloat((rgb >> 16) & 0xFF) / 255
|
||||||
|
let expectedGreen = CGFloat((rgb >> 8) & 0xFF) / 255
|
||||||
|
let expectedBlue = CGFloat(rgb & 0xFF) / 255
|
||||||
|
return abs(red - expectedRed) < 0.001 && abs(green - expectedGreen) < 0.001 && abs(blue - expectedBlue) < 0.001 && abs(alpha - 1) < 0.001
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@ -68,4 +68,19 @@ final class UIButtonConfigurationTests: XCTestCase {
|
|||||||
XCTAssertEqual(button.currentTitle, "更新标题")
|
XCTAssertEqual(button.currentTitle, "更新标题")
|
||||||
XCTAssertEqual(button.currentTitleColor, .red)
|
XCTAssertEqual(button.currentTitleColor, .red)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testImagePlacementPreservesExistingConfiguration() {
|
||||||
|
var configuration = UIButton.Configuration.plain()
|
||||||
|
configuration.title = "电话"
|
||||||
|
configuration.contentInsets = NSDirectionalEdgeInsets(top: 1, leading: 2, bottom: 3, trailing: 4)
|
||||||
|
let button = UIButton(configuration: configuration)
|
||||||
|
|
||||||
|
button.setConfigurationImagePlacement(.trailing, padding: 6)
|
||||||
|
|
||||||
|
XCTAssertEqual(button.configuration?.title, "电话")
|
||||||
|
XCTAssertEqual(button.configuration?.contentInsets.leading, 2)
|
||||||
|
XCTAssertEqual(button.configuration?.contentInsets.trailing, 4)
|
||||||
|
XCTAssertEqual(button.configuration?.imagePlacement, .trailing)
|
||||||
|
XCTAssertEqual(button.configuration?.imagePadding, 6)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -800,6 +800,21 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
XCTAssertEqual(viewModel.reportLocation, "瘦西湖路")
|
XCTAssertEqual(viewModel.reportLocation, "瘦西湖路")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testSubmitTextInputUpdatesWithoutRequestingStructuralRefresh() {
|
||||||
|
let viewModel = WildPhotographerReportSubmitViewModel(
|
||||||
|
homeViewModel: WildPhotographerReportHomeViewModel()
|
||||||
|
)
|
||||||
|
var stateChangeCount = 0
|
||||||
|
viewModel.onStateChange = { stateChangeCount += 1 }
|
||||||
|
|
||||||
|
viewModel.updateDescription("现场说明")
|
||||||
|
viewModel.updateContact("wx-test")
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.description, "现场说明")
|
||||||
|
XCTAssertEqual(viewModel.contact, "wx-test")
|
||||||
|
XCTAssertEqual(stateChangeCount, 0)
|
||||||
|
}
|
||||||
|
|
||||||
func testSubmitFailsWithoutEvidence() async {
|
func testSubmitFailsWithoutEvidence() async {
|
||||||
let homeViewModel = WildPhotographerReportHomeViewModel()
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
||||||
|
|||||||
Reference in New Issue
Block a user