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)
+ }
+}