diff --git a/suixinkan.xcodeproj/xcshareddata/xcschemes/suixinkan.xcscheme b/suixinkan.xcodeproj/xcshareddata/xcschemes/suixinkan.xcscheme index 087fe46..96c6a4b 100644 --- a/suixinkan.xcodeproj/xcshareddata/xcschemes/suixinkan.xcscheme +++ b/suixinkan.xcodeproj/xcshareddata/xcschemes/suixinkan.xcscheme @@ -29,6 +29,15 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" shouldAutocreateTestPlan = "YES"> + + + + + + diff --git a/suixinkan/Assets.xcassets/permission_open_scenic.imageset/Contents.json b/suixinkan/Assets.xcassets/permission_open_scenic.imageset/Contents.json new file mode 100644 index 0000000..ac149c2 --- /dev/null +++ b/suixinkan/Assets.xcassets/permission_open_scenic.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "permission_open_scenic.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/suixinkan/Assets.xcassets/permission_open_scenic.imageset/permission_open_scenic.svg b/suixinkan/Assets.xcassets/permission_open_scenic.imageset/permission_open_scenic.svg new file mode 100644 index 0000000..50b1d65 --- /dev/null +++ b/suixinkan/Assets.xcassets/permission_open_scenic.imageset/permission_open_scenic.svg @@ -0,0 +1,3 @@ + + + diff --git a/suixinkan/Core/WeChat/WeChatShareModels.swift b/suixinkan/Core/WeChat/WeChatShareModels.swift index 0daa0a3..f7c597d 100644 --- a/suixinkan/Core/WeChat/WeChatShareModels.swift +++ b/suixinkan/Core/WeChat/WeChatShareModels.swift @@ -67,7 +67,7 @@ enum WeChatMiniProgramType: Int, Equatable, Sendable { /// 当前编译配置下实际传给微信 SDK 的小程序版本。 var resolvedForCurrentBuild: WeChatMiniProgramType { #if DEBUG - return .test + return .preview #else return self #endif diff --git a/suixinkan/Features/Home/API/HomeAPI.swift b/suixinkan/Features/Home/API/HomeAPI.swift index b12cdc0..5252ebb 100644 --- a/suixinkan/Features/Home/API/HomeAPI.swift +++ b/suixinkan/Features/Home/API/HomeAPI.swift @@ -109,8 +109,8 @@ final class HomeAPI { } /// 提交角色权限申请。 - func submitRoleApply(scenicIds: [Int], roleId: Int) async throws -> RoleApplySubmitResult { - try await client.send( + func submitRoleApply(scenicIds: [Int], roleId: Int) async throws { + let _: EmptyPayload = try await client.send( APIRequest( method: .post, path: "/api/yf-handset-app/photog/role-apply/submit", diff --git a/suixinkan/Features/Home/Models/PermissionApplyModels.swift b/suixinkan/Features/Home/Models/PermissionApplyModels.swift index 77cf31d..a5192fb 100644 --- a/suixinkan/Features/Home/Models/PermissionApplyModels.swift +++ b/suixinkan/Features/Home/Models/PermissionApplyModels.swift @@ -5,6 +5,35 @@ import Foundation +/// 首页进入权限申请流程时的目标页面。 +enum PermissionApplyDestination: Equatable { + case apply + case status(applyCode: String) +} + +/// 权限申请页待确认删除的对象。 +enum PermissionDeletionTarget: Equatable { + case role(name: String) + case scenic(id: Int, name: String) + + /// Android 删除弹窗标题。 + var dialogTitle: String { + switch self { + case .role: "是否删除该角色" + case .scenic: "是否删除该景区" + } + } + + /// Android 删除弹窗正文。 + var dialogMessage: String { + switch self { + case .role(let name): "您确定要删除\(name)角色吗?" + case .scenic(_, let name): "您确定要删除\(name)吗?" + } + } +} + +/// 待审核的角色权限申请响应。 struct RoleApplyPendingResponse: Decodable, Equatable { let code: String let roleId: Int @@ -45,11 +74,13 @@ struct RoleApplyPendingResponse: Decodable, Equatable { } } +/// 权限申请中的景区摘要。 struct RoleApplyScenicItem: Decodable, Equatable { let id: Int let name: String } +/// 全部可申请景区响应。 struct ScenicListAllResponse: Decodable, Equatable { let total: Int let list: [ScenicListItem] @@ -60,11 +91,13 @@ struct ScenicListAllResponse: Decodable, Equatable { } } +/// 可申请景区条目。 struct ScenicListItem: Decodable, Equatable { let id: Int let name: String } +/// 权限申请提交参数。 struct RoleApplySubmitRequest: Encodable { let scenicId: [Int] let roleId: Int @@ -75,6 +108,7 @@ struct RoleApplySubmitRequest: Encodable { } } +/// 权限申请角色选项。 struct RoleOption: Equatable, Hashable { let id: Int let name: String @@ -82,26 +116,10 @@ struct RoleOption: Equatable, Hashable { var isSelected: Bool } +/// 权限申请景区选项。 struct ScenicOption: Equatable, Hashable { let id: Int let name: String var isSelected: Bool var isDisabled: Bool } - -struct RoleApplySubmitResult: Decodable, Equatable { - let code: String - - init(code: String = "") { - self.code = code - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - code = try container.decodeIfPresent(String.self, forKey: .code) ?? "" - } - - private enum CodingKeys: String, CodingKey { - case code - } -} diff --git a/suixinkan/Features/Home/Services/HomeMenuCatalog.swift b/suixinkan/Features/Home/Services/HomeMenuCatalog.swift index d7ceb57..7cc8b7f 100644 --- a/suixinkan/Features/Home/Services/HomeMenuCatalog.swift +++ b/suixinkan/Features/Home/Services/HomeMenuCatalog.swift @@ -23,16 +23,16 @@ enum HomeMenuCatalog { HomeMenuItem(uri: "sample_management", title: "样片管理", iconName: "sample_ic_home_menu_route"), HomeMenuItem(uri: "live_stream_management", title: "直播管理", iconName: "dot.radiowaves.left.and.right"), HomeMenuItem(uri: "verification_order", title: "核销订单", iconName: "checkmark.seal.fill"), - HomeMenuItem(uri: "travel_album", title: "新增相册", iconName: "photo.badge.plus"), + HomeMenuItem(uri: "travel_album", title: "新增相册", iconName: "rectangle.stack.badge.plus"), HomeMenuItem(uri: "live_album", title: "直播相册", iconName: "play.rectangle.on.rectangle.fill"), HomeMenuItem(uri: "pm", title: "项目管理", iconName: "circle.grid.2x2.fill"), HomeMenuItem(uri: "pm_manager", title: "项目管理", iconName: "circle.grid.2x2.fill"), HomeMenuItem(uri: "location_report", title: "位置上报", iconName: "mappin.circle.fill"), - HomeMenuItem(uri: "report_photographer", title: "举报摄影师", iconName: "shield.fill"), + HomeMenuItem(uri: "report_photographer", title: "举报摄影师", iconName: "shield.lefthalf.filled"), HomeMenuItem(uri: "registration_invitation", title: "注册邀请", iconName: "envelope.open.fill"), HomeMenuItem(uri: "photographer_invite", title: "邀请摄影师", iconName: "envelope.open.fill"), HomeMenuItem(uri: "invite_record", title: "邀请记录", iconName: "list.bullet.rectangle.fill"), - HomeMenuItem(uri: "store", title: "店铺管理", iconName: "storefront.fill"), + HomeMenuItem(uri: "store", title: "店铺管理", iconName: "building.2.fill"), HomeMenuItem(uri: "fly", title: "飞行管理", iconName: "paperplane.fill"), HomeMenuItem(uri: "pilot_cert", title: "飞手认证", iconName: "paperplane.fill"), HomeMenuItem(uri: "pilot_controller", title: "飞控", iconName: "paperplane.fill"), diff --git a/suixinkan/Features/Home/ViewModels/HomeViewModel.swift b/suixinkan/Features/Home/ViewModels/HomeViewModel.swift index 879258b..3c4c529 100644 --- a/suixinkan/Features/Home/ViewModels/HomeViewModel.swift +++ b/suixinkan/Features/Home/ViewModels/HomeViewModel.swift @@ -78,6 +78,21 @@ final class HomeViewModel { needsPermissionReload = true } + /// 查询待审核记录并决定进入申请页或审核状态页。 + func resolvePermissionApplyDestination(api: HomeAPI) async -> PermissionApplyDestination { + do { + let pendingList = try await api.roleApplyPending() + guard let first = pendingList.first, + first.status == PermissionApplyPendingStatus.reviewing + || first.status == PermissionApplyPendingStatus.rejected else { + return .apply + } + return .status(applyCode: first.code) + } catch { + return .apply + } + } + /// 按需重新拉取权限。 func reloadIfNeeded(api: HomeAPI) async { locationStateStore.restoreStateIfNeeded() diff --git a/suixinkan/Features/Home/ViewModels/PermissionApplyStatusViewModel.swift b/suixinkan/Features/Home/ViewModels/PermissionApplyStatusViewModel.swift index db69677..64d6e64 100644 --- a/suixinkan/Features/Home/ViewModels/PermissionApplyStatusViewModel.swift +++ b/suixinkan/Features/Home/ViewModels/PermissionApplyStatusViewModel.swift @@ -11,6 +11,9 @@ final class PermissionApplyStatusViewModel { private(set) var pendingData: RoleApplyPendingResponse? private(set) var isLoading = false private(set) var rolePermissionList: [RolePermissionResponse] = [] + private(set) var showScenicListDialog = false + private(set) var dialogRoleName = "" + private(set) var dialogScenicList: [ScenicInfo] = [] var onStateChange: (() -> Void)? var onNavigateToEdit: ((RoleApplyPendingResponse) -> Void)? @@ -29,7 +32,11 @@ final class PermissionApplyStatusViewModel { notifyStateChange() do { let pendingList = try await api.roleApplyPending() - pendingData = pendingList.first { $0.code == applyCode } + pendingData = pendingList.first { + $0.code == applyCode + && ($0.status == PermissionApplyPendingStatus.reviewing + || $0.status == PermissionApplyPendingStatus.rejected) + } } catch { pendingData = nil } @@ -42,6 +49,22 @@ final class PermissionApplyStatusViewModel { onNavigateToEdit?(pendingData) } + /// 展示某个已有角色的全部景区。 + func showScenicList(roleName: String, scenics: [ScenicInfo]) { + dialogRoleName = roleName + dialogScenicList = scenics + showScenicListDialog = true + notifyStateChange() + } + + /// 关闭已有景区列表弹窗。 + func hideScenicList() { + dialogRoleName = "" + dialogScenicList = [] + showScenicListDialog = false + notifyStateChange() + } + private func notifyStateChange() { onStateChange?() } diff --git a/suixinkan/Features/Home/ViewModels/PermissionApplyViewModel.swift b/suixinkan/Features/Home/ViewModels/PermissionApplyViewModel.swift index 6849be7..1452d5e 100644 --- a/suixinkan/Features/Home/ViewModels/PermissionApplyViewModel.swift +++ b/suixinkan/Features/Home/ViewModels/PermissionApplyViewModel.swift @@ -18,10 +18,16 @@ final class PermissionApplyViewModel { private(set) var isSubmitting = false private(set) var showRolePicker = false private(set) var showScenicPicker = false + private(set) var rolePermissionList: [RolePermissionResponse] = [] + private(set) var scenicSearchQuery = "" + private(set) var pendingDeletion: PermissionDeletionTarget? + private(set) var showScenicListDialog = false + private(set) var dialogRoleName = "" + private(set) var dialogScenicList: [ScenicInfo] = [] var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? - var onSubmitSuccess: ((String) -> Void)? + var onSubmitSuccess: (() -> Void)? private let appStore: AppStore @@ -30,6 +36,18 @@ final class PermissionApplyViewModel { loadRolesFromLocal() } + /// 当前表单是否满足 Android 的提交条件。 + var canSubmit: Bool { + selectedRoleId != nil && !selectedScenicIds.isEmpty && !isSubmitting + } + + /// 按当前搜索词过滤景区,保持接口原始顺序。 + var filteredScenics: [ScenicOption] { + let query = scenicSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return availableScenics } + return availableScenics.filter { $0.name.localizedCaseInsensitiveContains(query) } + } + /// 从待审核数据预填表单(status=3 去编辑)。 func initWithPendingData(_ pending: RoleApplyPendingResponse) { selectedRoleId = pending.roleId @@ -45,7 +63,7 @@ final class PermissionApplyViewModel { } func loadRolesFromLocal() { - let rolePermissionList = appStore.permissions.rolePermissionList() + rolePermissionList = appStore.permissions.rolePermissionList() var seen = Set() availableRoles = rolePermissionList.compactMap { item in guard !seen.contains(item.role.id) else { return nil } @@ -102,6 +120,7 @@ final class PermissionApplyViewModel { func loadAvailableScenics(api: HomeAPI?) async { guard let roleId = selectedRoleId else { return } guard let api else { return } + guard !isLoadingScenics else { return } isLoadingScenics = true notifyStateChange() do { @@ -143,7 +162,37 @@ final class PermissionApplyViewModel { notifyStateChange() } + /// 更新景区下拉菜单的搜索词。 + func updateScenicSearchQuery(_ query: String) { + scenicSearchQuery = query + notifyStateChange() + } + func removeSelectedScenic(id: Int, name: String) { + pendingDeletion = .scenic(id: id, name: name) + notifyStateChange() + } + + /// 确认删除当前待删除的角色或景区。 + func confirmDeletion() { + guard let pendingDeletion else { return } + switch pendingDeletion { + case .role: + clearRoleSelectionImmediately() + case .scenic(let id, let name): + removeSelectedScenicImmediately(id: id, name: name) + } + self.pendingDeletion = nil + notifyStateChange() + } + + /// 取消删除确认。 + func cancelDeletion() { + pendingDeletion = nil + notifyStateChange() + } + + private func removeSelectedScenicImmediately(id: Int, name: String) { selectedScenicIds.removeAll { $0 == id } selectedScenicNames.removeAll { $0 == name } availableScenics = availableScenics.map { item in @@ -153,7 +202,6 @@ final class PermissionApplyViewModel { } return copy } - notifyStateChange() } /// 已有角色下已开通的景区列表。 @@ -164,6 +212,12 @@ final class PermissionApplyViewModel { } func clearRoleSelection() { + guard !selectedRoleName.isEmpty else { return } + pendingDeletion = .role(name: selectedRoleName) + notifyStateChange() + } + + private func clearRoleSelectionImmediately() { selectedRoleId = nil selectedRoleName = "" selectedScenicIds = [] @@ -174,33 +228,42 @@ final class PermissionApplyViewModel { copy.isSelected = false return copy } + } + + /// 展示已有角色下的全部景区。 + func showScenicList(roleName: String, scenics: [ScenicInfo]) { + dialogRoleName = roleName + dialogScenicList = scenics + showScenicListDialog = true + notifyStateChange() + } + + /// 关闭已有景区列表弹窗。 + func hideScenicList() { + dialogRoleName = "" + dialogScenicList = [] + showScenicListDialog = false notifyStateChange() } func submit(api: HomeAPI) async { - guard let roleId = selectedRoleId else { - onShowMessage?("请选择角色") - return - } - let newScenicIds = selectedScenicIds.filter { id in - !existingScenicIds(for: roleId).contains(id) - } - guard !newScenicIds.isEmpty else { - onShowMessage?("请至少选择一个景区") + guard canSubmit, let roleId = selectedRoleId else { + onShowMessage?("请选择景区和角色") return } isSubmitting = true notifyStateChange() do { - let result = try await api.submitRoleApply(scenicIds: newScenicIds, roleId: roleId) - onShowMessage?("提交成功") - onSubmitSuccess?(result.code) + try await api.submitRoleApply(scenicIds: selectedScenicIds, roleId: roleId) + isSubmitting = false + notifyStateChange() + onSubmitSuccess?() } catch { onShowMessage?("提交失败,请稍后重试") + isSubmitting = false + notifyStateChange() } - isSubmitting = false - notifyStateChange() } private func existingScenicIds(for roleId: Int) -> Set { diff --git a/suixinkan/Features/ScenicQueue/Services/ScenicQueueTTSManager.swift b/suixinkan/Features/ScenicQueue/Services/ScenicQueueTTSManager.swift index a9bbd9a..7b1a1de 100644 --- a/suixinkan/Features/ScenicQueue/Services/ScenicQueueTTSManager.swift +++ b/suixinkan/Features/ScenicQueue/Services/ScenicQueueTTSManager.swift @@ -50,6 +50,18 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth case custom(String) } + /// 当前已初始化的阿里云原生 TTS 引擎类型。 + private enum NativeEngineKind { + case online + case offline + } + + /// 离线 TTS 工作目录及默认发音人资源。 + private struct OfflineResources { + let workspace: String + let voicePath: String + } + private let synthesizer = AVSpeechSynthesizer() private let loopQueue = DispatchQueue(label: "com.zhifly.suixinkan.scenicQueue.tts") private let nativeQueue = DispatchQueue(label: "com.zhifly.suixinkan.scenicQueue.aliyunNLS") @@ -57,26 +69,35 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth private let tokenProvider: AliyunNLSTokenProviding private let bridge: AliyunNuiTTSBridging private let streamingPlayer: AliyunPCMStreamingPlaying + private let workspacePathProvider: () -> String? private var activeLoop: LoopKind? private var currentContinuation: CheckedContinuation? private var nativeContinuation: CheckedContinuation? private var nlsToken: AliyunNLSToken? private var lastCredentials: AliyunOSSCredentials? private var nativeReady = false + private var nativeEngineKind: NativeEngineKind? + private var nativeSampleRate: Double private(set) var customTextLooping = false - let supportsOfflineTTS = false + + var supportsOfflineTTS: Bool { + bridge.sdkAvailable && offlineResources() != nil + } init( appStore: AppStore = .shared, tokenProvider: AliyunNLSTokenProviding = AliyunNLSTokenProvider(), bridge: AliyunNuiTTSBridging = AliyunNuiTTSBridgeAdapter(), - streamingPlayer: AliyunPCMStreamingPlaying = AliyunPCMStreamingPlayer() + streamingPlayer: AliyunPCMStreamingPlaying = AliyunPCMStreamingPlayer(), + workspacePathProvider: (() -> String?)? = nil ) { self.appStore = appStore self.tokenProvider = tokenProvider self.bridge = bridge self.streamingPlayer = streamingPlayer + self.workspacePathProvider = workspacePathProvider ?? Self.bundledWorkspacePath + nativeSampleRate = AliyunNLSTTSManager.onlineSampleRate super.init() synthesizer.delegate = self self.bridge.delegate = self @@ -93,7 +114,7 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth lastCredentials = sts.credentials let token = try await tokenProvider.createToken(credentials: sts.credentials) nlsToken = token - nativeReady = await prepareNativeEngineIfAvailable(token: token) + nativeReady = await prepareNativeEngineIfAvailable(token: token, credentials: sts.credentials) } catch { nativeReady = false } @@ -221,7 +242,7 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth self.cancelNativeSpeakLocked(resumeExisting: true) self.nativeContinuation = continuation self.streamingPlayer.stop() - self.streamingPlayer.start(sampleRate: Self.nuiSampleRate) + self.streamingPlayer.start(sampleRate: self.nativeSampleRate) self.applyNativePlaybackParams(text: text) let code = self.bridge.play(priority: "1", taskId: Self.nuiTaskId, text: text) if code != 0 { @@ -276,11 +297,20 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth resumeCurrentContinuationIfNeeded() } - private func prepareNativeEngineIfAvailable(token: AliyunNLSToken) async -> Bool { - guard bridge.sdkAvailable, - let ticket = nativeOnlineTicket(token: token.id) else { + private func prepareNativeEngineIfAvailable( + token: AliyunNLSToken, + credentials: AliyunOSSCredentials + ) async -> Bool { + guard bridge.sdkAvailable else { return false } + let wantsOffline = appStore.scenicQueue.useOfflineTTS + let offlineResources = wantsOffline ? offlineResources() : nil + guard !wantsOffline || offlineResources != nil else { return false } + guard let ticket = wantsOffline + ? nativeOfflineTicket(token: token.id, credentials: credentials, resources: offlineResources!) + : nativeOnlineTicket(token: token.id) else { return false } + return await withCheckedContinuation { continuation in nativeQueue.async { [weak self] in guard let self else { @@ -289,19 +319,34 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth } self.cancelNativeSpeakLocked(resumeExisting: true) _ = self.bridge.releaseEngine() + self.nativeEngineKind = nil let code = self.bridge.initialize(ticket: ticket, logLevel: 0, saveLog: true) guard code == 0 else { continuation.resume(returning: false) return } - self.applyNativeQueueProsodyParams() + let configured: Bool + if let offlineResources { + configured = self.configureOfflineEngine(resources: offlineResources) + } else { + self.nativeEngineKind = .online + self.nativeSampleRate = Self.onlineSampleRate + self.applyNativeOnlineQueueProsodyParams() + configured = true + } + guard configured else { + _ = self.bridge.releaseEngine() + self.nativeEngineKind = nil + continuation.resume(returning: false) + return + } continuation.resume(returning: true) } } } private func nativeOnlineTicket(token: String) -> String? { - guard let workspace = nativeWorkspacePath() else { return nil } + guard let workspace = workspacePathProvider() else { return nil } let ticket: [String: String] = [ "app_key": Self.onlineAppKey, "token": token, @@ -316,7 +361,30 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth return String(data: data, encoding: .utf8) } - private func nativeWorkspacePath() -> String? { + private func nativeOfflineTicket( + token: String, + credentials: AliyunOSSCredentials, + resources: OfflineResources + ) -> String? { + let ticket: [String: String] = [ + "app_key": Self.offlineAppKey, + "ak_id": credentials.accessKeyId, + "ak_secret": credentials.accessKeySecret, + "sts_token": credentials.securityToken, + "token": token, + "sdk_code": Self.offlineSDKCode, + "save_wav": "false", + "debug_path": nativeDebugPath(), + "workspace": resources.workspace, + "log_track_level": "5", + "mode_type": "0", + "device_id": Self.deviceId(), + ] + guard let data = try? JSONSerialization.data(withJSONObject: ticket) else { return nil } + return String(data: data, encoding: .utf8) + } + + private static func bundledWorkspacePath() -> String? { if let privateFrameworksPath = Bundle.main.privateFrameworksPath { let frameworkPath = (privateFrameworksPath as NSString).appendingPathComponent("nuisdk.framework") if let frameworkBundle = Bundle(path: frameworkPath), @@ -327,6 +395,19 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth return Bundle.main.path(forResource: "Resources", ofType: "bundle") } + private func offlineResources() -> OfflineResources? { + guard let workspace = workspacePathProvider() else { return nil } + let ttsPath = (workspace as NSString).appendingPathComponent("tts") + let languagePath = (ttsPath as NSString).appendingPathComponent("languagedata_embedded.bin") + let parameterPath = (ttsPath as NSString).appendingPathComponent("parameter.cfg") + let voicePath = (ttsPath as NSString).appendingPathComponent("voices/\(Self.offlineVoiceName)") + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: languagePath), + fileManager.fileExists(atPath: parameterPath), + fileManager.fileExists(atPath: voicePath) else { return nil } + return OfflineResources(workspace: workspace, voicePath: voicePath) + } + private func nativeDebugPath() -> String { let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first let url = (caches ?? URL(fileURLWithPath: NSTemporaryDirectory())).appendingPathComponent("aliyun_nls_tts_debug", isDirectory: true) @@ -335,16 +416,39 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth } private func applyNativePlaybackParams(text: String) { - _ = bridge.setParam("mode_type", value: "2") - _ = bridge.setParam("tts_version", value: text.count > 300 ? "1" : "0") - applyNativeQueueProsodyParams() + switch nativeEngineKind { + case .online: + _ = bridge.setParam("mode_type", value: "2") + _ = bridge.setParam("tts_version", value: text.count > 300 ? "1" : "0") + applyNativeOnlineQueueProsodyParams() + case .offline: + applyNativeOfflineQueueProsodyParams() + case nil: + break + } } - private func applyNativeQueueProsodyParams() { + private func applyNativeOnlineQueueProsodyParams() { _ = bridge.setParam("speech_rate", value: Self.onlineSpeechRate) _ = bridge.setParam("volume", value: Self.playbackVolume) } + private func configureOfflineEngine(resources: OfflineResources) -> Bool { + guard bridge.setParam("extend_font_name", value: resources.voicePath) == 0 else { return false } + guard bridge.setParam("encode_type", value: "pcm") == 0 else { return false } + guard bridge.setParam("sample_rate", value: Self.offlineSampleRateText) == 0 else { return false } + nativeEngineKind = .offline + nativeSampleRate = Double(bridge.paramValue(for: "model_sample_rate") ?? "") + .flatMap { $0 > 0 ? $0 : nil } ?? Self.offlineSampleRate + applyNativeOfflineQueueProsodyParams() + return true + } + + private func applyNativeOfflineQueueProsodyParams() { + _ = bridge.setParam("speed_level", value: Self.offlineSpeedLevel) + _ = bridge.setParam("volume", value: Self.playbackVolume) + } + private func cancelNativeSpeakLocked(resumeExisting: Bool) { _ = bridge.cancel(taskId: Self.nuiTaskId) streamingPlayer.stop() @@ -372,9 +476,15 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth } private static let onlineAppKey = "3WX8cD04JCGkbRLi" + private static let offlineAppKey = "gfWimmOuTku1AbIV" + private static let offlineSDKCode = "software_nls_tts_offline_standard" + private static let offlineVoiceName = "aijia" private static let nuiTaskId = "1" - private static let nuiSampleRate = 16_000.0 + private static let onlineSampleRate = 16_000.0 + private static let offlineSampleRate = 24_000.0 + private static let offlineSampleRateText = "24000" private static let onlineSpeechRate = "-120" + private static let offlineSpeedLevel = "1.0" private static let playbackVolume = "2.0" } diff --git a/suixinkan/UI/Common/DateRangePickerViewController.swift b/suixinkan/UI/Common/DateRangePickerViewController.swift index cfcf099..e624307 100644 --- a/suixinkan/UI/Common/DateRangePickerViewController.swift +++ b/suixinkan/UI/Common/DateRangePickerViewController.swift @@ -81,6 +81,7 @@ final class DateRangePickerViewController: UIViewController { StatisticsRangeShortcut.allCases.forEach { shortcut in let button = StatisticsPeriodButton() + button.cornerStyle = .fixed(10) button.periodTitle = shortcut.rawValue button.isSelected = shortcut.rawValue == selectedShortcut button.addTarget(self, action: #selector(shortcutTapped(_:)), for: .touchUpInside) @@ -130,6 +131,7 @@ final class DateRangePickerViewController: UIViewController { shortcutStack.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide).offset(12) make.leading.trailing.equalToSuperview().inset(16) + make.height.equalTo(36) } monthRow.snp.makeConstraints { make in make.top.equalTo(shortcutStack.snp.bottom).offset(12) diff --git a/suixinkan/UI/Common/UIButton+Configuration.swift b/suixinkan/UI/Common/UIButton+Configuration.swift new file mode 100644 index 0000000..847d96a --- /dev/null +++ b/suixinkan/UI/Common/UIButton+Configuration.swift @@ -0,0 +1,17 @@ +// +// UIButton+Configuration.swift +// suixinkan +// + +import UIKit + +/// `UIButton` 的现代 configuration 配置辅助能力。 +extension UIButton { + + /// 设置内容边距,并保留按钮已有的 configuration 内容。 + func setConfigurationContentInsets(_ contentInsets: NSDirectionalEdgeInsets) { + var updatedConfiguration = configuration ?? .plain() + updatedConfiguration.contentInsets = contentInsets + configuration = updatedConfiguration + } +} diff --git a/suixinkan/UI/CooperationOrder/Views/CooperationOrderViews.swift b/suixinkan/UI/CooperationOrder/Views/CooperationOrderViews.swift index 17ace25..459a2bb 100644 --- a/suixinkan/UI/CooperationOrder/Views/CooperationOrderViews.swift +++ b/suixinkan/UI/CooperationOrder/Views/CooperationOrderViews.swift @@ -503,7 +503,9 @@ final class CooperationAcquirerCell: UITableViewCell { editRemarkButton.setTitle("修改备注", for: .normal) editRemarkButton.titleLabel?.font = .systemFont(ofSize: 13) editRemarkButton.setTitleColor(AppColor.primary, for: .normal) - editRemarkButton.contentEdgeInsets = UIEdgeInsets(top: 16, left: 14, bottom: 16, right: 14) + editRemarkButton.setConfigurationContentInsets( + NSDirectionalEdgeInsets(top: 16, leading: 14, bottom: 16, trailing: 14) + ) editRemarkButton.addTarget(self, action: #selector(editRemarkTapped), for: .touchUpInside) callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal) @@ -516,7 +518,9 @@ final class CooperationAcquirerCell: UITableViewCell { editCommissionButton.setTitle("修改比例", for: .normal) editCommissionButton.titleLabel?.font = .systemFont(ofSize: 13) editCommissionButton.setTitleColor(AppColor.primary, for: .normal) - editCommissionButton.contentEdgeInsets = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8) + editCommissionButton.setConfigurationContentInsets( + NSDirectionalEdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8) + ) editCommissionButton.addTarget(self, action: #selector(editCommissionTapped), for: .touchUpInside) contentView.addSubview(cardView) diff --git a/suixinkan/UI/Home/AllFunctionsViewController.swift b/suixinkan/UI/Home/AllFunctionsViewController.swift index d61a87e..1473cc6 100644 --- a/suixinkan/UI/Home/AllFunctionsViewController.swift +++ b/suixinkan/UI/Home/AllFunctionsViewController.swift @@ -89,7 +89,8 @@ final class AllFunctionsViewController: BaseViewController { override func setupConstraints() { collectionView.snp.makeConstraints { make in - make.edges.equalTo(view.safeAreaLayoutGuide) + make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + make.bottom.equalToSuperview() } } diff --git a/suixinkan/UI/Home/HomeViewController.swift b/suixinkan/UI/Home/HomeViewController.swift index 309b86d..62a9e73 100644 --- a/suixinkan/UI/Home/HomeViewController.swift +++ b/suixinkan/UI/Home/HomeViewController.swift @@ -35,10 +35,10 @@ final class HomeViewController: BaseViewController { collectionViewLayout: HomeCollectionLayoutBuilder.makeLayout(sections: [.commonApps]) ) collectionView.backgroundColor = .clear - collectionView.alwaysBounceVertical = true - collectionView.alwaysBounceHorizontal = false - collectionView.showsHorizontalScrollIndicator = false - collectionView.isDirectionalLockEnabled = true +// collectionView.alwaysBounceVertical = true +// collectionView.alwaysBounceHorizontal = false +// collectionView.showsHorizontalScrollIndicator = false +// collectionView.contentInsetAdjustmentBehavior = .never // 水平边距由 section.contentInsets 控制,避免 contentInset 与布局宽度不一致导致横向滑动。 collectionView.contentInset = UIEdgeInsets( top: AppSpacing.sm, @@ -56,13 +56,13 @@ final class HomeViewController: BaseViewController { override func setupConstraints() { scenicHeaderView.snp.makeConstraints { make in - make.top.equalTo(view.safeAreaLayoutGuide) - make.leading.trailing.equalToSuperview() - make.height.equalTo(AppSpacing.homeHeaderHeight) + make.top.leading.trailing.equalToSuperview() + make.bottom.equalTo(view.safeAreaLayoutGuide.snp.top).offset(AppSpacing.homeHeaderHeight) } collectionView.snp.makeConstraints { make in make.top.equalTo(scenicHeaderView.snp.bottom) - make.leading.trailing.bottom.equalToSuperview() + make.leading.trailing.equalToSuperview() + make.bottom.equalTo(view.safeAreaLayoutGuide) } } @@ -73,7 +73,6 @@ final class HomeViewController: BaseViewController { viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } - scenicHeaderView.onTap = { [weak self] in self?.presentScenicSelection() } collectionView.delegate = self @@ -102,7 +101,14 @@ final class HomeViewController: BaseViewController { hasInitialized = true Task { await initializeHome() } } else { - Task { await viewModel.reloadIfNeeded(api: homeAPI) } + Task { + let shouldReevaluateDialogs = viewModel.needsPermissionReload + await viewModel.reloadIfNeeded(api: homeAPI) + if shouldReevaluateDialogs { + await viewModel.evaluateDialogs(api: homeAPI) + applyViewModel() + } + } } startCountdownTimer() } @@ -389,20 +395,34 @@ final class HomeViewController: BaseViewController { } private func presentPermissionDialog() { - let alert = UIAlertController( - title: "权限提示", - message: "您还没有权限,请先申请权限", - preferredStyle: .alert + let dialog = PermissionRequiredDialogViewController( + onExitApp: { + UIApplication.shared.perform(#selector(NSXPCConnection.suspend)) + }, + onGoToApply: { [weak self] in + guard let self else { return } + self.activeDialog = nil + Task { await self.openPermissionApplyFlow() } + } ) - alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in - self?.viewModel.hidePermissionDialog() - self?.activeDialog = nil - }) - alert.addAction(UIAlertAction(title: "去申请", style: .default) { [weak self] _ in - self?.showToast("功能开发中") - self?.activeDialog = nil - }) - present(alert, animated: true) + present(dialog, animated: true) + } + + private func openPermissionApplyFlow() async { + showLoading() + let destination = await viewModel.resolvePermissionApplyDestination(api: homeAPI) + hideLoading() + viewModel.markNeedsPermissionReload() + navigationController?.setNavigationBarHidden(false, animated: true) + switch destination { + case .apply: + navigationController?.pushViewController(PermissionApplyViewController(), animated: true) + case .status(let applyCode): + navigationController?.pushViewController( + PermissionApplyStatusViewController(applyCode: applyCode), + animated: true + ) + } } private func presentScenicDialog() { diff --git a/suixinkan/UI/Home/PermissionApplyStatusViewController.swift b/suixinkan/UI/Home/PermissionApplyStatusViewController.swift index 0936996..fe21b56 100644 --- a/suixinkan/UI/Home/PermissionApplyStatusViewController.swift +++ b/suixinkan/UI/Home/PermissionApplyStatusViewController.swift @@ -6,24 +6,26 @@ import SnapKit import UIKit -/// 权限申请审核状态页,对齐 Android `PermissionApplyStatusScreen`。 +/// 权限申请审核状态页,严格对齐 Android `PermissionApplyStatusScreen`。 +@MainActor final class PermissionApplyStatusViewController: BaseViewController { private let viewModel: PermissionApplyStatusViewModel private let homeAPI: HomeAPI - private let scrollView = UIScrollView() private let contentStack = UIStackView() private let bannerView = PermissionApplyBannerView() + private let existingRoleList = PermissionExistingRoleListView() private let loadingIndicator = UIActivityIndicatorView(style: .medium) + /// 使用申请编号创建审核状态页。 init( applyCode: String, viewModel: PermissionApplyStatusViewModel? = nil, - homeAPI: HomeAPI = NetworkServices.shared.homeAPI + homeAPI: HomeAPI? = nil ) { self.viewModel = viewModel ?? PermissionApplyStatusViewModel(applyCode: applyCode) - self.homeAPI = homeAPI + self.homeAPI = homeAPI ?? NetworkServices.shared.homeAPI super.init(nibName: nil, bundle: nil) } @@ -34,25 +36,23 @@ final class PermissionApplyStatusViewController: BaseViewController { override func setupNavigationBar() { title = "权限申请" + navigationItem.backButtonDisplayMode = .minimal } override func setupUI() { + view.backgroundColor = PermissionApplyUITokens.pageBackground contentStack.axis = .vertical - contentStack.spacing = 12 + contentStack.spacing = 0 scrollView.addSubview(contentStack) view.addSubview(scrollView) view.addSubview(loadingIndicator) - contentStack.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 16, right: 16)) - make.width.equalTo(scrollView).offset(-32) - } - scrollView.snp.makeConstraints { make in - make.edges.equalTo(view.safeAreaLayoutGuide) - } - loadingIndicator.snp.makeConstraints { make in - make.center.equalToSuperview() + make.edges.equalToSuperview() + make.width.equalTo(scrollView) } + scrollView.snp.makeConstraints { make in make.edges.equalTo(view.safeAreaLayoutGuide) } + loadingIndicator.color = PermissionApplyUITokens.selectedBlue + loadingIndicator.snp.makeConstraints { make in make.center.equalTo(view.safeAreaLayoutGuide) } } override func bindActions() { @@ -65,9 +65,13 @@ final class PermissionApplyStatusViewController: BaseViewController { bannerView.onApplyNewScenic = { [weak self] in self?.navigationController?.pushViewController(ScenicApplicationViewController(), animated: true) } + existingRoleList.onShowAllScenics = { [weak self] roleName, scenics in + self?.viewModel.showScenicList(roleName: roleName, scenics: scenics) + } Task { await viewModel.loadPendingData(api: homeAPI) } } + @MainActor private func applyViewModel() { if viewModel.isLoading { loadingIndicator.startAnimating() @@ -76,29 +80,44 @@ final class PermissionApplyStatusViewController: BaseViewController { } loadingIndicator.stopAnimating() scrollView.isHidden = false - contentStack.arrangedSubviews.forEach { $0.removeFromSuperview() } - contentStack.addArrangedSubview(bannerView) - navigationItem.rightBarButtonItem = nil - if let pending = viewModel.pendingData, pending.status == PermissionApplyPendingStatus.rejected { - navigationItem.rightBarButtonItem = UIBarButtonItem( - title: "去编辑", - style: .plain, - target: self, - action: #selector(editTapped) - ) - navigationItem.rightBarButtonItem?.tintColor = AppColor.primary - } + contentStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + contentStack.addArrangedSubview(bannerView) guard let pending = viewModel.pendingData else { return } - contentStack.addArrangedSubview(makeStatusCard(pending: pending)) - contentStack.addArrangedSubview(makeSectionCard(title: "新增角色", chips: [pending.roleName])) + if pending.status == PermissionApplyPendingStatus.rejected { + let editItem = UIBarButtonItem(title: "去编辑", style: .plain, target: self, action: #selector(editTapped)) + editItem.tintColor = AppColor.primary + editItem.setTitleTextAttributes([.font: UIFont.systemFont(ofSize: 14, weight: .medium)], for: .normal) + navigationItem.rightBarButtonItem = editItem + } + + contentStack.addArrangedSubview( + insetContainer(makeStatusCard(pending: pending), insets: UIEdgeInsets(top: 16, left: 16, bottom: 8, right: 16)) + ) + + let roleSection = PermissionStaticSectionView() + roleSection.apply(title: "新增角色", names: [pending.roleName]) + contentStack.addArrangedSubview( + insetContainer(roleSection, insets: UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16)) + ) + if !pending.scenicList.isEmpty { + let scenicSection = PermissionStaticSectionView() + scenicSection.apply(title: "新增景区", names: pending.scenicList.map(\.name)) contentStack.addArrangedSubview( - makeSectionCard(title: "新增景区", chips: pending.scenicList.map(\.name)) + insetContainer(scenicSection, insets: UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16)) ) } + + if !viewModel.rolePermissionList.isEmpty { + existingRoleList.apply(rolePermissions: viewModel.rolePermissionList) + contentStack.addArrangedSubview( + insetContainer(existingRoleList, insets: UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)) + ) + } + presentScenicListIfNeeded() } private func makeStatusCard(pending: RoleApplyPendingResponse) -> UIView { @@ -111,75 +130,66 @@ final class PermissionApplyStatusViewController: BaseViewController { let statusLabel = UILabel() statusLabel.text = pending.statusLabel statusLabel.font = .systemFont(ofSize: 18, weight: .medium) - statusLabel.textColor = AppColor.warning + statusLabel.textColor = UIColor(hex: 0xFF6B00) let stack = UIStackView() stack.axis = .vertical stack.spacing = 8 stack.addArrangedSubview(statusLabel) + stack.setCustomSpacing(12, after: statusLabel) [ ("提交时间:", pending.createdAt), ("审核时间:", pending.auditedAt ?? "--"), ("审核人:", pending.auditedBy ?? "--"), ("审核备注:", pending.auditNote.isEmpty ? "--" : pending.auditNote), - ].forEach { label, value in - stack.addArrangedSubview(makeInfoRow(label: label, value: value)) - } + ].forEach { stack.addArrangedSubview(makeInfoRow(label: $0.0, value: $0.1)) } card.addSubview(stack) - stack.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(16) - } - return card - } - - private func makeSectionCard(title: String, chips: [String]) -> UIView { - let card = UIView() - card.backgroundColor = .white - card.layer.cornerRadius = 12 - - let titleLabel = UILabel() - titleLabel.text = title - titleLabel.font = .systemFont(ofSize: 16, weight: .medium) - titleLabel.textColor = AppColor.textPrimary - - let chipStack = UIStackView() - chipStack.axis = .horizontal - chipStack.spacing = 8 - chips.forEach { name in - chipStack.addArrangedSubview(PermissionTagChip(title: name, showsRemove: false)) - } - - card.addSubview(titleLabel) - card.addSubview(chipStack) - titleLabel.snp.makeConstraints { make in - make.top.leading.trailing.equalToSuperview().inset(16) - } - chipStack.snp.makeConstraints { make in - make.top.equalTo(titleLabel.snp.bottom).offset(12) - make.leading.trailing.bottom.equalToSuperview().inset(16) - } + stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) } return card } private func makeInfoRow(label: String, value: String) -> UIView { let row = UIStackView() row.axis = .horizontal - row.distribution = .equalSpacing + row.alignment = .top + row.spacing = 8 let left = UILabel() left.text = label left.font = .systemFont(ofSize: 14) - left.textColor = AppColor.textSecondary + left.textColor = UIColor(hex: 0x4B5563) + left.setContentCompressionResistancePriority(.required, for: .horizontal) let right = UILabel() right.text = value.isEmpty ? "--" : value right.font = .systemFont(ofSize: 14) - right.textColor = AppColor.textSecondary + right.textColor = UIColor(hex: 0x4B5563) right.textAlignment = .right + right.numberOfLines = 0 row.addArrangedSubview(left) row.addArrangedSubview(right) + right.snp.makeConstraints { make in make.width.lessThanOrEqualTo(220) } return row } + private func insetContainer(_ content: UIView, insets: UIEdgeInsets) -> UIView { + let container = UIView() + container.addSubview(content) + content.snp.makeConstraints { make in make.edges.equalToSuperview().inset(insets) } + return container + } + + private func presentScenicListIfNeeded() { + guard viewModel.showScenicListDialog, presentedViewController == nil else { return } + present( + PermissionScenicListDialogViewController( + roleName: viewModel.dialogRoleName, + scenics: viewModel.dialogScenicList, + onDismiss: { [weak self] in self?.viewModel.hideScenicList() } + ), + animated: true + ) + } + @objc private func editTapped() { viewModel.navigateToEditIfRejected() } @@ -187,8 +197,7 @@ final class PermissionApplyStatusViewController: BaseViewController { private func navigateToEdit(pending: RoleApplyPendingResponse) { var controllers = navigationController?.viewControllers ?? [] controllers.removeAll { $0 is PermissionApplyStatusViewController } - let applyVC = PermissionApplyViewController(pendingData: pending) - controllers.append(applyVC) + controllers.append(PermissionApplyViewController(pendingData: pending)) navigationController?.setViewControllers(controllers, animated: true) } } diff --git a/suixinkan/UI/Home/PermissionApplyViewController.swift b/suixinkan/UI/Home/PermissionApplyViewController.swift index 94168e5..855426a 100644 --- a/suixinkan/UI/Home/PermissionApplyViewController.swift +++ b/suixinkan/UI/Home/PermissionApplyViewController.swift @@ -6,16 +6,17 @@ import SnapKit import UIKit -/// 权限申请页,对齐 Android `PermissionApplyScreen`。 +/// 权限申请页,严格对齐 Android `PermissionApplyScreen`。 +@MainActor final class PermissionApplyViewController: BaseViewController { private let viewModel: PermissionApplyViewModel private let homeAPI: HomeAPI private let pendingData: RoleApplyPendingResponse? - private let scrollView = UIScrollView() - private let contentStack = UIStackView() private let bannerView = PermissionApplyBannerView() + private let scrollView = UIScrollView() + private let cardView = UIView() private let roleSelector = PermissionFormSelectorRow( title: "角色信息", tag: "单选", @@ -30,16 +31,22 @@ final class PermissionApplyViewController: BaseViewController { placeholder: "请选择景区" ) private let selectedScenicTags = PermissionSelectedTagsView() + private let existingRoleList = PermissionExistingRoleListView() + private let bottomBar = UIView() private let submitButton = PermissionSubmitButton() + private weak var roleDropdown: PermissionDropdownViewController? + private weak var scenicDropdown: PermissionDropdownViewController? + + /// 创建新增或驳回编辑状态的权限申请页。 init( pendingData: RoleApplyPendingResponse? = nil, viewModel: PermissionApplyViewModel = PermissionApplyViewModel(), - homeAPI: HomeAPI = NetworkServices.shared.homeAPI + homeAPI: HomeAPI? = nil ) { self.pendingData = pendingData self.viewModel = viewModel - self.homeAPI = homeAPI + self.homeAPI = homeAPI ?? NetworkServices.shared.homeAPI super.init(nibName: nil, bundle: nil) } @@ -50,41 +57,60 @@ final class PermissionApplyViewController: BaseViewController { override func setupNavigationBar() { title = "申请权限" + navigationItem.backButtonDisplayMode = .minimal } override func setupUI() { - contentStack.axis = .vertical - contentStack.spacing = 16 + view.backgroundColor = PermissionApplyUITokens.pageBackground + scrollView.keyboardDismissMode = .onDrag + cardView.backgroundColor = .white + cardView.layer.cornerRadius = 8 + bottomBar.backgroundColor = .white - let card = UIView() - card.backgroundColor = .white - card.layer.cornerRadius = 8 - let cardStack = UIStackView(arrangedSubviews: [roleSelector, newRoleTags, scenicSelector, selectedScenicTags]) - cardStack.axis = .vertical - cardStack.spacing = 20 - card.addSubview(cardStack) - cardStack.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(16) - } + let roleSection = UIStackView(arrangedSubviews: [roleSelector, newRoleTags]) + roleSection.axis = .vertical + roleSection.spacing = 12 - contentStack.addArrangedSubview(bannerView) - contentStack.addArrangedSubview(card) - scrollView.addSubview(contentStack) + let scenicSection = UIStackView(arrangedSubviews: [scenicSelector, selectedScenicTags, existingRoleList]) + scenicSection.axis = .vertical + scenicSection.spacing = 12 + + let formStack = UIStackView(arrangedSubviews: [roleSection, scenicSection]) + formStack.axis = .vertical + formStack.spacing = 20 + + view.addSubview(bannerView) view.addSubview(scrollView) - view.addSubview(submitButton) + scrollView.addSubview(cardView) + cardView.addSubview(formStack) + view.addSubview(bottomBar) + bottomBar.addSubview(submitButton) - contentStack.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 16, right: 16)) - make.width.equalTo(scrollView).offset(-32) - } - scrollView.snp.makeConstraints { make in + bannerView.snp.makeConstraints { make in make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) - make.bottom.equalTo(submitButton.snp.top).offset(-12) + } + bottomBar.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() } submitButton.snp.makeConstraints { make in + make.top.equalToSuperview().offset(16) make.leading.trailing.equalToSuperview().inset(16) - make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12) + make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16) } + scrollView.snp.makeConstraints { make in + make.top.equalTo(bannerView.snp.bottom) + make.leading.trailing.equalToSuperview() + make.bottom.equalTo(bottomBar.snp.top) + } + cardView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(16) + make.width.equalTo(scrollView).offset(-32) + } + formStack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) } + + newRoleTags.isHidden = true + selectedScenicTags.isHidden = true + existingRoleList.isHidden = true } override func bindActions() { @@ -94,21 +120,23 @@ final class PermissionApplyViewController: BaseViewController { viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } - viewModel.onSubmitSuccess = { [weak self] applyCode in - Task { @MainActor in self?.handleSubmitSuccess(applyCode: applyCode) } + viewModel.onSubmitSuccess = { [weak self] in + Task { @MainActor in self?.handleSubmitSuccess() } } bannerView.onApplyNewScenic = { [weak self] in self?.navigationController?.pushViewController(ScenicApplicationViewController(), animated: true) } - roleSelector.onTap = { [weak self] in self?.presentRolePicker() } + roleSelector.onTap = { [weak self] in self?.viewModel.showRolePickerSheet() } scenicSelector.onTap = { [weak self] in guard let self else { return } self.viewModel.showScenicPickerSheet() - if self.viewModel.availableScenics.isEmpty { + if self.viewModel.showScenicPicker, self.viewModel.availableScenics.isEmpty { Task { await self.viewModel.loadAvailableScenics(api: self.homeAPI) } } - self.presentScenicPickerIfNeeded() + } + existingRoleList.onShowAllScenics = { [weak self] roleName, scenics in + self?.viewModel.showScenicList(roleName: roleName, scenics: scenics) } submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside) @@ -119,105 +147,112 @@ final class PermissionApplyViewController: BaseViewController { applyViewModel() } + @MainActor private func applyViewModel() { if viewModel.selectedRoleName.isEmpty { roleSelector.setValue("请选择角色", isPlaceholder: true) - newRoleTags.isHidden = true } else { roleSelector.setValue(viewModel.selectedRoleName, isPlaceholder: false) - newRoleTags.apply(title: "新增角色", names: [viewModel.selectedRoleName]) { [weak self] _ in - self?.viewModel.clearRoleSelection() - } + } + newRoleTags.apply(title: "新增角色", names: viewModel.selectedRoleName.isEmpty ? [] : [viewModel.selectedRoleName]) { + [weak self] _ in self?.viewModel.clearRoleSelection() } - if viewModel.selectedScenicNames.isEmpty { - scenicSelector.setValue("请选择景区", isPlaceholder: true) - selectedScenicTags.isHidden = true - } else { - let displayText: String - if viewModel.selectedScenicNames.count == 1 { - displayText = viewModel.selectedScenicNames[0] - } else { - displayText = "已选中\(viewModel.selectedScenicNames.count)个景区" - } - scenicSelector.setValue(displayText, isPlaceholder: false) - selectedScenicTags.apply( - title: "新增景区", - names: viewModel.selectedScenicNames - ) { [weak self] index in - guard let self else { return } - let id = self.viewModel.selectedScenicIds[index] - let name = self.viewModel.selectedScenicNames[index] - self.viewModel.removeSelectedScenic(id: id, name: name) - } + let scenicValue: String + switch viewModel.selectedScenicNames.count { + case 0: scenicValue = "请选择景区" + case 1: scenicValue = viewModel.selectedScenicNames[0] + default: scenicValue = "已选中\(viewModel.selectedScenicNames.count)个景区" } + scenicSelector.setValue(scenicValue, isPlaceholder: viewModel.selectedScenicNames.isEmpty) + selectedScenicTags.apply(title: "新增景区", names: viewModel.selectedScenicNames) { [weak self] index in + guard let self, + self.viewModel.selectedScenicIds.indices.contains(index), + self.viewModel.selectedScenicNames.indices.contains(index) else { return } + self.viewModel.removeSelectedScenic( + id: self.viewModel.selectedScenicIds[index], + name: self.viewModel.selectedScenicNames[index] + ) + } + existingRoleList.apply(rolePermissions: viewModel.rolePermissionList) + submitButton.applyState(canSubmit: viewModel.canSubmit, submitting: viewModel.isSubmitting) - submitButton.setSubmitting(viewModel.isSubmitting) - presentScenicPickerIfNeeded() + if viewModel.isSubmitting { showLoading() } else { hideLoading() } + scenicDropdown?.updateScenics(viewModel.filteredScenics, isLoading: viewModel.isLoadingScenics) presentRolePickerIfNeeded() + presentScenicPickerIfNeeded() + presentDeletionIfNeeded() + presentScenicListIfNeeded() } private func presentRolePickerIfNeeded() { - guard viewModel.showRolePicker else { return } - viewModel.hideRolePickerSheet() - presentRolePicker() - } - - private func presentRolePicker() { - let alert = UIAlertController(title: "选择角色", message: nil, preferredStyle: .actionSheet) - viewModel.availableRoles.forEach { role in - alert.addAction(UIAlertAction(title: role.name, style: .default) { [weak self] _ in - self?.viewModel.selectRole(role) - Task { await self?.viewModel.loadAvailableScenics(api: self?.homeAPI) } - }) + guard viewModel.showRolePicker, roleDropdown == nil, presentedViewController == nil else { return } + let dropdown = PermissionDropdownViewController( + anchor: roleSelector.selectorControl, + mode: .roles(viewModel.availableRoles) + ) + dropdown.onRoleSelect = { [weak self] role in self?.viewModel.selectRole(role) } + dropdown.onDismiss = { [weak self, weak dropdown] in + self?.viewModel.hideRolePickerSheet() + if self?.roleDropdown === dropdown { self?.roleDropdown = nil } } - alert.addAction(UIAlertAction(title: "取消", style: .cancel)) - present(alert, animated: true) + roleDropdown = dropdown + present(dropdown, animated: true) } private func presentScenicPickerIfNeeded() { - guard viewModel.showScenicPicker else { return } - viewModel.hideScenicPickerSheet() - presentScenicPicker() + guard viewModel.showScenicPicker, scenicDropdown == nil, presentedViewController == nil else { return } + let dropdown = PermissionDropdownViewController( + anchor: scenicSelector.selectorControl, + mode: .scenics(viewModel.filteredScenics, isLoading: viewModel.isLoadingScenics) + ) + dropdown.onScenicToggle = { [weak self] scenic in self?.viewModel.toggleScenic(scenic) } + dropdown.onSearchChange = { [weak self] query in self?.viewModel.updateScenicSearchQuery(query) } + dropdown.onDismiss = { [weak self, weak dropdown] in + self?.viewModel.hideScenicPickerSheet() + if self?.scenicDropdown === dropdown { self?.scenicDropdown = nil } + } + scenicDropdown = dropdown + present(dropdown, animated: true) } - private func presentScenicPicker() { - guard viewModel.selectedRoleId != nil else { - showToast("请先选择角色") - return - } - if viewModel.availableScenics.isEmpty { - Task { - await viewModel.loadAvailableScenics(api: homeAPI) - await MainActor.run { self.presentScenicPicker() } - } - return - } + private func presentDeletionIfNeeded() { + guard let target = viewModel.pendingDeletion, presentedViewController == nil else { return } + present( + PermissionDeleteDialogViewController( + target: target, + onCancel: { [weak self] in self?.viewModel.cancelDeletion() }, + onConfirm: { [weak self] in self?.viewModel.confirmDeletion() } + ), + animated: true + ) + } - let alert = UIAlertController(title: "选择景区", message: nil, preferredStyle: .actionSheet) - viewModel.availableScenics.filter { !$0.isDisabled }.forEach { scenic in - let prefix = scenic.isSelected ? "✓ " : "" - alert.addAction(UIAlertAction(title: "\(prefix)\(scenic.name)", style: .default) { [weak self] _ in - self?.viewModel.toggleScenic(scenic) - }) - } - alert.addAction(UIAlertAction(title: "取消", style: .cancel)) - present(alert, animated: true) + private func presentScenicListIfNeeded() { + guard viewModel.showScenicListDialog, presentedViewController == nil else { return } + present( + PermissionScenicListDialogViewController( + roleName: viewModel.dialogRoleName, + scenics: viewModel.dialogScenicList, + onDismiss: { [weak self] in self?.viewModel.hideScenicList() } + ), + animated: true + ) } @objc private func submitTapped() { Task { await viewModel.submit(api: homeAPI) } } - private func handleSubmitSuccess(applyCode: String) { - guard !applyCode.isEmpty else { - navigationController?.popViewController(animated: true) + private func handleSubmitSuccess() { + hideLoading() + guard let navigationController else { + showToast("提交成功") return } - var controllers = navigationController?.viewControllers ?? [] - controllers.removeAll { $0 === self } - let statusVC = PermissionApplyStatusViewController(applyCode: applyCode) - controllers.append(statusVC) - navigationController?.setViewControllers(controllers, animated: true) + navigationController.popViewController(animated: true) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak navigationController] in + (navigationController?.topViewController as? BaseViewController)?.showToast("提交成功") + } } } diff --git a/suixinkan/UI/Home/ScenicSelectionViewController.swift b/suixinkan/UI/Home/ScenicSelectionViewController.swift index 1c458aa..46f059c 100644 --- a/suixinkan/UI/Home/ScenicSelectionViewController.swift +++ b/suixinkan/UI/Home/ScenicSelectionViewController.swift @@ -52,11 +52,15 @@ final class ScenicSelectionViewController: BaseViewController, UITableViewDataSo emptyLabel.textAlignment = .center emptyLabel.isHidden = true - let headerStack = UIStackView(arrangedSubviews: [bannerView, searchBar, locationBar]) + let contentStack = UIStackView(arrangedSubviews: [searchBar, locationBar]) + contentStack.axis = .vertical + contentStack.spacing = 16 + contentStack.isLayoutMarginsRelativeArrangement = true + contentStack.layoutMargins = UIEdgeInsets(top: 0, left: 16, bottom: 16, right: 16) + + let headerStack = UIStackView(arrangedSubviews: [bannerView, contentStack]) headerStack.axis = .vertical headerStack.spacing = 16 - headerStack.isLayoutMarginsRelativeArrangement = true - headerStack.layoutMargins = UIEdgeInsets(top: 0, left: 16, bottom: 16, right: 16) headerStack.setContentHuggingPriority(.required, for: .vertical) headerStack.setContentCompressionResistancePriority(.required, for: .vertical) diff --git a/suixinkan/UI/Home/Views/HomeScenicHeaderView.swift b/suixinkan/UI/Home/Views/HomeScenicHeaderView.swift index d448830..bc53fc8 100644 --- a/suixinkan/UI/Home/Views/HomeScenicHeaderView.swift +++ b/suixinkan/UI/Home/Views/HomeScenicHeaderView.swift @@ -19,20 +19,23 @@ final class HomeScenicHeaderView: UIView { backgroundColor = AppColor.cardBackground titleLabel.font = .app(.subtitle) titleLabel.textColor = AppColor.textPrimary + titleLabel.lineBreakMode = .byTruncatingTail + titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) arrowView.tintColor = AppColor.textSecondary arrowView.contentMode = .scaleAspectFit + arrowView.setContentCompressionResistancePriority(.required, for: .horizontal) addSubview(titleLabel) addSubview(arrowView) titleLabel.snp.makeConstraints { make in - make.leading.equalToSuperview().offset(AppSpacing.screenHorizontalInset) - make.centerY.equalToSuperview() - make.trailing.lessThanOrEqualTo(arrowView.snp.leading).offset(-AppSpacing.xs) + make.leading.equalTo(safeAreaLayoutGuide).offset(AppSpacing.screenHorizontalInset) + make.centerY.equalTo(safeAreaLayoutGuide) } arrowView.snp.makeConstraints { make in - make.trailing.equalToSuperview().offset(-AppSpacing.screenHorizontalInset) - make.centerY.equalToSuperview() + make.leading.equalTo(titleLabel.snp.trailing).offset(AppSpacing.xs) + make.trailing.lessThanOrEqualTo(safeAreaLayoutGuide).offset(-AppSpacing.screenHorizontalInset) + make.centerY.equalTo(safeAreaLayoutGuide) make.width.height.equalTo(16) } diff --git a/suixinkan/UI/Home/Views/HomeWorkStatusCardView.swift b/suixinkan/UI/Home/Views/HomeWorkStatusCardView.swift index 58ec859..bde0a1b 100644 --- a/suixinkan/UI/Home/Views/HomeWorkStatusCardView.swift +++ b/suixinkan/UI/Home/Views/HomeWorkStatusCardView.swift @@ -34,7 +34,9 @@ final class HomeWorkStatusCardView: UIView { onlineButton.titleLabel?.font = .app(.captionMedium) onlineButton.layer.cornerRadius = AppRadius.sm - onlineButton.contentEdgeInsets = UIEdgeInsets(top: 7, left: 14, bottom: 7, right: 14) + onlineButton.setConfigurationContentInsets( + NSDirectionalEdgeInsets(top: 7, leading: 14, bottom: 7, trailing: 14) + ) onlineButton.addTarget(self, action: #selector(onlineTapped), for: .touchUpInside) countdownStackView.axis = .horizontal diff --git a/suixinkan/UI/Home/Views/PermissionApplyViews.swift b/suixinkan/UI/Home/Views/PermissionApplyViews.swift index 8017af2..88500a0 100644 --- a/suixinkan/UI/Home/Views/PermissionApplyViews.swift +++ b/suixinkan/UI/Home/Views/PermissionApplyViews.swift @@ -6,11 +6,26 @@ import SnapKit import UIKit -/// 权限申请页橙色横幅(跳转景区申请)。 +/// 权限申请模块视觉常量,严格对应 Android Compose 中的尺寸和颜色。 +enum PermissionApplyUITokens { + static let pageBackground = UIColor(hex: 0xF5F5F5) + static let selectorBorder = UIColor(hex: 0xE0E3EA) + static let placeholder = UIColor(hex: 0xB3B8C2) + static let text = UIColor(hex: 0x111827) + static let existingBackground = UIColor(hex: 0xF8F9FB) + static let selectedBackground = UIColor(hex: 0xF4F9FF) + static let selectedBlue = UIColor(hex: 0x1677FF) + static let existingGreen = UIColor(hex: 0x52C41A) + static let existingGreenBackground = UIColor(hex: 0xE6F7E6) + static let disabledButton = UIColor(hex: 0xE0E0E0) +} + +/// 权限申请页顶部“申请平台开通新景区”横幅。 final class PermissionApplyBannerView: UIView { var onApplyNewScenic: (() -> Void)? + private let iconView = UIImageView() private let titleLabel = UILabel() private let applyButton = UIButton(type: .system) @@ -18,28 +33,50 @@ final class PermissionApplyBannerView: UIView { super.init(frame: frame) backgroundColor = AppColor.warningBackground + iconView.image = UIImage(named: "permission_open_scenic") + ?? UIImage(systemName: "building.2.fill")?.withTintColor(AppColor.warning, renderingMode: .alwaysOriginal) + iconView.contentMode = .scaleAspectFit + iconView.isAccessibilityElement = false + titleLabel.text = "没有找到心仪的景区" titleLabel.font = .systemFont(ofSize: 14, weight: .medium) titleLabel.textColor = AppColor.warning + titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - applyButton.setTitle("申请平台开通新景区", for: .normal) - applyButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) - applyButton.setTitleColor(AppColor.warning, for: .normal) + var configuration = UIButton.Configuration.plain() + configuration.title = "申请平台开通新景区" + configuration.image = UIImage(named: "permission_more_scenic") + ?? UIImage(systemName: "chevron.right") + configuration.imagePlacement = .trailing + configuration.imagePadding = 2 + configuration.contentInsets = .zero + configuration.baseForegroundColor = AppColor.warning + configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in + var outgoing = incoming + outgoing.font = .systemFont(ofSize: 14, weight: .medium) + return outgoing + } + applyButton.configuration = configuration + applyButton.accessibilityLabel = "申请平台开通新景区" applyButton.addTarget(self, action: #selector(applyTapped), for: .touchUpInside) - addSubview(titleLabel) - addSubview(applyButton) - titleLabel.snp.makeConstraints { make in + [iconView, titleLabel, applyButton].forEach(addSubview) + iconView.snp.makeConstraints { make in make.leading.equalToSuperview().offset(16) make.centerY.equalToSuperview() + make.size.equalTo(20) + } + titleLabel.snp.makeConstraints { make in + make.leading.equalTo(iconView.snp.trailing) + make.centerY.equalToSuperview() + make.trailing.lessThanOrEqualTo(applyButton.snp.leading).offset(-12) } applyButton.snp.makeConstraints { make in make.trailing.equalToSuperview().offset(-16) make.centerY.equalToSuperview() + make.height.greaterThanOrEqualTo(44) } - snp.makeConstraints { make in - make.height.equalTo(44) - } + snp.makeConstraints { make in make.height.equalTo(48) } } @available(*, unavailable) @@ -52,7 +89,7 @@ final class PermissionApplyBannerView: UIView { } } -/// 表单字段选择行,对齐 Android `FormField` + Selector。 +/// Android 表单标题、说明和锚定选择框组合视图。 final class PermissionFormSelectorRow: UIView { var onTap: (() -> Void)? @@ -60,6 +97,7 @@ final class PermissionFormSelectorRow: UIView { private let titleLabel = UILabel() private let tagLabel = UILabel() private let descriptionLabel = UILabel() + let selectorControl = UIControl() private let valueLabel = UILabel() private let chevron = UIImageView(image: UIImage(systemName: "chevron.down")) @@ -67,7 +105,7 @@ final class PermissionFormSelectorRow: UIView { super.init(frame: .zero) titleLabel.text = title titleLabel.font = .systemFont(ofSize: 16, weight: .medium) - titleLabel.textColor = AppColor.textPrimary + titleLabel.textColor = UIColor(hex: 0x333333) tagLabel.text = tag tagLabel.font = .systemFont(ofSize: 12) @@ -76,28 +114,28 @@ final class PermissionFormSelectorRow: UIView { descriptionLabel.text = description descriptionLabel.font = .systemFont(ofSize: 12) - descriptionLabel.textColor = UIColor(hex: 0xB3B8C2) + descriptionLabel.textColor = PermissionApplyUITokens.placeholder descriptionLabel.numberOfLines = 0 + selectorControl.backgroundColor = .white + selectorControl.layer.cornerRadius = 12 + selectorControl.layer.borderWidth = 1 + selectorControl.layer.borderColor = PermissionApplyUITokens.selectorBorder.cgColor + selectorControl.accessibilityLabel = title + selectorControl.addTarget(self, action: #selector(rowTapped), for: .touchUpInside) + valueLabel.text = placeholder valueLabel.font = .systemFont(ofSize: 14) - valueLabel.textColor = UIColor(hex: 0xB3B8C2) + valueLabel.textColor = PermissionApplyUITokens.placeholder + valueLabel.lineBreakMode = .byTruncatingTail - chevron.tintColor = UIColor(hex: 0xB3B8C2) + chevron.tintColor = PermissionApplyUITokens.placeholder chevron.contentMode = .scaleAspectFit - let tap = UITapGestureRecognizer(target: self, action: #selector(rowTapped)) - addGestureRecognizer(tap) + [titleLabel, tagLabel, descriptionLabel, selectorControl].forEach(addSubview) + [valueLabel, chevron].forEach(selectorControl.addSubview) - addSubview(titleLabel) - addSubview(tagLabel) - addSubview(descriptionLabel) - addSubview(valueLabel) - addSubview(chevron) - - titleLabel.snp.makeConstraints { make in - make.top.leading.equalToSuperview() - } + titleLabel.snp.makeConstraints { make in make.top.leading.equalToSuperview() } tagLabel.snp.makeConstraints { make in make.centerY.equalTo(titleLabel) make.trailing.equalToSuperview() @@ -107,121 +145,18 @@ final class PermissionFormSelectorRow: UIView { make.top.equalTo(titleLabel.snp.bottom).offset(4) make.leading.trailing.equalToSuperview() } - valueLabel.snp.makeConstraints { make in + selectorControl.snp.makeConstraints { make in make.top.equalTo(descriptionLabel.snp.bottom).offset(8) - make.leading.bottom.equalToSuperview() + make.leading.trailing.bottom.equalToSuperview() + make.height.equalTo(46) + } + valueLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(16) + make.centerY.equalToSuperview() make.trailing.lessThanOrEqualTo(chevron.snp.leading).offset(-8) } chevron.snp.makeConstraints { make in - make.trailing.equalToSuperview() - make.centerY.equalTo(valueLabel) - make.size.equalTo(14) - } - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func setValue(_ text: String, isPlaceholder: Bool) { - valueLabel.text = text - valueLabel.textColor = isPlaceholder ? UIColor(hex: 0xB3B8C2) : AppColor.textPrimary - } - - @objc private func rowTapped() { - onTap?() - } -} - -/// 已选景区标签容器。 -final class PermissionSelectedTagsView: UIView { - - private let titleLabel = UILabel() - private let stackView = UIStackView() - - override init(frame: CGRect) { - super.init(frame: frame) - titleLabel.font = .systemFont(ofSize: 16, weight: .medium) - titleLabel.textColor = UIColor(hex: 0x111827) - stackView.axis = .vertical - stackView.spacing = 8 - addSubview(titleLabel) - addSubview(stackView) - titleLabel.snp.makeConstraints { make in - make.top.leading.trailing.equalToSuperview() - } - stackView.snp.makeConstraints { make in - make.top.equalTo(titleLabel.snp.bottom).offset(8) - make.leading.trailing.bottom.equalToSuperview() - } - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func apply(title: String, names: [String], onRemove: ((Int) -> Void)? = nil) { - titleLabel.text = title - stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } - guard !names.isEmpty else { - isHidden = true - return - } - isHidden = false - let row = UIStackView() - row.axis = .horizontal - row.spacing = 8 - row.alignment = .leading - names.enumerated().forEach { index, name in - let chip = PermissionTagChip(title: name) - if let onRemove { - chip.onRemove = { onRemove(index) } - } - row.addArrangedSubview(chip) - } - stackView.addArrangedSubview(row) - } -} - -/// 标签 Chip。 -final class PermissionTagChip: UIView { - - var onRemove: (() -> Void)? - - private let label = UILabel() - private let removeButton = UIButton(type: .system) - - init(title: String, showsRemove: Bool = true) { - super.init(frame: .zero) - layer.cornerRadius = 4 - layer.borderWidth = 1 - layer.borderColor = AppColor.primary.cgColor - backgroundColor = .white - - label.text = title - label.font = .systemFont(ofSize: 14) - label.textColor = AppColor.primary - - removeButton.setImage(UIImage(systemName: "xmark"), for: .normal) - removeButton.tintColor = AppColor.primary - removeButton.addTarget(self, action: #selector(removeTapped), for: .touchUpInside) - removeButton.isHidden = !showsRemove - - addSubview(label) - addSubview(removeButton) - label.snp.makeConstraints { make in - make.leading.equalToSuperview().offset(12) - make.top.bottom.equalToSuperview().inset(8) - if showsRemove { - make.trailing.equalTo(removeButton.snp.leading).offset(-4) - } else { - make.trailing.equalToSuperview().offset(-12) - } - } - removeButton.snp.makeConstraints { make in - make.trailing.equalToSuperview().offset(-8) + make.trailing.equalToSuperview().offset(-16) make.centerY.equalToSuperview() make.size.equalTo(20) } @@ -232,23 +167,156 @@ final class PermissionTagChip: UIView { fatalError("init(coder:) has not been implemented") } - @objc private func removeTapped() { - onRemove?() + /// 更新选择框显示值。 + func setValue(_ text: String, isPlaceholder: Bool) { + valueLabel.text = text + valueLabel.textColor = isPlaceholder ? PermissionApplyUITokens.placeholder : PermissionApplyUITokens.text + selectorControl.accessibilityValue = text + } + + @objc private func rowTapped() { + onTap?() } } -/// 底部主按钮。 -final class PermissionSubmitButton: UIButton { +/// 权限标签的展示样式。 +enum PermissionChipStyle: Hashable { + case selectedRemovable + case selectedStatic + case existingGreen +} - override init(frame: CGRect) { - super.init(frame: frame) - setTitle("提交审核", for: .normal) - setTitleColor(.white, for: .normal) - titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) - backgroundColor = AppColor.primary - layer.cornerRadius = 8 - snp.makeConstraints { make in - make.height.equalTo(48) +/// Diffable 标签网格中的稳定条目。 +struct PermissionChipItem: Hashable { + let id: String + let title: String +} + +/// Android `FlowRow` 的标签左对齐与换行坐标计算器。 +struct PermissionChipLayoutMetrics { + static let itemHeight: CGFloat = 44 + static let interitemSpacing: CGFloat = 12 + static let lineSpacing: CGFloat = 2 + + /// 按容器宽度为标签生成从左到右、超宽换行的坐标。 + static func makeFrames(itemWidths: [CGFloat], containerWidth: CGFloat) -> [CGRect] { + guard containerWidth > 0 else { return [] } + var x: CGFloat = 0 + var y: CGFloat = 0 + return itemWidths.map { rawWidth in + let width = min(containerWidth, max(44, rawWidth)) + if x > 0, x + width > containerWidth + 0.5 { + x = 0 + y += itemHeight + lineSpacing + } + let frame = CGRect(x: x, y: y, width: width, height: itemHeight) + x += width + interitemSpacing + return frame + } + } +} + +/// 确定性计算左对齐换行坐标,等价于 Android `FlowRow`。 +private final class PermissionChipFlowLayout: UICollectionViewLayout { + + var itemWidthProvider: ((IndexPath) -> CGFloat)? + private var cachedAttributes: [IndexPath: UICollectionViewLayoutAttributes] = [:] + private var contentHeight: CGFloat = 1 + + override func prepare() { + super.prepare() + guard let collectionView, collectionView.bounds.width > 0 else { + cachedAttributes = [:] + contentHeight = 1 + return + } + + cachedAttributes = [:] + let availableWidth = collectionView.bounds.width + var indexPaths: [IndexPath] = [] + var itemWidths: [CGFloat] = [] + for section in 0.. [UICollectionViewLayoutAttributes]? { + cachedAttributes.values.filter { $0.frame.intersects(rect) } + } + + override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { + cachedAttributes[indexPath] + } + + override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { + guard let collectionView else { return true } + return abs(collectionView.bounds.width - newBounds.width) > 0.5 + } +} + +/// 自适应换行的权限标签网格。 +final class PermissionChipCollectionView: UIView { + + private enum Section { case main } + + var onRemove: ((Int) -> Void)? + + private let style: PermissionChipStyle + private let layout: PermissionChipFlowLayout + private let collectionView: UICollectionView + private var dataSource: UICollectionViewDiffableDataSource! + private var items: [PermissionChipItem] = [] + private var heightConstraint: Constraint? + + init(style: PermissionChipStyle) { + self.style = style + let layout = PermissionChipFlowLayout() + self.layout = layout + collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + super.init(frame: .zero) + + collectionView.backgroundColor = .clear + collectionView.isScrollEnabled = false + collectionView.register(PermissionChipCell.self, forCellWithReuseIdentifier: PermissionChipCell.reuseIdentifier) + addSubview(collectionView) + collectionView.snp.makeConstraints { make in make.edges.equalToSuperview() } + snp.makeConstraints { make in heightConstraint = make.height.equalTo(1).constraint } + + dataSource = UICollectionViewDiffableDataSource( + collectionView: collectionView + ) { [weak self] collectionView, indexPath, item in + guard let self, + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: PermissionChipCell.reuseIdentifier, + for: indexPath + ) as? PermissionChipCell else { return UICollectionViewCell() } + cell.apply(title: item.title, style: self.style) + cell.onRemove = { [weak self] in + guard let self, let currentIndex = self.items.firstIndex(of: item) else { return } + self.onRemove?(currentIndex) + } + return cell + } + layout.itemWidthProvider = { [weak self] indexPath in + self?.itemWidth(at: indexPath) ?? 44 } } @@ -257,9 +325,189 @@ final class PermissionSubmitButton: UIButton { fatalError("init(coder:) has not been implemented") } - func setSubmitting(_ submitting: Bool) { - isEnabled = !submitting - alpha = submitting ? 0.6 : 1 - setTitle(submitting ? "提交中..." : "提交审核", for: .normal) + /// 使用 Diffable snapshot 更新标签。 + func apply(titles: [String]) { + var occurrences: [String: Int] = [:] + items = titles.map { title in + let occurrence = occurrences[title, default: 0] + occurrences[title] = occurrence + 1 + return PermissionChipItem(id: "\(title)#\(occurrence)", title: title) + } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(items) + dataSource.apply(snapshot, animatingDifferences: true) { [weak self] in + self?.updateHeight() + } + } + + override func layoutSubviews() { + super.layoutSubviews() + updateHeight() + } + + private func updateHeight() { + collectionView.collectionViewLayout.invalidateLayout() + collectionView.layoutIfNeeded() + let height = max(1, collectionView.collectionViewLayout.collectionViewContentSize.height) + heightConstraint?.update(offset: height) + invalidateIntrinsicContentSize() + } + + private func itemWidth(at indexPath: IndexPath) -> CGFloat { + guard let item = dataSource.itemIdentifier(for: indexPath) else { return 44 } + let textWidth = ceil((item.title as NSString).size(withAttributes: [ + .font: UIFont.systemFont(ofSize: 14), + ]).width) + let horizontalChrome: CGFloat = style == .selectedRemovable ? 42 : 24 + return textWidth + horizontalChrome + } +} + +/// 带标题背景的“新增角色/新增景区”区域。 +final class PermissionSelectedTagsView: UIView { + + private let titleLabel = UILabel() + private let chips = PermissionChipCollectionView(style: .selectedRemovable) + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = PermissionApplyUITokens.selectedBackground + layer.cornerRadius = 12 + titleLabel.font = .systemFont(ofSize: 16, weight: .medium) + titleLabel.textColor = PermissionApplyUITokens.text + + addSubview(titleLabel) + addSubview(chips) + titleLabel.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(12) + } + chips.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(12) + make.leading.trailing.bottom.equalToSuperview().inset(12) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 更新区域标题与标签内容。 + func apply(title: String, names: [String], onRemove: ((Int) -> Void)? = nil) { + titleLabel.text = title + isHidden = names.isEmpty + chips.onRemove = onRemove + chips.apply(titles: names) + } +} + +/// 权限申请页底部主按钮。 +final class PermissionSubmitButton: UIButton { + + override init(frame: CGRect) { + super.init(frame: frame) + setTitle("提交审核", for: .normal) + setTitleColor(.white, for: .normal) + titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + layer.cornerRadius = 8 + accessibilityLabel = "提交审核" + snp.makeConstraints { make in make.height.equalTo(48) } + applyState(canSubmit: false, submitting: false) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 更新可提交与提交中状态。 + func applyState(canSubmit: Bool, submitting: Bool) { + isEnabled = canSubmit && !submitting + backgroundColor = isEnabled ? PermissionApplyUITokens.selectedBlue : PermissionApplyUITokens.disabledButton + setTitle(submitting ? "提交中..." : "提交审核", for: .normal) + accessibilityValue = submitting ? "提交中" : nil + } +} + +/// 权限标签 CollectionView 单元格。 +private final class PermissionChipCell: UICollectionViewCell { + + static let reuseIdentifier = "PermissionChipCell" + + var onRemove: (() -> Void)? + + private let chipBackground = UIView() + private let label = UILabel() + private let removeButton = UIButton(type: .system) + private let removeImageView = UIImageView(image: UIImage(systemName: "xmark")) + + override init(frame: CGRect) { + super.init(frame: frame) + label.font = .systemFont(ofSize: 14) + label.lineBreakMode = .byTruncatingTail + removeButton.addTarget(self, action: #selector(removeTapped), for: .touchUpInside) + removeButton.accessibilityLabel = "删除" + removeImageView.contentMode = .scaleAspectFit + removeImageView.isUserInteractionEnabled = false + contentView.addSubview(chipBackground) + chipBackground.addSubview(label) + chipBackground.addSubview(removeImageView) + contentView.addSubview(removeButton) + + chipBackground.snp.makeConstraints { make in + make.leading.trailing.centerY.equalToSuperview() + make.height.equalTo(34) + } + removeButton.snp.makeConstraints { make in + make.trailing.centerY.equalToSuperview() + make.size.equalTo(44) + } + removeImageView.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-10) + make.centerY.equalToSuperview() + make.size.equalTo(14) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(title: String, style: PermissionChipStyle) { + label.text = title + chipBackground.layer.cornerRadius = style == .existingGreen ? 4 : 8 + chipBackground.layer.borderWidth = 1 + switch style { + case .selectedRemovable, .selectedStatic: + chipBackground.backgroundColor = style == .selectedRemovable + ? PermissionApplyUITokens.selectedBackground : .white + chipBackground.layer.borderColor = PermissionApplyUITokens.selectedBlue.cgColor + label.textColor = PermissionApplyUITokens.selectedBlue + removeImageView.tintColor = PermissionApplyUITokens.selectedBlue + case .existingGreen: + chipBackground.backgroundColor = PermissionApplyUITokens.existingGreenBackground + chipBackground.layer.borderColor = PermissionApplyUITokens.existingGreen.cgColor + label.textColor = PermissionApplyUITokens.existingGreen + removeImageView.tintColor = PermissionApplyUITokens.existingGreen + } + let removable = style == .selectedRemovable + removeButton.isHidden = !removable + removeImageView.isHidden = !removable + label.snp.remakeConstraints { make in + make.leading.equalToSuperview().offset(12) + make.centerY.equalToSuperview() + if removable { + make.trailing.equalTo(removeImageView.snp.leading).offset(-6) + } else { + make.trailing.equalToSuperview().offset(-12) + } + } + accessibilityLabel = title + } + + @objc private func removeTapped() { + onRemove?() } } diff --git a/suixinkan/UI/Home/Views/PermissionDropdownViewController.swift b/suixinkan/UI/Home/Views/PermissionDropdownViewController.swift new file mode 100644 index 0000000..321a59a --- /dev/null +++ b/suixinkan/UI/Home/Views/PermissionDropdownViewController.swift @@ -0,0 +1,360 @@ +// +// PermissionDropdownViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 权限申请下拉菜单的展示模式。 +enum PermissionDropdownMode { + case roles([RoleOption]) + case scenics([ScenicOption], isLoading: Bool) +} + +/// 锚定在表单选择框下方的 Android 风格权限下拉菜单。 +final class PermissionDropdownViewController: UIViewController, UITableViewDelegate, UITextFieldDelegate { + + private enum Section { case main } + + fileprivate enum Item: Hashable { + case role(RoleOption) + case scenic(ScenicOption) + case loading + case empty(String) + } + + var onDismiss: (() -> Void)? + var onRoleSelect: ((RoleOption) -> Void)? + var onScenicToggle: ((ScenicOption) -> Void)? + var onSearchChange: ((String) -> Void)? + + private let anchorFrame: CGRect + private var mode: PermissionDropdownMode + private var allScenics: [ScenicOption] = [] + private var query = "" + private let dismissControl = UIControl() + private let cardView = UIView() + private let searchContainer = UIView() + private let searchField = UITextField() + private let tableView = UITableView(frame: .zero, style: .plain) + private var dataSource: UITableViewDiffableDataSource! + private var cardHeightConstraint: Constraint? + + /// 创建与指定选择框等宽的下拉菜单。 + init(anchor: UIView, mode: PermissionDropdownMode) { + anchorFrame = anchor.convert(anchor.bounds, to: nil) + self.mode = mode + if case .scenics(let scenics, _) = mode { + allScenics = scenics + } + 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 = .clear + dismissControl.backgroundColor = .clear + dismissControl.addTarget(self, action: #selector(dismissTapped), for: .touchUpInside) + + cardView.backgroundColor = .white + cardView.layer.cornerRadius = 4 + cardView.layer.shadowColor = UIColor.black.cgColor + cardView.layer.shadowOpacity = 0.14 + cardView.layer.shadowRadius = 8 + cardView.layer.shadowOffset = CGSize(width: 0, height: 4) + cardView.clipsToBounds = false + + searchContainer.backgroundColor = .white + searchContainer.layer.cornerRadius = 8 + searchContainer.layer.borderWidth = 1 + searchContainer.layer.borderColor = PermissionApplyUITokens.selectorBorder.cgColor + + let searchIcon = UIImageView(image: UIImage(systemName: "magnifyingglass")) + searchIcon.tintColor = AppColor.textTertiary + searchIcon.contentMode = .scaleAspectFit + searchField.placeholder = "搜索景区名称" + searchField.font = .systemFont(ofSize: 14) + searchField.textColor = PermissionApplyUITokens.text + searchField.returnKeyType = .search + searchField.clearButtonMode = .whileEditing + searchField.delegate = self + searchField.addTarget(self, action: #selector(searchChanged), for: .editingChanged) + searchField.accessibilityLabel = "搜索景区名称" + + tableView.backgroundColor = .white + tableView.separatorStyle = .none + tableView.keyboardDismissMode = .onDrag + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 52 + tableView.delegate = self + tableView.register(PermissionDropdownCell.self, forCellReuseIdentifier: PermissionDropdownCell.reuseIdentifier) + + view.addSubview(dismissControl) + view.addSubview(cardView) + cardView.addSubview(searchContainer) + searchContainer.addSubview(searchIcon) + searchContainer.addSubview(searchField) + cardView.addSubview(tableView) + + dismissControl.snp.makeConstraints { make in make.edges.equalToSuperview() } + cardView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(anchorFrame.minX) + make.top.equalToSuperview().offset(anchorFrame.maxY + 4) + make.width.equalTo(anchorFrame.width) + cardHeightConstraint = make.height.equalTo(80).constraint + } + searchContainer.snp.makeConstraints { make in + make.top.equalToSuperview().offset(8) + make.leading.trailing.equalToSuperview().inset(12) + make.height.equalTo(38) + } + searchIcon.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(12) + make.centerY.equalToSuperview() + make.size.equalTo(18) + } + searchField.snp.makeConstraints { make in + make.leading.equalTo(searchIcon.snp.trailing).offset(10) + make.trailing.equalToSuperview().offset(-8) + make.top.bottom.equalToSuperview() + } + tableView.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + make.top.equalTo(searchContainer.snp.bottom).offset(4) + } + + configureDataSource() + applyMode(animated: false) + } + + /// 刷新景区数据和加载状态,同时保留下拉菜单及搜索词。 + func updateScenics(_ scenics: [ScenicOption], isLoading: Bool) { + allScenics = scenics + mode = .scenics(scenics, isLoading: isLoading) + guard isViewLoaded else { return } + applyMode(animated: false) + } + + private func configureDataSource() { + dataSource = UITableViewDiffableDataSource(tableView: tableView) { + tableView, indexPath, item in + guard let cell = tableView.dequeueReusableCell( + withIdentifier: PermissionDropdownCell.reuseIdentifier, + for: indexPath + ) as? PermissionDropdownCell else { return UITableViewCell() } + cell.apply(item: item) + return cell + } + } + + private func applyMode(animated: Bool) { + var items: [Item] + switch mode { + case .roles(let roles): + searchContainer.isHidden = true + tableView.snp.remakeConstraints { make in make.edges.equalToSuperview() } + items = roles.map(Item.role) + if items.isEmpty { items = [.empty("暂无可用角色")] } + case .scenics(_, let isLoading): + searchContainer.isHidden = isLoading + if isLoading { + tableView.snp.remakeConstraints { make in make.edges.equalToSuperview() } + items = [.loading] + } else { + tableView.snp.remakeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + make.top.equalTo(searchContainer.snp.bottom).offset(4) + } + let filtered = filteredScenics() + items = filtered.map(Item.scenic) + if items.isEmpty { + items = [.empty(query.isEmpty ? "暂无可用景区" : "未找到相关景区")] + } + } + } + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(items) + let completion: () -> Void = { [weak self] in + guard let self else { return } + self.updateCardHeight(itemCount: items.count) + } + if animated { + dataSource.apply(snapshot, animatingDifferences: true, completion: completion) + } else { + UIView.performWithoutAnimation { + dataSource.apply(snapshot, animatingDifferences: false, completion: completion) + tableView.layoutIfNeeded() + } + } + } + + private func filteredScenics() -> [ScenicOption] { + let value = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { return allScenics } + return allScenics.filter { $0.name.localizedCaseInsensitiveContains(value) } + } + + private func updateCardHeight(itemCount: Int) { + tableView.layoutIfNeeded() + let hasSearch = !searchContainer.isHidden + let searchHeight: CGFloat = hasSearch ? 58 : 0 + let estimatedListHeight = max(64, min(262, tableView.contentSize.height > 0 ? tableView.contentSize.height : CGFloat(itemCount) * 52)) + cardHeightConstraint?.update(offset: min(320, searchHeight + estimatedListHeight)) + view.layoutIfNeeded() + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: false) + guard let item = dataSource.itemIdentifier(for: indexPath) else { return } + switch item { + case .role(let role): + onRoleSelect?(role) + dismissMenu(notify: false) + case .scenic(let scenic): + guard !scenic.isDisabled else { return } + onScenicToggle?(scenic) + if let index = allScenics.firstIndex(where: { $0.id == scenic.id }) { + allScenics[index].isSelected.toggle() + } + mode = .scenics(allScenics, isLoading: false) + applyMode(animated: false) + case .loading, .empty: + break + } + } + + @objc private func searchChanged() { + query = searchField.text ?? "" + onSearchChange?(query) + applyMode(animated: false) + } + + @objc private func dismissTapped() { + dismissMenu(notify: true) + } + + private func dismissMenu(notify: Bool) { + searchField.resignFirstResponder() + dismiss(animated: true) { [weak self] in + if notify { self?.onDismiss?() } + } + } +} + +/// 权限下拉菜单中的角色、景区、加载或空状态单元格。 +private final class PermissionDropdownCell: UITableViewCell { + + static let reuseIdentifier = "PermissionDropdownCell" + + private let titleLabel = UILabel() + private let detailLabel = UILabel() + private let checkCircle = UIView() + private let checkImage = UIImageView(image: UIImage(systemName: "checkmark")) + private let activityIndicator = UIActivityIndicatorView(style: .medium) + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + backgroundColor = .white + selectionStyle = .none + titleLabel.font = .systemFont(ofSize: 14) + titleLabel.textColor = PermissionApplyUITokens.text + titleLabel.numberOfLines = 1 + detailLabel.font = .systemFont(ofSize: 12) + detailLabel.textColor = PermissionApplyUITokens.placeholder + detailLabel.numberOfLines = 0 + checkCircle.frame = CGRect(x: 0, y: 0, width: 20, height: 20) + checkCircle.layer.cornerRadius = 10 + checkImage.tintColor = .white + checkImage.contentMode = .scaleAspectFit + activityIndicator.color = PermissionApplyUITokens.selectedBlue + + [titleLabel, detailLabel, activityIndicator].forEach(contentView.addSubview) + accessoryView = checkCircle + checkCircle.addSubview(checkImage) + titleLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(12) + make.leading.equalToSuperview().offset(16) + make.trailing.equalToSuperview().offset(-12) + } + detailLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(4) + make.leading.equalTo(titleLabel) + make.trailing.equalToSuperview().offset(-12) + make.bottom.equalToSuperview().offset(-10) + } + checkImage.snp.makeConstraints { make in make.center.equalToSuperview(); make.size.equalTo(14) } + activityIndicator.snp.makeConstraints { make in make.center.equalToSuperview() } + contentView.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(48) } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(item: PermissionDropdownViewController.Item) { + activityIndicator.stopAnimating() + titleLabel.textAlignment = .left + titleLabel.font = .systemFont(ofSize: 14) + detailLabel.isHidden = true + checkCircle.isHidden = true + isUserInteractionEnabled = true + switch item { + case .role(let role): + titleLabel.text = role.name + titleLabel.font = .systemFont(ofSize: 14, weight: .medium) + titleLabel.textColor = PermissionApplyUITokens.text + detailLabel.text = role.description + detailLabel.isHidden = role.description.isEmpty + checkCircle.isHidden = false + checkCircle.backgroundColor = role.isSelected + ? PermissionApplyUITokens.selectedBlue : UIColor(hex: 0xE5E7EB) + checkImage.isHidden = !role.isSelected + case .scenic(let scenic): + titleLabel.text = scenic.name + titleLabel.textColor = scenic.isDisabled ? AppColor.textTertiary : PermissionApplyUITokens.text + checkCircle.isHidden = false + checkCircle.backgroundColor = scenic.isDisabled && scenic.isSelected + ? AppColor.textTertiary + : (scenic.isSelected ? PermissionApplyUITokens.selectedBlue : UIColor(hex: 0xE5E7EB)) + checkImage.isHidden = !scenic.isSelected + isUserInteractionEnabled = !scenic.isDisabled + case .loading: + titleLabel.text = nil + activityIndicator.startAnimating() + isUserInteractionEnabled = false + case .empty(let message): + titleLabel.text = message + titleLabel.textAlignment = .center + titleLabel.textColor = AppColor.textTertiary + isUserInteractionEnabled = false + } + if detailLabel.isHidden { + titleLabel.snp.remakeConstraints { make in + make.leading.equalToSuperview().offset(16) + make.trailing.equalToSuperview().offset(-12) + make.centerY.equalToSuperview() + make.top.greaterThanOrEqualToSuperview().offset(12) + make.bottom.lessThanOrEqualToSuperview().offset(-12) + } + } else { + titleLabel.snp.remakeConstraints { make in + make.top.equalToSuperview().offset(10) + make.leading.equalToSuperview().offset(16) + make.trailing.equalToSuperview().offset(-12) + } + } + accessibilityLabel = [titleLabel.text, detailLabel.isHidden ? nil : detailLabel.text] + .compactMap { $0 }.joined(separator: ",") + } +} diff --git a/suixinkan/UI/Home/Views/PermissionRequiredDialogViewController.swift b/suixinkan/UI/Home/Views/PermissionRequiredDialogViewController.swift new file mode 100644 index 0000000..828ccf9 --- /dev/null +++ b/suixinkan/UI/Home/Views/PermissionRequiredDialogViewController.swift @@ -0,0 +1,118 @@ +// +// PermissionRequiredDialogViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 首页无业务权限时展示的 Android 风格强制提示弹窗。 +@MainActor +final class PermissionRequiredDialogViewController: UIViewController { + + private let onExitApp: () -> Void + private let onGoToApply: () -> Void + private let dimView = UIView() + private let cardView = UIView() + + /// 创建不可点击遮罩关闭的权限提示弹窗。 + init(onExitApp: @escaping () -> Void, onGoToApply: @escaping () -> Void) { + self.onExitApp = onExitApp + self.onGoToApply = onGoToApply + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .crossDissolve + isModalInPresentation = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + dimView.backgroundColor = UIColor.black.withAlphaComponent(0.32) + cardView.backgroundColor = .white + cardView.layer.cornerRadius = 16 + + let titleLabel = UILabel() + titleLabel.text = "提示" + titleLabel.font = .systemFont(ofSize: 18, weight: .bold) + titleLabel.textColor = UIColor(hex: 0x333333) + titleLabel.textAlignment = .center + + let messageLabel = UILabel() + messageLabel.text = "您还没有权限,请先申请权限" + messageLabel.font = .systemFont(ofSize: 16) + messageLabel.textColor = UIColor(hex: 0x4B5563) + messageLabel.textAlignment = .center + messageLabel.numberOfLines = 0 + + let exitButton = makeButton( + title: "退出app", + titleColor: UIColor(hex: 0x333333), + backgroundColor: .white, + borderColor: UIColor(hex: 0xE5E5E5) + ) + exitButton.addTarget(self, action: #selector(exitTapped), for: .touchUpInside) + + let applyButton = makeButton( + title: "去申请", + titleColor: .white, + backgroundColor: AppColor.primary, + borderColor: nil + ) + applyButton.addTarget(self, action: #selector(applyTapped), for: .touchUpInside) + + let buttons = UIStackView(arrangedSubviews: [exitButton, applyButton]) + buttons.axis = .horizontal + buttons.spacing = 12 + buttons.distribution = .fillEqually + + let stack = UIStackView(arrangedSubviews: [titleLabel, messageLabel, buttons]) + stack.axis = .vertical + stack.alignment = .fill + stack.spacing = 16 + stack.setCustomSpacing(24, after: messageLabel) + + view.addSubview(dimView) + view.addSubview(cardView) + cardView.addSubview(stack) + + dimView.snp.makeConstraints { make in make.edges.equalToSuperview() } + cardView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(32) + } + stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(24) } + buttons.snp.makeConstraints { make in make.height.equalTo(44) } + } + + private func makeButton( + title: String, + titleColor: UIColor, + backgroundColor: UIColor, + borderColor: UIColor? + ) -> UIButton { + let button = UIButton(type: .system) + button.setTitle(title, for: .normal) + button.setTitleColor(titleColor, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + button.backgroundColor = backgroundColor + button.layer.cornerRadius = 8 + button.layer.borderWidth = borderColor == nil ? 0 : 1 + button.layer.borderColor = borderColor?.cgColor + button.accessibilityLabel = title + return button + } + + @objc private func exitTapped() { + onExitApp() + } + + @objc private func applyTapped() { + dismiss(animated: true) { [onGoToApply] in onGoToApply() } + } +} diff --git a/suixinkan/UI/Home/Views/PermissionRoleViews.swift b/suixinkan/UI/Home/Views/PermissionRoleViews.swift new file mode 100644 index 0000000..5ab5b97 --- /dev/null +++ b/suixinkan/UI/Home/Views/PermissionRoleViews.swift @@ -0,0 +1,590 @@ +// +// PermissionRoleViews.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// “已有角色信息”中的稳定可展开条目。 +private struct PermissionExistingRoleItem: Hashable { + let id: String + let roleName: String + let scenics: [ScenicInfo] + var isExpanded: Bool +} + +/// Android 风格“已有角色信息”折叠列表。 +final class PermissionExistingRoleListView: UIView, UITableViewDelegate { + + private enum Section { case main } + + var onShowAllScenics: ((String, [ScenicInfo]) -> Void)? + + private let titleLabel = UILabel() + private let tableView = UITableView(frame: .zero, style: .plain) + private var dataSource: UITableViewDiffableDataSource! + private var items: [PermissionExistingRoleItem] = [] + private var tableHeightConstraint: Constraint? + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = PermissionApplyUITokens.existingBackground + layer.cornerRadius = 12 + titleLabel.text = "已有角色信息" + titleLabel.font = .systemFont(ofSize: 16, weight: .medium) + titleLabel.textColor = PermissionApplyUITokens.text + + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.isScrollEnabled = false + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 52 + tableView.delegate = self + tableView.register(PermissionExistingRoleCell.self, forCellReuseIdentifier: PermissionExistingRoleCell.reuseIdentifier) + + addSubview(titleLabel) + addSubview(tableView) + titleLabel.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(12) + } + tableView.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(12) + make.leading.trailing.bottom.equalToSuperview().inset(12) + tableHeightConstraint = make.height.equalTo(1).constraint + } + + dataSource = UITableViewDiffableDataSource(tableView: tableView) { + [weak self] tableView, indexPath, itemID in + guard let self, + let item = self.items.first(where: { $0.id == itemID }), + let cell = tableView.dequeueReusableCell( + withIdentifier: PermissionExistingRoleCell.reuseIdentifier, + for: indexPath + ) as? PermissionExistingRoleCell else { return UITableViewCell() } + cell.apply(item: item) + cell.onToggle = { [weak self] in self?.toggle(itemID: item.id) } + cell.onShowAll = { [weak self] in self?.onShowAllScenics?(item.roleName, item.scenics) } + return cell + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 使用本地权限缓存构建已有角色列表。 + func apply(rolePermissions: [RolePermissionResponse]) { + let expandedIDs = Set(items.filter(\.isExpanded).map(\.id)) + items = rolePermissions.enumerated().map { index, value in + let id = "\(index)-\(value.role.id)-\(value.role.name)" + return PermissionExistingRoleItem( + id: id, + roleName: value.role.name, + scenics: value.scenic, + isExpanded: expandedIDs.contains(id) + ) + } + isHidden = items.isEmpty + applySnapshot(animatingExpansion: false, reconfigureExisting: true) + } + + private func toggle(itemID: String) { + guard let index = items.firstIndex(where: { $0.id == itemID }) else { return } + items[index].isExpanded.toggle() + if let indexPath = dataSource.indexPath(for: itemID), + let cell = tableView.cellForRow(at: indexPath) as? PermissionExistingRoleCell { + cell.apply(item: items[index], animated: true) + } + applySnapshot(animatingExpansion: true, reconfigureExisting: false) + } + + private func applySnapshot(animatingExpansion: Bool, reconfigureExisting: Bool) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + let itemIDs = items.map(\.id) + snapshot.appendItems(itemIDs) + if reconfigureExisting { + let currentIDs = Set(dataSource.snapshot().itemIdentifiers) + snapshot.reconfigureItems(itemIDs.filter(currentIDs.contains)) + } + dataSource.apply(snapshot, animatingDifferences: false) { [weak self] in + self?.updateHeight(animated: animatingExpansion) + } + } + + private func updateHeight(animated: Bool) { + let layoutRoot = superview ?? self + layoutRoot.layoutIfNeeded() + let updates = { [self] in + tableView.beginUpdates() + tableView.endUpdates() + tableView.layoutIfNeeded() + tableHeightConstraint?.update(offset: max(1, tableView.contentSize.height)) + invalidateIntrinsicContentSize() + layoutRoot.layoutIfNeeded() + } + guard animated, !UIAccessibility.isReduceMotionEnabled else { + UIView.performWithoutAnimation(updates) + return + } + UIView.animate( + withDuration: 0.24, + delay: 0, + options: [.curveEaseOut, .beginFromCurrentState, .allowUserInteraction], + animations: updates + ) + } +} + +/// 已有角色折叠列表单元格。 +private final class PermissionExistingRoleCell: UITableViewCell { + + static let reuseIdentifier = "PermissionExistingRoleCell" + + var onToggle: (() -> Void)? + var onShowAll: (() -> Void)? + + private let cardView = UIView() + private let headerControl = UIControl() + private let titleLabel = UILabel() + private let chevron = UIImageView() + private let expandedContainer = UIView() + private let flowView = PermissionSimpleFlowView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + backgroundColor = .clear + selectionStyle = .none + contentView.backgroundColor = .clear + cardView.backgroundColor = .white + cardView.layer.cornerRadius = 8 + cardView.layer.shadowColor = UIColor.black.cgColor + cardView.layer.shadowOpacity = 0.08 + cardView.layer.shadowRadius = 1 + cardView.layer.shadowOffset = CGSize(width: 0, height: 1) + + titleLabel.font = .systemFont(ofSize: 14, weight: .medium) + titleLabel.textColor = PermissionApplyUITokens.text + chevron.image = UIImage(systemName: "chevron.down") + chevron.tintColor = AppColor.textTertiary + chevron.contentMode = .scaleAspectFit + headerControl.addTarget(self, action: #selector(toggleTapped), for: .touchUpInside) + expandedContainer.backgroundColor = PermissionApplyUITokens.existingBackground + flowView.onMore = { [weak self] in self?.onShowAll?() } + + contentView.addSubview(cardView) + cardView.addSubview(headerControl) + headerControl.addSubview(titleLabel) + headerControl.addSubview(chevron) + cardView.addSubview(expandedContainer) + expandedContainer.addSubview(flowView) + + cardView.snp.makeConstraints { make in + make.top.equalToSuperview() + make.leading.trailing.equalToSuperview() + make.bottom.equalToSuperview().offset(-8) + } + headerControl.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.height.equalTo(44) + } + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(16) + make.centerY.equalToSuperview() + make.trailing.lessThanOrEqualTo(chevron.snp.leading).offset(-8) + } + chevron.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-16) + make.centerY.equalToSuperview() + make.size.equalTo(20) + } + expandedContainer.snp.makeConstraints { make in + make.top.equalTo(headerControl.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + } + flowView.snp.makeConstraints { make in make.edges.equalToSuperview().inset(12) } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(item: PermissionExistingRoleItem, animated: Bool = false) { + titleLabel.text = item.roleName + headerControl.accessibilityLabel = item.roleName + headerControl.accessibilityValue = item.isExpanded ? "已展开" : "已收起" + flowView.apply(titles: item.scenics.map(\.name), showsMore: item.scenics.count > 3) + let showsExpandedContent = item.isExpanded && !item.scenics.isEmpty + let isExpanding = expandedContainer.isHidden && showsExpandedContent + expandedContainer.isHidden = !showsExpandedContent + + let updates = { [self] in + chevron.transform = item.isExpanded + ? CGAffineTransform(rotationAngle: .pi) + : .identity + expandedContainer.alpha = 1 + expandedContainer.transform = .identity + } + guard animated, !UIAccessibility.isReduceMotionEnabled else { + UIView.performWithoutAnimation(updates) + return + } + if isExpanding { + expandedContainer.alpha = 0 + expandedContainer.transform = CGAffineTransform(translationX: 0, y: -8) + } + UIView.animate( + withDuration: 0.24, + delay: 0, + options: [.curveEaseOut, .beginFromCurrentState, .allowUserInteraction], + animations: updates + ) + } + + @objc private func toggleTapped() { + onToggle?() + } +} + +/// 小规模绿色景区标签的手动换行容器。 +private final class PermissionSimpleFlowView: UIView { + + var onMore: (() -> Void)? + + private var itemViews: [UIView] = [] + private var lastLayoutWidth: CGFloat = 0 + + func apply(titles: [String], showsMore: Bool) { + itemViews.forEach { $0.removeFromSuperview() } + itemViews = [] + var values = Array(titles.prefix(3)) + if showsMore { values.append("···") } + for value in values { + let button = UIButton(type: .system) + button.setTitle(value, for: .normal) + button.setTitleColor(PermissionApplyUITokens.existingGreen, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 14) + button.setConfigurationContentInsets( + NSDirectionalEdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12) + ) + button.backgroundColor = PermissionApplyUITokens.existingGreenBackground + button.layer.cornerRadius = 4 + button.layer.borderWidth = 1 + button.layer.borderColor = PermissionApplyUITokens.existingGreen.cgColor + button.isUserInteractionEnabled = value == "···" + if value == "···" { + button.addTarget(self, action: #selector(moreTapped), for: .touchUpInside) + button.accessibilityLabel = "查看全部景区" + } + addSubview(button) + itemViews.append(button) + } + setNeedsLayout() + invalidateIntrinsicContentSize() + } + + override func layoutSubviews() { + super.layoutSubviews() + guard bounds.width > 0 else { return } + let widthChanged = abs(bounds.width - lastLayoutWidth) > 0.5 + lastLayoutWidth = bounds.width + var x: CGFloat = 0 + var y: CGFloat = 0 + var rowHeight: CGFloat = 0 + for view in itemViews { + let fitted = view.sizeThatFits(CGSize(width: min(180, bounds.width), height: 34)) + let width = min(bounds.width, max(42, fitted.width)) + let height = max(34, fitted.height) + if x > 0, x + width > bounds.width { + x = 0 + y += rowHeight + 12 + rowHeight = 0 + } + view.frame = CGRect(x: x, y: y, width: width, height: height) + x += width + 12 + rowHeight = max(rowHeight, height) + } + if widthChanged { invalidateIntrinsicContentSize() } + } + + override var intrinsicContentSize: CGSize { + let width = bounds.width > 0 ? bounds.width : 280 + var x: CGFloat = 0 + var totalHeight: CGFloat = 0 + var rowHeight: CGFloat = 0 + for view in itemViews { + let fitted = view.sizeThatFits(CGSize(width: min(180, width), height: 34)) + let itemWidth = min(width, max(42, fitted.width)) + let itemHeight = max(34, fitted.height) + if x > 0, x + itemWidth > width { + totalHeight += rowHeight + 12 + x = 0 + rowHeight = 0 + } + x += itemWidth + 12 + rowHeight = max(rowHeight, itemHeight) + } + return CGSize(width: UIView.noIntrinsicMetric, height: itemViews.isEmpty ? 0 : totalHeight + rowHeight) + } + + @objc private func moreTapped() { + onMore?() + } +} + +/// 审核页“新增角色/新增景区”白色卡片。 +final class PermissionStaticSectionView: UIView { + + private let titleLabel = UILabel() + private let chips = PermissionChipCollectionView(style: .selectedStatic) + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .white + layer.cornerRadius = 12 + titleLabel.font = .systemFont(ofSize: 16, weight: .medium) + titleLabel.textColor = PermissionApplyUITokens.text + addSubview(titleLabel) + addSubview(chips) + titleLabel.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(16) + } + chips.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(12) + make.leading.trailing.bottom.equalToSuperview().inset(16) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 更新标题和不可删除的蓝色标签。 + func apply(title: String, names: [String]) { + titleLabel.text = title + chips.apply(titles: names) + } +} + +/// Android 风格角色或景区删除确认弹窗。 +final class PermissionDeleteDialogViewController: UIViewController { + + private let target: PermissionDeletionTarget + private let onCancel: () -> Void + private let onConfirm: () -> Void + + /// 创建删除确认弹窗。 + init(target: PermissionDeletionTarget, onCancel: @escaping () -> Void, onConfirm: @escaping () -> Void) { + self.target = target + 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.32) + let card = UIView() + card.backgroundColor = .white + card.layer.cornerRadius = 16 + + let titleLabel = UILabel() + titleLabel.text = target.dialogTitle + titleLabel.font = .systemFont(ofSize: 18, weight: .bold) + titleLabel.textColor = UIColor(hex: 0xFF0000) + + let messageLabel = UILabel() + messageLabel.numberOfLines = 0 + let message = NSMutableAttributedString( + string: target.dialogMessage, + attributes: [.font: UIFont.systemFont(ofSize: 16), .foregroundColor: UIColor(hex: 0x333333)] + ) + let name: String + switch target { + case .role(let value), .scenic(_, let value): name = value + } + let range = (target.dialogMessage as NSString).range(of: name) + if range.location != NSNotFound { + message.addAttribute(.font, value: UIFont.systemFont(ofSize: 16, weight: .bold), range: range) + } + messageLabel.attributedText = message + + let cancel = makeButton(title: "取消", background: UIColor(hex: 0xFFE5E5)) + let confirm = makeButton(title: "确认", background: UIColor(hex: 0xFF0000)) + cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) + confirm.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) + let buttons = UIStackView(arrangedSubviews: [cancel, confirm]) + buttons.axis = .horizontal + buttons.spacing = 12 + buttons.distribution = .fillEqually + buttons.snp.makeConstraints { make in make.height.equalTo(44) } + + let stack = UIStackView(arrangedSubviews: [titleLabel, messageLabel, buttons]) + stack.axis = .vertical + stack.spacing = 16 + stack.setCustomSpacing(24, after: messageLabel) + view.addSubview(card) + card.addSubview(stack) + card.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(32) + } + stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(24) } + } + + private func makeButton(title: String, background: UIColor) -> UIButton { + let button = UIButton(type: .system) + button.setTitle(title, for: .normal) + button.setTitleColor(.white, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + button.backgroundColor = background + button.layer.cornerRadius = 8 + button.accessibilityLabel = title + return button + } + + @objc private func cancelTapped() { + dismiss(animated: true) { [onCancel] in onCancel() } + } + + @objc private func confirmTapped() { + dismiss(animated: true) { [onConfirm] in onConfirm() } + } +} + +/// 某个已有角色的全部景区弹窗。 +final class PermissionScenicListDialogViewController: UIViewController { + + private enum Section { case main } + + private let roleName: String + private let scenics: [ScenicInfo] + private let onDismiss: () -> Void + private let tableView = UITableView(frame: .zero, style: .plain) + private var dataSource: UITableViewDiffableDataSource! + + /// 创建固定400pt列表高度的景区详情弹窗。 + init(roleName: String, scenics: [ScenicInfo], onDismiss: @escaping () -> Void) { + self.roleName = roleName + self.scenics = scenics + self.onDismiss = onDismiss + 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.32) + let card = UIView() + card.backgroundColor = .white + card.layer.cornerRadius = 16 + + let titleLabel = UILabel() + titleLabel.text = roleName + titleLabel.font = .systemFont(ofSize: 18, weight: .bold) + titleLabel.textColor = UIColor(hex: 0x333333) + let closeButton = UIButton(type: .system) + closeButton.setImage(UIImage(systemName: "xmark"), for: .normal) + closeButton.tintColor = AppColor.textSecondary + closeButton.accessibilityLabel = "关闭" + closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside) + + tableView.separatorStyle = .none + tableView.backgroundColor = .white + tableView.rowHeight = 50 + tableView.register(PermissionScenicDialogCell.self, forCellReuseIdentifier: PermissionScenicDialogCell.reuseIdentifier) + dataSource = UITableViewDiffableDataSource(tableView: tableView) { + tableView, indexPath, scenic in + guard let cell = tableView.dequeueReusableCell( + withIdentifier: PermissionScenicDialogCell.reuseIdentifier, + for: indexPath + ) as? PermissionScenicDialogCell else { return UITableViewCell() } + cell.apply(name: scenic.name) + return cell + } + + view.addSubview(card) + [titleLabel, closeButton, tableView].forEach(card.addSubview) + card.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(32) + } + titleLabel.snp.makeConstraints { make in + make.top.leading.equalToSuperview().inset(24) + make.trailing.lessThanOrEqualTo(closeButton.snp.leading).offset(-8) + make.centerY.equalTo(closeButton) + } + closeButton.snp.makeConstraints { make in + make.top.trailing.equalToSuperview().inset(16) + make.size.equalTo(44) + } + tableView.snp.makeConstraints { make in + make.top.equalTo(closeButton.snp.bottom).offset(8) + make.leading.trailing.bottom.equalToSuperview().inset(24) + make.height.equalTo(400) + } + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(scenics) + dataSource.apply(snapshot, animatingDifferences: false) + } + + @objc private func closeTapped() { + dismiss(animated: true) { [onDismiss] in onDismiss() } + } +} + +/// 景区详情弹窗中的绿色标签行。 +private final class PermissionScenicDialogCell: UITableViewCell { + + static let reuseIdentifier = "PermissionScenicDialogCell" + private let chipLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + chipLabel.font = .systemFont(ofSize: 14) + chipLabel.textColor = PermissionApplyUITokens.existingGreen + chipLabel.backgroundColor = PermissionApplyUITokens.existingGreenBackground + chipLabel.layer.cornerRadius = 4 + chipLabel.layer.borderWidth = 1 + chipLabel.layer.borderColor = PermissionApplyUITokens.existingGreen.cgColor + chipLabel.clipsToBounds = true + chipLabel.textAlignment = .center + contentView.addSubview(chipLabel) + chipLabel.snp.makeConstraints { make in + make.leading.equalToSuperview() + make.centerY.equalToSuperview() + make.height.equalTo(34) + make.trailing.lessThanOrEqualToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(name: String) { + chipLabel.text = " \(name) " + chipLabel.accessibilityLabel = name + } +} diff --git a/suixinkan/UI/Home/Views/ScenicSelectionViews.swift b/suixinkan/UI/Home/Views/ScenicSelectionViews.swift index 3b81792..bddb73a 100644 --- a/suixinkan/UI/Home/Views/ScenicSelectionViews.swift +++ b/suixinkan/UI/Home/Views/ScenicSelectionViews.swift @@ -137,7 +137,9 @@ final class ScenicCurrentLocationBar: UIView { relocateButton.setTitleColor(AppColor.primary, for: .normal) relocateButton.backgroundColor = AppColor.primaryLight relocateButton.layer.cornerRadius = 4 - relocateButton.contentEdgeInsets = UIEdgeInsets(top: 2, left: 8, bottom: 2, right: 8) + relocateButton.setConfigurationContentInsets( + NSDirectionalEdgeInsets(top: 2, leading: 8, bottom: 2, trailing: 8) + ) relocateButton.addTarget(self, action: #selector(relocateTapped), for: .touchUpInside) relocateButton.setContentHuggingPriority(.required, for: .horizontal) relocateButton.setContentCompressionResistancePriority(.required, for: .horizontal) diff --git a/suixinkan/UI/Live/LiveDetailViewController.swift b/suixinkan/UI/Live/LiveDetailViewController.swift index bdcfbc4..311be5b 100644 --- a/suixinkan/UI/Live/LiveDetailViewController.swift +++ b/suixinkan/UI/Live/LiveDetailViewController.swift @@ -245,7 +245,9 @@ private final class LiveSettingsCardView: UIView { [clarityButton, smoothButton].forEach { button in button.titleLabel?.font = .systemFont(ofSize: 14) button.layer.cornerRadius = 4 - button.contentEdgeInsets = UIEdgeInsets(top: 5, left: 12, bottom: 5, right: 12) + button.setConfigurationContentInsets( + NSDirectionalEdgeInsets(top: 5, leading: 12, bottom: 5, trailing: 12) + ) } clarityButton.setTitle(LivePushMode.clarityFirst.title, for: .normal) smoothButton.setTitle(LivePushMode.smoothFirst.title, for: .normal) diff --git a/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift b/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift index 758d60d..b52737c 100644 --- a/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift +++ b/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift @@ -559,7 +559,9 @@ private final class MaterialAnchoredDropdownView: UIControl { let button = UIButton(type: .system) button.tag = index button.contentHorizontalAlignment = .leading - button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12) + button.setConfigurationContentInsets( + NSDirectionalEdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12) + ) button.setTitle(option, for: .normal) button.setTitleColor(index == selectedIndex ? AppColor.primary : .black, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 14, weight: index == selectedIndex ? .medium : .regular) @@ -1026,7 +1028,9 @@ private final class MaterialTagEditorView: UIView { button.titleLabel?.font = .systemFont(ofSize: 12) button.backgroundColor = UIColor(hex: 0xEFF6FF) button.layer.cornerRadius = 4 - button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) + button.setConfigurationContentInsets( + NSDirectionalEdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8) + ) button.setContentHuggingPriority(.required, for: .horizontal) button.setContentCompressionResistancePriority(.required, for: .horizontal) button.snp.makeConstraints { make in diff --git a/suixinkan/UI/MaterialManagement/MaterialListViewController.swift b/suixinkan/UI/MaterialManagement/MaterialListViewController.swift index 5d259a9..a4b298e 100644 --- a/suixinkan/UI/MaterialManagement/MaterialListViewController.swift +++ b/suixinkan/UI/MaterialManagement/MaterialListViewController.swift @@ -281,7 +281,9 @@ final class MaterialListViewController: BaseViewController { MaterialFilterStatus.allCases.forEach { status in let button = UIButton(type: .system) button.contentHorizontalAlignment = .leading - button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) + button.setConfigurationContentInsets( + NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16) + ) button.setTitle(status.title, for: .normal) button.backgroundColor = .white button.snp.makeConstraints { make in diff --git a/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift b/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift index cf034c0..19aeebd 100644 --- a/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift +++ b/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift @@ -351,7 +351,9 @@ final class ProfileSpaceSettingsViewController: BaseViewController { addDeviceButton.setTitleColor(AppColor.primary, for: .normal) addDeviceButton.titleLabel?.font = .systemFont(ofSize: 14) addDeviceButton.layer.cornerRadius = 4 - addDeviceButton.contentEdgeInsets = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8) + addDeviceButton.setConfigurationContentInsets( + NSDirectionalEdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8) + ) addDeviceButton.contentHorizontalAlignment = .left addDeviceButton.snp.makeConstraints { make in make.height.equalTo(28) } deviceWrap.setTrailingView(addDeviceButton) @@ -940,7 +942,6 @@ final class ProfileAddScheduleViewController: BaseViewController { orderButton.layer.borderColor = AppColor.border.cgColor orderButton.layer.borderWidth = 1 orderButton.layer.cornerRadius = 8 - orderButton.contentEdgeInsets = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) var configuration = UIButton.Configuration.plain() configuration.title = "关联订单 (可选)" configuration.image = UIImage(systemName: "chevron.down") @@ -1101,7 +1102,9 @@ private final class ProfileTimeButton: UIButton { setTitleColor(UIColor(hex: 0x4B5563), for: .normal) backgroundColor = AppColor.inputBackground layer.cornerRadius = 4 - contentEdgeInsets = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) + setConfigurationContentInsets( + NSDirectionalEdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8) + ) contentHorizontalAlignment = .center } @@ -1253,7 +1256,10 @@ private final class ProfileTagWrapView: UIView { let button = UIButton(type: .system) button.setTitle(removable ? "\(title) ×" : title, for: .normal) button.titleLabel?.font = .systemFont(ofSize: style == .device ? 14 : 12) - button.contentEdgeInsets = UIEdgeInsets(top: 2, left: style == .device ? 8 : 4, bottom: 2, right: style == .device ? 8 : 4) + let horizontalInset: CGFloat = style == .device ? 8 : 4 + button.setConfigurationContentInsets( + NSDirectionalEdgeInsets(top: 2, leading: horizontalInset, bottom: 2, trailing: horizontalInset) + ) button.layer.cornerRadius = 4 switch style { case .label: diff --git a/suixinkan/UI/SampleManagement/SampleListViewController.swift b/suixinkan/UI/SampleManagement/SampleListViewController.swift index b36801a..c57ddd8 100644 --- a/suixinkan/UI/SampleManagement/SampleListViewController.swift +++ b/suixinkan/UI/SampleManagement/SampleListViewController.swift @@ -287,7 +287,9 @@ final class SampleListViewController: SampleBaseViewController { SampleFilterStatus.allCases.forEach { status in let button = UIButton(type: .system) button.contentHorizontalAlignment = .leading - button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) + button.setConfigurationContentInsets( + NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16) + ) button.setTitle(status.title, for: .normal) button.backgroundColor = .white button.snp.makeConstraints { make in diff --git a/suixinkan/UI/SampleManagement/SampleManagementUI.swift b/suixinkan/UI/SampleManagement/SampleManagementUI.swift index 8f4f139..92acaba 100644 --- a/suixinkan/UI/SampleManagement/SampleManagementUI.swift +++ b/suixinkan/UI/SampleManagement/SampleManagementUI.swift @@ -107,7 +107,9 @@ final class SampleAnchoredDropdownView: UIControl, UIScrollViewDelegate { let button = UIButton(type: .system) button.tag = index button.contentHorizontalAlignment = .leading - button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12) + button.setConfigurationContentInsets( + NSDirectionalEdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12) + ) button.setTitle(option.title, for: .normal) let color = option.isSelected ? AppColor.primary : (option.isLoadMore ? AppColor.textSecondary : .black) button.setTitleColor(color, for: .normal) diff --git a/suixinkan/UI/Statistics/StatisticsPeriodButton.swift b/suixinkan/UI/Statistics/StatisticsPeriodButton.swift index 21d96c0..84158a6 100644 --- a/suixinkan/UI/Statistics/StatisticsPeriodButton.swift +++ b/suixinkan/UI/Statistics/StatisticsPeriodButton.swift @@ -9,17 +9,33 @@ import UIKit /// 数据统计周期 pill 按钮,对齐 Android `PeriodButton`。 final class StatisticsPeriodButton: UIButton { + /// 按钮圆角策略,支持随高度变化的胶囊形与固定圆角。 + enum CornerStyle: Equatable { + case capsule + case fixed(CGFloat) + } + var periodTitle: String = "" { didSet { setTitle(periodTitle, for: .normal) } } + /// 当前圆角策略,默认使用高度一半的胶囊形。 + var cornerStyle: CornerStyle = .capsule { + didSet { setNeedsLayout() } + } + override var isSelected: Bool { didSet { updateAppearance() } } override func layoutSubviews() { super.layoutSubviews() - layer.cornerRadius = bounds.height / 2 + switch cornerStyle { + case .capsule: + layer.cornerRadius = bounds.height / 2 + case .fixed(let radius): + layer.cornerRadius = max(0, radius) + } } override init(frame: CGRect) { @@ -36,7 +52,9 @@ final class StatisticsPeriodButton: UIButton { titleLabel?.font = .app(.bodyMedium) layer.cornerCurve = .continuous clipsToBounds = true - contentEdgeInsets = UIEdgeInsets(top: 6, left: 20, bottom: 6, right: 20) + setConfigurationContentInsets( + NSDirectionalEdgeInsets(top: 6, leading: 20, bottom: 6, trailing: 20) + ) updateAppearance() } diff --git a/suixinkan/UI/TravelAlbum/CreateTravelAlbumSheetViewController.swift b/suixinkan/UI/TravelAlbum/CreateTravelAlbumSheetViewController.swift index d31d672..095ee29 100644 --- a/suixinkan/UI/TravelAlbum/CreateTravelAlbumSheetViewController.swift +++ b/suixinkan/UI/TravelAlbum/CreateTravelAlbumSheetViewController.swift @@ -78,7 +78,9 @@ final class CreateTravelAlbumSheetViewController: BaseViewController { orderButton.layer.cornerRadius = 8 orderButton.layer.borderColor = AppColor.border.cgColor orderButton.layer.borderWidth = 1 - orderButton.contentEdgeInsets = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) + orderButton.setConfigurationContentInsets( + NSDirectionalEdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12) + ) configureActionButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xF4F4F4), titleColor: AppColor.textSecondary) configureActionButton(confirmButton, title: "确定", backgroundColor: AppColor.primary, titleColor: .white) diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift index 7df228b..a59a1ed 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift @@ -216,7 +216,9 @@ final class TravelAlbumDetailViewController: BaseViewController { private func configurePillButton(_ button: UIButton) { button.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium) button.layer.cornerRadius = 16 - button.contentEdgeInsets = UIEdgeInsets(top: 7, left: 14, bottom: 7, right: 14) + button.setConfigurationContentInsets( + NSDirectionalEdgeInsets(top: 7, leading: 14, bottom: 7, trailing: 14) + ) } private func configureTabButton(_ button: UIButton, title: String, selected: Bool) { diff --git a/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift b/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift index c957a17..403089c 100644 --- a/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift +++ b/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift @@ -165,7 +165,9 @@ final class WiredCameraTransferViewController: BaseViewController { } retouchButton.isUserInteractionEnabled = false formatButton.isUserInteractionEnabled = false - modeButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 32) + modeButton.setConfigurationContentInsets( + NSDirectionalEdgeInsets(top: 6, leading: 10, bottom: 6, trailing: 32) + ) modeChevronView.image = UIImage(systemName: "chevron.down")? .withConfiguration(UIImage.SymbolConfiguration(pointSize: 13, weight: .semibold)) modeChevronView.tintColor = AppColor.textTertiary @@ -648,7 +650,9 @@ final class WiredCameraTransferViewController: BaseViewController { button.layer.borderWidth = 1 button.layer.borderColor = AppColor.border.cgColor button.contentHorizontalAlignment = .leading - button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10) + button.setConfigurationContentInsets( + NSDirectionalEdgeInsets(top: 6, leading: 10, bottom: 6, trailing: 10) + ) } private func makeStatButton(title: String, count: Int, selected: Bool, danger: Bool) -> UIButton { diff --git a/suixinkan/UI/WildPhotographerReport/Views/WildReportCommonViews.swift b/suixinkan/UI/WildPhotographerReport/Views/WildReportCommonViews.swift index 73e1c5f..a77d5d0 100644 --- a/suixinkan/UI/WildPhotographerReport/Views/WildReportCommonViews.swift +++ b/suixinkan/UI/WildPhotographerReport/Views/WildReportCommonViews.swift @@ -29,8 +29,11 @@ enum WildReportUI { button.setTitleColor(style == .primary ? .white : AppColor.primary, for: .normal) button.backgroundColor = style == .primary ? AppColor.primary : AppColor.primaryLight button.layer.cornerRadius = AppRadius.md - button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -4, bottom: 0, right: 4) - button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12) + button.setConfigurationContentInsets( + NSDirectionalEdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12) + ) + button.configuration?.imagePlacement = .leading + button.configuration?.imagePadding = 4 return button } } diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportHomeViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportHomeViewController.swift index 498723d..93cdbad 100644 --- a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportHomeViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportHomeViewController.swift @@ -183,7 +183,9 @@ final class WildPhotographerReportHomeViewController: BaseViewController { ruleButton.setTitleColor(AppColor.primary, for: .normal) ruleButton.backgroundColor = AppColor.primaryLight ruleButton.layer.cornerRadius = 18 - ruleButton.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12) + ruleButton.setConfigurationContentInsets( + NSDirectionalEdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12) + ) ruleButton.addTarget(self, action: #selector(ruleTapped), for: .touchUpInside) ruleButton.snp.makeConstraints { make in make.height.equalTo(36) diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift index 1ab7adf..efed8c9 100644 --- a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift @@ -1186,8 +1186,11 @@ final class WildPhotographerReportDetailViewController: BaseViewController { button.titleLabel?.font = .systemFont(ofSize: 16, weight: .bold) button.titleLabel?.adjustsFontSizeToFitWidth = true button.titleLabel?.minimumScaleFactor = 0.82 - button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -4, bottom: 0, right: 4) - button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10) + button.setConfigurationContentInsets( + NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10) + ) + button.configuration?.imagePlacement = .leading + button.configuration?.imagePadding = 4 return button } diff --git a/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift b/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift index 642152c..c0fa403 100644 --- a/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift @@ -605,8 +605,11 @@ final class WildReportRiskMapViewController: BaseViewController { button.setTitleColor(.white, for: .normal) button.backgroundColor = color button.layer.cornerRadius = 12 - button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -3, bottom: 0, right: 3) - button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12) + button.setConfigurationContentInsets( + NSDirectionalEdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12) + ) + button.configuration?.imagePlacement = .leading + button.configuration?.imagePadding = 3 button.snp.makeConstraints { make in make.height.equalTo(44) } return button } diff --git a/suixinkanTests/HomeCommonMenuStoreTests.swift b/suixinkanTests/HomeCommonMenuStoreTests.swift index 3839379..c5fb90f 100644 --- a/suixinkanTests/HomeCommonMenuStoreTests.swift +++ b/suixinkanTests/HomeCommonMenuStoreTests.swift @@ -78,10 +78,12 @@ final class HomeCommonMenuStoreTests: XCTestCase { XCTAssertEqual(item?.iconName, "shield.fill") } - func testHomeMenuCatalogUsesSyncedSymbols() { - XCTAssertEqual(HomeMenuCatalog.item(for: "space_settings")?.iconName, "person.crop.square.fill") - XCTAssertEqual(HomeMenuCatalog.item(for: "wallet")?.iconName, "creditcard.fill") + func testHomeMenuCatalogUsesIOS16CompatibleIcons() { + XCTAssertEqual(HomeMenuCatalog.item(for: "space_settings")?.iconName, "home_menu_space") + XCTAssertEqual(HomeMenuCatalog.item(for: "wallet")?.iconName, "home_menu_wallet") XCTAssertEqual(HomeMenuCatalog.item(for: "sample_management")?.iconName, "sample_ic_home_menu_route") + XCTAssertEqual(HomeMenuCatalog.item(for: "travel_album")?.iconName, "rectangle.stack.badge.plus") + XCTAssertEqual(HomeMenuCatalog.item(for: "store")?.iconName, "building.2.fill") XCTAssertEqual(HomeMenuCatalog.item(for: "/scenic-queue")?.iconName, "person.3.fill") XCTAssertEqual(HomeMenuItem.moreFunctions.iconName, "ellipsis") } diff --git a/suixinkanTests/HomeViewModelTests.swift b/suixinkanTests/HomeViewModelTests.swift index cc2596d..b170caf 100644 --- a/suixinkanTests/HomeViewModelTests.swift +++ b/suixinkanTests/HomeViewModelTests.swift @@ -123,6 +123,53 @@ final class HomeViewModelTests: XCTestCase { XCTAssertTrue(viewModel.commonMenus.isEmpty) } + func testPermissionApplyDestinationRoutesReviewingAndRejectedToStatus() async throws { + let reviewingJSON = """ + {"code":100000,"msg":"success","data":[{"code":"AP001","role_id":1,"role_name":"摄影师","scenic_list":[],"status":1,"status_label":"审核中","created_at":"","audit_note":"","audited_at":null,"audited_by":null}]} + """.data(using: .utf8)! + let rejectedJSON = """ + {"code":100000,"msg":"success","data":[{"code":"AP002","role_id":1,"role_name":"摄影师","scenic_list":[],"status":3,"status_label":"已驳回","created_at":"","audit_note":"资料不全","audited_at":null,"audited_by":null}]} + """.data(using: .utf8)! + let session = MockURLSession(responses: [reviewingJSON, rejectedJSON]) + let api = HomeAPI(client: APIClient(environment: .testing, session: session)) + let viewModel = HomeViewModel(appStore: appStore) + + let reviewingDestination = await viewModel.resolvePermissionApplyDestination(api: api) + let rejectedDestination = await viewModel.resolvePermissionApplyDestination(api: api) + XCTAssertEqual(reviewingDestination, .status(applyCode: "AP001")) + XCTAssertEqual(rejectedDestination, .status(applyCode: "AP002")) + } + + func testPermissionApplyDestinationFallsBackToApply() async throws { + let completedJSON = """ + {"code":100000,"msg":"success","data":[{"code":"AP003","role_id":1,"role_name":"摄影师","scenic_list":[],"status":2,"status_label":"已通过","created_at":"","audit_note":"","audited_at":null,"audited_by":null}]} + """.data(using: .utf8)! + let emptyJSON = #"{"code":100000,"msg":"success","data":[]}"#.data(using: .utf8)! + let session = MockURLSession(responses: [completedJSON, emptyJSON]) + let api = HomeAPI(client: APIClient(environment: .testing, session: session)) + let viewModel = HomeViewModel(appStore: appStore) + + let completedDestination = await viewModel.resolvePermissionApplyDestination(api: api) + let emptyDestination = await viewModel.resolvePermissionApplyDestination(api: api) + XCTAssertEqual(completedDestination, .apply) + XCTAssertEqual(emptyDestination, .apply) + } + + func testPermissionApplyDestinationFallsBackToApplyOnError() async { + let response = HTTPURLResponse( + url: URL(string: "https://api-test.zhifly.cn/mock")!, + statusCode: 500, + httpVersion: nil, + headerFields: nil + )! + let session = MockURLSession(results: [.success((Data(), response))]) + let api = HomeAPI(client: APIClient(environment: .testing, session: session)) + let viewModel = HomeViewModel(appStore: appStore) + + let destination = await viewModel.resolvePermissionApplyDestination(api: api) + XCTAssertEqual(destination, .apply) + } + func testRolePermissionBuildsCommonMenusAndStoreCardRole() async throws { appStore.session.roleCode = AppRoleCode.storeAdmin.rawValue let permissionResponse = RolePermissionResponse( diff --git a/suixinkanTests/PermissionApplyStatusViewModelTests.swift b/suixinkanTests/PermissionApplyStatusViewModelTests.swift index 901b467..1468f0f 100644 --- a/suixinkanTests/PermissionApplyStatusViewModelTests.swift +++ b/suixinkanTests/PermissionApplyStatusViewModelTests.swift @@ -53,4 +53,30 @@ final class PermissionApplyStatusViewModelTests: XCTestCase { XCTAssertNil(edited) } + + func testCompletedApplicationIsNotDisplayedAsPending() async throws { + let json = """ + {"code":100000,"msg":"success","data":[{"code":"AP004","role_id":1,"role_name":"摄影师","scenic_list":[],"status":2,"status_label":"已通过","created_at":"","audit_note":"","audited_at":null,"audited_by":null}]} + """.data(using: .utf8)! + let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [json]))) + let viewModel = PermissionApplyStatusViewModel(applyCode: "AP004") + + await viewModel.loadPendingData(api: api) + + XCTAssertNil(viewModel.pendingData) + XCTAssertFalse(viewModel.isLoading) + } + + func testScenicListDialogState() { + let viewModel = PermissionApplyStatusViewModel(applyCode: "AP005") + let scenic = ScenicInfo(id: 12, name: "西湖景区", status: 1) + viewModel.showScenicList(roleName: "摄影师", scenics: [scenic]) + + XCTAssertTrue(viewModel.showScenicListDialog) + XCTAssertEqual(viewModel.dialogRoleName, "摄影师") + XCTAssertEqual(viewModel.dialogScenicList, [scenic]) + + viewModel.hideScenicList() + XCTAssertFalse(viewModel.showScenicListDialog) + } } diff --git a/suixinkanTests/PermissionApplyViewModelTests.swift b/suixinkanTests/PermissionApplyViewModelTests.swift index 4acbf05..a698276 100644 --- a/suixinkanTests/PermissionApplyViewModelTests.swift +++ b/suixinkanTests/PermissionApplyViewModelTests.swift @@ -46,6 +46,8 @@ final class PermissionApplyViewModelTests: XCTestCase { XCTAssertEqual(existing?.isDisabled, true) XCTAssertEqual(existing?.isSelected, true) XCTAssertEqual(fresh?.isDisabled, false) + if let existing { viewModel.toggleScenic(existing) } + XCTAssertTrue(viewModel.selectedScenicIds.isEmpty) } func testSubmitValidationRequiresRoleAndScenic() async { @@ -55,21 +57,24 @@ final class PermissionApplyViewModelTests: XCTestCase { viewModel.onShowMessage = { message = $0 } await viewModel.submit(api: api) - XCTAssertEqual(message, "请选择角色") + XCTAssertEqual(message, "请选择景区和角色") + XCTAssertFalse(viewModel.canSubmit) viewModel.selectRole(RoleOption(id: 10, name: "摄影师", description: "", isSelected: true)) await viewModel.submit(api: api) - XCTAssertEqual(message, "请至少选择一个景区") + XCTAssertEqual(message, "请选择景区和角色") + XCTAssertFalse(viewModel.canSubmit) } - func testSubmitSuccessReturnsApplyCode() async throws { + func testSubmitSuccessUsesAndroidPayloadAndCompletion() async throws { let scenicJSON = """ {"code":100000,"msg":"success","data":{"total":1,"list":[{"id":101,"name":"新景区"}]}} """.data(using: .utf8)! let submitJSON = """ {"code":100000,"msg":"success","data":{"code":"AP999"}} """.data(using: .utf8)! - let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [scenicJSON, submitJSON]))) + let session = MockURLSession(responses: [scenicJSON, submitJSON]) + let api = HomeAPI(client: APIClient(environment: .testing, session: session)) let viewModel = PermissionApplyViewModel(appStore: appStore) viewModel.selectRole(RoleOption(id: 10, name: "摄影师", description: "", isSelected: true)) await viewModel.loadAvailableScenics(api: api) @@ -77,10 +82,138 @@ final class PermissionApplyViewModelTests: XCTestCase { viewModel.toggleScenic(scenic) } - var applyCode: String? - viewModel.onSubmitSuccess = { applyCode = $0 } + var didSubmit = false + viewModel.onSubmitSuccess = { didSubmit = true } + XCTAssertTrue(viewModel.canSubmit) await viewModel.submit(api: api) - XCTAssertEqual(applyCode, "AP999") + XCTAssertTrue(didSubmit) + XCTAssertFalse(viewModel.isSubmitting) + XCTAssertEqual(session.requests.last?.url?.path, "/api/yf-handset-app/photog/role-apply/submit") + let body = try XCTUnwrap(session.requests.last?.httpBody) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertEqual(object["role_id"] as? Int, 10) + XCTAssertEqual(object["scenic_id"] as? [Int], [101]) + } + + func testSubmitFailureRestoresEnabledButtonAndKeepsSelection() async throws { + let scenicJSON = """ + {"code":100000,"msg":"success","data":{"total":1,"list":[{"id":101,"name":"新景区"}]}} + """.data(using: .utf8)! + let failureJSON = #"{"code":100001,"msg":"failed","data":null}"#.data(using: .utf8)! + let api = HomeAPI( + client: APIClient( + environment: .testing, + session: MockURLSession(responses: [scenicJSON, failureJSON]) + ) + ) + let viewModel = PermissionApplyViewModel(appStore: appStore) + viewModel.selectRole(RoleOption(id: 10, name: "摄影师", description: "", isSelected: true)) + await viewModel.loadAvailableScenics(api: api) + viewModel.toggleScenic(try XCTUnwrap(viewModel.availableScenics.first)) + var message: String? + viewModel.onShowMessage = { message = $0 } + + await viewModel.submit(api: api) + + XCTAssertEqual(message, "提交失败,请稍后重试") + XCTAssertFalse(viewModel.isSubmitting) + XCTAssertTrue(viewModel.canSubmit) + XCTAssertEqual(viewModel.selectedScenicIds, [101]) + } + + func testSwitchingRoleClearsScenicSelectionAndLoadedOptions() async throws { + let scenicJSON = """ + {"code":100000,"msg":"success","data":{"total":1,"list":[{"id":101,"name":"新景区"}]}} + """.data(using: .utf8)! + let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [scenicJSON]))) + let viewModel = PermissionApplyViewModel(appStore: appStore) + viewModel.selectRole(RoleOption(id: 10, name: "摄影师", description: "", isSelected: true)) + await viewModel.loadAvailableScenics(api: api) + viewModel.toggleScenic(try XCTUnwrap(viewModel.availableScenics.first)) + + viewModel.selectRole(RoleOption(id: 11, name: "门店管理员", description: "", isSelected: true)) + + XCTAssertEqual(viewModel.selectedRoleId, 11) + XCTAssertTrue(viewModel.selectedScenicIds.isEmpty) + XCTAssertTrue(viewModel.availableScenics.isEmpty) + } + + func testAvailableRolesAreDeduplicatedByRoleID() { + appStore.permissions.saveRolePermissionList([ + RolePermissionResponse( + role: RoleInfo(id: 10, name: "摄影师", roleCode: "PHOTOGRAPHER", notes: "拍摄"), + scenic: [ScenicInfo(id: 100, name: "景区A", status: 1)] + ), + RolePermissionResponse( + role: RoleInfo(id: 10, name: "摄影师", roleCode: "PHOTOGRAPHER", notes: "拍摄"), + scenic: [ScenicInfo(id: 101, name: "景区B", status: 1)] + ), + ]) + + let viewModel = PermissionApplyViewModel(appStore: appStore) + + XCTAssertEqual(viewModel.availableRoles.map(\.id), [10]) + XCTAssertEqual(viewModel.rolePermissionList.count, 2) + } + + func testScenicSearchAndDeletionConfirmation() async throws { + let scenicJSON = """ + {"code":100000,"msg":"success","data":{"total":3,"list":[{"id":100,"name":"已有景区"},{"id":101,"name":"西湖景区"},{"id":102,"name":"东湖景区"}]}} + """.data(using: .utf8)! + let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [scenicJSON]))) + let viewModel = PermissionApplyViewModel(appStore: appStore) + viewModel.selectRole(RoleOption(id: 10, name: "摄影师", description: "", isSelected: true)) + await viewModel.loadAvailableScenics(api: api) + + viewModel.updateScenicSearchQuery("西湖") + XCTAssertEqual(viewModel.filteredScenics.map(\.id), [101]) + let scenic = try XCTUnwrap(viewModel.filteredScenics.first) + viewModel.toggleScenic(scenic) + XCTAssertTrue(viewModel.canSubmit) + + viewModel.removeSelectedScenic(id: scenic.id, name: scenic.name) + XCTAssertEqual(viewModel.pendingDeletion, .scenic(id: 101, name: "西湖景区")) + viewModel.cancelDeletion() + XCTAssertEqual(viewModel.selectedScenicIds, [101]) + + viewModel.removeSelectedScenic(id: scenic.id, name: scenic.name) + viewModel.confirmDeletion() + XCTAssertTrue(viewModel.selectedScenicIds.isEmpty) + XCTAssertFalse(viewModel.canSubmit) + } + + func testRoleDeletionRequiresConfirmationAndClearsScenics() async throws { + let scenicJSON = """ + {"code":100000,"msg":"success","data":{"total":1,"list":[{"id":101,"name":"新景区"}]}} + """.data(using: .utf8)! + let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [scenicJSON]))) + let viewModel = PermissionApplyViewModel(appStore: appStore) + viewModel.selectRole(RoleOption(id: 10, name: "摄影师", description: "", isSelected: true)) + await viewModel.loadAvailableScenics(api: api) + viewModel.toggleScenic(try XCTUnwrap(viewModel.availableScenics.first)) + + viewModel.clearRoleSelection() + XCTAssertEqual(viewModel.pendingDeletion, .role(name: "摄影师")) + XCTAssertEqual(viewModel.selectedRoleId, 10) + viewModel.confirmDeletion() + + XCTAssertNil(viewModel.selectedRoleId) + XCTAssertTrue(viewModel.selectedScenicIds.isEmpty) + XCTAssertTrue(viewModel.availableScenics.isEmpty) + } + + func testExistingRoleListAndScenicDialogState() { + let viewModel = PermissionApplyViewModel(appStore: appStore) + XCTAssertEqual(viewModel.rolePermissionList.count, 1) + XCTAssertEqual(viewModel.availableRoles.first?.description, "拍摄") + + let scenics = viewModel.rolePermissionList[0].scenic + viewModel.showScenicList(roleName: "摄影师", scenics: scenics) + XCTAssertTrue(viewModel.showScenicListDialog) + XCTAssertEqual(viewModel.dialogRoleName, "摄影师") + XCTAssertEqual(viewModel.dialogScenicList.map(\.id), [100]) + viewModel.hideScenicList() + XCTAssertFalse(viewModel.showScenicListDialog) } } diff --git a/suixinkanTests/PermissionApplyViewsTests.swift b/suixinkanTests/PermissionApplyViewsTests.swift new file mode 100644 index 0000000..beeef91 --- /dev/null +++ b/suixinkanTests/PermissionApplyViewsTests.swift @@ -0,0 +1,25 @@ +// +// PermissionApplyViewsTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// 权限申请页标签流式布局测试。 +@MainActor +final class PermissionApplyViewsTests: XCTestCase { + + func testSelectedChipsAreLeftAlignedAndWrapWithAndroidSpacing() { + let frames = PermissionChipLayoutMetrics.makeFrames( + itemWidths: [80, 120, 90], + containerWidth: 260 + ) + + XCTAssertEqual(frames[0].minX, 0, accuracy: 0.5) + XCTAssertEqual(frames[1].minX, frames[0].maxX + 12, accuracy: 0.5) + XCTAssertEqual(frames[2].minX, 0, accuracy: 0.5) + XCTAssertEqual(frames[2].minY, frames[0].maxY + 2, accuracy: 0.5) + XCTAssertTrue(frames.allSatisfy { $0.maxX <= 260.5 }) + } +} diff --git a/suixinkanTests/ScenicQueueFeatureTests.swift b/suixinkanTests/ScenicQueueFeatureTests.swift index 2728384..4444db3 100644 --- a/suixinkanTests/ScenicQueueFeatureTests.swift +++ b/suixinkanTests/ScenicQueueFeatureTests.swift @@ -304,7 +304,15 @@ final class ScenicQueueFeatureTests: XCTestCase { let tokenProvider = MockAliyunNLSTokenProvider() let bridge = MockAliyunNuiTTSBridge() let player = MockAliyunPCMStreamingPlayer() + let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.onlineTTS")! + defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.onlineTTS") + let store = AppStore(defaults: defaults) + store.session.userId = "u1" + store.session.accountType = .unknown("photog") + store.session.currentScenicId = 11 + store.scenicQueue.useOfflineTTS = false let manager = AliyunNLSTTSManager( + appStore: store, tokenProvider: tokenProvider, bridge: bridge, streamingPlayer: player @@ -321,6 +329,58 @@ final class ScenicQueueFeatureTests: XCTestCase { XCTAssertEqual(Array(player.events.suffix(3)), ["start:16000", "write:4", "drain"]) } + func testAliyunTTSManagerInitializesOfflineEngineWithBundledVoice() async throws { + let api = MockScenicQueueAPI() + let tokenProvider = MockAliyunNLSTokenProvider() + let bridge = MockAliyunNuiTTSBridge() + bridge.paramValues["model_sample_rate"] = "24000" + let player = MockAliyunPCMStreamingPlayer() + let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.nativeOfflineTTS")! + defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.nativeOfflineTTS") + let store = AppStore(defaults: defaults) + store.session.userId = "u1" + store.session.accountType = .unknown("photog") + store.session.currentScenicId = 11 + store.scenicQueue.useOfflineTTS = true + + let workspace = FileManager.default.temporaryDirectory + .appendingPathComponent("ScenicQueueFeatureTests-\(UUID().uuidString)", isDirectory: true) + let voiceDirectory = workspace.appendingPathComponent("tts/voices", isDirectory: true) + try FileManager.default.createDirectory(at: voiceDirectory, withIntermediateDirectories: true) + try Data([0]).write(to: workspace.appendingPathComponent("tts/languagedata_embedded.bin")) + try Data([0]).write(to: workspace.appendingPathComponent("tts/parameter.cfg")) + try Data([0]).write(to: voiceDirectory.appendingPathComponent("aijia")) + defer { try? FileManager.default.removeItem(at: workspace) } + + let manager = AliyunNLSTTSManager( + appStore: store, + tokenProvider: tokenProvider, + bridge: bridge, + streamingPlayer: player, + workspacePathProvider: { workspace.path } + ) + + XCTAssertTrue(manager.supportsOfflineTTS) + await manager.prepareQueueTTSEngine(api: api) + let ok = await manager.speakPlainTextOnce("请到拍摄区域") + + XCTAssertTrue(ok) + let ticketData = try XCTUnwrap(bridge.initializedTickets.last?.data(using: .utf8)) + let ticket = try XCTUnwrap(JSONSerialization.jsonObject(with: ticketData) as? [String: String]) + XCTAssertEqual(ticket["app_key"], "gfWimmOuTku1AbIV") + XCTAssertEqual(ticket["sdk_code"], "software_nls_tts_offline_standard") + XCTAssertEqual(ticket["mode_type"], "0") + XCTAssertEqual(ticket["workspace"], workspace.path) + XCTAssertEqual(ticket["ak_id"], "ak") + XCTAssertEqual(ticket["sts_token"], "security-token") + XCTAssertTrue(bridge.events.contains { $0.hasPrefix("set:extend_font_name=") && $0.hasSuffix("/tts/voices/aijia") }) + XCTAssertTrue(bridge.events.contains("set:encode_type=pcm")) + XCTAssertTrue(bridge.events.contains("set:sample_rate=24000")) + XCTAssertTrue(bridge.events.contains("set:speed_level=1.0")) + XCTAssertFalse(bridge.events.contains("set:mode_type=2")) + XCTAssertEqual(Array(player.events.suffix(3)), ["start:24000", "write:4", "drain"]) + } + func testSettingsOfflineTTSSwitchReportsUnavailable() { let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.offlineTTS")! defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.offlineTTS") @@ -471,10 +531,13 @@ private final class MockAliyunNLSTokenProvider: AliyunNLSTokenProviding { private final class MockAliyunNuiTTSBridge: AliyunNuiTTSBridging { weak var delegate: AliyunNuiTTSBridgeDelegate? var sdkAvailable = true + var paramValues: [String: String] = [:] private(set) var events: [String] = [] + private(set) var initializedTickets: [String] = [] private let callbackBridge = AliyunNuiTTSBridge() func initialize(ticket: String, logLevel: Int, saveLog: Bool) -> Int { + initializedTickets.append(ticket) events.append("initialize:\(ticket.contains("mock-nls-token"))") return 0 } @@ -507,7 +570,7 @@ private final class MockAliyunNuiTTSBridge: AliyunNuiTTSBridging { } func paramValue(for key: String) -> String? { - nil + paramValues[key] } } diff --git a/suixinkanTests/StatisticsViewModelTests.swift b/suixinkanTests/StatisticsViewModelTests.swift index 56e45db..bb7a3b6 100644 --- a/suixinkanTests/StatisticsViewModelTests.swift +++ b/suixinkanTests/StatisticsViewModelTests.swift @@ -49,6 +49,25 @@ final class StatisticsViewModelTests: XCTestCase { XCTAssertEqual(viewModel.rangeValue(for: "本月"), "4") } + func testPeriodButtonCapsuleCornerRadiusUsesHalfHeight() { + let button = StatisticsPeriodButton(frame: CGRect(x: 0, y: 0, width: 80, height: 32)) + + button.setNeedsLayout() + button.layoutIfNeeded() + + XCTAssertEqual(button.layer.cornerRadius, 16) + } + + func testPeriodButtonFixedCornerRadiusDoesNotFollowHeight() { + let button = StatisticsPeriodButton(frame: CGRect(x: 0, y: 0, width: 80, height: 32)) + button.cornerStyle = .fixed(10) + + button.setNeedsLayout() + button.layoutIfNeeded() + + XCTAssertEqual(button.layer.cornerRadius, 10) + } + func testFormattedSelectedPeriodTimeForToday() { let viewModel = StatisticsViewModel() let text = viewModel.formattedSelectedPeriodTime(for: "今日") diff --git a/suixinkanTests/UIButtonConfigurationTests.swift b/suixinkanTests/UIButtonConfigurationTests.swift new file mode 100644 index 0000000..0da4b89 --- /dev/null +++ b/suixinkanTests/UIButtonConfigurationTests.swift @@ -0,0 +1,71 @@ +// +// UIButtonConfigurationTests.swift +// suixinkanTests +// + +import UIKit +import XCTest +@testable import suixinkan + +/// `UIButton` configuration 内容边距辅助方法测试。 +@MainActor +final class UIButtonConfigurationTests: XCTestCase { + + func testContentInsetsCreatePlainConfiguration() { + let button = UIButton(type: .system) + + button.setConfigurationContentInsets( + NSDirectionalEdgeInsets(top: 2, leading: 4, bottom: 6, trailing: 8) + ) + + let contentInsets = button.configuration?.contentInsets + XCTAssertEqual(contentInsets?.top, 2) + XCTAssertEqual(contentInsets?.leading, 4) + XCTAssertEqual(contentInsets?.bottom, 6) + XCTAssertEqual(contentInsets?.trailing, 8) + } + + func testContentInsetsPreserveExistingConfiguration() { + let image = UIImage(systemName: "star.fill") + var configuration = UIButton.Configuration.filled() + configuration.title = "保留标题" + configuration.image = image + configuration.imagePlacement = .trailing + configuration.imagePadding = 7 + configuration.baseForegroundColor = .red + let button = UIButton(configuration: configuration) + + button.setConfigurationContentInsets( + NSDirectionalEdgeInsets(top: 1, leading: 3, bottom: 5, trailing: 9) + ) + + XCTAssertEqual(button.configuration?.title, "保留标题") + XCTAssertEqual(button.configuration?.image, image) + XCTAssertEqual(button.configuration?.imagePlacement, .trailing) + XCTAssertEqual(button.configuration?.imagePadding, 7) + XCTAssertEqual(button.configuration?.baseForegroundColor, .red) + XCTAssertEqual(button.configuration?.contentInsets.leading, 3) + XCTAssertEqual(button.configuration?.contentInsets.trailing, 9) + } + + func testContentInsetsPreserveLegacyButtonContentAndAllowUpdates() { + let image = UIImage(systemName: "star") + let button = UIButton(type: .system) + button.setTitle("原始标题", for: .normal) + button.setTitleColor(.blue, for: .normal) + button.setImage(image, for: .normal) + + button.setConfigurationContentInsets( + NSDirectionalEdgeInsets(top: 2, leading: 4, bottom: 2, trailing: 4) + ) + + XCTAssertEqual(button.currentTitle, "原始标题") + XCTAssertEqual(button.currentTitleColor, .blue) + XCTAssertEqual(button.currentImage, image) + + button.setTitle("更新标题", for: .normal) + button.setTitleColor(.red, for: .normal) + XCTAssertEqual(button.currentTitle, "更新标题") + XCTAssertEqual(button.currentTitleColor, .red) + } +}