From 8e356973bd8376eaa0ba6682a7ed5c658747c51a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Thu, 9 Jul 2026 17:34:00 +0800 Subject: [PATCH] feat: update app workflows and permissions --- suixinkan/Base/BaseViewController.swift | 2 +- suixinkan/Core/WeChat/WeChatShareModels.swift | 54 +- .../Core/WeChat/WeChatShareService.swift | 69 +- .../WeChat/WeChatThumbImageProcessor.swift | 22 + .../CooperationAcquirerViewModel.swift | 5 +- .../Features/Home/Models/HomeMenuItem.swift | 2 +- .../Home/Services/HomeMenuCatalog.swift | 48 +- .../MaterialManagementViewModels.swift | 18 +- .../API/WildPhotographerReportAPI.swift | 13 + .../Models/WildPhotographerReportModels.swift | 256 ++++++- .../WildPhotographerReportViewModels.swift | 84 +-- .../WildPhotographerReport/举报摄影师接口.md | 70 +- suixinkan/Theme/AppColor.swift | 5 + .../CooperationAcquirerViewController.swift | 80 ++- .../CooperationOrderListViewController.swift | 25 +- .../Views/CooperationOrderViews.swift | 517 ++++++++++++-- .../UI/Home/AllFunctionsViewController.swift | 2 +- suixinkan/UI/Home/HomeCollectionModels.swift | 24 +- suixinkan/UI/Home/HomeViewController.swift | 25 +- .../UI/Home/Views/AllFunctionMenuCell.swift | 28 +- .../UI/Home/Views/HomeCollectionCells.swift | 40 +- .../Views/HomeLocationReportCardView.swift | 121 +++- .../UI/Home/Views/HomeQuickActionsView.swift | 55 +- .../Home/Views/HomeWorkStatusCardView.swift | 21 +- suixinkan/UI/MainTab/MainScanTabItem.swift | 26 +- .../UI/MainTab/MainTabBarController.swift | 3 +- .../UI/Orders/OrderDialogControllers.swift | 647 +++++++++++++++++- .../OrderSourceLeadListViewController.swift | 7 - .../OrderSourcePickerViewController.swift | 14 +- .../UI/Orders/OrdersViewController.swift | 9 +- .../UI/Orders/Views/OrderTypeChipView.swift | 36 +- ...PhotographerReportListViewController.swift | 144 +++- ...otographerReportSubmitViewController.swift | 21 +- ...portSupplementEvidenceViewController.swift | 19 +- suixinkan/suixinkan.entitlements | 2 + .../CooperationAcquirerViewModelTests.swift | 33 + suixinkanTests/HomeCommonMenuStoreTests.swift | 10 +- suixinkanTests/HomeViewModelTests.swift | 73 ++ suixinkanTests/MainTabItemTests.swift | 42 ++ .../MaterialManagementViewModelTests.swift | 12 + suixinkanTests/OrderListUITokensTests.swift | 10 + .../OrderRefundDialogValidationTests.swift | 130 ++++ suixinkanTests/WeChatShareServiceTests.swift | 122 ++++ .../WildPhotographerReportTests.swift | 305 ++++++++- 44 files changed, 2944 insertions(+), 307 deletions(-) create mode 100644 suixinkanTests/MainTabItemTests.swift create mode 100644 suixinkanTests/OrderRefundDialogValidationTests.swift diff --git a/suixinkan/Base/BaseViewController.swift b/suixinkan/Base/BaseViewController.swift index 078b7ef..d715655 100644 --- a/suixinkan/Base/BaseViewController.swift +++ b/suixinkan/Base/BaseViewController.swift @@ -80,7 +80,7 @@ class BaseViewController: UIViewController { } container.snp.makeConstraints { make in make.centerX.equalToSuperview() - make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-32) + make.centerY.equalToSuperview() make.leading.greaterThanOrEqualToSuperview().offset(32) make.trailing.lessThanOrEqualToSuperview().offset(-32) } diff --git a/suixinkan/Core/WeChat/WeChatShareModels.swift b/suixinkan/Core/WeChat/WeChatShareModels.swift index 4f8a8de..0daa0a3 100644 --- a/suixinkan/Core/WeChat/WeChatShareModels.swift +++ b/suixinkan/Core/WeChat/WeChatShareModels.swift @@ -33,7 +33,7 @@ enum WeChatShareScene: Int, CaseIterable, Sendable { } /// 微信分享请求结果。 -enum WeChatShareResult: Equatable, Sendable { +enum WeChatShareResult: Equatable, Error, Sendable { case success case cancelled case notInstalled @@ -55,6 +55,25 @@ enum WeChatShareResult: Equatable, Sendable { } } +/// 微信小程序分享版本。 +enum WeChatMiniProgramType: Int, Equatable, Sendable { + /// 正式版。 + case release = 0 + /// 开发版。 + case test = 1 + /// 体验版。 + case preview = 2 + + /// 当前编译配置下实际传给微信 SDK 的小程序版本。 + var resolvedForCurrentBuild: WeChatMiniProgramType { + #if DEBUG + return .test + #else + return self + #endif + } +} + /// 网页分享载荷,供邀请等业务模块调用微信分享。 struct WeChatWebPageSharePayload: Equatable, Sendable { let title: String @@ -71,3 +90,36 @@ struct WeChatTextSharePayload: Equatable, Sendable { struct WeChatImageSharePayload: Equatable, Sendable { let imageData: Data } + +/// 微信小程序分享载荷。 +struct WeChatMiniProgramSharePayload: Equatable, Sendable { + let title: String + let description: String + let webpageURL: String + let userName: String + let path: String? + let miniProgramType: WeChatMiniProgramType + let withShareTicket: Bool + let disableForward: Bool + + /// 初始化小程序分享载荷。 + init( + title: String, + description: String, + webpageURL: String, + userName: String, + path: String? = nil, + miniProgramType: WeChatMiniProgramType = .release, + withShareTicket: Bool = false, + disableForward: Bool = false + ) { + self.title = title + self.description = description + self.webpageURL = webpageURL + self.userName = userName + self.path = path + self.miniProgramType = miniProgramType + self.withShareTicket = withShareTicket + self.disableForward = disableForward + } +} diff --git a/suixinkan/Core/WeChat/WeChatShareService.swift b/suixinkan/Core/WeChat/WeChatShareService.swift index e9ec199..8c627b9 100644 --- a/suixinkan/Core/WeChat/WeChatShareService.swift +++ b/suixinkan/Core/WeChat/WeChatShareService.swift @@ -6,7 +6,7 @@ import UIKit import WechatOpenSDK -/// 微信分享服务,封装文本、网页和图片分享能力,不包含微信支付。 +/// 微信分享服务,封装文本、网页、图片和小程序分享能力,不包含微信支付。 enum WeChatShareService { /// 当前设备是否已安装微信且支持 Open SDK。 static var isWeChatAvailable: Bool { @@ -102,6 +102,73 @@ enum WeChatShareService { WeChatManager.shared.sendShareRequest(request, completion: completion) } + /// 分享小程序到微信好友会话。 + @MainActor + static func shareMiniProgram( + _ payload: WeChatMiniProgramSharePayload, + previewImage: UIImage? = nil, + completion: ((WeChatShareResult) -> Void)? = nil + ) { + switch makeMiniProgramRequest(payload, scene: .session, previewImage: previewImage ?? defaultThumbImage()) { + case .success(let request): + WeChatManager.shared.sendShareRequest(request, completion: completion) + case .failure(let result): + completion?(result) + } + } + + /// 构建小程序分享请求,供业务分享入口和单元测试复用。 + static func makeMiniProgramRequest( + _ payload: WeChatMiniProgramSharePayload, + scene: WeChatShareScene, + previewImage: UIImage? = nil + ) -> Result { + guard scene == .session else { + return .failure(.invalidPayload("小程序仅支持分享到微信好友")) + } + + let title = payload.title.trimmingCharacters(in: .whitespacesAndNewlines) + let webpageURL = payload.webpageURL.trimmingCharacters(in: .whitespacesAndNewlines) + let userName = payload.userName.trimmingCharacters(in: .whitespacesAndNewlines) + let path = payload.path?.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !title.isEmpty else { + return .failure(.invalidPayload("分享标题不能为空")) + } + guard !webpageURL.isEmpty else { + return .failure(.invalidPayload("小程序兼容网页链接不能为空")) + } + guard webpageURL.lengthOfBytes(using: .utf8) <= 1024 else { + return .failure(.invalidPayload("小程序兼容网页链接不能超过1024字节")) + } + guard !userName.isEmpty else { + return .failure(.invalidPayload("小程序原始 ID 不能为空")) + } + + let miniProgramObject = WXMiniProgramObject() + miniProgramObject.webpageUrl = webpageURL + miniProgramObject.userName = userName + miniProgramObject.path = path?.isEmpty == false ? path : nil + miniProgramObject.miniProgramType = WXMiniProgramType(rawValue: UInt(payload.miniProgramType.resolvedForCurrentBuild.rawValue)) + ?? WXMiniProgramType(rawValue: UInt(WeChatMiniProgramType.release.rawValue))! + miniProgramObject.withShareTicket = payload.withShareTicket + miniProgramObject.disableForward = payload.disableForward + if let previewImage { + miniProgramObject.hdImageData = WeChatThumbImageProcessor.makeMiniProgramHDImageData(from: previewImage) + } + + let message = WXMediaMessage() + message.title = title + message.description = payload.description + message.mediaObject = miniProgramObject + + let request = SendMessageToWXReq() + request.bText = false + request.message = message + request.scene = scene.wxSceneValue + return .success(request) + } + /// 读取 App 图标,用作微信分享缩略图兜底。 static func defaultThumbImage() -> UIImage? { guard let icons = Bundle.main.infoDictionary?["CFBundleIcons"] as? [String: Any], diff --git a/suixinkan/Core/WeChat/WeChatThumbImageProcessor.swift b/suixinkan/Core/WeChat/WeChatThumbImageProcessor.swift index a4c456b..798496e 100644 --- a/suixinkan/Core/WeChat/WeChatThumbImageProcessor.swift +++ b/suixinkan/Core/WeChat/WeChatThumbImageProcessor.swift @@ -9,6 +9,8 @@ import UIKit enum WeChatThumbImageProcessor { /// 微信缩略图最大字节数。 static let maxThumbBytes = 32 * 1024 + /// 微信小程序预览图最大字节数。 + static let maxMiniProgramHDImageBytes = 128 * 1024 /// 将图片压缩为符合微信要求的缩略图数据。 static func makeThumbData(from image: UIImage, maxBytes: Int = maxThumbBytes) -> Data? { @@ -27,6 +29,26 @@ enum WeChatThumbImageProcessor { return result } + /// 将图片压缩为符合微信小程序分享要求的高清预览图数据。 + static func makeMiniProgramHDImageData( + from image: UIImage, + maxBytes: Int = maxMiniProgramHDImageBytes + ) -> Data? { + guard maxBytes > 0 else { return nil } + + let resized = resize(image, maxPixel: 500) + var quality: CGFloat = 0.86 + var data = resized.jpegData(compressionQuality: quality) + + while let current = data, current.count > maxBytes, quality > 0.12 { + quality -= 0.08 + data = resized.jpegData(compressionQuality: quality) + } + + guard let result = data, result.count <= maxBytes else { return nil } + return result + } + /// 等比缩放图片,使最长边不超过指定像素。 static func resize(_ image: UIImage, maxPixel: CGFloat) -> UIImage { let sourceSize = image.size diff --git a/suixinkan/Features/CooperationOrder/ViewModels/CooperationAcquirerViewModel.swift b/suixinkan/Features/CooperationOrder/ViewModels/CooperationAcquirerViewModel.swift index a11a6e9..a409a58 100644 --- a/suixinkan/Features/CooperationOrder/ViewModels/CooperationAcquirerViewModel.swift +++ b/suixinkan/Features/CooperationOrder/ViewModels/CooperationAcquirerViewModel.swift @@ -185,9 +185,12 @@ final class CooperationAcquirerViewModel { private func startCommissionSmsTimer() { commissionSmsTimerTask?.cancel() + commissionSmsCountdown = 60 + notifyStateChange() commissionSmsTimerTask = Task { [weak self] in guard let self else { return } - for second in stride(from: 60, through: 1, by: -1) { + try? await Task.sleep(nanoseconds: 1_000_000_000) + for second in stride(from: 59, through: 1, by: -1) { if Task.isCancelled { return } commissionSmsCountdown = second notifyStateChange() diff --git a/suixinkan/Features/Home/Models/HomeMenuItem.swift b/suixinkan/Features/Home/Models/HomeMenuItem.swift index d5b6a21..60c32db 100644 --- a/suixinkan/Features/Home/Models/HomeMenuItem.swift +++ b/suixinkan/Features/Home/Models/HomeMenuItem.swift @@ -23,6 +23,6 @@ struct HomeMenuItem: Equatable, Hashable, Sendable { static let moreFunctions = HomeMenuItem( uri: "more_functions", title: "更多功能", - iconName: "square.grid.2x2" + iconName: "ellipsis" ) } diff --git a/suixinkan/Features/Home/Services/HomeMenuCatalog.swift b/suixinkan/Features/Home/Services/HomeMenuCatalog.swift index bf66744..6d76f04 100644 --- a/suixinkan/Features/Home/Services/HomeMenuCatalog.swift +++ b/suixinkan/Features/Home/Services/HomeMenuCatalog.swift @@ -10,35 +10,35 @@ enum HomeMenuCatalog { /// 全部已知菜单项,对齐 Android `Constants.menuList`。 static let allItems: [HomeMenuItem] = [ - HomeMenuItem(uri: "space_settings", title: "空间设置", iconName: "gearshape"), - HomeMenuItem(uri: "wallet", title: "我的钱包", iconName: "wallet.pass"), - HomeMenuItem(uri: "cloud_management", title: "相册云盘", iconName: "icloud"), - HomeMenuItem(uri: "asset_management", title: "素材管理", iconName: "photo.on.rectangle"), + HomeMenuItem(uri: "space_settings", title: "空间设置", iconName: "person.crop.square.fill"), + HomeMenuItem(uri: "wallet", title: "我的钱包", iconName: "creditcard.fill"), + HomeMenuItem(uri: "cloud_management", title: "相册云盘", iconName: "icloud.fill"), + HomeMenuItem(uri: "asset_management", title: "素材管理", iconName: "photo.on.rectangle.angled"), HomeMenuItem(uri: "task_management", title: "任务管理", iconName: "checklist"), HomeMenuItem(uri: "task_management_editor", title: "任务管理", iconName: "checklist"), HomeMenuItem(uri: "schedule_management", title: "日程管理", iconName: "calendar"), - HomeMenuItem(uri: "system_settings", title: "设置中心", iconName: "slider.horizontal.3"), - HomeMenuItem(uri: "message_center", title: "消息中心", iconName: "bell"), + 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: "sample_management", title: "样片管理", iconName: "sample_ic_home_menu_route"), - HomeMenuItem(uri: "live_stream_management", title: "直播管理", iconName: "video"), - HomeMenuItem(uri: "verification_order", title: "核销订单", iconName: "checkmark.seal"), + HomeMenuItem(uri: "sample_management", title: "样片管理", iconName: "point.3.connected.trianglepath.dotted"), + HomeMenuItem(uri: "live_stream_management", title: "直播管理", iconName: "dot.radiowaves.left.and.right"), + HomeMenuItem(uri: "verification_order", title: "核销订单", iconName: "checkmark.seal.fill"), HomeMenuItem(uri: "travel_album", title: "新增相册", iconName: "photo.badge.plus"), - HomeMenuItem(uri: "live_album", title: "直播相册", iconName: "photo.on.rectangle.angled"), - HomeMenuItem(uri: "pm", title: "项目管理", iconName: "folder"), - HomeMenuItem(uri: "pm_manager", title: "项目管理", iconName: "folder"), - HomeMenuItem(uri: "location_report", title: "位置上报", iconName: "location"), - HomeMenuItem(uri: "report_photographer", title: "举报摄影师", iconName: "exclamationmark.shield"), - HomeMenuItem(uri: "registration_invitation", title: "注册邀请", iconName: "person.badge.plus"), - HomeMenuItem(uri: "photographer_invite", title: "邀请摄影师", iconName: "person.badge.plus"), - HomeMenuItem(uri: "invite_record", title: "邀请记录", iconName: "calendar.badge.clock"), - HomeMenuItem(uri: "store", title: "店铺管理", iconName: "building.2"), - HomeMenuItem(uri: "fly", title: "飞行管理", iconName: "airplane"), - HomeMenuItem(uri: "pilot_cert", title: "飞手认证", iconName: "person.text.rectangle"), - HomeMenuItem(uri: "pilot_controller", title: "飞控", iconName: "gamecontroller"), - HomeMenuItem(uri: "/scenic-queue", title: "排队管理", iconName: "person.3"), - HomeMenuItem(uri: "operating-area", title: "运营区域", iconName: "map"), - HomeMenuItem(uri: "/scenic-order-manage", title: "景区订单", iconName: "doc.text"), + HomeMenuItem(uri: "live_album", title: "直播相册", iconName: "play.rectangle.on.rectangle.fill"), + HomeMenuItem(uri: "pm", title: "项目管理", iconName: "circle.grid.2x2.fill"), + HomeMenuItem(uri: "pm_manager", title: "项目管理", iconName: "circle.grid.2x2.fill"), + HomeMenuItem(uri: "location_report", title: "位置上报", iconName: "mappin.circle.fill"), + HomeMenuItem(uri: "report_photographer", title: "举报摄影师", iconName: "shield.fill"), + HomeMenuItem(uri: "registration_invitation", title: "注册邀请", iconName: "envelope.open.fill"), + HomeMenuItem(uri: "photographer_invite", title: "邀请摄影师", iconName: "envelope.open.fill"), + HomeMenuItem(uri: "invite_record", title: "邀请记录", iconName: "list.bullet.rectangle.fill"), + HomeMenuItem(uri: "store", title: "店铺管理", iconName: "storefront.fill"), + HomeMenuItem(uri: "fly", title: "飞行管理", iconName: "paperplane.fill"), + HomeMenuItem(uri: "pilot_cert", title: "飞手认证", iconName: "paperplane.fill"), + HomeMenuItem(uri: "pilot_controller", title: "飞控", iconName: "paperplane.fill"), + HomeMenuItem(uri: "/scenic-queue", title: "排队管理", iconName: "person.3.fill"), + HomeMenuItem(uri: "operating-area", title: "运营区域", iconName: "map.fill"), + HomeMenuItem(uri: "/scenic-order-manage", title: "景区订单", iconName: "photo.on.rectangle.angled"), HomeMenuItem(uri: "cooperation_order", title: "合作订单", iconName: "person.2"), ] diff --git a/suixinkan/Features/MaterialManagement/ViewModels/MaterialManagementViewModels.swift b/suixinkan/Features/MaterialManagement/ViewModels/MaterialManagementViewModels.swift index 1311b53..7229dea 100644 --- a/suixinkan/Features/MaterialManagement/ViewModels/MaterialManagementViewModels.swift +++ b/suixinkan/Features/MaterialManagement/ViewModels/MaterialManagementViewModels.swift @@ -541,9 +541,12 @@ final class MaterialFormViewModel { guard let self else { return } Task { @MainActor in guard self.uploadedMediaList.indices.contains(index) else { return } - self.uploadedMediaList[index].uploadProgress = progress - self.uploadedMediaList[index].isUploading = progress < 100 - self.uploadDialogState = MaterialUploadDialogState(title: title, progress: progress) + let clampedProgress = max(0, min(100, progress)) + self.uploadedMediaList[index].uploadProgress = clampedProgress + self.uploadedMediaList[index].isUploading = clampedProgress < 100 + if clampedProgress < 100 { + self.uploadDialogState = MaterialUploadDialogState(title: title, progress: clampedProgress) + } self.notifyStateChange() } } @@ -583,9 +586,12 @@ final class MaterialFormViewModel { onProgress: { [weak self] progress in guard let self else { return } Task { @MainActor in - self.coverImage?.uploadProgress = progress - self.coverImage?.isUploading = progress < 100 - self.uploadDialogState = MaterialUploadDialogState(title: "正在上传封面", progress: progress) + let clampedProgress = max(0, min(100, progress)) + self.coverImage?.uploadProgress = clampedProgress + self.coverImage?.isUploading = clampedProgress < 100 + if clampedProgress < 100 { + self.uploadDialogState = MaterialUploadDialogState(title: "正在上传封面", progress: clampedProgress) + } self.notifyStateChange() } } diff --git a/suixinkan/Features/WildPhotographerReport/API/WildPhotographerReportAPI.swift b/suixinkan/Features/WildPhotographerReport/API/WildPhotographerReportAPI.swift index 52a308e..f157e6f 100644 --- a/suixinkan/Features/WildPhotographerReport/API/WildPhotographerReportAPI.swift +++ b/suixinkan/Features/WildPhotographerReport/API/WildPhotographerReportAPI.swift @@ -24,6 +24,8 @@ protocol WildPhotographerReportServing { func reportList(_ request: WildReportListRequest) async throws -> WildReportListResponse /// 拉取单条举报详情。 func reportDetail(id: Int) async throws -> WildReportDetailData + /// 拉取举报微信小程序分享配置。 + func reportShare(id: String) async throws -> WildReportMiniProgramShareData } /// 举报摄影师 API,负责和 `photog/report` 相关接口通讯。 @@ -115,6 +117,17 @@ final class WildPhotographerReportAPI: WildPhotographerReportServing { ) ) } + + /// 拉取举报微信小程序分享配置。 + func reportShare(id: String) async throws -> WildReportMiniProgramShareData { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/report/share", + queryItems: [URLQueryItem(name: "id", value: id)] + ) + ) + } } /// 补充证据接口请求体。 diff --git a/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift b/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift index a61b1ff..b0a8072 100644 --- a/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift +++ b/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift @@ -273,6 +273,138 @@ struct WildReportListResponse: Decodable, Equatable { } } +/// 举报微信小程序分享配置,由后端按具体举报记录生成。 +struct WildReportMiniProgramShareData: Decodable, Equatable, Hashable { + let userName: String + let path: String + let title: String + let description: String + let imagePath: String + let webpageURL: String + let withShareTicket: Bool + let miniProgramTypeValue: Int + let sceneValue: Int + let disableForward: Bool + + enum CodingKeys: String, CodingKey { + case userName + case userNameSnake = "user_name" + case path + case title + case description + case imagePath + case imagePathSnake = "image_path" + case webpageURL = "webpageUrl" + case webpageURLSnake = "webpage_url" + case withShareTicket + case withShareTicketSnake = "with_share_ticket" + case miniProgramTypeValue = "miniprogramType" + case miniProgramTypeValueSnake = "mini_program_type" + case sceneValue = "scene" + case disableForward + case disableForwardSnake = "disable_forward" + } + + init( + userName: String = "", + path: String = "", + title: String = "", + description: String = "", + imagePath: String = "", + webpageURL: String = "", + withShareTicket: Bool = false, + miniProgramTypeValue: Int = WeChatMiniProgramType.release.rawValue, + sceneValue: Int = WeChatShareScene.session.rawValue, + disableForward: Bool = false + ) { + self.userName = userName + self.path = path + self.title = title + self.description = description + self.imagePath = imagePath + self.webpageURL = webpageURL + self.withShareTicket = withShareTicket + self.miniProgramTypeValue = miniProgramTypeValue + self.sceneValue = sceneValue + self.disableForward = disableForward + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + userName = try container.decodeString(primary: .userName, fallback: .userNameSnake) + path = try container.decodeIfPresent(String.self, forKey: .path) ?? "" + title = try container.decodeIfPresent(String.self, forKey: .title) ?? "" + description = try container.decodeIfPresent(String.self, forKey: .description) ?? "" + imagePath = try container.decodeString(primary: .imagePath, fallback: .imagePathSnake) + webpageURL = try container.decodeString(primary: .webpageURL, fallback: .webpageURLSnake) + withShareTicket = try container.decodeBool(primary: .withShareTicket, fallback: .withShareTicketSnake) + miniProgramTypeValue = try container.decodeInt( + primary: .miniProgramTypeValue, + fallback: .miniProgramTypeValueSnake, + defaultValue: WeChatMiniProgramType.release.rawValue + ) + sceneValue = try container.decodeInt( + primary: .sceneValue, + fallback: nil, + defaultValue: WeChatShareScene.session.rawValue + ) + disableForward = try container.decodeBool(primary: .disableForward, fallback: .disableForwardSnake) + } + + var miniProgramType: WeChatMiniProgramType { + WeChatMiniProgramType(rawValue: miniProgramTypeValue) ?? .release + } + + var scene: WeChatShareScene { + WeChatShareScene(rawValue: sceneValue) ?? .session + } + + var payload: WeChatMiniProgramSharePayload { + WeChatMiniProgramSharePayload( + title: title, + description: description, + webpageURL: webpageURL, + userName: userName, + path: path.nonEmpty, + miniProgramType: miniProgramType, + withShareTicket: withShareTicket, + disableForward: disableForward + ) + } +} + +private extension KeyedDecodingContainer where Key == WildReportMiniProgramShareData.CodingKeys { + func decodeString(primary: Key, fallback: Key?) throws -> String { + if let value = try decodeIfPresent(String.self, forKey: primary) { + return value + } + if let fallback, let value = try decodeIfPresent(String.self, forKey: fallback) { + return value + } + return "" + } + + func decodeBool(primary: Key, fallback: Key?) throws -> Bool { + if let value = try decodeIfPresent(Bool.self, forKey: primary) { + return value + } + if let fallback, let value = try decodeIfPresent(Bool.self, forKey: fallback) { + return value + } + return false + } + + func decodeInt(primary: Key, fallback: Key?, defaultValue: Int) throws -> Int { + if let value = try decodeIfPresent(Int.self, forKey: primary) { + return value + } + if let fallback, let value = try decodeIfPresent(Int.self, forKey: fallback) { + return value + } + return defaultValue + } +} + /// 我的举报列表接口单条举报数据。 struct WildReportListItem: Decodable, Equatable, Hashable { let id: Int @@ -416,19 +548,32 @@ struct WildReportDetailEvidence: Decodable, Equatable, Hashable { let fileTypeText: String let fileURL: String let fileName: String + let evidenceKind: Int + let evidenceKindText: String enum CodingKeys: String, CodingKey { case fileType = "file_type" case fileTypeText = "file_type_text" case fileURL = "file_url" case fileName = "file_name" + case evidenceKind = "evidence_kind" + case evidenceKindText = "evidence_kind_text" } - init(fileType: Int = 0, fileTypeText: String = "", fileURL: String = "", fileName: String = "") { + init( + fileType: Int = 0, + fileTypeText: String = "", + fileURL: String = "", + fileName: String = "", + evidenceKind: Int = 0, + evidenceKindText: String = "" + ) { self.fileType = fileType self.fileTypeText = fileTypeText self.fileURL = fileURL self.fileName = fileName + self.evidenceKind = evidenceKind + self.evidenceKindText = evidenceKindText } init(from decoder: Decoder) throws { @@ -437,12 +582,52 @@ struct WildReportDetailEvidence: Decodable, Equatable, Hashable { fileTypeText = try container.decodeIfPresent(String.self, forKey: .fileTypeText) ?? "" fileURL = try container.decodeIfPresent(String.self, forKey: .fileURL) ?? "" fileName = try container.decodeIfPresent(String.self, forKey: .fileName) ?? "" + evidenceKind = try container.decodeIfPresent(Int.self, forKey: .evidenceKind) ?? 0 + evidenceKindText = try container.decodeIfPresent(String.self, forKey: .evidenceKindText) ?? "" } var isVideo: Bool { fileType == 2 } var isImage: Bool { fileType == 1 || fileType == 5 } } +/// 举报详情接口返回的处理人信息。 +struct WildReportDetailHandler: Decodable, Equatable, Hashable { + let id: Int + let name: String + let phone: String + let startedAt: String + let finishedAt: String + let remark: String + + enum CodingKeys: String, CodingKey { + case id + case name + case phone + case startedAt = "started_at" + case finishedAt = "finished_at" + case remark + } + + init(id: Int = 0, name: String = "", phone: String = "", startedAt: String = "", finishedAt: String = "", remark: String = "") { + self.id = id + self.name = name + self.phone = phone + self.startedAt = startedAt + self.finishedAt = finishedAt + self.remark = remark + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0 + name = try container.decodeIfPresent(String.self, forKey: .name) ?? "" + phone = try container.decodeIfPresent(String.self, forKey: .phone) ?? "" + startedAt = try container.decodeIfPresent(String.self, forKey: .startedAt) ?? "" + finishedAt = try container.decodeIfPresent(String.self, forKey: .finishedAt) ?? "" + remark = try container.decodeIfPresent(String.self, forKey: .remark) ?? "" + } +} + /// 举报详情接口返回的补充内容或举报内容时间轴条目。 struct WildReportDetailContent: Decodable, Equatable, Hashable { let type: String @@ -534,8 +719,13 @@ struct WildReportDetailData: Decodable, Equatable, Hashable { let handleStatus: Int let handleStatusText: String let handleRemark: String + let handledAt: String + let handler: WildReportDetailHandler? + let handlerName: String + let handlerPhone: String let evidenceSummary: WildReportEvidenceSummary? let evidences: [WildReportDetailEvidence] + let handleEvidences: [WildReportDetailEvidence] let paymentScreenshots: [WildReportDetailEvidence] let timeline: [WildReportDetailTimelineItem] let createdAt: String @@ -560,8 +750,13 @@ struct WildReportDetailData: Decodable, Equatable, Hashable { case handleStatus = "handle_status" case handleStatusText = "handle_status_text" case handleRemark = "handle_remark" + case handledAt = "handled_at" + case handler + case handlerName = "handler_name" + case handlerPhone = "handler_phone" case evidenceSummary = "evidence_summary" case evidences + case handleEvidences = "handle_evidences" case paymentScreenshots = "payment_screenshots" case timeline case createdAt = "created_at" @@ -587,8 +782,13 @@ struct WildReportDetailData: Decodable, Equatable, Hashable { handleStatus: Int = 0, handleStatusText: String = "", handleRemark: String = "", + handledAt: String = "", + handler: WildReportDetailHandler? = nil, + handlerName: String = "", + handlerPhone: String = "", evidenceSummary: WildReportEvidenceSummary? = nil, evidences: [WildReportDetailEvidence] = [], + handleEvidences: [WildReportDetailEvidence] = [], paymentScreenshots: [WildReportDetailEvidence] = [], timeline: [WildReportDetailTimelineItem] = [], createdAt: String = "" @@ -612,8 +812,13 @@ struct WildReportDetailData: Decodable, Equatable, Hashable { self.handleStatus = handleStatus self.handleStatusText = handleStatusText self.handleRemark = handleRemark + self.handledAt = handledAt + self.handler = handler + self.handlerName = handlerName + self.handlerPhone = handlerPhone self.evidenceSummary = evidenceSummary self.evidences = evidences + self.handleEvidences = handleEvidences self.paymentScreenshots = paymentScreenshots self.timeline = timeline self.createdAt = createdAt @@ -640,8 +845,13 @@ struct WildReportDetailData: Decodable, Equatable, Hashable { handleStatus = try container.decodeIfPresent(Int.self, forKey: .handleStatus) ?? 0 handleStatusText = try container.decodeIfPresent(String.self, forKey: .handleStatusText) ?? "" handleRemark = try container.decodeIfPresent(String.self, forKey: .handleRemark) ?? "" + handledAt = try container.decodeIfPresent(String.self, forKey: .handledAt) ?? "" + handler = try container.decodeIfPresent(WildReportDetailHandler.self, forKey: .handler) + handlerName = try container.decodeIfPresent(String.self, forKey: .handlerName) ?? "" + handlerPhone = try container.decodeIfPresent(String.self, forKey: .handlerPhone) ?? "" evidenceSummary = try container.decodeIfPresent(WildReportEvidenceSummary.self, forKey: .evidenceSummary) evidences = try container.decodeIfPresent([WildReportDetailEvidence].self, forKey: .evidences) ?? [] + handleEvidences = try container.decodeIfPresent([WildReportDetailEvidence].self, forKey: .handleEvidences) ?? [] paymentScreenshots = try container.decodeIfPresent([WildReportDetailEvidence].self, forKey: .paymentScreenshots) ?? [] timeline = try container.decodeIfPresent([WildReportDetailTimelineItem].self, forKey: .timeline) ?? [] createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? "" @@ -684,6 +894,40 @@ struct WildReportDetailData: Decodable, Equatable, Hashable { } } + var detailHandlerInfo: WildReportHandlerInfo? { + let resolvedName = handler?.name.nonEmpty ?? handlerName.nonEmpty ?? "" + let resolvedPhone = handler?.phone.nonEmpty ?? handlerPhone.nonEmpty ?? "" + let processingAt = handler?.startedAt.nonEmpty ?? "" + let processedAt = handler?.finishedAt.nonEmpty ?? handledAt.nonEmpty ?? "" + let processOpinion = handler?.remark.nonEmpty ?? handleRemark.nonEmpty ?? "" + let media = handleEvidences.enumerated().compactMap { index, evidence -> WildReportCompletionMediaItem? in + guard let url = evidence.fileURL.nonEmpty else { return nil } + return WildReportCompletionMediaItem( + id: "\(index)-\(url)", + kind: evidence.isVideo ? .video : .image, + coverURL: url, + duration: evidence.isVideo ? evidence.fileTypeText.nonEmpty : nil + ) + } + guard !resolvedName.isEmpty + || !resolvedPhone.isEmpty + || !processingAt.isEmpty + || !processedAt.isEmpty + || !processOpinion.isEmpty + || !media.isEmpty + else { + return nil + } + return WildReportHandlerInfo( + handlerName: resolvedName, + handlerPhone: resolvedPhone, + processingAt: processingAt, + processedAt: processedAt, + processOpinion: processOpinion, + completionMedia: media + ) + } + /// 转换为当前 UIKit 列表和详情页复用的举报记录实体。 func toRecord(fallback: WildReportRecord? = nil) -> WildReportRecord { WildReportRecord( @@ -702,7 +946,7 @@ struct WildReportDetailData: Decodable, Equatable, Hashable { phoneNumber: contactPhone.nonEmpty ?? fallback?.phoneNumber ?? "", imageCount: imageCount, videoCount: videoCount, - handlerInfo: fallback?.handlerInfo + handlerInfo: detailHandlerInfo ?? fallback?.handlerInfo ) } } @@ -724,6 +968,14 @@ struct WildReportSupplementaryEvidence: Hashable { } } +/// 举报补充证据展示文案格式化工具。 +enum WildReportSupplementDisplayFormatter { + /// 返回按当前列表展示顺序从 1 起算的补充次数文案。 + static func sequenceTitle(forDisplayIndex index: Int) -> String { + "第\(max(0, index) + 1)次补充" + } +} + /// 举报位置解析工具。 enum WildReportLocationParser { /// 将「景区 · 详细地址」拆成两段展示文案。 diff --git a/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift b/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift index f846ca6..5242e8c 100644 --- a/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift +++ b/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift @@ -291,7 +291,6 @@ final class WildPhotographerReportSubmitViewModel { guard !isSubmitting else { return } isSubmitting = true - submitProgressText = "正在上传证据" notifyStateChange() defer { isSubmitting = false @@ -303,19 +302,13 @@ final class WildPhotographerReportSubmitViewModel { let evidenceRequests = try await uploadAttachments( images + videos, uploader: uploader, - scenicId: scenicId, - progressPrefix: "正在上传现场证据" + scenicId: scenicId ) let paymentRequests = try await uploadAttachments( paymentImages, uploader: uploader, - scenicId: scenicId, - progressPrefix: "正在上传支付截图", - completedOffset: evidenceRequests.count, - totalOverride: evidenceRequests.count + paymentImages.count + scenicId: scenicId ) - submitProgressText = "正在提交举报" - notifyStateChange() let submitResponse = try await api.submitReport(buildSubmitRequest( scenicId: scenicId, @@ -447,27 +440,17 @@ final class WildPhotographerReportSubmitViewModel { private func uploadAttachments( _ attachments: [WildReportAttachment], uploader: any WildReportAttachmentUploading, - scenicId: Int, - progressPrefix: String, - completedOffset: Int = 0, - totalOverride: Int? = nil + scenicId: Int ) async throws -> [WildReportEvidenceRequest] { var requests: [WildReportEvidenceRequest] = [] - let total = totalOverride ?? attachments.count - for (index, attachment) in attachments.enumerated() { - let current = completedOffset + index + 1 - submitProgressText = "\(progressPrefix) \(current)/\(max(total, current))" - notifyStateChange() + for attachment in attachments { let data = try attachment.uploadData() let url = try await uploader.uploadWildReportAttachment( data: data, fileName: attachment.fileName, fileType: attachment.fileType, scenicId: scenicId - ) { [weak self] progress in - self?.submitProgressText = "\(progressPrefix) \(current)/\(max(total, current)) · \(progress)%" - self?.notifyStateChange() - } + ) { _ in } requests.append(WildReportEvidenceRequest( fileURL: url, fileType: attachment.fileType, @@ -666,9 +649,21 @@ final class WildPhotographerReportDetailViewModel { showActionMessage("举报位置:\(record.location)") } - /// 生成分享提示文案。 - func shareReport() { - showActionMessage("已生成举报 \(record.id) 的分享信息,可发送给景区处理人员查看进度。") + /// 拉取当前举报的小程序分享载荷。 + func shareReport(api: any WildPhotographerReportServing) async -> WeChatMiniProgramSharePayload? { + guard let serverID = record.serverID, serverID > 0 else { + showActionMessage("当前举报暂未同步到服务器,无法分享") + return nil + } + do { + let shareData = try await api.reportShare(id: String(serverID)) + return shareData.payload + } catch is CancellationError { + return nil + } catch { + showActionMessage(error.localizedDescription) + return nil + } } private func showActionMessage(_ message: String) { @@ -782,11 +777,27 @@ final class WildPhotographerReportListViewModel { } } - /// 生成分享提示文案。 - func shareReport(_ record: WildReportRecord) { - shareMessage = "已生成举报 \(record.id) 的分享信息,可发送给景区处理人员查看进度。" - onShowMessage?(shareMessage ?? "") - notifyStateChange() + /// 拉取指定举报的小程序分享载荷。 + func shareReport(_ record: WildReportRecord, api: any WildPhotographerReportServing) async -> WeChatMiniProgramSharePayload? { + guard let serverID = record.serverID, serverID > 0 else { + shareMessage = "当前举报暂未同步到服务器,无法分享" + onShowMessage?(shareMessage ?? "") + notifyStateChange() + return nil + } + do { + let shareData = try await api.reportShare(id: String(serverID)) + shareMessage = nil + notifyStateChange() + return shareData.payload + } catch is CancellationError { + return nil + } catch { + shareMessage = error.localizedDescription + onShowMessage?(shareMessage ?? "") + notifyStateChange() + return nil + } } private func reload(api: any WildPhotographerReportServing, scenicId: Int?) async { @@ -907,7 +918,6 @@ final class WildReportSupplementEvidenceViewModel { guard !isSubmitting else { return } isSubmitting = true - submitProgressText = evidenceCount > 0 ? "正在上传补充证据" : "正在提交补充证据" notifyStateChange() defer { isSubmitting = false @@ -921,8 +931,6 @@ final class WildReportSupplementEvidenceViewModel { uploader: uploader, scenicId: scenicId ) - submitProgressText = "正在提交补充证据" - notifyStateChange() try await api.supplementReport(WildReportSupplementRequest( id: serverID ?? 0, @@ -980,20 +988,14 @@ final class WildReportSupplementEvidenceViewModel { scenicId: Int ) async throws -> [WildReportEvidenceRequest] { var requests: [WildReportEvidenceRequest] = [] - for (index, attachment) in attachments.enumerated() { - let current = index + 1 - submitProgressText = "正在上传补充证据 \(current)/\(attachments.count)" - notifyStateChange() + for attachment in attachments { let data = try attachment.uploadData() let url = try await uploader.uploadWildReportAttachment( data: data, fileName: attachment.fileName, fileType: attachment.fileType, scenicId: scenicId - ) { [weak self] progress in - self?.submitProgressText = "正在上传补充证据 \(current)/\(attachments.count) · \(progress)%" - self?.notifyStateChange() - } + ) { _ in } requests.append(WildReportEvidenceRequest( fileURL: url, fileType: attachment.fileType, diff --git a/suixinkan/Features/WildPhotographerReport/举报摄影师接口.md b/suixinkan/Features/WildPhotographerReport/举报摄影师接口.md index 936be80..f53ca7b 100644 --- a/suixinkan/Features/WildPhotographerReport/举报摄影师接口.md +++ b/suixinkan/Features/WildPhotographerReport/举报摄影师接口.md @@ -9,7 +9,7 @@ - 网络入口:`NetworkServices.shared.wildPhotographerReportAPI` - 后端成功码:`100000` - 响应包裹:工程统一 `APIEnvelope` -- 当前状态:“首页文案”、“举报类型”、“提交举报”、“我的举报列表”、“举报详情”、“补充证据提交”和“附近风险地图”已接接口;模块内生产 Mock 数据已移除。 +- 当前状态:“首页文案”、“举报类型”、“提交举报”、“我的举报列表”、“举报详情”、“微信小程序分享”、“补充证据提交”和“附近风险地图”已接接口;模块内生产 Mock 数据已移除。 ## 首页文案 @@ -334,7 +334,7 @@ struct WildReportListResponse: Decodable, Equatable { ### 用途 -“举报详情”页面进入时调用。列表页传入 `WildReportRecord.serverID`,详情接口返回完整举报信息、现场证据、支付截图、补充内容和处理时间线。 +“举报详情”页面进入时调用。列表页传入 `WildReportRecord.serverID`,详情接口返回完整举报信息、现场证据、支付截图、补充内容、处理信息和处理时间线。 ### 请求 @@ -358,7 +358,10 @@ GET /api/yf-handset-app/photog/report/detail?id=32 - `supplements` / `report_contents`:补充内容记录,优先使用 `supplements`,为空时从 `report_contents` 过滤 `type=supplement`。 - `location_name` / `location_address`:详情页位置展示。 - `handle_status` / `handle_status_text`:处理状态;iOS 支持 0 待处理、1 处理中、2 已处理、3 驳回。 -- `handle_remark`:处理备注,有值时在详情信息卡展示。 +- `handle_remark`:处理备注;`handler.remark` 为空时作为“处理信息”卡的处理意见兜底。 +- `handler`:处理人结构,包含 `name`、`phone`、`started_at`、`finished_at`、`remark`,详情页“处理信息”卡优先使用。 +- `handler_name` / `handler_phone` / `handled_at`:处理信息顶层兼容字段;`handler` 缺字段时作为兜底。 +- `handle_evidences`:处理凭证附件数组,字段同证据附件,并额外支持 `evidence_kind` / `evidence_kind_text`。 - `timeline`:处理进度时间线;为空时只展示列表记录中的当前处理状态,不生成本地模拟流程。 ### iOS 模型 @@ -366,6 +369,7 @@ GET /api/yf-handset-app/photog/report/detail?id=32 ```swift struct WildReportDetailData: Decodable, Equatable, Hashable struct WildReportDetailEvidence: Decodable, Equatable, Hashable +struct WildReportDetailHandler: Decodable, Equatable, Hashable struct WildReportDetailContent: Decodable, Equatable, Hashable struct WildReportDetailTimelineItem: Decodable, Equatable, Hashable ``` @@ -376,6 +380,9 @@ struct WildReportDetailTimelineItem: Decodable, Equatable, Hashable - 如果传入记录没有 `serverID`,说明来自本地提交成功页等临时记录,不请求详情接口。 - 详情接口失败时保留现有基础信息,并 toast 错误。 - 图片证据使用 Kingfisher 加载网络图;视频证据展示视频样式缩略块和文件信息。 +- 状态为已处理或驳回且存在处理信息时展示“处理信息”卡:处理人、处理人电话、处理时间、处理完成时间、处理意见。 +- “处理人电话”展示脱敏文案,点击使用接口原始 `handler.phone` 或 `handler_phone` 发起 `tel://`。 +- `handle_evidences` 非空时在“处理信息”卡内展示“处理凭证”横向缩略图,`file_type=2` 按视频处理,其余按图片处理。 ### 当前调用位置 @@ -383,7 +390,62 @@ struct WildReportDetailTimelineItem: Decodable, Equatable, Hashable - `WildPhotographerReportDetailViewModel.loadDetailIfNeeded(api:)` - `WildPhotographerReportAPI.reportDetail(id:)` -## 5. 补充证据提交 +## 5. 举报微信小程序分享 + +### 用途 + +“我的举报”列表卡片和“举报详情”头部点击分享时调用,后端按举报 ID 返回微信小程序分享配置,iOS 映射为 `WeChatMiniProgramSharePayload` 后调用 `WeChatShareService.shareMiniProgram(...)`。 + +### 请求 + +```http +GET /api/yf-handset-app/photog/report/share?id=32 +``` + +### 请求参数 + +| 参数 | 类型 | 说明 | +| --- | --- | --- | +| `id` | string | 必填,举报记录服务端 ID;iOS 使用 `WildReportRecord.serverID` | + +### 响应示例 + +```json +{ + "code": 100000, + "msg": "success", + "data": { + "userName": "wx8c85189bf3bdda29", + "path": "pages/scenic/radar/report-detail/index?id=32", + "title": "私下收款举报", + "imagePath": "/assets/share/report-share-cover.png", + "webpageUrl": "https://www.youfuntour.com", + "withShareTicket": true, + "miniprogramType": 0, + "scene": 0 + }, + "time": "2026-07-09 15:42:19" +} +``` + +### 字段说明 + +- `title`:微信分享卡片标题,不能为空。 +- `webpageUrl`:小程序打不开时的兼容网页地址,微信要求不超过 1024 字节。 +- `userName`:微信 SDK `WXMiniProgramObject.userName` 字段;请后端确认返回值是否应为小程序原始 ID `gh_...`。 +- `path`:小程序页面路径,可携带举报 ID 或分享 token。 +- `miniprogramType`:0 正式版、1 开发版、2 体验版;由后端按环境明确返回。 +- `withShareTicket`:是否携带群分享票据。 +- `scene`:当前仅支持 0 微信好友会话;非 0 会被通用微信分享模块拒绝。 +- `imagePath`:当前返回的是小程序内资源路径,iOS 不下载;微信小程序预览图暂使用 App 图标兜底。若后续需要定制卡片图,请返回可下载的 `https` 图片地址。 + +### 当前调用位置 + +- `WildPhotographerReportListViewController` 列表卡片分享按钮 +- `WildPhotographerReportDetailViewController` 详情头部分享按钮 +- `WildPhotographerReportAPI.reportShare(id:)` + +## 6. 补充证据提交 ### 请求 diff --git a/suixinkan/Theme/AppColor.swift b/suixinkan/Theme/AppColor.swift index 158d321..fb98a50 100644 --- a/suixinkan/Theme/AppColor.swift +++ b/suixinkan/Theme/AppColor.swift @@ -13,6 +13,7 @@ enum AppColor { static let primary = UIColor(hex: 0x0073FF) static let primaryBanner = UIColor(hex: 0x1677FF) static let primaryLight = UIColor(hex: 0xEFF6FF) + static let primaryGradientEnd = UIColor(hex: 0x5CA8FF) // MARK: - Background @@ -20,11 +21,13 @@ enum AppColor { static let pageBackgroundSoft = UIColor(hex: 0xF7FAFF) static let cardBackground = UIColor.white static let inputBackground = UIColor(hex: 0xF4F4F4) + static let iconBackground = UIColor(hex: 0xEAF3FF) // MARK: - Border static let border = UIColor(hex: 0xEEEEEE) static let cardOutline = UIColor(hex: 0xE9ECEF) + static let tabBarShadow = UIColor(hex: 0xE9EDF3) // MARK: - Text @@ -44,6 +47,8 @@ enum AppColor { static let info = UIColor(hex: 0x0073FF) static let infoBackground = UIColor(hex: 0xE8F4FF) static let online = UIColor(hex: 0x22C55E) + static let controlDisabledStart = UIColor(hex: 0xC9CED6) + static let controlDisabledEnd = UIColor(hex: 0xD9DDE4) // MARK: - Overlay diff --git a/suixinkan/UI/CooperationOrder/CooperationAcquirerViewController.swift b/suixinkan/UI/CooperationOrder/CooperationAcquirerViewController.swift index 2209f88..a44ba55 100644 --- a/suixinkan/UI/CooperationOrder/CooperationAcquirerViewController.swift +++ b/suixinkan/UI/CooperationOrder/CooperationAcquirerViewController.swift @@ -17,6 +17,7 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa private let refreshControl = UIRefreshControl() private let emptyLabel = UILabel() private var shouldRefreshOnAppear = false + private var commissionDialogView: CooperationCommissionRateDialogView? override func setupNavigationBar() { title = "合作获客员" @@ -32,6 +33,10 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa override func setupUI() { tableView.backgroundColor = .clear tableView.separatorStyle = .none + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 142 + tableView.contentInset.top = AppSpacing.sm + tableView.verticalScrollIndicatorInsets.top = AppSpacing.sm tableView.dataSource = self tableView.delegate = self tableView.register(CooperationAcquirerCell.self, forCellReuseIdentifier: CooperationAcquirerCell.reuseIdentifier) @@ -145,6 +150,7 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa } emptyLabel.isHidden = !viewModel.acquirers.isEmpty || viewModel.isRefreshing tableView.reloadData() + applyCommissionDialogState() } private func presentRemarkDialog(for acquirer: CooperativeSalerEntity) { @@ -168,44 +174,56 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa private func presentCommissionDialog(for acquirer: CooperativeSalerEntity) { viewModel.startEditCommissionRate(acquirer) - let alert = UIAlertController(title: "修改分成比例", message: nil, preferredStyle: .alert) - alert.addTextField { [weak self] field in - field.placeholder = "分成比例 0-100" - field.keyboardType = .numberPad - field.text = self?.viewModel.commissionRateDraft - } - alert.addTextField { [weak self] field in - field.placeholder = "验证码" - field.keyboardType = .numberPad - field.text = self?.viewModel.commissionSmsCodeDraft - } - let phone = acquirer.salerPhone.isEmpty ? acquirer.displayPhone : acquirer.salerPhone - alert.message = "验证码将发送至 \(CooperationOrderPhoneMask.mask(phone))" + commissionDialogView?.dismiss() - alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in - self?.viewModel.dismissCommissionRateDialog() - }) - alert.addAction(UIAlertAction(title: "获取验证码", style: .default) { [weak self] _ in + let dialog = CooperationCommissionRateDialogView() + dialog.onCommissionRateChange = { [weak self] text in + self?.viewModel.updateCommissionRateDraft(text) + } + dialog.onSmsCodeChange = { [weak self] text in + self?.viewModel.updateCommissionSmsCodeDraft(text) + } + dialog.onSendSmsCode = { [weak self, weak dialog] in guard let self else { return } - if let rateText = alert.textFields?.first?.text { - self.viewModel.updateCommissionRateDraft(rateText) - } - if let codeText = alert.textFields?.last?.text { - self.viewModel.updateCommissionSmsCodeDraft(codeText) + if let dialog { + self.syncCommissionDialogDrafts(dialog) } Task { await self.viewModel.requestCommissionSmsCode(api: self.orderAPI) } - }) - alert.addAction(UIAlertAction(title: "确认修改", style: .default) { [weak self] _ in + } + dialog.onCancel = { [weak self] in + self?.viewModel.dismissCommissionRateDialog() + } + dialog.onConfirm = { [weak self, weak dialog] in guard let self else { return } - if let rateText = alert.textFields?.first?.text { - self.viewModel.updateCommissionRateDraft(rateText) - } - if let codeText = alert.textFields?.last?.text { - self.viewModel.updateCommissionSmsCodeDraft(codeText) + if let dialog { + self.syncCommissionDialogDrafts(dialog) } Task { await self.viewModel.saveCommissionRate(api: self.orderAPI) } - }) - present(alert, animated: true) + } + commissionDialogView = dialog + applyCommissionDialogState() + dialog.show(in: view) + } + + private func syncCommissionDialogDrafts(_ dialog: CooperationCommissionRateDialogView) { + viewModel.updateCommissionRateDraft(dialog.currentCommissionRate) + viewModel.updateCommissionSmsCodeDraft(dialog.currentSmsCode) + } + + private func applyCommissionDialogState() { + guard let dialog = commissionDialogView else { return } + guard let acquirer = viewModel.commissionEditingAcquirer else { + dialog.dismiss() + commissionDialogView = nil + return + } + let phone = acquirer.salerPhone.isEmpty ? acquirer.displayPhone : acquirer.salerPhone + dialog.apply( + commissionRate: viewModel.commissionRateDraft, + smsCode: viewModel.commissionSmsCodeDraft, + phone: CooperationOrderPhoneMask.mask(phone), + countdown: viewModel.commissionSmsCountdown + ) } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { diff --git a/suixinkan/UI/CooperationOrder/CooperationOrderListViewController.swift b/suixinkan/UI/CooperationOrder/CooperationOrderListViewController.swift index 6b16936..e6f168b 100644 --- a/suixinkan/UI/CooperationOrder/CooperationOrderListViewController.swift +++ b/suixinkan/UI/CooperationOrder/CooperationOrderListViewController.swift @@ -21,6 +21,8 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD private let footerLabel = UILabel() private let emptyLabel = UILabel() private var isApplyingTab = false + private var isSearchBarVisible = false + private var searchBarHeightConstraint: Constraint? override func setupNavigationBar() { title = "合作订单" @@ -39,6 +41,8 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD tableView.backgroundColor = .clear tableView.separatorStyle = .none + tableView.contentInset.top = AppSpacing.sm + tableView.verticalScrollIndicatorInsets.top = AppSpacing.sm tableView.dataSource = self tableView.delegate = self tableView.register(ReferralLeadCell.self, forCellReuseIdentifier: ReferralLeadCell.reuseIdentifier) @@ -75,6 +79,7 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD searchBar.snp.makeConstraints { make in make.top.equalTo(tabBarView.snp.bottom) make.leading.trailing.equalToSuperview() + searchBarHeightConstraint = make.height.equalTo(0).constraint } tableView.snp.makeConstraints { make in make.top.equalTo(searchBar.snp.bottom) @@ -139,7 +144,7 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD tabBarView.setSelectedTab(viewModel.selectedTab) isApplyingTab = false } - searchBar.isHidden = viewModel.selectedTab != 1 + updateSearchBarVisibility(viewModel.selectedTab == 1) searchBar.textField.text = viewModel.searchText let isEmpty = viewModel.selectedTab == 0 ? viewModel.leads.isEmpty : viewModel.orders.isEmpty @@ -163,6 +168,24 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD tableView.reloadData() } + private func updateSearchBarVisibility(_ isVisible: Bool) { + guard isSearchBarVisible != isVisible else { return } + isSearchBarVisible = isVisible + searchBarHeightConstraint?.update(offset: isVisible ? 62 : 0) + if isVisible { + searchBar.isHidden = false + } else { + searchBar.textField.resignFirstResponder() + } + UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut]) { + self.view.layoutIfNeeded() + } completion: { _ in + if self.isSearchBarVisible == isVisible { + self.searchBar.isHidden = !isVisible + } + } + } + private func tableFooterView(isEmpty: Bool) -> UIView? { guard !isEmpty else { return nil } if viewModel.isLoadingMore { diff --git a/suixinkan/UI/CooperationOrder/Views/CooperationOrderViews.swift b/suixinkan/UI/CooperationOrder/Views/CooperationOrderViews.swift index a618354..d456606 100644 --- a/suixinkan/UI/CooperationOrder/Views/CooperationOrderViews.swift +++ b/suixinkan/UI/CooperationOrder/Views/CooperationOrderViews.swift @@ -120,9 +120,6 @@ final class CooperationOrderSearchBar: UIView, UITextFieldDelegate { container.addSubview(icon) container.addSubview(textField) - snp.makeConstraints { make in - make.height.equalTo(62) - } container.snp.makeConstraints { make in make.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.centerY.equalToSuperview() @@ -451,12 +448,18 @@ final class CooperationAcquirerCell: UITableViewCell { var onCall: (() -> Void)? private let cardView = UIView() - private let nameLabel = UILabel() + private let avatarContainer = UIView() + private let avatarIconView = UIImageView(image: UIImage(systemName: "person.fill")) + private let nameTitleLabel = UILabel() + private let nameValueLabel = UILabel() private let editRemarkButton = UIButton(type: .system) - private let phoneLabel = UILabel() + private let phoneTitleLabel = UILabel() + private let phoneValueLabel = UILabel() private let callButton = UIButton(type: .system) - private let bindTimeRow = CooperationInfoRowView(label: "绑定时间") - private let commissionRow = CooperationInfoRowView(label: "分成比例") + private let bindTimeTitleLabel = UILabel() + private let bindTimeValueLabel = UILabel() + private let commissionTitleLabel = UILabel() + private let commissionValueLabel = UILabel() private let editCommissionButton = UIButton(type: .system) override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -466,70 +469,131 @@ final class CooperationAcquirerCell: UITableViewCell { contentView.backgroundColor = .clear cardView.backgroundColor = .white - cardView.layer.cornerRadius = AppRadius.lg + cardView.layer.cornerRadius = AppRadius.md + cardView.layer.shadowColor = UIColor.black.cgColor + cardView.layer.shadowOpacity = 0.04 + cardView.layer.shadowOffset = CGSize(width: 0, height: 1) + cardView.layer.shadowRadius = 2 - nameLabel.font = .systemFont(ofSize: 16, weight: .bold) - nameLabel.textColor = AppColor.textPrimary + avatarContainer.backgroundColor = AppColor.primary.withAlphaComponent(0.08) + avatarContainer.layer.cornerRadius = AppRadius.sm + avatarContainer.clipsToBounds = true + + avatarIconView.tintColor = AppColor.primary + avatarIconView.contentMode = .scaleAspectFit + + configureTitleLabel(nameTitleLabel, text: "获客员名称") + configureTitleLabel(phoneTitleLabel, text: "手机号") + configureTitleLabel(bindTimeTitleLabel, text: "绑定时间") + configureTitleLabel(commissionTitleLabel, text: "分成比例") + + nameValueLabel.font = .systemFont(ofSize: 15, weight: .bold) + nameValueLabel.textColor = AppColor.textPrimary + nameValueLabel.numberOfLines = 1 + nameValueLabel.lineBreakMode = .byTruncatingTail + + [phoneValueLabel, bindTimeValueLabel, commissionValueLabel].forEach { label in + label.font = .systemFont(ofSize: 13) + label.textColor = AppColor.textSecondary + label.numberOfLines = 1 + label.lineBreakMode = .byTruncatingTail + } + commissionValueLabel.font = .systemFont(ofSize: 13, weight: .medium) editRemarkButton.setTitle("修改备注", for: .normal) editRemarkButton.titleLabel?.font = .systemFont(ofSize: 13) editRemarkButton.setTitleColor(AppColor.primary, for: .normal) + editRemarkButton.contentEdgeInsets = UIEdgeInsets(top: 16, left: 14, bottom: 16, right: 14) editRemarkButton.addTarget(self, action: #selector(editRemarkTapped), for: .touchUpInside) - phoneLabel.font = .systemFont(ofSize: 13) - phoneLabel.textColor = AppColor.textSecondary - callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal) callButton.tintColor = AppColor.primary callButton.backgroundColor = AppColor.primaryLight callButton.layer.cornerRadius = 15 + callButton.imageView?.contentMode = .scaleAspectFit callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside) - editCommissionButton.setTitle("修改", for: .normal) + editCommissionButton.setTitle("修改比例", for: .normal) editCommissionButton.titleLabel?.font = .systemFont(ofSize: 13) editCommissionButton.setTitleColor(AppColor.primary, for: .normal) + editCommissionButton.contentEdgeInsets = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8) editCommissionButton.addTarget(self, action: #selector(editCommissionTapped), for: .touchUpInside) contentView.addSubview(cardView) - cardView.addSubview(nameLabel) + cardView.addSubview(avatarContainer) + avatarContainer.addSubview(avatarIconView) + cardView.addSubview(nameTitleLabel) + cardView.addSubview(nameValueLabel) cardView.addSubview(editRemarkButton) - cardView.addSubview(phoneLabel) + cardView.addSubview(phoneTitleLabel) + cardView.addSubview(phoneValueLabel) cardView.addSubview(callButton) - cardView.addSubview(bindTimeRow) - cardView.addSubview(commissionRow) + cardView.addSubview(bindTimeTitleLabel) + cardView.addSubview(bindTimeValueLabel) + cardView.addSubview(commissionTitleLabel) + cardView.addSubview(commissionValueLabel) cardView.addSubview(editCommissionButton) cardView.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: AppSpacing.md, bottom: AppSpacing.sm, right: AppSpacing.md)) + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 14, bottom: AppSpacing.sm, right: 14)) } - nameLabel.snp.makeConstraints { make in - make.top.leading.equalToSuperview().offset(14) + avatarContainer.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(14) + make.centerY.equalToSuperview() + make.size.equalTo(44) + } + avatarIconView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(22) + } + nameTitleLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(AppSpacing.md) + make.leading.equalTo(avatarContainer.snp.trailing).offset(AppSpacing.sm) + } + nameValueLabel.snp.makeConstraints { make in + make.centerY.equalTo(nameTitleLabel) + make.leading.equalTo(nameTitleLabel.snp.trailing).offset(AppSpacing.sm) + make.trailing.lessThanOrEqualTo(editRemarkButton.snp.leading).offset(-AppSpacing.xs) } editRemarkButton.snp.makeConstraints { make in - make.centerY.equalTo(nameLabel) - make.leading.equalTo(nameLabel.snp.trailing).offset(AppSpacing.xs) + make.top.trailing.equalToSuperview() } - phoneLabel.snp.makeConstraints { make in - make.top.equalTo(nameLabel.snp.bottom).offset(10) - make.leading.equalToSuperview().offset(14) + phoneTitleLabel.snp.makeConstraints { make in + make.top.equalTo(nameTitleLabel.snp.bottom).offset(AppSpacing.xs) + make.leading.equalTo(nameTitleLabel) + } + phoneValueLabel.snp.makeConstraints { make in + make.centerY.equalTo(phoneTitleLabel) + make.leading.equalTo(phoneTitleLabel.snp.trailing).offset(AppSpacing.sm) + make.trailing.lessThanOrEqualTo(callButton.snp.leading).offset(-AppSpacing.xs) } callButton.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(14) - make.centerY.equalTo(phoneLabel) + make.centerY.equalTo(phoneTitleLabel) make.size.equalTo(30) } - bindTimeRow.snp.makeConstraints { make in - make.top.equalTo(phoneLabel.snp.bottom).offset(10) - make.leading.trailing.equalToSuperview().inset(14) + bindTimeTitleLabel.snp.makeConstraints { make in + make.top.equalTo(phoneTitleLabel.snp.bottom).offset(AppSpacing.xs) + make.leading.equalTo(nameTitleLabel) } - commissionRow.snp.makeConstraints { make in - make.top.equalTo(bindTimeRow.snp.bottom).offset(10) - make.leading.equalToSuperview().offset(14) - make.bottom.equalToSuperview().inset(14) + bindTimeValueLabel.snp.makeConstraints { make in + make.centerY.equalTo(bindTimeTitleLabel) + make.leading.equalTo(bindTimeTitleLabel.snp.trailing).offset(AppSpacing.sm) + make.trailing.lessThanOrEqualToSuperview().inset(14) + } + commissionTitleLabel.snp.makeConstraints { make in + make.top.equalTo(bindTimeTitleLabel.snp.bottom).offset(AppSpacing.xs) + make.leading.equalTo(nameTitleLabel) + make.bottom.equalToSuperview().inset(AppSpacing.md) + } + commissionValueLabel.snp.makeConstraints { make in + make.centerY.equalTo(commissionTitleLabel) + make.leading.equalTo(commissionTitleLabel.snp.trailing).offset(AppSpacing.sm) + make.trailing.lessThanOrEqualTo(editCommissionButton.snp.leading).offset(-AppSpacing.xs) } editCommissionButton.snp.makeConstraints { make in - make.centerY.equalTo(commissionRow) - make.leading.equalTo(commissionRow.snp.trailing).offset(AppSpacing.xs) + make.centerY.equalTo(commissionTitleLabel) + make.trailing.equalToSuperview().inset(AppSpacing.xs) } } @@ -538,11 +602,26 @@ final class CooperationAcquirerCell: UITableViewCell { fatalError("init(coder:) has not been implemented") } + override func prepareForReuse() { + super.prepareForReuse() + onEditRemark = nil + onEditCommission = nil + onCall = nil + } + func configure(with acquirer: CooperativeSalerEntity) { - nameLabel.text = acquirer.displayName.isEmpty ? "—" : acquirer.displayName - phoneLabel.text = "手机号 \(CooperationOrderPhoneMask.mask(acquirer.displayPhone))" - bindTimeRow.setValue(acquirer.displayBindTime.isEmpty ? "—" : acquirer.displayBindTime) - commissionRow.setValue(acquirer.displayCommissionRate) + nameValueLabel.text = acquirer.displayName.isEmpty ? "—" : acquirer.displayName + phoneValueLabel.text = acquirer.displayPhone.isEmpty ? "—" : CooperationOrderPhoneMask.mask(acquirer.displayPhone) + bindTimeValueLabel.text = acquirer.displayBindTime.isEmpty ? "—" : acquirer.displayBindTime + commissionValueLabel.text = acquirer.displayCommissionRate.isEmpty ? "—" : acquirer.displayCommissionRate + } + + private func configureTitleLabel(_ label: UILabel, text: String) { + label.text = text + label.font = .systemFont(ofSize: 13) + label.textColor = AppColor.textTertiary + label.setContentHuggingPriority(.required, for: .horizontal) + label.setContentCompressionResistancePriority(.required, for: .horizontal) } @objc private func editRemarkTapped() { onEditRemark?() } @@ -550,6 +629,364 @@ final class CooperationAcquirerCell: UITableViewCell { @objc private func callTapped() { onCall?() } } +/// 修改获客员分成比例弹窗,对齐 Android `CommissionRateEditDialog`。 +final class CooperationCommissionRateDialogView: UIView { + + var onCommissionRateChange: ((String) -> Void)? + var onSmsCodeChange: ((String) -> Void)? + var onSendSmsCode: (() -> Void)? + var onCancel: (() -> Void)? + var onConfirm: (() -> Void)? + + var currentCommissionRate: String { rateField.text } + var currentSmsCode: String { smsCodeField.text } + + private let dimView = UIView() + private let cardView = UIView() + private let titleLabel = UILabel() + private let infoBox = UIView() + private let infoLabel = UILabel() + private let contentStack = UIStackView() + private let rateField = CooperationOutlinedTextFieldView(title: "分成比例", placeholder: "请输入0到100", suffix: "%") + private let phoneField = CooperationOutlinedTextFieldView(title: "验证码发送手机号", placeholder: "—") + private let smsCodeField = CooperationOutlinedTextFieldView(title: "验证码", placeholder: "请输入验证码") + private let sendCodeButton = UIButton(type: .system) + private let buttonStack = UIStackView() + private let cancelButton = UIButton(type: .system) + private let confirmButton = UIButton(type: .system) + private var cardCenterYConstraint: Constraint? + private var keyboardObservers: [NSObjectProtocol] = [] + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + unregisterKeyboardObservers() + } + + func apply(commissionRate: String, smsCode: String, phone: String, countdown: Int) { + rateField.text = commissionRate + smsCodeField.text = smsCode + phoneField.text = phone.isEmpty ? "—" : phone + + let isCountingDown = countdown > 0 + sendCodeButton.setTitle(isCountingDown ? "\(countdown)秒" : "获取验证码", for: .normal) + sendCodeButton.isEnabled = !isCountingDown + sendCodeButton.backgroundColor = AppColor.primary.withAlphaComponent(isCountingDown ? 0.7 : 1) + sendCodeButton.setTitleColor(.white.withAlphaComponent(isCountingDown ? 0.7 : 1), for: .normal) + sendCodeButton.setTitleColor(.white.withAlphaComponent(0.7), for: .disabled) + } + + func show(in parent: UIView) { + frame = parent.bounds + autoresizingMask = [.flexibleWidth, .flexibleHeight] + alpha = 0 + parent.addSubview(self) + layoutIfNeeded() + registerKeyboardObservers() + rateField.focus() + UIView.animate(withDuration: 0.2) { + self.alpha = 1 + } + } + + func dismiss() { + endEditing(true) + unregisterKeyboardObservers() + UIView.animate(withDuration: 0.2, animations: { + self.alpha = 0 + }, completion: { _ in + self.removeFromSuperview() + }) + } + + private func setupUI() { + dimView.backgroundColor = AppColor.overlayScrim + dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped))) + + cardView.backgroundColor = .white + cardView.layer.cornerRadius = AppRadius.xl + cardView.clipsToBounds = true + + titleLabel.text = "修改分成比例" + titleLabel.font = .systemFont(ofSize: 18, weight: .bold) + titleLabel.textColor = AppColor.textPrimary + + infoBox.backgroundColor = AppColor.primary.withAlphaComponent(0.08) + infoBox.layer.cornerRadius = AppRadius.sm + + infoLabel.text = "修改分成比例需验证手机号" + infoLabel.font = .systemFont(ofSize: 13) + infoLabel.textColor = AppColor.primary + + contentStack.axis = .vertical + contentStack.spacing = AppSpacing.sm + + rateField.keyboardType = .numberPad + smsCodeField.keyboardType = .numberPad + phoneField.isReadOnly = true + + rateField.onTextChange = { [weak self] text in + self?.onCommissionRateChange?(text) + } + smsCodeField.onTextChange = { [weak self] text in + self?.onSmsCodeChange?(text) + } + + sendCodeButton.setTitle("获取验证码", for: .normal) + sendCodeButton.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium) + sendCodeButton.setTitleColor(.white, for: .normal) + sendCodeButton.backgroundColor = AppColor.primary + sendCodeButton.layer.cornerRadius = AppRadius.sm + sendCodeButton.addTarget(self, action: #selector(sendCodeTapped), for: .touchUpInside) + + buttonStack.axis = .horizontal + buttonStack.alignment = .center + buttonStack.distribution = .fillEqually + buttonStack.spacing = AppSpacing.sm + + cancelButton.setTitle("取消", for: .normal) + cancelButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium) + cancelButton.setTitleColor(AppColor.textSecondary, for: .normal) + cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) + + confirmButton.setTitle("确认修改", for: .normal) + confirmButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium) + confirmButton.setTitleColor(AppColor.primary, for: .normal) + confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) + + addSubview(dimView) + addSubview(cardView) + cardView.addSubview(titleLabel) + cardView.addSubview(infoBox) + infoBox.addSubview(infoLabel) + cardView.addSubview(contentStack) + contentStack.addArrangedSubview(rateField) + contentStack.addArrangedSubview(phoneField) + contentStack.addArrangedSubview(makeSmsRow()) + cardView.addSubview(buttonStack) + buttonStack.addArrangedSubview(cancelButton) + buttonStack.addArrangedSubview(confirmButton) + + dimView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + cardView.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + cardCenterYConstraint = make.centerY.equalToSuperview().constraint + } + titleLabel.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(AppSpacing.md) + } + infoBox.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.md) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + } + infoLabel.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 10, left: 12, bottom: 10, right: 12)) + } + contentStack.snp.makeConstraints { make in + make.top.equalTo(infoBox.snp.bottom).offset(AppSpacing.sm) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + } + buttonStack.snp.makeConstraints { make in + make.top.equalTo(contentStack.snp.bottom).offset(AppSpacing.md) + make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.md) + make.height.equalTo(44) + } + } + + private func makeSmsRow() -> UIView { + let row = UIStackView(arrangedSubviews: [smsCodeField, sendCodeButton]) + row.axis = .horizontal + row.alignment = .fill + row.spacing = 10 + sendCodeButton.snp.makeConstraints { make in + make.width.equalTo(104) + make.height.equalTo(46) + } + return row + } + + private func registerKeyboardObservers() { + unregisterKeyboardObservers() + let center = NotificationCenter.default + keyboardObservers = [ + center.addObserver( + forName: UIResponder.keyboardWillChangeFrameNotification, + object: nil, + queue: .main + ) { [weak self] notification in + self?.handleKeyboardNotification(notification) + }, + center.addObserver( + forName: UIResponder.keyboardWillHideNotification, + object: nil, + queue: .main + ) { [weak self] notification in + self?.handleKeyboardNotification(notification) + } + ] + } + + private func unregisterKeyboardObservers() { + keyboardObservers.forEach(NotificationCenter.default.removeObserver) + keyboardObservers.removeAll() + } + + private func handleKeyboardNotification(_ notification: Notification) { + layoutIfNeeded() + guard let userInfo = notification.userInfo else { return } + + let duration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25 + let curveRaw = (userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue + ?? UInt(UIView.AnimationCurve.easeInOut.rawValue) + let options = UIView.AnimationOptions(rawValue: curveRaw << 16) + + let offset = keyboardAvoidanceOffset(from: userInfo) + cardCenterYConstraint?.update(offset: offset) + UIView.animate(withDuration: duration, delay: 0, options: options) { + self.layoutIfNeeded() + } + } + + private func keyboardAvoidanceOffset(from userInfo: [AnyHashable: Any]) -> CGFloat { + guard + let keyboardFrameValue = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue + else { + return 0 + } + + let keyboardFrame = convert(keyboardFrameValue.cgRectValue, from: nil) + guard keyboardFrame.minY < bounds.height else { return 0 } + + let cardHeight = cardView.bounds.height + let baseMinY = bounds.midY - cardHeight / 2 + let baseMaxY = bounds.midY + cardHeight / 2 + let desiredBottom = keyboardFrame.minY - AppSpacing.md + let neededShift = max(0, baseMaxY - desiredBottom) + let maxShift = max(0, baseMinY - AppSpacing.md) + return -min(neededShift, maxShift) + } + + @objc private func sendCodeTapped() { + onSendSmsCode?() + } + + @objc private func cancelTapped() { + onCancel?() + } + + @objc private func confirmTapped() { + onConfirm?() + } +} + +/// 合作订单弹窗内的描边输入框。 +private final class CooperationOutlinedTextFieldView: UIView { + + var onTextChange: ((String) -> Void)? + var text: String { + get { textField.text ?? "" } + set { + if textField.text != newValue { + textField.text = newValue + } + } + } + var keyboardType: UIKeyboardType { + get { textField.keyboardType } + set { textField.keyboardType = newValue } + } + var isReadOnly = false { + didSet { + textField.isUserInteractionEnabled = !isReadOnly + textField.textColor = isReadOnly ? AppColor.textSecondary : AppColor.textPrimary + } + } + + private let titleLabel = UILabel() + private let containerView = UIView() + private let textField = UITextField() + private let suffixLabel = UILabel() + private let suffix: String? + + init(title: String, placeholder: String, suffix: String? = nil) { + self.suffix = suffix + super.init(frame: .zero) + setupUI(title: title, placeholder: placeholder) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func focus() { + textField.becomeFirstResponder() + } + + private func setupUI(title: String, placeholder: String) { + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: 12) + titleLabel.textColor = AppColor.textSecondary + titleLabel.backgroundColor = .white + + containerView.layer.borderWidth = 1 + containerView.layer.borderColor = UIColor(hex: 0xD9DDE4).cgColor + containerView.layer.cornerRadius = AppRadius.sm + + textField.placeholder = placeholder + textField.font = .systemFont(ofSize: 15) + textField.textColor = AppColor.textPrimary + textField.borderStyle = .none + textField.backgroundColor = .clear + textField.addTarget(self, action: #selector(textChanged), for: .editingChanged) + + suffixLabel.text = suffix + suffixLabel.font = .systemFont(ofSize: 15) + suffixLabel.textColor = AppColor.textSecondary + suffixLabel.isHidden = suffix == nil + + addSubview(containerView) + addSubview(titleLabel) + containerView.addSubview(textField) + containerView.addSubview(suffixLabel) + + snp.makeConstraints { make in + make.height.equalTo(56) + } + containerView.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + make.top.equalToSuperview().offset(6) + } + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(10) + make.top.equalToSuperview() + } + textField.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(AppSpacing.sm) + make.trailing.equalTo(suffixLabel.snp.leading).offset(-AppSpacing.xs) + make.centerY.equalTo(containerView) + } + suffixLabel.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(AppSpacing.sm) + make.centerY.equalTo(containerView) + } + } + + @objc private func textChanged() { + onTextChange?(textField.text ?? "") + } +} + /// 标签-值行。 final class CooperationInfoRowView: UIView { private let titleLabel = UILabel() diff --git a/suixinkan/UI/Home/AllFunctionsViewController.swift b/suixinkan/UI/Home/AllFunctionsViewController.swift index 2177bda..465b688 100644 --- a/suixinkan/UI/Home/AllFunctionsViewController.swift +++ b/suixinkan/UI/Home/AllFunctionsViewController.swift @@ -167,7 +167,7 @@ final class AllFunctionsViewController: BaseViewController { private static func makeLayout() -> UICollectionViewCompositionalLayout { UICollectionViewCompositionalLayout { sectionIndex, environment in - let spacing: CGFloat = 12 + let spacing: CGFloat = 15 let columns = 3 let horizontalInset = AppSpacing.md let availableWidth = environment.container.effectiveContentSize.width - horizontalInset * 2 diff --git a/suixinkan/UI/Home/HomeCollectionModels.swift b/suixinkan/UI/Home/HomeCollectionModels.swift index 4ca60da..88dc480 100644 --- a/suixinkan/UI/Home/HomeCollectionModels.swift +++ b/suixinkan/UI/Home/HomeCollectionModels.swift @@ -28,8 +28,7 @@ enum HomeCollectionItem: Hashable { enum HomeCollectionLayoutBuilder { private static let menuColumns = 3 - private static let menuSpacing: CGFloat = 15 - private static let menuAspectRatio: CGFloat = 1.3 + private static let menuSpacing = AppSpacing.sm private static let sectionSpacing = AppSpacing.sm /// 构建 Compositional Layout,按当前分区类型返回 section 布局。 @@ -42,7 +41,7 @@ enum HomeCollectionLayoutBuilder { case .locationReport: return fullWidthSection(height: 132) case .quickActions: - return fullWidthSection(height: 72) + return quickActionsSection(environment: environment) case .store: return fullWidthSection(height: 96) case .commonApps: @@ -120,19 +119,16 @@ enum HomeCollectionLayoutBuilder { } private static func menuGridSection(environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection { - let availableWidth = environment.container.effectiveContentSize.width - horizontalInset * 2 - let totalSpacing = menuSpacing * CGFloat(menuColumns - 1) - let itemWidth = max(0, (availableWidth - totalSpacing) / CGFloat(menuColumns)) - let itemHeight = itemWidth / menuAspectRatio + let itemWidth = menuItemWidth(environment: environment) let itemSize = NSCollectionLayoutSize( widthDimension: .absolute(itemWidth), - heightDimension: .absolute(itemHeight) + heightDimension: .absolute(itemWidth) ) let item = NSCollectionLayoutItem(layoutSize: itemSize) let groupSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1), - heightDimension: .absolute(itemHeight) + heightDimension: .absolute(itemWidth) ) let group = NSCollectionLayoutGroup.horizontal( layoutSize: groupSize, @@ -162,4 +158,14 @@ enum HomeCollectionLayoutBuilder { section.boundarySupplementaryItems = [header] return section } + + private static func quickActionsSection(environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection { + fullWidthSection(height: menuItemWidth(environment: environment)) + } + + private static func menuItemWidth(environment: NSCollectionLayoutEnvironment) -> CGFloat { + let availableWidth = environment.container.effectiveContentSize.width - horizontalInset * 2 + let totalSpacing = menuSpacing * CGFloat(menuColumns - 1) + return max(0, (availableWidth - totalSpacing) / CGFloat(menuColumns)) + } } diff --git a/suixinkan/UI/Home/HomeViewController.swift b/suixinkan/UI/Home/HomeViewController.swift index 46abffa..9e78ca4 100644 --- a/suixinkan/UI/Home/HomeViewController.swift +++ b/suixinkan/UI/Home/HomeViewController.swift @@ -207,8 +207,8 @@ final class HomeViewController: BaseViewController { ) } cell.cardView.onSubmitTask = { [weak self] in - self?.navigationController?.pushViewController(TaskAddViewController(), animated: true) - } + self?.navigationController?.pushViewController(TaskAddViewController(), animated: true) + } cell.cardView.onToggleOnline = { [weak self] in self?.viewModel.showOnlineStatusSwitchDialog() } @@ -301,10 +301,17 @@ final class HomeViewController: BaseViewController { storeItem: viewModel.storeItem, commonMenus: viewModel.commonMenus ) - dataSource.apply(snapshot, animatingDifferences: false) + dataSource.apply(snapshot, animatingDifferences: false) { [weak self] in + self?.refreshStatusVisibleCells() + } presentDialogsIfNeeded() } + private func refreshStatusVisibleCells() { + refreshWorkStatusVisibleCell() + refreshQuickActionsVisibleCell() + } + private func refreshWorkStatusVisibleCell() { guard !viewModel.isMinimalTopRole, let sectionIndex = currentSections.firstIndex(of: .workStatus) else { @@ -321,6 +328,18 @@ final class HomeViewController: BaseViewController { ) } + private func refreshQuickActionsVisibleCell() { + guard !viewModel.isMinimalTopRole, + let sectionIndex = currentSections.firstIndex(of: .quickActions) else { + return + } + let indexPath = IndexPath(item: 0, section: sectionIndex) + guard let cell = collectionView.cellForItem(at: indexPath) as? HomeQuickActionsCell else { + return + } + cell.apply(isOnline: viewModel.isOnline) + } + private func presentDialogsIfNeeded() { if viewModel.showPermissionDialog { presentDialog(.permission) diff --git a/suixinkan/UI/Home/Views/AllFunctionMenuCell.swift b/suixinkan/UI/Home/Views/AllFunctionMenuCell.swift index d521fa6..e84ddf4 100644 --- a/suixinkan/UI/Home/Views/AllFunctionMenuCell.swift +++ b/suixinkan/UI/Home/Views/AllFunctionMenuCell.swift @@ -20,6 +20,7 @@ final class AllFunctionMenuCell: UICollectionViewCell { var onActionTap: (() -> Void)? private let cardView = UIView() + private let contentStackView = UIStackView() private let iconView = UIImageView() private let titleLabel = UILabel() private let actionButton = UIButton(type: .system) @@ -67,30 +68,33 @@ final class AllFunctionMenuCell: UICollectionViewCell { iconView.tintColor = AppColor.primary iconView.contentMode = .scaleAspectFit - titleLabel.font = .app(.caption) - titleLabel.textColor = AppColor.textPrimary + contentStackView.axis = .vertical + contentStackView.alignment = .center + contentStackView.spacing = AppSpacing.sm + contentStackView.isUserInteractionEnabled = false + + titleLabel.font = .app(.body) + titleLabel.textColor = AppColor.textSecondary titleLabel.textAlignment = .center titleLabel.numberOfLines = 2 actionButton.addTarget(self, action: #selector(actionTapped), for: .touchUpInside) contentView.addSubview(cardView) - cardView.addSubview(iconView) - cardView.addSubview(titleLabel) + cardView.addSubview(contentStackView) cardView.addSubview(actionButton) + contentStackView.addArrangedSubview(iconView) + contentStackView.addArrangedSubview(titleLabel) cardView.snp.makeConstraints { make in make.edges.equalToSuperview() } - iconView.snp.makeConstraints { make in - make.top.equalToSuperview().offset(AppSpacing.sm) - make.centerX.equalToSuperview() - make.width.height.equalTo(32) - } - titleLabel.snp.makeConstraints { make in - make.top.equalTo(iconView.snp.bottom).offset(AppSpacing.xs) + contentStackView.snp.makeConstraints { make in + make.center.equalToSuperview() make.leading.trailing.equalToSuperview().inset(AppSpacing.xxs) - make.bottom.lessThanOrEqualToSuperview().inset(AppSpacing.xs) + } + iconView.snp.makeConstraints { make in + make.width.height.equalTo(26) } actionButton.snp.makeConstraints { make in make.top.trailing.equalToSuperview().inset(AppSpacing.xxs) diff --git a/suixinkan/UI/Home/Views/HomeCollectionCells.swift b/suixinkan/UI/Home/Views/HomeCollectionCells.swift index e2e47a6..13518f0 100644 --- a/suixinkan/UI/Home/Views/HomeCollectionCells.swift +++ b/suixinkan/UI/Home/Views/HomeCollectionCells.swift @@ -112,6 +112,8 @@ final class HomeMenuCell: UICollectionViewCell { static let reuseIdentifier = "HomeMenuCell" private let cardView = UIView() + private let contentStackView = UIStackView() + private let iconContainerView = UIView() private let iconView = UIImageView() private let titleLabel = UILabel() @@ -127,40 +129,54 @@ final class HomeMenuCell: UICollectionViewCell { func apply(menu: HomeMenuItem) { iconView.image = UIImage(named: menu.iconName) ?? UIImage(systemName: menu.iconName) + iconView.accessibilityIdentifier = menu.iconName titleLabel.text = menu.title } private func setupUI() { contentView.backgroundColor = .clear - cardView.backgroundColor = .white - cardView.layer.cornerRadius = AppRadius.md + cardView.backgroundColor = AppColor.cardBackground + cardView.layer.cornerRadius = AppRadius.lg + cardView.layer.borderWidth = 1 + cardView.layer.borderColor = AppColor.cardOutline.cgColor cardView.clipsToBounds = true + iconContainerView.backgroundColor = AppColor.iconBackground + iconContainerView.layer.cornerRadius = 20 + iconView.tintColor = AppColor.primary iconView.contentMode = .scaleAspectFit + contentStackView.axis = .vertical + contentStackView.alignment = .center + contentStackView.spacing = AppSpacing.xs + contentStackView.isUserInteractionEnabled = false + titleLabel.font = .app(.body) titleLabel.textColor = AppColor.textSecondary titleLabel.textAlignment = .center titleLabel.numberOfLines = 2 contentView.addSubview(cardView) - cardView.addSubview(iconView) - cardView.addSubview(titleLabel) + cardView.addSubview(contentStackView) + iconContainerView.addSubview(iconView) + contentStackView.addArrangedSubview(iconContainerView) + contentStackView.addArrangedSubview(titleLabel) cardView.snp.makeConstraints { make in make.edges.equalToSuperview() } - iconView.snp.makeConstraints { make in - make.top.equalToSuperview().offset(AppSpacing.sm) - make.centerX.equalToSuperview() - make.width.height.equalTo(24) + contentStackView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(AppSpacing.xs) } - titleLabel.snp.makeConstraints { make in - make.top.equalTo(iconView.snp.bottom).offset(AppSpacing.sm) - make.leading.trailing.equalToSuperview().inset(AppSpacing.xxs) - make.bottom.lessThanOrEqualToSuperview().inset(AppSpacing.xs) + iconContainerView.snp.makeConstraints { make in + make.width.height.equalTo(40) + } + iconView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.width.height.equalTo(24) } } } diff --git a/suixinkan/UI/Home/Views/HomeLocationReportCardView.swift b/suixinkan/UI/Home/Views/HomeLocationReportCardView.swift index 45394b2..517057f 100644 --- a/suixinkan/UI/Home/Views/HomeLocationReportCardView.swift +++ b/suixinkan/UI/Home/Views/HomeLocationReportCardView.swift @@ -11,6 +11,7 @@ final class HomeLocationReportCardView: UIView { var onReportTap: (() -> Void)? + private let contentStackView = UIStackView() private let titleStackView = UIStackView() private let titleIconView = UIImageView(image: UIImage(systemName: "arrow.up.square.fill")) private let titleLabel = UILabel() @@ -22,9 +23,15 @@ final class HomeLocationReportCardView: UIView { override init(frame: CGRect) { super.init(frame: frame) backgroundColor = AppColor.cardBackground - layer.cornerRadius = AppRadius.md + layer.cornerRadius = AppRadius.lg + layer.borderWidth = 1 + layer.borderColor = AppColor.cardOutline.cgColor clipsToBounds = true + contentStackView.axis = .vertical + contentStackView.alignment = .fill + contentStackView.spacing = AppSpacing.xxs + titleStackView.axis = .horizontal titleStackView.alignment = .center titleStackView.spacing = AppSpacing.xs @@ -33,14 +40,14 @@ final class HomeLocationReportCardView: UIView { titleIconView.contentMode = .scaleAspectFit titleLabel.text = "立即上报" - titleLabel.font = .systemFont(ofSize: 22, weight: .bold) + titleLabel.font = .systemFont(ofSize: 24, weight: .bold) titleLabel.textColor = AppColor.textPrimary - addressLabel.font = .systemFont(ofSize: 15, weight: .regular) + addressLabel.font = .app(.subtitle) addressLabel.textColor = AppColor.textTertiary addressLabel.numberOfLines = 2 - reportGradientLayer.colors = [AppColor.primary.cgColor, UIColor(hex: 0x5CA8FF).cgColor] + reportGradientLayer.colors = [AppColor.primary.cgColor, AppColor.primaryGradientEnd.cgColor] reportGradientLayer.startPoint = CGPoint(x: 0.5, y: 0) reportGradientLayer.endPoint = CGPoint(x: 0.5, y: 1) reportButtonContainer.layer.insertSublayer(reportGradientLayer, at: 0) @@ -55,33 +62,34 @@ final class HomeLocationReportCardView: UIView { forImageIn: .normal ) reportButton.imageView?.contentMode = .scaleAspectFit - reportButton.addTarget(self, action: #selector(reportTapped), for: .touchUpInside) + reportButton.isExclusiveTouch = true + reportButton.addTarget(self, action: #selector(reportTouchDown), for: .touchDown) + reportButton.addTarget(self, action: #selector(reportTouchUpInside), for: .touchUpInside) + reportButton.addTarget(self, action: #selector(reportTouchEnded), for: [.touchUpOutside, .touchCancel, .touchDragExit]) reportButton.accessibilityLabel = "上报位置" titleStackView.addArrangedSubview(titleIconView) titleStackView.addArrangedSubview(titleLabel) - addSubview(titleStackView) - addSubview(addressLabel) + contentStackView.addArrangedSubview(titleStackView) + contentStackView.addArrangedSubview(addressLabel) + addSubview(contentStackView) addSubview(reportButtonContainer) reportButtonContainer.addSubview(reportButton) titleIconView.snp.makeConstraints { make in make.width.height.equalTo(25) } - titleStackView.snp.makeConstraints { make in - make.leading.equalToSuperview().offset(15) - make.top.equalToSuperview().offset(15) - make.trailing.lessThanOrEqualTo(reportButtonContainer.snp.leading).offset(-AppSpacing.sm) - } - addressLabel.snp.makeConstraints { make in - make.top.equalTo(titleStackView.snp.bottom).offset(AppSpacing.xxs) - make.leading.equalTo(titleStackView) - make.trailing.lessThanOrEqualTo(reportButtonContainer.snp.leading).offset(-AppSpacing.sm) + contentStackView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(AppSpacing.md) + make.centerY.equalTo(reportButtonContainer) + make.trailing.equalTo(reportButtonContainer.snp.leading).offset(-AppSpacing.sm) + make.top.greaterThanOrEqualToSuperview().offset(AppSpacing.md) + make.bottom.lessThanOrEqualToSuperview().offset(-AppSpacing.md) } reportButtonContainer.snp.makeConstraints { make in - make.trailing.equalToSuperview().offset(-15) + make.trailing.equalToSuperview().offset(-AppSpacing.md) make.centerY.equalToSuperview() - make.width.height.equalTo(92) + make.width.height.equalTo(96) } reportButton.snp.makeConstraints { make in make.edges.equalToSuperview() @@ -103,10 +111,81 @@ final class HomeLocationReportCardView: UIView { addressLabel.text = address reportButton.isEnabled = !isReporting reportButton.alpha = 1 + if isReporting { + resetReportButtonScale() + } reportGradientLayer.colors = isReporting - ? [UIColor(hex: 0xC9CED6).cgColor, UIColor(hex: 0xD9DDE4).cgColor] - : [AppColor.primary.cgColor, UIColor(hex: 0x5CA8FF).cgColor] + ? [AppColor.controlDisabledStart.cgColor, AppColor.controlDisabledEnd.cgColor] + : [AppColor.primary.cgColor, AppColor.primaryGradientEnd.cgColor] } - @objc private func reportTapped() { onReportTap?() } + @objc private func reportTouchDown() { + animateReportButtonScale(isPressed: true) + playCenteredRipple() + } + + @objc private func reportTouchUpInside() { + animateReportButtonScale(isPressed: false) + onReportTap?() + } + + @objc private func reportTouchEnded() { + animateReportButtonScale(isPressed: false) + } + + private func playCenteredRipple() { + reportButtonContainer.layer.sublayers? + .filter { $0.name == "HomeLocationReportRippleLayer" } + .forEach { $0.removeFromSuperlayer() } + + let diameter = max(reportButtonContainer.bounds.width, reportButtonContainer.bounds.height) * 1.45 + let rippleLayer = CALayer() + rippleLayer.name = "HomeLocationReportRippleLayer" + rippleLayer.backgroundColor = UIColor.white.withAlphaComponent(0.28).cgColor + rippleLayer.bounds = CGRect(x: 0, y: 0, width: diameter, height: diameter) + rippleLayer.cornerRadius = diameter / 2 + rippleLayer.position = CGPoint(x: reportButtonContainer.bounds.midX, y: reportButtonContainer.bounds.midY) + rippleLayer.transform = CATransform3DMakeScale(0.18, 0.18, 1) + rippleLayer.opacity = 0.32 + reportButtonContainer.layer.insertSublayer(rippleLayer, below: reportButton.layer) + + let scaleAnimation = CABasicAnimation(keyPath: "transform.scale") + scaleAnimation.fromValue = 0.18 + scaleAnimation.toValue = 1 + + let opacityAnimation = CABasicAnimation(keyPath: "opacity") + opacityAnimation.fromValue = 0.32 + opacityAnimation.toValue = 0 + + let group = CAAnimationGroup() + group.animations = [scaleAnimation, opacityAnimation] + group.duration = 0.55 + group.timingFunction = CAMediaTimingFunction(name: .easeOut) + group.isRemovedOnCompletion = false + group.fillMode = .forwards + + CATransaction.begin() + CATransaction.setCompletionBlock { [weak rippleLayer] in + rippleLayer?.removeFromSuperlayer() + } + rippleLayer.add(group, forKey: "centeredRipple") + CATransaction.commit() + } + + private func animateReportButtonScale(isPressed: Bool) { + UIView.animate( + withDuration: isPressed ? 0.12 : 0.18, + delay: 0, + options: [.beginFromCurrentState, .curveEaseOut] + ) { + self.reportButtonContainer.transform = isPressed + ? CGAffineTransform(scaleX: 0.96, y: 0.96) + : .identity + } + } + + private func resetReportButtonScale() { + reportButtonContainer.layer.removeAllAnimations() + reportButtonContainer.transform = .identity + } } diff --git a/suixinkan/UI/Home/Views/HomeQuickActionsView.swift b/suixinkan/UI/Home/Views/HomeQuickActionsView.swift index ccaee36..9b2bf02 100644 --- a/suixinkan/UI/Home/Views/HomeQuickActionsView.swift +++ b/suixinkan/UI/Home/Views/HomeQuickActionsView.swift @@ -23,7 +23,6 @@ final class HomeQuickActionsView: UIView { addSubview(stackView) stackView.snp.makeConstraints { make in make.edges.equalToSuperview() - make.height.equalTo(72) } } @@ -34,19 +33,36 @@ final class HomeQuickActionsView: UIView { func apply(isOnline: Bool) { stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } - stackView.addArrangedSubview(makeActionButton(title: "立即收款", icon: "yensign.circle", action: #selector(collectPaymentTapped))) - stackView.addArrangedSubview(makeActionButton(title: "提交任务", icon: "doc.text", action: #selector(submitTaskTapped))) + stackView.addArrangedSubview(makeActionButton(title: "立即收款", icon: "qrcode", action: #selector(collectPaymentTapped))) + stackView.addArrangedSubview(makeActionButton(title: "提交任务", icon: "checklist.checked", action: #selector(submitTaskTapped))) stackView.addArrangedSubview(makeActionButton( title: isOnline ? "在线" : "离线", - icon: isOnline ? "dot.radiowaves.left.and.right" : "moon", - action: #selector(toggleOnlineTapped) + icon: isOnline ? "wifi" : "wifi.slash", + action: #selector(toggleOnlineTapped), + backgroundColor: isOnline ? AppColor.online : AppColor.cardBackground, + iconBackgroundColor: isOnline ? UIColor.white.withAlphaComponent(0.18) : AppColor.inputBackground, + iconColor: isOnline ? AppColor.successBackground : AppColor.textTabInactive, + titleColor: isOnline ? AppColor.successBackground : AppColor.textTabInactive, + showsBorder: !isOnline )) } - private func makeActionButton(title: String, icon: String, action: Selector) -> UIView { + private func makeActionButton( + title: String, + icon: String, + action: Selector, + backgroundColor: UIColor = AppColor.cardBackground, + iconBackgroundColor: UIColor = AppColor.iconBackground, + iconColor: UIColor = AppColor.primary, + titleColor: UIColor = AppColor.textSecondary, + showsBorder: Bool = true + ) -> UIView { let container = UIView() - container.backgroundColor = AppColor.cardBackground - container.layer.cornerRadius = AppRadius.md + container.backgroundColor = backgroundColor + container.layer.cornerRadius = AppRadius.lg + container.layer.borderWidth = showsBorder ? 1 : 0 + container.layer.borderColor = AppColor.cardOutline.cgColor + container.accessibilityLabel = title let button = UIButton(type: .system) button.addTarget(self, action: action, for: .touchUpInside) @@ -55,26 +71,39 @@ final class HomeQuickActionsView: UIView { make.edges.equalToSuperview() } + let iconContainer = UIView() + iconContainer.backgroundColor = iconBackgroundColor + iconContainer.layer.cornerRadius = 18 + let imageView = UIImageView(image: UIImage(systemName: icon)) - imageView.tintColor = AppColor.primary + imageView.accessibilityIdentifier = icon + imageView.tintColor = iconColor imageView.contentMode = .scaleAspectFit let label = UILabel() label.text = title - label.font = .app(.bodyMedium) - label.textColor = AppColor.textPrimary + label.font = .app(.body) + label.textColor = titleColor label.textAlignment = .center + label.numberOfLines = 2 - let column = UIStackView(arrangedSubviews: [imageView, label]) + iconContainer.addSubview(imageView) + + let column = UIStackView(arrangedSubviews: [iconContainer, label]) column.axis = .vertical column.alignment = .center - column.spacing = 6 + column.spacing = AppSpacing.xs column.isUserInteractionEnabled = false container.addSubview(column) column.snp.makeConstraints { make in make.center.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(AppSpacing.xxs) + } + iconContainer.snp.makeConstraints { make in + make.width.height.equalTo(36) } imageView.snp.makeConstraints { make in + make.center.equalToSuperview() make.width.height.equalTo(22) } return container diff --git a/suixinkan/UI/Home/Views/HomeWorkStatusCardView.swift b/suixinkan/UI/Home/Views/HomeWorkStatusCardView.swift index e968403..58ec859 100644 --- a/suixinkan/UI/Home/Views/HomeWorkStatusCardView.swift +++ b/suixinkan/UI/Home/Views/HomeWorkStatusCardView.swift @@ -15,14 +15,16 @@ final class HomeWorkStatusCardView: UIView { private let contentStackView = UIStackView() private let onlineButton = UIButton(type: .system) private let countdownStackView = UIStackView() - private let countdownIconView = UIImageView(image: UIImage(systemName: "clock")) + private let countdownIconView = UIImageView(image: UIImage(systemName: "clock.fill")) private let countdownLabel = UILabel() private let reminderButton = UIButton(type: .system) override init(frame: CGRect) { super.init(frame: frame) backgroundColor = AppColor.cardBackground - layer.cornerRadius = AppRadius.md + layer.cornerRadius = AppRadius.lg + layer.borderWidth = 1 + layer.borderColor = AppColor.cardOutline.cgColor clipsToBounds = true contentStackView.axis = .horizontal @@ -31,8 +33,8 @@ final class HomeWorkStatusCardView: UIView { contentStackView.spacing = AppSpacing.sm onlineButton.titleLabel?.font = .app(.captionMedium) - onlineButton.layer.cornerRadius = AppRadius.xs - onlineButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12) + onlineButton.layer.cornerRadius = AppRadius.sm + onlineButton.contentEdgeInsets = UIEdgeInsets(top: 7, left: 14, bottom: 7, right: 14) onlineButton.addTarget(self, action: #selector(onlineTapped), for: .touchUpInside) countdownStackView.axis = .horizontal @@ -42,16 +44,17 @@ final class HomeWorkStatusCardView: UIView { countdownIconView.contentMode = .scaleAspectFit countdownIconView.setContentCompressionResistancePriority(.required, for: .horizontal) - countdownLabel.font = .app(.subtitle) + countdownLabel.font = .systemFont(ofSize: 18, weight: .semibold) countdownLabel.textColor = AppColor.primary countdownLabel.adjustsFontSizeToFitWidth = true countdownLabel.minimumScaleFactor = 0.85 countdownLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - reminderButton.titleLabel?.font = .app(.subtitle) + reminderButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold) reminderButton.setTitleColor(AppColor.primary, for: .normal) reminderButton.tintColor = AppColor.primary - reminderButton.setImage(UIImage(systemName: "bell.fill"), for: .normal) + let reminderIconConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .semibold) + reminderButton.setImage(UIImage(systemName: "bell.fill", withConfiguration: reminderIconConfig), for: .normal) reminderButton.semanticContentAttribute = .forceLeftToRight reminderButton.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 6) reminderButton.titleEdgeInsets = UIEdgeInsets(top: 0, left: 6, bottom: 0, right: -6) @@ -67,10 +70,10 @@ final class HomeWorkStatusCardView: UIView { addSubview(contentStackView) contentStackView.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(15) + make.edges.equalToSuperview().inset(AppSpacing.md) } countdownIconView.snp.makeConstraints { make in - make.width.height.equalTo(15) + make.width.height.equalTo(18) } snp.makeConstraints { make in make.height.equalTo(84) diff --git a/suixinkan/UI/MainTab/MainScanTabItem.swift b/suixinkan/UI/MainTab/MainScanTabItem.swift index ebf7792..b9df189 100644 --- a/suixinkan/UI/MainTab/MainScanTabItem.swift +++ b/suixinkan/UI/MainTab/MainScanTabItem.swift @@ -10,10 +10,34 @@ enum MainScanTabItem { /// 构建扫码 TabBarItem,点击行为由 `MainTabBarController` 拦截。 static func makeTabBarItem() -> UITabBarItem { - let image = UIImage(named: "icon_scan")?.withRenderingMode(.alwaysOriginal) + let image = makeScanImage() let item = UITabBarItem(title: nil, image: image, selectedImage: image) item.accessibilityLabel = "扫码核销" item.accessibilityIdentifier = "main.scan" return item } + + private static func makeScanImage() -> UIImage { + let diameter: CGFloat = 48 + let renderer = UIGraphicsImageRenderer(size: CGSize(width: diameter, height: diameter)) + return renderer.image { _ in + let bounds = CGRect(origin: .zero, size: CGSize(width: diameter, height: diameter)) + AppColor.primary.setFill() + UIBezierPath(ovalIn: bounds).fill() + + let configuration = UIImage.SymbolConfiguration(pointSize: 25, weight: .semibold) + guard let symbol = UIImage(systemName: "qrcode.viewfinder", withConfiguration: configuration) else { + return + } + let image = symbol.withTintColor(.white, renderingMode: .alwaysOriginal) + let symbolSize = CGSize(width: 28, height: 28) + let symbolRect = CGRect( + x: (diameter - symbolSize.width) / 2, + y: (diameter - symbolSize.height) / 2, + width: symbolSize.width, + height: symbolSize.height + ) + image.draw(in: symbolRect) + }.withRenderingMode(.alwaysOriginal) + } } diff --git a/suixinkan/UI/MainTab/MainTabBarController.swift b/suixinkan/UI/MainTab/MainTabBarController.swift index 4bc7808..ceff4df 100644 --- a/suixinkan/UI/MainTab/MainTabBarController.swift +++ b/suixinkan/UI/MainTab/MainTabBarController.swift @@ -67,6 +67,7 @@ final class MainTabBarController: UITabBarController { private func configureTabBarAppearance() { tabBar.tintColor = AppColor.primary + tabBar.unselectedItemTintColor = AppColor.textTabInactive tabBar.backgroundColor = AppColor.cardBackground tabBar.shadowImage = UIImage() @@ -82,7 +83,7 @@ final class MainTabBarController: UITabBarController { let appearance = UITabBarAppearance() appearance.configureWithOpaqueBackground() appearance.backgroundColor = AppColor.cardBackground - appearance.shadowColor = .clear + appearance.shadowColor = AppColor.tabBarShadow appearance.stackedLayoutAppearance.normal.titleTextAttributes = normalAttributes appearance.stackedLayoutAppearance.selected.titleTextAttributes = selectedAttributes tabBar.standardAppearance = appearance diff --git a/suixinkan/UI/Orders/OrderDialogControllers.swift b/suixinkan/UI/Orders/OrderDialogControllers.swift index ae8d37c..f9dd2f8 100644 --- a/suixinkan/UI/Orders/OrderDialogControllers.swift +++ b/suixinkan/UI/Orders/OrderDialogControllers.swift @@ -3,46 +3,651 @@ // suixinkan // +import SnapKit import UIKit +/// 普通订单退款弹窗入口,对齐 Android `OrderRefundDialog`。 enum OrderRefundAlertController { static func present( from presenter: UIViewController, order: OrderEntity, onConfirm: @escaping (OrderRefundRequest) -> Void ) { - let alert = UIAlertController(title: "订单退款", message: order.orderNumber, preferredStyle: .alert) - alert.addTextField { $0.placeholder = "退款金额"; $0.text = order.actualPayAmount } - alert.addTextField { $0.placeholder = "退款原因" } - alert.addAction(UIAlertAction(title: "取消", style: .cancel)) - alert.addAction(UIAlertAction(title: "确认退款", style: .destructive) { _ in - let amount = alert.textFields?.first?.text ?? order.actualPayAmount - let reason = alert.textFields?.last?.text ?? "" - onConfirm(OrderRefundRequest(orderNumber: order.orderNumber, refundType: 1, refundAmount: amount, refundReason: reason)) - }) - presenter.present(alert, animated: true) + let fullAmount = order.orderAmount.isEmpty ? order.actualPayAmount : order.orderAmount + let dialog = OrderRefundDialogView( + configuration: .photographerOrder( + orderNumber: order.orderNumber, + fullRefundAmount: fullAmount + ) + ) + dialog.onMessage = { [weak presenter] message in + (presenter as? BaseViewController)?.showToast(message) + } + dialog.onConfirm = { payload in + onConfirm( + OrderRefundRequest( + orderNumber: order.orderNumber, + refundType: payload.refundType, + refundAmount: payload.refundAmount, + refundReason: payload.refundReason + ) + ) + } + dialog.show(in: presenter.view) } } +/// 店铺/定金订单退款弹窗入口,对齐 Android `DepositOrderRefundDialog`。 enum DepositOrderRefundAlertController { static func present( from presenter: UIViewController, orderNumber: String, amount: String, refundType: Int, - onConfirm: @escaping (String, String) -> Void + onConfirm: @escaping (Int, String, String) -> Void ) { - let title = refundType == 3 ? "部分退款" : "全额退款" - let alert = UIAlertController(title: title, message: orderNumber, preferredStyle: .alert) - alert.addTextField { $0.placeholder = "退款金额"; $0.text = amount } - alert.addTextField { $0.placeholder = "退款原因" } - alert.addAction(UIAlertAction(title: "取消", style: .cancel)) - alert.addAction(UIAlertAction(title: "确认", style: .destructive) { _ in - let refundAmount = alert.textFields?.first?.text ?? amount - let reason = alert.textFields?.last?.text ?? "" - onConfirm(refundAmount, reason) + let dialog = OrderRefundDialogView( + configuration: .storeOrder( + orderNumber: orderNumber, + actualPayAmount: amount, + initialMode: OrderRefundMode(rawValue: refundType) ?? .full + ) + ) + dialog.onMessage = { [weak presenter] message in + (presenter as? BaseViewController)?.showToast(message) + } + dialog.onConfirm = { payload in + onConfirm(payload.refundType, payload.refundAmount, payload.refundReason) + } + dialog.show(in: presenter.view) + } +} + +/// 退款方式,和 Android 常量 `1=全额退款、3=部分退款` 保持一致。 +enum OrderRefundMode: Int { + case full = 1 + case partial = 3 +} + +/// 退款弹窗校验场景。 +enum OrderRefundDialogKind { + case photographerOrder + case storeOrder +} + +/// 退款弹窗确认后的结构化结果。 +struct OrderRefundDialogPayload: Equatable { + let refundType: Int + let refundAmount: String + let refundReason: String +} + +/// 退款弹窗校验失败信息。 +struct OrderRefundDialogValidationError: Error, Equatable { + let message: String +} + +/// 退款弹窗输入校验与金额格式化逻辑,对齐 Android 两个退款弹窗。 +enum OrderRefundDialogValidation { + + static func filterMoneyInput(_ text: String, maxLength: Int) -> String { + var result = "" + var hasDot = false + var decimalCount = 0 + + for character in text { + guard result.count < maxLength else { break } + if character.isNumber { + if hasDot { + guard decimalCount < 2 else { continue } + decimalCount += 1 + } + result.append(character) + } else if character == ".", !hasDot { + hasDot = true + result.append(character) + } + } + return result + } + + static func validate( + kind: OrderRefundDialogKind, + mode: OrderRefundMode, + amountText: String, + reason: String, + fullRefundAmount: String, + maxRefundAmount: String + ) -> Result { + let trimmedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines) + switch kind { + case .photographerOrder: + return validatePhotographerOrder( + mode: mode, + amountText: amountText, + reason: trimmedReason, + fullRefundAmount: fullRefundAmount + ) + case .storeOrder: + return validateStoreOrder( + mode: mode, + amountText: amountText, + reason: trimmedReason, + maxRefundAmount: maxRefundAmount + ) + } + } + + private static func validatePhotographerOrder( + mode: OrderRefundMode, + amountText: String, + reason: String, + fullRefundAmount: String + ) -> Result { + guard !reason.isEmpty else { + return .failure(OrderRefundDialogValidationError(message: "请输入退款原因")) + } + guard reason.count <= 255 else { + return .failure(OrderRefundDialogValidationError(message: "退款原因最多255个字符")) + } + if mode == .partial, amountText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return .failure(OrderRefundDialogValidationError(message: "请输入退款金额")) + } + let refundAmount = mode == .full ? fullRefundAmount : amountText + return .success( + OrderRefundDialogPayload( + refundType: mode.rawValue, + refundAmount: refundAmount, + refundReason: reason + ) + ) + } + + private static func validateStoreOrder( + mode: OrderRefundMode, + amountText: String, + reason: String, + maxRefundAmount: String + ) -> Result { + guard reason.count >= 5 else { + return .failure(OrderRefundDialogValidationError(message: "退款原因至少5个字符")) + } + guard reason.count <= 255 else { + return .failure(OrderRefundDialogValidationError(message: "退款原因最多255个字符")) + } + guard mode == .partial else { + return .success( + OrderRefundDialogPayload( + refundType: mode.rawValue, + refundAmount: "0", + refundReason: reason + ) + ) + } + let trimmedAmount = amountText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedAmount.isEmpty else { + return .failure(OrderRefundDialogValidationError(message: "请输入退款金额")) + } + guard let amount = Double(trimmedAmount), amount > 0 else { + return .failure(OrderRefundDialogValidationError(message: "请输入有效的退款金额")) + } + let maxAmount = parseAmount(maxRefundAmount) + guard maxAmount <= 0 || amount <= maxAmount else { + return .failure(OrderRefundDialogValidationError(message: "退款金额不能大于实际支付金额")) + } + return .success( + OrderRefundDialogPayload( + refundType: mode.rawValue, + refundAmount: String(format: "%.2f", amount), + refundReason: reason + ) + ) + } + + private static func parseAmount(_ text: String) -> Double { + text + .replacingOccurrences(of: ",", with: "") + .replacingOccurrences(of: "¥", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .toDoubleOrZero + } +} + +private extension String { + var toDoubleOrZero: Double { + Double(self) ?? 0 + } +} + +/// 自定义订单退款弹窗视图,对齐 Android `OrderRefundDialog` / `DepositOrderRefundDialog`。 +final class OrderRefundDialogView: UIView, UITextViewDelegate { + + /// 退款弹窗展示与校验配置,用于区分普通订单和店铺/定金订单。 + struct Configuration { + let kind: OrderRefundDialogKind + let orderNumber: String + let fullRefundAmount: String + let maxRefundAmount: String + let initialMode: OrderRefundMode + let remarkPlaceholder: String + let moneyMaxLength: Int + + static func photographerOrder(orderNumber: String, fullRefundAmount: String) -> Configuration { + Configuration( + kind: .photographerOrder, + orderNumber: orderNumber, + fullRefundAmount: fullRefundAmount, + maxRefundAmount: fullRefundAmount, + initialMode: .full, + remarkPlaceholder: "请输入退款原因(必填)", + moneyMaxLength: 10 + ) + } + + static func storeOrder( + orderNumber: String, + actualPayAmount: String, + initialMode: OrderRefundMode + ) -> Configuration { + Configuration( + kind: .storeOrder, + orderNumber: orderNumber, + fullRefundAmount: "0", + maxRefundAmount: actualPayAmount, + initialMode: initialMode, + remarkPlaceholder: "请输入退款原因(必填,5-255字)", + moneyMaxLength: 15 + ) + } + } + + var onConfirm: ((OrderRefundDialogPayload) -> Void)? + var onMessage: ((String) -> Void)? + + private let configuration: Configuration + private var selectedMode: OrderRefundMode + private var keyboardObservers: [NSObjectProtocol] = [] + private var cardCenterYConstraint: Constraint? + + private let dimView = UIView() + private let cardView = UIView() + private let titleLabel = UILabel() + private let separatorView = UIView() + private let orderTitleLabel = UILabel() + private let orderNumberLabel = UILabel() + private let refundModeTitleLabel = UILabel() + private let modeButtonStack = UIStackView() + private let fullRefundButton = UIButton(type: .system) + private let partialRefundButton = UIButton(type: .system) + private let amountGroupView = UIView() + private let amountTitleLabel = UILabel() + private let amountContainerView = UIView() + private let amountField = UITextField() + private let remarkTitleLabel = UILabel() + private let remarkContainerView = UIView() + private let remarkTextView = UITextView() + private let remarkPlaceholderLabel = UILabel() + private let actionRow = UIStackView() + private let cancelButton = UIButton(type: .system) + private let confirmButton = UIButton(type: .system) + + init(configuration: Configuration) { + self.configuration = configuration + self.selectedMode = configuration.initialMode + super.init(frame: .zero) + setupUI() + applyMode(animated: false) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + unregisterKeyboardObservers() + } + + func show(in parent: UIView) { + frame = parent.bounds + autoresizingMask = [.flexibleWidth, .flexibleHeight] + alpha = 0 + parent.addSubview(self) + registerKeyboardObservers() + UIView.animate(withDuration: 0.2) { + self.alpha = 1 + } + } + + func dismiss() { + endEditing(true) + unregisterKeyboardObservers() + UIView.animate(withDuration: 0.2, animations: { + self.alpha = 0 + }, completion: { _ in + self.removeFromSuperview() }) - presenter.present(alert, animated: true) + } + + private func setupUI() { + dimView.backgroundColor = UIColor.black.withAlphaComponent(0.2) + dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped))) + + cardView.backgroundColor = .white + cardView.layer.cornerRadius = AppRadius.md + cardView.clipsToBounds = true + + titleLabel.text = "订单退款" + titleLabel.font = .systemFont(ofSize: 18, weight: .bold) + titleLabel.textColor = .black + + separatorView.backgroundColor = UIColor(hex: 0xF4F4F4) + + configureSectionLabel(orderTitleLabel, text: "订单号") + orderNumberLabel.text = configuration.orderNumber + orderNumberLabel.font = .systemFont(ofSize: 16) + orderNumberLabel.textColor = .black + orderNumberLabel.numberOfLines = 0 + + configureSectionLabel(refundModeTitleLabel, text: "退款方式") + configureModeButton(fullRefundButton, title: "全额退款") + configureModeButton(partialRefundButton, title: "部分退款") + fullRefundButton.addTarget(self, action: #selector(fullRefundTapped), for: .touchUpInside) + partialRefundButton.addTarget(self, action: #selector(partialRefundTapped), for: .touchUpInside) + + modeButtonStack.axis = .horizontal + modeButtonStack.spacing = AppSpacing.md + modeButtonStack.distribution = .fillEqually + + configureSectionLabel(amountTitleLabel, text: "退款金额") + amountContainerView.layer.borderWidth = 1 + amountContainerView.layer.borderColor = UIColor(hex: 0xB6BECA).cgColor + amountContainerView.layer.cornerRadius = AppRadius.sm + + amountField.placeholder = "请输入退款金额" + amountField.font = .systemFont(ofSize: 14) + amountField.textColor = AppColor.textPrimary + amountField.keyboardType = .decimalPad + amountField.borderStyle = .none + amountField.addTarget(self, action: #selector(amountChanged), for: .editingChanged) + + configureSectionLabel(remarkTitleLabel, text: "退款备注") + remarkContainerView.layer.borderWidth = 1 + remarkContainerView.layer.borderColor = UIColor(hex: 0xB6BECA).cgColor + remarkContainerView.layer.cornerRadius = AppRadius.sm + + remarkTextView.font = .systemFont(ofSize: 14) + remarkTextView.textColor = AppColor.textPrimary + remarkTextView.backgroundColor = .clear + remarkTextView.textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 10, right: 8) + remarkTextView.delegate = self + + remarkPlaceholderLabel.text = configuration.remarkPlaceholder + remarkPlaceholderLabel.font = .systemFont(ofSize: 14) + remarkPlaceholderLabel.textColor = AppColor.textTertiary + + actionRow.axis = .horizontal + actionRow.alignment = .center + actionRow.spacing = AppSpacing.xs + + configureActionButton(cancelButton, title: "取消", foreground: AppColor.primary, background: AppColor.primaryLight) + configureActionButton(confirmButton, title: "确认", foreground: .white, background: AppColor.primary) + cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) + confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) + + addSubview(dimView) + addSubview(cardView) + cardView.addSubview(titleLabel) + cardView.addSubview(separatorView) + cardView.addSubview(orderTitleLabel) + cardView.addSubview(orderNumberLabel) + cardView.addSubview(refundModeTitleLabel) + cardView.addSubview(modeButtonStack) + modeButtonStack.addArrangedSubview(fullRefundButton) + modeButtonStack.addArrangedSubview(partialRefundButton) + cardView.addSubview(amountGroupView) + amountGroupView.addSubview(amountTitleLabel) + amountGroupView.addSubview(amountContainerView) + amountContainerView.addSubview(amountField) + cardView.addSubview(remarkTitleLabel) + cardView.addSubview(remarkContainerView) + remarkContainerView.addSubview(remarkTextView) + remarkContainerView.addSubview(remarkPlaceholderLabel) + cardView.addSubview(actionRow) + actionRow.addArrangedSubview(cancelButton) + actionRow.addArrangedSubview(confirmButton) + + dimView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + cardView.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + cardCenterYConstraint = make.centerY.equalToSuperview().constraint + } + titleLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(AppSpacing.sm) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + } + separatorView.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.height.equalTo(1) + } + orderTitleLabel.snp.makeConstraints { make in + make.top.equalTo(separatorView.snp.bottom).offset(AppSpacing.md) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + } + orderNumberLabel.snp.makeConstraints { make in + make.top.equalTo(orderTitleLabel.snp.bottom).offset(AppSpacing.xs) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + } + refundModeTitleLabel.snp.makeConstraints { make in + make.top.equalTo(orderNumberLabel.snp.bottom).offset(AppSpacing.xl) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + } + modeButtonStack.snp.makeConstraints { make in + make.top.equalTo(refundModeTitleLabel.snp.bottom).offset(AppSpacing.xs) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.height.equalTo(46) + } + amountGroupView.snp.makeConstraints { make in + make.top.equalTo(modeButtonStack.snp.bottom).offset(AppSpacing.xl) + make.leading.trailing.equalToSuperview() + make.height.equalTo(73) + } + amountTitleLabel.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(UIEdgeInsets(top: 0, left: AppSpacing.md, bottom: 0, right: AppSpacing.md)) + } + amountContainerView.snp.makeConstraints { make in + make.top.equalTo(amountTitleLabel.snp.bottom).offset(AppSpacing.xs) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.height.equalTo(46) + } + amountField.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(AppSpacing.sm) + make.centerY.equalToSuperview() + } + remarkTitleLabel.snp.makeConstraints { make in + make.top.equalTo(amountGroupView.snp.bottom).offset(AppSpacing.xl) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + } + remarkContainerView.snp.makeConstraints { make in + make.top.equalTo(remarkTitleLabel.snp.bottom).offset(AppSpacing.xs) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.height.equalTo(104) + } + remarkTextView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + remarkPlaceholderLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(16) + make.leading.trailing.equalToSuperview().inset(AppSpacing.sm) + } + actionRow.snp.makeConstraints { make in + make.top.equalTo(remarkContainerView.snp.bottom).offset(20) + make.trailing.equalToSuperview().inset(AppSpacing.md) + make.bottom.equalToSuperview().inset(AppSpacing.lg) + } + [cancelButton, confirmButton].forEach { button in + button.snp.makeConstraints { make in + make.width.equalTo(84) + make.height.equalTo(38) + } + } + } + + private func configureSectionLabel(_ label: UILabel, text: String) { + label.text = text + label.font = .systemFont(ofSize: 16) + label.textColor = UIColor(hex: 0x4B5563) + } + + private func configureModeButton(_ button: UIButton, title: String) { + button.setTitle(title, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 14) + button.layer.cornerRadius = AppRadius.sm + button.layer.borderWidth = 1 + } + + private func configureActionButton(_ button: UIButton, title: String, foreground: UIColor, background: UIColor) { + button.setTitle(title, for: .normal) + button.setTitleColor(foreground, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + button.backgroundColor = background + button.layer.cornerRadius = AppRadius.sm + } + + private func applyMode(animated: Bool) { + let amountHidden = selectedMode == .full + amountGroupView.isHidden = amountHidden + amountGroupView.snp.updateConstraints { make in + make.height.equalTo(amountHidden ? 0 : 73) + } + remarkTitleLabel.snp.remakeConstraints { make in + make.top.equalTo((amountHidden ? modeButtonStack : amountGroupView).snp.bottom).offset(AppSpacing.xl) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + } + + updateModeButton(fullRefundButton, selected: selectedMode == .full) + updateModeButton(partialRefundButton, selected: selectedMode == .partial) + let changes = { self.layoutIfNeeded() } + if animated { + UIView.animate(withDuration: 0.2, animations: changes) + } else { + changes() + } + } + + private func updateModeButton(_ button: UIButton, selected: Bool) { + button.backgroundColor = selected ? AppColor.primary : .white + button.layer.borderColor = selected ? UIColor.clear.cgColor : UIColor(hex: 0xB6BECA).cgColor + button.setTitleColor(selected ? .white : UIColor(hex: 0x4B5563), for: .normal) + } + + private func registerKeyboardObservers() { + unregisterKeyboardObservers() + let center = NotificationCenter.default + keyboardObservers = [ + center.addObserver( + forName: UIResponder.keyboardWillChangeFrameNotification, + object: nil, + queue: .main + ) { [weak self] notification in + self?.handleKeyboardNotification(notification) + }, + center.addObserver( + forName: UIResponder.keyboardWillHideNotification, + object: nil, + queue: .main + ) { [weak self] notification in + self?.handleKeyboardNotification(notification) + } + ] + } + + private func unregisterKeyboardObservers() { + keyboardObservers.forEach(NotificationCenter.default.removeObserver) + keyboardObservers.removeAll() + } + + private func handleKeyboardNotification(_ notification: Notification) { + layoutIfNeeded() + guard let userInfo = notification.userInfo else { return } + let duration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25 + let curveRaw = (userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue + ?? UInt(UIView.AnimationCurve.easeInOut.rawValue) + let options = UIView.AnimationOptions(rawValue: curveRaw << 16) + let offset = keyboardAvoidanceOffset(from: userInfo) + cardCenterYConstraint?.update(offset: offset) + UIView.animate(withDuration: duration, delay: 0, options: options) { + self.layoutIfNeeded() + } + } + + private func keyboardAvoidanceOffset(from userInfo: [AnyHashable: Any]) -> CGFloat { + guard + let keyboardFrameValue = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue + else { + return 0 + } + let keyboardFrame = convert(keyboardFrameValue.cgRectValue, from: nil) + guard keyboardFrame.minY < bounds.height else { return 0 } + + let desiredBottom = keyboardFrame.minY - AppSpacing.sm + let overlap = cardView.frame.maxY - desiredBottom + return overlap > 0 ? -overlap : 0 + } + + func textViewDidChange(_ textView: UITextView) { + if textView.text.count > 255 { + textView.text = String(textView.text.prefix(255)) + } + remarkPlaceholderLabel.isHidden = !textView.text.isEmpty + } + + @objc private func amountChanged() { + let filtered = OrderRefundDialogValidation.filterMoneyInput( + amountField.text ?? "", + maxLength: configuration.moneyMaxLength + ) + if amountField.text != filtered { + amountField.text = filtered + } + } + + @objc private func fullRefundTapped() { + selectedMode = .full + amountField.text = configuration.kind == .storeOrder ? configuration.maxRefundAmount : configuration.fullRefundAmount + applyMode(animated: true) + } + + @objc private func partialRefundTapped() { + selectedMode = .partial + amountField.text = "" + applyMode(animated: true) + amountField.becomeFirstResponder() + } + + @objc private func cancelTapped() { + dismiss() + } + + @objc private func confirmTapped() { + let result = OrderRefundDialogValidation.validate( + kind: configuration.kind, + mode: selectedMode, + amountText: amountField.text ?? "", + reason: remarkTextView.text ?? "", + fullRefundAmount: configuration.fullRefundAmount, + maxRefundAmount: configuration.maxRefundAmount + ) + switch result { + case .success(let payload): + onConfirm?(payload) + dismiss() + case .failure(let error): + onMessage?(error.message) + } } } diff --git a/suixinkan/UI/Orders/OrderSourceLeadListViewController.swift b/suixinkan/UI/Orders/OrderSourceLeadListViewController.swift index 08311e3..c73f7cd 100644 --- a/suixinkan/UI/Orders/OrderSourceLeadListViewController.swift +++ b/suixinkan/UI/Orders/OrderSourceLeadListViewController.swift @@ -125,13 +125,6 @@ final class OrderSourceLeadListViewController: BaseViewController, UITableViewDa } } - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - if isMovingFromParent { - navigationController?.setNavigationBarHidden(true, animated: false) - } - } - func reloadFromViewModel() { applyViewModel() } diff --git a/suixinkan/UI/Orders/OrderSourcePickerViewController.swift b/suixinkan/UI/Orders/OrderSourcePickerViewController.swift index 6a7912f..08c12a9 100644 --- a/suixinkan/UI/Orders/OrderSourcePickerViewController.swift +++ b/suixinkan/UI/Orders/OrderSourcePickerViewController.swift @@ -16,7 +16,6 @@ final class OrderSourcePickerViewController: UIViewController, UITableViewDataSo private let onLeadBound: (OrderSourceSelection) -> Void private let onCallPhone: (String) -> Void - private let titleLabel = UILabel() private let subtitleLabel = UILabel() private let tableView = UITableView(frame: .zero, style: .plain) private let loadingIndicator = UIActivityIndicatorView(style: .medium) @@ -46,6 +45,8 @@ final class OrderSourcePickerViewController: UIViewController, UITableViewDataSo override func viewDidLoad() { super.viewDidLoad() + title = "选择获客员" + navigationItem.largeTitleDisplayMode = .never view.backgroundColor = .white setupUI() applyViewModel() @@ -58,10 +59,6 @@ final class OrderSourcePickerViewController: UIViewController, UITableViewDataSo } private func setupUI() { - titleLabel.font = .systemFont(ofSize: 18, weight: .bold) - titleLabel.textColor = AppColor.text333 - titleLabel.text = "选择获客员" - subtitleLabel.font = .systemFont(ofSize: 13) subtitleLabel.textColor = AppColor.text666 subtitleLabel.text = "选择人员后,再选择该人员创建的带客单" @@ -82,20 +79,15 @@ final class OrderSourcePickerViewController: UIViewController, UITableViewDataSo emptyLabel.numberOfLines = 0 emptyLabel.text = "暂无合作获客员" - view.addSubview(titleLabel) view.addSubview(subtitleLabel) view.addSubview(tableView) view.addSubview(loadingIndicator) view.addSubview(emptyLabel) - titleLabel.snp.makeConstraints { make in + subtitleLabel.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.md) make.leading.trailing.equalToSuperview().inset(AppSpacing.md) } - subtitleLabel.snp.makeConstraints { make in - make.top.equalTo(titleLabel.snp.bottom).offset(5) - make.leading.trailing.equalTo(titleLabel) - } tableView.snp.makeConstraints { make in make.top.equalTo(subtitleLabel.snp.bottom).offset(14) make.leading.trailing.bottom.equalToSuperview() diff --git a/suixinkan/UI/Orders/OrdersViewController.swift b/suixinkan/UI/Orders/OrdersViewController.swift index d206e80..9c5122f 100644 --- a/suixinkan/UI/Orders/OrdersViewController.swift +++ b/suixinkan/UI/Orders/OrdersViewController.swift @@ -411,11 +411,11 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT orderNumber: item.orderNumber, amount: item.actualPayAmount, refundType: 1 - ) { amount, reason in + ) { refundType, amount, reason in Task { await self.depositViewModel.requestRefund( orderNumber: item.orderNumber, - refundType: 1, + refundType: refundType, refundAmount: amount, refundReason: reason, api: self.orderAPI @@ -430,11 +430,11 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT orderNumber: item.orderNumber, amount: item.actualPayAmount, refundType: 3 - ) { amount, reason in + ) { refundType, amount, reason in Task { await self.depositViewModel.requestRefund( orderNumber: item.orderNumber, - refundType: 3, + refundType: refundType, refundAmount: amount, refundReason: reason, api: self.orderAPI @@ -496,7 +496,6 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT ) self.orderSourcePicker = controller let nav = UINavigationController(rootViewController: controller) - nav.setNavigationBarHidden(true, animated: false) if let sheet = nav.sheetPresentationController { sheet.prefersGrabberVisible = true if #available(iOS 16.0, *) { diff --git a/suixinkan/UI/Orders/Views/OrderTypeChipView.swift b/suixinkan/UI/Orders/Views/OrderTypeChipView.swift index 07d8e3c..64a050c 100644 --- a/suixinkan/UI/Orders/Views/OrderTypeChipView.swift +++ b/suixinkan/UI/Orders/Views/OrderTypeChipView.swift @@ -9,19 +9,44 @@ import UIKit /// 订单类型标签 chip。 final class OrderTypeChipView: UIView { + private enum Metrics { + static let contentInsets = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8) + } + + private let pillView = UIView() private let label = UILabel() + override var intrinsicContentSize: CGSize { + let labelSize = label.intrinsicContentSize + return CGSize( + width: labelSize.width + Metrics.contentInsets.left + Metrics.contentInsets.right, + height: labelSize.height + Metrics.contentInsets.top + Metrics.contentInsets.bottom + ) + } + override init(frame: CGRect) { super.init(frame: frame) - backgroundColor = OrderTokens.chipBackground - layer.cornerRadius = OrderTokens.pillRadius - clipsToBounds = true + backgroundColor = .clear + setContentHuggingPriority(.required, for: .horizontal) + setContentHuggingPriority(.required, for: .vertical) + + pillView.backgroundColor = OrderTokens.chipBackground + pillView.layer.cornerRadius = OrderTokens.pillRadius + pillView.clipsToBounds = true label.font = .app(.caption) label.textColor = AppColor.primary - addSubview(label) + label.lineBreakMode = .byTruncatingTail + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + addSubview(pillView) + pillView.addSubview(label) + pillView.snp.makeConstraints { make in + make.top.bottom.leading.equalToSuperview() + make.trailing.lessThanOrEqualToSuperview() + } label.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: AppSpacing.xxs, bottom: 0, right: AppSpacing.xxs)) + make.edges.equalToSuperview().inset(Metrics.contentInsets) } } @@ -33,5 +58,6 @@ final class OrderTypeChipView: UIView { func apply(text: String) { label.text = text isHidden = text.isEmpty + invalidateIntrinsicContentSize() } } diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift index e1d08c4..39da0da 100644 --- a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift @@ -125,7 +125,7 @@ final class WildPhotographerReportListViewController: BaseViewController { reports.forEach { record in let card = WildReportListCardView(record: record) card.onOpen = { [weak self] in self?.openDetail(record) } - card.onShare = { [weak self] in self?.viewModel.shareReport(record) } + card.onShare = { [weak self] in self?.shareReport(record) } stack.addArrangedSubview(card) } } @@ -148,6 +148,21 @@ final class WildPhotographerReportListViewController: BaseViewController { ) } + private func shareReport(_ record: WildReportRecord) { + showLoading() + Task { [weak self] in + guard let self else { return } + let payload = await self.viewModel.shareReport(record, api: self.api) + await MainActor.run { + self.hideLoading() + guard let payload else { return } + WeChatShareService.shareMiniProgram(payload) { [weak self] result in + self?.showWildReportShareResult(result) + } + } + } + } + private var currentScenicId: Int? { let scenicId = AppStore.shared.currentScenicId return scenicId > 0 ? scenicId : nil @@ -736,7 +751,7 @@ final class WildPhotographerReportDetailViewController: BaseViewController { card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "举报说明", value: viewModel.descriptionText, multiline: true)) card.stack.addArrangedSubview(makeInsetDivider()) card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "微信号/手机号", value: viewModel.contactText)) - if !viewModel.handleRemarkText.isEmpty { + if !viewModel.handleRemarkText.isEmpty, !viewModel.showHandlerInfo { card.stack.addArrangedSubview(makeInsetDivider()) card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "处理备注", value: viewModel.handleRemarkText, multiline: true)) } @@ -768,13 +783,16 @@ final class WildPhotographerReportDetailViewController: BaseViewController { make.edges.equalToSuperview().inset(UIEdgeInsets(top: 18, left: 18, bottom: 10, right: 18)) } card.stack.addArrangedSubview(titleWrap) - card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "处理人", value: info.handlerName)) + card.stack.addArrangedSubview(WildReportHandlerInfoRowView(title: "处理人", value: info.handlerName)) card.stack.addArrangedSubview(makeInsetDivider()) - card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "处理人电话", value: info.maskedHandlerPhone, trailingIconName: "phone.fill")) + let phoneRow = WildReportHandlerInfoRowView(title: "处理人电话", value: info.maskedHandlerPhone, trailingIconName: "phone.fill") + phoneRow.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handlerPhoneTapped))) + phoneRow.accessibilityTraits.insert(.button) + card.stack.addArrangedSubview(phoneRow) card.stack.addArrangedSubview(makeInsetDivider()) - card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "处理时间", value: info.processingAt)) + card.stack.addArrangedSubview(WildReportHandlerInfoRowView(title: "处理时间", value: info.processingAt)) card.stack.addArrangedSubview(makeInsetDivider()) - card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "处理完成时间", value: info.processedAt)) + card.stack.addArrangedSubview(WildReportHandlerInfoRowView(title: "处理完成时间", value: info.processedAt)) card.stack.addArrangedSubview(makeInsetDivider()) card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "处理意见", value: info.processOpinion, multiline: true)) @@ -800,7 +818,11 @@ final class WildPhotographerReportDetailViewController: BaseViewController { header.alignment = .center header.addArrangedSubview(WildReportUI.label(item.submitTime, font: .app(.captionMedium), color: AppColor.textSecondary)) header.addArrangedSubview(UIView()) - header.addArrangedSubview(WildReportUI.label("第\(items.count - index)次补充", font: .app(.captionMedium), color: AppColor.primary)) + header.addArrangedSubview(WildReportUI.label( + WildReportSupplementDisplayFormatter.sequenceTitle(forDisplayIndex: index), + font: .app(.captionMedium), + color: AppColor.primary + )) itemStack.addArrangedSubview(header) itemStack.addArrangedSubview(WildReportUI.label(item.summaryText, font: .app(.bodyMedium), color: AppColor.textPrimary, lines: 0)) card.stack.addArrangedSubview(itemStack) @@ -948,12 +970,11 @@ final class WildPhotographerReportDetailViewController: BaseViewController { section.spacing = 12 section.layoutMargins = UIEdgeInsets(top: 14, left: 18, bottom: 16, right: 18) section.isLayoutMarginsRelativeArrangement = true - section.addArrangedSubview(makeSectionHeader( + section.addArrangedSubview(makeCompactMediaSectionHeader( iconName: "photo.on.rectangle.angled", title: "处理凭证", - subtitle: info.completionMediaSummary, - chevron: false, - action: nil + summary: info.completionMediaSummary, + action: #selector(completionPreviewTapped) )) let mediaScroll = UIScrollView() @@ -1199,10 +1220,33 @@ final class WildPhotographerReportDetailViewController: BaseViewController { return wrapper } - @objc private func shareTapped() { viewModel.shareReport() } + @objc private func shareTapped() { + showLoading() + Task { [weak self] in + guard let self else { return } + let payload = await self.viewModel.shareReport(api: self.api) + await MainActor.run { + self.hideLoading() + guard let payload else { return } + WeChatShareService.shareMiniProgram(payload) { [weak self] result in + self?.showWildReportShareResult(result) + } + } + } + } @objc private func previewTapped() { presentReporterMediaPreview(selectedKey: nil) } @objc private func paymentPreviewTapped() { presentPaymentMediaPreview(selectedKey: nil) } + @objc private func completionPreviewTapped() { presentCompletionMediaPreview(selectedKey: nil) } @objc private func locationTapped() { viewModel.showLocationPreview() } + @objc private func handlerPhoneTapped() { + guard let phone = viewModel.handlerInfo?.handlerPhone.trimmingCharacters(in: .whitespacesAndNewlines), + !phone.isEmpty, + let url = URL(string: "tel://\(phone)") else { + showToast("暂无处理人电话") + return + } + UIApplication.shared.open(url) + } @objc private func reporterMediaTapped(_ recognizer: UITapGestureRecognizer) { guard let tile = recognizer.view as? WildReportDetailMediaThumbnailView else { return } @@ -1367,6 +1411,60 @@ private final class WildReportDetailInfoRowView: UIView { } } +/// 举报详情页处理信息单行,左侧字段名、右侧内容。 +private final class WildReportHandlerInfoRowView: UIView { + init(title: String, value: String, trailingIconName: String? = nil) { + super.init(frame: .zero) + + let titleLabel = WildReportUI.label(title, font: .app(.captionMedium), color: AppColor.textSecondary) + titleLabel.setContentHuggingPriority(.required, for: .horizontal) + titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + + let valueLabel = WildReportUI.label(value, font: .app(.subtitle), color: AppColor.textPrimary, lines: 1) + valueLabel.textAlignment = .right + valueLabel.lineBreakMode = .byTruncatingTail + valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + addSubview(titleLabel) + addSubview(valueLabel) + + if let trailingIconName { + let icon = UIImageView(image: UIImage(systemName: trailingIconName)) + icon.tintColor = AppColor.primary + icon.contentMode = .scaleAspectFit + addSubview(icon) + icon.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(18) + make.centerY.equalToSuperview() + make.size.equalTo(16) + } + valueLabel.snp.makeConstraints { make in + make.trailing.equalTo(icon.snp.leading).offset(-6) + } + } + + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(18) + make.centerY.equalToSuperview() + } + valueLabel.snp.makeConstraints { make in + make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(14) + make.centerY.equalToSuperview() + if trailingIconName == nil { + make.trailing.equalToSuperview().inset(18) + } + } + snp.makeConstraints { make in + make.height.equalTo(48) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + private extension MediaPreviewItem { init?(wildReportReporterItem item: WildReportReporterMediaItem) { guard let urlText = item.previewKey else { return nil } @@ -1393,6 +1491,28 @@ private extension MediaPreviewItem { } } +private extension BaseViewController { + @MainActor + func showWildReportShareResult(_ result: WeChatShareResult) { + switch result { + case .success: + showToast("分享成功") + case .cancelled: + showToast("已取消分享") + case .notInstalled: + showToast("请先安装微信") + case .unsupported: + showToast("当前微信版本不支持分享") + case .invalidPayload(let message): + showToast(message) + case .sendFailed: + showToast("微信分享发送失败") + case .unknown: + showToast("微信分享失败") + } + } +} + private extension WildReportReporterMediaItem { var previewKey: String? { guard let text = fileURL?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return nil } diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift index 517d889..a5e8a65 100644 --- a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift @@ -20,6 +20,7 @@ final class WildPhotographerReportSubmitViewController: BaseViewController { private let submitButton = UIButton(type: .system) private var pickerTarget: WildReportPickerTarget = .image private weak var activeInputView: UIView? + private var isShowingSubmitLoading = false init( homeViewModel: WildPhotographerReportHomeViewModel, @@ -109,7 +110,11 @@ final class WildPhotographerReportSubmitViewController: BaseViewController { override func bindActions() { viewModel.onStateChange = { [weak self] in - Task { @MainActor in self?.rebuildContent() } + Task { @MainActor in + guard let self else { return } + self.updateSubmitLoading() + self.rebuildContent() + } } viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } @@ -152,7 +157,7 @@ final class WildPhotographerReportSubmitViewController: BaseViewController { contentStack.addArrangedSubview(paymentCard) contentStack.setCustomSpacing(16, after: paymentCard) contentStack.addArrangedSubview(submitButton) - submitButton.setTitle(viewModel.isSubmitting ? viewModel.submitProgressText : "提交举报", for: .normal) + submitButton.setTitle("提交举报", for: .normal) submitButton.isEnabled = !viewModel.isSubmitting submitButton.alpha = viewModel.isSubmitting ? 0.78 : 1 submitButton.snp.remakeConstraints { make in @@ -561,6 +566,18 @@ final class WildPhotographerReportSubmitViewController: BaseViewController { @objc private func contactChanged() { viewModel.updateContact(contactField.text ?? "") } @objc private func dismissKeyboard() { view.endEditing(true) } + @MainActor + private func updateSubmitLoading() { + if viewModel.isSubmitting { + guard !isShowingSubmitLoading else { return } + isShowingSubmitLoading = true + showLoading() + } else if isShowingSubmitLoading { + isShowingSubmitLoading = false + hideLoading() + } + } + private func registerKeyboardNotifications() { NotificationCenter.default.addObserver( self, diff --git a/suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift b/suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift index 1a93e14..e7341a0 100644 --- a/suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift @@ -23,6 +23,7 @@ final class WildReportSupplementEvidenceViewController: BaseViewController { private var pickerTarget: WildReportPickerTarget = .image private weak var activeInputView: UIView? + private var isShowingSubmitLoading = false init( record: WildReportRecord, @@ -111,6 +112,7 @@ final class WildReportSupplementEvidenceViewController: BaseViewController { viewModel.onStateChange = { [weak self] in Task { @MainActor in guard let self else { return } + self.updateSubmitLoading() if self.textView.isFirstResponder { self.updateTextInputState() self.updateSubmitButton() @@ -271,14 +273,23 @@ final class WildReportSupplementEvidenceViewController: BaseViewController { } private func updateSubmitButton() { - let title = viewModel.isSubmitting && !viewModel.submitProgressText.isEmpty - ? viewModel.submitProgressText - : "提交补充证据" - submitButton.setTitle(title, for: .normal) + submitButton.setTitle("提交补充证据", for: .normal) submitButton.isEnabled = !viewModel.isSubmitting submitButton.alpha = viewModel.isSubmitting ? 0.78 : 1 } + @MainActor + private func updateSubmitLoading() { + if viewModel.isSubmitting { + guard !isShowingSubmitLoading else { return } + isShowingSubmitLoading = true + showLoading() + } else if isShowingSubmitLoading { + isShowingSubmitLoading = false + hideLoading() + } + } + private func canAddAttachment(kind: WildReportAttachmentKind, currentCount: Int, maxCount: Int) -> Bool { let ownRemaining = max(0, maxCount - currentCount) let evidenceRemaining = max(0, 9 - viewModel.images.count - viewModel.videos.count) diff --git a/suixinkan/suixinkan.entitlements b/suixinkan/suixinkan.entitlements index b9bf05e..8d1e200 100644 --- a/suixinkan/suixinkan.entitlements +++ b/suixinkan/suixinkan.entitlements @@ -6,5 +6,7 @@ applinks:www.zhifly.cn + com.apple.developer.networking.wifi-info + diff --git a/suixinkanTests/CooperationAcquirerViewModelTests.swift b/suixinkanTests/CooperationAcquirerViewModelTests.swift index ae78a43..95e5373 100644 --- a/suixinkanTests/CooperationAcquirerViewModelTests.swift +++ b/suixinkanTests/CooperationAcquirerViewModelTests.swift @@ -46,6 +46,39 @@ final class CooperationAcquirerViewModelTests: XCTestCase { XCTAssertEqual(message, "请输入验证码") } + func testCommissionRateDraftFiltersDigitsAndLimitsLength() { + let viewModel = CooperationAcquirerViewModel(appStore: appStore) + + viewModel.updateCommissionRateDraft("a12b345") + + XCTAssertEqual(viewModel.commissionRateDraft, "123") + } + + func testCommissionSmsCodeDraftFiltersDigitsAndLimitsLength() { + let viewModel = CooperationAcquirerViewModel(appStore: appStore) + + viewModel.updateCommissionSmsCodeDraft("1a2b345678") + + XCTAssertEqual(viewModel.commissionSmsCodeDraft, "123456") + } + + func testRequestCommissionSmsCodeShowsCountdownMessageWhenTimerActive() async { + let json = """ + {"code":100000,"msg":"success","data":{}} + """.data(using: .utf8)! + let viewModel = CooperationAcquirerViewModel(appStore: appStore) + var message: String? + viewModel.onShowMessage = { message = $0 } + viewModel.startEditCommissionRate(CooperativeSalerEntity(saleUserId: 1, salerPhone: "13800138000")) + + await viewModel.requestCommissionSmsCode(api: makeAPI(responses: [json])) + await Task.yield() + await viewModel.requestCommissionSmsCode(api: makeAPI(responses: [])) + + XCTAssertEqual(message, "请在倒计时结束后重新获取") + viewModel.dismissCommissionRateDialog() + } + func testProcessScanResultNavigatesWithValidQR() { let viewModel = CooperationAcquirerViewModel(appStore: appStore) var navigatedId: Int? diff --git a/suixinkanTests/HomeCommonMenuStoreTests.swift b/suixinkanTests/HomeCommonMenuStoreTests.swift index 5e844f5..20f9809 100644 --- a/suixinkanTests/HomeCommonMenuStoreTests.swift +++ b/suixinkanTests/HomeCommonMenuStoreTests.swift @@ -63,6 +63,14 @@ final class HomeCommonMenuStoreTests: XCTestCase { let item = HomeMenuCatalog.item(for: "report_photographer") XCTAssertEqual(item?.title, "举报摄影师") - XCTAssertEqual(item?.iconName, "exclamationmark.shield") + XCTAssertEqual(item?.iconName, "shield.fill") + } + + func testHomeMenuCatalogUsesSyncedSymbols() { + XCTAssertEqual(HomeMenuCatalog.item(for: "space_settings")?.iconName, "person.crop.square.fill") + XCTAssertEqual(HomeMenuCatalog.item(for: "wallet")?.iconName, "creditcard.fill") + XCTAssertEqual(HomeMenuCatalog.item(for: "sample_management")?.iconName, "point.3.connected.trianglepath.dotted") + XCTAssertEqual(HomeMenuCatalog.item(for: "/scenic-queue")?.iconName, "person.3.fill") + XCTAssertEqual(HomeMenuItem.moreFunctions.iconName, "ellipsis") } } diff --git a/suixinkanTests/HomeViewModelTests.swift b/suixinkanTests/HomeViewModelTests.swift index 9a26b42..e76962b 100644 --- a/suixinkanTests/HomeViewModelTests.swift +++ b/suixinkanTests/HomeViewModelTests.swift @@ -3,6 +3,7 @@ // suixinkanTests // +import UIKit import XCTest @testable import suixinkan @@ -48,6 +49,33 @@ final class HomeViewModelTests: XCTestCase { XCTAssertEqual(viewModel.locationInfoText, "您已进入打卡范围") } + func testQuickActionsOnlineEntrySyncsOfflineStyle() throws { + let view = HomeQuickActionsView() + + view.apply(isOnline: false) + + let statusContainer = try statusActionContainer(in: view) + XCTAssertEqual(statusContainer.backgroundColor?.homeTestHexRGB, AppColor.cardBackground.homeTestHexRGB) + XCTAssertEqual(statusContainer.layer.borderWidth, 1) + XCTAssertEqual(statusLabel(in: statusContainer)?.text, "离线") + XCTAssertEqual(statusLabel(in: statusContainer)?.textColor.homeTestHexRGB, AppColor.textTabInactive.homeTestHexRGB) + XCTAssertEqual(statusIcon(in: statusContainer)?.accessibilityIdentifier, "wifi.slash") + XCTAssertEqual(statusIcon(in: statusContainer)?.tintColor.homeTestHexRGB, AppColor.textTabInactive.homeTestHexRGB) + } + + func testQuickActionsOnlineEntrySyncsOnlineStyle() throws { + let view = HomeQuickActionsView() + + view.apply(isOnline: true) + + let statusContainer = try statusActionContainer(in: view) + XCTAssertEqual(statusContainer.backgroundColor?.homeTestHexRGB, AppColor.online.homeTestHexRGB) + XCTAssertEqual(statusLabel(in: statusContainer)?.text, "在线") + XCTAssertEqual(statusLabel(in: statusContainer)?.textColor.homeTestHexRGB, AppColor.successBackground.homeTestHexRGB) + XCTAssertEqual(statusIcon(in: statusContainer)?.accessibilityIdentifier, "wifi") + XCTAssertEqual(statusIcon(in: statusContainer)?.tintColor.homeTestHexRGB, AppColor.successBackground.homeTestHexRGB) + } + func testRebuildCommonMenusFiltersUnknownURIs() { appStore.savePermissionItems([ HomePermissionItem(id: "1", name: "钱包", uri: "wallet"), @@ -178,4 +206,49 @@ final class HomeViewModelTests: XCTestCase { XCTAssertEqual(viewModel.currentScenicName, "") XCTAssertEqual(appStore.currentScenicName, "") } + + private func statusActionContainer(in view: HomeQuickActionsView) throws -> UIView { + let stackView = try XCTUnwrap(view.subviews.compactMap { $0 as? UIStackView }.first) + XCTAssertEqual(stackView.arrangedSubviews.count, 3) + return stackView.arrangedSubviews[2] + } + + private func statusLabel(in view: UIView) -> UILabel? { + if let label = view as? UILabel { + return label + } + for subview in view.subviews { + if let label = statusLabel(in: subview) { + return label + } + } + return nil + } + + private func statusIcon(in view: UIView) -> UIImageView? { + if let imageView = view as? UIImageView { + return imageView + } + for subview in view.subviews { + if let imageView = statusIcon(in: subview) { + return imageView + } + } + return nil + } +} + +private extension UIColor { + + var homeTestHexRGB: 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) + let r = Int(round(red * 255)) + let g = Int(round(green * 255)) + let b = Int(round(blue * 255)) + return UInt(r << 16 | g << 8 | b) + } } diff --git a/suixinkanTests/MainTabItemTests.swift b/suixinkanTests/MainTabItemTests.swift new file mode 100644 index 0000000..4eb6a4c --- /dev/null +++ b/suixinkanTests/MainTabItemTests.swift @@ -0,0 +1,42 @@ +// +// MainTabItemTests.swift +// suixinkanTests +// + +import UIKit +import XCTest +@testable import suixinkan + +/// 主 TabBar 图标与可访问性测试。 +@MainActor +final class MainTabItemTests: XCTestCase { + + func testAppTabsUseRestoredAssetIconsAndBuildImages() { + XCTAssertEqual(AppTab.home.unselectedImageName, "tab_home_unselected") + XCTAssertEqual(AppTab.home.selectedImageName, "tab_home_selected") + XCTAssertEqual(AppTab.orders.unselectedImageName, "tab_order_unselected") + XCTAssertEqual(AppTab.statistics.selectedImageName, "tab_data_selected") + XCTAssertEqual(AppTab.profile.unselectedImageName, "tab_profile_unselected") + + for tab in AppTab.allCases { + let item = tab.makeTabBarItem() + XCTAssertEqual(item.title, tab.title) + XCTAssertEqual(item.accessibilityIdentifier, "main.tab.\(tab.rawValue)") + XCTAssertNotNil(item.image) + XCTAssertNotNil(item.selectedImage) + XCTAssertEqual(item.image?.renderingMode, .alwaysOriginal) + XCTAssertEqual(item.selectedImage?.renderingMode, .alwaysOriginal) + } + } + + func testScanTabItemBuildsGeneratedImageAndKeepsAccessibility() { + let item = MainScanTabItem.makeTabBarItem() + + XCTAssertNil(item.title) + XCTAssertEqual(item.accessibilityLabel, "扫码核销") + XCTAssertEqual(item.accessibilityIdentifier, "main.scan") + XCTAssertNotNil(item.image) + XCTAssertNotNil(item.selectedImage) + XCTAssertEqual(item.image?.renderingMode, .alwaysOriginal) + } +} diff --git a/suixinkanTests/MaterialManagementViewModelTests.swift b/suixinkanTests/MaterialManagementViewModelTests.swift index 8b3005a..95031ca 100644 --- a/suixinkanTests/MaterialManagementViewModelTests.swift +++ b/suixinkanTests/MaterialManagementViewModelTests.swift @@ -118,6 +118,18 @@ final class MaterialManagementViewModelTests: XCTestCase { XCTAssertNil(api.lastUploadRequest?.id) } + func testUploadDialogStaysHiddenAfterCompletionProgressCallback() async { + let viewModel = MaterialFormViewModel(mode: .create, currentScenicIdProvider: { 3 }) + let uploader = MockMaterialUploader(urls: ["https://cdn/a.jpg"]) + + await viewModel.addLocalMedia([makeUploadItem(fileName: "a.jpg")], uploader: uploader) + await Task.yield() + + XCTAssertNil(viewModel.uploadDialogState) + XCTAssertFalse(viewModel.uploadedMediaList.first?.isUploading ?? true) + XCTAssertEqual(viewModel.uploadedMediaList.first?.uploadProgress, 100) + } + func testEditLoadsDetailAndBuildsEditRequest() async { let api = MockMaterialAPI() api.spotResponse = ScenicSpotListResponse(list: [ScenicSpotItem(id: 9, name: "打卡点")], total: 1) diff --git a/suixinkanTests/OrderListUITokensTests.swift b/suixinkanTests/OrderListUITokensTests.swift index 627e43d..fa2bde6 100644 --- a/suixinkanTests/OrderListUITokensTests.swift +++ b/suixinkanTests/OrderListUITokensTests.swift @@ -47,6 +47,16 @@ final class OrderListUITokensTests: XCTestCase { badge.applyOrderStatus(50, text: "已退款") XCTAssertEqual(badge.backgroundColor?.hexRGB, AppColor.dangerBackground.hexRGB) } + + func testOrderTypeChipIntrinsicSizeWrapsTextWithPadding() { + let chip = OrderTypeChipView() + chip.apply(text: "订单类型") + + let size = chip.intrinsicContentSize + XCTAssertGreaterThan(size.width, "订单类型".size(withAttributes: [.font: UIFont.app(.caption)]).width) + XCTAssertGreaterThan(size.height, UIFont.app(.caption).lineHeight) + XCTAssertFalse(chip.isHidden) + } } private extension UIColor { diff --git a/suixinkanTests/OrderRefundDialogValidationTests.swift b/suixinkanTests/OrderRefundDialogValidationTests.swift new file mode 100644 index 0000000..5fc7701 --- /dev/null +++ b/suixinkanTests/OrderRefundDialogValidationTests.swift @@ -0,0 +1,130 @@ +// +// OrderRefundDialogValidationTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +final class OrderRefundDialogValidationTests: XCTestCase { + + func testPhotographerOrderRequiresRefundReason() { + let result = OrderRefundDialogValidation.validate( + kind: .photographerOrder, + mode: .full, + amountText: "", + reason: "", + fullRefundAmount: "88.00", + maxRefundAmount: "88.00" + ) + + XCTAssertEqual(failureMessage(from: result), "请输入退款原因") + } + + func testPhotographerPartialRefundRequiresAmount() { + let result = OrderRefundDialogValidation.validate( + kind: .photographerOrder, + mode: .partial, + amountText: "", + reason: "用户申请退款", + fullRefundAmount: "88.00", + maxRefundAmount: "88.00" + ) + + XCTAssertEqual(failureMessage(from: result), "请输入退款金额") + } + + func testStoreOrderRefundReasonLengthValidation() { + let shortResult = OrderRefundDialogValidation.validate( + kind: .storeOrder, + mode: .full, + amountText: "", + reason: "太短", + fullRefundAmount: "0", + maxRefundAmount: "99.00" + ) + XCTAssertEqual(failureMessage(from: shortResult), "退款原因至少5个字符") + + let longResult = OrderRefundDialogValidation.validate( + kind: .storeOrder, + mode: .full, + amountText: "", + reason: String(repeating: "a", count: 256), + fullRefundAmount: "0", + maxRefundAmount: "99.00" + ) + XCTAssertEqual(failureMessage(from: longResult), "退款原因最多255个字符") + } + + func testStoreOrderPartialRefundAmountValidation() { + let emptyResult = OrderRefundDialogValidation.validate( + kind: .storeOrder, + mode: .partial, + amountText: "", + reason: "用户申请退款", + fullRefundAmount: "0", + maxRefundAmount: "99.00" + ) + XCTAssertEqual(failureMessage(from: emptyResult), "请输入退款金额") + + let invalidResult = OrderRefundDialogValidation.validate( + kind: .storeOrder, + mode: .partial, + amountText: "0", + reason: "用户申请退款", + fullRefundAmount: "0", + maxRefundAmount: "99.00" + ) + XCTAssertEqual(failureMessage(from: invalidResult), "请输入有效的退款金额") + + let overflowResult = OrderRefundDialogValidation.validate( + kind: .storeOrder, + mode: .partial, + amountText: "100.00", + reason: "用户申请退款", + fullRefundAmount: "0", + maxRefundAmount: "99.00" + ) + XCTAssertEqual(failureMessage(from: overflowResult), "退款金额不能大于实际支付金额") + } + + func testStoreOrderFullRefundUsesZeroAmount() throws { + let result = OrderRefundDialogValidation.validate( + kind: .storeOrder, + mode: .full, + amountText: "99.00", + reason: "用户申请退款", + fullRefundAmount: "0", + maxRefundAmount: "99.00" + ) + + let payload = try successPayload(from: result) + XCTAssertEqual(payload.refundType, OrderRefundMode.full.rawValue) + XCTAssertEqual(payload.refundAmount, "0") + XCTAssertEqual(payload.refundReason, "用户申请退款") + } + + func testMoneyInputKeepsSingleDotAndTwoDecimalPlaces() { + XCTAssertEqual(OrderRefundDialogValidation.filterMoneyInput("12..345a6", maxLength: 10), "12.34") + XCTAssertEqual(OrderRefundDialogValidation.filterMoneyInput("12345678901", maxLength: 10), "1234567890") + } + + private func failureMessage(from result: Result) -> String? { + if case .failure(let error) = result { + return error.message + } + return nil + } + + private func successPayload( + from result: Result + ) throws -> OrderRefundDialogPayload { + switch result { + case .success(let payload): + return payload + case .failure(let error): + XCTFail("Expected success, got failure: \(error.message)") + throw NSError(domain: "OrderRefundDialogValidationTests", code: 1) + } + } +} diff --git a/suixinkanTests/WeChatShareServiceTests.swift b/suixinkanTests/WeChatShareServiceTests.swift index c9eff96..9934fbd 100644 --- a/suixinkanTests/WeChatShareServiceTests.swift +++ b/suixinkanTests/WeChatShareServiceTests.swift @@ -4,6 +4,7 @@ // import UIKit +import WechatOpenSDK import XCTest @testable import suixinkan @@ -24,6 +25,24 @@ final class WeChatShareServiceTests: XCTestCase { XCTAssertEqual(WeChatShareScene.favorite.wxSceneValue, 2) } + /// 测试小程序版本映射到微信 SDK 值。 + func testMiniProgramTypeMapsToWXValues() { + XCTAssertEqual(WeChatMiniProgramType.release.rawValue, 0) + XCTAssertEqual(WeChatMiniProgramType.test.rawValue, 1) + XCTAssertEqual(WeChatMiniProgramType.preview.rawValue, 2) + } + + /// 测试 Debug 包使用体验版小程序,Release 包保持传入版本。 + func testMiniProgramTypeResolvesForCurrentBuild() { + #if DEBUG + XCTAssertEqual(WeChatMiniProgramType.release.resolvedForCurrentBuild, .preview) + XCTAssertEqual(WeChatMiniProgramType.test.resolvedForCurrentBuild, .preview) + #else + XCTAssertEqual(WeChatMiniProgramType.release.resolvedForCurrentBuild, .release) + XCTAssertEqual(WeChatMiniProgramType.test.resolvedForCurrentBuild, .test) + #endif + } + /// 测试微信错误码解析。 func testShareResultFromWXErrorCode() { XCTAssertEqual(WeChatShareResult.fromWXErrorCode(0), .success) @@ -45,6 +64,23 @@ final class WeChatShareServiceTests: XCTestCase { XCTAssertLessThanOrEqual(thumbData.count, WeChatThumbImageProcessor.maxThumbBytes) } + /// 测试小程序高清预览图压缩到微信限制内。 + func testMiniProgramHDImageProcessorKeepsDataUnderLimit() throws { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 1200, height: 800)) + let image = renderer.image { context in + UIColor.systemGreen.setFill() + context.fill(CGRect(x: 0, y: 0, width: 1200, height: 800)) + UIColor.white.setFill() + context.fill(CGRect(x: 160, y: 120, width: 880, height: 560)) + UIColor.systemBlue.setFill() + context.fill(CGRect(x: 260, y: 220, width: 680, height: 360)) + } + + let imageData = try XCTUnwrap(WeChatThumbImageProcessor.makeMiniProgramHDImageData(from: image)) + XCTAssertLessThanOrEqual(imageData.count, WeChatThumbImageProcessor.maxMiniProgramHDImageBytes) + XCTAssertNil(WeChatThumbImageProcessor.makeMiniProgramHDImageData(from: image, maxBytes: 0)) + } + /// 测试空标题和空链接会直接返回参数错误。 func testShareWebPageRejectsInvalidPayloadBeforeSending() { var results: [WeChatShareResult] = [] @@ -66,4 +102,90 @@ final class WeChatShareServiceTests: XCTestCase { .invalidPayload("分享链接不能为空"), ]) } + + /// 测试小程序分享请求字段填充。 + func testMakeMiniProgramRequestFillsWXObject() throws { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 600, height: 400)) + let image = renderer.image { context in + UIColor.systemOrange.setFill() + context.fill(CGRect(x: 0, y: 0, width: 600, height: 400)) + } + let payload = WeChatMiniProgramSharePayload( + title: " 举报详情 ", + description: "查看处理进度", + webpageURL: " https://www.zhifly.cn/report/40 ", + userName: " gh_xxxxxxxx ", + path: " /pages/report/detail?id=40 ", + miniProgramType: .preview, + withShareTicket: true, + disableForward: true + ) + + let request = try XCTUnwrap(try WeChatShareService.makeMiniProgramRequest(payload, scene: .session, previewImage: image).get()) + let message = try XCTUnwrap(request.message) + let miniProgramObject = try XCTUnwrap(message.mediaObject as? WXMiniProgramObject) + + XCTAssertFalse(request.bText) + XCTAssertEqual(request.scene, WeChatShareScene.session.wxSceneValue) + XCTAssertEqual(message.title, "举报详情") + XCTAssertEqual(message.description, "查看处理进度") + XCTAssertEqual(miniProgramObject.webpageUrl, "https://www.zhifly.cn/report/40") + XCTAssertEqual(miniProgramObject.userName, "gh_xxxxxxxx") + XCTAssertEqual(miniProgramObject.path, "/pages/report/detail?id=40") + XCTAssertEqual(miniProgramObject.miniProgramType.rawValue, UInt(WeChatMiniProgramType.preview.resolvedForCurrentBuild.rawValue)) + XCTAssertEqual(miniProgramObject.withShareTicket, true) + XCTAssertEqual(miniProgramObject.disableForward, true) + XCTAssertLessThanOrEqual(miniProgramObject.hdImageData?.count ?? 0, WeChatThumbImageProcessor.maxMiniProgramHDImageBytes) + } + + /// 测试小程序分享参数校验。 + func testMakeMiniProgramRequestRejectsInvalidPayload() { + let valid = WeChatMiniProgramSharePayload( + title: "举报详情", + description: "查看处理进度", + webpageURL: "https://www.zhifly.cn/report/40", + userName: "gh_xxxxxxxx" + ) + let longURL = "https://www.zhifly.cn/" + String(repeating: "a", count: 1025) + + XCTAssertEqual( + WeChatShareService.makeMiniProgramRequest(valid, scene: .timeline).failure, + .invalidPayload("小程序仅支持分享到微信好友") + ) + XCTAssertEqual( + WeChatShareService.makeMiniProgramRequest( + WeChatMiniProgramSharePayload(title: " ", description: "", webpageURL: valid.webpageURL, userName: valid.userName), + scene: .session + ).failure, + .invalidPayload("分享标题不能为空") + ) + XCTAssertEqual( + WeChatShareService.makeMiniProgramRequest( + WeChatMiniProgramSharePayload(title: valid.title, description: "", webpageURL: " ", userName: valid.userName), + scene: .session + ).failure, + .invalidPayload("小程序兼容网页链接不能为空") + ) + XCTAssertEqual( + WeChatShareService.makeMiniProgramRequest( + WeChatMiniProgramSharePayload(title: valid.title, description: "", webpageURL: longURL, userName: valid.userName), + scene: .session + ).failure, + .invalidPayload("小程序兼容网页链接不能超过1024字节") + ) + XCTAssertEqual( + WeChatShareService.makeMiniProgramRequest( + WeChatMiniProgramSharePayload(title: valid.title, description: "", webpageURL: valid.webpageURL, userName: " "), + scene: .session + ).failure, + .invalidPayload("小程序原始 ID 不能为空") + ) + } +} + +private extension Result where Success == SendMessageToWXReq, Failure == WeChatShareResult { + var failure: WeChatShareResult? { + guard case .failure(let result) = self else { return nil } + return result + } } diff --git a/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift b/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift index e1643af..7c028cf 100644 --- a/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift +++ b/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift @@ -233,6 +233,17 @@ final class WildPhotographerReportTests: XCTestCase { "handle_status": 0, "handle_status_text": "待处理", "handle_remark": "", + "handled_at": "2026-07-09 14:18:40", + "handler": { + "id": 43, + "name": "管铜彬", + "phone": "132****9413", + "started_at": "2026-07-09 14:18:15", + "finished_at": "2026-07-09 14:18:40", + "remark": "我是处理意见" + }, + "handler_name": "管铜彬", + "handler_phone": "132****9413", "evidence_summary": { "image_count": 1, "video_count": 1 }, "evidences": [ { @@ -248,6 +259,24 @@ final class WildPhotographerReportTests: XCTestCase { "file_name": "report.mov" } ], + "handle_evidences": [ + { + "evidence_kind": 2, + "evidence_kind_text": "处理凭证", + "file_type": 1, + "file_type_text": "图片", + "file_url": "http://tmp/handler-one.jpg", + "file_name": "handler-one.jpg" + }, + { + "evidence_kind": 2, + "evidence_kind_text": "处理凭证", + "file_type": 1, + "file_type_text": "图片", + "file_url": "http://tmp/handler-two.png", + "file_name": "handler-two.png" + } + ], "payment_screenshots": [ { "file_type": 5, @@ -276,6 +305,55 @@ final class WildPhotographerReportTests: XCTestCase { XCTAssertEqual(response.evidences.count, 2) XCTAssertEqual(response.paymentScreenshots.first?.fileName, "payment.jpg") XCTAssertEqual(response.timeline.first?.content, "摄影师提交举报") + XCTAssertEqual(response.handler?.name, "管铜彬") + XCTAssertEqual(response.handler?.phone, "132****9413") + XCTAssertEqual(response.handler?.startedAt, "2026-07-09 14:18:15") + XCTAssertEqual(response.handler?.finishedAt, "2026-07-09 14:18:40") + XCTAssertEqual(response.handler?.remark, "我是处理意见") + XCTAssertEqual(response.handledAt, "2026-07-09 14:18:40") + XCTAssertEqual(response.handleEvidences.count, 2) + XCTAssertEqual(response.handleEvidences.first?.evidenceKind, 2) + XCTAssertEqual(response.handleEvidences.first?.evidenceKindText, "处理凭证") + XCTAssertEqual(response.handleEvidences.first?.fileURL, "http://tmp/handler-one.jpg") + XCTAssertEqual(response.handleEvidences.last?.fileName, "handler-two.png") + } + + func testReportShareAPILoadsMiniProgramConfig() async throws { + let json = Data(""" + { + "code": 100000, + "msg": "success", + "data": { + "userName": "wx8c85189bf3bdda29", + "path": "pages/scenic/radar/report-detail/index?id=32", + "title": "私下收款举报", + "imagePath": "/assets/share/report-share-cover.png", + "webpageUrl": "https://www.youfuntour.com", + "withShareTicket": true, + "miniprogramType": 0, + "scene": 0 + }, + "time": "2026-07-09 15:42:19" + } + """.utf8) + let session = MockURLSession(responses: [json]) + let api = WildPhotographerReportAPI(client: APIClient(environment: .testing, session: session)) + + let response = try await api.reportShare(id: "32") + + let components = URLComponents(url: try XCTUnwrap(session.requests.first?.url), resolvingAgainstBaseURL: false) + XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/report/share") + XCTAssertTrue(components?.queryItems?.contains(URLQueryItem(name: "id", value: "32")) == true) + XCTAssertEqual(response.userName, "wx8c85189bf3bdda29") + XCTAssertEqual(response.path, "pages/scenic/radar/report-detail/index?id=32") + XCTAssertEqual(response.title, "私下收款举报") + XCTAssertEqual(response.imagePath, "/assets/share/report-share-cover.png") + XCTAssertEqual(response.webpageURL, "https://www.youfuntour.com") + XCTAssertTrue(response.withShareTicket) + XCTAssertEqual(response.miniProgramType, .release) + XCTAssertEqual(response.scene, .session) + XCTAssertEqual(response.payload.userName, "wx8c85189bf3bdda29") + XCTAssertEqual(response.payload.path, "pages/scenic/radar/report-detail/index?id=32") } func testSupplementReportAPISendsRequestBody() async throws { @@ -472,6 +550,106 @@ final class WildPhotographerReportTests: XCTestCase { XCTAssertEqual(detail.supplementRecords.first?.summaryText, "补充说明 · 1张图片") } + func testSupplementRecordSequenceTitleStartsFromDisplayOrder() { + let titles = (0..<3).map { + WildReportSupplementDisplayFormatter.sequenceTitle(forDisplayIndex: $0) + } + + XCTAssertEqual(titles, ["第1次补充", "第2次补充", "第3次补充"]) + } + + func testReportDetailMapsHandlerInfoFromHandlerFields() throws { + let detail = makeReportDetailData( + handleStatus: 2, + handleStatusText: "已处理", + handleRemark: "顶层处理备注", + handledAt: "2026-07-09 14:18:40", + handler: WildReportDetailHandler( + id: 43, + name: "管铜彬", + phone: "132****9413", + startedAt: "2026-07-09 14:18:15", + finishedAt: "2026-07-09 14:18:40", + remark: "我是处理意见" + ), + handleEvidences: [ + WildReportDetailEvidence( + fileType: 1, + fileTypeText: "图片", + fileURL: "http://tmp/handler-one.jpg", + fileName: "handler-one.jpg", + evidenceKind: 2, + evidenceKindText: "处理凭证" + ), + WildReportDetailEvidence( + fileType: 1, + fileTypeText: "图片", + fileURL: "http://tmp/handler-two.png", + fileName: "handler-two.png", + evidenceKind: 2, + evidenceKindText: "处理凭证" + ) + ] + ) + + let info = try XCTUnwrap(detail.toRecord().handlerInfo) + + XCTAssertEqual(info.handlerName, "管铜彬") + XCTAssertEqual(info.handlerPhone, "132****9413") + XCTAssertEqual(info.processingAt, "2026-07-09 14:18:15") + XCTAssertEqual(info.processedAt, "2026-07-09 14:18:40") + XCTAssertEqual(info.processOpinion, "我是处理意见") + XCTAssertEqual(info.completionMedia.count, 2) + XCTAssertEqual(info.completionMedia.first?.coverURL, "http://tmp/handler-one.jpg") + XCTAssertEqual(info.completionMediaSummary, "2张") + } + + func testReportDetailMapsHandlerInfoFromFallbackFields() throws { + let detail = makeReportDetailData( + handleStatus: 2, + handleStatusText: "已处理", + handleRemark: "fallback 处理意见", + handledAt: "2026-07-09 14:18:40", + handlerName: "管铜彬", + handlerPhone: "132****9413" + ) + + let info = try XCTUnwrap(detail.toRecord().handlerInfo) + + XCTAssertEqual(info.handlerName, "管铜彬") + XCTAssertEqual(info.handlerPhone, "132****9413") + XCTAssertEqual(info.processingAt, "") + XCTAssertEqual(info.processedAt, "2026-07-09 14:18:40") + XCTAssertEqual(info.processOpinion, "fallback 处理意见") + } + + func testReportShareDataDecodesSnakeCaseAndMapsPayload() throws { + let json = Data(""" + { + "user_name": "gh_report_original_id", + "path": "pages/report/detail?id=32", + "title": "私下收款举报", + "description": "查看举报处理进度", + "image_path": "https://example.com/share.png", + "webpage_url": "https://www.youfuntour.com/report/32", + "with_share_ticket": true, + "mini_program_type": 2, + "scene": 0, + "disable_forward": true + } + """.utf8) + + let data = try JSONDecoder().decode(WildReportMiniProgramShareData.self, from: json) + + XCTAssertEqual(data.userName, "gh_report_original_id") + XCTAssertEqual(data.webpageURL, "https://www.youfuntour.com/report/32") + XCTAssertEqual(data.miniProgramType, .preview) + XCTAssertEqual(data.scene, .session) + XCTAssertEqual(data.payload.description, "查看举报处理进度") + XCTAssertTrue(data.payload.withShareTicket) + XCTAssertTrue(data.payload.disableForward) + } + func testReportStatusMapsRejected() { XCTAssertEqual(WildReportStatus(handleStatus: 0), .pending) XCTAssertEqual(WildReportStatus(handleStatus: 1), .processing) @@ -731,6 +909,61 @@ final class WildPhotographerReportTests: XCTestCase { XCTAssertEqual(messages.last, URLError(.badServerResponse).localizedDescription) } + func testListViewModelLoadsMiniProgramSharePayload() async throws { + let homeViewModel = WildPhotographerReportHomeViewModel() + let viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel) + let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord() + let api = MockWildPhotographerReportAPI() + api.reportShareResponse = WildReportMiniProgramShareData( + userName: "gh_report_original_id", + path: "pages/report/detail?id=32", + title: "私下收款举报", + description: "查看举报处理进度", + webpageURL: "https://www.youfuntour.com/report/32", + withShareTicket: true, + miniProgramTypeValue: WeChatMiniProgramType.preview.rawValue + ) + + let maybePayload = await viewModel.shareReport(record, api: api) + let payload = try XCTUnwrap(maybePayload) + + XCTAssertEqual(api.reportShareRequests, ["32"]) + XCTAssertEqual(payload.title, "私下收款举报") + XCTAssertEqual(payload.userName, "gh_report_original_id") + XCTAssertEqual(payload.path, "pages/report/detail?id=32") + XCTAssertEqual(payload.miniProgramType, .preview) + XCTAssertTrue(payload.withShareTicket) + } + + func testListViewModelShareRejectsLocalRecord() async { + let homeViewModel = WildPhotographerReportHomeViewModel() + let viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel) + let record = WildReportRecord( + serverID: nil, + id: "JBLOCAL", + title: "摄影师举报", + reportType: WildReportType(value: 2, label: "疑似打野"), + status: .pending, + location: "测试景区", + submitTime: "2026-07-08 16:06:59", + description: "本地记录", + wechatID: "", + phoneNumber: "", + imageCount: 1, + videoCount: 0, + handlerInfo: nil + ) + let api = MockWildPhotographerReportAPI() + var messages: [String] = [] + viewModel.onShowMessage = { messages.append($0) } + + let payload = await viewModel.shareReport(record, api: api) + + XCTAssertNil(payload) + XCTAssertTrue(api.reportShareRequests.isEmpty) + XCTAssertEqual(messages.last, "当前举报暂未同步到服务器,无法分享") + } + func testSupplementEvidenceValidationAndSuccess() async throws { let homeViewModel = WildPhotographerReportHomeViewModel() let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord() @@ -1023,7 +1256,28 @@ final class WildPhotographerReportTests: XCTestCase { let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord() let viewModel = WildPhotographerReportDetailViewModel(record: record) let api = MockWildPhotographerReportAPI() - api.reportDetailResponses = [makeReportDetailData()] + api.reportDetailResponses = [ + makeReportDetailData( + handleStatus: 2, + handleStatusText: "已处理", + handler: WildReportDetailHandler( + id: 43, + name: "管铜彬", + phone: "132****9413", + startedAt: "2026-07-09 14:18:15", + finishedAt: "2026-07-09 14:18:40", + remark: "我是处理意见" + ), + handleEvidences: [ + WildReportDetailEvidence( + fileType: 1, + fileTypeText: "图片", + fileURL: "http://tmp/handler-one.jpg", + fileName: "handler-one.jpg" + ) + ] + ) + ] await viewModel.loadDetailIfNeeded(api: api) @@ -1032,6 +1286,10 @@ final class WildPhotographerReportTests: XCTestCase { XCTAssertEqual(viewModel.reporterMediaItems.count, 2) XCTAssertEqual(viewModel.paymentScreenshotItems.count, 1) XCTAssertEqual(viewModel.progressSteps.first?.title, "摄影师提交举报") + XCTAssertTrue(viewModel.showHandlerInfo) + XCTAssertEqual(viewModel.handlerInfo?.handlerName, "管铜彬") + XCTAssertEqual(viewModel.handlerInfo?.processingAt, "2026-07-09 14:18:15") + XCTAssertEqual(viewModel.handlerInfo?.completionMedia.first?.coverURL, "http://tmp/handler-one.jpg") } func testDetailViewModelFailureKeepsInitialRecord() async { @@ -1048,6 +1306,27 @@ final class WildPhotographerReportTests: XCTestCase { XCTAssertEqual(messages.last, URLError(.badServerResponse).localizedDescription) } + func testDetailViewModelLoadsMiniProgramSharePayload() async throws { + let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord() + let viewModel = WildPhotographerReportDetailViewModel(record: record) + let api = MockWildPhotographerReportAPI() + api.reportShareResponse = WildReportMiniProgramShareData( + userName: "gh_report_original_id", + path: "pages/report/detail?id=32", + title: "私下收款举报", + webpageURL: "https://www.youfuntour.com/report/32", + miniProgramTypeValue: WeChatMiniProgramType.test.rawValue + ) + + let maybePayload = await viewModel.shareReport(api: api) + let payload = try XCTUnwrap(maybePayload) + + XCTAssertEqual(api.reportShareRequests, ["32"]) + XCTAssertEqual(payload.webpageURL, "https://www.youfuntour.com/report/32") + XCTAssertEqual(payload.userName, "gh_report_original_id") + XCTAssertEqual(payload.miniProgramType, .test) + } + func testDetailViewModelWithoutServerIDSkipsRequest() async { let record = WildReportRecord( id: "JBLOCAL", @@ -1087,6 +1366,9 @@ private final class MockWildPhotographerReportAPI: WildPhotographerReportServing var reportDetailResponses: [WildReportDetailData] = [] var reportDetailRequests: [Int] = [] var reportDetailError: Error? + var reportShareResponse = WildReportMiniProgramShareData() + var reportShareRequests: [String] = [] + var reportShareError: Error? var supplementRequests: [WildReportSupplementRequest] = [] var supplementError: Error? var riskMapResponses: [WildReportRiskMapResponse] = [] @@ -1150,6 +1432,12 @@ private final class MockWildPhotographerReportAPI: WildPhotographerReportServing guard !reportDetailResponses.isEmpty else { return WildReportDetailData(id: id) } return reportDetailResponses.removeFirst() } + + func reportShare(id: String) async throws -> WildReportMiniProgramShareData { + if let reportShareError { throw reportShareError } + reportShareRequests.append(id) + return reportShareResponse + } } private enum MockRiskMapLocationResult: Sendable { @@ -1312,7 +1600,13 @@ private func makeReportListItem( private func makeReportDetailData( handleStatus: Int = 0, - handleStatusText: String = "待处理" + handleStatusText: String = "待处理", + handleRemark: String = "处理备注", + handledAt: String = "", + handler: WildReportDetailHandler? = nil, + handlerName: String = "", + handlerPhone: String = "", + handleEvidences: [WildReportDetailEvidence] = [] ) -> WildReportDetailData { WildReportDetailData( id: 32, @@ -1349,7 +1643,11 @@ private func makeReportDetailData( longitude: "119.4403263", handleStatus: handleStatus, handleStatusText: handleStatusText, - handleRemark: "处理备注", + handleRemark: handleRemark, + handledAt: handledAt, + handler: handler, + handlerName: handlerName, + handlerPhone: handlerPhone, evidenceSummary: WildReportEvidenceSummary(imageCount: 1, videoCount: 1), evidences: [ WildReportDetailEvidence( @@ -1365,6 +1663,7 @@ private func makeReportDetailData( fileName: "report.mov" ) ], + handleEvidences: handleEvidences, paymentScreenshots: [ WildReportDetailEvidence( fileType: 5,