diff --git a/suixinkan/Assets.xcassets/PunchPoint/Contents.json b/suixinkan/Assets.xcassets/PunchPoint/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/suixinkan/Assets.xcassets/PunchPoint/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/PunchPoint/punch_point_delete.imageset/Contents.json b/suixinkan/Assets.xcassets/PunchPoint/punch_point_delete.imageset/Contents.json new file mode 100644 index 0000000..a3877f3 --- /dev/null +++ b/suixinkan/Assets.xcassets/PunchPoint/punch_point_delete.imageset/Contents.json @@ -0,0 +1,5 @@ +{ + "images" : [{ "filename" : "punch_point_delete.svg", "idiom" : "universal" }], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { "preserves-vector-representation" : true } +} diff --git a/suixinkan/Assets.xcassets/PunchPoint/punch_point_delete.imageset/punch_point_delete.svg b/suixinkan/Assets.xcassets/PunchPoint/punch_point_delete.imageset/punch_point_delete.svg new file mode 100644 index 0000000..66a9817 --- /dev/null +++ b/suixinkan/Assets.xcassets/PunchPoint/punch_point_delete.imageset/punch_point_delete.svg @@ -0,0 +1 @@ + diff --git a/suixinkan/Assets.xcassets/PunchPoint/punch_point_dropdown.imageset/Contents.json b/suixinkan/Assets.xcassets/PunchPoint/punch_point_dropdown.imageset/Contents.json new file mode 100644 index 0000000..4980ff1 --- /dev/null +++ b/suixinkan/Assets.xcassets/PunchPoint/punch_point_dropdown.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "punch_point_dropdown.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/suixinkan/Assets.xcassets/PunchPoint/punch_point_dropdown.imageset/punch_point_dropdown.svg b/suixinkan/Assets.xcassets/PunchPoint/punch_point_dropdown.imageset/punch_point_dropdown.svg new file mode 100644 index 0000000..37b5796 --- /dev/null +++ b/suixinkan/Assets.xcassets/PunchPoint/punch_point_dropdown.imageset/punch_point_dropdown.svg @@ -0,0 +1,3 @@ + + + diff --git a/suixinkan/Assets.xcassets/PunchPoint/punch_point_image_add.imageset/Contents.json b/suixinkan/Assets.xcassets/PunchPoint/punch_point_image_add.imageset/Contents.json new file mode 100644 index 0000000..419c091 --- /dev/null +++ b/suixinkan/Assets.xcassets/PunchPoint/punch_point_image_add.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "punch_point_image_add.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/suixinkan/Assets.xcassets/PunchPoint/punch_point_image_add.imageset/punch_point_image_add.svg b/suixinkan/Assets.xcassets/PunchPoint/punch_point_image_add.imageset/punch_point_image_add.svg new file mode 100644 index 0000000..b12bd25 --- /dev/null +++ b/suixinkan/Assets.xcassets/PunchPoint/punch_point_image_add.imageset/punch_point_image_add.svg @@ -0,0 +1,3 @@ + + + diff --git a/suixinkan/Assets.xcassets/PunchPoint/punch_point_image_delete.imageset/Contents.json b/suixinkan/Assets.xcassets/PunchPoint/punch_point_image_delete.imageset/Contents.json new file mode 100644 index 0000000..0d3d710 --- /dev/null +++ b/suixinkan/Assets.xcassets/PunchPoint/punch_point_image_delete.imageset/Contents.json @@ -0,0 +1,5 @@ +{ + "images" : [{ "filename" : "punch_point_image_delete.svg", "idiom" : "universal" }], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { "preserves-vector-representation" : true } +} diff --git a/suixinkan/Assets.xcassets/PunchPoint/punch_point_image_delete.imageset/punch_point_image_delete.svg b/suixinkan/Assets.xcassets/PunchPoint/punch_point_image_delete.imageset/punch_point_image_delete.svg new file mode 100644 index 0000000..201977d --- /dev/null +++ b/suixinkan/Assets.xcassets/PunchPoint/punch_point_image_delete.imageset/punch_point_image_delete.svg @@ -0,0 +1 @@ + diff --git a/suixinkan/Assets.xcassets/PunchPoint/punch_point_location.imageset/Contents.json b/suixinkan/Assets.xcassets/PunchPoint/punch_point_location.imageset/Contents.json new file mode 100644 index 0000000..24a96d8 --- /dev/null +++ b/suixinkan/Assets.xcassets/PunchPoint/punch_point_location.imageset/Contents.json @@ -0,0 +1,5 @@ +{ + "images" : [{ "filename" : "punch_point_location.svg", "idiom" : "universal" }], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { "preserves-vector-representation" : true } +} diff --git a/suixinkan/Assets.xcassets/PunchPoint/punch_point_location.imageset/punch_point_location.svg b/suixinkan/Assets.xcassets/PunchPoint/punch_point_location.imageset/punch_point_location.svg new file mode 100644 index 0000000..c037e4e --- /dev/null +++ b/suixinkan/Assets.xcassets/PunchPoint/punch_point_location.imageset/punch_point_location.svg @@ -0,0 +1 @@ + diff --git a/suixinkan/Assets.xcassets/PunchPoint/punch_point_map_location.imageset/Contents.json b/suixinkan/Assets.xcassets/PunchPoint/punch_point_map_location.imageset/Contents.json new file mode 100644 index 0000000..08325d4 --- /dev/null +++ b/suixinkan/Assets.xcassets/PunchPoint/punch_point_map_location.imageset/Contents.json @@ -0,0 +1,5 @@ +{ + "images" : [{ "filename" : "punch_point_map_location.svg", "idiom" : "universal" }], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { "preserves-vector-representation" : true } +} diff --git a/suixinkan/Assets.xcassets/PunchPoint/punch_point_map_location.imageset/punch_point_map_location.svg b/suixinkan/Assets.xcassets/PunchPoint/punch_point_map_location.imageset/punch_point_map_location.svg new file mode 100644 index 0000000..7907218 --- /dev/null +++ b/suixinkan/Assets.xcassets/PunchPoint/punch_point_map_location.imageset/punch_point_map_location.svg @@ -0,0 +1 @@ + diff --git a/suixinkan/Assets.xcassets/PunchPoint/punch_point_marker.imageset/Contents.json b/suixinkan/Assets.xcassets/PunchPoint/punch_point_marker.imageset/Contents.json new file mode 100644 index 0000000..28e5948 --- /dev/null +++ b/suixinkan/Assets.xcassets/PunchPoint/punch_point_marker.imageset/Contents.json @@ -0,0 +1,5 @@ +{ + "images" : [{ "filename" : "punch_point_marker.svg", "idiom" : "universal" }], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { "preserves-vector-representation" : true } +} diff --git a/suixinkan/Assets.xcassets/PunchPoint/punch_point_marker.imageset/punch_point_marker.svg b/suixinkan/Assets.xcassets/PunchPoint/punch_point_marker.imageset/punch_point_marker.svg new file mode 100644 index 0000000..8907bbf --- /dev/null +++ b/suixinkan/Assets.xcassets/PunchPoint/punch_point_marker.imageset/punch_point_marker.svg @@ -0,0 +1 @@ + diff --git a/suixinkan/Assets.xcassets/PunchPoint/punch_point_qr.imageset/Contents.json b/suixinkan/Assets.xcassets/PunchPoint/punch_point_qr.imageset/Contents.json new file mode 100644 index 0000000..aa7573b --- /dev/null +++ b/suixinkan/Assets.xcassets/PunchPoint/punch_point_qr.imageset/Contents.json @@ -0,0 +1,5 @@ +{ + "images" : [{ "filename" : "punch_point_qr.svg", "idiom" : "universal" }], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { "preserves-vector-representation" : true } +} diff --git a/suixinkan/Assets.xcassets/PunchPoint/punch_point_qr.imageset/punch_point_qr.svg b/suixinkan/Assets.xcassets/PunchPoint/punch_point_qr.imageset/punch_point_qr.svg new file mode 100644 index 0000000..dcbcfa2 --- /dev/null +++ b/suixinkan/Assets.xcassets/PunchPoint/punch_point_qr.imageset/punch_point_qr.svg @@ -0,0 +1 @@ + diff --git a/suixinkan/Assets.xcassets/PunchPoint/punch_point_zoom_in.imageset/Contents.json b/suixinkan/Assets.xcassets/PunchPoint/punch_point_zoom_in.imageset/Contents.json new file mode 100644 index 0000000..7519c79 --- /dev/null +++ b/suixinkan/Assets.xcassets/PunchPoint/punch_point_zoom_in.imageset/Contents.json @@ -0,0 +1,5 @@ +{ + "images" : [{ "filename" : "punch_point_zoom_in.svg", "idiom" : "universal" }], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { "preserves-vector-representation" : true } +} diff --git a/suixinkan/Assets.xcassets/PunchPoint/punch_point_zoom_in.imageset/punch_point_zoom_in.svg b/suixinkan/Assets.xcassets/PunchPoint/punch_point_zoom_in.imageset/punch_point_zoom_in.svg new file mode 100644 index 0000000..d913202 --- /dev/null +++ b/suixinkan/Assets.xcassets/PunchPoint/punch_point_zoom_in.imageset/punch_point_zoom_in.svg @@ -0,0 +1 @@ + diff --git a/suixinkan/Assets.xcassets/PunchPoint/punch_point_zoom_out.imageset/Contents.json b/suixinkan/Assets.xcassets/PunchPoint/punch_point_zoom_out.imageset/Contents.json new file mode 100644 index 0000000..53e9eb5 --- /dev/null +++ b/suixinkan/Assets.xcassets/PunchPoint/punch_point_zoom_out.imageset/Contents.json @@ -0,0 +1,5 @@ +{ + "images" : [{ "filename" : "punch_point_zoom_out.svg", "idiom" : "universal" }], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { "preserves-vector-representation" : true } +} diff --git a/suixinkan/Assets.xcassets/PunchPoint/punch_point_zoom_out.imageset/punch_point_zoom_out.svg b/suixinkan/Assets.xcassets/PunchPoint/punch_point_zoom_out.imageset/punch_point_zoom_out.svg new file mode 100644 index 0000000..b42cd53 --- /dev/null +++ b/suixinkan/Assets.xcassets/PunchPoint/punch_point_zoom_out.imageset/punch_point_zoom_out.svg @@ -0,0 +1 @@ + diff --git a/suixinkan/Assets.xcassets/RealName/Contents.json b/suixinkan/Assets.xcassets/RealName/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/suixinkan/Assets.xcassets/RealName/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/RealName/ic_accept_orders.imageset/Contents.json b/suixinkan/Assets.xcassets/RealName/ic_accept_orders.imageset/Contents.json new file mode 100644 index 0000000..67a821b --- /dev/null +++ b/suixinkan/Assets.xcassets/RealName/ic_accept_orders.imageset/Contents.json @@ -0,0 +1,7 @@ +{ + "images" : [ + { "filename" : "ic_accept_orders.svg", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { "preserves-vector-representation" : true, "template-rendering-intent" : "template" } +} diff --git a/suixinkan/Assets.xcassets/RealName/ic_accept_orders.imageset/ic_accept_orders.svg b/suixinkan/Assets.xcassets/RealName/ic_accept_orders.imageset/ic_accept_orders.svg new file mode 100644 index 0000000..063fd9b --- /dev/null +++ b/suixinkan/Assets.xcassets/RealName/ic_accept_orders.imageset/ic_accept_orders.svg @@ -0,0 +1,3 @@ + + + diff --git a/suixinkan/Assets.xcassets/RealName/ic_area_authority.imageset/Contents.json b/suixinkan/Assets.xcassets/RealName/ic_area_authority.imageset/Contents.json new file mode 100644 index 0000000..ee1407b --- /dev/null +++ b/suixinkan/Assets.xcassets/RealName/ic_area_authority.imageset/Contents.json @@ -0,0 +1,7 @@ +{ + "images" : [ + { "filename" : "ic_area_authority.svg", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { "preserves-vector-representation" : true, "template-rendering-intent" : "template" } +} diff --git a/suixinkan/Assets.xcassets/RealName/ic_area_authority.imageset/ic_area_authority.svg b/suixinkan/Assets.xcassets/RealName/ic_area_authority.imageset/ic_area_authority.svg new file mode 100644 index 0000000..6386936 --- /dev/null +++ b/suixinkan/Assets.xcassets/RealName/ic_area_authority.imageset/ic_area_authority.svg @@ -0,0 +1,3 @@ + + + diff --git a/suixinkan/Assets.xcassets/RealName/ic_checked.imageset/Contents.json b/suixinkan/Assets.xcassets/RealName/ic_checked.imageset/Contents.json new file mode 100644 index 0000000..e207e90 --- /dev/null +++ b/suixinkan/Assets.xcassets/RealName/ic_checked.imageset/Contents.json @@ -0,0 +1,7 @@ +{ + "images" : [ + { "filename" : "ic_checked.svg", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { "preserves-vector-representation" : true } +} diff --git a/suixinkan/Assets.xcassets/RealName/ic_checked.imageset/ic_checked.svg b/suixinkan/Assets.xcassets/RealName/ic_checked.imageset/ic_checked.svg new file mode 100644 index 0000000..94877be --- /dev/null +++ b/suixinkan/Assets.xcassets/RealName/ic_checked.imageset/ic_checked.svg @@ -0,0 +1,3 @@ + + + diff --git a/suixinkan/Assets.xcassets/RealName/ic_marketing.imageset/Contents.json b/suixinkan/Assets.xcassets/RealName/ic_marketing.imageset/Contents.json new file mode 100644 index 0000000..770c7fc --- /dev/null +++ b/suixinkan/Assets.xcassets/RealName/ic_marketing.imageset/Contents.json @@ -0,0 +1,7 @@ +{ + "images" : [ + { "filename" : "ic_marketing.svg", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { "preserves-vector-representation" : true, "template-rendering-intent" : "template" } +} diff --git a/suixinkan/Assets.xcassets/RealName/ic_marketing.imageset/ic_marketing.svg b/suixinkan/Assets.xcassets/RealName/ic_marketing.imageset/ic_marketing.svg new file mode 100644 index 0000000..de21414 --- /dev/null +++ b/suixinkan/Assets.xcassets/RealName/ic_marketing.imageset/ic_marketing.svg @@ -0,0 +1,3 @@ + + + diff --git a/suixinkan/Assets.xcassets/RealName/ic_unchecked.imageset/Contents.json b/suixinkan/Assets.xcassets/RealName/ic_unchecked.imageset/Contents.json new file mode 100644 index 0000000..c8d2ca6 --- /dev/null +++ b/suixinkan/Assets.xcassets/RealName/ic_unchecked.imageset/Contents.json @@ -0,0 +1,7 @@ +{ + "images" : [ + { "filename" : "ic_unchecked.svg", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { "preserves-vector-representation" : true } +} diff --git a/suixinkan/Assets.xcassets/RealName/ic_unchecked.imageset/ic_unchecked.svg b/suixinkan/Assets.xcassets/RealName/ic_unchecked.imageset/ic_unchecked.svg new file mode 100644 index 0000000..01a801d --- /dev/null +++ b/suixinkan/Assets.xcassets/RealName/ic_unchecked.imageset/ic_unchecked.svg @@ -0,0 +1,3 @@ + + + diff --git a/suixinkan/Assets.xcassets/home_menu_punch_point.imageset/Contents.json b/suixinkan/Assets.xcassets/home_menu_punch_point.imageset/Contents.json new file mode 100644 index 0000000..1c3aa91 --- /dev/null +++ b/suixinkan/Assets.xcassets/home_menu_punch_point.imageset/Contents.json @@ -0,0 +1,5 @@ +{ + "images" : [{ "filename" : "home_menu_punch_point.svg", "idiom" : "universal" }], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { "preserves-vector-representation" : true } +} diff --git a/suixinkan/Assets.xcassets/home_menu_punch_point.imageset/home_menu_punch_point.svg b/suixinkan/Assets.xcassets/home_menu_punch_point.imageset/home_menu_punch_point.svg new file mode 100644 index 0000000..71ff7f5 --- /dev/null +++ b/suixinkan/Assets.xcassets/home_menu_punch_point.imageset/home_menu_punch_point.svg @@ -0,0 +1 @@ + diff --git a/suixinkan/Assets.xcassets/home_menu_wallet.imageset/Contents.json b/suixinkan/Assets.xcassets/home_menu_wallet.imageset/Contents.json new file mode 100644 index 0000000..7113487 --- /dev/null +++ b/suixinkan/Assets.xcassets/home_menu_wallet.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "home_menu_wallet.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/suixinkan/Assets.xcassets/home_menu_wallet.imageset/home_menu_wallet.svg b/suixinkan/Assets.xcassets/home_menu_wallet.imageset/home_menu_wallet.svg new file mode 100644 index 0000000..bc23060 --- /dev/null +++ b/suixinkan/Assets.xcassets/home_menu_wallet.imageset/home_menu_wallet.svg @@ -0,0 +1,3 @@ + + + diff --git a/suixinkan/Config/AppAgreementURLs.swift b/suixinkan/Config/AppAgreementURLs.swift index c1a7b69..65deec8 100644 --- a/suixinkan/Config/AppAgreementURLs.swift +++ b/suixinkan/Config/AppAgreementURLs.swift @@ -22,6 +22,16 @@ enum AppAgreementURLs { APIEnvironment.current.baseURL.appending(path: "h5/app/user-agreement") } + /// 钱包与实名认证流程使用的用户协议 H5 地址。 + nonisolated static var walletUserNotice: URL { + APIEnvironment.current.baseURL.appending(path: "h5/app/wallet-user-notice") + } + + /// 钱包与实名认证流程使用的隐私政策 H5 地址。 + nonisolated static var walletPrivacyPolicy: URL { + APIEnvironment.current.baseURL.appending(path: "h5/app/wallet-privacy") + } + /// App 下载 H5 地址。 nonisolated static var appDownload: URL { APIEnvironment.current.baseURL.appending(path: "h5/app/download") diff --git a/suixinkan/Core/Upload/OSSUploadService.swift b/suixinkan/Core/Upload/OSSUploadService.swift index 14bbd64..0328235 100644 --- a/suixinkan/Core/Upload/OSSUploadService.swift +++ b/suixinkan/Core/Upload/OSSUploadService.swift @@ -32,7 +32,7 @@ final class OSSUploadService { } func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { - try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "real_name", onProgress: onProgress) + try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "real_name_auth", onProgress: onProgress) } func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { @@ -303,8 +303,10 @@ enum OSSUploadPolicy { switch moduleType { case "user_avatar": return "avatar/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" - case "real_name": - return "real_name/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + case "real_name_auth": + let userId = AppStore.shared.session.userId.trimmingCharacters(in: .whitespacesAndNewlines) + let ownerId = userId.isEmpty ? String(scenicId) : userId + return "real_name_auth/\(currentDate)/\(ownerId)/\(uploadId)_\(safeName)" case "bank_card": return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" case "task_upload": diff --git a/suixinkan/Features/Home/Services/HomeMenuCatalog.swift b/suixinkan/Features/Home/Services/HomeMenuCatalog.swift index a2a56ba..d7ceb57 100644 --- a/suixinkan/Features/Home/Services/HomeMenuCatalog.swift +++ b/suixinkan/Features/Home/Services/HomeMenuCatalog.swift @@ -11,7 +11,7 @@ enum HomeMenuCatalog { /// 全部已知菜单项,对齐 Android `Constants.menuList`。 static let allItems: [HomeMenuItem] = [ HomeMenuItem(uri: "space_settings", title: "空间设置", iconName: "home_menu_space"), - HomeMenuItem(uri: "wallet", title: "我的钱包", iconName: "creditcard.fill"), + HomeMenuItem(uri: "wallet", title: "我的钱包", iconName: "home_menu_wallet"), HomeMenuItem(uri: "cloud_management", title: "相册云盘", iconName: "icloud.fill"), HomeMenuItem(uri: "asset_management", title: "素材管理", iconName: "home_menu_material"), HomeMenuItem(uri: "task_management", title: "任务管理", iconName: "checklist"), @@ -19,7 +19,7 @@ enum HomeMenuCatalog { HomeMenuItem(uri: "schedule_management", title: "日程管理", iconName: "calendar"), HomeMenuItem(uri: "system_settings", title: "设置中心", iconName: "gearshape.fill"), HomeMenuItem(uri: "message_center", title: "消息中心", iconName: "bell.fill"), - HomeMenuItem(uri: "checkin_points", title: "打卡点管理", iconName: "mappin.and.ellipse"), + HomeMenuItem(uri: "checkin_points", title: "打卡点管理", iconName: "home_menu_punch_point"), HomeMenuItem(uri: "sample_management", title: "样片管理", iconName: "sample_ic_home_menu_route"), HomeMenuItem(uri: "live_stream_management", title: "直播管理", iconName: "dot.radiowaves.left.and.right"), HomeMenuItem(uri: "verification_order", title: "核销订单", iconName: "checkmark.seal.fill"), diff --git a/suixinkan/Features/Profile/API/ProfileAPI.swift b/suixinkan/Features/Profile/API/ProfileAPI.swift index 7bc9a5a..6a49ba0 100644 --- a/suixinkan/Features/Profile/API/ProfileAPI.swift +++ b/suixinkan/Features/Profile/API/ProfileAPI.swift @@ -139,8 +139,7 @@ final class ProfileAPI { let _: EmptyPayload = try await client.send( APIRequest( method: .post, - path: "/api/yf-handset-app/photog/real-name/sms-verify-code", - body: EmptyPayload() + path: "/api/yf-handset-app/photog/real-name/sms-verify-code" ) ) } diff --git a/suixinkan/Features/Profile/ViewModels/RealNameAuthViewModel.swift b/suixinkan/Features/Profile/ViewModels/RealNameAuthViewModel.swift index ac4aa84..0df2cef 100644 --- a/suixinkan/Features/Profile/ViewModels/RealNameAuthViewModel.swift +++ b/suixinkan/Features/Profile/ViewModels/RealNameAuthViewModel.swift @@ -5,59 +5,147 @@ import Foundation -/// 实名认证 ViewModel,管理认证资料表单、审核状态、短信验证码和提交状态。 +/// 实名认证页面模式,描述首次申请与各审核状态下的编辑权限。 +enum RealNameAuthMode: Equatable { + case newApplication + case pending + case approved + case rejected + + /// 页面是否只允许查看资料。 + var isReadOnly: Bool { + self == .pending || self == .approved + } + + /// 页面是否允许编辑并提交资料。 + var isEditable: Bool { + !isReadOnly + } +} + +/// 实名认证两步表单的当前步骤。 +enum RealNameAuthStep: Int, Equatable { + case identity = 1 + case verification = 2 +} + +/// 实名认证 ViewModel,管理两步表单、审核模式、图片上传、验证码和提交状态。 final class RealNameAuthViewModel { private(set) var info: RealNameInfo? + private(set) var mode: RealNameAuthMode + private(set) var currentStep: RealNameAuthStep = .identity private(set) var realName = "" private(set) var idCardNo = "" private(set) var smsCode = "" - private(set) var startDate = Date() - private(set) var endDate = Calendar.current.date(byAdding: .year, value: 10, to: Date()) ?? Date() + private(set) var startDate: Date? + private(set) var endDate: Date? private(set) var isLongValid = false + private(set) var isAgreed = false private(set) var frontUrl = "" private(set) var backUrl = "" - private(set) var pendingFrontImageData: Data? - private(set) var pendingBackImageData: Data? + private(set) var frontPreviewImageData: Data? + private(set) var backPreviewImageData: Data? + private(set) var frontUploadProgress = 0 + private(set) var backUploadProgress = 0 + private(set) var isFrontUploading = false + private(set) var isBackUploading = false private(set) var loading = false private(set) var sendingCode = false private(set) var submitting = false + private(set) var otpCountdown = 0 private(set) var statusMessage: String? - private var pendingFrontFileName: String? - private var pendingBackFileName: String? + private var countdownTask: Task? + /// 页面状态变化后通知 View 主动刷新。 var onStateChange: (() -> Void)? + /// 使用可选审核信息创建表单;无信息表示首次申请。 + init(info: RealNameInfo? = nil) { + self.info = info + mode = Self.mode(for: info) + apply(info) + } + + deinit { + countdownTask?.cancel() + } + + /// 当前是否存在任一证件图片上传任务。 + var isUploading: Bool { + isFrontUploading || isBackUploading + } + + /// 第一步是否满足进入短信验证步骤的条件。 + var canGoNextStep: Bool { + guard mode.isEditable, currentStep == .identity, !isUploading else { return false } + return identityValidationMessage == nil && isAgreed + } + + /// 第二步是否满足最终提交条件。 + var canSubmit: Bool { + guard mode.isEditable, currentStep == .verification, !submitting, !isUploading else { return false } + return validationMessage == nil + } + + /// 验证码按钮是否可点击。 + var canRequestCode: Bool { + mode.isEditable && currentStep == .verification && otpCountdown == 0 && !sendingCode + } + + /// 更新真实姓名。 func updateRealName(_ value: String) { + guard mode.isEditable else { return } realName = value notifyStateChange() } + /// 更新身份证号,只保留数字和 X 并统一为大写。 func updateIdCardNo(_ value: String) { - idCardNo = value + guard mode.isEditable else { return } + idCardNo = String(value.filter { $0.isNumber || $0 == "X" || $0 == "x" }.prefix(18)).uppercased() notifyStateChange() } + /// 更新短信验证码,只保留最多 11 位数字。 func updateSmsCode(_ value: String) { - smsCode = value + guard mode.isEditable else { return } + smsCode = String(value.filter(\.isNumber).prefix(11)) notifyStateChange() } + /// 切换协议同意状态。 + func toggleAgreement() { + guard mode.isEditable else { return } + isAgreed.toggle() + notifyStateChange() + } + + /// 更新长期有效状态;开启后清空截止日期。 func updateLongValid(_ value: Bool) { + guard mode.isEditable else { return } isLongValid = value + if value { + endDate = nil + } notifyStateChange() } + /// 更新证件有效期起始日期。 func updateStartDate(_ value: Date) { - startDate = value + guard mode.isEditable else { return } + startDate = Calendar.current.startOfDay(for: value) notifyStateChange() } + /// 更新证件有效期截止日期。 func updateEndDate(_ value: Date) { - endDate = value + guard mode.isEditable, !isLongValid else { return } + endDate = Calendar.current.startOfDay(for: value) notifyStateChange() } + /// 从服务端重新拉取实名认证信息并应用到页面。 func load(api: ProfileAPI) async throws { guard !loading else { return } loading = true @@ -67,11 +155,37 @@ final class RealNameAuthViewModel { notifyStateChange() } let response = try await api.realNameInfo() + info = response.realNameInfo + mode = Self.mode(for: response.realNameInfo) apply(response.realNameInfo) } + /// 进入短信验证步骤;校验失败时返回对应提示。 + @discardableResult + func goToVerificationStep() -> String? { + guard mode.isEditable else { + currentStep = .verification + notifyStateChange() + return nil + } + if let identityValidationMessage { + return identityValidationMessage + } + guard isAgreed else { return "请阅读并同意用户协议与隐私政策" } + currentStep = .verification + notifyStateChange() + return nil + } + + /// 返回证件资料步骤。 + func backToIdentityStep() { + currentStep = .identity + notifyStateChange() + } + + /// 发送实名认证短信验证码并启动 60 秒倒计时。 func sendCode(api: ProfileAPI) async throws { - guard !sendingCode else { return } + guard canRequestCode else { return } sendingCode = true notifyStateChange() defer { @@ -79,31 +193,56 @@ final class RealNameAuthViewModel { notifyStateChange() } try await api.realNameSmsVerifyCode() - statusMessage = "验证码已发送" + statusMessage = "验证码发送成功" + startCountdown() notifyStateChange() } - func prepareIdentityImage(data: Data, side: RealNameImageSide, timestamp: TimeInterval = Date().timeIntervalSince1970) throws { + /// 处理并立即上传身份证图片。 + func uploadIdentityImage( + data: Data, + side: RealNameImageSide, + uploader: any OSSUploadServing, + scenicId: Int, + timestamp: TimeInterval = Date().timeIntervalSince1970 + ) async throws { + guard mode.isEditable else { return } + guard !uploading(side) else { return } + let processed = try RealNameImageProcessor.process(data: data, side: side, timestamp: timestamp) - switch side { - case .front: - pendingFrontImageData = processed.data - pendingFrontFileName = processed.fileName - frontUrl = "" - case .back: - pendingBackImageData = processed.data - pendingBackFileName = processed.fileName - backUrl = "" + setPreview(processed.data, side: side) + setUploading(true, progress: 0, side: side) + defer { setUploading(false, progress: progress(for: side), side: side) } + + do { + let url = try await uploader.uploadRealNameImage( + data: processed.data, + fileName: processed.fileName, + scenicId: scenicId + ) { [weak self] progress in + self?.setProgress(progress, side: side) + } + setURL(url, side: side) + setProgress(100, side: side) + } catch { + setPreview(nil, side: side) + setURL("", side: side) + throw error } + } + + /// 删除指定一面的证件图片。 + func deleteIdentityImage(side: RealNameImageSide) { + guard mode.isEditable, !uploading(side) else { return } + setPreview(nil, side: side) + setURL("", side: side) + setProgress(0, side: side) notifyStateChange() } - func submit(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws { - if info?.verified == true { - statusMessage = "已完成实名认证" - notifyStateChange() - return - } + /// 提交实名认证资料。 + func submit(api: ProfileAPI) async throws { + guard mode.isEditable, !submitting else { return } if let validationMessage { throw RealNameValidationError.message(validationMessage) } @@ -114,30 +253,45 @@ final class RealNameAuthViewModel { submitting = false notifyStateChange() } - try await uploadPendingIdentityImages(uploader: uploader, scenicId: scenicId) - try await api.realNameSubmit(makeRequest()) - statusMessage = "已提交审核" + try await api.realNameSubmit(try makeRequest()) + statusMessage = "认证成功" notifyStateChange() - try await load(api: api) } + /// 当前完整表单的首个校验错误。 var validationMessage: String? { - if realName.trimmed.isEmpty { return "请输入真实姓名" } - if idCardNo.trimmed.isEmpty { return "请输入身份证号" } - if !Self.isValidMainlandIDCardNumber(idCardNo) { return "请输入有效的身份证号" } - if info?.verified != true { - if smsCode.trimmed.isEmpty { return "请输入短信验证码" } - if frontUrl.trimmed.isEmpty && pendingFrontImageData == nil { return "请选择身份证人像面图片" } - if backUrl.trimmed.isEmpty && pendingBackImageData == nil { return "请选择身份证国徽面图片" } - } - if !isLongValid && endDate < startDate { return "证件结束日期不能早于起始日期" } + if let identityValidationMessage { return identityValidationMessage } + if !isAgreed { return "请阅读并同意用户协议与隐私政策" } + if smsCode.trimmed.isEmpty { return "请输入短信验证码" } return nil } + /// 生成与 Android DTO 对齐的实名认证提交请求。 + func makeRequest() throws -> RealNameAuthRequest { + if let validationMessage { + throw RealNameValidationError.message(validationMessage) + } + guard let startDate else { + throw RealNameValidationError.message("请选择证件起始日期") + } + return RealNameAuthRequest( + realName: realName.trimmed, + idCardNo: Self.normalizedIDCardNumber(idCardNo), + smsVerifyCode: smsCode.trimmed, + startDate: Self.dateFormatter.string(from: startDate), + endDate: isLongValid ? "" : endDate.map(Self.dateFormatter.string(from:)) ?? "", + isLongValid: isLongValid ? 1 : 0, + frontUrl: frontUrl.trimmed, + backUrl: backUrl.trimmed + ) + } + + /// 规范化身份证号码,移除首尾空白并统一大写。 nonisolated static func normalizedIDCardNumber(_ value: String) -> String { value.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() } + /// 校验大陆 18 位身份证号码的格式和校验码。 nonisolated static func isValidMainlandIDCardNumber(_ value: String) -> Bool { let number = normalizedIDCardNumber(value) guard number.count == 18 else { return false } @@ -152,38 +306,112 @@ final class RealNameAuthViewModel { return chars[17] == checkCodes[sum % 11] } + private var identityValidationMessage: String? { + if realName.trimmed.isEmpty { return "请输入真实姓名" } + if idCardNo.trimmed.isEmpty { return "请输入身份证号" } + if !Self.isValidMainlandIDCardNumber(idCardNo) { return "请输入有效的身份证号" } + if backUrl.trimmed.isEmpty { return "请选择身份证国徽面图片" } + if frontUrl.trimmed.isEmpty { return "请选择身份证人像面图片" } + guard let startDate else { return "请选择证件起始日期" } + if !isLongValid { + guard let endDate else { return "请选择证件截止日期" } + if endDate < startDate { return "证件结束日期不能早于起始日期" } + } + return nil + } + private func apply(_ info: RealNameInfo?) { - self.info = info guard let info else { - notifyStateChange() + isAgreed = false return } realName = info.realName idCardNo = info.idCardNo frontUrl = info.frontUrl ?? "" backUrl = info.backUrl ?? "" - pendingFrontImageData = nil - pendingBackImageData = nil + frontPreviewImageData = nil + backPreviewImageData = nil isLongValid = info.isLongValid - startDate = Self.date(from: info.startDate) ?? startDate - endDate = Self.date(from: info.endDate) ?? endDate + startDate = Self.date(from: info.startDate) + endDate = info.isLongValid ? nil : Self.date(from: info.endDate) + isAgreed = true + } + + private static func mode(for info: RealNameInfo?) -> RealNameAuthMode { + guard let info else { return .newApplication } + switch info.auditStatus { + case 2: return .approved + case 3: return .rejected + default: return .pending + } + } + + private func uploading(_ side: RealNameImageSide) -> Bool { + side == .front ? isFrontUploading : isBackUploading + } + + private func progress(for side: RealNameImageSide) -> Int { + side == .front ? frontUploadProgress : backUploadProgress + } + + private func setPreview(_ data: Data?, side: RealNameImageSide) { + if side == .front { + frontPreviewImageData = data + } else { + backPreviewImageData = data + } notifyStateChange() } - private func makeRequest() -> RealNameAuthRequest { - RealNameAuthRequest( - realName: realName.trimmed, - idCardNo: Self.normalizedIDCardNumber(idCardNo), - smsVerifyCode: smsCode.trimmed, - startDate: Self.dateFormatter.string(from: startDate), - endDate: isLongValid ? "" : Self.dateFormatter.string(from: endDate), - isLongValid: isLongValid ? 1 : 0, - frontUrl: frontUrl.trimmed, - backUrl: backUrl.trimmed - ) + private func setURL(_ url: String, side: RealNameImageSide) { + if side == .front { + frontUrl = url + } else { + backUrl = url + } + notifyStateChange() } - private static let dateFormatter: DateFormatter = { + private func setUploading(_ uploading: Bool, progress: Int, side: RealNameImageSide) { + if side == .front { + isFrontUploading = uploading + frontUploadProgress = progress + } else { + isBackUploading = uploading + backUploadProgress = progress + } + notifyStateChange() + } + + private func setProgress(_ progress: Int, side: RealNameImageSide) { + let normalized = max(0, min(100, progress)) + if side == .front { + frontUploadProgress = normalized + } else { + backUploadProgress = normalized + } + notifyStateChange() + } + + private func startCountdown(seconds: Int = 60) { + countdownTask?.cancel() + otpCountdown = seconds + countdownTask = Task { [weak self] in + guard seconds > 0 else { return } + for remaining in stride(from: seconds - 1, through: 0, by: -1) { + do { + try await Task.sleep(for: .seconds(1)) + } catch { + return + } + guard let self else { return } + self.otpCountdown = remaining + self.notifyStateChange() + } + } + } + + nonisolated private static let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.calendar = Calendar(identifier: .gregorian) formatter.locale = Locale(identifier: "en_US_POSIX") @@ -196,33 +424,16 @@ final class RealNameAuthViewModel { return dateFormatter.date(from: value) } - private func uploadPendingIdentityImages(uploader: any OSSUploadServing, scenicId: Int) async throws { - if let pendingFrontImageData { - frontUrl = try await uploader.uploadRealNameImage( - data: pendingFrontImageData, - fileName: pendingFrontFileName ?? "real_name_front.jpg", - scenicId: scenicId - ) { _ in } - self.pendingFrontImageData = nil - } - if let pendingBackImageData { - backUrl = try await uploader.uploadRealNameImage( - data: pendingBackImageData, - fileName: pendingBackFileName ?? "real_name_back.jpg", - scenicId: scenicId - ) { _ in } - self.pendingBackImageData = nil - } - } - private func notifyStateChange() { onStateChange?() } } +/// 实名认证表单校验错误。 enum RealNameValidationError: LocalizedError, Equatable { case message(String) + /// 面向用户展示的校验提示。 var errorDescription: String? { switch self { case .message(let message): message diff --git a/suixinkan/Features/PunchPoint/Models/PunchPointModels.swift b/suixinkan/Features/PunchPoint/Models/PunchPointModels.swift index e74b754..07fcd81 100644 --- a/suixinkan/Features/PunchPoint/Models/PunchPointModels.swift +++ b/suixinkan/Features/PunchPoint/Models/PunchPointModels.swift @@ -390,6 +390,13 @@ struct PunchPointUploadDialogState: Sendable, Equatable { let progress: Int } +/// 打卡点状态标签的文案与配色。 +struct PunchPointStatusPresentation: Sendable, Equatable { + let title: String + let backgroundColor: UInt + let textColor: UInt +} + /// 打卡点 UI 文案与格式化工具。 enum PunchPointDisplayFormatter { /// 手机号脱敏。 @@ -401,32 +408,40 @@ enum PunchPointDisplayFormatter { return "\(prefix)****\(suffix)" } - /// 审核/运营标签背景色。 - static func statusColors(_ status: Int) -> (background: UInt, text: UInt) { + /// 运营状态标签,严格对应 Android `OperatingTag`。 + static func operatingStatus(_ status: Int) -> PunchPointStatusPresentation { switch status { case 1: - return (0xF0FDF4, 0x09BE4F) + return PunchPointStatusPresentation(title: "营运中", backgroundColor: 0xF0FDF4, textColor: 0x22C55E) case 2: - return (0xFFF0E2, 0xFF7B00) - case 3: - return (0xEFF6FF, 0x0073FF) - case 4: - return (0xFFE7E7, 0xEF4444) + return PunchPointStatusPresentation(title: "已暂停", backgroundColor: 0xF4F4F4, textColor: 0x4B5563) default: - return (0xF4F4F4, 0x666666) + return PunchPointStatusPresentation(title: "未上线", backgroundColor: 0xF4F4F4, textColor: 0x4B5563) + } + } + + /// 审核状态标签,严格对应 Android `StatusLabelChip`。 + static func auditStatus(_ status: Int) -> PunchPointStatusPresentation { + switch status { + case 1, 2: + return PunchPointStatusPresentation(title: "已通过", backgroundColor: 0xF0FDF4, textColor: 0x22C55E) + case 4: + return PunchPointStatusPresentation(title: "已驳回", backgroundColor: 0xFFE7E7, textColor: 0xEF4444) + default: + return PunchPointStatusPresentation(title: "待审核", backgroundColor: 0xFFF0E2, textColor: 0xFF7B00) } } } private extension KeyedDecodingContainer { func decodeFlexibleInt64(forKey key: Key) throws -> Int64? { - if let value = try decodeIfPresent(Int64.self, forKey: key) { + if let value = try? decodeIfPresent(Int64.self, forKey: key) { return value } - if let value = try decodeIfPresent(Int.self, forKey: key) { + if let value = try? decodeIfPresent(Int.self, forKey: key) { return Int64(value) } - if let value = try decodeIfPresent(String.self, forKey: key) { + if let value = try? decodeIfPresent(String.self, forKey: key) { return Int64(value) } return nil diff --git a/suixinkan/Features/PunchPoint/ViewModels/PunchPointViewModels.swift b/suixinkan/Features/PunchPoint/ViewModels/PunchPointViewModels.swift index 6675b09..582e72f 100644 --- a/suixinkan/Features/PunchPoint/ViewModels/PunchPointViewModels.swift +++ b/suixinkan/Features/PunchPoint/ViewModels/PunchPointViewModels.swift @@ -29,11 +29,13 @@ final class PunchPointListViewModel { var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? var onShowQRCode: ((String) -> Void)? + var onBeginDeleteAnimation: ((Int64) -> Void)? private var currentPage = 1 private var totalCount = 0 private let pageSize = 10 private let currentScenicIdProvider: () -> Int + private var pendingDeleteIDs = Set() /// 初始化打卡点列表 ViewModel。 init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }) { @@ -81,12 +83,11 @@ final class PunchPointListViewModel { /// 删除打卡点。 func delete(item: PunchPointItem, api: any PunchPointAPIProtocol) async { + guard !pendingDeleteIDs.contains(item.id) else { return } do { try await api.delete(id: item.id) - items.removeAll { $0.id == item.id } - totalCount = max(0, totalCount - 1) - canLoadMore = items.count < totalCount - notifyStateChange() + pendingDeleteIDs.insert(item.id) + onBeginDeleteAnimation?(item.id) } catch is CancellationError { return } catch { @@ -94,6 +95,15 @@ final class PunchPointListViewModel { } } + /// 删除动画完成后,从列表数据中正式移除条目。 + func completeDeleteAnimation(id: Int64) { + guard pendingDeleteIDs.remove(id) != nil else { return } + items.removeAll { $0.id == id } + totalCount = max(0, totalCount - 1) + canLoadMore = items.count < totalCount + notifyStateChange() + } + private func load(reset: Bool, showLoading: Bool, api: any PunchPointAPIProtocol) async { if reset { currentPage = 1 diff --git a/suixinkan/Features/Wallet/API/WalletAPI.swift b/suixinkan/Features/Wallet/API/WalletAPI.swift index 660a7e7..be45fff 100644 --- a/suixinkan/Features/Wallet/API/WalletAPI.swift +++ b/suixinkan/Features/Wallet/API/WalletAPI.swift @@ -145,8 +145,7 @@ final class WalletAPI: WalletPageServing { let _: EmptyPayload = try await client.send( APIRequest( method: .post, - path: "/api/yf-handset-app/photog/wallet/withdraw-send-sms", - body: EmptyPayload() + path: "/api/yf-handset-app/photog/wallet/withdraw-send-sms" ) ) } @@ -169,8 +168,7 @@ final class WalletAPI: WalletPageServing { APIRequest( method: .post, path: "/api/yf-handset-app/photog/point/overview", - queryItems: [URLQueryItem(name: "staff_id", value: String(staffId))], - body: EmptyPayload() + queryItems: [URLQueryItem(name: "staff_id", value: String(staffId))] ) ) } diff --git a/suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift b/suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift index b79e2b3..f8bbf22 100644 --- a/suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift +++ b/suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift @@ -89,6 +89,7 @@ final class WalletViewModel { private let staffIdProvider: () -> Int? private let calendar: Calendar + private let nowProvider: () -> Date private var withdrawPage = 1 private var withdrawCanLoadMore = true private var transactionPage = 1 @@ -101,10 +102,12 @@ final class WalletViewModel { staffIdProvider: @escaping () -> Int? = { Int(AppStore.shared.session.userId.trimmingCharacters(in: .whitespacesAndNewlines)) }, - calendar: Calendar = Calendar(identifier: .gregorian) + calendar: Calendar = Calendar(identifier: .gregorian), + nowProvider: @escaping () -> Date = Date.init ) { self.staffIdProvider = staffIdProvider self.calendar = calendar + self.nowProvider = nowProvider } /// 首次加载钱包首页全部首屏数据。 @@ -223,8 +226,11 @@ final class WalletViewModel { private func loadWithdraw(api: any WalletPageServing, refresh: Bool) async { if withdrawLoading && !refresh { return } let targetPage = refresh ? 1 : withdrawPage + let previousPage = withdrawPage + let previousCanLoadMore = withdrawCanLoadMore withdrawLoading = true withdrawRefreshing = refresh + errorMessage = nil if refresh { withdrawCanLoadMore = true withdrawPage = 1 @@ -238,8 +244,8 @@ final class WalletViewModel { withdrawPage = response.item.isEmpty ? targetPage : targetPage + 1 } catch { if refresh { - withdrawRecords = [] - withdrawCanLoadMore = false + withdrawPage = previousPage + withdrawCanLoadMore = previousCanLoadMore } errorMessage = error.localizedDescription } @@ -252,9 +258,12 @@ final class WalletViewModel { private func loadTransaction(api: any WalletPageServing, refresh: Bool) async { if transactionLoading && !refresh { return } let targetPage = refresh ? 1 : transactionPage + let previousPage = transactionPage + let previousCanLoadMore = transactionCanLoadMore let range = dateRange(for: filter) transactionLoading = true transactionRefreshing = refresh + errorMessage = nil if refresh { transactionCanLoadMore = true transactionPage = 1 @@ -276,8 +285,8 @@ final class WalletViewModel { transactionPage = response.list.isEmpty ? targetPage : targetPage + 1 } catch { if refresh { - transactionGroups = [] - transactionCanLoadMore = false + transactionPage = previousPage + transactionCanLoadMore = previousCanLoadMore } errorMessage = error.localizedDescription } @@ -287,7 +296,8 @@ final class WalletViewModel { notifyStateChange() } - private func dateRange(for filter: WalletFilter, now: Date = Date()) -> (start: String, end: String) { + private func dateRange(for filter: WalletFilter) -> (start: String, end: String) { + let now = nowProvider() let end = calendar.startOfDay(for: now) let start = calendar.date(byAdding: .day, value: -filter.dayOffset, to: end) ?? end return (Self.dayFormatter.string(from: start), Self.dayFormatter.string(from: end)) @@ -367,7 +377,7 @@ final class WithdrawViewModel { /// 更新短信验证码。 func updateVerificationCode(_ value: String) { - verificationCode = value.filter(\.isNumber) + verificationCode = value notifyStateChange() } @@ -416,14 +426,17 @@ final class WithdrawViewModel { /// 表单是否可提交。 var canSubmit: Bool { - validationMessage == nil && !submitting + withdrawInfo != nil && + !amount.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !verificationCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !submitting } /// 表单校验提示。 var validationMessage: String? { guard let withdrawInfo else { return "提现信息加载中" } guard !amount.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return "请输入提现金额" } - guard let amountDecimal = Decimal(string: amount), amountDecimal > 0 else { return "请输入有效的金额" } + guard let amountDecimal = Decimal(string: amount) else { return "请输入有效的金额" } let withdrawable = Decimal(string: withdrawInfo.amountWithdrawable) ?? 0 if amountDecimal > withdrawable { return "金额不能大于可提现金额" } guard !verificationCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return "请输入验证码" } @@ -495,6 +508,7 @@ final class PointsRedemptionViewModel { func refreshOverview(api: any WalletPageServing) async { guard let staffId = staffIdProvider(), staffId > 0 else { return } overviewLoading = true + errorMessage = nil notifyStateChange() do { let response = try await api.pointOverview(staffId: staffId) @@ -527,7 +541,7 @@ final class PointsRedemptionViewModel { return } let points = min(Int(digits) ?? 0, withdrawnPoints) - pointsInput = points > 0 ? String(points) : "" + pointsInput = String(points) notifyStateChange() } @@ -566,8 +580,11 @@ final class PointsRedemptionViewModel { private func loadWithdrawList(api: any WalletPageServing, refresh: Bool) async { if withdrawLoading && !refresh { return } let targetPage = refresh ? 1 : withdrawPage + let previousPage = withdrawPage + let previousCanLoadMore = withdrawCanLoadMore withdrawLoading = true withdrawRefreshing = refresh + errorMessage = nil if refresh { withdrawCanLoadMore = true withdrawPage = 1 @@ -581,8 +598,8 @@ final class PointsRedemptionViewModel { withdrawPage = response.list.isEmpty ? targetPage : targetPage + 1 } catch { if refresh { - withdrawRecords = [] - withdrawCanLoadMore = false + withdrawPage = previousPage + withdrawCanLoadMore = previousCanLoadMore } errorMessage = error.localizedDescription } diff --git a/suixinkan/Theme/Modules/WalletTokens.swift b/suixinkan/Theme/Modules/WalletTokens.swift new file mode 100644 index 0000000..a8c8b71 --- /dev/null +++ b/suixinkan/Theme/Modules/WalletTokens.swift @@ -0,0 +1,82 @@ +// +// WalletTokens.swift +// suixinkan +// + +import CoreGraphics +import UIKit + +/// 钱包模块视觉令牌,逐项对齐 Android 钱包、提现和积分兑现页面的内联样式。 +enum WalletTokens { + // MARK: - Colors + + static let pageBackground = UIColor(hex: 0xF5F6F8) + static let withdrawPageBackground = UIColor(hex: 0xF9FAFB) + static let summaryBackground = UIColor(hex: 0x1677FF) + static let primary = UIColor(hex: 0x0073FF) + static let contentBackground = UIColor.white + static let filterBackground = UIColor(hex: 0xF6F6F6) + static let inputBackground = UIColor(hex: 0xF4F4F4) + static let inputFocusedBackground = UIColor(hex: 0xF4F5F9) + static let border = UIColor(hex: 0xEEEEEE) + static let cardBorder = UIColor(hex: 0xF3F4F6) + static let progressTrack = UIColor(hex: 0xF2F4F8) + static let textPrimary = UIColor.black + static let text333 = UIColor(hex: 0x333333) + static let textSecondary = UIColor(hex: 0x4B5563) + static let textMuted = UIColor(hex: 0x7B8EAA) + static let textTertiary = UIColor(hex: 0x9CA3AF) + static let textDisabled = UIColor(hex: 0xB3B8C2) + static let tabNormal = UIColor(hex: 0x666666) + static let points = UIColor(hex: 0xFF7B00) + static let danger = UIColor(hex: 0xEF4444) + static let progressDanger = UIColor(hex: 0xFF4D4F) + static let success = UIColor(hex: 0x22C55E) + static let settlementSuccess = UIColor(hex: 0x16C26D) + static let warning = UIColor(hex: 0xFF8A00) + static let successBackground = UIColor(hex: 0xF0FDF4) + static let settlementSuccessBackground = UIColor(hex: 0xEBFFF5) + static let warningBackground = UIColor(hex: 0xFFF3E8) + static let dangerBackground = UIColor(hex: 0xFFE7E7) + static let settlementDangerBackground = UIColor(hex: 0xFFF1F0) + static let infoBackground = UIColor(hex: 0xE6F1FF) + static let pointsPendingBackground = UIColor(hex: 0xFFF0E2) + static let orderBackground = UIColor(hex: 0xEFF6FF) + static let neutralChipBackground = UIColor(hex: 0xF4F4F4) + + // MARK: - Layout + + static let screenInset: CGFloat = 16 + static let cardPadding: CGFloat = 16 + static let cardGap: CGFloat = 16 + static let rowGap: CGFloat = 12 + static let compactGap: CGFloat = 8 + static let tinyGap: CGFloat = 4 + static let summaryHorizontalPadding: CGFloat = 24 + static let summaryRadius: CGFloat = 24 + static let contentRadius: CGFloat = 16 + static let cardRadius: CGFloat = 12 + static let innerRadius: CGFloat = 8 + static let chipRadius: CGFloat = 4 + static let inputHeight: CGFloat = 46 + static let filterFieldHeight: CGFloat = 56 + static let filterFieldMaxWidth: CGFloat = 148 + static let progressHeight: CGFloat = 6 + static let bottomButtonRadius: CGFloat = 12 + static let bottomButtonHeight: CGFloat = 46 + + // MARK: - Typography + + static let displayFont = UIFont.systemFont(ofSize: 36, weight: .bold) + static let metricBoldFont = UIFont.systemFont(ofSize: 18, weight: .bold) + static let metricMediumFont = UIFont.systemFont(ofSize: 18, weight: .medium) + static let titleFont = UIFont.systemFont(ofSize: 18, weight: .medium) + static let subtitleFont = UIFont.systemFont(ofSize: 16, weight: .medium) + static let tabFont = UIFont.systemFont(ofSize: 16) + static let tabSelectedFont = UIFont.systemFont(ofSize: 16, weight: .semibold) + static let bodyFont = UIFont.systemFont(ofSize: 14) + static let bodyMediumFont = UIFont.systemFont(ofSize: 14, weight: .medium) + static let captionFont = UIFont.systemFont(ofSize: 12) + static let captionMediumFont = UIFont.systemFont(ofSize: 12, weight: .medium) + static let stepFont = UIFont.systemFont(ofSize: 11) +} diff --git a/suixinkan/UI/Profile/RealNameAuthAuditViewController.swift b/suixinkan/UI/Profile/RealNameAuthAuditViewController.swift index 502c90c..ef99765 100644 --- a/suixinkan/UI/Profile/RealNameAuthAuditViewController.swift +++ b/suixinkan/UI/Profile/RealNameAuthAuditViewController.swift @@ -3,111 +3,5 @@ // suixinkan // -import SnapKit -import UIKit - -/// 实名认证审核状态页,展示审核结果与证件预览。 -final class RealNameAuthAuditViewController: BaseViewController { - - private let info: RealNameInfo - - private let scrollView = UIScrollView() - private let contentStack = UIStackView() - private let statusBadge = ProfileStatusBadgeView() - private let rejectLabel = UILabel() - private let frontImageView = UIImageView() - private let backImageView = UIImageView() - - init(info: RealNameInfo) { - self.info = info - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func setupNavigationBar() { - title = "认证状态" - } - - override func setupUI() { - view.backgroundColor = UIColor(hex: 0xF7FAFF) - contentStack.axis = .vertical - contentStack.spacing = 16 - - rejectLabel.numberOfLines = 0 - rejectLabel.font = .systemFont(ofSize: 14) - rejectLabel.textColor = UIColor(hex: 0xEF4444) - - [frontImageView, backImageView].forEach { - $0.contentMode = .scaleAspectFit - $0.backgroundColor = UIColor(hex: 0xF8FAFC) - $0.layer.cornerRadius = 8 - $0.clipsToBounds = true - $0.snp.makeConstraints { make in make.height.equalTo(160) } - } - - view.addSubview(scrollView) - scrollView.addSubview(contentStack) - - contentStack.addArrangedSubview(makeTitle("审核状态")) - contentStack.addArrangedSubview(statusBadge) - if let reason = info.rejectReason, !reason.isEmpty, info.auditStatus == 3 { - rejectLabel.text = "驳回原因:\(reason)" - contentStack.addArrangedSubview(rejectLabel) - } - contentStack.addArrangedSubview(makeInfoRow("姓名", info.realName)) - contentStack.addArrangedSubview(makeInfoRow("身份证号", maskID(info.idCardNo))) - contentStack.addArrangedSubview(makeTitle("身份证国徽面")) - contentStack.addArrangedSubview(backImageView) - contentStack.addArrangedSubview(makeTitle("身份证人像面")) - contentStack.addArrangedSubview(frontImageView) - - applyStatus() - frontImageView.loadRemoteImage(urlString: info.frontUrl) - backImageView.loadRemoteImage(urlString: info.backUrl) - } - - override func setupConstraints() { - scrollView.snp.makeConstraints { make in make.edges.equalToSuperview() } - contentStack.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(16) - make.width.equalTo(scrollView).offset(-32) - } - } - - private func applyStatus() { - switch info.auditStatus { - case 2: - statusBadge.apply(style: .auditApproved, text: "已实名认证") - case 3: - statusBadge.apply(style: .auditRejected, text: "审核不通过") - default: - statusBadge.apply(style: .auditPending, text: info.auditStatusText ?? "审核中") - } - } - - private func makeTitle(_ text: String) -> UILabel { - let label = UILabel() - label.text = text - label.font = .systemFont(ofSize: 15, weight: .semibold) - return label - } - - private func makeInfoRow(_ title: String, _ value: String) -> UILabel { - let label = UILabel() - label.font = .systemFont(ofSize: 14) - label.textColor = AppColor.text666 - label.text = "\(title):\(value)" - return label - } - - private func maskID(_ value: String) -> String { - guard value.count > 8 else { return value } - let prefix = value.prefix(4) - let suffix = value.suffix(4) - return "\(prefix)**********\(suffix)" - } -} +/// 兼容旧调用入口;审核页现已与实名认证提交页共用同一套页面实现。 +typealias RealNameAuthAuditViewController = RealNameAuthViewController diff --git a/suixinkan/UI/Profile/RealNameAuthViewController.swift b/suixinkan/UI/Profile/RealNameAuthViewController.swift index 11727b3..88ea2e8 100644 --- a/suixinkan/UI/Profile/RealNameAuthViewController.swift +++ b/suixinkan/UI/Profile/RealNameAuthViewController.swift @@ -7,151 +7,356 @@ import PhotosUI import SnapKit import UIKit -/// 实名认证页面,展示表单并允许提交基础实名资料。 +/// 实名认证页面,对齐 Android 两步提交与审核状态的完整视觉和交互。 final class RealNameAuthViewController: BaseViewController { - - private let viewModel = RealNameAuthViewModel() + private let viewModel: RealNameAuthViewModel private let profileAPI = NetworkServices.shared.profileAPI private let ossUploadService = NetworkServices.shared.ossUploadService + private let rootStack = UIStackView() + private let statusView = RealNameAuditStatusView() private let scrollView = UIScrollView() private let contentStack = UIStackView() - private let realNameField = UITextField() - private let idCardField = UITextField() - private let smsCodeField = UITextField() - private let frontImageView = UIImageView() - private let backImageView = UIImageView() - private let longValidSwitch = UISwitch() - private let startDatePicker = UIDatePicker() - private let endDatePicker = UIDatePicker() - private let submitButton = AppButton(title: "提交审核") + private let formStack = UIStackView() + private let stepOneStack = UIStackView() + private let stepTwoStack = UIStackView() + + private let nationalEmblemUploadView = RealNameIDCardUploadView(title: "身份证国徽面") + private let portraitUploadView = RealNameIDCardUploadView(title: "身份证人像面") + private let realNameField = RealNameLabeledTextField(title: "姓名", placeholder: "请输入真实名称") + private let idCardField = RealNameLabeledTextField(title: "身份证号码", placeholder: "请输入18位身份证号码") + private let validitySelectionView = RealNameValiditySelectionView() + private let startDateButton = RealNameDateButton(placeholder: "起始日期") + private let endDateButton = RealNameDateButton(placeholder: "截至日期") + + private let readonlyNameField = RealNameReadOnlyField(title: "姓名") + private let readonlyIDField = RealNameReadOnlyField(title: "身份证号码") + private let verificationStack = UIStackView() + private let phoneLabel = UILabel() + private let smsCodeField = RealNameBorderTextField(placeholder: "请输入验证码") + private let sendCodeButton = UIButton(type: .system) + + private let actionButton = AppButton(title: "下一步") + private let agreementRow = AgreementRowView() + private let agreementContainer = UIView() + + private var pickingSide: RealNameImageSide = .front + + /// 使用可选审核信息创建页面;无信息时进入首次认证模式。 + init(info: RealNameInfo? = nil) { + viewModel = RealNameAuthViewModel(info: info) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } override func setupNavigationBar() { - title = "实名认证" + let titleLabel = UILabel() + titleLabel.text = "实名认证" + titleLabel.font = .systemFont(ofSize: 18, weight: .medium) + titleLabel.textColor = AppColor.text333 + navigationItem.titleView = titleLabel + let backImage = UIImage(systemName: "arrow.left")?.withConfiguration( + UIImage.SymbolConfiguration(pointSize: 18, weight: .medium) + ) + navigationItem.leftBarButtonItem = UIBarButtonItem( + image: backImage, + style: .plain, + target: self, + action: #selector(backTapped) + ) + navigationItem.leftBarButtonItem?.tintColor = AppColor.text333 } override func setupUI() { - view.backgroundColor = UIColor(hex: 0xF7FAFF) - contentStack.axis = .vertical - contentStack.spacing = 16 - - view.addSubview(scrollView) - scrollView.addSubview(contentStack) - view.addSubview(submitButton) - - [realNameField, idCardField, smsCodeField].forEach { - $0.borderStyle = .roundedRect - $0.font = .systemFont(ofSize: 16) - } - realNameField.placeholder = "请输入姓名" - idCardField.placeholder = "请输入身份证号码" - smsCodeField.placeholder = "请输入短信验证码" - smsCodeField.keyboardType = .numberPad - - [frontImageView, backImageView].forEach { - $0.backgroundColor = UIColor(hex: 0xF8FAFC) - $0.contentMode = .scaleAspectFit - $0.layer.cornerRadius = 8 - $0.clipsToBounds = true - $0.isUserInteractionEnabled = true - $0.snp.makeConstraints { make in make.height.equalTo(160) } - } - frontImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(pickFront))) - backImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(pickBack))) - - startDatePicker.datePickerMode = .date - endDatePicker.datePickerMode = .date - if #available(iOS 17.0, *) { - startDatePicker.preferredDatePickerStyle = .compact - endDatePicker.preferredDatePickerStyle = .compact - } - - let sendCodeButton = UIButton(type: .system) - sendCodeButton.setTitle("获取验证码", for: .normal) - sendCodeButton.addTarget(self, action: #selector(sendCodeTapped), for: .touchUpInside) - - contentStack.addArrangedSubview(makeSectionTitle("身份证国徽面")) - contentStack.addArrangedSubview(backImageView) - contentStack.addArrangedSubview(makeSectionTitle("身份证人像面")) - contentStack.addArrangedSubview(frontImageView) - contentStack.addArrangedSubview(labeledField("姓名", realNameField)) - contentStack.addArrangedSubview(labeledField("身份证号码", idCardField)) - - let longValidRow = UIStackView(arrangedSubviews: [UILabel(text: "长期有效"), longValidSwitch]) - longValidRow.axis = .horizontal - contentStack.addArrangedSubview(longValidRow) - contentStack.addArrangedSubview(labeledField("起始日期", startDatePicker)) - contentStack.addArrangedSubview(labeledField("结束日期", endDatePicker)) - - let smsRow = UIStackView(arrangedSubviews: [smsCodeField, sendCodeButton]) - smsRow.axis = .horizontal - smsRow.spacing = 8 - smsCodeField.snp.makeConstraints { make in make.width.equalTo(200) } - contentStack.addArrangedSubview(labeledField("短信验证码", smsRow)) + view.backgroundColor = UIColor(hex: 0xF9FAFB) + setupRootLayout() + setupFormCard() + setupSupplementaryCards() + setupInputs() + applyViewModel() } override func setupConstraints() { - scrollView.snp.makeConstraints { make in - make.top.leading.trailing.equalToSuperview() - make.bottom.equalTo(submitButton.snp.top).offset(-12) + rootStack.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) } contentStack.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(16) - make.width.equalTo(scrollView).offset(-32) - } - submitButton.snp.makeConstraints { make in - make.leading.trailing.equalToSuperview().inset(16) - make.bottom.equalTo(view.safeAreaLayoutGuide).inset(14) + make.edges.equalTo(scrollView.contentLayoutGuide).inset(UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)) + make.width.equalTo(scrollView.frameLayoutGuide).offset(-32) } } override func bindActions() { - viewModel.onStateChange = { [weak self] in self?.applyViewModel() } - submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside) - longValidSwitch.addTarget(self, action: #selector(longValidChanged), for: .valueChanged) - realNameField.addTarget(self, action: #selector(fieldChanged), for: .editingChanged) - idCardField.addTarget(self, action: #selector(fieldChanged), for: .editingChanged) - smsCodeField.addTarget(self, action: #selector(fieldChanged), for: .editingChanged) - startDatePicker.addTarget(self, action: #selector(dateChanged), for: .valueChanged) - endDatePicker.addTarget(self, action: #selector(dateChanged), for: .valueChanged) - Task { await loadInfo() } + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + + realNameField.textField.addTarget(self, action: #selector(realNameChanged), for: .editingChanged) + idCardField.textField.addTarget(self, action: #selector(idCardChanged), for: .editingChanged) + smsCodeField.addTarget(self, action: #selector(smsCodeChanged), for: .editingChanged) + actionButton.addTarget(self, action: #selector(actionTapped), for: .touchUpInside) + sendCodeButton.addTarget(self, action: #selector(sendCodeTapped), for: .touchUpInside) + + nationalEmblemUploadView.onSelect = { [weak self] in self?.pickImage(side: .back) } + nationalEmblemUploadView.onDelete = { [weak self] in self?.viewModel.deleteIdentityImage(side: .back) } + portraitUploadView.onSelect = { [weak self] in self?.pickImage(side: .front) } + portraitUploadView.onDelete = { [weak self] in self?.viewModel.deleteIdentityImage(side: .front) } + validitySelectionView.onToggle = { [weak self] in + guard let self else { return } + self.viewModel.updateLongValid(!self.viewModel.isLongValid) + } + startDateButton.onTap = { [weak self] in self?.presentDatePicker(for: .start) } + endDateButton.onTap = { [weak self] in self?.presentDatePicker(for: .end) } + agreementRow.onCheckedChange = { [weak self] in self?.viewModel.toggleAgreement() } + agreementRow.onUserAgreementTap = { [weak self] in + self?.openAgreement(title: "用户协议", url: AppAgreementURLs.walletUserNotice) + } + agreementRow.onPrivacyPolicyTap = { [weak self] in + self?.openAgreement(title: "隐私政策", url: AppAgreementURLs.walletPrivacyPolicy) + } + } + + private func setupRootLayout() { + rootStack.axis = .vertical + rootStack.spacing = 0 + scrollView.alwaysBounceVertical = true + contentStack.axis = .vertical + contentStack.spacing = 8 + + view.addSubview(rootStack) + rootStack.addArrangedSubview(statusView) + rootStack.addArrangedSubview(scrollView) + scrollView.addSubview(contentStack) + } + + private func setupFormCard() { + formStack.axis = .vertical + formStack.spacing = 8 + stepOneStack.axis = .vertical + stepOneStack.spacing = 8 + stepTwoStack.axis = .vertical + stepTwoStack.spacing = 8 + + stepOneStack.addArrangedSubview(nationalEmblemUploadView) + stepOneStack.addArrangedSubview(portraitUploadView) + stepOneStack.addArrangedSubview(realNameField) + stepOneStack.addArrangedSubview(idCardField) + stepOneStack.addArrangedSubview(validitySelectionView) + + let dateRow = UIStackView(arrangedSubviews: [startDateButton, endDateButton]) + dateRow.axis = .horizontal + dateRow.spacing = 13 + dateRow.distribution = .fillEqually + stepOneStack.addArrangedSubview(dateRow) + + verificationStack.axis = .vertical + verificationStack.spacing = 8 + phoneLabel.font = .systemFont(ofSize: 12, weight: .medium) + phoneLabel.textColor = AppColor.text666 + + let codeRow = UIStackView(arrangedSubviews: [smsCodeField, sendCodeButton]) + codeRow.axis = .horizontal + codeRow.alignment = .fill + codeRow.spacing = 8 + sendCodeButton.snp.makeConstraints { make in make.width.equalTo(100) } + verificationStack.addArrangedSubview(phoneLabel) + verificationStack.addArrangedSubview(codeRow) + + stepTwoStack.addArrangedSubview(readonlyNameField) + stepTwoStack.addArrangedSubview(readonlyIDField) + stepTwoStack.addArrangedSubview(verificationStack) + + let actionContainer = UIView() + actionContainer.addSubview(actionButton) + actionButton.titleLabel?.font = .systemFont(ofSize: 17) + actionButton.snp.makeConstraints { make in + make.top.bottom.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(4) + } + + agreementContainer.addSubview(agreementRow) + agreementRow.snp.makeConstraints { make in + make.top.bottom.equalToSuperview().inset(4) + make.leading.trailing.equalToSuperview().inset(4) + } + + formStack.addArrangedSubview(stepOneStack) + formStack.addArrangedSubview(stepTwoStack) + formStack.setCustomSpacing(12, after: stepTwoStack) + formStack.addArrangedSubview(actionContainer) + formStack.addArrangedSubview(agreementContainer) + + contentStack.addArrangedSubview(makeCard(containing: formStack, insets: UIEdgeInsets(top: 16, left: 16, bottom: 10, right: 16))) + } + + private func setupSupplementaryCards() { + let privileges = UIStackView() + privileges.axis = .vertical + privileges.spacing = 16 + privileges.addArrangedSubview(makeHeading("实名认证特权")) + privileges.addArrangedSubview( + RealNamePrivilegeItemView( + imageName: "ic_accept_orders", + title: "开启接单权限", + detail: "完成实名认证后可立即接单,实名状态对游客可见" + ) + ) + privileges.addArrangedSubview( + RealNamePrivilegeItemView( + imageName: "ic_marketing", + title: "加入营销群", + detail: "专业摄影师交流群,获取更多流量与技术支持" + ) + ) + privileges.addArrangedSubview( + RealNamePrivilegeItemView( + imageName: "ic_area_authority", + title: "景区选择权限", + detail: "可自主选择运营景区,获得独家运营资源" + ) + ) + contentStack.addArrangedSubview(makeCard(containing: privileges, insets: UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20))) + + let hints = UIStackView() + hints.axis = .vertical + hints.spacing = 4 + hints.addArrangedSubview(makeHeading("温馨提示:")) + hints.setCustomSpacing(8, after: hints.arrangedSubviews[0]) + [ + "• 请确保填写的信息真实有效", + "• 身份信息仅用于实名认证,我们将严格保护您的隐私", + "• 如有疑问请联系客服", + ].forEach { text in + let label = UILabel() + label.text = text + label.font = .systemFont(ofSize: 14) + label.textColor = AppColor.text666 + label.numberOfLines = 0 + hints.addArrangedSubview(label) + } + contentStack.addArrangedSubview(makeCard(containing: hints, insets: UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20))) + } + + private func setupInputs() { + realNameField.textField.textContentType = .name + realNameField.textField.returnKeyType = .next + idCardField.textField.keyboardType = .asciiCapable + idCardField.textField.returnKeyType = .done + smsCodeField.keyboardType = .numberPad + + sendCodeButton.backgroundColor = AppColor.primary + sendCodeButton.layer.cornerRadius = 10 + sendCodeButton.clipsToBounds = true + sendCodeButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) + sendCodeButton.setTitleColor(.white, for: .normal) + sendCodeButton.setTitleColor(UIColor.white.withAlphaComponent(0.7), for: .disabled) + sendCodeButton.snp.makeConstraints { make in make.height.equalTo(46) } } private func applyViewModel() { - realNameField.text = viewModel.realName - idCardField.text = viewModel.idCardNo - smsCodeField.text = viewModel.smsCode - longValidSwitch.isOn = viewModel.isLongValid - startDatePicker.date = viewModel.startDate - endDatePicker.date = viewModel.endDate - endDatePicker.isEnabled = !viewModel.isLongValid - submitButton.isEnabled = !viewModel.submitting - - if let data = viewModel.pendingFrontImageData, let image = UIImage(data: data) { - frontImageView.image = image - } else { - frontImageView.loadRemoteImage(urlString: viewModel.frontUrl) + let readOnly = viewModel.mode.isReadOnly + statusView.isHidden = viewModel.info == nil + if let info = viewModel.info { + statusView.configure(info: info) } - if let data = viewModel.pendingBackImageData, let image = UIImage(data: data) { - backImageView.image = image + + stepOneStack.isHidden = viewModel.currentStep != .identity + stepTwoStack.isHidden = viewModel.currentStep != .verification + + if realNameField.textField.text != viewModel.realName { + realNameField.textField.text = viewModel.realName + } + if idCardField.textField.text != viewModel.idCardNo { + idCardField.textField.text = viewModel.idCardNo + } + realNameField.setReadOnly(readOnly) + idCardField.setReadOnly(readOnly) + + nationalEmblemUploadView.configure( + imageData: viewModel.backPreviewImageData, + url: viewModel.backUrl, + uploading: viewModel.isBackUploading, + progress: viewModel.backUploadProgress, + canEdit: viewModel.mode.isEditable + ) + portraitUploadView.configure( + imageData: viewModel.frontPreviewImageData, + url: viewModel.frontUrl, + uploading: viewModel.isFrontUploading, + progress: viewModel.frontUploadProgress, + canEdit: viewModel.mode.isEditable + ) + + validitySelectionView.configure(isLongValid: viewModel.isLongValid, enabled: viewModel.mode.isEditable) + startDateButton.configure(date: viewModel.startDate, readOnly: readOnly, enabled: viewModel.mode.isEditable) + endDateButton.configure( + date: viewModel.endDate, + replacementText: viewModel.isLongValid ? "长期有效" : nil, + readOnly: readOnly || viewModel.isLongValid, + enabled: viewModel.mode.isEditable && !viewModel.isLongValid + ) + + readonlyNameField.setValue(viewModel.realName) + readonlyIDField.setValue(viewModel.idCardNo) + phoneLabel.text = "验证码将发送至:\(AppStore.shared.session.phone)" + verificationStack.isHidden = readOnly + if smsCodeField.text != viewModel.smsCode { + smsCodeField.text = viewModel.smsCode + } + + let codeTitle = viewModel.otpCountdown > 0 ? "\(viewModel.otpCountdown)秒" : "获取验证码" + sendCodeButton.setTitle(codeTitle, for: .normal) + sendCodeButton.setTitle(codeTitle, for: .disabled) + sendCodeButton.isEnabled = viewModel.canRequestCode + sendCodeButton.backgroundColor = viewModel.canRequestCode + ? AppColor.primary + : (viewModel.otpCountdown > 0 ? AppColor.primary.withAlphaComponent(0.7) : UIColor(hex: 0xE5E7EB)) + + switch viewModel.currentStep { + case .identity: + actionButton.setTitle("下一步", for: .normal) + actionButton.isEnabled = readOnly || viewModel.canGoNextStep + case .verification: + actionButton.setTitle("确认提交", for: .normal) + actionButton.isEnabled = viewModel.canSubmit + } + agreementContainer.isHidden = readOnly + agreementRow.isChecked = viewModel.isAgreed + } + + @objc private func backTapped() { + if viewModel.currentStep == .verification { + viewModel.backToIdentityStep() } else { - backImageView.loadRemoteImage(urlString: viewModel.backUrl) + navigationController?.popViewController(animated: true) } } - @objc private func fieldChanged() { - viewModel.updateRealName(realNameField.text ?? "") - viewModel.updateIdCardNo(idCardField.text ?? "") + @objc private func realNameChanged() { + viewModel.updateRealName(realNameField.textField.text ?? "") + } + + @objc private func idCardChanged() { + viewModel.updateIdCardNo(idCardField.textField.text ?? "") + } + + @objc private func smsCodeChanged() { viewModel.updateSmsCode(smsCodeField.text ?? "") } - @objc private func dateChanged() { - viewModel.updateStartDate(startDatePicker.date) - viewModel.updateEndDate(endDatePicker.date) - } - - @objc private func longValidChanged() { - viewModel.updateLongValid(longValidSwitch.isOn) + @objc private func actionTapped() { + switch viewModel.currentStep { + case .identity: + if let message = viewModel.goToVerificationStep() { + showToast(message) + } + case .verification: + Task { await submit() } + } } @objc private func sendCodeTapped() { @@ -160,23 +365,28 @@ final class RealNameAuthViewController: BaseViewController { defer { hideLoading() } do { try await viewModel.sendCode(api: profileAPI) - showToast(viewModel.statusMessage ?? "验证码已发送") + showToast(viewModel.statusMessage ?? "验证码发送成功") } catch { - showToast(error.localizedDescription) + showToast("验证码发送失败,请稍后重试") } } } - @objc private func submitTapped() { - Task { await submit() } + private func submit() async { + showLoading() + defer { hideLoading() } + do { + try await viewModel.submit(api: profileAPI) + showToast(viewModel.statusMessage ?? "认证成功") + try? await Task.sleep(for: .milliseconds(350)) + navigationController?.popViewController(animated: true) + } catch { + showToast(error.localizedDescription) + } } - @objc private func pickFront() { pickImage(side: .front) } - @objc private func pickBack() { pickImage(side: .back) } - - private var pickingSide: RealNameImageSide = .front - private func pickImage(side: RealNameImageSide) { + guard viewModel.mode.isEditable else { return } pickingSide = side var configuration = PHPickerConfiguration(photoLibrary: .shared()) configuration.filter = .images @@ -186,59 +396,65 @@ final class RealNameAuthViewController: BaseViewController { present(picker, animated: true) } - private func loadInfo() async { - showLoading() - defer { hideLoading() } - do { - try await viewModel.load(api: profileAPI) - } catch { - showToast(error.localizedDescription) + private func presentDatePicker(for field: RealNameDateField) { + guard viewModel.mode.isEditable else { return } + let selectedDate = field == .start ? viewModel.startDate : viewModel.endDate + let controller = RealNameDatePickerViewController(selectedDate: selectedDate) + controller.onConfirm = { [weak self] date in + if field == .start { + self?.viewModel.updateStartDate(date) + } else { + self?.viewModel.updateEndDate(date) + } } + present(controller, animated: false) } - private func submit() async { - let scenicId = AppStore.shared.session.currentScenicId - guard scenicId > 0 else { - showToast("当前景区信息缺失") - return - } - showLoading() - defer { hideLoading() } - do { - try await viewModel.submit(api: profileAPI, uploader: ossUploadService, scenicId: scenicId) - showToast("提交成功") - navigationController?.popViewController(animated: true) - } catch { - showToast(error.localizedDescription) - } + private func openAgreement(title: String, url: URL) { + navigationController?.pushViewController(AgreementWebViewController(title: title, url: url), animated: true) } - private func makeSectionTitle(_ text: String) -> UILabel { + private func makeCard(containing stack: UIStackView, insets: UIEdgeInsets) -> UIView { + let card = UIView() + card.backgroundColor = .white + card.layer.cornerRadius = 12 + card.clipsToBounds = true + card.addSubview(stack) + stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(insets) } + return card + } + + private func makeHeading(_ text: String) -> UILabel { let label = UILabel() label.text = text - label.font = .systemFont(ofSize: 15, weight: .semibold) + label.font = .systemFont(ofSize: 16, weight: .medium) + label.textColor = AppColor.text333 return label } - - private func labeledField(_ title: String, _ field: UIView) -> UIStackView { - let titleLabel = UILabel(text: title) - titleLabel.font = .systemFont(ofSize: 14) - let stack = UIStackView(arrangedSubviews: [titleLabel, field]) - stack.axis = .vertical - stack.spacing = 8 - return stack - } } extension RealNameAuthViewController: PHPickerViewControllerDelegate { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true) guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return } - provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in - guard let self, let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { return } + provider.loadObject(ofClass: UIImage.self) { [weak self] object, error in + guard let self else { return } + if let error { + Task { @MainActor in self.showToast(error.localizedDescription) } + return + } + guard let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.95) else { return } Task { @MainActor in + let scenicId = AppStore.shared.session.currentScenicId + self.showLoading() + defer { self.hideLoading() } do { - try self.viewModel.prepareIdentityImage(data: data, side: self.pickingSide) + try await self.viewModel.uploadIdentityImage( + data: data, + side: self.pickingSide, + uploader: self.ossUploadService, + scenicId: scenicId + ) } catch { self.showToast(error.localizedDescription) } @@ -247,9 +463,532 @@ extension RealNameAuthViewController: PHPickerViewControllerDelegate { } } -private extension UILabel { - convenience init(text: String) { - self.init() - self.text = text +/// 实名认证日期字段类型。 +private enum RealNameDateField { + case start + case end +} + +/// 带标题和边框的实名认证输入框。 +private final class RealNameLabeledTextField: UIView { + let textField = RealNameBorderTextField(placeholder: "") + private let titleLabel = UILabel() + + init(title: String, placeholder: String) { + super.init(frame: .zero) + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: 14, weight: .medium) + titleLabel.textColor = .black + textField.placeholder = placeholder + + let stack = UIStackView(arrangedSubviews: [titleLabel, textField]) + stack.axis = .vertical + stack.spacing = 8 + addSubview(stack) + stack.snp.makeConstraints { make in make.edges.equalToSuperview() } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setReadOnly(_ readOnly: Bool) { + textField.isEnabled = !readOnly + textField.backgroundColor = readOnly ? UIColor(hex: 0xEEEEEE) : .white + } +} + +/// Android 边框输入框的 UIKit 实现。 +private final class RealNameBorderTextField: UITextField { + private let horizontalInset: CGFloat = 8 + + init(placeholder: String) { + super.init(frame: .zero) + self.placeholder = placeholder + font = .systemFont(ofSize: 14) + textColor = AppColor.text333 + layer.borderWidth = 1 + layer.borderColor = AppColor.border.cgColor + layer.cornerRadius = 8 + clipsToBounds = true + clearButtonMode = .whileEditing + snp.makeConstraints { make in make.height.equalTo(46) } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func textRect(forBounds bounds: CGRect) -> CGRect { + bounds.insetBy(dx: horizontalInset, dy: 0) + } + + override func editingRect(forBounds bounds: CGRect) -> CGRect { + bounds.insetBy(dx: horizontalInset, dy: 0) + } + + override func placeholderRect(forBounds bounds: CGRect) -> CGRect { + bounds.insetBy(dx: horizontalInset, dy: 0) + } +} + +/// 灰底只读字段,对齐 Android 第二步信息展示。 +private final class RealNameReadOnlyField: UIView { + private let valueLabel = UILabel() + + init(title: String) { + super.init(frame: .zero) + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: 14, weight: .medium) + titleLabel.textColor = .black + + let valueBox = UIView() + valueBox.backgroundColor = UIColor(hex: 0xEEEEEE) + valueBox.layer.borderWidth = 1 + valueBox.layer.borderColor = UIColor(hex: 0xF4F4F4).cgColor + valueBox.layer.cornerRadius = 8 + valueLabel.font = .systemFont(ofSize: 14) + valueLabel.textColor = AppColor.text333 + valueBox.addSubview(valueLabel) + valueBox.snp.makeConstraints { make in make.height.equalTo(45) } + valueLabel.snp.makeConstraints { make in make.leading.trailing.equalToSuperview().inset(8); make.centerY.equalToSuperview() } + + let stack = UIStackView(arrangedSubviews: [titleLabel, valueBox]) + stack.axis = .vertical + stack.spacing = 4 + addSubview(stack) + stack.snp.makeConstraints { make in make.edges.equalToSuperview() } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setValue(_ value: String) { + valueLabel.text = value + } +} + +/// 长期有效选择行。 +private final class RealNameValiditySelectionView: UIView { + private let checkboxButton = UIButton(type: .custom) + var onToggle: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + let titleLabel = UILabel() + titleLabel.text = "有效期" + titleLabel.font = .systemFont(ofSize: 14, weight: .medium) + titleLabel.textColor = .black + + checkboxButton.setTitle(" 长期有效", for: .normal) + checkboxButton.setTitleColor(UIColor(hex: 0xB6BECA), for: .normal) + checkboxButton.titleLabel?.font = .systemFont(ofSize: 14) + checkboxButton.contentHorizontalAlignment = .right + checkboxButton.addTarget(self, action: #selector(tapped), for: .touchUpInside) + + let row = UIStackView(arrangedSubviews: [titleLabel, checkboxButton]) + row.axis = .horizontal + row.alignment = .center + row.distribution = .equalSpacing + addSubview(row) + row.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(28) } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(isLongValid: Bool, enabled: Bool) { + checkboxButton.setImage(UIImage(named: isLongValid ? "ic_checked" : "ic_unchecked"), for: .normal) + checkboxButton.isEnabled = enabled + checkboxButton.alpha = enabled ? 1 : 0.75 + } + + @objc private func tapped() { + onToggle?() + } +} + +/// 有效期日期按钮。 +private final class RealNameDateButton: UIButton { + private let placeholder: String + var onTap: (() -> Void)? + + init(placeholder: String) { + self.placeholder = placeholder + super.init(frame: .zero) + layer.borderWidth = 1 + layer.borderColor = AppColor.border.cgColor + layer.cornerRadius = 4 + titleLabel?.font = .systemFont(ofSize: 14) + addTarget(self, action: #selector(tapped), for: .touchUpInside) + snp.makeConstraints { make in make.height.equalTo(45) } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(date: Date?, replacementText: String? = nil, readOnly: Bool, enabled: Bool) { + let text = replacementText ?? date.map(RealNameDateTextFormatter.string(from:)) ?? placeholder + setTitle(text, for: .normal) + setTitleColor(date == nil && replacementText == nil ? UIColor(hex: 0xB6BECA) : AppColor.text333, for: .normal) + backgroundColor = readOnly ? UIColor(hex: 0xEEEEEE) : .white + isEnabled = enabled + } + + @objc private func tapped() { + onTap?() + } +} + +/// 身份证图片上传区域,负责虚线框、预览、删除和上传遮罩。 +private final class RealNameIDCardUploadView: UIView { + private let titleLabel = UILabel() + private let canvas = RealNameDashedBorderView() + private let imageView = UIImageView() + private let placeholderStack = UIStackView() + private let placeholderLabel = UILabel() + private let deleteButton = UIButton(type: .custom) + private let progressOverlay = UIView() + private let progressIndicator = UIActivityIndicatorView(style: .medium) + private let progressLabel = UILabel() + private var canEdit = false + private var hasImage = false + private var loadedURL = "" + + var onSelect: (() -> Void)? + var onDelete: (() -> Void)? + + init(title: String) { + super.init(frame: .zero) + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: 14, weight: .medium) + titleLabel.textColor = .black + + imageView.contentMode = .scaleAspectFit + imageView.clipsToBounds = true + imageView.isAccessibilityElement = true + imageView.accessibilityLabel = title + + let plusView = UIImageView(image: UIImage(systemName: "plus")) + plusView.tintColor = AppColor.primary + plusView.contentMode = .scaleAspectFit + plusView.snp.makeConstraints { make in make.size.equalTo(32) } + placeholderLabel.text = "点击上传图片" + placeholderLabel.textColor = AppColor.text666 + placeholderLabel.font = .systemFont(ofSize: 12) + placeholderStack.axis = .vertical + placeholderStack.alignment = .center + placeholderStack.spacing = 8 + placeholderStack.addArrangedSubview(plusView) + placeholderStack.addArrangedSubview(placeholderLabel) + + deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.5) + deleteButton.layer.cornerRadius = 10 + deleteButton.setImage(UIImage(systemName: "xmark")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 10, weight: .medium)), for: .normal) + deleteButton.tintColor = .white + deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) + + progressOverlay.backgroundColor = UIColor.black.withAlphaComponent(0.45) + progressIndicator.color = .white + progressLabel.font = .systemFont(ofSize: 12, weight: .medium) + progressLabel.textColor = .white + let progressStack = UIStackView(arrangedSubviews: [progressIndicator, progressLabel]) + progressStack.axis = .vertical + progressStack.alignment = .center + progressStack.spacing = 6 + + canvas.addSubview(imageView) + canvas.addSubview(placeholderStack) + canvas.addSubview(deleteButton) + canvas.addSubview(progressOverlay) + progressOverlay.addSubview(progressStack) + canvas.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(canvasTapped))) + + let stack = UIStackView(arrangedSubviews: [titleLabel, canvas]) + stack.axis = .vertical + stack.spacing = 4 + addSubview(stack) + stack.snp.makeConstraints { make in make.edges.equalToSuperview() } + canvas.snp.makeConstraints { make in make.height.equalTo(canvas.snp.width).dividedBy(2.1) } + imageView.snp.makeConstraints { make in make.edges.equalToSuperview() } + placeholderStack.snp.makeConstraints { make in make.center.equalToSuperview() } + deleteButton.snp.makeConstraints { make in make.top.trailing.equalToSuperview().inset(4); make.size.equalTo(20) } + progressOverlay.snp.makeConstraints { make in make.edges.equalToSuperview() } + progressStack.snp.makeConstraints { make in make.center.equalToSuperview() } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(imageData: Data?, url: String, uploading: Bool, progress: Int, canEdit: Bool) { + self.canEdit = canEdit + hasImage = imageData != nil || !url.isEmpty + if let imageData, let image = UIImage(data: imageData) { + imageView.image = image + loadedURL = "" + } else if !url.isEmpty, loadedURL != url { + loadedURL = url + imageView.loadRemoteImage(urlString: url, placeholderColor: .white) + } else if url.isEmpty { + loadedURL = "" + imageView.image = nil + } + + placeholderStack.isHidden = hasImage + placeholderLabel.text = canEdit ? "点击上传图片" : "暂无图片" + deleteButton.isHidden = !hasImage || !canEdit || uploading + progressOverlay.isHidden = !uploading + progressLabel.text = "\(progress)%" + uploading ? progressIndicator.startAnimating() : progressIndicator.stopAnimating() + canvas.accessibilityTraits = canEdit && !hasImage ? .button : .image + } + + @objc private func canvasTapped() { + guard canEdit, !hasImage else { return } + onSelect?() + } + + @objc private func deleteTapped() { + onDelete?() + } +} + +/// 带圆角虚线边框的身份证图片容器。 +private final class RealNameDashedBorderView: UIView { + private let borderLayer = CAShapeLayer() + + override init(frame: CGRect) { + super.init(frame: frame) + layer.cornerRadius = 8 + clipsToBounds = true + borderLayer.fillColor = UIColor.clear.cgColor + borderLayer.strokeColor = AppColor.border.cgColor + borderLayer.lineWidth = 2 + borderLayer.lineDashPattern = [6, 6] + layer.addSublayer(borderLayer) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + borderLayer.frame = bounds + borderLayer.path = UIBezierPath(roundedRect: bounds.insetBy(dx: 1, dy: 1), cornerRadius: 8).cgPath + } +} + +/// 实名认证特权说明行。 +private final class RealNamePrivilegeItemView: UIView { + init(imageName: String, title: String, detail: String) { + super.init(frame: .zero) + let iconContainer = UIView() + iconContainer.backgroundColor = AppColor.primary + iconContainer.layer.cornerRadius = 12 + iconContainer.snp.makeConstraints { make in make.size.equalTo(24) } + let iconView = UIImageView(image: UIImage(named: imageName)?.withRenderingMode(.alwaysTemplate)) + iconView.tintColor = .white + iconView.contentMode = .scaleAspectFit + iconContainer.addSubview(iconView) + iconView.snp.makeConstraints { make in make.center.equalToSuperview(); make.size.equalTo(16) } + + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: 14, weight: .medium) + titleLabel.textColor = AppColor.text333 + let detailLabel = UILabel() + detailLabel.text = detail + detailLabel.font = .systemFont(ofSize: 12) + detailLabel.textColor = AppColor.text666 + detailLabel.numberOfLines = 0 + + let textStack = UIStackView(arrangedSubviews: [titleLabel, detailLabel]) + textStack.axis = .vertical + textStack.spacing = 2 + let row = UIStackView(arrangedSubviews: [iconContainer, textStack]) + row.axis = .horizontal + row.alignment = .top + row.spacing = 12 + addSubview(row) + row.snp.makeConstraints { make in make.edges.equalToSuperview() } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +/// 审核状态顶部信息条。 +private final class RealNameAuditStatusView: UIView { + private let stack = UIStackView() + + override init(frame: CGRect) { + super.init(frame: frame) + stack.axis = .vertical + stack.spacing = 8 + addSubview(stack) + stack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 16, left: 20, bottom: 16, right: 20)) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(info: RealNameInfo) { + stack.arrangedSubviews.forEach { view in + stack.removeArrangedSubview(view) + view.removeFromSuperview() + } + let color: UIColor + switch info.auditStatus { + case 2: + color = AppColor.primary + backgroundColor = UIColor(hex: 0xEFF6FF) + case 3: + color = UIColor(hex: 0xEF4444) + backgroundColor = UIColor(hex: 0xFFE7E7) + default: + color = UIColor(hex: 0xFF7B00) + backgroundColor = UIColor(hex: 0xFFF0E2) + } + let statusText = info.auditStatusText?.nonEmpty ?? Self.fallbackStatusText(for: info.auditStatus) + stack.addArrangedSubview(makeRow(label: "审核状态", value: statusText, color: color)) + if info.auditStatus == 2 { + stack.addArrangedSubview(makeRow(label: "审核人:", value: info.auditor?.name ?? "--", color: color)) + stack.addArrangedSubview(makeRow(label: "审核时间:", value: info.auditAt ?? "--", color: color)) + } + stack.addArrangedSubview(makeRow(label: "审核备注:", value: info.rejectReason ?? "--", color: color)) + } + + private func makeRow(label: String, value: String, color: UIColor) -> UIView { + let labelView = UILabel() + labelView.text = label + labelView.font = .systemFont(ofSize: 14) + labelView.textColor = color + let valueView = UILabel() + valueView.text = value + valueView.font = .systemFont(ofSize: 14) + valueView.textColor = color + valueView.textAlignment = .right + valueView.numberOfLines = 0 + valueView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + let row = UIStackView(arrangedSubviews: [labelView, valueView]) + row.axis = .horizontal + row.alignment = .center + row.distribution = .equalSpacing + row.spacing = 12 + return row + } + + private static func fallbackStatusText(for status: Int) -> String { + switch status { + case 2: "审核通过" + case 3: "审核驳回" + default: "审核中" + } + } +} + +/// Android 滚轮日期弹窗的 UIKit 实现。 +private final class RealNameDatePickerViewController: UIViewController { + private let datePicker = UIDatePicker() + private let selectedDate: Date? + var onConfirm: ((Date) -> Void)? + + init(selectedDate: Date?) { + self.selectedDate = selectedDate + 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 + + let dismissButton = UIButton(type: .custom) + dismissButton.backgroundColor = UIColor.black.withAlphaComponent(0.35) + dismissButton.addTarget(self, action: #selector(backgroundTapped), for: .touchUpInside) + view.addSubview(dismissButton) + dismissButton.snp.makeConstraints { make in make.edges.equalToSuperview() } + + let container = UIView() + container.backgroundColor = .white + container.layer.cornerRadius = 10 + container.clipsToBounds = true + datePicker.datePickerMode = .date + datePicker.preferredDatePickerStyle = .wheels + datePicker.locale = Locale(identifier: "zh_CN") + datePicker.date = selectedDate ?? Date() + + let confirmButton = AppButton(title: "确认") + confirmButton.titleLabel?.font = .systemFont(ofSize: 17, weight: .medium) + confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) + confirmButton.snp.remakeConstraints { make in make.height.equalTo(45) } + + let stack = UIStackView(arrangedSubviews: [datePicker, confirmButton]) + stack.axis = .vertical + stack.spacing = 24 + container.addSubview(stack) + view.addSubview(container) + container.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(30) + } + stack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 24, left: 15, bottom: 24, right: 15)) + } + } + + @objc private func confirmTapped() { + onConfirm?(datePicker.date) + dismiss(animated: false) + } + + @objc private func backgroundTapped() { + dismiss(animated: false) + } +} + +/// 实名认证日期文本格式化工具。 +private enum RealNameDateTextFormatter { + static let formatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + static func string(from date: Date) -> String { + formatter.string(from: date) + } +} + +private extension String { + var nonEmpty: String? { + isEmpty ? nil : self } } diff --git a/suixinkan/UI/PunchPoint/PunchPointDetailViewController.swift b/suixinkan/UI/PunchPoint/PunchPointDetailViewController.swift index af0cd31..603f21a 100644 --- a/suixinkan/UI/PunchPoint/PunchPointDetailViewController.swift +++ b/suixinkan/UI/PunchPoint/PunchPointDetailViewController.swift @@ -15,9 +15,9 @@ final class PunchPointDetailViewController: BaseViewController { private let mapView = PunchPointMapView() private let zoomStack = UIStackView() - private let zoomInButton = PunchPointMapControlButton(symbol: "plus") - private let zoomOutButton = PunchPointMapControlButton(symbol: "minus") - private let locateButton = PunchPointMapControlButton(symbol: "location.fill") + private let zoomInButton = PunchPointMapControlButton(assetName: "punch_point_zoom_in") + private let zoomOutButton = PunchPointMapControlButton(assetName: "punch_point_zoom_out") + private let locateButton = PunchPointMapControlButton(assetName: "punch_point_map_location") private let cardView = UIView() private let scrollView = UIScrollView() private let contentStack = UIStackView() @@ -102,11 +102,11 @@ final class PunchPointDetailViewController: BaseViewController { } zoomStack.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(16) - make.bottom.equalTo(cardView.snp.top).offset(-16) + make.bottom.equalTo(locateButton.snp.top).offset(-12) } locateButton.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(16) - make.bottom.equalTo(zoomStack.snp.top).offset(-12) + make.bottom.equalTo(cardView.snp.top).offset(-16) make.size.equalTo(40) } zoomInButton.snp.makeConstraints { make in @@ -178,8 +178,11 @@ final class PunchPointDetailViewController: BaseViewController { bottomBar.isHidden = false if let region = detail.region { let coordinate = CLLocationCoordinate2D(latitude: region.lat, longitude: region.lot) - mapView.updateMarker(coordinate: coordinate) - mapView.setCenter(coordinate, zoomLevel: 16) + mapView.updatePunchPointMarker( + coordinate: coordinate, + imageURL: detail.guideImages.first, + fitWithUserLocation: true + ) } rebuildContent(detail) } @@ -188,7 +191,11 @@ final class PunchPointDetailViewController: BaseViewController { contentStack.arrangedSubviews.forEach { $0.removeFromSuperview() } contentStack.addArrangedSubview(PunchPointReadOnlyField(title: "*打卡点名称", value: detail.name)) let coordinateText = detail.region.map { "\($0.lat),\($0.lot)" } ?? "--" - contentStack.addArrangedSubview(PunchPointReadOnlyField(title: "*打卡点坐标", value: coordinateText)) + contentStack.addArrangedSubview(PunchPointReadOnlyField( + title: "*打卡点坐标", + value: coordinateText, + showsLocationIcon: true + )) contentStack.addArrangedSubview(PunchPointReadOnlyField(title: "*打卡点地址", value: detail.displayAddress.nonEmptyTrimmed ?? "--")) if let description = detail.description?.nonEmptyTrimmed { contentStack.addArrangedSubview(PunchPointReadOnlyField(title: "打卡点描述", value: description, multiline: true)) @@ -241,7 +248,9 @@ final class PunchPointDetailViewController: BaseViewController { imageView.isUserInteractionEnabled = true imageView.tag = index if let url = URL(string: urlText) { - imageView.kf.setImage(with: url) + imageView.kf.setImage(with: url, placeholder: UIImage(named: "SplashLogo")) + } else { + imageView.image = UIImage(named: "SplashLogo") } imageView.snp.makeConstraints { make in make.width.equalTo(120) @@ -266,7 +275,7 @@ final class PunchPointDetailViewController: BaseViewController { label.font = .systemFont(ofSize: 14) label.textColor = UIColor(hex: 0x2E3746) let chip = PunchPointStatusChip() - chip.apply(text: detail.statusLabel.nonEmptyTrimmed ?? "--", status: detail.status) + chip.apply(PunchPointDisplayFormatter.auditStatus(detail.status), compact: false) row.addArrangedSubview(label) row.addArrangedSubview(chip) row.isLayoutMarginsRelativeArrangement = true @@ -301,7 +310,9 @@ final class PunchPointDetailViewController: BaseViewController { imageView.clipsToBounds = true imageView.layer.cornerRadius = 16 if let url = URL(string: qrCode) { - imageView.kf.setImage(with: url) + imageView.kf.setImage(with: url, placeholder: UIImage(named: "SplashLogo")) + } else { + imageView.image = UIImage(named: "SplashLogo") } imageView.snp.makeConstraints { make in make.size.equalTo(180) @@ -331,6 +342,11 @@ final class PunchPointDetailViewController: BaseViewController { } Task { do { + let authorization = await PHPhotoLibrary.requestAuthorization(for: .addOnly) + guard authorization == .authorized || authorization == .limited else { + await MainActor.run { self.presentPhotoPermissionAlert() } + return + } let (data, _) = try await URLSession.shared.data(from: url) guard let image = UIImage(data: data) else { throw APIError.decodeFailed("二维码图片无效") } try await PHPhotoLibrary.shared().performChanges { @@ -343,6 +359,21 @@ final class PunchPointDetailViewController: BaseViewController { } } + @MainActor + private func presentPhotoPermissionAlert() { + let alert = UIAlertController( + title: "无法保存二维码", + message: "相册权限未授予,请在设置中开启", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + alert.addAction(UIAlertAction(title: "去设置", style: .default) { _ in + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + }) + present(alert, animated: true) + } + private func requiredTitle(_ text: String) -> NSAttributedString { let mutable = NSMutableAttributedString(string: text, attributes: [ .font: UIFont.systemFont(ofSize: 14, weight: .medium), @@ -368,7 +399,35 @@ final class PunchPointDetailViewController: BaseViewController { } @objc private func locateTapped() { - mapView.centerOnUserLocation() + Task { + do { + let snapshot = try await LocationProvider.shared.requestSnapshot() + let coordinate = CLLocationCoordinate2D( + latitude: snapshot.latitude, + longitude: snapshot.longitude + ) + await MainActor.run { self.mapView.setCenter(coordinate, zoomLevel: 16) } + } catch LocationProviderError.permissionDenied { + await MainActor.run { self.presentLocationPermissionAlert() } + } catch { + await MainActor.run { self.showToast("定位失败") } + } + } + } + + @MainActor + private func presentLocationPermissionAlert() { + let alert = UIAlertController( + title: "无法定位", + message: "定位权限未授予,请在设置中开启", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + alert.addAction(UIAlertAction(title: "去设置", style: .default) { _ in + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + }) + present(alert, animated: true) } @objc private func retryTapped() { @@ -393,7 +452,10 @@ final class PunchPointMapView: UIView, MAMapViewDelegate { var onMapTap: ((CLLocationCoordinate2D) -> Void)? private let mapView: MAMapView - private var markerAnnotation: MAPointAnnotation? + private var punchPointAnnotation: MAPointAnnotation? + private var selectedAnnotation: MAPointAnnotation? + private var punchPointImageURL: URL? + private var fitWithUserLocation = false override init(frame: CGRect) { _ = AMapBootstrap.configureIfNeeded() @@ -403,6 +465,7 @@ final class PunchPointMapView: UIView, MAMapViewDelegate { mapView.showsUserLocation = true mapView.showsCompass = false mapView.showsScale = false + mapView.touchPOIEnabled = true mapView.zoomLevel = 15 addSubview(mapView) mapView.snp.makeConstraints { make in @@ -415,17 +478,45 @@ final class PunchPointMapView: UIView, MAMapViewDelegate { fatalError("init(coder:) has not been implemented") } - /// 更新打卡点标记。 + /// 更新原打卡点标记,可使用首张图片作为 Android 同款圆角 Marker。 + func updatePunchPointMarker( + coordinate: CLLocationCoordinate2D?, + imageURL: String? = nil, + fitWithUserLocation: Bool = false + ) { + if let punchPointAnnotation { + mapView.removeAnnotation(punchPointAnnotation) + self.punchPointAnnotation = nil + } + punchPointImageURL = imageURL.flatMap(URL.init(string:)) + self.fitWithUserLocation = fitWithUserLocation + guard let coordinate, CLLocationCoordinate2DIsValid(coordinate) else { return } + let annotation = MAPointAnnotation() + annotation.coordinate = coordinate + annotation.title = "punch_point_original" + punchPointAnnotation = annotation + mapView.addAnnotation(annotation) + if fitWithUserLocation { + fitPunchPointAndUserLocation() + } + } + + /// 兼容仅需更新普通 Marker 的调用。 func updateMarker(coordinate: CLLocationCoordinate2D?) { - if let markerAnnotation { - mapView.removeAnnotation(markerAnnotation) - self.markerAnnotation = nil + updatePunchPointMarker(coordinate: coordinate) + } + + /// 更新地图点击产生的新位置 Marker。 + func updateSelectedMarker(coordinate: CLLocationCoordinate2D?) { + if let selectedAnnotation { + mapView.removeAnnotation(selectedAnnotation) + self.selectedAnnotation = nil } guard let coordinate, CLLocationCoordinate2DIsValid(coordinate) else { return } let annotation = MAPointAnnotation() annotation.coordinate = coordinate - annotation.title = "打卡点位置" - markerAnnotation = annotation + annotation.title = "punch_point_selected" + selectedAnnotation = annotation mapView.addAnnotation(annotation) } @@ -453,21 +544,92 @@ final class PunchPointMapView: UIView, MAMapViewDelegate { setCenter(coordinate) } + /// 同时展示原打卡点、用户位置和新选位置,并留出 Android 同款边距。 + func fitAllMarkers(edgePadding: UIEdgeInsets = UIEdgeInsets(top: 80, left: 56, bottom: 80, right: 56)) { + var annotations: [MAAnnotation] = [] + if let punchPointAnnotation { annotations.append(punchPointAnnotation) } + if let selectedAnnotation { annotations.append(selectedAnnotation) } + if let userLocation = mapView.userLocation, + let location = userLocation.location, + CLLocationCoordinate2DIsValid(location.coordinate) { + annotations.append(userLocation) + } + guard !annotations.isEmpty else { return } + if annotations.count == 1, let annotation = annotations.first { + setCenter(annotation.coordinate, zoomLevel: 16) + } else { + mapView.showAnnotations(annotations, edgePadding: edgePadding, animated: true) + } + } + + private func fitPunchPointAndUserLocation() { + guard fitWithUserLocation else { return } + fitAllMarkers(edgePadding: UIEdgeInsets(top: 80, left: 50, bottom: 80, right: 50)) + } + + func mapView(_ mapView: MAMapView!, viewFor annotation: MAAnnotation!) -> MAAnnotationView! { + if annotation is MAUserLocation { return nil } + let isOriginal = annotation === punchPointAnnotation + let reuseIdentifier = isOriginal ? "PunchPointImageMarker" : "PunchPointSelectedMarker" + let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: reuseIdentifier) + ?? MAAnnotationView(annotation: annotation, reuseIdentifier: reuseIdentifier) + annotationView?.annotation = annotation + annotationView?.canShowCallout = false + annotationView?.centerOffset = CGPoint(x: 0, y: -12) + + if isOriginal, let punchPointImageURL { + annotationView?.image = UIImage(named: "punch_point_marker") + KingfisherManager.shared.retrieveImage(with: punchPointImageURL) { result in + guard case let .success(value) = result else { return } + Task { @MainActor in + guard annotationView?.annotation === annotation else { return } + annotationView?.image = Self.roundedMarkerImage(value.image) + } + } + } else { + annotationView?.image = UIImage(named: "punch_point_marker") + } + return annotationView + } + + func mapView(_ mapView: MAMapView!, didUpdate userLocation: MAUserLocation!, updatingLocation: Bool) { + guard updatingLocation, userLocation.location != nil else { return } + fitPunchPointAndUserLocation() + } + + private static func roundedMarkerImage(_ source: UIImage) -> UIImage { + let size = CGSize(width: 40, height: 40) + return UIGraphicsImageRenderer(size: size).image { _ in + UIBezierPath(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: 6).addClip() + source.draw(in: CGRect(origin: .zero, size: size)) + } + } + func mapView(_ mapView: MAMapView!, didSingleTappedAt coordinate: CLLocationCoordinate2D) { onMapTap?(coordinate) } + + func mapView(_ mapView: MAMapView!, didTouchPois pois: [Any]!) { + guard let poi = pois.first as? MATouchPoi else { return } + onMapTap?(poi.coordinate) + } } /// 打卡点地图控制按钮。 /// 地图悬浮控制按钮。 final class PunchPointMapControlButton: UIButton { - init(symbol: String) { + init(assetName: String) { super.init(frame: .zero) - setImage(UIImage(systemName: symbol), for: .normal) - tintColor = AppColor.textPrimary + setImage(UIImage(named: assetName), for: .normal) backgroundColor = .white layer.cornerRadius = 12 clipsToBounds = true + imageView?.contentMode = .scaleAspectFit + accessibilityLabel = switch assetName { + case "punch_point_zoom_in": "放大地图" + case "punch_point_zoom_out": "缩小地图" + default: "定位当前位置" + } } @available(*, unavailable) @@ -479,20 +641,46 @@ final class PunchPointMapControlButton: UIButton { /// 打卡点只读字段。 /// 详情页只读字段块。 final class PunchPointReadOnlyField: UIView { - init(title: String, value: String, multiline: Bool = false) { + init(title: String, value: String, multiline: Bool = false, showsLocationIcon: Bool = false) { super.init(frame: .zero) let stack = UIStackView() stack.axis = .vertical stack.spacing = 8 let titleLabel = UILabel() titleLabel.attributedText = Self.requiredTitle(title) + let valueContainer = UIView() + valueContainer.backgroundColor = UIColor(hex: 0xF4F4F4) + valueContainer.layer.cornerRadius = 8 let valueLabel = UILabel() valueLabel.text = value.nonEmptyTrimmed ?? "--" - valueLabel.font = .systemFont(ofSize: 14, weight: .medium) - valueLabel.textColor = AppColor.textPrimary + valueLabel.font = .systemFont(ofSize: 14) + valueLabel.textColor = UIColor(hex: 0x4B5563) valueLabel.numberOfLines = multiline ? 0 : 2 stack.addArrangedSubview(titleLabel) - stack.addArrangedSubview(valueLabel) + stack.addArrangedSubview(valueContainer) + valueContainer.addSubview(valueLabel) + if showsLocationIcon { + let icon = UIImageView(image: UIImage(named: "punch_point_location")) + icon.contentMode = .scaleAspectFit + valueContainer.addSubview(icon) + icon.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(12) + make.centerY.equalToSuperview() + make.size.equalTo(20) + } + valueLabel.snp.makeConstraints { make in + make.top.bottom.equalToSuperview().inset(12) + make.leading.equalToSuperview().offset(12) + make.trailing.lessThanOrEqualTo(icon.snp.leading).offset(-8) + } + } else { + valueLabel.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)) + } + } + valueContainer.snp.makeConstraints { make in + make.height.greaterThanOrEqualTo(multiline ? 72 : 46) + } addSubview(stack) stack.snp.makeConstraints { make in make.edges.equalToSuperview() diff --git a/suixinkan/UI/PunchPoint/PunchPointFormViewController.swift b/suixinkan/UI/PunchPoint/PunchPointFormViewController.swift index 29ea3c4..a0e51ed 100644 --- a/suixinkan/UI/PunchPoint/PunchPointFormViewController.swift +++ b/suixinkan/UI/PunchPoint/PunchPointFormViewController.swift @@ -17,9 +17,9 @@ final class PunchPointFormViewController: BaseViewController { private let mapView = PunchPointMapView() private let zoomStack = UIStackView() - private let zoomInButton = PunchPointMapControlButton(symbol: "plus") - private let zoomOutButton = PunchPointMapControlButton(symbol: "minus") - private let locateButton = PunchPointMapControlButton(symbol: "location.fill") + private let zoomInButton = PunchPointMapControlButton(assetName: "punch_point_zoom_in") + private let zoomOutButton = PunchPointMapControlButton(assetName: "punch_point_zoom_out") + private let locateButton = PunchPointMapControlButton(assetName: "punch_point_map_location") private let cardView = UIView() private let scrollView = UIScrollView() private let formStack = UIStackView() @@ -105,11 +105,11 @@ final class PunchPointFormViewController: BaseViewController { } zoomStack.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(16) - make.bottom.equalTo(cardView.snp.top).offset(-16) + make.bottom.equalTo(locateButton.snp.top).offset(-12) } locateButton.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(16) - make.bottom.equalTo(zoomStack.snp.top).offset(-12) + make.bottom.equalTo(cardView.snp.top).offset(-16) make.size.equalTo(40) } zoomInButton.snp.makeConstraints { make in @@ -148,12 +148,11 @@ final class PunchPointFormViewController: BaseViewController { Task { @MainActor in self?.applyViewModel() } } viewModel.onShowMessage = { [weak self] message in - Task { @MainActor in self?.showToast(message) } + Task { @MainActor in self?.handleMessage(message) } } viewModel.onCoordinateChange = { [weak self] coordinate in Task { @MainActor in - self?.mapView.updateMarker(coordinate: coordinate) - self?.mapView.setCenter(coordinate, zoomLevel: 17) + self?.applyMapCoordinate(coordinate) } } viewModel.onSubmitSuccess = { [weak self] in @@ -232,6 +231,39 @@ final class PunchPointFormViewController: BaseViewController { return PunchPointImageState(data: data, fileName: fileName) } + @MainActor + private func handleMessage(_ message: String) { + guard message.contains("定位权限未授予") else { + showToast(message) + return + } + let alert = UIAlertController(title: "无法定位", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + alert.addAction(UIAlertAction(title: "去设置", style: .default) { _ in + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + }) + present(alert, animated: true) + } + + @MainActor + private func applyMapCoordinate(_ coordinate: CLLocationCoordinate2D) { + if let initialDetail, let original = initialDetail.coordinate { + mapView.updatePunchPointMarker( + coordinate: original, + imageURL: initialDetail.guideImages.first + ) + let isOriginal = abs(original.latitude - coordinate.latitude) < 0.000_001 + && abs(original.longitude - coordinate.longitude) < 0.000_001 + mapView.updateSelectedMarker(coordinate: isOriginal ? nil : coordinate) + mapView.fitAllMarkers() + } else { + mapView.updatePunchPointMarker(coordinate: nil) + mapView.updateSelectedMarker(coordinate: coordinate) + mapView.setCenter(coordinate, zoomLevel: 17) + } + } + private func updateUploadDialog(_ state: PunchPointUploadDialogState?) { guard let state else { progressAlert?.dismiss(animated: true) @@ -329,13 +361,13 @@ private final class PunchPointTextFieldView: UIView { super.init(frame: .zero) titleLabel.attributedText = Self.requiredTitle(title) textField.placeholder = placeholder - textField.font = .systemFont(ofSize: 14) + textField.font = .systemFont(ofSize: 15) textField.textColor = AppColor.textPrimary textField.backgroundColor = .white textField.layer.cornerRadius = 8 textField.layer.borderColor = AppColor.border.cgColor textField.layer.borderWidth = 1 - textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 44)) + textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 46)) textField.leftViewMode = .always addSubview(titleLabel) addSubview(textField) @@ -345,7 +377,7 @@ private final class PunchPointTextFieldView: UIView { textField.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(8) make.leading.trailing.bottom.equalToSuperview() - make.height.equalTo(44) + make.height.equalTo(46) } textField.addTarget(self, action: #selector(textChanged), for: .editingChanged) } @@ -395,10 +427,11 @@ private final class PunchPointCoordinateFieldView: UIView { container.layer.borderWidth = 1 container.layer.borderColor = AppColor.border.cgColor valueLabel.text = placeholder - valueLabel.font = .systemFont(ofSize: 14) + valueLabel.font = .systemFont(ofSize: 15) valueLabel.textColor = UIColor(hex: 0x999999) - locateButton.setImage(UIImage(systemName: "location.fill"), for: .normal) - locateButton.tintColor = AppColor.textPrimary + locateButton.setImage(UIImage(named: "punch_point_location"), for: .normal) + locateButton.imageView?.contentMode = .scaleAspectFit + locateButton.accessibilityLabel = "定位打卡点坐标" addSubview(titleLabel) addSubview(container) container.addSubview(valueLabel) @@ -409,7 +442,7 @@ private final class PunchPointCoordinateFieldView: UIView { container.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(8) make.leading.trailing.bottom.equalToSuperview() - make.height.equalTo(44) + make.height.equalTo(46) } valueLabel.snp.makeConstraints { make in make.leading.equalToSuperview().offset(12) @@ -457,7 +490,7 @@ private final class PunchPointTextViewFieldView: UIView, UITextViewDelegate { titleLabel.text = title titleLabel.font = .systemFont(ofSize: 14, weight: .medium) titleLabel.textColor = UIColor(hex: 0x333333) - textView.font = .systemFont(ofSize: 14) + textView.font = .systemFont(ofSize: 15) textView.textColor = AppColor.textPrimary textView.layer.cornerRadius = 8 textView.layer.borderWidth = 1 @@ -476,7 +509,7 @@ private final class PunchPointTextViewFieldView: UIView, UITextViewDelegate { textView.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(8) make.leading.trailing.bottom.equalToSuperview() - make.height.equalTo(92) + make.height.equalTo(100) } placeholderLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(10) @@ -575,7 +608,8 @@ private final class PunchPointImageGridView: UIView { /// 打卡点图片缩略图。 private final class PunchPointImageThumbView: UIControl { private let imageView = UIImageView() - private let addIcon = UIImageView(image: UIImage(systemName: "plus")) + private let addIcon = UIImageView(image: UIImage(named: "punch_point_image_add")) + private let addLabel = UILabel() private let deleteButton = UIButton(type: .system) private let overlayLabel = UILabel() var onAdd: (() -> Void)? @@ -586,26 +620,35 @@ private final class PunchPointImageThumbView: UIControl { super.init(frame: frame) layer.cornerRadius = 8 clipsToBounds = true - backgroundColor = .white + backgroundColor = UIColor(hex: 0xF4F4F4) imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true - addIcon.tintColor = AppColor.primary - deleteButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal) - deleteButton.tintColor = .white + addLabel.text = "点击上传" + addLabel.font = .systemFont(ofSize: 12) + addLabel.textColor = UIColor(hex: 0xB6BECA) + addLabel.textAlignment = .center + deleteButton.setImage(UIImage(named: "punch_point_image_delete"), for: .normal) + deleteButton.accessibilityLabel = "删除图片" overlayLabel.font = .systemFont(ofSize: 14, weight: .medium) overlayLabel.textColor = .white overlayLabel.textAlignment = .center overlayLabel.backgroundColor = UIColor.black.withAlphaComponent(0.45) addSubview(imageView) addSubview(addIcon) + addSubview(addLabel) addSubview(deleteButton) addSubview(overlayLabel) imageView.snp.makeConstraints { make in make.edges.equalToSuperview() } addIcon.snp.makeConstraints { make in - make.center.equalToSuperview() - make.size.equalTo(28) + make.centerX.equalToSuperview() + make.centerY.equalToSuperview().offset(-8) + make.size.equalTo(24) + } + addLabel.snp.makeConstraints { make in + make.top.equalTo(addIcon.snp.bottom).offset(2) + make.centerX.equalToSuperview() } deleteButton.snp.makeConstraints { make in make.top.trailing.equalToSuperview().inset(4) @@ -624,18 +667,22 @@ private final class PunchPointImageThumbView: UIControl { } func applyAdd() { + accessibilityLabel = "点击上传图片" layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor layer.borderWidth = 1 imageView.image = nil imageView.kf.cancelDownloadTask() addIcon.isHidden = false + addLabel.isHidden = false deleteButton.isHidden = true overlayLabel.isHidden = true } func apply(item: PunchPointImageState) { + accessibilityLabel = item.errorMessage == nil ? "打卡点图片" : "图片上传失败,点击重试" layer.borderWidth = 0 addIcon.isHidden = true + addLabel.isHidden = true deleteButton.isHidden = item.isUploading if let urlText = item.previewURL, let url = URL(string: urlText), !urlText.isEmpty { imageView.kf.setImage(with: url) diff --git a/suixinkan/UI/PunchPoint/PunchPointListViewController.swift b/suixinkan/UI/PunchPoint/PunchPointListViewController.swift index 56a3c6e..9ff2629 100644 --- a/suixinkan/UI/PunchPoint/PunchPointListViewController.swift +++ b/suixinkan/UI/PunchPoint/PunchPointListViewController.swift @@ -15,7 +15,7 @@ final class PunchPointListViewController: BaseViewController { private let filterContainer = UIView() private let filterButton = UIButton(type: .system) private let filterTitleLabel = UILabel() - private let filterChevronView = UIImageView(image: UIImage(systemName: "chevron.down")) + private let filterChevronView = UIImageView(image: UIImage(named: "punch_point_dropdown")) private let filterDropdownView = UIView() private let filterDropdownStack = UIStackView() private let tableView = UITableView(frame: .zero, style: .plain) @@ -43,18 +43,22 @@ final class PunchPointListViewController: BaseViewController { override func setupNavigationBar() { title = "打卡点列表" + navigationController?.navigationBar.titleTextAttributes = [ + .font: UIFont.systemFont(ofSize: 18, weight: .regular), + .foregroundColor: UIColor(hex: 0x333333), + ] } override func setupUI() { - view.backgroundColor = AppColor.pageBackground + view.backgroundColor = UIColor(hex: 0xF4F4F4) filterContainer.backgroundColor = .white filterButton.backgroundColor = UIColor(hex: 0xF5F5F5) filterButton.layer.cornerRadius = 8 + filterButton.accessibilityLabel = "筛选打卡点状态" filterTitleLabel.text = PunchPointFilterType.all.title filterTitleLabel.font = .systemFont(ofSize: 14) filterTitleLabel.textColor = AppColor.textPrimary - filterChevronView.tintColor = AppColor.textSecondary filterChevronView.contentMode = .scaleAspectFit filterDropdownView.backgroundColor = .white @@ -66,7 +70,7 @@ final class PunchPointListViewController: BaseViewController { filterDropdownView.isHidden = true filterDropdownStack.axis = .vertical - tableView.backgroundColor = AppColor.pageBackground + tableView.backgroundColor = UIColor(hex: 0xF4F4F4) tableView.separatorStyle = .none tableView.delegate = self tableView.rowHeight = UITableView.automaticDimension @@ -91,6 +95,7 @@ final class PunchPointListViewController: BaseViewController { addButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) addButton.backgroundColor = AppColor.primary addButton.layer.cornerRadius = 8 + addButton.accessibilityLabel = "添加打卡点" emptyView.isHidden = true emptyView.onAction = { [weak self] in self?.addTapped() } @@ -166,6 +171,9 @@ final class PunchPointListViewController: BaseViewController { viewModel.onShowQRCode = { [weak self] url in Task { @MainActor in self?.showQRCodeDialog(url: url) } } + viewModel.onBeginDeleteAnimation = { [weak self] id in + Task { @MainActor in self?.animateRemoval(id: id) } + } tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged) filterButton.addTarget(self, action: #selector(filterTapped), for: .touchUpInside) addButton.addTarget(self, action: #selector(addTapped), for: .touchUpInside) @@ -222,35 +230,49 @@ final class PunchPointListViewController: BaseViewController { } private func confirmDelete(_ item: PunchPointItem) { - let alert = UIAlertController( + let alert = PunchPointMaterialDialogController.confirmation( title: "删除打卡点", message: "确定删除\(item.name)打卡点吗?", - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: "取消", style: .cancel)) - alert.addAction(UIAlertAction(title: "确定", style: .destructive) { [weak self] _ in - guard let self else { return } - Task { await self.viewModel.delete(item: item, api: self.api) } - }) + confirmTitle: "确定", + onConfirm: { [weak self] in + self?.dismiss(animated: true) + guard let self else { return } + Task { await self.viewModel.delete(item: item, api: self.api) } + } + ) { [weak self] in + self?.dismiss(animated: true) + } present(alert, animated: true) } + @MainActor + private func animateRemoval(id: Int64) { + guard let index = viewModel.items.firstIndex(where: { $0.id == id }) else { + viewModel.completeDeleteAnimation(id: id) + return + } + let indexPath = IndexPath(row: index, section: 0) + guard let cell = tableView.cellForRow(at: indexPath) else { + viewModel.completeDeleteAnimation(id: id) + return + } + UIView.animate( + withDuration: 0.3, + delay: 0, + options: [.curveEaseIn, .allowUserInteraction] + ) { + cell.contentView.transform = CGAffineTransform(translationX: self.tableView.bounds.width, y: 0) + cell.contentView.alpha = 0 + } completion: { [weak self] _ in + guard let self else { return } + self.viewModel.completeDeleteAnimation(id: id) + } + } + private func showQRCodeDialog(url: String) { - let controller = UIAlertController(title: nil, message: "\n\n\n\n\n\n\n\n", preferredStyle: .alert) - let imageView = UIImageView() - imageView.contentMode = .scaleAspectFill - imageView.clipsToBounds = true - imageView.layer.cornerRadius = 12 - if let imageURL = URL(string: url) { - imageView.kf.setImage(with: imageURL) + let controller = PunchPointMaterialDialogController.qrCode(url: url) { [weak self] in + self?.dismiss(animated: true) } - controller.view.addSubview(imageView) - imageView.snp.makeConstraints { make in - make.centerX.equalToSuperview() - make.top.equalToSuperview().offset(52) - make.size.equalTo(180) - } - controller.addAction(UIAlertAction(title: "确定", style: .default)) present(controller, animated: true) } @@ -294,7 +316,7 @@ private final class PunchPointListCell: UITableViewCell { private let operatingTag = PunchPointStatusChip() private let creatorLabel = UILabel() private let createdAtLabel = UILabel() - private let locationIcon = UIImageView(image: UIImage(systemName: "mappin.and.ellipse")) + private let locationIcon = UIImageView(image: UIImage(named: "punch_point_location")) private let addressLabel = UILabel() private let reviewTag = PunchPointStatusChip() private let auditTimeLabel = UILabel() @@ -324,14 +346,14 @@ private final class PunchPointListCell: UITableViewCell { $0.font = .systemFont(ofSize: 12) $0.textColor = AppColor.textSecondary } - locationIcon.tintColor = UIColor(hex: 0x4B5563) + locationIcon.contentMode = .scaleAspectFit addressLabel.font = .systemFont(ofSize: 12) addressLabel.textColor = UIColor(hex: 0x4B5563) addressLabel.numberOfLines = 2 - qrButton.setImage(UIImage(systemName: "qrcode"), for: .normal) - qrButton.tintColor = AppColor.textPrimary - deleteButton.setImage(UIImage(systemName: "trash"), for: .normal) - deleteButton.tintColor = AppColor.textPrimary + qrButton.setImage(UIImage(named: "punch_point_qr"), for: .normal) + deleteButton.setImage(UIImage(named: "punch_point_delete"), for: .normal) + qrButton.accessibilityLabel = "查看二维码" + deleteButton.accessibilityLabel = "删除打卡点" contentView.addSubview(cardView) [coverImageView, nameLabel, operatingTag, creatorLabel, createdAtLabel, locationIcon, addressLabel, reviewTag, auditTimeLabel, qrButton, deleteButton].forEach(cardView.addSubview) @@ -341,7 +363,8 @@ private final class PunchPointListCell: UITableViewCell { make.leading.trailing.equalToSuperview().inset(15) } coverImageView.snp.makeConstraints { make in - make.top.leading.equalToSuperview().offset(16) + make.top.equalToSuperview().offset(14) + make.leading.equalToSuperview().offset(16) make.size.equalTo(128) } nameLabel.snp.makeConstraints { make in @@ -402,17 +425,19 @@ private final class PunchPointListCell: UITableViewCell { } func apply(item: PunchPointItem) { + contentView.transform = .identity + contentView.alpha = 1 nameLabel.text = item.name creatorLabel.text = "负责人:\(item.creator?.nonEmptyTrimmed ?? "--")" createdAtLabel.text = "创建时间:\(item.createdAt)" addressLabel.text = item.displayAddress.nonEmptyTrimmed ?? "--" auditTimeLabel.text = "审核时间:\(item.auditTime?.nonEmptyTrimmed ?? "--")" - operatingTag.apply(text: item.statusLabel.nonEmptyTrimmed ?? "--", status: item.status) - reviewTag.apply(text: item.statusLabel.nonEmptyTrimmed ?? "--", status: item.status) + operatingTag.apply(PunchPointDisplayFormatter.operatingStatus(item.status), compact: true) + reviewTag.apply(PunchPointDisplayFormatter.auditStatus(item.status), compact: false) if let url = item.guideImages.first.flatMap(URL.init(string:)) { - coverImageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo")) + coverImageView.kf.setImage(with: url, placeholder: UIImage(named: "SplashLogo")) } else { - coverImageView.image = UIImage(systemName: "photo") + coverImageView.image = UIImage(named: "SplashLogo") } } @@ -432,14 +457,12 @@ final class PunchPointStatusChip: UIView { override init(frame: CGRect) { super.init(frame: frame) - layer.cornerRadius = 10 + layer.cornerRadius = 4 clipsToBounds = true - label.font = .systemFont(ofSize: 12, weight: .medium) + label.font = .systemFont(ofSize: 12) label.textAlignment = .center addSubview(label) - label.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(UIEdgeInsets(top: 3, left: 8, bottom: 3, right: 8)) - } + label.snp.makeConstraints { make in make.edges.equalToSuperview() } } @available(*, unavailable) @@ -448,11 +471,15 @@ final class PunchPointStatusChip: UIView { } /// 应用标签文案和状态色。 - func apply(text: String, status: Int) { - label.text = text - let colors = PunchPointDisplayFormatter.statusColors(status) - backgroundColor = UIColor(hex: colors.background) - label.textColor = UIColor(hex: colors.text) + func apply(_ presentation: PunchPointStatusPresentation, compact: Bool = false) { + label.text = presentation.title + backgroundColor = UIColor(hex: presentation.backgroundColor) + label.textColor = UIColor(hex: presentation.textColor) + label.snp.remakeConstraints { make in + make.edges.equalToSuperview().inset( + UIEdgeInsets(top: compact ? 2 : 4, left: compact ? 8 : 10, bottom: compact ? 2 : 4, right: compact ? 8 : 10) + ) + } } } @@ -465,11 +492,11 @@ private final class PunchPointEmptyView: UIView { override init(frame: CGRect) { super.init(frame: frame) - titleLabel.text = "暂无打卡点" - titleLabel.font = .systemFont(ofSize: 15) + titleLabel.text = "暂无打卡点,点击下方按钮即可添加" + titleLabel.font = .systemFont(ofSize: 14) titleLabel.textColor = AppColor.textSecondary titleLabel.textAlignment = .center - button.setTitle("添加打卡点", for: .normal) + button.setTitle("立即添加", for: .normal) button.setTitleColor(.white, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) button.backgroundColor = AppColor.primary @@ -482,7 +509,7 @@ private final class PunchPointEmptyView: UIView { button.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(12) make.centerX.bottom.equalToSuperview() - make.width.equalTo(128) + make.width.greaterThanOrEqualTo(96) make.height.equalTo(40) } button.addTarget(self, action: #selector(actionTapped), for: .touchUpInside) @@ -498,6 +525,151 @@ private final class PunchPointEmptyView: UIView { } } +/// Android Material3 风格的打卡点确认与二维码弹窗。 +private final class PunchPointMaterialDialogController: UIViewController { + private enum Content { + case confirmation(title: String, message: String, confirmTitle: String) + case qrCode(url: String) + } + + private let content: Content + private let onConfirm: () -> Void + private let onCancel: (() -> Void)? + + private init(content: Content, onConfirm: @escaping () -> Void, onCancel: (() -> Void)? = nil) { + self.content = content + self.onConfirm = onConfirm + self.onCancel = onCancel + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .crossDissolve + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + static func confirmation( + title: String, + message: String, + confirmTitle: String, + onConfirm: @escaping () -> Void, + onCancel: @escaping () -> Void + ) -> PunchPointMaterialDialogController { + PunchPointMaterialDialogController( + content: .confirmation(title: title, message: message, confirmTitle: confirmTitle), + onConfirm: onConfirm, + onCancel: onCancel + ) + } + + static func qrCode(url: String, onConfirm: @escaping () -> Void) -> PunchPointMaterialDialogController { + PunchPointMaterialDialogController(content: .qrCode(url: url), onConfirm: onConfirm) + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = UIColor.black.withAlphaComponent(0.32) + view.accessibilityViewIsModal = true + + let dialog = UIView() + dialog.backgroundColor = .white + dialog.layer.cornerRadius = 28 + dialog.clipsToBounds = true + view.addSubview(dialog) + dialog.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.greaterThanOrEqualToSuperview().offset(32) + make.trailing.lessThanOrEqualToSuperview().offset(-32) + make.width.lessThanOrEqualTo(320) + } + + switch content { + case let .confirmation(title, message, confirmTitle): + configureConfirmation(dialog: dialog, title: title, message: message, confirmTitle: confirmTitle) + case let .qrCode(url): + configureQRCode(dialog: dialog, url: url) + } + } + + private func configureConfirmation(dialog: UIView, title: String, message: String, confirmTitle: String) { + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: 16, weight: .medium) + titleLabel.textColor = UIColor(hex: 0x333333) + let messageLabel = UILabel() + messageLabel.text = message + messageLabel.font = .systemFont(ofSize: 14) + messageLabel.textColor = UIColor(hex: 0x666666) + messageLabel.numberOfLines = 0 + let cancel = textButton(title: "取消", color: UIColor(hex: 0x666666), action: #selector(cancelTapped)) + let confirm = textButton(title: confirmTitle, color: AppColor.primary, action: #selector(confirmTapped)) + let actions = UIStackView(arrangedSubviews: [UIView(), cancel, confirm]) + actions.axis = .horizontal + actions.spacing = 8 + + [titleLabel, messageLabel, actions].forEach(dialog.addSubview) + titleLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(24) + make.leading.trailing.equalToSuperview().inset(24) + } + messageLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(16) + make.leading.trailing.equalToSuperview().inset(24) + } + actions.snp.makeConstraints { make in + make.top.equalTo(messageLabel.snp.bottom).offset(16) + make.leading.trailing.equalToSuperview().inset(16) + make.bottom.equalToSuperview().inset(12) + make.height.equalTo(44) + } + } + + private func configureQRCode(dialog: UIView, url: String) { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.layer.cornerRadius = 12 + if let imageURL = URL(string: url) { + imageView.kf.setImage(with: imageURL) + } + let confirm = textButton(title: "确定", color: AppColor.primary, action: #selector(confirmTapped)) + dialog.addSubview(imageView) + dialog.addSubview(confirm) + imageView.snp.makeConstraints { make in + make.top.equalToSuperview().offset(32) + make.centerX.equalToSuperview() + make.size.equalTo(180) + make.leading.greaterThanOrEqualToSuperview().offset(32) + } + confirm.snp.makeConstraints { make in + make.top.equalTo(imageView.snp.bottom).offset(12) + make.trailing.equalToSuperview().inset(16) + make.bottom.equalToSuperview().inset(12) + make.width.equalTo(64) + make.height.equalTo(44) + } + } + + private func textButton(title: String, color: UIColor, action: Selector) -> UIButton { + let button = UIButton(type: .system) + button.setTitle(title, for: .normal) + button.setTitleColor(color, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) + button.addTarget(self, action: action, for: .touchUpInside) + return button + } + + @objc private func confirmTapped() { + onConfirm() + } + + @objc private func cancelTapped() { + onCancel?() + } +} + private extension String { var nonEmptyTrimmed: String? { let text = trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/suixinkan/UI/Task/CloudStoragePickForTaskViewController.swift b/suixinkan/UI/Task/CloudStoragePickForTaskViewController.swift index 874577b..f4af953 100644 --- a/suixinkan/UI/Task/CloudStoragePickForTaskViewController.swift +++ b/suixinkan/UI/Task/CloudStoragePickForTaskViewController.swift @@ -330,7 +330,7 @@ private final class CloudPickCell: UICollectionViewCell { private let imageView = UIImageView() private let nameLabel = UILabel() - private let badgeView = UIView() + private let selectionIconView = UIImageView(image: UIImage(systemName: "checkmark.circle.fill")) private let folderIconView = UIImageView() override init(frame: CGRect) { @@ -351,14 +351,14 @@ private final class CloudPickCell: UICollectionViewCell { nameLabel.numberOfLines = 2 nameLabel.textAlignment = .center - badgeView.backgroundColor = AppColor.primary - badgeView.layer.cornerRadius = 10 - badgeView.isHidden = true + selectionIconView.tintColor = AppColor.primary + selectionIconView.contentMode = .scaleAspectFit + selectionIconView.isHidden = true contentView.addSubview(imageView) contentView.addSubview(folderIconView) contentView.addSubview(nameLabel) - contentView.addSubview(badgeView) + contentView.addSubview(selectionIconView) imageView.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() @@ -372,7 +372,7 @@ private final class CloudPickCell: UICollectionViewCell { make.top.equalTo(imageView.snp.bottom).offset(AppSpacing.xxs) make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.xxs) } - badgeView.snp.makeConstraints { make in + selectionIconView.snp.makeConstraints { make in make.top.trailing.equalToSuperview().inset(AppSpacing.xxs) make.width.height.equalTo(20) } @@ -388,7 +388,7 @@ private final class CloudPickCell: UICollectionViewCell { imageView.kf.cancelDownloadTask() imageView.image = nil folderIconView.isHidden = true - badgeView.isHidden = true + selectionIconView.isHidden = true contentView.alpha = 1 } @@ -396,7 +396,7 @@ private final class CloudPickCell: UICollectionViewCell { nameLabel.text = file.name contentView.layer.borderWidth = isSelected ? 2 : 0 contentView.layer.borderColor = AppColor.primary.cgColor - badgeView.isHidden = !isSelected + selectionIconView.isHidden = !isSelected if isGridMode { imageView.snp.remakeConstraints { make in diff --git a/suixinkan/UI/Wallet/PointsRedemptionViewController.swift b/suixinkan/UI/Wallet/PointsRedemptionViewController.swift index f61d723..2744f1c 100644 --- a/suixinkan/UI/Wallet/PointsRedemptionViewController.swift +++ b/suixinkan/UI/Wallet/PointsRedemptionViewController.swift @@ -19,6 +19,7 @@ final class PointsRedemptionViewController: BaseViewController, UITableViewDeleg private let contentStack = UIStackView() private let pointsCard = UIView() private let pointsValueLabel = UILabel() + private let summaryLoadingIndicator = UIActivityIndicatorView(style: .large) private let inputCard = UIView() private let pointsInputField = UITextField() private let withdrawablePointsLabel = UILabel() @@ -29,6 +30,7 @@ final class PointsRedemptionViewController: BaseViewController, UITableViewDeleg private let submitButton = AppButton(title: "立即兑换") private var dataSource: UITableViewDiffableDataSource! + private var tableHeightConstraint: Constraint? private var lastShownMessage: String? /// 初始化积分兑现页面。 @@ -47,13 +49,17 @@ final class PointsRedemptionViewController: BaseViewController, UITableViewDeleg } override func setupNavigationBar() { - title = "积分兑现" + let titleLabel = UILabel() + titleLabel.text = "积分兑现" + titleLabel.textColor = WalletTokens.text333 + titleLabel.font = UIFont.systemFont(ofSize: 18) + navigationItem.titleView = titleLabel } override func setupUI() { - view.backgroundColor = UIColor(hex: 0xF5F6F8) + view.backgroundColor = WalletTokens.pageBackground contentStack.axis = .vertical - contentStack.spacing = 16 + contentStack.spacing = WalletTokens.cardGap bottomBar.backgroundColor = .white view.addSubview(scrollView) @@ -72,21 +78,21 @@ final class PointsRedemptionViewController: BaseViewController, UITableViewDeleg make.leading.trailing.bottom.equalToSuperview() } submitButton.snp.makeConstraints { make in - make.top.equalToSuperview().offset(16) - make.leading.trailing.equalToSuperview().inset(16) + make.top.equalToSuperview().offset(WalletTokens.screenInset) + make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset) make.bottom.equalTo(view.safeAreaLayoutGuide).inset(22) } + submitButton.layer.cornerRadius = WalletTokens.bottomButtonRadius scrollView.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.bottom.equalTo(bottomBar.snp.top) } contentStack.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(16) + make.edges.equalToSuperview().inset(WalletTokens.screenInset) make.width.equalTo(scrollView).offset(-32) } tableView.snp.makeConstraints { make in - make.height.greaterThanOrEqualTo(260) - make.height.lessThanOrEqualTo(420) + tableHeightConstraint = make.height.equalTo(200).constraint } } @@ -97,7 +103,8 @@ final class PointsRedemptionViewController: BaseViewController, UITableViewDeleg pointsInputField.addTarget(self, action: #selector(pointsInputChanged), for: .editingChanged) refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged) submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside) - Task { await viewModel.loadInitial(api: walletAPI) } + Task { await viewModel.refreshOverview(api: walletAPI) } + Task { await viewModel.refreshWithdrawList(api: walletAPI) } } private func configureTable() { @@ -106,7 +113,7 @@ final class PointsRedemptionViewController: BaseViewController, UITableViewDeleg tableView.delegate = self tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 120 - tableView.refreshControl = refreshControl + scrollView.refreshControl = refreshControl tableView.register(PointWithdrawRecordCell.self, forCellReuseIdentifier: PointWithdrawRecordCell.reuseIdentifier) dataSource = UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item in let cell = tableView.dequeueReusableCell( @@ -121,47 +128,82 @@ final class PointsRedemptionViewController: BaseViewController, UITableViewDeleg private func applyState() { pointsValueLabel.text = "\(viewModel.withdrawnPoints)" withdrawablePointsLabel.text = "\(viewModel.withdrawnPoints)" + pointsValueLabel.isHidden = viewModel.overviewLoading + if viewModel.overviewLoading { + summaryLoadingIndicator.startAnimating() + } else { + summaryLoadingIndicator.stopAnimating() + } pointsInputField.text = viewModel.pointsInput submitButton.isEnabled = (Int(viewModel.pointsInput) ?? 0) > 0 - refreshControl.endRefreshing() + if !viewModel.withdrawRefreshing, !viewModel.overviewLoading { + refreshControl.endRefreshing() + } var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) snapshot.appendItems(viewModel.withdrawRecords, toSection: .main) - dataSource.apply(snapshot, animatingDifferences: true) - updateEmptyState() + dataSource.apply(snapshot, animatingDifferences: true) { [weak self] in + self?.updateTablePresentation() + } showMessageIfNeeded(viewModel.statusMessage) showMessageIfNeeded(viewModel.errorMessage) } - private func updateEmptyState() { - guard viewModel.withdrawRecords.isEmpty, !viewModel.withdrawLoading else { + private func updateTablePresentation() { + tableView.layoutIfNeeded() + let isEmpty = viewModel.withdrawRecords.isEmpty + updateLoadingFooter() + guard isEmpty, !viewModel.withdrawLoading else { tableView.backgroundView = nil + let minimumHeight: CGFloat = viewModel.withdrawLoading ? 56 : 44 + let height = max(minimumHeight, min(tableView.contentSize.height, 400)) + tableHeightConstraint?.update(offset: height) + tableView.isScrollEnabled = tableView.contentSize.height > 400 return } let label = UILabel() label.text = "暂无提现记录" - label.font = .systemFont(ofSize: 14) - label.textColor = UIColor(hex: 0xB3B8C2) + label.font = WalletTokens.bodyFont + label.textColor = WalletTokens.textDisabled label.textAlignment = .center tableView.backgroundView = label + tableHeightConstraint?.update(offset: 200) + tableView.isScrollEnabled = false + } + + private func updateLoadingFooter() { + guard viewModel.withdrawLoading else { + tableView.tableFooterView = UIView(frame: .zero) + return + } + let footer = UIView(frame: CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 56)) + let indicator = UIActivityIndicatorView(style: .medium) + indicator.color = WalletTokens.primary + indicator.startAnimating() + footer.addSubview(indicator) + indicator.snp.makeConstraints { make in make.center.equalToSuperview() } + tableView.tableFooterView = footer } private func makeSummaryCard() -> UIView { - pointsCard.backgroundColor = UIColor(hex: 0x1677FF) - pointsCard.layer.cornerRadius = 16 + pointsCard.backgroundColor = WalletTokens.summaryBackground + pointsCard.layer.cornerRadius = WalletTokens.contentRadius pointsCard.clipsToBounds = true let title = UILabel() title.text = "可提现积分" - title.font = .systemFont(ofSize: 14) + title.font = WalletTokens.bodyFont title.textColor = UIColor.white.withAlphaComponent(0.8) title.textAlignment = .center - pointsValueLabel.font = .systemFont(ofSize: 36, weight: .bold) + pointsValueLabel.font = WalletTokens.displayFont pointsValueLabel.textColor = .white pointsValueLabel.textAlignment = .center pointsCard.addSubview(title) pointsCard.addSubview(pointsValueLabel) + summaryLoadingIndicator.color = .white + summaryLoadingIndicator.hidesWhenStopped = true + pointsCard.addSubview(summaryLoadingIndicator) title.snp.makeConstraints { make in make.top.equalToSuperview().offset(16) make.leading.trailing.equalToSuperview().inset(24) @@ -171,35 +213,40 @@ final class PointsRedemptionViewController: BaseViewController, UITableViewDeleg make.leading.trailing.equalToSuperview().inset(24) make.bottom.equalToSuperview().inset(16) } + summaryLoadingIndicator.snp.makeConstraints { make in + make.center.equalTo(pointsValueLabel) + } return pointsCard } private func makeInputCard() -> UIView { inputCard.backgroundColor = .white - inputCard.layer.cornerRadius = 12 + inputCard.layer.cornerRadius = WalletTokens.cardRadius inputCard.clipsToBounds = true let title = makeTitle("积分提现", size: 18) + let titleSpacer = UIView() + titleSpacer.snp.makeConstraints { make in make.height.equalTo(WalletTokens.compactGap) } let row = UIStackView() row.axis = .horizontal row.spacing = 16 let label = makeBody("可提现积分", color: .black, weight: .medium) - withdrawablePointsLabel.font = .systemFont(ofSize: 14, weight: .medium) - withdrawablePointsLabel.textColor = UIColor(hex: 0xFF7B00) + withdrawablePointsLabel.font = WalletTokens.bodyMediumFont + withdrawablePointsLabel.textColor = WalletTokens.points row.addArrangedSubview(label) row.addArrangedSubview(withdrawablePointsLabel) let inputContainer = UIView() - inputContainer.layer.cornerRadius = 4 + inputContainer.layer.cornerRadius = WalletTokens.chipRadius inputContainer.layer.borderWidth = 1 - inputContainer.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor + inputContainer.layer.borderColor = WalletTokens.border.cgColor pointsInputField.placeholder = "请输入积分" pointsInputField.keyboardType = .numberPad - pointsInputField.font = .systemFont(ofSize: 14) + pointsInputField.font = WalletTokens.bodyFont let allButton = UIButton(type: .system) allButton.setTitle("全部提现", for: .normal) - allButton.setTitleColor(UIColor(hex: 0x0073FF), for: .normal) - allButton.titleLabel?.font = .systemFont(ofSize: 14) + allButton.setTitleColor(WalletTokens.primary, for: .normal) + allButton.titleLabel?.font = WalletTokens.bodyFont allButton.addTarget(self, action: #selector(withdrawAllTapped), for: .touchUpInside) inputContainer.addSubview(pointsInputField) inputContainer.addSubview(allButton) @@ -212,14 +259,14 @@ final class PointsRedemptionViewController: BaseViewController, UITableViewDeleg make.trailing.equalToSuperview().inset(12) make.centerY.equalToSuperview() } - inputContainer.snp.makeConstraints { make in make.height.equalTo(46) } + inputContainer.snp.makeConstraints { make in make.height.equalTo(WalletTokens.inputHeight) } - let ratio = makeBody("积分金额兑换比例12积分=1元人民币", color: UIColor(hex: 0xEF4444)) - ratio.font = .systemFont(ofSize: 12) + let ratio = makeBody("积分金额兑换比例12积分=1元人民币", color: WalletTokens.danger) + ratio.font = WalletTokens.captionFont - let stack = UIStackView(arrangedSubviews: [title, row, inputContainer, ratio]) + let stack = UIStackView(arrangedSubviews: [title, titleSpacer, row, inputContainer, ratio]) stack.axis = .vertical - stack.spacing = 12 + stack.spacing = WalletTokens.compactGap inputCard.addSubview(stack) stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) } return inputCard @@ -227,20 +274,21 @@ final class PointsRedemptionViewController: BaseViewController, UITableViewDeleg private func makeRecordCard() -> UIView { recordCard.backgroundColor = .white - recordCard.layer.cornerRadius = 12 + recordCard.layer.cornerRadius = WalletTokens.cardRadius recordCard.clipsToBounds = true let title = makeTitle("积分提现记录", size: 14) - title.textColor = UIColor(hex: 0x0073FF) + title.textColor = WalletTokens.primary + title.font = UIFont.systemFont(ofSize: 14, weight: .bold) title.textAlignment = .center recordCard.addSubview(title) recordCard.addSubview(tableView) title.snp.makeConstraints { make in - make.top.equalToSuperview().offset(16) - make.leading.trailing.equalToSuperview().inset(16) + make.top.equalToSuperview().offset(WalletTokens.cardPadding) + make.leading.trailing.equalToSuperview().inset(WalletTokens.cardPadding) } tableView.snp.makeConstraints { make in - make.top.equalTo(title.snp.bottom).offset(12) - make.leading.trailing.bottom.equalToSuperview().inset(16) + make.top.equalTo(title.snp.bottom).offset(WalletTokens.rowGap) + make.leading.trailing.bottom.equalToSuperview().inset(WalletTokens.cardPadding) } return recordCard } @@ -276,7 +324,8 @@ final class PointsRedemptionViewController: BaseViewController, UITableViewDeleg } @objc private func refreshPulled() { - Task { await viewModel.loadInitial(api: walletAPI) } + Task { await viewModel.refreshOverview(api: walletAPI) } + Task { await viewModel.refreshWithdrawList(api: walletAPI) } } @objc private func submitTapped() { @@ -301,9 +350,11 @@ private final class PointWithdrawRecordCell: UITableViewCell { private let amountLabel = UILabel() private let timeLabel = UILabel() private let progressView = UIProgressView(progressViewStyle: .bar) + private let percentLabel = UILabel() private let stepsStack = UIStackView() private let infoLabel = UILabel() private let statusLabel = UILabel() + private let remarkLabel = UILabel() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) @@ -329,41 +380,52 @@ private final class PointWithdrawRecordCell: UITableViewCell { progressView.progress = Float(progress) / 100.0 progressView.progressTintColor = style.progress progressView.isHidden = progress == 0 + percentLabel.isHidden = progress == 0 stepsStack.isHidden = progress == 0 + percentLabel.text = "\(progress)%" configureSteps(progress: progress, tint: style.progress) infoLabel.text = infoText(for: record) + remarkLabel.text = record.rejectReason.isEmpty ? "" : "备注信息:\(record.rejectReason)" + remarkLabel.isHidden = record.rejectReason.isEmpty + updateDynamicConstraints(progress: progress, showsRemark: !record.rejectReason.isEmpty) } private func setupUI() { selectionStyle = .none backgroundColor = .white cardView.backgroundColor = .white - cardView.layer.cornerRadius = 16 + cardView.layer.cornerRadius = WalletTokens.contentRadius cardView.layer.borderWidth = 1 - cardView.layer.borderColor = UIColor(hex: 0xF3F4F6).cgColor + cardView.layer.borderColor = WalletTokens.cardBorder.cgColor cardView.clipsToBounds = true - pointsLabel.font = .systemFont(ofSize: 18, weight: .medium) - pointsLabel.textColor = .black - amountLabel.font = .systemFont(ofSize: 18, weight: .medium) - amountLabel.textColor = UIColor(hex: 0xEF4444) + pointsLabel.font = WalletTokens.metricMediumFont + pointsLabel.textColor = WalletTokens.textPrimary + amountLabel.font = WalletTokens.metricMediumFont + amountLabel.textColor = WalletTokens.danger amountLabel.textAlignment = .right - timeLabel.font = .systemFont(ofSize: 14) - timeLabel.textColor = UIColor(hex: 0x7B8EAA) - progressView.trackTintColor = UIColor(hex: 0xF2F4F8) + timeLabel.font = WalletTokens.bodyFont + timeLabel.textColor = WalletTokens.textMuted + progressView.trackTintColor = WalletTokens.progressTrack progressView.layer.cornerRadius = 3 progressView.clipsToBounds = true + percentLabel.font = WalletTokens.bodyMediumFont + percentLabel.textColor = WalletTokens.textTertiary + percentLabel.textAlignment = .right stepsStack.axis = .horizontal stepsStack.distribution = .equalSpacing - statusLabel.font = .systemFont(ofSize: 12) + statusLabel.font = WalletTokens.captionFont statusLabel.textAlignment = .center - statusLabel.layer.cornerRadius = 4 + statusLabel.layer.cornerRadius = WalletTokens.chipRadius statusLabel.clipsToBounds = true - infoLabel.font = .systemFont(ofSize: 14) - infoLabel.textColor = UIColor(hex: 0x4B5563) + infoLabel.font = WalletTokens.bodyFont + infoLabel.textColor = WalletTokens.textSecondary infoLabel.numberOfLines = 0 + remarkLabel.font = WalletTokens.bodyFont + remarkLabel.textColor = WalletTokens.danger + remarkLabel.numberOfLines = 0 contentView.addSubview(cardView) - [pointsLabel, amountLabel, timeLabel, progressView, stepsStack, infoLabel, statusLabel].forEach(cardView.addSubview) + [pointsLabel, amountLabel, timeLabel, progressView, percentLabel, stepsStack, infoLabel, statusLabel, remarkLabel].forEach(cardView.addSubview) cardView.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 6, left: 0, bottom: 6, right: 0)) @@ -382,25 +444,47 @@ private final class PointWithdrawRecordCell: UITableViewCell { } progressView.snp.makeConstraints { make in make.top.equalTo(timeLabel.snp.bottom).offset(12) - make.leading.trailing.equalToSuperview().inset(16) - make.height.equalTo(6) + make.leading.equalToSuperview().offset(WalletTokens.cardPadding) + make.trailing.equalTo(percentLabel.snp.leading).offset(-WalletTokens.rowGap) + make.height.equalTo(WalletTokens.progressHeight) + } + percentLabel.snp.makeConstraints { make in + make.centerY.equalTo(progressView) + make.trailing.equalToSuperview().inset(WalletTokens.cardPadding) } stepsStack.snp.makeConstraints { make in make.top.equalTo(progressView.snp.bottom).offset(8) make.leading.trailing.equalToSuperview().inset(16) } - infoLabel.snp.makeConstraints { make in - make.top.equalTo(stepsStack.snp.bottom).offset(10) - make.leading.equalToSuperview().offset(16) - make.trailing.lessThanOrEqualTo(statusLabel.snp.leading).offset(-8) - make.bottom.equalToSuperview().inset(14) - } statusLabel.snp.makeConstraints { make in make.centerY.equalTo(infoLabel) make.trailing.equalToSuperview().inset(16) make.height.equalTo(22) make.width.greaterThanOrEqualTo(70) } + updateDynamicConstraints(progress: 0, showsRemark: false) + } + + private func updateDynamicConstraints(progress: Int, showsRemark: Bool) { + infoLabel.snp.remakeConstraints { make in + if progress > 0 { + make.top.equalTo(stepsStack.snp.bottom).offset(WalletTokens.compactGap) + } else { + make.top.equalTo(timeLabel.snp.bottom).offset(WalletTokens.compactGap) + } + make.leading.equalToSuperview().offset(WalletTokens.cardPadding) + make.trailing.lessThanOrEqualTo(statusLabel.snp.leading).offset(-WalletTokens.compactGap) + if !showsRemark { + make.bottom.equalToSuperview().inset(14) + } + } + remarkLabel.snp.remakeConstraints { make in + make.top.equalTo(infoLabel.snp.bottom).offset(WalletTokens.tinyGap) + make.leading.trailing.equalToSuperview().inset(WalletTokens.cardPadding) + if showsRemark { + make.bottom.equalToSuperview().inset(14) + } + } } private func configureSteps(progress: Int, tint: UIColor) { @@ -408,15 +492,10 @@ private final class PointWithdrawRecordCell: UITableViewCell { ["提交申请", "审核中", "打款中", "已完成"].enumerated().forEach { index, title in let label = UILabel() label.text = title - label.font = .systemFont(ofSize: 11) - label.textColor = progress >= Int((Float(index) / 3.0) * 100) ? tint : UIColor(hex: 0x7B8EAA) + label.font = WalletTokens.stepFont + label.textColor = progress >= Int((Float(index) / 3.0) * 100) ? tint : WalletTokens.textMuted stepsStack.addArrangedSubview(label) } - let percent = UILabel() - percent.text = "\(progress)%" - percent.font = .systemFont(ofSize: 12, weight: .medium) - percent.textColor = UIColor(hex: 0x9CA3AF) - stepsStack.addArrangedSubview(percent) } private func statusText(for status: Int) -> String { @@ -431,13 +510,13 @@ private final class PointWithdrawRecordCell: UITableViewCell { private func statusStyle(for status: Int) -> (background: UIColor, text: UIColor, progress: UIColor) { switch status { case 2: - (UIColor(hex: 0xF0FDF4), UIColor(hex: 0x22C55E), UIColor(hex: 0x1677FF)) + (WalletTokens.successBackground, WalletTokens.success, WalletTokens.summaryBackground) case 1: - (UIColor(hex: 0xFFF0E2), UIColor(hex: 0xFF7B00), UIColor(hex: 0x1677FF)) + (WalletTokens.pointsPendingBackground, WalletTokens.points, WalletTokens.summaryBackground) case 3: - (UIColor(hex: 0xFFE7E7), UIColor(hex: 0xEF4444), UIColor(hex: 0xFF4D4F)) + (WalletTokens.dangerBackground, WalletTokens.danger, WalletTokens.progressDanger) default: - (UIColor(hex: 0xE6F1FF), UIColor(hex: 0x1677FF), UIColor(hex: 0x1677FF)) + (WalletTokens.infoBackground, WalletTokens.summaryBackground, WalletTokens.summaryBackground) } } @@ -452,8 +531,7 @@ private final class PointWithdrawRecordCell: UITableViewCell { private func infoText(for record: PointWithdrawItem) -> String { switch record.status { case 3: - let remark = record.rejectReason.isEmpty ? "" : "\n备注信息:\(record.rejectReason)" - return "审核时间:\(record.reviewedAt.isEmpty ? "暂无" : record.reviewedAt)\(remark)" + return "审核时间:\(record.reviewedAt.isEmpty ? "暂无" : record.reviewedAt)" case 2: return "到账时间:\(record.paymentTime.isEmpty ? "暂无" : record.paymentTime)" default: diff --git a/suixinkan/UI/Wallet/WalletCells.swift b/suixinkan/UI/Wallet/WalletCells.swift index 2b74f37..d90ffe2 100644 --- a/suixinkan/UI/Wallet/WalletCells.swift +++ b/suixinkan/UI/Wallet/WalletCells.swift @@ -41,8 +41,17 @@ final class WalletWithdrawRecordCell: UITableViewCell { configureSteps(progress: progress(for: record.settlementStatus), tint: style.progress) progressView.isHidden = record.settlementStatus == 50 stepsStack.isHidden = record.settlementStatus == 50 - infoLabel.text = infoText(for: record) - infoLabel.isHidden = infoLabel.text?.isEmpty ?? true + infoLabel.attributedText = infoText(for: record) + infoLabel.isHidden = infoLabel.attributedText?.length == 0 + infoLabel.snp.remakeConstraints { make in + if record.settlementStatus == 50 { + make.top.equalTo(timeLabel.snp.bottom).offset(WalletTokens.compactGap) + } else { + make.top.equalTo(stepsStack.snp.bottom).offset(10) + } + make.leading.trailing.equalToSuperview().inset(WalletTokens.cardPadding) + make.bottom.equalToSuperview().inset(14) + } } private func setupUI() { @@ -51,23 +60,23 @@ final class WalletWithdrawRecordCell: UITableViewCell { contentView.backgroundColor = .white cardView.backgroundColor = .white - cardView.layer.cornerRadius = 16 + cardView.layer.cornerRadius = WalletTokens.contentRadius cardView.layer.borderWidth = 1 - cardView.layer.borderColor = UIColor(hex: 0xF3F4F6).cgColor + cardView.layer.borderColor = WalletTokens.cardBorder.cgColor cardView.clipsToBounds = true - amountLabel.font = .systemFont(ofSize: 18, weight: .bold) - amountLabel.textColor = .black + amountLabel.font = WalletTokens.metricBoldFont + amountLabel.textColor = WalletTokens.textPrimary - statusLabel.font = .systemFont(ofSize: 12, weight: .medium) + statusLabel.font = WalletTokens.captionMediumFont statusLabel.textAlignment = .center - statusLabel.layer.cornerRadius = 4 + statusLabel.layer.cornerRadius = WalletTokens.chipRadius statusLabel.clipsToBounds = true - timeLabel.font = .systemFont(ofSize: 14) - timeLabel.textColor = UIColor(hex: 0x7B8EAA) + timeLabel.font = WalletTokens.bodyFont + timeLabel.textColor = WalletTokens.textMuted - progressView.trackTintColor = UIColor(hex: 0xF2F4F8) + progressView.trackTintColor = WalletTokens.progressTrack progressView.layer.cornerRadius = 3 progressView.clipsToBounds = true @@ -75,8 +84,8 @@ final class WalletWithdrawRecordCell: UITableViewCell { stepsStack.distribution = .equalSpacing stepsStack.alignment = .center - infoLabel.font = .systemFont(ofSize: 14) - infoLabel.textColor = UIColor(hex: 0x4B5563) + infoLabel.font = WalletTokens.bodyFont + infoLabel.textColor = WalletTokens.textSecondary infoLabel.numberOfLines = 0 contentView.addSubview(cardView) @@ -120,14 +129,14 @@ final class WalletWithdrawRecordCell: UITableViewCell { ["提交申请", "审核中", "打款中", "已完成"].enumerated().forEach { index, title in let label = UILabel() label.text = title - label.font = .systemFont(ofSize: 11) + label.font = WalletTokens.stepFont let threshold = Float(index) / 3.0 - label.textColor = progress >= threshold ? tint : UIColor(hex: 0xB3B8C2) + label.textColor = progress >= threshold ? tint : WalletTokens.textDisabled stepsStack.addArrangedSubview(label) } let percent = UILabel() percent.text = "\(Int(progress * 100))%" - percent.font = .systemFont(ofSize: 12, weight: .medium) + percent.font = WalletTokens.captionMediumFont percent.textColor = tint stepsStack.addArrangedSubview(percent) } @@ -135,13 +144,13 @@ final class WalletWithdrawRecordCell: UITableViewCell { private func statusStyle(for status: Int) -> (background: UIColor, text: UIColor, progress: UIColor) { switch status { case 50, 20: - (UIColor(hex: 0xEBFFF5), UIColor(hex: 0x16C26D), UIColor(hex: 0x1677FF)) + (WalletTokens.settlementSuccessBackground, WalletTokens.settlementSuccess, WalletTokens.summaryBackground) case 60, 30: - (UIColor(hex: 0xFFF1F0), UIColor(hex: 0xFF4D4F), UIColor(hex: 0xFF4D4F)) + (WalletTokens.settlementDangerBackground, WalletTokens.progressDanger, WalletTokens.progressDanger) case 40: - (UIColor(hex: 0xFFF3E8), UIColor(hex: 0xFF8A00), UIColor(hex: 0x1677FF)) + (WalletTokens.warningBackground, WalletTokens.warning, WalletTokens.summaryBackground) default: - (UIColor(hex: 0xE6F1FF), UIColor(hex: 0x1677FF), UIColor(hex: 0x1677FF)) + (WalletTokens.infoBackground, WalletTokens.summaryBackground, WalletTokens.summaryBackground) } } @@ -156,18 +165,34 @@ final class WalletWithdrawRecordCell: UITableViewCell { } } - private func infoText(for record: WalletWithdrawItem) -> String { + private func infoText(for record: WalletWithdrawItem) -> NSAttributedString { + let result = NSMutableAttributedString() + let normal: [NSAttributedString.Key: Any] = [ + .font: WalletTokens.bodyFont, + .foregroundColor: WalletTokens.textSecondary, + ] + let success: [NSAttributedString.Key: Any] = [ + .font: WalletTokens.bodyFont, + .foregroundColor: WalletTokens.settlementSuccess, + ] + let danger: [NSAttributedString.Key: Any] = [ + .font: WalletTokens.bodyFont, + .foregroundColor: WalletTokens.progressDanger, + ] if (record.settlementStatus == 10 || record.settlementStatus == 40), !record.expectedAt.isEmpty { - return "预计到账时间:\(record.expectedAt)" + result.append(NSAttributedString(string: "预计到账时间:\(record.expectedAt)", attributes: normal)) } if (record.settlementStatus == 60 || record.settlementStatus == 30), !record.auditTime.isEmpty { - let remark = record.auditRemark.isEmpty ? "" : "\n备注信息:\(record.auditRemark)" - return "审核时间:\(record.auditTime)\(remark)" + result.append(NSAttributedString(string: "审核时间:\(record.auditTime)", attributes: normal)) + } + if (record.settlementStatus == 60 || record.settlementStatus == 30), !record.auditRemark.isEmpty { + if result.length > 0 { result.append(NSAttributedString(string: "\n")) } + result.append(NSAttributedString(string: "备注信息:\(record.auditRemark)", attributes: danger)) } if record.settlementStatus == 50, !record.expectedAt.isEmpty { - return "到账时间:\(record.expectedAt)" + result.append(NSAttributedString(string: "到账时间:\(record.expectedAt)", attributes: success)) } - return "" + return result } } @@ -199,13 +224,13 @@ final class WalletTransactionHeaderCell: UITableViewCell { private func setupUI() { selectionStyle = .none backgroundColor = .white - dateLabel.font = .systemFont(ofSize: 14) - dateLabel.textColor = UIColor(hex: 0x9CA3AF) - incomeLabel.font = .systemFont(ofSize: 14) - incomeLabel.textColor = UIColor(hex: 0x4B5563) + dateLabel.font = WalletTokens.bodyFont + dateLabel.textColor = WalletTokens.textTertiary + incomeLabel.font = WalletTokens.bodyFont + incomeLabel.textColor = WalletTokens.textSecondary incomeLabel.textAlignment = .right - pointsLabel.font = .systemFont(ofSize: 14) - pointsLabel.textColor = UIColor(hex: 0xFF7B00) + pointsLabel.font = WalletTokens.bodyFont + pointsLabel.textColor = WalletTokens.points pointsLabel.textAlignment = .right contentView.addSubview(dateLabel) @@ -277,27 +302,27 @@ final class WalletTransactionRecordCell: UITableViewCell { selectionStyle = .none backgroundColor = .white cardView.backgroundColor = .white - cardView.layer.cornerRadius = 12 + cardView.layer.cornerRadius = WalletTokens.cardRadius cardView.layer.borderWidth = 1 - cardView.layer.borderColor = UIColor(hex: 0xF3F4F6).cgColor + cardView.layer.borderColor = WalletTokens.cardBorder.cgColor cardView.clipsToBounds = true - amountLabel.font = .systemFont(ofSize: 18, weight: .medium) - amountLabel.textColor = .black - pointsLabel.font = .systemFont(ofSize: 18, weight: .medium) - pointsLabel.textColor = UIColor(hex: 0xFF7B00) + amountLabel.font = WalletTokens.metricMediumFont + amountLabel.textColor = WalletTokens.textPrimary + pointsLabel.font = WalletTokens.metricMediumFont + pointsLabel.textColor = WalletTokens.points pointsLabel.textAlignment = .right - typeLabel.font = .systemFont(ofSize: 14) - typeLabel.textColor = UIColor(hex: 0x9CA3AF) - orderLabel.font = .systemFont(ofSize: 12) + typeLabel.font = WalletTokens.bodyFont + typeLabel.textColor = WalletTokens.textTertiary + orderLabel.font = WalletTokens.captionFont orderLabel.textAlignment = .center - orderLabel.layer.cornerRadius = 4 + orderLabel.layer.cornerRadius = WalletTokens.chipRadius orderLabel.clipsToBounds = true - timeLabel.font = .systemFont(ofSize: 14) - timeLabel.textColor = UIColor(hex: 0x4B5563) - withdrawLabel.font = .systemFont(ofSize: 12) + timeLabel.font = WalletTokens.bodyFont + timeLabel.textColor = WalletTokens.textSecondary + withdrawLabel.font = WalletTokens.captionFont withdrawLabel.textAlignment = .center - withdrawLabel.layer.cornerRadius = 4 + withdrawLabel.layer.cornerRadius = WalletTokens.chipRadius withdrawLabel.clipsToBounds = true contentView.addSubview(cardView) @@ -340,9 +365,34 @@ final class WalletTransactionRecordCell: UITableViewCell { private func labelStyle(for label: String) -> (background: UIColor, text: UIColor, orderBackground: UIColor, orderText: UIColor) { switch label { case "可提现": - return (UIColor(hex: 0xF0FDF4), UIColor(hex: 0x22C55E), UIColor(hex: 0xEFF6FF), UIColor(hex: 0x0073FF)) + return (WalletTokens.successBackground, WalletTokens.success, WalletTokens.orderBackground, WalletTokens.primary) default: - return (UIColor(hex: 0xF4F4F4), UIColor(hex: 0x7B8EAA), UIColor(hex: 0xF4F4F4), UIColor(hex: 0x7B8EAA)) + return (WalletTokens.neutralChipBackground, WalletTokens.textMuted, WalletTokens.neutralChipBackground, WalletTokens.textMuted) } } } + +/// 钱包列表分页加载 Cell,展示 Android 同款蓝色加载指示器。 +final class WalletLoadingCell: UITableViewCell { + static let reuseIdentifier = "WalletLoadingCell" + + private let indicator = UIActivityIndicatorView(style: .medium) + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = .white + indicator.color = WalletTokens.summaryBackground + indicator.startAnimating() + contentView.addSubview(indicator) + indicator.snp.makeConstraints { make in + make.center.equalToSuperview() + make.top.bottom.equalToSuperview().inset(WalletTokens.cardPadding) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/suixinkan/UI/Wallet/WalletViewController.swift b/suixinkan/UI/Wallet/WalletViewController.swift index 5cd325b..5a62258 100644 --- a/suixinkan/UI/Wallet/WalletViewController.swift +++ b/suixinkan/UI/Wallet/WalletViewController.swift @@ -16,6 +16,7 @@ final class WalletViewController: BaseViewController, UITableViewDelegate { case withdraw(WalletWithdrawItem) case transactionHeader(EarningDetailGroup) case transaction(EarningDetailItem) + case loading } private let viewModel: WalletViewModel @@ -27,6 +28,7 @@ final class WalletViewController: BaseViewController, UITableViewDelegate { private let tabStack = UIStackView() private let withdrawTabButton = UIButton(type: .system) private let transactionTabButton = UIButton(type: .system) + private let tabIndicator = UIView() private let filterPanel = WalletFilterPanelView() private let tableView = UITableView(frame: .zero, style: .plain) private let refreshControl = UIRefreshControl() @@ -54,24 +56,30 @@ final class WalletViewController: BaseViewController, UITableViewDelegate { } override func setupNavigationBar() { - title = "我的钱包" + let titleLabel = UILabel() + titleLabel.text = "我的钱包" + titleLabel.textColor = WalletTokens.text333 + titleLabel.font = UIFont.systemFont(ofSize: 18) + navigationItem.titleView = titleLabel } override func setupUI() { - view.backgroundColor = UIColor(hex: 0xF5F6F8) + view.backgroundColor = WalletTokens.pageBackground contentCard.backgroundColor = .white - contentCard.layer.cornerRadius = 16 + contentCard.layer.cornerRadius = WalletTokens.contentRadius contentCard.clipsToBounds = true tabStack.axis = .horizontal tabStack.distribution = .fillEqually [withdrawTabButton, transactionTabButton].forEach { - $0.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold) - $0.setTitleColor(UIColor(hex: 0x666666), for: .normal) + $0.titleLabel?.font = WalletTokens.tabSelectedFont + $0.setTitleColor(WalletTokens.tabNormal, for: .normal) tabStack.addArrangedSubview($0) } withdrawTabButton.setTitle("提现记录", for: .normal) transactionTabButton.setTitle("收益明细", for: .normal) + tabIndicator.backgroundColor = WalletTokens.summaryBackground + tabIndicator.layer.cornerRadius = 1.5 tableView.backgroundColor = .white tableView.separatorStyle = .none @@ -79,15 +87,19 @@ final class WalletViewController: BaseViewController, UITableViewDelegate { tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 120 tableView.refreshControl = refreshControl + tableView.contentInset = UIEdgeInsets(top: WalletTokens.rowGap, left: 0, bottom: WalletTokens.rowGap, right: 0) + tableView.verticalScrollIndicatorInsets = tableView.contentInset tableView.register(WalletWithdrawRecordCell.self, forCellReuseIdentifier: WalletWithdrawRecordCell.reuseIdentifier) tableView.register(WalletTransactionHeaderCell.self, forCellReuseIdentifier: WalletTransactionHeaderCell.reuseIdentifier) tableView.register(WalletTransactionRecordCell.self, forCellReuseIdentifier: WalletTransactionRecordCell.reuseIdentifier) + tableView.register(WalletLoadingCell.self, forCellReuseIdentifier: WalletLoadingCell.reuseIdentifier) bottomBar.backgroundColor = .white view.addSubview(summaryCard) view.addSubview(contentCard) contentCard.addSubview(tabStack) + contentCard.addSubview(tabIndicator) contentCard.addSubview(filterPanel) contentCard.addSubview(tableView) view.addSubview(bottomBar) @@ -98,32 +110,41 @@ final class WalletViewController: BaseViewController, UITableViewDelegate { override func setupConstraints() { summaryCard.snp.makeConstraints { make in - make.top.equalTo(view.safeAreaLayoutGuide).offset(16) - make.leading.trailing.equalToSuperview().inset(16) + make.top.equalTo(view.safeAreaLayoutGuide).offset(WalletTokens.screenInset) + make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset) } bottomBar.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } withdrawButton.snp.makeConstraints { make in - make.top.equalToSuperview().offset(16) - make.leading.trailing.equalToSuperview().inset(16) + make.top.equalToSuperview().offset(WalletTokens.screenInset) + make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset) make.bottom.equalTo(view.safeAreaLayoutGuide).inset(22) } + withdrawButton.layer.cornerRadius = WalletTokens.bottomButtonRadius contentCard.snp.makeConstraints { make in - make.top.equalTo(summaryCard.snp.bottom).offset(16) - make.leading.trailing.equalToSuperview().inset(16) - make.bottom.equalTo(bottomBar.snp.top).offset(-16) + make.top.equalTo(summaryCard.snp.bottom).offset(WalletTokens.cardGap) + make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset) + make.bottom.equalTo(bottomBar.snp.top).offset(-WalletTokens.cardGap) } tabStack.snp.makeConstraints { make in - make.top.leading.trailing.equalToSuperview() + make.top.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(32) make.height.equalTo(50) } + tabIndicator.snp.makeConstraints { make in + make.width.equalTo(24) + make.height.equalTo(3) + make.bottom.equalTo(tabStack) + make.centerX.equalTo(withdrawTabButton) + } filterPanel.snp.makeConstraints { make in make.top.equalTo(tabStack.snp.bottom) - make.leading.trailing.equalToSuperview().inset(16) + make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset) + make.height.equalTo(0) } tableView.snp.makeConstraints { make in - make.top.equalTo(filterPanel.snp.bottom).offset(4) + make.top.equalTo(filterPanel.snp.bottom).offset(WalletTokens.rowGap) make.leading.trailing.bottom.equalToSuperview() } } @@ -139,13 +160,17 @@ final class WalletViewController: BaseViewController, UITableViewDelegate { } withdrawTabButton.addTarget(self, action: #selector(withdrawTabTapped), for: .touchUpInside) transactionTabButton.addTarget(self, action: #selector(transactionTabTapped), for: .touchUpInside) - filterPanel.onFilterTap = { [weak self] in - self?.presentFilterMenu() + filterPanel.onFilterSelected = { [weak self] filter in + guard let self else { return } + Task { await self.viewModel.selectFilter(filter, api: self.walletAPI) } } refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged) withdrawButton.addTarget(self, action: #selector(withdrawTapped), for: .touchUpInside) - Task { await viewModel.loadInitial(api: walletAPI) } + Task { await viewModel.refreshSummary(api: walletAPI) } + Task { await viewModel.refreshWithdraw(api: walletAPI) } + Task { await viewModel.refreshTransaction(api: walletAPI) } + Task { await viewModel.refreshPoints(api: walletAPI) } } private func configureDataSource() { @@ -172,6 +197,11 @@ final class WalletViewController: BaseViewController, UITableViewDelegate { ) as? WalletTransactionRecordCell cell?.apply(record) return cell ?? UITableViewCell() + case .loading: + return tableView.dequeueReusableCell( + withIdentifier: WalletLoadingCell.reuseIdentifier, + for: indexPath + ) } } } @@ -184,9 +214,15 @@ final class WalletViewController: BaseViewController, UITableViewDelegate { points: viewModel.availablePoints ) applyTabAppearance() - filterPanel.isHidden = viewModel.selectedTab != .transaction + updateContentLayout() filterPanel.apply(filter: viewModel.filter, totalIncome: viewModel.totalIncomeLabel, totalPoints: viewModel.totalPointsLabel) - refreshControl.endRefreshing() + let isRefreshing = switch viewModel.selectedTab { + case .withdraw: viewModel.withdrawRefreshing + case .transaction: viewModel.transactionRefreshing + } + if !isRefreshing { + refreshControl.endRefreshing() + } applySnapshot() if let message = viewModel.errorMessage, message != lastShownMessage { lastShownMessage = message @@ -195,12 +231,35 @@ final class WalletViewController: BaseViewController, UITableViewDelegate { } private func applyTabAppearance() { - let selectedColor = UIColor(hex: 0x1677FF) - let normalColor = UIColor(hex: 0x666666) + let selectedColor = WalletTokens.summaryBackground + let normalColor = WalletTokens.tabNormal withdrawTabButton.setTitleColor(viewModel.selectedTab == .withdraw ? selectedColor : normalColor, for: .normal) transactionTabButton.setTitleColor(viewModel.selectedTab == .transaction ? selectedColor : normalColor, for: .normal) - withdrawTabButton.titleLabel?.font = .systemFont(ofSize: 16, weight: viewModel.selectedTab == .withdraw ? .semibold : .regular) - transactionTabButton.titleLabel?.font = .systemFont(ofSize: 16, weight: viewModel.selectedTab == .transaction ? .semibold : .regular) + withdrawTabButton.titleLabel?.font = viewModel.selectedTab == .withdraw ? WalletTokens.tabSelectedFont : WalletTokens.tabFont + transactionTabButton.titleLabel?.font = viewModel.selectedTab == .transaction ? WalletTokens.tabSelectedFont : WalletTokens.tabFont + tabIndicator.snp.remakeConstraints { make in + make.width.equalTo(24) + make.height.equalTo(3) + make.bottom.equalTo(tabStack) + make.centerX.equalTo(viewModel.selectedTab == .withdraw ? withdrawTabButton : transactionTabButton) + } + UIView.animate(withDuration: 0.2) { + self.contentCard.layoutIfNeeded() + } + } + + private func updateContentLayout() { + let showsFilter = viewModel.selectedTab == .transaction + filterPanel.isHidden = !showsFilter + filterPanel.snp.remakeConstraints { make in + make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset) + if showsFilter { + make.top.equalTo(tabStack.snp.bottom).offset(WalletTokens.rowGap) + } else { + make.top.equalTo(tabStack.snp.bottom) + make.height.equalTo(0) + } + } } private func applySnapshot() { @@ -208,6 +267,9 @@ final class WalletViewController: BaseViewController, UITableViewDelegate { snapshot.appendSections([.main]) if viewModel.selectedTab == .withdraw { snapshot.appendItems(viewModel.withdrawRecords.map(Item.withdraw), toSection: .main) + if viewModel.withdrawLoading { + snapshot.appendItems([.loading], toSection: .main) + } } else { let items = viewModel.transactionEntries.map { entry -> Item in switch entry { @@ -216,6 +278,9 @@ final class WalletViewController: BaseViewController, UITableViewDelegate { } } snapshot.appendItems(items, toSection: .main) + if viewModel.transactionLoading { + snapshot.appendItems([.loading], toSection: .main) + } } dataSource.apply(snapshot, animatingDifferences: true) updateEmptyState() @@ -236,24 +301,12 @@ final class WalletViewController: BaseViewController, UITableViewDelegate { } let label = UILabel() label.text = emptyText - label.textColor = UIColor(hex: 0xB3B8C2) - label.font = .systemFont(ofSize: 14) + label.textColor = WalletTokens.textDisabled + label.font = WalletTokens.bodyFont label.textAlignment = .center tableView.backgroundView = label } - private func presentFilterMenu() { - let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) - WalletFilter.allCases.forEach { filter in - alert.addAction(UIAlertAction(title: filter.title, style: .default) { [weak self] _ in - guard let self else { return } - Task { await self.viewModel.selectFilter(filter, api: self.walletAPI) } - }) - } - alert.addAction(UIAlertAction(title: "取消", style: .cancel)) - present(alert, animated: true) - } - private func openPointsRedemption() { navigationController?.pushViewController(PointsRedemptionViewController(), animated: true) } @@ -270,9 +323,7 @@ final class WalletViewController: BaseViewController, UITableViewDelegate { Task { switch viewModel.selectedTab { case .withdraw: - await viewModel.refreshSummary(api: walletAPI) await viewModel.refreshWithdraw(api: walletAPI) - await viewModel.refreshPoints(api: walletAPI) case .transaction: await viewModel.refreshTransaction(api: walletAPI) } @@ -363,17 +414,17 @@ private final class WalletSummaryCardView: UIView { } private func setupUI() { - backgroundColor = UIColor(hex: 0x1677FF) - layer.cornerRadius = 24 + backgroundColor = WalletTokens.summaryBackground + layer.cornerRadius = WalletTokens.summaryRadius clipsToBounds = true withdrawableTitle.text = "可提现金额" withdrawableTitle.textColor = UIColor.white.withAlphaComponent(0.8) - withdrawableTitle.font = .systemFont(ofSize: 14) + withdrawableTitle.font = WalletTokens.bodyFont withdrawableTitle.textAlignment = .center withdrawableLabel.textColor = .white - withdrawableLabel.font = .systemFont(ofSize: 36, weight: .bold) + withdrawableLabel.font = WalletTokens.displayFont withdrawableLabel.textAlignment = .center withdrawableLabel.adjustsFontSizeToFitWidth = true withdrawableLabel.minimumScaleFactor = 0.7 @@ -388,17 +439,17 @@ private final class WalletSummaryCardView: UIView { addSubview(metricsStack) withdrawableTitle.snp.makeConstraints { make in - make.top.equalToSuperview().offset(16) - make.leading.trailing.equalToSuperview().inset(24) + make.top.equalToSuperview().offset(WalletTokens.cardPadding) + make.leading.trailing.equalToSuperview().inset(WalletTokens.summaryHorizontalPadding) } withdrawableLabel.snp.makeConstraints { make in - make.top.equalTo(withdrawableTitle.snp.bottom).offset(4) - make.leading.trailing.equalToSuperview().inset(24) + make.top.equalTo(withdrawableTitle.snp.bottom).offset(WalletTokens.tinyGap) + make.leading.trailing.equalToSuperview().inset(WalletTokens.summaryHorizontalPadding) } metricsStack.snp.makeConstraints { make in make.top.equalTo(withdrawableLabel.snp.bottom).offset(18) - make.leading.trailing.equalToSuperview().inset(12) - make.bottom.equalToSuperview().inset(16) + make.leading.trailing.equalToSuperview().inset(WalletTokens.summaryHorizontalPadding) + make.bottom.equalToSuperview().inset(WalletTokens.cardPadding) } } @@ -432,11 +483,11 @@ private final class WalletSummaryMetricView: UIControl { private func setupUI() { titleLabel.textColor = UIColor.white.withAlphaComponent(0.8) - titleLabel.font = .systemFont(ofSize: 14) + titleLabel.font = WalletTokens.bodyFont titleLabel.textAlignment = .center valueLabel.textColor = .white - valueLabel.font = .systemFont(ofSize: 14, weight: .medium) + valueLabel.font = WalletTokens.bodyMediumFont valueLabel.textAlignment = .center valueLabel.adjustsFontSizeToFitWidth = true valueLabel.minimumScaleFactor = 0.75 @@ -469,7 +520,7 @@ private final class WalletSummaryMetricView: UIControl { /// 收益明细筛选和汇总面板。 private final class WalletFilterPanelView: UIView { - var onFilterTap: (() -> Void)? + var onFilterSelected: ((WalletFilter) -> Void)? private let filterButton = UIButton(type: .system) private let totalTitleLabel = UILabel() @@ -487,30 +538,40 @@ private final class WalletFilterPanelView: UIView { } func apply(filter: WalletFilter, totalIncome: String, totalPoints: Int) { - filterButton.setTitle("\(filter.title) ▾", for: .normal) + filterButton.configuration?.title = filter.title + configureMenu(selected: filter) totalLabel.text = totalIncome.isEmpty ? "¥ 0.00" : totalIncome pointsLabel.text = "积分+\(totalPoints)" } private func setupUI() { - backgroundColor = UIColor(hex: 0xF6F6F6) - layer.cornerRadius = 16 + backgroundColor = WalletTokens.filterBackground + layer.cornerRadius = WalletTokens.contentRadius clipsToBounds = true filterButton.setTitleColor(UIColor(hex: 0x111827), for: .normal) - filterButton.titleLabel?.font = .systemFont(ofSize: 14) + filterButton.titleLabel?.font = WalletTokens.bodyFont filterButton.contentHorizontalAlignment = .left - filterButton.addTarget(self, action: #selector(filterTapped), for: .touchUpInside) + filterButton.backgroundColor = WalletTokens.inputFocusedBackground + filterButton.layer.cornerRadius = WalletTokens.contentRadius + filterButton.showsMenuAsPrimaryAction = true + filterButton.changesSelectionAsPrimaryAction = false + filterButton.configuration = .plain() + filterButton.configuration?.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 12) + filterButton.configuration?.image = UIImage(systemName: "chevron.down") + filterButton.configuration?.imagePlacement = .trailing + filterButton.configuration?.imagePadding = 8 + filterButton.tintColor = WalletTokens.text333 totalTitleLabel.text = "总收益:" - totalTitleLabel.font = .systemFont(ofSize: 14, weight: .medium) + totalTitleLabel.font = WalletTokens.bodyMediumFont totalTitleLabel.textColor = .black - totalLabel.font = .systemFont(ofSize: 14, weight: .medium) - totalLabel.textColor = UIColor(hex: 0x0073FF) + totalLabel.font = WalletTokens.bodyMediumFont + totalLabel.textColor = WalletTokens.primary - pointsLabel.font = .systemFont(ofSize: 14, weight: .medium) - pointsLabel.textColor = UIColor(hex: 0xFF7B00) + pointsLabel.font = WalletTokens.bodyMediumFont + pointsLabel.textColor = WalletTokens.points pointsLabel.textAlignment = .right addSubview(filterButton) @@ -519,15 +580,14 @@ private final class WalletFilterPanelView: UIView { addSubview(pointsLabel) filterButton.snp.makeConstraints { make in - make.top.equalToSuperview().offset(8) - make.leading.equalToSuperview().offset(16) - make.width.lessThanOrEqualTo(150) - make.height.equalTo(32) + make.top.leading.equalToSuperview() + make.width.lessThanOrEqualTo(WalletTokens.filterFieldMaxWidth) + make.height.equalTo(WalletTokens.filterFieldHeight) } totalTitleLabel.snp.makeConstraints { make in - make.top.equalTo(filterButton.snp.bottom).offset(4) - make.leading.equalToSuperview().offset(16) - make.bottom.equalToSuperview().inset(12) + make.top.equalTo(filterButton.snp.bottom) + make.leading.equalToSuperview().offset(WalletTokens.cardPadding) + make.bottom.equalToSuperview().inset(WalletTokens.rowGap) } totalLabel.snp.makeConstraints { make in make.centerY.equalTo(totalTitleLabel) @@ -540,7 +600,15 @@ private final class WalletFilterPanelView: UIView { } } - @objc private func filterTapped() { - onFilterTap?() + private func configureMenu(selected: WalletFilter) { + let actions = WalletFilter.allCases.map { [weak self] filter in + UIAction( + title: filter.title, + state: filter == selected ? .on : .off + ) { _ in + self?.onFilterSelected?(filter) + } + } + filterButton.menu = UIMenu(children: actions) } } diff --git a/suixinkan/UI/Wallet/WithdrawViewController.swift b/suixinkan/UI/Wallet/WithdrawViewController.swift index 5d426fd..e8491b1 100644 --- a/suixinkan/UI/Wallet/WithdrawViewController.swift +++ b/suixinkan/UI/Wallet/WithdrawViewController.swift @@ -51,14 +51,22 @@ final class WithdrawViewController: BaseViewController { } override func setupNavigationBar() { - title = "提现设置" + let titleLabel = UILabel() + titleLabel.text = "提现设置" + titleLabel.textColor = WalletTokens.text333 + titleLabel.font = UIFont.systemFont(ofSize: 18) + navigationItem.titleView = titleLabel } override func setupUI() { - view.backgroundColor = UIColor(hex: 0xF5F6F8) + view.backgroundColor = WalletTokens.withdrawPageBackground contentStack.axis = .vertical - contentStack.spacing = 16 + contentStack.spacing = WalletTokens.cardGap + contentStack.backgroundColor = .white + contentStack.layer.cornerRadius = WalletTokens.cardRadius + contentStack.clipsToBounds = true bottomBar.backgroundColor = .white + bottomBar.isHidden = true view.addSubview(scrollView) scrollView.addSubview(contentStack) @@ -76,16 +84,16 @@ final class WithdrawViewController: BaseViewController { make.leading.trailing.bottom.equalToSuperview() } submitButton.snp.makeConstraints { make in - make.top.equalToSuperview().offset(16) - make.leading.trailing.equalToSuperview().inset(16) - make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16) + make.top.equalToSuperview().offset(WalletTokens.screenInset) + make.leading.trailing.equalToSuperview().inset(WalletTokens.screenInset) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(WalletTokens.screenInset) } scrollView.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.bottom.equalTo(bottomBar.snp.top) } contentStack.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(16) + make.edges.equalToSuperview().inset(WalletTokens.screenInset) make.width.equalTo(scrollView).offset(-32) } } @@ -98,7 +106,15 @@ final class WithdrawViewController: BaseViewController { codeField.addTarget(self, action: #selector(codeChanged), for: .editingChanged) codeButton.addTarget(self, action: #selector(sendCodeTapped), for: .touchUpInside) submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside) - Task { await viewModel.load(api: walletAPI) } + Task { + showLoading() + await viewModel.load(api: walletAPI) + hideLoading() + if viewModel.withdrawInfo == nil, let message = viewModel.errorMessage { + navigationController?.popViewController(animated: true) + (navigationController?.topViewController as? BaseViewController)?.showToast(message) + } + } } private func applyState() { @@ -116,9 +132,24 @@ final class WithdrawViewController: BaseViewController { codeButton.setTitle(viewModel.countdown > 0 ? "\(viewModel.countdown)s" : "获取验证码", for: .normal) codeButton.isEnabled = viewModel.countdown == 0 submitButton.isEnabled = viewModel.canSubmit + updateBottomBarVisibility(hasContent: viewModel.withdrawInfo != nil && !viewModel.loading) if viewModel.countdown > 0 { startCountdownTimerIfNeeded() } showMessageIfNeeded(viewModel.statusMessage) - showMessageIfNeeded(viewModel.errorMessage) + if viewModel.withdrawInfo != nil { + showMessageIfNeeded(viewModel.errorMessage) + } + } + + private func updateBottomBarVisibility(hasContent: Bool) { + bottomBar.isHidden = !hasContent + scrollView.snp.remakeConstraints { make in + make.top.leading.trailing.equalToSuperview() + if hasContent { + make.bottom.equalTo(bottomBar.snp.top) + } else { + make.bottom.equalToSuperview() + } + } } private func makeAmountCard() -> UIView { @@ -129,19 +160,19 @@ final class WithdrawViewController: BaseViewController { row.alignment = .center row.spacing = 16 let withdrawableTitle = makeBodyLabel("可提现金额", color: .black, weight: .medium) - withdrawableLabel.font = .systemFont(ofSize: 14, weight: .medium) - withdrawableLabel.textColor = UIColor(hex: 0x0073FF) + withdrawableLabel.font = WalletTokens.bodyMediumFont + withdrawableLabel.textColor = WalletTokens.primary row.addArrangedSubview(withdrawableTitle) row.addArrangedSubview(withdrawableLabel) let amountContainer = borderedInputContainer() amountField.placeholder = "请输入金额" amountField.keyboardType = .decimalPad - amountField.font = .systemFont(ofSize: 14) + amountField.font = WalletTokens.bodyFont let allButton = UIButton(type: .system) allButton.setTitle("全部提现", for: .normal) - allButton.setTitleColor(UIColor(hex: 0x0073FF), for: .normal) - allButton.titleLabel?.font = .systemFont(ofSize: 14) + allButton.setTitleColor(WalletTokens.primary, for: .normal) + allButton.titleLabel?.font = WalletTokens.bodyFont allButton.addTarget(self, action: #selector(withdrawAllTapped), for: .touchUpInside) amountContainer.addSubview(amountField) amountContainer.addSubview(allButton) @@ -155,10 +186,10 @@ final class WithdrawViewController: BaseViewController { make.centerY.equalToSuperview() } - limitLabel.font = .systemFont(ofSize: 12) - limitLabel.textColor = UIColor(hex: 0x9CA3AF) - phoneLabel.font = .systemFont(ofSize: 12) - phoneLabel.textColor = UIColor(hex: 0x4B5563) + limitLabel.font = WalletTokens.captionFont + limitLabel.textColor = WalletTokens.textTertiary + phoneLabel.font = WalletTokens.captionFont + phoneLabel.textColor = WalletTokens.textSecondary let codeRow = UIStackView() codeRow.axis = .horizontal @@ -166,18 +197,19 @@ final class WithdrawViewController: BaseViewController { let codeContainer = borderedInputContainer() codeField.placeholder = "请输入验证码" codeField.keyboardType = .numberPad - codeField.font = .systemFont(ofSize: 14) + codeField.font = WalletTokens.bodyFont codeContainer.addSubview(codeField) codeField.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)) } codeButton.setTitle("获取验证码", for: .normal) - codeButton.setTitleColor(UIColor(hex: 0x0073FF), for: .normal) - codeButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) + codeButton.setTitleColor(WalletTokens.primary, for: .normal) + codeButton.setTitleColor(WalletTokens.textDisabled, for: .disabled) + codeButton.titleLabel?.font = WalletTokens.bodyMediumFont codeButton.layer.borderWidth = 1 - codeButton.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor - codeButton.layer.cornerRadius = 4 + codeButton.layer.borderColor = WalletTokens.border.cgColor + codeButton.layer.cornerRadius = WalletTokens.chipRadius codeRow.addArrangedSubview(codeContainer) codeRow.addArrangedSubview(codeButton) - codeContainer.snp.makeConstraints { make in make.height.equalTo(46) } + codeContainer.snp.makeConstraints { make in make.height.equalTo(WalletTokens.inputHeight) } codeButton.snp.makeConstraints { make in make.width.equalTo(100) } let stack = UIStackView(arrangedSubviews: [title, row, amountContainer, limitLabel, phoneLabel, codeRow]) @@ -185,23 +217,26 @@ final class WithdrawViewController: BaseViewController { stack.spacing = 12 card.addSubview(stack) stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) } - amountContainer.snp.makeConstraints { make in make.height.equalTo(46) } + amountContainer.snp.makeConstraints { make in make.height.equalTo(WalletTokens.inputHeight) } return card } private func makeBankCard() -> UIView { let card = makeCard() - let title = makeTitle("银行卡信息") + let title = makeTitle("银行卡信息", size: 16) let inner = makeInnerPanel() - bankCardLabel.font = .systemFont(ofSize: 14, weight: .medium) - bankCardLabel.textColor = UIColor(hex: 0x333333) - bankHolderLabel.font = .systemFont(ofSize: 14) - bankHolderLabel.textColor = UIColor(hex: 0x4B5563) + bankCardLabel.font = WalletTokens.bodyMediumFont + bankCardLabel.textColor = WalletTokens.text333 + bankHolderLabel.font = WalletTokens.bodyFont + bankHolderLabel.textColor = WalletTokens.textSecondary let innerStack = UIStackView(arrangedSubviews: [bankCardLabel, bankHolderLabel]) innerStack.axis = .vertical innerStack.spacing = 12 inner.addSubview(innerStack) - innerStack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) } + innerStack.snp.makeConstraints { make in + make.top.bottom.equalToSuperview().inset(WalletTokens.compactGap) + make.leading.trailing.equalToSuperview().inset(WalletTokens.cardPadding) + } let stack = UIStackView(arrangedSubviews: [title, inner]) stack.axis = .vertical stack.spacing = 8 @@ -212,16 +247,19 @@ final class WithdrawViewController: BaseViewController { private func makeSettlementCard() -> UIView { let card = makeCard() - let title = makeTitle("预计到账时间") + let title = makeTitle("预计到账时间", size: 16) let inner = makeInnerPanel() - settlementLabel.font = .systemFont(ofSize: 14) - settlementLabel.textColor = UIColor(hex: 0x4B5563) + settlementLabel.font = WalletTokens.bodyFont + settlementLabel.textColor = WalletTokens.textSecondary settlementLabel.numberOfLines = 0 inner.addSubview(settlementLabel) - settlementLabel.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) } + settlementLabel.snp.makeConstraints { make in + make.top.bottom.equalToSuperview().inset(WalletTokens.compactGap) + make.leading.trailing.equalToSuperview().inset(WalletTokens.cardPadding) + } let stack = UIStackView(arrangedSubviews: [title, inner]) stack.axis = .vertical - stack.spacing = 8 + stack.spacing = WalletTokens.tinyGap card.addSubview(stack) stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) } return card @@ -229,15 +267,18 @@ final class WithdrawViewController: BaseViewController { private func makeInfoCard() -> UIView { let card = makeCard() - let title = makeTitle("提现说明") + let title = makeTitle("提现说明", size: 16) let inner = makeInnerPanel() infoStack.axis = .vertical infoStack.spacing = 4 inner.addSubview(infoStack) - infoStack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) } + infoStack.snp.makeConstraints { make in + make.top.bottom.equalToSuperview().inset(WalletTokens.compactGap) + make.leading.trailing.equalToSuperview().inset(WalletTokens.cardPadding) + } let stack = UIStackView(arrangedSubviews: [title, inner]) stack.axis = .vertical - stack.spacing = 8 + stack.spacing = WalletTokens.rowGap card.addSubview(stack) stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) } return card @@ -246,32 +287,32 @@ final class WithdrawViewController: BaseViewController { private func makeCard() -> UIView { let view = UIView() view.backgroundColor = .white - view.layer.cornerRadius = 12 + view.layer.cornerRadius = WalletTokens.cardRadius view.clipsToBounds = true return view } private func makeInnerPanel() -> UIView { let view = UIView() - view.backgroundColor = UIColor(hex: 0xF4F4F4) - view.layer.cornerRadius = 8 + view.backgroundColor = WalletTokens.inputBackground + view.layer.cornerRadius = WalletTokens.innerRadius view.layer.borderWidth = 1 - view.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor + view.layer.borderColor = WalletTokens.border.cgColor return view } private func borderedInputContainer() -> UIView { let view = UIView() - view.layer.cornerRadius = 4 + view.layer.cornerRadius = WalletTokens.chipRadius view.layer.borderWidth = 1 - view.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor + view.layer.borderColor = WalletTokens.border.cgColor return view } - private func makeTitle(_ text: String) -> UILabel { + private func makeTitle(_ text: String, size: CGFloat = 18) -> UILabel { let label = UILabel() label.text = text - label.font = .systemFont(ofSize: 18, weight: .medium) + label.font = .systemFont(ofSize: size, weight: .medium) label.textColor = .black return label } @@ -287,7 +328,7 @@ final class WithdrawViewController: BaseViewController { private func applyInfoLines(_ lines: [String]) { infoStack.arrangedSubviews.forEach { $0.removeFromSuperview() } lines.forEach { text in - let label = makeBodyLabel(text, color: UIColor(hex: 0x4B5563)) + let label = makeBodyLabel(text, color: WalletTokens.textSecondary) label.numberOfLines = 0 infoStack.addArrangedSubview(label) } diff --git a/suixinkanTests/ProfileModelsTests.swift b/suixinkanTests/ProfileModelsTests.swift index 7b2d457..12a6042 100644 --- a/suixinkanTests/ProfileModelsTests.swift +++ b/suixinkanTests/ProfileModelsTests.swift @@ -62,4 +62,54 @@ final class ProfileModelsTests: XCTestCase { XCTAssertTrue(info.verified) } + + func testRealNameInfoDecodesCompleteAuditDetails() throws { + let json = """ + { + "real_name": "张三", + "id_card_no": "11010519491231002X", + "audit_status": "3", + "audit_status_text": "审核驳回", + "reject_reason": "国徽面照片模糊", + "start_date": "2020-01-01", + "end_date": "2030-01-01", + "is_long_valid": "0", + "front_url": "https://cdn.example.com/front.jpg", + "back_url": "https://cdn.example.com/back.jpg", + "auditor_id": "8", + "auditor": { "id": "8", "name": "审核员" }, + "audit_at": "2026-07-13 12:00:00" + } + """.data(using: .utf8)! + + let info = try JSONDecoder().decode(RealNameInfo.self, from: json) + + XCTAssertEqual(info.auditStatus, 3) + XCTAssertEqual(info.rejectReason, "国徽面照片模糊") + XCTAssertEqual(info.frontUrl, "https://cdn.example.com/front.jpg") + XCTAssertEqual(info.backUrl, "https://cdn.example.com/back.jpg") + XCTAssertEqual(info.auditorId, 8) + XCTAssertEqual(info.auditor?.name, "审核员") + XCTAssertEqual(info.auditAt, "2026-07-13 12:00:00") + } + + func testRealNameInfoRemainsCompatibleWhenOptionalAuditFieldsAreMissing() throws { + let json = """ + { + "real_name": "李四", + "id_card_no": "11010519491231002X", + "audit_status": 1, + "is_long_valid": 1 + } + """.data(using: .utf8)! + + let info = try JSONDecoder().decode(RealNameInfo.self, from: json) + + XCTAssertEqual(info.auditStatus, 1) + XCTAssertTrue(info.isLongValid) + XCTAssertNil(info.rejectReason) + XCTAssertNil(info.frontUrl) + XCTAssertNil(info.auditor) + XCTAssertNil(info.auditAt) + } } diff --git a/suixinkanTests/PunchPointModelsTests.swift b/suixinkanTests/PunchPointModelsTests.swift index 2fb19d3..d51feb4 100644 --- a/suixinkanTests/PunchPointModelsTests.swift +++ b/suixinkanTests/PunchPointModelsTests.swift @@ -82,4 +82,22 @@ final class PunchPointModelsTests: XCTestCase { XCTAssertTrue(image.isUploaded) XCTAssertFalse(image.isUploading) } + + func testOperatingAndAuditStatusPresentationMatchAndroid() { + XCTAssertEqual(PunchPointDisplayFormatter.operatingStatus(1).title, "营运中") + XCTAssertEqual(PunchPointDisplayFormatter.operatingStatus(2).title, "已暂停") + XCTAssertEqual(PunchPointDisplayFormatter.operatingStatus(4).title, "未上线") + XCTAssertEqual(PunchPointDisplayFormatter.auditStatus(1).title, "已通过") + XCTAssertEqual(PunchPointDisplayFormatter.auditStatus(2).textColor, 0x22C55E) + XCTAssertEqual(PunchPointDisplayFormatter.auditStatus(3).title, "待审核") + XCTAssertEqual(PunchPointDisplayFormatter.auditStatus(4).title, "已驳回") + } + + func testAllAndroidFiltersKeepExactStatusValuesAndTitles() { + XCTAssertEqual(PunchPointFilterType.allCases.map(\.apiStatus), [0, 1, 2, 3, 4]) + XCTAssertEqual( + PunchPointFilterType.allCases.map(\.title), + ["全部", "运营中", "已暂停", "未审核", "审核未通过"] + ) + } } diff --git a/suixinkanTests/PunchPointViewModelTests.swift b/suixinkanTests/PunchPointViewModelTests.swift index bc92edc..4186826 100644 --- a/suixinkanTests/PunchPointViewModelTests.swift +++ b/suixinkanTests/PunchPointViewModelTests.swift @@ -39,8 +39,10 @@ final class PunchPointViewModelTests: XCTestCase { let viewModel = PunchPointListViewModel(currentScenicIdProvider: { 9 }) var messages: [String] = [] var qrcode: String? + var removalID: Int64? viewModel.onShowMessage = { messages.append($0) } viewModel.onShowQRCode = { qrcode = $0 } + viewModel.onBeginDeleteAnimation = { removalID = $0 } await viewModel.loadInitial(api: api) viewModel.showQRCode(for: viewModel.items[1]) @@ -50,6 +52,9 @@ final class PunchPointViewModelTests: XCTestCase { XCTAssertEqual(messages, ["未审核通过不可查看二维码", "暂无二维码"]) XCTAssertEqual(qrcode, "https://cdn/qr.png") + XCTAssertEqual(removalID, 1) + XCTAssertEqual(viewModel.items.map(\.id), [1, 2]) + viewModel.completeDeleteAnimation(id: 1) XCTAssertEqual(viewModel.items.map(\.id), [2]) XCTAssertEqual(api.deletedIds, [1]) } @@ -140,6 +145,59 @@ final class PunchPointViewModelTests: XCTestCase { XCTAssertTrue(api.addRequests.isEmpty) } + func testImageLimitAcceptsNineAndRejectsFurtherSelection() async { + let uploader = MockPunchPointUploader() + let viewModel = PunchPointFormViewModel(currentScenicIdProvider: { 7 }) + var messages: [String] = [] + viewModel.onShowMessage = { messages.append($0) } + let images = (0 ..< 10).map { + PunchPointImageState(data: Data([UInt8($0)]), fileName: "\($0).jpg") + } + + await viewModel.addLocalImages(images, uploader: uploader) + await viewModel.addLocalImages( + [PunchPointImageState(data: Data([99]), fileName: "extra.jpg")], + uploader: uploader + ) + + XCTAssertEqual(viewModel.images.count, 9) + XCTAssertEqual(uploader.uploads.count, 9) + XCTAssertEqual(messages.last, "最多只能选择9张图片") + } + + func testFailedImageCanRetryAndBecomeUploaded() async { + let uploader = SequencedPunchPointUploader() + let viewModel = PunchPointFormViewModel(currentScenicIdProvider: { 7 }) + + await viewModel.addLocalImages( + [PunchPointImageState(data: Data([1]), fileName: "retry.jpg")], + uploader: uploader + ) + XCTAssertNotNil(viewModel.images.first?.errorMessage) + + await viewModel.retryUpload(at: 0, uploader: uploader) + + XCTAssertNil(viewModel.images.first?.errorMessage) + XCTAssertEqual(viewModel.images.first?.uploadedURL, "https://cdn/retry.jpg") + XCTAssertEqual(uploader.callCount, 2) + } + + func testAutoLocationFillsCoordinateAndAddress() async { + let location = MockPunchPointLocationProvider( + snapshot: HomeLocationSnapshot(latitude: 31.2, longitude: 121.5, address: "当前位置地址") + ) + let viewModel = PunchPointFormViewModel(currentScenicIdProvider: { 7 }, locationProvider: location) + var changedCoordinate: CLLocationCoordinate2D? + viewModel.onCoordinateChange = { changedCoordinate = $0 } + + await viewModel.autoLocation() + + XCTAssertEqual(viewModel.coordinate?.latitude, 31.2) + XCTAssertEqual(viewModel.coordinate?.longitude, 121.5) + XCTAssertEqual(viewModel.address, "当前位置地址") + XCTAssertEqual(changedCoordinate?.latitude, 31.2) + } + private func item(id: Int64, status: Int = 1, mpQrcode: String? = nil) -> PunchPointItem { PunchPointItem( id: id, @@ -233,6 +291,25 @@ private final class SlowPunchPointUploader: PunchPointImageUploading { } } +@MainActor +private final class SequencedPunchPointUploader: PunchPointImageUploading { + var callCount = 0 + + func uploadPunchPointImage( + data: Data, + fileName: String, + scenicId: Int, + onProgress: @escaping (Int) -> Void + ) async throws -> String { + callCount += 1 + if callCount == 1 { + throw URLError(.cannotConnectToHost) + } + onProgress(100) + return "https://cdn/retry.jpg" + } +} + private final class MockPunchPointLocationProvider: LocationProviding, @unchecked Sendable { let snapshot: HomeLocationSnapshot let reverseAddress: String diff --git a/suixinkanTests/RealNameAuthAPITests.swift b/suixinkanTests/RealNameAuthAPITests.swift new file mode 100644 index 0000000..fed3188 --- /dev/null +++ b/suixinkanTests/RealNameAuthAPITests.swift @@ -0,0 +1,66 @@ +// +// RealNameAuthAPITests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// 实名认证 API 契约测试。 +@MainActor +final class RealNameAuthAPITests: XCTestCase { + func testInfoUsesAndroidGETPath() async throws { + let response = Data(#"{"code":100000,"msg":"success","data":{"real_name_info":null}}"#.utf8) + let session = MockURLSession(responses: [response]) + let api = ProfileAPI(client: APIClient(environment: .testing, session: session)) + + _ = try await api.realNameInfo() + + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/real-name/info") + } + + func testSMSCodeUsesPOSTWithoutBody() async throws { + let response = Data(#"{"code":100000,"msg":"success","data":{}}"#.utf8) + let session = MockURLSession(responses: [response]) + let api = ProfileAPI(client: APIClient(environment: .testing, session: session)) + + try await api.realNameSmsVerifyCode() + + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/real-name/sms-verify-code") + XCTAssertNil(request.httpBody) + } + + func testSubmitUsesAndroidJSONFields() async throws { + let response = Data(#"{"code":100000,"msg":"success","data":{}}"#.utf8) + let session = MockURLSession(responses: [response]) + let api = ProfileAPI(client: APIClient(environment: .testing, session: session)) + let payload = RealNameAuthRequest( + realName: "张三", + idCardNo: "11010519491231002X", + smsVerifyCode: "123456", + startDate: "2026-07-13", + endDate: "", + isLongValid: 1, + frontUrl: "front", + backUrl: "back" + ) + + try await api.realNameSubmit(payload) + + let request = try XCTUnwrap(session.requests.first) + let body = try XCTUnwrap(request.httpBody) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/real-name/submit") + XCTAssertEqual(object["real_name"] as? String, "张三") + XCTAssertEqual(object["id_card_no"] as? String, "11010519491231002X") + XCTAssertEqual(object["sms_verify_code"] as? String, "123456") + XCTAssertEqual(object["is_long_valid"] as? Int, 1) + XCTAssertEqual(object["front_url"] as? String, "front") + XCTAssertEqual(object["back_url"] as? String, "back") + } +} diff --git a/suixinkanTests/RealNameAuthViewModelTests.swift b/suixinkanTests/RealNameAuthViewModelTests.swift index 4184e82..559943a 100644 --- a/suixinkanTests/RealNameAuthViewModelTests.swift +++ b/suixinkanTests/RealNameAuthViewModelTests.swift @@ -3,17 +3,157 @@ // suixinkanTests // +import UIKit import XCTest @testable import suixinkan /// 实名认证 ViewModel 测试。 +@MainActor final class RealNameAuthViewModelTests: XCTestCase { - func testValidationRequiresSmsCodeWhenNotVerified() { + func testNewApplicationStartsAtIdentityStepAndRequiresName() { + let viewModel = RealNameAuthViewModel() + + XCTAssertEqual(viewModel.mode, .newApplication) + XCTAssertEqual(viewModel.currentStep, .identity) + XCTAssertFalse(viewModel.isAgreed) + XCTAssertFalse(viewModel.canGoNextStep) + XCTAssertEqual(viewModel.validationMessage, "请输入真实姓名") + } + + func testInputFilteringMatchesFormRules() { + let viewModel = RealNameAuthViewModel() + + viewModel.updateIdCardNo("11010519491231002xABC") + viewModel.updateSmsCode("1a2b345678901234") + + XCTAssertEqual(viewModel.idCardNo, "11010519491231002X") + XCTAssertEqual(viewModel.smsCode, "12345678901") + } + + func testModesFollowAuditStatus() throws { + XCTAssertEqual(RealNameAuthViewModel(info: try makeInfo(status: 1)).mode, .pending) + XCTAssertEqual(RealNameAuthViewModel(info: try makeInfo(status: 2)).mode, .approved) + XCTAssertEqual(RealNameAuthViewModel(info: try makeInfo(status: 3)).mode, .rejected) + } + + func testRejectedInfoIsEditableAndCanAdvance() throws { + let viewModel = RealNameAuthViewModel(info: try makeInfo(status: 3)) + + XCTAssertTrue(viewModel.isAgreed) + XCTAssertTrue(viewModel.canGoNextStep) + XCTAssertNil(viewModel.goToVerificationStep()) + XCTAssertEqual(viewModel.currentStep, .verification) + XCTAssertFalse(viewModel.canSubmit) + } + + func testReadOnlyInfoCanBrowseSecondStepButCannotSubmit() throws { + let viewModel = RealNameAuthViewModel(info: try makeInfo(status: 2)) + + XCTAssertNil(viewModel.goToVerificationStep()) + XCTAssertEqual(viewModel.currentStep, .verification) + XCTAssertFalse(viewModel.canSubmit) + viewModel.updateRealName("不能修改") + XCTAssertEqual(viewModel.realName, "张三") + } + + func testLongValidityClearsEndDate() throws { + let viewModel = RealNameAuthViewModel(info: try makeInfo(status: 3)) + XCTAssertNotNil(viewModel.endDate) + + viewModel.updateLongValid(true) + + XCTAssertTrue(viewModel.isLongValid) + XCTAssertNil(viewModel.endDate) + XCTAssertTrue(viewModel.canGoNextStep) + } + + func testEndDateCannotBeEarlierThanStartDate() throws { + let viewModel = RealNameAuthViewModel(info: try makeInfo(status: 3)) + + viewModel.updateEndDate(Self.date("2026-07-12")) + + XCTAssertFalse(viewModel.canGoNextStep) + XCTAssertEqual(viewModel.validationMessage, "证件结束日期不能早于起始日期") + } + + func testAgreementControlsStepTransition() throws { + let viewModel = RealNameAuthViewModel(info: try makeInfo(status: 3)) + XCTAssertTrue(viewModel.canGoNextStep) + + viewModel.toggleAgreement() + + XCTAssertFalse(viewModel.canGoNextStep) + XCTAssertEqual(viewModel.goToVerificationStep(), "请阅读并同意用户协议与隐私政策") + XCTAssertEqual(viewModel.currentStep, .identity) + } + + func testImmediateUploadsProduceURLsAndEnableNextStep() async throws { let viewModel = RealNameAuthViewModel() viewModel.updateRealName("张三") viewModel.updateIdCardNo("11010519491231002X") + viewModel.updateStartDate(Self.date("2026-07-13")) + viewModel.updateEndDate(Self.date("2036-07-13")) + viewModel.toggleAgreement() + let imageData = try XCTUnwrap(Self.imageData()) + let uploader = MockRealNameOSSUploadService() - XCTAssertEqual(viewModel.validationMessage, "请输入短信验证码") + try await viewModel.uploadIdentityImage(data: imageData, side: .back, uploader: uploader, scenicId: 9, timestamp: 1) + try await viewModel.uploadIdentityImage(data: imageData, side: .front, uploader: uploader, scenicId: 9, timestamp: 2) + + XCTAssertEqual(viewModel.backUrl, "https://cdn.example.com/real_name_back_1.jpg") + XCTAssertEqual(viewModel.frontUrl, "https://cdn.example.com/real_name_front_2.jpg") + XCTAssertEqual(viewModel.backUploadProgress, 100) + XCTAssertTrue(viewModel.canGoNextStep) + + viewModel.deleteIdentityImage(side: .front) + XCTAssertTrue(viewModel.frontUrl.isEmpty) + XCTAssertFalse(viewModel.canGoNextStep) + } + + func testUploadFailureClearsPendingPreviewAndURL() async throws { + let viewModel = RealNameAuthViewModel() + let imageData = try XCTUnwrap(Self.imageData()) + + do { + try await viewModel.uploadIdentityImage( + data: imageData, + side: .front, + uploader: MockRealNameOSSUploadService(shouldFail: true), + scenicId: 9 + ) + XCTFail("Expected upload failure") + } catch { + XCTAssertTrue(viewModel.frontUrl.isEmpty) + XCTAssertNil(viewModel.frontPreviewImageData) + XCTAssertFalse(viewModel.isFrontUploading) + } + } + + func testRequestUsesNormalizedFieldsAndLongTermDate() throws { + let viewModel = RealNameAuthViewModel(info: try makeInfo(status: 3)) + viewModel.updateLongValid(true) + viewModel.updateSmsCode("12a3456") + _ = viewModel.goToVerificationStep() + + let request = try viewModel.makeRequest() + + XCTAssertEqual(request.idCardNo, "11010519491231002X") + XCTAssertEqual(request.smsVerifyCode, "123456") + XCTAssertEqual(request.isLongValid, 1) + XCTAssertEqual(request.endDate, "") + } + + func testSendCodeStartsSixtySecondCountdown() async throws { + let viewModel = RealNameAuthViewModel(info: try makeInfo(status: 3)) + _ = viewModel.goToVerificationStep() + let response = Data(#"{"code":100000,"msg":"success","data":{}}"#.utf8) + let api = ProfileAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [response]))) + + try await viewModel.sendCode(api: api) + + XCTAssertEqual(viewModel.otpCountdown, 60) + XCTAssertFalse(viewModel.canRequestCode) + XCTAssertEqual(viewModel.statusMessage, "验证码发送成功") } func testIsValidMainlandIDCardNumberChecksChecksum() { @@ -27,4 +167,67 @@ final class RealNameAuthViewModelTests: XCTestCase { "11010519491231002X" ) } + + private func makeInfo(status: Int) throws -> RealNameInfo { + let json = Data( + """ + { + "real_name":"张三", + "id_card_no":"11010519491231002X", + "audit_status":\(status), + "audit_status_text":"审核状态", + "start_date":"2026-07-13", + "end_date":"2036-07-13", + "is_long_valid":0, + "front_url":"https://cdn.example.com/front.jpg", + "back_url":"https://cdn.example.com/back.jpg", + "auditor":{"id":1,"name":"审核员"}, + "audit_at":"2026-07-13 12:00:00", + "reject_reason":"照片模糊" + } + """.utf8 + ) + return try JSONDecoder().decode(RealNameInfo.self, from: json) + } + + private static func date(_ value: String) -> Date { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter.date(from: value)! + } + + private static func imageData() -> Data? { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 40, height: 24)) + let image = renderer.image { context in + UIColor.white.setFill() + context.fill(CGRect(x: 0, y: 0, width: 40, height: 24)) + } + return image.jpegData(compressionQuality: 0.9) + } +} + +/// 实名认证上传测试替身。 +@MainActor +private struct MockRealNameOSSUploadService: OSSUploadServing { + let shouldFail: Bool + + init(shouldFail: Bool = false) { + self.shouldFail = shouldFail + } + + func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + "https://cdn.example.com/avatar.jpg" + } + + func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + if shouldFail { throw URLError(.cannotWriteToFile) } + onProgress(100) + return "https://cdn.example.com/\(fileName)" + } + + func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + "https://cdn.example.com/bank.jpg" + } } diff --git a/suixinkanTests/TravelAlbumOTGCoreTests.swift b/suixinkanTests/TravelAlbumOTGCoreTests.swift index b9e6c57..6e86912 100644 --- a/suixinkanTests/TravelAlbumOTGCoreTests.swift +++ b/suixinkanTests/TravelAlbumOTGCoreTests.swift @@ -106,4 +106,52 @@ final class TravelAlbumOTGCoreTests: XCTestCase { XCTAssertEqual(key, "punch_point/20260709/u900/aaaaaaaabbbbccccddddeeeeeeeeeeee_guide_photo.jpg") } + + func testOSSRealNameObjectKeyUsesAndroidUserPath() { + let previousUserId = AppStore.shared.session.userId + AppStore.shared.session.userId = "u901" + defer { AppStore.shared.session.userId = previousUserId } + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + let date = calendar.date(from: DateComponents(year: 2026, month: 7, day: 13))! + let uuid = UUID(uuidString: "11111111-2222-3333-4444-555555555555")! + + let key = OSSUploadPolicy.objectKey( + fileName: "id/front.jpg", + scenicId: 9, + moduleType: "real_name_auth", + date: date, + uuid: uuid, + timeZone: TimeZone(secondsFromGMT: 0)! + ) + + XCTAssertEqual(key, "real_name_auth/20260713/u901/11111111222233334444555555555555_id_front.jpg") + } + + func testOSSRealNamePathDoesNotChangeAvatarOrBankCardModules() { + let date = Date(timeIntervalSince1970: 0) + let uuid = UUID(uuidString: "11111111-2222-3333-4444-555555555555")! + let timeZone = TimeZone(secondsFromGMT: 0)! + + let avatar = OSSUploadPolicy.objectKey( + fileName: "avatar.jpg", + scenicId: 9, + moduleType: "user_avatar", + date: date, + uuid: uuid, + timeZone: timeZone + ) + let bankCard = OSSUploadPolicy.objectKey( + fileName: "bank.jpg", + scenicId: 9, + moduleType: "bank_card", + date: date, + uuid: uuid, + timeZone: timeZone + ) + + XCTAssertTrue(avatar.hasPrefix("avatar/19700101/9/")) + XCTAssertTrue(bankCard.hasPrefix("bank_card/19700101/9/")) + } } diff --git a/suixinkanTests/WalletFeatureTests.swift b/suixinkanTests/WalletFeatureTests.swift index 4356791..bc40a89 100644 --- a/suixinkanTests/WalletFeatureTests.swift +++ b/suixinkanTests/WalletFeatureTests.swift @@ -4,6 +4,7 @@ // import XCTest +import UIKit @testable import suixinkan /// 钱包功能模型、API 和 ViewModel 测试。 @@ -71,6 +72,8 @@ final class WalletFeatureTests: XCTestCase { XCTAssertEqual(Self.queryItems(from: session.requests[1])["page"], "1") XCTAssertEqual(Self.queryItems(from: session.requests[2])["start_date"], "2026-07-01") XCTAssertEqual(Self.queryItems(from: session.requests[6])["staff_id"], "9") + XCTAssertNil(session.requests[4].httpBody) + XCTAssertNil(session.requests[6].httpBody) XCTAssertEqual(Self.bodyString(from: session.requests[5]).contains(#""sms_code":"123456""#), true) XCTAssertEqual(Self.bodyString(from: session.requests[7]).contains(#""points":24"#), true) XCTAssertEqual(Self.bodyString(from: session.requests[8]).contains(#""page_size":10"#), true) @@ -96,6 +99,46 @@ final class WalletFeatureTests: XCTestCase { XCTAssertGreaterThanOrEqual(api.earningQueries.count, 2) } + func testWalletFiltersUseAndroidDateOffsets() async { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + let now = calendar.date(from: DateComponents(year: 2026, month: 7, day: 13, hour: 12))! + let api = FakeWalletPageService() + let viewModel = WalletViewModel( + staffIdProvider: { 9 }, + calendar: calendar, + nowProvider: { now } + ) + + await viewModel.refreshTransaction(api: api) + await viewModel.selectFilter(.last30Days, api: api) + await viewModel.selectFilter(.last180Days, api: api) + + XCTAssertEqual(api.earningQueries.map(\.start), ["2026-07-06", "2026-06-13", "2026-01-14"]) + XCTAssertEqual(api.earningQueries.map(\.end), ["2026-07-13", "2026-07-13", "2026-07-13"]) + } + + func testRefreshFailureKeepsWalletAndPointRecords() async { + let api = FakeWalletPageService() + let walletViewModel = WalletViewModel(staffIdProvider: { 9 }) + let pointsViewModel = PointsRedemptionViewModel(staffIdProvider: { 9 }) + + await walletViewModel.refreshWithdraw(api: api) + await walletViewModel.refreshTransaction(api: api) + await pointsViewModel.refreshWithdrawList(api: api) + api.shouldFailWithdrawList = true + api.shouldFailEarningDetail = true + api.shouldFailPointWithdrawList = true + + await walletViewModel.refreshWithdraw(api: api) + await walletViewModel.refreshTransaction(api: api) + await pointsViewModel.refreshWithdrawList(api: api) + + XCTAssertEqual(walletViewModel.withdrawRecords.count, 1) + XCTAssertEqual(walletViewModel.transactionGroups.count, 1) + XCTAssertEqual(pointsViewModel.withdrawRecords.count, 1) + } + func testWalletWithdrawDestinationFlow() async throws { let viewModel = WalletViewModel() @@ -107,6 +150,12 @@ final class WalletFeatureTests: XCTestCase { destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile) XCTAssertEqual(destination, .realNamePending) + profile = FakeWalletProfileService(realNameStatus: 3, bankCardStatus: nil) + destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile) + guard case .realNameAudit = destination else { + return XCTFail("实名认证驳回应进入审核页") + } + profile = FakeWalletProfileService(realNameStatus: 2, bankCardStatus: nil) destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile) XCTAssertEqual(destination, .withdrawalSettings) @@ -115,6 +164,12 @@ final class WalletFeatureTests: XCTestCase { destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile) XCTAssertEqual(destination, .bankCardPending) + profile = FakeWalletProfileService(realNameStatus: 2, bankCardStatus: 3) + destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile) + guard case .withdrawalAudit = destination else { + return XCTFail("银行卡驳回应进入审核页") + } + profile = FakeWalletProfileService(realNameStatus: 2, bankCardStatus: 2) destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile) XCTAssertEqual(destination, .withdraw) @@ -129,11 +184,21 @@ final class WalletFeatureTests: XCTestCase { viewModel.updateVerificationCode("12a456") XCTAssertEqual(viewModel.amount, "12.34") - XCTAssertEqual(viewModel.verificationCode, "12456") + XCTAssertEqual(viewModel.verificationCode, "12a456") let success = await viewModel.submit(api: api) XCTAssertTrue(success) XCTAssertEqual(api.withdrawApplyRequests.first?.amount, "12.34") + + viewModel.updateAmount("0") + XCTAssertNil(viewModel.validationMessage) + viewModel.updateAmount("99") + XCTAssertEqual(viewModel.validationMessage, "金额不能大于可提现金额") + + await viewModel.requestVerificationCode(api: api) + XCTAssertEqual(viewModel.countdown, 60) + viewModel.decrementCountdown() + XCTAssertEqual(viewModel.countdown, 59) } func testPointsRedemptionViewModelClampsAndRefreshes() async { @@ -141,6 +206,8 @@ final class WalletFeatureTests: XCTestCase { let viewModel = PointsRedemptionViewModel(staffIdProvider: { 9 }) await viewModel.loadInitial(api: api) + viewModel.updatePointsInput("0") + XCTAssertEqual(viewModel.pointsInput, "0") viewModel.updatePointsInput("999") XCTAssertEqual(viewModel.pointsInput, "60") @@ -151,6 +218,18 @@ final class WalletFeatureTests: XCTestCase { XCTAssertEqual(viewModel.withdrawRecords.count, 1) } + func testWalletTokensAndHomeIconMatchAndroid() { + XCTAssertEqual(WalletTokens.pageBackground.walletHexRGB, 0xF5F6F8) + XCTAssertEqual(WalletTokens.withdrawPageBackground.walletHexRGB, 0xF9FAFB) + XCTAssertEqual(WalletTokens.summaryBackground.walletHexRGB, 0x1677FF) + XCTAssertEqual(WalletTokens.primary.walletHexRGB, 0x0073FF) + XCTAssertEqual(WalletTokens.summaryRadius, 24) + XCTAssertEqual(WalletTokens.contentRadius, 16) + XCTAssertEqual(WalletTokens.cardRadius, 12) + XCTAssertEqual(HomeMenuCatalog.item(for: "wallet")?.iconName, "home_menu_wallet") + XCTAssertNotNil(UIImage(named: "home_menu_wallet")) + } + private static func envelope(_ dataJSON: String) -> Data { Data(#"{"code":100000,"msg":"success","data":\#(dataJSON)}"#.utf8) } @@ -175,6 +254,9 @@ private final class FakeWalletPageService: WalletPageServing { private(set) var earningQueries: [(start: String, end: String, page: Int)] = [] private(set) var withdrawApplyRequests: [(amount: String, smsCode: String)] = [] private(set) var pointApplyRequests: [Int] = [] + var shouldFailWithdrawList = false + var shouldFailEarningDetail = false + var shouldFailPointWithdrawList = false func walletSummary(type: Int) async throws -> WalletSummaryResponse { WalletSummaryResponse(amountTotal: "100.00", amountCurrentBalance: "80.00", amountWithdrawable: "50.00") @@ -191,6 +273,7 @@ private final class FakeWalletPageService: WalletPageServing { } func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse { + if shouldFailWithdrawList { throw FakeWalletError.requestFailed } WalletWithdrawListResponse( total: 1, item: [WalletWithdrawItem(id: page, amount: "10.00", createdAt: "2026-07-01", settlementStatus: 40, statusLabel: "打款中")] @@ -198,6 +281,7 @@ private final class FakeWalletPageService: WalletPageServing { } func earningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> EarningDetailResponse { + if shouldFailEarningDetail { throw FakeWalletError.requestFailed } earningQueries.append((startDate, endDate, page)) return EarningDetailResponse( totalAmount: "12.30", @@ -242,10 +326,18 @@ private final class FakeWalletPageService: WalletPageServing { } func pointWithdrawList(status: Int?, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse { + if shouldFailPointWithdrawList { throw FakeWalletError.requestFailed } PointWithdrawListResponse(total: 1, list: [PointWithdrawItem(id: page, points: 24, amount: 2, status: 1, createdAt: "2026-07-01")]) } } +/// 钱包测试替身错误。 +private enum FakeWalletError: LocalizedError { + case requestFailed + + var errorDescription: String? { "请求失败" } +} + @MainActor /// 钱包前置资料服务测试替身。 private struct FakeWalletProfileService: WalletProfileServing { @@ -270,3 +362,14 @@ private struct FakeWalletProfileService: WalletProfileServing { return try JSONDecoder().decode(BankCardInfoResponse.self, from: data) } } + +private extension UIColor { + var walletHexRGB: UInt { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + getRed(&red, green: &green, blue: &blue, alpha: &alpha) + return UInt(Int(round(red * 255)) << 16 | Int(round(green * 255)) << 8 | Int(round(blue * 255))) + } +}