feat: update app workflows and permissions
This commit is contained in:
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<SendMessageToWXReq, WeChatShareResult> {
|
||||
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],
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -23,6 +23,6 @@ struct HomeMenuItem: Equatable, Hashable, Sendable {
|
||||
static let moreFunctions = HomeMenuItem(
|
||||
uri: "more_functions",
|
||||
title: "更多功能",
|
||||
iconName: "square.grid.2x2"
|
||||
iconName: "ellipsis"
|
||||
)
|
||||
}
|
||||
|
||||
@ -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"),
|
||||
]
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 补充证据接口请求体。
|
||||
|
||||
@ -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 {
|
||||
/// 将「景区 · 详细地址」拆成两段展示文案。
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
- 网络入口:`NetworkServices.shared.wildPhotographerReportAPI`
|
||||
- 后端成功码:`100000`
|
||||
- 响应包裹:工程统一 `APIEnvelope<T>`
|
||||
- 当前状态:“首页文案”、“举报类型”、“提交举报”、“我的举报列表”、“举报详情”、“补充证据提交”和“附近风险地图”已接接口;模块内生产 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. 补充证据提交
|
||||
|
||||
### 请求
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<OrderRefundDialogPayload, OrderRefundDialogValidationError> {
|
||||
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<OrderRefundDialogPayload, OrderRefundDialogValidationError> {
|
||||
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<OrderRefundDialogPayload, OrderRefundDialogValidationError> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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, *) {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -6,5 +6,7 @@
|
||||
<array>
|
||||
<string>applinks:www.zhifly.cn</string>
|
||||
</array>
|
||||
<key>com.apple.developer.networking.wifi-info</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
42
suixinkanTests/MainTabItemTests.swift
Normal file
42
suixinkanTests/MainTabItemTests.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
130
suixinkanTests/OrderRefundDialogValidationTests.swift
Normal file
130
suixinkanTests/OrderRefundDialogValidationTests.swift
Normal file
@ -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<OrderRefundDialogPayload, OrderRefundDialogValidationError>) -> String? {
|
||||
if case .failure(let error) = result {
|
||||
return error.message
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func successPayload(
|
||||
from result: Result<OrderRefundDialogPayload, OrderRefundDialogValidationError>
|
||||
) 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user