完善首页权限申请流程与队列播报体验。

对齐 Android 无权限强制弹窗、角色下拉与申请页交互,补充 UIButton configuration 适配,并增强景区排队 TTS 与相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-14 13:46:45 +08:00
parent c083f1d4b3
commit efb3925257
50 changed files with 2585 additions and 455 deletions

View File

@ -29,6 +29,15 @@
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "93FF17C42FFB84950033C304"
BuildableName = "suixinkan.app"
BlueprintName = "suixinkan"
ReferencedContainer = "container:suixinkan.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "permission_more_scenic.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.754 8.616C11.918 8.437 12 8.232 12 8C12 7.768 11.918 7.563 11.754 7.384L6.504 2.241C6.321 2.08 6.111 2 5.875 2C5.638 2 5.429 2.08 5.246 2.241C5.082 2.42 5 2.625 5 2.857C5 3.089 5.082 3.294 5.246 3.473L9.895 8L5.246 12.527C5.082 12.706 5 12.911 5 13.143C5 13.375 5.082 13.58 5.246 13.759C5.429 13.92 5.638 14 5.875 14C6.111 14 6.321 13.92 6.504 13.759L11.754 8.616Z" fill="#FF7B00"/>
</svg>

After

Width:  |  Height:  |  Size: 501 B

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "permission_open_scenic.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.5 1C2.875 1.027 2.438 1.383 2 2.312V13.688C2.031 14.234 2.438 14.617 3.5 15H6.5V12.813C6.531 12.266 6.937 11.883 8 11.5C8.625 11.527 9.063 11.883 9.5 12.813V15H12.5C13.125 14.973 13.563 14.617 14 13.688V2.312C13.969 1.766 13.563 1.383 12.5 1H3.5ZM4 7.563C4.031 7.152 4.5 7.125 5.5 7.125C5.969 7.152 6 7.563 6 8.438C5.969 8.848 5.5 8.875 4.5 8.875C4.031 8.848 4 8.438 4 7.563ZM7 7.563C7.031 7.152 7.5 7.125 8.5 7.125C8.969 7.152 9 7.563 9 8.438C8.969 8.848 8.5 8.875 7.5 8.875C7.031 8.848 7 8.438 7 7.563ZM10 7.563C10.031 7.152 10.5 7.125 11.5 7.125C11.969 7.152 12 7.563 12 8.438C11.969 8.848 11.5 8.875 10.5 8.875C10.031 8.848 10 8.438 10 7.563ZM4 4.062C4.031 3.652 4.5 3.625 5.5 3.625C5.969 3.652 6 4.062 6 4.937C5.969 5.348 5.5 5.375 4.5 5.375C4.031 5.348 4 4.937 4 4.062ZM7 4.062C7.031 3.652 7.5 3.625 8.5 3.625C8.969 3.652 9 4.062 9 4.937C8.969 5.348 8.5 5.375 7.5 5.375C7.031 5.348 7 4.937 7 4.062ZM10 4.062C10.031 3.652 10.5 3.625 11.5 3.625C11.969 3.652 12 4.062 12 4.937C11.969 5.348 11.5 5.375 10.5 5.375C10.031 5.348 10 4.937 10 4.062Z" fill="#FF7B00"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -67,7 +67,7 @@ enum WeChatMiniProgramType: Int, Equatable, Sendable {
/// SDK
var resolvedForCurrentBuild: WeChatMiniProgramType {
#if DEBUG
return .test
return .preview
#else
return self
#endif

View File

@ -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",

View File

@ -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
}
}

View File

@ -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"),

View File

@ -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()

View File

@ -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?()
}

View File

@ -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<Int>()
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)
} catch {
onShowMessage?("提交失败,请稍后重试")
}
try await api.submitRoleApply(scenicIds: selectedScenicIds, roleId: roleId)
isSubmitting = false
notifyStateChange()
onSubmitSuccess?()
} catch {
onShowMessage?("提交失败,请稍后重试")
isSubmitting = false
notifyStateChange()
}
}
private func existingScenicIds(for roleId: Int) -> Set<Int> {

View File

@ -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<Void, Never>?
private var nativeContinuation: CheckedContinuation<Bool, Never>?
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) {
switch nativeEngineKind {
case .online:
_ = bridge.setParam("mode_type", value: "2")
_ = bridge.setParam("tts_version", value: text.count > 300 ? "1" : "0")
applyNativeQueueProsodyParams()
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"
}

View File

@ -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)

View File

@ -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
}
}

View File

@ -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)

View File

@ -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()
}
}

View File

@ -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() {

View File

@ -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.scenicList.isEmpty {
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(
makeSectionCard(title: "新增景区", chips: pending.scenicList.map(\.name))
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(
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)
}
}

View File

@ -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()
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 }
}
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) }
})
}
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("提交成功")
}
}
}

View File

@ -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)

View File

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

View File

@ -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

View File

@ -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..<collectionView.numberOfSections {
for item in 0..<collectionView.numberOfItems(inSection: section) {
let indexPath = IndexPath(item: item, section: section)
indexPaths.append(indexPath)
itemWidths.append(itemWidthProvider?(indexPath) ?? 44)
}
}
let frames = PermissionChipLayoutMetrics.makeFrames(
itemWidths: itemWidths,
containerWidth: availableWidth
)
for (index, indexPath) in indexPaths.enumerated() {
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attributes.frame = frames[index]
cachedAttributes[indexPath] = attributes
}
contentHeight = frames.last?.maxY ?? 1
}
override var collectionViewContentSize: CGSize {
CGSize(width: collectionView?.bounds.width ?? 0, height: contentHeight)
}
override func layoutAttributesForElements(in rect: CGRect) -> [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<Section, PermissionChipItem>!
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<Section, PermissionChipItem>(
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<Section, PermissionChipItem>()
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?()
}
}

View File

@ -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<Section, Item>!
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<Section, Item>(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<Section, Item>()
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: "")
}
}

View File

@ -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() }
}
}

View File

@ -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<Section, String>!
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<Section, String>(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<Section, String>()
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<Section, ScenicInfo>!
/// 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<Section, ScenicInfo>(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<Section, ScenicInfo>()
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
}
}

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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)

View File

@ -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()
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()
}

View File

@ -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)

View File

@ -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) {

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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)

View File

@ -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
}

View File

@ -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
}

View File

@ -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")
}

View File

@ -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(

View File

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

View File

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

View File

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

View File

@ -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]
}
}

View File

@ -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: "今日")

View File

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