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