完善首页权限申请流程与队列播报体验。
对齐 Android 无权限强制弹窗、角色下拉与申请页交互,补充 UIButton configuration 适配,并增强景区排队 TTS 与相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -29,6 +29,15 @@
|
|||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
shouldAutocreateTestPlan = "YES">
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "93FF17C42FFB84950033C304"
|
||||||
|
BuildableName = "suixinkan.app"
|
||||||
|
BlueprintName = "suixinkan"
|
||||||
|
ReferencedContainer = "container:suixinkan.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
<Testables>
|
<Testables>
|
||||||
<TestableReference
|
<TestableReference
|
||||||
skipped = "NO"
|
skipped = "NO"
|
||||||
|
|||||||
15
suixinkan/Assets.xcassets/permission_more_scenic.imageset/Contents.json
vendored
Normal file
15
suixinkan/Assets.xcassets/permission_more_scenic.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "permission_more_scenic.svg",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
},
|
||||||
|
"properties" : {
|
||||||
|
"preserves-vector-representation" : true
|
||||||
|
}
|
||||||
|
}
|
||||||
3
suixinkan/Assets.xcassets/permission_more_scenic.imageset/permission_more_scenic.svg
vendored
Normal file
3
suixinkan/Assets.xcassets/permission_more_scenic.imageset/permission_more_scenic.svg
vendored
Normal 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 |
15
suixinkan/Assets.xcassets/permission_open_scenic.imageset/Contents.json
vendored
Normal file
15
suixinkan/Assets.xcassets/permission_open_scenic.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "permission_open_scenic.svg",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
},
|
||||||
|
"properties" : {
|
||||||
|
"preserves-vector-representation" : true
|
||||||
|
}
|
||||||
|
}
|
||||||
3
suixinkan/Assets.xcassets/permission_open_scenic.imageset/permission_open_scenic.svg
vendored
Normal file
3
suixinkan/Assets.xcassets/permission_open_scenic.imageset/permission_open_scenic.svg
vendored
Normal 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 |
@ -67,7 +67,7 @@ enum WeChatMiniProgramType: Int, Equatable, Sendable {
|
|||||||
/// 当前编译配置下实际传给微信 SDK 的小程序版本。
|
/// 当前编译配置下实际传给微信 SDK 的小程序版本。
|
||||||
var resolvedForCurrentBuild: WeChatMiniProgramType {
|
var resolvedForCurrentBuild: WeChatMiniProgramType {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
return .test
|
return .preview
|
||||||
#else
|
#else
|
||||||
return self
|
return self
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@ -109,8 +109,8 @@ final class HomeAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 提交角色权限申请。
|
/// 提交角色权限申请。
|
||||||
func submitRoleApply(scenicIds: [Int], roleId: Int) async throws -> RoleApplySubmitResult {
|
func submitRoleApply(scenicIds: [Int], roleId: Int) async throws {
|
||||||
try await client.send(
|
let _: EmptyPayload = try await client.send(
|
||||||
APIRequest(
|
APIRequest(
|
||||||
method: .post,
|
method: .post,
|
||||||
path: "/api/yf-handset-app/photog/role-apply/submit",
|
path: "/api/yf-handset-app/photog/role-apply/submit",
|
||||||
|
|||||||
@ -5,6 +5,35 @@
|
|||||||
|
|
||||||
import Foundation
|
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 {
|
struct RoleApplyPendingResponse: Decodable, Equatable {
|
||||||
let code: String
|
let code: String
|
||||||
let roleId: Int
|
let roleId: Int
|
||||||
@ -45,11 +74,13 @@ struct RoleApplyPendingResponse: Decodable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 权限申请中的景区摘要。
|
||||||
struct RoleApplyScenicItem: Decodable, Equatable {
|
struct RoleApplyScenicItem: Decodable, Equatable {
|
||||||
let id: Int
|
let id: Int
|
||||||
let name: String
|
let name: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 全部可申请景区响应。
|
||||||
struct ScenicListAllResponse: Decodable, Equatable {
|
struct ScenicListAllResponse: Decodable, Equatable {
|
||||||
let total: Int
|
let total: Int
|
||||||
let list: [ScenicListItem]
|
let list: [ScenicListItem]
|
||||||
@ -60,11 +91,13 @@ struct ScenicListAllResponse: Decodable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 可申请景区条目。
|
||||||
struct ScenicListItem: Decodable, Equatable {
|
struct ScenicListItem: Decodable, Equatable {
|
||||||
let id: Int
|
let id: Int
|
||||||
let name: String
|
let name: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 权限申请提交参数。
|
||||||
struct RoleApplySubmitRequest: Encodable {
|
struct RoleApplySubmitRequest: Encodable {
|
||||||
let scenicId: [Int]
|
let scenicId: [Int]
|
||||||
let roleId: Int
|
let roleId: Int
|
||||||
@ -75,6 +108,7 @@ struct RoleApplySubmitRequest: Encodable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 权限申请角色选项。
|
||||||
struct RoleOption: Equatable, Hashable {
|
struct RoleOption: Equatable, Hashable {
|
||||||
let id: Int
|
let id: Int
|
||||||
let name: String
|
let name: String
|
||||||
@ -82,26 +116,10 @@ struct RoleOption: Equatable, Hashable {
|
|||||||
var isSelected: Bool
|
var isSelected: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 权限申请景区选项。
|
||||||
struct ScenicOption: Equatable, Hashable {
|
struct ScenicOption: Equatable, Hashable {
|
||||||
let id: Int
|
let id: Int
|
||||||
let name: String
|
let name: String
|
||||||
var isSelected: Bool
|
var isSelected: Bool
|
||||||
var isDisabled: 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -23,16 +23,16 @@ enum HomeMenuCatalog {
|
|||||||
HomeMenuItem(uri: "sample_management", title: "样片管理", iconName: "sample_ic_home_menu_route"),
|
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: "live_stream_management", title: "直播管理", iconName: "dot.radiowaves.left.and.right"),
|
||||||
HomeMenuItem(uri: "verification_order", title: "核销订单", iconName: "checkmark.seal.fill"),
|
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: "live_album", title: "直播相册", iconName: "play.rectangle.on.rectangle.fill"),
|
||||||
HomeMenuItem(uri: "pm", title: "项目管理", iconName: "circle.grid.2x2.fill"),
|
HomeMenuItem(uri: "pm", title: "项目管理", iconName: "circle.grid.2x2.fill"),
|
||||||
HomeMenuItem(uri: "pm_manager", 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: "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: "registration_invitation", title: "注册邀请", iconName: "envelope.open.fill"),
|
||||||
HomeMenuItem(uri: "photographer_invite", 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: "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: "fly", title: "飞行管理", iconName: "paperplane.fill"),
|
||||||
HomeMenuItem(uri: "pilot_cert", title: "飞手认证", iconName: "paperplane.fill"),
|
HomeMenuItem(uri: "pilot_cert", title: "飞手认证", iconName: "paperplane.fill"),
|
||||||
HomeMenuItem(uri: "pilot_controller", title: "飞控", iconName: "paperplane.fill"),
|
HomeMenuItem(uri: "pilot_controller", title: "飞控", iconName: "paperplane.fill"),
|
||||||
|
|||||||
@ -78,6 +78,21 @@ final class HomeViewModel {
|
|||||||
needsPermissionReload = true
|
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 {
|
func reloadIfNeeded(api: HomeAPI) async {
|
||||||
locationStateStore.restoreStateIfNeeded()
|
locationStateStore.restoreStateIfNeeded()
|
||||||
|
|||||||
@ -11,6 +11,9 @@ final class PermissionApplyStatusViewModel {
|
|||||||
private(set) var pendingData: RoleApplyPendingResponse?
|
private(set) var pendingData: RoleApplyPendingResponse?
|
||||||
private(set) var isLoading = false
|
private(set) var isLoading = false
|
||||||
private(set) var rolePermissionList: [RolePermissionResponse] = []
|
private(set) var rolePermissionList: [RolePermissionResponse] = []
|
||||||
|
private(set) var showScenicListDialog = false
|
||||||
|
private(set) var dialogRoleName = ""
|
||||||
|
private(set) var dialogScenicList: [ScenicInfo] = []
|
||||||
|
|
||||||
var onStateChange: (() -> Void)?
|
var onStateChange: (() -> Void)?
|
||||||
var onNavigateToEdit: ((RoleApplyPendingResponse) -> Void)?
|
var onNavigateToEdit: ((RoleApplyPendingResponse) -> Void)?
|
||||||
@ -29,7 +32,11 @@ final class PermissionApplyStatusViewModel {
|
|||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
do {
|
do {
|
||||||
let pendingList = try await api.roleApplyPending()
|
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 {
|
} catch {
|
||||||
pendingData = nil
|
pendingData = nil
|
||||||
}
|
}
|
||||||
@ -42,6 +49,22 @@ final class PermissionApplyStatusViewModel {
|
|||||||
onNavigateToEdit?(pendingData)
|
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() {
|
private func notifyStateChange() {
|
||||||
onStateChange?()
|
onStateChange?()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,10 +18,16 @@ final class PermissionApplyViewModel {
|
|||||||
private(set) var isSubmitting = false
|
private(set) var isSubmitting = false
|
||||||
private(set) var showRolePicker = false
|
private(set) var showRolePicker = false
|
||||||
private(set) var showScenicPicker = 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 onStateChange: (() -> Void)?
|
||||||
var onShowMessage: ((String) -> Void)?
|
var onShowMessage: ((String) -> Void)?
|
||||||
var onSubmitSuccess: ((String) -> Void)?
|
var onSubmitSuccess: (() -> Void)?
|
||||||
|
|
||||||
private let appStore: AppStore
|
private let appStore: AppStore
|
||||||
|
|
||||||
@ -30,6 +36,18 @@ final class PermissionApplyViewModel {
|
|||||||
loadRolesFromLocal()
|
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 去编辑)。
|
/// 从待审核数据预填表单(status=3 去编辑)。
|
||||||
func initWithPendingData(_ pending: RoleApplyPendingResponse) {
|
func initWithPendingData(_ pending: RoleApplyPendingResponse) {
|
||||||
selectedRoleId = pending.roleId
|
selectedRoleId = pending.roleId
|
||||||
@ -45,7 +63,7 @@ final class PermissionApplyViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadRolesFromLocal() {
|
func loadRolesFromLocal() {
|
||||||
let rolePermissionList = appStore.permissions.rolePermissionList()
|
rolePermissionList = appStore.permissions.rolePermissionList()
|
||||||
var seen = Set<Int>()
|
var seen = Set<Int>()
|
||||||
availableRoles = rolePermissionList.compactMap { item in
|
availableRoles = rolePermissionList.compactMap { item in
|
||||||
guard !seen.contains(item.role.id) else { return nil }
|
guard !seen.contains(item.role.id) else { return nil }
|
||||||
@ -102,6 +120,7 @@ final class PermissionApplyViewModel {
|
|||||||
func loadAvailableScenics(api: HomeAPI?) async {
|
func loadAvailableScenics(api: HomeAPI?) async {
|
||||||
guard let roleId = selectedRoleId else { return }
|
guard let roleId = selectedRoleId else { return }
|
||||||
guard let api else { return }
|
guard let api else { return }
|
||||||
|
guard !isLoadingScenics else { return }
|
||||||
isLoadingScenics = true
|
isLoadingScenics = true
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
do {
|
do {
|
||||||
@ -143,7 +162,37 @@ final class PermissionApplyViewModel {
|
|||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 更新景区下拉菜单的搜索词。
|
||||||
|
func updateScenicSearchQuery(_ query: String) {
|
||||||
|
scenicSearchQuery = query
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
func removeSelectedScenic(id: Int, name: String) {
|
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 }
|
selectedScenicIds.removeAll { $0 == id }
|
||||||
selectedScenicNames.removeAll { $0 == name }
|
selectedScenicNames.removeAll { $0 == name }
|
||||||
availableScenics = availableScenics.map { item in
|
availableScenics = availableScenics.map { item in
|
||||||
@ -153,7 +202,6 @@ final class PermissionApplyViewModel {
|
|||||||
}
|
}
|
||||||
return copy
|
return copy
|
||||||
}
|
}
|
||||||
notifyStateChange()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 已有角色下已开通的景区列表。
|
/// 已有角色下已开通的景区列表。
|
||||||
@ -164,6 +212,12 @@ final class PermissionApplyViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func clearRoleSelection() {
|
func clearRoleSelection() {
|
||||||
|
guard !selectedRoleName.isEmpty else { return }
|
||||||
|
pendingDeletion = .role(name: selectedRoleName)
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearRoleSelectionImmediately() {
|
||||||
selectedRoleId = nil
|
selectedRoleId = nil
|
||||||
selectedRoleName = ""
|
selectedRoleName = ""
|
||||||
selectedScenicIds = []
|
selectedScenicIds = []
|
||||||
@ -174,33 +228,42 @@ final class PermissionApplyViewModel {
|
|||||||
copy.isSelected = false
|
copy.isSelected = false
|
||||||
return copy
|
return copy
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 展示已有角色下的全部景区。
|
||||||
|
func showScenicList(roleName: String, scenics: [ScenicInfo]) {
|
||||||
|
dialogRoleName = roleName
|
||||||
|
dialogScenicList = scenics
|
||||||
|
showScenicListDialog = true
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关闭已有景区列表弹窗。
|
||||||
|
func hideScenicList() {
|
||||||
|
dialogRoleName = ""
|
||||||
|
dialogScenicList = []
|
||||||
|
showScenicListDialog = false
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
func submit(api: HomeAPI) async {
|
func submit(api: HomeAPI) async {
|
||||||
guard let roleId = selectedRoleId else {
|
guard canSubmit, let roleId = selectedRoleId else {
|
||||||
onShowMessage?("请选择角色")
|
onShowMessage?("请选择景区和角色")
|
||||||
return
|
|
||||||
}
|
|
||||||
let newScenicIds = selectedScenicIds.filter { id in
|
|
||||||
!existingScenicIds(for: roleId).contains(id)
|
|
||||||
}
|
|
||||||
guard !newScenicIds.isEmpty else {
|
|
||||||
onShowMessage?("请至少选择一个景区")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isSubmitting = true
|
isSubmitting = true
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
do {
|
do {
|
||||||
let result = try await api.submitRoleApply(scenicIds: newScenicIds, roleId: roleId)
|
try await api.submitRoleApply(scenicIds: selectedScenicIds, roleId: roleId)
|
||||||
onShowMessage?("提交成功")
|
|
||||||
onSubmitSuccess?(result.code)
|
|
||||||
} catch {
|
|
||||||
onShowMessage?("提交失败,请稍后重试")
|
|
||||||
}
|
|
||||||
isSubmitting = false
|
isSubmitting = false
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
|
onSubmitSuccess?()
|
||||||
|
} catch {
|
||||||
|
onShowMessage?("提交失败,请稍后重试")
|
||||||
|
isSubmitting = false
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func existingScenicIds(for roleId: Int) -> Set<Int> {
|
private func existingScenicIds(for roleId: Int) -> Set<Int> {
|
||||||
|
|||||||
@ -50,6 +50,18 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
|
|||||||
case custom(String)
|
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 synthesizer = AVSpeechSynthesizer()
|
||||||
private let loopQueue = DispatchQueue(label: "com.zhifly.suixinkan.scenicQueue.tts")
|
private let loopQueue = DispatchQueue(label: "com.zhifly.suixinkan.scenicQueue.tts")
|
||||||
private let nativeQueue = DispatchQueue(label: "com.zhifly.suixinkan.scenicQueue.aliyunNLS")
|
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 tokenProvider: AliyunNLSTokenProviding
|
||||||
private let bridge: AliyunNuiTTSBridging
|
private let bridge: AliyunNuiTTSBridging
|
||||||
private let streamingPlayer: AliyunPCMStreamingPlaying
|
private let streamingPlayer: AliyunPCMStreamingPlaying
|
||||||
|
private let workspacePathProvider: () -> String?
|
||||||
private var activeLoop: LoopKind?
|
private var activeLoop: LoopKind?
|
||||||
private var currentContinuation: CheckedContinuation<Void, Never>?
|
private var currentContinuation: CheckedContinuation<Void, Never>?
|
||||||
private var nativeContinuation: CheckedContinuation<Bool, Never>?
|
private var nativeContinuation: CheckedContinuation<Bool, Never>?
|
||||||
private var nlsToken: AliyunNLSToken?
|
private var nlsToken: AliyunNLSToken?
|
||||||
private var lastCredentials: AliyunOSSCredentials?
|
private var lastCredentials: AliyunOSSCredentials?
|
||||||
private var nativeReady = false
|
private var nativeReady = false
|
||||||
|
private var nativeEngineKind: NativeEngineKind?
|
||||||
|
private var nativeSampleRate: Double
|
||||||
|
|
||||||
private(set) var customTextLooping = false
|
private(set) var customTextLooping = false
|
||||||
let supportsOfflineTTS = false
|
|
||||||
|
var supportsOfflineTTS: Bool {
|
||||||
|
bridge.sdkAvailable && offlineResources() != nil
|
||||||
|
}
|
||||||
|
|
||||||
init(
|
init(
|
||||||
appStore: AppStore = .shared,
|
appStore: AppStore = .shared,
|
||||||
tokenProvider: AliyunNLSTokenProviding = AliyunNLSTokenProvider(),
|
tokenProvider: AliyunNLSTokenProviding = AliyunNLSTokenProvider(),
|
||||||
bridge: AliyunNuiTTSBridging = AliyunNuiTTSBridgeAdapter(),
|
bridge: AliyunNuiTTSBridging = AliyunNuiTTSBridgeAdapter(),
|
||||||
streamingPlayer: AliyunPCMStreamingPlaying = AliyunPCMStreamingPlayer()
|
streamingPlayer: AliyunPCMStreamingPlaying = AliyunPCMStreamingPlayer(),
|
||||||
|
workspacePathProvider: (() -> String?)? = nil
|
||||||
) {
|
) {
|
||||||
self.appStore = appStore
|
self.appStore = appStore
|
||||||
self.tokenProvider = tokenProvider
|
self.tokenProvider = tokenProvider
|
||||||
self.bridge = bridge
|
self.bridge = bridge
|
||||||
self.streamingPlayer = streamingPlayer
|
self.streamingPlayer = streamingPlayer
|
||||||
|
self.workspacePathProvider = workspacePathProvider ?? Self.bundledWorkspacePath
|
||||||
|
nativeSampleRate = AliyunNLSTTSManager.onlineSampleRate
|
||||||
super.init()
|
super.init()
|
||||||
synthesizer.delegate = self
|
synthesizer.delegate = self
|
||||||
self.bridge.delegate = self
|
self.bridge.delegate = self
|
||||||
@ -93,7 +114,7 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
|
|||||||
lastCredentials = sts.credentials
|
lastCredentials = sts.credentials
|
||||||
let token = try await tokenProvider.createToken(credentials: sts.credentials)
|
let token = try await tokenProvider.createToken(credentials: sts.credentials)
|
||||||
nlsToken = token
|
nlsToken = token
|
||||||
nativeReady = await prepareNativeEngineIfAvailable(token: token)
|
nativeReady = await prepareNativeEngineIfAvailable(token: token, credentials: sts.credentials)
|
||||||
} catch {
|
} catch {
|
||||||
nativeReady = false
|
nativeReady = false
|
||||||
}
|
}
|
||||||
@ -221,7 +242,7 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
|
|||||||
self.cancelNativeSpeakLocked(resumeExisting: true)
|
self.cancelNativeSpeakLocked(resumeExisting: true)
|
||||||
self.nativeContinuation = continuation
|
self.nativeContinuation = continuation
|
||||||
self.streamingPlayer.stop()
|
self.streamingPlayer.stop()
|
||||||
self.streamingPlayer.start(sampleRate: Self.nuiSampleRate)
|
self.streamingPlayer.start(sampleRate: self.nativeSampleRate)
|
||||||
self.applyNativePlaybackParams(text: text)
|
self.applyNativePlaybackParams(text: text)
|
||||||
let code = self.bridge.play(priority: "1", taskId: Self.nuiTaskId, text: text)
|
let code = self.bridge.play(priority: "1", taskId: Self.nuiTaskId, text: text)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
@ -276,11 +297,20 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
|
|||||||
resumeCurrentContinuationIfNeeded()
|
resumeCurrentContinuationIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func prepareNativeEngineIfAvailable(token: AliyunNLSToken) async -> Bool {
|
private func prepareNativeEngineIfAvailable(
|
||||||
guard bridge.sdkAvailable,
|
token: AliyunNLSToken,
|
||||||
let ticket = nativeOnlineTicket(token: token.id) else {
|
credentials: AliyunOSSCredentials
|
||||||
|
) async -> Bool {
|
||||||
|
guard bridge.sdkAvailable else {
|
||||||
return false
|
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
|
return await withCheckedContinuation { continuation in
|
||||||
nativeQueue.async { [weak self] in
|
nativeQueue.async { [weak self] in
|
||||||
guard let self else {
|
guard let self else {
|
||||||
@ -289,19 +319,34 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
|
|||||||
}
|
}
|
||||||
self.cancelNativeSpeakLocked(resumeExisting: true)
|
self.cancelNativeSpeakLocked(resumeExisting: true)
|
||||||
_ = self.bridge.releaseEngine()
|
_ = self.bridge.releaseEngine()
|
||||||
|
self.nativeEngineKind = nil
|
||||||
let code = self.bridge.initialize(ticket: ticket, logLevel: 0, saveLog: true)
|
let code = self.bridge.initialize(ticket: ticket, logLevel: 0, saveLog: true)
|
||||||
guard code == 0 else {
|
guard code == 0 else {
|
||||||
continuation.resume(returning: false)
|
continuation.resume(returning: false)
|
||||||
return
|
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)
|
continuation.resume(returning: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func nativeOnlineTicket(token: String) -> String? {
|
private func nativeOnlineTicket(token: String) -> String? {
|
||||||
guard let workspace = nativeWorkspacePath() else { return nil }
|
guard let workspace = workspacePathProvider() else { return nil }
|
||||||
let ticket: [String: String] = [
|
let ticket: [String: String] = [
|
||||||
"app_key": Self.onlineAppKey,
|
"app_key": Self.onlineAppKey,
|
||||||
"token": token,
|
"token": token,
|
||||||
@ -316,7 +361,30 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
|
|||||||
return String(data: data, encoding: .utf8)
|
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 {
|
if let privateFrameworksPath = Bundle.main.privateFrameworksPath {
|
||||||
let frameworkPath = (privateFrameworksPath as NSString).appendingPathComponent("nuisdk.framework")
|
let frameworkPath = (privateFrameworksPath as NSString).appendingPathComponent("nuisdk.framework")
|
||||||
if let frameworkBundle = Bundle(path: frameworkPath),
|
if let frameworkBundle = Bundle(path: frameworkPath),
|
||||||
@ -327,6 +395,19 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
|
|||||||
return Bundle.main.path(forResource: "Resources", ofType: "bundle")
|
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 {
|
private func nativeDebugPath() -> String {
|
||||||
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
||||||
let url = (caches ?? URL(fileURLWithPath: NSTemporaryDirectory())).appendingPathComponent("aliyun_nls_tts_debug", isDirectory: true)
|
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) {
|
private func applyNativePlaybackParams(text: String) {
|
||||||
|
switch nativeEngineKind {
|
||||||
|
case .online:
|
||||||
_ = bridge.setParam("mode_type", value: "2")
|
_ = bridge.setParam("mode_type", value: "2")
|
||||||
_ = bridge.setParam("tts_version", value: text.count > 300 ? "1" : "0")
|
_ = 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("speech_rate", value: Self.onlineSpeechRate)
|
||||||
_ = bridge.setParam("volume", value: Self.playbackVolume)
|
_ = 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) {
|
private func cancelNativeSpeakLocked(resumeExisting: Bool) {
|
||||||
_ = bridge.cancel(taskId: Self.nuiTaskId)
|
_ = bridge.cancel(taskId: Self.nuiTaskId)
|
||||||
streamingPlayer.stop()
|
streamingPlayer.stop()
|
||||||
@ -372,9 +476,15 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static let onlineAppKey = "3WX8cD04JCGkbRLi"
|
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 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 onlineSpeechRate = "-120"
|
||||||
|
private static let offlineSpeedLevel = "1.0"
|
||||||
private static let playbackVolume = "2.0"
|
private static let playbackVolume = "2.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -81,6 +81,7 @@ final class DateRangePickerViewController: UIViewController {
|
|||||||
|
|
||||||
StatisticsRangeShortcut.allCases.forEach { shortcut in
|
StatisticsRangeShortcut.allCases.forEach { shortcut in
|
||||||
let button = StatisticsPeriodButton()
|
let button = StatisticsPeriodButton()
|
||||||
|
button.cornerStyle = .fixed(10)
|
||||||
button.periodTitle = shortcut.rawValue
|
button.periodTitle = shortcut.rawValue
|
||||||
button.isSelected = shortcut.rawValue == selectedShortcut
|
button.isSelected = shortcut.rawValue == selectedShortcut
|
||||||
button.addTarget(self, action: #selector(shortcutTapped(_:)), for: .touchUpInside)
|
button.addTarget(self, action: #selector(shortcutTapped(_:)), for: .touchUpInside)
|
||||||
@ -130,6 +131,7 @@ final class DateRangePickerViewController: UIViewController {
|
|||||||
shortcutStack.snp.makeConstraints { make in
|
shortcutStack.snp.makeConstraints { make in
|
||||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
|
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
|
||||||
make.leading.trailing.equalToSuperview().inset(16)
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
make.height.equalTo(36)
|
||||||
}
|
}
|
||||||
monthRow.snp.makeConstraints { make in
|
monthRow.snp.makeConstraints { make in
|
||||||
make.top.equalTo(shortcutStack.snp.bottom).offset(12)
|
make.top.equalTo(shortcutStack.snp.bottom).offset(12)
|
||||||
|
|||||||
17
suixinkan/UI/Common/UIButton+Configuration.swift
Normal file
17
suixinkan/UI/Common/UIButton+Configuration.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -503,7 +503,9 @@ final class CooperationAcquirerCell: UITableViewCell {
|
|||||||
editRemarkButton.setTitle("修改备注", for: .normal)
|
editRemarkButton.setTitle("修改备注", for: .normal)
|
||||||
editRemarkButton.titleLabel?.font = .systemFont(ofSize: 13)
|
editRemarkButton.titleLabel?.font = .systemFont(ofSize: 13)
|
||||||
editRemarkButton.setTitleColor(AppColor.primary, for: .normal)
|
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)
|
editRemarkButton.addTarget(self, action: #selector(editRemarkTapped), for: .touchUpInside)
|
||||||
|
|
||||||
callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal)
|
callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal)
|
||||||
@ -516,7 +518,9 @@ final class CooperationAcquirerCell: UITableViewCell {
|
|||||||
editCommissionButton.setTitle("修改比例", for: .normal)
|
editCommissionButton.setTitle("修改比例", for: .normal)
|
||||||
editCommissionButton.titleLabel?.font = .systemFont(ofSize: 13)
|
editCommissionButton.titleLabel?.font = .systemFont(ofSize: 13)
|
||||||
editCommissionButton.setTitleColor(AppColor.primary, for: .normal)
|
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)
|
editCommissionButton.addTarget(self, action: #selector(editCommissionTapped), for: .touchUpInside)
|
||||||
|
|
||||||
contentView.addSubview(cardView)
|
contentView.addSubview(cardView)
|
||||||
|
|||||||
@ -89,7 +89,8 @@ final class AllFunctionsViewController: BaseViewController {
|
|||||||
|
|
||||||
override func setupConstraints() {
|
override func setupConstraints() {
|
||||||
collectionView.snp.makeConstraints { make in
|
collectionView.snp.makeConstraints { make in
|
||||||
make.edges.equalTo(view.safeAreaLayoutGuide)
|
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||||
|
make.bottom.equalToSuperview()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -35,10 +35,10 @@ final class HomeViewController: BaseViewController {
|
|||||||
collectionViewLayout: HomeCollectionLayoutBuilder.makeLayout(sections: [.commonApps])
|
collectionViewLayout: HomeCollectionLayoutBuilder.makeLayout(sections: [.commonApps])
|
||||||
)
|
)
|
||||||
collectionView.backgroundColor = .clear
|
collectionView.backgroundColor = .clear
|
||||||
collectionView.alwaysBounceVertical = true
|
// collectionView.alwaysBounceVertical = true
|
||||||
collectionView.alwaysBounceHorizontal = false
|
// collectionView.alwaysBounceHorizontal = false
|
||||||
collectionView.showsHorizontalScrollIndicator = false
|
// collectionView.showsHorizontalScrollIndicator = false
|
||||||
collectionView.isDirectionalLockEnabled = true
|
// collectionView.contentInsetAdjustmentBehavior = .never
|
||||||
// 水平边距由 section.contentInsets 控制,避免 contentInset 与布局宽度不一致导致横向滑动。
|
// 水平边距由 section.contentInsets 控制,避免 contentInset 与布局宽度不一致导致横向滑动。
|
||||||
collectionView.contentInset = UIEdgeInsets(
|
collectionView.contentInset = UIEdgeInsets(
|
||||||
top: AppSpacing.sm,
|
top: AppSpacing.sm,
|
||||||
@ -56,13 +56,13 @@ final class HomeViewController: BaseViewController {
|
|||||||
|
|
||||||
override func setupConstraints() {
|
override func setupConstraints() {
|
||||||
scenicHeaderView.snp.makeConstraints { make in
|
scenicHeaderView.snp.makeConstraints { make in
|
||||||
make.top.equalTo(view.safeAreaLayoutGuide)
|
make.top.leading.trailing.equalToSuperview()
|
||||||
make.leading.trailing.equalToSuperview()
|
make.bottom.equalTo(view.safeAreaLayoutGuide.snp.top).offset(AppSpacing.homeHeaderHeight)
|
||||||
make.height.equalTo(AppSpacing.homeHeaderHeight)
|
|
||||||
}
|
}
|
||||||
collectionView.snp.makeConstraints { make in
|
collectionView.snp.makeConstraints { make in
|
||||||
make.top.equalTo(scenicHeaderView.snp.bottom)
|
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
|
viewModel.onShowMessage = { [weak self] message in
|
||||||
Task { @MainActor in self?.showToast(message) }
|
Task { @MainActor in self?.showToast(message) }
|
||||||
}
|
}
|
||||||
|
|
||||||
scenicHeaderView.onTap = { [weak self] in self?.presentScenicSelection() }
|
scenicHeaderView.onTap = { [weak self] in self?.presentScenicSelection() }
|
||||||
collectionView.delegate = self
|
collectionView.delegate = self
|
||||||
|
|
||||||
@ -102,7 +101,14 @@ final class HomeViewController: BaseViewController {
|
|||||||
hasInitialized = true
|
hasInitialized = true
|
||||||
Task { await initializeHome() }
|
Task { await initializeHome() }
|
||||||
} else {
|
} 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()
|
startCountdownTimer()
|
||||||
}
|
}
|
||||||
@ -389,20 +395,34 @@ final class HomeViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func presentPermissionDialog() {
|
private func presentPermissionDialog() {
|
||||||
let alert = UIAlertController(
|
let dialog = PermissionRequiredDialogViewController(
|
||||||
title: "权限提示",
|
onExitApp: {
|
||||||
message: "您还没有权限,请先申请权限",
|
UIApplication.shared.perform(#selector(NSXPCConnection.suspend))
|
||||||
preferredStyle: .alert
|
},
|
||||||
|
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
|
present(dialog, animated: true)
|
||||||
self?.viewModel.hidePermissionDialog()
|
}
|
||||||
self?.activeDialog = nil
|
|
||||||
})
|
private func openPermissionApplyFlow() async {
|
||||||
alert.addAction(UIAlertAction(title: "去申请", style: .default) { [weak self] _ in
|
showLoading()
|
||||||
self?.showToast("功能开发中")
|
let destination = await viewModel.resolvePermissionApplyDestination(api: homeAPI)
|
||||||
self?.activeDialog = nil
|
hideLoading()
|
||||||
})
|
viewModel.markNeedsPermissionReload()
|
||||||
present(alert, animated: true)
|
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() {
|
private func presentScenicDialog() {
|
||||||
|
|||||||
@ -6,24 +6,26 @@
|
|||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 权限申请审核状态页,对齐 Android `PermissionApplyStatusScreen`。
|
/// 权限申请审核状态页,严格对齐 Android `PermissionApplyStatusScreen`。
|
||||||
|
@MainActor
|
||||||
final class PermissionApplyStatusViewController: BaseViewController {
|
final class PermissionApplyStatusViewController: BaseViewController {
|
||||||
|
|
||||||
private let viewModel: PermissionApplyStatusViewModel
|
private let viewModel: PermissionApplyStatusViewModel
|
||||||
private let homeAPI: HomeAPI
|
private let homeAPI: HomeAPI
|
||||||
|
|
||||||
private let scrollView = UIScrollView()
|
private let scrollView = UIScrollView()
|
||||||
private let contentStack = UIStackView()
|
private let contentStack = UIStackView()
|
||||||
private let bannerView = PermissionApplyBannerView()
|
private let bannerView = PermissionApplyBannerView()
|
||||||
|
private let existingRoleList = PermissionExistingRoleListView()
|
||||||
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
|
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
|
||||||
|
|
||||||
|
/// 使用申请编号创建审核状态页。
|
||||||
init(
|
init(
|
||||||
applyCode: String,
|
applyCode: String,
|
||||||
viewModel: PermissionApplyStatusViewModel? = nil,
|
viewModel: PermissionApplyStatusViewModel? = nil,
|
||||||
homeAPI: HomeAPI = NetworkServices.shared.homeAPI
|
homeAPI: HomeAPI? = nil
|
||||||
) {
|
) {
|
||||||
self.viewModel = viewModel ?? PermissionApplyStatusViewModel(applyCode: applyCode)
|
self.viewModel = viewModel ?? PermissionApplyStatusViewModel(applyCode: applyCode)
|
||||||
self.homeAPI = homeAPI
|
self.homeAPI = homeAPI ?? NetworkServices.shared.homeAPI
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,25 +36,23 @@ final class PermissionApplyStatusViewController: BaseViewController {
|
|||||||
|
|
||||||
override func setupNavigationBar() {
|
override func setupNavigationBar() {
|
||||||
title = "权限申请"
|
title = "权限申请"
|
||||||
|
navigationItem.backButtonDisplayMode = .minimal
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupUI() {
|
override func setupUI() {
|
||||||
|
view.backgroundColor = PermissionApplyUITokens.pageBackground
|
||||||
contentStack.axis = .vertical
|
contentStack.axis = .vertical
|
||||||
contentStack.spacing = 12
|
contentStack.spacing = 0
|
||||||
scrollView.addSubview(contentStack)
|
scrollView.addSubview(contentStack)
|
||||||
view.addSubview(scrollView)
|
view.addSubview(scrollView)
|
||||||
view.addSubview(loadingIndicator)
|
view.addSubview(loadingIndicator)
|
||||||
|
|
||||||
contentStack.snp.makeConstraints { make in
|
contentStack.snp.makeConstraints { make in
|
||||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 16, right: 16))
|
make.edges.equalToSuperview()
|
||||||
make.width.equalTo(scrollView).offset(-32)
|
make.width.equalTo(scrollView)
|
||||||
}
|
|
||||||
scrollView.snp.makeConstraints { make in
|
|
||||||
make.edges.equalTo(view.safeAreaLayoutGuide)
|
|
||||||
}
|
|
||||||
loadingIndicator.snp.makeConstraints { make in
|
|
||||||
make.center.equalToSuperview()
|
|
||||||
}
|
}
|
||||||
|
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() {
|
override func bindActions() {
|
||||||
@ -65,9 +65,13 @@ final class PermissionApplyStatusViewController: BaseViewController {
|
|||||||
bannerView.onApplyNewScenic = { [weak self] in
|
bannerView.onApplyNewScenic = { [weak self] in
|
||||||
self?.navigationController?.pushViewController(ScenicApplicationViewController(), animated: true)
|
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) }
|
Task { await viewModel.loadPendingData(api: homeAPI) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
private func applyViewModel() {
|
private func applyViewModel() {
|
||||||
if viewModel.isLoading {
|
if viewModel.isLoading {
|
||||||
loadingIndicator.startAnimating()
|
loadingIndicator.startAnimating()
|
||||||
@ -76,29 +80,44 @@ final class PermissionApplyStatusViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
loadingIndicator.stopAnimating()
|
loadingIndicator.stopAnimating()
|
||||||
scrollView.isHidden = false
|
scrollView.isHidden = false
|
||||||
contentStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
|
||||||
contentStack.addArrangedSubview(bannerView)
|
|
||||||
|
|
||||||
navigationItem.rightBarButtonItem = nil
|
navigationItem.rightBarButtonItem = nil
|
||||||
if let pending = viewModel.pendingData, pending.status == PermissionApplyPendingStatus.rejected {
|
contentStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
|
||||||
title: "去编辑",
|
|
||||||
style: .plain,
|
|
||||||
target: self,
|
|
||||||
action: #selector(editTapped)
|
|
||||||
)
|
|
||||||
navigationItem.rightBarButtonItem?.tintColor = AppColor.primary
|
|
||||||
}
|
|
||||||
|
|
||||||
|
contentStack.addArrangedSubview(bannerView)
|
||||||
guard let pending = viewModel.pendingData else { return }
|
guard let pending = viewModel.pendingData else { return }
|
||||||
|
|
||||||
contentStack.addArrangedSubview(makeStatusCard(pending: pending))
|
if pending.status == PermissionApplyPendingStatus.rejected {
|
||||||
contentStack.addArrangedSubview(makeSectionCard(title: "新增角色", chips: [pending.roleName]))
|
let editItem = UIBarButtonItem(title: "去编辑", style: .plain, target: self, action: #selector(editTapped))
|
||||||
if !pending.scenicList.isEmpty {
|
editItem.tintColor = AppColor.primary
|
||||||
|
editItem.setTitleTextAttributes([.font: UIFont.systemFont(ofSize: 14, weight: .medium)], for: .normal)
|
||||||
|
navigationItem.rightBarButtonItem = editItem
|
||||||
|
}
|
||||||
|
|
||||||
contentStack.addArrangedSubview(
|
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 {
|
private func makeStatusCard(pending: RoleApplyPendingResponse) -> UIView {
|
||||||
@ -111,75 +130,66 @@ final class PermissionApplyStatusViewController: BaseViewController {
|
|||||||
let statusLabel = UILabel()
|
let statusLabel = UILabel()
|
||||||
statusLabel.text = pending.statusLabel
|
statusLabel.text = pending.statusLabel
|
||||||
statusLabel.font = .systemFont(ofSize: 18, weight: .medium)
|
statusLabel.font = .systemFont(ofSize: 18, weight: .medium)
|
||||||
statusLabel.textColor = AppColor.warning
|
statusLabel.textColor = UIColor(hex: 0xFF6B00)
|
||||||
|
|
||||||
let stack = UIStackView()
|
let stack = UIStackView()
|
||||||
stack.axis = .vertical
|
stack.axis = .vertical
|
||||||
stack.spacing = 8
|
stack.spacing = 8
|
||||||
stack.addArrangedSubview(statusLabel)
|
stack.addArrangedSubview(statusLabel)
|
||||||
|
stack.setCustomSpacing(12, after: statusLabel)
|
||||||
[
|
[
|
||||||
("提交时间:", pending.createdAt),
|
("提交时间:", pending.createdAt),
|
||||||
("审核时间:", pending.auditedAt ?? "--"),
|
("审核时间:", pending.auditedAt ?? "--"),
|
||||||
("审核人:", pending.auditedBy ?? "--"),
|
("审核人:", pending.auditedBy ?? "--"),
|
||||||
("审核备注:", pending.auditNote.isEmpty ? "--" : pending.auditNote),
|
("审核备注:", pending.auditNote.isEmpty ? "--" : pending.auditNote),
|
||||||
].forEach { label, value in
|
].forEach { stack.addArrangedSubview(makeInfoRow(label: $0.0, value: $0.1)) }
|
||||||
stack.addArrangedSubview(makeInfoRow(label: label, value: value))
|
|
||||||
}
|
|
||||||
|
|
||||||
card.addSubview(stack)
|
card.addSubview(stack)
|
||||||
stack.snp.makeConstraints { make in
|
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
|
||||||
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)
|
|
||||||
}
|
|
||||||
return card
|
return card
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeInfoRow(label: String, value: String) -> UIView {
|
private func makeInfoRow(label: String, value: String) -> UIView {
|
||||||
let row = UIStackView()
|
let row = UIStackView()
|
||||||
row.axis = .horizontal
|
row.axis = .horizontal
|
||||||
row.distribution = .equalSpacing
|
row.alignment = .top
|
||||||
|
row.spacing = 8
|
||||||
let left = UILabel()
|
let left = UILabel()
|
||||||
left.text = label
|
left.text = label
|
||||||
left.font = .systemFont(ofSize: 14)
|
left.font = .systemFont(ofSize: 14)
|
||||||
left.textColor = AppColor.textSecondary
|
left.textColor = UIColor(hex: 0x4B5563)
|
||||||
|
left.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
let right = UILabel()
|
let right = UILabel()
|
||||||
right.text = value.isEmpty ? "--" : value
|
right.text = value.isEmpty ? "--" : value
|
||||||
right.font = .systemFont(ofSize: 14)
|
right.font = .systemFont(ofSize: 14)
|
||||||
right.textColor = AppColor.textSecondary
|
right.textColor = UIColor(hex: 0x4B5563)
|
||||||
right.textAlignment = .right
|
right.textAlignment = .right
|
||||||
|
right.numberOfLines = 0
|
||||||
row.addArrangedSubview(left)
|
row.addArrangedSubview(left)
|
||||||
row.addArrangedSubview(right)
|
row.addArrangedSubview(right)
|
||||||
|
right.snp.makeConstraints { make in make.width.lessThanOrEqualTo(220) }
|
||||||
return row
|
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() {
|
@objc private func editTapped() {
|
||||||
viewModel.navigateToEditIfRejected()
|
viewModel.navigateToEditIfRejected()
|
||||||
}
|
}
|
||||||
@ -187,8 +197,7 @@ final class PermissionApplyStatusViewController: BaseViewController {
|
|||||||
private func navigateToEdit(pending: RoleApplyPendingResponse) {
|
private func navigateToEdit(pending: RoleApplyPendingResponse) {
|
||||||
var controllers = navigationController?.viewControllers ?? []
|
var controllers = navigationController?.viewControllers ?? []
|
||||||
controllers.removeAll { $0 is PermissionApplyStatusViewController }
|
controllers.removeAll { $0 is PermissionApplyStatusViewController }
|
||||||
let applyVC = PermissionApplyViewController(pendingData: pending)
|
controllers.append(PermissionApplyViewController(pendingData: pending))
|
||||||
controllers.append(applyVC)
|
|
||||||
navigationController?.setViewControllers(controllers, animated: true)
|
navigationController?.setViewControllers(controllers, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,16 +6,17 @@
|
|||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 权限申请页,对齐 Android `PermissionApplyScreen`。
|
/// 权限申请页,严格对齐 Android `PermissionApplyScreen`。
|
||||||
|
@MainActor
|
||||||
final class PermissionApplyViewController: BaseViewController {
|
final class PermissionApplyViewController: BaseViewController {
|
||||||
|
|
||||||
private let viewModel: PermissionApplyViewModel
|
private let viewModel: PermissionApplyViewModel
|
||||||
private let homeAPI: HomeAPI
|
private let homeAPI: HomeAPI
|
||||||
private let pendingData: RoleApplyPendingResponse?
|
private let pendingData: RoleApplyPendingResponse?
|
||||||
|
|
||||||
private let scrollView = UIScrollView()
|
|
||||||
private let contentStack = UIStackView()
|
|
||||||
private let bannerView = PermissionApplyBannerView()
|
private let bannerView = PermissionApplyBannerView()
|
||||||
|
private let scrollView = UIScrollView()
|
||||||
|
private let cardView = UIView()
|
||||||
private let roleSelector = PermissionFormSelectorRow(
|
private let roleSelector = PermissionFormSelectorRow(
|
||||||
title: "角色信息",
|
title: "角色信息",
|
||||||
tag: "单选",
|
tag: "单选",
|
||||||
@ -30,16 +31,22 @@ final class PermissionApplyViewController: BaseViewController {
|
|||||||
placeholder: "请选择景区"
|
placeholder: "请选择景区"
|
||||||
)
|
)
|
||||||
private let selectedScenicTags = PermissionSelectedTagsView()
|
private let selectedScenicTags = PermissionSelectedTagsView()
|
||||||
|
private let existingRoleList = PermissionExistingRoleListView()
|
||||||
|
private let bottomBar = UIView()
|
||||||
private let submitButton = PermissionSubmitButton()
|
private let submitButton = PermissionSubmitButton()
|
||||||
|
|
||||||
|
private weak var roleDropdown: PermissionDropdownViewController?
|
||||||
|
private weak var scenicDropdown: PermissionDropdownViewController?
|
||||||
|
|
||||||
|
/// 创建新增或驳回编辑状态的权限申请页。
|
||||||
init(
|
init(
|
||||||
pendingData: RoleApplyPendingResponse? = nil,
|
pendingData: RoleApplyPendingResponse? = nil,
|
||||||
viewModel: PermissionApplyViewModel = PermissionApplyViewModel(),
|
viewModel: PermissionApplyViewModel = PermissionApplyViewModel(),
|
||||||
homeAPI: HomeAPI = NetworkServices.shared.homeAPI
|
homeAPI: HomeAPI? = nil
|
||||||
) {
|
) {
|
||||||
self.pendingData = pendingData
|
self.pendingData = pendingData
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
self.homeAPI = homeAPI
|
self.homeAPI = homeAPI ?? NetworkServices.shared.homeAPI
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,41 +57,60 @@ final class PermissionApplyViewController: BaseViewController {
|
|||||||
|
|
||||||
override func setupNavigationBar() {
|
override func setupNavigationBar() {
|
||||||
title = "申请权限"
|
title = "申请权限"
|
||||||
|
navigationItem.backButtonDisplayMode = .minimal
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupUI() {
|
override func setupUI() {
|
||||||
contentStack.axis = .vertical
|
view.backgroundColor = PermissionApplyUITokens.pageBackground
|
||||||
contentStack.spacing = 16
|
scrollView.keyboardDismissMode = .onDrag
|
||||||
|
cardView.backgroundColor = .white
|
||||||
|
cardView.layer.cornerRadius = 8
|
||||||
|
bottomBar.backgroundColor = .white
|
||||||
|
|
||||||
let card = UIView()
|
let roleSection = UIStackView(arrangedSubviews: [roleSelector, newRoleTags])
|
||||||
card.backgroundColor = .white
|
roleSection.axis = .vertical
|
||||||
card.layer.cornerRadius = 8
|
roleSection.spacing = 12
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
contentStack.addArrangedSubview(bannerView)
|
let scenicSection = UIStackView(arrangedSubviews: [scenicSelector, selectedScenicTags, existingRoleList])
|
||||||
contentStack.addArrangedSubview(card)
|
scenicSection.axis = .vertical
|
||||||
scrollView.addSubview(contentStack)
|
scenicSection.spacing = 12
|
||||||
|
|
||||||
|
let formStack = UIStackView(arrangedSubviews: [roleSection, scenicSection])
|
||||||
|
formStack.axis = .vertical
|
||||||
|
formStack.spacing = 20
|
||||||
|
|
||||||
|
view.addSubview(bannerView)
|
||||||
view.addSubview(scrollView)
|
view.addSubview(scrollView)
|
||||||
view.addSubview(submitButton)
|
scrollView.addSubview(cardView)
|
||||||
|
cardView.addSubview(formStack)
|
||||||
|
view.addSubview(bottomBar)
|
||||||
|
bottomBar.addSubview(submitButton)
|
||||||
|
|
||||||
contentStack.snp.makeConstraints { make in
|
bannerView.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.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
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
|
submitButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(16)
|
||||||
make.leading.trailing.equalToSuperview().inset(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() {
|
override func bindActions() {
|
||||||
@ -94,21 +120,23 @@ final class PermissionApplyViewController: BaseViewController {
|
|||||||
viewModel.onShowMessage = { [weak self] message in
|
viewModel.onShowMessage = { [weak self] message in
|
||||||
Task { @MainActor in self?.showToast(message) }
|
Task { @MainActor in self?.showToast(message) }
|
||||||
}
|
}
|
||||||
viewModel.onSubmitSuccess = { [weak self] applyCode in
|
viewModel.onSubmitSuccess = { [weak self] in
|
||||||
Task { @MainActor in self?.handleSubmitSuccess(applyCode: applyCode) }
|
Task { @MainActor in self?.handleSubmitSuccess() }
|
||||||
}
|
}
|
||||||
|
|
||||||
bannerView.onApplyNewScenic = { [weak self] in
|
bannerView.onApplyNewScenic = { [weak self] in
|
||||||
self?.navigationController?.pushViewController(ScenicApplicationViewController(), animated: true)
|
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
|
scenicSelector.onTap = { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.viewModel.showScenicPickerSheet()
|
self.viewModel.showScenicPickerSheet()
|
||||||
if self.viewModel.availableScenics.isEmpty {
|
if self.viewModel.showScenicPicker, self.viewModel.availableScenics.isEmpty {
|
||||||
Task { await self.viewModel.loadAvailableScenics(api: self.homeAPI) }
|
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)
|
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
|
||||||
|
|
||||||
@ -119,105 +147,112 @@ final class PermissionApplyViewController: BaseViewController {
|
|||||||
applyViewModel()
|
applyViewModel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
private func applyViewModel() {
|
private func applyViewModel() {
|
||||||
if viewModel.selectedRoleName.isEmpty {
|
if viewModel.selectedRoleName.isEmpty {
|
||||||
roleSelector.setValue("请选择角色", isPlaceholder: true)
|
roleSelector.setValue("请选择角色", isPlaceholder: true)
|
||||||
newRoleTags.isHidden = true
|
|
||||||
} else {
|
} else {
|
||||||
roleSelector.setValue(viewModel.selectedRoleName, isPlaceholder: false)
|
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 {
|
let scenicValue: String
|
||||||
scenicSelector.setValue("请选择景区", isPlaceholder: true)
|
switch viewModel.selectedScenicNames.count {
|
||||||
selectedScenicTags.isHidden = true
|
case 0: scenicValue = "请选择景区"
|
||||||
} else {
|
case 1: scenicValue = viewModel.selectedScenicNames[0]
|
||||||
let displayText: String
|
default: scenicValue = "已选中\(viewModel.selectedScenicNames.count)个景区"
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
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)
|
if viewModel.isSubmitting { showLoading() } else { hideLoading() }
|
||||||
presentScenicPickerIfNeeded()
|
scenicDropdown?.updateScenics(viewModel.filteredScenics, isLoading: viewModel.isLoadingScenics)
|
||||||
presentRolePickerIfNeeded()
|
presentRolePickerIfNeeded()
|
||||||
|
presentScenicPickerIfNeeded()
|
||||||
|
presentDeletionIfNeeded()
|
||||||
|
presentScenicListIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presentRolePickerIfNeeded() {
|
private func presentRolePickerIfNeeded() {
|
||||||
guard viewModel.showRolePicker else { return }
|
guard viewModel.showRolePicker, roleDropdown == nil, presentedViewController == nil else { return }
|
||||||
viewModel.hideRolePickerSheet()
|
let dropdown = PermissionDropdownViewController(
|
||||||
presentRolePicker()
|
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 }
|
||||||
}
|
}
|
||||||
|
roleDropdown = dropdown
|
||||||
private func presentRolePicker() {
|
present(dropdown, animated: true)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presentScenicPickerIfNeeded() {
|
private func presentScenicPickerIfNeeded() {
|
||||||
guard viewModel.showScenicPicker else { return }
|
guard viewModel.showScenicPicker, scenicDropdown == nil, presentedViewController == nil else { return }
|
||||||
viewModel.hideScenicPickerSheet()
|
let dropdown = PermissionDropdownViewController(
|
||||||
presentScenicPicker()
|
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() {
|
private func presentDeletionIfNeeded() {
|
||||||
guard viewModel.selectedRoleId != nil else {
|
guard let target = viewModel.pendingDeletion, presentedViewController == nil else { return }
|
||||||
showToast("请先选择角色")
|
present(
|
||||||
return
|
PermissionDeleteDialogViewController(
|
||||||
}
|
target: target,
|
||||||
if viewModel.availableScenics.isEmpty {
|
onCancel: { [weak self] in self?.viewModel.cancelDeletion() },
|
||||||
Task {
|
onConfirm: { [weak self] in self?.viewModel.confirmDeletion() }
|
||||||
await viewModel.loadAvailableScenics(api: homeAPI)
|
),
|
||||||
await MainActor.run { self.presentScenicPicker() }
|
animated: true
|
||||||
}
|
)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let alert = UIAlertController(title: "选择景区", message: nil, preferredStyle: .actionSheet)
|
private func presentScenicListIfNeeded() {
|
||||||
viewModel.availableScenics.filter { !$0.isDisabled }.forEach { scenic in
|
guard viewModel.showScenicListDialog, presentedViewController == nil else { return }
|
||||||
let prefix = scenic.isSelected ? "✓ " : ""
|
present(
|
||||||
alert.addAction(UIAlertAction(title: "\(prefix)\(scenic.name)", style: .default) { [weak self] _ in
|
PermissionScenicListDialogViewController(
|
||||||
self?.viewModel.toggleScenic(scenic)
|
roleName: viewModel.dialogRoleName,
|
||||||
})
|
scenics: viewModel.dialogScenicList,
|
||||||
}
|
onDismiss: { [weak self] in self?.viewModel.hideScenicList() }
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
),
|
||||||
present(alert, animated: true)
|
animated: true
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func submitTapped() {
|
@objc private func submitTapped() {
|
||||||
Task { await viewModel.submit(api: homeAPI) }
|
Task { await viewModel.submit(api: homeAPI) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleSubmitSuccess(applyCode: String) {
|
private func handleSubmitSuccess() {
|
||||||
guard !applyCode.isEmpty else {
|
hideLoading()
|
||||||
navigationController?.popViewController(animated: true)
|
guard let navigationController else {
|
||||||
|
showToast("提交成功")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var controllers = navigationController?.viewControllers ?? []
|
navigationController.popViewController(animated: true)
|
||||||
controllers.removeAll { $0 === self }
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak navigationController] in
|
||||||
let statusVC = PermissionApplyStatusViewController(applyCode: applyCode)
|
(navigationController?.topViewController as? BaseViewController)?.showToast("提交成功")
|
||||||
controllers.append(statusVC)
|
}
|
||||||
navigationController?.setViewControllers(controllers, animated: true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,11 +52,15 @@ final class ScenicSelectionViewController: BaseViewController, UITableViewDataSo
|
|||||||
emptyLabel.textAlignment = .center
|
emptyLabel.textAlignment = .center
|
||||||
emptyLabel.isHidden = true
|
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.axis = .vertical
|
||||||
headerStack.spacing = 16
|
headerStack.spacing = 16
|
||||||
headerStack.isLayoutMarginsRelativeArrangement = true
|
|
||||||
headerStack.layoutMargins = UIEdgeInsets(top: 0, left: 16, bottom: 16, right: 16)
|
|
||||||
headerStack.setContentHuggingPriority(.required, for: .vertical)
|
headerStack.setContentHuggingPriority(.required, for: .vertical)
|
||||||
headerStack.setContentCompressionResistancePriority(.required, for: .vertical)
|
headerStack.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||||
|
|
||||||
|
|||||||
@ -19,20 +19,23 @@ final class HomeScenicHeaderView: UIView {
|
|||||||
backgroundColor = AppColor.cardBackground
|
backgroundColor = AppColor.cardBackground
|
||||||
titleLabel.font = .app(.subtitle)
|
titleLabel.font = .app(.subtitle)
|
||||||
titleLabel.textColor = AppColor.textPrimary
|
titleLabel.textColor = AppColor.textPrimary
|
||||||
|
titleLabel.lineBreakMode = .byTruncatingTail
|
||||||
|
titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
arrowView.tintColor = AppColor.textSecondary
|
arrowView.tintColor = AppColor.textSecondary
|
||||||
arrowView.contentMode = .scaleAspectFit
|
arrowView.contentMode = .scaleAspectFit
|
||||||
|
arrowView.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
|
||||||
addSubview(titleLabel)
|
addSubview(titleLabel)
|
||||||
addSubview(arrowView)
|
addSubview(arrowView)
|
||||||
|
|
||||||
titleLabel.snp.makeConstraints { make in
|
titleLabel.snp.makeConstraints { make in
|
||||||
make.leading.equalToSuperview().offset(AppSpacing.screenHorizontalInset)
|
make.leading.equalTo(safeAreaLayoutGuide).offset(AppSpacing.screenHorizontalInset)
|
||||||
make.centerY.equalToSuperview()
|
make.centerY.equalTo(safeAreaLayoutGuide)
|
||||||
make.trailing.lessThanOrEqualTo(arrowView.snp.leading).offset(-AppSpacing.xs)
|
|
||||||
}
|
}
|
||||||
arrowView.snp.makeConstraints { make in
|
arrowView.snp.makeConstraints { make in
|
||||||
make.trailing.equalToSuperview().offset(-AppSpacing.screenHorizontalInset)
|
make.leading.equalTo(titleLabel.snp.trailing).offset(AppSpacing.xs)
|
||||||
make.centerY.equalToSuperview()
|
make.trailing.lessThanOrEqualTo(safeAreaLayoutGuide).offset(-AppSpacing.screenHorizontalInset)
|
||||||
|
make.centerY.equalTo(safeAreaLayoutGuide)
|
||||||
make.width.height.equalTo(16)
|
make.width.height.equalTo(16)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -34,7 +34,9 @@ final class HomeWorkStatusCardView: UIView {
|
|||||||
|
|
||||||
onlineButton.titleLabel?.font = .app(.captionMedium)
|
onlineButton.titleLabel?.font = .app(.captionMedium)
|
||||||
onlineButton.layer.cornerRadius = AppRadius.sm
|
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)
|
onlineButton.addTarget(self, action: #selector(onlineTapped), for: .touchUpInside)
|
||||||
|
|
||||||
countdownStackView.axis = .horizontal
|
countdownStackView.axis = .horizontal
|
||||||
|
|||||||
@ -6,11 +6,26 @@
|
|||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
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 {
|
final class PermissionApplyBannerView: UIView {
|
||||||
|
|
||||||
var onApplyNewScenic: (() -> Void)?
|
var onApplyNewScenic: (() -> Void)?
|
||||||
|
|
||||||
|
private let iconView = UIImageView()
|
||||||
private let titleLabel = UILabel()
|
private let titleLabel = UILabel()
|
||||||
private let applyButton = UIButton(type: .system)
|
private let applyButton = UIButton(type: .system)
|
||||||
|
|
||||||
@ -18,28 +33,50 @@ final class PermissionApplyBannerView: UIView {
|
|||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
backgroundColor = AppColor.warningBackground
|
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.text = "没有找到心仪的景区"
|
||||||
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
titleLabel.textColor = AppColor.warning
|
titleLabel.textColor = AppColor.warning
|
||||||
|
titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
|
|
||||||
applyButton.setTitle("申请平台开通新景区", for: .normal)
|
var configuration = UIButton.Configuration.plain()
|
||||||
applyButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
|
configuration.title = "申请平台开通新景区"
|
||||||
applyButton.setTitleColor(AppColor.warning, for: .normal)
|
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)
|
applyButton.addTarget(self, action: #selector(applyTapped), for: .touchUpInside)
|
||||||
|
|
||||||
addSubview(titleLabel)
|
[iconView, titleLabel, applyButton].forEach(addSubview)
|
||||||
addSubview(applyButton)
|
iconView.snp.makeConstraints { make in
|
||||||
titleLabel.snp.makeConstraints { make in
|
|
||||||
make.leading.equalToSuperview().offset(16)
|
make.leading.equalToSuperview().offset(16)
|
||||||
make.centerY.equalToSuperview()
|
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
|
applyButton.snp.makeConstraints { make in
|
||||||
make.trailing.equalToSuperview().offset(-16)
|
make.trailing.equalToSuperview().offset(-16)
|
||||||
make.centerY.equalToSuperview()
|
make.centerY.equalToSuperview()
|
||||||
|
make.height.greaterThanOrEqualTo(44)
|
||||||
}
|
}
|
||||||
snp.makeConstraints { make in
|
snp.makeConstraints { make in make.height.equalTo(48) }
|
||||||
make.height.equalTo(44)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
@ -52,7 +89,7 @@ final class PermissionApplyBannerView: UIView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 表单字段选择行,对齐 Android `FormField` + Selector。
|
/// Android 表单标题、说明和锚定选择框组合视图。
|
||||||
final class PermissionFormSelectorRow: UIView {
|
final class PermissionFormSelectorRow: UIView {
|
||||||
|
|
||||||
var onTap: (() -> Void)?
|
var onTap: (() -> Void)?
|
||||||
@ -60,6 +97,7 @@ final class PermissionFormSelectorRow: UIView {
|
|||||||
private let titleLabel = UILabel()
|
private let titleLabel = UILabel()
|
||||||
private let tagLabel = UILabel()
|
private let tagLabel = UILabel()
|
||||||
private let descriptionLabel = UILabel()
|
private let descriptionLabel = UILabel()
|
||||||
|
let selectorControl = UIControl()
|
||||||
private let valueLabel = UILabel()
|
private let valueLabel = UILabel()
|
||||||
private let chevron = UIImageView(image: UIImage(systemName: "chevron.down"))
|
private let chevron = UIImageView(image: UIImage(systemName: "chevron.down"))
|
||||||
|
|
||||||
@ -67,7 +105,7 @@ final class PermissionFormSelectorRow: UIView {
|
|||||||
super.init(frame: .zero)
|
super.init(frame: .zero)
|
||||||
titleLabel.text = title
|
titleLabel.text = title
|
||||||
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
|
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
|
||||||
titleLabel.textColor = AppColor.textPrimary
|
titleLabel.textColor = UIColor(hex: 0x333333)
|
||||||
|
|
||||||
tagLabel.text = tag
|
tagLabel.text = tag
|
||||||
tagLabel.font = .systemFont(ofSize: 12)
|
tagLabel.font = .systemFont(ofSize: 12)
|
||||||
@ -76,28 +114,28 @@ final class PermissionFormSelectorRow: UIView {
|
|||||||
|
|
||||||
descriptionLabel.text = description
|
descriptionLabel.text = description
|
||||||
descriptionLabel.font = .systemFont(ofSize: 12)
|
descriptionLabel.font = .systemFont(ofSize: 12)
|
||||||
descriptionLabel.textColor = UIColor(hex: 0xB3B8C2)
|
descriptionLabel.textColor = PermissionApplyUITokens.placeholder
|
||||||
descriptionLabel.numberOfLines = 0
|
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.text = placeholder
|
||||||
valueLabel.font = .systemFont(ofSize: 14)
|
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
|
chevron.contentMode = .scaleAspectFit
|
||||||
|
|
||||||
let tap = UITapGestureRecognizer(target: self, action: #selector(rowTapped))
|
[titleLabel, tagLabel, descriptionLabel, selectorControl].forEach(addSubview)
|
||||||
addGestureRecognizer(tap)
|
[valueLabel, chevron].forEach(selectorControl.addSubview)
|
||||||
|
|
||||||
addSubview(titleLabel)
|
titleLabel.snp.makeConstraints { make in make.top.leading.equalToSuperview() }
|
||||||
addSubview(tagLabel)
|
|
||||||
addSubview(descriptionLabel)
|
|
||||||
addSubview(valueLabel)
|
|
||||||
addSubview(chevron)
|
|
||||||
|
|
||||||
titleLabel.snp.makeConstraints { make in
|
|
||||||
make.top.leading.equalToSuperview()
|
|
||||||
}
|
|
||||||
tagLabel.snp.makeConstraints { make in
|
tagLabel.snp.makeConstraints { make in
|
||||||
make.centerY.equalTo(titleLabel)
|
make.centerY.equalTo(titleLabel)
|
||||||
make.trailing.equalToSuperview()
|
make.trailing.equalToSuperview()
|
||||||
@ -107,121 +145,18 @@ final class PermissionFormSelectorRow: UIView {
|
|||||||
make.top.equalTo(titleLabel.snp.bottom).offset(4)
|
make.top.equalTo(titleLabel.snp.bottom).offset(4)
|
||||||
make.leading.trailing.equalToSuperview()
|
make.leading.trailing.equalToSuperview()
|
||||||
}
|
}
|
||||||
valueLabel.snp.makeConstraints { make in
|
selectorControl.snp.makeConstraints { make in
|
||||||
make.top.equalTo(descriptionLabel.snp.bottom).offset(8)
|
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)
|
make.trailing.lessThanOrEqualTo(chevron.snp.leading).offset(-8)
|
||||||
}
|
}
|
||||||
chevron.snp.makeConstraints { make in
|
chevron.snp.makeConstraints { make in
|
||||||
make.trailing.equalToSuperview()
|
make.trailing.equalToSuperview().offset(-16)
|
||||||
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.centerY.equalToSuperview()
|
make.centerY.equalToSuperview()
|
||||||
make.size.equalTo(20)
|
make.size.equalTo(20)
|
||||||
}
|
}
|
||||||
@ -232,23 +167,156 @@ final class PermissionTagChip: UIView {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
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) {
|
/// Diffable 标签网格中的稳定条目。
|
||||||
super.init(frame: frame)
|
struct PermissionChipItem: Hashable {
|
||||||
setTitle("提交审核", for: .normal)
|
let id: String
|
||||||
setTitleColor(.white, for: .normal)
|
let title: String
|
||||||
titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
}
|
||||||
backgroundColor = AppColor.primary
|
|
||||||
layer.cornerRadius = 8
|
/// Android `FlowRow` 的标签左对齐与换行坐标计算器。
|
||||||
snp.makeConstraints { make in
|
struct PermissionChipLayoutMetrics {
|
||||||
make.height.equalTo(48)
|
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")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
func setSubmitting(_ submitting: Bool) {
|
/// 使用 Diffable snapshot 更新标签。
|
||||||
isEnabled = !submitting
|
func apply(titles: [String]) {
|
||||||
alpha = submitting ? 0.6 : 1
|
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)
|
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?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
360
suixinkan/UI/Home/Views/PermissionDropdownViewController.swift
Normal file
360
suixinkan/UI/Home/Views/PermissionDropdownViewController.swift
Normal 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: ",")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
590
suixinkan/UI/Home/Views/PermissionRoleViews.swift
Normal file
590
suixinkan/UI/Home/Views/PermissionRoleViews.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -137,7 +137,9 @@ final class ScenicCurrentLocationBar: UIView {
|
|||||||
relocateButton.setTitleColor(AppColor.primary, for: .normal)
|
relocateButton.setTitleColor(AppColor.primary, for: .normal)
|
||||||
relocateButton.backgroundColor = AppColor.primaryLight
|
relocateButton.backgroundColor = AppColor.primaryLight
|
||||||
relocateButton.layer.cornerRadius = 4
|
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.addTarget(self, action: #selector(relocateTapped), for: .touchUpInside)
|
||||||
relocateButton.setContentHuggingPriority(.required, for: .horizontal)
|
relocateButton.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
relocateButton.setContentCompressionResistancePriority(.required, for: .horizontal)
|
relocateButton.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
|||||||
@ -245,7 +245,9 @@ private final class LiveSettingsCardView: UIView {
|
|||||||
[clarityButton, smoothButton].forEach { button in
|
[clarityButton, smoothButton].forEach { button in
|
||||||
button.titleLabel?.font = .systemFont(ofSize: 14)
|
button.titleLabel?.font = .systemFont(ofSize: 14)
|
||||||
button.layer.cornerRadius = 4
|
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)
|
clarityButton.setTitle(LivePushMode.clarityFirst.title, for: .normal)
|
||||||
smoothButton.setTitle(LivePushMode.smoothFirst.title, for: .normal)
|
smoothButton.setTitle(LivePushMode.smoothFirst.title, for: .normal)
|
||||||
|
|||||||
@ -559,7 +559,9 @@ private final class MaterialAnchoredDropdownView: UIControl {
|
|||||||
let button = UIButton(type: .system)
|
let button = UIButton(type: .system)
|
||||||
button.tag = index
|
button.tag = index
|
||||||
button.contentHorizontalAlignment = .leading
|
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.setTitle(option, for: .normal)
|
||||||
button.setTitleColor(index == selectedIndex ? AppColor.primary : .black, for: .normal)
|
button.setTitleColor(index == selectedIndex ? AppColor.primary : .black, for: .normal)
|
||||||
button.titleLabel?.font = .systemFont(ofSize: 14, weight: index == selectedIndex ? .medium : .regular)
|
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.titleLabel?.font = .systemFont(ofSize: 12)
|
||||||
button.backgroundColor = UIColor(hex: 0xEFF6FF)
|
button.backgroundColor = UIColor(hex: 0xEFF6FF)
|
||||||
button.layer.cornerRadius = 4
|
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.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
button.setContentCompressionResistancePriority(.required, for: .horizontal)
|
button.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
button.snp.makeConstraints { make in
|
button.snp.makeConstraints { make in
|
||||||
|
|||||||
@ -281,7 +281,9 @@ final class MaterialListViewController: BaseViewController {
|
|||||||
MaterialFilterStatus.allCases.forEach { status in
|
MaterialFilterStatus.allCases.forEach { status in
|
||||||
let button = UIButton(type: .system)
|
let button = UIButton(type: .system)
|
||||||
button.contentHorizontalAlignment = .leading
|
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.setTitle(status.title, for: .normal)
|
||||||
button.backgroundColor = .white
|
button.backgroundColor = .white
|
||||||
button.snp.makeConstraints { make in
|
button.snp.makeConstraints { make in
|
||||||
|
|||||||
@ -351,7 +351,9 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
|
|||||||
addDeviceButton.setTitleColor(AppColor.primary, for: .normal)
|
addDeviceButton.setTitleColor(AppColor.primary, for: .normal)
|
||||||
addDeviceButton.titleLabel?.font = .systemFont(ofSize: 14)
|
addDeviceButton.titleLabel?.font = .systemFont(ofSize: 14)
|
||||||
addDeviceButton.layer.cornerRadius = 4
|
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.contentHorizontalAlignment = .left
|
||||||
addDeviceButton.snp.makeConstraints { make in make.height.equalTo(28) }
|
addDeviceButton.snp.makeConstraints { make in make.height.equalTo(28) }
|
||||||
deviceWrap.setTrailingView(addDeviceButton)
|
deviceWrap.setTrailingView(addDeviceButton)
|
||||||
@ -940,7 +942,6 @@ final class ProfileAddScheduleViewController: BaseViewController {
|
|||||||
orderButton.layer.borderColor = AppColor.border.cgColor
|
orderButton.layer.borderColor = AppColor.border.cgColor
|
||||||
orderButton.layer.borderWidth = 1
|
orderButton.layer.borderWidth = 1
|
||||||
orderButton.layer.cornerRadius = 8
|
orderButton.layer.cornerRadius = 8
|
||||||
orderButton.contentEdgeInsets = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
|
|
||||||
var configuration = UIButton.Configuration.plain()
|
var configuration = UIButton.Configuration.plain()
|
||||||
configuration.title = "关联订单 (可选)"
|
configuration.title = "关联订单 (可选)"
|
||||||
configuration.image = UIImage(systemName: "chevron.down")
|
configuration.image = UIImage(systemName: "chevron.down")
|
||||||
@ -1101,7 +1102,9 @@ private final class ProfileTimeButton: UIButton {
|
|||||||
setTitleColor(UIColor(hex: 0x4B5563), for: .normal)
|
setTitleColor(UIColor(hex: 0x4B5563), for: .normal)
|
||||||
backgroundColor = AppColor.inputBackground
|
backgroundColor = AppColor.inputBackground
|
||||||
layer.cornerRadius = 4
|
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
|
contentHorizontalAlignment = .center
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1253,7 +1256,10 @@ private final class ProfileTagWrapView: UIView {
|
|||||||
let button = UIButton(type: .system)
|
let button = UIButton(type: .system)
|
||||||
button.setTitle(removable ? "\(title) ×" : title, for: .normal)
|
button.setTitle(removable ? "\(title) ×" : title, for: .normal)
|
||||||
button.titleLabel?.font = .systemFont(ofSize: style == .device ? 14 : 12)
|
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
|
button.layer.cornerRadius = 4
|
||||||
switch style {
|
switch style {
|
||||||
case .label:
|
case .label:
|
||||||
|
|||||||
@ -287,7 +287,9 @@ final class SampleListViewController: SampleBaseViewController {
|
|||||||
SampleFilterStatus.allCases.forEach { status in
|
SampleFilterStatus.allCases.forEach { status in
|
||||||
let button = UIButton(type: .system)
|
let button = UIButton(type: .system)
|
||||||
button.contentHorizontalAlignment = .leading
|
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.setTitle(status.title, for: .normal)
|
||||||
button.backgroundColor = .white
|
button.backgroundColor = .white
|
||||||
button.snp.makeConstraints { make in
|
button.snp.makeConstraints { make in
|
||||||
|
|||||||
@ -107,7 +107,9 @@ final class SampleAnchoredDropdownView: UIControl, UIScrollViewDelegate {
|
|||||||
let button = UIButton(type: .system)
|
let button = UIButton(type: .system)
|
||||||
button.tag = index
|
button.tag = index
|
||||||
button.contentHorizontalAlignment = .leading
|
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)
|
button.setTitle(option.title, for: .normal)
|
||||||
let color = option.isSelected ? AppColor.primary : (option.isLoadMore ? AppColor.textSecondary : .black)
|
let color = option.isSelected ? AppColor.primary : (option.isLoadMore ? AppColor.textSecondary : .black)
|
||||||
button.setTitleColor(color, for: .normal)
|
button.setTitleColor(color, for: .normal)
|
||||||
|
|||||||
@ -9,17 +9,33 @@ import UIKit
|
|||||||
/// 数据统计周期 pill 按钮,对齐 Android `PeriodButton`。
|
/// 数据统计周期 pill 按钮,对齐 Android `PeriodButton`。
|
||||||
final class StatisticsPeriodButton: UIButton {
|
final class StatisticsPeriodButton: UIButton {
|
||||||
|
|
||||||
|
/// 按钮圆角策略,支持随高度变化的胶囊形与固定圆角。
|
||||||
|
enum CornerStyle: Equatable {
|
||||||
|
case capsule
|
||||||
|
case fixed(CGFloat)
|
||||||
|
}
|
||||||
|
|
||||||
var periodTitle: String = "" {
|
var periodTitle: String = "" {
|
||||||
didSet { setTitle(periodTitle, for: .normal) }
|
didSet { setTitle(periodTitle, for: .normal) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 当前圆角策略,默认使用高度一半的胶囊形。
|
||||||
|
var cornerStyle: CornerStyle = .capsule {
|
||||||
|
didSet { setNeedsLayout() }
|
||||||
|
}
|
||||||
|
|
||||||
override var isSelected: Bool {
|
override var isSelected: Bool {
|
||||||
didSet { updateAppearance() }
|
didSet { updateAppearance() }
|
||||||
}
|
}
|
||||||
|
|
||||||
override func layoutSubviews() {
|
override func layoutSubviews() {
|
||||||
super.layoutSubviews()
|
super.layoutSubviews()
|
||||||
|
switch cornerStyle {
|
||||||
|
case .capsule:
|
||||||
layer.cornerRadius = bounds.height / 2
|
layer.cornerRadius = bounds.height / 2
|
||||||
|
case .fixed(let radius):
|
||||||
|
layer.cornerRadius = max(0, radius)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
@ -36,7 +52,9 @@ final class StatisticsPeriodButton: UIButton {
|
|||||||
titleLabel?.font = .app(.bodyMedium)
|
titleLabel?.font = .app(.bodyMedium)
|
||||||
layer.cornerCurve = .continuous
|
layer.cornerCurve = .continuous
|
||||||
clipsToBounds = true
|
clipsToBounds = true
|
||||||
contentEdgeInsets = UIEdgeInsets(top: 6, left: 20, bottom: 6, right: 20)
|
setConfigurationContentInsets(
|
||||||
|
NSDirectionalEdgeInsets(top: 6, leading: 20, bottom: 6, trailing: 20)
|
||||||
|
)
|
||||||
updateAppearance()
|
updateAppearance()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -78,7 +78,9 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
|||||||
orderButton.layer.cornerRadius = 8
|
orderButton.layer.cornerRadius = 8
|
||||||
orderButton.layer.borderColor = AppColor.border.cgColor
|
orderButton.layer.borderColor = AppColor.border.cgColor
|
||||||
orderButton.layer.borderWidth = 1
|
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(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xF4F4F4), titleColor: AppColor.textSecondary)
|
||||||
configureActionButton(confirmButton, title: "确定", backgroundColor: AppColor.primary, titleColor: .white)
|
configureActionButton(confirmButton, title: "确定", backgroundColor: AppColor.primary, titleColor: .white)
|
||||||
|
|||||||
@ -216,7 +216,9 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
private func configurePillButton(_ button: UIButton) {
|
private func configurePillButton(_ button: UIButton) {
|
||||||
button.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
|
button.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
|
||||||
button.layer.cornerRadius = 16
|
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) {
|
private func configureTabButton(_ button: UIButton, title: String, selected: Bool) {
|
||||||
|
|||||||
@ -165,7 +165,9 @@ final class WiredCameraTransferViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
retouchButton.isUserInteractionEnabled = false
|
retouchButton.isUserInteractionEnabled = false
|
||||||
formatButton.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")?
|
modeChevronView.image = UIImage(systemName: "chevron.down")?
|
||||||
.withConfiguration(UIImage.SymbolConfiguration(pointSize: 13, weight: .semibold))
|
.withConfiguration(UIImage.SymbolConfiguration(pointSize: 13, weight: .semibold))
|
||||||
modeChevronView.tintColor = AppColor.textTertiary
|
modeChevronView.tintColor = AppColor.textTertiary
|
||||||
@ -648,7 +650,9 @@ final class WiredCameraTransferViewController: BaseViewController {
|
|||||||
button.layer.borderWidth = 1
|
button.layer.borderWidth = 1
|
||||||
button.layer.borderColor = AppColor.border.cgColor
|
button.layer.borderColor = AppColor.border.cgColor
|
||||||
button.contentHorizontalAlignment = .leading
|
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 {
|
private func makeStatButton(title: String, count: Int, selected: Bool, danger: Bool) -> UIButton {
|
||||||
|
|||||||
@ -29,8 +29,11 @@ enum WildReportUI {
|
|||||||
button.setTitleColor(style == .primary ? .white : AppColor.primary, for: .normal)
|
button.setTitleColor(style == .primary ? .white : AppColor.primary, for: .normal)
|
||||||
button.backgroundColor = style == .primary ? AppColor.primary : AppColor.primaryLight
|
button.backgroundColor = style == .primary ? AppColor.primary : AppColor.primaryLight
|
||||||
button.layer.cornerRadius = AppRadius.md
|
button.layer.cornerRadius = AppRadius.md
|
||||||
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -4, bottom: 0, right: 4)
|
button.setConfigurationContentInsets(
|
||||||
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
|
NSDirectionalEdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12)
|
||||||
|
)
|
||||||
|
button.configuration?.imagePlacement = .leading
|
||||||
|
button.configuration?.imagePadding = 4
|
||||||
return button
|
return button
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -183,7 +183,9 @@ final class WildPhotographerReportHomeViewController: BaseViewController {
|
|||||||
ruleButton.setTitleColor(AppColor.primary, for: .normal)
|
ruleButton.setTitleColor(AppColor.primary, for: .normal)
|
||||||
ruleButton.backgroundColor = AppColor.primaryLight
|
ruleButton.backgroundColor = AppColor.primaryLight
|
||||||
ruleButton.layer.cornerRadius = 18
|
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.addTarget(self, action: #selector(ruleTapped), for: .touchUpInside)
|
||||||
ruleButton.snp.makeConstraints { make in
|
ruleButton.snp.makeConstraints { make in
|
||||||
make.height.equalTo(36)
|
make.height.equalTo(36)
|
||||||
|
|||||||
@ -1186,8 +1186,11 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
|
|||||||
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .bold)
|
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .bold)
|
||||||
button.titleLabel?.adjustsFontSizeToFitWidth = true
|
button.titleLabel?.adjustsFontSizeToFitWidth = true
|
||||||
button.titleLabel?.minimumScaleFactor = 0.82
|
button.titleLabel?.minimumScaleFactor = 0.82
|
||||||
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -4, bottom: 0, right: 4)
|
button.setConfigurationContentInsets(
|
||||||
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10)
|
NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)
|
||||||
|
)
|
||||||
|
button.configuration?.imagePlacement = .leading
|
||||||
|
button.configuration?.imagePadding = 4
|
||||||
return button
|
return button
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -605,8 +605,11 @@ final class WildReportRiskMapViewController: BaseViewController {
|
|||||||
button.setTitleColor(.white, for: .normal)
|
button.setTitleColor(.white, for: .normal)
|
||||||
button.backgroundColor = color
|
button.backgroundColor = color
|
||||||
button.layer.cornerRadius = 12
|
button.layer.cornerRadius = 12
|
||||||
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -3, bottom: 0, right: 3)
|
button.setConfigurationContentInsets(
|
||||||
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
|
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) }
|
button.snp.makeConstraints { make in make.height.equalTo(44) }
|
||||||
return button
|
return button
|
||||||
}
|
}
|
||||||
|
|||||||
@ -78,10 +78,12 @@ final class HomeCommonMenuStoreTests: XCTestCase {
|
|||||||
XCTAssertEqual(item?.iconName, "shield.fill")
|
XCTAssertEqual(item?.iconName, "shield.fill")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testHomeMenuCatalogUsesSyncedSymbols() {
|
func testHomeMenuCatalogUsesIOS16CompatibleIcons() {
|
||||||
XCTAssertEqual(HomeMenuCatalog.item(for: "space_settings")?.iconName, "person.crop.square.fill")
|
XCTAssertEqual(HomeMenuCatalog.item(for: "space_settings")?.iconName, "home_menu_space")
|
||||||
XCTAssertEqual(HomeMenuCatalog.item(for: "wallet")?.iconName, "creditcard.fill")
|
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: "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(HomeMenuCatalog.item(for: "/scenic-queue")?.iconName, "person.3.fill")
|
||||||
XCTAssertEqual(HomeMenuItem.moreFunctions.iconName, "ellipsis")
|
XCTAssertEqual(HomeMenuItem.moreFunctions.iconName, "ellipsis")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -123,6 +123,53 @@ final class HomeViewModelTests: XCTestCase {
|
|||||||
XCTAssertTrue(viewModel.commonMenus.isEmpty)
|
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 {
|
func testRolePermissionBuildsCommonMenusAndStoreCardRole() async throws {
|
||||||
appStore.session.roleCode = AppRoleCode.storeAdmin.rawValue
|
appStore.session.roleCode = AppRoleCode.storeAdmin.rawValue
|
||||||
let permissionResponse = RolePermissionResponse(
|
let permissionResponse = RolePermissionResponse(
|
||||||
|
|||||||
@ -53,4 +53,30 @@ final class PermissionApplyStatusViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertNil(edited)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,6 +46,8 @@ final class PermissionApplyViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(existing?.isDisabled, true)
|
XCTAssertEqual(existing?.isDisabled, true)
|
||||||
XCTAssertEqual(existing?.isSelected, true)
|
XCTAssertEqual(existing?.isSelected, true)
|
||||||
XCTAssertEqual(fresh?.isDisabled, false)
|
XCTAssertEqual(fresh?.isDisabled, false)
|
||||||
|
if let existing { viewModel.toggleScenic(existing) }
|
||||||
|
XCTAssertTrue(viewModel.selectedScenicIds.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSubmitValidationRequiresRoleAndScenic() async {
|
func testSubmitValidationRequiresRoleAndScenic() async {
|
||||||
@ -55,21 +57,24 @@ final class PermissionApplyViewModelTests: XCTestCase {
|
|||||||
viewModel.onShowMessage = { message = $0 }
|
viewModel.onShowMessage = { message = $0 }
|
||||||
|
|
||||||
await viewModel.submit(api: api)
|
await viewModel.submit(api: api)
|
||||||
XCTAssertEqual(message, "请选择角色")
|
XCTAssertEqual(message, "请选择景区和角色")
|
||||||
|
XCTAssertFalse(viewModel.canSubmit)
|
||||||
|
|
||||||
viewModel.selectRole(RoleOption(id: 10, name: "摄影师", description: "", isSelected: true))
|
viewModel.selectRole(RoleOption(id: 10, name: "摄影师", description: "", isSelected: true))
|
||||||
await viewModel.submit(api: api)
|
await viewModel.submit(api: api)
|
||||||
XCTAssertEqual(message, "请至少选择一个景区")
|
XCTAssertEqual(message, "请选择景区和角色")
|
||||||
|
XCTAssertFalse(viewModel.canSubmit)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSubmitSuccessReturnsApplyCode() async throws {
|
func testSubmitSuccessUsesAndroidPayloadAndCompletion() async throws {
|
||||||
let scenicJSON = """
|
let scenicJSON = """
|
||||||
{"code":100000,"msg":"success","data":{"total":1,"list":[{"id":101,"name":"新景区"}]}}
|
{"code":100000,"msg":"success","data":{"total":1,"list":[{"id":101,"name":"新景区"}]}}
|
||||||
""".data(using: .utf8)!
|
""".data(using: .utf8)!
|
||||||
let submitJSON = """
|
let submitJSON = """
|
||||||
{"code":100000,"msg":"success","data":{"code":"AP999"}}
|
{"code":100000,"msg":"success","data":{"code":"AP999"}}
|
||||||
""".data(using: .utf8)!
|
""".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)
|
let viewModel = PermissionApplyViewModel(appStore: appStore)
|
||||||
viewModel.selectRole(RoleOption(id: 10, name: "摄影师", description: "", isSelected: true))
|
viewModel.selectRole(RoleOption(id: 10, name: "摄影师", description: "", isSelected: true))
|
||||||
await viewModel.loadAvailableScenics(api: api)
|
await viewModel.loadAvailableScenics(api: api)
|
||||||
@ -77,10 +82,138 @@ final class PermissionApplyViewModelTests: XCTestCase {
|
|||||||
viewModel.toggleScenic(scenic)
|
viewModel.toggleScenic(scenic)
|
||||||
}
|
}
|
||||||
|
|
||||||
var applyCode: String?
|
var didSubmit = false
|
||||||
viewModel.onSubmitSuccess = { applyCode = $0 }
|
viewModel.onSubmitSuccess = { didSubmit = true }
|
||||||
|
XCTAssertTrue(viewModel.canSubmit)
|
||||||
await viewModel.submit(api: api)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
suixinkanTests/PermissionApplyViewsTests.swift
Normal file
25
suixinkanTests/PermissionApplyViewsTests.swift
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -304,7 +304,15 @@ final class ScenicQueueFeatureTests: XCTestCase {
|
|||||||
let tokenProvider = MockAliyunNLSTokenProvider()
|
let tokenProvider = MockAliyunNLSTokenProvider()
|
||||||
let bridge = MockAliyunNuiTTSBridge()
|
let bridge = MockAliyunNuiTTSBridge()
|
||||||
let player = MockAliyunPCMStreamingPlayer()
|
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(
|
let manager = AliyunNLSTTSManager(
|
||||||
|
appStore: store,
|
||||||
tokenProvider: tokenProvider,
|
tokenProvider: tokenProvider,
|
||||||
bridge: bridge,
|
bridge: bridge,
|
||||||
streamingPlayer: player
|
streamingPlayer: player
|
||||||
@ -321,6 +329,58 @@ final class ScenicQueueFeatureTests: XCTestCase {
|
|||||||
XCTAssertEqual(Array(player.events.suffix(3)), ["start:16000", "write:4", "drain"])
|
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() {
|
func testSettingsOfflineTTSSwitchReportsUnavailable() {
|
||||||
let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.offlineTTS")!
|
let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.offlineTTS")!
|
||||||
defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.offlineTTS")
|
defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.offlineTTS")
|
||||||
@ -471,10 +531,13 @@ private final class MockAliyunNLSTokenProvider: AliyunNLSTokenProviding {
|
|||||||
private final class MockAliyunNuiTTSBridge: AliyunNuiTTSBridging {
|
private final class MockAliyunNuiTTSBridge: AliyunNuiTTSBridging {
|
||||||
weak var delegate: AliyunNuiTTSBridgeDelegate?
|
weak var delegate: AliyunNuiTTSBridgeDelegate?
|
||||||
var sdkAvailable = true
|
var sdkAvailable = true
|
||||||
|
var paramValues: [String: String] = [:]
|
||||||
private(set) var events: [String] = []
|
private(set) var events: [String] = []
|
||||||
|
private(set) var initializedTickets: [String] = []
|
||||||
private let callbackBridge = AliyunNuiTTSBridge()
|
private let callbackBridge = AliyunNuiTTSBridge()
|
||||||
|
|
||||||
func initialize(ticket: String, logLevel: Int, saveLog: Bool) -> Int {
|
func initialize(ticket: String, logLevel: Int, saveLog: Bool) -> Int {
|
||||||
|
initializedTickets.append(ticket)
|
||||||
events.append("initialize:\(ticket.contains("mock-nls-token"))")
|
events.append("initialize:\(ticket.contains("mock-nls-token"))")
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@ -507,7 +570,7 @@ private final class MockAliyunNuiTTSBridge: AliyunNuiTTSBridging {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func paramValue(for key: String) -> String? {
|
func paramValue(for key: String) -> String? {
|
||||||
nil
|
paramValues[key]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -49,6 +49,25 @@ final class StatisticsViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(viewModel.rangeValue(for: "本月"), "4")
|
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() {
|
func testFormattedSelectedPeriodTimeForToday() {
|
||||||
let viewModel = StatisticsViewModel()
|
let viewModel = StatisticsViewModel()
|
||||||
let text = viewModel.formattedSelectedPeriodTime(for: "今日")
|
let text = viewModel.formattedSelectedPeriodTime(for: "今日")
|
||||||
|
|||||||
71
suixinkanTests/UIButtonConfigurationTests.swift
Normal file
71
suixinkanTests/UIButtonConfigurationTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user